Add off-graph KV-cache cell reference: multi-sequence batching - #21761
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21761
Note: Links to docs will display an error until the docs builds have been completed. ✅ No FailuresAs of commit 490bfea with merge base e60faa2 ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
|
@claude review this code |
|
Claude finished @metascroy's task in 3m 34s —— View job Code review: off-graph KV-cache cell reference
Nicely done — the design is coherent and the docstrings carry the intent well. I traced the masking, allocation, growth, and step-protocol paths and believe the core logic is correct. The mask predicate ( A few observations, none blocking: 1. Sequence verbs skip the range validation that 2. A failed 3. Read window spans the whole occupied pool, not the per-sequence footprint 4. Minor / optional
Everything above is polish on an otherwise solid change; the correctness-critical paths hold up. |
| mechanism (``attend`` below) is applied by the op/backend from that spec. | ||
|
|
||
| Scope for this initial slice: single sequence, contiguous placement, float KV. | ||
| Two caches share the op: ``ContiguousReferenceCache`` (one sequence appended in |
There was a problem hiding this comment.
It can be a follow-up PR, but didn't we rename ContiguousReferenceCache as SequenceCache or something in C++ code?
| def free_cells(self) -> int: | ||
| return self._pos.count(-1) | ||
|
|
||
| def can_extend(self, n: int = 1) -> bool: |
There was a problem hiding this comment.
What does can_extend mean in the context of multiple sequences?
Whether the total concatenated length is extendible?
There was a problem hiding this comment.
Yes exactly, it checks if there are enough cells for this step's total tokens up to a capacity
| self._shrink() | ||
| self._invalidate_plan() | ||
|
|
||
| def seq_keep(self, keep: int) -> None: |
There was a problem hiding this comment.
Get rid of this for now. We can always extend API in future
| "update_and_attend KV cache is experimental and may change without notice." | ||
| ) | ||
| class CellReferenceCache: | ||
| """Per-cell KV history for several sequences sharing one pool. |
There was a problem hiding this comment.
The API here is leaving a lot of bookkeeping to the caller to flatten sequences.
Can we have a utility that flattens {seq_id: (token_id, pos_id)} to the parallel arrays seq_id, token_id, pos_id?
|
|
||
| # -- internals ---------------------------------------------------------- | ||
|
|
||
| def _allocate(self, position: torch.Tensor, device: torch.device) -> _CellStepPlan: |
There was a problem hiding this comment.
It was there to build the plan's tensors on the same device as the K/V, since both are used against the pools. Now dropped it, it takes the device from the pools themselves (self._k[0].device).
| f"the forward carries {len(positions)}" | ||
| ) | ||
| cells = [ | ||
| self._claim(pos, 1 << seq) for pos, seq in zip(positions, self._step_seq) |
There was a problem hiding this comment.
How are prefixes being shared here?
Isn't 1 << seq just having one owners.
If (pos, seq, tok) all match, are we identify shared cell? How do we do prefix sharing with batch prefill?
There was a problem hiding this comment.
Yes currently during write it is only one owner, and shared with seq_cp
| self._plan = None | ||
| self._served.clear() | ||
|
|
||
| def seq_cp(self, src: int, dst: int, p0: int = 0, p1: Optional[int] = None) -> None: |
There was a problem hiding this comment.
Are both p0/p1 definable if only prefix sharing is supported?
There was a problem hiding this comment.
I dropped p0, I guess it has no use case.
| def can_extend(self, n: int = 1) -> bool: | ||
| return self.free_cells() >= n | ||
|
|
||
| def seq_len(self, seq: int) -> int: |
There was a problem hiding this comment.
There's inconsistent use of seq vs. seq_id in the code.
Let's consolidate on seq_id.
There was a problem hiding this comment.
Done, also changed self._seq → self._owners
| self._seq[i] |= dst_bit | ||
| self._invalidate_plan() | ||
|
|
||
| def seq_rm(self, seq: int, p0: int = 0, p1: Optional[int] = None) -> None: |
There was a problem hiding this comment.
Document that None for p1 means until end?
| """Drop seq's claim; a cell frees only once no sequence owns it.""" | ||
| bit = 1 << seq | ||
| for i in range(self._used_end): | ||
| if self._seq[i] & bit and self._in_range(self._pos[i], p0, p1): |
There was a problem hiding this comment.
If we start with [0, 10] populated for seq0 and seq1, sharing cell_ids [0, 10], and we then do seq_rm on seq1 from [2, 5], what does that mean for what remains?
Is that a valid operation?
There was a problem hiding this comment.
That is valid, seq0 stays as it is, seq1 owns positions 0,1 and 5-10. One use case could be attention sink; keeping the first tokens and evicting the ones after sink.
| out, self.model.reference_forward(x, torch.arange(5)), atol=1e-4, rtol=1e-4 | ||
| ) | ||
|
|
||
| def test_batched_sequences_match_separate_runs(self): |
There was a problem hiding this comment.
I think these would be eaiser to read if we had the flatten utiltity mentioned above
|
@claude review the code again, along with reviewer comments |
|
Claude finished @metascroy's task in 1m 52s —— View job Re-review with reviewer comments
Re-read the current revision and worked through each of @metascroy's threads. My take on where I agree, where I'd push, and a couple of things worth pinning down before this lands. The one that matters most: prefix sharing vs. batch prefill (
|
| self.model(tokens, positions, logits_indices) | ||
|
|
||
| tokens, positions, seq_ids, logits_indices = flatten_step( | ||
| {0: (a[:, 3:], 3), 1: (b[:, 2:], 2)} |
There was a problem hiding this comment.
who will keep track of these positions?
There was a problem hiding this comment.
Right now the caller does. The cache could report it instead: next_pos(seq_id) = one past the highest position among the cells that sequence owns (0 if it owns none), so the call becomes flatten_step({0: (tok, cache.next_pos(0))}) and the caller stops tracking positions itself.
| self._owners[i] &= ~bit | ||
| if self._owners[i] == 0: | ||
| self._pos[i] = -1 | ||
| self._shrink() |
There was a problem hiding this comment.
I guess this will wind it back id self._owners == 0 (when the last seq_id is removed)? if yes, do we have a test? if no, can we add? For memory leaks.
There was a problem hiding this comment.
Yes _shrink walks used_end back past every trailing cell whose owners went empty. Now I added test_freeing_the_tail_shrinks_the_read_window
| ) | ||
| return q, k, v | ||
|
|
||
| def forward(self, x, position, logits_indices): |
There was a problem hiding this comment.
can you remind me why position and logits_indices has to be in the forward sign?
There was a problem hiding this comment.
position is for RoPE and logits_indices for lm head
|
|
||
| Scope for this initial slice: single sequence, contiguous placement, float KV. | ||
| Two caches share the op: ``ContiguousReferenceCache`` (one sequence appended in | ||
| place) and ``CellReferenceCache`` (many sequences over a pool of per-token cells, |
There was a problem hiding this comment.
did you consider cell size to be >1 token? This is in the context of the paged attn impls.
7e83627 to
490bfea
Compare
Summary
Adds CellReferenceCache to the eager KV-cache reference: many sequences over one pool of per-token cells. A cell holds one token's K/V plus that token's position and the set of sequences owning it, so a sequence needn't occupy a contiguous range and two sequences can share cells — a fork sets a second owner bit instead of copying K/V. The batch is flat on the token axis (B = 1).
Files
Testing
Ten cases added to the existing unittest suite:
a skipped declaration
pytest extension/llm/cache/test_update_and_attend.py -q