Skip to content

[Performance] Phase A counters and Phase B benchmark: compositor deadlines, per-system CPU time, GPU pass timer, hitches, recorder, runtime stats switch, PerfBench - #53

Draft
miogds wants to merge 17 commits into
developfrom
feature/perf_counters
Draft

miogds wants to merge 17 commits into
developfrom
feature/perf_counters

Conversation

@miogds

@miogds miogds commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Phases A and B of the performance work: the counters the rest of the plan depends on, and a benchmark that can be re-run (see docs/proposals/PerformanceMeasurementResearch.md, section 4). Fork-only for now; nothing goes upstream until the whole effort is done.

Phase A: counters (Sources/UntoldEngine/Profiling)

  1. Compositor deadline accounting (visionOS). EngineCompositorStats: update-phase ms, input slack against optimalInputTime, submission ms, the GPU's margin against renderingDeadline and presentationTime, missed-deadline counts and rate, missing-anchor count, view count and per-view drawable size. Compositor signpost category and a MissedDeadline event. timing.semaphoreWaitMs on macOS and visionOS.
  2. Per-system CPU time. Scene graph, extensions, LOD, animation, scripting, physics (per fixed step, with the step count), custom systems and the game callback: a signpost each (Systems category) and a field on EngineTimingStats.
  3. GPU pass timer. GPUPassTimer samples MTLCounterSampleBuffer timestamps at stage boundaries for every labelled render, compute and blit pass of the frame command buffer (two samples per pass, no extra GPU work). Passes attach through makeRenderCommandEncoder(descriptor:passLabel:), makeComputeCommandEncoder(passLabel:) and makeBlitCommandEncoder(passLabel:), which fall back to the plain Metal calls; 50 pass sites converted, labels unchanged. Enable with GPUPassTimer.shared.isEnabled = true or UNTOLD_GPU_PASS_TIMING=1. A test checks the pass sum stays in the same order of magnitude as gpuEndTime - gpuStartTime (the first calibration was off by the mach timebase, 41x; fixed in a follow-up commit).
  4. Hitches, recorder, thermal and memory. EngineHitchStats (histogram with buckets straddling the 90 Hz and 60 Hz periods, frames over a configurable budget, rolling one-second window), startEngineStatsRecording(to:interval:) / stopEngineStatsRecording() writing JSON Lines plus a summary (percentiles, over-budget frames, mean GPU time, deadline misses, mean per-pass GPU time, peak GPU allocation), every stats struct Codable and Sendable, MTLDevice.currentAllocatedSize, os_proc_available_memory and ProcessInfo.thermalState in the memory stats with a ThermalStateChanged event.
  5. Runtime switch (decision D1). ENGINE_STATS_ENABLED is compiled into every configuration; collection is a runtime flag (OSAllocatedUnfairLock<Bool> read without the monitor lock), on in debug, off in release, UNTOLD_STATS=1 or setEngineStatsCollection(enabled:) / setEngine(.metrics(.enabled)) to turn it on. When off every per-frame entry point returns at once and the per-draw counter reads one Bool.

docs/API/UsingProfiler.md documents every new line and field.

Phase B: benchmark (Examples/PerfBench, decision D2)

XcodeGen project with macOS, iOS and visionOS targets, engine referenced by path. Seven scenes (1k individual primitives, 10k batched, 64 point lights, the post chain, 16 skinned characters, stadium and grass, a Gaussian splat), assets from the render-test resources. BenchRunner builds, warms up, records every frame through the engine recorder, tears down, and writes summary.json with device, render configuration (platform, viewport, view count, layout, foveation, immersion, refresh rate) and every scene's summary and last snapshot. Configured through UNTOLD_BENCH_* environment variables so devicectl can drive it; exits when done.

scripts/perf/run_bench.sh <macos|ios|visionos> builds (arm64 only), runs locally or installs and launches on a device with the environment, optionally records an Instruments trace, pulls the run folder into perf/results/ and calls scripts/perf/compare_baseline.py, which compares frame percentiles, GPU time, over-budget and missed-deadline rates and per-pass GPU means against perf/baselines/<model>/<platform>/<scene>.json and refuses to compare different render configurations. Examples/PerfBench/README.md has the details and how to read a regression.

Measurement methodology (learned from the first runs)

