Skip to content

Commit 9958d39

Browse files
[MLX] Add off-graph KV cache export mode for HF models (pytorch#21680)
**Summary** Adds --use-offgraph-cache, which exports a HuggingFace causal LM against kvcache::update_and_attend instead of an in-graph cache. The model runs with use_cache=False and past_key_values=None, so each attention layer emits one op fed only that step's k/v; history lives in a cache the runtime owns and binds by cache_key. KV-sharing layers address their donor's cache rather than one of their own, so gemma-4 E2B needs 15 caches for its 35 layers. Files - hf_attention.py — registers the mlx_offgraph attention implementation and a mask function returning None, since the op masks internally from the position it is given. OffGraphExportWrapper exposes the (input_ids, cache_position) signature a runner drives and passes position_ids explicitly, because HuggingFace otherwise derives them from past_key_values.get_seq_length(), which is - export_llm_hf.py — adds the off-graph export path and publishes the layout as get_n_caches, get_kv_heads, get_head_dims, get_windows. - cache.py — reshapes the HFStaticCache fallback's cache_position to 1-D. That path belongs to the in-graph cache, but it is the fix gemma-4 needs: gemma-4 calls update() without cache_kwargs, so the fallback reads a 0-dim cumulative_length and torch.export fails indexing it. Test Exported Llama-3.2-1B and gemma-3-1b; both partition into a single MLX subgraph, and the published layout matches each architecture — 16 flat caches for llama, 26 for gemma-3 with full attention at layers 5/11/17/23. python -m executorch.backends.mlx.examples.llm.export_llm_hf \ --model-id unsloth/gemma-3-1b-it --output gemma3_offgraph.pte \ --use-offgraph-cache --max-seq-len 2048 --dtype fp32 python -m executorch.backends.mlx.examples.llm.export_llm_hf \ --model-id unsloth/Llama-3.2-1B-Instruct --output llama_offgraph.pte \ --use-offgraph-cache --max-seq-len 2048 --dtype bf16 Add --qlinear 4w for INT4.
1 parent 75bd109 commit 9958d39

3 files changed

Lines changed: 338 additions & 11 deletions

File tree

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

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,148 @@ def _export_with_custom_components(
346346
_save_program(executorch_program, output_path)
347347

348348

349+
def _export_with_offgraph_cache(
350+
model_id: str,
351+
revision: Optional[str],
352+
output_path: str,
353+
max_seq_len: int,
354+
dtype: str,
355+
qlinear: Optional[str],
356+
qembedding: Optional[str],
357+
no_tie_word_embeddings: bool = False,
358+
qlinear_group_size: Optional[int] = None,
359+
qembedding_group_size: Optional[int] = None,
360+
) -> None:
361+
"""
362+
Export using the off-graph KV cache op (kvcache::update_and_attend).
363+
364+
Unlike the custom-components path, the cache is not a graph tensor: the model
365+
is run with use_cache=False (no StaticCache wrapper, no HFStaticCache swap),
366+
so each attention layer emits an update_and_attend node fed this step's k/v.
367+
The cache is created and bound at runtime via a cache_key.
368+
"""
369+
import executorch.exir as exir
370+
from executorch.backends.mlx import MLXPartitioner
371+
from executorch.backends.mlx.llm.hf_attention import (
372+
OffGraphExportWrapper,
373+
register_mlx_offgraph_attention,
374+
)
375+
from executorch.backends.mlx.passes import get_default_passes
376+
from executorch.exir import EdgeCompileConfig
377+
from executorch.exir.capture._config import ExecutorchBackendConfig
378+
from executorch.exir.passes import MemoryPlanningPass
379+
from transformers import AutoModelForCausalLM
380+
381+
torch_dtype_map = {
382+
"fp32": torch.float32,
383+
"fp16": torch.float16,
384+
"bf16": torch.bfloat16,
385+
}
386+
torch_dtype = torch_dtype_map.get(dtype, torch.bfloat16)
387+
388+
register_mlx_offgraph_attention()
389+
logger.info("Registered MLX off-graph attention (update_and_attend)")
390+
391+
logger.info(f"Loading HuggingFace model: {model_id}")
392+
load_kwargs = {
393+
"torch_dtype": torch_dtype,
394+
"low_cpu_mem_usage": True,
395+
"attn_implementation": "mlx_offgraph",
396+
}
397+
if revision is not None:
398+
load_kwargs["revision"] = revision
399+
model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
400+
model.eval()
401+
402+
from executorch.backends.mlx.llm.quantization import quantize_model_
403+
404+
quantize_model_(
405+
model,
406+
qlinear_config=qlinear,
407+
qlinear_group_size=qlinear_group_size,
408+
qembedding_config=qembedding,
409+
qembedding_group_size=qembedding_group_size,
410+
tie_word_embeddings=getattr(model.config, "tie_word_embeddings", False)
411+
and not no_tie_word_embeddings,
412+
)
413+
414+
exportable = OffGraphExportWrapper(model)
415+
416+
# The cache is built before the graph runs, so the runtime cannot learn its
417+
# shape from the model. Publish it, one entry per cache:
418+
# n_caches how many caches to allocate
419+
# kv_heads KV heads per cache
420+
# head_dims head dim per cache -- gemma-4 mixes 256 and 512
421+
# windows sliding window per cache, 0 = flat
422+
from executorch.backends.mlx.llm.cache import resolve_hf_cache_layout
423+
424+
# resolve_hf_cache_layout drops the KV-shared tail, so these are indexed by
425+
# cache -- the same indexing _cache_id maps layers onto.
426+
layer_types, cache_kv_heads, cache_head_dims = resolve_hf_cache_layout(model.config)
427+
text_config = model.config.get_text_config()
428+
sliding_window = getattr(text_config, "sliding_window", None) or 0
429+
cache_windows = [
430+
sliding_window if t == "sliding_attention" else 0 for t in layer_types
431+
]
432+
kv_metadata = {
433+
# The cache count, not num_hidden_layers: gemma-4 E2B shares KV across
434+
# its tail, so 15 caches for 35 layers.
435+
"get_n_caches": len(layer_types),
436+
"get_kv_heads": torch.tensor(cache_kv_heads, dtype=torch.int32),
437+
"get_head_dims": torch.tensor(cache_head_dims, dtype=torch.int32),
438+
"get_windows": torch.tensor(cache_windows, dtype=torch.int32),
439+
}
440+
logger.info(
441+
f"KV cache layout: {len(layer_types)} caches, "
442+
f"{sum(1 for w in cache_windows if w)} sliding (window {sliding_window})"
443+
)
444+
445+
logger.info("Exporting model with torch.export...")
446+
seq_length = 3
447+
example_input_ids = torch.zeros((1, seq_length), dtype=torch.long)
448+
example_cache_position = torch.arange(seq_length, dtype=torch.long)
449+
450+
seq_len_dim = torch.export.Dim("seq_length_dim", max=max_seq_len - 1)
451+
dynamic_shapes = {
452+
"input_ids": {1: seq_len_dim},
453+
"cache_position": {0: seq_len_dim},
454+
}
455+
456+
with torch.no_grad():
457+
exported_program = torch.export.export(
458+
exportable,
459+
args=(),
460+
kwargs={
461+
"input_ids": example_input_ids,
462+
"cache_position": example_cache_position,
463+
},
464+
dynamic_shapes=dynamic_shapes,
465+
strict=True,
466+
)
467+
468+
logger.info("Delegating to MLX backend...")
469+
edge_program = exir.to_edge_transform_and_lower(
470+
{"forward": exported_program},
471+
transform_passes=get_default_passes(),
472+
constant_methods=kv_metadata,
473+
partitioner=[MLXPartitioner()],
474+
compile_config=EdgeCompileConfig(
475+
_check_ir_validity=False,
476+
_skip_dim_order=True,
477+
),
478+
)
479+
480+
logger.info("Exporting to ExecuTorch...")
481+
executorch_program = edge_program.to_executorch(
482+
config=ExecutorchBackendConfig(
483+
extract_delegate_segments=True,
484+
memory_planning_pass=MemoryPlanningPass(alloc_graph_input=True),
485+
)
486+
)
487+
488+
_save_program(executorch_program, output_path)
489+
490+
349491
def _save_program(executorch_program, output_path: str) -> None:
350492
"""Save the ExecuTorch program to disk."""
351493
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
@@ -366,6 +508,7 @@ def export_llama_hf(
366508
qembedding: Optional[str] = None,
367509
use_custom_sdpa: bool = False,
368510
use_custom_kv_cache: bool = False,
511+
use_offgraph_cache: bool = False,
369512
no_tie_word_embeddings: bool = False,
370513
qlinear_group_size: Optional[int] = None,
371514
qembedding_group_size: Optional[int] = None,
@@ -383,7 +526,26 @@ def export_llama_hf(
383526
use_custom_sdpa: Use MLX custom SDPA (mlx::custom_sdpa)
384527
use_custom_kv_cache: Use MLX custom KV cache (mlx::kv_cache_update)
385528
"""
386-
if use_custom_sdpa or use_custom_kv_cache:
529+
if use_offgraph_cache:
530+
if use_custom_sdpa or use_custom_kv_cache:
531+
raise ValueError(
532+
"--use-offgraph-cache is exclusive with --use-custom-sdpa / "
533+
"--use-custom-kv-cache (it replaces both)"
534+
)
535+
logger.info("Using off-graph KV cache (update_and_attend)")
536+
_export_with_offgraph_cache(
537+
model_id=model_id,
538+
revision=revision,
539+
output_path=output_path,
540+
max_seq_len=max_seq_len,
541+
dtype=dtype,
542+
qlinear=qlinear,
543+
qembedding=qembedding,
544+
no_tie_word_embeddings=no_tie_word_embeddings,
545+
qlinear_group_size=qlinear_group_size,
546+
qembedding_group_size=qembedding_group_size,
547+
)
548+
elif use_custom_sdpa or use_custom_kv_cache:
387549
logger.info(
388550
f"Using custom components: sdpa={use_custom_sdpa}, "
389551
f"kv_cache={use_custom_kv_cache}"
@@ -468,6 +630,13 @@ def main():
468630
default=False,
469631
help="Use MLX custom KV cache (mlx::kv_cache_update)",
470632
)
633+
parser.add_argument(
634+
"--use-offgraph-cache",
635+
action="store_true",
636+
default=False,
637+
help="Use the off-graph KV cache op (kvcache::update_and_attend); "
638+
"replaces --use-custom-sdpa/--use-custom-kv-cache",
639+
)
471640

472641
args = parser.parse_args()
473642

@@ -481,6 +650,7 @@ def main():
481650
qembedding=args.qembedding,
482651
use_custom_sdpa=args.use_custom_sdpa,
483652
use_custom_kv_cache=args.use_custom_kv_cache,
653+
use_offgraph_cache=args.use_offgraph_cache,
484654
no_tie_word_embeddings=args.no_tie_word_embeddings,
485655
qlinear_group_size=args.qlinear_group_size,
486656
qembedding_group_size=args.qembedding_group_size,

‎backends/mlx/llm/cache.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,9 @@ def update(
525525
# Current HF ExecuTorch wrappers copy the requested cache position
526526
# into each StaticCache layer's cumulative_length before forward().
527527
if hasattr(self.layers[layer_idx], "cumulative_length"):
528-
cache_position = self.layers[layer_idx].cumulative_length
528+
# cumulative_length is a scalar; KVCache.update indexes [0], so
529+
# give it the 1-D shape a cache_kwargs caller would have passed.
530+
cache_position = self.layers[layer_idx].cumulative_length.reshape(1)
529531
else:
530532
raise RuntimeError(
531533
"cache_position was not provided and the pinned "

0 commit comments

Comments
 (0)