feat(inkling): reuse the attention state a previous turn already built - #786
Conversation
A chat client resends the whole transcript every turn. colibri.c has pinned each conversation to a KV slot since #639, so its turn N prefills only the new text. inkling.c, kimi_k3.c and the DeepSeek engine were written without it and re-processed turns 1..N-1 from scratch: the cost of a message grew with the length of the conversation, and every replayed position pulled its experts off disk again. On a streaming engine that is the dominant cost, and it shows up to users as "it gets slower the longer we talk" rather than as a bug. Nobody had reported it because it does not look like a defect. It was found by auditing what colibri.c has that the other three do not — the same shape as #707 (Apple OMP exclusion applied to inkling.c and not colibri.c) and #718 (physical-core sizing added to kimi_k3.c and olmoe.c and not colibri.c). THE IDEA. After a turn the state covers some number of positions. If the next prompt BEGINS with the token sequence that produced them, that state already IS the state at that position: skip the reset and prefill only the tail. No snapshot and no rewind — a prompt that diverges anywhere starts over. So either the reused positions are token-identical or nothing is reused, and the emitted tokens are the same in both cases. WHY A RECORD AND NOT A COUNTER. Deriving the reusable length from the caller's bookkeeping ("prompt + generated - 1") is tempting and wrong: that invariant differs per engine — whether the last sampled token was fed back, whether a chunked prefill ran to completion, whether generation stopped early — and getting it wrong does not crash. It answers the user from a state built out of a different conversation, and the reply looks plausible. So kv_prefix.h records the ids WHERE THEY ARE FED and that record is the only description anyone consults. The failure mode stops being expressible. Audio taints the record. Every Inkling audio frame carries the same token id (c->audio_tok) while the mel payload differs, so an id-only comparison would happily match two different clips. A state that consumed audio, or a request that brings its own, is never reused. kv_prefix.h is shared rather than inlined per engine: one implementation to review, testable without a checkpoint, and ready for kimi_k3.c and the DeepSeek engine. MEASURED, on the DeepSeek V4 engine where the same mechanism landed first: a second turn reusing 82% of its prompt took 61.2s instead of 320.0s (5.23x), with output byte-identical to the same turn on a cold engine. tests/test_kv_prefix.c covers every rejection rule, including the ones that look paranoid: divergence at the first token, divergence at the LAST recorded token (the boundary an off-by-one memcmp would let through), a prompt shorter than or equal to the record, a write past the end of the buffer, taint, and a NULL record. It builds without a model, so CI carries it everywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit test covers the decision; this covers the consequence. Two turns on one engine, the second reusing the first's state, must emit the SAME tokens as that second turn alone on a cold engine — and a diverging prompt must not be contaminated by the turn before it. It runs against the tiny random-init fixture the oracle job already builds, so it needs no checkpoint. That is deliberate rather than convenient: the real 469 GB model cannot serve this test on a developer machine. Measured here, one token pulled 79 GB off disk in nine minutes without finishing, because 16,384 experts at ~28 MB each leave no useful cache in 24 GB of RAM — and raising the cap does not help, since cap=64 would ask for 117 GB. Verified the same wall on a pristine origin/dev build, so it is the hardware, not this change. Why it matters that this gate exists at all: a wrong reuse length does not crash. It answers the user from an attention state belonging to a different conversation, and the reply still reads plausibly. Nothing else in the tree would notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Update: the end-to-end identity test now runs in CI, so this PR no longer rests on a measurement I could not take. The test that matters
It runs against the tiny random-init fixture that job already builds, so it needs no checkpoint and no fast disk. Why it is a fixture and not a benchmarkI tried to take the real number on the 469 GB checkpoint and could not, and the reason is worth recording because it is a fact about the model, not about this change: I built a pristine Why this gate has to existA wrong reuse length does not crash. It answers the user from an attention state that belongs to a different conversation, and the reply still reads plausibly. Nothing else in the tree would notice — which is also why this defect survived in three engines without a single report. So the evidence now stands at:
|
… mode make_tiny_inkling.py emits weights and a teacher-forcing oracle but no tokenizer -- the oracle feeds token ids directly, serve mode goes through text, so the engine exited with 'tokenizer.json: No such file or directory'. The fixture is vocab_size=256 / unpadded 250, so one token per byte is not a simplification, it is the whole vocabulary. The GPT-2 byte->unicode map is reproduced here rather than imported, with its own assertions, so a drift from tok.h's tk_build_bytemap fails as itself instead of as an unreadable prefix-reuse failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught a defect the local box could not: the second turn never reused anything, because kv_alloc freed and re-allocated K/V whenever a longer prompt arrived — and a conversation's prompt is longer EVERY turn. The state was thrown away immediately before the point of using it, so prefix reuse could not fire in the one situation it exists for. It was invisible until now because each turn re-prefilled regardless, and because reaching a second turn on the 469 GB checkpoint takes longer than a developer machine can sustain (measured: 79 GB read for one token). The tiny CI fixture reaches turn two in seconds, which is exactly why that gate was added. K/V are laid out [kv_head][max_t][hd], so a larger max_t changes the stride and the contents cannot be realloc'd — they are re-laid-out head by head. That copy costs a memcpy of what is already computed, against re-running the prefill that produced it. kv_prefix_grow() preserves the record across the same operation, and returns 0 rather than leaving a stale one if its own allocation fails. The distinction matters and is now tested: kv_prefix_alloc RESTARTS (the caller discarded the KV), kv_prefix_grow PRESERVES (the caller copied it). Tests cover keep clamped above len, keep=0, and a cap smaller than what is held. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CI failure read '[PREFIX] reusing not found in ""' — an empty capture, which is indistinguishable from an engine that decided not to reuse and said nothing about it. Two changes so the next failure explains itself: - INK_PREFIX_LOG now reports the decision either way, with the state behind a 'no': held/cap/prompt, whether the record was tainted, whether it diverged. 'It did not get faster' is otherwise the same observation as 'reuse is not wired up', for a user as much as for this gate. - The test drains stderr on a thread from process start instead of reading it once at close(), and puts whatever it captured into the failure message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness was the fault, not the engine. Its diagnostics were right:
turn 1 no reuse: held=0 cap=36 prompt=24
turn 2 no reuse: held=27 cap=72 prompt=60 (diverged)
held=27 is exactly correct — 24 prompt tokens plus 3 of the 4 generated (the
last is sampled and never fed back). The bookkeeping worked.
The divergence was mine. A random-init model emits arbitrary byte sequences that
are mostly not valid UTF-8; decoding them with errors='replace' collapses them
to U+FFFD, and re-encoding that as the next turn's prefix produces entirely
different bytes. So the second prompt genuinely did NOT begin with the sequence
the state held, and the engine was right to refuse it.
Payloads now stay bytes end to end, with an assert on the prompt type so the
next person cannot reintroduce it quietly. Protocol lines are read as latin-1,
which is byte-preserving, rather than utf-8-with-replacement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeepSeek V4 was the only engine in the tree that re-prefilled the whole context on every request. colibri.c has done same-slot prefix reuse since it had a serve path; inkling.c (JustVugg#786) and kimi_k3.c (JustVugg#787) got it through the shared kv_prefix.h. This adds the third user of that header. The visible effect is the one people report as "the second message is slower than the first": turn ten was paying for turns one through nine again, with the dense tensors and expert cache already warm. ## Why the reusable case is narrow, and why that is enough The window attention state cannot be truncated to an arbitrary position. The sliding window is a ring, and the compressor carries recurrent kv_state/score_state rather than per-position rows -- unlike GLM's MLA rows, which are position-addressed and self-contained, so colibri.c can truncate to any shared prefix and even copy rows between slots. What this state can do is keep going. So the case handled here is the exact one a conversation produces: turn N+1's prompt begins with every id turn N fed, prompt and reply alike, and only the tail is new. kv_prefix_reuse returns 0 unless the record is a strict prefix of the new prompt, so an identical prompt, a shorter one, or a divergent one all fall back to a full reset and prefill. ## Correctness Positions stay absolute: the fresh tail is prefilled with start=reuse, so every token sees the same position it would have in a cold run. Only the batch indexing shifts, since the batch now holds the tail alone. The record tracks what was actually fed, not what was asked for. The last generated token is emitted but never fed back, so it is recorded inside the decode loop as each token enters the state rather than in bulk afterwards -- recording after the loop would claim one token too many and corrupt the next turn. Every failure path taints the record, because a half-updated attention state matches neither the old ids nor the new. kv_prefix_alloc failing is not an error. Per the header's contract it leaves the record empty, reuse returns 0, and every request prefills in full -- exactly the behaviour before this change. ## Tests tests/test_deepseek_v4_prefix.py drives the SUBMIT/DATA/DONE protocol directly and runs each second turn twice: once continuing a warm session, once against a freshly started engine. It asserts they agree token for token, so the test fails if reuse changes the output, and separately that reuse actually fired -- an optimisation that silently never engages would otherwise pass every correctness check. PASS prefix reuse: 11 tokens reused, output identical to a cold prefill PASS prefix repeat: identical prompt re-prefills, answer unchanged PASS prefix reset: divergent prompt re-prefills and matches cold Wired into `make deepseek-v4-tiny-check`; the existing 11 checks still pass. The DONE frame gained a trailing reuse count so the test can measure it; openai_server.py parses `len(fields) >= 7`, so older readers ignore it. V4_PREFIX_LOG=1 prints the reuse length per request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion) The JustVugg#786 grow assumes position t lives at row t; a wrapped ring stores it at row t % window, so the linear copy would silently rotate the cache. Branch on row count instead of layer type: a layer whose kv_ring_rows is unchanged has nothing to grow and cannot be copied linearly anyway, so its buffer is stolen wholesale (slot map unchanged, contents stay valid). Every layer that does reach the copy is provably linear: rows only change when old rows == old max_t, which includes the window >= max_t transition case where a linear sliding buffer grows INTO a ring. kv_prefix_grow tail preserved; reuse continues to work on Inkling. tests/test_kv_ring_grow.c pins the three cases: wrapped ring stolen intact across a grow, global layer re-laid-out at the new stride, and the transition copy. Also passes the tiny-oracle harness on dev (36/36 teacher-forced, 24/24 greedy). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01828WtkfFqicywaZLZu1139
A chat client resends the whole transcript every turn.
colibri.chas pinned each conversation to a KV slot since #639, so its turn N prefills only the new text.inkling.c,kimi_k3.cand the DeepSeek engine re-process turns 1..N-1 from scratch, so the cost of a message grows with the length of the conversation — and every replayed position pulls its experts off disk again, which on a streaming engine is the dominant cost.Nobody reported this because it does not present as a bug. It presents as "it gets slower the longer we talk", and on an engine that streams a 469 GB model from disk, that reads as normal.
colibri.c(GLM)inkling.ckimi_k3.cIt was found by auditing what
colibri.chas that the others do not — the same shape as #707 (the Apple OMP exclusion landed ininkling.cand never reachedcolibri.c) and #718 (physical-core sizing added tokimi_k3.candolmoe.cand not tocolibri.c).The idea
After a turn the state covers some number of positions. If the next prompt begins with the token sequence that produced them, that state already is the state at that position: skip the reset and prefill only the tail.
No snapshot, no rewind. A prompt that diverges anywhere starts over. Either the reused positions are token-identical, or nothing is reused — the emitted tokens are the same in both cases.
Why a record and not a counter
Deriving the reusable length from the caller's bookkeeping (
prompt_count + generated - 1) is tempting and wrong. That invariant differs per engine — whether the last sampled token was fed back, whether a chunked prefill ran to completion, whether generation stopped early — and getting it wrong does not crash. It answers the user from an attention state built out of a different conversation, and the reply looks entirely plausible.So
kv_prefix.hrecords the ids where they are fed, and that record is the only description of the state anyone consults. The failure mode stops being expressible.Audio taints the record. Every Inkling audio frame carries the same token id (
c->audio_tok) while the mel payload differs, so an id-only comparison would happily match two different clips. A state that consumed audio — or a request that brings its own — is never reused.kv_prefix.his shared rather than inlined per engine: one implementation to review, testable without a checkpoint, and ready for the two engines that follow.Measured
On the DeepSeek V4 engine, where this mechanism landed first (local work, not in this PR):
The speedup tracks the ratio of positions still to prefill, so it grows with conversation length.
Full disclosure on this PR specifically: the equivalent end-to-end identity run on the 469 GB Inkling checkpoint is still going on my box — two engine loads at once put the machine into OOM twice, which is a hardware limit here, not a code path. I will post the numbers under this PR when it lands. The correctness argument does not rest on that run: it rests on the structure above and on
tests/test_kv_prefix.c.Tests
tests/test_kv_prefix.ccovers every rejection rule, including the ones that look paranoid:memcmplength would let throughIt builds without a checkpoint, so CI carries it on every platform.
make checkpicks it up automatically via the derivedTEST_BINS(#733).🤖 Generated with Claude Code