Skip to content

perf(cache): write the decode KV row in place instead of copying the cache - #1961

Merged
inureyes merged 2 commits into
mainfrom
update/issue-1959-inplace-kv-write
Sep 23, 2026
Merged

inureyes merged 2 commits into
mainfrom
update/issue-1959-inplace-kv-write

Conversation

@inureyes

Copy link
Copy Markdown
Member

A single-token FP16 decode step now writes its K/V row into the dense cache buffer in place instead of through slice_update. During pipelined decode slice_update could never donate the buffer: MLX's Metal backend keeps each kernel's input buffers alive until the command buffer completes, and the next step is encoded while the previous one still runs, so every layer's K and V cache was copied every token. The copy grows with context.

  • inplace_slice_write (src/lib/mlx-cpp/turbo/kv_inplace_write): a primitive whose output adopts the destination buffer (copy_shared_buffer) and writes only the new rows (copy_gpu_inplace), i.e. SliceUpdate without the leading full copy.
  • Cache-poisoning guard: KVCache takes the in-place write only when the current keys/values are the arrays its own last write produced (compared by MLX array id, with the handles held so ids cannot be reused) and the row lands exactly at that write's length. It therefore never rewrites a row another holder (prompt-cache snapshot, restored or detached cache) could consider valid. Anything else takes one copying write and returns to in-place.
  • On by default; MLXCEL_KV_INPLACE_WRITE=0 restores the copying path. Documented in docs/environment-variables.md.

Measurement (M1 Ultra)

copying in-place
command-r7b pp512/tg128 (bench_decode.sh, median of 6) 115.5 120.2
command-r7b pp2048/tg128 99.1 107.3
server, concurrency 1, prompt-cache restores 115.2 to 116.7 119.6 to 120.6
Llama 3.1 8B 4-bit pp512 107.2, 107.8 112.3, 112.5
Qwen2.5 7B 4-bit pp512 111.3, 111.6 114.2, 114.8

Peak memory footprint at 2048 + 512 drops from 7.41/7.53 GB to 6.80/6.82 GB. Gemma 3 moves 0.6%, as its sliding layers use the rotating cache. Record: docs/benchmark_results/kv-inplace-write-m1ultra-2026-09-23.md.

Validation

  • Greedy output byte-identical with and without the switch for command-r7b, Llama 3.1 8B, Qwen2.5 7B and Gemma 3 4B (300 tokens, crossing the buffer growth boundary).
  • Server: eight greedy chat requests sharing prefixes, replayed so snapshots are restored, return identical replies in both arms.
  • New tests for the ownership rule and for a shared snapshot surviving continued in-place writes; removing the rule makes the latter fail.
  • make verify-test: 11430 passed, 0 failed. Workspace clippy and fmt pass.
  • CUDA and ROCm not measured; the paged decode backend returns before this path.

Closes #1959

…cache

A single-token FP16 decode step wrote its K/V row with slice_update, which copies the whole buffer unless it is donatable. During pipelined decode it never is: MLX's Metal backend keeps each kernel's input buffers alive until the command buffer completes, and the next step is encoded while the previous one still runs, so every layer's K and V cache was copied every token.

A new primitive, inplace_slice_write (turbo/kv_inplace_write), adopts the destination buffer and writes only the new rows. KVCache takes it only when its own last write produced the current buffers and the row lands exactly at that write's length, so it never rewrites a row another holder (prompt-cache snapshot, restored or detached cache) could consider valid; anything else takes one copying write and returns to in-place. MLXCEL_KV_INPLACE_WRITE=0 restores the copying path.

M1 Ultra, bench_decode.sh tg128: command-r7b 4-bit +2.5% at context 16, +4.1% at 512 (115.5 to 120.2), +8.2% at 2048; server c=1 decode 115-117 to 120-121; Llama 3.1 8B +4.6%, Qwen2.5 7B +2.7%. Peak memory footprint about 0.6 GB lower at 2048+512. Greedy output identical across those models and on server requests that restore prompt-cache snapshots. New tests cover the ownership rule and a shared snapshot surviving continued in-place writes; dropping the rule fails the latter.

Closes #1959
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:macos macOS (Apple Silicon) specific priority:high High priority status:done Completed type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(cache): write the decode KV row in place instead of copying the cache

1 participant