DEV Community

Unicorn Developer
Unicorn Developer

Posted on

Game++. Part 2.1: Types of optimization

This book offers insights into C++, including algorithms and practices in game development, explores strengths and weaknesses of the language, its established workflows, and hands-on solutions. C++ continues to dominate the game development industry today thanks to its combination of high performance, flexibility, and extensive low-level control capabilities.

1397_pt_5/image1.png

Wikipedia defines optimization as the process of improving a system to increase its efficiency or reduce its resource consumption. In game development, those words hit harder, since every game runs on a tight budget. On one side of the scale sit CPU time, memory, and frame time; on the other, the player and the market, with their expectations around graphics, physics, AI, and overall experience. That scale almost never tips in the developer's favor.

Optimization should always start with the big picture. Random small tweaks waste time—what matters is finding the real bottlenecks: where exactly performance drops, and which subsystems cost more than they give back. This is where profilers, performance counters, and tracing tools come into play. The process looks like this: locate a trouble spot, measure its impact, try an improvement, and measure again. If nothing improves, roll back and look for the next candidate.

Here are the steps to follow:

  • Benchmark—build a reproducible example of the problem scenario.
  • Measure—establish a baseline for memory and resource consumption.
  • Detect—find the part of the program that makes the poorest use of resources.
  • Solve—change how it works to improve resource usage.
  • Check—confirm through measurement that the improvement actually happened.
  • Repeat—go back to the first step until you hit your performance goals.

There's a more vivid concept—MEDAL:

Measure > Examine > Develop > Assess (Check results) > Loop.

An experienced developer treats optimization as a data-driven process, not a bag of tricks. Even so, it pays to keep proven techniques in your back pocket—for memory management, multithreading, rendering, math, and so on—and to pair them with personal experience and tooling to get a measurable result without wasting time. Hands-on experience is essential here: without practice or good mentors, learning to spot problem areas can be difficult. I've always been amazed by developers' ability to run demanding computations on relatively "weak" hardware, and by the tricks they use to keep applications running fast. This applies not only to game engines but also to databases, control systems, and the like. The gap becomes especially obvious when porting relatively recent games (PS3-generation and later) to handhelds like the Nintendo Switch and Steam Deck, or to mobile devices. In terms of raw performance, the Nintendo Switch hasn't moved far beyond the PlayStation 3, so attempts to port demanding titles built for at least the next console generation (PS4) run into significant and stubborn problems. Table 3.1 compares three generations of gaming hardware. It shows which solutions can be ported without major compromises, and what level of optimization developers need to focus on for each.

Component PlayStation 3 Nintendo Switch (portable) Steam Deck
CPU Cellx1 PPE + x6 SPE
~25 GFlops
4× ARM Cortex-A57 1.02 GHz
~25 GFlops
AMD Zen 2 2.4–3.5 GHz
(~448 GFlops)
GPU NVIDIA (~GeForce 7800)
~192 GFlops
NVIDIA Maxwell GM20B
~157 GFlops
AMD RDNA 2
~1,6 TFlops
RAM 256 MB XDR
256 MB GDDR3
~25.6 GB/s (GPU)
4 GB LPDDR4
~25.6 GB/s
16 GB LPDDR5
~88 GB/s
API Proprietary OpenGL ES 3.2 / Vulkan / NX OpenGL3+ / Vulkan

Table 3.1. Comparison of gaming platform specifications

From a CPU standpoint, the PS3 uses the Cell architecture with one main PPE core and six active SPE co-processors. In real game workloads, the performance of this configuration is generally estimated at around 20–30 GFLOPS, though fully leveraging Cell's potential is notoriously difficult.

The Nintendo Switch has a 4-core ARM Cortex-A57 running at 1.02 GHz, but effectively it's a 3-core machine, since one core is always reserved for the system. It delivers roughly 20–25 GFLOPS under typical loads. Despite the architectural differences, both CPUs perform at a similar level. The GPU picture is a little different. The PS3's RSX is an adapted GeForce 7800 with peak performance around 192 GFLOPS and an outdated shader model. The Switch uses a GPU based on NVIDIA's Maxwell architecture (Tegra X1): in handheld mode it runs at reduced clocks, delivering about 157 GFLOPS, while docked performance can reach 393 GFLOPS.

