feat(benchmark): measure graphics changes with $bench - #571
Conversation
Every performance number in the GLP series so far has been read off a screenshot of the $glstats overlay, at "the same test spot", and compared by eye. That is where GLP-29 came from: a 35% Debug win and a Release delta of 0.07ms, with an untouched pass moving 0.33ms in the same pair - the measurement could not answer the question it was taken to answer. GLP-16 spent three code changes chasing a terrain reading that turned out to be the instrumentation. And half the milestones are still marked "Intel HD 530 numbers outstanding" because getting numbers means sitting at that machine with a camera. This is the analysis half of a benchmark that writes files instead. It has no project dependencies and no GL, so all of it is linkable from a test, which is the point: the arithmetic that decides whether a change counts as a win should not be the part nobody can check. A run is a list of segments, each measured on its own. That is what lets a result say where a change had an effect rather than only whether the frame got faster - a change that helps particle-heavy scenes and does nothing elsewhere reads very differently from one that shifts everything by the same amount. The first catalog varies the effect surfaces of whatever scene the client is showing, using the $effects toggles that already exist, so it needs no scripted content. The summary is deliberately more than a mean. It carries the 1% low, the percentile curve and the pacing metrics side by side, because a change can improve the average frame time and make the stream stutter, and an average alone will call that a win. It also carries the repeat spread - the gap between repeat medians of the same segment measuring the same thing - which is the noise band a delta has to clear before it means anything. The findings are rule-based and named, never prose. The one that matters most is the attribution check: pass CPU ms summed over the non-nested passes against the frame time. A large remainder means work is running outside every FRAME_PROFILE scope, which is exactly the blind spot GLP-24 found after it had already invalidated a phase of measurements. run.json is canonical and schema-versioned; report.md and the two CSVs are views rendered from it at fixed precision, so two reports diff cleanly. Counters are per segment rather than per frame - they are near-deterministic for a fixed workload, so crossing 16 passes with 18 counters per frame would be 288 columns nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnwayPjwJEQG17DLXNyYzQ
Wires the recorder into the frame loop and gives it a vocabulary. `$bench run` measures every segment, `$bench run fx.*` or `#particles` a subset for faster iteration, `$bench quick` one short repeat for finding out whether a change did anything at all. A line at the top of the screen says which segment is running and how far along it is - a run reconfigures the scene for minutes, and a client that silently switches effects off with no visible reason is a bug report waiting to happen. A repeat is a fixed frame count, never a fixed duration: a faster build has to render the same frames, not more of them, or the workload moves with the thing being measured. Each repeat discards a warmup window, because the first frames of a segment pay for shader compiles, texture uploads and streaming-ring growth - real hitching, but not the steady state a comparison is about. A frame over half a second discards its whole repeat rather than averaging the alt-tab in. The baseline segment is measured first and again last whatever the selection was. The difference between the two is drift - the machine getting hotter or busier under the run - and if it is large then nothing measured in between is trustworthy. Without it a subset run would also have nothing to read its segments against. Two things the run must not do to the client: leave it instrumented, and change what it looks like. The recorder restores both the effect toggles and the counter flag when it finishes, including on an abort. That flag needed splitting first: $glstats used FrameProfiler::g_CountersEnabled as both "count things" and "draw the overlay", so a benchmark turning the counters on would have switched a several-hundred-draw-call text overlay on in the middle of its own measurement. The environment capture is the other half of the value. Which GL context the version chain settled on and which capability flags the driver reported decide which code path the client is running at all, and on the hardware this tooling exists for they have had to be read out of a boot log by hand. Every run now records them, with the resolution, the vsync state and the build configuration, next to the numbers they explain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnwayPjwJEQG17DLXNyYzQ
The DXP-10 state-wrapper monopoly guard failed the build: the benchmark's environment capture called glGetString directly from Core/Utilities/Benchmark, which is exactly the kind of raw GL call outside Render/ that guard exists to catch. Allowlisting it would have been wrong - the strings have a proper home. RHI_GL's capability probe already reads GL_VERSION to work out the context version, so it now captures vendor, renderer, version and GLSL version in the same place and publishes them as RHI::DriverInfo, alongside the Caps struct that the exports read anyway. Nothing branches on them; they are what makes a performance number attributable to a machine rather than to "my laptop". The version parse reads the captured string instead of calling glGetString a second time, so the probe now makes one call where it made two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnwayPjwJEQG17DLXNyYzQ
|
It does not seem to work correctly. It runs through, but it doesn't deactivate any effects and the results of each test looks similar. |
The first real run found it. Reported: "it doesn't deactivate any effects and the results of each test looks similar." The attached report shows what happened, and it is two separate things. fx.all.off was broken. `$effects off` is two actions - SetDisableEffects AND g_pOption->SetRenderAllEffects(false) - and the segment only did the first. RenderSprites() and RenderParticles() early-return on the options switch and never look at SetDisableEffects at all, and RenderJoints() looks at neither, so the segment that promises "everything off" left the three heaviest effect paths running: 162 particle draws per frame against the baseline's 171. SceneConfig gains the options switch, and the segment now closes all three gates. Its tags gain the surfaces it really covers, so `#particles` selects it too. The per-surface segments were working the whole time. fx.particles.off took the Particles pass from 170.6 draws and 0.61ms to 0.00 across 900 frames; fx.joints.off is the best segment in the run at 99.4 FPS against the baseline's 92.5. What made every row look alike is the second thing: vsync was on, so every frame time was pinned near the 10ms refresh and a 0.6ms saving reads as a rounding error. The report already warned about it afterwards, which is exactly when the warning is useless - it is said in chat now, as the run starts, while there is still time to stop. That a broken segment produced a plausible-looking row rather than an obvious failure is the deeper problem, so `segment-inert` now compares each segment's draw calls per frame against the baseline's and says so when nothing measurably changed. It fires on a segment whose config does not reach the path it names, and equally on one standing somewhere with no wings or no particles to disable - both cases produce a timing that would otherwise be read as "this is cheap". Two tests cover the defect directly: every non-baseline segment has to disable something, and fx.all.off has to close all three gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnwayPjwJEQG17DLXNyYzQ
|
Thanks — the report had everything needed. It turned out to be two separate things, and one of them is a real bug.
SetDisableEffects(true);
if (g_pOption) g_pOption->SetRenderAllEffects(false);My segment only did the first.
The per-surface segments were working the whole time. What made every row look alike is vsync. The report has The deeper problem is that a broken segment produced a plausible-looking row instead of an obvious failure. New Two tests cover the defect directly: every non-baseline segment must disable something, and Pushed as 22bb352. Generated by Claude Code |
Summary
Adds a segmented benchmark to the client, so a graphics change can be judged against a measurement instead of an impression.
$bench runmeasures a list of named segments, each on its own, and writes a run directory underbench/runs/.$bench run fx.*or#particlesruns a subset for faster iteration;$bench quickis one short repeat for finding out whether a change did anything at all. Usage is documented indocs/benchmark.md.Why segments. Splitting a run is what lets the result say where a change had an effect, rather than only whether the frame got faster. This first catalog varies the effect surfaces of whatever scene you are standing in, using the
$effectstoggles that already exist, so it needs no scripted content:scene.fullfx.all.off$effects offdoes itfx.sprites.off/fx.particles.off/fx.joints.offfx.skillmodels.offfx.boids.offfx.wingshadow.off/fx.winglayers.offWhat it exports.
run.jsonis canonical and schema-versioned;report.md,frames.csvandpasses.csvare views rendered from it at fixed precision so two reports diff cleanly. The report header carries the build configuration, GPU and driver, the GL context version the chain actually settled on, everyRHI::Capsflag, the resolution and the vsync state — the facts that decide which code path is running, which have so far had to be read out of a boot log by hand.What it does to avoid lying.
segment-inert(a segment that submitted the same draw calls as the baseline disabled nothing here, so its timing is not evidence that what it targets is cheap).Two supporting changes.
$glstatsusedFrameProfiler::g_CountersEnabledas both "count things" and "draw the overlay", so a run turning the counters on would have switched a several-hundred-draw-call text overlay on inside its own measurement — the overlay now has its own flag, behaviour otherwise unchanged. The compile-time build info the$detailsoverlay printed inline moved toCore/Utilities/BuildInfo.h, since the exports need the same facts.Not in this PR, and worth doing next: scripted scenes (spawned characters, camera paths, weather), per-segment screenshots with an A/B image diff, and a
benchdifftool that compares two runs with a compatibility gate on the manifest hash.Related issues
Verification
fx.all.offset onlySetDisableEffectsand not the options switch$effects offalso flips, so it left sprites, particles and joints rendering — 162 particle draws/frame against the baseline's 171. Fixed in 22bb352, with two tests covering it.fx.particles.offtook the Particles pass from 170.6 draws / 0.61 ms to 0.00 across 900 frames, andfx.joints.offreached 99.4 FPS against the baseline's 92.5.$vsync off. A clean uncapped re-run is still outstanding.Checklist
docs/CODING_RULES.md.docs/build/README.md) — full builds via CI on all six configurations; unit tests and per-file syntax checks locally.Generated by Claude Code