Repository navigation
Conversation
Every commit files the conversation's SSD write on the idle lane, which runs only in a quiet window. Two clients taking turns on one server leave none: one client's request is always queued behind the other's. When a request does not fit, the admission's last step releases the idle conversation and cancelled that queued write, so the conversation existed only in RAM and was lost. Its next turn re-read everything. A field log of two coding agents on a 27B: seven idle sessions of 7.5 to 9.9 GB released with on_ssd_entries 0, each followed by a re-read of 80K to 103K tokens (4 to 6 minutes) that the other agent then waited behind. - SessionBank.release_sessions(write_out_before_release=True) writes each released entry whose own SSD write is still queued (not published, not a lease) to the cold tier first, on the model-owner thread, while the release holds the session. The write streams through spill_entry whatever the entry's size: one tensor's bytes at a time, so it adds little to the memory being given back. The queued job is cancelled afterwards as before, which frees its arrays. - The write is bounded by RELEASE_WRITE_OUT_MAX_S (30 s for the whole release). A write that runs out of time, that the tier refuses (hourly budget, disk room, size cap) or that fails leaves the old behaviour: the entry is dropped. - spill_entry takes give_up_at_s: a caller that is itself the foreground work passes a deadline, which replaces the foreground signal (that signal is up during any request, so the idle-lane yield would stop the write at its first tensor). - Only the admission's idle-conversation step opts in. The shed before a prefill abort, the request's own siblings, the pressure trim and the allocation-failure shed keep cancelling: they run when memory is shortest, or off the model-owner thread. - The release receipt and the guard's idle_release carry written_out_entries and write_out_s; the eviction log records each write's outcome and time (release_write_out). With the SSD cache off, or a tier without the deadline, nothing changes. Tests: tests/test_release_writes_out_unwritten.py (10 tests, real SessionBank and SessionBankColdTier, the engine busy).
jvmenen
pushed a commit
to jvmenen/MTPLX
that referenced
this pull request
Oct 10, 2026
…ll before release, youssofal#617 waits); slow cached requests report, VONDSTEN 128-129 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A client's side call (a title or summary request) can arrive under its conversation's session id. The conversation's newest state waits in RAM, its SSD write queued behind turns a few seconds apart, so the idle lane never ran it. When the side call's prefill tripped the memory guard, the shed before an abort gave back the request's own entries it does not restore from: the conversation itself, with its queued write cancelled. Its next turn re-read everything. A field log on a 64 GB Mac with a 27B: an 8K side request released its 23K conversation's three entries (9 GB, nothing on disk), and the next turn took 69 s to its first token, 60 s of it prefill. - The prefill shed's idle-conversation release, the prefill shed's own-session release (_release_own_unusable_entries) and the admission's own-sibling step pass write_out_before_release=True, like the admission's idle step. - These all run on the model-owner thread: the admission and the per-chunk check both run inside the generation item. During the write the prefill waits between chunks, so the engine allocates nothing meanwhile, and the write streams one tensor at a time. - The shed receipt gains written_out_entries, write_out_s and own_session_written_out_entries. - Unchanged: the pressure trim and the allocation-failure shed. Tests: tests/test_release_writes_out_unwritten.py gains three: a side request's own-session release writes the conversation out, the prefill shed (death signature) writes out an idle and the own conversation, and the admission's own-sibling step with the conversation and the request under one session id. All three fail on the previous commit and pass here.
jvmenen
pushed a commit
to jvmenen/MTPLX
that referenced
this pull request
Oct 29, 2026
… (a side request released its own conversation)
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
When two clients take turns on one server, each with a long conversation, the idle conversation's SSD copy is often never written. Every commit queues that write on the idle lane, which runs only in a quiet window, and with one client's request always waiting that window does not come. When a request then does not fit in memory, the admission releases the idle conversation and cancels its queued write, so a conversation that existed only in RAM is lost. Its next turn re-reads the whole conversation, 80K to 100K tokens and several minutes, and the other client waits behind that prefill.
The same loss happens inside one conversation. A client's side call (a title or summary request) can arrive under the conversation's session id while the conversation's newest state waits in RAM with its write queued. When the side call's prefill trips the memory guard, the shed before an abort gives back the request's own entries it does not restore from, which is the conversation itself. In a field log on a 64 GB Mac, an 8K side request released its 23K conversation (9 GB, nothing on disk) and the next turn waited 69 s for its first token.
How it works, in plain terms
Before the memory guard lets a conversation go, it checks whether that conversation's newest state is still waiting for its SSD write. If so, it writes it out right away, then frees the memory as before. The write streams one tensor at a time in units of at most 32 MiB, so it needs almost no extra memory: the arrays it reads are the ones about to be freed. It has a time limit; if the disk cannot finish in time, or the SSD cache refuses the write, the conversation is dropped as it was before. The next turn of that conversation restores from disk instead of re-reading it.
What changes
SessionBank.release_sessions(write_out_before_release=...);EngineSessionManager.release_idle_sessionspasses it through. The memory guard sets it wherever it releases on the model-owner thread: the admission's idle-conversation and own-sibling steps, and the shed before a prefill abort (idle conversations and the request's own entries). During that shed the prefill waits between chunks, so the engine allocates nothing while the write runs.SessionBankColdTier.spill_entry(give_up_at_s=...): a deadline replaces the foreground signal for a caller that is itself the foreground work. Advertised bySUPPORTS_SPILL_DEADLINE.RELEASE_WRITE_OUT_MAX_S = 30.0per release.written_out_entriesandwrite_out_s(the prefill shed alsoown_session_written_out_entries); the eviction log gainsrelease_write_out(outcome, time).Results
Checked for correctness on a 27B with segmented KV. Two long conversations (about 60K tokens each) took turns while two small clients kept the server busy, so the idle lane never ran, and the memory limit was set so that each turn had to release the other conversation. All four releases wrote the conversation first (
release_write_outoutcomewritten,on_ssd_entries 1,dropped_entries 0). The next turn of each released conversation restored from SSD (ssd_clone, 97 to 98 % of the prompt cached) instead of re-reading it. While each write ran, sampled every 20 ms, MLX active memory rose 143 to 262 MiB in three writes and 1.3 GiB in one, and the system's available memory did not drop by more than 0.2 GiB. Without the change, the same release cancels the queued write and the conversation's next turn re-reads it.The second commit (the prefill shed and the own-session release) is covered by the tests below; it has not been measured in a run like the one above yet.
Not changed here: the pressure trim can still drop an unwritten conversation (seen once in a run with a higher limit). It runs on another thread and is left for a separate change.
Tests
tests/test_release_writes_out_unwritten.py(13 tests, realSessionBankandSessionBankColdTier, the engine busy): the deadline against the foreground signal; nothing restorable left after a deadline; an unwritten entry is written before release and its queued job is gone; an entry pushed out of RAM with its write queued is written too; out of time it is dropped as before; without the flag the release still cancels; an entry already on disk is not rewritten; no SSD cache changes nothing; a busy session is skipped; the admission scenario, where the released conversation's newest entry is on SSD; and three side-request cases: the own-session release, the prefill shed on a death signature (an idle and the own conversation), and the admission's own-sibling step with the conversation and the request under one session id. The 8 release and admission tests fail on main, the three side-request tests fail on the first commit, and all pass with the change. Full suite passes.