Technical diary

A profiler, and the three things it found

You cannot optimise what you cannot see, and I could not have told you where a single millisecond of a 4.1 ms frame went. A compile-time-gated, per-thread, hierarchical profiler with two sinks — an aggregating tree for what things cost, a wrapping event ring for what happened — and D3D12 timestamp queries reported as one more thread. Then a harder lesson: measuring work is not the same as measuring the frame. Separating the waiting from the working found a diagnostic taxing every shipped frame, a shadow flicker paced in frames rather than hertz, and two of every five frames rendered for a monitor that was never going to show them.

The frame was 4.1 ms and I could not have told you where a single millisecond of it went. Not vaguely — at all. There was an FPS counter, a CPU percentage, and a lot of reasoning from first principles about which parts of the engine ought to be expensive.

That is a bad position to optimise from, because the thing you are sure about is the thing you never measure. So before touching performance again I built the instrument: a compile-time-gated, per-thread, hierarchical profiler that reports CPU scopes and GPU passes in one tree.

The last three sections are what it found, none of them in a place I would have looked. The third only became visible after I learned that a profiler measuring work still cannot tell you what is holding a frame back — which is the part of this I got wrong first time and had to go back for.

What a scope costs

Every design decision in a profiler falls out of one number: what it costs to ask the clock. Instrument a scope and you pay two timestamps, so that number sets how finely you are allowed to look.

I measured it rather than assuming, on the development machine, pinned to one core, minimum of seven trials:

call cost tick
__rdtsc 4.52 ns 0.41 ns
__rdtscp 8.49 ns 0.41 ns
QueryPerformanceCounter 9.85 ns 100 ns
chrono::steady_clock 10.80 ns 100 ns

A full scope — two timestamps plus the node lookup and accumulate — is 10.6 ns with the TSC against 22.1 ns with QPC. At ten thousand scopes a frame that is 0.11 ms versus 0.22 ms, and honestly both are affordable.

The resolution is what decides it. QPC ticks at exactly 100 ns here, so a scope shorter than a microsecond has both its ends snapped to the grid: a real 250 ns scope reads as 200. Averaging hides that in an aggregate and cannot touch a raw timeline at all. Windows derives QPC from the TSC and then scales it down to 10 MHz; __rdtsc just declines to throw that resolution away.

A 250 nanosecond scope measured against each clock Three timelines over the same 400 nanosecond span. The actual scope runs from 130 to 380 nanoseconds, a duration of 250. QueryPerformanceCounter ticks only every 100 nanoseconds, so both ends of the scope snap inward to the grid and it measures as 200 nanoseconds. The timestamp counter ticks every 0.41 nanoseconds, far finer than the scope, and measures the true 250. ONE SHORT SCOPE, TWO CLOCKS actual 250 ns QPC 100 ns tick reads 200 ns both ends floor to the grid TSC 0.41 ns tick reads 250 ns Measured on the development machine: QPC 9.85 ns a call at a 100 ns tick, __rdtsc 4.52 ns at 0.41 ns. The cost was never the deciding number.
A 250 ns scope measured against each clock. QPC's 100 ns grid rounds both ends inward.

There is deliberately no per-read fallback to QPC. That would put a branch on the hottest path in the engine to serve a CPU that has not shipped since about 2008. Startup checks for an invariant TSC, warns loudly if it is missing, and carries the answer in the readout so every number below it inherits the doubt visibly.

The gate goes in front of the clock

If the clock is nine of a scope's eleven nanoseconds, then the way to instrument aggressively is to not read the clock.

Every zone carries a compile-time level — a frame landmark, a subsystem phase, an inner loop — and every node in the tree carries a runtime threshold that is inherited by everything beneath it. A scope whose level is past the threshold in force costs a comparison and a balanced push/pop. No timestamp, no node lookup, nothing.

The threshold is raised from the console on one branch at a time:

profile detail frame/render 1

The override lives on the node, not the zone, so it is path-scoped: Physics under Update can be turned up while Physics under the editor preview stays quiet. It inherits downward and costs nothing to inherit, because the effective threshold is already sitting on the shadow stack — one read of a value being carried anyway.

Typing a path is a poor way to reach it, though, because the path is precisely what you are reading the tree to learn. So a row is the control: click it and it cycles inherit → 1 → 2 → back. The marker distinguishes an override set here from a level inherited from a parent — [1] against (1) — since only the first is that row's to clear.

