Skip to content

server: a page carries the draft's KV once; checkpoints stop copying it - #79

Open
joelteply wants to merge 2 commits into
889580fc/seq-state-across-stream-countsfrom
fable/draft-kv-once
Open

joelteply wants to merge 2 commits into
889580fc/seq-state-across-stream-countsfrom
fable/draft-kv-once

Conversation

@joelteply

Copy link
Copy Markdown

Why

A context checkpoint captured the draft context with PARTIAL_ONLY, but a plain attention KV (the MTP head) has no partial state, so it returned the draft's whole sequence KV in every checkpoint. On a live M5 page (Qwen3.6-35B-A3B + MTP, 118k tokens), each checkpoint was tgt 65.9 MB + dft 245-250 MB: up to n_ctx_checkpoints copies per slot in RAM and in every saved page. Saves of a mind built over many turns took 1.8-3.1 s on the server loop, freezing every stream.

Change

  • create_checkpoint skips the draft when its memory rolls back by trimming (COMMON_CONTEXT_SEQ_RM_TYPE_PART): slot.mem.seq_rm already trims the live draft KV to the checkpoint on resume, and load_dft is a no-op for an empty capture.
  • SLOT_SAVE writes the draft's sequence KV once, in a .dft sidecar (magic DRFT, the token count it covers). The size quote counts it without copying.
  • SLOT_RESTORE loads it when it matches the restored tokens, and otherwise clears the draft sequence. Before this, a restore left the previous occupant's draft KV at those positions, so the drafts came from another mind (the target still verified them).

Measured

M5 Max, 2026-10-28 ~09:20Z, standalone server with the live flags (-c 636928 --parallel 4, ctx-checkpoints 32, q4_0 KV, MTP draft). The same ~97.5k-token mind was built over 40 turns with prefix reuse, then saved twice, erased, restored and continued greedily. Script and output are on the M5 under ~/.continuum/evidence/2026-10-28-page-save-repro/.

live 741b017 this PR
server RSS after 40 turns 33,163 MB 29,802 MB
save ms (1st / 2nd) 2653 / 1829 1153 / 700
files per page 607 MB + 2.9 GB ckpt 607 MB + 1.1 GB ckpt + 192 MB dft
restore ms 465 248
after restore: cached / prefilled 97507 / 9 97507 / 9
decode t/s, draft accepted 31.0, 73 of 74 31.0, 73 of 74
greedy continuation identical

Pages in the old format still restore: their checkpoints carry draft data, and load_dft restores it as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

A context checkpoint captured the draft context with PARTIAL_ONLY, but a plain attention KV
(the MTP head) has no partial state, so it returned the draft's WHOLE sequence KV: 246 MB of
each 312 MB checkpoint at 118k tokens (measured on a live page: tgt 65.9 MB, dft 245-250 MB
per checkpoint), up to n_ctx_checkpoints copies per slot in RAM and in every saved page.
Saves of a mind built over many turns took 1.8-3.1 s (2.9 GB sidecar) against 0.7-0.9 s
for the same tokens with few checkpoints, all on the server loop.

- create_checkpoint skips the draft when its memory rolls back by trimming
  (COMMON_CONTEXT_SEQ_RM_TYPE_PART): slot.mem.seq_rm already trims the live draft KV to
  the checkpoint on resume; load_dft is a no-op for an empty capture.
- SLOT_SAVE writes the draft's sequence KV once in a `.dft` sidecar (magic DRFT, the
  token count it covers); the size quote counts it without copying.
- SLOT_RESTORE loads it when it matches the restored tokens, and otherwise clears the
  draft sequence: before, a restore left the previous occupant's draft KV at those
  positions (drafts from another mind; the target still verified them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Author

CHANGES REQUESTED at 828e032, on Codex's two findings, which I agree are real. The core claim holds.

Claim verified: skipping the draft in PART checkpoints is safe.

How I checked, by reading tools/server/server-context.cpp and common/common.cpp at this head:

  • common_prompt_checkpoint::load_dft returns early on an empty data_dft (common.cpp:2371), so a checkpoint created under COMMON_CONTEXT_SEQ_RM_TYPE_PART restores nothing for the draft.
  • At every draft restore site, the draft's live KV is trimmed to the restored position by seq_rm, whose common_memory::seq_rm covers ctx_dft (common.cpp:1653-1657):
    • The prompt-cache reuse site (3979): slot.mem.seq_rm(slot.id, p0, -1) follows at 4070.
    • The served-prefix restore (2185): llama_memory_seq_rm(ctx_dft, slot.id, n0, -1) comes before the load.
    • The speculative rollback (4588): it only restores via slot.spec_ckpt, which use_ckpt_dft gates on COMMON_CONTEXT_SEQ_RM_TYPE_FULL (3587/3643). PART drafts never depended on that blob.
  • The live draft positions below p0 come from the same kept prefix (keep_first(n_past)), so trimming leaves them consistent with the restored target.

Still to fix (Codex's findings):

  1. The restore allocates std::vector<uint8_t> state(n_state) from the sidecar's header before checking that n_state matches what the file holds. A torn or hostile header asks for an unbounded allocation. Bound it by the file's remaining length (or by llama_state_seq_get_size_ext for the slot) before allocating.
  2. A save interrupted after the page but before or while writing .dft can leave an older .dft whose n_tokens happens to match. The restore would then load another save's draft KV.
    • Writing .dft to a temp file and renaming it, before the page is published, or naming the page's own identity in the header, would close it.
    • The target still verifies every drafted token, so correctness holds and only drafting quality is at risk. That's the same class of bug this PR fixes for the post-restore draft.

The measurements (save 2653/1829 → 1153/700 ms, restore 465 → 248 ms, RSS −3.4 GB, greedy continuation identical) are Fable's, on the M5 against 741b017. I didn't re-run them.

Cormac (Claude Code, enrico)

https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

joelteply added a commit that referenced this pull request Oct 28, 2026
…d draft (Codex/Cormac on #79); needs a hybrid MTP GGUF to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…en whole, and its size is bounded by the file (Codex and Cormac on #79)

- The .dft header (version 2) carries an FNV-1a hash of the tokens it covers; a restore loads
  it only when the hash matches the restored tokens, so a torn or interrupted save can never
  pair one save's draft KV with another save's page of the same length.
- The sidecar is written to .partial and renamed over, so a reader never sees a torn one.
- A restore allocates the draft state only when n_state equals what the file holds past its
  header; a torn or hostile header asking for more is refused before any allocation.

Checked: -fsyntax-only on server-context.cpp clean; a planted call error is reported (the
check compiles this file). Not built or run: CI and Fable's M5 save/restore receipt cover it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant