Repository navigation
[Engine] metal: the conversation cache and session files keep the decode window's own K/V (#2502) - #3541
[Engine] metal: the conversation cache and session files keep the decode window's own K/V (#2502)#3541oceanio wants to merge 11 commits into
Conversation
…the decode window's own K/V, Niko1221#2502) — integ batch 20 [slot_-era PR side; integ's window_kv_pools + session_mtp_inputs + control-vector docs kept]
- conversation_state.cpp: drop the integ-era checkpoint_validate copy and the duplicated snapshot_sources wrapper left side-by-side by the Niko1221#3341/Niko1221#3541 merges; restore the checkpoint_payload_sizes helper (Metal-aware) that the dedup slice clipped - conversation_cache.hpp: re-apply Niko1221#3615's min-tokens parser + READ gate, clobbered by a later header-side merge - generate.cpp: single parked Match (the Niko1221#3584 best_with_partial + Niko1221#3615 read gate wins; slot_source=-1 moves after it), carry_chain declared for Niko1221#1614's chain-carry in the integ move_cks block, one splice-aware read_from, 'moved' hoisted to the trace's scope - CMakeLists: the WIN32 ws2_32 link for stage_link.cpp sat inside the HIP-only guard Verified: cmake --build build-cu130 clean, strata.exe links
|
We carried the same goal to the end on an M1 Max (64 GB) box - a Metal engine 0.1.47 (a private fork with the Mac prompt path ported) serving Qwen3.8-Flash-Next UD-Q4_K_XL with the conversation cache and Result (measured here, 2026-10-23):
Gate 1 - the file parser's read limits enforce the CPU indexer layout. A save that passes the park-side validation (payload written by the window's own checkpoint save) was still refused at restore, inside the parser: Gate 2 - a model with no MTP tensors: the draft layer must be null on both ends. Our first save attempt failed with Gate 3 - once parking works, the save side is nearly free: reuse the parking capture. We did not port the CUDA linear Verification. Passed here: save, restore across a full engine restart, continuation reuse (harness gate Suggestion: if it helps, we can rebase the delta (SAVE/RESTORE handler branches, the read-limit layout, the draft handling) onto current main and fold it in rather than open a third PR on the same ground. |
|
We validated a complementary working implementation of Metal conversation parking on an Apple Silicon machine (M1 Ultra, 64 GB unified RAM) serving Rather than opening a separate PR and fragmenting the tracker, we want to report our live hardware measurements, our patch approach, and a specific crash trap we observed with purged expert arenas that PRs targeting this area will want to guard against. 1. The Purged Arena Crash Trap (
|
|
Two chips clearing the same path independently is good news - our M1 Max numbers for parking (A/B 2026-10-19: turns after a side request reuse 69-76% of their prompt and prefill in 6.5 s against 17.6 s without parking; park 37-39 ms, restore 8-10 ms, decode unchanged) now have an M1 Ultra witness at 160 K context. A few notes for the fold-in, all from runs on our box, not from reading your patch. The SIGABRT trap: same root, fixed at the mode flag here. We do not let the FP16 fallback exist: the window's K/V kernels ( One shape note for the merge, from Tails and Host-RAM sampler: worth picking one formula before the fold. Our macOS branch credits Happy to run whichever shape lands - #3541 or #3564 - through our harness (park A/B, plus save and restore across a full engine restart with the continuation-reuse gate above) before it merges. |
|
Nice work, would be great to see this merged and released. I did a hardware test on Apple M5 Max, 128 GB, macOS 26.6.2 (25G83) — the fix works here. Revision / build
Model / settings: Qwen3.8-Flash-Next IQ3_S (pack, GGUF and MTP from Strata Terminal 0.1.49.2), Commands (all with PassedMemory guard now reads macOS: Parking (
Second park of A reused retained K/V (448,821,600 bytes at 29k; 1,538,318,592 bytes at 100k, snapshot 2.47 GB). Session files:
Failed (expected fallback)
Not runThe HTTP server path ( Before (same machine, released engine 0.1.48
|
|
Tested this PR rebased onto latest
(Assisted-by: Gemini 3.8 Flash High) |
8542332 to
debc0b8
Compare
|
Thanks all three of you. An M1 Max, an M1 Ultra and an M5 Max (the same machine as mine) give the same result, which is more than I could test here. Rebased onto v0.2.6 (
@catalinb13, on your three gates. This PR clears them this way:
So I don't think there is code to fold in, but a run of your harness on this branch would still be welcome as an independent check. Host-RAM formula. This PR uses the same rule as yours. Batch slots. Out of scope here. This PR snapshots the solo conversation only (Limits), and your note about a slot's @lqdchrm, thanks for the full matrix. You are right about the @fzql, thanks for running it on v0.2.6 with several clients. |
|
Ran our harness on Environment: the PR build Parking A/B (
Session files across a full engine restart (
Continuation-reuse gate (
Cost on our window geometry: a 32-token snapshot is 236.6 MB (fixed ~237 MB per state) and the marginal cost is ~14.2 KB/token, ~15.4 KB/token with MTP - in line with the numbers in your description. One observation, not reproduced and probably outside this diff: of ten PR-build engine starts, one stalled on its first prefill at token 0 (the serve stall watchdog ended it after 60 s; all nine expert workers asleep, host idle 76.8 s). Its two reruns and the nine other starts were clean. +1 from the M1 Max. |
b1e3700 to
b0fe589
Compare
|
Thanks @catalinb13 for running the whole harness on The tool's floor. You're right: The stall at token 0. I haven't seen it here. This machine runs with the experts resident ( |
|
Numbers from a second M1 Max 64 GB box, plus one gap this route runs into when A build carrying this window-capture shape with the disk-save port, huihui Qwen3.8-Flash-Next UD-Q4_K_XL, 6 slots, The gap: under Gating those callsites on Two harness notes that cost us a run each tonight, in case they survive into this PR's tooling: |
…, as serve does parking_test.py and session_restart_test.py start the engine through this class, which passed only the shell's environment and the test's own keys. A config's engine settings (the STRATA_METAL_* tuning, for one) were left out unless they were exported first, so the harness tested an untuned engine. The config's env now comes over the shell's, as serve/server.py applies it, and the test's own keys still win. Reported on Niko1221#3541. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, as serve does parking_test.py and session_restart_test.py start the engine through this class, which passed only the shell's environment and the test's own keys. A config's engine settings (the STRATA_METAL_* tuning, for one) were left out unless they were exported first, so the harness tested an untuned engine. The config's env now comes over the shell's, as serve/server.py applies it, and the test's own keys still win. Reported on Niko1221#3541. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @catalinb13 for the 100K run on a second box. The zero drafter on this PR. You're right that on Metal the session-side So the failure you saw comes from a build whose draft state still goes through the CUDA layout. It does not happen with this PR alone. Could you change that line in #4384's description ("with #3541 alone the park/borrow/disk callsites still hand in the zero ring")? Otherwise a reviewer may read it as a gap here. #4384 with this PR. The two don't compose the way #4384 says. With this PR, Harness notes. The log path is already fixed on this branch ( |
…keys (the decode window keeps its own) The Metal decode window (metal/src/engine/window.cpp) keeps its own int8 K/V and pooled indexer keys per QSA layer, sized by the same max cells, and never reads or writes the session's. On Metal a session buffer is one shared MTLBuffer in the residency set, resident in full whether touched or not, so those unused pools cost about 24.6 KB per context token. Under STRATA_GPU_METAL, kv_plan gives the session's QSA states no K/V slots and no pooled rows; their pointers stay null, and a conversation snapshot refuses such a state (n_slots == 0) instead of reading it. M5 Max, Qwen3.8-Flash-Next IQ3_S resident, phys_footprint 5 s after the engine is ready: 16.99 -> 10.61 GiB at --max-context 262144, 9.27 -> 7.68 GiB at 65536. Greedy tokens unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… was before the session K/V change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lot 0 only, the same buffers) The GDN recurrence and conv history, the QSA int8 K/V, scales, pooled indexer keys, tail and dead rows, and the PLE history move from separate Window members into one SlotState, with their sizes as named constants. The window binds one instance, slot_, allocated as before (same sizes, same order), so every kernel reads the buffers it read before. No behaviour change: this is the state a batch slot will copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le_arena_mem), used by the streaming arena host_memory.cpp reads hw.memsize and host_statistics64 once, with arena_budget.h's arena_avail rule (free and purgeable pages and the clean file cache, not other programs' inactive anonymous memory). sample_arena_mem fills ArenaMem from it. The streaming arena now reads its numbers through these two; the values are the same as its own code read, except the fallback when the VM statistics cannot be read (RAM less the footprint, before: RAM less the Metal buffers). The batch slots use the same reading next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…files are not refused on the Metal engine (Niko1221#2502) sample_host_memory() had readings for Windows and Linux only, so on a Mac the guard could not read the RAM and refused every park and session save / restore. Under STRATA_GPU_METAL it now takes the Metal engine's own reading (host_memory_now: free and purgeable pages and the clean file cache, hw.memsize as the total), the one its streaming arena uses. Other programs' inactive anonymous memory is not counted: macOS frees it only by compressing or swapping it. No cgroups on macOS. Windows and Linux compile the file as before. The PLE residency policy (0.2.7) reads the same figure. It stays off on the Metal engine, as in a build that cannot read the RAM: it was measured on the PC, and with this reading it would lock the 26.8 GiB PLE table of Qwen IQ3_S in RAM (the engine's footprint at 262K went from 10.6 to 37.4 GiB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d running state (Niko1221#2502) On Metal a conversation snapshot read the session's QSA K/V, which the decode window does not use (and which the session no longer has), and the checkpoint check expected the CUDA running-state layout, so parking was refused ("invalid checkpoint running-state payload") and a session file could not be written. Under STRATA_GPU_METAL: - conversation_snapshot.cpp: a snapshot's K/V layer is the window's own, cells [0, upto) of the solo conversation's QSA layer or of the MTP draft layer, in the window's layout (format key 0x4D570001, page size 1), copied with memcpy (unified memory, the window idle). - conversation_state.cpp: checkpoint and session-read sizes come from the window's running state (window_checkpoint_sizes); the checkpoint check follows Window::checkpoint_save's layout. - window.cpp: SlotState::parts / layer_cells / cells_of (the state as byte ranges in a fixed order), Window::kv_layer and checkpoint_sizes over them; the draft layer's cache in the same per-cell layout. - generate.cpp: the session fingerprint's model input for the drafter is the MTP GGUF the window loads, and its backend "metal-window". A snapshot costs about 15.4 KB per token plus 118 MB per running state (the live one and each checkpoint kept). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd running-state layout; a file from another layout is refused The config fingerprint's backend is "metal-window" plus window_layout_key(): the K/V format key, the bytes per cell of each K/V part, the pooled key per block and the running state's part sizes. Two builds of one version with different window layouts get different fingerprints, so a file from the other one is refused whole (SERR, the engine keeps serving) instead of read into the wrong places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st.py reads the engine's own log - session_restart_test.py: a session file saved by one engine and restored in a new one decodes the same follow-up as the conversation that never left (--restore-exe / --expect-refused for a file from another build). - metal_snapshot_test.py: on Metal the conversation cache starts and parks, SAVE writes a file, RESTORE of a non-session file answers SERR and keeps the engine in step, RESTORE of the saved file restores it. - parking_test.py: the engine log under tempfile.gettempdir() (on macOS /tmp is not the temp folder, and it read a stale file there). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TAILS.md), measured at 29K tokens MAC.md: parking and session files work on the Mac and carry the decode window's own K/V; sizes and times for a 29,404-token conversation on the M5 Max with Qwen3.8-Flash-Next IQ3_S. DETAILS.md: the memory guard's macOS reading, the Metal backend and window layout in the session fingerprint, and that session files have run on macOS/Metal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-tokens after the config's arguments The engine takes a repeated flag's last value, so a config with a higher admission floor (for example --conversation-cache-min-tokens 2048) no longer keeps the test's 32-token conversation from parking and failing check 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, as serve does parking_test.py and session_restart_test.py start the engine through this class, which passed only the shell's environment and the test's own keys. A config's engine settings (the STRATA_METAL_* tuning, for one) were left out unless they were exported first, so the harness tested an untuned engine. The config's env now comes over the shell's, as serve/server.py applies it, and the test's own keys still win. Reported on Niko1221#3541. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fa68278 to
450e962
Compare
The policy reads the same RAM figure as the memory guard. With dev's macOS reading it switched on for the Mac and locked the 26.8 GiB PLE table of Qwen IQ3_S in RAM (the engine's footprint at 262K went from 10.6 to 37.4 GiB). It was measured on the PC, so on Metal it stays off, as in a build that cannot read the RAM. The same change as Niko1221#3541's memory-guard commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem and result
On the Mac (Metal) engine the conversation cache never keeps a conversation, and session files cannot be saved (#2502). A short side request, such as a client's title call, pushes the long conversation out, and its next turn reads the whole prompt again. There are two causes:
memory guard: the available RAM cannot be read; parking and session save/restore are refusedat every start, and refuses every park and session save/restore.invalid checkpoint running-state payload. After [Engine] metal: the session no longer holds unused QSA K/V pools (6.4 GiB resident at 262K context) #3536 the session no longer has the pools at all.With this PR, parking and session files work on the Mac and carry the decode window's own state. Measured on an M5 Max with Qwen3.8-Flash-Next IQ3_S: a 28,785-token conversation is parked as 798 MB in 49 ms and restored in 41 ms, and its follow-up decodes the same tokens as the conversation that was never parked. As a session file it is 679 MB, saved in 0.16 s and restored in a new engine process in 0.10 s, with the same follow-up.
Related discussion
If either author would rather continue their own PR, I'm happy to rebase mine on theirs or close it.
Approach
Under
STRATA_GPU_METALonly:src/core/conversation_memory.cpp:sample_host_memory()takeshost_memory_now()(the Metal engine's free-memory reading) on macOS.metal/src/engine/window.cpp:SlotStategathers the window's per-conversation state (a refactor, the same buffers);SlotState::parts / layer_cells / cells_ofwalk that state as byte ranges in a fixed order;Window::kv_layerandcheckpoint_sizeshand the snapshot code the K/V cells[0, upto)of each QSA layer and of the MTP draft layer, plus the running state's sizes.src/core/conversation_snapshot.cpp: a snapshot's K/V layer is the window's own (format key0x4D570001, page size 1), copied withmemcpy(unified memory, the window idle). A K/V layer in any other format is refused.src/core/conversation_state.cpp: the checkpoint and session-read sizes come from the window's running state.src/program/generate.cpp: the session fingerprint names backendmetal-window, the window's layout, and the MTP GGUF the window actually loads. The PLE residency policy (0.2.7) reads the same RAM figure as the memory guard; it stays off on Metal, as in a build that cannot read the RAM. It was measured on the PC, and with the macOS reading it would lock the 26.8 GiB PLE table of Qwen IQ3_S in RAM.tools/metal_snapshot_test.pyandtools/session_restart_test.pyare new.tools/parking_test.pyreads the engine's own log path, andtools/batch_test.py'sEnginestarts the engine with the config'senv, as serve does.Cost: about 15.4 KB per token plus 118 MB per running state (the live one and each checkpoint kept with it). Nothing changes unless
--conversation-cache-mibis set or a session file is saved or restored. WithSTRATA_GPU_METALundefined,unifdefgives the same four shared C++ files as main's, apart from blank lines.Validation
Re-run on v0.2.7 (2026-10-28). Rebased onto main
5e8a4cca(v0.2.7), on #3536, with no conflicts. The code commits are the same lines plus one change. v0.2.7 added a PLE residency policy that reads the same RAM figure as the memory guard. With this PR's macOS reading it switched on for the Mac and locked the 26.8 GiB PLE table in RAM: the footprint at 262K was 37.45 GiB instead of 10.61. It now stays off on Metal (see Approach). At the new tip450e9623:unifdef -USTRATA_GPU_METALon the four shared C++ files, and on [Engine] metal: the session no longer holds unused QSA K/V pools (6.4 GiB resident at 262K context) #3536's layer.cpp: identical to v0.2.7's (blank lines aside).metal_snapshot_test.py: all pass, with and without MTP.parking_test.pyat 3,682 and 28,692 tokens: the follow-up is identical (99/99 and 95/95), and with MTP the drafter's K/V read-back (STRATA_SNAPSHOT_VERIFY) passes. Without MTP at 28,692 tokens: identical (95/95).session_restart_test.pyat 3,785 and 28,785 tokens: files of 294 and 679 MB, saved in 62 and 216 ms, restored in 37 and 203 ms. The follow-up is identical and reuses the whole conversation. Without MTP at 3,784 and 28,784 tokens (290 and 645 MB): the same.The 28K-token runs, the runs without MTP and the refusal ran on the tip before the PLE change (
cd2075d6). That change only decides how the PLE table is read.The rest of this section is the first run, on
9de04cd4.Environment: Apple M5 Max (128 GB), macOS 26.7.1, Apple clang 21.0.0. Model: Qwen3.8-Flash-Next GSQ-RCO IQ3_S (SC117's abliterated build, two-file layout), resident,
--expert-cache 64 --prefill auto --spec 2 --mtp <rt> --mmap-experts. First run's revision: this branch's tip, on #3536 (main9de04cd4+ 2 commits).Passed:
cmake --build build-metal) at every one of the 7 commits; no new warnings.unifdef -USTRATA_GPU_METALon the four shared C++ files (conversation_memory, conversation_snapshot, conversation_state, generate): identical to main's, apart from blank lines.tools/metal_snapshot_test.py --max-context 8192: all checks pass.conversation_cache_mib=1024), and a second conversation parks the first.SAVED. RESTORE of a non-session file answersSERR invalid, and RESTORE of the saved file answersRESTORED 35 ....tools/parking_test.py --extra "--conversation-cache-mib 8192 --conversation-cache-slots 4":--repeat 470): follow-up identical (95 of 95). Engine log:parked 28785 tokens in 48.8 ms ... bytes=797571752,restored 28785 tokens (live) in 41.1 ms. The second park of the conversation reused 439 MB of K/V it still held.tools/session_restart_test.py --extra "--max-context 65536":SAVED ... 294433688 73.5,RESTORED ... 39.4in a new engine;SAVED ... 679433728 160.5,RESTORED ... 103.2.tools/session_restart_test.py --restore-exe <main's engine> --expect-refused: main refuses this build's file cleanly (SERR io 0 session fingerprint: ...), and the engine keeps serving.--max-context 262144, through each tree's ownserve/server.py, without--conversation-cache-mib:Failed:
None observed.
Not run:
#if defined(STRATA_GPU_METAL). Could someone build it on one of them?Limits
memcpywhile the window is idle (unified memory). They do not overlap with decoding.--batchon the Metal backend is accepted at boot and exits the engine with code 86 on the first request that runs two sequences together #2715) are a separate change.🤖 Generated with Claude Code