There is a subtlety in that cycle worth naming, because I got it wrong and only caught it by driving the whole loop rather than reading the code. An override governs a node's children, not the node, so the cycle has to count from the level currently in force — otherwise the first click on a row beneath an already-raised parent re-grants a level it was getting anyway and reads as a dead control. And the last step is the mirror of that: testing the effective level alone swallowed the clear, leaving a raised branch with no way back except the typed command.

Default level. `render` is a single flat row — the whole render half of the frame as one number.
One click on `render`. The same branch five zones deeper, and nothing else in the tree changed.

There is a limitation that falls out of this rather than being designed in: you can only raise detail on a branch that already exists, because you name a node by its path. Level-0 landmarks always record, so the top of every tree is there to aim at and you drill down from what you can see — raise a branch, look again, raise the branch that just appeared.

Storage belongs to the registry, not the thread

Each thread writes only to its own collector, so the hot path takes no lock and contends with nothing. But the collector does not live in the thread. The registry owns a fixed array of slots and a thread holds a pointer to one.

That inversion is about one specific API. This engine's thread manager can force-terminate a wedged worker with TerminateThread, which runs no destructors — not thread_local ones, not anything, and it may release the thread's TLS underneath whoever is holding it. A collector held as a genuine thread_local would leak or vanish at exactly the moment its profile is most wanted. Registry-owned, a killed worker's last tree is still sitting in its slot, readable.

Registration lives in the thread manager's worker loop rather than in any client, which is the difference between "the AI workers are profiled" and "threads are profiled". When I later moved a stray OS query onto a new background worker, it appeared in the profiler without anyone wiring it up.

And a frame is the main thread's unit; a tick is a worker's. The AI buckets run at 251/499/997/1999 ms, so forcing them into frame buckets would show empty trees and one false spike. Each collector publishes on its own boundary.

Two sinks, because an average cannot see a hitch

The tree aggregates. One node per call path, accumulating inclusive time and the time its children took, so exclusive time falls out by subtraction. It can tell you the average frame spends 4.1 ms in render. It can never tell you that one frame spent forty.

So there is a second sink: a per-thread ring of raw enter/exit events that wraps rather than growing, always recording, dumped on demand as Chrome Trace JSON that Perfetto and speedscope both read.

Always recording matters. A flight recorder is not a press-record button — by the time you notice a stutter it has already happened.

One level gate, two sinks Every instrumented scope is first tested against the detail threshold in force for its part of the tree. A scope past the threshold is skipped before any clock is read, costing only a comparison and a balanced push and pop. A scope that passes feeds two sinks at once: an aggregating call tree, read live by the console panel, and a wrapping ring of raw enter and exit events that is dumped to a Perfetto trace on demand. ONE GATE, TWO SINKS scope enter zone level vs threshold before any clock is read skipped a compare and a push/pop call tree aggregated in place event ring wraps, never grows console panel what does it cost Perfetto trace what happened, and when The tree can say the average frame spends 4.1 ms in render. Only the ring can say that one frame spent 55.
One gate, two sinks. The tree answers "what does it cost"; the ring answers "what happened at 08:41:12".

An event is 16 bytes and carries no kind field: a null zone means "exit of the innermost open scope", which the reader is already keeping a stack to know. That buys the packing for free and needs no interning, because zones are function-scope statics whose address is their identity.

On its first trace the ring justified itself: update had a mean of 0.025 ms and a max of 55.6 ms. A 2200x outlier that the tree had been averaging into nothing the entire time.

The GPU is one more thread

The CPU tree kept saying the same thing: about 0.2 ms of CPU work in a 4.1 ms frame, and scene costing eight microseconds. Eight microseconds, because that is only command recording — the GPU does the work afterwards, on its own clock, and nothing CPU-side can see it.

D3D12 timestamp queries fix that: a timestamp either side of each marked pass, resolved into a readback buffer at end of frame, read kFrameCount frames later in BeginFrame — right after the fence wait for the slot being reused, which is the exact point the GPU is known to have finished writing them. A GPU timing is therefore always a few frames stale. That is inherent; you cannot read a number the GPU has not written yet.

The part I am pleased with is where the results go. The profiler gained an external slot — a registry slot owned by no thread — and an AddSpan that takes a duration measured on another clock. GPU ticks convert to TSC units once via the frequency ratio, so one conversion function still means one thing everywhere.

The GPU is then simply a row called gpu in the tree. The console's list view, the graph view with its twelve seconds of history, and the trace dump all already knew how to draw a thread. None of them needed a line changed.

The main thread, four AI workers, the OS-sampler worker and the GPU — all rows in one tree, because the GPU is fed in as a registry slot that no thread owns.

Measuring work is not the same as measuring the frame

The tree could now tell me what everything cost, and it still could not tell me what I actually wanted to know: what is holding the frame rate down. Those are different questions, and the second one is not answered by any amount of detail on the first.

Here is why. The render zone wrapped three things:

DN_PROFILE_ZONE("render");
list = device.BeginFrame(clearColor);   // fence wait: blocked on the GPU
game.Render(list);                      // command recording: actual work
device.EndFrame();                      // Present: blocked on the display

Two of those three are the main thread stopped, and the CPU-versus-GPU question lives entirely in which one it is stopped in. Undivided, all three arrive as one number that reads as "rendering is expensive" — so a frame parked at the display and a frame drowning in draw calls produce the same 4.1 ms and look identical.

The profiler had been measuring work and never waiting.

Splitting them is three zones, and each is a signature. Time in the fence wait means the GPU is running frames behind — GPU-bound. Time in command recording means the main thread is the ceiling — CPU-bound. Time in Present means being ahead of the display, which is the healthy answer.

A frame's wall clock split into work and blocks The render zone measured as one number hides three different things: command recording, a fence wait on the GPU, and a blocking Present. Which one dominates is the diagnosis. Measured as one zone render 4.115 ms reads as “rendering is expensive” Split into work and blocks 0.16 3.87 record work wait.gpu blocked present blocked Whichever dominates is the answer record the main thread is the ceiling — CPU-bound wait.gpu the GPU is running frames behind — GPU-bound present ahead of the display — healthy, or the GPU is hiding here present is ambiguous vblank, or no free back buffer? GPU busy time is what tells them apart
A frame's wall clock is work plus blocks. Which block dominates is the answer, and it is not visible until they are separated.

The trap is that one of those three signatures is ambiguous. A blocking Present means either waiting for the vblank it is synced to or waiting for a back buffer the GPU has not released. Nothing in the call distinguishes them, so "which wait was longest" cannot tell display-bound from GPU-bound — the two most different diagnoses on the list.

What disambiguates them was already being collected: GPU busy time. If the GPU is saturated it is the ceiling regardless of which wait blocked; if it is mostly idle and the frame is at refresh cadence, it is the display. So the verdict tests GPU saturation first and lets the waits break only the remaining tie. That ordering is the whole reason the answer is trustworthy rather than merely plausible.

CPU time is then taken by elimination — the frame minus every block — rather than by reading the recording zone, which would miss update and the parts of render no zone covers and quietly flatter the CPU.

The panel now says bound by CPU, bound by GPU, bound by display or bound by cap on its header, with the four numbers it reasoned from beside it.

Hovering the header explains all seven terms at once, each in its own colour so the tooltip doubles as the key for the bar and the graphs.

Reading it

The console panel has two views, and getting them readable took longer than getting them right.

The digits could not be read at all. Every figure on the panel was one published period, so at 240 fps they changed 240 times a second. Averaging alone does not fix that — a mean recomputed every frame is smoother but still repaints its low digits every frame, and the eye needs the number to hold still more than it needs it to be exact. So the window tumbles: samples accumulate for 250 ms, then the mean is committed and displayed unchanged until the next one. One mechanism buys both halves.

worst is the exception and takes the maximum over the window rather than the mean, for the same reason the graph history does. Averaging a worst case produces a number that is neither.

The bars became a chart rather than seven unrelated gauges. They used to be indented to match their row, which meant every depth measured from a different origin and no two bars could be compared; giving them one origin lets a single set of gridlines run the whole column and mean the same thing on every row. Nesting moved to pips in the gutter — outside the measured area, where nothing can be mistaken for a quantity — and a light frame around each parent and its children carries the grouping across the numbers, which the indentation only ever did at the far left.

Hovering the frame bar redraws it larger with every part named. The GPU sits below a rule rather than in the stack: it overlaps the *next* frame's work, so stacking it would count the same milliseconds twice.

The graph view answers the other question. Every measure gets a line scrolling leftward, twelve seconds wide, sampled on a timer rather than per frame — at 240 fps a per-frame ring of 240 slots would hold one second, which is far too short to watch anything travel across it. Each slot keeps the maximum since the last commit, because a mean averages away the one frame that spiked and that frame is the entire reason to be looking.