Two runs of the same build on this Mac differ by 20 to 50 % in per-pass GPU and per-system CPU times when a scene fits in a frame: the machine idles most of each display-paced frame and its clocks drift, which only ever inflates a time. So the recording summary now carries per-system CPU means (timingMeanMs), per-pass GPU minimums (gpuPassMinMs) and the fastest GPU frame (minGPUExecutionMs); run_bench.sh --repeat N runs the scene set N times and the comparer aggregates by the minimum of each time metric, judges the CPU means, the GPU minimums and the frame percentiles, and prints the GPU mean as information only. The committed Mac16,5 baseline is a three-run aggregate. The CPU-bound scene (primitives-10k) is the stable one and already profiles the engine at scale: about 17 ms encoding, 3 ms culling and 1.5 ms scene-graph walk per frame against 4 ms of GPU time.

Not in here

  • D3: StateReporting domains (OS 27 only; engine minimum is visionOS 2).
  • A headset run: the paired Vision Pro was offline while this was built; the visionOS target builds for the simulator.

Verified locally (Xcode 27.0)

  • swift build (debug and release) and make strict-concurrency-check.
  • xcodebuild build -scheme UntoldEngineXR for the visionOS 27 simulator.
  • GPUPassTimerTests (6, real frames on the macOS renderer, including the pass-sum consistency check), EngineStatsMonitorTests (30), RenderStatsCollectorTests, FrameMetricsCollectorTests.
  • A 290-test slice of UntoldEngineRenderTests over the converted passes: the only failures are the 8 PSNR comparisons, which fail on this machine for lack of cv2 regardless of the change. UntoldEngineTests: the only failure is the external-package test that needs the checkout folder to be named UntoldEngine.
  • PerfBench: macOS, iOS simulator and visionOS simulator targets build; scripts/perf/run_bench.sh macos ran all seven scenes end to end on this Mac and produced a comparable summary.

@miogds miogds changed the title [Performance] Phase A profiling counters: compositor deadlines, per-system CPU time, GPU pass timer, hitches and run recorder [Performance] Phase A counters and Phase B benchmark: compositor deadlines, per-system CPU time, GPU pass timer, hitches, recorder, runtime stats switch, PerfBench Sep 20, 2026
Javier Segura added 17 commits October 16, 2026 13:43
…per frame

On visionOS the frame loop waited for the compositor's optimal input time but
recorded nothing about the outcome, so the stats could not say whether a frame
was submitted late or finished on the GPU after its rendering deadline.

- Add EngineCompositorStats to the stats snapshot: update phase ms, input
  slack against optimalInputTime, submission ms, GPU margin against the
  drawable's renderingDeadline and presentationTime, missed-deadline flag and
  cumulative counts (with a miss rate), missing-anchor count, view count and
  per-view drawable size.
- Record them in UntoldEngineXR.renderNewFrame/executeXRSystemPass; the GPU
  margins come from the command buffer completion handler.
- Add timing.semaphoreWaitMs on the macOS and visionOS paths.
- Add a Compositor signpost category (CompositorUpdate, CompositorWaitForInput,
  CompositorSubmission) and a MissedDeadline point event so Instruments shows
  the phases next to the Metal System Trace GPU track.
- Print a Compositor line in the verbose stats output when views > 0, and
  document the fields in UsingProfiler.md.
The Update scope was one number; when it grew there was no way to tell
scene-graph propagation from animation, scripting, physics or the game
callback without a Time Profiler session.

- Wrap traverseSceneGraph, extension updates, LOD selection, animation,
  USC scripting, each fixed-step physics update, custom systems and the
  game update callback in a profileSystem helper that emits a signpost in
  a new Systems category and, when engine stats are compiled in, adds the
  CPU time to new per-system fields on EngineTimingStats (physics and
  custom systems sum over the fixed steps; physicsStepCount records the
  steps taken).
- Print a Systems line in the verbose stats output and document it.
GPU time was one number per command buffer, which on visionOS holds
culling, Gaussian sorting, both eyes and the HZB build. There was no way
to attribute it to a pass without a Metal System Trace session.

The engine's GPUPassTiming (Profiling/GPUPassTiming.swift) times the
passes: MTLCounterSampleBuffer timestamps at stage boundaries, a ring of
sample buffers per in-flight frame, resolution when the command buffer
completes, passes merged by label (per-eye passes sum), switched on with
setRendering(.gpuPassTiming(.enabled)).

- GPUPassTimingSnapshot: the timer's last resolved frame as the engine
  stats carry it (the passes, their sum, ms(for:)), published into
  EngineStatsSnapshot.gpuPasses once per frame.
- The verbose stats output prints the heaviest passes; the timer and the
  snapshot are documented in docs/API/UsingProfiler.md.
- Passes the timer did not cover are timed too: the three Gaussian
  compute passes, the Gaussian occlusion depth copy, the extension
  surfaces and the scene render target. Picking encodes on a command
  buffer of its own and stays untimed.
- GPUPassStatsTests: the stats carry the timed passes.
…l state and GPU memory

Mean and p99 frame times hide single long frames, and a benchmark run had
no way to leave a file behind that a script could compare.

- EngineHitchStats: cumulative frame-time histogram (buckets straddling
  the 90 Hz and 60 Hz periods), frames over a configurable budget
  (EngineStatsMonitor.frameBudgetMs, set to the 90 Hz period by the
  visionOS runtime), and a rolling one-second window with its worst frame.
- EngineStatsRecorder with startEngineStatsRecording(to:interval:) and
  stopEngineStatsRecording(): every published snapshot (or the last of
  each second) written as JSON Lines on a utility queue, plus a summary
  line with frame percentiles, frames over budget, mean GPU time, deadline
  misses, mean GPU time per pass and peak GPU allocation. Every stats
  struct is now Codable and Sendable.
- Memory stats gain MTLDevice.currentAllocatedSize, os_proc_available_memory
  (iOS/visionOS) and ProcessInfo.thermalState; thermal transitions emit a
  ThermalStateChanged signpost event.
- The AR path times its passes too: GPUPassTiming's frame begins and
  ends around its command buffer.
- Hitches line, memory fields and the recorder documented; tests added.
…build configuration

ENGINE_STATS_ENABLED was defined for debug builds only, so a release build
(the one that ships and the one to measure on a device) had no stats at all
unless the package was rebuilt with -DENGINE_STATS_ENABLED.

- Define ENGINE_STATS_ENABLED for the engine and XR targets in every
  configuration.
- EngineStatsMonitor gains isCollecting (an OSAllocatedUnfairLock<Bool>
  read on every frame-loop call without the monitor lock): on by default in
  debug builds, off in release, overridable with UNTOLD_STATS=1. All
  per-frame entry points return at once when it is off, publishEngineStats
  skips gathering the snapshot, and RenderStatsCollector samples the flag
  at frame start so the per-draw path reads one Bool.
- setEngineStatsCollection(enabled:) / isEngineStatsCollectionEnabled();
  setEngine(.metrics(.enabled/.disabled)) toggles collection together with
  the profiler.
- Tests for the switch; UsingProfiler.md build notes updated.
A repeatable way to measure a build: the same scenes, drawable size and
camera motion, the engine stats and the GPU pass timer on, in release, on
macOS, iOS or Apple Vision Pro, with the results pulled into one folder and
compared against a stored baseline.

Examples/PerfBench (XcodeGen project, engine referenced by path):
- Seven scenes: 1k individual primitives, 10k batched primitives, 64 point
  lights, the post-processing chain, 16 skinned characters, the stadium and
  grass assets, a Gaussian splat. Assets come from the render-test
  resources and are copied into the bundle.
- BenchRunner builds, warms up, records (JSON Lines per frame through the
  engine recorder), tears down and moves on, then writes summary.json with
  the device, the render configuration (platform, viewport, view count,
  layout, foveation, immersion, refresh rate) and every scene's summary and
  last snapshot; configuration comes from UNTOLD_BENCH_* environment
  variables so devicectl can drive it, and the app exits when done.
- macOS: fixed 1920x1080 drawable, orbiting camera. iOS: SwiftUI host.
  visionOS: CompositorLayer with the engine's dedicated layout, scenes
  placed in front of the user, full or mixed immersion.
- The hitch budget is one and a half refresh intervals (a skipped refresh).

scripts/perf/run_bench.sh builds the target (arm64 only), runs it locally
or installs and launches it on a device with the environment, optionally
records an Instruments trace, copies the run folder back and calls
scripts/perf/compare_baseline.py, which compares frame percentiles, GPU
time, over-budget and missed-deadline rates and per-pass GPU means against
perf/baselines/<model>/<platform>/<scene>.json and refuses to compare
different render configurations.
15 s per scene, 1920x1080, display-paced at 120 Hz, release build of
0226477, so later runs on this Mac have something to compare with.
…s; aggregate repeated benchmark runs

Two 15 s runs of the same build on this Mac differed by 20 to 50 % in
per-pass GPU and per-system CPU times: the machine idles most of each
display-paced frame and its clocks drift, which only ever inflates a time.

- EngineStatsRecordingSummary gains timingMeanMs (every EngineTimingStats
  field and the compositor phases) and gpuPassMinMs.
- compare_baseline.py accepts several summaries, aggregates time metrics
  by their minimum and rates by their mean, compares the per-system CPU
  means at the frame-time tolerance and the per-pass GPU minimums instead
  of the means; --update stores the aggregate.
- run_bench.sh --repeat N runs the scene set N times and passes every
  summary to the comparer; the README explains the noise and the remedy.
10 s per scene, three repeats, aggregated by the minimum of each time
metric; includes the per-system CPU means and per-pass GPU minimums.
…r information

Three repeats of the same build still varied 40 to 70 % in mean GPU time
on an idle GPU. The recording summary gains minGPUExecutionMs; the
comparer judges that and prints the mean as information only. Existing
baselines lack the field and skip the check until re-recorded.
On visionOS the engine calls the game update from the XR render thread, so
MainActor.assumeIsolated in the PerfBench callback trapped on the first
frame. BenchRunner is no longer main-actor bound: its state machine runs on
whatever thread the engine updates from (main on macOS and iOS, the XR
render thread on visionOS, inside the frame's update phase where the engine
expects scene mutations), start() only requests the run, and the observable
UI properties are published on the main thread. The scenes and the config
lose their main-actor annotations for the same reason.
…visionOS 27)

One 15 s run per scene from the headset: dedicated layout, no foveation,
2048x1984 per eye, full immersion, release build of ac740ee. Every scene
misses the compositor deadline on most frames; this is the reference the
visionOS work is measured against.
…nchmark scene

The scene's teardown set anti-aliasing to .none, so every scene after it
ran without the engine's default FXAA: on the headset the stadium lines
shimmered. Capture the mode at build time and put it back.
A cube on a plane is the frame's fixed cost and the scene to judge image
quality on a headset without load. UNTOLD_BENCH_AA selects fxaa, smaa,
msaa or none for every scene (the post-FX scene keeps SMAA); the mode is
recorded in the run and is part of the baseline configuration key.
…y baselines by refresh rate

On a Mac with several displays the bench opened on NSScreen.main, the screen
with keyboard focus, so the same command was paced at 60, 100 or 120 Hz
depending on where the terminal was. It now runs on the fastest connected
display, or on one with the rate given by --refresh / UNTOLD_BENCH_REFRESH, and
keeps its window inside that display.

The window shows the fixed 1920x1080 drawable one drawable pixel per device
pixel. Stretched over a Retina display, the window server rescaled it every
frame on the GPU being measured: mean GPU time of the light scenes was 3.3 to
5.6 ms and varied between runs, against 1.2 to 1.8 ms and stable at 1:1.

Comparer:
- The refresh rate is part of a baseline's configuration (times taken at
  different rates are not comparable); a mismatch now says which setting differs.
- Rates are aggregated by the best run, like times. Other activity on the
  machine makes the window server skip refreshes in bursts, which only inflates
  the over-budget rate; a real regression raises it in every run.
- GPU times are judged only when the baseline shows the GPU busy for at least
  half the frame, as on a headset, and printed as information otherwise: with
  the GPU mostly idle they follow its clock state (one frame's minimum GPU time
  measured 0.8 and 2.2 ms in consecutive runs of the same build).
- A GPU or CPU time also has to move by more than an absolute slack to count.
Three-run aggregate of all eight scenes (the single-cube scene had none) at
88737f3, on the built-in display at 120 Hz with the 1:1 window. The earlier
baseline predates the merge of develop and the window change.

Per-system CPU means repeat within a few percent across the three runs (encode
6.32 / 6.39 / 6.34 ms in primitives-1k, 17.5 / 18.0 / 18.1 ms in
primitives-10k). One of the three runs was free of skipped refreshes; the other
two had bursts from other activity on the machine, which the best-run
aggregation leaves out.
@miogds
miogds force-pushed the feature/perf_counters branch from 4bb0bb5 to 51873ca Compare October 16, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant