Skip to content

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.

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 scene
perf.stop {} frame statistics of the capture
perf.report {"skip_frames": 120} frame p50/p95/p99/worst, scopes by self time, GPU passes, hitches with causes
perf.budget {"budget": {...}, "assert": true} limits; a broken one is E_ASSERT_FAILED
perf.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).

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.

  • 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; busy and the GPU numbers do not (much). Quote the uptime load with any wall number.
  • scopes: self time per frame (mean, p95, worst) and self_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 than hitch_factor x the median (and hitch_min_ms above it). Each lists the scopes that grew the most against their typical time (with cpu_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) and unexplained_ms (frame minus CPU and GPU: presentation, vsync, the OS).
  • unmeasured in 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.
{"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.

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.

  1. 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), or perf.start / play / perf.stop / perf.report.
  2. Read hitches[]: causes[0] and notes say what was created; starved: true says the machine, not the engine.
  3. If the cause is a first-use creation, move it behind a loading moment (warm_up) or make it asynchronous.
  4. Re-run and compare hitch_count and worst_ms; add a perf.budget / frame_budget assertion with headroom to keep it fixed.
  • 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: true changes the CPU/GPU overlap: use a gpu: false capture for the frame time an application sees and a gpu: true one for the passes (the benchmark examples do).
  • Windows and Linux were not measured.