Which sets up a distinction I had to be told about, by switching to this view and finding the numbers unreadable again: a plot and the number beside it want opposite things from the same measurement. The plot is a shape and needs the raw samples — smoothing it would iron out the very spike it exists to show. The label is read, and at frame rate it cannot be. So the graphs draw raw and their figures draw held, and the avg 250 ms note in the header had to stop being a list-view label, or a whole view would have been quietly reporting means as instants.

Two graphs lead it now. The budget in milliseconds, three series on one shared scale so that which of them tracks the frame is which one is the ceiling — the per-node graphs each autoscale to themselves, which makes them all look equally full and useless for that comparison. And utilisation on a fixed 0–100%, where the empty space above the lines is the headroom. No millisecond graph can show headroom, because it has no idea what "full" would be.

Frame budget and utilisation lead the graph view. 10% CPU and 26% GPU of a frame, which is a picture of an engine doing almost nothing.

And a panel that only shows now cannot answer the question anyone has, which is "did that change help". profile snap <name> records over a few seconds; profile diff a b prints the budget deltas and then the rows that moved, biggest first. Keyed by thread and path, never by index — between two snapshots the tree has usually changed shape, and a positional key would diff one scope against another and report confident nonsense.

Both panels graphed. The six machine gauges use fixed scales and the profile timings autoscale, which is the only difference between them.

The six machine gauges — frame rate, CPU, GPU, RAM, VRAM, descriptor slots — get the same treatment with one difference: they graph against a fixed scale, because each has a natural ceiling. A profile timing has none, so it autoscales to its own window. Autoscaling a percentage would redraw 4% CPU as a full graph and make an idle machine look pegged.

Two details that only came from looking at it. The bars were originally drawn as each row's share of its thread's total — which meant every worker's single root node got a full-width bar beside a 0.000 ms reading, and the panel confidently showed four idle threads as saturated. And on a fixed scale, VRAM at 1 GB of an 11 GB budget is a 1.5px line four pixels off the floor, visually identical to an empty graph, until the area under it is filled.

PERFORMANCE collapsed to one line. It still reads FPS, CPU and GPU, because collapsing should cost the detail and not the reason to look.

The first thing it found

main/update had a mean of 0.03 ms and a peak over 1 ms, and in the graph view the peaks were suspiciously regular. A rhythm, not noise. An average would never have shown it; a max alone would have shown it without saying it repeated.

Level-2 zones through the console's own sampling named it in one trace: PerfMonitor's PDH GPU-engine query. Forty-one calls, every one over 50 µs, mean 578 µs — and the gap between them was 333 ms with a standard deviation of zero. A metronome, which is exactly what a 0.33 s sample interval makes it.

The size was not what made it worth fixing. PerfMonitor::Tick ran before the console's own is-open check, so that query was paid three times a second in every build whether the console was open or not. A diagnostic was taxing shipping frames. It moved, with the CPU and memory queries beside it, onto a managed worker:

main thread before GPU query moved all OS queries moved
perfmon p99 591 µs 26 µs 0.6 µs
update p99 627 µs 146 µs 124 µs
world p99 72 µs 86 µs 97 µs

world is the control, unchanged across all three, which is what says the comparison measures the change rather than the weather. And the signature disappeared rather than shrinking: main-thread calls over 50 µs went 41 (every 333 ms, stdev 0) → 22 (irregular) → zero of 3,277.

Then the shadows

With the GPU finally visible, the largest single thing the machine did was not the scene. It was shadows: gpu.shadows at 1.236 ms against gpu.scene at 0.876 ms.

The shadow pass already had a cache — a cube re-renders only if its light moved, the geometry changed, an animating caster is near, or a flicker tick is due. So the question was not "what does a cube cost" but "how much of the cache is missing".

That is a call count, not a timing, and the profiler reports call counts. Two level-2 zones — one per cube re-rendered, one per face — and the answer over 1,009 frames was 3.50 cubes and 20.98 faces per frame out of eight slots. At 240 fps, about five thousand shadow-face renders a second.

The cause was one line:

constexpr u64 kFlickerInterval = 2; // re-render wandering fire cubes at half rate
const bool flickerDue = (m_frameCounter + slot) % kFlickerInterval == 0;

