Skip to content

perf(pipelines): take compute pipelines from the pipeline archives instead of compiling them at the first dispatch - #1522

Closed
Romen-Adama-Dev wants to merge 1 commit into
seedds:mainfrom
Romen-Adama-Dev:perf/compute-pipeline-archive
Closed

Romen-Adama-Dev wants to merge 1 commit into
seedds:mainfrom
Romen-Adama-Dev:perf/compute-pipeline-archive

Conversation

@Romen-Adama-Dev

Copy link
Copy Markdown
Contributor

What

  • cps_make (nvn_mtl.m): a compute pipeline is taken from the pipeline archives first, the bundled one and this Mac's files (MTL4Archive newComputePipelineStateWithDescriptor, about 0.3 ms), else compiled through the flush period's MTL4Compiler so the flush writes it into the per-game archive, else through the MTLDevice as before. The prewarm's C items, the dispatch and the prefetch go through it. The pipeline statistics line counts compute pipelines from the archives N / ms, compiled N / ms.
  • pso_offline.py --compute <shader_map.tsv> (the profile's map with --game): every compute shader of the map goes into the bundled archive as a compute_pipeline_descriptors entry of the metal-tt script, whole (there is no base to specialize). The archive key covers them, so an existing archive is rebuilt once; the render batches are reused.
  • mtl_prog_prefetch also builds a compute pipeline when the game makes the program (through cps_make), and a dispatch that comes first takes a queued build over instead of waiting behind the hundreds a scene load queues (DM_NVN_PREFETCH_CPS=0 turns that part off). It only helps when the program is made well before its first dispatch; the archive is the fix.
  • Render pipeline events over 100 ms on the submit thread are named in the log, with how they were served.

Why

A compute shader's pipeline was made with newComputePipelineStateWithFunction at its first dispatch, on the submit thread. No stand-in exists for a dispatch, as the hybrid pipelines are for draws, and the archives held render bases only, so the Metal shader cache was all that stood between a dispatch and a cold compile: 20-40 ms warm, 100-1,200 ms cold for an effect kernel, 58-83 s for a title movie network kernel. Ocarina of Time's effects bring about 20 new compute shaders with each area.

Measured

The Legend of Zelda: Ocarina of Time on an M3 Pro (18 GB). A 25-minute play session on main had four frames of 2.2-4.4 s, all on entering a new area, with on the submit thread pipelines 6,932 ms in one 300-frame window while only 20 render draws had waited (425 ms).

The same scripted route (title, save load, Kokiri Forest) with the app's Metal shader cache set aside and an empty DM_PSO_USER list, so every pipeline is met cold:

Run Submit-thread pipeline time Compute compiles on the submit thread Longest frame
main, archive without compute pipelines 195 s (155 s in the title's twelve kernels) 8 logged over 20 ms: 83 s, 58 s, 7.3 s, 3.5 s, 1.1 s, … 155.6 s (the title)
this change, archive with 4,117 compute pipelines 0.9 s 0 0.65 s (315 render pipelines at the save load)

750 compute pipelines came from the archive in 263 ms in all. The offline archive grew from 4.44 to 4.97 GB; its compute batches took 8 minutes on the M3 Pro (the render batches were reused).

Checks

  • t_pso_archive 102, t_pso_bundle 58, t_pso_warm 99 checks pass (make -C dm/gfx/tests GAME=0400486024DEC000 t_pso_archive t_pso_bundle t_pso_warm, run directly: the check_ targets fail on this Mac on a path with a space, unrelated).
  • python3 dm/tools/tests/test_pso_offline.py: 39 tests, with a new one for the compute kernels (map reading, what is left out, the script). test_docs.py ok.
  • metal-tt's compute_pipeline_descriptors and the archive lookup were checked first with a standalone program on one kernel: 0.32 ms from the archive, 31 ms through the device.
  • scripts/regress.py check --games: not run, this Mac has only Ocarina of Time. The change reaches every NVN game on the Mac (every compute dispatch); a run on the usual five would be welcome.

Docs

  • docs/reference/frame-rate.md: "Compute pipelines built on the submit thread at their first dispatch"; docs/development.md: the archive section; the game note: "Found on this game" and the frame-rate measurements (also the display-paced present numbers, left out of the profile until the game's speed under it is checked by eye); symptom index regenerated.

…stead of compiling them at the first dispatch

A compute shader's pipeline was made with newComputePipelineStateWithFunction
at its first dispatch, on the submit thread: no stand-in exists for a dispatch,
as the hybrid pipelines are for draws, and the pipeline archives held render
bases only, so the Metal shader cache was all that stood between a dispatch and
a cold compile (20-40 ms warm, 100-1,200 ms cold, a title movie network kernel
58-83 s). Ocarina of Time's effects bring about 20 new compute shaders with each
area: four frames of 2.2-4.4 s in a 25-minute session, and a cold-cache route
to Kokiri Forest spent 195 s of its submit thread in pipelines, 155 s of it in
the title's twelve kernels.

cps_make takes a compute pipeline from the archives first (the bundled one, then
this Mac's files; about 0.3 ms), else compiles it through the flush period's
compiler so the flush writes it into the archive, else the device as before; the
prewarm's C items, the dispatch and the prefetch go through it. pso_offline.py
puts every compute shader of the shader map into the bundled archive (--compute,
the profile's map with --game; metal-tt's compute_pipeline_descriptors), so a
packed app never compiles a compute pipeline at play: 4,117 pipelines, 0.5 GB,
8 minutes on an M3 Pro.

mtl_prog_prefetch also builds a compute pipeline when the game makes the
program, through the same path, and a dispatch that comes first takes a queued
build over instead of waiting behind the hundreds a scene load queues
(DM_NVN_PREFETCH_CPS=0 turns that part off). Render pipeline events over
100 ms on the submit thread are named in the log.
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) tools Tools and scripts (dm/tools, scripts) docs Documentation game: 0400486024DEC000 A game labels Nov 6, 2026
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
# Conflicts:
#	dm/gfx/nvn/nvn_mtl.m
thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 6, 2026
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
…s well as render bases

The batched pipeline archive build keys every entry of the list, and seedds#1522
appends compute kernels to it; base_key unpacked four values and the first
full 0.13.0 Ocarina build stopped with 'expected 4, got 2', leaving the app
without a pipeline archive. A kernel's key is tagged so it never equals a
render base's. Tests cover the keys and a batched build with a kernel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK3qWJVi2jG3Gz4i1eY
@dderiso1

dderiso1 commented Nov 6, 2026

Copy link
Copy Markdown

Independent corroboration of the first-dispatch compute stalls on Ocarina of Time v131072, macOS 27.0, Apple M5 Ultra. Our downstream build was based on b4318c7a with separate terrain-ownership and native-mesh-prewarm fixes.

During the approach to the Great Deku Tree, the log recorded frames of 3.461 s, 1.865 s, 1.820 s and 1.731 s, with 3.425 s, 1.842 s, 1.805 s and 1.717 s respectively charged to pipeline acquisition. At frame 22500, aggregate pipeline time was 5431.7 ms while the named render-wait reasons accounted for only 226.1 ms. The startup manifest warmed 196 compute pipelines and grew to 246 C rows during that run. This supports the compute-path diagnosis in this PR.

We independently added program-time compute prefetch with a content-keyed shared compile job and queued-demand takeover. The user reported substantially steadier play afterward. This tested a separate downstream implementation. The now-warm cache also contributes to the user result; this PR itself remains untested here.

Our synthetic Metal checks covered one compile across concurrent same-key requests, a demanded kernel taking over work on a suspended background queue, an unrelated kernel completing while another compile was gated, warmup and prefetch sharing a content-keyed result, failure caching, and actual GPU output. These cases may be useful additions to this PR's tests, particularly queued takeover before the shader-library worker publishes its result.

@ycarogabriel-lgtm

Copy link
Copy Markdown

Integration heads-up: when combining this PR (37e7906b) with #1590 (d3536794) on main e2b76d4e, the Mac host build failed in cps_make: the two ep_enter() calls added here have no argument, while #1590 changes ep_enter to require vd for separate archive epochs. The existing compute-prefetch block from #1590 also overlaps the prefetch path added here, so both should not simply be retained.

In my local combined tree I used ep_enter(1) for the two compute captures and retained this PR's prefetch path in place of the duplicate. make -C dm/host GAME=0400486024DEC000 -j4 then completed on Apple M1, macOS 26.6.2. That establishes compilation only: I have not verified the choice of epoch through archive compaction/reload, measured compute archive hits, rebuilt the app, or played this combination. This is a cross-PR integration issue, not a claim that this PR fails on its own base.

seedds added a commit that referenced this pull request Nov 7, 2026
…y compute shader of the map, and captured into the device's archive when the bundle lacks one (PR #1307)

pso_offline.py gives the bundled archive a compute pipeline for each compute shader (the prewarm list's C rows, then the shader map's compute rows; functions with constants left out). The host's cps_new looks a compute pipeline up in the bundle, then in the device's archive files. On a miss it builds through the MTLDevice for the dispatch, as before, and compiles it again into the flush period on a background queue, so the next start finds it.

plan_compute now reads the libraries through romfs.Path, as plan does. With pathlib.Path, a one-file library (b-<chain>.img, what dmsh_build.py writes) left out every compute shader as having no reflection, and the archive held none (test_pipelines_from_a_library_image).

Ocarina of Time (chain f9f01e4f9994, M6): 4,125 compute pipelines in a 480 MB archive. With it, all 103 of a route's compute pipelines came from the bundle and none were built. Without it, the first start wrote 88 into the device's archive and the second took 88 from it. PR #1522 measured the cold-cache gain: 195 s of submit-thread pipelines to 0.9 s. Return of the Obra Dinn quick regress: PASS (its route dispatches no compute).

Co-authored-by: Pedro Nascimento <91118+lunks@users.noreply.github.com>
@seedds

seedds commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Thanks for this. Your diagnosis and cold-cache measurements decided it. Compute pipelines in the archives landed through #1307's version (30fb213), for two reasons:

Your Ocarina numbers (195 s to 0.9 s on a cold route) are cited in the reference entry and the game note.

The prefetch at program creation was left out: it had no measurement of its own beyond the archive. On Ocarina with the bundled archive, a route's 103 compute pipelines all came from it and none were built, so it has nothing left to cover there. If kernels with function constants still stall (they can't go in the archive), a prefetch limited to those, with its own before/after numbers, would be welcome as a new PR.

@seedds seedds closed this Nov 7, 2026
thomasruegsegger pushed a commit to thomasruegsegger/nxnative that referenced this pull request Nov 7, 2026
…s first write stays resident while any of them holds it: releasing one took it out of the residency set (GPU page faults, issue seedds#1326)

The serials of one storage share one packaged texture (g_pkg_share). At the GPU's first write over that storage, cpu_unc_drop gives each of them the package's texture itself (tex_put), so they all hold one texture that was in no shared set. The first holder released (tex_put's ghost-ring eviction, serial_put or serial_del, through obj_drop or defer_obj_release), or the package's own release (NvPkg dealloc's res_remove), took it out of the residency set while the other serials still drew with it, and a command buffer reading it faulted.

pkg_hand_over enters the handed-over texture in g_alias_set and in g_handed, a list by the package's sharing key, so releasing one holder only drops a reference (alias_has). The package's release and its re-upload's spare leave a handed-over texture resident, mtl_alias_drop_pool retires it with its pool, and tex_upgrade moves its entry. It does not enter g_alias, so a new texture object over the same storage still gets a texture of its own. DM_UNC_SHARE=0 gives back the old hand-over.

Ocarina of Time v131072, Mac mini M4 16 GB, macOS 27.2, on main b2d46b8 with seedds#1590, seedds#1557, seedds#1556, seedds#1575, seedds#1522, seedds#1543 and seedds#1390: page faults at 44, 29, 24 and 15.5 minutes, with the missing textures named by DM_RES_CHECK and a diagnostic of where each left the residency set, before two of them, as 260x260 terrain tiles handed over this way and released by the ghost-ring eviction. One binary: with the change, 107 minutes and no fault (86 hand-overs); with DM_UNC_SHARE=0, a fault at 37 minutes with the same kind of tiles named. t_cpu_tex --metal handover_shared: 97 checks, 0 failed; with DM_UNC_SHARE=0, 2 failed. The other GPU suites are unchanged.

(cherry picked from commit 8cbc330)
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…y compute shader of the map, and captured into the device's archive when the bundle lacks one (PR seedds#1307)

pso_offline.py gives the bundled archive a compute pipeline for each compute shader (the prewarm list's C rows, then the shader map's compute rows; functions with constants left out). The host's cps_new looks a compute pipeline up in the bundle, then in the device's archive files. On a miss it builds through the MTLDevice for the dispatch, as before, and compiles it again into the flush period on a background queue, so the next start finds it.

plan_compute now reads the libraries through romfs.Path, as plan does. With pathlib.Path, a one-file library (b-<chain>.img, what dmsh_build.py writes) left out every compute shader as having no reflection, and the archive held none (test_pipelines_from_a_library_image).

Ocarina of Time (chain f9f01e4f9994, M6): 4,125 compute pipelines in a 480 MB archive. With it, all 103 of a route's compute pipelines came from the bundle and none were built. Without it, the first start wrote 88 into the device's archive and the second took 88 from it. PR seedds#1522 measured the cold-cache gain: 195 s of submit-thread pipelines to 0.9 s. Return of the Obra Dinn quick regress: PASS (its route dispatches no compute).

Co-authored-by: Pedro Nascimento <91118+lunks@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants