[MLX] Add the cell KV-cache layout to the MLX runtime - #22167
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22167
Note: Links to docs will display an error until the docs builds have been completed. ❌ 1 New Failure, 2 PendingAs of commit cec6bf6 with merge base f3982af ( 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 1m 56s —— View job Code Review — [MLX] Cell KV-cache layout
Overall this is clean, well-scoped, and reads like its neighbors — the 1.
|
| // Max absolute difference within tolerance. Computed in float32: item<float>() | ||
| // reads sizeof(float) bytes, so calling it on an fp16 scalar misreads the | ||
| // buffer. | ||
| bool allclose(const array& a, const array& b, float atol) { |
There was a problem hiding this comment.
Did I see a similar helper in one of your previous PRs? If so, let's factor out into test/utils.h and reuse
| // A window bounds what a query attends, not where its token lives, so | ||
| // every layer spans the whole cell table whatever its policy. | ||
| kpool_.emplace_back( | ||
| cfg.initial_capacity, cfg.capacity, lc.n_kv_heads, lc.head_dim, dt); |
There was a problem hiding this comment.
Is capacity here capacity across all sequences? So even for windowed layers, total capacity can exceed the window size?
There was a problem hiding this comment.
Capacity is for total tokens across all sequences. Yes we have the same pool size for windowed layers because we have a global cell table not a table per policy. place() claims one cell index per token per forward, and every layer's K/V pools are addressed by the same index. Then we mask according to window for each sequence.
|
On claude's review; put_along_axis casts the update to the destination's dtype inside scatter_axis (mlx/ops.cpp:3642). We have a test case StorageDtypeDiffersCastsOnWrite writes fp32 into a bf16 pool and passes. |
I think Claude's point is to just be explicit about it, rather than rely on type promotion (which I agree with). Stamping b/c I think it looks good, but consider the explicit cast |
| } | ||
|
|
||
| // A single unwindowed layer, so a step's slots are one run over the capacity. | ||
| inline ::executorch::extension::llm::cache::CacheConfig flat_config( |
There was a problem hiding this comment.
Why are ring_config/flat_config test utils?
Couldn't the contructor on CacheConfig do what they do?
There was a problem hiding this comment.
CacheConfig doesn't have a constructor, it's an aggregate
Summary
MLXCellCacheturns aCellStepinto a scatter over the cell axis and materialises the step's mask bits as an explicit SDPA mask, one row per query token.Poolgrows a matchingwrite_cellsnext to its existing contiguouswrite, keeping the pool layout-agnostic: layouts differ only in which slots they ask for. The layout is registered as the"cell"builder alongside"seq", which is how a runner selects one.Files
backends/mlx/runtime/MLXPool.h—write_cells, the scatter primitivebackends/mlx/runtime/MLXCellCache.h— new; theCellStepperface overPoolbackends/mlx/runtime/MLXBackend.cpp— registers the"cell"builderbackends/mlx/test/mlx_cell_cache_test.cpp— new; 11 testsbackends/mlx/test/CMakeLists.txt,.github/workflows/mlx.yml— build and run itTest
mlx_cell_cache_test, 11 tests. Covers cell claiming on prefill and decode, the mask isolating sequences, a freed cellrefilling below a live one (the case no contiguous write can express, so the only real exercise of the scatter), a windowed layer hiding older cells, pool growth preserving written cells, storage-dtype casts, the step-verb contract, capacity refusal, and the registry lookup by
(backend_id, kind).Added to the existing
test-mlxCI job.