From e8f1a4b4428e042fe2ae3c8850ffddf9bb393cae Mon Sep 17 00:00:00 2001 From: Adya <114347520+AdyaGMD@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:18:18 +0000 Subject: [PATCH 1/2] nvn/barrier: image heaps take the alias flush once a submission and after memory writes, not on every barrier: Ocarina of Time -1.3 ms a frame on an M1 resource_vis() put MTL4VisibilityOptionResourceAlias on every barrier while an image heap was alive, about 230 barriers a frame. Ocarina of Time keeps one from its first frame (its dynamic-resolution targets are a resolution family, one storage shared by several sizes), and a counter-free Metal System Trace of its Kokiri Forest slow spot on an M1 showed the GPU's stages never overlapping: vertex work started under a running fragment phase in 0.9 % of kicks, compute under fragment in 0 %, and the GPU was idle a quarter of each frame. The relaxed draw-pass barriers allowed the overlap; the alias flush on each of them took it away. A family changes the size it draws at between frames, not inside one, so the flush is needed at a submission's start and after a write that another view of the memory might read. Now, with DM_NVN_VIS_HEAPFRAME (on by default; =0 flushes on every barrier again, ab for the DM_AB phases), an image heap's alias flush is on the first barrier of each submission (g_alias_heap_pend, set by mtl_sub_begin) and on the first barrier after an encoder that may have written memory (g_alias_pend: any compute or copy encoder, a draw pass with shader writes, g_pass_swrite). Large chunked pools without an image heap keep the flush on every barrier, as before. Measured (0400486024DEC000, Kokiri Forest, MacBook M1 8-core GPU, fullscreen, with the light-pass merge and mesh compute of seedds/nxnative#2441 / #2413 on, in a tree with other local changes): DM_NVN_VIS_HEAPFRAME=ab:1/3 in 300-frame phases, frame p50 36.61 / 36.25 / 37.20 / 37.27 ms with it (mean 36.83) against 38.12 / 38.16 / 38.15 without (38.14): -1.3 ms, lower in every phase. Its GPU p50 (the sum of command-buffer durations) rises to 37.7-39.7 ms, as command buffers now overlap. With it a Metal System Trace shows vertex under fragment in 32.8 % of kicks and the GPU idle 21 % of the time. Screenshots look right (the fog of #2487 included). Dropping the flush altogether (a diagnostic) gave -1.5 to -1.8 ms; this keeps most of that. Not run: this branch's host in game, any test suite, the iPhone, M3 and later, other games. Co-Authored-By: Claude Opus 5.5 --- dm/gfx/nvn/nvn_mtl.m | 19 ++++++++++++++++++- docs/env_switches.tsv | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index a66eaad09e..dd26751d96 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -281,7 +281,21 @@ static void ws_report(uint64_t frame, uint64_t frames) { #include "nvn_rec.h" // deferred encoding (DM_NVN_ENC_THREAD) static NSHashTable *g_res_objects; static _Atomic unsigned g_image_heap_n, g_big_n; // g_big_n: large pools in chunks, whose buffers overlap (BIG_MIN) -static inline MTL4VisibilityOptions resource_vis(void) { return MTL4VisibilityOptionDevice | (g_image_heap_n || g_big_n ? MTL4VisibilityOptionResourceAlias : 0); } +// Image heaps (a resolution family's sizes sharing one storage) take the alias flush only at the first barrier of each submission (g_alias_heap_pend, set by mtl_sub_begin) +// and after an encoder that may have written memory (g_alias_pend: a compute or copy encoder, or a draw pass with shader writes), not on every barrier: a family changes the +// size it draws at between frames (dynamic resolution), not inside one. With the flush on every barrier a Metal System Trace of Ocarina of Time on an M1 (its dynamic-resolution +// targets are a family from the first frame) showed the GPU's stages never overlapping: vertex work started under a running fragment phase in 0.9 % of kicks, and the GPU was +// idle a quarter of each frame; flushed this way, 32.8 %, and frames take 1.3 ms less. DM_NVN_VIS_HEAPFRAME=0 flushes on every barrier again (ab for the DM_AB phases) +static int sw_mode(const char *name, int dflt); static inline int sw_on(int m); +static int vis_heapframe(void) { static int m = -1; if (m < 0) { m = sw_mode("DM_NVN_VIS_HEAPFRAME", 1); if (!m) MLOG("DM_NVN_VIS_HEAPFRAME=0: the image-heap alias flush on every barrier"); } return m && sw_on(m); } +static _Atomic int g_alias_pend = 1, g_alias_heap_pend = 1; +static inline MTL4VisibilityOptions resource_vis(void) { + if (g_image_heap_n && vis_heapframe()) { + int need = atomic_exchange_explicit(&g_alias_heap_pend, 0, memory_order_relaxed) | atomic_exchange_explicit(&g_alias_pend, 0, memory_order_relaxed); + return MTL4VisibilityOptionDevice | (need ? MTL4VisibilityOptionResourceAlias : 0); + } + return MTL4VisibilityOptionDevice | (g_image_heap_n || g_big_n ? MTL4VisibilityOptionResourceAlias : 0); +} // Residency churn by call site (the power line every 300 frames, mtl_res_stats): each commit with changes is a kernel call whose cost grows with what was added or removed enum { RS_SITES = 16 }; static struct { int line; uint64_t n, bytes; } g_rs_site[RS_SITES]; static uint64_t g_rs_rm, g_rs_commits, g_rs_commit_ns; #define res_add(r) res_add_at((r), __LINE__) @@ -1479,6 +1493,7 @@ void mtl_prepare(void) { static void gmem_commit_patch(void); // the raw pool table words of the arguments encoded so far follow the pools (gmem_slot_note) void mtl_sub_begin(void) { mtl_init(); g_sub_serial++; g_seg = 0; + atomic_store_explicit(&g_alias_heap_pend, 1, memory_order_relaxed); // the submission's first barrier carries the image-heap alias flush (resource_vis) if (g_pkg_kept) { g_pkg_kept = 0; nvn_dirty_touch(); } seg_clk_put(g_sclk); g_sclk = NULL; sub_cut_begin(); defer_drain(); @@ -1563,6 +1578,7 @@ static inline void m4_end(id e) { MTL4VisibilityOptions vis = e == g_blit && g_blit_alias ? blit_vis() : end_alias && sw_on(end_alias) ? resource_vis() : MTL4VisibilityOptionDevice; if (e == g_blit) g_blit_alias = 0; if ([e conformsToProtocol:@protocol(MTL4CommandEncoder)]) [(id)e barrierAfterStages:MTLStageAll beforeQueueStages:MTLStageAll visibilityOptions:vis]; [e endEncoding]; g_last_end_relaxed = 0; cm_enc_mark(-1); + if (![e conformsToProtocol:@protocol(MTL4RenderCommandEncoder)] || g_pass_swrite) atomic_store_explicit(&g_alias_pend, 1, memory_order_relaxed); // a write the alias flush may be needed for (resource_vis) } // DM_NVN_BARRIER=game (default the profile's draw_barriers, full unless it says game): draw passes are ordered by the game's own barriers. A draw pass ends with a barrier from its vertex and fragment stages to the fragment, compute and copy stages of // later work, so the next pass's vertex work can overlap this pass's fragments (with the full barriers every pass waited for the previous one to finish completely: about 350 passes a frame in Breath of @@ -1664,6 +1680,7 @@ static inline void enc_r_end_pass(id e) { if (!barrier_game()) { m4_end(e); return; } gp_end(e); [(id)e barrierAfterStages:MTLStageVertex | MTLStageObject | MTLStageMesh | MTLStageFragment beforeQueueStages:MTLStageFragment | MTLStageDispatch | MTLStageBlit visibilityOptions:MTL4VisibilityOptionDevice]; [e endEncoding]; g_last_end_relaxed = 1; + if (g_pass_swrite) atomic_store_explicit(&g_alias_pend, 1, memory_order_relaxed); // the pass's shaders wrote memory (resource_vis) } static inline id enc_r_begin(MTL4RenderPassDescriptor *d) { g_cm_enc++; cm_enc_mark(2); id e = [g_cb renderCommandEncoderWithDescriptor:d]; cm_enc_mark(0); [e barrierAfterQueueStages:MTLStageAll beforeStages:MTLStageAll visibilityOptions:resource_vis()]; [e setArgumentTable:g_vt atStages:MTLRenderStageVertex]; [e setArgumentTable:g_ft atStages:MTLRenderStageFragment]; gp_begin(e, 'R', d); return e; } static inline void enc_r_end(id e) { m4_end(e); } diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index e23e305512..fc6c60f86e 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -249,6 +249,7 @@ DM_NVN_VSDUMP path Output file for post-vertex captures; used with DM_NVN_VSDUMP DM_NVN_VSDUMP_FROM diagnostic First presented frame eligible for a post-vertex capture (default 0) DM_NVN_VSDUMP_KEY diagnostic Vertex key to capture, or `*` for all eligible vertex shaders; storage-writing shaders are excluded DM_NVN_VSDUMP_MAX diagnostic Maximum post-vertex captures (default 1, or 256 for the wildcard key) +DM_NVN_VIS_HEAPFRAME switch `0` puts the image-heap alias flush back on every barrier; on by default it is at each submission's first barrier and after encoders that may have written memory (a resolution family changes size between frames): Ocarina of Time on an M1 -1.3 ms a frame, its GPU stages overlapping again DM_NVN_WAITS diagnostic `=1` turns on the wait ledger: every place a host or game thread waits on the CPU for the GPU or the encode thread, counted per frame DM_NVN_XV switch `0` turns off the cross-submission attachment validity ledger, which skips write-backs of colour attachments cleared before their next use (on by default, only with `DM_NVN_TBDR` on) DM_NVN_ZBLOCK diagnostic `=1` logs (`[zb]`) the all-zero blocks per level of each ASTC texture uploaded on the CPU (magenta when drawn); `=` also writes the raw storage of the first one per format and size From 7f9492aa02f9e662cd57416295fb822f4bcfda18 Mon Sep 17 00:00:00 2001 From: Adya <114347520+AdyaGMD@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:56:11 +0000 Subject: [PATCH 2/2] nvn/barrier: the image-heap flush rule on by default only on M1 / M2 (no MTLGPUFamilyApple9); an M3 Max lost Ocarina of Time's foliage with it Co-Authored-By: Claude Opus 5.5 --- dm/gfx/nvn/nvn_mtl.m | 8 +++++++- docs/env_switches.tsv | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index dd26751d96..a06eb74a77 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -286,8 +286,14 @@ static void ws_report(uint64_t frame, uint64_t frames) { // size it draws at between frames (dynamic resolution), not inside one. With the flush on every barrier a Metal System Trace of Ocarina of Time on an M1 (its dynamic-resolution // targets are a family from the first frame) showed the GPU's stages never overlapping: vertex work started under a running fragment phase in 0.9 % of kicks, and the GPU was // idle a quarter of each frame; flushed this way, 32.8 %, and frames take 1.3 ms less. DM_NVN_VIS_HEAPFRAME=0 flushes on every barrier again (ab for the DM_AB phases) +// This skips flushes Metal asks for: a draw pass's attachment stores are writes too, and the host places other texture objects over the same bytes. On an M3 Max (macOS 27.2) +// that left Ocarina of Time's Kokiri Forest bushes and ground ivy out of half to two thirds of frames (#2525); flagging the flush after every draw pass fixes it there, and on an +// M1 that costs as much as no rule at all (36.4-36.8 ms against 33.3-33.5 ms a frame, the foliage steady in all three, screenshot diffs between frames 5 apart 2-3 %). So the rule +// is on by default only on Macs without mesh-shading hardware (no MTLGPUFamilyApple9: M1, M2), where no missing draws were seen; elsewhere every barrier flushes as before static int sw_mode(const char *name, int dflt); static inline int sw_on(int m); -static int vis_heapframe(void) { static int m = -1; if (m < 0) { m = sw_mode("DM_NVN_VIS_HEAPFRAME", 1); if (!m) MLOG("DM_NVN_VIS_HEAPFRAME=0: the image-heap alias flush on every barrier"); } return m && sw_on(m); } +static int vis_heapframe(void) { static int m = -1; if (m < 0 && g_dev) { int old_gpu = ![g_dev supportsFamily:MTLGPUFamilyApple9]; m = sw_mode("DM_NVN_VIS_HEAPFRAME", old_gpu); + MLOG("DM_NVN_VIS_HEAPFRAME: the image-heap alias flush %s (%s)", m ? "once a submission and after memory writes" : "on every barrier", getenv("DM_NVN_VIS_HEAPFRAME") ? "DM_NVN_VIS_HEAPFRAME" : old_gpu ? "default on M1 / M2" : "default with MTLGPUFamilyApple9"); } + return m > 0 && sw_on(m); } static _Atomic int g_alias_pend = 1, g_alias_heap_pend = 1; static inline MTL4VisibilityOptions resource_vis(void) { if (g_image_heap_n && vis_heapframe()) { diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index fc6c60f86e..ccedfddac5 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -249,7 +249,7 @@ DM_NVN_VSDUMP path Output file for post-vertex captures; used with DM_NVN_VSDUMP DM_NVN_VSDUMP_FROM diagnostic First presented frame eligible for a post-vertex capture (default 0) DM_NVN_VSDUMP_KEY diagnostic Vertex key to capture, or `*` for all eligible vertex shaders; storage-writing shaders are excluded DM_NVN_VSDUMP_MAX diagnostic Maximum post-vertex captures (default 1, or 256 for the wildcard key) -DM_NVN_VIS_HEAPFRAME switch `0` puts the image-heap alias flush back on every barrier; on by default it is at each submission's first barrier and after encoders that may have written memory (a resolution family changes size between frames): Ocarina of Time on an M1 -1.3 ms a frame, its GPU stages overlapping again +DM_NVN_VIS_HEAPFRAME switch `0` gives image heaps the alias flush on every barrier; `1` only at each submission's start and after memory writes (it skips flushes Metal asks for after attachment stores); the default is 1 on Macs without MTLGPUFamilyApple9 (M1, M2: Ocarina of Time about -3 ms a frame, no missing draws seen) and 0 elsewhere (an M3 Max lost Ocarina of Time's foliage); ab for the DM_AB phases DM_NVN_WAITS diagnostic `=1` turns on the wait ledger: every place a host or game thread waits on the CPU for the GPU or the encode thread, counted per frame DM_NVN_XV switch `0` turns off the cross-submission attachment validity ledger, which skips write-backs of colour attachments cleared before their next use (on by default, only with `DM_NVN_TBDR` on) DM_NVN_ZBLOCK diagnostic `=1` logs (`[zb]`) the all-zero blocks per level of each ASTC texture uploaded on the CPU (magenta when drawn); `=` also writes the raw storage of the first one per format and size