Benchmarks
How Cinder measures performance, what the numbers mean, and how regressions are caught. The
measured results for each run live in REPORT.md (section 4); this page is the method.
Programs #
| Program | Scene | Default run | Output |
|---|---|---|---|
bench/stress.mojo |
Section 8 stress scene: 50,000 static instances in a grid, 2,000 dynamic boxes falling onto a floor, 256 clustered point lights plus a shadowed sun, headless at 1920x1080, high preset | 120 frames after one start-up frame and 10 warm-up frames (pipelines, environment bake) | .cache/bench/stress.json |
bench/sample_bench.mojo |
The sample game (sample/) on autopilot, headless at 1920x1080, high preset, arena level by default |
300 frames after one start-up frame | .cache/bench/sample.json |
bench/ecs_bench.mojo |
100,000 entities, two archetypes, position += velocity * dt through a two-component query, against flecs through the shim |
100 frames | stdout (ADR-0011) |
All three are plain Mojo programs; --frames, --width, --height, --instances, --bodies,
--lights, --level and --out override the defaults.
tools/in_container.sh pixi run bench-stress
tools/in_container.sh pixi run bench-sample
tools/in_container.sh pixi run bench-all # both, then tools/bench_check.pyWhat is measured #
Each frame records three durations with the engine's own clock, from inside App.run_frame:
- frame_ms: the whole frame, from the start of input polling to the end of the offscreen readback (headless) or present (windowed). This is what the p99 budget is about.
- physics_ms: the Jolt
stepcalls of that frame's fixed steps, summed (zero on frames with no step). Kinematic push, body pull andGame.fixed_updateare inframe_msbut not here, so a level whose bodies are asleep reports microseconds. - render_ms:
Renderer.render, from the bridge's render scene to command submission.
The JSON carries median, p99, mean, max and count of each, the adapter name reported by wgpu, the
time from App construction to the end of the first frame (startup_ms in the stress result,
cold_start_ms in the sample result, where it includes loading the level's assets), the scene
parameters, the draw-call and instance counts of the last frame and the wgpu error count, so any two
results can be told apart.
The GPU time itself is not measured separately: a headless frame ends with a readback that waits
for the GPU, so frame_ms includes it. render_ms stops at submission, so the difference between
the two is a usable proxy for GPU time on a real adapter.
Budgets (section 8 of the brief) #
| Budget | Where it is checked |
|---|---|
| Sample p99 frame time <= 16.6 ms at 1080p, high preset, on the reference hardware | bench/sample_bench p99 frame_ms |
bench/stress >= 60 fps (50k instances, 2,000 bodies, 256 lights) |
bench/stress median frame_ms <= 16.6 |
| Physics step <= 4 ms for 2,000 bodies | bench/stress p99 physics_ms |
| Cold start <= 2 s to the first frame of the sample | cold_start_ms in both results |
| Zero allocations per frame in steady state | tests/perf/test_zero_alloc.mojo (pixi run test-zero-alloc), a hard test rather than a benchmark |
The budgets name hardware (Linux with an RTX 3060-class GPU, Apple M2). Numbers from any other
adapter are regression baselines for that adapter only; REPORT.md says which adapter produced
each table.
Regression checking #
tools/bench_check.py RESULT.json... [--baselines bench/baselines] [--tolerance 0.10] compares each
result with the baseline recorded for the same benchmark name and adapter and fails when any tracked
metric (median and p99 of frame, physics and render time; cold start) is more than 10% slower. A
result from an adapter with no baseline is printed and skipped, never compared against a different
GPU. --update records the current numbers as the new baseline; a baseline change is a reviewable
diff in bench/baselines/.
Counting allocations #
native/src/alloc_counter.c builds to libcinder_alloccount.so. When LD_PRELOADed it interposes
malloc, calloc, realloc, posix_memalign, aligned_alloc and the Mojo runtime's
KGEN_CompilerRT_AlignedAlloc, and attributes each call to the main executable, the Mojo runtime or
a shared library by its return address. The shim exposes the counters as cn_alloc_counter_*
(no-ops when the library is not loaded). tests/perf/test_zero_alloc.mojo runs a representative
scene, warms up for 90 frames, then asserts that 60 frames make zero allocations from the
executable. App.count_allocations prints per-phase deltas and Renderer.alloc_marks prints
per-render-stage deltas to locate a regression; CN_ALLOCCOUNT_DUMP=1 prints the per-module totals
at exit.
Interpreting a run on lavapipe #
Inside the toolchain container the only Vulkan device is Mesa's lavapipe, a CPU rasteriser. Frame times there are dominated by shading on the CPU and say nothing about the budgets; they are still stable enough (the same container, the same CPU) to catch CPU-side regressions in the engine, which is how they are used in CI.