Skip to content

feat: reuse KV cache across chat turns (recurrent / hybrid / SWA + multimodal)#367

Open
a-ghorbani wants to merge 7 commits into
mybigday:mainfrom
a-ghorbani:feat/kv-cache-reuse
Open

feat: reuse KV cache across chat turns (recurrent / hybrid / SWA + multimodal)#367
a-ghorbani wants to merge 7 commits into
mybigday:mainfrom
a-ghorbani:feat/kv-cache-reuse

Conversation

@a-ghorbani

@a-ghorbani a-ghorbani commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

This pr adds KV cache reuse across chat turns for model families that previously wiped it every turn, recurrent / hybrid / SWA + multimodal.

Closes #360


Vision reuse — image restored bit-exact after the media fix

The mtmd path checkpoints the image KV at the media boundary during ingest

The tests from run_vision_fidelity flow:

Turn 1: [image] + "Describe the animal in this image.", ingested with max_new=0
Turn 2: the same image re-sent + a new question "What color is its fur?".

Then the fixed probe ("The animal in the picture has fur and four legs…") is scored three times, all at Turn 2's context:
nll_reuse: with the checkpoint restore active
nll_cold1: clearCache() → re-ingest the identical Turn-2 history from scratch

Δ = |nll_reuse − nll_cold1| = diff of mean NLLs of a probe after a checkpoint-restore reuse vs a cold re-encode (≈0 = faithful; 0.000 noise floor, tol 0.15).

model · memory Δ reuse vs cold image reused ingest decodes last tok checks
smolvlm · dense 0.000 127/127 once (n_past==L) 27/27
gemma4 · SWA 0.000 119/118 once (n_past==L) 34/34
lfm2vl · hybrid 0.000 182/180 once (n_past==L) 28/28
qwen35 · hybrid + M-RoPE 0.000 58/86 once (n_past==L) 29/30 †
  • Bit-exact restore on every vision architecture: dense, SWA, hybrid and Qwen-VL M-RoPE
  • Double-decode removed. The old path re-decoded the last prompt token after ingest
  • Same processMedia feeds the slot/parallel path so it is verified end-to-end.
  • Confirmed on real hardware: Adreno SM8850, GPU-offloaded decode (real OpenCL, ~360 MiB resident): lfm2vl 28/28 at d=0.000, smolvlm 27/27 at d=0.004 (GPU-vs-host float noise). Per-arch Δ above is host CPU.
  • qwen35's lone miss is the <think>-strip text-reuse case (append turn 3 reused 32/71).

Correctness: reuse vs a cold recompute

Mean NLL after a reuse-restore vs a cold recompute (≈0 = faithful, run-to-run noise ≲0.03; corruption shifts ≫0.1).

model · memory CPU Adreno NPU MTP
mamba · recurrent 0.000 ✓
lfm2 · hybrid 0.020 ✓ 0.012 ✓ 0.011 ✓
granite4 · hybrid mamba2 0.003 ✓ 0.007 ✓ ✗ off
qwen35 · hybrid 0.009 ✓ 0.000 ✓ 0.016 ✓ 0.009 ✓
gemma4 · SWA 0.042 ✓ 0.001 ✓ 0 ✓
Performance: time-to-first-token, cache off → on (devices × backends)
device backend model warm append regenerate new session
Pixel 9 CPU lfm2 441ms→445ms · 1.0× 8.5s→442ms · 19× 2.9s→367ms · 8.0×
Pixel 9 CPU granite4 517ms→529ms · 1.0× 9.2s→544ms · 17× 3.2s→469ms · 6.9×
Pixel 9 CPU qwen35 73.4s→17.6s · 4.2× 101.7s→10.0s · 10× 35.4s→3.4s · 10×
Pixel 9 CPU gemma4 5.7s→5.7s · 1.0× 366ms→354ms · 1.0× 3.0s→2.8s · 1.0×
MT6899 CPU lfm2 342ms→344ms · 1.0× 7.0s→351ms · 20× 2.4s→217ms · 11×
MT6899 CPU granite4 378ms→392ms · 1.0× 8.0s→394ms · 20× 2.8s→256ms · 11×
MT6899 CPU qwen35 56.3s→13.8s · 4.1× 77.7s→7.8s · 10× 27.3s→2.6s · 10×
MT6899 CPU gemma4 4.5s→4.4s · 1.0× 246ms→247ms · 1.0× 2.2s→2.3s · 1.0×
OnePlus 6 CPU lfm2 726ms→714ms · 1.0× 16.1s→744ms · 22× 6.2s→473ms · 13×
OnePlus 6 CPU granite4 765ms→784ms · 1.0× 17.2s→855ms · 20× 6.0s→516ms · 12×
MT6769 CPU lfm2 1.2s→1.2s · 1.0× 25.7s→1.2s · 21× 8.8s→755ms · 12×
MT6769 CPU granite4 1.6s→1.5s · 1.0× 32.6s→1.7s · 20× 11.5s→1.1s · 11×
Galaxy S23 CPU lfm2 358ms→368ms · 1.0× 7.5s→366ms · 21× 2.7s→240ms · 11×
Galaxy S23 CPU granite4 388ms→407ms · 1.0× 8.6s→429ms · 20× 3.1s→313ms · 10×
Galaxy S23 CPU qwen35 70.2s→17.6s · 4.0× 95.7s→10.1s · 9.5× 34.1s→3.3s · 10×
Galaxy S23 CPU gemma4 5.9s→6.0s · 1.0× 296ms→296ms · 1.0× 3.0s→3.0s · 1.0×
Galaxy S23 Adreno lfm2 240ms→237ms · 1.0× 489ms→289ms · 1.7× 171ms→278ms · 0.6×
Galaxy S23 Adreno granite4 554ms→590ms · 0.9× 3.3s→594ms · 5.6× 1.3s→852ms · 1.5×
Galaxy S23 Adreno qwen35 4.1s→2.2s · 1.8× 5.7s→1.2s · 4.9× 2.3s→2.2s · 1.0×
Galaxy S23 Adreno gemma4 2.7s→2.7s · 1.0×
Galaxy S23 NPU lfm2 488ms→581ms · 0.8× 8.6s→599ms · 14× 3.3s→427ms · 7.7×
Galaxy S23 NPU qwen35 1.9s→616ms · 3.1× 2.7s→353ms · 7.6× 972ms→338ms · 2.9×
SM8850 CPU lfm2 374ms→476ms · 0.8× 5.4s→251ms · 21× 1.8s→149ms · 12×
SM8850 CPU granite4 968ms→334ms · 2.9× 11.5s→289ms · 40× 2.0s→208ms · 9.4×
SM8850 CPU qwen35 44.6s→10.2s · 4.4× 60.9s→6.3s · 9.7× 22.8s→1.9s · 12×
SM8850 CPU gemma4 3.6s→3.7s · 1.0× 198ms→201ms · 1.0× 1.8s→1.7s · 1.0×
SM8850 Adreno lfm2 37ms→39ms · 1.0× 253ms→41ms · 6.2× 81ms→68ms · 1.2×
SM8850 Adreno granite4 426ms→446ms · 1.0× 3.2s→540ms · 5.8× 1.3s→1.0s · 1.3×
SM8850 Adreno qwen35 911ms→420ms · 2.2× 1.3s→219ms · 5.8× 477ms→342ms · 1.4×
SM8850 Adreno gemma4 333ms→328ms · 1.0× 156ms→156ms · 1.0× 254ms→257ms · 1.0×
SM8850 NPU lfm2 291ms→292ms · 1.0× 5.4s→303ms · 18× 1.9s→247ms · 7.8×
SM8850 NPU qwen35 1.0s→492ms · 2.0× 1.4s→252ms · 5.7× 543ms→289ms · 1.9×
Memory: peak process RSS, cache off → on (Δ bounded by the 160 MiB budget)
device model off on Δ
Pixel 9 lfm2 248 MB 240 MB -8
Pixel 9 granite4 450 MB 596 MB +146
Pixel 9 qwen35 1446 MB 1575 MB +129
Pixel 9 gemma4 2975 MB 3497 MB +522
MT6899 lfm2 250 MB 242 MB -8
MT6899 granite4 455 MB 601 MB +146
MT6899 qwen35 1422 MB 1560 MB +137
MT6899 gemma4 2859 MB 2437 MB -422
OnePlus 6 lfm2 248 MB 240 MB -8
OnePlus 6 granite4 452 MB 598 MB +146
MT6769 lfm2 246 MB 238 MB -8
MT6769 granite4 447 MB 593 MB +146
Galaxy S23 lfm2 244 MB 236 MB -8
Galaxy S23 granite4 445 MB 590 MB +146
Galaxy S23 qwen35 1432 MB 1561 MB +129
Galaxy S23 gemma4 3221 MB 3208 MB -13
SM8850 lfm2 249 MB 242 MB -8
SM8850 granite4 452 MB 598 MB +146
SM8850 qwen35 1447 MB 1560 MB +113
SM8850 gemma4 3416 MB 3399 MB -16

Known limitations

  • gemma4 / pure-SWA —> cache off. ` so it is gated to recurrent/hybrid.
  • qwen35: think-strip caps reuse. <think> tokens are stripped from history (by qwen's template), so every turn diverges after the system prefix. Append still wins ≈4×.
  • granite4 · Hexagon NPU —> off for reuse. mamba2 state doesn't round-trip on the DSP. Clean on CPU/GPU.

…dels

Reuse the shared KV prefix across turns instead of full-reprocessing on every
divergence. Recurrent/hybrid/SWA state that seq_rm cannot roll back is snapshotted
(PARTIAL_ONLY) and restored.

- Frontier capture: snapshot at n_past where the reused state already rests, so a
  warm turn adds a readback and no extra decode. Cold ingest lays boundary
  snapshots (system-prefix anchor); the restore path advances the frontier.
- Gate to recurrent/hybrid: pure-SWA can never restore a checkpoint.
- Multimodal / MTP / embedding paths share the restore logic; mem-shared MTP
  drafts force a full reprocess; the multimodal cold path trims the cache after
  the prompt-end decrement.
- Host config: state_cache_budget_mb (0 = off).
Correctness suite (attention control, pure recurrent, hybrids, SWA, vision, MTP):
reuse counts, answer correctness, no-leak invariants, and a distribution-fidelity
probe. Plus a per-turn TTFT/RSS A/B bench and a multi-round device sweep over
CPU / Adreno OpenCL / Hexagon NPU. The bench shares the test's build (kernel
headers) and lives alongside it. See tests/android/README.md.
The make-only path built the generator flag as an unquoted string, so
"-G Unix Makefiles" word-split into "-G Unix" + "Makefiles" (invalid generator).
Use a bash array so the name stays one argument. Ninja path unaffected.
Add coverage for the paths the KV-reuse feature touches that the existing
differential suite could not see:

- run_slot_media_test: drive a real image request end-to-end through the
  slot manager (parallel_decoding_test only checked that media_paths are
  stored). Asserts the model recognizes the image, i.e. the shared
  processMedia trim is intact on the previously-untested parallel path.
  Passes on lfm2vl (hybrid) and gemma4 (SWA).

- run_trim_roundtrip_probe: an absolute (non-differential) check. Scores a
  fixed continuation after (A) a clean single decode, (B) this feature's
  decode -> seq_rm(L-1) -> re-decode trim, and (C) origin/main's n_past--
  only trim. Gates "feature no worse than origin/main"; documents that the
  feature fixes a pre-existing double-write on attention/SWA (0.16 -> 0.00)
  and is a no-op on recurrent (seq_rm can't roll back; pre-existing, Tier-A).

- Make the vision-fidelity acceptance bar explicit: reused > 0 && d <= tol,
  not "bit-exact" (a d==0 with reused==0 is the fallback-to-cold bug).

Test-only; no production code changed.
(--no-verify: commit-msg hook's commitlint crashes on Node 18 < required 20;
message is conventional-commits compliant.)
Two changes to processMedia:

- Drop the seq_rm(L-1) trim. The media path decoded the whole prompt to L, then
  set n_past=L-1 and seq_rm'd so nextToken re-decoded the last token. On
  recurrent/hybrid memory that can't partially roll back, seq_rm no-ops and the
  last token is consumed twice (state corruption); the slot path overrides
  n_past=L and never re-decoded, so the trim left a gap there. Instead keep
  n_past=L and sample from the last-token logits the chunk eval already produced
  (chunk_logits_last) -- decode once, like mtmd-cli and the server.

- Snapshot at the media boundary and the reused frontier, not at L. The single L
  snapshot sat past the trailing text, so a turn whose prompt diverges before L
  (an edit, a regenerate, or a chat template that re-renders the reply -- e.g. the
  <think> strip) had no checkpoint <= the divergence and re-encoded the image. Now
  a frontier snapshot (at the reused n_past, before reprocessing the tail) advances
  the chain each turn, and a media-boundary anchor (right after the last image
  chunk, before the trailing text) keeps the image behind a restore point.

Verified on dense (SmolVLM), SWA (gemma4), hybrid (LFM2-VL) and M-RoPE hybrid
(Qwen3.5-VL) vision models: image reuse restored, restore bit-exact (fidelity
d=0.0000).
- Deterministic media-path probe: a media ingest must leave n_past == embd.size()
  (last prompt token decoded once), which the differential fidelity test can't see.
- Wire dense (SmolVLM-500M) and M-RoPE hybrid (Qwen3.5-VL; mmproj is in the same
  repo) vision models into download.sh and the model registry, alongside the
  existing SWA (gemma4) and hybrid (LFM2-VL) ones.
- image-reuse bar: assert the reused prefix covers the image and most of the
  context (>= half the prompt) rather than near-full. A <think>-stripping template
  diverges in the reply, bounding reuse below near-full while still reusing the
  image; exactness stays gated by the fidelity d<=tol check.
- MTP cell: skip (not fail) when the model has no draft head.
@a-ghorbani a-ghorbani marked this pull request as draft July 14, 2026 22:15
@a-ghorbani

a-ghorbani commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

@jhen0409, the fix here is on the deprecated path, but since it's still being used I made the fix there. The approach can be tested, and if it's helpful we can port it to the slot path, if that makes sense.

The implementation is done and I've been testing it, and now am in the final testing. If you have suggestions on how you'd prefer to approach state management and checkpointing, feel free to let me know.

For coverage (since this is a bit complex) added a number of tests + a harness to run them on device (Android). I put these under the tests folder. Happy to reorganize if you'd rather manage that differently.

Re the unrelated change scripts/build-hexagon-htp.sh: This would fail in linux machine, where i don't have jinja that it takes "elif command -v make &> /dev/null; then" path.

@a-ghorbani

Copy link
Copy Markdown
Collaborator Author

A few common path tests (on an iPhone device):

IMG_5857 IMG_5858 IMG_5859 IMG_5860

@a-ghorbani a-ghorbani marked this pull request as ready for review July 15, 2026 09:28
@a-ghorbani a-ghorbani requested a review from jhen0409 July 15, 2026 09:28
@a-ghorbani

Copy link
Copy Markdown
Collaborator Author

@jhen0409, the fix here is on the deprecated path, but since it's still being used I made the fix there. The approach can be tested, and if it's helpful we can port it to the slot path, if that makes sense.

The implementation is done and I've been testing it, and now am in the final testing. If you have suggestions on how you'd prefer to approach state management and checkpointing, feel free to let me know.

For coverage (since this is a bit complex) added a number of tests + a harness to run them on device (Android). I put these under the tests folder. Happy to reorganize if you'd rather manage that differently.

Re the unrelated change scripts/build-hexagon-htp.sh: This would fail in linux machine, where i don't have jinja that it takes "elif command -v make &> /dev/null; then" path.

@jhen0409 I ran all the tests, including a couple of manual tests using the example app (see the screenshots above). so it is ready for your review, whenever you get the chance. Thanks!

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.

Recurrent/hybrid/SWA models reprocess the whole prompt when the KV cache diverges

1 participant