perf-las-live (2026-10-25): LAS slowdowns measured in live play¶
Interactive session: the user played LAS windowed (default settings, default save root) while NAPPLE_PERF_LOG=1 and tools/long_monitor.sh logged every 5 s.
Findings¶
- The sustained ~30 FPS state did not reproduce in 5 live runs (3-6 min each, sword on the beach -> village -> forest north). Memory plateaued (it does not leak without bound). It may need a cold shader/pipeline cache (new build or translator version) or a longer session.
- Stutters = first-use pipeline/shader builds on the draw thread.
mtl_pipe_getbuilds a new pipeline synchronously on first use (deliberate: nothing skipped). Every stalled 5 s window in runs 2-4 had 7-29 new pipelines and 3-28 new shaders; stalls 170-535 ms. With the blocking-build counter (run 4): windows with 400, 778 and 1262 ms of blocking builds, max single build 333 ms. Run 3 (first pass over the route) had 10 of 44 windows stalled; run 4 (caches warm) 3 of 74; run 5 (warm) 0 of 49. The cost is paid once per area; the binary archive/shader cache makes the second visit smooth. - Command-list graveyard grew up to ~2 GB.
grave_add(gfx/src/api_cmd.c) released at most 256 idle/finalized recordings per second, but live play creates more (heap: 290k plain mallocs, 2 GB in Malloc Small;nvnCommandBufferEndRecordingis the top malloc site in MallocStackLogging). The backlog grew towardGRAVE_CAP(262144 x ~7.5 KB). Fix: no per-pass cap. First 230 s peak RSS: runs 2-4 2.6 / 2.9 / 3.4 GB, run 5 (fix) 1.96 GB. Not an isolated A/B (route differs slightly); the countercmdrecs livein the perf line now makes it measurable (run 5 max 2408 live recordings). - Other footprint at ~3.7 GB peak: guest memory ("Untagged") ~1.2 GB, IOAccelerator ~320 MB, Malloc Small 1.9 GB (mostly the above).
- Audio latency drifted 21 -> 44 ms over 4 min (output stats line); no underruns. Not investigated.
- Game FPS sometimes reads 85-125 for a window right after a stall: the game catches up (not a timescale bug).
Changes (all env-guarded or bounded)¶
gfx/src/api_cmd.c: graveyard releases everything idle/dead each pass (dynamic list);gfx_cmdrec_live().gfx/src/mtl_present.m:NAPPLE_PERF_LOG=1prints a[perf]line every 5 s (fps, worst frame gap, GPU ms, pipelines/shaders built, blocking build ms, live recordings).gfx/src/mtl_shader.m,internal.h,obj.h: blocking pipeline build time counters.tools/long_monitor.sh(RSS/threads/CPU sampler),tools/long_run_perf.sh(headless run + summary).
Verified¶
make -C gfx test and make -C runtime test pass; LAS headless 45 s exit 0 (0 stalled windows); live runs above. SMO not run: its data is not on this Mac.
Not done / next¶
- Remove first-visit stutter: build pipelines off the frame path (async with fallback, or a shipped warm archive). Needs a design decision (skipped draws vs. blocking).
- Guest memory (~1.2 GB) and GPU texture memory for iOS.
SMO live run (same build, user played ~10 min from the title menu into the first kingdom)¶
- 51 windows, 4 stalled (>150 ms; worst 990 ms, at the scene change into gameplay: 656 pipelines built in one window), otherwise a steady 60 fps, GPU 6-12 ms. Peak RSS 2.0 GB.
cmdrecs livepeaks at ~112k (SMO builds thousands of display lists per second) and drains after the 30 s idle window: no unbounded growth. Headless 45 s boot: exit 0.- Visual bugs seen (not from this branch; the user says they predate it): shattered cap brim and white ribbons on the title menu, exploded geometry and a half-scale duplicate view in the top-left ~53% of the frame in-game. Hand-over note in the session notes folder (smo-title-screen-visual-bugs.md).
SMO glitches (user-driven follow-up, same branch)¶
- Half-scale duplicate view in the top-left ~53% (FIXED, user-confirmed live). With render scale on, SMO's G-buffer passes mixed attachments: colour 0 is a 2D-array texture that
mtl_scale_pick_sizenever scales (plain 2D only) next to scaled siblings and depth. Metal renders the overlap only (1600/3024 = 0.53 of the frame) and the viewport followed colour 0 (scale 1).pass_unify_scale(mtl_replay.m) +mtl_tex_force_scale(mtl_tex.m) lift unscaled same-size attachments of a pass to the largest factor. Found withNAPPLE_SCALE_DIAG=1(logs passes whose attachments disagree). Still open: one 400x228 pass (probably a mipmapped target, skipped on purpose). - Register-indexed constant-buffer reads past the bound size now return 0 (hardware behaviour) instead of reading the neighbouring ring memory:
cbr(cb, size, off)with the sizes table at[[buffer(30)]](CBSZ_BUFFER_INDEX), TRANSLATOR_VERSION 7. Found because one skinned draw binds a 0xc0-byte palette and indexes bone 11. Not proven to affect the visible glitches. - Exploded geometry (NOT fixed). Reproduced headlessly and deterministically enough:
NAPPLE_INPUT_SCRIPT="30:A:0.3,45:A:0.3,60:A:0.3"boots SMO into the first kingdom (frames ~7000-9000 show it, also withNAPPLE_RSCALE=0, so independent of render scale). Bisecting withNAPPLE_DEBUG_SKIP_VS(one vertex program per frame, three frames each, screenshot in the middle) shows the large wedge comes from the skinned programfa3fa31651fa2f19(3 draws in frame 7996; bone-index stream attr 11 is R8_UINT stride 1 = repacked per draw; all bone indices 11; palette in UBO 3, 12 bones x 0x30). Ruled out: render scale, the stride-1 stream repack row range (forcing the full range changes nothing), out-of-range palette reads. Not yet checked: the translation of the program's maths (compare against a CPU evaluation of one vertex), the RGB10A2 normal / RGBA8 snorm decode, the 0x30-byte matrix layout. Other glitch families the user sent (grass blade streaks, purple sky, dithered dark Mario silhouette, shattered cap brim on the title menu) are probably separate programs; the bisect recipe above finds each.