Skip to content

engine(kv-cache): IKvCache.TryReserveSlot — write-into-cache primitive - #309

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/278-kvcache-reserve-slot
Open

engine(kv-cache): IKvCache.TryReserveSlot — write-into-cache primitive#309
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/278-kvcache-reserve-slot

Conversation

@jamesburton

Copy link
Copy Markdown

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

  • New: ReserveSlotTests (437 lines).
  • 4 files changed, 608 insertions.
  • All existing KV-cache tests continue to pass.

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

#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>
Copilot AI review requested due to automatic review settings June 8, 2026 21:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.CommitSlot APIs (with safe fallbacks).
  • Implemented in-place slot reservation + commit semantics in SimpleKvCache and PagedKvCache.
  • 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.

Comment on lines +178 to +218
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;
}
Comment on lines +176 to +178
// Bounds: the entire run must fit within the cache.
if ((uint)start >= (uint)_maxSeqLen) return false;
if (start + seqLen > _maxSeqLen) return false;
Comment on lines +198 to +200
// Bounds: entire run must fit within MaxLength.
if ((uint)start >= (uint)_maxSeqLen) return false;
if (start + seqLen > _maxSeqLen) return false;
Comment on lines +209 to +210
// Ensure a block exists (with refcount-1 fast-path) for the start position.
_blockTable.EnsureCapacity(start + seqLen);
Comment on lines +103 to +104
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>
@jamesburton

Copy link
Copy Markdown
Author

Worked through all 5 Copilot review comments. Pushed as 7e6ba2f.

1. PagedKvCache.TryReserveSlot — missing layerIndex validation — accepted (with a correction).

Fixed: the (uint)layerIndex >= (uint)_numLayers guard now runs first thing in PagedKvCache.TryReserveSlot, matching SimpleKvCache.

One correction to the comment's premise though: this was never a memory-safety hole. KvBlockPool.GetKeyPtr/GetValuePtr index a managed nint[] (_keyBuffers[layerIndex]) before doing any pointer arithmetic, so an out-of-range layer already threw IndexOutOfRangeException rather than producing a wild pointer. The real problem was the wrong exception type and the inconsistency with SimpleKvCache — which is what the guard fixes.

2–4. start + seqLen int overflow (SimpleKvCache:178, PagedKvCache:200/210) — accepted.

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 0 <= start < _maxSeqLen, so _maxSeqLen - start is positive and cannot overflow. This also discharges comments 3 and 4 together: once the run has passed this check, start + seqLen <= _maxSeqLen, so the start + seqLen value handed to EnsureCapacity is provably non-overflowing. I kept the addition there rather than recomputing, with a comment recording why it is safe.

Practically this needs a _maxSeqLen near int.MaxValue and a ~2-billion-element positions span to trigger, so it was latent rather than live — but the subtraction form costs nothing and removes the reasoning burden.

5. NativeMemory.AlignedAlloc null check in ReserveSlotTests — accepted.

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

  • SimpleKvCache's layer guard moved ahead of the position checks. It previously sat after them, so an invalid layerIndex combined with non-contiguous or out-of-range positions returned false silently instead of throwing. An out-of-range layer is a caller bug, not a "cannot reserve in place" condition, so it should always throw.
  • IKvCache.TryReserveSlot XML docs now state the ArgumentOutOfRangeException contract for layerIndex, so the throw-vs-return-false split is part of the documented interface rather than an implementation detail.
  • Two new regression tests (Simple_TryReserveSlot_LayerIndexOutOfRange_Throws, Paged_TryReserveSlot_LayerIndexOutOfRange_Throws) covering both the >= numLayers and negative cases.

Verification: dotnet test --filter "FullyQualifiedName~KvCache|FullyQualifiedName~ReserveSlot" — 77 passed, 0 failed, 2 skipped (up from 75 passed; the 2 new guard tests). No behavioural change to any accepted-path reservation.

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.

engine(kv-cache): IKvCache.TryReserveSlot — write-into-cache primitive for direct K/V projection

2 participants