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/runner.py ADDED
@@ -0,0 +1,754 @@
1
+ """T5 paged fp8 decode-attention runner (framework-agnostic).
2
+
3
+ Owns everything the vLLM backend needs that is not vLLM-specific:
4
+ * the occupancy-pinned split plan, written into FIXED-ADDRESS device
5
+ buffers and padded to a FIXED grid size per q_len -> CUDA-graph safe
6
+ * a process-wide cache of compiled kernel variants (+ precompile)
7
+ * the launch: zero-copy Q/O views for q_len == 1, packing for q_len > 1,
8
+ per-tensor / per-head / per-(seq, head) descales via strides, optional
9
+ dynamic fp8 Q quantization
10
+
11
+ Nothing here imports vLLM, so tests/test_runner.py exercises it directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import math
18
+ import os
19
+ import threading
20
+ from dataclasses import dataclass
21
+
22
+ import torch
23
+
24
+ from . import qpack
25
+ from . import t5_decode as t5
26
+
27
+ FP8_MAX = 448.0
28
+ _log = logging.getLogger("vllm.mjolnir")
29
+ SUPPORTED_PAGE_SIZES = (16, 32, 64, 128)
30
+
31
+ _TORCH_TO_CUTLASS = None
32
+
33
+
34
+ def _cutlass_dtype(dt: torch.dtype):
35
+ global _TORCH_TO_CUTLASS
36
+ if _TORCH_TO_CUTLASS is None:
37
+ import cutlass
38
+
39
+ _TORCH_TO_CUTLASS = {
40
+ torch.bfloat16: cutlass.BFloat16,
41
+ torch.float16: cutlass.Float16,
42
+ torch.float32: cutlass.Float32,
43
+ torch.int32: cutlass.Int32,
44
+ torch.float8_e4m3fn: cutlass.Float8E4M3FN,
45
+ }
46
+ return _TORCH_TO_CUTLASS[dt]
47
+
48
+
49
+ def _ptr(t: torch.Tensor):
50
+ """Raw cute pointer for a tensor (cheap; no dlpack round trip)."""
51
+ import cutlass.cute as cute
52
+
53
+ assert t.data_ptr() % 16 == 0, "T5 kernel needs 16-byte aligned base pointers"
54
+ dtype = _cutlass_dtype(t.dtype)
55
+ try:
56
+ from cutlass.cute.runtime import make_ptr
57
+
58
+ return make_ptr(dtype, t.data_ptr(), cute.AddressSpace.gmem, assumed_align=16)
59
+ except ImportError: # older DSL: go through dlpack
60
+ from cutlass.cute.runtime import from_dlpack
61
+
62
+ src = t.view(torch.uint8) if t.dtype == torch.float8_e4m3fn else t
63
+ ct = from_dlpack(src, assumed_align=16)
64
+ ct.element_type = dtype
65
+ return ct.iterator
66
+
67
+
68
+ def _kv_cutlass_dtype(kv_format: str):
69
+ import cutlass
70
+
71
+ return {"fp8": cutlass.Float8E4M3FN, "e2m3": cutlass.Float6E2M3FN}[kv_format]
72
+
73
+
74
+ def _stream():
75
+ import cuda.bindings.driver as cuda
76
+
77
+ return cuda.CUstream(torch.cuda.current_stream().cuda_stream)
78
+
79
+
80
+ def tile_width(n_q: int, fp8_q: bool) -> int:
81
+ """Rows per CTA tile: next power of two >= n_q, min 8 (bf16) / 16 (fp8), max 32."""
82
+ base = 16 if fp8_q else 8
83
+ return max(base, min(32, 1 << max(0, (n_q - 1).bit_length())))
84
+
85
+
86
+ # ----------------------------------------------------------------------------
87
+ # Compiled-kernel cache (process wide; shared by all layers / builders)
88
+ # ----------------------------------------------------------------------------
89
+ _COMPILED: dict = {}
90
+ _COMPILE_LOCK = threading.Lock()
91
+
92
+ # ----------------------------------------------------------------------------
93
+ # On-disk kernel cache: a compiled variant is exported as an object file
94
+ # (CuTe DSL export_to_c) and loaded back on the next server start
95
+ # (cute.runtime.load_module), skipping the ~2-3 s compile per variant.
96
+ # MJOLNIR_ATTN_KERNEL_CACHE: unset/1 = on (under $VLLM_CACHE_ROOT/mjolnir or
97
+ # ~/.cache/mjolnir), 0 = off, a path = that directory. The entry key covers
98
+ # the variant, the kernel source, the CuTe DSL stack and the GPU, so a new
99
+ # kernel, image or DSL never loads a stale object. Best effort: any failure
100
+ # compiles in-process as before and turns the cache off for the process.
101
+ # ----------------------------------------------------------------------------
102
+ _DISK_MODULES: dict = {} # keep loaded modules alive
103
+ CACHE_STATS = {"loaded": 0, "compiled": 0}
104
+ _disk_cache_ok = True
105
+
106
+
107
+ def kernel_cache_dir():
108
+ from pathlib import Path
109
+
110
+ v = env_flag("MJOLNIR_ATTN_KERNEL_CACHE")
111
+ if v.lower() in ("0", "false", "no", "off"):
112
+ return None
113
+ if v and v.lower() not in ("1", "true", "yes", "on"):
114
+ return Path(v)
115
+ base = os.environ.get("VLLM_CACHE_ROOT") or os.path.join(os.path.expanduser("~"), ".cache")
116
+ return Path(base) / "mjolnir" / "kernels"
117
+
118
+
119
+ _FINGERPRINT: str | None = None
120
+
121
+
122
+ def _cache_fingerprint() -> str:
123
+ """Everything a compiled object depends on besides the variant key."""
124
+ global _FINGERPRINT
125
+ if _FINGERPRINT is None:
126
+ import hashlib
127
+ import importlib.metadata
128
+
129
+ from . import __version__
130
+
131
+ h = hashlib.sha256()
132
+ with open(t5.__file__, "rb") as f:
133
+ h.update(f.read())
134
+ dsl = sorted(
135
+ f"{d.metadata['Name']}=={d.version}"
136
+ for d in importlib.metadata.distributions()
137
+ if (d.metadata["Name"] or "").lower().startswith("nvidia-cutlass-dsl")
138
+ )
139
+ cap = torch.cuda.get_device_capability() if torch.cuda.is_available() else (0, 0)
140
+ h.update(f"{__version__}|{';'.join(dsl)}|sm{cap[0]}{cap[1]}".encode())
141
+ _FINGERPRINT = h.hexdigest()[:20]
142
+ return _FINGERPRINT
143
+
144
+
145
+ def _cache_entry(key):
146
+ import hashlib
147
+
148
+ d = kernel_cache_dir()
149
+ if d is None:
150
+ return None
151
+ kh = hashlib.sha256(repr(key).encode()).hexdigest()[:20]
152
+ return d / _cache_fingerprint() / f"t5_{kh}.o", f"mjolnir_t5_{kh}"
153
+
154
+
155
+ def _disk_load(key):
156
+ global _disk_cache_ok
157
+ if not _disk_cache_ok:
158
+ return None
159
+ try:
160
+ entry = _cache_entry(key)
161
+ if entry is None or not entry[0].exists():
162
+ return None
163
+ import cutlass.cute as cute
164
+
165
+ module = cute.runtime.load_module(str(entry[0]))
166
+ fn = getattr(module, entry[1])
167
+ _DISK_MODULES[key] = module
168
+ _log.debug("Mjolnir: loaded %s from %s", key, entry[0])
169
+ return fn
170
+ except Exception as e: # noqa: BLE001 - any failure: compile instead
171
+ _disk_cache_ok = False
172
+ _log.warning("Mjolnir: kernel cache load failed (%r); compiling, cache off for this process", e)
173
+ return None
174
+
175
+
176
+ def _export(fn, path, symbol: str) -> None:
177
+ """Write ``fn``'s object file to ``path`` (atomically), exported under
178
+ ``symbol``. CuTe DSL 4.7 (vLLM 0.31's): ``export_to_c(dir, stem,
179
+ function_prefix=...)`` writes ``<dir>/<stem>.o`` + ``.h``; later DSLs:
180
+ ``export_to_c(path, function_name=...)``."""
181
+ import inspect
182
+
183
+ params = inspect.signature(fn.export_to_c).parameters
184
+ stem = f"{path.stem}.tmp{os.getpid()}"
185
+ tmp_o, tmp_h = path.parent / f"{stem}.o", path.parent / f"{stem}.h"
186
+ try:
187
+ if "function_prefix" in params:
188
+ fn.export_to_c(str(path.parent), stem, function_prefix=symbol)
189
+ elif "function_name" in params:
190
+ fn.export_to_c(str(tmp_o), function_name=symbol)
191
+ else:
192
+ raise TypeError(f"unknown export_to_c signature {tuple(params)}")
193
+ os.replace(tmp_o, path)
194
+ finally:
195
+ for t in (tmp_o, tmp_h):
196
+ if t.exists():
197
+ t.unlink()
198
+
199
+
200
+ def _disk_store(key, fn) -> None:
201
+ global _disk_cache_ok
202
+ if not _disk_cache_ok:
203
+ return
204
+ try:
205
+ entry = _cache_entry(key)
206
+ if entry is None:
207
+ return
208
+ path, symbol = entry
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ _export(fn, path, symbol)
211
+ except Exception as e: # noqa: BLE001 - persistence is best effort
212
+ _disk_cache_ok = False
213
+ _log.warning("Mjolnir: kernel cache store failed (%r); cache off for this process", e)
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class VariantKey:
218
+ q_dtype: torch.dtype # bfloat16 | float8_e4m3fn
219
+ out_dtype: torch.dtype
220
+ head_size: int
221
+ heads_q: int
222
+ heads_k: int
223
+ page_size: int
224
+ q_len: int
225
+ max_splits: int
226
+ with_reduction: bool
227
+ ctas_per_sm: int
228
+ kv_stages: int # 0 = kernel default
229
+ pdl: bool
230
+ l2_prefetch: int
231
+ kv_evict_first: bool
232
+ kv_format: str = "fp8" # "fp8" | "e2m3" (packed fp6, read by kind::f8f6f4)
233
+ causal: bool = True # False: parallel-drafter blocks (every query sees every key)
234
+
235
+
236
+ # KV formats the vLLM path serves: e4m3 bytes, or e2m3 codes packed 4 per 3
237
+ # bytes (little-endian bitstream, mjolnir.t5_decode.pack_subbyte).
238
+ RUNNER_KV_FORMATS = {"fp8": 8, "e2m3": 6}
239
+
240
+
241
+ @dataclass
242
+ class StepPlan:
243
+ """Per-step plan shared by every layer of one KV-cache group."""
244
+
245
+ num_seqs: int
246
+ q_len: int
247
+ n_q: int
248
+ num_work: int # == grid.x, fixed per (q_len) capacity
249
+ with_reduction: bool
250
+ block_table: torch.Tensor # (>= num_seqs, max_blocks) int32, device
251
+ seq_lens: torch.Tensor # (>= num_seqs,) int32, device
252
+ work: torch.Tensor # (num_work, 4) int32 view of a persistent buffer
253
+ n_splits: torch.Tensor # (num_seqs,) int32 view of a persistent buffer
254
+ chunk_tiles: int
255
+ num_chunks: int
256
+ causal: bool = True
257
+
258
+
259
+ class T5DecodeRunner:
260
+ def __init__(
261
+ self,
262
+ *,
263
+ num_heads: int,
264
+ num_kv_heads: int,
265
+ head_size: int,
266
+ page_size: int,
267
+ out_dtype: torch.dtype,
268
+ max_num_seqs: int,
269
+ max_q_len: int = 1,
270
+ q_mode: str = "bf16", # "bf16" (Q as is) | "fp8" (dynamic per (seq, kv head))
271
+ ctas_per_sm: int = 2,
272
+ max_splits: int = 16,
273
+ kv_stages: int = 0,
274
+ pdl: bool = False, # programmatic dependent launch (decode + reduction)
275
+ l2_prefetch: int = 0, # TMA L2 prefetch distance in 128-row KV tiles
276
+ kv_evict_first: bool = False, # EVICT_FIRST L2 policy on K/V TMA loads
277
+ kv_format: str = "fp8", # "fp8" | "e2m3" (fp8 Q only)
278
+ device: torch.device | str = "cuda",
279
+ sm_count: int | None = None,
280
+ ):
281
+ assert num_heads % num_kv_heads == 0
282
+ assert head_size % 128 == 0, "T5 decode needs head_size % 128 == 0"
283
+ assert page_size in SUPPORTED_PAGE_SIZES, f"page size {page_size} unsupported"
284
+ assert q_mode in ("bf16", "fp8")
285
+ assert out_dtype in (torch.bfloat16, torch.float16)
286
+ self.h_q, self.h_k, self.d = num_heads, num_kv_heads, head_size
287
+ self.h_r = num_heads // num_kv_heads
288
+ self.page_size = page_size
289
+ self.out_dtype = out_dtype
290
+ self.max_num_seqs = max_num_seqs
291
+ self.max_q_len = max_q_len
292
+ self.fp8_q = q_mode == "fp8"
293
+ self.ctas_per_sm = ctas_per_sm
294
+ self.max_splits = max_splits
295
+ self.kv_stages = kv_stages
296
+ self.pdl = bool(pdl)
297
+ self.l2_prefetch = int(l2_prefetch)
298
+ self.kv_evict_first = bool(kv_evict_first)
299
+ assert kv_format in RUNNER_KV_FORMATS, f"kv_format must be one of {tuple(RUNNER_KV_FORMATS)}"
300
+ assert kv_format == "fp8" or q_mode == "fp8", f"{kv_format} KV runs on the fp8-Q path only"
301
+ self.kv_format = kv_format
302
+ self.kv_bits = RUNNER_KV_FORMATS[kv_format]
303
+ self.kv_row_bytes = head_size * self.kv_bits // 8 # bytes of one K (or V) row
304
+ self.device = torch.device(device)
305
+ if sm_count is None:
306
+ sm_count = torch.cuda.get_device_properties(self.device).multi_processor_count
307
+ self.sm_count = sm_count
308
+
309
+ # Capacity per q_len: work items that fit one wave (slots) or one per
310
+ # sequence, whichever is larger. Grid.x is always this -> fixed grid.
311
+ self._slots = {ql: self.slots(ql) for ql in range(1, max_q_len + 1)}
312
+ self._cap = {ql: max(self._slots[ql], max_num_seqs) for ql in self._slots}
313
+ max_cap = max(self._cap.values())
314
+ max_slots = max(self._slots.values())
315
+ nq_max = max_q_len * self.h_r
316
+ dev = self.device
317
+
318
+ # Persistent (fixed-address) buffers
319
+ self.work_buf = torch.zeros(max_cap, 4, dtype=torch.int32, device=dev)
320
+ self.nsplit_buf = torch.ones(max_num_seqs, dtype=torch.int32, device=dev)
321
+ self.lse_buf = torch.empty(max_num_seqs * self.h_k * nq_max, dtype=torch.float32, device=dev)
322
+ # Partials are only written for sequences the plan split, and the plan
323
+ # splits only when num_seqs < slots -> size them by slots, not batch.
324
+ part = max_splits * max_slots * self.h_k * nq_max
325
+ self.o_part = torch.empty(part * head_size, dtype=torch.float32, device=dev)
326
+ self.m_part = torch.empty(part, dtype=torch.float32, device=dev)
327
+ self.l_part = torch.empty(part, dtype=torch.float32, device=dev)
328
+ # Pack buffers: used for q_len > 1, fp8 Q, or a Q/O whose head stride
329
+ # is not d (always allocated so any vLLM view layout works).
330
+ rows = max_num_seqs * self.h_k * nq_max * head_size
331
+ q_pack_dtype = torch.float8_e4m3fn if self.fp8_q else torch.bfloat16
332
+ self.q_pack = torch.empty(rows, dtype=q_pack_dtype, device=dev)
333
+ self.o_pack = torch.empty(rows, dtype=out_dtype, device=dev)
334
+ # placeholder for the kernel's nvfp4 scale pointers (the vLLM path
335
+ # runs the fp8 KV format)
336
+ self.dummy_scales = torch.zeros(16, dtype=torch.float8_e4m3fn, device=dev)
337
+ self.q_descale = (
338
+ torch.ones(max_num_seqs, self.h_k, dtype=torch.float32, device=dev)
339
+ if self.fp8_q
340
+ else torch.ones(1, dtype=torch.float32, device=dev)
341
+ )
342
+ # One fused Triton launch for the fp8 Q quantization (MJOLNIR_ATTN_FUSED_QPACK=0:
343
+ # the PyTorch ops, ~10 launches per layer and step).
344
+ self._fused_qpack = (
345
+ self.fp8_q
346
+ and self.device.type == "cuda"
347
+ and qpack.available()
348
+ and env_flag("MJOLNIR_ATTN_FUSED_QPACK", "1").lower() not in ("0", "false", "no")
349
+ )
350
+
351
+ # ------------------------------------------------------------------ plan
352
+ def n_tiles(self, q_len: int) -> int:
353
+ n_q = q_len * self.h_r
354
+ return math.ceil(n_q / tile_width(n_q, self.fp8_q))
355
+
356
+ def slots(self, q_len: int) -> int:
357
+ return max(1, (self.sm_count * self.ctas_per_sm) // (self.h_k * self.n_tiles(q_len)))
358
+
359
+ def plan(
360
+ self,
361
+ seq_lens_cpu, # sequence of ints (len >= num_seqs)
362
+ num_seqs: int,
363
+ q_len: int,
364
+ block_table: torch.Tensor,
365
+ seq_lens: torch.Tensor,
366
+ margin: int = 0,
367
+ causal: bool = True,
368
+ ) -> StepPlan:
369
+ """CPU plan + async copy into the persistent device buffers.
370
+
371
+ ``margin`` plans every sequence that many tokens longer: the kernel
372
+ masks with the device ``seq_lens``, so the plan stays valid while
373
+ they grow by up to ``margin`` (fused multi-step draft decode)."""
374
+ assert 1 <= q_len <= self.max_q_len and num_seqs <= self.max_num_seqs
375
+ lens = [int(x) + margin for x in seq_lens_cpu[:num_seqs]]
376
+ cap = self._cap[q_len]
377
+ p = t5.plan_split_kv(
378
+ lens,
379
+ self.h_k,
380
+ self.n_tiles(q_len),
381
+ self.sm_count,
382
+ ctas_per_sm=self.ctas_per_sm,
383
+ max_splits=self.max_splits,
384
+ pad_to=cap,
385
+ )
386
+ # Pinned staging from the caching host allocator: safe to drop after
387
+ # a non_blocking copy (the allocator tracks the stream use).
388
+ work_h = torch.tensor(p.work, dtype=torch.int32).pin_memory()
389
+ ns_h = torch.tensor(p.n_splits, dtype=torch.int32).pin_memory()
390
+ self.work_buf[:cap].copy_(work_h, non_blocking=True)
391
+ self.nsplit_buf[:num_seqs].copy_(ns_h, non_blocking=True)
392
+ # Reduction launch is decided per batch SIZE (deterministic for a
393
+ # captured CUDA graph): a batch below one wave may split, a full
394
+ # wave never does.
395
+ with_reduction = self.max_splits > 1 and num_seqs < self._slots[q_len]
396
+ return StepPlan(
397
+ num_seqs=num_seqs,
398
+ q_len=q_len,
399
+ n_q=q_len * self.h_r,
400
+ num_work=cap,
401
+ with_reduction=with_reduction,
402
+ block_table=block_table,
403
+ seq_lens=seq_lens,
404
+ work=self.work_buf[:cap],
405
+ n_splits=self.nsplit_buf[:num_seqs],
406
+ chunk_tiles=p.chunk_tiles,
407
+ num_chunks=sum(1 for w in p.work if w[2] > w[1]),
408
+ causal=causal or q_len == 1, # one query token: the same kernel either way
409
+ )
410
+
411
+ # --------------------------------------------------------------- compile
412
+ def _key(self, q_len: int, with_reduction: bool, causal: bool = True) -> VariantKey:
413
+ return VariantKey(
414
+ q_dtype=torch.float8_e4m3fn if self.fp8_q else torch.bfloat16,
415
+ out_dtype=self.out_dtype,
416
+ head_size=self.d,
417
+ heads_q=self.h_q,
418
+ heads_k=self.h_k,
419
+ page_size=self.page_size,
420
+ q_len=q_len,
421
+ max_splits=self.max_splits,
422
+ with_reduction=with_reduction,
423
+ ctas_per_sm=self.ctas_per_sm,
424
+ kv_stages=self.kv_stages,
425
+ pdl=self.pdl,
426
+ l2_prefetch=self.l2_prefetch,
427
+ kv_evict_first=self.kv_evict_first,
428
+ kv_format=self.kv_format,
429
+ causal=causal or q_len == 1,
430
+ )
431
+
432
+ def _make_kernel(self, key: VariantKey):
433
+ n_q = key.q_len * self.h_r
434
+ fp8 = key.q_dtype == torch.float8_e4m3fn
435
+ if fp8:
436
+ cw = 0
437
+ elif self.d > 128 and key.ctas_per_sm == 1:
438
+ cw = 2
439
+ else:
440
+ cw = 1
441
+ k = t5.MixedInputFusedMultiHeadAttentionDecode(
442
+ headdim=self.d,
443
+ grouped_head_tile=tile_width(n_q, fp8),
444
+ convert_warpgroups=cw,
445
+ page_size=key.page_size,
446
+ q_len=key.q_len,
447
+ max_splits=key.max_splits,
448
+ ctas_per_sm=key.ctas_per_sm,
449
+ max_kv_stages=key.kv_stages,
450
+ with_reduction=key.with_reduction,
451
+ pdl=key.pdl,
452
+ l2_prefetch=key.l2_prefetch,
453
+ kv_evict_first=key.kv_evict_first,
454
+ kv_format=key.kv_format,
455
+ causal=key.causal,
456
+ )
457
+ import cutlass
458
+
459
+ k.can_implement(
460
+ 1,
461
+ self.h_q,
462
+ self.h_k,
463
+ self.d,
464
+ cutlass.Float8E4M3FN if fp8 else cutlass.BFloat16,
465
+ _kv_cutlass_dtype(key.kv_format),
466
+ )
467
+ return k
468
+
469
+ def _get_compiled(self, key: VariantKey, args):
470
+ fn = _COMPILED.get(key)
471
+ if fn is not None:
472
+ return fn
473
+ with _COMPILE_LOCK:
474
+ fn = _COMPILED.get(key)
475
+ if fn is None:
476
+ if torch.cuda.is_current_stream_capturing():
477
+ raise RuntimeError(
478
+ f"T5 kernel variant {key} not compiled before CUDA-graph "
479
+ "capture; call precompile() (the vLLM backend does this)"
480
+ )
481
+ fn = _disk_load(key)
482
+ if fn is None:
483
+ import cutlass.cute as cute
484
+
485
+ kernel = self._make_kernel(key)
486
+ fn = cute.compile(kernel, *args, options="--opt-level 2")
487
+ CACHE_STATS["compiled"] += 1
488
+ _disk_store(key, fn)
489
+ else:
490
+ CACHE_STATS["loaded"] += 1
491
+ _COMPILED[key] = fn
492
+ return fn
493
+
494
+ def is_compiled(self, q_len: int, with_reduction: bool, causal: bool = True) -> bool:
495
+ return self._key(q_len, with_reduction, causal) in _COMPILED
496
+
497
+ def ensure_compiled(self, q_len: int, with_reduction: bool, causal: bool = True):
498
+ """Compile one variant with tiny dummy tensors if it is missing.
499
+ Runtime shapes/strides/pointers are dynamic, so one compile per
500
+ (q_len, reduction) serves every batch and every layer. Must run
501
+ outside CUDA-graph capture (the vLLM builder calls it from build())."""
502
+ if self.is_compiled(q_len, with_reduction, causal):
503
+ return
504
+ dev = self.device
505
+ PS, d = self.page_size, self.d
506
+ n_q = q_len * self.h_r
507
+ if self.kv_format == "fp8":
508
+ kv = torch.zeros(1, PS, self.h_k, d, dtype=torch.float8_e4m3fn, device=dev)
509
+ else: # packed bytes, like vLLM's uint8 view
510
+ kv = torch.zeros(1, PS, self.h_k, self.kv_row_bytes, dtype=torch.uint8, device=dev)
511
+ one = torch.ones(1, dtype=torch.float32, device=dev)
512
+ q = torch.zeros(1, self.h_k, n_q, d, device=dev, dtype=torch.float8_e4m3fn if self.fp8_q else torch.bfloat16)
513
+ o = torch.empty(1, self.h_k, n_q, d, dtype=self.out_dtype, device=dev)
514
+ plan = StepPlan(
515
+ num_seqs=1,
516
+ q_len=q_len,
517
+ n_q=n_q,
518
+ num_work=1,
519
+ with_reduction=with_reduction,
520
+ block_table=torch.zeros(1, 1, dtype=torch.int32, device=dev),
521
+ seq_lens=torch.full((1,), PS, dtype=torch.int32, device=dev),
522
+ work=torch.tensor([[0, 0, 1, 0]], dtype=torch.int32, device=dev),
523
+ n_splits=torch.ones(1, dtype=torch.int32, device=dev),
524
+ chunk_tiles=1,
525
+ num_chunks=1,
526
+ causal=causal or q_len == 1,
527
+ )
528
+ self._launch(plan, q, o, kv, kv, one, one, one, 1.0, compile_only=True)
529
+
530
+ def precompile(self, q_lens=None):
531
+ """Compile every variant this runner can launch."""
532
+ for ql in q_lens or range(1, self.max_q_len + 1):
533
+ for red in (False, True) if self.max_splits > 1 else (False,):
534
+ self.ensure_compiled(ql, red)
535
+ torch.cuda.synchronize(self.device)
536
+
537
+ # ---------------------------------------------------------------- launch
538
+ def _kv_ptr(self, t: torch.Tensor):
539
+ """K/V pointer: e4m3 elements, or packed fp6 elements of a uint8 byte
540
+ view (the kernel converts byte strides to element strides)."""
541
+ if self.kv_format == "fp8":
542
+ return _ptr(t)
543
+ import cutlass.cute as cute
544
+ from cutlass.cute.runtime import make_ptr
545
+
546
+ assert t.dtype == torch.uint8 and t.data_ptr() % 32 == 0, "packed fp6 KV needs a 32-byte aligned uint8 view"
547
+ return make_ptr(_kv_cutlass_dtype(self.kv_format), t.data_ptr(), cute.AddressSpace.gmem, assumed_align=32)
548
+
549
+ def _launch(self, plan, q, o, k_cache, v_cache, qd, kd, vd, scale, compile_only=False):
550
+ b, n_q = plan.num_seqs, plan.n_q
551
+ strides = t5.kernel_strides(q, o, k_cache, v_cache, qd, kd, vd, kv_bits=self.kv_bits)
552
+ nqk = self.h_k * n_q
553
+ args = (
554
+ (b, self.h_k, n_q, self.d),
555
+ plan.block_table.stride(0), # block table row length (max_blocks)
556
+ k_cache.shape[0], # num_pages
557
+ plan.num_work,
558
+ strides,
559
+ _ptr(q),
560
+ self._kv_ptr(k_cache),
561
+ self._kv_ptr(v_cache),
562
+ _ptr(self.dummy_scales),
563
+ _ptr(self.dummy_scales), # nvfp4 scales (unused: fp8 KV)
564
+ _ptr(plan.block_table),
565
+ _ptr(plan.seq_lens),
566
+ _ptr(plan.work),
567
+ _ptr(plan.n_splits),
568
+ _ptr(qd),
569
+ _ptr(kd),
570
+ _ptr(vd),
571
+ _ptr(o),
572
+ _ptr(self.lse_buf[: max(1, b * nqk)]),
573
+ _ptr(self.o_part),
574
+ _ptr(self.m_part),
575
+ _ptr(self.l_part),
576
+ float(scale),
577
+ 1.0,
578
+ _stream(),
579
+ )
580
+ fn = self._get_compiled(self._key(plan.q_len, plan.with_reduction, plan.causal), args)
581
+ if not compile_only:
582
+ fn(*args)
583
+
584
+ def _pack_q_fused(self, q3, b: int, ql: int, q_view, qd, q_mul=None) -> bool:
585
+ """fp8 Q quantization + packing in one Triton launch (qpack). False
586
+ when unavailable; a failure disables it for good, outside capture."""
587
+ if not self._fused_qpack or q3.stride(-1) != 1:
588
+ return False
589
+ try:
590
+ qpack.pack_q_fp8(q3, b, ql, self.h_k, self.h_r, q_view, qd, q_mul)
591
+ return True
592
+ except Exception as e: # noqa: BLE001 - e.g. a Triton build that cannot target this GPU
593
+ if torch.cuda.is_current_stream_capturing():
594
+ raise
595
+ self._fused_qpack = False
596
+ _log.warning("Mjolnir: fused fp8 Q pack unavailable (%r); using the PyTorch ops", e)
597
+ return False
598
+
599
+ def run(
600
+ self,
601
+ plan: StepPlan,
602
+ query: torch.Tensor, # (>= b*q_len, h_q, d) bf16, any token stride
603
+ out: torch.Tensor, # (>= b*q_len, h_q, d) or (>= b*q_len, h_q*d)
604
+ k_cache: torch.Tensor, # (num_pages, page_size, h_k, d) logical view, fp8/uint8
605
+ v_cache: torch.Tensor,
606
+ k_scale: torch.Tensor, # fp32: 1 elem (per tensor), (h_k,), or (b, h_k)
607
+ v_scale: torch.Tensor,
608
+ softmax_scale: float,
609
+ q_mul: torch.Tensor | None = None, # (h_q, d) fp32: Q *= q_mul first (fp6 K channel scales)
610
+ o_mul: torch.Tensor | None = None, # (h_q, d) fp32: O *= o_mul last (fp6 V channel scales)
611
+ ):
612
+ b, ql, n_q = plan.num_seqs, plan.q_len, plan.n_q
613
+ h_k, h_r, d = self.h_k, self.h_r, self.d
614
+ tokens = b * ql
615
+ if self.kv_format == "fp8":
616
+ if k_cache.dtype == torch.uint8:
617
+ k_cache = k_cache.view(torch.float8_e4m3fn)
618
+ if v_cache.dtype == torch.uint8:
619
+ v_cache = v_cache.view(torch.float8_e4m3fn)
620
+ q3 = query[:tokens]
621
+ if q3.dim() == 2:
622
+ q3 = q3.view(tokens, self.h_q, d)
623
+ o3 = out[:tokens]
624
+ if o3.dim() == 2:
625
+ o3 = o3.view(tokens, self.h_q, d)
626
+
627
+ # ---- Q -------------------------------------------------------------
628
+ qd = self.q_descale
629
+ if self.fp8_q:
630
+ # dynamic per-(seq, kv head) e4m3 quantization into the pack buffer
631
+ qd = self.q_descale[:b]
632
+ q_view = self.q_pack[: b * h_k * n_q * d].view(b, h_k, n_q, d)
633
+ if not self._pack_q_fused(q3, b, ql, q_view, qd, q_mul):
634
+ qg = q3.float()
635
+ if q_mul is not None:
636
+ qg = qg * q_mul.view(self.h_q, d)
637
+ qg = qg.reshape(b, ql, h_k, h_r, d)
638
+ amax = qg.abs().amax(dim=(1, 3, 4)).clamp_min(1e-6) # (b, h_k)
639
+ scale_q = amax / FP8_MAX
640
+ qd.copy_(scale_q)
641
+ q_view.copy_(
642
+ (qg / scale_q[:, None, :, None, None])
643
+ .to(torch.float8_e4m3fn)
644
+ .permute(0, 2, 1, 3, 4)
645
+ .reshape(b, h_k, n_q, d)
646
+ )
647
+ elif q_mul is not None:
648
+ q_view = self.q_pack[: b * h_k * n_q * d].view(b, h_k, n_q, d)
649
+ qm = (q3.float() * q_mul.view(self.h_q, d)).to(q_view.dtype)
650
+ q_view.copy_(qm.reshape(b, ql, h_k, h_r, d).permute(0, 2, 1, 3, 4).reshape(b, h_k, n_q, d))
651
+ elif ql == 1 and q3.stride(2) == 1 and q3.stride(1) == d and q3.data_ptr() % 16 == 0:
652
+ q_view = q3.unflatten(1, (h_k, h_r)) # zero-copy (b, h_k, h_r, d)
653
+ else:
654
+ q_view = self.q_pack[: b * h_k * n_q * d].view(b, h_k, n_q, d)
655
+ q_view.copy_(q3.reshape(b, ql, h_k, h_r, d).permute(0, 2, 1, 3, 4).reshape(b, h_k, n_q, d))
656
+
657
+ # ---- O -------------------------------------------------------------
658
+ direct_out = ql == 1 and o3.stride(2) == 1 and o3.stride(1) == d and o3.data_ptr() % 16 == 0
659
+ o_view = o3.unflatten(1, (h_k, h_r)) if direct_out else self.o_pack[: b * h_k * n_q * d].view(b, h_k, n_q, d)
660
+
661
+ self._launch(plan, q_view, o_view, k_cache, v_cache, qd, k_scale, v_scale, softmax_scale)
662
+
663
+ if not direct_out:
664
+ o3.copy_(o_view.reshape(b, h_k, ql, h_r, d).permute(0, 2, 1, 3, 4).reshape(tokens, self.h_q, d))
665
+ if o_mul is not None:
666
+ o3.mul_(o_mul.view(self.h_q, d))
667
+ return out
668
+
669
+
670
+ ENV_PREFIX = "MJOLNIR_ATTN_"
671
+ LEGACY_ENV_PREFIX = "THOR_T5_"
672
+
673
+
674
+ def env_flag(name: str, default: str = "") -> str:
675
+ """Read a kernel knob. ``name`` is the canonical ``MJOLNIR_ATTN_*`` name;
676
+ the pre-rename ``THOR_T5_*`` spelling is still honored as a fallback."""
677
+ if name in os.environ:
678
+ return os.environ[name].strip()
679
+ if name.startswith(ENV_PREFIX):
680
+ legacy = LEGACY_ENV_PREFIX + name[len(ENV_PREFIX) :]
681
+ if legacy in os.environ:
682
+ return os.environ[legacy].strip()
683
+ return default.strip()
684
+
685
+
686
+ # ----------------------------------------------------------------------------
687
+ # KV-cache views
688
+ # ----------------------------------------------------------------------------
689
+ KV_LAYOUTS = ("BHN2D", "BNH2D", "B2NHD", "B2HND", "2BNHD", "2BHND")
690
+
691
+
692
+ def _canon_singleton(t: torch.Tensor) -> torch.Tensor:
693
+ """Size-1 dims may carry arbitrary strides (e.g. h_k == 1 under TP);
694
+ TMA wants 16-byte multiples -> zero them (never used for indexing)."""
695
+ if all(s != 1 for s in t.shape):
696
+ return t
697
+ st = [0 if n == 1 else s for n, s in zip(t.shape, t.stride())]
698
+ return t.as_strided(t.shape, st, t.storage_offset())
699
+
700
+
701
+ def detect_kv_layout(shape, num_kv_heads: int, page_size: int, head_size: int) -> str:
702
+ """Name the logical layout of a vLLM per-layer KV cache tensor.
703
+
704
+ vLLM main: (B, H, N, 2D) K|V interleaved in the last dim
705
+ older FlashInfer NHD: (B, 2, N, H, D); HND: (B, 2, H, N, D)
706
+ older FlashAttention: (2, B, N, H, D)
707
+ Ambiguous (H == N) shapes resolve to the vLLM-main / NHD reading;
708
+ MJOLNIR_ATTN_KV_LAYOUT overrides.
709
+ """
710
+ forced = env_flag("MJOLNIR_ATTN_KV_LAYOUT").upper()
711
+ if forced:
712
+ assert forced in KV_LAYOUTS, f"MJOLNIR_ATTN_KV_LAYOUT must be one of {KV_LAYOUTS}"
713
+ return forced
714
+ H, N, D = num_kv_heads, page_size, head_size
715
+ s = tuple(shape)
716
+ if len(s) == 4 and s[3] == 2 * D:
717
+ if (s[1], s[2]) == (H, N):
718
+ return "BHN2D"
719
+ if (s[1], s[2]) == (N, H):
720
+ return "BNH2D"
721
+ if len(s) == 5 and s[4] == D:
722
+ if s[1] == 2 and (s[2], s[3]) == (N, H):
723
+ return "B2NHD"
724
+ if s[1] == 2 and (s[2], s[3]) == (H, N):
725
+ return "B2HND"
726
+ if s[0] == 2 and (s[2], s[3]) == (N, H):
727
+ return "2BNHD"
728
+ if s[0] == 2 and (s[2], s[3]) == (H, N):
729
+ return "2BHND"
730
+ raise ValueError(
731
+ f"unrecognized KV cache shape {s} for H={H} N={N} D={D}; set MJOLNIR_ATTN_KV_LAYOUT to one of {KV_LAYOUTS}"
732
+ )
733
+
734
+
735
+ def split_kv_cache(kv_cache: torch.Tensor, layout: str, head_size: int):
736
+ """Zero-copy logical (num_pages, page_size, h_k, d) K and V views of any
737
+ supported layout. Physical stride order (NHD/HND, layer-major, ...) is
738
+ carried in the strides, which the kernel takes as runtime arguments."""
739
+ D = head_size
740
+ if layout == "BHN2D":
741
+ k, v = kv_cache[..., :D].transpose(1, 2), kv_cache[..., D:].transpose(1, 2)
742
+ elif layout == "BNH2D":
743
+ k, v = kv_cache[..., :D], kv_cache[..., D:]
744
+ elif layout == "B2NHD":
745
+ k, v = kv_cache[:, 0], kv_cache[:, 1]
746
+ elif layout == "B2HND":
747
+ k, v = kv_cache[:, 0].transpose(1, 2), kv_cache[:, 1].transpose(1, 2)
748
+ elif layout == "2BNHD":
749
+ k, v = kv_cache[0], kv_cache[1]
750
+ elif layout == "2BHND":
751
+ k, v = kv_cache[0].transpose(1, 2), kv_cache[1].transpose(1, 2)
752
+ else:
753
+ raise ValueError(layout)
754
+ return _canon_singleton(k), _canon_singleton(v)