As for RAM, the Switch is powered by 4 GB of LPDDR4 offering about 25.6 GB/s of bandwidth, while the PS3 has 256 MB of XDR for the CPU and 256 MB of GDDR3 for the GPU, with roughly the same bandwidth. The Switch slightly wins on memory capacity, but not on bandwidth. In any case, it's the Steam Deck that sets today's baseline. Moving beyond that level, though, is no longer feasible, purely because of technical limitations. This comparison is really just a sketch. Once the Steam Deck 2 arrives, the Switch goes to the PS3 league, and suddenly the bar is raised for everyone. The optimization levels stay the same, with the same set of problems and solutions, and just backed by more resources.

A game is demanding software, often with soft real-time requirements, whether you're targeting a PS3 or the latest Steam Deck. Acceptable performance is non-negotiable—otherwise the experience suffers and nobody buys the game. There's a small upside when porting to mobile platforms: the hardware tends to be fixed. On smartphones, though, you're staring into a jungle of CPUs, GPUs, and runtime environments. Consoles fare better here, since their hardware specs change only every few years.

When porting a game, optimization can be split into several levels: architecture, algorithms, and code.

Architecture-level optimizations (~50% gain)

Architecture is about the bones of the system: how modules interact, how data sits in layers, the whole memory allocation dance (custom allocators vs. system malloc, object pooling vs. free-form allocation, dynamic vs. allocation-free), and the threading approach (single-threaded, multi-threaded, shared state, actor-based concurrency). A sub-optimal architecture means excessive memory consumption, sluggish execution, idle threads, resource contention, or expensive access patterns.

Fixing architectural flaws requires large investments of time and people to refactor the game's core subsystems or engine, which often means rewriting fundamental components like the object management system, renderer, or memory system. Examples include moving from an inheritance-and-components model to an Entity Component System (ECS) architecture, replacing the object allocation mechanism, or reorganizing the command system. These architectural shifts deliver the most dramatic performance gains—smarter memory access can boost performance, and a well-tuned thread management model keeps all CPU cores busy, with idle time dropping sharply.

However, late-stage architectural changes create a domino effect throughout the entire codebase, requiring months of fixes and expensive regression testing and makes such optimizations virtually impossible right before a game's release or when porting to new platforms.

Algorithm/data structure-level optimizations (~30%)

Algorithms determine how efficiently the program runs. The right choice can literally save your frame rate: replacing a plain iteration over array elements (complexity O(n)) with a binary search on a sorted array (O(log n)) or a hash-table lookup (O(1)) yields a massive speedup as data volume grows. In an array of a million elements, a linear search might cost a million operations, a binary search only about 20, and a hash table finds the element almost instantly. We rarely have arrays that large, but a handful of frequently called hot spots can still rack up that million accesses.

But theoretical algorithmic complexity doesn't always match real-world performance, because of how modern hardware works. Processors use caches (L1, L2, L3), and algorithms that access data sequentially get a huge boost from hardware prefetching. As a result, a simple linear scan over a tightly packed array can significantly outperform a hash-table lookup when the data fits in the L1 cache. Linked lists, theoretically efficient for insertions, end up slower than dynamic arrays in practice because of pointer-chasing. And since CPUs rely on branch prediction, predictable code runs faster—a straightforward algorithm with simple logic often leaves a complex, branch-heavy one in the dust.

That's why developers often look beyond Big-O notation and weigh real-world conditions: data size, memory access patterns, branch predictability, and the architecture of the target hardware.

Development includes profiling code on real data and writing benchmarks for the tricky sections. In real projects, a "naive" solution often beats the textbook approach: data locality works in its favor, and there's no heavy scaffolding to drag along. Here's a typical example:

// The example of search optimization

bool slow_contains(const std::vector<int>& vec, int target) {
  // Call 'find' each time

  return std::find(vec.begin(), vec.end(), target) != vec.end();
}

bool fast_contains(const std::vector<int>& sorted_vec, int target) {
  // Simple optimization,
  // use binary search for sorted array

  return std::binary_search(sorted_vec.begin(),
                            sorted_vec.end(), target);
}
Enter fullscreen mode Exit fullscreen mode

Source code-level optimizations (~10%)

Local-level optimizations target individual blocks of code, functions, or bounded algorithmic scopes. They focus on fine-tuning a solution to raise performance and lower overhead. These small-scale optimizations usually touch only limited parts of the program and can be handled in a single code review: up to a hundred changes and maybe ten files at once.

Key areas of focus include heapless logic (i.e. implementing algorithms and data structures on the stack rather than the heap), object pools, and specialized allocators. Loop unrolling improves instruction-level parallelism when the compiler can't do it automatically due to code complexity. Other techniques include inlining "hot" functions, introducing SIMD for vectorized computation, minding data locality, and following the access patterns. The optimization process often involves detailed profiling with tools like Intel VTune, PIX, Razor, Tracy, or a game engine's built-in profiler. The analysis covers inefficient instructions, cache miss rates, excessive virtual-function calls, hidden copies when passing parameters, and expensive object operations. In most cases, fixing these issues requires trade-offs between code readability and the solution you implement, so these optimizations apply only to code that eats up most of the frame time.

Loop optimization is easily one of the most common tasks at this level. Developers regularly run into nested loops the compiler can't refine but that can be simplified or swapped out for smarter algorithms. For example, if an inner loop is searching through a collection, consider preparing the data structure in advance or using a hash table to avoid redundant passes.

Sometimes simply moving loop invariants outside the loop or changing the iteration order is enough to reduce the load on the CPU:

void calculatePhysicsSlow(vector<float>& pos, vector<float>& vel, int frameStep)
{
  for (int i = 0; i < iterations; ++i) {
    for (size_t j = 0; j < positions.size(); ++j) {
      // Invariant computations are repeated,
      // the compiler may not spot them in difficult cases

      float gravity = world->getGravity(); float damping = object->getDamping();
      float timeStep = getDeltaTime() / frameStep;

      vel[j] += gravity * timeStep; vel[j] *= damping;
      pos[j] += velocities[j] * timeStep;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

--------------------------------------------------------------------------

void calculatePhysicsFast(vector<float>& pos, vector<float>& vel, int frameStep)
{
  // Invariant computations were moved beyond loop boundaries

  float gravity = world->getGravity(); float damping = object->getDamping();
  float timeStep = getDeltaTime() / frameStep;
  const float gravityTimeStep = gravity * timeStep;

  for (int i = 0; i < iterations; ++i) {
    for (size_t j = 0; j < positions.size(); ++j) {
      velocities[j] += gravityTimeStep;
      velocities[j] *= damping;
      positions[j] += velocities[j] * timeStep;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Modern compilers with optimizations enabled can handle these transformations automatically, but once pointers, virtual functions, or convoluted logic enter the picture, you still have to step in to squeeze out maximum performance. Hoisting loop-invariant code is a fundamental optimization based on the loop-invariant code motion principle: you take expressions whose value never changes across iterations and move them out of the loop so they're computed once. Invariant candidates include operations on constants, accesses to unchanged variables, math expressions with fixed values, and calls to functions with deterministic results.

For example, evaluating deltaTime / iterations, sin(angle) for a fixed angle, or sqrt(maxDistance) inside a loop causes redundant operations on every iteration, whereas hoisting these into pre-computed variables reduces the operation count from O(n) to O(1).

Another key step is rooting out linear searches in your code, especially those hiding inside loops. A linear search as std::find or a manual array scan where works perfectly fine on tiny collections, but in a hot path or with a lot of data it quickly turns into a serious bottleneck. Replacing a linear search with a binary search, using an associative container, or pre-sorting the data and any of these can give you a performance boost.

struct GameObject {
  int id; string name; float x, y, z; bool isActive;

  GameObject(int _id, const string& _name,
             float _x, float _y, float _z, bool _active)
             : id(_id), name(_name),
               x(_x), y(_y), z(_z), isActive(_active) {}
};

// The linear search by the name - O(n)
GameObject* findObjectByName(const string& name) {
  auto it = find_if(objects.begin(), objects.end(),
                    <a href="const GameObject& obj">&name</a>
                    {
                      return obj.name == name;
                    });

  return (it != objects.end()) ? &(*it) : nullptr;
}
Enter fullscreen mode Exit fullscreen mode

Main optimization methods fall into three approaches: hash tables, binary search, and caching. Hash tables (unordered_map) provide average O(1) lookup complexity and are especially effective when access is frequent and keyed by unique identifiers. Pre-building indexes is the best practice: map an ID to an array position or index by a string name, and data access speeds up noticeably. Caching stores the results of expensive operations and returns them quickly on repeated requests. The main thing is to keep the data fresh and invalidate the cache correctly.

A one-time investment in indexing and sorting quickly pays off when you're performing multiple searches. When choosing a data structure, the key is to pick one that fits your specific use case and to be ready to make trade-offs: faster access often requires more memory.

Hash tables are good for frequent key-based lookups, binary search for stable, rarely changing data, and caching for heavy but repeated computations. All of this becomes a big deal inside a game loop, where you might be querying objects hundreds of times per frame. Here's what it looks like in code:

void addObject(const GameObject& obj) {
  size_t index = objects.size();
  objects.push_back(obj);
  idToIndex[obj.id] = index;  // Update indexes
  nameToIndex[obj.name] = index;

  // Active objects cache invalidation
  activeObjectsCacheValid = false;
}

// Search by the name via the hash table - O(1)
GameObject* findObjectByName(const std::string& name) {
  auto it = nameToIndex.find(name);
  if (it != nameToIndex.end())
    return &objects[it->second];

  return nullptr;
}
Enter fullscreen mode Exit fullscreen mode

One key direction is minimizing function-call overhead in critical code paths. A function call generally involves creating a new stack frame, passing parameters, saving register state, and restoring it on return. When the iteration count is high enough, that overhead noticeably hurts performance. It becomes especially painful when functions do almost nothing—the actual work costs about as much as the call overhead itself.

// Unoptimized code
class Vector3D {
  double x, y, z;
public:
  double getX() const { return x; }
  . . .
  double magnitude() const { return sqrt(x*x + y*y + z*z); }
};

// Slow version with multiple function calls
double calculateDistance(const Vector3D& v1, const Vector3D& v2) {
  double sum = 0.0;
  for (int i = 0; i < 1000000; ++i) {
    double dx = v1.getX() - v2.getX();
    double dy = v1.getY() - v2.getY();
    double dz = v1.getZ() - v2.getZ();
    sum += sqrt(dx*dx + dy*dy + dz*dz);
  }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

Compiler developers are well aware of this and use a variety of optimization techniques to address it. Inlining lets the compiler embed the function body directly at the call site, eliminating the overhead.

Hoisting loop-invariant computations (loop hoisting) prevents repeated execution of identical operations. Merging several simple functions into one reduces the number of jumps and improves data locality in the CPU cache. And yes, the compiler will most likely notice simple getter functions and optimize that fragment but it can't come up with a fundamentally better solution:

class OptimizedVector3D {
  double x, y, z;
public:
  inline double getX() const { return x; }
  . . .

  // Optimized function to evaluate distance
  inline double distanceTo(const OptimizedVector3D& other) const {
    const double dx = x - other.x;
    const double dy = y - other.y;
    const double dz = z - other.z;
    return sqrt(dx*dx + dy*dy + dz*dz);
  }
};
Enter fullscreen mode Exit fullscreen mode

An alternative approach using OptimizedVector3D inlines the getters, which lets the compiler embed their code directly where they're called. It also adds a new method, distanceTo(), which performs all calculations locally without any extra calls. Keep a close eye on memory allocations that sit inside loops or hot functions; they can tank performance. Dynamic memory allocation ranks among the most expensive operations, and its cost multiplies when it shows up frequently in the program's hot paths. We'll dig deeper into how to optimize this kind of code in chapter "The Joy of Programming."

Source-code and function-level optimizations affect performance differently depending on the hardware platform. On high-performance systems—modern consoles and desktops with multi-core CPUs, generous L2/L3 caches, and fast memory—local code tweaks rarely net more than a 2–3% gain and barely move the needle overall. Human mistakes, of course, are another story.

Such improvements rarely make sense for the business: the engineering time they eat up would be far better spent building new features or fixing critical bugs.

The picture flips completely on portable hardware. There, developers deal with ARM chips clocked at 1–3 GHz (versus 4–5 GHz on desktop), L2 caches measured in kilobytes rather than megabytes, and strict power and thermal limits that leave little room to spare. Under those constraints, the same micro-optimizations can deliver double-digit percentage gains, smoother frame pacing, and longer battery life.

Some code examples to make it more tangible:

long unpredictable_branches(const vector<int>& data) {
  long sum = 0;
  for (int val : data) {
    // The data is random, and the branching is unpredictable
    if (val > 0)
      sum += val;
   }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

--------------------------------------------------------------------------

// Avoid branches through bitwise operations
long branchless_version(const vector<int>& data) {
  long sum = 0;
  for (int val : data) {
    // Use a mask instead of branching
    sum += val & (-(val > 0));
  }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

Tracking down and landing real micro-optimizations is an art unto itself—you can't do it well without a deep understanding of how the CPU and memory hierarchy actually work. Squeezing a few lines of code down to optimal can eat up days. You're profiling, chasing bottlenecks, staring at cache behavior, wading through assembly, and figuring out exactly how branch prediction works on that one particular architecture.

The payoff from a micro-optimization depends heavily on context: memory access patterns, how your working set maps to the different cache levels, cache-line alignment, how often that code actually runs, and how it interacts with the rest of the system. Many developers treat this as a last resort, preferring changes at other architectural layers and in most cases that's justified.

Game replays (~?%)

Game replays are one of the most powerful debugging tools out there, especially in complex systems with many interacting objects and non-linear logic. A replay is more than a video recording—it's a time-ordered sequence of input data (game commands, events, and states) that the game engine plays back. Its frame-level accuracy lets it reproduce game behavior regardless of the hardware or time of day, which makes it ideal for deterministic debugging. In a nutshell, it's time-travel debugging without the usual baggage: you don't touch the compiler, you don't freeze the entire VM state, and the runtime cost is next to nothing.

Under the hood, a software replay comes down to capturing action points for every object on each game loop iteration: player commands, AI states, network messages, random-number generator updates, and so on. With the right architecture, a replay stays reproducible even months later.

Such a mechanism lets QA or a developer reproduce a bug simply by "scrolling" the match to the right moment. Replays can also be adapted for automated tests, game-scenario simulation, and even AI training. In production, a replay is often more practical and powerful than classic debuggers or logs.

Component isolation (~5%)

Modular component isolation comes down to a simple rule: separate interfaces from implementations, and lean on dependency injection. Well-designed modules interact strictly through defined API boundaries, hiding their internals and keeping subsystems loosely coupled.

An audio system might expose a clean interface PlaySound(), SetVolume() while hiding whether it's backed by DirectSound, OpenAL, or a completely custom pipeline. That kind of isolation lets you swap implementations without touching any client code. It also smooths out porting and platform-specific work: each component can be tuned on its own, with no ripple effects through the rest of the system. The renderer can have separate backends for DirectX, Vulkan, Metal, or OpenGL, selected at startup, leaving the gameplay code completely untouched. The same goes for the input system: it can switch between keyboard/mouse, gamepad, touch, or VR controllers while the game code sees only a uniform interface. All of this enables platform-specific solutions without merge conflicts, lets developers test the performance of individual components in isolation, and makes it possible to change one part without threatening the stability of the whole system.

Specific low-level optimizations (<3%)

Many developers fall into the trap of thinking low-level optimization calls for the heavy artillery—inline assembly, fancy algorithms, a full SIMD rewrite. It usually doesn't; in reality, many optimizations turn out to be fairly simple and intuitive. Clean code tends to be fast to compilers and optimizers like it that way. But that's not always the case.

Other (~10%)

So what does this slice of development actually look like? Normally it means solid build pipelines: a build reaches QA within an hour of the commit and, if something's broken, lands back in the author's lap within two. A healthy setup includes good debuggers, replays, and modern game development tools like behavior trees (BT), visual scripts (VS), blueprints, and animation scripts (AS). These systems are notoriously hard to debug under a debugger, and the bugs they produce have a nasty habit of being unreproducible 99% of the time. Of course, a lot hinges on the skill of your QA team. These improvements may not directly affect the game's runtime performance, but they help identify and resolve issues faster. And when a developer builds a feature instead of fixing a bug, that's a direct win for the project.

Benchmarks

Benchmarks are the cornerstone of any optimization approach. You can't improve what you can't measure. A good benchmark acts as a compass in the vast sea of optimization, giving you immediate feedback on the impact of every change. At its core, a benchmark is an execution scenario that reproduces a specific workload and serves as a reference point for quantitative comparison of different code versions and their performance.

So, a solid benchmark should satisfy four critical requirements:

  • Reproducibility, a fundamental property that gets overlooked surprisingly often. Repeated runs under the same configuration should produce statistically consistent results, although individual outliers can occur (even fairly large ones). Fixed input data, deterministic behavior for random values through a predefined seed, stable system conditions, and strong isolation from external influences such as network activity or background processes all contribute to reproducibility.
  • Speed test, a practical requirement for maximizing development efficiency. A well-designed benchmark rarely takes more than a few dozen seconds to run, letting developers iterate on optimizations quickly without sitting around waiting.
  • Representativeness or realistic behavior, achieved by modeling real gameplay scenarios with CPU, GPU, memory, and I/O usage patterns that closely match actual game behavior. A benchmark should reproduce real workloads such as physics simulations, rendering tasks, or AI-intensive calculations. Avoid purely synthetic tests that have little connection to the engine's real architecture.
  • Bench sensitivity, which ensures the benchmark can detect both performance improvements and regressions. Statistical analysis needs at least 10–20 runs to mean anything. A good benchmark shows a clear difference (typically 5–10%) after significant code changes while staying steady through cosmetic tweaks that don't affect performance. For example, you might notice the frame rate dropping in a particular game scene. Before starting any optimization work, set up a reproducible test case: pin down the exact spot where the problem shows up and make sure you can get to it quickly. This lets you take repeated measurements under identical conditions. In reality, though, that isn't always feasible, so developers often build a dedicated benchmark scene or test mode with strictly deterministic behavior. Each run then produces comparable results, making performance measurements far more trustworthy.

The whole point of benchmarking is to put a number on the comparison between the original and the optimized version of your code. Whenever possible, start with measurements so you can back up any claimed improvement with actual numbers rather than intuition. Despite its apparent simplicity, building good benchmarks hides plenty of pitfalls. One of the most common mistakes is measuring only hot runs: developers discard the first iterations to avoid warm-up effects or cache population, but forget that real games hit cold starts and cold data just as often as hot ones.

Automating benchmarks through CI/CD catches performance regressions early, while graphing the results makes it much easier to spot long-term trends and correlations between different performance metrics.

Measurements

The next step in optimization involves collecting and analyzing performance metrics to pinpoint bottlenecks. Every change should improve performance while consuming as little time and effort as possible—in other words, it should deliver a strong return on investment (ROI). Not every result will be a clear win, but even failed experiments help reveal the most promising direction for the next step.

Proper diagnostics ensure that developers address an actual performance issue rather than a random piece of code that barely affects overall efficiency. Metrics, profiles, and logs are the main sources of information here, helping teams avoid blind changes and unnecessary rounds of optimization.

The approach to optimization mirrors how engineers analyze any system: start with a high-level view, then gradually narrow the focus to specific cases. At the top level, the goal is to identify the primary performance constraint—whether it's the CPU, GPU, or memory. In most cases that actually need fixing, the bottleneck ultimately traces back to a particular module or code fragment, which narrows it down to the level of algorithms and data structures. To track down these issues, developers rely on profilers, system monitors, tracing tools, and engine performance metrics. These tools help identify hot spots—functions or routines that consume a significant share of CPU time, GPU resources, or memory. This is where optimization reaches the source-code level.

When analyzing performance, start by evaluating CPU and GPU usage with general-purpose monitoring tools. If the GPU isn't under heavy load, a profiler can help measure how much time the CPU spends updating and processing game logic. Identifying issues at the high level narrows the scope of analysis and lets developers focus optimization efforts on a specific area.

At the same time, it's important to remember that a performance issue rarely comes down to a single root cause. A game consists of countless micro-operations, and bottlenecks often emerge from the combined effect of several factors. At the instruction level, the issue may stem from a long-running call that leaves the processor sitting idle while it waits for data. At the GPU level, it might be an imbalance in how rendering commands are processed.

These may look like two separate problems, but fixing one often affects the other. By looking across different levels of analysis, from low-level details to high-level system behavior, you can identify the component that's actually constraining performance the most and put your resources where they matter. Always start with the big picture, then gradually drill down into specific bottlenecks. Combine multiple tools and metrics to track down and address real issues rather than rushing to fix the first suspicious symptom you run into. Building a list of potential problem areas can help, as it often points the way toward the actual source of the issue.

Finally, effective optimization has to factor in the cost of implementation. In some cases, removing a particular bottleneck may take substantial effort while delivering little gain. When that happens, it often makes more sense to focus elsewhere. The impact-to-cost ratio can serve as an optimization metric that helps teams avoid getting stuck obsessing over micro-optimizations. As a result, optimization stops being a collection of isolated tweaks and becomes a structured process that improves the efficiency of the whole system.

Changes and engineering intuition

Once you've gathered enough data and identified the bottleneck limiting performance, set those results aside and analyze everything again from scratch, this time deliberately excluding that bottleneck.

This technique is known as hotspot avoidance. The idea is to focus first on how the other system components behave, rather than on the hotspot you've already found (in about 80% of cases, there won't be any other significant bottlenecks). This approach can reveal hidden bottlenecks that stay invisible when a dominant hotspot monopolizes the profiler. It also gives you a fuller picture of how the workload distributes across the components.

The real value of this method becomes clearer once you've built up extensive experience optimizing different systems. By eliminating the main bottleneck, you can spot the points that, combined, can yield a significant performance gain, or that affect the hotspot itself in ways you wouldn't otherwise notice. The technique also simplifies the analysis of complex systems, where hidden dependencies often produce counterintuitive behavior (this is where so-called engineering intuition kicks in).

At that point, you can start making changes. They may involve modifying algorithms, optimizing data structures, improving memory access patterns, or, in extreme cases, rewriting an entire subsystem. Even experienced optimization engineers rarely land on the best solution on the first try. Most end up testing several hypotheses and trying out multiple approaches before settling on a final implementation. This is where the insights from hotspot avoidance prove especially valuable.

Once the changes are in place, measure performance again using benchmarks and profilers. Without measurement, there's no reliable way to tell whether fixing a bottleneck actually helped, or whether the real issue was sitting elsewhere. Systematic measurement not only helps identify the true causes of performance issues but also builds experience and sharpens what many engineers call engineering intuition—the ability to spot similar issues faster and more accurately on future projects. Some of my colleagues, who spent years working on game engines, could often pinpoint a problem just by examining logs and a profiler snapshot, without even looking at the source code. More often than not, their read on which subsystem or code fragment to modify turned out to be right. That's exactly the kind of intuition I mean.

Author: Sergei Kushnirenko

Sergei has over 20-year experience in coding and game development. He graduated from ITMO National Research University and began his career developing software for naval simulators, navigation systems, and network solutions. For the past fifteen years, Sergei has specialized in game development: at Electronic Arts, he worked on optimizing The Sims and SimCity BuildIt, and at Gaijin Entertainment, Sergei headed up the porting of games to the Nintendo Switch and Apple TV platforms. Sergei actively participates in open-source projects, including the ImSpinner library and the Pharaoh (1999) game restoration project.

All parts

Game++. Part 1: C++, game engines, and architectures

Game++. Part 1.2: C++, game engines, and architectures

Game++. Part 1.3: Game engine architectures

Game++. Part 1.4: Game engine architectures

Top comments (0)