Skip to content

feat(benchmark): measure graphics changes with $bench - #571

Open
sven-n wants to merge 5 commits into
mainfrom
claude/client-graphics-benchmark-s3z79x
Open

feat(benchmark): measure graphics changes with $bench#571
sven-n wants to merge 5 commits into
mainfrom
claude/client-graphics-benchmark-s3z79x

Conversation

@sven-n

@sven-n sven-n commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a segmented benchmark to the client, so a graphics change can be judged against a measurement instead of an impression.

$bench run measures a list of named segments, each on its own, and writes a run directory under bench/runs/. $bench run fx.* or #particles runs a subset for faster iteration; $bench quick is one short repeat for finding out whether a change did anything at all. Usage is documented in docs/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 $effects toggles that already exist, so it needs no scripted content:

Segment Isolates
scene.full Nothing disabled — the reference, and the drift control
fx.all.off Every effect surface at once, exactly as $effects off does it
fx.sprites.off / fx.particles.off / fx.joints.off The three IR per-quad paths
fx.skillmodels.off Skill effect models, impact effects still rendering
fx.boids.off Ambient wildlife — an always-on town cost
fx.wingshadow.off / fx.winglayers.off The extra wing shadow draw, the glow overlay passes

What it exports. run.json is canonical and schema-versioned; report.md, frames.csv and passes.csv are 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, every RHI::Caps flag, 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.

  • A repeat is a fixed frame count, never a fixed duration — a faster build has to render the same frames, not more of them.
  • Each repeat discards a warmup window (shader compiles, texture uploads, streaming-ring growth). A frame over half a second discards its whole repeat rather than averaging an alt-tab in.
  • The baseline is measured first and again last whatever the selection was; the difference is drift, and if it is large the report says nothing measured in between is trustworthy.
  • The summary carries the 1% low, the percentile curve, the pacing metrics and the repeat spread — the noise band a delta has to clear before it means anything. This is the number GLP-29 did not have.
  • Findings are rule-based and named, never prose. Two matter most: the attribution check (pass CPU ms against frame time — the automatic version of the blind spot GLP-24 found after it had already invalidated a phase of measurements), and 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. $glstats used FrameProfiler::g_CountersEnabled as 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 $details overlay printed inline moved to Core/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 benchdiff tool that compares two runs with a compatibility gate on the manifest hash.

Related issues

Verification

  • Run in the client, on an RX 6800 in Lorencia, and that run found a real bug (#issuecomment-5461948274): fx.all.off set only SetDisableEffects and not the options switch $effects off also 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.
  • The same run confirms the rest of the mechanism works: fx.particles.off took the Particles pass from 170.6 draws / 0.61 ms to 0.00 across 900 frames, and fx.joints.off reached 99.4 FPS against the baseline's 92.5.
  • That run also had vsync on, which pinned every median between 9.89 and 10.63 ms and made the segments look alike. The report flagged it afterwards; it is now said in chat as the run starts, while there is still time to $vsync off. A clean uncapped re-run is still outstanding.
  • 36 doctest cases / 167 assertions over the pure layer (stats, glob selection, findings rules, and the exact bytes of all four exports), passing in CI on Linux and MinGW.
  • Full client build green on all six CI jobs: Linux and MinGW x86_64/i686, editor on and off.

Checklist

  • I have read and followed docs/CODING_RULES.md.
  • I have built the project locally (see docs/build/README.md) — full builds via CI on all six configurations; unit tests and per-file syntax checks locally.
  • Changes are focused on one concern; the diff is as small as it reasonably can be.

Generated by Claude Code

claude and others added 4 commits August 18, 2026 16:00
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
@sven-n

sven-n commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

report.md

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

sven-n commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Thanks — the report had everything needed. It turned out to be two separate things, and one of them is a real bug.

fx.all.off was broken. $effects off is two actions:

SetDisableEffects(true);
if (g_pOption) g_pOption->SetRenderAllEffects(false);

My segment only did the first. RenderSprites() and RenderParticles() early-return on the options switch and never look at SetDisableEffects, and RenderJoints() looks at neither — so the one segment that promises "everything off" left the three heaviest paths running. Your own numbers show it:

Pass scene.full fx.all.off fx.particles.off
Particles draws 170.6 162.4 0.0
Sprites draws 33.8 34.0 33.8
Joints draws 3.2 4.1 2.5

SceneConfig now carries the options switch too, and fx.all.off closes all three gates.

The per-surface segments were working the whole time. fx.particles.off took the Particles pass from 170.6 draws / 0.61 ms to 0.00 across 900 frames, and fx.joints.off is the best row in your run — 99.4 FPS against the baseline's 92.5. So the mechanism is sound; it was that one segment that lied.

What made every row look alike is vsync. The report has Vsync requested / effective: yes / yes, and every median sits between 9.89 and 10.63 ms — pinned at the refresh interval. Under that ceiling a genuine 0.6 ms saving reads as a rounding error. The report warned about it afterwards, which is precisely when the warning is useless, so it is now said in chat as the run starts, while there is still time to stop and $vsync off. Worth re-running that way — I'd expect fx.particles.off and fx.joints.off to separate clearly.

The deeper problem is that a broken segment produced a plausible-looking row instead of an obvious failure. New segment-inert rule: it compares each segment's draw calls per frame against the baseline and says so when nothing measurably changed. It would have flagged fx.all.off in your run, and it also catches the honest version of the same trap — fx.wingshadow.off with no wings equipped, whose 95.1 FPS is otherwise easy to read as "wing shadows are cheap" when the segment simply had nothing to disable.

Two tests cover the defect directly: every non-baseline segment must disable something, and fx.all.off must close all three gates.

Pushed as 22bb352.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants