Skip to content

Write an idle conversation to SSD before the admission releases it - #618

Open
jvmenen wants to merge 2 commits into
youssofal:mainfrom
jvmenen:fix/spill-before-idle-release
Open

jvmenen wants to merge 2 commits into
youssofal:mainfrom
jvmenen:fix/spill-before-idle-release

Conversation

@jvmenen

@jvmenen jvmenen commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

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_sessions passes 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 by SUPPORTS_SPILL_DEADLINE.
  • RELEASE_WRITE_OUT_MAX_S = 30.0 per release.
  • Receipts gain written_out_entries and write_out_s (the prefill shed also own_session_written_out_entries); the eviction log gains release_write_out (outcome, time).
  • Unchanged: the pressure trim, the allocation-failure shed, and everything when the SSD cache is off.

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_out outcome written, 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, real SessionBank and SessionBankColdTier, 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.

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
jvmenen requested a review from youssofal as a code owner October 10, 2026 13:49
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant