Skip to content

Commit 63b4c4d

Browse files
authored
Enable 128k context for Gemma4-31B CUDA (pytorch#20316)
## Enable 128k context for Gemma4-31B (CUDA, TurboQuant TQ4 KV) ### What & why This PR enables 128k context end-to-end (export + C++ CUDA runtime) by using TurboQuant **TQ4 (4-bit)** format and fixing the fused TQ4 attention kernel so decode/prefill scale with the *actual* context length and are CUDA-graph capturable. The 50 sliding-window layers are unchanged (2,048-entry ring cache). With these fixes, enabling long context is just `--max-seq-len 131072 --turboquant`. ### Changes - **`TurboQuantKVCache.update` → `index_copy_`**: write the compressed cache via a static scatter so the decode step is CUDA-graph-capturable (the previous slice-assignment lowered to `index_put_`, which breaks graph capture). - **`tq4_sdpa` kv_len clamp**: an optional on-device int32 `kv_len` scalar bounds the kernel's KV loop to the filled context instead of the full pre-allocated 131,072 buffer → decode/prefill become **O(context)** instead of O(max_seq_len). It is read on-device (no host sync) so it stays correct across CUDA-graph replays. `kv_len=None` falls back to the original full-loop behavior (the shared Qwen path is byte-identical). - **`tq4_sdpa` prefill causal block-skip**: skip fully-masked causal upper-triangle KV blocks during chunked prefill. - **Tests**: extend `test_tq4_sdpa.py` to cover the actual 128k paths — long-KV kv_len clamp (with a large-magnitude "garbage tail" beyond `kv_len` as a built-in negative control) and bottom-right `mask_is_causal` chunked-prefill, at Gemma-global (D=512) and Qwen (D=256) shapes, plus a gated 131,072 case. Runs in the existing `unittest-cuda` CI. ### Results Model: Gemma4-31B (GGUF int6 weights) + TQ4 KV @128k. Hardware: A100 80GB. C++ CUDA runner, `--cuda_graph`, temperature 0. - **Works e2e**: 128k export + C++ CUDA runtime produce coherent output. - **Memory**: runtime peak **~27.0 GiB** (export peak 32.05 GiB) — runtime fits a 32 GB card, and is context-independent (KV buffers are pre-allocated at load). - **Quality**: needle-in-haystack retrieval is exact; per-position logit cosine of TQ4-KV vs bf16-KV is ~0.9997 and holds to the full 128k. Throughput (decode is the instantaneous, windowed rate measured at each KV depth): | Phase | Context length | Throughput (tok/s) | |---------|---------------:|-------------------:| | Prefill | 2,048 | 2,247 | | Decode | 128 | 45.6 | | Decode | 512 | 43.4 | | Decode | 2,048 | 36.5 | | Decode | 8,192 | 22.3 | | Decode | 32,768 | 8.7 | | Decode | 131,072 | N/A (too long) | ### Known limitation / follow-up 1. Decode throughput degrades with context depth: the global-layer TQ4 attention is O(context) and currently launches few CTAs (no split-K), so deep-context decode is under-parallelized. Adding split-K / flash-decoding to `tq4_sdpa` is the natural follow-up to speed up decode at long context. 2. Exportation memory consumption is too large to fit in consumer-based GPU like 5090, make it impossible to export on user server. Should optimize the gguf exportation path for better support.
1 parent 0e65ba6 commit 63b4c4d

5 files changed

Lines changed: 364 additions & 8 deletions

File tree

backends/cuda/tests/test_tq4_sdpa.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,242 @@ def test_output_shape_and_dtype(self):
438438
self.assertEqual(out.shape, (1, H_q, Lq, D))
439439
self.assertEqual(out.dtype, torch.bfloat16)
440440

441+
# ------------------------------------------------------------------
442+
# 128k code path: kv_len clamp (decode) + mask_is_causal (prefill)
443+
#
444+
# Every test above calls tq4_sdpa WITHOUT kv_len and WITHOUT
445+
# mask_is_causal, so they only exercise the kv_len=None fallback
446+
# (full-Lk loop) at short KV. The cases below drive the actual
447+
# long-context paths used in production by the Gemma-4 31B global
448+
# layers (head_dim=512, GQA 8:4) and Qwen 3.5 MoE (head_dim=256,
449+
# GQA 16:2):
450+
# * the on-device kv_len scalar that bounds the KV loop to the
451+
# filled context (decode), and
452+
# * the mask_is_causal per-tile causal block-skip (prefill).
453+
#
454+
# "GARBAGE TAIL": in production the KV cache is a fixed buffer
455+
# pre-allocated to max_seq_len (e.g. 131072). At any step only the
456+
# first kv_len positions hold real K/V; the rest is stale /
457+
# uninitialized memory that attention must ignore. We simulate that
458+
# tail by writing large-magnitude (x1000) values into [kv_len:]. If
459+
# the clamp / block-skip works the kernel never reads the tail and
460+
# the output matches a reference built from [0, kv_len) only; if it
461+
# is broken the huge tail values dominate the softmax and the cosine
462+
# collapses to ~0. So the garbage tail is a built-in negative control
463+
# (verified: dropping kv_len drives the cosine to ~-0.01 and fails).
464+
#
465+
# CAUSAL ALIGNMENT (top-left vs bottom-right): when L_q < L_kv (a
466+
# chunked prefill / decode, where the Lq new queries sit at the END
467+
# of a kv_len-long context) there are two ways to place the causal
468+
# triangle. PyTorch F.sdpa(is_causal=True) uses TOP-LEFT alignment
469+
# (query row i attends to keys [0, i]) -- wrong for a KV cache. This
470+
# kernel and gemma4_31b/model.py::_build_masks use BOTTOM-RIGHT
471+
# alignment: query row i is absolute position (kv_len - Lq + i) and
472+
# attends to keys [0, kv_len - Lq + i]. So the reference below builds
473+
# an explicit bottom-right mask (q_pos >= cache_pos) rather than
474+
# passing is_causal=True, which would otherwise mismatch the kernel.
475+
# ------------------------------------------------------------------
476+
477+
def _run_long_kv_test(
478+
self,
479+
*,
480+
H_q,
481+
H_kv,
482+
D,
483+
Lq,
484+
kv_len,
485+
buffer_len,
486+
causal=False,
487+
garbage=True,
488+
pass_kv_len=True,
489+
min_cosine=0.99,
490+
seed=42,
491+
):
492+
"""Drive tq4_sdpa over a buffer whose first ``kv_len`` positions are
493+
real and whose ``[kv_len:]`` tail is large-magnitude garbage, then
494+
compare against an fp32 reference built from the first ``kv_len``
495+
positions only.
496+
497+
The kernel sees the full (garbage-tailed) compressed buffer; the
498+
on-device ``kv_len`` scalar (and, for prefill, the bottom-right
499+
causal mask) must confine attention to ``[0, kv_len)``.
500+
501+
``causal=True`` builds a bottom-right-aligned mask (the Lq queries
502+
are the last Lq positions of a kv_len-long context), mirroring the
503+
production ``q_pos >= cache_pos`` mask in gemma4_31b/model.py
504+
``_build_masks`` and the kernel's ``(kv_len - Lq) + seq_pos`` block
505+
bound. We deliberately do NOT use ``F.sdpa(is_causal=True)`` for the
506+
reference: PyTorch aligns is_causal top-left when L_q < L_kv, while
507+
this kernel (and the model) align bottom-right.
508+
"""
509+
torch.manual_seed(seed)
510+
centroids, boundaries, rotation = _make_codebook_and_rotation(D)
511+
centroids = centroids.cuda()
512+
boundaries = boundaries.cuda()
513+
rotation = rotation.cuda()
514+
515+
B = 1
516+
k = torch.randn(B, H_kv, buffer_len, D, dtype=torch.bfloat16, device="cuda")
517+
v = torch.randn(B, H_kv, buffer_len, D, dtype=torch.bfloat16, device="cuda")
518+
if garbage and buffer_len > kv_len:
519+
g = buffer_len - kv_len
520+
k[:, :, kv_len:, :] = (
521+
torch.randn(B, H_kv, g, D, dtype=torch.bfloat16, device="cuda") * 1000.0
522+
)
523+
v[:, :, kv_len:, :] = (
524+
torch.randn(B, H_kv, g, D, dtype=torch.bfloat16, device="cuda") * 1000.0
525+
)
526+
527+
q = torch.randn(B, H_q, Lq, D, dtype=torch.bfloat16, device="cuda")
528+
529+
k_packed, k_norms = _compress(k, boundaries, rotation)
530+
v_packed, v_norms = _compress(v, boundaries, rotation)
531+
532+
attn_mask = None
533+
if causal:
534+
cache_pos = torch.arange(buffer_len, device="cuda")
535+
q_pos = torch.arange(kv_len - Lq, kv_len, device="cuda").unsqueeze(1)
536+
attn_mask = (q_pos >= cache_pos.unsqueeze(0)).view(1, 1, Lq, buffer_len)
537+
538+
kv_len_t = (
539+
torch.tensor([kv_len], dtype=torch.int32, device="cuda")
540+
if pass_kv_len
541+
else None
542+
)
543+
544+
out = self.tq4_sdpa(
545+
q,
546+
k_packed,
547+
k_norms,
548+
v_packed,
549+
v_norms,
550+
centroids,
551+
rotation,
552+
attn_mask=attn_mask,
553+
is_causal=False,
554+
scale=None,
555+
kv_len=kv_len_t,
556+
mask_is_causal=causal,
557+
)
558+
559+
# Reference: the same decompress-then-fp32-SDPA path the other tests
560+
# use (_reference_tq4_sdpa), but over ONLY the first kv_len positions
561+
# so the garbage tail can never influence it. _compress is per-row,
562+
# so compressing the sliced K/V here is bit-identical to the kernel's
563+
# view of the full buffer sliced to [:, :, :kv_len]; the helper also
564+
# handles the GQA repeat_interleave and mask broadcast internally.
565+
ref_mask = attn_mask[:, :, :, :kv_len] if attn_mask is not None else None
566+
ref, *_ = _reference_tq4_sdpa(
567+
q,
568+
k[:, :, :kv_len],
569+
v[:, :, :kv_len],
570+
centroids,
571+
boundaries,
572+
rotation,
573+
attn_mask=ref_mask,
574+
)
575+
576+
self.assertFalse(torch.isnan(out).any(), "NaN in output")
577+
cos = _cosine_sim(out, ref)
578+
self.assertGreater(
579+
cos,
580+
min_cosine,
581+
f"Cosine {cos:.5f} < {min_cosine} "
582+
f"(H_q={H_q} H_kv={H_kv} D={D} Lq={Lq} kv_len={kv_len} "
583+
f"buffer={buffer_len} causal={causal} kv_len_passed={pass_kv_len})",
584+
)
585+
return cos
586+
587+
def test_kv_len_clamp_decode_gemma_global(self):
588+
"""Decode (Lq=1) kv_len clamp at Gemma-4 31B global-layer shape
589+
(head_dim=512, GQA 8:4). N=8192 leaves a 24k garbage tail in a 32k
590+
buffer (clamp guard); N=32768 fills the buffer (full 32k loop)."""
591+
for N in (8192, 32768):
592+
with self.subTest(N=N):
593+
self._run_long_kv_test(
594+
H_q=8, H_kv=4, D=512, Lq=1, kv_len=N, buffer_len=32768
595+
)
596+
597+
def test_kv_len_clamp_decode_qwen(self):
598+
"""Decode (Lq=1) kv_len clamp at Qwen 3.5 MoE shape
599+
(head_dim=256, GQA 16:2)."""
600+
for N in (8192, 32768):
601+
with self.subTest(N=N):
602+
self._run_long_kv_test(
603+
H_q=16, H_kv=2, D=256, Lq=1, kv_len=N, buffer_len=32768
604+
)
605+
606+
def test_mask_is_causal_prefill_gemma_global(self):
607+
"""Chunked prefill (Lq>1) with mask_is_causal at Gemma global shape.
608+
The Lq queries are the last Lq of a kv_len-long context; the
609+
per-tile causal block-skip plus bottom-right mask must match the
610+
fp32 causal reference over the first kv_len positions. A garbage
611+
tail beyond kv_len also exercises the clamp."""
612+
for Lq, kv_len, buf in ((256, 4096, 8192), (2048, 8192, 16384)):
613+
with self.subTest(Lq=Lq, kv_len=kv_len):
614+
self._run_long_kv_test(
615+
H_q=8,
616+
H_kv=4,
617+
D=512,
618+
Lq=Lq,
619+
kv_len=kv_len,
620+
buffer_len=buf,
621+
causal=True,
622+
)
623+
624+
def test_mask_is_causal_prefill_qwen(self):
625+
"""Chunked prefill (Lq>1) with mask_is_causal at Qwen shape."""
626+
for Lq, kv_len, buf in ((256, 4096, 8192), (2048, 8192, 16384)):
627+
with self.subTest(Lq=Lq, kv_len=kv_len):
628+
self._run_long_kv_test(
629+
H_q=16,
630+
H_kv=2,
631+
D=256,
632+
Lq=Lq,
633+
kv_len=kv_len,
634+
buffer_len=buf,
635+
causal=True,
636+
)
637+
638+
def test_kv_len_none_fallback_qwen(self):
639+
"""Regression: the kv_len=None fallback (HAS_KV_LEN False, full-Lk
640+
loop) that the Qwen path relies on still matches the fp32 reference.
641+
This guards the original behavior the kv_len feature must preserve
642+
for callers that pass neither kv_len nor mask_is_causal."""
643+
self._run_long_kv_test(
644+
H_q=16,
645+
H_kv=2,
646+
D=256,
647+
Lq=1,
648+
kv_len=256,
649+
buffer_len=256,
650+
garbage=False,
651+
pass_kv_len=False,
652+
)
653+
654+
@unittest.skipUnless(
655+
os.environ.get("TQ4_RUN_128K") == "1",
656+
"128k case is heavy for the 24GB CI runner; set TQ4_RUN_128K=1 to run",
657+
)
658+
def test_kv_len_clamp_128k(self):
659+
"""Full 131072-entry buffer (Qwen shape). (a) kv_len=8192 with a
660+
~123k garbage tail — the clamp keeps decode O(context) and never
661+
touches the tail; (b) kv_len=131072 — correctness at true 128k
662+
scale. Gated behind TQ4_RUN_128K because the fp32 reference for (b)
663+
needs >~6GB and CI runs on a 24GB A10G."""
664+
self._run_long_kv_test(
665+
H_q=16, H_kv=2, D=256, Lq=1, kv_len=8192, buffer_len=131072
666+
)
667+
self._run_long_kv_test(
668+
H_q=16,
669+
H_kv=2,
670+
D=256,
671+
Lq=1,
672+
kv_len=131072,
673+
buffer_len=131072,
674+
garbage=False,
675+
)
676+
441677
# ------------------------------------------------------------------
442678
# Validation errors
443679
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)