Skip to content

feat(inkling): reuse the attention state a previous turn already built - #786

Merged
JustVugg merged 6 commits into
devfrom
feat/inkling-kv-prefix
Aug 2, 2026
Merged

feat(inkling): reuse the attention state a previous turn already built#786
JustVugg merged 6 commits into
devfrom
feat/inkling-kv-prefix

Conversation

@JustVugg

@JustVugg JustVugg commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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 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.

KV prefix reuse
colibri.c (GLM) #639
inkling.c ❌ → this PR
kimi_k3.c ❌ → next PR
DeepSeek engine ❌ → local

It was found by auditing what colibri.c has that the others do not — the same shape as #707 (the Apple OMP exclusion landed in inkling.c and never reached colibri.c) and #718 (physical-core sizing added to kimi_k3.c and olmoe.c and not to 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, 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.h records 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.h is 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):

reuse            45 of 55 prompt tokens (82%)
turn 2 cached    61.2s
turn 2 cold     320.0s
speedup         5.23x
identical       YES

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.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 length would let through
  • a prompt shorter than the record (the state cannot be rewound)
  • a prompt equal to the record (nothing left to prefill, so no final hidden state to sample)
  • a write past the end of the buffer — drops the record rather than truncating it, because claiming coverage the state does not have is the one failure that answers wrongly
  • taint, and a NULL record

It builds without a checkpoint, so CI carries it on every platform. make check picks it up automatically via the derived TEST_BINS (#733).

🤖 Generated with Claude Code

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>
@JustVugg

JustVugg commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

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

tests/test_inkling_prefix_serve.py, wired into the existing Inkling-oracle job:

  • 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
  • a diverging prompt must not be contaminated by the turn before it

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 benchmark

I 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:

16,384 experts x ~28 MB          = no useful cache in 24 GB of RAM
one token, minimal prompt        = 79 GB read in 9 minutes, unfinished
cap=8 (the practical maximum)    TIERS 0 0 16384 0.00 0.00
cap=64 would need                117 GB

I built a pristine origin/dev inkling and it hit exactly the same wall, at the same point, so the behaviour is the hardware and not this PR.

Why this gate has to exist

A 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:

decision logic, every rejection rule tests/test_kv_prefix.c — no model needed
token-identity, end to end tests/test_inkling_prefix_serve.py — CI fixture
speed, on hardware that can show it DeepSeek V4: 5.23x, 82% of prompt reused, output byte-identical

JustVugg and others added 4 commits August 2, 2026 23:22
… 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>
@JustVugg JustVugg added bug Difetto verificato nel codice performance Velocità / tok-s / ottimizzazioni labels Aug 2, 2026
@JustVugg
JustVugg merged commit 846b148 into dev Aug 2, 2026
13 checks passed
ZacharyZcR pushed a commit to ZacharyZcR/colibri that referenced this pull request Aug 5, 2026
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>
dpanelli added a commit to dpanelli/colibri that referenced this pull request Aug 5, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Difetto verificato nel codice performance Velocità / tok-s / ottimizzazioni

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant