Performance
The engine profiles itself. An agent starts a capture, plays or steps the game, stops it, and reads frame statistics, the costliest
CPU scopes, GPU time per render pass and a list of hitches with their causes. Frame-time budgets are assertions. Numbers and
baselines: docs/PERFORMANCE.md. The crate: crates/sparklight-profile.
The profiler in one minute
Section titled “The profiler in one minute”perf.start {} start a capture (frame times, CPU scopes + thread CPU time, GPU time per render pass)run.step {"frames": 600} ... or play a window, or step a 3D sceneperf.stop {} frame statistics of the captureperf.report {"skip_frames": 120} frame p50/p95/p99/worst, scopes by self time, GPU passes, hitches with causesperf.budget {"budget": {...}, "assert": true} limits; a broken one is E_ASSERT_FAILEDperf.export {"path": "perf/trace.json"} Chrome trace for Perfetto, chrome://tracing, Tracy (tracy-import-chrome)All five are control-plane commands and therefore MCP tools (perf_start, perf_stop, perf_report, perf_budget,
perf_export). perf.stats / perf.reset / assert.perf (older, per-session wall-clock samples of simulated frames) still work.
A windowed game needs no command: run it with SPARKLIGHT_PERF=<dir> (and optionally SPARKLIGHT_PERF_SKIP=<frames>). The whole run
is captured and <dir>/report.json and <dir>/trace.json are written at exit (the 3D play window does this; a game calls sparklight::profile::start_from_env() before its loop and finish() after it).
What is measured
Section titled “What is measured”| Source | What | Where it is placed |
|---|---|---|
| Frame time | time between two frame_begin() calls = what the player sees; CPU part (frame_cpu_end()); GPU part |
Session::step_frames, the 3D play window, benchmark loops (a game’s own loop calls profile::frame_begin() once per frame) |
| CPU scope | wall time and thread CPU time, nested (self time = own time minus children) | step, one scope per ECS system under its own name (engine.physics, engine.animations, engine.scripts, engine.particles, systems you add), physics and scripts of Space3D, lua_call, audio, world.stream, world.render, world.attach_renderer, space3d.update, nav, fx_pre_step, fx_post_step, draw_list, render.encode/cull/lists/upload_objects/graph_build/local_shadow_plan/record_passes/submit, gpu.readback, present.acquire, present, overlay.draw, render2d*, sprites.*, warm_up |
| First-use costs | scope + a note with what was created | pipeline_create (main passes), pipeline_create.worker, pipeline_create.post, pipeline_create.probes, setup.particles, setup.probe_cubes, setup.local_shadows, upload.model, upload.texture |
| GPU pass | timestamp query pair per render-graph pass, grouped by the name before : |
RenderSettings::profile or a running capture with gpu: true (windows request TIMESTAMP_QUERY through Renderer3D::device_descriptor) |
Add a scope to your own code with sparklight_profile::scope!("name"); (until the end of the block) or
let _s = sparklight_profile::scope("name");. A scope that runs while no capture is on costs one relaxed atomic load: measured
0.6 ns, i.e. 0.0003 % of a 16.6 ms frame with 200 scopes (crates/sparklight-profile/tests/overhead.rs). With a capture on, a scope
costs about 80 ns (two clock reads and an uncontended lock) plus the thread-CPU clock when cpu_time is on.
Reading a report
Section titled “Reading a report”frame.p50_ms ... worst_ms: frame intervals.busy_*: CPU time the frame thread really used. On a machine shared with other work the wall numbers move with the load;busyand the GPU numbers do not (much). Quote theuptimeload with any wall number.scopes: self time per frame (mean, p95, worst) andself_cpu_ms(total CPU time of the scope). A scope whose wall time is much larger than its CPU time was waiting (the GPU, a lock, a core).hitches: frames longer thanhitch_factorx the median (andhitch_min_msabove it). Each lists the scopes that grew the most against their typical time (withcpu_ms), the notes recorded in that frame (pipeline: ...,upload: texture ... 1024x1024,setup: particle resources),starved(the frame thread used less than half of the frame as CPU: it waited for a core or the driver) andunexplained_ms(frame minus CPU and GPU: presentation, vsync, the OS).unmeasuredin a budget result: a limit on something the capture could not see (no GPU timings, a scope that never ran, too few frames). It fails the budget; a budget never passes on data it does not have.
Budgets as assertions
Section titled “Budgets as assertions”{"do": "assert", "that": {"kind": "frame_budget", "clock": "cpu", "p50_ms": 4, "p95_ms": 8, "p99_ms": 12, "worst_ms": 30, "skip_frames": 60}}A scenario assertion (kind: frame_budget, crates/sparklight-scenario): limits on the median, the 95th and 99th percentile and the
worst frame of the frames simulated so far. clock: "cpu" reads the CPU time of the stepping thread (stable when other processes
compete for the machine), "wall" (default) the wall clock. Fewer than min_frames (30) measured frames, or no per-thread clock, make
it skipped, never passed. The older frame_time (one percentile, wall clock) remains. In the control plane the same limits plus
per-scope ({"name": "engine.physics", "mean_ms": 2}) and per-GPU-pass ("name": "gpu:shadow") limits are perf.budget.
Rust tests: sparklight_profile::Budget { ... }.check(&capture); result.describe() is the assertion message.
Benchmark scenes
Section titled “Benchmark scenes”| Scene | Run | What a frame is |
|---|---|---|
| city: 1,500 objects, 256 point lights, 8 spots, glass, sky | cargo run -p sparklight-3d --release --example perf_scenes -- --scene city |
cull, sort, record, submit, wait for the GPU |
| 500 rigid bodies | ... --scene bodies |
1 physics step, 500 poses, one instanced draw |
| 100 skinned characters (64 joints) | ... --scene skinned |
100 palettes built, skinned draws |
| 8-car race | perf_race example of the Apex-Rush repository (the racing demo moved out of this repo; baselines measured while it was in-tree are in docs/PERFORMANCE.md) |
Game::update, Game::draw, renderer + overlay, wait |
| 2D platformer with 10,000 extra sprites | cargo run -p sparklight-qa --release --example perf_2d |
Runtime::step, render_frame incl. pixel read-back |
All of them take --size WxH, --frames N, --json out.json; the 3D scenes --tier low|medium|high|ultra|all; the race
--quality, --streaming and --skip. scripts/perf-baselines.sh <dir> runs the whole matrix (two sizes x four tiers).
Hitches: what causes them and what the engine does
Section titled “Hitches: what causes them and what the engine does”| Source | Measured before | What the engine does now |
|---|---|---|
| Pipeline creation on first use (31 pipelines in the race, up to 0.7-0.8 s each) | race start 8.6-12.3 s | Renderer3D::set_async_pipelines(true): compiled on worker threads, a draw whose pipeline is not ready is left out and appears a few frames later. Renderer3D::warm_up() compiles everything the current scene needs (all cores) behind a loading moment. Windowed games turn it on (racing: View::enable_streaming(); the 3D play window does the same and warms up on its first scene). The default stays synchronous so a single screenshot frame is always complete |
| sRGB mip chain of a texture upload (1024^2: 146 ms, 2048^2: 901 ms) | 70 uploads = 2.0 s at race start, single uploads of 70-144 ms mid-race | exact table-based encode/decode and threaded rows: 3.0 ms and 11.0 ms, results bit-identical (test); the mip chains of all textures of one model are prepared on several threads before the upload |
| Model/texture upload in the middle of a frame (new damage models, props) | 111 ms frame | ModelCache::set_upload_budget(Some(3 ms)): a model that does not fit in the frame’s budget is uploaded in a later frame (at least one per frame) |
| Particle resources (atlas, pipelines) on the first particle | 285-887 ms frame | created in warm_up() |
| Post-processing pipelines on first use of a feature | 7 pipelines, 0.8 s | created in warm_up() |
| Per-frame allocation in the renderer | palette copy per skinned object, map rebuilt each frame | palette buffers pooled; motion-vector maps swapped, not rebuilt; fast hasher |
| Loading model files one after another | 8 cars’ files read on the frame thread | ModelCache::preload(keys) reads and decodes several models on threads (use it when a scene is about to need a known set) |
Still on the frame thread at a race start (see docs/PERFORMANCE.md, hitch table): the 8 cars’ materials and liveries (0.35 s), the circuit’s layer split (0.2-0.45 s) and the first car meshes (0.15-0.2 s). They show up in perf.report as view.part_materials, view.circuit_layers, view.push_car.
Wgpu’s PipelineCache (disk cache of compiled pipelines) exists only on Vulkan; this Mac runs Metal, so it is not used and could not
be tested here. Warm-up on worker threads is the Metal answer.
Recipe: find a hitch
Section titled “Recipe: find a hitch”SPARKLIGHT_PERF=.gwdev/out/perf/run1 spark run <scene>.space3d.json --gw-mute --exit-after 60(or your game’s binary with the same variable), orperf.start/ play /perf.stop/perf.report.- Read
hitches[]:causes[0]andnotessay what was created;starved: truesays the machine, not the engine. - If the cause is a first-use creation, move it behind a loading moment (
warm_up) or make it asynchronous. - Re-run and compare
hitch_countandworst_ms; add aperf.budget/frame_budgetassertion with headroom to keep it fixed.
Not done / limits
Section titled “Not done / limits”- GPU pass times overlap on Apple GPUs: the per-pass numbers add up to more than the frame;
gpu_p50_ms(first pass start to last pass end) is the real span. - GPU timing readback waits for the frame (a blocking poll), so a capture with
gpu: truechanges the CPU/GPU overlap: use agpu: falsecapture for the frame time an application sees and agpu: trueone for the passes (the benchmark examples do). - Windows and Linux were not measured.