engine(kv-cache): IKvCache.TryReserveSlot — write-into-cache primitive - #309
engine(kv-cache): IKvCache.TryReserveSlot — write-into-cache primitive#309jamesburton wants to merge 2 commits into
Conversation
#278) Adds an opt-in primitive that lets callers reserve in-place K/V cache slots so the projection GEMM (and the post-projection in-place pipeline — AddBias, LoRA delta, QK-norm, RoPE) can target the cache directly, skipping the scratch buffer and the `Update` memcpy that follows it. API: two methods on `IKvCache`, both with default no-op implementations so every existing cache impl remains backward-compatible without changes: - `bool TryReserveSlot(int layer, ReadOnlySpan<int> positions, out Span<float> kDst, out Span<float> vDst)` — returns true and exposes in-place K/V cache buffers when reservable; false otherwise (caller falls back to the scratch + `Update` path). - `void CommitSlot(int layer, ReadOnlySpan<int> positions)` — advances `CurrentLength` after the caller has written into the slot. Idempotent across layers, mirrors `Update`'s length semantics. Per-impl behaviour: | Cache | TryReserveSlot | |----------------------|-------------------------------------------| | SimpleKvCache | true for contiguous in-range positions | | PagedKvCache | true for contiguous single-block runs | | QuantizedKvCache | false (default; quantized rows, no F32 slot) | | CudaKvCache | false (default; device-side writes) | | CudaQuantizedKvCache | false (default) | | HybridKvCache | false (default) | Gating rules for the impls that opt in: - Contiguous positions only (`positions[i] == positions[0] + i`). The GEMM output is a single contiguous `[seqLen, kvStride]` block, which can only map onto a contiguous cache region. - Within `MaxLength` (`positions[0] + seqLen <= MaxLength`). - Paged additionally requires the run to fit inside one block — decode (seqLen=1) always satisfies this; multi-token runs only when they don't cross a block boundary. Block-spanning runs return false and let the caller fall back to `Update`, which handles boundaries correctly. Wiring into `TransformerModel.Forward` ships separately as the direct-to-cache K/V PR for #25 item 4 — this commit is the precursor that exposes the primitive without changing any call site. Tests (tests/DotLLM.Tests.Unit/Engine/KvCache/ReserveSlotTests.cs, 14 cases): - Simple: contiguous/non-contiguous/out-of-range/empty gating; CommitSlot advances length; **bit-exact byte comparison** vs the legacy `Update` path for both a prefill burst and a per-step decode sequence. - Paged: single-block / block-boundary / non-contiguous gating; every single-token decode position reservable; bit-exact vs `Update` for both decode and single-block prefill (compared via the staging-gathered view the attention kernel actually consumes). - Quantized: confirms the default-fallback `false` is observed through the `IKvCache` interface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR adds an opt-in “in-place KV write” path to IKvCache via TryReserveSlot/CommitSlot, with implementations for SimpleKvCache and PagedKvCache, plus unit tests ensuring behavior and bit-exact parity with the legacy Update path.
Changes:
- Added default
IKvCache.TryReserveSlot/IKvCache.CommitSlotAPIs (with safe fallbacks). - Implemented in-place slot reservation + commit semantics in
SimpleKvCacheandPagedKvCache. - Added extensive unit tests covering reservability rules and byte-identical parity vs
Update.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Engine/KvCache/ReserveSlotTests.cs | New unit tests validating reservability and bit-exact parity for Simple/Paged caches and opt-out behavior. |
| src/DotLLM.Engine/KvCache/SimpleKvCache.cs | Implements contiguous in-place reservations and length commit behavior. |
| src/DotLLM.Engine/KvCache/PagedKvCache.cs | Implements single-block in-place reservations for paged storage and commit behavior. |
| src/DotLLM.Core/Attention/IKvCache.cs | Extends cache interface with opt-in slot reservation APIs and default fallbacks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public bool TryReserveSlot( | ||
| int layerIndex, | ||
| ReadOnlySpan<int> positions, | ||
| out Span<float> kDst, | ||
| out Span<float> vDst) | ||
| { | ||
| kDst = default; | ||
| vDst = default; | ||
|
|
||
| int seqLen = positions.Length; | ||
| if (seqLen == 0) return false; | ||
|
|
||
| int start = positions[0]; | ||
|
|
||
| // Contiguous run required (GEMM output is contiguous). | ||
| for (int i = 1; i < seqLen; i++) | ||
| { | ||
| if (positions[i] != start + i) return false; | ||
| } | ||
|
|
||
| // Bounds: entire run must fit within MaxLength. | ||
| if ((uint)start >= (uint)_maxSeqLen) return false; | ||
| if (start + seqLen > _maxSeqLen) return false; | ||
|
|
||
| // Single-block run only: the run must not cross a block boundary, otherwise the | ||
| // in-place slot wouldn't be physically contiguous. Decode (seqLen=1) always | ||
| // satisfies this; multi-token runs only when they fit inside one block. | ||
| int blockSize = _pool.BlockSize; | ||
| int offset = start % blockSize; | ||
| if (offset + seqLen > blockSize) return false; | ||
|
|
||
| // Ensure a block exists (with refcount-1 fast-path) for the start position. | ||
| _blockTable.EnsureCapacity(start + seqLen); | ||
| _blockTable.EnsureWritable(start); | ||
| var (blockId, offsetInBlock) = _blockTable.Resolve(start); | ||
|
|
||
| int totalFloats = seqLen * _kvStride; | ||
| kDst = new Span<float>(_pool.GetKeyPtr(blockId, layerIndex) + offsetInBlock * _kvStride, totalFloats); | ||
| vDst = new Span<float>(_pool.GetValuePtr(blockId, layerIndex) + offsetInBlock * _kvStride, totalFloats); | ||
| return true; | ||
| } |
| // Bounds: the entire run must fit within the cache. | ||
| if ((uint)start >= (uint)_maxSeqLen) return false; | ||
| if (start + seqLen > _maxSeqLen) return false; |
| // Bounds: entire run must fit within MaxLength. | ||
| if ((uint)start >= (uint)_maxSeqLen) return false; | ||
| if (start + seqLen > _maxSeqLen) return false; |
| // Ensure a block exists (with refcount-1 fast-path) for the start position. | ||
| _blockTable.EnsureCapacity(start + seqLen); |
| nint kSrc = (nint)NativeMemory.AlignedAlloc((nuint)(SeqLen * KvStride * sizeof(float)), 64); | ||
| nint vSrc = (nint)NativeMemory.AlignedAlloc((nuint)(SeqLen * KvStride * sizeof(float)), 64); |
Addresses Copilot review feedback on the TryReserveSlot PR. - PagedKvCache.TryReserveSlot now validates layerIndex up-front, matching SimpleKvCache. Previously an out-of-range layer reached KvBlockPool's raw pointer accessors and surfaced as IndexOutOfRangeException from the layer-buffer array rather than a proper ArgumentOutOfRangeException. - Both caches now express the run bounds check as `seqLen > _maxSeqLen - start` instead of `start + seqLen > _maxSeqLen`. The start check guarantees 0 <= start < _maxSeqLen, so the subtraction cannot overflow, and it also makes the subsequent `start + seqLen` passed to EnsureCapacity provably safe. - SimpleKvCache's layer guard moved ahead of the position checks so an invalid layer always throws rather than silently returning false. - IKvCache documents the ArgumentOutOfRangeException contract for layerIndex. - Tests: added Simple/Paged layer-index guard tests; native scratch buffers now allocate through an AllocFloats helper that asserts a non-null pointer, so an allocation failure fails the test instead of access-violating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Worked through all 5 Copilot review comments. Pushed as 1. Fixed: the One correction to the comment's premise though: this was never a memory-safety hole. 2–4. Both caches now use the overflow-safe form: if ((uint)start >= (uint)_maxSeqLen) return false;
if (seqLen > _maxSeqLen - start) return false;The first check guarantees Practically this needs a 5. The four alloc pairs now go through a single helper: private static nint AllocFloats(int floatCount)
{
nint ptr = (nint)NativeMemory.AlignedAlloc((nuint)(floatCount * sizeof(float)), 64);
Assert.True(ptr != 0, "NativeMemory.AlignedAlloc returned null.");
return ptr;
}so an allocation failure surfaces as a test failure instead of an AV on first write. Also included (not requested, but fallout from fix 1):
Verification: |
Summary
Closes #278. Adds
IKvCache.TryReserveSlot— a write-into-cache primitive that returns a destination span the caller writes K/V directly into, avoiding the previous "compute K/V, then copy into cache" pattern.Test results
ReserveSlotTests(437 lines).Notes for review
Single-commit primitive on
main. This is the precursor to the #25 direct-to-cache K/V writes PR (which stacks on this branch).