mjolnir-kernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mjolnir/backend.py ADDED
@@ -0,0 +1,934 @@
1
+ """vLLM attention backend: the Mjolnir (T5) fp8 paged decode on Jetson Thor (sm_110).
2
+
3
+ Design
4
+ ------
5
+ MjolnirAttentionBackend is a *subclass of a stock backend*, the "fallback":
6
+ FlashAttention 4 on the Mjolnir image (its prefill is ~40% faster than
7
+ FlashInfer's on Thor), FlashInfer on a stock vLLM (whose FA on sm_110 cannot
8
+ read an fp8 KV cache); MJOLNIR_ATTN_FALLBACK picks another. It inherits the
9
+ KV-cache layout, the KV-cache write, prefill, validation and everything else.
10
+ Only two things change:
11
+
12
+ * the metadata builder: a step whose batch is decode-only (uniform
13
+ q_len <= 1 + num_speculative_tokens, causal or a parallel drafter's
14
+ non-causal block, no cascade) gets a T5 plan
15
+ (occupancy-pinned split-KV, written into persistent buffers, padded to a
16
+ fixed grid -> FULL CUDA graphs replay it). Any other step is built by
17
+ the base builder and run by the base impl, unchanged.
18
+ * the impl: forward() runs the T5 kernel when it gets T5 metadata,
19
+ otherwise it calls the base forward.
20
+
21
+ KV formats: fp8 (e4m3, vLLM's own cache write), and ``fp6_e2m3`` on the
22
+ image (``thor-fp6-e2m3-kv-cache.patch`` adds the dtype): packed 6-bit codes
23
+ with static per-(layer, KV head, channel) scales from a calibration file
24
+ (``mjolnir.kvscales``). Mjolnir writes that cache itself (``mjolnir.kvfp6``),
25
+ T5 reads it directly (the channel scales folded into Q and the output), and
26
+ the steps the FlashAttention 4 fallback runs see their pages expanded to an
27
+ fp8 scratch first (channel scales applied, one descale per head).
28
+
29
+ Select it with `--attention-backend MJOLNIR` (the Mjolnir image's vLLM has
30
+ the enum member) or `--attention-backend CUSTOM` (stock vLLM); the
31
+ `vllm.general_plugins` entry point registers the class at vLLM start-up.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import copy
37
+ import logging
38
+ from typing import Any, ClassVar
39
+
40
+ import torch
41
+
42
+ from . import banner
43
+ from .runner import (
44
+ SUPPORTED_PAGE_SIZES,
45
+ StepPlan,
46
+ T5DecodeRunner,
47
+ detect_kv_layout,
48
+ env_flag,
49
+ split_kv_cache,
50
+ )
51
+
52
+ logger = logging.getLogger("vllm.mjolnir") # under vLLM's handler
53
+
54
+ # --------------------------------------------------------------------------
55
+ # vLLM imports (paths moved between releases; try both)
56
+ # --------------------------------------------------------------------------
57
+ try:
58
+ from vllm.v1.attention.backends.registry import AttentionBackendEnum
59
+ except ImportError: # older layout
60
+ from vllm.attention.backends.registry import AttentionBackendEnum # type: ignore
61
+
62
+ try:
63
+ from vllm.v1.attention.backend import AttentionCGSupport
64
+ except ImportError:
65
+ from vllm.v1.attention.backends.utils import AttentionCGSupport # type: ignore
66
+
67
+ FP8_CACHE_DTYPES = ("fp8", "fp8_e4m3")
68
+ FP6_CACHE_DTYPES = ("fp6_e2m3",)
69
+ MJOLNIR_CACHE_DTYPES = FP8_CACHE_DTYPES + FP6_CACHE_DTYPES
70
+
71
+
72
+ def _kv_format(cache_dtype: str) -> str:
73
+ return "e2m3" if str(cache_dtype) in FP6_CACHE_DTYPES else "fp8"
74
+
75
+
76
+ # Builder-level step counters (per process): smoke tests read these to make
77
+ # sure decode steps really ran on T5.
78
+ STATS = {"t5_steps": 0, "fallback_steps": 0, "t5_noncausal_steps": 0}
79
+ # Parallel drafters' non-causal block attention on T5 (MJOLNIR_ATTN_NONCAUSAL=0:
80
+ # those steps go to the fallback, as before)
81
+ _NONCAUSAL = env_flag("MJOLNIR_ATTN_NONCAUSAL", "1").lower() not in ("0", "false", "no")
82
+
83
+
84
+ def _default_fallback() -> tuple[str, bool]:
85
+ """(backend name, explicitly chosen?). The Mjolnir image's vLLM carries
86
+ the default (``fa_utils.mjolnir_fallback_name``: FLASH_ATTN, which the same
87
+ patch resolves to FA4 on sm_110); a stock vLLM gets FLASHINFER."""
88
+ explicit = (env_flag("MJOLNIR_ATTN_FALLBACK", "") or "").strip().upper()
89
+ if explicit:
90
+ return explicit, True
91
+ try:
92
+ from vllm.v1.attention.backends.fa_utils import mjolnir_fallback_name
93
+ except ImportError: # stock vLLM: no Thor FA4 FP8-KV support
94
+ return "FLASHINFER", False
95
+ return mjolnir_fallback_name(), False
96
+
97
+
98
+ def _fa4_serves_fp8_kv() -> bool | None:
99
+ """Can FlashAttention serve this model's (fp8) KV cache here? None when
100
+ there is no config to ask (import outside vLLM's model construction)."""
101
+ try:
102
+ from vllm.config import get_current_vllm_config_or_none
103
+ from vllm.v1.attention.backends.fa_utils import flash_attn_supports_kv_cache_dtype
104
+ except ImportError:
105
+ return None
106
+ cfg = get_current_vllm_config_or_none()
107
+ if cfg is None or cfg.model_config is None:
108
+ return None
109
+ cache_dtype = str(getattr(cfg.cache_config, "cache_dtype", "auto"))
110
+ if cache_dtype not in FP8_CACHE_DTYPES:
111
+ return True # nothing to gate: FA reads bf16/fp16 KV on any version
112
+ head = cfg.model_config.get_head_size()
113
+ return bool(flash_attn_supports_kv_cache_dtype(cache_dtype, head_size=head, supports_fa4_hd256=True))
114
+
115
+
116
+ def _resolve_fallback() -> str:
117
+ name, explicit = _default_fallback()
118
+ assert name not in ("CUSTOM", "MJOLNIR"), "MJOLNIR_ATTN_FALLBACK must name a stock backend"
119
+ if name == "FLASH_ATTN" and not explicit and _fa4_serves_fp8_kv() is False:
120
+ # The image default, but FA4's one-shot FP8-KV probe failed on this
121
+ # device (or the layer shape is not FA4's): keep serving on FlashInfer.
122
+ logger.warning(
123
+ "Mjolnir: FlashAttention 4 cannot serve this fp8 KV cache here "
124
+ "(see the 'THOR FA4 ... probe' log line); falling back to FLASHINFER"
125
+ )
126
+ return "FLASHINFER"
127
+ return name
128
+
129
+
130
+ _FALLBACK = _resolve_fallback()
131
+
132
+
133
+ def _fallback_name() -> str:
134
+ return _FALLBACK
135
+
136
+
137
+ def _base_backend():
138
+ return AttentionBackendEnum[_FALLBACK].get_class()
139
+
140
+
141
+ _Base = _base_backend()
142
+ _BaseBuilder = _Base.get_builder_cls()
143
+ _BaseImpl = _Base.get_impl_cls()
144
+ _BASE_WRITES_KV_IN_FORWARD = getattr(_Base, "forward_includes_kv_cache_update", True)
145
+
146
+
147
+ def _fp6_known_to_vllm() -> bool:
148
+ """Does this vLLM have the fp6_e2m3 cache dtype (the image's patch)?"""
149
+ try:
150
+ from vllm.v1.kv_cache_interface import KVQuantMode, get_kv_quant_mode
151
+
152
+ return get_kv_quant_mode("fp6_e2m3") == getattr(KVQuantMode, "FP6_E2M3", None)
153
+ except ImportError:
154
+ return False
155
+
156
+
157
+ def _block_size_ok(sizes, size: int) -> bool:
158
+ """Does a backend's supported-size list (ints and MultipleOf) take ``size``?"""
159
+ for s in sizes:
160
+ base = getattr(s, "base", None)
161
+ if (base is not None and size % base == 0) or s == size:
162
+ return True
163
+ return False
164
+
165
+
166
+ def _max_q_len(vllm_config) -> int:
167
+ spec = getattr(vllm_config, "speculative_config", None)
168
+ n = getattr(spec, "num_speculative_tokens", None) if spec is not None else None
169
+ cap = int(env_flag("MJOLNIR_ATTN_MAX_QLEN", "0") or 0)
170
+ q = 1 + int(n or 0)
171
+ return min(q, cap) if cap > 0 else q
172
+
173
+
174
+ def _gpu_sync_allowed():
175
+ try:
176
+ from vllm.utils.gpu_sync_debug import gpu_sync_allowed
177
+
178
+ return gpu_sync_allowed()
179
+ except ImportError:
180
+ import contextlib
181
+
182
+ return contextlib.nullcontext()
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # Static eligibility (config level). Returns None if OK, else the reason.
187
+ # --------------------------------------------------------------------------
188
+ def _layers(vllm_config, layer_names):
189
+ try:
190
+ from vllm.config import get_layers_from_vllm_config
191
+ from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
192
+
193
+ return get_layers_from_vllm_config(vllm_config, AttentionLayerBase, layer_names)
194
+ except Exception: # noqa: BLE001 - layout differs across vLLM versions
195
+ return {}
196
+
197
+
198
+ def _layer_reason(impl) -> str | None:
199
+ sw = getattr(impl, "sliding_window", None)
200
+ if sw not in (None, (-1, -1), -1):
201
+ return "sliding window"
202
+ if getattr(impl, "logits_soft_cap", None) not in (None, 0, 0.0):
203
+ return "logits soft-cap"
204
+ if getattr(impl, "sinks", None) is not None:
205
+ return "attention sinks"
206
+ if getattr(impl, "alibi_slopes", None) is not None:
207
+ return "ALiBi"
208
+ return None
209
+
210
+
211
+ def ineligible_reason(kv_cache_spec, layer_names, vllm_config) -> str | None:
212
+ if env_flag("MJOLNIR_ATTN_DISABLE") in ("1", "true", "yes"):
213
+ return "MJOLNIR_ATTN_DISABLE set"
214
+ cache_dtype = str(getattr(vllm_config.cache_config, "cache_dtype", "auto"))
215
+ if cache_dtype not in MJOLNIR_CACHE_DTYPES:
216
+ return f"kv cache dtype {cache_dtype!r} (T5 needs fp8_e4m3 or fp6_e2m3)"
217
+ head = getattr(kv_cache_spec, "head_size", None)
218
+ if head is None or head % 128:
219
+ return f"head_size {head} (needs a multiple of 128)"
220
+ if getattr(kv_cache_spec, "head_size_v", head) != head:
221
+ return "K/V head sizes differ"
222
+ if getattr(kv_cache_spec, "sliding_window", None):
223
+ return "sliding-window KV spec"
224
+ bs = getattr(kv_cache_spec, "block_size", None)
225
+ if bs not in SUPPORTED_PAGE_SIZES:
226
+ return f"block size {bs} (supported: {SUPPORTED_PAGE_SIZES})"
227
+ if vllm_config.model_config.dtype not in (torch.bfloat16, torch.float16):
228
+ return f"model dtype {vllm_config.model_config.dtype}"
229
+ pc = vllm_config.parallel_config
230
+ if getattr(pc, "decode_context_parallel_size", 1) > 1:
231
+ return "decode context parallel"
232
+ if getattr(pc, "prefill_context_parallel_size", 1) > 1:
233
+ return "prefill context parallel"
234
+ for name, layer in _layers(vllm_config, layer_names).items():
235
+ r = _layer_reason(getattr(layer, "impl", None))
236
+ if r:
237
+ return f"layer {name}: {r}"
238
+ return None
239
+
240
+
241
+ # --------------------------------------------------------------------------
242
+ # fp6 (e2m3) KV: preconditions and per-channel scales
243
+ # --------------------------------------------------------------------------
244
+ def _check_fp6(t5_reason, layer_names) -> None:
245
+ """fp6 caches have exactly two readers: T5 and the fallback's fp8 scratch."""
246
+ if t5_reason is not None:
247
+ raise ValueError(
248
+ f"kv cache dtype fp6_e2m3 needs the Mjolnir decode kernel, but it is off for "
249
+ f"{layer_names[:1]}: {t5_reason}. Serve fp8 KV for this model."
250
+ )
251
+ if _FALLBACK != "FLASH_ATTN":
252
+ raise ValueError(
253
+ f"kv cache dtype fp6_e2m3 needs the FLASH_ATTN fallback (prefill reads an fp8 "
254
+ f"scratch through it); MJOLNIR_ATTN_FALLBACK is {_FALLBACK}"
255
+ )
256
+ from . import kvscales
257
+
258
+ if kvscales.calibrating():
259
+ raise ValueError("calibrate on fp8 KV (MJOLNIR_ATTN_KV_CALIBRATE), then serve fp6_e2m3 with the file")
260
+
261
+
262
+ _FP6_SCRATCH: dict = {}
263
+
264
+
265
+ def _fp6_scratch(pages: int, H: int, N: int, row: int, device) -> torch.Tensor:
266
+ """A growing per-device (pages, H, N, 2d) uint8 buffer for the fallback's
267
+ e4m3 pages. Layers run one after another, so one buffer serves them all;
268
+ it lives outside vLLM's KV budget (a 200K-token context at H=4, d=256 is
269
+ ~0.4 GB) and grows to the largest step seen."""
270
+ key = (str(device), H, N, row)
271
+ buf = _FP6_SCRATCH.get(key)
272
+ need = max(1, pages)
273
+ if buf is None or buf.shape[0] < need:
274
+ cap = max(need, 2 * buf.shape[0] if buf is not None else need)
275
+ buf = torch.empty(cap, H, N, row, dtype=torch.uint8, device=device)
276
+ _FP6_SCRATCH[key] = buf
277
+ return buf
278
+
279
+
280
+ def _set_scale(layer, name: str, value: torch.Tensor) -> None:
281
+ """Replace a layer's k/v scale with ``value`` (any shape), keeping its
282
+ kind: quantized checkpoints (e.g. ModelOpt NVFP4) register the scales as
283
+ nn.Parameters, which a plain tensor cannot be assigned over."""
284
+ cur = getattr(layer, name, None)
285
+ if isinstance(cur, torch.nn.Parameter):
286
+ setattr(layer, name, torch.nn.Parameter(value, requires_grad=False))
287
+ elif name in getattr(layer, "_buffers", {}):
288
+ layer._buffers[name] = value
289
+ else:
290
+ setattr(layer, name, value)
291
+
292
+
293
+ def _install_fp6_scales(vllm_config, layer_names, h_k: int, d: int) -> None:
294
+ """Per-(layer, KV head, channel) e2m3 scales from the calibration file.
295
+
296
+ Channel scales s_c (h_k, d) quantize the cache write. Attention never
297
+ sees them as descales — they fold into the math around it:
298
+
299
+ * T5: K's into Q (q . k = sum_c (q_c s_c) code_c) and V's into the output
300
+ (O_c = s_c sum_t p_t code_tc); the kernel's K/V descales are 1.
301
+ * FA4 fallback: the scratch holds e4m3(code * s_c / S) with one
302
+ S = max_c(s_c) * 7.5 / 448 per head, and S is the descale — so
303
+ ``layer._k_scale`` / ``_v_scale`` are the per-head S.
304
+ """
305
+ from . import kvscales
306
+
307
+ path = kvscales.scales_path()
308
+ if not path:
309
+ raise ValueError(
310
+ "kv cache dtype fp6_e2m3 needs calibrated scales: set MJOLNIR_ATTN_KV_SCALES to a "
311
+ "calibration file (`mjolnir calibrate`, or python -m mjolnir.calibrate)"
312
+ )
313
+ doc = kvscales.load(path)
314
+ missing = kvscales.missing_layers(doc, layer_names)
315
+ if missing:
316
+ raise ValueError(f"{path} has no scales for {missing[:3]}{' ...' if len(missing) > 3 else ''}")
317
+ layers = _layers(vllm_config, layer_names)
318
+ for name, layer in layers.items():
319
+ ks, vs = kvscales.e2m3_scales(doc, name, h_k, d)
320
+ dev = layer._k_scale.device
321
+ h_r = max(1, int(getattr(layer, "num_heads", h_k)) // h_k)
322
+ state = {}
323
+ for side, rows in (("k", ks), ("v", vs)):
324
+ ch = torch.tensor(rows, dtype=torch.float32, device=dev).contiguous() # (h_k, d)
325
+ head = ch.amax(dim=1) * (kvscales.E2M3_MAX / 448.0) # (h_k,) fallback descale
326
+ state[f"{side}_ch"] = ch
327
+ state[f"{side}_head"] = head
328
+ state[f"{side}_ratio"] = (ch / head[:, None]).contiguous()
329
+ state[f"{side}_mul"] = ch.repeat_interleave(h_r, dim=0).contiguous() # (h_q, d)
330
+ state["one"] = torch.ones(1, dtype=torch.float32, device=dev)
331
+ layer._mjolnir_fp6 = state
332
+ _set_scale(layer, "_k_scale", state["k_head"])
333
+ _set_scale(layer, "_v_scale", state["v_head"])
334
+ logger.info(
335
+ "Mjolnir: fp6_e2m3 KV scales (%s) for %d layer(s) from %s",
336
+ "per channel" if any(kvscales.per_channel(doc, n) for n in layers) else "per head",
337
+ len(layers),
338
+ path,
339
+ )
340
+
341
+
342
+ # --------------------------------------------------------------------------
343
+ # Metadata
344
+ # --------------------------------------------------------------------------
345
+ class MjolnirMetadata:
346
+ """Decode-only step metadata for T5 (one per KV-cache group).
347
+
348
+ Carries the attributes generic vLLM code reads from attention metadata
349
+ (num_actual_tokens, slot_mapping, ...); FlashInfer-specific fields are
350
+ absent because the base impl never sees this object."""
351
+
352
+ is_t5 = True
353
+
354
+ def __init__(self, *, plan: StepPlan, runner: T5DecodeRunner, cam):
355
+ self.plan = plan
356
+ self.runner = runner
357
+ self.num_actual_tokens = cam.num_actual_tokens
358
+ self.num_reqs = cam.num_reqs
359
+ self.max_query_len = cam.max_query_len
360
+ self.max_seq_len = cam.max_seq_len
361
+ self.query_start_loc = cam.query_start_loc
362
+ self.seq_lens = cam.seq_lens
363
+ self.block_table = cam.block_table_tensor
364
+ self.slot_mapping = cam.slot_mapping
365
+ self.num_decodes = cam.num_reqs
366
+ self.num_decode_tokens = cam.num_actual_tokens
367
+ self.num_prefills = 0
368
+ self.num_prefill_tokens = 0
369
+ self.use_cascade = False
370
+ self.causal = True
371
+
372
+
373
+ # --------------------------------------------------------------------------
374
+ # Builder
375
+ # --------------------------------------------------------------------------
376
+ class MjolnirMetadataBuilder(_BaseBuilder): # type: ignore[misc, valid-type]
377
+ # Shared-metadata shortcut across groups would bypass our plan.
378
+ supports_update_block_table: bool = False
379
+
380
+ def __init__(self, kv_cache_spec, layer_names, vllm_config, device, *args, **kwargs):
381
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device, *args, **kwargs)
382
+ if env_flag("MJOLNIR_ATTN_BANNER", "1").lower() not in ("0", "false", "no"):
383
+ banner.emit() # the hammer, once, into this log
384
+ # Fused multi-step draft decode (MTP / EAGLE): the speculator builds the
385
+ # draft metadata once and runs every draft step on it, advancing the
386
+ # device seq_lens in place. A T5 plan stays valid across those steps if
387
+ # it is made for the longest of them — the kernel masks with the live
388
+ # seq_lens, so tiles past a sequence's end are empty — hence every plan
389
+ # gets num_speculative_tokens of headroom, and update_draft_decode_metadata
390
+ # has nothing to do for T5 steps (fallback steps defer to the base
391
+ # builder). The capability is whatever the base builder grants.
392
+ spec = getattr(vllm_config, "speculative_config", None)
393
+ self._plan_margin = int(getattr(spec, "num_speculative_tokens", 0) or 0) if spec is not None else 0
394
+ self._kv_format = _kv_format(getattr(vllm_config.cache_config, "cache_dtype", "auto"))
395
+ self._fp6 = self._kv_format == "e2m3"
396
+ self._page = kv_cache_spec.block_size
397
+ self._t5_reason = ineligible_reason(kv_cache_spec, layer_names, vllm_config)
398
+ # Diagnosis knobs for the draft path (MTP / EAGLE drafter layers):
399
+ # MJOLNIR_ATTN_DRAFT_T5=0 runs every drafter step on the fallback;
400
+ # MJOLNIR_ATTN_FUSED_DRAFT=0 makes vLLM rebuild the draft metadata
401
+ # between draft steps instead of fusing them into one graph.
402
+ self._is_draft = any("mtp" in n or "draft" in n for n in layer_names)
403
+ self._force_fallback = self._is_draft and env_flag("MJOLNIR_ATTN_DRAFT_T5", "1") in ("0", "false", "no")
404
+ if env_flag("MJOLNIR_ATTN_FUSED_DRAFT", "1") in ("0", "false", "no"):
405
+ self.supports_draft_decode_metadata_update = False
406
+ if self._force_fallback and self._fp6:
407
+ # the drafter's steps replay as FULL CUDA graphs, and an fp6 fallback
408
+ # step (page expansion from a per-step page map) cannot be captured
409
+ raise ValueError(
410
+ "MJOLNIR_ATTN_DRAFT_T5=0 needs fp8 KV (e.g. NVFP4_MJOLNIR): on fp6 the drafter's "
411
+ "fallback steps would be captured into CUDA graphs with a stale page map"
412
+ )
413
+ if self._force_fallback:
414
+ logger.info("Mjolnir: drafter layers %s on the fallback (MJOLNIR_ATTN_DRAFT_T5=0)", layer_names[:1])
415
+ if self._fp6:
416
+ _check_fp6(self._t5_reason, layer_names)
417
+ _install_fp6_scales(vllm_config, layer_names, kv_cache_spec.num_kv_heads, kv_cache_spec.head_size)
418
+ self.t5: T5DecodeRunner | None = None
419
+ if self._t5_reason is not None:
420
+ logger.warning(
421
+ "Mjolnir decode disabled for %s: %s -> %s for all steps",
422
+ layer_names[:1],
423
+ self._t5_reason,
424
+ _fallback_name(),
425
+ )
426
+ return
427
+ mc, pc = vllm_config.model_config, vllm_config.parallel_config
428
+ num_heads = None
429
+ try:
430
+ from vllm.v1.attention.backends.utils import get_num_attention_heads_from_layers
431
+
432
+ num_heads = get_num_attention_heads_from_layers(vllm_config, layer_names)
433
+ except Exception: # noqa: BLE001
434
+ pass
435
+ if num_heads is None:
436
+ num_heads = mc.get_num_attention_heads(pc)
437
+ q_mode = env_flag("MJOLNIR_ATTN_Q_DTYPE", "fp8").lower()
438
+ q_mode = {"bf16": "bf16", "bfloat16": "bf16", "fp8": "fp8", "e4m3": "fp8"}[q_mode]
439
+ if self._fp6 and q_mode != "fp8":
440
+ raise ValueError("fp6_e2m3 KV runs on the fp8-Q path only: unset MJOLNIR_ATTN_Q_DTYPE")
441
+ self.t5 = T5DecodeRunner(
442
+ num_heads=num_heads,
443
+ num_kv_heads=kv_cache_spec.num_kv_heads,
444
+ head_size=kv_cache_spec.head_size,
445
+ page_size=kv_cache_spec.block_size,
446
+ out_dtype=mc.dtype,
447
+ max_num_seqs=vllm_config.scheduler_config.max_num_seqs,
448
+ max_q_len=_max_q_len(vllm_config),
449
+ q_mode=q_mode,
450
+ ctas_per_sm=int(env_flag("MJOLNIR_ATTN_CTAS", "2")),
451
+ max_splits=int(env_flag("MJOLNIR_ATTN_MAX_SPLITS", "16")),
452
+ kv_stages=int(env_flag("MJOLNIR_ATTN_KV_STAGES", "0")),
453
+ # EVICT_FIRST: default ON for the fully-fp8 path — the one change
454
+ # that won in every Thor A/B (b=1 wall clock -8..-29% at 4K-16K,
455
+ # ncu neutral at 64K-256K); bf16-Q long-L was mixed -> opt-in.
456
+ # PDL: opt-in — its A/Bs flipped sign between runs (-17% .. +5%)
457
+ # on a busy box; re-measure on an idle one before defaulting it.
458
+ pdl=env_flag("MJOLNIR_ATTN_PDL", "0").lower() in ("1", "true", "yes"),
459
+ l2_prefetch=int(env_flag("MJOLNIR_ATTN_L2_PREFETCH", "0")),
460
+ kv_evict_first=env_flag("MJOLNIR_ATTN_KV_EVICT_FIRST", "1" if q_mode == "fp8" else "0").lower()
461
+ in ("1", "true", "yes"),
462
+ kv_format=self._kv_format,
463
+ device=device,
464
+ )
465
+ pre = env_flag("MJOLNIR_ATTN_PRECOMPILE", "all").lower()
466
+ if pre in ("all", "1", "true", "yes"):
467
+ import time
468
+
469
+ from . import runner as _runner
470
+
471
+ before = dict(_runner.CACHE_STATS)
472
+ t0 = time.perf_counter()
473
+ self.t5.precompile()
474
+ loaded = _runner.CACHE_STATS["loaded"] - before["loaded"]
475
+ compiled = _runner.CACHE_STATS["compiled"] - before["compiled"]
476
+ if loaded or compiled:
477
+ logger.info(
478
+ "Mjolnir: decode kernels (q_len 1..%d, %s Q) ready in %.1f s: %d compiled, %d from %s",
479
+ self.t5.max_q_len,
480
+ q_mode,
481
+ time.perf_counter() - t0,
482
+ compiled,
483
+ loaded,
484
+ _runner.kernel_cache_dir() or "cache (off)",
485
+ )
486
+ logger.info(
487
+ "Mjolnir decode enabled: h_q=%d h_k=%d d=%d page=%d q_len<=%d Q=%s "
488
+ "ctas/SM=%d slots(q1)=%d pdl=%s l2_prefetch=%d "
489
+ "kv_evict_first=%s kv=%s fallback=%s",
490
+ num_heads,
491
+ kv_cache_spec.num_kv_heads,
492
+ kv_cache_spec.head_size,
493
+ kv_cache_spec.block_size,
494
+ self.t5.max_q_len,
495
+ q_mode,
496
+ self.t5.ctas_per_sm,
497
+ self.t5.slots(1),
498
+ self.t5.pdl,
499
+ self.t5.l2_prefetch,
500
+ self.t5.kv_evict_first,
501
+ self._kv_format,
502
+ _fallback_name(),
503
+ )
504
+
505
+ # -- CUDA graph support ------------------------------------------------
506
+ @classmethod
507
+ def get_cudagraph_support(cls, vllm_config, kv_cache_spec):
508
+ base = super().get_cudagraph_support(vllm_config, kv_cache_spec)
509
+ # Mixed batches always go to the base backend, so never promise more
510
+ # than it does for those; uniform decode is T5's and graph-safe.
511
+ if base == AttentionCGSupport.ALWAYS:
512
+ return base
513
+ if _max_q_len(vllm_config) > 1:
514
+ return max(base, AttentionCGSupport.UNIFORM_BATCH, key=lambda e: e.value)
515
+ return max(base, AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE, key=lambda e: e.value)
516
+
517
+ @classmethod
518
+ def get_varlen_cudagraph_max_query_len(cls, vllm_config, kv_cache_spec):
519
+ return None # T5 graphs replay uniform-q_len batches only
520
+
521
+ # -- build ---------------------------------------------------------------
522
+ def _t5_q_len(self, cam) -> int | None:
523
+ """q_len if this step is a uniform decode batch T5 can take: causal
524
+ (decode, speculative verification), or non-causal (a parallel drafter's
525
+ block — DSpark / DFlash — where every query token sees every key; off
526
+ with MJOLNIR_ATTN_NONCAUSAL=0)."""
527
+ t5 = self.t5
528
+ if t5 is None:
529
+ return None
530
+ q_len = int(cam.max_query_len)
531
+ if not 1 <= q_len <= t5.max_q_len or cam.num_reqs > t5.max_num_seqs:
532
+ return None
533
+ causal = getattr(cam, "causal", True)
534
+ if causal is not True and not (causal is False and _NONCAUSAL):
535
+ return None
536
+ if getattr(cam, "dcp_local_seq_lens", None) is not None:
537
+ return None
538
+ if q_len == 1 and cam.num_actual_tokens <= cam.num_reqs:
539
+ return 1
540
+ # Uniform: every request has q_len tokens, except trailing CUDA-graph
541
+ # padding requests with 0.
542
+ qsl = cam.query_start_loc_cpu[: cam.num_reqs + 1]
543
+ lens = (qsl[1:] - qsl[:-1]).tolist()
544
+ n_real = 0
545
+ while n_real < len(lens) and lens[n_real] == q_len:
546
+ n_real += 1
547
+ if any(x != 0 for x in lens[n_real:]):
548
+ return None
549
+ return q_len
550
+
551
+ def _seq_lens_cpu(self, cam):
552
+ ub = getattr(cam, "seq_lens_cpu_upper_bound", None)
553
+ if ub is not None:
554
+ # An upper bound is fine for planning: the kernel masks with the
555
+ # exact device seq_lens, extra tiles are empty.
556
+ return ub
557
+ legacy = getattr(cam, "seq_lens_cpu", None)
558
+ if legacy is not None:
559
+ return legacy
560
+ with _gpu_sync_allowed():
561
+ return cam.seq_lens.cpu()
562
+
563
+ def build(self, common_prefix_len: int, common_attn_metadata, fast_build: bool = False):
564
+ cam = common_attn_metadata
565
+ q_len = self._t5_q_len(cam) if common_prefix_len == 0 and not self._force_fallback else None
566
+ if q_len is None:
567
+ STATS["fallback_steps"] += 1
568
+ md = super().build(common_prefix_len, cam, fast_build)
569
+ if self._fp6:
570
+ md.mjolnir_fp6_pages = self._fp6_page_map(cam)
571
+ return md
572
+ STATS["t5_steps"] += 1
573
+ t5 = self.t5
574
+ num_seqs = cam.num_reqs
575
+ causal = getattr(cam, "causal", True) is not False
576
+ if not causal:
577
+ STATS["t5_noncausal_steps"] += 1
578
+ plan = t5.plan(
579
+ self._seq_lens_cpu(cam),
580
+ num_seqs,
581
+ q_len,
582
+ cam.block_table_tensor,
583
+ cam.seq_lens,
584
+ margin=self._plan_margin,
585
+ causal=causal,
586
+ )
587
+ # Never compiled inside graph capture: build() runs outside it.
588
+ t5.ensure_compiled(q_len, plan.with_reduction, plan.causal)
589
+ return MjolnirMetadata(plan=plan, runner=t5, cam=cam)
590
+
591
+ def _fp6_page_map(self, cam):
592
+ """For a step the fallback runs on an fp6 cache: which cache page
593
+ becomes which scratch page, and the block table over the scratch.
594
+ Scratch page offset_r + j holds request r's page j (no deduplication:
595
+ a shared prefix page is expanded once per request that uses it)."""
596
+ num_reqs = cam.num_reqs
597
+ lens = [int(x) for x in self._seq_lens_cpu(cam)[:num_reqs]]
598
+ n_pages = [max(1, -(-L // self._page)) if L > 0 else 0 for L in lens]
599
+ total = sum(n_pages)
600
+ width = cam.block_table_tensor.shape[1]
601
+ remap = torch.zeros(cam.block_table_tensor.shape[0], width, dtype=torch.int32)
602
+ req_idx, page_idx, off = [], [], 0
603
+ for r, n in enumerate(n_pages):
604
+ if n:
605
+ remap[r, :n] = torch.arange(off, off + n, dtype=torch.int32)
606
+ req_idx += [r] * n
607
+ page_idx += list(range(n))
608
+ off += n
609
+ dev = cam.block_table_tensor.device
610
+ rows = torch.tensor(req_idx, dtype=torch.long).pin_memory().to(dev, non_blocking=True)
611
+ cols = torch.tensor(page_idx, dtype=torch.long).pin_memory().to(dev, non_blocking=True)
612
+ src = cam.block_table_tensor[rows, cols].to(torch.int32).contiguous()
613
+ return {"src_pages": src, "block_table": remap.pin_memory().to(dev, non_blocking=True), "total": total}
614
+
615
+ def build_for_cudagraph_capture(self, common_attn_metadata):
616
+ return self.build(0, common_attn_metadata)
617
+
618
+ def update_draft_decode_metadata(self, metadata) -> None:
619
+ """Fused draft decode: advance the metadata to the next draft step,
620
+ capture-safe. A T5 plan already covers it (see __init__); other steps
621
+ were built by the base builder, which updates its own metadata."""
622
+ if getattr(metadata, "is_t5", False):
623
+ return
624
+ super().update_draft_decode_metadata(metadata)
625
+
626
+ def use_cascade_attention(self, *args, **kwargs) -> bool:
627
+ # Cascade steps are built by the base builder (common_prefix_len > 0);
628
+ # keep the base decision — except on fp6, whose fallback path expands
629
+ # per-request pages and has no shared-prefix variant.
630
+ if self._fp6:
631
+ return False
632
+ return super().use_cascade_attention(*args, **kwargs)
633
+
634
+
635
+ # --------------------------------------------------------------------------
636
+ # Impl
637
+ # --------------------------------------------------------------------------
638
+ class MjolnirImpl(_BaseImpl): # type: ignore[misc, valid-type]
639
+ def __init__(self, *args, **kwargs):
640
+ super().__init__(*args, **kwargs)
641
+ # T5 quantizes Q itself (dynamic, per (sequence, KV head)); the
642
+ # layer's static per-tensor quantization would change the input of
643
+ # every step, so the layer keeps handing us bf16 Q. A fallback that
644
+ # wants fp8 Q next to an fp8 KV cache (FlashAttention 4 on sm_110
645
+ # needs Q, K and V in one fp8 dtype) gets it quantized here, on the
646
+ # steps it runs — the same static quantization the layer would do.
647
+ cache_dtype = str(getattr(self, "kv_cache_dtype", "auto"))
648
+ # fp6: the fallback reads an fp8 scratch (see _forward_fp6_fallback)
649
+ self._fp6 = cache_dtype in FP6_CACHE_DTYPES
650
+ quantized_kv = cache_dtype in MJOLNIR_CACHE_DTYPES
651
+ self._fallback_fp8_q = (
652
+ bool(self.supports_quant_query_input) and quantized_kv and _FALLBACK == "FLASH_ATTN"
653
+ ) or self._fp6
654
+ self.supports_quant_query_input = False
655
+ self._kv_layout: str | None = None
656
+ self._t5_reason = _layer_reason(self)
657
+
658
+ # -- KV cache write ----------------------------------------------------
659
+ def do_kv_cache_update(self, layer, key, value, kv_cache, slot_mapping):
660
+ """fp6: Mjolnir's own packed write; fp8: the base backend's. In
661
+ calibration mode (MJOLNIR_ATTN_KV_CALIBRATE=1) also fold every
662
+ written K/V into the layer's per-channel amax."""
663
+ from . import kvscales
664
+
665
+ if kvscales.calibrating():
666
+ kvscales.observe(getattr(layer, "layer_name", str(id(layer))), key, value, slot_mapping)
667
+ if not self._fp6:
668
+ return super().do_kv_cache_update(layer, key, value, kv_cache, slot_mapping)
669
+ if getattr(self, "kv_sharing_target_layer_name", None) is not None:
670
+ return None
671
+ from . import kvfp6
672
+
673
+ st = layer._mjolnir_fp6
674
+ kvfp6.write_kv(key, value, kv_cache, slot_mapping, st["k_ch"], st["v_ch"])
675
+ return None
676
+
677
+ def _forward_fp6_fallback(self, layer, query, key, value, kv_cache, attn_metadata, *rest, **kwargs):
678
+ """A fallback step on an fp6 cache: expand the pages this step reads
679
+ to e4m3 as code * s_c / S (s_c the channel scale, S the layer's
680
+ per-head k/v scale, the descale FA4 applies), point a copy of the
681
+ metadata at the scratch, and run the base forward on it."""
682
+ from . import kvfp6
683
+
684
+ if torch.cuda.is_current_stream_capturing():
685
+ raise RuntimeError(
686
+ "Mjolnir: an fp6 fallback step inside CUDA-graph capture — its page expansion "
687
+ "depends on per-step host data and would replay stale. Serve fp8 KV or report this shape."
688
+ )
689
+ pages = attn_metadata.mjolnir_fp6_pages
690
+ _, H, N, _ = kv_cache.shape
691
+ scratch = _fp6_scratch(pages["total"], H, N, 2 * self.head_size, kv_cache.device)
692
+ st = layer._mjolnir_fp6
693
+ kvfp6.expand_pages_to_fp8(kv_cache, pages["src_pages"], scratch, self.head_size, st["k_ratio"], st["v_ratio"])
694
+ md = copy.copy(attn_metadata)
695
+ md.block_table = pages["block_table"]
696
+ return super().forward(layer, query, key, value, scratch[: max(1, pages["total"])], md, *rest, **kwargs)
697
+
698
+ def _quantize_q(self, layer, query: torch.Tensor) -> torch.Tensor:
699
+ """Static fp8 Q for the fallback, as the attention layer would
700
+ quantize it: Q / q_scale, saturated to e4m3, per tensor or per KV head."""
701
+ scale = layer._q_scale
702
+ n = query.shape[0]
703
+ if scale.numel() == 1:
704
+ from vllm import _custom_ops as ops
705
+
706
+ q8, _ = ops.scaled_fp8_quant(query.reshape(n, -1), scale.reshape(1).float())
707
+ return q8.view(query.shape)
708
+ fp8 = torch.float8_e4m3fn
709
+ lim = torch.finfo(fp8).max
710
+ q = query.reshape(n, scale.numel(), -1).float() / scale.float().reshape(1, -1, 1)
711
+ return q.clamp_(-lim, lim).to(fp8).view(query.shape)
712
+
713
+ def fused_output_quant_supported(self, quant_key) -> bool:
714
+ return False # T5 writes bf16/fp16 output
715
+
716
+ def _kv_views(self, kv_cache: torch.Tensor, runner: T5DecodeRunner):
717
+ # fp6 rows are packed bytes: K|V split at d * 6 / 8 bytes, not d
718
+ row = runner.kv_row_bytes
719
+ if self._kv_layout is None:
720
+ self._kv_layout = detect_kv_layout(kv_cache.shape, runner.h_k, runner.page_size, row)
721
+ # logger.info(
722
+ # "Mjolnir: KV cache %s %s -> layout %s (%s)",
723
+ # tuple(kv_cache.shape),
724
+ # tuple(kv_cache.stride()),
725
+ # self._kv_layout,
726
+ # runner.kv_format,
727
+ # )
728
+ return split_kv_cache(kv_cache, self._kv_layout, row)
729
+
730
+ def _write_kv(self, layer, key, value, kv_cache, k_cache, v_cache, md):
731
+ """Only for base backends whose forward() also writes the KV cache
732
+ (forward_includes_kv_cache_update = True, e.g. older vLLM)."""
733
+ if not _BASE_WRITES_KV_IN_FORWARD:
734
+ return
735
+ if key is None or value is None:
736
+ return
737
+ if getattr(self, "kv_sharing_target_layer_name", None) is not None:
738
+ return
739
+ n = md.slot_mapping.shape[0]
740
+ torch.ops._C_cache_ops.reshape_and_cache_flash(
741
+ key[:n],
742
+ value[:n],
743
+ k_cache,
744
+ v_cache,
745
+ md.slot_mapping,
746
+ self.kv_cache_dtype,
747
+ layer._k_scale,
748
+ layer._v_scale,
749
+ )
750
+
751
+ def forward(
752
+ self,
753
+ layer,
754
+ query,
755
+ key,
756
+ value,
757
+ kv_cache,
758
+ attn_metadata,
759
+ output=None,
760
+ output_scale=None,
761
+ output_block_scale=None,
762
+ *args: Any,
763
+ **kwargs: Any,
764
+ ):
765
+ if not getattr(attn_metadata, "is_t5", False):
766
+ if self._fallback_fp8_q and attn_metadata is not None and query.dtype in (torch.bfloat16, torch.float16):
767
+ query = self._quantize_q(layer, query)
768
+ # The base forward passes the layer's q scale as q_descale
769
+ # only when it believes the layer quantized Q.
770
+ self.supports_quant_query_input = True
771
+ try:
772
+ if self._fp6:
773
+ return self._forward_fp6_fallback(
774
+ layer,
775
+ query,
776
+ key,
777
+ value,
778
+ kv_cache,
779
+ attn_metadata,
780
+ output,
781
+ output_scale,
782
+ output_block_scale,
783
+ *args,
784
+ **kwargs,
785
+ )
786
+ return super().forward(
787
+ layer,
788
+ query,
789
+ key,
790
+ value,
791
+ kv_cache,
792
+ attn_metadata,
793
+ output,
794
+ output_scale,
795
+ output_block_scale,
796
+ *args,
797
+ **kwargs,
798
+ )
799
+ finally:
800
+ self.supports_quant_query_input = False
801
+ return super().forward(
802
+ layer,
803
+ query,
804
+ key,
805
+ value,
806
+ kv_cache,
807
+ attn_metadata,
808
+ output,
809
+ output_scale,
810
+ output_block_scale,
811
+ *args,
812
+ **kwargs,
813
+ )
814
+ if self._t5_reason is not None:
815
+ raise RuntimeError(f"Mjolnir got a layer it cannot run ({self._t5_reason}); set MJOLNIR_ATTN_DISABLE=1")
816
+ assert output is not None and output_scale is None
817
+ md: MjolnirMetadata = attn_metadata
818
+ runner = md.runner
819
+ k_cache, v_cache = self._kv_views(kv_cache, runner)
820
+ if k_cache.shape[1] != runner.page_size:
821
+ raise RuntimeError(
822
+ f"Mjolnir: KV page size {k_cache.shape[1]} != planned {runner.page_size}; "
823
+ "set MJOLNIR_ATTN_KV_LAYOUT or MJOLNIR_ATTN_DISABLE=1"
824
+ )
825
+ self._write_kv(layer, key, value, kv_cache, k_cache, v_cache, md)
826
+ if query.dtype not in (torch.bfloat16, torch.float16):
827
+ # pre-quantized fp8 query from a layer fusion: dequantize
828
+ query = (query.float() * layer._q_scale).to(torch.bfloat16)
829
+ if self._fp6: # channel scales fold into Q and O; the cache codes have descale 1
830
+ st = layer._mjolnir_fp6
831
+ runner.run(
832
+ md.plan,
833
+ query,
834
+ output,
835
+ k_cache,
836
+ v_cache,
837
+ st["one"],
838
+ st["one"],
839
+ self.scale,
840
+ q_mul=st["k_mul"],
841
+ o_mul=st["v_mul"],
842
+ )
843
+ return output
844
+ runner.run(
845
+ md.plan,
846
+ query,
847
+ output,
848
+ k_cache,
849
+ v_cache,
850
+ layer._k_scale,
851
+ layer._v_scale,
852
+ self.scale,
853
+ )
854
+ return output
855
+
856
+
857
+ # --------------------------------------------------------------------------
858
+ # Backend
859
+ # --------------------------------------------------------------------------
860
+ class MjolnirAttentionBackend(_Base): # type: ignore[misc, valid-type]
861
+ accept_output_buffer: bool = True
862
+ base_backend: ClassVar[type] = _Base
863
+
864
+ @staticmethod
865
+ def get_name() -> str:
866
+ # vLLM maps this name back through AttentionBackendEnum[...]; the
867
+ # MJOLNIR member exists only in a patched vLLM (the Mjolnir image),
868
+ # a stock vLLM reaches this class through CUSTOM.
869
+ from mjolnir import BACKEND_NAME, has_native_member
870
+
871
+ return BACKEND_NAME if has_native_member() else "CUSTOM"
872
+
873
+ @classmethod
874
+ def get_supported_kernel_block_sizes(cls, kv_cache_spec=None) -> list:
875
+ """The Mjolnir kernel runs 16/32/64/128-token pages. On FlashInfer
876
+ (which advertises 16/32/64 on sm_110) it adds 128 — the kernel's
877
+ benchmark default and the best fit for head-major pages — since FI's
878
+ prefill and KV-write paths take any page size. Any other fallback's
879
+ constraint is kept: FA4's hd256 kernel needs exactly 128."""
880
+ if _FALLBACK == "FLASHINFER":
881
+ return list(SUPPORTED_PAGE_SIZES)
882
+ try:
883
+ base = _Base.get_supported_kernel_block_sizes(kv_cache_spec)
884
+ except TypeError: # older vLLM: no kv_cache_spec argument
885
+ base = _Base.get_supported_kernel_block_sizes()
886
+ ours = [s for s in SUPPORTED_PAGE_SIZES if _block_size_ok(base, s)]
887
+ return ours or list(base)
888
+
889
+ # -- KV cache dtypes --------------------------------------------------
890
+ # The fallback's own list, plus fp6_e2m3 where vLLM knows the dtype (the
891
+ # image's thor-fp6-e2m3-kv-cache patch) and the FA4 fallback can read the
892
+ # fp8 scratch.
893
+ supported_kv_cache_dtypes: ClassVar[list] = list(getattr(_Base, "supported_kv_cache_dtypes", [])) + (
894
+ list(FP6_CACHE_DTYPES) if _FALLBACK == "FLASH_ATTN" else []
895
+ )
896
+
897
+ @classmethod
898
+ def supports_kv_cache_dtype(cls, kv_cache_dtype) -> bool:
899
+ if kv_cache_dtype in FP6_CACHE_DTYPES:
900
+ return _fp6_known_to_vllm() and _FALLBACK == "FLASH_ATTN" and _Base.supports_kv_cache_dtype("fp8")
901
+ return _Base.supports_kv_cache_dtype(kv_cache_dtype)
902
+
903
+ @classmethod
904
+ def supports_combination(cls, head_size, dtype, kv_cache_dtype, block_size, *args, **kwargs):
905
+ # fp6: the fallback runs on an e4m3 scratch, so it must support fp8
906
+ if kv_cache_dtype in FP6_CACHE_DTYPES:
907
+ kv_cache_dtype = "fp8"
908
+ return _Base.supports_combination(head_size, dtype, kv_cache_dtype, block_size, *args, **kwargs)
909
+
910
+ @classmethod
911
+ def customize_spec(cls, spec):
912
+ """fp6_e2m3: one (head, token) cell is the packed K row then the
913
+ packed V row, d * 6 / 8 bytes each."""
914
+ mode = getattr(spec, "kv_quant_mode", None)
915
+ if getattr(mode, "is_fp6", False) and getattr(spec, "state_content_bytes", None) is None:
916
+ from dataclasses import replace
917
+
918
+ from .kvfp6 import row_bytes
919
+
920
+ return replace(spec, state_content_bytes=row_bytes(spec.head_size) + row_bytes(spec.head_size_v))
921
+ return super().customize_spec(spec)
922
+
923
+ @staticmethod
924
+ def get_impl_cls():
925
+ return MjolnirImpl
926
+
927
+ @staticmethod
928
+ def get_builder_cls():
929
+ return MjolnirMetadataBuilder
930
+
931
+ @classmethod
932
+ def supports_device_cpu_query_lens_mismatch(cls) -> bool:
933
+ # The uniform-decode check reads query_start_loc_cpu; it must be exact.
934
+ return False