ggml decode: the KV cache was sized by two literals the write site ignored #372

Merged
dcharlot merged 1 commit from fix/kv-cache-attn-layer-count into main 2026-10-21 05:38:26 -04:00
Owner

DecodeBuffers::new built the KV cache from two literals:

let max_seq = 4096usize;
let num_attn_layers = 6; // Nemotron: 6 attention layers

while encode_decode_step indexes that cache with the model's real attention-layer ordinal and the real position:

let attn_idx = layers.iter().take(layer.idx + 1)
    .filter(|l| l.layer_type == LayerType::Attention).count() - 1;
let dst_offset = _position as u64 * kv_row_f16;

Neither mismatch reported anything.

What went wrong

A seventh attention layer lost its KV writes. With attn_idx == 6, the guard if attn_idx < bufs.kv_k_cache.len() was false, so that layer's K and V were never written. Attention then read a zero-filled cache — wrong output, no error anywhere. The guard that looks defensive is what made it silent.

_position is never bounded. Not anywhere in encode_decode_step. At or past 4096, set_buffer receives an offset past the end of the buffer.

The fix

Both lengths come from the model now. init resolves the layer types and passes the attention-layer count; max_seq comes from config.max_seq_len, falling back only when it is 0 — which would otherwise allocate a zero-length cache that fails every write.

The capacity is recorded on DecodeBuffers so the decode path can check instead of assuming, and encode_decode_step checks both once, up front. That placement is deliberate: the per-site if idx < len guards sit inside nested closures where return Err cannot propagate, which is why the original failure had nowhere to surface.

Tests

tests/ggml_kv_cache.rs pins the two lengths:

  • one buffer per attention layer for counts of 1, 6, 7 and 11 — 6 being the literal that hid this
  • buffer length following max_seq_len for 512, 4096 and 8192
  • a config with max_seq_len == 0 still producing a usable cache

Verified by corruption, not just a green run: restoring the two literals fails exactly the two tests that should fail, with left: 4096 / right: 512 and left: 6 / right: 1. The third passes either way, because the old code ignored the config entirely — it guards the new fallback, not the old bug.

Also: 0 metal compile errors, 26/26 Metal kernel tests still passing, clippy -- -D warnings clean.

Scope and what is still assumed

This path is behind the GGML_GRAPH environment variable, so it is opt-in rather than the default decode path.

Left alone deliberately, and worth separate attention: GRAPH and LAYERS_CACHE are process-global OnceLocks, so a second model loaded in the same process reuses the first one's buffers and layer pattern.

🤖 Generated with Claude Code

`DecodeBuffers::new` built the KV cache from two literals: ```rust let max_seq = 4096usize; let num_attn_layers = 6; // Nemotron: 6 attention layers ``` while `encode_decode_step` indexes that cache with the model's **real** attention-layer ordinal and the **real** position: ```rust let attn_idx = layers.iter().take(layer.idx + 1) .filter(|l| l.layer_type == LayerType::Attention).count() - 1; let dst_offset = _position as u64 * kv_row_f16; ``` Neither mismatch reported anything. ## What went wrong **A seventh attention layer lost its KV writes.** With `attn_idx == 6`, the guard `if attn_idx < bufs.kv_k_cache.len()` was false, so that layer's K and V were never written. Attention then read a zero-filled cache — wrong output, no error anywhere. The guard that looks defensive is what made it silent. **`_position` is never bounded.** Not anywhere in `encode_decode_step`. At or past 4096, `set_buffer` receives an offset past the end of the buffer. ## The fix Both lengths come from the model now. `init` resolves the layer types and passes the attention-layer count; `max_seq` comes from `config.max_seq_len`, falling back only when it is 0 — which would otherwise allocate a zero-length cache that fails every write. The capacity is recorded on `DecodeBuffers` so the decode path can **check** instead of assuming, and `encode_decode_step` checks both once, up front. That placement is deliberate: the per-site `if idx < len` guards sit inside nested closures where `return Err` cannot propagate, which is why the original failure had nowhere to surface. ## Tests `tests/ggml_kv_cache.rs` pins the two lengths: - one buffer per attention layer for counts of 1, 6, 7 and 11 — 6 being the literal that hid this - buffer length following `max_seq_len` for 512, 4096 and 8192 - a config with `max_seq_len == 0` still producing a usable cache Verified by corruption, not just a green run: restoring the two literals fails exactly the two tests that should fail, with `left: 4096 / right: 512` and `left: 6 / right: 1`. The third passes either way, because the old code ignored the config entirely — it guards the new fallback, not the old bug. Also: 0 metal compile errors, 26/26 Metal kernel tests still passing, `clippy -- -D warnings` clean. ## Scope and what is still assumed This path is behind the `GGML_GRAPH` environment variable, so it is opt-in rather than the default decode path. Left alone deliberately, and worth separate attention: `GRAPH` and `LAYERS_CACHE` are process-global `OnceLock`s, so a second model loaded in the same process reuses the first one's buffers and layer pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ggml decode: the KV cache was sized by two literals the write site ignored
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
jouleclaw fabrics / cuda (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 16s
jouleclaw fabrics / vulkan (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 20s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m39s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m58s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 4m46s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m2s
jouleclaw fabrics / metal (macOS, Apple GPU) (pull_request) Successful in 1m12s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 9m13s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m54s
950634f200
`DecodeBuffers::new` built the KV cache with

    let max_seq = 4096usize;
    let num_attn_layers = 6; // Nemotron: 6 attention layers

while `encode_decode_step` indexes it with the model's real attention-layer
ordinal and the real position:

    let attn_idx = layers.iter().take(layer.idx + 1)
        .filter(|l| l.layer_type == LayerType::Attention).count() - 1;
    let dst_offset = _position as u64 * kv_row_f16;

Two consequences, neither of which reported anything:

- A model with a seventh attention layer got `attn_idx == 6`, the
  `if attn_idx < bufs.kv_k_cache.len()` guard was false, and that layer's
  K and V were simply never written. Attention then read a zero-filled
  cache: wrong output, no error.
- `_position` is never bounded anywhere in the function, so a position at
  or past 4096 gave `set_buffer` an offset past the end of the buffer.

Both lengths now come from the model. `init` resolves the layer types and
passes the attention-layer count; `max_seq` comes from
`config.max_seq_len`, falling back only when it is 0, which would
otherwise allocate a zero-length cache that fails every write. The
capacity is recorded on `DecodeBuffers` so the decode path can check
rather than assume, and `encode_decode_step` now checks both once, up
front, where an error can propagate -- the per-site `if idx < len` guards
are deep inside nested closures where a `return Err` cannot.

tests/ggml_kv_cache.rs pins the two lengths: one buffer per attention
layer for counts of 1, 6, 7 and 11 (6 being the literal that hid this),
buffer length following max_seq_len for 512, 4096 and 8192, and a
zero-length config still producing a usable cache.

Scope: this path is behind the GGML_GRAPH environment variable, so it is
opt-in rather than the default decode path.

Still assumed, and worth separate attention: `GRAPH` and `LAYERS_CACHE`
are process-global `OnceLock`s, so a second model loaded in the same
process reuses the first one's buffers and layer pattern.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!372
No description provided.