Twenty-One Milliseconds: Draw Calls and a Blocking Occlusion Readback
A 1080p title targets 60 Hz on a mid-range desktop GPU. A 300-frame capture reports: frame time 21.4 ms (P99 27 ms), GPU busy 17.2 ms, CPU main thread 19.8 ms, render thread 14.1 ms. The frame issues 2,400 draw calls, of which 1,900 are static props drawn from only 12 meshes. Each frame, one occlusion-query result from the previous frame is read back with a synchronous get-query-result call before culling; the profiler attributes 5–8 ms of main-thread time to that call. The shadow pass costs 5.1 ms at 4096² with a single cascade covering 200 m. Post-processing costs 3.2 ms and reads three RGBA16F targets. MSAA is off and FXAA is used. The engine already supports instancing and indirect draws, and the shader compiler reports no register spills.
1. Classify the bottleneck from this data, explain the apparent contradiction between GPU and frame time, and quantify the readback problem.
Read the worked answer
Classification. Frame time 21.4 ms > GPU busy 17.2 ms, and main-thread 19.8 ms is the largest single component, so the frame is CPU-bound with a partly idle GPU - the GPU finishes and waits. Note, however, that 17.2 ms of GPU work is already 0.5 ms over the 16.67 ms budget, so the GPU is not healthy either; removing the CPU stall alone will not reach 60 Hz. Both sides need work, in that order of expected gain.
The contradiction explained. GPU busy is measured from timestamps around submitted work, not wall-clock occupancy; a CPU-bound frame leaves bubbles the GPU sits idle through. So "GPU 17.2 ms" is not the limit - it is what was submitted, and it can legitimately sit inside a 19.8 ms main-thread frame.
The readback. A synchronous query result forces the driver to wait until the GPU has finished the query's work. Because the query is issued the frame before but the wait happens mid-frame, the driver may flush the command buffer and block until completion, stalling the CPU for 5–8 ms and also serialising the CPU/GPU pipeline. Effectively, one query result is costing more than the shadow pass it is meant to save. On a 14.1 ms render thread, 2,400 draws at roughly 6 µs each account for most of the remaining submission cost, consistent with the observed 1,900 small props.
2. Propose a prioritised fix list with estimated savings that closes the 4.7 ms gap, and justify the ordering.
Read the worked answer
Priority 1 - remove the stall (CPU, 5–8 ms). Make occlusion queries fully asynchronous with a 2–3 frame latency: issue queries this frame, poll results without blocking, and use last-known visibility. If the engine's indirect-draw path can run culling in a compute shader and write the argument buffer on the GPU, the readback disappears entirely. Expected: 5–7 ms off the main thread. Risk: stale results cause popping; mitigate with conservative bounds, hysteresis, and always drawing objects that were visible last frame.
Priority 2 - collapse draw calls (CPU, ~10 ms render thread). 1,900 static props from 12 meshes should become 12 instanced/indirect draws plus a few hundred for genuinely unique content, aiming for <300 total. Individual draws are dominated by per-draw validation and state setup, so this scales roughly linearly. Expected: 8–10 ms off the render thread.
Priority 3 - shadow pass (GPU, ~3.5 ms). A 4096² map over 200 m gives 4.9 cm texel density - far finer than the frame needs. Drop to 2048² (9.8 cm) or split into two tighter cascades: ~4× fewer shadow texels, roughly 5.1 → 1.5–2.0 ms.
Priority 4 - render-target bandwidth (GPU, ~0.5–1 ms). Three RGBA16F targets cost 8 bytes/px; R11G11B10F costs 4 and is enough for colour before tonemapping. Only convert targets that genuinely need the extra exponent range.
Ordering logic. Do the CPU work first because it is the stated bottleneck and carries the largest, most certain savings; then the GPU so the new headroom is not immediately consumed. Re-capture after each change - a single combined patch makes attribution impossible.
3. Describe the validation and measurement plan, including the risks introduced by asynchronous occlusion and how you would detect them.
Read the worked answer
Measurement hygiene.
- Never profile with a synchronous readback present, and remove any debug readback from the profiled build; it changes what is being measured.
- Use GPU timestamps per pass plus CPU markers on main and render threads, and record P50 and P99, not means - the P99 of 27 ms is the player-visible problem.
- Check frame pacing, not just average FPS: a 16 ms mean with 27 ms spikes still stutters.
- Re-run the same 300-frame capture path for before/after comparisons, and hold scene, resolution, and driver version fixed.
Asynchronous occlusion risks.
- False occlusion. An object judged invisible two frames ago becomes visible and pops in. Detect with an automated flythrough that diffs frames against a ground-truth build with queries disabled; flag any pixel turning from background to foreground in a single frame near a screen edge.
- Latency accumulation. If results are consumed one frame late but culling runs before submission, a moving camera can invalidate the result; enforce a conservative bounding-box expansion proportional to camera speed.
- Query pool starvation. Too many queries in flight throttles the driver; cap the number issued per frame.
- Silent coverage loss. Track an "objects culled vs drawn" counter and compare against a reference build; a sudden jump in culled count without a frame-time drop usually means the results are stale or the bounds are wrong.
Finally, keep a runtime toggle that reverts to frustum-only culling so that field reports can be triaged without a rebuild.