Rendering in Retrospect · #6 of 12

Stop Guessing: A Profiling Habit for Graphics Programmers

Retrospective — written in 2026, looking back.

Every graphics programmer I know, including me, has a story about optimizing the wrong thing. Mine follow a pattern: a frame feels slow, I form a confident theory from the code — that shader looks expensive, that pass must be the problem — I spend an evening rewriting it, and the frame time does not move. The cost was never where the code looked expensive. It was in a state change, a synchronization point, or a pass I had not thought about at all.

This post is about the habit I built in my personal projects to stop that cycle. It is not tied to any particular tool. The tools change; the discipline does not.

The CPU Number Lies by Omission

The first trap is measuring the wrong clock. Timing a render loop on the CPU tells you how long the CPU spent submitting work. It tells you almost nothing about how long the GPU spent executing it, because the two run asynchronously. A CPU frame time can look excellent while the GPU falls further and further behind, buffered work piling up until a presentation or a readback forces everything to wait at once — at which point the hitch gets blamed on whatever happened to be in that frame.

So the rule I settled on is simple: if the question is about rendering cost, the answer has to come from the GPU's own timeline. In practice that means GPU timestamp queries placed around the work I care about — a pair of timestamps bracketing a pass, read back a few frames later so the readback itself never stalls the pipeline.

A minimal version of the pattern, in pseudocode:

// At record time, for pass i:
writeTimestamp(queryPool, i * 2,     BEFORE_PASS);
recordPassCommands(...);
writeTimestamp(queryPool, i * 2 + 1, AFTER_PASS);

// Several frames later, once those queries are known complete:
readTimestampResults(queryPool, results);
duration[i] = results[i * 2 + 1] - results[i * 2];

Three details in that sketch matter more than the code itself. First, results are read several frames later — querying this frame's timestamps this frame defeats the entire point of asynchronous execution. Second, timestamps measure elapsed GPU time between two points in the command stream, which is not always the same as the isolated cost of one pass; gaps, overlaps, and stalls between the markers are included, and that is often exactly what you want to discover. Third, a single frame's numbers are nearly worthless. I record distributions over many frames and look at medians and bad percentiles, because the interesting bugs live in the frames that spike, not the average frame that behaves.

Hypothesis First, Measurement Second

The habit that changed things most was writing the hypothesis down before opening any profiler. Not in my head — in a text file, in one sentence, with a predicted number attached.

"I think the shadow pass dominates because it re-renders all casters. If I halve the caster count, that pass should get roughly proportionally cheaper, and the total should drop noticeably."

That sentence does three jobs. It names the suspected pass, so I know which timestamps to look at. It makes a prediction, so the measurement can actually fail — a vague suspicion can survive any result, but a predicted proportionality cannot. And it defines the experiment: change exactly the thing the hypothesis is about, nothing else.

The corollary is just as important: change one variable per experiment. In my demo projects I used to bundle improvements — tighten a shader, reduce a resolution, and sort objects in the same session, then declare victory when things improved. Which change helped? Which one hurt and was carried by the others? Unknowable. The bundled version feels faster and teaches nothing, and graphics performance work is mostly teaching yourself where time actually goes.

When a measurement contradicts the hypothesis, that is the good outcome, not a failed experiment. It means the mental model of my own renderer was wrong in a specific, locatable way. My most useful profiling sessions have all started with "that can't be right" — a pass costing far more than its work should require — followed by the slower realization that the cost was a pipeline barrier, a layout transition, or a resource being written and read in a pattern that serialized work I assumed ran in parallel.

Captures Are for Questions, Not for Browsing

A frame capture — a recorded, inspectable snapshot of everything submitted in one frame — is an extraordinary tool and an easy time sink. Opened without a question, it invites an afternoon of browsing draw calls and admiring one's own pipeline.

My rule now: never capture speculatively. A capture answers a specific question that timestamps raised but could not resolve. Timestamps tell me which pass is expensive and by roughly how much. A capture tells me why — what state was bound, what resources were attached, whether a pass rendered far more pixels or instances than intended, whether a barrier sits where no dependency requires one.

So the workflow in my personal renderer became a loop:

  1. 1. Timestamp everything coarsely, every frame, cheaply. A ring of per-pass timings costs almost nothing and runs always.
  2. 2. Notice an anomaly in those numbers — a pass out of proportion, a spike pattern, a cost that does not scale the way the scene does.
  3. 3. Write the hypothesis and the prediction.
  4. 4. Capture one representative bad frame and interrogate it with that question only.
  5. 5. Change one thing, re-measure the same scene from the same camera, compare distributions — not single frames.

Step 1 deserves emphasis because it is the one people skip. If timing instrumentation only exists when you go looking for a problem, you will only ever find problems you already suspected. Always-on coarse timing is how the unsuspected ones surface: the pass you never think about quietly doubling when some unrelated scene content changes.

Reproducibility Is Half the Work

A measurement is only as good as the scene it was taken in, and graphics scenes are slippery. A free camera means every measurement run renders something slightly different. Dynamic objects, varying simulation state, and shader compilation hitches all contaminate results.

In my demo projects I keep a handful of fixed test views: saved camera positions with the scene in a deterministic state, chosen because each stresses something different — one overdraw-heavy, one geometry-heavy, one with many small draws. Optimizations are judged by re-running the same views before and after. It feels pedantic until the first time an "optimization" turns out to have been a slightly different camera angle.

Two more pieces of capture discipline that cost little and save a lot:

Note what I am deliberately not doing in this post: quoting timings from my own projects as if they were general truths. They are not. A pass cost on one machine, one driver, one scene says very little about yours. The transferable part is never the number — it is the method that produced it.

The Habit, Compressed

If I had to distill years of misdirected optimization into the advice I now tape to my own monitor, it would be this. Measure on the GPU's timeline, not the CPU's. Write a falsifiable hypothesis with a predicted outcome before you measure. Capture frames to answer questions, not to go looking for them. Change one variable at a time, in a reproducible scene, and compare distributions across warmed-up runs.

None of that makes a renderer faster by itself. What it does is make your beliefs about the renderer accurate, and accurate beliefs compound: every subsequent decision — what to optimize, what to leave alone, what a design will cost before you build it — gets better. Guessing feels fast and is the slowest thing I ever did. The profiling habit is the opposite: it feels slow for one frame, and then it pays for every frame after.