Skip to content

Commit 3850a74

Browse files
[executorch][muse] Add dynamic off-graph KV export (pytorch#23295)
This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: pytorch#22817 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/gasoonjia/187/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/gasoonjia/187/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/gasoonjia/191/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/gasoonjia/187/orig Differential Revision: [D119571989](https://our.internmc.facebook.com/intern/diff/D119571989/) @diff-train-skip-merge --------- Co-authored-by: gasoonjia <gasoonjia@icloud.com>
1 parent 605b561 commit 3850a74

3 files changed

Lines changed: 260 additions & 16 deletions

File tree

‎examples/models/muse-glimmer/export/export_solo.py‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def export_and_lower(
7878
backend: str = "cuda",
7979
sample: bool = True,
8080
use_turboquant: bool = False,
81+
use_offgraph_kv_cache: bool = False,
8182
activation_dtype: torch.dtype = torch.bfloat16,
8283
max_prefill_chunk: int = 512,
8384
vision_model: nn.Module | None = None,
@@ -92,12 +93,20 @@ def export_and_lower(
9293
output_dir,
9394
sample=sample,
9495
use_turboquant=use_turboquant,
96+
use_offgraph_kv_cache=use_offgraph_kv_cache,
9597
vision_model=vision_model,
9698
pos_embed_table=pos_embed_table,
9799
max_vision_patches=max_vision_patches,
98100
vision_fp32_mm=vision_fp32_mm,
99101
)
100102
elif backend == "mlx":
103+
# The off-graph cache is lowered by the CUDA backend only, and dropping
104+
# the flag silently would hand back an in-graph model that looks like
105+
# what was asked for.
106+
if use_offgraph_kv_cache:
107+
raise ValueError(
108+
"--use-offgraph-kv-cache is not supported by the mlx backend"
109+
)
101110
_export_mlx(
102111
model,
103112
config,
@@ -119,7 +128,7 @@ def _solo_constant_methods(
119128
config: MuseGlimmerConfig,
120129
max_prefill: int,
121130
activation_dtype: torch.dtype,
122-
mutable_buffer_metadata: str,
131+
mutable_buffer_metadata: str | None,
123132
has_vision: bool,
124133
max_vision_patches: int,
125134
) -> dict[str, object]:
@@ -136,12 +145,13 @@ def _solo_constant_methods(
136145
"get_vocab_size": config.vocab_size,
137146
"get_max_prefill_chunk": max_prefill,
138147
"get_activation_dtype": common.activation_dtype_tag(activation_dtype),
139-
"get_mutable_buffer_metadata": mutable_buffer_metadata,
140148
"use_kv_cache": True,
141149
"use_sdpa_with_kv_cache": False,
142150
"enable_dynamic_shape": True,
143151
"has_vision_encoder": bool(has_vision),
144152
}
153+
if mutable_buffer_metadata is not None:
154+
constant_methods["get_mutable_buffer_metadata"] = mutable_buffer_metadata
145155
if has_vision:
146156
constant_methods["get_vision_hidden_size"] = config.dim
147157
constant_methods["get_max_vision_patches"] = int(max_vision_patches)
@@ -154,6 +164,7 @@ def _export_cuda(
154164
output_dir: str,
155165
sample: bool = True,
156166
use_turboquant: bool = False,
167+
use_offgraph_kv_cache: bool = False,
157168
vision_model: nn.Module | None = None,
158169
pos_embed_table: torch.Tensor | None = None,
159170
max_vision_patches: int = 16384,
@@ -162,6 +173,9 @@ def _export_cuda(
162173
import torch._inductor.config as inductor_config
163174
from executorch.backends.cuda.cuda_backend import CudaBackend
164175
from executorch.backends.cuda.cuda_partitioner import CudaPartitioner
176+
from executorch.backends.cuda.passes.lower_offgraph_kv import (
177+
OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC,
178+
)
165179
from executorch.exir import (
166180
EdgeCompileConfig,
167181
ExecutorchBackendConfig,
@@ -191,17 +205,31 @@ def _export_cuda(
191205
from executorch.examples.models.muse_glimmer.source_transformations.cuda import (
192206
add_on_device_sampler,
193207
cuda_source_transformations,
208+
enable_offgraph_kv_cache,
209+
offgraph_kv_cache_geometry,
194210
vision_cuda_source_transformations,
195211
)
196212

197213
# Always applied: bounds global-attention SDPA to the valid context via a
198214
# runtime kv_len (O(context) decode). With use_turboquant=True it also swaps
199215
# the global KV caches for TurboQuant TQ4.
200-
cuda_source_transformations(model, use_turboquant=use_turboquant)
216+
if use_offgraph_kv_cache and use_turboquant:
217+
raise ValueError("off-graph KV cache and TurboQuant are mutually exclusive")
201218

202-
# Max prefill chunk must fit in the ring buffer (2 * sliding_window)
219+
# A prefill chunk is one write step, so the ring must hold the union of the
220+
# step's per-query windows. enable_offgraph_kv_cache sizes it from this.
203221
max_prefill = min(config.max_seq_len - 1, model._sliding_window * 2)
204222

223+
offgraph_manifest = None
224+
offgraph_geometry = {}
225+
if use_offgraph_kv_cache:
226+
offgraph_manifest = enable_offgraph_kv_cache(model, max_prefill)
227+
# Read the geometry while the model is alive; it is freed before the
228+
# constant methods are assembled.
229+
offgraph_geometry = offgraph_kv_cache_geometry(model)
230+
else:
231+
cuda_source_transformations(model, use_turboquant=use_turboquant)
232+
205233
has_vision = vision_model is not None
206234
programs: dict[str, "torch.export.ExportedProgram"] = {}
207235

@@ -267,20 +295,31 @@ def _export_cuda(
267295
vision_model, pos_embed_table, max_vision_patches
268296
)
269297

270-
mutable_buffer_metadata = common.mutable_buffer_metadata(model)
271-
del model
272-
if has_vision:
273-
del vision_model
298+
mutable_buffer_metadata = (
299+
None if offgraph_manifest else common.mutable_buffer_metadata(model)
300+
)
301+
del model, vision_model
274302
gc.collect()
275303
torch.cuda.empty_cache()
276304

277305
def _partitioner_for(name: str) -> "CudaPartitioner":
278-
return CudaPartitioner(
279-
[
280-
CudaBackend.generate_method_name_compile_spec(name),
281-
CompileSpec("low_memory_mode", b"ON"),
282-
]
283-
)
306+
compile_specs = [
307+
CudaBackend.generate_method_name_compile_spec(name),
308+
CompileSpec("low_memory_mode", b"ON"),
309+
]
310+
if offgraph_manifest is not None:
311+
compile_specs.extend(
312+
(
313+
CompileSpec("offgraph_kv_manifest", offgraph_manifest.encode()),
314+
# Where the delegate reads this step's token count, as
315+
# "input_index:dim". Lowering compiles the cache op away, so
316+
# the runtime cannot recover the width from the graph; every
317+
# method here takes input_pos second, shaped [T].
318+
CompileSpec(OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC, b"1:0"),
319+
CompileSpec("autotune_at_compile_time", b"OFF"),
320+
)
321+
)
322+
return CudaPartitioner(compile_specs)
284323

285324
constant_methods = _solo_constant_methods(
286325
config=config,
@@ -292,6 +331,13 @@ def _partitioner_for(name: str) -> "CudaPartitioner":
292331
)
293332
constant_methods["get_min_prefill_chunk"] = _CUDA_MIN_PREFILL_CHUNK
294333
constant_methods["use_sampling"] = sample
334+
if offgraph_manifest is not None:
335+
# The manifest is a compile spec (the lowering pass needs geometry at
336+
# partition time). The runtime instead reads the neutral cache-geometry
337+
# constant methods, the same ones the MLX off-graph path publishes.
338+
# Sizing already travels as get_max_seq_len (context) and
339+
# get_max_prefill_chunk (largest step); only the geometry is new.
340+
constant_methods.update(offgraph_geometry)
295341

296342
print(
297343
f"Lowering {len(programs)} methods to ExecuTorch (CUDA): "
@@ -570,6 +616,11 @@ def main() -> None:
570616
action="store_true",
571617
help="Use TurboQuant TQ4 KV cache on global (NoPE) layers (CUDA).",
572618
)
619+
parser.add_argument(
620+
"--use-offgraph-kv-cache",
621+
action="store_true",
622+
help="Allocate CUDA KV cache at runtime instead of storing it in the PTE/PTD.",
623+
)
573624
parser.add_argument(
574625
"--activation-dtype",
575626
default=None,
@@ -623,6 +674,10 @@ def main() -> None:
623674
parser.error("--activation-dtype is only supported with --backend mlx.")
624675
if args.backend != "mlx" and args.max_prefill_chunk != 512:
625676
parser.error("--max-prefill-chunk is only supported with --backend mlx.")
677+
if args.use_offgraph_kv_cache and args.backend != "cuda":
678+
parser.error("--use-offgraph-kv-cache requires --backend cuda.")
679+
if args.use_offgraph_kv_cache and args.turboquant:
680+
parser.error("--use-offgraph-kv-cache cannot be combined with --turboquant.")
626681
if args.gguf:
627682
from executorch.examples.models.muse_glimmer.loaders.checkpoint_loader import (
628683
load_gguf_model,
@@ -679,6 +734,7 @@ def main() -> None:
679734
backend=args.backend,
680735
sample=not args.logits,
681736
use_turboquant=args.turboquant,
737+
use_offgraph_kv_cache=args.use_offgraph_kv_cache,
682738
activation_dtype=activation_dtype,
683739
max_prefill_chunk=args.max_prefill_chunk,
684740
vision_model=vision_model,

‎examples/models/muse-glimmer/source_transformations/cuda.py‎

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212

1313
from __future__ import annotations
1414

15+
import json
1516
import types
1617

1718
# Register the length-aware global-attention operator.
1819
import executorch.backends.cuda.triton.kernels.sdpa # noqa: F401
1920

2021
# Register the TurboQuant attention operator.
2122
import executorch.backends.cuda.triton.kernels.tq4_sdpa # noqa: F401
23+
import executorch.extension.llm.cache.update_and_attend # noqa: F401
2224

2325
import torch
2426
import torch.nn as nn
@@ -30,11 +32,16 @@
3032
build_dflash_swa_mask,
3133
rotate_half,
3234
)
33-
from executorch.examples.models.muse_glimmer.model.model import FlatKVCache, RingKVCache
35+
from executorch.examples.models.muse_glimmer.model.model import (
36+
apply_rotary_emb,
37+
FlatKVCache,
38+
RingKVCache,
39+
)
3440
from executorch.examples.models.muse_glimmer.source_transformations.sampler import (
3541
sample,
3642
)
37-
from executorch.extension.llm.modules.turboquant import TurboQuantKVCache
43+
44+
from executorch.extension.llm.export.model_metadata import write_cache_geometry
3845
from torch.library import triton_op, wrap_triton
3946

4047

@@ -564,6 +571,99 @@ def _lenaware_attention_forward(
564571
return self.o_proj(y)
565572

566573

574+
def _offgraph_attention_forward(
575+
self,
576+
x: torch.Tensor,
577+
input_pos: torch.Tensor,
578+
attn_mask: torch.Tensor,
579+
) -> torch.Tensor:
580+
B, T, _ = x.shape
581+
h = self.qkv_proj_norm(x)
582+
xq, xk, xv, og = _project_qkvo(self, h)
583+
xq = xq.view(B, T, self.n_heads, self.head_dim)
584+
xk = xk.view(B, T, self.n_kv_heads, self.head_dim)
585+
xv = xv.view(B, T, self.n_kv_heads, self.head_dim)
586+
587+
if self.q_norm is not None:
588+
xq = self.q_norm(xq)
589+
xk = self.k_norm(xk)
590+
591+
xq = xq.transpose(1, 2)
592+
xk = xk.transpose(1, 2)
593+
xv = xv.transpose(1, 2)
594+
if self.use_rope:
595+
freqs = torch.outer(input_pos.float(), self.inv_freq)
596+
cos = torch.cos(freqs).unsqueeze(0).unsqueeze(0)
597+
sin = torch.sin(freqs).unsqueeze(0).unsqueeze(0)
598+
xq, xk = apply_rotary_emb(xq, xk, cos, sin)
599+
600+
# The neutral op takes per-token positions as [q_len, n_dims].
601+
y = torch.ops.kvcache.update_and_attend(
602+
xq,
603+
xk,
604+
xv,
605+
input_pos.reshape(-1, 1),
606+
self.layer_idx,
607+
self.attn_scale,
608+
xq.dtype,
609+
)
610+
y = y.transpose(1, 2).contiguous()
611+
if self.use_o_gate and og is not None:
612+
og = og.view(B, T, self.n_heads, self.head_dim)
613+
y = torch.sigmoid(og) * y
614+
return self.o_proj(y.reshape(B, T, -1))
615+
616+
617+
def enable_offgraph_kv_cache(model: nn.Module, max_write: int) -> str:
618+
"""Replace graph-owned caches and return the CUDA lowering manifest.
619+
620+
``max_write`` is the largest number of tokens one forward writes, which is
621+
what a ring layer sizes its slots from.
622+
"""
623+
layers = []
624+
for layer in model.layers:
625+
attn = layer.self_attn
626+
# Only what the graph cannot express: head counts, head dim and dtype
627+
# are read off the step's K tensor by the lowering pass.
628+
layers.append(
629+
{
630+
"layer_id": attn.layer_idx,
631+
"policy": "ring" if attn.is_sliding else "flat",
632+
"window": attn.window_size if attn.is_sliding else 0,
633+
}
634+
)
635+
del attn.kv_cache
636+
attn.forward = types.MethodType(_offgraph_attention_forward, attn)
637+
638+
return json.dumps(
639+
{
640+
"version": 1,
641+
"maximum_capacity": model.config.max_seq_len,
642+
"max_write": max_write,
643+
"layers": layers,
644+
},
645+
sort_keys=True,
646+
separators=(",", ":"),
647+
)
648+
649+
650+
def offgraph_kv_cache_geometry(model: nn.Module) -> dict[str, object]:
651+
"""Neutral cache-geometry constant methods for the off-graph runtime.
652+
653+
The runtime reads these through read_cache_geometry() rather than parsing
654+
the lowering manifest, so CUDA and MLX describe a cache the same way.
655+
"""
656+
kv_heads = []
657+
head_dims = []
658+
windows = []
659+
for layer in model.layers:
660+
attn = layer.self_attn
661+
kv_heads.append(attn.n_kv_heads)
662+
head_dims.append(attn.head_dim)
663+
windows.append(attn.window_size if attn.is_sliding else 0)
664+
return write_cache_geometry(kv_heads, head_dims, windows)
665+
666+
567667
def cuda_source_transformations(
568668
model: nn.Module,
569669
*,
@@ -605,6 +705,8 @@ def cuda_source_transformations(
605705
return
606706

607707
config = model.config
708+
from executorch.extension.llm.modules.turboquant import TurboQuantKVCache
709+
608710
n_swapped = 0
609711
for layer in model.layers:
610712
attn = layer.self_attn

0 commit comments

Comments
 (0)