Skip to content

Commit 8011520

Browse files
Split the cache's model geometry from its runtime config (pytorch#22763)
CacheConfig split in two: CacheGeometry holds the immutable per-layer facts a model was built with, CacheConfig keeps only what a runtime chooses — capacity, dtype, initial capacity, max_write. The export publishes the geometry through write_cache_geometry; read_cache_geometry reads it back at load, so run_llm_hf no longer takes --kv-cache or --kv-cache-windows. The broadcast form, a layers list of size 1 standing for every layer, is gone. Read extension/llm/cache/cache.h for the split and extension/llm/runner/model_metadata.h for the reader; the MLX files are call sites following the new constructor signature, except run_llm_hf.cpp, which loses its geometry flags. Tests extension/llm/runner/test/test_model_metadata.cpp extension/llm/runner/test/export_model_metadata.py llama-3.2-1B, gemma-3-1B and gemma-4-E2B all export and generate.
1 parent 9939f2b commit 8011520

27 files changed

Lines changed: 512 additions & 376 deletions

‎backends/mlx/examples/llm/export_llm_hf.py‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from executorch.extension.llm.export.model_metadata import (
4646
model_vocab_size,
4747
write_activation_dtype,
48+
write_cache_geometry,
4849
write_logits_to_keep_mode,
4950
write_max_context_len,
5051
write_max_seq_len,
@@ -522,12 +523,7 @@ def _export_with_offgraph_cache(
522523
max_seq_len=prefill_chunk_size,
523524
)
524525
kv_metadata.update(
525-
{
526-
"get_n_caches": len(layer_types),
527-
"get_kv_heads": torch.tensor(cache_kv_heads, dtype=torch.int32),
528-
"get_head_dims": torch.tensor(cache_head_dims, dtype=torch.int32),
529-
"get_windows": torch.tensor(cache_windows, dtype=torch.int32),
530-
}
526+
write_cache_geometry(cache_kv_heads, cache_head_dims, cache_windows)
531527
)
532528
logger.info(
533529
f"KV cache layout: {len(layer_types)} caches, "

‎backends/mlx/examples/llm/run_llm_hf.cpp‎

Lines changed: 23 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ using ::executorch::extension::Module;
112112
using ::executorch::extension::llm::check_vocab_size;
113113
using ::executorch::extension::llm::LogitsToKeepMode;
114114
using ::executorch::extension::llm::read_activation_dtype;
115+
using ::executorch::extension::llm::read_cache_geometry;
115116
using ::executorch::extension::llm::read_logits_to_keep_mode;
116117
using ::executorch::extension::llm::read_max_seq_len;
117118
using ::executorch::extension::llm::read_vocab_size;
@@ -154,17 +155,6 @@ bool parse_int_list(
154155
return true;
155156
}
156157

157-
// Constant methods the export publishes (get_n_caches and friends). They carry
158-
// no delegate, so reading them only needs the program loaded -- which is what
159-
// lets the cache be built before forward's backend init consumes its key.
160-
std::optional<int64_t> const_int(Module& module, const char* name) {
161-
const auto r = module.execute(name);
162-
if (!r.ok() || r->empty() || !r->at(0).isInt()) {
163-
return std::nullopt;
164-
}
165-
return r->at(0).toInt();
166-
}
167-
168158
// The sampler (sample_from_logits) fatally aborts on any other dtype, so an
169159
// unsupported logits type must be rejected at startup rather than at inference.
170160
bool is_supported_logits_type(::executorch::aten::ScalarType type) {
@@ -212,54 +202,6 @@ bool validate_forward_abi(
212202
return true;
213203
}
214204

215-
std::optional<std::vector<int>> const_ints(Module& module, const char* name) {
216-
const auto r = module.execute(name);
217-
if (!r.ok() || r->empty() || !r->at(0).isTensor()) {
218-
return std::nullopt;
219-
}
220-
const auto t = r->at(0).toTensor();
221-
if (t.scalar_type() != ::executorch::aten::ScalarType::Int) {
222-
return std::nullopt;
223-
}
224-
const int32_t* p = t.const_data_ptr<int32_t>();
225-
return std::vector<int>(p, p + t.numel());
226-
}
227-
228-
// Fill in the cache geometry the export published: get_n_caches, then one
229-
// entry per cache in get_kv_heads / get_head_dims / get_windows (0 = flat).
230-
// Capacity and dtype stay with the flags. False means this is not an off-graph
231-
// model.
232-
bool read_kv_layout(
233-
Module& module,
234-
int prefill_chunk,
235-
cache::CacheConfig& cfg) {
236-
const auto n_caches = const_int(module, "get_n_caches");
237-
const auto kv_heads = const_ints(module, "get_kv_heads");
238-
const auto head_dims = const_ints(module, "get_head_dims");
239-
const auto windows = const_ints(module, "get_windows");
240-
if (!n_caches || !kv_heads || !head_dims || !windows) {
241-
return false;
242-
}
243-
cfg.max_write = prefill_chunk;
244-
const size_t n = static_cast<size_t>(*n_caches);
245-
if (kv_heads->size() != n || head_dims->size() != n || windows->size() != n) {
246-
return false;
247-
}
248-
cfg.n_layers = static_cast<int>(n);
249-
cfg.layers.clear();
250-
cfg.layers.reserve(n);
251-
for (size_t l = 0; l < n; ++l) {
252-
cache::LayerConfig lc{};
253-
lc.n_kv_heads = (*kv_heads)[l];
254-
lc.head_dim = (*head_dims)[l];
255-
lc.policy = (*windows)[l] > 0
256-
? cache::LayerPolicy{cache::LayerPolicy::Kind::Ring, (*windows)[l]}
257-
: cache::LayerPolicy{cache::LayerPolicy::Kind::Flat, 0};
258-
cfg.layers.push_back(lc);
259-
}
260-
return true;
261-
}
262-
263205
// Replace the model's own attention pattern with `spec`, a comma-separated list
264206
// of windows repeating over the caches (0 = flat). One entry makes every layer
265207
// sliding. Only the policy changes; each cache keeps the geometry the .pte
@@ -268,14 +210,17 @@ bool read_kv_layout(
268210
// The export sizes the chunk to the model's own window; narrowing the window
269211
// here would leave the ring (window + max_write - 1) sized by the chunk
270212
// instead, so the chunk follows the window down.
271-
bool apply_window_override(const std::string& spec, cache::CacheConfig& cfg) {
213+
bool apply_window_override(
214+
const std::string& spec,
215+
cache::CacheGeometry& geometry,
216+
cache::CacheConfig& cfg) {
272217
std::vector<int> pattern;
273218
if (!parse_int_list(spec, ',', pattern) || pattern.empty()) {
274219
return false;
275220
}
276-
for (size_t l = 0; l < cfg.layers.size(); ++l) {
221+
for (size_t l = 0; l < geometry.layers.size(); ++l) {
277222
const int w = pattern[l % pattern.size()];
278-
cfg.layers[l].policy = w > 0
223+
geometry.layers[l].policy = w > 0
279224
? cache::LayerPolicy{cache::LayerPolicy::Kind::Ring, w}
280225
: cache::LayerPolicy{cache::LayerPolicy::Kind::Flat, 0};
281226
}
@@ -288,7 +233,7 @@ bool apply_window_override(const std::string& spec, cache::CacheConfig& cfg) {
288233
if (cfg.max_write && narrowest > 0 && narrowest < *cfg.max_write) {
289234
cfg.max_write = narrowest;
290235
}
291-
return cache::valid(cfg);
236+
return cache::valid(geometry, cfg);
292237
}
293238

294239
// Human-readable name for a kv_dtype (an ET ScalarType int). Only the
@@ -310,14 +255,14 @@ std::string dtype_name(int st) {
310255
// Announce the cache shape: the same .pte runs under whatever config this
311256
// invocation asks for -- capacity, storage dtype, flat/ring layers -- with no
312257
// re-export. The footprint lines printed later then show it growing at runtime.
313-
void print_cache_summary(const cache::CacheConfig& cfg) {
258+
void print_cache_summary(
259+
const cache::CacheGeometry& geometry,
260+
const cache::CacheConfig& cfg) {
314261
// Ring layers grouped by window: --kv-windows can give each layer its own,
315262
// and the pools are sized per layer, so a single number would misreport them.
316263
std::map<int, int> ring;
317264
int flat = 0;
318-
for (int l = 0; l < cfg.n_layers; ++l) {
319-
const cache::LayerConfig& lc =
320-
cfg.layers.size() == 1 ? cfg.layers.front() : cfg.layers[l];
265+
for (const cache::LayerGeometry& lc : geometry.layers) {
321266
if (lc.policy.kind == cache::LayerPolicy::Kind::Ring) {
322267
++ring[lc.policy.window];
323268
} else {
@@ -330,7 +275,8 @@ void print_cache_summary(const cache::CacheConfig& cfg) {
330275
if (cfg.max_write) {
331276
std::cout << " max_write=" << *cfg.max_write;
332277
}
333-
std::cout << "\n " << cfg.n_layers << " layers: " << flat << " flat";
278+
std::cout << "\n " << geometry.layers.size() << " layers: " << flat
279+
<< " flat";
334280
for (const auto& [window, n] : ring) {
335281
std::cout << " + " << n << " ring(window " << window << ")";
336282
}
@@ -743,16 +689,19 @@ int main(int argc, char** argv) {
743689
<< " (bf16|fp16|fp32)" << std::endl;
744690
return 1;
745691
}
746-
if (!read_kv_layout(module, prefill_chunk, cfg)) {
747-
std::cerr << "No KV cache layout in " << pte
692+
auto geometry = read_cache_geometry(module);
693+
if (!geometry.ok()) {
694+
std::cerr << "No valid KV cache geometry in " << pte
748695
<< "; re-export with --use-offgraph-cache" << std::endl;
749696
return 1;
750697
}
751-
if (!kv_windows.empty() && !apply_window_override(kv_windows, cfg)) {
698+
cfg.max_write = prefill_chunk;
699+
if (!kv_windows.empty() &&
700+
!apply_window_override(kv_windows, *geometry, cfg)) {
752701
std::cerr << "Invalid --kv-windows: " << kv_windows << std::endl;
753702
return 1;
754703
}
755-
if (!cache::valid(cfg)) {
704+
if (!cache::valid(*geometry, cfg)) {
756705
std::cerr << "Invalid cache config" << std::endl;
757706
return 1;
758707
}
@@ -762,7 +711,7 @@ int main(int argc, char** argv) {
762711

763712
const char* const cache_kind = cache::kind::kSingle;
764713
auto built = cache::CacheFactory::global().build(
765-
::executorch::backends::mlx::kMLXBackendId, cache_kind, cfg);
714+
::executorch::backends::mlx::kMLXBackendId, cache_kind, *geometry, cfg);
766715
if (!built.ok()) {
767716
std::cerr << "Failed to build cache: " << static_cast<int>(built.error())
768717
<< std::endl;
@@ -775,7 +724,7 @@ int main(int argc, char** argv) {
775724
// load_method() inside it.
776725
const cache::InstallGuard guard{kv};
777726

778-
print_cache_summary(cfg);
727+
print_cache_summary(*geometry, cfg);
779728
if (guard.set_option(mlx_opts) != Error::Ok ||
780729
options_map.set_options(
781730
::executorch::backends::mlx::kMLXBackendId, mlx_opts.view()) !=

‎backends/mlx/llm/cache.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,23 @@ def resolve_hf_cache_layout(config):
5353
else:
5454
layer_types = list(layer_types)
5555

56-
if hasattr(text_config, "num_kv_shared_layers"):
57-
layer_types = layer_types[: -text_config.num_kv_shared_layers]
58-
59-
if hasattr(text_config, "global_head_dim"):
56+
shared = getattr(text_config, "num_kv_shared_layers", 0)
57+
if shared:
58+
layer_types = layer_types[:-shared]
59+
60+
per_layer = getattr(text_config, "per_layer_config", None)
61+
if per_layer is not None:
62+
# Entries index from 0, as layer_types does after the truncation.
63+
head_dims = [per_layer[i].head_dim for i in range(len(layer_types))]
64+
num_heads = [
65+
getattr(
66+
per_layer[i],
67+
"num_key_value_heads",
68+
per_layer[i].num_attention_heads,
69+
)
70+
for i in range(len(layer_types))
71+
]
72+
elif hasattr(text_config, "global_head_dim"):
6073
head_dims = [
6174
(
6275
text_config.global_head_dim

‎backends/mlx/runtime/MLXBackend.cpp‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,11 @@ static auto success_with_compiler = register_backend(backend);
608608
// Cache it installs under a cache_key. Adding a kind is a new builder here.
609609
const int cache_builders_registered = [] {
610610
const Error single = cache::CacheFactory::global().register_builder(
611-
kMLXBackendId, cache::kind::kSingle, [](const cache::CacheConfig& cfg) {
611+
kMLXBackendId,
612+
cache::kind::kSingle,
613+
[](const cache::CacheGeometry& geometry, const cache::CacheConfig& cfg) {
612614
return std::shared_ptr<cache::Cache>(
613-
std::make_shared<MLXSequenceCache>(cfg));
615+
std::make_shared<MLXSequenceCache>(geometry, cfg));
614616
});
615617
ET_CHECK_MSG(
616618
single == Error::Ok,
@@ -620,9 +622,9 @@ const int cache_builders_registered = [] {
620622
const Error batched_cell = cache::CacheFactory::global().register_builder(
621623
kMLXBackendId,
622624
cache::kind::kBatchedCell,
623-
[](const cache::CacheConfig& cfg) {
625+
[](const cache::CacheGeometry& geometry, const cache::CacheConfig& cfg) {
624626
return std::shared_ptr<cache::Cache>(
625-
std::make_shared<MLXCellCache>(cfg));
627+
std::make_shared<MLXCellCache>(geometry, cfg));
626628
});
627629
ET_CHECK_MSG(
628630
batched_cell == Error::Ok,
@@ -632,9 +634,9 @@ const int cache_builders_registered = [] {
632634
const Error batched_seq = cache::CacheFactory::global().register_builder(
633635
kMLXBackendId,
634636
cache::kind::kBatchedSequence,
635-
[](const cache::CacheConfig& cfg) {
637+
[](const cache::CacheGeometry& geometry, const cache::CacheConfig& cfg) {
636638
return std::shared_ptr<cache::Cache>(
637-
std::make_shared<MLXBatchedSequenceCache>(cfg));
639+
std::make_shared<MLXBatchedSequenceCache>(geometry, cfg));
638640
});
639641
ET_CHECK_MSG(
640642
batched_seq == Error::Ok,
@@ -643,9 +645,11 @@ const int cache_builders_registered = [] {
643645
cache::kind::kBatchedSequence);
644646
// The layout kBatched points at, also registered under its own name above.
645647
const Error batched = cache::CacheFactory::global().register_builder(
646-
kMLXBackendId, cache::kind::kBatched, [](const cache::CacheConfig& cfg) {
648+
kMLXBackendId,
649+
cache::kind::kBatched,
650+
[](const cache::CacheGeometry& geometry, const cache::CacheConfig& cfg) {
647651
return std::shared_ptr<cache::Cache>(
648-
std::make_shared<MLXBatchedSequenceCache>(cfg));
652+
std::make_shared<MLXBatchedSequenceCache>(geometry, cfg));
649653
});
650654
ET_CHECK_MSG(
651655
batched == Error::Ok,

‎backends/mlx/runtime/MLXBatchedSequenceCache.h‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ class MLXBatchedSequenceCache : public cache::Cache,
3333
public cache::BatchControl,
3434
public MLXCache {
3535
public:
36-
explicit MLXBatchedSequenceCache(const cache::CacheConfig& cfg)
37-
: cfg_(checked(cfg)) {}
36+
MLXBatchedSequenceCache(
37+
const cache::CacheGeometry& geometry,
38+
const cache::CacheConfig& cfg)
39+
: geometry_(checked(geometry, cfg)), cfg_(cfg) {}
3840

3941
int capacity() const override {
4042
return cfg_.capacity;
@@ -74,7 +76,7 @@ class MLXBatchedSequenceCache : public cache::Cache,
7476

7577
std::optional<int32_t> seq_new() override {
7678
const int32_t id = free_id();
77-
rows_.try_emplace(id, cfg_);
79+
rows_.try_emplace(id, geometry_, cfg_);
7880
return id;
7981
}
8082

@@ -193,7 +195,7 @@ class MLXBatchedSequenceCache : public cache::Cache,
193195
const Tensor& q,
194196
const Tensor& k,
195197
const Tensor& v) const {
196-
if (layer < 0 || layer >= cfg_.n_layers) {
198+
if (layer < 0 || layer >= static_cast<int>(geometry_.layers.size())) {
197199
throw std::out_of_range("attend: layer out of range");
198200
}
199201
if (q.ndim() != 4 || k.ndim() != 4 || v.ndim() != 4) {
@@ -278,13 +280,17 @@ class MLXBatchedSequenceCache : public cache::Cache,
278280
placed_ = false;
279281
}
280282

281-
static const cache::CacheConfig& checked(const cache::CacheConfig& cfg) {
282-
if (!cache::valid(cfg)) {
283-
throw std::runtime_error("MLXBatchedSequenceCache: invalid CacheConfig");
283+
static const cache::CacheGeometry& checked(
284+
const cache::CacheGeometry& geometry,
285+
const cache::CacheConfig& cfg) {
286+
if (!cache::valid(geometry, cfg)) {
287+
throw std::runtime_error(
288+
"MLXBatchedSequenceCache: invalid geometry or config");
284289
}
285-
return cfg;
290+
return geometry;
286291
}
287292

293+
cache::CacheGeometry geometry_;
288294
cache::CacheConfig cfg_;
289295
std::map<int32_t, MLXSequenceCache> rows_;
290296
// Set at init by the delegate that resolved this cache.

‎backends/mlx/runtime/MLXCellCache.h‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,15 @@ namespace cache = ::executorch::extension::llm::cache;
3434
// prefill does, and neither is what MLX's fused "causal" describes.
3535
class MLXCellCache : public cache::CellCache, public MLXCache {
3636
public:
37-
explicit MLXCellCache(const cache::CacheConfig& cfg)
38-
: cache::CellCache(checked(cfg)) {
37+
MLXCellCache(
38+
const cache::CacheGeometry& geometry,
39+
const cache::CacheConfig& cfg)
40+
: cache::CellCache(checked(geometry, cfg), cfg) {
3941
const ::mlx::core::Dtype dt =
4042
resolve_dtype(static_cast<int8_t>(cfg.kv_dtype));
41-
kpool_.reserve(static_cast<size_t>(cfg.n_layers));
42-
vpool_.reserve(static_cast<size_t>(cfg.n_layers));
43-
for (int l = 0; l < cfg.n_layers; ++l) {
44-
// layers size 1 = one config broadcast to every layer, else per-layer.
45-
const cache::LayerConfig& lc =
46-
cfg.layers.size() == 1 ? cfg.layers.front() : cfg.layers[l];
43+
kpool_.reserve(geometry.layers.size());
44+
vpool_.reserve(geometry.layers.size());
45+
for (const cache::LayerGeometry& lc : geometry.layers) {
4746
// A window bounds what a query attends, not where its token lives, so
4847
// every layer spans the whole cell table whatever its policy.
4948
kpool_.emplace_back(
@@ -116,12 +115,14 @@ class MLXCellCache : public cache::CellCache, public MLXCache {
116115

117116
// Enforce the neutral contract as an exception, the failure mode this layer
118117
// already uses. Runs as the base initializer's argument because CellCache's
119-
// own ctor indexes `layers` before this class's body does.
120-
static const cache::CacheConfig& checked(const cache::CacheConfig& cfg) {
121-
if (!cache::valid(cfg)) {
122-
throw std::runtime_error("MLXCellCache: invalid CacheConfig");
118+
// own constructor reads the geometry before this body runs.
119+
static const cache::CacheGeometry& checked(
120+
const cache::CacheGeometry& geometry,
121+
const cache::CacheConfig& cfg) {
122+
if (!cache::valid(geometry, cfg)) {
123+
throw std::runtime_error("MLXCellCache: invalid geometry or config");
123124
}
124-
return cfg;
125+
return geometry;
125126
}
126127

127128
std::vector<Pool> kpool_;

0 commit comments

Comments
 (0)