[MLX] Add off-graph KV-cache flat runtime - #21501
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21501
Note: Links to docs will display an error until the docs builds have been completed. ⏳ No Failures, 25 PendingAs of commit 83085d9 with merge base e0c610a ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
| causal: bool = false; | ||
| } | ||
|
|
||
| table UpdateAndAttendNode { |
There was a problem hiding this comment.
Didn't the op have an output dtype?
| // write is a slice_update run, a read a [0, len) slice. H/D inferred on write. | ||
| class FlatPool { | ||
| public: | ||
| void write(int start, const Tensor& kv, int capacity, StreamOrDevice s) { |
| // write is a slice_update run, a read a [0, len) slice. H/D inferred on write. | ||
| class FlatPool { | ||
| public: | ||
| void write(int start, const Tensor& kv, int capacity, StreamOrDevice s) { |
There was a problem hiding this comment.
Why is capacity a property of write?
| buf_ = ::mlx::core::slice_update(*buf_, kv, to_shape(lo), to_shape(hi), s); | ||
| } | ||
|
|
||
| Tensor read(int len, StreamOrDevice s) const { |
There was a problem hiding this comment.
Is return type Tensor or ::mlx::array?
There was a problem hiding this comment.
Tensor is the runtime's existing alias for ::mlx::core::array (in MLXExecutor.h), so I used it as in there.
88761ae to
9321153
Compare
|
Recent changes:
|
| : cache::SequenceCache(cfg) { | ||
| const ::mlx::core::Dtype dt = cfg.kv_dtype | ||
| ? resolve_dtype(static_cast<int8_t>(*cfg.kv_dtype)) | ||
| : ::mlx::core::float16; |
There was a problem hiding this comment.
Raise if dtype not specified.
| // position vector is tiny). Cast to int32 so an int64 position also works. | ||
| static int read_start(const Tensor& position, StreamOrDevice s) { | ||
| Tensor p = ::mlx::core::astype(position, ::mlx::core::int32, s); | ||
| ::mlx::core::eval(p); |
There was a problem hiding this comment.
I'm quite worried about this. It breaks async evalution.
Can we lift this logic to exec_update_and_attend at least and pass an int down. I think it'll be easier to correct at that level if needed.
9321153 to
4d88209
Compare
| #include "MLXFlatCache.h" | ||
|
|
||
| #include <mlx/mlx.h> | ||
|
|
There was a problem hiding this comment.
We also need something to run the c++ test in CI. You might need to modify mlx.yml
ad06aae to
b27b89c
Compare
b27b89c to
011bd32
Compare
| FlatPool(int capacity, int H, int D, ::mlx::core::Dtype dtype) | ||
| : dtype_(dtype), | ||
| buf_(::mlx::core::zeros( | ||
| to_shape(std::vector<int>{1, H, capacity, D}), |
There was a problem hiding this comment.
construct shape directly? Why go through vector?
There was a problem hiding this comment.
I guess it is a leftover from lazy first write, fixing it.
| // over per-layer FlatPools. update_and_fetch plans the step (integer runs), | ||
| // writes the new K/V, reads the retained window, and declares the mask; the op | ||
| // handler owns q/scale and calls SDPA. | ||
| class MLXFlatCache : public cache::SequenceCache, public MLXCacheImpl { |
There was a problem hiding this comment.
Curious: what is the rational for the indirection here? Why have MLXCacheImpl?
Isn't MLXFlatCache an impl itself?
| std::optional<int> max_write; | ||
| // ET ScalarType storage precision; a backend that stores K/V may require it | ||
| // (MLX raises if unset). | ||
| std::optional<int> kv_dtype; |
There was a problem hiding this comment.
If it's required field, why make it optional?
| // handler owns q/scale and calls SDPA. | ||
| class MLXFlatCache : public cache::SequenceCache, public MLXCacheImpl { | ||
| public: | ||
| explicit MLXFlatCache(const cache::CacheConfig& cfg) |
There was a problem hiding this comment.
Something about this seems a bit off. In sequence cache we have have ring/flat/etc as different policies on the same class b/c different layers of the cache have different policies.
But in the implementation here, you've made flatcache as the top-level (instead of something like mlxsequencecache), and ignored the layer-by-layer policy on cfg, and assumed all are flat.
How do you intend to extend this to ring in the next PR?
There was a problem hiding this comment.
Right, now I restructured to address ring as well. Renamed MLXFlatCache to MLXSequenceCache and the constructor reads lc.policy per layer. Does it look like the right structure?
| std::nullopt, | ||
| std::nullopt, | ||
| s); | ||
| array ref0 = fast::scaled_dot_product_attention( |
There was a problem hiding this comment.
What is the point of running SDPA twice here? Don't you just need to test the spec0.K = k0 etc?
There was a problem hiding this comment.
True, changing it.
|
@claude review this code |
|
Claude finished @metascroy's task in 5m 40s —— View job Code ReviewReviewed the full diff against Correctness / behavior1. 2. Per-layer host sync remains (MLXInterpreter.h:311–313). You lifted the 3. Design (raised by @metascroy — I concur)4. 5. Minor6. 7. 8. Prefill test runs SDPA twice (mlx_flat_cache_test.cpp:94–105). For the prefill case Nothing here is a blocker for a scoped runtime PR. #1 (scale default) and #5 (the cross-cast reachability) are the two I'd want confirmed before the op goes live. |
| // it differs. | ||
| // | ||
| // Each write chains a lazy slice_update onto the previous buf_. That stays | ||
| // bounded only because the caller eval()s the step's output, which |
There was a problem hiding this comment.
I don't think this comment is accurate
|
@claude review this code |
|
Claude finished @metascroy's task in 1m 34s —— View job Code ReviewRe-reviewed the full diff ( A few remaining observations, none blocking: 1. No bounds check on 2. 3. 4. Minor — Nothing here blocks the merge. #1 and #2 are the two cheap hardening steps I'd fold in — both close silent-corruption paths in the same spirit as the |
bbc36b8 to
1e895e4
Compare
Summary
Adds the MLX runtime for the off-graph KV cache: a flat (full-history) cache plus the update_and_attend op handler, wiring the neutral SequenceCache/FlatPolicy bookkeeping to real MLX tensors. The graph carries a cache-agnostic UpdateAndAttendNode — no cache operand, since the cache is off-graph runtime state keyed by layer_id. At runtime the handler asks the cache for the K/V window and mask kind (AttendSpec) and calls SDPA: the cache owns KV byte movement + attention semantics, the handler owns q/scale + the SDPA call. Single-sequence, fp16, full-history; the per-query mask stays in the backend as MLX's fused "causal"/none (no materialized tensor).
Files
Testing
C++ op-level test: drives MLXFlatCache::update_and_fetch directly and attends the returned AttendSpec exactly as the handler does, comparing to MLX SDPA over the full K/V history the cache should have assembled, prefill (Causal), decode (None), and the capacity-reject path. Runs on Apple Silicon (MLX SDPA needs the Metal backend). Built and ran via the standard flow, passes:
cmake --preset mlx-release -DEXECUTORCH_BUILD_TESTS=ON
cmake --build cmake-out --target mlx_flat_cache_test
ctest --test-dir cmake-out -R mlx_flat_cache --output-on-failure