Diagnosing "it stutters" tickets ② — pinning the culprit with Profiler and Frame Timing
The resolution test, what to actually look at in the Profiler, and splitting CPU/GPU time with FrameTimingManager. Turning felt reports into measured data.
- unity
- performance
- profiler
Diagnosing "it stutters", part 2 of 3 — ① The four causes · ② Measuring in practice · ③ Capturing the moment (③ coming next)
Part 1 sorted frame drops into four classes (CPU / GPU / GC / loading). This post is about confirming that classification with data instead of a guess.
0. Before measuring: don't trust editor numbers
Editor frame times include the editor's own overhead — inspector repaints, the scene view, the profiler UI. Deep Profile in particular injects instrumentation into every function call, so profiling itself manufactures a bottleneck. A function that looks heavy in the editor is frequently a non-issue in a build.
One rule: judge from a development build; use the editor for exploration only.
1. The 30-second test — flip the resolution
Before opening any tooling, run the cheapest experiment there is.
// Halve the render resolution — this reduces GPU load selectively
Screen.SetResolution(Screen.width / 2, Screen.height / 2, Screen.fullScreenMode);
- Noticeably better → GPU-bound. Look at shaders, overdraw, draw calls.
- No change → the CPU side. The search narrows to scripts, physics, and GC.
2. Reading the Profiler — where to actually look
In Window → Analysis → Profiler, three places matter.
The Timeline view under CPU Usage — look here before the Hierarchy view. You can see at a glance what fills the main thread bar and whether the render thread is idling. Long Gfx.WaitForPresent or WaitForTargetFPS means the CPU has headroom and is waiting on the GPU (or VSync) — the classic GPU-bound shape.
The GC Alloc column — in the Hierarchy view, this shows managed heap allocation per frame. The functions steadily posting non-zero values every frame are what feed GC spikes. Look at allocations in ordinary frames, not the spike frame — the culprit isn't in the spike.
The shape of the frame time graph — the signatures from part 1 show up directly here. High across the board means a bound problem, periodic needles mean GC, a one-off wall means a loading hitch.
3. FrameTimingManager — splitting CPU and GPU time into numbers
Where you can't attach the Profiler (QA machines, shipped builds), a runtime API gives you the same verdict.
// Requires Project Settings → Player → Frame Timing Stats (returns 0 without it)
FrameTiming[] timings = new FrameTiming[1];
void Update()
{
FrameTimingManager.CaptureFrameTimings();
if (FrameTimingManager.GetLatestTimings(1, timings) == 0) return;
double cpuMs = timings[0].cpuFrameTime;
double gpuMs = timings[0].gpuFrameTime;
// gpuMs consistently larger than cpuMs means GPU-bound,
// the reverse means CPU-bound — the guess becomes a number
}
There are platform and graphics API constraints, and unsupported environments return 0. Skip the zero check and you end up with a dataset claiming every frame took 0ms.
4. A case where we picked the culprit by guessing, and were wrong
Here's ours. Stuttering reports came in for Windows builds only, and the first suspect was our SDK's always-on capture. It was a reasonable guess — it was the only thing running constantly.
Measurement said the opposite. The stutter was identical with the SDK completely disabled, and splitting the frame time showed the GPU as the bottleneck. Had we acted on the guess and "optimized" the SDK, we'd have spent the time for no effect while the real cause sat untouched.
The lesson is plain: in performance work, circumstantial evidence and intuition are wrong often, and measurement is cheap. The resolution test takes 30 seconds.
What's still missing
Everything above shares one premise — the instrumentation has to be attached at the moment the problem happens. But performance tickets happen on QA machines, when no developer is around, with no Profiler running. Attaching instrumentation after receiving an "it stuttered" report puts you back in the problem from the earlier series: the record button is always pressed too late.
Collecting performance data ahead of time, always, and cheaply is the subject of the last post.