From 7dbecab4fd1e1f64baa9d64e6b38b869c113a1a2 Mon Sep 17 00:00:00 2001 From: EmmanueleC Date: Wed, 11 Nov 2026 10:00:58 +0100 Subject: [PATCH] DM_NVN_TEX_BREAKDOWN measures the render targets shared in GPU-only pools against the guest storage they stand for: [rtbreakdown] lines on DM_CTL restop and an offline report How much memory the shared render targets of a game like Ocarina of Time could give back depends on two numbers the host did not log: the Metal memory those targets hold, and the guest storage they stand for once overlapping intervals are counted once. The memory line and restop sum allocated sizes; several targets can sit in one family heap, and on NVN several descriptions overlap in the same pool bytes, so neither sum says what the targets need. DM_NVN_TEX_BREAKDOWN=1 (off by default) makes DM_CTL restop log one [rtbreakdown] line per shared render target of a GPU-only or virtual pool: pool, offset, guest size, Metal format, sample count, the texture and its allocated size, its heap and whether that is a resolution family's, and the frame of its last recorded GPU write (mtl_tex_gpu_written, kept apart from the lookup stamp so a descriptor lookup does not count as a write). dm/tools/nvn_rt_breakdown.py reads a log and reports nominal texture bytes, distinct Metal allocation bytes, the union of guest intervals per pool, format and sample count, what family heaps already cover, and an estimate of the entries a newer write elsewhere has overwritten (NX2navi's rule; equal offsets, a writer inside the older range, unknown write times and targets written in the last two frames are left out). It changes no allocation or residency: it measures the opportunity, not the saving. Measured on an M5 with 24 GB, on a branch that also carries PR #1453 and PR #1731 (so not main's numbers), the scripted Castle Town walk from one save: at its peak 3,950 MiB of distinct shared-target allocations against 841 MiB of guest unions, 469 MiB of them already in family heaps; RGBA16F alone 757 MiB against 37 MiB. The overwrite estimate at that snapshot was 666 MiB. With the diagnostic on and off on the same host (one run each), neither run had a 300-frame window over 17.5 ms; footprint 12,917 and 12,472 MiB. On that branch the lines went to the texstat command of PR #1436; here they go to restop, which main has. Checked: t_rt_breakdown with the switch on and off under Metal validation (only the enabled diagnostic stamps writes, finalizing the pool removes them, the snapshot makes no pool buffer), test_nvn_rt_breakdown (interval unions, allocation deduplication, overwrite exceptions, shared heaps), test_docs; the Mac host builds. Not run: the game on main, whose shaders do not convert there since 0662f9d (issue #2272). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7 --- dm/games/0400486024DEC000/README.md | 2 + dm/gfx/nvn/nvn_mtl.m | 45 +++++++++- dm/gfx/tests/Makefile | 8 ++ dm/gfx/tests/t_rt_breakdown.m | 29 +++++++ dm/tools/nvn_rt_breakdown.py | 107 ++++++++++++++++++++++++ dm/tools/tests/test_nvn_rt_breakdown.py | 63 ++++++++++++++ docs/agents/symptoms.md | 1 + docs/env_switches.tsv | 1 + docs/reference/memory.md | 8 ++ 9 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 dm/gfx/tests/t_rt_breakdown.m create mode 100644 dm/tools/nvn_rt_breakdown.py create mode 100644 dm/tools/tests/test_nvn_rt_breakdown.py diff --git a/dm/games/0400486024DEC000/README.md b/dm/games/0400486024DEC000/README.md index b0f00b85ef..1d6236ccbc 100644 --- a/dm/games/0400486024DEC000/README.md +++ b/dm/games/0400486024DEC000/README.md @@ -72,6 +72,7 @@ Measured on an M6 Mac with 16 GB, headless, on one walk with camera turns (`walk ## Memory +- Diagnostic census (M5, 24 GB, 2026-11-11, on a branch that also carries PR #1453 and PR #1731, so not main's numbers): the fixed Castle Town walk peaked at 3,950 MiB of distinct GPU-only shared-target allocations versus 841 MiB of guest interval unions per format/pool/sample count; 469 MiB were already family heaps. RGBA16F alone was 757 MiB versus 37 MiB guest. The overwrite estimate at that snapshot was 666 MiB potentially releasable, so heaps per pixel format at the guest offset (one heap per pool, format and sample count) have the larger measured opportunity. With the diagnostic on/off on the same host (one run each, indicative), neither run had a 300-frame window above 17.5 ms; system wired peaked at 10,988/10,868 MiB and process footprint at 12,917/12,472 MiB. This measures the opportunity, not the saving of an optimization. - Current: on the walk, run from an external drive with a 3.5 GB pipeline archive: resident 10.9-11.4 GB, wired about 9.4 GB, on a 16 GB Mac. `vmmap` and `footprint`: 6.4 GB of guest memory and 3.8 GB of Metal textures and targets, 1 GB of IOAccelerator memory. [Reading the archive into memory](../../../docs/reference/memory.md#a-pipeline-archive-on-an-external-drive-is-read-into-memory) used to add 3.5 GB (footprint 15.0, peak 16.7). `mem_map_limit on`: a map past the application pool fails as on Horizon, and the SDK's allocator then gives back the pages it keeps. Other apps' memory adds to the swap: other games running beside it can push the Mac over. - This game took the Mac down before the fixes (kernel panic in `IOGPUGroupMemory` at 14.7 GB wired): [raw pool table](../../../docs/reference/memory.md#memory-pools-nothing-gave-an-address-to-stay-out-of-the-raw-pool-table), [textures made only when read](../../../docs/reference/memory.md#shaders-report-which-textures-they-read-through-a-pool), [out-of-memory exit](../../../docs/reference/memory.md#repeated-gpu-out-of-memory-failures-end-the-game). - Why so much: the game's own memory is about the console's 9 GB application pool, and on the Mac its textures also exist as Metal textures, as Metal cannot sample the guest's block-linear storage. @@ -127,6 +128,7 @@ Its 37 kernels that correct samples, NDSR's network among them, gave about 0.25 - [Reads past a uniform binding's size](../../../docs/reference/shaders.md#reads-past-a-uniform-bindings-size): open ([GitHub issue #2218](https://github.com/seedds/nxnative/issues/2218)), long white streaks from the horses at Lon Lon Ranch and from Link's slingshot, and flickering parts of the slingshot and of the ReDeads' faces, reported with the uniform read bound on. The bound now takes the size the driver gives the GPU, rounded up to 256 bytes; the game binds sizes that are not multiples of 256. Not reproduced here before or after (the slingshot at Link's house, the ReDeads of adult Castle Town), so the reporters' retest decides. - [A NaN colour under a zero blend weight](../../../docs/reference/shaders.md#a-nan-colour-under-a-zero-blend-weight): resolved, white clouds over the ground mist between Kokiri Forest and Hyrule Field and over the ferns at the camera in Kokiri Forest ([issue #1839](https://github.com/seedds/nxnative/issues/1839)). The mist shader `5ad65c0eefd09c7f` writes NaN at alpha 0 where its layer has density 0; NVIDIA's blender, with the option NVN's driver sets, adds nothing there, Apple's stored the NaN. +- [Render-target allocation and guest storage census](../../../docs/reference/memory.md#render-target-allocation-and-guest-storage-census): opt-in measurement of shared targets, guest interval unions, family heaps and the overwrite estimate. - [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](../../../docs/reference/frame-rate.md#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen): 40 fps in fullscreen on an M5 Pro MacBook Pro with the GPU at 10 ms of the 16.7, every present one 120 Hz refresh late and every other display-link tick skipped; the screen's fixed 60 Hz mode held 60.0 fps there and fullscreen now switches to it, with the 120 Hz link (59 fps) for screens with no such mode. - [The shader package and the shader map come from different splits](../../../docs/reference/packing.md#the-shader-package-and-the-shader-map-come-from-different-splits): resolved, a black screen with sound in a build through NX2Apple ([issue #1841](https://github.com/seedds/nxnative/issues/1841)); the present pass was translated from a terrain shader's record. Records now carry their keys, and the shader build finds them by key. - [Native mesh pipelines compiled at their first draw](../../../docs/reference/shaders.md#native-mesh-pipelines-compiled-at-their-first-draw): resolved. The foliage, shadows and enemies drawn with task and mesh shaders stalled at their first draw on a cold Metal cache (59.5 s of builds at the Kokiri Forest entry on an M5 Max, one frame of 42.9 s), as nothing recorded or kept their pipelines; `pso_manifest.tsv` now carries 164 `M` rows (Kokiri Forest, Hyrule Field, Castle Town, Hyrule Castle, and 41 from later play, Death Mountain Trail among it), and with the prepare step they are built before the first frame. Pipelines outside the list still stall once, then go into `pso_user.tsv`. With the first 123 alone, a cold first start (empty Metal cache and pipeline archive, M4 Pro) on a Death Mountain Trail save built 9 and 10 at their first draw in two runs, 2.7 and 4.2 s, frames of 1,775 and 1,657 ms; with all 164, none, worst frame 25.6 and 18.5 ms, the prepare step 29.6 and 29.3 s → 32.1 and 31.0 s. diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 50e54eed10..405db75af3 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -462,7 +462,8 @@ static void res_top(const char *why) { @autoreleasepool { else { id b = o; int sh = b.storageMode == MTLStorageModeShared; MLOG(" buffer %p+%#lx (%s): %llu MB", sh ? b.contents : NULL, (unsigned long)b.length, sh ? "shared" : "private", (unsigned long long)(b.allocatedSize >> 20)); } } } } void mem_census(const char *why); -void dm_gfx_restop(void) { res_top("DM_CTL restop"); mem_census("DM_CTL restop"); } +static void rt_breakdown(void); // DM_NVN_TEX_BREAKDOWN +void dm_gfx_restop(void) { res_top("DM_CTL restop"); mem_census("DM_CTL restop"); rt_breakdown(); } // Going on helps nobody once command buffers keep failing: no frame reaches the window, and each attempt asks the driver to wire more. A host left running like that on a 16 GB Mac took the whole // system down, hung for hours and then a kernel panic in IOGPUGroupMemory at 14.7 GB wired. So 32 failures within a minute end the game static void res_top_once(NSError *err) { @@ -2584,6 +2585,15 @@ static void count_add(nvn_hmap *m, const void *o, int d) { static void held_add(const void *o, int d) { count_add(&g_by_serial_held, o, d); } static void ghost_held_add(const void *o, int d) { count_add(&g_ghost_held, o, d); } static nvn_hmap g_alias_seen = {.klen = sizeof(alias_key)}; // key → g_fence + 1 at its last lookup or creation (alias_take_over spares entries looked up lately) +// Diagnostic only: frame + 1 of the last recorded GPU write to an existing sharing entry. +// Keeping this separate from g_alias_seen avoids mistaking a descriptor lookup for a write. +static nvn_hmap g_alias_written = {.klen = sizeof(alias_key)}; +static int rt_breakdown_on(void) { + static int on; static dispatch_once_t once; + dispatch_once(&once, ^{ const char *e = getenv("DM_NVN_TEX_BREAKDOWN"); on = e && atoi(e) > 0; }); + return on; +} + // Live GPU-only image descriptions, including those not used yet. A dynamic-resolution family declares its largest image before any particular resolution is used. // Keeping only descriptions here lets its first allocation cover that family exactly, without allocating every resolution or rounding to a power of two. static nvn_hmap g_image_desc = {.klen = 8}; // serial → malloc'ed tex_t, under g_smu @@ -2646,6 +2656,7 @@ void mtl_alias_drop_pool(const void *pool) { void *old = nvn_hm_del(&g_alias, &ks[j]); if (!old) continue; nvn_hm_del(&g_alias_seen, &ks[j]); + nvn_hm_del(&g_alias_written, &ks[j]); id o = CFBridgingRelease(old); if (g_alias_set) CFSetRemoveValue(g_alias_set, (__bridge const void *)o); defer_obj_release(o); @@ -2691,6 +2702,7 @@ static void alias_take_over(const alias_key *nk) { void *old = nvn_hm_del(&g_alias, &ks[j]); if (!old) continue; nvn_hm_del(&g_alias_seen, &ks[j]); + nvn_hm_del(&g_alias_written, &ks[j]); id o = CFBridgingRelease(old); if (g_alias_set) CFSetRemoveValue(g_alias_set, (__bridge const void *)o); defer_obj_release(o); @@ -4046,6 +4058,12 @@ static void gws_room(size_t more) { // g_gws_mu held atomic_store(&g_gws_new, 1); } void mtl_tex_gpu_written(const tex_t *t) { + if (rt_breakdown_on() && t && t->pool && (t->pool->flags & (NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESS | NVN_MEMORY_POOL_FLAGS_VIRTUAL))) { + alias_key k = tex_alias_key(t); + pthread_mutex_lock(&g_smu); + if (nvn_hm_get(&g_alias, &k)) nvn_hm_put(&g_alias_written, &k, (void *)(uintptr_t)(atomic_load(&g_cur_frame) + 1)); + pthread_mutex_unlock(&g_smu); + } if (!t || !t->mem || !t->L.size || !t->pool || (t->pool->flags & (NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESS | NVN_MEMORY_POOL_FLAGS_VIRTUAL))) { return; } @@ -15539,6 +15557,31 @@ void dm_gfx_texlist(int target) { t->d.width, t->d.height, t->d.depth, t->d.format, t->d.levels, t->d.flags, (void *)t->mem, (unsigned long long)t->L.size, t->L.tileH, t->L.tileD); } os_unfair_lock_unlock(&g_rmu); } +// Raw snapshots let the offline report union guest intervals per pool/format/sample count, +// and charge shared heaps once. The existing shape report sums texture sizes instead. +static void rt_breakdown(void) { + if (!rt_breakdown_on()) return; + pthread_mutex_lock(&g_smu); + uint64_t frame = atomic_load(&g_cur_frame); + MLOG("[rtbreakdown] begin=1 frame=%llu", (unsigned long long)frame); + for (uint32_t i = 0; i < g_alias.cap; i++) if (g_alias.vals[i]) { + alias_key k; memcpy(&k, g_alias.keys + (size_t)i * g_alias.klen, sizeof k); + const pool_t *p = (const pool_t *)(uintptr_t)k.pool; + if (!p || !(p->flags & (NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESS | NVN_MEMORY_POOL_FLAGS_VIRTUAL))) continue; + id t = (__bridge id)g_alias.vals[i]; + if (!(t.usage & MTLTextureUsageRenderTarget)) continue; + if (t.parentTexture) t = t.parentTexture; + nvn_texlayout L; nvn_tex_layout(&k.d, &L); + NvImageHeap *owner = objc_getAssociatedObject(t, &g_image_heap_owner_key); + MLOG("[rtbreakdown] pool=%llu flags=%u off=%llu guest=%llu format=%lu samples=%lu texture=%llu bytes=%llu heap=%llu heap_bytes=%llu family=%d written=%llu", + (unsigned long long)k.pool, p->flags, (unsigned long long)k.off, (unsigned long long)L.size, + (unsigned long)t.pixelFormat, (unsigned long)t.sampleCount, (unsigned long long)(uintptr_t)(__bridge void *)t, + (unsigned long long)t.allocatedSize, (unsigned long long)(uintptr_t)(__bridge void *)t.heap, (unsigned long long)t.heap.size, + owner != nil, (unsigned long long)(uintptr_t)nvn_hm_get(&g_alias_written, &k)); + } + MLOG("[rtbreakdown] end=1 frame=%llu", (unsigned long long)frame); + pthread_mutex_unlock(&g_smu); +} void dm_gfx_texdump_next(const char *path) { if (g_td_frame == -2) td_note(nil, 0); // read DM_NVN_TEXDUMP first, so it does not override the request g_td_path = strdup(path); g_td_list = [NSMutableArray array]; g_td_done = 0; g_td_frame = (long)atomic_load(&g_cur_frame) + 1; diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index 30a80c6f99..1b4cba2ec2 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -623,3 +623,11 @@ check_t_pool_standin: t_pool_standin MTL_DEBUG_LAYER=1 DM_NVN_ENC_THREAD=0 ./t_pool_standin MTL_DEBUG_LAYER=1 DM_NVN_ENC_THREAD=1 ./t_pool_standin MTL_DEBUG_LAYER=1 DM_NVN_ENC_THREAD=0 DM_NVN_POOL_STANDIN=0 ./t_pool_standin + +# Snapshot diagnostics must not materialize guest pool buffers. +t_rt_breakdown: t_rt_breakdown.m $(MTL_T_DEP) + clang $(CFLAGS) -fobjc-arc -o $@ t_rt_breakdown.m $(MTL_T_SRC) $(FW) +.PHONY: check_t_rt_breakdown +check_t_rt_breakdown: t_rt_breakdown + ./t_rt_breakdown + ./t_rt_breakdown off diff --git a/dm/gfx/tests/t_rt_breakdown.m b/dm/gfx/tests/t_rt_breakdown.m new file mode 100644 index 0000000000..bebc30471e --- /dev/null +++ b/dm/gfx/tests/t_rt_breakdown.m @@ -0,0 +1,29 @@ +// A snapshot records GPU writes without changing pool residency or retaining dead entries. +#include "../nvn/nvn_mtl.m" +void *front_metal_layer(void) { return NULL; } +module_t *module_of(const void *p) { (void)p; return NULL; } +module_t *g_mod; int g_nmod; +void svc_thread_dump(void) {} +uint64_t now_ns(void) { return bp_now(); } +static int checks, failed; +#define CHECK(c, label) do { checks++; if (!(c)) { failed++; fprintf(stderr, "FAIL: %s\n", label); } } while (0) + +int main(int argc, char **argv) { @autoreleasepool { + int on = argc == 1; setenv("DM_NVN_TEX_BREAKDOWN", on ? "1" : "0", 1); + mtl_init(); + pool_t p = {.magic = MAGIC('P','O','O','L'), .flags = NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESS, .size = 1 << 20}; + tex_t t = {.pool = &p, .serial = 998000, .off = 512}; + t.d = (nvn_texdesc){.target = NVN_TEXTURE_TARGET_2D, .format = NVN_FORMAT_RGBA8, .flags = NVN_TEXTURE_FLAGS_COMPRESSIBLE, .width = 64, .height = 64, .depth = 1, .levels = 1, .samples = 1}; + nvn_tex_layout(&t.d, &t.L); + alias_key k = tex_alias_key(&t); + id mt = [g_dev newTextureWithDescriptor:tex_desc(&t, 0)]; + hm_put_id(&g_alias, &k, mt); + atomic_store(&g_cur_frame, 10); + mtl_tex_gpu_written(&t); + CHECK((uintptr_t)nvn_hm_get(&g_alias_written, &k) == (on ? 11 : 0), "only the enabled diagnostic stamps GPU writes"); + rt_breakdown(); + CHECK(g_npool == 0, "a diagnostic does not create pool buffers"); + mtl_alias_drop_pool(&p); + CHECK(g_alias_written.n == 0, "pool finalization removes recorded writes"); + printf("%d checks, %d failures\n", checks, failed); return failed != 0; +} } diff --git a/dm/tools/nvn_rt_breakdown.py b/dm/tools/nvn_rt_breakdown.py new file mode 100644 index 0000000000..f0b840ab78 --- /dev/null +++ b/dm/tools/nvn_rt_breakdown.py @@ -0,0 +1,107 @@ +"""Summarize DM_NVN_TEX_BREAKDOWN snapshots without charging aliases twice. + +The overwrite count is an estimate over entries still in the sharing table: +recorded GPU writes describe whole textures, not the pixels of a render area. +Heap bytes are releasable only if every observed member is a candidate; other +holders outside the sharing table can still keep that heap alive. +""" +import argparse +from collections import defaultdict +import json +import re + + +def overwritten(old, writer, frame): + lo, hi = old['off'], old['off'] + old['guest'] + wl, wh = writer['off'], writer['off'] + writer['guest'] + return (old['written'] > 0 and frame + 1 >= old['written'] + 2 + and writer['written'] > old['written'] + and old['pool'] == writer['pool'] and lo != wl + and wl < hi and wh > lo and not (wl >= lo and wh <= hi)) + + +def union_bytes(ranges): + total, end = 0, -1 + for lo, hi in sorted(ranges): + total += max(0, hi - max(lo, end)) + end = max(end, hi) + return total + + +def summarize(rows, frame): + stats = defaultdict(lambda: defaultdict(int)) + ranges = defaultdict(list) + allocations = defaultdict(list) + textures = set() + last_write = defaultdict(int) + for row in rows: + last_write[row['texture']] = max(last_write[row['texture']], row['written']) + for row in rows: + fmt = row['format'] + ranges[(fmt, row['pool'], row['samples'])].append( + (row['off'], row['off'] + row['guest'])) + if row['texture'] in textures: + continue + textures.add(row['texture']) + s = stats[fmt] + s['textures'] += 1 + s['nominal'] += row['bytes'] + old = dict(row, written=last_write[row['texture']]) + dead = any(overwritten(old, other, frame) for other in rows) + s['overwritten_nominal'] += row['bytes'] if dead else 0 + key = ('heap', row['heap']) if row['heap'] else ('texture', row['texture']) + allocations[key].append((row, dead)) + for (fmt, _, _), spans in ranges.items(): + stats[fmt]['guest'] += union_bytes(spans) + for members in allocations.values(): + row = members[0][0] + mixed = len({r['format'] for r, _ in members}) > 1 + s = stats[-1 if mixed else row['format']] + size = row['heap_bytes'] if row['heap'] else row['bytes'] + s['allocated'] += size + s['family'] += size if row['family'] else 0 + s['releasable'] += size if all(dead for _, dead in members) else 0 + # The old pool heaps can span formats: report their charge separately. + s['mixed_heap'] += size if mixed else 0 + return {fmt: dict(values) for fmt, values in stats.items()} + + +def snapshots(lines): + frame, rows = None, [] + for line in lines: + if '[rtbreakdown]' not in line: + continue + values = {k: int(v, 0) for k, v in re.findall(r'(\w+)=(0x[0-9a-f]+|\d+)', line)} + if 'begin' in values: + frame, rows = values['frame'], [] + elif 'end' in values and frame is not None: + yield frame, rows + frame = None + elif 'texture' in values and frame is not None: + rows.append(values) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('log') + ap.add_argument('--json', action='store_true') + args = ap.parse_args() + with open(args.log, errors='replace') as f: + reports = [(frame, summarize(rows, frame)) for frame, rows in snapshots(f)] + if not reports: + ap.error('no complete render-target snapshots found') + if args.json: + print(json.dumps(reports)) + return + peak = max(reports, key=lambda r: sum(s.get('allocated', 0) for s in r[1].values())) + for label, (frame, report) in [('peak', peak), ('last', reports[-1])]: + print(f'{label} frame {frame}: MiB, GPU-only shared targets; overwrite is an estimate') + print('format -1 charges a heap shared by multiple formats once, outside the per-format totals') + print('format count nominal allocated guest_union family_heap overwritten releasable mixed_heap') + for fmt, s in sorted(report.items()): + keys = ('nominal', 'allocated', 'guest', 'family', 'overwritten_nominal', 'releasable', 'mixed_heap') + print(fmt, s.get('textures', 0), *(f'{s.get(k, 0) / 2**20:.2f}' for k in keys)) + + +if __name__ == '__main__': + main() diff --git a/dm/tools/tests/test_nvn_rt_breakdown.py b/dm/tools/tests/test_nvn_rt_breakdown.py new file mode 100644 index 0000000000..639ffe59c0 --- /dev/null +++ b/dm/tools/tests/test_nvn_rt_breakdown.py @@ -0,0 +1,63 @@ +"""Guest overlap is not extra storage, and a family heap is paid for once.""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from nvn_rt_breakdown import summarize, overwritten + + +def target(off=0, guest=100, **kw): + return dict(pool=1, flags=0x2a1, off=off, guest=guest, format=92, + samples=1, texture=kw.pop('texture', off + 1), bytes=120, + heap=0, heap_bytes=0, family=0, written=1, **kw) + + +class Breakdown(unittest.TestCase): + def test_union_separates_pools_formats_and_samples(self): + a = target() + rows = [a, target(50), dict(a, pool=2, texture=3), + dict(a, format=115, texture=4), dict(a, samples=4, texture=5)] + stats = summarize(rows, 20) + self.assertEqual(stats[92]['guest'], 350) + self.assertEqual(stats[115]['guest'], 100) + + def test_texture_views_and_family_heap_are_not_double_counted(self): + a = dict(target(), heap=50, heap_bytes=200, family=1) + b = dict(a, off=50, texture=2) + s = summarize([a, dict(a), b], 20)[92] + self.assertEqual(s['textures'], 2) + self.assertEqual(s['nominal'], 240) + self.assertEqual(s['allocated'], 200) + self.assertEqual(s['family'], 200) + self.assertEqual(s['guest'], 150) + + def test_overwrite_exceptions_match_nx2navi(self): + old = target() + newer = dict(target(50), written=19) + self.assertTrue(overwritten(old, newer, 20)) + for changed in (dict(newer, off=0), dict(newer, off=10, guest=20), + dict(newer, pool=2), dict(newer, off=100), + dict(newer, written=1)): + self.assertFalse(overwritten(old, changed, 20)) + self.assertFalse(overwritten(dict(old, written=19), newer, 20)) + self.assertFalse(overwritten(dict(old, written=0), newer, 20)) + self.assertFalse(overwritten(dict(old, written=20), dict(newer, written=21), 20)) + + def test_candidate_in_shared_heap_does_not_release_live_sibling(self): + old = dict(target(), heap=50, heap_bytes=200, family=1) + sibling = dict(old, off=200, texture=2) + newer = dict(target(50), written=19) + s = summarize([old, sibling, newer], 20)[92] + self.assertEqual(s['overwritten_nominal'], 120) + self.assertEqual(s['releasable'], 0) + + def test_recent_write_through_another_view_keeps_the_allocation(self): + old = target() + view = dict(old, written=21) + writer = dict(target(50), written=20) + self.assertEqual(summarize([old, view, writer], 20)[92]['overwritten_nominal'], 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 19099792bf..d41b1ec947 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -182,6 +182,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a game whose shaders read texture handles from memory wires far past the working set while a scene loads, with `Insufficient Memory` errors or a panic, and stutters when new textures stream in. With a limit on how many it makes, surfaces in shadow go near black instead. → [Shaders report which textures they read through a pool](../reference/memory.md#shaders-report-which-textures-they-read-through-a-pool) - memory pools that hold only shader code are in the residency set, though no shader reads them. In The Great Ace Attorney Chronicles, three shared buffers, about 5 MB: chunks of its 1,000 MB pool (`DM_CTL` `restop`: shared buffers 241 MB at 30 s, 236 MB with the fix). → [Pools that hold shader code made resident to key the shaders](../reference/memory.md#pools-that-hold-shader-code-made-resident-to-key-the-shaders) - the Mac grows unresponsive for hours, then panics, with the host still submitting work that fails. → [Repeated GPU out-of-memory failures end the game](../reference/memory.md#repeated-gpu-out-of-memory-failures-end-the-game) +- `restop` and the memory line give texture totals by allocated size; several objects can share a family heap, while their guest intervals overlap too. Neither sum gives the physical storage needed for those targets. → [Render-target allocation and guest storage census](../reference/memory.md#render-target-allocation-and-guest-storage-census) ## Threads diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 1d05c13c00..3e6e60fcc9 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -208,6 +208,7 @@ DM_NVN_STORAGE_FIRST_USE switch `0` keeps a sampled block-compressed texture in DM_NVN_T2T_FROM diagnostic `=`: `DM_NVN_T2T_TRACE` records copies only from that frame on (default 0) DM_NVN_T2T_TRACE diagnostic `=x:`: after each copy into an RGBA16F texture of that size, copies the result into a trace buffer on the GPU timeline; 240 entries are written to the file DM_NVN_TBDR switch `=1` turns on the replay's TBDR pass optimizations (store / load elision, pass merging, same-target continuation); off by default since bindless textures defeat their read inference +DM_NVN_TEX_BREAKDOWN diagnostic `1` adds GPU-only shared render-target snapshots to `DM_CTL restop`: guest intervals, Metal formats, unique allocations, family heaps and recorded write frames; `dm/tools/nvn_rt_breakdown.py` reports union bytes and estimated overwritten entries (off by default) DM_NVN_TEXCPU diagnostic `=1` logs non-packaged (non-swizzled) textures in CPU-visible pools DM_NVN_TEXDUMP diagnostic `=:`, `size=x:` or `layers=:`: saves every colour render target of that frame as PNG (`%d` = serial × 100 + layer × 10 + level) DM_NVN_TEXINFO diagnostic `=x` logs storage and description of every texture of that size on first use, registration and initialization (pool, flags, packaged, non-zero bytes, swizzles) diff --git a/docs/reference/memory.md b/docs/reference/memory.md index d87dca6b12..a82c8c5743 100644 --- a/docs/reference/memory.md +++ b/docs/reference/memory.md @@ -569,3 +569,11 @@ The guest address space, heaps, pools, virtual address memory and the allocators - **Cause:** a command buffer that fails with `Insufficient Memory` frees nothing, and the game submits the next one. - **Fix:** general (`nvn_mtl.m` `res_top_once`): 32 such failures within 60 s end the process with a red line, as the session cannot recover and the machine may not either. - **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md). + +### Render-target allocation and guest storage census + +- **Symptom:** `restop` and the memory line give texture totals by allocated size; several objects can share a family heap, while their guest intervals overlap too. Neither sum gives the physical storage needed for those targets. +- **Cause:** GPU-only targets are shared by pool, offset and description, but Metal allocation size and NVN layout size differ. A descriptor lookup is also not evidence that the GPU wrote its storage. +- **Fix:** diagnostic only, off by default: `DM_NVN_TEX_BREAKDOWN=1` adds `[rtbreakdown]` snapshots to `DM_CTL restop`. `python3 dm/tools/nvn_rt_breakdown.py ` reports nominal texture bytes, distinct Metal allocation bytes, union guest bytes per pool/format/sample count, existing family heaps and the NX2navi overwrite estimate. The estimate excludes equal offsets, a writer contained in the older range, unknown write times and targets written in the last two frames. A write through any view keeps its allocation recent. It observes recorded whole-texture writes and entries still in the sharing table, not render-area coverage or every holder; it is not permission to discard a texture. A heap counts as potentially releasable only when all its observed members qualify. Format `-1` accounts once for legacy pool heaps spanning several formats. +- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md), the Castle Town fixed-save walk on an M5 with 24 GB. +- **Check:** `t_rt_breakdown` tests enabled/disabled write stamps, pool cleanup and absence of pool-buffer creation; `test_nvn_rt_breakdown.py` tests interval unions, allocation deduplication, overwrite exceptions and shared-heap retention.