§3 Performance
Profiling Your Game: Finding the 3% of Code Eating 90% of Your Frame
Most performance work is wasted, because it's aimed at code that was never the problem. The frame is slow, so you rewrite the thing that feels slow, spend three days on it, and the frame time barely moves. Profiling replaces that guessing with a number, and the number is almost always more lopsided than you expect.
The frame budget is the only target that matters
Sixty frames per second means each frame gets 16.67 milliseconds — for everything: input, simulation, animation, culling, draw calls, the lot. Thirty fps gives you 33.3 ms. That budget is your ruler. "Is this function slow?" is unanswerable; "does this function fit the budget?" has a yes-or-no answer. Before you optimise a single line, know your target frame time and how far over it you are.
Amdahl, roughly: speeding up code that's 5% of the frame can, at absolute best, make the frame 5% faster. Find the big slice first. A 2× win on the hot path beats a 100× win on a cold one.
Measure the whole frame before you zoom in
Start coarse. Wrap the top-level phases in timers and print a per-frame breakdown. You don't need a fancy tool for this first pass — a scoped timer that accumulates into named buckets is enough to point you at the right continent before you go looking for the right street.
struct ScopeTimer {
const char* name;
std::chrono::high_resolution_clock::time_point start;
ScopeTimer(const char* n)
: name(n), start(Clock::now()) {}
~ScopeTimer() {
auto us = duration_cast<microseconds>(
Clock::now() - start).count();
Profiler::record(name, us);
}
};
// usage
{ ScopeTimer t("physics"); stepPhysics(dt); }
{ ScopeTimer t("render"); renderScene(); }
Run it, look at the buckets. Nine times out of ten one phase dwarfs the rest. That's your continent. Now you can afford to pull out the real tool.
Read the flame graph, don't skim it
A sampling profiler — perf, Instruments, Superluminal, VTune, Tracy — interrupts your program hundreds of times a second and records the call stack each time. Aggregate those samples and you get a flame graph: stacked bars where width is time. It's the single most useful performance picture there is, and most people misread it.
- Width is everything. A bar's width is the fraction of samples spent in that call. A tall, thin tower is deep recursion that costs nothing. A short, fat bar is a shallow function eating your frame. Hunt width, ignore height.
- Prefer "self" time to find the culprit. Total time includes children; self time is the work done in that function alone. A wide bar with tiny self time is just a parent — the cost is in a child. A wide bar with large self time is where the CPU actually sits.
- Look for surprises, not suspects. The win is usually a function you didn't expect: a per-frame allocation, an
std::stringbuilt in a loop, a sort that didn't need to run, a texture bind called thousands of times.
The "3% of code, 90% of frame" in the title is not hyperbole. On a real project I once found a single std::map lookup, called once per particle per frame, taking 70% of the simulation because the map hashed a string key every time. Twelve lines changed to an integer key. The frame dropped from 22 ms to 9 ms. No amount of staring at the code would have revealed it — only the sampler did.
Change one thing, then prove it
This is the step people skip and the reason optimisation gets a bad name. After a fix, re-measure the exact same scene under the exact same conditions and compare frame times. If the number didn't move, revert the change — even if it "should" be faster. Speculative optimisation that doesn't show up in the profile is just added complexity and new bugs.
- Capture a baseline on a fixed, repeatable scene.
- Read the flame graph; pick the single widest self-time bar.
- Make one focused change to that function.
- Re-capture the same scene; compare against the baseline.
- Keep it only if the frame time actually dropped. Then repeat from step 2.
A few reliable hot spots in games
Once you've done this a dozen times, patterns emerge. Per-frame heap allocations are a classic — pool them. Cache-hostile iteration over pointer-chasing structures shows up constantly, which is exactly why the ECS layout keeps components in packed arrays. Redundant state changes on the GPU (binding the same shader or texture over and over) inflate the render bucket. And doing work every frame that only changes occasionally — rebuilding a navigation mesh, re-sorting a static list — is free performance the moment you cache it.
But don't take my list and start "fixing" these blind. That's just guessing with extra steps. Measure your frame, read your flame graph, and let the profiler tell you which of these — if any — is actually costing you today.
Read next
- Writing a Fixed-Timestep Game Loop That Doesn't Drift →
- An Entity Component System From Scratch, Explained Simply →
Members download the scoped-profiler snippet and a sample capture to practice reading. See membership →