Perf: instrumentation — headless benchmark mode, per-pass counters, edit-mode profiling
Part of #158. Blocks every other perf issue — no optimization lands before there is a number to move.
Problem
We cannot currently measure the thing we are being asked to fix.
ProfilerPanel.tickRecordingsetsengine.Profiler.enabled = active and g_record— profiling only happens during Play. The ~20 fps Scene-viewport number in #158 cannot be profiled at all.- Otherwise
Profiler.enableddefaults tobuiltin.mode == .Debug, so a compiled release build can't be profiled either. Profiler.Countersare frame-global. With draws coming from the prepass, shadow, main, transparent and probe-bake passes, a singledraw_callsnumber can't be attributed to any of them.- Real GPU timing is an opt-in fence stall covering 2 of 8 passes (
gpu_timing.zigwraps shadow and cull only). - Nothing counts indirect commands issued, so the ADR-0013 zero-instance overhead is invisible.
- There is no headless way to produce a trace. A backgrounded, unfocused Studio never pumps frames —
profiler.captureover the debug protocol returns an empty thread list, and synthetic input is never applied. OnlyTURIAN_CAPTURE_AFTER_MSforces real frames, becauseMain.zig:612capswaitEventTimeoutat 16 ms while a capture is pending.
That last point is why #147 (closed)'s acceptance criterion was never met: getting numbers requires a human at a focused window.
Scope
a. Headless benchmark mode.
turian-studio --benchmark <project> [--scene <path>] [--frames N]
[--camera <name>] [--warmup N] [--features <spec>]Reuses the capture_pending mechanism in Main.zig:605-613 to keep an unfocused window pumping, drives N frames from a fixed or scripted camera, writes <project>/benchmark/<timestamp>/{trace.json,summary.json} and exits. trace.json is the existing Profiler.writeChromeTrace output (loads straight into Perfetto); summary.json extends writeFrameJson with p50/p95/p99. --features lets one invocation A/B a quality toggle set.
b. Profile outside Play. Replace the active and g_record gate with an explicit off / edit / play source, and honour TURIAN_PROFILER=1 to force the profiler on in release builds.
c. Per-pass counter attribution. Profiler.beginPass(name) / endPass() snapshotting counter deltas into a per-pass row, so draw_calls reads prepass=3880, shadow=824, main=206, transparent=N instead of one lump. Add indirect_commands to Counters.
d. Fence-bracketed GPU timing for every pass. gpu_timing.zig already has the pattern; extend it to prepass, SSAO, SSR, main, transparent, post-process and probe bake behind the existing "detailed GPU timing" toggle. Per #147 (closed) this is the only GPU timing SDL3 permits (no timestamp-query API on any backend), and it is enough to answer "CPU or GPU bound".
Files
studio/Main.zig, studio/main-window/ProfilerPanel.zig, engine/Profiler.zig, subsystems/render/gpu_timing.zig, subsystems/render/root.zig
Acceptance
-
--benchmarkproduces a Perfetto-loadable trace and a summary JSON from an unfocused window, with no human interaction - The Scene viewport can be profiled without entering Play
- Draw calls, triangles and indirect commands are attributed per pass
- Detailed GPU timing reports every pass, not two
-
docs/decisions/bistro-perf-baseline.mdcommitted with real Bistro numbers