"Half rate" is not a rate. Seven fire lights held shadow slots, each re-rendering every other frame on a stagger: 3.5 cubes a frame by construction, and the measurement agreed to two decimal places.

Two things follow, and the second is the real bug. The cost per second scaled with frame rate for no visual gain — and the flicker itself ran four times faster at 240 fps than at 60. The fire looked different on different hardware.

Shadow re-render cadence, counted in frames against measured in hertz Two timelines over the same 240 millisecond window at 240 frames per second. Counting in frames, each of seven fire lights re-rendered its shadow cube every other frame, giving about twenty-one shadow faces per frame and roughly five thousand a second. Measured in hertz, a cube re-renders at most once every 71 milliseconds, giving about two faces per frame — and the flicker no longer changes speed when the frame rate does. SHADOW CUBE RE-RENDERS OVER 240 ms AT 240 fps frames every 2nd frame 20.98 faces per frame — about 5,000 a second and four times faster at 240 fps than at 60, so the fire looked different on different hardware hertz every 71 ms 2.33 faces per frame at 14 Hz — nine times fewer and the same speed at any frame rate, because a cadence is a property of seconds Counts measured over 1,009 frames at 14 Hz. The shipped default is 25 Hz, chosen by eye, which costs proportionally more and is still a large saving.
Frame-counted pacing ties both the cost and the look to frame rate. A time-based interval does not.

The fix is that a flicker interval is measured in hertz. A fire cube now re-renders at most every 1/hz seconds, with a per-frame budget that also staggers them so they cannot all come due on one frame:

cubes/frame faces/frame CPU shadows mean / p99
frame-counted 3.50 20.98 95.7 µs / 200.4 µs
time-based, 14 Hz 0.39 2.33 14.5 µs / 94.0 µs

Nine times fewer shadow faces, and the GPU gauge fell from 44–47% to 30% across the change — a corroboration from a number the profiler does not produce.

Only a flicker render re-paces the flicker clock. A cube re-rendered because a monster walked past does not reset the aesthetic cadence, or a busy room would flicker faster than a quiet one.

The two counting zones. `shadow.cube` and `shadow.face` read a fraction of a call per frame now; every frame used to render three or four cubes.

And then the frames nobody ever saw

With the waits separated, the panel said something I had been looking straight past for weeks: of a 4.163 ms frame, 3.85 ms was present and the fence wait was a flat 0.000. The CPU did 0.31 ms of work, the GPU 1.79 ms, and the rest of the frame was the main thread standing still.

Display-bound, then, and healthy. Except the numbers did not agree with the machine. A 4.163 ms frame is 240 fps. The window was on a 144 Hz monitor, where vblank is every 6.94 ms.

So Present was blocking — genuinely, measurably, for 3.85 ms — and syncing to something at 240 Hz that was not the display it was drawn on.

A sync interval of 1 does not mean "the refresh rate of this monitor". A windowed flip-model swapchain is paced by DWM, and on a mixed-refresh desktop DWM composes at the fastest attached display's rate. There are three 240 Hz panels elsewhere on this desk. The game was being paced by monitors it was not on.

A windowed swapchain paced by the wrong monitor On a mixed-refresh desktop the compositor composes at the fastest attached display's rate, so a window on a 144 Hz monitor presents at 240 Hz and two of every five frames are discarded. The desk this monitor 144 Hz the window is here three others 240 Hz the compositor paces to the fastest of these Presented at 240 Hz · 4.16 ms apart ten frames Shown at 144 Hz · 6.94 ms apart six survive Four of ten rendered and discarded — full GPU cost, never seen. A sync interval of 1 means “every composition”, not “this monitor's refresh”.
The compositor paces to the fastest attached display, not to the one the window occupies. Two of every five frames are rendered and then discarded.

I did not want to build a fix on an inference, so the inference got a test with a prediction attached. Halving the present interval should divide whichever clock is really pacing: 8.33 ms if it is 240 Hz, 13.9 ms if it is 144. Measured: 8.336 ms. Then moving the window bodily onto a 240 Hz monitor changed the frame time by 0.001 ms — the pacing clock never depended on the window's monitor at all.

Two of every five frames were being rendered and thrown away.

Present cannot be asked to fix this, since its interval divides DWM's clock rather than the output's, so the cap is a wait of our own on top: a deadline that accumulates a slice per frame — restarting from "now" each time folds every overshoot into the next slice and drifts permanently slow — a high-resolution waitable timer for the bulk, a spin for the last half-millisecond, and a resync when the deadline goes more than four slices stale, since after a load screen honouring it would run a burst of uncapped frames trying to make the time back.

uncapped capped
frame 4.163 ms (240 fps) 6.936 ms (144.2 fps)
present 3.852 0.137
wait.cap 0.000 6.403
GPU per frame 1.786 1.771

Per-frame GPU work is unchanged, so the entire 40% is saved: 1.79 ms × 240 becomes 1.77 ms × 144, for the same thing on screen. The GPU gauge fell from 41% to 28%.

The cap's own wait is instrumented, and that was not optional. wait.cap is a sibling of render with its own zone, and the budget subtracts it like the other two blocks. Without that, capping the frame rate would have made the panel report the engine as CPU-bound — the exact opposite of what a frame spent deliberately idle means. It gets its own verdict for the same reason: display-bound and cap-bound are both "not the hardware", but one is finished and the other is a limit you set and can raise.

A side effect I only noticed afterwards: the Video tab's Frame Rate labels were already computed as refresh / interval and had been describing a cap that nothing enforced. They are true now.

What it cost

A profiler that lies is worse than none, and mine lied twice before I looked at it. The share bar showed idle worker threads as saturated. The GPU rows were absent from the trace dump because AddSpan accumulates into the tree without writing ring events — the console shows them, a Perfetto dump does not. That second one is still true.

I instrumented the wrong side of a call. The world update is reached from two places in Game::Update: the normal playing path, and the one taken while the dev console owns input. I put the zone on the playing call site, which meant that with the console open — which is to say, whenever anyone is looking — the world's cost silently vanished from the tree. update showed 0.027 ms of unexplained exclusive time and no world row at all. Moving the zone into the callee fixed it and the accounting closed exactly. The rule: instrument where the work is, not where a caller invokes it, whenever more than one path can reach it.

The panel outgrew its own screen. With both sections graphed it drew over the command line — over the one control that would have let me narrow it back down. It now bounds itself and scrolls, and says what it dropped, because a panel showing eight of twelve measures looks exactly like a panel showing all twelve.

if constexpr does not discard in a non-template. Twenty unreachable-code warnings from a body the disabled build was never going to run. The preprocessor split does what I meant.

The verdict had only ever said one thing. Across an entire session of use it read bound by display in every screenshot — which is consistent with a correct instrument on an idle engine, and equally consistent with a constant. I nearly shipped it on that. It took deliberately loading the machine two different ways before I had any evidence it could say anything else, and I do not think an instrument that has produced one reading has been tested at all.

Two colours meant two things. The budget painted CPU work in the same amber the gauges use for RAM, and present in the same blue they use for CPU — so the largest block on the bar looked like CPU time, which is the precise misreading the bar exists to prevent. The two processors now have one colour each, defined once and used by both sections. Fixing that exposed a second contradiction: the ordinary share bar was also that CPU blue, so the present row drew a blue bar underneath a grey present segment, saying the CPU worked for 3.8 ms and did nothing for 3.8 ms at once.

Two things I built could not be read. The first real profile diff produced a correct twenty-line comparison into a scrollback that shows three lines above the prompt, so the answer scrolled past as it was printed. And four snapshot slots ran out mid-investigation, after which snap refused and the diff that followed reported an unknown name. Both are the same failure — an answer that exists and cannot be reached — and both were found by using the feature rather than by testing it.

I set a colour by taste and it was invisible. The group frames went in at an alpha that only showed up under 2× magnification, which makes them decoration rather than a cue. Looking at it at 1:1 is what fixed it, and there is no substitute for that step.

What it bought

Three real fixes, none in a place I would have guessed: a diagnostic taxing every shipped frame three times a second, a shadow cadence tied to frame rate, and two of every five frames rendered for a monitor that was never going to show them.

The last one is the one I would not have found by reasoning. Every individual number was innocent — the frame was fast, Present was blocking exactly as a synced present should, the GPU was idle. It only became visible when the frame's wall clock was broken into work and blocks and the arithmetic stopped matching the refresh rate of the screen it was on.

More usefully, the questions changed. "Is render slow?" became "render is 4.07 ms of which 3.94 is exclusive, so 95% of the frame is present wait and the CPU is idle" — and then stopped being a question at all, because the panel now says bound by display and shows its working.

