Add the off-graph KV-cache cell layout to the neutral C++ layer - #21902
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21902
Note: Links to docs will display an error until the docs builds have been completed. ❌ 1 Cancelled Job, 2 Unclassified FailuresAs of commit 9369873 with merge base c461421 ( UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:
CANCELLED JOB - The following job was cancelled. Please retry:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
b2df2c2 to
12796fa
Compare
| // Which sequence each of the next forward's tokens belongs to, one entry per | ||
| // token, and the admission gate: false = rejected, nothing changed. Deciding | ||
| // it here means a step that passes cannot later fail to place. | ||
| virtual bool begin_step(const int32_t* seq_ids, int n_tok) = 0; |
There was a problem hiding this comment.
Why raw pointer and not something like vector? What does n_tok mean?
Is begin_step the best name here for what this function does?
There was a problem hiding this comment.
True we don't need a pointer here, changing begin_step to take a std::vector<int32_t>.
| public: | ||
| virtual ~CellPlanner() = default; | ||
| virtual const CellStepPlan* | ||
| plan(int layer, const int32_t* positions, int n_tok) = 0; |
There was a problem hiding this comment.
The positions live in the graph's position tensor, and the backend calls plan once per layer with that tensor's buffer. A vector would build a copy on every layer and 31 of them would go unread, since only the step's first layer looks at the positions and the rest reuse the placement.
| // full/sliding-window layers) shares one logical length. Errors are plain C++ | ||
| // (bool / std::optional); cache_et.h adapts them to Error/Result for ET | ||
| // cache exposes two faces recovered from the owning CacheBase* (static upcasts | ||
| // -- no dynamic_cast/RTTI, no diamond): a runner-facing control face and a |
There was a problem hiding this comment.
These comments are overly explanatory I think
| info_[seq] = info; | ||
| } | ||
|
|
||
| int claim(int32_t pos, int32_t seq) { |
There was a problem hiding this comment.
Can we move the implementation to a cpp file
| TEST_F(CacheTest, CellSingleSequenceIsFused) { | ||
| Cells c(16); | ||
| const auto* prefill = | ||
| c.step({0, 0, 0, 0}, {0, 1, 2, 3}); // seq 0 places 4 tokens at 0..3 |
There was a problem hiding this comment.
In the same way we had helper for python one, can we have for C++
|
@claude review this code |
metascroy
left a comment
There was a problem hiding this comment.
Design is sound and the invariant commentary is unusually careful — the cell/owner-bitset model and the "placement happens once per forward, mask memoized per policy" split both read well. Comments below are mostly about invariants the code claims but doesn't enforce, plus the new face pair being less disciplined than the SequenceControl/SequencePlanner pair it sits beside.
Blocking
fused()compares a position-window against a cell count (cell_cache.h:334) — produces a wrong mask when a sequence's positions have gaps. Reproducer inline.assert(cell >= 0)compiles out over an OOB write (cell_cache.h:283).- Constructor indexes
cfg.layersunchecked (cell_cache.h:76) —valid(cfg)is never called, andcache.h:185-188explicitly warns this reads past the end.
Should fix before landing
seq_cpcan leave one sequence owning two cells for one position (cell_cache.h:128) — the same invariantextends()exists to protect, unenforced on the other path.begin_step's stated admission guarantee doesn't hold (cell_cache.h:189).CellPlanner::plan— naming, return type, and pointer lifetime (cell_cache.h:54).lowest_free()is O(capacity) per token (cell_cache.h:240).- Test coverage gaps, and the
Cells::stephelper hides which gate rejected a step (cache_test.cpp:261).
Smaller items (not filed inline)
API clarity
SequencePlanner::planreturnsstd::optional,CellPlanner::planreturns a raw pointer.cache_et.hadapts the first toError/Resultand has nothing forBatchControl/CellPlanner, so the new face pair has no ET-facing error mapping at all.CacheBase::as_control()went from pure virtual to defaultingnullptr(cache.h:44-49). A cache that forgets a face now fails at runtime instead of compile time, andCacheSession::control()incache_registry.hforwards it out with no null check — aCellCachein a session yields a nullSequenceControl*silently. Not live yet, but worth the null check now.cache_registry.h's header comment is also stale (still says faces are recovered "via as_control()/as_planner()").CacheControl::capacity()is documented as "logical cap" (cache.h:64), but forCellCacheit's a cell-pool size, and per-sequence lengths can legitimately sum past it once a prefix is shared viaseq_cp. Worth calling out on the face — a runner will get this wrong when sizing.CellStepPlan(cell_cache.h:38-46) is a de-facto union:write_startis meaningful only when fused,cells/mask_bitsonly whenExplicit, andwrite_start = -1is a sentinel. Two structs or avariantwould make the invalid states unrepresentable.free_cells()/used_end()(cell_cache.h:169-174) are public non-virtual accessors on the concrete class, used only by tests, on no face. Either they're contract or they're inspection — worth saying which.
Organization / DRY
SeqInfo::min_cellandmax_cell(cell_cache.h:203-204) are written in bothclaim()andrescan()and read nowhere. Dead — delete.SeqInfois derived state maintained two ways: incrementally inclaim()(cell_cache.h:288-292), wholesale inrescan()(cell_cache.h:260-277). The fast path buys little —rescan()is O(used_end_) and already runs on every verb, so one call afterplace()would collapse both. Same argument forused_count_, which could be derived.- Three overlapping reset helpers (
clear/invalidate_step/invalidate_plan), andbegin_stephand-rolls two ofinvalidate_step's four actions inline (cell_cache.h:122-123) instead of calling it. The subtle part — thatinvalidate_plan()deliberately sparesdeclared_/served_so a mid-step verb can't disguise a forward that skippedbegin_step— isn't written down. That's the one comment I'd actually add to this file. - Agreeing with my earlier note on the
cache.h:11-21header: it's now a paragraph covering two cache kinds and the RTTI strategy. Two or three lines is plenty; the per-declaration comments are the ones carrying weight. build_mask(cell_cache.h:375-379) implicitly converts aboolexpression touint8_t— add thestatic_cast.intvsint32_tis inconsistent:pos_/cells_/step_pos_areint32_t;CellStepPlan::write_start/read_lenandseq_rm'sp0/p1areint. Pick one type for positions and one for cell indices.- Per-step allocation churn:
plan_forcopiescells_(cell_cache.h:322),build_maskallocatesn_tok × used_end_bytes (2 MB for a 512-token prefill into 4096 cells), andplans_is astd::mapcleared and rebuilt every step. targets.bzlis stilldef define_common_targets(): pass— none of this C++ is built or tested under Buck. Worth fixing here or filing.
On moving the implementation to a .cpp (my earlier comment): CMakeLists.txt already has add_library(extension_llm_cache cache_registry.cpp) and the install(DIRECTORY ... PATTERN "*.h") glob already picks up the header, so it's about a one-line change.
| // kind expresses. | ||
| bool fused(int window) const { | ||
| // The window has outgrown the span, so old cells need excluding. | ||
| if (window > 0 && window < used_end_) { |
There was a problem hiding this comment.
Blocking — this produces a wrong mask.
window is measured in positions; used_end_ is a cell extent. Those only coincide when the sequence's positions are dense and ascending in cell order, and nothing enforces that — extends() below requires positions to be strictly increasing, not contiguous.
Cells c(16, {ring_layer(2)});
c.step({0}, {0}); // cell 0, pos 0
auto* p = c.step({0}, {5}); // cell 1, pos 5 -- legal, 5 > 0
// used_end_ == 2, window == 2 -> !(2 < 2) -> passes
// info.count == 2 == used_end_ -> passes
// cells_ == {1} == tail run -> passes
// => kind == MaskKind::None, read_len == 2MaskKind::None tells the backend the query may attend the entire window, so the query at position 5 attends cell 0 (position 0) — five positions outside a window of 2.
The tail-run check at L352-357 does catch the hole-refill cases (I worked through several and couldn't break it that way), but it doesn't catch this one. The guard needs to be in position space, e.g. reject unless every occupied cell's pos_ is >= min(step positions) - window + 1.
Worth a test at each of window == used_end_, window == used_end_ - 1, window > used_end_, plus the gap case above.
There was a problem hiding this comment.
Fused now checks the span instead of the cell count: it scans the read window for the oldest and newest positions and refuses unless newest - oldest < window. Added CellFusesOnlyWhileTheWindowCoversTheSpan test cases.
| const int cell = lowest_free(); | ||
| // Only a slip in used_count_ can get here: begin_step admitted the step, | ||
| // and the step claims exactly the cells it declared. | ||
| assert(cell >= 0); |
There was a problem hiding this comment.
Blocking. Under NDEBUG this assert vanishes and the next line is pos_[-1] = pos — a heap write before the buffer.
The comment says only a used_count_ slip reaches here, but used_count_ is duplicated derived state (it's maintained incrementally in claim/seq_rm rather than derived), which is exactly the kind of thing that slips. place() is the only caller and plan() already has a nullptr failure path, so this can propagate instead of asserting.
ET runtime code also generally avoids <cassert> — worth dropping the include.
| windows_.reserve(cfg.n_layers); | ||
| for (int l = 0; l < cfg.n_layers; ++l) { | ||
| const LayerConfig& lc = | ||
| cfg.layers.size() == 1 ? cfg.layers.front() : cfg.layers[l]; |
There was a problem hiding this comment.
Blocking. This indexes cfg.layers directly without ever calling valid(cfg). cache.h:185-188 warns about exactly this case:
Callers must check this before constructing a cache: the
layersbroadcast rule is indexed directly, so a list that is neither size 1 nor n_layers reads past the end.
An empty layers also makes .front() UB, since size() == 1 is false and it falls through to cfg.layers[l]. The Cells test helper doesn't call valid() either, so nothing in the PR exercises the guard. Since the config is caller-supplied, I'd guard in the ctor rather than rely on the precondition.
There was a problem hiding this comment.
valid(cfg) now runs in CacheBuilderRegistry::build. Both CellCache and SequenceCache constructors additionally assert(valid(cfg)).
| } | ||
|
|
||
| void seq_cp(int32_t src, int32_t dst, std::optional<int> upto) override { | ||
| if (!valid_seq(src) || !valid_seq(dst) || src == dst) { |
There was a problem hiding this comment.
Two things here.
1. No guard that dst is position-disjoint from src. If dst already owns positions 0–3 and src owns 0–3, after seq_cp(src, dst, nullopt) dst owns 8 cells covering each position twice, and build_mask will let every dst query attend both copies — duplicated K/V in attention. seq_len(dst) then reports 8 while next_pos(dst) reports 4.
What makes this worth fixing rather than documenting away: extends() exists specifically to stop "a sequence owning two cells for one token" on the begin_step path (see its comment at L215-218). The identical invariant is simply unenforced here. Enforce it in both places, or state it as a caller precondition on BatchControl in cache.h:140-143.
2. Silent no-op on a bad seq id. Within this one class a bad seq id is reported three ways: begin_step returns false, plan() returns nullptr, and seq_cp/seq_rm return void and silently do nothing. A caller that typos a seq id gets no signal and finds out later as a wrong mask. These two should return bool.
There was a problem hiding this comment.
seq_cp now refuses when dst already holds slots. Since upto only bounds a prefix, a non-empty dst always overlaps its own positions, so a fork is only ever onto a fresh sequence. seq_cp and seq_rm return bool now.
| return nullptr; // no declaration, or a token count disagreeing with it | ||
| } | ||
| declared_ = false; // one declaration, one attempt at placing it | ||
| if (!extends(positions, n_tok)) { |
There was a problem hiding this comment.
The admission guarantee in begin_step's doc doesn't hold. From cache.h:136-139:
the admission gate: false = rejected, nothing changed. Deciding it here means a step that passes cannot later fail to place.
But extends() runs here, not in begin_step, because positions don't exist until the forward. So begin_step can return true and plan() still return nullptr. Worse: declared_ is consumed on the line above, so the step isn't retryable — the caller has to go all the way back to begin_step, and the only signal it gets is a nullptr that's indistinguishable from "layer out of range" or "layer served twice".
Either take positions in begin_step so admission really is decided in one place, or soften the doc and give the caller a way to tell the failures apart.
There was a problem hiding this comment.
I softened the comment instead of supplying positions twice.
| // its kind and mask, so the plan is per policy and memoized for the step. | ||
| // nullptr = no declaration, a token count disagreeing with it, a layer out of | ||
| // range, or a layer served twice. | ||
| class CellPlanner { |
There was a problem hiding this comment.
Three things about this face, expanding on my raw-pointer question below.
Name. SequencePlanner::plan (cache.h:99-107) is const, pure, and paired with an explicit commit(). This plan() is non-const, mutates the cell table, and is the commit — as the comment above says, "placing the cells is what commits them." Same verb, same slot in the design, opposite contract. A reader will assume it's safe to call speculatively. place_step() or similar.
Return type. The sibling face returns std::optional<SeqStepPlan>; this returns a raw pointer, and nullptr collapses four distinct failures (no declaration, token-count mismatch, layer out of range, layer served twice) into one value. cache_et.h also has an adapter for the optional and nothing for this.
Lifetime. The returned pointer points into plans_, which invalidate_plan() clears on every begin_step/seq_cp/seq_rm/clear. So the backend's pointer dangles the moment the runner touches a verb, and that rule isn't documented anywhere. Related: std::map is load-bearing here for reference stability — if someone later swaps it for a vector this silently becomes a use-after-free. Worth a comment saying so.
| // Precondition: begin_step admitted the step, so a free cell exists. | ||
| int lowest_free() const { | ||
| for (int i = 0; i < capacity_; ++i) { | ||
| if (pos_[i] < 0) { |
There was a problem hiding this comment.
This restarts the scan from index 0 for every token, so place() is O(capacity × n_tok) — a 512-token prefill into a 4096-cell pool is ~2M iterations per forward, and it runs on the first layer of every step.
The comment above notes the placement choice is semantically free ("the mask keys off pos/owners, never the index"), so this is pure implementation — a free list, or even just a rolling cursor that survives across claim() calls within a step, fixes it without changing any result.
There was a problem hiding this comment.
Added a rolling cursor.
| const CellStepPlan* step( | ||
| std::vector<int32_t> seq_ids, | ||
| std::vector<int32_t> positions) { | ||
| if (!ctl->begin_step(seq_ids.data(), static_cast<int>(seq_ids.size()))) { |
There was a problem hiding this comment.
This helper collapses two distinct failures into one nullptr: begin_step rejecting the step, and plan() rejecting it. That weakens the tests that depend on the distinction — in CellRejectsAPositionASequenceStillHolds, EXPECT_EQ(c.step({0}, {0}), nullptr) passes even if the admission gate had wrongly rejected it, which is the opposite of what that test means to assert.
ASSERT_TRUE the begin_step inside the helper and return only the plan. (This is also roughly what I was after with my other comment about a C++ helper.)
While here — row() just below indexes plan.mask_bits[...] unchecked, and on a fused plan mask_bits is empty, so that's UB the moment someone calls it on the wrong plan. An ASSERT_EQ(plan.kind, MaskKind::Explicit) would make it safe.
Coverage gaps, mapping the 9 tests against the branches in cell_cache.h — several of the riskiest paths have nothing on them:
seq_cpwithupto— the prefix-fork predicate atcell_cache.h:133is never exercised;CellForkSharesCellsAndEvictionRefcountsonly passesstd::nullopt. A whole documented feature with zero coverage.fused()boundaries — six early-outs, one test touching them. See my comment oncell_cache.h:334.- Windowed + multi-sequence together — every windowed test uses one sequence, every multi-sequence test is flat, so the
oldestbound inbuild_maskis never exercised alongside the owner-bit check. clear()— never called.can_extend()/capacity()— never called directly, only reached throughbegin_step.- Out-of-range layer in
plan()— neither negative nor>= n_layers. - A verb invalidating a memoized plan —
invalidate_plan()fromseq_cp/seq_rmis untested, and that's the subtle step-protocol behaviour worth pinning. seq_cp/seq_rmwith a bad seq id — the silent no-op path.mask_bits.size() == n_tok * read_lenis never asserted anywhere.
b1e5845 to
6bac422
Compare
30ed962 to
60978d7
Compare
|
| int seq_len(int32_t seq_id) const override; | ||
| int next_pos(int32_t seq_id) const override; | ||
|
|
||
| int free_cells() const; |
There was a problem hiding this comment.
Do users call these? Or are they private methods?
There was a problem hiding this comment.
next_pos is call ed to get the position to feed the model next, instead of tracking a counter.
seq_len doesn't have a caller yet, could be useful if scheduler has an eviction/trimming policy for long sequences. free_cells and used_end are only used by tests.
|
@claude review this code |
|
Claude finished @metascroy's task in 3m 35s —— View job Code review — cell layout (updated pass)
This is a re-review against the current tip ( Resolved since the last round
Remaining items1. Dead 2. 3. 4. Direct-construction validation is assert-only. 5. Minor / non-blocking
None of the remaining items are correctness-blocking — the design and invariants are sound and the test suite now covers the paths that worried me. Item #1 (dead fields) is the only thing I'd insist on before landing since it was already raised. |
|
Address the nits like "Dead SeqInfo::min_cell / max_cell" before landing |
Summary
Adds the cell layout to the neutral C++ cache. Cells are addressed absolutely: each holds a position and a bitset of the sequences that own it, so a sequence need not be contiguous, a fork sets a second bit instead of copying K/V, and a cell frees when its bitset empties.
plan()places a step's tokens once and every later layer of that forward reuses the placement. It classifies: a single sequence appending a contiguous run at the tail of a window it owns outright gets a fused kind and a run write; anything else— multi-sequence, forks, holes from removals — gets each token's cell plus a dense mask. Layers may window differently, so the mask is memoized per policy.
A step is declared before the forward through
begin_step, which is also the admission gate: it reports whether the tokens fit before any compute is spent. A step may only extend its sequences, and one that would rewrite a position a sequence still holds is refused rather than stored twice.Nothing constructs a
CellCacheyet — this is the neutral layer only. The existingSequenceCacheis untouched behaviourally.Files
extension/llm/cache/cache.h—CacheControlfactored out ofSequenceControlto holdcan_extend/capacity/clear; newBatchControl(the sequence verbs) andCellPlannerfaces;MaskKind; the fouras_*accessors now default tonullptrso a cache exposes only the faces it has.extension/llm/cache/cell_cache.h— new.CellStepPlan,CellPlanner,CellCache: placement, the seq verbs, classification, and the per-policy mask.extension/llm/cache/test/cache_test.cpp— 9 cell tests.Testing
cmake --build cmake-out --target extension_llm_cache_test && ctest --test-dir cmake-out -R extension_llm_cache --output-on-failureCovers the fused path, a second sequence forcing an explicit mask, one plan shared
across layers, a fork sharing cells and the refcounted free, ranged removal, holes
giving up the fused path, a rejected rewrite, layers sharing a window sharing a
plan, and the step protocol.