Repository navigation
[Engine] serve: parked conversations on the disk, optionally across restarts (--conversation-disk-dir, --conversation-disk-persist) - #1937
Conversation
…fter a disk restore; 165K tokens back in 6.4 s instead of 77.8 s; upstream PR Niko1221#1937 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This looks useful for parent/worker conversations that need to survive a full engine unload. I reviewed
Could the prefix be recomputed from the parsed payload, or explicitly validated before replacing the outgoing state? A regression case would pair a valid A payload with an equal-length B key in the persistent index, then request B. It should reject that association or reread B, never reuse A's state as B. Please include image keys and the cvec flag in that check; an index checksum alone would not bind a valid index to the right payload. This is a source-review finding, not a runtime reproduction. I have a bounded Windows A/B/unload/A multimodal test ready, but have not run this PR yet. |
|
Thanks, good catch. Fixed in 1d52b02: after the read, |
|
I’ve now run the multimodal unload test with On Windows with a Ryzen 9 7940HS, RTX 4070 Laptop 8 GB and 64 GB RAM:
The full handoff includes draining/persistence, confirmed process exit, the real four-job build, release, reload and the answer. It also includes the test’s bank-file verification overhead. These are reductions of 14.8% and 18.4% in this single matched pair. Both disk-enabled unload cases recorded an actual disk hit followed by restoration and 1,431 reused tokens; the disabled cases reused zero. Image embedding bytes and token IDs matched across the transition. All 16 requests and four compilation cycles passed the answer, memory-floor and owned-process cleanup checks. The A/B/A controls without unloading also passed, separately as RAM-cache tests. Return-request prompt processing fell from 14.38 to 0.59 seconds for A, and from 14.24 to 0.71 seconds for parked A. Generated reasoning lengths varied despite matched seeds, so the whole-handoff figures are end-to-end observations, not isolated kernel-speed measurements. This was a small ~1.4K-token image prompt with 64K capacity, not a filled-64K or complete Hermes workload. I kept the build frozen during testing, so this does not include the later |
|
Thanks for running it, and for the careful setup (same build in both runs, only disk persistence changed). Good to see it on Windows and with an image prompt; I had only run Linux and text. For comparison, numbers from a real working session on our side: the live server log of one engine run at Setup: 2x NVIDIA CMP 50HX 20 GB (Turing sm_75, unlocked to RTX 2080 Ti clocks, PCIe Gen2 x16, The session: 271 requests, 33.9M prompt tokens (median 127K, max 230K), 92.1% of them reused; prompt time 1,897 s, decode 305K tokens in 3,873 s.
Fixed in 29a7110: a request that needs RAM now drops the queued images (they leave RAM at once, as they did before the disk tier) and waits only for the one already being written; the totals line counts them as "dropped for RAM". |
|
Thanks for measuring the queued-write cost. That is especially relevant when the same machine needs its RAM back for a tool. One additional Windows result on the same frozen The unchanged 2 GiB RAM bank and 2 GiB disk budget accommodated this case: about 1.018 GiB of RAM-cache accounting and 0.908 GiB of persisted payloads/indexes. Lowest sampled available RAM was 7.389 GiB. This was one synthetic long-parent/short-worker case, not two long conversations or a matched long disk-OFF comparison. I have not tested |
|
A follow-up from the same live sessions, now in its own PR: #1959 ( To see what the caches still miss, I added a diagnostic line to our build: when a request reads 4K+ tokens again that the live session, a parked conversation or one on the disk already had, it logs how many. In one ~40-minute pi coding agent session it caught 421K such tokens (~227 s). Most of it (~200 s) was not about parking at all: the agent (the pi coding agent with the pi-blackhole extensions) changed its history a few thousand tokens back, and the conversation, still held in RAM, resumed from the system prompt:
The cause is the checkpoint retention: least recent use, and every turn adds a checkpoint, so after a few short turns all 6 sit in the last 1-2K tokens. #1959 keeps them spread back from the end instead (the same number of checkpoints, no more RAM): an edit 6K back reads ~8K again instead of ~200K. On the box an edited 82K conversation resumed at 73,610 tokens in 10.2 s instead of 40.0 s, with the same answer as a full read when the expert placement is fixed. It is independent of this PR, so a separate one. Conversations parked in RAM keep their spread checkpoints; the disk tier here writes only the live state and the deepest checkpoint (the session files' rule), so a conversation back from the disk starts with those two. |
|
The complete Windows Hermes media trial has now finished on the same frozen The two natural unloads logged 402 ms and 465 ms waiting for queued writes, with 0.7 s and 0.6 s total save-for-next-start times. These are nested native measurements, not extra time to add to the full handoff. The first supervisor requests after the worker returns reused 19,814 and 24,974 tokens; their full request times were 49.59 s and 72.82 s, including new input and generation. Exact scope, source differences and the separate audit correction are recorded. This is one complete workload, not a matched persistence speedup test, and does not validate your later queued-write change. Thanks for pointing out #1959 too; its history-edit case is worth a separate matched test. Our largest remaining prefill pause in this run came from genuinely new verbose tool output, so spread checkpoints would not remove that particular cost. |
…k tier too The conflict: upstream's ConversationCache::clear() (Niko1221#879) beside set_on_evict - both kept. Niko1221#879 drops every parked conversation after non-finite logits; ConversationDiskTier::clear() drops the conversations on the disk too, at both places (as in PR Niko1221#1937). conversation_disk_test 83, conversation_cache_test 4219, conv_cache_test, serve test_server 296 pass; builds (sm_75, GCC 14). FORK.md: the sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
A stopgap solution in the meantime might be to mark conversation cache as MADV_COLD or MADV_PAGEOUT |
|
Thanks - that would help with the RAM pressure on Linux, as a quick measure. A few reasons it doesn't cover what this PR is for: it needs swap sized for the parked images (1-4 GB each here, while swap is often a few GB); it doesn't survive an engine restart (the Interestingly, on our box the kernel already swaps part of the parked images (3 GB of swap in use, 4 GB available), which matches some slow 6-8 s restores of 220K-token conversations - so I'll look at keeping the RAM budget below that point and letting the tier take the rest. |
|
Correction to my earlier comment about 29a7110: dropping the queued writes when a request needs their RAM turned out to be the wrong trade. In a longer session (657 requests, the same box and agent) it dropped a 180K-token conversation (3.3 GB) whose write would have taken ~4 s, and the next request of that conversation read it again from token 0 (~90 s). A few seconds of waiting for a write is far cheaper than losing a long conversation, so 3fe88e6 reverts it: a request waits for the queued writes again, as before 29a7110. Your tests on 1d52b02 predate both, so they are unaffected. |
commit 7185f2eaebebc9dc5d7997f08b5b4b7b5860fb53
Author: John-Henry Lim <42513874+Interpause@users.noreply.github.com>
Date: Mon Oct 12 21:47:39 2026 +0800
conversation disk: queued writes are waited for again (reverts the drop of 29a7110)
Dropping the queued images when a request needed their RAM turned out to be the wrong trade: in a longer session
on 2x CMP 50HX it dropped a 180K-token conversation (3.3 GB) whose write would have taken ~4 s, and its next request
read it again from token 0 (~90 s). make_ram() waits for the queued writes again; drop_queued() and its counter go.
conversation_disk_test 77, conversation_cache_test 4219; builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit 232bd53
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 16:10:31 2026 +0300
conversation disk: the disk time in the request line only
The restore line repeated it; the "disk hit:" line already has the read. Builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit 29a7110
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 16:02:30 2026 +0300
conversation disk: a request that needs RAM drops the queued writes instead of waiting for them
In a real session (pi coding agent, 271 requests, 2x CMP 50HX) 5 requests waited 25.5 s in total (1.7-6.8 s each)
for 1-3 queued writes to give their RAM back; the low-priority writer ran at 60-70 MB/s while the prompt read kept the
CPUs busy. make_ram() now drops the queued images first (they leave RAM at once, as before the disk tier) and waits
only for the one being written. ConversationDiskTier::drop_queued(), "dropped for RAM" in the totals.
conversation_disk_test: 79 checks (the queued images go, the one being written stays).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit f0ed90e
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 14:21:11 2026 +0300
conversation disk: the request line says a prompt reused from the disk, with the read plus the restore
"prompt N tokens = R reused + F read in ..., 3 checkpoints, reused from the disk in 3.3 s" (at the end, so the
server's ENGINE_REQUEST and the bench scripts still parse it); the restore line adds ", from the disk in X ms with
the read". The time was already in the prompt time, unnamed: an 84K-token hit on 2x CMP 50HX took 2747 ms to read
and 572 ms to restore. Log only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit 1d52b02
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 13:53:32 2026 +0300
conversation disk: refuse a payload that is not the conversation its index names
take() restored the image with the index's keys and resume decision without checking that the session files hold
those keys. Now the payload's live and checkpoint token ids, image keys and control-vector flag must equal the
index's, in every stage; otherwise the read fails and the prompt is read cold. Regression check: a's valid image
copied under b's equal-length index is refused (fails without the check). conversation_disk_test: 73 checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit eb2493b
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 12:41:43 2026 +0300
serve: --conversation-disk-persist, parked conversations outlive the engine
Who it is for: whoever does not keep Strata running all day - start it, continue yesterday's long session, stop it -
and whoever restarts the server (an update, a settings change, a reboot). Without it every restart reads every
conversation from token 0 again; with it the next start continues from the disk in seconds, paid for with disk space.
- ConversationDiskTier, persist: files bound to the session files' identity (model fingerprint, engine version,
settings) instead of a random number of the run; a small index per conversation (token ids, write time) lets the
next start take them in; another identity's files, a damaged index and interrupted writes are removed at start; no
time limit, the budget alone (least recently written first); the queue is written and the files kept at the end.
- generate.cpp: the tier is created after the session identity; on QUIT the current conversation is parked and every
parked one goes to the disk before the engine ends; the engine ignores SIGINT in this mode (the server's process
group gets a terminal's Ctrl+C; the server's QUIT ends it after the save).
- serve/server.py: with --conversation-disk-persist close() waits up to 300 s for that save (20 s otherwise).
- docs/DETAILS.md: who it is for, how it works, the measurement.
Measured on 2x CMP 50HX + 2x Xeon E5-2670 v2, NVMe, IQ3_XXS: the save at the stop 6.8 s (a 165,557-token current
conversation and two parked 42K ones; 2,639 MiB written at 956 MB/s); the next start took in 3 conversations
(4,311 MiB), refused none; all three came back from the disk with the same greedy answers as before the restart, the
165K one in 6.7 s instead of 77.6 s. conversation_disk_test 67 checks (restart cases included), 5 ThreadSanitizer
runs with no race; conversation_cache_test 4214; serve.test_server 291 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit 2036949
Author: xrip <xrip@xrip.ru>
Date: Sun Oct 11 11:12:22 2026 +0300
serve: parked conversations on the disk (--conversation-disk-dir, opt-in)
A conversation the RAM conversation cache evicts (slots or byte budget full) was dropped, so a client coming back
to it read its whole prompt again; an agent alternating between its main conversation and sub-agents reads ~150K
tokens again on every switch once two of them do not fit the budget together. With --conversation-disk-dir DIR the
evicted conversation is written to DIR and restored when a request continues it.
- ConversationCache::set_on_evict: evict_oldest hands the image to a callback instead of only dropping it (the RAM
cache is otherwise unchanged; drop_superseded and take do not spill).
- ConversationDiskTier (conversation_disk.hpp/.cpp, host code only): one session file per image of a layer split
(session_file_write / session_file_read: the same checks and the --session-min-free-mib preflight), the live state
and the deepest checkpoint only, identity = a random number of this run; a background writer thread (nice 10 on
Linux) so a switch never waits for the disk; a queued image comes back from RAM; --conversation-disk-ttl-min
(default 30) and --conversation-disk-mib (default 32768, oldest out); its files are removed at start and at the end;
a diagnostic line per queue, write, hit and wait and a totals line after every hit.
- generate.cpp: the tier beside the RAM cache; make_ram() is the one "room in RAM above the floor" loop (wait for the
tier's queued writes, then evict the RAM cache's oldest) for parking and for a read back; a longer prefix on the
disk than in the session, a --batch slot or the RAM cache is restored through the same validation and restore.
Off without the flag: the behaviour is unchanged.
Measured on 2x CMP 50HX (layer split), 2x Xeon E5-2670 v2, NVMe, Flash-Next IQ3_XXS, one RAM slot: greedy follow-up
answers after a restore from the disk equal the live conversation's (two 42K-token conversations; also via RAM). A
165,557-token conversation: cold read 77.8 s, back from the disk 6.4 s (read 3.6 s, 2,639 MiB at 761 MB/s; GPU
restore 0.87 s). No file left after the engine ended. conversation_disk_test 55 checks (CPU only; also clean under
ThreadSanitizer); conversation_cache_test 4214 and conversation_file_test 201 unchanged. docs/DETAILS.md: the
section after the conversation cache's settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit 3fe88e6 Author: xrip <xrip@xrip.ru> Date: Mon Oct 12 08:13:13 2026 +0300 conversation disk: queued writes are waited for again (reverts the drop of 29a7110) Dropping the queued images when a request needed their RAM turned out to be the wrong trade: in a longer session on 2x CMP 50HX it dropped a 180K-token conversation (3.3 GB) whose write would have taken ~4 s, and its next request read it again from token 0 (~90 s). make_ram() waits for the queued writes again; drop_queued() and its counter go. conversation_disk_test 77, conversation_cache_test 4219; builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit 9f03dda Merge: 232bd53 7695859 Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 18:43:35 2026 +0300 Merge upstream/main into pr/conversation-disk-tier; Niko1221#879 clears the disk tier too The conflict: upstream's ConversationCache::clear() (Niko1221#879) beside set_on_evict - both kept. After non-finite logits Niko1221#879 drops every parked conversation because any may carry the poison; the disk tier holds conversations that were parked, so ConversationDiskTier::clear() now drops them too (queued, the one being written once it ends, and every entry with its files), at both places Niko1221#879 clears the RAM cache, counted in its log line. conversation_disk_test 83 checks, conversation_cache_test 4219; builds (sm_75, GCC 14). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit 232bd53 Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 16:10:31 2026 +0300 conversation disk: the disk time in the request line only The restore line repeated it; the "disk hit:" line already has the read. Builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit 29a7110 Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 16:02:30 2026 +0300 conversation disk: a request that needs RAM drops the queued writes instead of waiting for them In a real session (pi coding agent, 271 requests, 2x CMP 50HX) 5 requests waited 25.5 s in total (1.7-6.8 s each) for 1-3 queued writes to give their RAM back; the low-priority writer ran at 60-70 MB/s while the prompt read kept the CPUs busy. make_ram() now drops the queued images first (they leave RAM at once, as before the disk tier) and waits only for the one being written. ConversationDiskTier::drop_queued(), "dropped for RAM" in the totals. conversation_disk_test: 79 checks (the queued images go, the one being written stays). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit f0ed90e Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 14:21:11 2026 +0300 conversation disk: the request line says a prompt reused from the disk, with the read plus the restore "prompt N tokens = R reused + F read in ..., 3 checkpoints, reused from the disk in 3.3 s" (at the end, so the server's ENGINE_REQUEST and the bench scripts still parse it); the restore line adds ", from the disk in X ms with the read". The time was already in the prompt time, unnamed: an 84K-token hit on 2x CMP 50HX took 2747 ms to read and 572 ms to restore. Log only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit 1d52b02 Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 13:53:32 2026 +0300 conversation disk: refuse a payload that is not the conversation its index names take() restored the image with the index's keys and resume decision without checking that the session files hold those keys. Now the payload's live and checkpoint token ids, image keys and control-vector flag must equal the index's, in every stage; otherwise the read fails and the prompt is read cold. Regression check: a's valid image copied under b's equal-length index is refused (fails without the check). conversation_disk_test: 73 checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit eb2493b Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 12:41:43 2026 +0300 serve: --conversation-disk-persist, parked conversations outlive the engine Who it is for: whoever does not keep Strata running all day - start it, continue yesterday's long session, stop it - and whoever restarts the server (an update, a settings change, a reboot). Without it every restart reads every conversation from token 0 again; with it the next start continues from the disk in seconds, paid for with disk space. - ConversationDiskTier, persist: files bound to the session files' identity (model fingerprint, engine version, settings) instead of a random number of the run; a small index per conversation (token ids, write time) lets the next start take them in; another identity's files, a damaged index and interrupted writes are removed at start; no time limit, the budget alone (least recently written first); the queue is written and the files kept at the end. - generate.cpp: the tier is created after the session identity; on QUIT the current conversation is parked and every parked one goes to the disk before the engine ends; the engine ignores SIGINT in this mode (the server's process group gets a terminal's Ctrl+C; the server's QUIT ends it after the save). - serve/server.py: with --conversation-disk-persist close() waits up to 300 s for that save (20 s otherwise). - docs/DETAILS.md: who it is for, how it works, the measurement. Measured on 2x CMP 50HX + 2x Xeon E5-2670 v2, NVMe, IQ3_XXS: the save at the stop 6.8 s (a 165,557-token current conversation and two parked 42K ones; 2,639 MiB written at 956 MB/s); the next start took in 3 conversations (4,311 MiB), refused none; all three came back from the disk with the same greedy answers as before the restart, the 165K one in 6.7 s instead of 77.6 s. conversation_disk_test 67 checks (restart cases included), 5 ThreadSanitizer runs with no race; conversation_cache_test 4214; serve.test_server 291 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> commit 2036949 Author: xrip <xrip@xrip.ru> Date: Sun Oct 11 11:12:22 2026 +0300 serve: parked conversations on the disk (--conversation-disk-dir, opt-in) A conversation the RAM conversation cache evicts (slots or byte budget full) was dropped, so a client coming back to it read its whole prompt again; an agent alternating between its main conversation and sub-agents reads ~150K tokens again on every switch once two of them do not fit the budget together. With --conversation-disk-dir DIR the evicted conversation is written to DIR and restored when a request continues it. - ConversationCache::set_on_evict: evict_oldest hands the image to a callback instead of only dropping it (the RAM cache is otherwise unchanged; drop_superseded and take do not spill). - ConversationDiskTier (conversation_disk.hpp/.cpp, host code only): one session file per image of a layer split (session_file_write / session_file_read: the same checks and the --session-min-free-mib preflight), the live state and the deepest checkpoint only, identity = a random number of this run; a background writer thread (nice 10 on Linux) so a switch never waits for the disk; a queued image comes back from RAM; --conversation-disk-ttl-min (default 30) and --conversation-disk-mib (default 32768, oldest out); its files are removed at start and at the end; a diagnostic line per queue, write, hit and wait and a totals line after every hit. - generate.cpp: the tier beside the RAM cache; make_ram() is the one "room in RAM above the floor" loop (wait for the tier's queued writes, then evict the RAM cache's oldest) for parking and for a read back; a longer prefix on the disk than in the session, a --batch slot or the RAM cache is restored through the same validation and restore. Off without the flag: the behaviour is unchanged. Measured on 2x CMP 50HX (layer split), 2x Xeon E5-2670 v2, NVMe, Flash-Next IQ3_XXS, one RAM slot: greedy follow-up answers after a restore from the disk equal the live conversation's (two 42K-token conversations; also via RAM). A 165,557-token conversation: cold read 77.8 s, back from the disk 6.4 s (read 3.6 s, 2,639 MiB at 761 MB/s; GPU restore 0.87 s). No file left after the engine ended. conversation_disk_test 55 checks (CPU only; also clean under ThreadSanitizer); conversation_cache_test 4214 and conversation_file_test 201 unchanged. docs/DETAILS.md: the section after the conversation cache's settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
I ended up combining MADV_COLD plus this PR for best of both worlds, if anyone wants to reference main...Interpause:fork-Strata:new-main but im not going to PR probably since it probably only helps a few people. |
|
Suggestion from testing: would it be possible to evict slots straight to disk when insufficient RAM? To mitigate, I already maxed |
|
Thanks - I hit the same line here. In both of ours the telling part is 8552d06 (now in this PR) calls |
|
This would be an amazing feature. I only have enough SRAM and VRAM to support 1 large conversation, so there's LOTS of prefill time on my box when I run more than one conversation. |
|
Thanks - that is the case it is for: with |
8552d06 to
f731322
Compare
|
I cleaned up the branch for review: it is rebased onto the current main (no conflicts) and the 9 working commits are now 3, one per part:
At each commit If you want only part of it, 1 works alone, and 3 applies cleanly on 1 without 2. |
|
Following up with the log I mentioned. Self-hosted, single user, 2x RTX 3080 20 GB with The shipped session-file path is not usable on this box at all ( What the gate does on this boxOne 7.7 h window from the engine log, 551 requests, segmented at the start markers:
A representative line: Because the refusal happens before the snapshot is captured, there is nothing for the tier to take: all 109 refusals dropped the conversation, and the next request read the whole prompt again. 138 of 551 requests reused nothing, and those cold reads are ~85% of all prompt-read time (a 20-50K cold read is ~16 s, 50-80K ~23 s, 200K+ p50 81.5 s / p90 104.2 s). Alternating between 2-3 long sessions, one is parked (2.2 s to switch back) and the rest pay full prefill on every turn. The 109 refused conversations are p50 4.03 GiB, p90 4.96 GiB images, i.e. roughly 2.3-2.8 s of reads on this NVMe. The ask
Two smaller questions while I am here:
One cost data point for the design: 109 parks in 7.7 h is 249.5 GiB of images, so a per-turn full-image write is a real amount of churn on a 256 GB consumer drive - the same pathology as #2264, on the RAM side. An incremental/delta write would matter for us eventually. For referenceThe design question - when an entry no longer fits, is it dropped or does it fall to a lower tier? - has a well-documented answer in the serving stacks, which is why I am asking rather than assuming:
|
|
Hardware: 2x RTX 3090 24 GB (NVLink bridge), Ryzen 9 3950X, 121 GB RAM, PCIe 4.0 x8 per card. Qwen3.8-Flash-Next unsloth UD-Q4_K_XL, Two rows for the thread: It works on a 2-GPU layer split. Most reports here are single-GPU or unload cases; the disk tier also behaves on a split engine. Same build with conversations kept in RAM vs parked on the disk: word-for-word identical on our exactness gate (10 greedy prompts, a 20K long gate, an image prompt, a 10-question verifiable probe). Resume latency: a 111K-token chat parked on disk came back in ~1.9 s against ~36 s re-reading it. An observation we cannot explain, from a different box — is this config expected to resume? 2x RTX 5060 Ti 16 GB layer split + RTX 2080 Super for vision, Ryzen 7 3700X, ~32 GB RAM, IQ3_XXS, |
|
/reopen |
|
Reopened, thank you. A maintainer will pick it up from here. If it conflicts with main, rebase it first; that speeds up review. |
6e71ac5 to
43b942b
Compare
…--conversation-disk-dir, --conversation-disk-persist) A conversation the RAM conversation cache evicts is written to --conversation-disk-dir instead of dropped, and read back when a request continues it (the session files' writer and reader, a background writer, every checkpoint kept). --conversation-disk-persist on|exit-only binds the files to the session files' identity so they outlive the engine; QUIT saves the current and the parked conversations, and the engine ignores SIGINT/SIGTERM/SIGHUP while it does. conversation_disk.cpp is in the CUDA/HIP, SYCL and Metal engine lists. Docs: docs/DETAILS.md. Rebased onto 0.2.0-beta (f57c0ad) as one commit; the streamed park/restore of the earlier version is left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ding the tokens it adds A new conversation that shared only the system prompt with a saved one read the saved one's whole file: on 2x CMP 50HX 6,033 MiB for 16,384 tokens, 11.4 s from the NVMe against ~8 s to read the tokens (four such reads in a day's log; from a hard disk the file would take ~37 s). The tier's match reports its files' bytes and whether it is still queued (in RAM). generate.cpp measures the prompt path's own speed on its parts of 2,048 tokens or more and skips a file when reading it (this run's measured disk speed) is not faster than reading the tokens it adds, with a "not read" line giving both estimates. Until both speeds are measured the file is read, as before. docs/DETAILS.md: one bullet. conversation_disk_test 69 (the match's bytes and queued flag), conversation_cache_test 4772, conversation_file_test 201; strata builds (CUDA 13.3, sm_75, GCC 14). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43b942b to
c6b9997
Compare
|
Updated on 2026-10-20. What changed since you last looked:
Now ready for review: the same disk-tier code has run live on my box all day (the measurements above), and HIP / SYCL / Metal / Windows stay under "Not run" because I have no environment for them. |
|
Thanks for bringing this back to V0.2. Is it possible to write/read from disk directly, without reserving slot in RAM. This is useful when the system RAM is all used for model or other shared task. |
…onversation-disk-mib, --conversation-disk-persist) Port of upstream PR Niko1221#1937 (xrip, commits 65a79b5 and c6b9997) onto the fork: the RAM conversation cache's evictions and the parks over its budget go to session files in DIR (background writer, budget, oldest out), and a request that continues one reads it back; with --conversation-disk-persist the files outlive the engine, bound to the session files' identity (model, engine version, settings), and files of another identity are removed at start. A file is read back only when that is faster than reading the tokens it adds. Conflict resolved against the fork's Niko1221#2255 pinned-prefix eviction: evict_oldest keeps pick_victim and hands the evicted image to the on_evict hook; the over-budget test keeps can_make_room.
|
A data point from the SYCL side (Arc Pro B70), plus a port, since the tier is not reachable on the Intel engine as it stands. The SYCL engine does not get the wiring
PortI ported the wiring to
It is the same 12 pieces: options, usage, parse, the persist check, the tier and
Measured (SYCL, Arc Pro B70 32 GB, i7-12700K, 61 GiB RAM, NVMe ext4, Ubuntu 26.04, kernel 7.0
|
| step | result |
|---|---|
| 160,000-token conversation, turn 1 cold | TTFT 139.8 s |
| turn 2, live | TTFT 2.4 s |
| server SIGTERM → QUIT save | saved for the next start in 4.6 s (4,817 MiB at 1,840 MB/s) |
| next start | 1 conversations from earlier runs (4817 MiB) |
| the same turn 2 again | TTFT 4.3 s: disk hit: 160142 tokens, 4817 MiB in 1 file, 3552 ms (1422 MB/s), restore 433 ms, 7 tokens read, answer byte-identical to the live turn 2 |
| three other 32K conversations, then turn 3 | it was evicted to the disk (written in the background) and came back in 5.0 s TTFT (reused from the disk in 4.1 s) |
through systemctl --user restart (unit TimeoutStopSec=90) |
saved at stop, taken in at start, reused with the same answer |
The restore after the restart came from a checkpoint (160,142), not from the saved live state, because the repeated turn 2 diverges from the saved conversation after its user message. So the checkpoint path is exercised, not just the exact-match one.
I re-checked on this same branch (0.2.1 base, c6b99976 plus the port, engine 0.1.46, AOT bmg-g31 build with 0 errors), with a shorter run. A 32,000-token conversation took 24.2 s cold. After a server restart, the same turn came back from the disk in 1.2 s TTFT with a byte-identical answer (saved for the next start in 1.1 s, 1,145 MiB). After three other conversations evicted it, it came back from the disk in 2.1 s.
Merge note for main
Against current main the only conflict is one hunk in serve/server.py: the engine-priority block (engine_nice) that landed next to your quit_s lines. Keeping both sides works.
Happy to re-run on the B70 if the design changes, or to test whichever disk design gets picked (#1090).
|
Thanks for the disk tier. We run a backport of an earlier revision of this PR (047192e, with the streamed park and restore) on RX 7900 XTX machines. One problem we hit is still present at the current head, and we have a small fix to offer. Two waits have no beat and no bound (line numbers at c6b9997):
What we saw (on the 0.1.44.1 backport, all on one DRAM-less NVMe whose plain
The fix (one commit on c6b9997; the patch is below):
Happy to adjust it, split it, or turn it into review suggestions, whichever is easiest for you. The patch (one commit on c6b9997,
|
…ck for agents' heavy jobs, waking cut-off sessions An experimental fork of Strata 0.1.44.1 for coding agents that also run heavy jobs on the same PC. Developed and used on Windows 11 (native) with an NVIDIA card. See FORK.md. - Upstream PR Niko1221#1937's persistent conversation disk tier (xrip), with additions: delta files, parallel reads, a max age, lean reads under tight RAM. - Hibernate/resume: POST /unload {"save": true} saves the conversations and frees RAM and VRAM; keep_gpu frees only the RAM (Windows); save_on_close. - A lease lock: tools/strata_suspend.py run -- <job> takes a lease renewed every 10 s; while any lease is live nothing loads the model and requests wait; a killed wrapper takes its job with it and its lease expires. - Waking cut-off opencode / Claude Code sessions at the next start, from expired leases and the last checkpoint. - Self-restart (tools/strata_service.py, POST /restart), model-only API keys, web chat additions (/convert, detached runs, queue, Compact, subagents, shared chat store), an opencode plugin and commands, faster engine start on Windows. Credits: the disk tier is xrip's work (upstream Niko1221#1937); review fixes by midhatn and K8nsch. Co-authored-by: xrip <xrip@xrip.ru> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I have a follow-up that I would like to scope before opening a PR: versioned codecs for state stored in disk session snapshots. The local implementation has two opt-in pieces:
The records carry explicit codec descriptors; existing v1 files remain readable, and the default behavior is unchanged. Prefix snapshots are excluded from RHT. I have host round-trip/compatibility tests and a same-version restart restore test on a V100. The RHT piece also covers delta records, so it needs an agreed delta representation as a prerequisite. Would you prefer this as one follow-up PR after the disk design and delta format are settled, or as two PRs (REF first, then RHT)? If useful, I can prepare a stacked draft, but I do not want to duplicate the base disk-tier work here. |
Thanks, this helps with scoping. My numbers: RX 7900 XTX (HIP), --kv int8, 786 disk writes from three ~20 h agent soaks (median ~118K tokens). File size fits ~350-550 MiB fixed + ~17-19 KiB/token, so ~2.4-2.9 GB at 118K. The fixed part is 14-22% of bytes; the rest is int8 KV, which neither codec touches. That share caps what REF can save for us. REF is the half I’d adopt, since it’s lossless. Two PRs, REF first, would be easiest to test. Two questions: Is the fixed part the recurrent state plus checkpoint copies? On your workload, how many checkpoints per record does REF dedupe, and what are the bytes per record before and after? I'll run a REF draft on HIP and report bytes per park, write time and restore equality. One request: the codec write path uses the queued writes behind the two unbounded waits I raised (make_ram’s wait_idle(), take()’s in-flight wait; [link]). I know dropping queued writes was tried and reverted in 3fe88e6, so I’m not asking for that. Could the codec work stay inside a bounded wait, or leave room for the fix I offered? |
…on is superseded by its shared tokens drop_superseded (Niko1221#342) drops a parked copy only when the outgoing chain holds that copy's deepest checkpoint, so a copy whose last turns the client rewrote stays parked once the outgoing chain's spread checkpoints no longer hold a checkpoint near its end: it takes the cache's byte budget and is evicted in place of another conversation (or, with a disk tier, written out). Measured with the disk tier of PR Niko1221#1937 on 2x CMP 50HX: copies of one 120-326K-token conversation (120,236 tokens rewritten after 116,787; 248,844 after 246,337) were written to the disk at the next park, which waited 41.4 s for those writes. superseded_at() keeps the deepest-checkpoint rule and otherwise drops a copy that is shorter than the outgoing conversation with at most a tenth of its tokens not shared. An entry without checkpoints, a conversation sharing only the system prompt, and an equal or longer entry are kept. parked_prefix_tokens() reports by the same rule. conversation_cache_test: 4774 checks (the new case with the outgoing chain thinned to the root). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7518f3b)
…isk, the older-copies rule; FORK.md rewritten The fork rebuilt as upstream 0.2.7 plus one patch (the old history: tag fork-main-2026-10-28). - --conversation-disk-dir / -mib / -persist off|on|exit-only (upstream PR Niko1221#1937), carried onto 0.2.7's park_source: a park the RAM budget refuses goes to the disk; make_ram waits for the queued writes; a disk file is read back only when faster than reading its extra tokens; QUIT saves for the next start; the server waits 300 s for it. - An older, shorter copy sharing >= 90 % of the outgoing conversation's tokens is superseded (upstream PR Niko1221#3404). - The Monitor's cache card shows the disk tier. Dropped as leftovers: the tokenizer text cache (upstream's piece cache and PromptEncoder cover it), the prefill_mmq_bench tool, docs/AVX1_SM75_PLAN.md, dual3090/verify (test tools, now in ~/opt-avx1 on the box), the Monitor's prompt time-left. Measured 2026-10-28 (64K cold read / decode at 8K): the fork on 0.2.4.2 30.8 s / 92 tok/s, clean upstream 0.2.7 with these settings 30.5 s / 92 tok/s, with setup's defaults 43.7 s / 79 tok/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…isk, the older-copies rule; FORK.md rewritten The fork rebuilt as upstream 0.2.7 plus one patch (the old history: tag fork-main-2026-10-28). - --conversation-disk-dir / -mib / -persist off|on|exit-only (upstream PR Niko1221#1937), carried onto 0.2.7's park_source: a park the RAM budget refuses goes to the disk; make_ram waits for the queued writes; a disk file is read back only when faster than reading its extra tokens; QUIT saves for the next start; the server waits 300 s for it. - An older, shorter copy sharing >= 90 % of the outgoing conversation's tokens is superseded (upstream PR Niko1221#3404). - The Monitor's cache card shows the disk tier. Dropped as leftovers: the tokenizer text cache (upstream's piece cache and PromptEncoder cover it), the prefill_mmq_bench tool, docs/AVX1_SM75_PLAN.md, dual3090/verify (test tools, now in ~/opt-avx1 on the box), the Monitor's prompt time-left. Measured 2026-10-28 (64K cold read / decode at 8K): the fork on 0.2.4.2 30.8 s / 92 tok/s, clean upstream 0.2.7 with these settings 30.5 s / 92 tok/s, with setup's defaults 43.7 s / 79 tok/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Pallav0099 thank you for the patch, the soak numbers and the clear write-up. The maintainer has not yet chosen which disk design, if any, goes into the main branch. This PR is one of several, and it has had no review since 0.2.0. Until that choice is made, I don't think it makes sense to keep working on this PR or to take patches into it: work on a design that may never be merged is likely wasted, for you and for me. The same goes for follow-ups built on top of it, such as the snapshot codecs above. If the maintainer picks this design, your bounded waits will be the first thing I look at. Until then, your patch stays here in the thread, and keeping it in your backport is the right place for it. |
|
@Pallav0099 — thanks. Two answers, and one correction on where our codec work lives. Where it lives: the codecs are not stacked on this PR. Our disk tier is an integration of #1271, #1269 and #1480 (plus the delta/LRU work), carried in #4226. So your REF draft here and our work there are independent of the design choice xrip described. 1. Fixed part and REF dedupe. Yes — the fixed part is the per-checkpoint running state (five GDN recurrent arrays plus the RAW convolution state); the int8 K/V grows with tokens and is what REF cannot touch. In our largest prefix sample (Flash-Next UD-Q4_K_XL, int8 K/V, V100): 278,223,720 B without REF → 160,160,728 B with REF. The 118,062,992 B saved is exactly one complete state copy (117,669,888 B, convolution included). Caveat: only the REF file was measured; the no-REF side is format arithmetic. REF only dedupes checkpoint arrays byte-identical to the live copy, so your 14–22% fixed share is the right ceiling — if your checkpoints differ from live even slightly, it saves less. We did not count matches per record beyond this sample. 2. RHT+INT16 is not exact. We have state-fidelity numbers only: relative L2 ≈ 1.9e-5 for RHT+INT16 vs ≈ 2.1e-4 for FP16 over 141,557,760 real FP32 values. We have no greedy, same-expert-placement A/B of restored answers, so by your test (exact equality) keep it off — we would too. On the bounded waits: the 250 ms beat plus a bound is a good rule beyond this PR. Our disk-only save joins a background writer at a cache boundary with no bound (DiskSaveJob in generate.cpp), so a slow drive can block the request thread the same way. We are not porting your patch — the implementations differ — but if we add a bound we will follow the same rule. |
|
@xrip thanks for this, we run it daily (Swift 1.5 IQ3_XXS on a 3090). We rebased it onto v0.2.7.1 for our own use; the conflicts were the park path in generate.cpp (vs the new ParkSource), quit_s vs engine_nice in server.py, and the conversation_cache.hpp include. If it saves you time, the resolved branch is https://github.com/jaylfc/strata-12gb/tree/port-1937-0271 (2 commits on v0.2.7.1, conversation_disk_test 69 checks pass). After a restart, a continuation reused 11524 of 11623 tokens from the disk: prompt 1.3 s vs 6.6 s cold. |
Problem and result
A conversation the RAM conversation cache evicts (its slots or its byte budget full) is dropped, and a client that comes back to it reads the whole prompt again: an agent that switches between its main conversation and sub-agents too large for the budget together reads ~150K tokens again on every switch. Every restart of the engine (an update, a settings change, a reboot, a poweroff) also reads every conversation from token 0 again.
--conversation-disk-dir DIR: the evicted conversation goes toDIRand comes back from there when a request continues it. With--conversation-disk-persist on|exit-onlythe conversations outlive the engine: the same 165,558-token conversation came back after a restart in 8.4 s, with the same answer.Related discussion
The maintainer said in #1090 that one disk-persistence design will be chosen; this is one of them. It was closed in the 0.2.0-beta reset and is rebased here onto 0.2.1. #3404 (separate, in the conversation cache) drops stale parked copies of a conversation, which the disk tier would otherwise write out.
Approach
Off by default (opt-in options, help text and
docs/DETAILS.md):conversation_disk.hpp/.cpp: the tier. Each image (one per stage with--layer-split) is one session file, written and read with the session files' own code (size, header and hash checks, the free-space preflight). A background writer (nice 10 on Linux) means a switch never waits for the disk, and queued images can be read back from RAM. Every checkpoint is kept, as in RAM.--conversation-disk-mib N(default 32768): oldest out first.conversation_cache.hpp:set_on_evict, so an evicted conversation goes to the tier instead of being dropped. A park that does not fit the cache's byte budget is also sent to the disk.generate.cpp: options, wiring, the read back when the disk holds a longer prefix than the session / a batch slot / the RAM cache (it needs RAM for the whole image above--conversation-cache-min-free-mib, else the prompt is read again), and the request line says when it was reused from the disk.--conversation-disk-persist on|exit-only: the files are bound to the session files' identity (model, engine version, settings), with an index per conversation, so the next start takes them in. Files of another identity are removed at start. On QUIT the engine saves the current conversation and the parked ones; while persist is on it ignores SIGINT/SIGTERM/SIGHUP and ends through its stdin, so a poweroff's SIGTERM does not cut the save short. The server waits up to 300 s for QUIT (serve/server.py, 4 lines).exit-onlywrites nothing while running and saves only at QUIT.not read. Second commit.conversation_disk.cppis added to the CUDA/HIP, SYCL and Metal engine lists (all three compilegenerate.cpp).Compatibility: with no
--conversation-disk-dirnothing is written or read. One change stays in the parking path: before the RAM admission evicts a conversation, it calls glibc'smalloc_trim(0), so freed images count in MemAvailable. Output is unaffected.Validation
Passed:
90767252) as two commits:65a79b51(the tier) andc6b99976(the read check). CUDA build in Docker (nvidia/cuda:13.3.0-devel-ubuntu24.04, GCC 14,-DCMAKE_CUDA_ARCHITECTURES=75 -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=ON -DSTRATA_BUILD_CONVERSATION_TESTS=ON):strataand the tests build;conversation_disk_test69 checks,conversation_cache_test4,772,conversation_file_test201, all pass.python -m pytest -q serve/test_server.py serve/test_parallel.py serve/test_parallel_yield.py serve/test_request_accounting.py: 392 passed, 3 skipped, 444 subtests passed (Windows 11, Python 3.14).342185bd: that is 0.2.0-beta plus this disk tier, plus one diagnostic log line and fork-only serve/tool patches (the disk-tier code is otherwise this commit). Box: 2x CMP 50HX 20 GB (sm_75, layer split), 2x Xeon E5-2670 v2, 64 GB, NVMe ext4, Ubuntu 24.04.4, driver 610.43.03, Flash-Next IQ3_XXS;exit-only,--adapt-swaps 0, greedy, 48 tokens.on(my fork on 0.2.1 with both commits and [Engine] conversation cache: drop an older copy of the conversation rewritten turns back (#342 follow-up) #3404): a 304,364-token conversation came back from the disk in 47.6 s (5,465 MiB read in 44.1 s, then the restore) and a 286,199-token one in 41.7 s, where reading them takes ~140 s.not read: its 16384 more tokens read in ~9.4 s, its file (2018 MiB) in ~12.5 s. On the NVMe days the same case read whole files: four reads of 4-6 GB for 13,764-16,384 tokens (6,033 MiB in 11.4 s for 16,384 tokens).Failed: the same end-to-end check with adaptive expert swaps on (the live config) gave one restored conversation a different first token. Swaps make decode non-repeatable on this box even without a restore; with
--adapt-swaps 0the check passed (above).Not run:
Limits