WIP: feat(macos): RX_VK_LMEM_LOG names each module's private memory and each pipeline's first bind #681
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!681
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:mac/metal-scratch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: feat · Risk: low · Tested on: macOS 26.7.1, Apple M4 Max (MoltenVK 1.4.2), arm64 and x86_64 under Rosetta · Needs: Windows and Linux, any GPU (build and start; the switch is off by default)
Why
On a Mac the game's GPU memory climbed in steps of gigabytes a second during the first scene load until the device was lost
(runs #21-#22). One suspect was the programs' local memory: LS gives each program the local memory its header declares as a
zero-initialised private array, which Metal backs for every thread the GPU can keep resident, so a program declaring tens of KiB
a thread could cost gigabytes when it first runs. The log could not say which programs ask for how much, nor which pipelines run
for the first time when the memory steps. This PR adds an opt-in, log-only switch that says both, so a run can confirm or rule
out that cause. It is part of the macOS port. See #420.
What changed
RX_VK_LMEM_LOG=1(off by default, macOS only, log-only): for each SPIR-V module translated or read from the program cache, whatit asks of memory a thread (its Private and Function bytes, its largest variable and how many of that variable's access chains use
a computed index, its workgroup memory, a compute module's local size), one line per module of at least
RX_VK_LMEM_LOG_MINbytes (1024) and a count by size every 4096 modules; and once a second at most, the pipelines bound for the first time since the
last line, naming those whose programs ask for at least the minimum, with the driver's memory in use (
VK_EXT_memory_budget: onMoltenVK the Metal device's
currentAllocatedSize). Unset, each hook is one test of a constant; nothing built, drawn or storedchanges, and nothing is in the program cache's stamp.
gpu/vk/private_memory.h(new):scan()walks a module's words;Census(module lines, at most 2000, and thecounts by size);
FirstBinds(each pipeline once, a line at most once a second); the switch is read only here.gpu/vk/vk_progcache.cppnote_private_memory, beside each place a stage's phis and storage buffer length useare noted (
program_cache_savefor every translated program,program_cache_read,complete_fromfor a light compute programat its first dispatch);
Stage::private_bytes(gpu/vk/vk_shader.h) keeps the result for the first binds.gpu/vk/vk_draw.cppprivate_memory_boundafter the dispatch's compute bind (the pipeline bound: main'scompute_optimizeone once made, #252), the draw's graphics bind and the mesh fallback's compute binds.docs/development.md, the two switches' rows.tests/test_private_memory.py+tests/private_memory_ir.cpp(new): eight real back-end modules (local memory 0 to65536 B, computed and immediate offsets, shared memory, compute, vertex, fragment) read independently in Python against what each
declares and against
scan(), hand-made words, the census and first-bind rules, the switch, the hooks' places, and that thetranslator names none of it. Four fixtures that excerpt the touched functions get an include or a stand-in
(
program_cache_cleanup_test.cpp,program_cache_one_variant_test.cpp, #663'sseg_xfer_test.cpp,test_fallback_pipeline_state.py).Measurements
Verdict: it did its job: with the switch on, two game starts showed that no program asks for more than 9,359 B a thread
and that the memory steps do not follow first binds of programs with local memory, which ruled local memory out as the cause of
the memory growth (that turned out to be MoltenVK widening 8-bit indices, #678). The switch itself costs nothing unset.
Baseline: main does not start on macOS. The numbers come from integration builds that carry the source commits (
552cc3d4forrun #23,
e7730869for #24), arm64, MoltenVK 1.4.2, the field-dawn save,RX_VK_LOAD=start(every program read before thetitle),
RX_VK_LMEM_LOG=1withRX_VK_LMEM_LOG_MIN=256(#23) and1024(#24); and probe LM-01 (Metal's thread-array memory in astandalone MoltenVK process).
Hardware: macOS 26.7.1, Apple M4 Max (40-core GPU), MoltenVK 1.4.2, 16-core CPU, 64 GB
Scene: #23: the title, the menu and the save's load (the device was lost in the load); #24: to play
Method: every
vk: private memory:line of each run's log beside the 1 s GPU memory samples (reports/2026-11-14-mac-startup-23.md"RX_VK_LMEM_LOG",
-24.md); LM-01:reports/MetalScratch-files/lm01/runs-01-arm64/read inreports/2026-11-14-local-mem-buffer.md§1How to reproduce: start with
RX_VK_LMEM_LOG=1(andRX_VK_LOAD=startto note every program before the title) and read thevk: private memory:lines6c9edffe(no switch)LM-01 (one run, arm64): Metal's thread-array memory is one buffer of about (bytes a thread × 120-134 K threads) sized for the
pipeline that ran last, not one per pipeline (a second 16 KiB pipeline added 0.5 MB, not a second 2.19 GB).
Flags and settings
Two new switches, off by default, log-only.
RX_VK_LMEM_LOG=1vk: private memory:linesRX_VK_LMEM_LOG_MIN=<bytes>Testing
Tested: on macOS 26.7.1, Apple M4 Max, at this head (main
6c9edffeplus #663 and what it carries):tests/test_private_memory.pywith/usr/bin/clang++and withclang++ -arch x86_64(the fixtures under Rosetta): pass at thishead (
reports/PubWave-files/proof/ms-gate-*.log); with the test copied onto #663's head it fails (nogpu/vk/private_memory.h); at the last pick (49ba77ee, before4edae82c) it failed on the two places main's code moved(
reports/PubWave-files/PubLate2/proof-02a/ms-*,proof-01/ms0-private-arm64); at4edae82c, before the macOS gate4703372d, its new gate check fails (proof/ms-gate-before-4edae82c.log).reports/PubWave-files/compile/metal-scratch-4703372d/).test_seg_xfer(--arch arm64,--arch x86_64) andtest_fallback_pipeline_state(/usr/bin/clang++and
clang++ -arch x86_64): pass (proof-02a/ms-*).test_program_cache_cleanupandtest_program_cache_one_variantfail onmacOS on main
6c9edffeand here alike (POSIX_FADV_WILLNEEDundeclared: #476's fix); with #476 merged in a scratch tree onlythey pass, both compilers, at this head and at #663's (
proof-02a/ms-pc*,proof-03/).gpu/vk/vk_draw.cppandgpu/vk/vk_progcache.cppcompiled whole for arm64 and x86_64 with main's renderer flags (#476's,#492's and #490's macOS compile pieces merged in a scratch tree only): 4 objects, exit 0, no warnings
(
reports/PubWave-files/PubLate2/compile/metal-scratch/).unifdef -U__APPLE__):vk_draw.cpp,vk_progcache.cppandvk_shader.hdiffer andprivate_memory.his included:the switch works on every driver (
reports/PubWave-files/PubLate2/offapple-metal-scratch.txt).tools/ls-suite.py: 124 pass, 12fail: 10 fail on main
6c9edffeon this Mac too, andtest_precompile_compute_guardandtest_shader_module_guard, which main'sfull suite skips here (Windows structured exceptions) and which fail the same way at #663's head when named
(
reports/PubWave-files/PubLate2/related-metal-scratch-01/,-compare.txt,related-base-b8579f1a-01/).de0ada6bon the integration branch: 566 passed, 1 failed (test_menu_i18n, failingon its base too), 16 skipped (
reports/2026-11-14-metal-scratch.md§7).Not tested: a game start on this PR's stack (main with #663 does not start on macOS by itself; runs #23/#24 had the source
commits, whose hooks this PR places in main's code); not built on Windows or Linux (there the switch compiles to
return false;and every hook returns at once); its test request covers it.
Must be tested:
4703372d, it carries #663, #662, #658, #649, #648, #647, #645, #644, #643 and#491) and main
6c9edffethe same way and start each to the title, expecting novk: private memory:line, and every other line tomatch main's (by hand: test request T-XXXX).
Risks
What could break: only runs that set the switch: every module is scanned once more where the program cache notes it (a
RX_VK_LOAD=startrun notes about 100,000), and the first-bind lines take a lock once per new pipeline. Unset, each hook tests aconstant.
Turning it off: leave
RX_VK_LMEM_LOGunset (the default), or revert the PR.Compatibility: Nothing changes for players: the switch is off by default; no file in the translator or the shader stamp
changed and
RX_VK_LMEMmatches none oftranslator_stamp's prefixes, so nothing is translated again; noMKWVK_API_VERSIONchange.
Reviewer notes
b8579f1a..4703372d(4 commits):cherry-pick -xofmacos/metal-scratchb0d47fb3andde0ada6b, then one endcommit that follows main.
b0d47fb3's pick (ca3e8994) conflicted where the integration branch's neighbours are not on main or #663: the docs rowssit after
RX_VK_PRECOMPILE(no compiler-service rows here);vk_progcache.cpp's three notes followarray_length(noStage::words, #652's);vk_shader.h'sprivate_byteswithoutwords;vk_draw.cppwithout #677's include andblock; the dispatch's hook after main's bind (
compute_optimize, #252).de0ada6b's pick (49ba77ee) was clean. Each pick'schanged lines equal its source commit's (sorted;
pick-metal-scratch-*-lines.txt).4edae82c(semantic): the dispatch's first bind names the pipeline main binds (optimized ? optimized : p.compute_pipeline),and
test_private_memoryanchors the notes onarray_lengthand the dispatch on main's bind (it failed at49ba77eeon both).tests/seg_xfer_test.cpp, whose excerpt needs the stand-in), and through it #662, #658, #649, #648, #647,#645, #644 (
spirv_lengths.h,array_length, the program-cache fixtures' stubs), #643 and #491.git merge-treeof this head (reports/PubWave-files/PubLate2/merge-check-metal-scratch.txt):docs/development.mdwith #652and its stack (#664-#667, #669: their compiler-service rows where these two go: keep both; with #652 in, the
notes may follow
wordsas the integration branch has them);gpu/vk/vk_draw.cppwith #677, #678,#679 and #680 (the include and the block next to #677's: keep both); every other conflict is
#663's own (with #490, #454, #646's test, #650, #652 and its stack).
4703372dmakes the switch macOS only (private_memory::on()isreturn false;elsewhere), so this PR changes no Windows orLinux log; an every-platform log switch could be its own later PR.
Issues
See #420
Checklist
Everyone
type(scope): description) of the status line's type.main(maine090cc48).python -I -B tests/run_all.pypasses (any failure that also fails onmainis named). (On the wave's merge937a502a= drawid-direct9c2bd272(carries count-draws and mode-census) + mvk-temp3e34116c+ this PR at4edae82c: 562 passed, 31 failed, 18 skipped against main 6c9edffe's 531/32/17 on this Mac; the 31 failures are main's own and nothing that passes on main fails;test_collect_widenow passes; 30 new tests pass;test_darwin_audio_service, Darwin-only, is skipped by the runner;reports/PubWave-files/waveC2r/full-937a502a/. This PR's head4703372dadds one commit after that run, which makesRX_VK_LMEM_LOGmacOS only:test_private_memorypasses at the head on arm64 and x86_64 and its new gate check fails at4edae82c;vk_draw.cppandvk_progcache.cppcompile whole at the head on both arches.)third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened.Every scan down vk_memory.cpp's import table by start (pool_lookup's covering import and merge plan, bg_drain, prefetch_chunks, import_in_background, memory_note_gpu_write and memory_guest_imported) stopped once an import ended more than 1 GiB below its address, on the belief that no import is larger than that. pool_lookup's own merge escape leaves an import above 1 GiB over the smaller ones inside it ("left overlapping"), and the device allows imports up to max_import_size: a scan stopped at a small import inside a large one, missed the large one, and the same bytes were imported again, two memories over the same bytes. import_reach.h counts the live imports by span class; its bound is above every live span, within twice the largest. imported_add and imported_take, now the table's only ways in and out, keep the count. A scan stops only where no import further down can reach its address, so it also stops sooner than before when the imports are small. The covering-import checks in pool_lookup and import_in_background look past the last import at or below the range for the same reason. Tests: test_import_reach.py runs the actual scans with a 4 GiB import over a 16 MiB chunk, left by the merge escape (imports are recorded ranges: no payload, no Vulkan, no game). Its lookup, query, note, drain, background and prefetch cases fail on main5e072fc8and pass here, and a source check keeps every change to the table in the two helpers. test_import_windows runs pool_lookup with the helpers; test_mirror_gpu_only names the new publication point. Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]>044635ea added vma_startup_rollback (vk_vma.cpp, run after a failed replay_start), which calls vmaDestroyAllocator. vk_vma.cpp includes vk_work.h last so that RX_VK_RECORDER=defer/thread drains before its destructions, and every such boundary call in a vk_work.h file must be in RX_REC_BOUNDARIES with a drain-first macro. This one was not, and test_recorder_sync caught it ("vk_vma.cpp calls ['vmaDestroyAllocator'], which vk_work.h does not drain before"): it passes at 58c5c5aa (044635ea's parent) and fails at 044635ea. Add it to the list and to vk_work.h, next to vmaDestroyBuffer and vmaDestroyImage. With the recorder off (the default) boundary() is a relaxed load and a branch. Rollback runs before any image or buffer exists, so nothing should be held there today, but the allocator is no longer destroyed while a recorder could still hold packets. The test is unchanged (sources: 42 boundaries drained, was 41). Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]>A guest span larger than one device allocation (the cap on MoltenVK/M4 Max is 41,747,087,360 bytes) is bound as pieces: one descriptor per allocation segment, at the descriptor's own binding (reports/2026-11-14-full-range-design.md sections 4.1-4.4). This is the shader half; the draw side (descriptors, layout, gathers) comes later. - runtime_info.h: SegEntry {Storage|Table|TexBuf|ImgBuf, desc, pieces} and RuntimeInfo::segmented. - Back end: a segmented storage buffer is an array of pieces at its binding (a table count * pieces, entry k's piece j at k * pieces + j); its loads, stores and atomics call DontInline helpers made after main, which find the piece by the pieces' OpArrayLength in order, with constant indices. The global memory fallbacks sum the pieces' lengths for a segmented descriptor's bound end and branch to its piece (single, native candidate, table written out and in the loop). Texel and image buffers are arrays too, their fetches, reads, writes and atomics through helpers that pick the piece by OpImageQuerySize, and a texel buffer's size query sums the pieces. ImageQuery was declared for every module already. With RuntimeInfo::segmented empty nothing new is made: the SPIR-V is byte-identical. - vk_shader: SegSig (entries, hash) on Program, seg_sig and seg_variant; program_segmented finds or translates the variant of a plain program with plain's texture questions and answers and checks its descriptors are plain's; program_pick, mesh_program_locked, program_find_plain, loaded_variants and portable_variant keep the plain and segmented variants apart; every stage's RuntimeInfo::segmented comes from Program::seg. - vk_progcache: a segmented variant is never written to the program cache nor listed among the loaded programs. - tests/test_seg_shader.py + tests/seg_shader_ir.cpp: synthetic IR programs through the real back end, plain and segmented; structure checks, spirv-val (Vulkan 1.3, uniform buffer standard layout), and with --base-tree a byte comparison of the plain modules against an older tree. Five source-reading checks follow the new Program field and the atomics' new branch. The translator stamp changes with this (eden/, vk_shader.cpp, sizeof(Program)): every program cache is rebuilt once. Land it in the same release as the vk_api.h change that already does so. Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]> (cherry picked from commit b7928876dab846485f667c1c934d34d25ecef035)The interfaces the four segmented-consumer lanes (SegBind, SegXfer, TexelDesc, NullShader; reports/2026-11-14-seg-consumer-plan.md section 3) code against, frozen before they fork. Inert: no caller of anything new, no SPIR-V, layout, push range or stream change, sizeof(Program) unchanged. - vk_internal.h / vk_memory.cpp, SegMem's consumer interface: gpu_span_any() (a relaxed count of the live domains, stored in seg_commit; domains are kept for the run), gpu_span (gpu_buffer's flavour of gpu_span_bind: import buffers, no written marks, no mirror), SpanLayout::bind_stamp (gpu_bind_stamp(): a segment can be imported again with its cuts kept, so cut_gen alone never keys piece buffers), span_unused_piece's semantics ({null_storage, 0, 1}), and the SegArena API (SegArenaRef, seg_arena_take, seg_gather, seg_scatter; stub bodies after stream_alloc that give no room and log, SegBind's to fill). - Review fixes in the same interface (renderer-core review P2-3, P3.2): every piece of gpu_span_bind carries its buffer's device address (the import's or the mirror's; 0 only with bufferDeviceAddress off), and its one-piece path stores the translated guest address, not a virtual one. - vk_texture.h/.cpp: texel_view_pieces / texel_piece_views, stubs that return 1 and false (TexelDesc's to fill). - vk_shader.h: Program::presence_words, in what was padding. - Back end: PRESENCE_PUSH_OFFSET (88), Profile::emulate_null_descriptors, Bindings::presence / presence_read with the bit numbering rule. - tests: test_resolver_coverage lists gpu_span_bind / gpu_span sites too (scoped to gpu/vk: gpu/render has an rx::gpu_span of its own), checks that gpu_span follows its own gpu_buffer, and splits SITES into SITES_BASE plus one block per lane. test_segmented_domains executes the contract (the count 0, 1, 2 -> 1 on a merge, 0; gpu_span's pieces; addresses with the fixture's gpu_buffer_bind returning none as the real one does; the bind stamp; a virtual address's guest). test_seg_shader accepts a base tree of this back end's generation and compares every module. - docs/segmented-binding.md: the gate (every new behaviour Apple-only, other platforms byte-identical) and one heading per lane. Conflict on this branch (not with main): tests/test_seg_shader.py's docstring, against the array_pointer_calls paragraph #P20's msl-arrayptr port added; both kept, this paragraph first. Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]> (cherry picked from commit 95fa06d4e6526601bd2e1bc88c2f8f5720855350)The segmented single storage array's case (plan 4.3 item 5) had no negative: the check always cut its own tree. With --source <B2 archive> it fails as required ("moltenvk.segmented_single want=set got=push"). Conflict with #P28 (read_write textures; not with main): the docstring keeps #P28's paragraph with this usage line, and main() cuts from --source's vk_draw.cpp with #P28's read_write count (as in the macOS integration, Integration 17). Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]> (cherry picked from commit 5824f6828e21cf810dcceaa6be425a9224b15994)plaine37c097da2plaina997b3629a4703372d68f9dc41ac83f9dc41ac83e0400e839cFolded into #849 (fix(macos): MoltenVK's precompile and pipelines on Metal)
Pull request closed