And the headroom stopped being a guess. Ultra quality costs +1.7 ms of GPU and moves the frame time by 0.1%, because the idle absorbs all of it. That is a measurement, not an argument, and it is the difference between "we could probably afford better shadows" and "turn it on".

How it's proved

The clock decision came from a benchmark, not an argument, and two of its six numbers turned out to be measurement artefacts — the optimiser had hoisted a loop-invariant scan, and a constant-folded comparison — which I could only tell because the results were implausible. They are reported as unmeasurable rather than as zero.

The trace format is checked by parsing it back: 30,077 events, zero out-of-order timestamps, zero unmatched ends, zero unclosed scopes, nesting depth 2 on main and 1 per worker. A trace that merely looks written is worthless if a viewer chokes on it.

Every performance claim above is a before/after from two traces of the same length analysed by the same script, with world as an unchanged control. The disabled build is verified by absence: no profiling strings in the binary, and 31 KB smaller.

The pacing bug was diagnosed by prediction, not observation: halving the present interval had to divide one of two clocks, and the two candidates gave 8.33 ms and 13.9 ms. Getting 8.336 is a different kind of evidence from noticing that 4.163 looks wrong.

And the panel now has a check in the regression suite that does what a person does with it — snapshot, change, snapshot. It asserts the budget still sums to the frame, which is the failure mode that matters: add a blocking call without a zone and the unaccounted time lands in cpu by elimination, so an idle engine reads as CPU-bound with nothing crashing to say so. It forces two different changes, because a half-dead readout can pass one by accident. Current numbers: drift 0.0000 ms on every snapshot, ultra +94% GPU, cap 0.1% off a 144 Hz target — with the target parsed from the game's own report rather than hardcoded, so the check is not pinned to my monitor.

Its self-test inverts only the coverage assertion. The partition and reaction checks have each failed for real during development, which is weaker evidence than a harness that can produce the failure on demand, and the run prints that limitation every time rather than leaving it to be assumed.

Still open

GPU spans are not in the trace. They reach the console but not a Perfetto dump. Worse, putting them on the same timeline as CPU spans needs GetClockCalibration to pair the two clocks — durations would be right, absolute placement would not.

The 25 Hz default is not measured. 14 Hz is the figure in the table; 25 Hz is where the fire stopped looking slowed down when I compared 8 / 14 / 25 / 40 with the live knob. The cost at 25 Hz should be roughly 4 faces a frame rather than 2.33, still a five-fold improvement over frame-counted — but that is arithmetic, not a measurement, and it is labelled as such.

GPU spans are flat. Passes are siblings anyway, so flat keeps the root placement honest rather than double-counting a parent, but a nested GPU pass would currently be dropped rather than mis-parented.

os.cpu costs more on the worker than it did on the frame — 297 µs against 78 µs — because a background thread is descheduled more readily. That is not a regression; it is precisely why it belongs there. But it is a reminder that moving work off the frame changes what the work costs, and a number measured in one place does not transfer to another.

Clearing a detail override does not remove the rows it revealed — they stay listed at zero calls, because a node is never dropped from a published tree and that is what keeps node indices stable for the graph series. The tree therefore shows what is being measured, not what once was, and a collapsed branch has to be filtered out at display time rather than actually going away.

ui2d is 65% of all CPU command recording — more than the entire 3D path combined. At 0.3 ms of CPU in a 6.9 ms frame that is irrelevant, and it is the first place I will look if the CPU ever becomes the bound. I have not looked yet.

The GPU scene cost swings 30% on a static view — 1.21 ms to 0.87 ms with nothing moving and nobody touching the controls. Either something real is varying (fire flicker moves light positions, which moves the dust raymarch and the shadow-cube cache) or the GPU timing is noisier than it looks. I would want to know which before trusting gpu.scene to a fine margin, and I do not.

The frame cap is on by default, which is a behaviour change I made on my own judgement. It is right for this machine and this desk arrangement. On a single-monitor setup it does nothing at all, which is fine — but "does nothing" and "is correct" are not the same claim, and I have only tested the first.

The console is the largest screen in the game that the UI overlap audit does not cover, because it predates the control-tree work and draws immediate-mode. Every hand-written y += in it is a future bug. I chose to keep paying that rather than convert it mid-investigation; the trigger to revisit is the next panel it grows.

Landed

August 2026 · 28 commits

Topics

Profiling rdtsc D3D12 timestamps Frame pacing Measurement

View source ↗
← Back to Dungeon