Files
iw4L/docs/BENCH.md
T
vtroc 9856f91a98 Share one decode between askers, and time each frame from its own wall.
The split of an image variant into `payload` and `usage` is now what sharing
keys on. A registry holds the texels an archive entry decodes to — the mip
chain in one allocation, laid out as an upload wants it — and the `Image` is
built over them where the row is placed. Two materials that want the same file
under a different colour space or sampler cost one read, one inflate and one
decode, and the second of them a copy of the chain; the last asker copies
nothing, because the decode's own buffer becomes the image's. Asking whether a
name is a cubemap reads the thirty-two byte IWI header out of the entry instead
of inflating the megabyte behind it.

The resident-bytes ceiling is charged per payload rather than per batch, so two
batches pointing at one buffer hold one allocation between them. Load rows
split `produced_bytes` into `prepared_bytes` and `reused_bytes`: only the first
is work, and their sum is what the plan served. The merge census names who won
a disputed claim by plan id, not only that somebody did.

The frame clock closes the row and opens the next at the top of `First`, before
anything else runs, so every main-phase interval falls wholly inside one frame;
the frame the process exits from is closed at exit and its row carries
`partial`. Which spans are a frame's coverage roots is declared by
`Span::coverage_root` rather than read off a thread's stack — an executor is
free to run a schedule's `begin` and its `end` on two workers — and spans a
migration left behind are pruned. A counter measured on another thread is
charged to the frame it ran in through `emit_at`, and a sample that finds no
row is counted rather than moved to whichever frame is open.

`IW4L_PIPELINED_RENDERING=1` runs the render world a frame behind on its own
thread, and it is the only thing the switch moves; the manifest records which
branch a run took. The report gains `main/render overlap`, read off the
intervals rather than off the frame a render stage names as its origin. A
`bevy-trace` build writes bevy's own spans as a Chrome trace, which is the only
place the render graph's submit can be timed from, and
`scheduling.bevy_tracing` says whether a run was one.

Glass keeps its shattered material while damaged, scales the authored UV to it,
picks its shatter effect by whether the piece is landing, and carries the
surface normal on the presentation event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:28:01 +03:00

4.1 KiB

make bench — three reports, one run, one package

IW4L_BENCH=1 turns on an in-process recorder. At exit it prints three independent reports to iw4l-artifacts/bench/<stamp>.txt. None needs a trace file; any can be MISS while the others stand.

make bench demo0011              # play a demo   (a goal is always a demo name)
make bench ZONE=mp_boneyard      # a live map instead
IW4L_BENCH_JOBS=assets make bench demo0011   # a load_jobs row per asset
IW4L_BENCH_FRAMES=0 make bench demo0011      # no per-frame table

[1/3] Map load — the waterfall from the shell command to a playable map, the load-pool stages, then each image plan's wait split from its decode. A stage's own duration says nothing on a parallel walk, so the table carries sole_open: how much of the window that stage was the only thing running. It is not a bound on what deleting it would save. A stage's RSS delta is the whole process, so the deltas do not add up. Match audio is split by decoder, in worker time.

[2/3] Frame time — every typed span from PERF.md as a tree, for the frames after the first playable one, then the rows: first seconds against steady state (exact percentiles), the worst frames, and unclassified — wall minus the union of the frame's declared top-level spans, so two threads inside one nanosecond cover it once and the remainder is never clamped. A span is charged by intersection: a task spanning four frames shows its part in each and the rest as carried in. The frame clock closes and reopens at the top of First, before anything else runs, so every main-phase interval falls wholly inside one frame; the frame the process exits from is closed at exit and its row carries partial. Which spans are roots is declared, not read off a thread's stack — an executor is free to run a schedule's begin and its end on different workers, and the report counts those as migrated. Nesting is still observed, and the report counts frames where a span outlives its parent.

main/render overlap is the part of the wall a main schedule and the render thread were both inside, from their intervals. It is zero whenever rendering is serialised, which is the default; IW4L_PIPELINED_RENDERING=1 runs the render world a frame behind on its own thread instead, and scheduling in the manifest says which branch a run took.

[3/3] Counters — render stages, the bodies inside Present and Ui, the HUD schedule gaps, GPU passes, per-frame work. A *_schedule_interval is a gap between two systems, which the executor is free to fill: compare it against hud_tess_body, never add the two. A counter nobody sampled prints MISS, never 0.00; GPU passes overlap and resolve late, so they are never summed.

The run's own directory, beside the trace.pftrace:

  • report.txt — the three reports above;
  • manifest.json — what the run was: revision and dirty-patch hash, binary and demo SHA-256, toolchain, adapter, window, render target and the viewport the passes were sized by, pools, affinity, env toggles, cache state by kind. An absent fact is null, never a default;
  • summary.json — spans, counters and audio totals as data;
  • frames.csv — one row per frame: each span's overlap with it, every counter, covered_ns, main_covered_ns, carried_in_ns, flags, thread slots. GPU counters are delivered_*: a timestamp arrives frames after the work that queued it;
  • load_jobs.csv — one row per load job: discovered → plan_ready → enqueued → started → decode_started → finished → joined. budget_wait_ms is a worker held by the image memory threshold, decode_ms the work; prepared_bytes is what the job decoded itself and reused_bytes what it served out of another job's payload without decoding anything, against what the merge kept and threw away. Only prepared_bytes is work.

A diagnostic build with --features bevy-trace writes Bevy's own tracing spans — the ones inside the render graph, queue_submit among them — as a Chrome trace. It costs the frame it measures, so it is never a timed run; scheduling.bevy_tracing in the manifest says whether a run was one.