WIP: feat(macos): RX_VK_METAL_ARCHIVE_DIR keeps MoltenVK's pipelines in Metal binary archives #667
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!667
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:mac/metal-cache2"
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: macOS, Apple silicon (two starts with the switch on the rebased stack); Windows and Linux, any GPU (build, test and start)
Why
On a Mac, every start of the game compiles tens of thousands of Metal pipelines again, and the slowest of them (the large
compute programs, minutes each) on every start. Metal's own shader cache cannot keep them: each of its two files holds at
most 1 GiB, and once a start has touched 1 GiB it stores nothing more (read from macOS 26.7.1's Metal and libCoreFSCache;
runs #16-#18 filled both files in the first minute of the precompile). MoltenVK 1.4.2 has no way to keep compiled pipelines
itself (no
MTLBinaryArchiveuse, noVK_KHR_pipeline_binary; MoltenVK issue 1765 is open since 2022). This PR adds anopt-in switch that keeps MoltenVK's pipelines in Metal binary archives, files the game owns with no size limit, so that a
later start makes them without compiling, and saves those archives so that they cost the start nothing it can feel: once
each, off the compile threads, never while a giant compile or the game's play needs the machine, with Metal's staging removed.
It is part of the macOS port. See #420.
What changed
RX_VK_METAL_ARCHIVE_DIR=<folder>(macOS only, off by default): after the device is made, the renderer puts a hook underthe two calls MoltenVK 1.4.2 makes every pipeline with. Each pipeline is looked up in the archives earlier starts saved
(no compile), or, until the game shows its first frame, added to a run archive of this start (its one compile) that is saved
for the next. Anything that fails makes MoltenVK's own call unchanged, so no pipeline is lost. Unset or empty, nothing is
loaded, replaced or logged. All of it is inside
#if defined(__APPLE__).gpu/vk/metal_archive.h(new).begin()replaces, on the class of Metal's device, MoltenVK'snewComputePipelineStateWithDescriptor:options:completionHandler:andnewRenderPipelineStateWithDescriptor:completionHandler:(MVKPipeline.mm). MoltenVK calls them inside@synchronized(device), so the replacement only copies the descriptor and returns; athread_stack::threadof its own(at its requester's QoS class) then (1) makes the pipeline from earlier starts' archives with
MTLPipelineOptionFailOnBinaryArchiveMiss, (2) otherwise, before the first frame, adds it to a run archive no other threadholds meanwhile and makes it from there, (3) otherwise makes MoltenVK's call with the untouched descriptor. MoltenVK's
completion handler is called exactly once on every path. After 4 add-path failures with none added the start stops adding.
Archives live in
<folder>/<macOS build>-<GPU name>/(an archive serves one pair). Render pipelines without a fragmentfunction go to archives of their own. Everything is looked up with
dlopen/dlsym(libobjc's C functions, the blocksABI): no new link, no Objective-C source.
467b90d4, from run #21's costs): a run archive takes pipelines until it is estimated at 64 MiB, then is savedonce, by one saver thread at utility QoS, and let go; at most 16 take pipelines at once, and those not saved yet hold 1 GiB
at most. The saver waits while a giant compile is in flight (
compile_board().giants(), #666) or the game is inplay, unless the archives not saved yet hold 512 MiB or more; the ones still open are saved at the close. A failed save is
logged with Metal's reason, tried once more 5 s later and then let go. After each save the archive's own
gpuarchiver-*staging folder (named by
getArchiveIDWithError:) is removed. The close says the time spent adding, making, looking up andsaving, and the staging removed.
gpu/vk/vk_main.cpp:mkwvk_init()callsmetal_archive::begin(…, vkx::metal_archive_gate)right after theMetal cache check (after
create_device, before any pipeline); the gate reads the compile board and whether the game haspresented;
mkwvk_close_asked()saves this start's archives afterpipeline_cache_close(). The archives' state is made withnewand never destroyed (95ce0915): a pipeline's job may still run while the process exits.gpu/vk/vk_draw.cpp:compile_board()moves out of the anonymous namespace, sovk_main.cppcan name it (0640d405).docs/development.md, theRX_VK_METAL_ARCHIVE_DIRrow.tests/test_metal_archive.pyruns the real header against made-up Metal calls on any OS (5ce09630: no-pthreadon Windows), and on macOS also its Objective-C binding against made-up classes registered with libobjc (no Metal, no GPU).
Not kept by archives: the libraries (MSL to AIR) MoltenVK compiles before each pipeline call stay in Metal's library cache,
with its 1 GiB.
Measurements
Verdict: the mechanism works, and the saving rules this PR ships fix what its first version cost in the game; a warm game
start from archives is not measured yet, so the switch stays off. In the game-free probes every pipeline came back from the
archive in the second process: MC-01 (37 of 37), whose second process took 41% (compute), 52% (render) and 16% (large compute)
of the first's time per pipeline, and MC-02 (61 of 61, with this PR's saving: 3 run archives saved once each, 0 failed saves,
vertex-only render pipelines packed, 3 staging folders removed and 0 left, the saver waiting while a giant compiled). The add
path costs little (MC-02, archive over control per median: compute 1.003, render 1.144, depth-only 1.248, rasterizer-discard
1.174, large 0.985). The game's only start with archives on (run #21) used the first version's saving (
81085744-5ce09630),which cost about 91 s of graphics precompile, left 12 GB of Metal's staging and stopped the runner from carrying anything;
467b90d4is the fix for each of those, and is in this PR for that reason. Run #21's lost device was not the archives: run#22 lost it the same way without them.
Baseline: main does not start on macOS. The game columns are the same integration build
09f9a171(which carries the firstversion), arm64, MoltenVK 1.4.2, the field-dawn save, once without and once with the switch (runs #22 and #21); the probes
compare the switch off and on in standalone MoltenVK processes that call the renderer's own headers (MC-01 the first version's,
MC-02 this head's
metal_archive.h).Hardware: macOS 26.7.1 (25G241), Apple M4 Max (40-core GPU), MoltenVK 1.4.2, 16-core CPU, 64 GB
Scene: runs #21/#22: preparation and precompile from a seeded program cache, then the title and the field-dawn save's load,
windowless; the probes: 24 compute, 12 render and 1 large compute pipeline per process (MC-02 also 12 depth-only, 12 rasterizer
discard and a 2,200-pipeline route), distinct SPIR-V, no
VkPipelineCache, a fresh empty Metal cache folder per processMethod: runs #21/#22: the precompile's time line and statistics line, the run's Metal folder and the runner's receipt, one run
each; probes MC-01 (
reports/MetalCache2-files/probe/runs-01,run.jsonf017ef2d…, 2026-11-14 16:47 UTC) and MC-02(
reports/ArchiveFix-files/probe/runs-01,run.json60572c7e…, 2026-11-14 19:29 UTC): processes A then B per route,per-pipeline medians from each process's
SUMMARYlineHow to reproduce: the probes:
reports/2026-11-14-metal-cache2.md§4 andreports/2026-11-14-archive-fix.md§6 (the exactdriver.py runcommands); in the game, setRX_VK_METAL_ARCHIVE_DIRto an existing folder (withRX_VK_METAL_CACHE_DIRsettoo, as the runner does) and read the
vk: Metal binary archiveslinesgpuarchiver-*staging left in the Metal cache folderReports:
reports/2026-11-14-metal-cache2.md(Metal's cache limit, the design, MC-01),-archive-fix.md(run #21's costs,where the staging comes from, the fixes, MC-02),
-mac-startup-21.md,-mac-startup-22.md,-device-lost-21.md(the archivehook ranked at 5-10% as #21's cause: the archiver was idle in the critical window).
Flags and settings
One new switch, macOS only, off by default.
RX_VK_METAL_ARCHIVE_DIR=<folder>NOT appliedline and unchangedTesting
Tested: on macOS 26.7.1, Apple M4 Max, at this head (main
6c9edffeplus #438, #653, #650, #651, #652, #664,#665, #666 and this PR):
tests/test_metal_archive.pywith/usr/bin/clang++and withclang++ -arch x86_64(the fixture under Rosetta): pass on both;#653's
test_metal_cache_dir(both), #651'stest_thread_stack(arm64 and x86_64 in one run) andtest_loading_stall(arm64) pass too (
reports/PubWave-files/PubLate1/final-proof-03/, at31fe0f1b, the same code;test_metal_archiveagain atthis head,
final-proof-04/mc2-*). At the first version's last commit (5ce09630)test_metal_archivepasses as well(
final-proof-03/mc2-mid-archive-arm64).negatives.pyon the first version (base: the branch before the archives, and 7mutations that each break one rule: begin before the device, no close, the fallback given the copy, a lookup that may compile,
the handler kept, never giving up, no save at the close): all 8 fail (
mc2-neg-mid-487977a9/); ArchiveFix'snegatives.pyonthis head (base: the first version; its header with the new wiring; 15 mutations that each put one of run #21's defects back):
all 17 fail (
mc2-neg-head-829fc066/). They ran on the branch as first built; the files they read (vk_main.cpp,metal_archive.h,thread_stack.h,docs/development.md, the test) are byte-identical at this head (blob ids checked).tools/ls-suite.pyat31fe0f1b: 284 pass, 20 fail: the same 19 as at #652's head (16 also fail on main6c9edffeon macOS, 3 only because named),and main's
test_intel_quick_compute, fixed in #666's3cd25d0eand passing at this head(
metal-cache2-related-02/,-compare*.txt;final-proof-04/mc2-iqc).gpu/vk/vk_main.cppandgpu/vk/vk_draw.cppcompiled whole for arm64 and x86_64 with main's renderer flags (with #476's and#492's macOS compile pieces in a scratch tree): 4 objects made, exit 0 (
reports/PubWave-files/PubLate1/compile/metal-cache2/,at
31fe0f1b; both files are byte-identical at this head).unifdef -U__APPLE__, base against head):vk_main.cppthe same;vk_draw.cppdiffers only bycompile_board()leaving the anonymous namespace;
metal_archive.his new and not included there(
reports/PubWave-files/PubLate1/offapple-metal-cache2.txt).Not tested: a game start with this head's saving, and a warm game start that makes its pipelines from archives (run #21's
archives were never carried: the runner refused publication); a Mac with two GPUs or Metal API validation on (the hook would
be on another class: then no request arrives and the close line counts zeros); macOS versions other than 26.7.1; Windows and
Linux (no behaviour changes there: the header is included only under
__APPLE__, andcompile_board()only changes linkage;test_metal_archive.pyruns there with made-up Metal): not built on Windows or Linux; its test request covers it.Must be tested:
RX_VK_METAL_ARCHIVE_DIRset to one folder, the secondfrom the first's, expecting the close lines with saves once each, no
gpuarchiver-*left, and in the second most pipelinesfrom the archivesand a shorter precompile (by hand: the parent's GPU slot).0640d405, it carries #653, #438, #650, #651, #652, #664,#665 and #666) and main
6c9edffethe same way, runtests/test_metal_archive.pyand start each to thetitle, expecting the test to pass and no
vk: Metal binary archivesline, and every other line to match main's (by hand: testrequest T-XXXX).
Risks
What could break: only starts that set the switch on macOS. With it set, a start adds its pipelines to run archives until the
first frame and saves them off the compile threads; a save that fails twice is let go (its pipelines are compiled again next
time); a start that dies before its close leaves the staging of the archives still open. A Metal or MoltenVK update that changes
the two calls makes the hook see no request (the close line counts zeros) or logs
NOT applied; every failure path makesMoltenVK's own call.
Turning it off: leave
RX_VK_METAL_ARCHIVE_DIRunset (the default), or revert the PR.Compatibility: Nothing changes for players: the switch is off by default and Apple-only.
vk_main.cpp,vk_draw.cpp'scompile_board()andmetal_archive.hare not hashed into the shader stamp, so no cache is translated again; noMKWVK_API_VERSIONchange.Reviewer notes
81085744..0640d405(5 commits; in git's notation5f7dfa90..0640d405): the hook as first committed (81085744,95ce0915,5ce09630), then its saving fixed for run #21's costs (467b90d4) andcompile_board()'s linkage (0640d405).The first three alone would ship run #21's costs, so they open together with the fix (PubWave's decision).
metal_cache::check, the switch's neighbour, and through it #438) and #666 (compile_board()andBoard::giants(), which the saver waits on; and through it #665, #664, #652, #650 and #651, whosethread_stack.hthe jobs start with). The branch is #653's headfc8ee75dwith #666's head3cd25d0emerged(
5f7dfa90): one conflict,gpu/vk/vk_main.cpp's Apple block aftercreate_device, resolved asmetal_cache::check, then#651's stack line.
-xfrommacos/metal-cache2(050961c0 426ffa55 f4cd09cb) andmacos/archive-fix(
ccebc6a6 54ec563e;54ec563eis the integration branch'sd6a89208), with no conflict:metal_archive::beginlands betweenmetal_cache::checkand the stack line, as the integration branch has it. Each pick's change equals its source commit's linefor line.
git merge-treeof this head:docs/development.mdwith #654 (rows next to each other: keep both);host/posix/win32_posix.cwith #480 (inherited from #438, not this PR);gpu/vk/vk_draw.cppwith #658 at the top ofdispatch(#666's); and #652's own (vk_main.cppwith #428 and #498; #646's four files)(
reports/PubWave-files/PubLate1/merge-check-metal-cache2*.txt). With #669: no conflict, and the saver and thatgate cannot wait on each other (Integration 30's probe: the gate defers a giant's start, never one in flight).
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 mergebfe5c752of this PR and giant-mem-gate: 544 passed, 32 failed, 17 skipped against main 6c9edffe's 531/32/17 on this Mac; the 32 failures are main's own, nothing that passes on main fails, 13 new tests pass;reports/PubWave-files/waveC1/.)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.7707c634) under the precompile hand-off f9f166f7cb3cd25d0e) under the Metal archives 5f7dfa900a0640d405ddbf5be68150bf5be68150bf988f6226Folded into #849 (fix(macos): MoltenVK's precompile and pipelines on Metal)
Pull request closed