Repository navigation
Add a batched-sequence KV cache layout and reorganize the control faces - #22662
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22662
Note: Links to docs will display an error until the docs builds have been completed. ⏳ 2 Pending, 2 Unrelated FailuresAs of commit 54d4915 with merge base 3faa2e4 ( BROKEN TRUNK - The following jobs failed but were present on the merge base:👉 Rebase onto the `viable/strict` branch to avoid these failures
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
91cac31 to
cf88ee4
Compare
|
|
||
| // Lifecycle and admission, tensor-free. | ||
| // Lifecycle, tensor-free. Admission needs a subject, so it belongs to the | ||
| // faces below, which have one. |
There was a problem hiding this comment.
This comment is narrating why can_extend moved out of cache, but comments should not document history in this way. Keep them clear and concise and code self-documenting.
| virtual bool can_extend(int n = 1) const = 0; | ||
| // Truncate to new_len; false = cannot grow, or the target is older than an | ||
| // evicting layer still retains. | ||
| virtual bool rewind(int new_len) = 0; |
There was a problem hiding this comment.
Maybe call position? It's the same thing conceptually
| // the model was trained to. Sequences are bounded independently, so this | ||
| // holds per sequence across a whole step. | ||
| // False also = an unknown sequence. | ||
| virtual bool can_admit(int32_t seq_id, int n = 1) const = 0; |
There was a problem hiding this comment.
Should this have same signature as declare_step? So user doesn't have to translate between the two formats?
| virtual bool seq_rm(int32_t seq_id) = 0; | ||
| // Truncate one sequence to new_len. False = an unknown sequence, a target | ||
| // that would grow it, or one older than an evicting layer still retains. | ||
| virtual bool rewind(int32_t seq_id, int new_len) = 0; |
There was a problem hiding this comment.
maybe call as int position? Same thing conceptually
| // Truncate one sequence to new_len. False = an unknown sequence, a target | ||
| // that would grow it, or one older than an evicting layer still retains. | ||
| virtual bool rewind(int32_t seq_id, int new_len) = 0; | ||
| virtual int seq_len(int32_t seq_id) const = 0; // slots the sequence owns |
There was a problem hiding this comment.
Do we need both seq_len/next_pos?
Could we just have pos(int32_t seq_id) that reports the current position? This ties nicely with signature for rewind
There was a problem hiding this comment.
Removed seq_len and renamed next_pos
| // Positions any one sequence may reach, from the model rather than from the | ||
| // caller: past it the model was never trained. Bounds a sequence, where | ||
| // capacity bounds them all together. Unset = only capacity bounds a sequence. | ||
| std::optional<int> max_context; |
There was a problem hiding this comment.
Thinking more here, maybe cache shouldn't be in business of max_context and can_admit/can_extend should be purely capacity based?
There was a problem hiding this comment.
I agree, since can_admit was just checking max context, I removed that as well.
| // A fork: same capacity and policies, and the source's history. The byte | ||
| // layer copies the cells. Policies are rebuilt rather than shared, so the | ||
| // two sequences cannot disturb each other. | ||
| SequenceCache(const SequenceCache& other) |
There was a problem hiding this comment.
I suppose this is to support clone? If so, shouldn't we have an optional second arg for upto?
There was a problem hiding this comment.
Yes, it's for seq_clone, we copy the full history and then rewind, so it can be rejected for evicted parts.
| } | ||
| auto it = rows_.find(src); | ||
| if (it != rows_.end()) { | ||
| rows_.emplace(*dst, it->second); |
There was a problem hiding this comment.
upto is not being applied correctly
| // Apply `spec` to `q`: match the stored K/V to the query dtype, translate the | ||
| // mask kind into what MLX takes, and run SDPA. | ||
| inline Tensor | ||
| attend(const AttendSpec& spec, const Tensor& q, float scale, StreamOrDevice s) { |
There was a problem hiding this comment.
what is the point of AttendSpec here? My not just pass args directl?
There was a problem hiding this comment.
I think update_and_fetch is still useful to plan the runs, dtype and mask switch
| int at = 0; | ||
| for (const cache::SeqSpan& span : *spans) { | ||
| LayerPools& row = row_for(span.seq_id); | ||
| write_runs( |
There was a problem hiding this comment.
Is this correct for both sliding window and full linear attention?
Could we reuse the battle-tested logic in MLXSequenceCache.h by just having batched sequence cache hold std::map<seq_id, MLXSequenceCache>, then just directly calling attend in the for loop?
| "KV storage dtype: bf16, fp16, or fp32"); | ||
| DEFINE_string( | ||
| cache_kind, | ||
| ::executorch::extension::llm::cache::kind::kBatchedCell, |
There was a problem hiding this comment.
why is batchedcell default when you said batchedsequence was faster?
|
In batched_sequence_cache.cpp, BatchControl::declare_step()’s contract that a rejected declaration changes nothing. After a valid declaration, attempting one with an unknown sequence ID clears spans_ and declared_, so the previously valid step can no longer be placed. Build and validate candidate spans first, then replace the active declaration only on success. This could be really simple if can_admit and declare_step had the same signature. Then declare_step could rely on can_admit for validation by calling it first thing. |
|
@claude review this code |
|
Claude finished @metascroy's task in 5m 21s —— View job Code ReviewI read the new control faces ( 1.
|
4a6adfd to
b7dbde1
Compare
b7dbde1 to
ed94d84
Compare
| return std::nullopt; | ||
| } | ||
|
|
||
| MLXSequenceCache fork(it->second); |
There was a problem hiding this comment.
Should the constructor itself take upto? THis is unecessarily copying the whole history and then rewinding, but we only need to copy the slice
|
In MLXBatchedSequenceCache.h:94–99, fork.rewind(len) lowers the logical length without preserving the physical retention floor. With window=4 and max_write=1, decode A to length 10, clone A at length 9 into B, then clone B at length 8 into C. Both clones succeed, but the copied ring contains only positions 6–9. C’s next attention at position 8 needs positions 5–8 and instead reads position 9’s KV from position 5’s slot. Preserve the actual retained-history bound across copies and rewinds so the second fork is rejected. |
b8660ce to
54d4915
Compare
| if (upto <= 0 || !rewind(upto)) { | ||
| throw std::runtime_error("fork: position is not one this can rewind to"); | ||
| } | ||
| kpool_.reserve(other.kpool_.size()); |
There was a problem hiding this comment.
should we be reserving other.size?
something like max(initial_capacity, upto)?
There was a problem hiding this comment.
This is actually the vector's element count: one Pool per layer, so n_layers. The slot sizing is separate, in clone_prefix: max(upto, initial_slots)
Summary
A third KV cache layout: many sequences, each over its own private history, sharing one capacity. A step still arrives flat on the token axis, but is answered per sequence. Against the cell layout on llama-1B/M4 Max it crosses over around B=8 and is 17–36% faster at B=32; below that it costs 2–5%.
Files
extension/llm/cache/cache.h — CacheControl down to capacity/clear; can_extend(n) on SequenceControl; seq_rm(id), rewind(id, new_len), can_admit(id, n), max_seqs() on BatchControl.
extension/llm/cache/cell_cache.{h,cpp} — the same interface on the incumbent: seq_rm/rewind over a shared drop_from, can_admit keyed off next_pos, max_seqs = kMaxSeqs.
extension/llm/cache/sequence_cache.h — can_extend gains the max_context term; copy constructor and length() so a batched row can fork.
extension/llm/cache/cache_registry.h, CMakeLists.txt, README.md — kind::kBatchedSequence, the new .cpp in extension_llm_cache, the renamed verb in the example.
backends/mlx/runtime/MLXCache.h — attend(layer, positions, q, k, v, scale, s) becomes the single virtual; a free attend(AttendSpec, q, scale, s) holds the dtype match and mask-mode switch, so a layout answering per sequence needn't materialise every sequence's history first.
backends/mlx/runtime/MLXSequenceCache.h — gains attend(), which runs update_and_fetch and hands the spec to the shared SDPA helper.
backends/mlx/runtime/MLXBatchedSequenceCache.h — the new layout. Implements BatchControl over std::map<seq_id, MLXSequenceCache>, so each sequence's bookkeeping and pools are the single-sequence cache that already existed.
backends/mlx/runtime/MLXCellCache.h, MLXInterpreter.h, MLXBackend.cpp — cell gets attend over its update_and_fetch, exec_update_and_attend shrinks to one call, the third builder is registered.
backends/mlx/examples/llm/run_llm_batched.cpp — --cache_kind, defaulting to batched-cell.
extension/llm/cache/test/cache_test.cpp, backends/mlx/test/mlx_batched_sequence_cache_test.cpp, backends/mlx/test/CMakeLists.txt, mlx_cell_cache_test.cpp — neutral tests for spans, placement, forks, context and admission; op-level parity test whose oracle is an MLXSequenceCache fed the same tokens.
extension/llm/cache/reference_cache.py, test_update_and_attend.py — the eager oracle tracks the same interface: max_context, can_admit(seq_id, n), and a next_pos on the cell reference, which didn't have one.run-length-encoding ids into spans, place_step planning each span against that sequence's own SequenceCache, seq_clone copying a prefix.
extension/llm/cache/cell_cache.{h,cpp} — the same interface on the incumbent: seq_rm/rewind over a shared drop_from, can_admit keyed off next_pos rather than seq_len (positions need not be consecutive there), max_seqs = kMaxSeqs.
extension/llm/cache/sequence_cache.h — can_extend gains the max_context term; copy constructor and length() so a batched row can fork.
extension/llm/cache/cache_registry.h, CMakeLists.txt, README.md — kind::kBatchedSequence, the new .cpp in extension_llm_cache, the renamed verb in the example.
backends/mlx/runtime/MLXCache.h — attend(layer, positions, q, k, v, scale, s) becomes the single virtual; a free attend(AttendSpec, q, scale, s) holds the dtype match and mask-mode switch, so a layout answering per sequence needn't materialise every sequence's history first.
backends/mlx/runtime/MLXSequenceCache.h — mostly relocation: write_runs, read_runs, LayerPools, make_pools, spec_for from private members to free functions, bodies byte-identical; three pool members collapse into one LayerPools; attend() added over the existing update_and_fetch. Read with --color-moved=zebra. Flat pools now cap at min(capacity, max_context).
backends/mlx/runtime/MLXBatchedSequenceCache.h — the byte layer: map<seq_id, LayerPools> built lazily on a sequence's first write, attend looping the spans and concatenating in declared order, seq_clone copying the donor's pools.
backends/mlx/runtime/MLXCellCache.h, MLXInterpreter.h, MLXBackend.cpp — cell gets attend over its update_and_fetch, exec_update_and_attend shrinks to one call, the third builder is registered.
backends/mlx/examples/llm/run_llm_batched.cpp — --cache_kind, defaulting to batched-cell.
extension/llm/cache/test/cache_test.cpp, backends/mlx/test/mlx_batched_sequence_cache_test.cpp, backends/mlx/test/CMakeLists.txt, mlx_cell_cache_test.cpp — neutral tests for spans, placement, forks, context and admission; op-level parity test whose oracle is an MLXSequenceCache fed the same tokens.
Testing
extension_llm_cache_test 51, mlx_batched_sequence_cache_test 9, mlx_cell_cache_test 11, mlx_sequence_cache_test 16, test_update_and_attend.py 59.