From 06a86a046bd8670ac0a50777f389590002bd366a Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Thu, 20 Aug 2026 21:57:16 +0000 Subject: [PATCH 1/5] Add NVFP4/FP8 Q/K/P/V + 2:4 MLA attention on the vLLM path MLA kernels (modelopt/torch/kernels/quantization/attention/mla/): - mla_prefill: varlen prefill with asymmetric head dims, fused Q/K/P/V fake quant (NVFP4 1x16-along-contraction / per-tensor FP8), token-exact N:M score sparsity, natural-log LSE for chunked-context merging. - mla_decode: split-K absorbed decode over the paged latent cache with grouped query heads and fused P QDQ. The write-once quantized latent (module-level kv_c/k_pe QDQ before the cache write) is the single representation both BMM1-K and BMM2-V consume - no on-read re-quant. - reference: independent eager oracles replicating the kernel schedules. vLLM integration (TRITON_MLA, vLLM >= 0.26): - ModelOptMLAImpl reclasses TritonMLAImpl; forward_mha swaps a per-layer prefill backend around the inherited kv_b_proj/chunked-context plumbing, forward_mqa runs the FP32-carrier absorbed-Q QDQ + decode kernel. - Installer discovers MLAAttention on the quantize path with MLA gates (fp8 cache, sparse indexer, q padding, skip-softmax rejected) and the existing validate-then-publish/rollback flow. - configure_vllm_nvfp4_mla_quantizers: k_format governs kv_c/k_pe/k_mha, v_format the prefill projected V (k_mha/v_mha named to stay outside the *[kv]_bmm_quantizer calibration patterns). - Fix stale vllm.attention.layer MLA import in vllm_ptq_utils; extend the reload key rewrite to kv_c/k_pe quantizers. Verified in vllm/vllm-openai:v0.26.0 on RTX A5000 (SM86): MLA kernel golden tests 28 passed, dense kernel regression 33 passed, runtime and worker suites 84 passed, dynamic-modules e2e 10 passed. E4M3 quant-path tests are capability-gated and still need an SM89+ run. Signed-off-by: Kai Xu --- .gitignore | 1 + examples/vllm_serve/README.md | 22 +- examples/vllm_serve/vllm_ptq_utils.py | 14 +- examples/vllm_serve/vllm_reload_utils.py | 8 + .../quantization/attention/bmm2_qdq.py | 26 +- .../quantization/attention/mla/__init__.py | 21 + .../quantization/attention/mla/mla_decode.py | 405 +++++++++++++++ .../quantization/attention/mla/mla_prefill.py | 461 ++++++++++++++++++ .../quantization/attention/mla/reference.py | 376 ++++++++++++++ modelopt/torch/quantization/plugins/vllm.py | 119 ++++- .../attention_sparsity/plugins/vllm_mla.py | 303 ++++++++++++ .../plugins/vllm_runtime.py | 124 ++++- .../attention/mla/test_mla_decode.py | 271 ++++++++++ .../attention/mla/test_mla_prefill.py | 239 +++++++++ .../quantization/test_vllm_dynamic_modules.py | 65 +++ .../test_vllm_mla_runtime.py | 341 +++++++++++++ 16 files changed, 2769 insertions(+), 27 deletions(-) create mode 100644 modelopt/torch/kernels/quantization/attention/mla/__init__.py create mode 100644 modelopt/torch/kernels/quantization/attention/mla/mla_decode.py create mode 100644 modelopt/torch/kernels/quantization/attention/mla/mla_prefill.py create mode 100644 modelopt/torch/kernels/quantization/attention/mla/reference.py create mode 100644 modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py create mode 100644 tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py create mode 100644 tests/gpu/torch/kernels/quantization/attention/mla/test_mla_prefill.py create mode 100644 tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py diff --git a/.gitignore b/.gitignore index 72089b06e7e..b3c01d41ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ htmlcov/ .coverage.* coverage.xml .pytest_cache/ +.cache/ # Sphinx documentation docs/build diff --git a/examples/vllm_serve/README.md b/examples/vllm_serve/README.md index fc4e8a0ebcc..b488e7816f3 100644 --- a/examples/vllm_serve/README.md +++ b/examples/vllm_serve/README.md @@ -219,7 +219,27 @@ K is QDQ before its cache write, while V is written pristine. Complete 16-token Supported configurations are regular decoder self-attention with FlashInfer or FlashAttention, fp16/bf16 model and KV cache, equal Q/K/V head dimensions that are multiples of 16, and DCP 1. The FlashInfer adapter preserves both NHD and HND cache strides and separates mixed decode/prefill launches so each phase keeps its own kernel contract. The default `FULL_AND_PIECEWISE` mode remains enabled for fixed N:M and attention-only NVFP4; checkpoints with calibrated decode `threshold_scale_factor` must use a non-`FULL` decode graph mode such as `--enforce-eager` because the live sequence length is not replayed as a Python scalar. -Unsupported features are sliding window, ALiBi, softcap, sinks, FP8 KV cache, cross/encoder/MLA attention, KV sharing or transfer, prefix caching, speculative decoding, DBO/ubatching, and `FULL` mixed/prefill CUDA graphs. +Unsupported features are sliding window, ALiBi, softcap, sinks, FP8 KV cache, cross/encoder attention, KV sharing or transfer, prefix caching, speculative decoding, DBO/ubatching, and `FULL` mixed/prefill CUDA graphs. + +#### MLA attention (DeepSeek-family) + +The quantized worker also supports dense MLA text attention on the `TRITON_MLA` backend (pass `--attention-backend TRITON_MLA` when it is not the platform default): + +```bash +python vllm_serve_sparse_attn.py -tp 8 \ + --no-enable-prefix-caching --enforce-eager \ + --attention-backend TRITON_MLA \ + --worker-cls sparse_attn_worker.QuantSparseAttnWorker +``` + +The MLA operand mapping differs from regular attention because the latent cache is shared by both BMMs: + +- `q_format`: prefill quantizes the projected 192-d query in-kernel; decode quantizes the absorbed `kv_lora_rank + rope`-d query (FP32 QDQ carrier). With `q_format=fp8` the module-level quantizer QDQs the pre-projection query instead. +- `k_format`: governs the write-once latent-cache QDQ (`kv_c`, `k_pe` before the cache write) and the prefill projected K (in-kernel). +- `v_format`: governs the prefill projected V (in-kernel) only. Decode BMM2 consumes the write-once quantized latent cache as-is — a single stored representation with no on-read re-quantization. +- `p_format`: fused into both the prefill and decode kernels; the softmax denominator stays unquantized and P amax defaults to 1.0. + +MLA decode uses a fixed 32-split, 32-key-tile schedule with tile boundaries at absolute token positions, so quantized decode results are stable as the sequence grows and reproducible across batch shapes and devices. Optional checkpoint N:M sparsity applies to prefill new-token attention only (cached-context chunks run dense); skip-softmax is rejected on MLA layers. Sparse-only installation ignores MLA layers. DeepSeek V3.2-style sparse-indexer MLA and FP8 latent caches are unsupported. ## Known Problems diff --git a/examples/vllm_serve/vllm_ptq_utils.py b/examples/vllm_serve/vllm_ptq_utils.py index 709d6532fb3..fe050492599 100644 --- a/examples/vllm_serve/vllm_ptq_utils.py +++ b/examples/vllm_serve/vllm_ptq_utils.py @@ -141,12 +141,14 @@ def update_kv_cfg_for_mla(model: torch.nn.Module, kv_quant_cfg: list) -> list: `k_bmm_quantizer` and `v_bmm_quantizer`. This function copies the config from `*[kv]_bmm_quantizer` to also cover `*kv_c_bmm_quantizer`. """ - try: - from vllm.attention.layer import MLAAttention - except ImportError: - return kv_quant_cfg - - if not any(isinstance(m, MLAAttention) for m in model.modules()): + # Resolved via the quant plugin, which handles the MLAAttention module + # moving across vLLM releases (vllm.attention.layer no longer exists in + # vLLM >= 0.26). + from modelopt.torch.quantization.plugins.vllm import VllmMLAAttention + + if VllmMLAAttention is None or not any( + isinstance(m, VllmMLAAttention) for m in model.modules() + ): return kv_quant_cfg kv_entry = next( diff --git a/examples/vllm_serve/vllm_reload_utils.py b/examples/vllm_serve/vllm_reload_utils.py index 1bfc7f95fd4..362283058de 100644 --- a/examples/vllm_serve/vllm_reload_utils.py +++ b/examples/vllm_serve/vllm_reload_utils.py @@ -143,6 +143,14 @@ def _convert_key_for_vllm(key: str, value: Any) -> tuple[str, str | None, Any]: ) return ("group", group_key, value) + # MLA latent quantizers: self_attn.kv_c/k_pe_bmm_quantizer -> + # self_attn.mla_attn.mla_attn.* (DeepSeek-style wrapper nesting in vLLM). + # vLLM-native keys that already carry the mla_attn prefix copy as-is below. + mla_bmm_match = re.search(r"(.*\.self_attn)\.((?:kv_c|k_pe)_bmm_quantizer.*)$", key) + if mla_bmm_match: + new_key = mla_bmm_match.group(1) + ".mla_attn.mla_attn." + mla_bmm_match.group(2) + return ("copy", new_key, value) + # Transform bmm_quantizer keys: self_attn.q/k/v_bmm_quantizer -> self_attn.attn.q/k/v_bmm_quantizer bmm_match = re.search(r"(.*\.self_attn)\.([qkv]_bmm_quantizer.*)$", key) or re.search( r"(.*\.mixer)\.([qkv]_bmm_quantizer.*)$", key diff --git a/modelopt/torch/kernels/quantization/attention/bmm2_qdq.py b/modelopt/torch/kernels/quantization/attention/bmm2_qdq.py index b33900546eb..70802a9b999 100644 --- a/modelopt/torch/kernels/quantization/attention/bmm2_qdq.py +++ b/modelopt/torch/kernels/quantization/attention/bmm2_qdq.py @@ -13,12 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""NVFP4 operand helpers for the attention ``P @ V`` matmul (BMM2). +"""NVFP4 operand helpers for the attention BMM matmuls. -P and V share the low-level ``nvfp4_scalar_qdq`` primitive, but retain thin -operand-specific wrappers because their layouts and amax reductions differ. -P is nonnegative with layout ``[M, K]``; V is signed with layout ``[K, N]``. -Both use block-16 scaling along the BMM2 contraction axis. +P, V, and the signed A-side share the low-level ``nvfp4_scalar_qdq`` +primitive, but retain thin operand-specific wrappers because their layouts +and amax reductions differ. P is nonnegative with layout ``[M, K]``; V is +signed with layout ``[K, N]``; the signed A-side (Q of BMM1) is ``[M, K]``. +All use block-16 scaling along the BMM contraction axis. """ import math @@ -70,6 +71,21 @@ def _v_qdq_nvfp4(v, global_scale, BLOCK_N: tl.constexpr, BLOCK_D: tl.constexpr): return tl.reshape(nvfp4_scalar_qdq(grouped, block_amax, global_scale, 16), (BLOCK_N, BLOCK_D)) +@triton.jit +def _a_qdq_nvfp4(x, global_scale, BLOCK_M: tl.constexpr, BLOCK_K: tl.constexpr): + """Fake-quantize a signed A-side operand ``[M, K]`` in block-16 groups along K. + + The BMM1 Q-operand counterpart of :func:`_p_qdq_nvfp4`: same ``[M, K]`` + layout and contraction-axis blocking, but signed, so the block amax uses + ``abs``. Zero-padded lanes (masked loads) form all-zero blocks that + ``nvfp4_scalar_qdq`` guards to zero. + """ + tl.static_assert(BLOCK_K % 16 == 0, "BLOCK_K must be divisible by 16 for NVFP4") + grouped = tl.reshape(x, (BLOCK_M, BLOCK_K // 16, 16)) + block_amax = tl.expand_dims(tl.max(tl.abs(grouped), axis=2), 2) + return tl.reshape(nvfp4_scalar_qdq(grouped, block_amax, global_scale, 16), (BLOCK_M, BLOCK_K)) + + @triton.jit def _fake_quant_v_onwrite_kernel( V_cache, diff --git a/modelopt/torch/kernels/quantization/attention/mla/__init__.py b/modelopt/torch/kernels/quantization/attention/mla/__init__.py new file mode 100644 index 00000000000..e83482d0eee --- /dev/null +++ b/modelopt/torch/kernels/quantization/attention/mla/__init__.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MLA attention kernels with fused fake quantization (prefill and decode).""" + +from .mla_decode import mla_attention_decode +from .mla_prefill import mla_prefill_attention + +__all__ = ["mla_attention_decode", "mla_prefill_attention"] diff --git a/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py new file mode 100644 index 00000000000..cc5b822d3ab --- /dev/null +++ b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py @@ -0,0 +1,405 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Split-K absorbed-MLA decode over a paged latent cache, with fused P QDQ. + +Absorbed MLA decode is MQA: every query head attends to the single latent KV +"head". The absorbed query carries ``kv_lora_rank + qk_rope_head_dim`` +features (576 for DeepSeek-family models); K is the full latent cache row and +V is its first ``kv_lora_rank`` features — the same memory read once per tile +and reused for both BMMs. + +Quantization contract: the latent cache is expected to hold write-once +fake-quantized values (module-level ``kv_c``/``k_pe`` quantizers applied +before the cache write). Both BMM1-K and BMM2-V consume that single +representation as-is — there is deliberately no on-read re-quantization. The +absorbed Q is expected to be fake-quantized by the caller (dynamic NVFP4 uses +an FP32 QDQ carrier, like ``triton_fa``'s ``Q_IS_FP32``). Only the softmax P +is quantized inside the kernel, after the row-sum, so the softmax denominator +stays unquantized. + +P QDQ operates on split-local, unnormalized online-softmax probabilities; +its numerics therefore include the fixed split count and tile size as part of +the kernel schedule. Split bounds are tile-aligned so quant-relevant tile +boundaries sit at absolute token positions and results are stable as the +sequence grows. Inference-only. +""" + +import torch +import triton +import triton.language as tl + +from modelopt.torch.kernels.common.attention.decode_attention import _qdq_scale +from modelopt.torch.kernels.common.attention.triton_fa import LOG2E +from modelopt.torch.kernels.quantization.attention.bmm2_qdq import _p_qdq_nvfp4 +from modelopt.torch.kernels.quantization.common.fp8_quant import fp8_scalar_qdq + +__all__ = ["mla_attention_decode"] + +# Referenced inside @triton.jit code, so it must be a tl.constexpr global. +LN2 = tl.constexpr(0.6931471805599453) + +_BLOCK_N = 32 +_DEFAULT_KV_SPLITS = 32 +_MAX_KV_SPLITS = 32 +_P_QDQ_MODES = {None: 0, "fp8": 1, "nvfp4": 2} + + +@triton.jit +def _mla_decode_split_kernel( + Q, # [batch, num_heads, KV_LORA_RANK + QK_ROPE_DIM] absorbed query + Latent_cache, # [num_blocks, page_size, KV_LORA_RANK + QK_ROPE_DIM] + Block_table, # [batch, max_blocks_per_seq] + B_seq_len, # [batch] + M_partial, # [batch, num_heads, NUM_KV_SPLITS] + L_partial, # [batch, num_heads, NUM_KV_SPLITS] + Acc_partial, # [batch, num_heads, NUM_KV_SPLITS, BLOCK_DL] + qk_scale, # softmax_scale * log2(e) + stride_qb, + stride_qh, + stride_lc_block, + stride_lc_pos, + stride_mb, + stride_mh, + stride_ab, + stride_ah, + stride_as, + p_qdq_scale, + max_blocks_per_seq, + H: tl.constexpr, # number of query heads + BLOCK_H: tl.constexpr, # query heads per program (grouped MQA) + BLOCK_N: tl.constexpr, + BLOCK_DL: tl.constexpr, # next_power_of_2(KV_LORA_RANK) + BLOCK_DPE: tl.constexpr, # next_power_of_2(QK_ROPE_DIM) + KV_LORA_RANK: tl.constexpr, + QK_ROPE_DIM: tl.constexpr, + PAGE_SIZE: tl.constexpr, + NUM_KV_SPLITS: tl.constexpr, + P_QDQ: tl.constexpr, # 0=off, 1=FP8 E4M3, 2=NVFP4 + Q_IS_FP32: tl.constexpr, # dynamic NVFP4 QDQ carrier uses FP32 +): + """Compute one partial softmax for one head group, request, and KV split.""" + # Head groups on axis 0 (fastest varying) so programs sharing the same + # latent KV tiles are co-scheduled for L2 reuse (MQA: kv_group_num == H). + head_group = tl.program_id(0) + batch_idx = tl.program_id(1) + split_idx = tl.program_id(2) + + head_ids = head_group * BLOCK_H + tl.arange(0, BLOCK_H) + mask_h = head_ids < H + seq_len = tl.load(B_seq_len + batch_idx) + + dl_pos = tl.arange(0, BLOCK_DL) + dpe_pos = tl.arange(0, BLOCK_DPE) + dl_mask = dl_pos < KV_LORA_RANK + dpe_mask = dpe_pos < QK_ROPE_DIM + kv_pos = tl.arange(0, BLOCK_N) + + q_base = Q + batch_idx * stride_qb + head_ids[:, None] * stride_qh + q_nope = tl.load(q_base + dl_pos[None, :], mask=mask_h[:, None] & dl_mask[None, :], other=0.0) + q_pe = tl.load( + q_base + KV_LORA_RANK + dpe_pos[None, :], + mask=mask_h[:, None] & dpe_mask[None, :], + other=0.0, + ) + if Q_IS_FP32: + q_nope = q_nope.to(tl.float32) + q_pe = q_pe.to(tl.float32) + + # Tile-aligned split bounds: quant-relevant tile boundaries sit at + # absolute token positions, so numerics are stable as the sequence grows. + num_tiles = tl.cdiv(seq_len, BLOCK_N) + tiles_per_split = tl.cdiv(num_tiles, NUM_KV_SPLITS) + kv_lo = split_idx * tiles_per_split * BLOCK_N + kv_hi = tl.minimum(kv_lo + tiles_per_split * BLOCK_N, seq_len) + + running_max = tl.zeros([BLOCK_H], dtype=tl.float32) - float("inf") + running_sum = tl.zeros([BLOCK_H], dtype=tl.float32) + acc = tl.zeros([BLOCK_H, BLOCK_DL], dtype=tl.float32) + + for kv_start in range(kv_lo, kv_hi, BLOCK_N): + kv_start = tl.multiple_of(kv_start, BLOCK_N) + kv_abs = kv_start + kv_pos + kv_valid = kv_abs < seq_len + + page = tl.load( + Block_table + batch_idx * max_blocks_per_seq + kv_abs // PAGE_SIZE, + mask=kv_valid, + other=0, + ).to(tl.int64) + pos_ptrs = page * stride_lc_block + (kv_abs % PAGE_SIZE) * stride_lc_pos + + # K^T tiles from the latent cache: NOPE [BLOCK_DL, BLOCK_N], PE [BLOCK_DPE, BLOCK_N] + k_nope = tl.load( + Latent_cache + pos_ptrs[None, :] + dl_pos[:, None], + mask=kv_valid[None, :] & dl_mask[:, None], + other=0.0, + ) + k_pe = tl.load( + Latent_cache + pos_ptrs[None, :] + KV_LORA_RANK + dpe_pos[:, None], + mask=kv_valid[None, :] & dpe_mask[:, None], + other=0.0, + ) + + if Q_IS_FP32: + scores = tl.dot(q_nope, k_nope.to(tl.float32), input_precision="ieee") + scores += tl.dot(q_pe, k_pe.to(tl.float32), input_precision="ieee") + else: + scores = tl.dot(q_nope, k_nope) + tl.dot(q_pe, k_pe) + scores = scores * qk_scale + scores = tl.where(kv_valid[None, :], scores, float("-inf")) + + # --- Online softmax update: the denominator uses unquantized p --- + m_new = tl.maximum(running_max, tl.max(scores, 1)) + p = tl.math.exp2(scores - m_new[:, None]) + p = tl.where(kv_valid[None, :], p, 0.0) + correction = tl.math.exp2(running_max - m_new) + running_sum = running_sum * correction + tl.sum(p, 1) + acc = acc * correction[:, None] + + if P_QDQ == 1: + p = fp8_scalar_qdq(p, p_qdq_scale) + elif P_QDQ == 2: + # Native packing consumes the cache dtype; the QDQ value stays FP32. + p = p.to(Latent_cache.dtype.element_ty).to(tl.float32) + p = _p_qdq_nvfp4(p, p_qdq_scale, BLOCK_H, BLOCK_N) + + # V is the first KV_LORA_RANK features of the same latent tile, + # consumed as-is (single stored representation; no on-read re-quant). + v = tl.trans(k_nope) + if P_QDQ == 2: + acc = tl.dot(p, v.to(tl.float32), acc, input_precision="ieee") + else: + acc = tl.dot(p.to(v.dtype), v, acc) + running_max = m_new + + partial_offset = batch_idx * stride_mb + head_ids * stride_mh + split_idx + tl.store(M_partial + partial_offset, running_max, mask=mask_h) + tl.store(L_partial + partial_offset, running_sum, mask=mask_h) + acc_ptrs = ( + batch_idx * stride_ab + + head_ids[:, None] * stride_ah + + split_idx * stride_as + + dl_pos[None, :] + ) + tl.store(Acc_partial + acc_ptrs, acc, mask=mask_h[:, None] & dl_mask[None, :]) + + +@triton.jit +def _mla_decode_combine_kernel( + M_partial, + L_partial, + Acc_partial, + Out, # [batch, num_heads, KV_LORA_RANK] + Lse, # [batch, num_heads] natural-log LSE (dummy when not STORE_LSE) + stride_mb, + stride_mh, + stride_ab, + stride_ah, + stride_as, + stride_ob, + stride_oh, + stride_lse_b, + BLOCK_DL: tl.constexpr, + KV_LORA_RANK: tl.constexpr, + NUM_KV_SPLITS: tl.constexpr, + STORE_LSE: tl.constexpr, +): + """Merge split-local online-softmax states.""" + batch_idx = tl.program_id(0) + head_idx = tl.program_id(1) + dl_pos = tl.arange(0, BLOCK_DL) + dl_mask = dl_pos < KV_LORA_RANK + base_ml = batch_idx * stride_mb + head_idx * stride_mh + base_acc = batch_idx * stride_ab + head_idx * stride_ah + + running_max = -float("inf") + running_sum = 0.0 + acc = tl.zeros([BLOCK_DL], dtype=tl.float32) + for split_idx in range(NUM_KV_SPLITS): + split_sum = tl.load(L_partial + base_ml + split_idx) + if split_sum > 0.0: + split_max = tl.load(M_partial + base_ml + split_idx) + split_acc = tl.load( + Acc_partial + base_acc + split_idx * stride_as + dl_pos, + mask=dl_mask, + other=0.0, + ) + new_max = tl.maximum(running_max, split_max) + correction = tl.math.exp2(running_max - new_max) + split_correction = tl.math.exp2(split_max - new_max) + acc = acc * correction + split_acc * split_correction + running_sum = running_sum * correction + split_sum * split_correction + running_max = new_max + + output = acc / tl.maximum(running_sum, 1e-6) + tl.store( + Out + batch_idx * stride_ob + head_idx * stride_oh + dl_pos, + output, + mask=dl_mask, + ) + if STORE_LSE: + lse = LN2 * (running_max + tl.math.log2(running_sum)) + lse = tl.where(running_sum == 0.0, float("-inf"), lse) + tl.store(Lse + batch_idx * stride_lse_b + head_idx, lse) + + +def mla_attention_decode( + q: torch.Tensor, + latent_cache: torch.Tensor, + block_table: torch.Tensor, + b_seq_len: torch.Tensor, + *, + softmax_scale: float, + kv_lora_rank: int = 512, + qk_rope_head_dim: int = 64, + page_size: int | None = None, + num_kv_splits: int = _DEFAULT_KV_SPLITS, + p_qdq: str | None = None, + p_qdq_amax: float = 1.0, + return_lse: bool = True, + out_dtype: torch.dtype | None = None, +) -> tuple[torch.Tensor, torch.Tensor | None]: + """Decode one absorbed query token per request over a paged latent cache. + + Args: + q: ``[batch, num_heads, kv_lora_rank + qk_rope_head_dim]`` absorbed + query. Pass FP32 for the dynamic-NVFP4 QDQ carrier (Q is expected + to be fake-quantized by the caller); BF16/FP16 otherwise. + latent_cache: ``[num_blocks, page_size, kv_lora_rank + qk_rope_head_dim]`` + paged latent cache. Expected to hold write-once fake-quantized + values; consumed as-is for both BMM1-K and BMM2-V. + block_table: ``[batch, max_blocks_per_seq]`` page table. + b_seq_len: ``[batch]`` KV sequence lengths. + softmax_scale: Softmax scale (required; MLA layers fold in mscale). + kv_lora_rank: Latent width (V/output width). + qk_rope_head_dim: RoPE feature width appended to the latent. + page_size: Tokens per page; defaults to ``latent_cache.shape[1]``. + num_kv_splits: Fixed split count. P QDQ numerics follow the + split-local schedule, so this stays fixed by default for + reproducibility across batch shapes and devices. + p_qdq: Softmax-P fake quant-dequant: ``None``, ``"fp8"``, ``"nvfp4"``. + p_qdq_amax: Per-tensor P amax (default 1.0, the theoretical bound). + return_lse: Also return the natural-log LSE ``[batch, num_heads]``. + out_dtype: Output dtype (default ``latent_cache.dtype``, the model + compute dtype expected by the V up-projection). + + Returns: + ``(out [batch, num_heads, kv_lora_rank], lse [batch, num_heads] | None)``. + """ + if q.ndim != 3: + raise ValueError(f"q must be [batch, heads, head_dim], got {tuple(q.shape)}") + if latent_cache.ndim != 3: + raise ValueError( + f"latent_cache must be [num_blocks, page_size, head_dim], " + f"got {tuple(latent_cache.shape)}" + ) + head_dim = kv_lora_rank + qk_rope_head_dim + if q.shape[2] != head_dim or latent_cache.shape[2] != head_dim: + raise ValueError( + f"q and latent_cache feature dims must equal kv_lora_rank + qk_rope_head_dim " + f"({head_dim}), got {q.shape[2]} and {latent_cache.shape[2]}" + ) + if page_size is None: + page_size = latent_cache.shape[1] + if page_size != latent_cache.shape[1]: + raise ValueError(f"page_size {page_size} must match latent_cache.shape[1]") + if not 1 <= num_kv_splits <= _MAX_KV_SPLITS: + raise ValueError(f"num_kv_splits must be in [1, {_MAX_KV_SPLITS}], got {num_kv_splits}") + if p_qdq == "nvfp4" and (kv_lora_rank % 16 or qk_rope_head_dim % 16): + raise ValueError("NVFP4 decode requires dimensions divisible by 16") + batch, num_heads = q.shape[0], q.shape[1] + if b_seq_len.shape != (batch,) or block_table.shape[0] != batch: + raise ValueError("decode metadata batch dimension must match q") + + p_qdq_scale = _qdq_scale(p_qdq, p_qdq_amax, "p") + q = q.contiguous() + if latent_cache.stride(-1) != 1: + raise ValueError("latent_cache last dim must be contiguous") + + block_dl = triton.next_power_of_2(kv_lora_rank) + block_dpe = triton.next_power_of_2(qk_rope_head_dim) + block_h = 16 if num_heads <= 16 else 32 + qk_scale = softmax_scale * LOG2E + if out_dtype is None: + out_dtype = latent_cache.dtype + + m_partial = torch.empty(batch, num_heads, num_kv_splits, dtype=torch.float32, device=q.device) + l_partial = torch.empty_like(m_partial) + acc_partial = torch.empty( + batch, num_heads, num_kv_splits, block_dl, dtype=torch.float32, device=q.device + ) + out = torch.empty(batch, num_heads, kv_lora_rank, dtype=out_dtype, device=q.device) + if return_lse: + lse = torch.empty(batch, num_heads, dtype=torch.float32, device=q.device) + else: + lse = torch.empty(1, dtype=torch.float32, device=q.device) + + with torch.cuda.device(q.device): + _mla_decode_split_kernel[(triton.cdiv(num_heads, block_h), batch, num_kv_splits)]( + q, + latent_cache, + block_table, + b_seq_len, + m_partial, + l_partial, + acc_partial, + qk_scale, + q.stride(0), + q.stride(1), + latent_cache.stride(0), + latent_cache.stride(1), + m_partial.stride(0), + m_partial.stride(1), + acc_partial.stride(0), + acc_partial.stride(1), + acc_partial.stride(2), + p_qdq_scale, + block_table.shape[1], + H=num_heads, + BLOCK_H=block_h, + BLOCK_N=_BLOCK_N, + BLOCK_DL=block_dl, + BLOCK_DPE=block_dpe, + KV_LORA_RANK=kv_lora_rank, + QK_ROPE_DIM=qk_rope_head_dim, + PAGE_SIZE=page_size, + NUM_KV_SPLITS=num_kv_splits, + P_QDQ=_P_QDQ_MODES[p_qdq], + Q_IS_FP32=q.dtype == torch.float32, + num_warps=4, + num_stages=2, + ) + _mla_decode_combine_kernel[(batch, num_heads)]( + m_partial, + l_partial, + acc_partial, + out, + lse, + m_partial.stride(0), + m_partial.stride(1), + acc_partial.stride(0), + acc_partial.stride(1), + acc_partial.stride(2), + out.stride(0), + out.stride(1), + lse.stride(0) if return_lse else 0, + BLOCK_DL=block_dl, + KV_LORA_RANK=kv_lora_rank, + NUM_KV_SPLITS=num_kv_splits, + STORE_LSE=return_lse, + num_warps=4, + ) + return out, (lse if return_lse else None) diff --git a/modelopt/torch/kernels/quantization/attention/mla/mla_prefill.py b/modelopt/torch/kernels/quantization/attention/mla/mla_prefill.py new file mode 100644 index 00000000000..dbd134812ee --- /dev/null +++ b/modelopt/torch/kernels/quantization/attention/mla/mla_prefill.py @@ -0,0 +1,461 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Varlen MLA prefill attention with fused Q/K/P/V fake quantization. + +MLA prefill runs standard multi-head attention over the up-projected per-head +K/V, but with asymmetric head dims: Q and K carry ``qk_nope_head_dim + +qk_rope_head_dim`` features (192 for DeepSeek-family models) while V and the +output carry ``v_head_dim`` (128). The kernel keeps two independent feature +axes so no V padding is required. + +Quantization follows the ModelOpt conventions: NVFP4 uses 1x16 blocks along +the BMM contraction axis (Q/K along the feature axis, P/V along the key/token +axis) with E4M3 block scales and a per-tensor global scale ``amax / (6*448)``; +FP8 is per-tensor E4M3 with scale ``amax / 448``. The softmax denominator +accumulates the unquantized P; only the quantized P feeds ``P @ V``. NVFP4 +operands run their dots on FP32 carriers with IEEE precision. RoPE feature +dims of Q/K participate in the same single quantization pass (no PE +special-casing). + +Causal masking treats Q as the suffix of the KV span (``k_len >= q_len``). +Cached-context chunks run with ``causal=False`` and ``return_lse=True``; the +caller (e.g. vLLM's MLA layer) merges chunk states via ``merge_attn_states`` +using the returned natural-log LSE. Inference-only: no autograd support. +""" + +import math + +import torch +import triton +import triton.language as tl + +from modelopt.torch.kernels.common.attention.triton_fa import LOG2E, _apply_mask +from modelopt.torch.kernels.quantization.attention.bmm2_qdq import ( + _a_qdq_nvfp4, + _p_qdq_nvfp4, + _v_qdq_nvfp4, +) +from modelopt.torch.kernels.quantization.common.fp8_quant import fp8_scalar_qdq +from modelopt.torch.kernels.sparsity.attention.skip_softmax_helpers import ( + _apply_sparse_nm_to_qk_tile, +) + +__all__ = ["mla_prefill_attention"] + +# Referenced inside @triton.jit code, so it must be a tl.constexpr global. +LN2 = tl.constexpr(0.6931471805599453) + +# Maps public operand quant options to kernel constexpr values. +_OPERAND_MODES = {None: 0, "fp8": 1, "nvfp4": 2} + + +def _operand_scale(mode: int, amax: float | None, name: str) -> float: + """Convert a per-tensor amax to the kernel scale for one operand.""" + if mode == 0 or amax is None: + return 1.0 + if not (math.isfinite(amax) and amax > 0): + raise ValueError(f"{name} must be a finite positive value, got {amax}") + return amax / 448.0 if mode == 1 else amax / (6.0 * 448.0) + + +def _resolve_mode(mode: str | None, name: str) -> int: + if mode not in _OPERAND_MODES: + raise ValueError( + f"{name} must be one of {sorted(m for m in _OPERAND_MODES if m)} or None, got {mode!r}" + ) + return _OPERAND_MODES[mode] + + +@triton.jit +def _qdq_a(x, MODE: tl.constexpr, scale, M: tl.constexpr, K: tl.constexpr): + """A-side (Q) operand QDQ: FP8 per-tensor or NVFP4 1x16 along K (axis 1).""" + if MODE == 1: + x = fp8_scalar_qdq(x, scale).to(x.dtype) + elif MODE == 2: + x = _a_qdq_nvfp4(x, scale, M, K) + return x + + +@triton.jit +def _qdq_b(x, MODE: tl.constexpr, scale, K: tl.constexpr, N: tl.constexpr): + """B-side (K^T / V) operand QDQ: FP8 per-tensor or NVFP4 1x16 along K (axis 0).""" + if MODE == 1: + x = fp8_scalar_qdq(x, scale).to(x.dtype) + elif MODE == 2: + x = _v_qdq_nvfp4(x, scale, K, N) + return x + + +@triton.jit +def _apply_sparse_nm_dense_tokens( + scores, + kv_start, + q_pos, + kv_pos, + seq_len_q, + seq_len_kv, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + SPARSITY_N: tl.constexpr, + SPARSITY_M: tl.constexpr, + DENSE_SINK_TOKENS: tl.constexpr, + DENSE_RECENT_TOKENS: tl.constexpr, +): + """Apply N:M sparsity outside token-exact sink and recent regions. + + Same semantics as ``triton_fa._apply_sparse_nm_with_dense_tokens`` (Q is + the suffix of the KV span), duplicated here because that helper resolves + the sparsity primitive through lazily-populated module globals. + """ + sparse_scores = _apply_sparse_nm_to_qk_tile(scores, BLOCK_M, BLOCK_N, SPARSITY_N, SPARSITY_M) + q_abs_pos = q_pos[:, None] + seq_len_kv - seq_len_q + kv_abs_pos = kv_start + kv_pos[None, :] + token_distance = q_abs_pos - kv_abs_pos + dense_tokens = ( + (seq_len_q <= 1) + | (kv_abs_pos < DENSE_SINK_TOKENS) + | ((token_distance >= 0) & (token_distance < DENSE_RECENT_TOKENS)) + ) + return tl.where(dense_tokens, scores, sparse_scores) + + +@triton.jit +def _mla_prefill_kernel( + Q, # [total_q, num_heads, LQK] + K, # [total_k, num_kv_heads, LQK] + V, # [total_k, num_kv_heads, LV] + Out, # [total_q, num_heads, LV] + Lse, # [num_heads, total_q] natural-log LSE (dummy when not RETURN_LSE) + qk_scale, # softmax_scale * log2(e) + Cu_seqlens_q, # [batch + 1] + Cu_seqlens_k, # [batch + 1] + stride_qbs, + stride_qh, + stride_kbs, + stride_kh, + stride_vbs, + stride_vh, + stride_obs, + stride_oh, + stride_lse_h, + stride_lse_s, + q_scale, # runtime per-tensor scales (host-converted from amax) + k_scale, + p_scale, + v_scale, + kv_group_num: tl.constexpr, # num_heads // num_kv_heads + IS_CAUSAL: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_DQK: tl.constexpr, # next_power_of_2(LQK) + BLOCK_DV: tl.constexpr, # next_power_of_2(LV) + LQK: tl.constexpr, + LV: tl.constexpr, + RETURN_LSE: tl.constexpr, + Q_QUANT: tl.constexpr, # 0=off, 1=FP8 E4M3, 2=NVFP4 + K_QUANT: tl.constexpr, + P_QUANT: tl.constexpr, + V_QUANT: tl.constexpr, + IEEE_QK: tl.constexpr, # FP32 carriers + IEEE dot for BMM1 + IEEE_PV: tl.constexpr, # FP32 carriers + IEEE dot for BMM2 + SPARSITY_N: tl.constexpr = 0, # N:M sparsity - keep top-N of every M (0 = off) + SPARSITY_M: tl.constexpr = 4, + DENSE_SINK_TOKENS: tl.constexpr = 0, + DENSE_RECENT_TOKENS: tl.constexpr = 64, +): + # --- Grid: (batch, num_heads, num_q_tiles) --- + batch_idx = tl.program_id(0) + head_idx = tl.program_id(1) + tile_q = tl.program_id(2) + kv_head_idx = head_idx // kv_group_num + + q_start = tl.load(Cu_seqlens_q + batch_idx) + q_end = tl.load(Cu_seqlens_q + batch_idx + 1) + k_start = tl.load(Cu_seqlens_k + batch_idx) + k_end = tl.load(Cu_seqlens_k + batch_idx + 1) + q_len = q_end - q_start + k_len = k_end - k_start + + if tile_q * BLOCK_M >= q_len: + return + + q_pos = tile_q * BLOCK_M + tl.arange(0, BLOCK_M) + kv_pos = tl.arange(0, BLOCK_N) + offs_dqk = tl.arange(0, BLOCK_DQK) + offs_dv = tl.arange(0, BLOCK_DV) + mask_dqk = offs_dqk < LQK + mask_dv = offs_dv < LV + q_mask = q_pos < q_len + + # --- Load Q tile [BLOCK_M, BLOCK_DQK]: stays in registers for the KV loop --- + q_ptrs = (q_start + q_pos)[:, None] * stride_qbs + head_idx * stride_qh + offs_dqk[None, :] + q = tl.load(Q + q_ptrs, mask=q_mask[:, None] & mask_dqk[None, :], other=0.0) + if IEEE_QK: + q = q.to(tl.float32) + q = _qdq_a(q, Q_QUANT, q_scale, BLOCK_M, BLOCK_DQK) + + # --- Online softmax state --- + row_max = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf") + row_sum = tl.zeros([BLOCK_M], dtype=tl.float32) + acc = tl.zeros([BLOCK_M, BLOCK_DV], dtype=tl.float32) + + # Causal: Q is the suffix of the KV span (k_len >= q_len). + causal_offset = k_len - q_len + kv_bound = k_len if not IS_CAUSAL else tl.minimum(causal_offset + (tile_q + 1) * BLOCK_M, k_len) + + for kv_start in range(0, kv_bound, BLOCK_N): + kv_start = tl.multiple_of(kv_start, BLOCK_N) + kv_valid = (kv_start + kv_pos) < k_len + + # K^T tile [BLOCK_DQK, BLOCK_N] + k_ptrs = ( + (k_start + kv_start + kv_pos)[None, :] * stride_kbs + + kv_head_idx * stride_kh + + offs_dqk[:, None] + ) + k = tl.load(K + k_ptrs, mask=kv_valid[None, :] & mask_dqk[:, None], other=0.0) + if IEEE_QK: + k = k.to(tl.float32) + k = _qdq_b(k, K_QUANT, k_scale, BLOCK_DQK, BLOCK_N) + + if IEEE_QK: + scores = tl.dot(q, k, input_precision="ieee") * qk_scale + else: + scores = tl.dot(q, k) * qk_scale + scores = _apply_mask(scores, q_pos, kv_pos, q_len, k_len, kv_start, IS_CAUSAL) + + if SPARSITY_N > 0: + scores = _apply_sparse_nm_dense_tokens( + scores, + kv_start, + q_pos, + kv_pos, + q_len, + k_len, + BLOCK_M, + BLOCK_N, + SPARSITY_N, + SPARSITY_M, + DENSE_SINK_TOKENS, + DENSE_RECENT_TOKENS, + ) + + # --- Online softmax update: the denominator uses unquantized p --- + m_new = tl.maximum(row_max, tl.max(scores, 1)) + p = tl.math.exp2(scores - m_new[:, None]) + l_new = tl.sum(p, 1) + correction = tl.math.exp2(row_max - m_new) + row_sum = row_sum * correction + l_new + acc = acc * correction[:, None] + + if P_QUANT == 1: + p = fp8_scalar_qdq(p, p_scale) + elif P_QUANT == 2: + # Native packing consumes the model dtype; the QDQ value stays FP32. + p = p.to(V.dtype.element_ty).to(tl.float32) + p = _p_qdq_nvfp4(p, p_scale, BLOCK_M, BLOCK_N) + + # V tile [BLOCK_N, BLOCK_DV] + v_ptrs = ( + (k_start + kv_start + kv_pos)[:, None] * stride_vbs + + kv_head_idx * stride_vh + + offs_dv[None, :] + ) + v = tl.load(V + v_ptrs, mask=kv_valid[:, None] & mask_dv[None, :], other=0.0) + if IEEE_PV: + v = v.to(tl.float32) + v = _qdq_b(v, V_QUANT, v_scale, BLOCK_N, BLOCK_DV) + + if IEEE_PV: + acc = tl.dot(p.to(tl.float32), v, acc, input_precision="ieee") + else: + acc = tl.dot(p.to(v.dtype), v, acc) + row_max = m_new + + # Clamp the denominator: empty context chunks (k_len == 0) leave acc at 0. + acc = acc / tl.maximum(row_sum[:, None], 1e-6) + + if RETURN_LSE: + lse = LN2 * (row_max + tl.math.log2(row_sum)) + lse = tl.where(row_sum == 0.0, float("-inf"), lse) + lse_ptrs = head_idx * stride_lse_h + (q_start + q_pos) * stride_lse_s + tl.store(Lse + lse_ptrs, lse, mask=q_mask) + + o_ptrs = (q_start + q_pos)[:, None] * stride_obs + head_idx * stride_oh + offs_dv[None, :] + tl.store(Out + o_ptrs, acc, mask=q_mask[:, None] & mask_dv[None, :]) + + +def mla_prefill_attention( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + cu_seqlens_q: torch.Tensor, + cu_seqlens_k: torch.Tensor, + max_seqlen_q: int, + softmax_scale: float | None = None, + causal: bool = True, + return_lse: bool = False, + *, + q_quant: str | None = None, + k_quant: str | None = None, + p_quant: str | None = None, + v_quant: str | None = None, + q_amax: float | None = None, + k_amax: float | None = None, + p_amax: float = 1.0, + v_amax: float | None = None, + sparsity_n: int = 0, + sparsity_m: int = 4, + dense_sink_tokens: int = 0, + dense_recent_tokens: int = 64, + block_m: int = 64, + block_n: int = 64, +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + """Varlen MLA prefill attention with fused Q/K/P/V fake quantization. + + Args: + q: ``[total_q, num_heads, qk_head_dim]`` packed queries. + k: ``[total_k, num_kv_heads, qk_head_dim]`` packed keys (NOPE ++ RoPE). + v: ``[total_k, num_kv_heads, v_head_dim]`` packed values. + cu_seqlens_q: ``[batch + 1]`` cumulative Q sequence lengths. + cu_seqlens_k: ``[batch + 1]`` cumulative K/V sequence lengths. + max_seqlen_q: Maximum Q sequence length (grid sizing). + softmax_scale: Scale factor (default ``qk_head_dim ** -0.5``). + causal: Causal masking; Q is treated as the suffix of the KV span. + Cached-context chunks use ``causal=False``. + return_lse: Also return the natural-log LSE ``[num_heads, total_q]`` + for chunk-state merging (``merge_attn_states``). + q_quant: Q fake quant-dequant: ``None``, ``"fp8"`` (per-tensor E4M3), + or ``"nvfp4"`` (1x16 blocks along the feature/contraction axis). + k_quant: K fake quant-dequant, same modes; blocks along features. + p_quant: Softmax-P fake quant-dequant, blocks along the key axis. + The softmax denominator stays unquantized. + v_quant: V fake quant-dequant, blocks along the key/token axis. + q_amax: Per-tensor amax for Q (``None`` = scale 1.0). + k_amax: Per-tensor amax for K (``None`` = scale 1.0). + p_amax: Per-tensor amax for P; defaults to 1.0, the theoretical upper + bound of the unnormalized P's amax. + v_amax: Per-tensor amax for V (``None`` = scale 1.0). + sparsity_n: N:M score sparsity along the key axis (0 = off). + sparsity_m: N:M group size (4 or 8). + dense_sink_tokens: Leading KV tokens kept dense (token-exact). + dense_recent_tokens: Recent KV tokens kept dense (token-exact). + block_m: Q tile size (multiple of 16). + block_n: KV tile size (multiple of 16). + + Returns: + Output ``[total_q, num_heads, v_head_dim]`` in ``v.dtype``; with + ``return_lse`` a tuple ``(out, lse)``. + """ + if q.ndim != 3 or k.ndim != 3 or v.ndim != 3: + raise ValueError("q, k, v must be [tokens, heads, head_dim] tensors") + total_q, num_heads, lqk = q.shape + total_k, num_kv_heads, lv = v.shape[0], v.shape[1], v.shape[2] + if k.shape != (total_k, num_kv_heads, lqk): + raise ValueError(f"k shape {tuple(k.shape)} != ({total_k}, {num_kv_heads}, {lqk})") + if num_heads % num_kv_heads: + raise ValueError("num_heads must be divisible by num_kv_heads") + if cu_seqlens_q.numel() != cu_seqlens_k.numel(): + raise ValueError("cu_seqlens_q and cu_seqlens_k must have the same length") + if block_m % 16 or block_n % 16: + raise ValueError("block_m and block_n must be multiples of 16") + + q_mode = _resolve_mode(q_quant, "q_quant") + k_mode = _resolve_mode(k_quant, "k_quant") + p_mode = _resolve_mode(p_quant, "p_quant") + v_mode = _resolve_mode(v_quant, "v_quant") + if (q_mode == 2 or k_mode == 2) and lqk % 16: + raise ValueError(f"NVFP4 Q/K requires qk_head_dim % 16 == 0, got {lqk}") + q_scale = _operand_scale(q_mode, q_amax, "q_amax") + k_scale = _operand_scale(k_mode, k_amax, "k_amax") + p_scale = _operand_scale(p_mode, p_amax, "p_amax") + v_scale = _operand_scale(v_mode, v_amax, "v_amax") + + if q.stride(-1) != 1: + q = q.contiguous() + if k.stride(-1) != 1: + k = k.contiguous() + if v.stride(-1) != 1: + v = v.contiguous() + + sm_scale = lqk**-0.5 if softmax_scale is None else softmax_scale + block_dqk = triton.next_power_of_2(lqk) + block_dv = triton.next_power_of_2(lv) + if block_dqk >= 512: + # Very wide QK tiles (e.g. absorbed 576-d shapes) blow the shared + # memory budget at 64x64 tiles (q + k^T staging ~ 3 * BLOCK_DQK KB); + # shrink to ~96 KB so the kernel fits 100 KB-class SMs. + block_m = min(block_m, 16) + block_n = min(block_n, 32) + batch = cu_seqlens_q.numel() - 1 + + out = torch.empty(total_q, num_heads, lv, dtype=v.dtype, device=q.device) + if return_lse: + lse = torch.empty(num_heads, total_q, dtype=torch.float32, device=q.device) + else: + lse = torch.empty(1, dtype=torch.float32, device=q.device) + + grid = (batch, num_heads, triton.cdiv(max(1, max_seqlen_q), block_m)) + with torch.cuda.device(q.device): + _mla_prefill_kernel[grid]( + q, + k, + v, + out, + lse, + sm_scale * LOG2E, + cu_seqlens_q, + cu_seqlens_k, + q.stride(0), + q.stride(1), + k.stride(0), + k.stride(1), + v.stride(0), + v.stride(1), + out.stride(0), + out.stride(1), + lse.stride(0) if return_lse else 0, + lse.stride(1) if return_lse else 0, + q_scale, + k_scale, + p_scale, + v_scale, + kv_group_num=num_heads // num_kv_heads, + IS_CAUSAL=causal, + BLOCK_M=block_m, + BLOCK_N=block_n, + BLOCK_DQK=block_dqk, + BLOCK_DV=block_dv, + LQK=lqk, + LV=lv, + RETURN_LSE=return_lse, + Q_QUANT=q_mode, + K_QUANT=k_mode, + P_QUANT=p_mode, + V_QUANT=v_mode, + IEEE_QK=(q_mode == 2 or k_mode == 2 or q.dtype == torch.float32), + IEEE_PV=(p_mode == 2 or v_mode == 2), + SPARSITY_N=sparsity_n, + SPARSITY_M=sparsity_m, + DENSE_SINK_TOKENS=dense_sink_tokens, + DENSE_RECENT_TOKENS=dense_recent_tokens, + # 256-wide FP32 tiles are register-heavy; revisit if tuning. # tune + num_warps=8 if block_dqk >= 256 else 4, + num_stages=1, + ) + if return_lse: + return out, lse + return out diff --git a/modelopt/torch/kernels/quantization/attention/mla/reference.py b/modelopt/torch/kernels/quantization/attention/mla/reference.py new file mode 100644 index 00000000000..5406715800a --- /dev/null +++ b/modelopt/torch/kernels/quantization/attention/mla/reference.py @@ -0,0 +1,376 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pure-torch oracles for the MLA attention kernels (no Triton imports). + +Independent eager re-implementations of the fake-quant math and the kernels' +tile schedules, used by the GPU golden tests. The FP4 rounding ladder mirrors +``fp4_round_magnitude`` (RNE ties-to-even on the E2M1 grid) and the two-level +NVFP4 scale mirrors ``fp8_quantize_scale``, but no production QDQ helper is +called here. +""" + +import math + +import torch + +LOG2E: float = 1.4426950408889634 +LN2: float = 0.6931471805599453 + +_E2M1_LEVELS = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0) + + +def round_to_e2m1(x: torch.Tensor) -> torch.Tensor: + """Round to the nearest E2M1 value, ties to even (matches the kernels).""" + a = x.abs() + q = torch.where( + a <= 0.25, + torch.zeros_like(a), + torch.where( + a < 0.75, + torch.full_like(a, 0.5), + torch.where( + a <= 1.25, + torch.full_like(a, 1.0), + torch.where( + a < 1.75, + torch.full_like(a, 1.5), + torch.where( + a <= 2.5, + torch.full_like(a, 2.0), + torch.where( + a < 3.5, + torch.full_like(a, 3.0), + torch.where(a <= 5.0, torch.full_like(a, 4.0), torch.full_like(a, 6.0)), + ), + ), + ), + ), + ), + ) + return torch.where(x >= 0, q, -q) + + +def _quant_e4m3(x: torch.Tensor) -> torch.Tensor: + """Round-trip through FP8 E4M3 with saturation at +-448.""" + return x.clamp(-448.0, 448.0).to(torch.float8_e4m3fn).float() + + +def nvfp4_fake_quant( + x: torch.Tensor, + amax: float | None = None, + block_axis: int = -1, + block: int = 16, +) -> torch.Tensor: + """Two-level NVFP4 fake quant: E2M1 elements, E4M3 block scales along one axis.""" + x = x.float() + x = x.movedim(block_axis, -1) + shape = x.shape + assert shape[-1] % block == 0, f"axis size {shape[-1]} not divisible by {block}" + g = x.reshape(*shape[:-1], shape[-1] // block, block) + block_amax = g.abs().amax(dim=-1, keepdim=True) + global_scale = 1.0 if amax is None else amax / (6.0 * 448.0) + scale = _quant_e4m3(block_amax / (6.0 * global_scale)) * global_scale + scale_safe = torch.where(scale == 0, torch.ones_like(scale), scale) + q = round_to_e2m1(g / scale_safe) * scale_safe + q = torch.where(scale == 0, torch.zeros_like(q), q) + return q.reshape(shape).movedim(-1, block_axis) + + +def fp8_tensor_fake_quant(x: torch.Tensor, amax: float | None = None) -> torch.Tensor: + """Per-tensor FP8 E4M3 fake quant with scale ``amax / 448``.""" + scale = 1.0 if amax is None else amax / 448.0 + return _quant_e4m3(x.float() / scale) * scale + + +def apply_operand_quant( + x: torch.Tensor, + mode: str | None, + amax: float | None, + block_axis: int = -1, +) -> torch.Tensor: + """Dispatch one operand's fake quant by mode name.""" + if mode is None: + return x.float() + if mode == "fp8": + return fp8_tensor_fake_quant(x, amax) + if mode == "nvfp4": + return nvfp4_fake_quant(x, amax, block_axis=block_axis) + raise ValueError(f"unknown quant mode {mode!r}") + + +def apply_sparse_nm( + scores: torch.Tensor, + sparsity_n: int, + sparsity_m: int, + q_abs_pos: torch.Tensor, + kv_abs_pos: torch.Tensor, + seq_len_q: int, + dense_sink_tokens: int, + dense_recent_tokens: int, +) -> torch.Tensor: + """Token-exact N:M score sparsity with dense sink/recent regions. + + ``scores`` is ``[..., Lq, Lk]`` with the key axis last; ``q_abs_pos`` and + ``kv_abs_pos`` are the absolute positions matching those two axes. + """ + shape = scores.shape + grouped = scores.reshape(*shape[:-1], shape[-1] // sparsity_m, sparsity_m) + topk = grouped.topk(sparsity_n, dim=-1).indices + keep = torch.zeros_like(grouped, dtype=torch.bool).scatter_(-1, topk, True) + sparse = torch.where(keep, grouped, torch.full_like(grouped, float("-inf"))) + sparse = sparse.reshape(shape) + distance = q_abs_pos[:, None] - kv_abs_pos[None, :] + dense = ( + (seq_len_q <= 1) + | (kv_abs_pos[None, :] < dense_sink_tokens) + | ((distance >= 0) & (distance < dense_recent_tokens)) + ) + return torch.where(dense, scores, sparse) + + +def _quantize_p_tile( + p: torch.Tensor, + mode: str | None, + amax: float, + carrier_dtype: torch.dtype, +) -> torch.Tensor: + """Quantize an unnormalized softmax tile ``[..., block_n]`` like the kernels do.""" + if mode is None: + return p + if mode == "fp8": + return fp8_tensor_fake_quant(p, amax) + if mode == "nvfp4": + p = p.to(carrier_dtype).float() + return nvfp4_fake_quant(p, amax, block_axis=-1) + raise ValueError(f"unknown quant mode {mode!r}") + + +def mla_attention_reference( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + cu_seqlens_q: torch.Tensor, + cu_seqlens_k: torch.Tensor, + softmax_scale: float | None = None, + causal: bool = True, + *, + q_mode: str | None = None, + k_mode: str | None = None, + p_mode: str | None = None, + v_mode: str | None = None, + q_amax: float | None = None, + k_amax: float | None = None, + p_amax: float = 1.0, + v_amax: float | None = None, + sparsity_n: int = 0, + sparsity_m: int = 4, + dense_sink_tokens: int = 0, + dense_recent_tokens: int = 64, + block_n: int = 64, + return_lse: bool = False, +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + """Eager oracle for :func:`mla_prefill_attention`. + + Replicates the kernel schedule: Q/K quantized along the feature axis and + V along the token axis up front; P quantized per ``block_n`` KV tile on + the unnormalized online-softmax probabilities, with the denominator kept + unquantized. Returns FP32 output ``[total_q, num_heads, LV]`` (and + natural-log LSE ``[num_heads, total_q]`` with ``return_lse``). + """ + total_q, num_heads, lqk = q.shape + num_kv_heads, lv = k.shape[1], v.shape[2] + scale = lqk**-0.5 if softmax_scale is None else softmax_scale + carrier_dtype = v.dtype + batch = cu_seqlens_q.numel() - 1 + + out = torch.zeros(total_q, num_heads, lv, dtype=torch.float32, device=q.device) + lse_out = torch.full((num_heads, total_q), float("-inf"), dtype=torch.float32, device=q.device) + + for b in range(batch): + q0, q1 = int(cu_seqlens_q[b]), int(cu_seqlens_q[b + 1]) + k0, k1 = int(cu_seqlens_k[b]), int(cu_seqlens_k[b + 1]) + lq, lk = q1 - q0, k1 - k0 + if lq == 0: + continue + qb = apply_operand_quant(q[q0:q1], q_mode, q_amax, block_axis=-1) # [Lq, H, LQK] + kb = apply_operand_quant(k[k0:k1], k_mode, k_amax, block_axis=-1) # [Lk, Hkv, LQK] + # V groups along tokens: the kernel's masked (zero) tail rows are + # equivalent to zero-padding the sequence to a multiple of 16. + vb = v[k0:k1].float() + if v_mode is not None: + pad = (-lk) % 16 + vb = torch.nn.functional.pad(vb, (0, 0, 0, 0, 0, pad)) + vb = apply_operand_quant(vb, v_mode, v_amax, block_axis=0)[:lk] + if num_kv_heads != num_heads: + kb = kb.repeat_interleave(num_heads // num_kv_heads, dim=1) + vb = vb.repeat_interleave(num_heads // num_kv_heads, dim=1) + + qh = qb.permute(1, 0, 2) # [H, Lq, LQK] + kh = kb.permute(1, 2, 0) # [H, LQK, Lk] + vh = vb.permute(1, 0, 2) # [H, Lk, LV] + + q_pos = torch.arange(lq, device=q.device) + row_max = torch.full((num_heads, lq), float("-inf"), device=q.device) + row_sum = torch.zeros(num_heads, lq, device=q.device) + acc = torch.zeros(num_heads, lq, lv, device=q.device) + + for kv_start in range(0, lk, block_n): + kv_end = min(kv_start + block_n, lk) + kv_abs = torch.arange(kv_start, kv_start + block_n, device=q.device) + scores = torch.matmul(qh, kh[:, :, kv_start:kv_end]) * scale * LOG2E + if kv_end - kv_start < block_n: # pad tile to block_n like the kernel + pad = block_n - (kv_end - kv_start) + scores = torch.nn.functional.pad(scores, (0, pad), value=float("-inf")) + valid = kv_abs < lk + if causal: + allowed = (q_pos[:, None] + (lk - lq)) >= kv_abs[None, :] + mask = valid[None, :] & allowed + else: + mask = valid[None, :].expand(lq, block_n) + scores = scores.masked_fill(~mask[None], float("-inf")) + if sparsity_n > 0: + scores = apply_sparse_nm( + scores, + sparsity_n, + sparsity_m, + q_pos + (lk - lq), + kv_abs, + lq, + dense_sink_tokens, + dense_recent_tokens, + ) + scores = scores.masked_fill(~mask[None], float("-inf")) + + m_new = torch.maximum(row_max, scores.amax(dim=-1)) + # Rows with no valid keys yet keep -inf; guard exp2 of (-inf) - (-inf). + shifted = scores - m_new.unsqueeze(-1) + p = torch.where(torch.isneginf(scores), torch.zeros_like(scores), torch.exp2(shifted)) + correction = torch.where( + torch.isneginf(row_max), torch.zeros_like(row_max), torch.exp2(row_max - m_new) + ) + row_sum = row_sum * correction + p.sum(dim=-1) + acc = acc * correction.unsqueeze(-1) + p_q = _quantize_p_tile(p, p_mode, p_amax, carrier_dtype) + v_tile = vh[:, kv_start:kv_end] + if kv_end - kv_start < block_n: + v_tile = torch.nn.functional.pad(v_tile, (0, 0, 0, block_n - (kv_end - kv_start))) + if p_mode != "nvfp4" and v_mode != "nvfp4": + # Non-IEEE kernel path dots p (and unquantized v) in the + # compute dtype; the IEEE path keeps p in FP32. + p_q = p_q.to(carrier_dtype).float() + v_tile = v_tile.to(carrier_dtype).float() if v_mode is None else v_tile + acc = acc + torch.matmul(p_q, v_tile) + row_max = m_new + + out[q0:q1] = (acc / row_sum.clamp_min(1e-6).unsqueeze(-1)).permute(1, 0, 2) + lse = LN2 * (row_max + torch.log2(row_sum.clamp_min(1e-30))) + lse = torch.where(row_sum == 0, torch.full_like(lse, float("-inf")), lse) + lse_out[:, q0:q1] = lse + + if return_lse: + return out, lse_out + return out + + +def mla_decode_reference( + q: torch.Tensor, + latent: torch.Tensor, + seq_lens: torch.Tensor, + softmax_scale: float, + kv_lora_rank: int, + *, + p_mode: str | None = None, + p_amax: float = 1.0, + num_kv_splits: int = 32, + block_n: int = 32, + return_lse: bool = False, +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + """Eager split-local oracle for :func:`mla_attention_decode`. + + ``latent`` is the dense (unpaged) cache ``[batch, max_seq, head_dim]``; + ``q`` is the absorbed query ``[batch, num_heads, head_dim]``. Replicates + the fixed-split, tile-aligned stage-1 schedule and the stage-2 merge. + """ + batch, num_heads, head_dim = q.shape + carrier_dtype = latent.dtype + out = torch.zeros(batch, num_heads, kv_lora_rank, dtype=torch.float32, device=q.device) + lse_out = torch.zeros(batch, num_heads, dtype=torch.float32, device=q.device) + + qf = q.float() + q_nope, q_pe = qf[..., :kv_lora_rank], qf[..., kv_lora_rank:] + + for b in range(batch): + s = int(seq_lens[b]) + k_all = latent[b, :s].float() # [s, head_dim] + k_nope, k_pe = k_all[:, :kv_lora_rank], k_all[:, kv_lora_rank:] + num_tiles = math.ceil(s / block_n) + tiles_per_split = math.ceil(num_tiles / num_kv_splits) + + split_m, split_l, split_acc = [], [], [] + for split in range(num_kv_splits): + kv_lo = split * tiles_per_split * block_n + kv_hi = min(kv_lo + tiles_per_split * block_n, s) + m = torch.full((num_heads,), float("-inf"), device=q.device) + lsum = torch.zeros(num_heads, device=q.device) + acc = torch.zeros(num_heads, kv_lora_rank, device=q.device) + for kv_start in range(kv_lo, kv_hi, block_n): + kv_end = min(kv_start + block_n, s) + kn = k_nope[kv_start:kv_end] + kp = k_pe[kv_start:kv_end] + scores = (q_nope[b] @ kn.T + q_pe[b] @ kp.T) * softmax_scale * LOG2E # [H, tile] + if kv_end - kv_start < block_n: + # Pad to block_n like the kernel: -inf scores -> p == 0, + # zero V rows -> no BMM2 contribution. + pad = block_n - (kv_end - kv_start) + scores = torch.nn.functional.pad(scores, (0, pad), value=float("-inf")) + kn = torch.nn.functional.pad(kn, (0, 0, 0, pad)) + m_new = torch.maximum(m, scores.amax(dim=-1)) + shifted = scores - m_new.unsqueeze(-1) + p = torch.where( + torch.isneginf(scores), torch.zeros_like(scores), torch.exp2(shifted) + ) + correction = torch.where( + torch.isneginf(m), torch.zeros_like(m), torch.exp2(m - m_new) + ) + lsum = lsum * correction + p.sum(dim=-1) + acc = acc * correction.unsqueeze(-1) + p_q = _quantize_p_tile(p, p_mode, p_amax, carrier_dtype) + if p_mode != "nvfp4": + p_q = p_q.to(carrier_dtype).float() + acc = acc + p_q @ kn + m = m_new + split_m.append(m) + split_l.append(lsum) + split_acc.append(acc) + + m = torch.full((num_heads,), float("-inf"), device=q.device) + lsum = torch.zeros(num_heads, device=q.device) + acc = torch.zeros(num_heads, kv_lora_rank, device=q.device) + for sm, sl, sa in zip(split_m, split_l, split_acc): + has = sl > 0 + new_max = torch.where(has, torch.maximum(m, sm), m) + corr = torch.where(torch.isneginf(m), torch.zeros_like(m), torch.exp2(m - new_max)) + scorr = torch.where(has, torch.exp2(sm - new_max), torch.zeros_like(sm)) + acc = acc * corr.unsqueeze(-1) + sa * scorr.unsqueeze(-1) + lsum = lsum * corr + sl * scorr + m = new_max + out[b] = acc / lsum.clamp_min(1e-6).unsqueeze(-1) + lse = LN2 * (m + torch.log2(lsum.clamp_min(1e-30))) + lse_out[b] = torch.where(lsum == 0, torch.full_like(lse, float("-inf")), lse) + + if return_lse: + return out, lse_out + return out diff --git a/modelopt/torch/quantization/plugins/vllm.py b/modelopt/torch/quantization/plugins/vllm.py index 749d6b10ee5..23d6b46936d 100644 --- a/modelopt/torch/quantization/plugins/vllm.py +++ b/modelopt/torch/quantization/plugins/vllm.py @@ -84,6 +84,52 @@ def build_vllm_attention_quant_cfg( ] +# MLA operand -> quantizer-name mapping: k_format governs the write-once +# latent-cache QDQ (kv_c, k_pe) and the prefill projected K; v_format governs +# the prefill projected V (decode BMM2 consumes the quantized cache as-is). +_MLA_FORMAT_QUANTIZERS = { + "q": ("q",), + "k": ("kv_c", "k_pe", "k_mha"), + "p": ("p",), + "v": ("v_mha",), +} + + +def build_vllm_mla_attention_quant_cfg( + *, + q_format: str = "nvfp4", + k_format: str = "nvfp4", + p_format: str = "nvfp4", + v_format: str = "nvfp4", +) -> list: + """Build the MLA attention quantizer config with per-operand formats. + + Same format set as :func:`build_vllm_attention_quant_cfg`; the MLA operand + knobs fan out per :data:`_MLA_FORMAT_QUANTIZERS`. + """ + formats = {"q": q_format, "k": k_format, "p": p_format, "v": v_format} + for name, fmt in formats.items(): + if fmt not in _BMM2_FORMAT_CFGS: + raise ValueError( + f"{name}_format must be one of {sorted(_BMM2_FORMAT_CFGS)}, got {fmt!r}" + ) + return [ + {"quantizer_name": "*_bmm_quantizer", "enable": False}, + *( + { + "quantizer_name": f"*{quantizer}_bmm_quantizer", + "cfg": _BMM2_FORMAT_CFGS[fmt], + "enable": True, + } + for name, fmt in formats.items() + for quantizer in _MLA_FORMAT_QUANTIZERS[name] + ), + ] + + +_VLLM_NVFP4_MLA_ATTENTION_QUANT_CFG = build_vllm_mla_attention_quant_cfg() + + def _import_attention_module(): """Import a vLLM module that exports the concrete ``Attention`` class.""" for module_name in ( @@ -324,7 +370,14 @@ def vllm_replace_quant_module_hook(model: torch.nn.Module) -> None: def _set_vllm_attention_kv_default_amax(module, device: torch.device) -> None: """Set a global-scale-one amax on uncalibrated block-16 NVFP4 K/V quantizers.""" - for name in ("k_bmm_quantizer", "v_bmm_quantizer"): + for name in ( + "k_bmm_quantizer", + "v_bmm_quantizer", + "kv_c_bmm_quantizer", + "k_pe_bmm_quantizer", + "k_mha_bmm_quantizer", + "v_mha_bmm_quantizer", + ): quantizer = getattr(module, name, None) if ( not isinstance(quantizer, TensorQuantizer) @@ -344,6 +397,10 @@ def _set_vllm_attention_fp8_bmm2_default_amax(module, device: torch.device) -> N ("k_bmm_quantizer", 448.0), ("p_bmm_quantizer", 1.0), ("v_bmm_quantizer", 448.0), + ("kv_c_bmm_quantizer", 448.0), + ("k_pe_bmm_quantizer", 448.0), + ("k_mha_bmm_quantizer", 448.0), + ("v_mha_bmm_quantizer", 448.0), ): quantizer = getattr(module, name, None) if ( @@ -398,6 +455,51 @@ def configure_vllm_nvfp4_attention_quantizers( return module +def configure_vllm_nvfp4_mla_quantizers( + module: torch.nn.Module, + *, + device: torch.device | str, + dtype: torch.dtype, + cfg: list | None = None, +) -> torch.nn.Module: + """Configure one vLLM ``MLAAttention`` module for fused fake quantization. + + MLA counterpart of :func:`configure_vllm_nvfp4_attention_quantizers`. + ``kv_c``/``k_pe`` quantizers stay module-level (write-once latent-cache QDQ + — the single representation both decode BMMs consume); ``k``/``v`` + quantizers drive the prefill kernel's projected-K/V QDQ; ``q`` and ``p`` + drive both phases. The caller remains responsible for installing the + ModelOpt MLA impl and setting ``_query_quant_in_kernel``. + + Args: + module: A vLLM ``MLAAttention`` module to convert and configure in place. + device: Device on which the attention quantizer state should reside. + dtype: Model compute dtype associated with the attention module. + cfg: Optional quantizer config (default: all-NVFP4). + + Returns: + The supplied module, converted in place to ``_QuantVLLMMLAAttention``. + """ + if VllmMLAAttention is None or not isinstance(module, VllmMLAAttention): + raise TypeError(f"Expected vLLM MLAAttention, got {type(module).__name__}") + if not isinstance(dtype, torch.dtype): + raise TypeError(f"Expected torch.dtype, got {type(dtype).__name__}") + + device = torch.device(device) + module.device, module.dtype = device, dtype + if not isinstance(module, _QuantVLLMMLAAttention): + module = QuantModuleRegistry.convert(module) + if not hasattr(module, "p_bmm_quantizer"): + module.p_bmm_quantizer = TensorQuantizer() + + set_quantizer_by_cfg(module, _VLLM_NVFP4_MLA_ATTENTION_QUANT_CFG if cfg is None else cfg) + for name in ("q", "kv_c", "k_pe", "k_mha", "p", "v_mha"): + getattr(module, f"{name}_bmm_quantizer").to(device=device) + _set_vllm_attention_kv_default_amax(module, device) + _set_vllm_attention_fp8_bmm2_default_amax(module, device) + return module + + def _vllm_attention_modelopt_post_restore(self) -> None: """Move Attention module to its correct device after ModelOpt state restore.""" device, dtype = _get_device_dtype(self) @@ -765,10 +867,23 @@ def _setup(self): self.q_bmm_quantizer = TensorQuantizer() self.kv_c_bmm_quantizer = TensorQuantizer() self.k_pe_bmm_quantizer = TensorQuantizer() + # Prefill-only quantizers for the up-projected per-head K/V; the + # ModelOpt MLA kernels consume them in-kernel. Disabled by default + # so pre-existing MLA checkpoints restore unchanged. + self.k_mha_bmm_quantizer = TensorQuantizer() + self.k_mha_bmm_quantizer.disable() + self.v_mha_bmm_quantizer = TensorQuantizer() + self.v_mha_bmm_quantizer.disable() self.parallel_state = create_parallel_state() def forward(self, query, kv_c, k_pe, *args, **kwargs): - query = self.q_bmm_quantizer(query) + # With in-kernel Q quantization, prefill quantizes the projected + # 192-d q inside the kernel and decode quantizes the absorbed + # 576-d q (FP32 carrier) — skip the module-level QDQ entirely. + if not getattr(self, "_query_quant_in_kernel", False): + query = self.q_bmm_quantizer(query) + # Write-once latent-cache QDQ: the single representation both + # decode BMMs consume from the paged cache. kv_c = self.kv_c_bmm_quantizer(kv_c) k_pe = self.k_pe_bmm_quantizer(k_pe) return super().forward(query, kv_c, k_pe, *args, **kwargs) diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py new file mode 100644 index 00000000000..3e0c043b188 --- /dev/null +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py @@ -0,0 +1,303 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ModelOpt MLA attention adapter for vLLM's TRITON_MLA backend. + +Same integration philosophy as the regular-attention adapter in +``plugins/vllm.py``: the ``MLAAttention`` module stays intact (its module-level +``kv_c``/``k_pe`` quantizers provide the write-once latent-cache QDQ before the +native cache write), and only ``layer.impl`` is reclassed. vLLM keeps owning +projections, RoPE, cache writes, metadata, chunked-context gathering, state +merging, and the V up-projection: + +- ``forward_mha`` (all prefill) temporarily swaps the per-layer ModelOpt + prefill backend into the prefill metadata and delegates to the inherited + implementation, so the ``kv_b_proj`` projection and chunked-context plumbing + are reused rather than forked. The backend routes the two attention calls + (causal new tokens; non-causal context chunks with LSE) to + :func:`mla_prefill_attention` with fused Q/K/P/V QDQ and optional 2:4 + score sparsity (prefill only). +- ``forward_mqa`` (decode) fake-quantizes the absorbed query (FP32 QDQ + carrier) and calls :func:`mla_attention_decode`, which fuses the P QDQ. + BMM1-K and BMM2-V both consume the write-once quantized latent cache as-is + (single stored representation; no on-read re-quantization). +""" + +from dataclasses import dataclass +from typing import Any + +import torch +from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend +from vllm.v1.attention.backends.mla.triton_mla import TritonMLAImpl + +from modelopt.torch.kernels.quantization.attention.mla import ( + mla_attention_decode, + mla_prefill_attention, +) + +from . import vllm as attention_plugin + +__all__ = [ + "ModelOptMLAImpl", + "clone_mla_impl", + "mla_quant_kw_from_layer", + "select_mla_impl_cls", +] + +# Fixed split count: decode P QDQ numerics follow the split-local schedule, so +# a fixed value keeps results reproducible across batch shapes and devices. +_MLA_DECODE_NUM_KV_SPLITS = 32 + + +@dataclass(frozen=True, slots=True) +class _MLAQuantKw: + """Kernel quantization kwargs resolved once per layer at install time.""" + + prefill: dict[str, Any] + decode: dict[str, Any] + + @property + def any_active(self) -> bool: + return ( + any(self.prefill[f"{op}_quant"] is not None for op in ("q", "k", "p", "v")) + or self.decode["p_qdq"] is not None + ) + + +def mla_quant_kw_from_layer(layer, *, query_in_kernel: bool) -> _MLAQuantKw: + """Resolve the MLA kernels' quantization kwargs from the layer's quantizers. + + ``kv_c``/``k_pe`` quantizers are module-level (write-once latent-cache QDQ) + and therefore never appear here. With ``query_in_kernel`` False (FP8 Q), + the module-level quantizer already QDQ'd the 192-d query, so neither + kernel applies a Q transform. + """ + q_qdq, q_amax = attention_plugin._bmm_qdq_from_layer(layer, "q_bmm_quantizer", None) + k_qdq, k_amax = attention_plugin._bmm_qdq_from_layer(layer, "k_mha_bmm_quantizer", None) + v_qdq, v_amax = attention_plugin._bmm_qdq_from_layer(layer, "v_mha_bmm_quantizer", None) + p_qdq, p_amax = attention_plugin._p_qdq_from_layer(layer) + return _MLAQuantKw( + prefill={ + "q_quant": q_qdq if query_in_kernel else None, + "q_amax": q_amax, + "k_quant": k_qdq, + "k_amax": k_amax, + "p_quant": p_qdq, + "p_amax": p_amax, + "v_quant": v_qdq, + "v_amax": v_amax, + }, + decode={"p_qdq": p_qdq, "p_qdq_amax": p_amax}, + ) + + +class _ModelOptMLAPrefillBackend(MLAPrefillBackend): + """Per-layer prefill backend carrying this layer's quant/sparse kwargs. + + Swapped into ``prefill_metadata.prefill_backend`` for the duration of one + ``forward_mha`` call (the builder-stamped backend is shared across layers, + so per-layer state cannot live there permanently). + """ + + def __init__(self, base_backend: MLAPrefillBackend, quant_kw: dict, sparse_kw: dict): + super().__init__( + num_heads=base_backend.num_heads, + scale=base_backend.scale, + kv_lora_rank=base_backend.kv_lora_rank, + qk_nope_head_dim=base_backend.qk_nope_head_dim, + qk_rope_head_dim=base_backend.qk_rope_head_dim, + v_head_dim=base_backend.v_head_dim, + vllm_config=base_backend.vllm_config, + ) + self._quant_kw = dict(quant_kw) + self._sparse_kw = dict(sparse_kw) + + @staticmethod + def get_name() -> str: + return "MODELOPT_MLA" + + def run_prefill_new_tokens( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + if output_scale is not None: + raise NotImplementedError( + "ModelOpt MLA attention does not support fused FP8 output quantization" + ) + pm = self._prefill_metadata + result = mla_prefill_attention( + q, + k, + v, + cu_seqlens_q=pm.query_start_loc, + cu_seqlens_k=pm.query_start_loc, + max_seqlen_q=pm.max_query_len, + softmax_scale=self.scale, + causal=True, + return_lse=return_softmax_lse, + **self._quant_kw, + **self._sparse_kw, + ) + if out is not None and not return_softmax_lse: + out.copy_(result) + return out + return result + + def run_prefill_context_chunk( + self, + chunk_idx: int, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + pm = self._prefill_metadata + assert pm.chunked_context is not None + # No sparse kwargs here: the 2:4 dense-window semantics are + # suffix-relative, so cached-context chunks run dense. + return mla_prefill_attention( + q, + k, + v, + cu_seqlens_q=pm.query_start_loc, + cu_seqlens_k=pm.chunked_context.cu_seq_lens[chunk_idx], + max_seqlen_q=pm.max_query_len, + softmax_scale=self.scale, + causal=False, + return_lse=True, + **self._quant_kw, + ) + + +class ModelOptMLAImpl(TritonMLAImpl): + """TRITON_MLA impl adapter routing prefill and decode to ModelOpt kernels. + + Instances are created by :func:`clone_mla_impl` (state-preserving reclass); + ``quant_kw`` (an :class:`_MLAQuantKw`) and ``sparse_kw`` are stashed by the + installer before the impl is published on the layer. + """ + + quant_kw: _MLAQuantKw + sparse_kw: dict[str, Any] + + def _get_prefill_backend(self, prefill_metadata) -> _ModelOptMLAPrefillBackend: + backend = self.__dict__.get("_modelopt_prefill_backend") + if backend is None: + backend = _ModelOptMLAPrefillBackend( + prefill_metadata.prefill_backend, + quant_kw=self.quant_kw.prefill, + sparse_kw=self.sparse_kw, + ) + self._modelopt_prefill_backend = backend + return backend + + def forward_mha( + self, + q, + kv_c_normed, + k_pe, + kv_c_and_k_pe_cache, + attn_metadata, + k_scale, + output, + output_scale=None, + ) -> None: + """Run MLA prefill with the ModelOpt prefill backend swapped in.""" + prefill_metadata = attn_metadata.prefill + assert prefill_metadata is not None + if not self.quant_kw.any_active and not self.sparse_kw: + return super().forward_mha( + q, + kv_c_normed, + k_pe, + kv_c_and_k_pe_cache, + attn_metadata, + k_scale, + output, + output_scale, + ) + backend = self._get_prefill_backend(prefill_metadata) + backend.prepare_metadata(prefill_metadata) + saved = prefill_metadata.prefill_backend + prefill_metadata.prefill_backend = backend + try: + return super().forward_mha( + q, + kv_c_normed, + k_pe, + kv_c_and_k_pe_cache, + attn_metadata, + k_scale, + output, + output_scale, + ) + finally: + prefill_metadata.prefill_backend = saved + + def forward_mqa(self, q, kv_c_and_k_pe_cache, attn_metadata, layer): + """Run absorbed-MLA decode through the ModelOpt split-K kernel.""" + query_in_kernel = getattr(layer, "_query_quant_in_kernel", False) + if self.quant_kw.decode["p_qdq"] is None and not query_in_kernel: + return super().forward_mqa(q, kv_c_and_k_pe_cache, attn_metadata, layer) + if isinstance(q, tuple): + q = torch.cat(q, dim=-1) + if query_in_kernel: + # FP32 QDQ carrier on the absorbed query: the hardware-faithful + # emulation of the decode BMM1 A-operand. + q = layer.q_bmm_quantizer(q.float()) + decode_meta = attn_metadata.decode + assert decode_meta is not None + return mla_attention_decode( + q, + kv_c_and_k_pe_cache, + decode_meta.block_table, + decode_meta.seq_lens, + softmax_scale=self.scale, + kv_lora_rank=self.kv_lora_rank, + qk_rope_head_dim=self.qk_rope_head_dim, + num_kv_splits=_MLA_DECODE_NUM_KV_SPLITS, + out_dtype=kv_c_and_k_pe_cache.dtype, + return_lse=True, + **self.quant_kw.decode, + ) + + +def select_mla_impl_cls(impl) -> type | None: + """Return the ModelOpt MLA adapter class matching a native implementation.""" + if isinstance(impl, ModelOptMLAImpl): + return type(impl) + if isinstance(impl, TritonMLAImpl): + return ModelOptMLAImpl + return None + + +def clone_mla_impl(old_impl) -> ModelOptMLAImpl: + """Create the MLA adapter while preserving vLLM's initialized impl state.""" + new_cls = select_mla_impl_cls(old_impl) + if new_cls is None: + raise TypeError( + f"MLA backend {type(old_impl).__name__} is not supported; launch vLLM with " + "--attention-backend TRITON_MLA" + ) + new_impl = object.__new__(new_cls) + new_impl.__dict__.update(vars(old_impl)) + # A re-install over an existing adapter must not inherit the cached prefill + # backend, which froze the previous install's quant/sparse kwargs. + new_impl.__dict__.pop("_modelopt_prefill_backend", None) + return new_impl diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py index b4141740b64..5a58e1121e9 100644 --- a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py @@ -55,6 +55,30 @@ def _import_attention_type() -> type: _VLLM_ATTENTION = _import_attention_type() +def _import_mla_attention_type() -> type | None: + """Import the concrete vLLM MLAAttention type, or None when unavailable.""" + for module_name in ( + "vllm.attention.layer", + "vllm.model_executor.layers.attention", + "vllm.attention", + ): + try: + module = importlib.import_module(module_name) + except ImportError: + continue + if hasattr(module, "MLAAttention"): + return module.MLAAttention + return None + + +def _load_mla_plugin(): + # MLA support is optional: imported lazily so regular FlashAttention and + # FlashInfer users never acquire the TRITON_MLA import requirements. + from . import vllm_mla + + return vllm_mla + + @dataclass(frozen=True, slots=True) class VllmAttentionInstallReport: """Summary of attention modules changed by a vLLM runtime installation.""" @@ -86,6 +110,7 @@ class _AttentionPlan: device: object | None dtype: torch.dtype | None requires_flashinfer_patch: bool + is_mla: bool = False @dataclass(frozen=True, slots=True) @@ -233,6 +258,37 @@ def _layer_errors(module) -> list[str]: return errors +def _layer_errors_mla(module, sparse_kw: dict[str, Any]) -> list[str]: + """Per-layer gates for MLA attention (decoder-only latent attention).""" + errors = [] + if str(getattr(module, "kv_cache_dtype", "auto")).startswith("fp8"): + errors.append("FP8 KV cache is unsupported for MLA") + if getattr(module, "use_sparse", False) or getattr(module, "indexer", None) is not None: + errors.append("sparse-indexer MLA (DeepSeek V3.2-style) is unsupported") + num_heads = getattr(module, "num_heads", None) + q_pad = getattr(module, "q_pad_num_heads", None) + if q_pad not in (None, num_heads): + errors.append(f"q_pad_num_heads={q_pad!r} padding is unsupported") + for dim_name in ("kv_lora_rank", "qk_rope_head_dim", "qk_head_dim", "v_head_dim"): + dim = getattr(module, dim_name, None) + if not isinstance(dim, int) or dim <= 0 or dim % 16: + errors.append(f"{dim_name}={dim!r} must be a positive multiple of 16") + if "skip_softmax_threshold" in sparse_kw or "threshold_scale_factor" in sparse_kw: + errors.append("skip-softmax is unsupported on MLA layers (N:M sparsity only)") + return errors + + +def _select_new_mla_impl(module) -> tuple[object | None, str | None]: + try: + mla_plugin = _load_mla_plugin() + except ImportError as err: + return None, f"MLA attention support requires a TRITON_MLA-capable vLLM: {err}" + try: + return mla_plugin.clone_mla_impl(module.impl), None + except (NotImplementedError, TypeError) as err: + return None, str(err) + + def _device_capability_error(device) -> str | None: if device is None: return None @@ -331,15 +387,19 @@ def _plan_vllm_attention( ) -> _InstallPlan: model = _unwrapped_model(model_runner) resolved_sparse_cfg, sparse_algorithm = _resolve_sparse_config(model_runner, sparse_cfg) + # MLA layers are quantize-only: the sparse-only entry point keeps ignoring + # them (MLAAttention is a sibling of Attention, so they never matched). + mla_type = _import_mla_attention_type() if quantize else None candidates = [] attention_count = 0 for name, module in model.named_modules(): - if not isinstance(module, _VLLM_ATTENTION): + is_mla = mla_type is not None and isinstance(module, mla_type) + if not is_mla and not isinstance(module, _VLLM_ATTENTION): continue attention_count += 1 sparse_kw = _sparse_kwargs(name, resolved_sparse_cfg) if quantize or sparse_kw: - candidates.append((name, module, sparse_kw)) + candidates.append((name, module, sparse_kw, is_mla)) if not candidates and not quantize: return _InstallPlan( @@ -351,8 +411,8 @@ def _plan_vllm_attention( mode = _cudagraph_mode(model_runner) if quantize else None quant_plugin: Any = _load_quant_plugin() if quantize else None plans = [] - for name, module, sparse_kw in candidates: - reasons = _layer_errors(module) + for name, module, sparse_kw, is_mla in candidates: + reasons = _layer_errors_mla(module, sparse_kw) if is_mla else _layer_errors(module) device = dtype = None if quantize: device, dtype = quant_plugin._get_device_dtype(module) @@ -368,7 +428,11 @@ def _plan_vllm_attention( if quantize: if graph_error := _sparse_graph_error(sparse_kw, mode): reasons.append(graph_error) - new_impl, requires_flashinfer_patch, backend_error = _select_new_impl(module) + requires_flashinfer_patch = False + if is_mla: + new_impl, backend_error = _select_new_mla_impl(module) + else: + new_impl, requires_flashinfer_patch, backend_error = _select_new_impl(module) if backend_error: reasons.append(backend_error) if reasons: @@ -383,10 +447,11 @@ def _plan_vllm_attention( device, dtype, requires_flashinfer_patch, + is_mla, ) ) if quantize and attention_count == 0: - errors.append("no regular attention layers were found") + errors.append("no attention layers were found") _raise_unsupported(errors, "NVFP4 attention" if quantize else "sparse attention") return _InstallPlan( model_runner, @@ -428,11 +493,43 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor plan.model_runner.cascade_attn_enabled = False for layer in plan.layers: layer.new_impl.sparse_kw = layer.sparse_kw - if plan.quantize: + default_formats = (plan.q_format, plan.k_format, plan.p_format, plan.v_format) == ( + "nvfp4", + "nvfp4", + "nvfp4", + "nvfp4", + ) + if plan.quantize and layer.is_mla: + _cfg_kwargs = ( + {} + if default_formats + else { + "cfg": quant_plugin.build_vllm_mla_attention_quant_cfg( + q_format=plan.q_format, + k_format=plan.k_format, + p_format=plan.p_format, + v_format=plan.v_format, + ) + } + ) + converted = quant_plugin.configure_vllm_nvfp4_mla_quantizers( + layer.module, + device=layer.device, + dtype=layer.dtype, + **_cfg_kwargs, + ) + if converted is not None and converted is not layer.module: + raise RuntimeError("vLLM attention quantization must convert modules in place") + layer.new_impl.quant_kw = _load_mla_plugin().mla_quant_kw_from_layer( + layer.module, query_in_kernel=plan.q_format != "fp8" + ) + elif plan.quantize: # Pass cfg only for non-default formats: keeps the default call # signature stable for callers/fakes that predate the cfg parameter. _cfg_kwargs = ( - { + {} + if default_formats + else { "cfg": quant_plugin.build_vllm_attention_quant_cfg( q_format=plan.q_format, k_format=plan.k_format, @@ -440,9 +537,6 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor v_format=plan.v_format, ) } - if (plan.q_format, plan.k_format, plan.p_format, plan.v_format) - != ("nvfp4", "nvfp4", "nvfp4", "nvfp4") - else {} ) converted = quant_plugin.configure_vllm_nvfp4_attention_quantizers( layer.module, @@ -473,7 +567,10 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor # fp8 Q is module-level (bf16 losslessly carries E4M3 QDQ values); # the kernel then runs a plain bf16 BMM1 with no Q transform. layer.module._query_quant_in_kernel = plan.q_format != "fp8" - layer.module._value_quant_in_kernel = plan.v_format != "fp8" + if not layer.is_mla: + # MLA V is either in-kernel (prefill projected V) or the + # write-once quantized cache (decode) — no module-level flag. + layer.module._value_quant_in_kernel = plan.v_format != "fp8" try: # Publish the adapter last so a native impl never runs with in-kernel # quantization flags that only the ModelOpt adapter understands. @@ -485,7 +582,8 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor ("_value_quant_in_kernel", old_value_flag), ): if value is missing: - delattr(layer.module, name) + if hasattr(layer.module, name): + delattr(layer.module, name) else: setattr(layer.module, name, value) raise diff --git a/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py new file mode 100644 index 00000000000..6229d7e8932 --- /dev/null +++ b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Golden tests for the split-K absorbed-MLA decode kernel.""" + +import pytest +import torch + +from modelopt.torch.kernels.common.attention import IS_AVAILABLE as TRITON_KERNEL_AVAILABLE + +if TRITON_KERNEL_AVAILABLE: + from modelopt.torch.kernels.quantization.attention.mla import mla_attention_decode + from modelopt.torch.kernels.quantization.attention.mla.reference import ( + mla_decode_reference, + nvfp4_fake_quant, + ) + +NATIVE_E4M3_AVAILABLE = TRITON_KERNEL_AVAILABLE and torch.cuda.get_device_capability() >= (8, 9) +requires_native_e4m3 = pytest.mark.skipif( + not NATIVE_E4M3_AVAILABLE, reason="Native E4M3 requires compute capability >= 8.9" +) + +pytestmark = pytest.mark.skipif( + not TRITON_KERNEL_AVAILABLE, reason="Triton attention kernel requires CUDA + triton" +) + +_RANK = 128 # kv_lora_rank kept small so tests run fast; 16-divisible +_ROPE = 64 +_DIM = _RANK + _ROPE + + +def _make_decode_inputs( + seq_lens: list[int], + num_heads: int = 32, + page_size: int = 16, + dtype: torch.dtype = torch.float16, + seed: int = 0, +): + """Build an absorbed query, a dense latent, and its paged copy.""" + torch.manual_seed(seed) + batch = len(seq_lens) + max_seq = max(seq_lens) + q = torch.randn(batch, num_heads, _DIM, device="cuda", dtype=dtype) + latent_dense = torch.randn(batch, max_seq, _DIM, device="cuda", dtype=dtype) + + max_blocks = (max_seq + page_size - 1) // page_size + cache = torch.zeros(batch * max_blocks, page_size, _DIM, device="cuda", dtype=dtype) + block_table = torch.zeros(batch, max_blocks, dtype=torch.int32, device="cuda") + # Shuffled page assignment to exercise the block-table walk. + perm = torch.randperm(batch * max_blocks) + next_page = 0 + for b, s in enumerate(seq_lens): + for blk in range((s + page_size - 1) // page_size): + page = int(perm[next_page]) + next_page += 1 + block_table[b, blk] = page + lo = blk * page_size + hi = min(lo + page_size, s) + cache[page, : hi - lo] = latent_dense[b, lo:hi] + seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device="cuda") + return q, latent_dense, cache, block_table, seq_lens_t + + +def _dense_decode(q, latent_dense, seq_lens, scale): + """Straightforward fp32 decode oracle (no schedule replication).""" + batch, num_heads, _ = q.shape + out = torch.zeros(batch, num_heads, _RANK, device=q.device, dtype=torch.float32) + lse = torch.zeros(batch, num_heads, device=q.device, dtype=torch.float32) + for b in range(int(seq_lens.shape[0])): + s = int(seq_lens[b]) + kv = latent_dense[b, :s].float() # [s, DIM] + scores = q[b].float() @ kv.T * scale # [H, s] + p = torch.softmax(scores, dim=-1) + out[b] = p @ kv[:, :_RANK] + lse[b] = torch.logsumexp(scores, dim=-1) + return out, lse + + +def _cos(a: torch.Tensor, b: torch.Tensor) -> float: + return torch.nn.functional.cosine_similarity( + a.flatten().float(), b.flatten().float(), dim=0 + ).item() + + +class TestMLADecodeBaseline: + @pytest.mark.parametrize("num_kv_splits", [1, 32]) + @pytest.mark.parametrize("page_size", [16, 32, 64]) + def test_no_quant_matches_dense(self, num_kv_splits, page_size): + seq_lens = [200, 7, 64] + q, latent, cache, block_table, seq_t = _make_decode_inputs(seq_lens, page_size=page_size) + scale = _DIM**-0.5 + out, lse = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + page_size=page_size, + num_kv_splits=num_kv_splits, + ) + ref, ref_lse = _dense_decode(q, latent, seq_t, scale) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + torch.testing.assert_close(lse, ref_lse, rtol=5e-3, atol=5e-3) + + def test_small_head_count(self): + """Head counts below BLOCK_H exercise the head-group masking.""" + q, latent, cache, block_table, seq_t = _make_decode_inputs([33], num_heads=2, seed=1) + scale = 0.13 + out, _ = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + ) + ref, _ = _dense_decode(q, latent, seq_t, scale) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + def test_output_dtype_follows_cache(self): + q, _, cache, block_table, seq_t = _make_decode_inputs([16], dtype=torch.bfloat16, seed=2) + out, lse = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=0.1, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + ) + assert out.dtype == torch.bfloat16 + assert lse.dtype == torch.float32 + + +class TestMLADecodeQuant: + @requires_native_e4m3 + @pytest.mark.parametrize("mode", ["fp8", "nvfp4"]) + def test_p_qdq_matches_split_local_oracle(self, mode): + seq_lens = [150, 40] + q, latent, cache, block_table, seq_t = _make_decode_inputs(seq_lens, seed=3) + scale = _DIM**-0.5 + out, _ = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + p_qdq=mode, + ) + dense_out, _ = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + ) + assert not torch.equal(out, dense_out) # P quant actually applied + ref = mla_decode_reference( + q, latent, seq_t, scale, _RANK, p_mode=mode, num_kv_splits=32, block_n=32 + ) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=2e-2) + + @requires_native_e4m3 + def test_full_nvfp4_recipe_cosine(self): + """Write-once cache QDQ + fp32-carrier Q QDQ + fused NVFP4 P.""" + seq_lens = [96] + q, latent, cache, block_table, seq_t = _make_decode_inputs( + seq_lens, dtype=torch.bfloat16, seed=4 + ) + scale = _DIM**-0.5 + # Emulate the module-level write-once QDQ: quantize the cache rows + # along the feature axis (kv_c and k_pe global scales both 1.0, so a + # single 16-block pass over the full row is equivalent). + cache_q = nvfp4_fake_quant(cache.float(), block_axis=-1).to(cache.dtype) + q_carrier = nvfp4_fake_quant(q.float(), block_axis=-1) # FP32 QDQ carrier + out, _ = mla_attention_decode( + q_carrier, + cache_q, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + p_qdq="nvfp4", + ) + ref, _ = _dense_decode(q, latent, seq_t, scale) + assert torch.isfinite(out.float()).all() + assert _cos(out, ref) > 0.98 + + def test_quantized_cache_consumed_as_is(self): + """BMM2 reads the cache values unchanged (no on-read re-quant).""" + seq_lens = [64] + q, _, cache, block_table, seq_t = _make_decode_inputs(seq_lens, seed=5) + scale = 0.1 + # Any cache contents must flow through V untouched: compare against + # the dense oracle computed from the exact same (arbitrary) cache. + latent_view = torch.zeros(1, 64, _DIM, device="cuda", dtype=cache.dtype) + for blk in range(64 // 16): + latent_view[0, blk * 16 : (blk + 1) * 16] = cache[int(block_table[0, blk])] + out, _ = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + ) + ref, _ = _dense_decode(q, latent_view, seq_t, scale) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + +class TestMLADecodeErrors: + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"num_kv_splits": 0}, "num_kv_splits"), + ({"num_kv_splits": 64}, "num_kv_splits"), + ({"page_size": 8}, "page_size"), + ({"p_qdq": "int8"}, "p_qdq must be one of"), + ], + ) + def test_invalid_config(self, kwargs, match): + q, _, cache, block_table, seq_t = _make_decode_inputs([16]) + with pytest.raises(ValueError, match=match): + mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=0.1, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + **kwargs, + ) + + def test_nvfp4_requires_divisible_dims(self): + q = torch.randn(1, 4, 88 + 8, device="cuda", dtype=torch.float16) + cache = torch.randn(4, 16, 96, device="cuda", dtype=torch.float16) + block_table = torch.zeros(1, 4, dtype=torch.int32, device="cuda") + seq_t = torch.tensor([16], dtype=torch.int32, device="cuda") + with pytest.raises(ValueError, match="divisible by 16"): + mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=0.1, + kv_lora_rank=88, + qk_rope_head_dim=8, + p_qdq="nvfp4", + ) diff --git a/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_prefill.py b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_prefill.py new file mode 100644 index 00000000000..292aaffef4a --- /dev/null +++ b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_prefill.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Golden tests for the varlen MLA prefill kernel against eager torch oracles.""" + +import pytest +import torch + +from modelopt.torch.kernels.common.attention import IS_AVAILABLE as TRITON_KERNEL_AVAILABLE + +if TRITON_KERNEL_AVAILABLE: + from modelopt.torch.kernels.quantization.attention.mla import mla_prefill_attention + from modelopt.torch.kernels.quantization.attention.mla.reference import mla_attention_reference + +NATIVE_E4M3_AVAILABLE = TRITON_KERNEL_AVAILABLE and torch.cuda.get_device_capability() >= (8, 9) +requires_native_e4m3 = pytest.mark.skipif( + not NATIVE_E4M3_AVAILABLE, reason="Native E4M3 requires compute capability >= 8.9" +) + +pytestmark = pytest.mark.skipif( + not TRITON_KERNEL_AVAILABLE, reason="Triton attention kernel requires CUDA + triton" +) + + +def _cu_seqlens(seq_lens: list[int], device: str = "cuda") -> torch.Tensor: + cu = torch.zeros(len(seq_lens) + 1, dtype=torch.int32, device=device) + cu[1:] = torch.cumsum(torch.tensor(seq_lens, device=device), dim=0) + return cu + + +def _make_mla_qkv( + q_lens: list[int], + k_lens: list[int], + num_heads: int, + num_kv_heads: int, + lqk: int, + lv: int, + dtype: torch.dtype = torch.float16, + seed: int = 0, +): + torch.manual_seed(seed) + total_q, total_k = sum(q_lens), sum(k_lens) + q = torch.randn(total_q, num_heads, lqk, device="cuda", dtype=dtype) + k = torch.randn(total_k, num_kv_heads, lqk, device="cuda", dtype=dtype) + v = torch.randn(total_k, num_kv_heads, lv, device="cuda", dtype=dtype) + return q, k, v, _cu_seqlens(q_lens), _cu_seqlens(k_lens) + + +def _cos(a: torch.Tensor, b: torch.Tensor) -> float: + return torch.nn.functional.cosine_similarity( + a.flatten().float(), b.flatten().float(), dim=0 + ).item() + + +class TestMLAPrefillBaseline: + def test_no_quant_matches_reference(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([37, 64], [37, 64], 4, 4, 192, 128) + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 64) + ref = mla_attention_reference(q, k, v, cu_q, cu_k) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + def test_gqa_no_quant(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([48, 33], [48, 33], 8, 2, 192, 128, seed=1) + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 48) + ref = mla_attention_reference(q, k, v, cu_q, cu_k) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + @pytest.mark.parametrize( + ("num_heads", "num_kv_heads", "lqk", "lv"), + [(8, 8, 192, 128), (8, 2, 64, 32), (4, 4, 576, 512)], + ) + def test_dim_sweep_no_quant(self, num_heads, num_kv_heads, lqk, lv): + q, k, v, cu_q, cu_k = _make_mla_qkv([50], [50], num_heads, num_kv_heads, lqk, lv, seed=2) + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 50) + ref = mla_attention_reference(q, k, v, cu_q, cu_k) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + def test_causal_suffix_qk(self): + """Causal with k_len > q_len: Q is the suffix of the KV span.""" + q, k, v, cu_q, cu_k = _make_mla_qkv([16, 8], [80, 40], 4, 4, 192, 128, seed=3) + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 16) + ref = mla_attention_reference(q, k, v, cu_q, cu_k) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + def test_lse_matches_logsumexp(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([40], [40], 4, 4, 192, 128, seed=4) + _, lse = mla_prefill_attention(q, k, v, cu_q, cu_k, 40, causal=False, return_lse=True) + scale = 192**-0.5 + scores = torch.einsum("qhd,khd->hqk", q.float(), k.float()) * scale + expected = torch.logsumexp(scores, dim=-1) # [H, total_q] + torch.testing.assert_close(lse, expected, rtol=5e-3, atol=5e-3) + + def test_two_chunk_lse_merge_matches_single_shot(self): + """Non-causal chunked K/V merged via LSE equals the single-shot result.""" + q, k, v, cu_q, cu_k = _make_mla_qkv([32], [128], 4, 4, 192, 128, seed=5) + full = mla_prefill_attention(q, k, v, cu_q, cu_k, 32, causal=False) + + outs, lses = [], [] + for lo, hi in ((0, 64), (64, 128)): + o, lse = mla_prefill_attention( + q, + k[lo:hi], + v[lo:hi], + cu_q, + _cu_seqlens([hi - lo]), + 32, + causal=False, + return_lse=True, + ) + outs.append(o.float()) + lses.append(lse) + lse_all = torch.logaddexp(lses[0], lses[1]) + w0 = torch.exp(lses[0] - lse_all).transpose(0, 1).unsqueeze(-1) # [total_q, H, 1] + w1 = torch.exp(lses[1] - lse_all).transpose(0, 1).unsqueeze(-1) + merged = outs[0] * w0 + outs[1] * w1 + torch.testing.assert_close(merged, full.float(), rtol=5e-3, atol=5e-3) + + def test_empty_kv_rows_are_zero_with_neg_inf_lse(self): + q, k, v, cu_q, _ = _make_mla_qkv([8], [8], 4, 4, 192, 128, seed=6) + cu_k_empty = _cu_seqlens([0]) + out, lse = mla_prefill_attention( + q, k[:0], v[:0], cu_q, cu_k_empty, 8, causal=False, return_lse=True + ) + assert torch.all(out == 0) + assert torch.all(torch.isneginf(lse)) + + +class TestMLAPrefillSparse: + def test_sparse24_no_quant(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([96], [96], 4, 4, 192, 128, seed=7) + dense = mla_prefill_attention(q, k, v, cu_q, cu_k, 96) + out = mla_prefill_attention( + q, k, v, cu_q, cu_k, 96, sparsity_n=2, sparsity_m=4, dense_recent_tokens=16 + ) + ref = mla_attention_reference( + q, k, v, cu_q, cu_k, sparsity_n=2, sparsity_m=4, dense_recent_tokens=16 + ) + assert not torch.equal(out, dense) # sparsity actually applied + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + + @requires_native_e4m3 + def test_sparse24_nvfp4_composition(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([96], [96], 4, 4, 192, 128, seed=8) + out = mla_prefill_attention( + q, + k, + v, + cu_q, + cu_k, + 96, + q_quant="nvfp4", + k_quant="nvfp4", + p_quant="nvfp4", + v_quant="nvfp4", + sparsity_n=2, + sparsity_m=4, + dense_recent_tokens=16, + ) + ref = mla_attention_reference( + q, + k, + v, + cu_q, + cu_k, + q_mode="nvfp4", + k_mode="nvfp4", + p_mode="nvfp4", + v_mode="nvfp4", + sparsity_n=2, + sparsity_m=4, + dense_recent_tokens=16, + ) + assert _cos(out, ref) > 0.98 + + +class TestMLAPrefillQuant: + @requires_native_e4m3 + @pytest.mark.parametrize("mode", ["fp8", "nvfp4"]) + def test_qkpv_quant_matches_reference(self, mode): + q, k, v, cu_q, cu_k = _make_mla_qkv([64, 41], [64, 41], 4, 4, 192, 128, seed=9) + kwargs = {f"{op}_quant": mode for op in ("q", "k", "p", "v")} + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 64, **kwargs) + dense = mla_prefill_attention(q, k, v, cu_q, cu_k, 64) + assert not torch.equal(out, dense) # quant actually applied + ref = mla_attention_reference( + q, k, v, cu_q, cu_k, **{f"{op}_mode": mode for op in ("q", "k", "p", "v")} + ) + if mode == "fp8": + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=2e-2) + else: + assert _cos(out, ref) > 0.98 + + @requires_native_e4m3 + def test_p_only_nvfp4(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([80], [80], 4, 4, 192, 128, seed=10) + out = mla_prefill_attention(q, k, v, cu_q, cu_k, 80, p_quant="nvfp4") + ref = mla_attention_reference(q, k, v, cu_q, cu_k, p_mode="nvfp4") + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=2e-2) + + @requires_native_e4m3 + def test_amax_scales_change_results(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([64], [64], 4, 4, 192, 128, seed=11) + out_default = mla_prefill_attention(q, k, v, cu_q, cu_k, 64, q_quant="fp8") + out_amax = mla_prefill_attention(q, k, v, cu_q, cu_k, 64, q_quant="fp8", q_amax=4.0) + assert not torch.equal(out_default, out_amax) + ref = mla_attention_reference(q, k, v, cu_q, cu_k, q_mode="fp8", q_amax=4.0) + torch.testing.assert_close(out_amax.float(), ref, rtol=5e-3, atol=2e-2) + + +class TestMLAPrefillErrors: + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"q_quant": "int8"}, "q_quant must be one of"), + ({"block_n": 24}, "multiples of 16"), + ({"p_quant": "fp8", "p_amax": -1.0}, "finite positive"), + ], + ) + def test_invalid_config(self, kwargs, match): + q, k, v, cu_q, cu_k = _make_mla_qkv([16], [16], 4, 4, 192, 128) + with pytest.raises(ValueError, match=match): + mla_prefill_attention(q, k, v, cu_q, cu_k, 16, **kwargs) + + def test_nvfp4_requires_divisible_qk_dim(self): + q, k, v, cu_q, cu_k = _make_mla_qkv([16], [16], 4, 4, 40, 32) + with pytest.raises(ValueError, match="qk_head_dim % 16"): + mla_prefill_attention(q, k, v, cu_q, cu_k, 16, q_quant="nvfp4") diff --git a/tests/gpu_vllm/torch/quantization/test_vllm_dynamic_modules.py b/tests/gpu_vllm/torch/quantization/test_vllm_dynamic_modules.py index 038bbd8e978..62ab3f9b523 100644 --- a/tests/gpu_vllm/torch/quantization/test_vllm_dynamic_modules.py +++ b/tests/gpu_vllm/torch/quantization/test_vllm_dynamic_modules.py @@ -498,6 +498,71 @@ def test_tiny_deepseek_mla_quantize(tiny_deepseek_llm): assert vllm_key.rsplit("._amax", 1)[0] in summary["quantizer_names"], vllm_key +@pytest.fixture(scope="module") +def tiny_deepseek_triton_mla_llm(tmp_path_factory): + """Tiny DeepSeek pinned to TRITON_MLA for the fused NVFP4 attention path.""" + tmp = tmp_path_factory.mktemp("tiny_deepseek_triton_mla") + model_dir = create_tiny_deepseek_v3_dir( + tmp, qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128 + ) + llm = _boot_llm( + model_dir, + moe_backend="triton", + enable_expert_parallel=True, + attention_backend="TRITON_MLA", + enable_prefix_caching=False, # rejected by the attention installer + ) + try: + yield llm + finally: + _shutdown_llm(llm) + + +def _install_mla_nvfp4_attention(self): + """Worker-side: install fused NVFP4 MLA attention and summarize the result.""" + from modelopt.torch.sparsity.attention_sparsity.plugins import vllm_mla + from modelopt.torch.sparsity.attention_sparsity.plugins.vllm_runtime import ( + install_vllm_nvfp4_attention, + ) + + report = install_vllm_nvfp4_attention(self.model_runner, sparse_cfg=None) + model = self.model_runner.model + mla_modules = [m for m in model.modules() if isinstance(m, VllmMLAAttention)] + return { + "installed_layers": list(report.installed_layers), + "backend_counts": dict(report.backend_counts), + "mla_count": len(mla_modules), + "all_modelopt_impl": all(isinstance(m.impl, vllm_mla.ModelOptMLAImpl) for m in mla_modules), + "query_in_kernel": all(getattr(m, "_query_quant_in_kernel", False) for m in mla_modules), + } + + +@pytest.mark.skipif( + not torch.cuda.is_available() or torch.cuda.get_device_capability() < (8, 9), + reason="NVFP4 attention install requires compute capability >= 8.9", +) +def test_tiny_deepseek_mla_nvfp4_attention_install_and_generate(tiny_deepseek_triton_mla_llm): + """End-to-end: install fused NVFP4 Q/K/P/V MLA attention, then prefill + decode.""" + from vllm import SamplingParams + from vllm.inputs import TokensPrompt + + llm = tiny_deepseek_triton_mla_llm + summaries = llm.collective_rpc(_install_mla_nvfp4_attention) + summary = summaries[0] + assert summary["mla_count"] >= 2, summary + assert summary["all_modelopt_impl"], summary + assert summary["query_in_kernel"], summary + assert summary["backend_counts"] == {"ModelOptMLAImpl": summary["mla_count"]}, summary + + # Prefill (17 tokens) + decode (8 steps) through the ModelOpt MLA kernels. + outputs = llm.generate( + [TokensPrompt(prompt_token_ids=list(range(1, 18)))], + SamplingParams(max_tokens=8, temperature=0.0), + ) + token_ids = outputs[0].outputs[0].token_ids + assert len(token_ids) == 8, outputs + + def test_configure_vllm_attention_quantizers_fp8_bmm2(monkeypatch): monkeypatch.setattr( vllm_plugin, diff --git a/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py new file mode 100644 index 00000000000..2048270fce6 --- /dev/null +++ b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the vLLM MLA attention runtime installer and impl adapter.""" + +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +# The MLA adapter targets the vLLM 0.26-style prefill-backend architecture. +pytest.importorskip("vllm.v1.attention.backends.mla.prefill.base") + +from vllm.config.compilation import CUDAGraphMode +from vllm.v1.attention.backends.flash_attn import FlashAttentionImpl +from vllm.v1.attention.backends.mla.triton_mla import TritonMLAImpl + +from modelopt.torch.quantization.plugins import vllm as quant_plugin +from modelopt.torch.sparsity.attention_sparsity.plugins import vllm_mla, vllm_runtime + +_MLA_ATTENTION = vllm_runtime._import_mla_attention_type() + +pytestmark = pytest.mark.skipif( + _MLA_ATTENTION is None, reason="vLLM MLAAttention type is unavailable" +) + + +def _bare_mla_attention(impl_cls=TritonMLAImpl, num_heads=8): + module = object.__new__(_MLA_ATTENTION) + nn.Module.__init__(module) + module.kv_cache_dtype = "auto" + module.use_sparse = False + module.indexer = None + module.q_pad_num_heads = None + module.num_heads = num_heads + module.kv_lora_rank = 128 + module.qk_rope_head_dim = 64 + module.qk_nope_head_dim = 128 + module.qk_head_dim = 192 + module.v_head_dim = 128 + module.device = torch.device("cpu") + module.dtype = torch.float16 + impl = object.__new__(impl_cls) + impl.scale = 192**-0.5 + impl.num_heads = num_heads + impl.kv_lora_rank = module.kv_lora_rank + impl.qk_rope_head_dim = module.qk_rope_head_dim + module.impl = impl + return module + + +def _model_runner(model): + model_config = SimpleNamespace( + hf_config=SimpleNamespace(sparse_attention_config=None), dtype=torch.float16 + ) + return SimpleNamespace( + model=model, + model_config=model_config, + cascade_attn_enabled=True, + vllm_config=SimpleNamespace( + model_config=model_config, + parallel_config=SimpleNamespace( + decode_context_parallel_size=1, + enable_dbo=False, + use_ubatching=False, + ), + cache_config=SimpleNamespace(enable_prefix_caching=False, cache_dtype="auto"), + compilation_config=SimpleNamespace(cudagraph_mode=CUDAGraphMode.NONE), + kv_transfer_config=None, + speculative_config=None, + ), + ) + + +@pytest.fixture +def patched_parallel_state(monkeypatch): + monkeypatch.setattr( + quant_plugin, + "create_parallel_state", + lambda: quant_plugin.ParallelState(data_parallel_group=None), + ) + + +class TestMLAInstall: + def test_nvfp4_install_configures_quantizers_and_impl(self, patched_parallel_state): + attention = _bare_mla_attention() + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + + report = vllm_runtime.install_vllm_nvfp4_attention(runner, sparse_cfg=None) + + assert isinstance(attention, quant_plugin._QuantVLLMMLAAttention) + assert type(attention.impl) is vllm_mla.ModelOptMLAImpl + for name in ("q", "kv_c", "k_pe", "k_mha", "p", "v_mha"): + quantizer = getattr(attention, f"{name}_bmm_quantizer") + assert quantizer.is_enabled + assert quantizer.is_nvfp4_dynamic + for name in ("kv_c", "k_pe", "k_mha", "v_mha"): + amax = getattr(attention, f"{name}_bmm_quantizer")._amax + assert float(amax) == 6.0 * 448.0 + assert attention._query_quant_in_kernel is True + assert not hasattr(attention, "_value_quant_in_kernel") + assert attention.impl.quant_kw.prefill == { + "q_quant": "nvfp4", + "q_amax": None, + "k_quant": "nvfp4", + "k_amax": 6.0 * 448.0, + "p_quant": "nvfp4", + "p_amax": 1.0, + "v_quant": "nvfp4", + "v_amax": 6.0 * 448.0, + } + assert attention.impl.quant_kw.decode == {"p_qdq": "nvfp4", "p_qdq_amax": 1.0} + assert attention.impl.sparse_kw == {} + assert report.installed_layers == ("mla_attn",) + assert report.quantized_layers == ("mla_attn",) + assert report.backend_counts == {"ModelOptMLAImpl": 1} + assert runner.cascade_attn_enabled is False + + def test_fp8_q_and_v_formats(self, patched_parallel_state): + attention = _bare_mla_attention() + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + + vllm_runtime.install_vllm_nvfp4_attention( + runner, sparse_cfg=None, q_format="fp8", v_format="fp8" + ) + + assert attention._query_quant_in_kernel is False + # Module-level FP8 Q means neither kernel applies a Q transform. + assert attention.impl.quant_kw.prefill["q_quant"] is None + assert attention.impl.quant_kw.prefill["v_quant"] == "fp8" + assert attention.impl.quant_kw.prefill["v_amax"] == 448.0 + assert float(attention.q_bmm_quantizer._amax) == 448.0 + + def test_unsupported_mla_backend_rejected_before_mutation(self, patched_parallel_state): + attention = _bare_mla_attention(impl_cls=FlashAttentionImpl) + original_impl = attention.impl + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + + with pytest.raises(NotImplementedError, match="TRITON_MLA"): + vllm_runtime.install_vllm_nvfp4_attention(runner, sparse_cfg=None) + + assert attention.impl is original_impl + assert not hasattr(attention, "q_bmm_quantizer") + assert runner.cascade_attn_enabled is True + + @pytest.mark.parametrize( + ("mutation", "match"), + [ + ({"kv_cache_dtype": "fp8"}, "FP8 KV cache"), + ({"indexer": object()}, "sparse-indexer"), + ({"use_sparse": True}, "sparse-indexer"), + ({"q_pad_num_heads": 256}, "q_pad_num_heads"), + ({"v_head_dim": 100}, "v_head_dim"), + ], + ) + def test_layer_gates(self, patched_parallel_state, mutation, match): + attention = _bare_mla_attention() + for name, value in mutation.items(): + setattr(attention, name, value) + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + + with pytest.raises(NotImplementedError, match=match): + vllm_runtime.install_vllm_nvfp4_attention(runner, sparse_cfg=None) + + def test_skip_softmax_sparse_config_rejected(self, patched_parallel_state, monkeypatch): + attention = _bare_mla_attention() + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + monkeypatch.setattr( + vllm_runtime, + "_sparse_kwargs", + lambda name, cfg: {"skip_softmax_threshold": 0.5}, + ) + + with pytest.raises(NotImplementedError, match="skip-softmax is unsupported on MLA"): + vllm_runtime.install_vllm_nvfp4_attention(runner, sparse_cfg={"*": {"enable": True}}) + + def test_sparse_only_install_ignores_mla(self): + attention = _bare_mla_attention() + original_impl = attention.impl + runner = _model_runner(nn.ModuleDict({"mla_attn": attention})) + runner.model_config.hf_config.sparse_attention_config = { + "config_groups": { + "group_0": {"algorithm": "sparse_softmax", "sparsity_n": 2, "sparsity_m": 4} + } + } + + report = vllm_runtime.install_vllm_sparse_attention_from_checkpoint(runner) + + assert attention.impl is original_impl + assert not hasattr(attention, "q_bmm_quantizer") + assert report.installed_layers == () + + +def _bare_mla_impl(*, p_qdq="nvfp4", prefill_active=True, sparse_kw=None): + impl = object.__new__(vllm_mla.ModelOptMLAImpl) + impl.scale = 0.25 + impl.kv_lora_rank = 128 + impl.qk_rope_head_dim = 64 + mode = "nvfp4" if prefill_active else None + impl.quant_kw = vllm_mla._MLAQuantKw( + prefill={ + "q_quant": mode, + "q_amax": None, + "k_quant": mode, + "k_amax": None, + "p_quant": mode, + "p_amax": 1.0, + "v_quant": mode, + "v_amax": None, + }, + decode={"p_qdq": p_qdq, "p_qdq_amax": 1.0}, + ) + impl.sparse_kw = dict(sparse_kw or {}) + return impl + + +class TestMLAImplDispatch: + def test_forward_mqa_dispatches_to_decode_kernel(self, monkeypatch): + impl = _bare_mla_impl() + recorded = {} + + def _record_decode(q, latent_cache, block_table, b_seq_len, **kwargs): + recorded["q"] = q + recorded["latent_cache"] = latent_cache + recorded["block_table"] = block_table + recorded["b_seq_len"] = b_seq_len + recorded.update(kwargs) + return torch.zeros(q.shape[0], q.shape[1], 128), torch.zeros(q.shape[0], q.shape[1]) + + monkeypatch.setattr(vllm_mla, "mla_attention_decode", _record_decode) + + def _poison(self, *args, **kwargs): + raise AssertionError("native TritonMLAImpl.forward_mqa must not run") + + monkeypatch.setattr(TritonMLAImpl, "forward_mqa", _poison) + + layer = SimpleNamespace(_query_quant_in_kernel=False) + cache = torch.zeros(4, 16, 192, dtype=torch.float16) + ql_nope = torch.zeros(2, 8, 128, dtype=torch.float16) + q_pe = torch.zeros(2, 8, 64, dtype=torch.float16) + metadata = SimpleNamespace( + decode=SimpleNamespace( + block_table=torch.zeros(2, 4, dtype=torch.int32), + seq_lens=torch.tensor([5, 9], dtype=torch.int32), + ) + ) + + o, lse = impl.forward_mqa((ql_nope, q_pe), cache, metadata, layer) + + assert recorded["q"].shape == (2, 8, 192) # tuple q concatenated + assert recorded["latent_cache"] is cache + assert recorded["block_table"] is metadata.decode.block_table + assert recorded["b_seq_len"] is metadata.decode.seq_lens + assert recorded["softmax_scale"] == impl.scale + assert recorded["num_kv_splits"] == 32 + assert recorded["p_qdq"] == "nvfp4" + assert recorded["p_qdq_amax"] == 1.0 + assert recorded["kv_lora_rank"] == 128 + assert recorded["qk_rope_head_dim"] == 64 + assert recorded["out_dtype"] == cache.dtype + assert o.shape == (2, 8, 128) + assert lse.shape == (2, 8) + + def test_forward_mqa_no_transform_uses_native_path(self, monkeypatch): + impl = _bare_mla_impl(p_qdq=None, prefill_active=False) + called = {} + + def _native(self, q, cache, metadata, layer): + called["native"] = True + return "native-result" + + monkeypatch.setattr(TritonMLAImpl, "forward_mqa", _native) + layer = SimpleNamespace(_query_quant_in_kernel=False) + + result = impl.forward_mqa(torch.zeros(1, 8, 192), None, None, layer) + + assert called.get("native") is True + assert result == "native-result" + + @pytest.mark.parametrize("super_raises", [False, True]) + def test_forward_mha_swaps_and_restores_prefill_backend(self, monkeypatch, super_raises): + impl = _bare_mla_impl() + base_backend = SimpleNamespace( + num_heads=8, + scale=impl.scale, + kv_lora_rank=128, + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + vllm_config=None, + ) + prefill_metadata = SimpleNamespace(prefill_backend=base_backend) + metadata = SimpleNamespace(prefill=prefill_metadata) + seen = {} + + def _fake_super(self, q, kv_c, k_pe, cache, attn_metadata, k_scale, output, output_scale): + seen["backend_during_call"] = attn_metadata.prefill.prefill_backend + if super_raises: + raise RuntimeError("boom") + + monkeypatch.setattr(TritonMLAImpl, "forward_mha", _fake_super) + + args = (None, None, None, None, metadata, None, None, None) + if super_raises: + with pytest.raises(RuntimeError, match="boom"): + impl.forward_mha(*args) + else: + impl.forward_mha(*args) + + assert isinstance(seen["backend_during_call"], vllm_mla._ModelOptMLAPrefillBackend) + assert seen["backend_during_call"]._prefill_metadata is prefill_metadata + assert prefill_metadata.prefill_backend is base_backend # restored + + def test_forward_mha_no_transform_uses_native_backend(self, monkeypatch): + impl = _bare_mla_impl(p_qdq=None, prefill_active=False) + base_backend = SimpleNamespace(prefill_backend=None) + prefill_metadata = SimpleNamespace(prefill_backend=base_backend) + metadata = SimpleNamespace(prefill=prefill_metadata) + seen = {} + + def _fake_super(self, q, kv_c, k_pe, cache, attn_metadata, k_scale, output, output_scale): + seen["backend_during_call"] = attn_metadata.prefill.prefill_backend + + monkeypatch.setattr(TritonMLAImpl, "forward_mha", _fake_super) + + impl.forward_mha(None, None, None, None, metadata, None, None, None) + + assert seen["backend_during_call"] is base_backend # untouched From 9aa048d95620be0e4ab147936e7383f5e09108da Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Sat, 22 Aug 2026 17:40:32 -0700 Subject: [PATCH 2/5] Fix MLA prefill double-quant of K/V operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level kv_c/k_pe latent QDQ was applied in _QuantVLLMMLAAttention.forward before super().forward, so vLLM's prefill kv_b_proj projected an already-quantized latent, which the prefill kernel then quantized again (k_mha/v_mha) — double-quantizing the new-tokens prefill K/V operands. Decode was unaffected (single write-once latent, consumed as-is). Move the latent QDQ off the module forward and into a ModelOptMLAImpl.do_kv_cache_update override that quantizes out-of-place at cache-write time. vLLM calls do_kv_cache_update before forward_impl and passes forward_impl the same tensors, so the quantized copy reaches the cache (read by decode) while prefill projects the bf16 latent — making the new-tokens prefill operands single-quant. Gated by a _kv_quant_in_cache_write flag so the fakequant/calibration path (fused impl not installed) still quantizes module-side. Residual, documented in the README: cached-context prefill chunks gather from the quantized paged cache and re-quantize, so those chunks stay double-quant — inherent to reading a stored quantized latent, and absent for single-chunk prompts. Verified: MLA + dense vLLM runtime synthetic suites 35 passed in vllm/vllm-openai:v0.26.0; numeric single-quant behavior exercised by the SM89+ tiny-DeepSeek install+generate e2e. Signed-off-by: Kai Xu --- examples/vllm_serve/README.md | 3 +- modelopt/torch/quantization/plugins/vllm.py | 13 +++- .../attention_sparsity/plugins/vllm_mla.py | 24 ++++++++ .../plugins/vllm_runtime.py | 12 ++++ .../test_vllm_mla_runtime.py | 59 +++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/examples/vllm_serve/README.md b/examples/vllm_serve/README.md index b488e7816f3..87c80aa3198 100644 --- a/examples/vllm_serve/README.md +++ b/examples/vllm_serve/README.md @@ -235,8 +235,9 @@ python vllm_serve_sparse_attn.py -tp 8 \ The MLA operand mapping differs from regular attention because the latent cache is shared by both BMMs: - `q_format`: prefill quantizes the projected 192-d query in-kernel; decode quantizes the absorbed `kv_lora_rank + rope`-d query (FP32 QDQ carrier). With `q_format=fp8` the module-level quantizer QDQs the pre-projection query instead. -- `k_format`: governs the write-once latent-cache QDQ (`kv_c`, `k_pe` before the cache write) and the prefill projected K (in-kernel). +- `k_format`: governs the write-once latent-cache QDQ (`kv_c`, `k_pe`, applied at cache-write time by the impl) and the prefill projected K (in-kernel). - `v_format`: governs the prefill projected V (in-kernel) only. Decode BMM2 consumes the write-once quantized latent cache as-is — a single stored representation with no on-read re-quantization. +- The latent QDQ is applied at the cache-write hook (not the module forward), so the **new-tokens** prefill projection reads the bf16 latent and its K/V operands are quantized exactly once. Note one residual: cached-**context** prefill chunks gather from the already-quantized paged cache and re-quantize the projected operands, so those chunks are double-quantized — inherent to reading a stored quantized latent, and absent for short prompts that fit a single chunk. - `p_format`: fused into both the prefill and decode kernels; the softmax denominator stays unquantized and P amax defaults to 1.0. MLA decode uses a fixed 32-split, 32-key-tile schedule with tile boundaries at absolute token positions, so quantized decode results are stable as the sequence grows and reproducible across batch shapes and devices. Optional checkpoint N:M sparsity applies to prefill new-token attention only (cached-context chunks run dense); skip-softmax is rejected on MLA layers. Sparse-only installation ignores MLA layers. DeepSeek V3.2-style sparse-indexer MLA and FP8 latent caches are unsupported. diff --git a/modelopt/torch/quantization/plugins/vllm.py b/modelopt/torch/quantization/plugins/vllm.py index 23d6b46936d..df9b4a1849b 100644 --- a/modelopt/torch/quantization/plugins/vllm.py +++ b/modelopt/torch/quantization/plugins/vllm.py @@ -883,9 +883,16 @@ def forward(self, query, kv_c, k_pe, *args, **kwargs): if not getattr(self, "_query_quant_in_kernel", False): query = self.q_bmm_quantizer(query) # Write-once latent-cache QDQ: the single representation both - # decode BMMs consume from the paged cache. - kv_c = self.kv_c_bmm_quantizer(kv_c) - k_pe = self.k_pe_bmm_quantizer(k_pe) + # decode BMMs consume from the paged cache. When the fused MLA impl + # owns this at cache-write time (``_kv_quant_in_cache_write``), skip + # it here so the *bf16* latent reaches vLLM's prefill ``kv_b_proj`` + # projection — the projected K/V are then quantized exactly once in + # the prefill kernel, avoiding a double quant. Otherwise (fakequant / + # calibration path, where the fused impl is not installed) quantize + # module-side as before. + if not getattr(self, "_kv_quant_in_cache_write", False): + kv_c = self.kv_c_bmm_quantizer(kv_c) + k_pe = self.k_pe_bmm_quantizer(k_pe) return super().forward(query, kv_c, k_pe, *args, **kwargs) def modelopt_post_restore(self, prefix: str = "") -> None: diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py index 3e0c043b188..0eb830db42f 100644 --- a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py @@ -196,6 +196,30 @@ class ModelOptMLAImpl(TritonMLAImpl): quant_kw: _MLAQuantKw sparse_kw: dict[str, Any] + def do_kv_cache_update(self, kv_c_normed, k_pe, *args, **kwargs): + """Write-once latent QDQ, applied here (cache write) not in the module. + + vLLM calls this before ``forward_impl`` and passes ``forward_impl`` the + same latent tensors. Quantizing here — **out of place**, so the caller's + tensors stay bf16 — writes a quantized cache (read by decode) while the + prefill ``kv_b_proj`` projection still consumes bf16 latent. That makes + the prefill K/V operands single-quant (quantized once in the prefill + kernel) instead of double-quant. Quantizer refs are stashed by the + installer; absent/disabled quantizers pass through unchanged. + + Note: cached-context prefill chunks gather from this quantized cache and + re-quantize the projected operands, so they remain double-quant — that + is inherent to reading a stored quantized latent and is not addressed + here. + """ + kv_q = getattr(self, "_kv_c_quantizer", None) + kpe_q = getattr(self, "_k_pe_quantizer", None) + if kv_q is not None and getattr(kv_q, "is_enabled", False): + kv_c_normed = kv_q(kv_c_normed) + if kpe_q is not None and getattr(kpe_q, "is_enabled", False): + k_pe = kpe_q(k_pe) + return super().do_kv_cache_update(kv_c_normed, k_pe, *args, **kwargs) + def _get_prefill_backend(self, prefill_metadata) -> _ModelOptMLAPrefillBackend: backend = self.__dict__.get("_modelopt_prefill_backend") if backend is None: diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py index 5a58e1121e9..51079ce9646 100644 --- a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py @@ -523,6 +523,12 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor layer.new_impl.quant_kw = _load_mla_plugin().mla_quant_kw_from_layer( layer.module, query_in_kernel=plan.q_format != "fp8" ) + # Move the latent (kv_c/k_pe) QDQ off the module forward and into the + # impl's cache-write hook, so prefill projects bf16 latent and the + # projected K/V are single-quant. Stash the quantizer refs the + # override needs; the module-side flag is set below with the others. + layer.new_impl._kv_c_quantizer = layer.module.kv_c_bmm_quantizer + layer.new_impl._k_pe_quantizer = layer.module.k_pe_bmm_quantizer elif plan.quantize: # Pass cfg only for non-default formats: keeps the default call # signature stable for callers/fakes that predate the cfg parameter. @@ -563,6 +569,7 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor missing = object() old_query_flag = getattr(layer.module, "_query_quant_in_kernel", missing) old_value_flag = getattr(layer.module, "_value_quant_in_kernel", missing) + old_kv_flag = getattr(layer.module, "_kv_quant_in_cache_write", missing) if plan.quantize: # fp8 Q is module-level (bf16 losslessly carries E4M3 QDQ values); # the kernel then runs a plain bf16 BMM1 with no Q transform. @@ -571,6 +578,10 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor # MLA V is either in-kernel (prefill projected V) or the # write-once quantized cache (decode) — no module-level flag. layer.module._value_quant_in_kernel = plan.v_format != "fp8" + else: + # Latent QDQ moves to the impl's cache-write hook (single-quant + # prefill); paired with the do_kv_cache_update override. + layer.module._kv_quant_in_cache_write = True try: # Publish the adapter last so a native impl never runs with in-kernel # quantization flags that only the ModelOpt adapter understands. @@ -580,6 +591,7 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor for name, value in ( ("_query_quant_in_kernel", old_query_flag), ("_value_quant_in_kernel", old_value_flag), + ("_kv_quant_in_cache_write", old_kv_flag), ): if value is missing: if hasattr(layer.module, name): diff --git a/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py index 2048270fce6..2040da85eb8 100644 --- a/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py +++ b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py @@ -112,6 +112,12 @@ def test_nvfp4_install_configures_quantizers_and_impl(self, patched_parallel_sta assert float(amax) == 6.0 * 448.0 assert attention._query_quant_in_kernel is True assert not hasattr(attention, "_value_quant_in_kernel") + # Latent QDQ is moved to the impl's cache-write hook (single-quant + # prefill): the module flag skips the module-side kv_c/k_pe quant, and + # the impl carries the quantizer refs its override applies. + assert attention._kv_quant_in_cache_write is True + assert attention.impl._kv_c_quantizer is attention.kv_c_bmm_quantizer + assert attention.impl._k_pe_quantizer is attention.k_pe_bmm_quantizer assert attention.impl.quant_kw.prefill == { "q_quant": "nvfp4", "q_amax": None, @@ -339,3 +345,56 @@ def _fake_super(self, q, kv_c, k_pe, cache, attn_metadata, k_scale, output, outp impl.forward_mha(None, None, None, None, metadata, None, None, None) assert seen["backend_during_call"] is base_backend # untouched + + +class _RecorderQuantizer: + """Stand-in TensorQuantizer that records calls and returns a marked tensor.""" + + def __init__(self, *, is_enabled=True): + self.is_enabled = is_enabled + self.calls = [] + + def __call__(self, x): + self.calls.append(x) + return f"quant({x})" # distinct object => out-of-place + + +class TestMLACacheWriteQuant: + def test_do_kv_cache_update_quantizes_latent_out_of_place(self, monkeypatch): + impl = _bare_mla_impl() + impl._kv_c_quantizer = _RecorderQuantizer() + impl._k_pe_quantizer = _RecorderQuantizer() + seen = {} + + def _fake_super(self, kv_c_normed, k_pe, *args, **kwargs): + seen["kv_c"] = kv_c_normed + seen["k_pe"] = k_pe + + monkeypatch.setattr(TritonMLAImpl, "do_kv_cache_update", _fake_super) + + impl.do_kv_cache_update("kvc", "kpe", "cache", "slot", "auto", "scale") + + # The latent QDQ is applied here (cache write), not skipped. + assert impl._kv_c_quantizer.calls == ["kvc"] + assert impl._k_pe_quantizer.calls == ["kpe"] + # The quantized (new) tensors are what gets written to cache. + assert seen["kv_c"] == "quant(kvc)" + assert seen["k_pe"] == "quant(kpe)" + + def test_do_kv_cache_update_skips_disabled_quantizers(self, monkeypatch): + impl = _bare_mla_impl() + impl._kv_c_quantizer = _RecorderQuantizer(is_enabled=False) + impl._k_pe_quantizer = None + seen = {} + + def _fake_super(self, kv_c_normed, k_pe, *args, **kwargs): + seen["kv_c"] = kv_c_normed + seen["k_pe"] = k_pe + + monkeypatch.setattr(TritonMLAImpl, "do_kv_cache_update", _fake_super) + + impl.do_kv_cache_update("kvc", "kpe", "cache", "slot", "auto", "scale") + + assert impl._kv_c_quantizer.calls == [] # disabled => passthrough + assert seen["kv_c"] == "kvc" + assert seen["k_pe"] == "kpe" From f8c49a125975ba15d21bfac3f8e31872ffcee271 Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Mon, 24 Aug 2026 16:02:18 -0700 Subject: [PATCH 3/5] Document that MLA decode V inherits k_format (not independent v_format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decode kernel consumes the write-once quantized latent as-is for both BMMs (V = trans(k_nope)), so decode V reuses the latent's K-side feature-axis quantization — it does not honor v_format, which applies to prefill only. A shared latent cannot be stored quantized along both K's feature axis and V's token axis; independent token-axis decode V would require an on-read re-quant (double-quant) or a raw-cache on-read model, either of which forfeits the write-once step/split stability. Correct the README and decode kernel docstring to state the actual contract. Signed-off-by: Kai Xu --- examples/vllm_serve/README.md | 2 +- .../kernels/quantization/attention/mla/mla_decode.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/examples/vllm_serve/README.md b/examples/vllm_serve/README.md index 87c80aa3198..30c26df1efb 100644 --- a/examples/vllm_serve/README.md +++ b/examples/vllm_serve/README.md @@ -236,7 +236,7 @@ The MLA operand mapping differs from regular attention because the latent cache - `q_format`: prefill quantizes the projected 192-d query in-kernel; decode quantizes the absorbed `kv_lora_rank + rope`-d query (FP32 QDQ carrier). With `q_format=fp8` the module-level quantizer QDQs the pre-projection query instead. - `k_format`: governs the write-once latent-cache QDQ (`kv_c`, `k_pe`, applied at cache-write time by the impl) and the prefill projected K (in-kernel). -- `v_format`: governs the prefill projected V (in-kernel) only. Decode BMM2 consumes the write-once quantized latent cache as-is — a single stored representation with no on-read re-quantization. +- `v_format`: governs the prefill projected V (in-kernel) **only**. **Decode V is not independently quantized** — decode BMM2 consumes the write-once quantized latent as-is, so decode V inherits `k_format` (the latent's feature-axis quantization used for BMM1-K), not `v_format`. This is a deliberate consequence of the write-once, no-on-read-re-quant design (stable across steps/splits): a shared latent cannot be stored quantized along both K's feature axis and V's token axis at once. Reading it independently as a token-axis-quantized V would require an on-read re-quant (the MNI-style raw-cache decode model), trading away that stability. So for decode, treat the contract as "V inherits k_format," not independent V quantization. - The latent QDQ is applied at the cache-write hook (not the module forward), so the **new-tokens** prefill projection reads the bf16 latent and its K/V operands are quantized exactly once. Note one residual: cached-**context** prefill chunks gather from the already-quantized paged cache and re-quantize the projected operands, so those chunks are double-quantized — inherent to reading a stored quantized latent, and absent for short prompts that fit a single chunk. - `p_format`: fused into both the prefill and decode kernels; the softmax denominator stays unquantized and P amax defaults to 1.0. diff --git a/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py index cc5b822d3ab..daf3d1a24a2 100644 --- a/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py +++ b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py @@ -30,6 +30,15 @@ is quantized inside the kernel, after the row-sum, so the softmax denominator stays unquantized. +Decode V is therefore NOT independently quantized: ``V = trans(k_nope)`` reuses +the latent's K-side (feature-axis) quantization, so decode V inherits +``k_format`` and there is no ``v_qdq`` here — ``v_format`` applies to prefill +only. A shared latent cannot be stored quantized along both K's feature axis +and V's token (contraction) axis at once; honoring an independent token-axis V +in decode would require an on-read re-quant of the already-quantized latent +(double-quant), or a raw cache with on-read K/V quant (the MNI-style model), +which forfeits the write-once step/split stability documented below. + P QDQ operates on split-local, unnormalized online-softmax probabilities; its numerics therefore include the fixed split count and tile size as part of the kernel schedule. Split bounds are tile-aligned so quant-relevant tile From 0aabd6d7ee293bf13010d685be13392b25c407d9 Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Mon, 24 Aug 2026 17:11:34 -0700 Subject: [PATCH 4/5] Align MLA decode to the quantized-BMM model (raw cache, on-read K/V quant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the fused MLA quant model from a compressed write-once NVFP4 latent cache to the faithful quantized-BMM-operand model used by the MNI reference and the dense Q/K/P/V path: the latent cache stays RAW (bf16), and every BMM operand is fake-quantized once, in-kernel, along its own contraction axis. Decode now honors k_format and v_format independently — it reads the raw latent and quantizes K along the feature axis and V along the token axis separately (plus the existing in-kernel P and caller-side Q). This fixes the prior contract gap where decode V inherited k_format and v_format was ignored. Kernel (mla_decode.py): add K_QDQ/V_QDQ constexprs + k_qdq/v_qdq host args; capture raw V before the K on-read quant; independent feature-axis K and token-axis V QDQ via _v_qdq_nvfp4/fp8_scalar_qdq; IEEE fp32 dots when an operand is NVFP4. V groups are block-16 at absolute token positions (split determinism); the open tail block re-quantizes as the sequence grows (the on-read tradeoff vs write-once, documented). Integration: keep the latent cache raw — _QuantVLLMMLAAttention.forward skips the module kv_c/k_pe QDQ under the renamed _skip_module_kv_quant flag, and ModelOptMLAImpl no longer overrides do_kv_cache_update (removed the write-once/double-quant machinery from the prior two commits). quant_kw.decode carries k_qdq/v_qdq from k_format/v_format; forward_mqa passes them. Reference oracle + tests updated for independent on-read K/V quant; README rewritten for the quantized-BMM model. Verified in vllm/vllm-openai:v0.26.0 on RTX A5000 (SM86): decode/prefill kernel baselines, MLA + dense vLLM runtime suites, and dense kernel regression all pass (94 passed); the NVFP4/FP8 K/V/P decode branches compile past all structural stages and gate only at the SM89 fp8e4nv cast, so their numerics validate on the pending SM89 cluster run. Signed-off-by: Kai Xu --- .../quantization/attention/mla/mla_decode.py | 123 ++++++++++++------ .../quantization/attention/mla/reference.py | 39 ++++-- modelopt/torch/quantization/plugins/vllm.py | 16 +-- .../attention_sparsity/plugins/vllm_mla.py | 79 +++++------ .../plugins/vllm_runtime.py | 17 +-- .../attention/mla/test_mla_decode.py | 90 ++++++++----- .../test_vllm_mla_runtime.py | 88 +++++-------- 7 files changed, 252 insertions(+), 200 deletions(-) diff --git a/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py index daf3d1a24a2..3c7e9f4a7ff 100644 --- a/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py +++ b/modelopt/torch/kernels/quantization/attention/mla/mla_decode.py @@ -21,29 +21,23 @@ V is its first ``kv_lora_rank`` features — the same memory read once per tile and reused for both BMMs. -Quantization contract: the latent cache is expected to hold write-once -fake-quantized values (module-level ``kv_c``/``k_pe`` quantizers applied -before the cache write). Both BMM1-K and BMM2-V consume that single -representation as-is — there is deliberately no on-read re-quantization. The -absorbed Q is expected to be fake-quantized by the caller (dynamic NVFP4 uses -an FP32 QDQ carrier, like ``triton_fa``'s ``Q_IS_FP32``). Only the softmax P -is quantized inside the kernel, after the row-sum, so the softmax denominator -stays unquantized. - -Decode V is therefore NOT independently quantized: ``V = trans(k_nope)`` reuses -the latent's K-side (feature-axis) quantization, so decode V inherits -``k_format`` and there is no ``v_qdq`` here — ``v_format`` applies to prefill -only. A shared latent cannot be stored quantized along both K's feature axis -and V's token (contraction) axis at once; honoring an independent token-axis V -in decode would require an on-read re-quant of the already-quantized latent -(double-quant), or a raw cache with on-read K/V quant (the MNI-style model), -which forfeits the write-once step/split stability documented below. - -P QDQ operates on split-local, unnormalized online-softmax probabilities; -its numerics therefore include the fixed split count and tile size as part of -the kernel schedule. Split bounds are tile-aligned so quant-relevant tile -boundaries sit at absolute token positions and results are stable as the -sequence grows. Inference-only. +Quantization contract (quantized-BMM model): the latent cache holds RAW +(bf16/fp16) values. Each BMM operand is fake-quantized once, on read, along +its own contraction axis, and K and V are quantized INDEPENDENTLY from the +raw latent — K along the feature axis (``k_qdq``), V along the token axis +(``v_qdq``). So decode honors ``k_format`` and ``v_format`` separately, the +same faithful independent-operand model the prefill kernel and the dense +Q/K/P/V path use. The absorbed Q is fake-quantized by the caller (dynamic +NVFP4 uses an FP32 carrier, like ``triton_fa``'s ``Q_IS_FP32``); P is +quantized in-kernel after the row-sum, so the softmax denominator stays +unquantized. + +Split determinism: P and V groups are block-16 at absolute token positions +(``kv_start`` is a multiple of ``BLOCK_N``) under a fixed split/tile schedule, +so partial results are reproducible across batch shapes and devices. The +token-axis V QDQ does re-quantize the open (incomplete) tail block as the +sequence grows — the inherent tradeoff of on-read operand quantization versus +an immutable write-once cache. Inference-only. """ import torch @@ -52,7 +46,7 @@ from modelopt.torch.kernels.common.attention.decode_attention import _qdq_scale from modelopt.torch.kernels.common.attention.triton_fa import LOG2E -from modelopt.torch.kernels.quantization.attention.bmm2_qdq import _p_qdq_nvfp4 +from modelopt.torch.kernels.quantization.attention.bmm2_qdq import _p_qdq_nvfp4, _v_qdq_nvfp4 from modelopt.torch.kernels.quantization.common.fp8_quant import fp8_scalar_qdq __all__ = ["mla_attention_decode"] @@ -86,6 +80,8 @@ def _mla_decode_split_kernel( stride_ah, stride_as, p_qdq_scale, + k_qdq_scale, + v_qdq_scale, max_blocks_per_seq, H: tl.constexpr, # number of query heads BLOCK_H: tl.constexpr, # query heads per program (grouped MQA) @@ -96,6 +92,8 @@ def _mla_decode_split_kernel( QK_ROPE_DIM: tl.constexpr, PAGE_SIZE: tl.constexpr, NUM_KV_SPLITS: tl.constexpr, + K_QDQ: tl.constexpr, # 0=off, 1=FP8 E4M3, 2=NVFP4 (on-read, feature axis) + V_QDQ: tl.constexpr, # 0=off, 1=FP8 E4M3, 2=NVFP4 (on-read, token axis) P_QDQ: tl.constexpr, # 0=off, 1=FP8 E4M3, 2=NVFP4 Q_IS_FP32: tl.constexpr, # dynamic NVFP4 QDQ carrier uses FP32 ): @@ -150,7 +148,7 @@ def _mla_decode_split_kernel( ).to(tl.int64) pos_ptrs = page * stride_lc_block + (kv_abs % PAGE_SIZE) * stride_lc_pos - # K^T tiles from the latent cache: NOPE [BLOCK_DL, BLOCK_N], PE [BLOCK_DPE, BLOCK_N] + # K^T tiles from the RAW latent cache: NOPE [BLOCK_DL, BLOCK_N], PE [BLOCK_DPE, BLOCK_N] k_nope = tl.load( Latent_cache + pos_ptrs[None, :] + dl_pos[:, None], mask=kv_valid[None, :] & dl_mask[:, None], @@ -162,9 +160,23 @@ def _mla_decode_split_kernel( other=0.0, ) - if Q_IS_FP32: - scores = tl.dot(q_nope, k_nope.to(tl.float32), input_precision="ieee") - scores += tl.dot(q_pe, k_pe.to(tl.float32), input_precision="ieee") + # Capture RAW V (tokens x features) before the K on-read quant reassigns + # k_nope. K and V are quantized independently from the raw latent, each + # along its own BMM contraction axis (K: feature; V: token), so decode + # honors k_format and v_format separately (quantized-BMM model). + v = tl.trans(k_nope) + + # On-read K quant along the feature (BMM1 contraction) axis. + if K_QDQ == 1: + k_nope = fp8_scalar_qdq(k_nope, k_qdq_scale).to(k_nope.dtype) + k_pe = fp8_scalar_qdq(k_pe, k_qdq_scale).to(k_pe.dtype) + elif K_QDQ == 2: + k_nope = _v_qdq_nvfp4(k_nope.to(tl.float32), k_qdq_scale, BLOCK_DL, BLOCK_N) + k_pe = _v_qdq_nvfp4(k_pe.to(tl.float32), k_qdq_scale, BLOCK_DPE, BLOCK_N) + + if Q_IS_FP32 or K_QDQ == 2: + scores = tl.dot(q_nope.to(tl.float32), k_nope.to(tl.float32), input_precision="ieee") + scores += tl.dot(q_pe.to(tl.float32), k_pe.to(tl.float32), input_precision="ieee") else: scores = tl.dot(q_nope, k_nope) + tl.dot(q_pe, k_pe) scores = scores * qk_scale @@ -185,11 +197,17 @@ def _mla_decode_split_kernel( p = p.to(Latent_cache.dtype.element_ty).to(tl.float32) p = _p_qdq_nvfp4(p, p_qdq_scale, BLOCK_H, BLOCK_N) - # V is the first KV_LORA_RANK features of the same latent tile, - # consumed as-is (single stored representation; no on-read re-quant). - v = tl.trans(k_nope) - if P_QDQ == 2: - acc = tl.dot(p, v.to(tl.float32), acc, input_precision="ieee") + # On-read V quant along the token (BMM2 contraction) axis, independent of + # K. Groups are block-16 at absolute token positions (kv_start is a + # multiple of BLOCK_N), so split results are deterministic; the open tail + # block re-quantizes as the sequence grows (the on-read tradeoff). + if V_QDQ == 1: + v = fp8_scalar_qdq(v, v_qdq_scale).to(v.dtype) + elif V_QDQ == 2: + v = _v_qdq_nvfp4(v.to(tl.float32), v_qdq_scale, BLOCK_N, BLOCK_DL) + + if P_QDQ == 2 or V_QDQ == 2: + acc = tl.dot(p.to(tl.float32), v.to(tl.float32), acc, input_precision="ieee") else: acc = tl.dot(p.to(v.dtype), v, acc) running_max = m_new @@ -276,6 +294,10 @@ def mla_attention_decode( qk_rope_head_dim: int = 64, page_size: int | None = None, num_kv_splits: int = _DEFAULT_KV_SPLITS, + k_qdq: str | None = None, + k_qdq_amax: float | None = None, + v_qdq: str | None = None, + v_qdq_amax: float | None = None, p_qdq: str | None = None, p_qdq_amax: float = 1.0, return_lse: bool = True, @@ -283,22 +305,34 @@ def mla_attention_decode( ) -> tuple[torch.Tensor, torch.Tensor | None]: """Decode one absorbed query token per request over a paged latent cache. + Quantized-BMM model: the latent cache holds RAW (bf16/fp16) values, and each + BMM operand is fake-quantized once, on read, along its own contraction axis + — K along the feature axis (``k_qdq``), V along the token axis (``v_qdq``), + independently. So decode honors ``k_format`` and ``v_format`` separately. + Q is fake-quantized by the caller (dynamic NVFP4 uses an FP32 carrier); P is + quantized in-kernel after the row-sum (denominator stays unquantized). + Args: q: ``[batch, num_heads, kv_lora_rank + qk_rope_head_dim]`` absorbed query. Pass FP32 for the dynamic-NVFP4 QDQ carrier (Q is expected to be fake-quantized by the caller); BF16/FP16 otherwise. latent_cache: ``[num_blocks, page_size, kv_lora_rank + qk_rope_head_dim]`` - paged latent cache. Expected to hold write-once fake-quantized - values; consumed as-is for both BMM1-K and BMM2-V. + paged latent cache holding raw (unquantized) values. block_table: ``[batch, max_blocks_per_seq]`` page table. b_seq_len: ``[batch]`` KV sequence lengths. softmax_scale: Softmax scale (required; MLA layers fold in mscale). kv_lora_rank: Latent width (V/output width). qk_rope_head_dim: RoPE feature width appended to the latent. page_size: Tokens per page; defaults to ``latent_cache.shape[1]``. - num_kv_splits: Fixed split count. P QDQ numerics follow the - split-local schedule, so this stays fixed by default for - reproducibility across batch shapes and devices. + num_kv_splits: Fixed split count. P/V QDQ numerics follow the + split-local schedule; kept fixed by default for reproducibility + across batch shapes and devices. + k_qdq: K fake quant-dequant: ``None``, ``"fp8"``, ``"nvfp4"`` (feature + axis, block-16 for NVFP4). Covers both the NOPE and RoPE slices. + k_qdq_amax: Per-tensor K amax (``None`` = scale 1.0). + v_qdq: V fake quant-dequant, same modes; token/contraction axis. The + open 16-token tail block re-quantizes as the sequence grows. + v_qdq_amax: Per-tensor V amax (``None`` = scale 1.0). p_qdq: Softmax-P fake quant-dequant: ``None``, ``"fp8"``, ``"nvfp4"``. p_qdq_amax: Per-tensor P amax (default 1.0, the theoretical bound). return_lse: Also return the natural-log LSE ``[batch, num_heads]``. @@ -327,12 +361,19 @@ def mla_attention_decode( raise ValueError(f"page_size {page_size} must match latent_cache.shape[1]") if not 1 <= num_kv_splits <= _MAX_KV_SPLITS: raise ValueError(f"num_kv_splits must be in [1, {_MAX_KV_SPLITS}], got {num_kv_splits}") - if p_qdq == "nvfp4" and (kv_lora_rank % 16 or qk_rope_head_dim % 16): + nvfp4_active = "nvfp4" in (k_qdq, v_qdq, p_qdq) + if nvfp4_active and (kv_lora_rank % 16 or qk_rope_head_dim % 16): raise ValueError("NVFP4 decode requires dimensions divisible by 16") + if v_qdq == "nvfp4" and _BLOCK_N % 16: + raise ValueError("NVFP4 V decode requires the KV tile (BLOCK_N) divisible by 16") batch, num_heads = q.shape[0], q.shape[1] if b_seq_len.shape != (batch,) or block_table.shape[0] != batch: raise ValueError("decode metadata batch dimension must match q") + # Operand "p" permits None|fp8|nvfp4 with the standard amax/448 (fp8) and + # amax/(6*448) (nvfp4) scales — shared by K, V, and P here. + k_qdq_scale = _qdq_scale(k_qdq, k_qdq_amax, "p") + v_qdq_scale = _qdq_scale(v_qdq, v_qdq_amax, "p") p_qdq_scale = _qdq_scale(p_qdq, p_qdq_amax, "p") q = q.contiguous() if latent_cache.stride(-1) != 1: @@ -376,6 +417,8 @@ def mla_attention_decode( acc_partial.stride(1), acc_partial.stride(2), p_qdq_scale, + k_qdq_scale, + v_qdq_scale, block_table.shape[1], H=num_heads, BLOCK_H=block_h, @@ -386,6 +429,8 @@ def mla_attention_decode( QK_ROPE_DIM=qk_rope_head_dim, PAGE_SIZE=page_size, NUM_KV_SPLITS=num_kv_splits, + K_QDQ=_P_QDQ_MODES[k_qdq], + V_QDQ=_P_QDQ_MODES[v_qdq], P_QDQ=_P_QDQ_MODES[p_qdq], Q_IS_FP32=q.dtype == torch.float32, num_warps=4, diff --git a/modelopt/torch/kernels/quantization/attention/mla/reference.py b/modelopt/torch/kernels/quantization/attention/mla/reference.py index 5406715800a..a19f7f6c431 100644 --- a/modelopt/torch/kernels/quantization/attention/mla/reference.py +++ b/modelopt/torch/kernels/quantization/attention/mla/reference.py @@ -292,6 +292,10 @@ def mla_decode_reference( softmax_scale: float, kv_lora_rank: int, *, + k_mode: str | None = None, + k_amax: float | None = None, + v_mode: str | None = None, + v_amax: float | None = None, p_mode: str | None = None, p_amax: float = 1.0, num_kv_splits: int = 32, @@ -300,9 +304,10 @@ def mla_decode_reference( ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: """Eager split-local oracle for :func:`mla_attention_decode`. - ``latent`` is the dense (unpaged) cache ``[batch, max_seq, head_dim]``; - ``q`` is the absorbed query ``[batch, num_heads, head_dim]``. Replicates - the fixed-split, tile-aligned stage-1 schedule and the stage-2 merge. + ``latent`` is the dense (unpaged) RAW cache ``[batch, max_seq, head_dim]``; + ``q`` is the absorbed query ``[batch, num_heads, head_dim]``. Replicates the + fixed-split, tile-aligned stage-1 schedule and stage-2 merge, with on-read + K (feature axis) and V (token axis) quant applied independently per tile. """ batch, num_heads, head_dim = q.shape carrier_dtype = latent.dtype @@ -328,15 +333,23 @@ def mla_decode_reference( acc = torch.zeros(num_heads, kv_lora_rank, device=q.device) for kv_start in range(kv_lo, kv_hi, block_n): kv_end = min(kv_start + block_n, s) - kn = k_nope[kv_start:kv_end] - kp = k_pe[kv_start:kv_end] - scores = (q_nope[b] @ kn.T + q_pe[b] @ kp.T) * softmax_scale * LOG2E # [H, tile] - if kv_end - kv_start < block_n: - # Pad to block_n like the kernel: -inf scores -> p == 0, - # zero V rows -> no BMM2 contribution. - pad = block_n - (kv_end - kv_start) - scores = torch.nn.functional.pad(scores, (0, pad), value=float("-inf")) - kn = torch.nn.functional.pad(kn, (0, 0, 0, pad)) + valid = kv_end - kv_start + kn_raw = k_nope[kv_start:kv_end] # [valid, rank] raw + kp_raw = k_pe[kv_start:kv_end] # [valid, rope] raw + if valid < block_n: + # Pad to block_n like the kernel (masked -> 0); the padded + # rows get -inf scores below so they never contribute. + pad = block_n - valid + kn_raw = torch.nn.functional.pad(kn_raw, (0, 0, 0, pad)) + kp_raw = torch.nn.functional.pad(kp_raw, (0, 0, 0, pad)) + # Independent on-read operand quant from the raw latent: + # K along the feature axis, V along the token axis. + kn_k = apply_operand_quant(kn_raw, k_mode, k_amax, block_axis=1) + kp_k = apply_operand_quant(kp_raw, k_mode, k_amax, block_axis=1) + v_op = apply_operand_quant(kn_raw, v_mode, v_amax, block_axis=0) + scores = (q_nope[b] @ kn_k.T + q_pe[b] @ kp_k.T) * softmax_scale * LOG2E + if valid < block_n: + scores[:, valid:] = float("-inf") m_new = torch.maximum(m, scores.amax(dim=-1)) shifted = scores - m_new.unsqueeze(-1) p = torch.where( @@ -350,7 +363,7 @@ def mla_decode_reference( p_q = _quantize_p_tile(p, p_mode, p_amax, carrier_dtype) if p_mode != "nvfp4": p_q = p_q.to(carrier_dtype).float() - acc = acc + p_q @ kn + acc = acc + p_q @ v_op m = m_new split_m.append(m) split_l.append(lsum) diff --git a/modelopt/torch/quantization/plugins/vllm.py b/modelopt/torch/quantization/plugins/vllm.py index df9b4a1849b..b2a15293e7e 100644 --- a/modelopt/torch/quantization/plugins/vllm.py +++ b/modelopt/torch/quantization/plugins/vllm.py @@ -882,15 +882,13 @@ def forward(self, query, kv_c, k_pe, *args, **kwargs): # 576-d q (FP32 carrier) — skip the module-level QDQ entirely. if not getattr(self, "_query_quant_in_kernel", False): query = self.q_bmm_quantizer(query) - # Write-once latent-cache QDQ: the single representation both - # decode BMMs consume from the paged cache. When the fused MLA impl - # owns this at cache-write time (``_kv_quant_in_cache_write``), skip - # it here so the *bf16* latent reaches vLLM's prefill ``kv_b_proj`` - # projection — the projected K/V are then quantized exactly once in - # the prefill kernel, avoiding a double quant. Otherwise (fakequant / - # calibration path, where the fused impl is not installed) quantize - # module-side as before. - if not getattr(self, "_kv_quant_in_cache_write", False): + # Latent-cache QDQ (models a quantized latent cache; used by the + # eager fakequant / calibration path). The fused quantized-BMM MLA + # impl instead keeps the cache RAW and quantizes each BMM operand + # in-kernel (prefill projected K/V; decode on-read latent K/V), so + # it sets ``_skip_module_kv_quant`` to bypass this and let the bf16 + # latent reach both the cache write and vLLM's prefill projection. + if not getattr(self, "_skip_module_kv_quant", False): kv_c = self.kv_c_bmm_quantizer(kv_c) k_pe = self.k_pe_bmm_quantizer(k_pe) return super().forward(query, kv_c, k_pe, *args, **kwargs) diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py index 0eb830db42f..d42156411f6 100644 --- a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_mla.py @@ -16,23 +16,27 @@ """ModelOpt MLA attention adapter for vLLM's TRITON_MLA backend. Same integration philosophy as the regular-attention adapter in -``plugins/vllm.py``: the ``MLAAttention`` module stays intact (its module-level -``kv_c``/``k_pe`` quantizers provide the write-once latent-cache QDQ before the -native cache write), and only ``layer.impl`` is reclassed. vLLM keeps owning -projections, RoPE, cache writes, metadata, chunked-context gathering, state -merging, and the V up-projection: +``plugins/vllm.py``: the ``MLAAttention`` module stays intact and only +``layer.impl`` is reclassed. vLLM keeps owning projections, RoPE, cache writes, +metadata, chunked-context gathering, state merging, and the V up-projection. + +Quantized-BMM model (aligned with the MNI reference and the dense Q/K/P/V +path): the latent cache stays RAW, and every BMM operand is fake-quantized +once, in-kernel, along its own contraction axis. The module sets +``_skip_module_kv_quant`` so the module-level ``kv_c``/``k_pe`` quantizers do +not touch the cache. - ``forward_mha`` (all prefill) temporarily swaps the per-layer ModelOpt prefill backend into the prefill metadata and delegates to the inherited implementation, so the ``kv_b_proj`` projection and chunked-context plumbing are reused rather than forked. The backend routes the two attention calls (causal new tokens; non-causal context chunks with LSE) to - :func:`mla_prefill_attention` with fused Q/K/P/V QDQ and optional 2:4 - score sparsity (prefill only). + :func:`mla_prefill_attention`, which quantizes the projected Q/K/P/V operands + once (from the bf16 latent) with optional 2:4 score sparsity (prefill only). - ``forward_mqa`` (decode) fake-quantizes the absorbed query (FP32 QDQ - carrier) and calls :func:`mla_attention_decode`, which fuses the P QDQ. - BMM1-K and BMM2-V both consume the write-once quantized latent cache as-is - (single stored representation; no on-read re-quantization). + carrier) and calls :func:`mla_attention_decode`, which reads the raw latent + and quantizes K (feature axis), V (token axis), and P independently in the + kernel — so decode honors ``k_format`` and ``v_format`` separately. """ from dataclasses import dataclass @@ -79,10 +83,13 @@ def any_active(self) -> bool: def mla_quant_kw_from_layer(layer, *, query_in_kernel: bool) -> _MLAQuantKw: """Resolve the MLA kernels' quantization kwargs from the layer's quantizers. - ``kv_c``/``k_pe`` quantizers are module-level (write-once latent-cache QDQ) - and therefore never appear here. With ``query_in_kernel`` False (FP8 Q), - the module-level quantizer already QDQ'd the 192-d query, so neither - kernel applies a Q transform. + Quantized-BMM model: the latent cache is RAW, and ``k_format``/``v_format`` + (the ``k_mha``/``v_mha`` quantizers) drive the in-kernel operand quant for + BOTH prefill (projected K/V) and decode (on-read latent K feature-axis / V + token-axis). The module-level ``kv_c``/``k_pe`` quantizers do not quantize + the cache on this path (see ``_skip_module_kv_quant``). With + ``query_in_kernel`` False (FP8 Q), the module already QDQ'd the query, so + neither kernel applies a Q transform. """ q_qdq, q_amax = attention_plugin._bmm_qdq_from_layer(layer, "q_bmm_quantizer", None) k_qdq, k_amax = attention_plugin._bmm_qdq_from_layer(layer, "k_mha_bmm_quantizer", None) @@ -99,7 +106,14 @@ def mla_quant_kw_from_layer(layer, *, query_in_kernel: bool) -> _MLAQuantKw: "v_quant": v_qdq, "v_amax": v_amax, }, - decode={"p_qdq": p_qdq, "p_qdq_amax": p_amax}, + decode={ + "p_qdq": p_qdq, + "p_qdq_amax": p_amax, + "k_qdq": k_qdq, + "k_qdq_amax": k_amax, + "v_qdq": v_qdq, + "v_qdq_amax": v_amax, + }, ) @@ -196,29 +210,10 @@ class ModelOptMLAImpl(TritonMLAImpl): quant_kw: _MLAQuantKw sparse_kw: dict[str, Any] - def do_kv_cache_update(self, kv_c_normed, k_pe, *args, **kwargs): - """Write-once latent QDQ, applied here (cache write) not in the module. - - vLLM calls this before ``forward_impl`` and passes ``forward_impl`` the - same latent tensors. Quantizing here — **out of place**, so the caller's - tensors stay bf16 — writes a quantized cache (read by decode) while the - prefill ``kv_b_proj`` projection still consumes bf16 latent. That makes - the prefill K/V operands single-quant (quantized once in the prefill - kernel) instead of double-quant. Quantizer refs are stashed by the - installer; absent/disabled quantizers pass through unchanged. - - Note: cached-context prefill chunks gather from this quantized cache and - re-quantize the projected operands, so they remain double-quant — that - is inherent to reading a stored quantized latent and is not addressed - here. - """ - kv_q = getattr(self, "_kv_c_quantizer", None) - kpe_q = getattr(self, "_k_pe_quantizer", None) - if kv_q is not None and getattr(kv_q, "is_enabled", False): - kv_c_normed = kv_q(kv_c_normed) - if kpe_q is not None and getattr(kpe_q, "is_enabled", False): - k_pe = kpe_q(k_pe) - return super().do_kv_cache_update(kv_c_normed, k_pe, *args, **kwargs) + # do_kv_cache_update is intentionally NOT overridden: the latent cache stays + # RAW (bf16). Decode quantizes K/V on read and prefill quantizes the + # projected operands in-kernel — the quantized-BMM model — so nothing + # quantizes the stored latent. def _get_prefill_backend(self, prefill_metadata) -> _ModelOptMLAPrefillBackend: backend = self.__dict__.get("_modelopt_prefill_backend") @@ -277,7 +272,13 @@ def forward_mha( def forward_mqa(self, q, kv_c_and_k_pe_cache, attn_metadata, layer): """Run absorbed-MLA decode through the ModelOpt split-K kernel.""" query_in_kernel = getattr(layer, "_query_quant_in_kernel", False) - if self.quant_kw.decode["p_qdq"] is None and not query_in_kernel: + dec = self.quant_kw.decode + if ( + dec["p_qdq"] is None + and dec["k_qdq"] is None + and dec["v_qdq"] is None + and not query_in_kernel + ): return super().forward_mqa(q, kv_c_and_k_pe_cache, attn_metadata, layer) if isinstance(q, tuple): q = torch.cat(q, dim=-1) diff --git a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py index 51079ce9646..a0c9c739547 100644 --- a/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py +++ b/modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py @@ -523,12 +523,6 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor layer.new_impl.quant_kw = _load_mla_plugin().mla_quant_kw_from_layer( layer.module, query_in_kernel=plan.q_format != "fp8" ) - # Move the latent (kv_c/k_pe) QDQ off the module forward and into the - # impl's cache-write hook, so prefill projects bf16 latent and the - # projected K/V are single-quant. Stash the quantizer refs the - # override needs; the module-side flag is set below with the others. - layer.new_impl._kv_c_quantizer = layer.module.kv_c_bmm_quantizer - layer.new_impl._k_pe_quantizer = layer.module.k_pe_bmm_quantizer elif plan.quantize: # Pass cfg only for non-default formats: keeps the default call # signature stable for callers/fakes that predate the cfg parameter. @@ -569,7 +563,7 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor missing = object() old_query_flag = getattr(layer.module, "_query_quant_in_kernel", missing) old_value_flag = getattr(layer.module, "_value_quant_in_kernel", missing) - old_kv_flag = getattr(layer.module, "_kv_quant_in_cache_write", missing) + old_kv_flag = getattr(layer.module, "_skip_module_kv_quant", missing) if plan.quantize: # fp8 Q is module-level (bf16 losslessly carries E4M3 QDQ values); # the kernel then runs a plain bf16 BMM1 with no Q transform. @@ -579,9 +573,10 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor # write-once quantized cache (decode) — no module-level flag. layer.module._value_quant_in_kernel = plan.v_format != "fp8" else: - # Latent QDQ moves to the impl's cache-write hook (single-quant - # prefill); paired with the do_kv_cache_update override. - layer.module._kv_quant_in_cache_write = True + # Quantized-BMM model: keep the latent cache RAW by skipping the + # module-level kv_c/k_pe QDQ. Prefill projects bf16 latent and + # decode quantizes K/V on read; both quantize operands in-kernel. + layer.module._skip_module_kv_quant = True try: # Publish the adapter last so a native impl never runs with in-kernel # quantization flags that only the ModelOpt adapter understands. @@ -591,7 +586,7 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor for name, value in ( ("_query_quant_in_kernel", old_query_flag), ("_value_quant_in_kernel", old_value_flag), - ("_kv_quant_in_cache_write", old_kv_flag), + ("_skip_module_kv_quant", old_kv_flag), ): if value is missing: if hasattr(layer.module, name): diff --git a/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py index 6229d7e8932..f70248e1087 100644 --- a/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py +++ b/tests/gpu/torch/kernels/quantization/attention/mla/test_mla_decode.py @@ -180,53 +180,83 @@ def test_p_qdq_matches_split_local_oracle(self, mode): torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=2e-2) @requires_native_e4m3 - def test_full_nvfp4_recipe_cosine(self): - """Write-once cache QDQ + fp32-carrier Q QDQ + fused NVFP4 P.""" - seq_lens = [96] - q, latent, cache, block_table, seq_t = _make_decode_inputs( - seq_lens, dtype=torch.bfloat16, seed=4 - ) + @pytest.mark.parametrize("mode", ["fp8", "nvfp4"]) + def test_kv_qdq_matches_oracle(self, mode): + """On-read K (feature axis) + V (token axis) quant vs the split-local oracle.""" + seq_lens = [150, 40] + q, latent, cache, block_table, seq_t = _make_decode_inputs(seq_lens, seed=6) scale = _DIM**-0.5 - # Emulate the module-level write-once QDQ: quantize the cache rows - # along the feature axis (kv_c and k_pe global scales both 1.0, so a - # single 16-block pass over the full row is equivalent). - cache_q = nvfp4_fake_quant(cache.float(), block_axis=-1).to(cache.dtype) - q_carrier = nvfp4_fake_quant(q.float(), block_axis=-1) # FP32 QDQ carrier out, _ = mla_attention_decode( - q_carrier, - cache_q, + q, + cache, block_table, seq_t, softmax_scale=scale, kv_lora_rank=_RANK, qk_rope_head_dim=_ROPE, - p_qdq="nvfp4", + k_qdq=mode, + v_qdq=mode, ) - ref, _ = _dense_decode(q, latent, seq_t, scale) - assert torch.isfinite(out.float()).all() - assert _cos(out, ref) > 0.98 + dense_out, _ = mla_attention_decode( + q, + cache, + block_table, + seq_t, + softmax_scale=scale, + kv_lora_rank=_RANK, + qk_rope_head_dim=_ROPE, + ) + assert not torch.equal(out, dense_out) # K/V quant actually applied + ref = mla_decode_reference( + q, latent, seq_t, scale, _RANK, k_mode=mode, v_mode=mode, num_kv_splits=32, block_n=32 + ) + torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=2e-2) + + @requires_native_e4m3 + def test_v_format_honored_independently(self): + """Decode V is quantized independently along the token axis — toggling + v_qdq must change the output (the quantized-BMM contract), and it must + differ from the K-only quantization of the same latent.""" + seq_lens = [96] + q, _, cache, block_table, seq_t = _make_decode_inputs(seq_lens, seed=7) + scale = _DIM**-0.5 + common = { + "softmax_scale": scale, + "kv_lora_rank": _RANK, + "qk_rope_head_dim": _ROPE, + "num_kv_splits": 32, + } + k_only, _ = mla_attention_decode(q, cache, block_table, seq_t, k_qdq="nvfp4", **common) + kv_both, _ = mla_attention_decode( + q, cache, block_table, seq_t, k_qdq="nvfp4", v_qdq="nvfp4", **common + ) + # v_qdq has an independent effect (V is not merely inheriting k_qdq). + assert not torch.equal(k_only, kv_both) - def test_quantized_cache_consumed_as_is(self): - """BMM2 reads the cache values unchanged (no on-read re-quant).""" - seq_lens = [64] - q, _, cache, block_table, seq_t = _make_decode_inputs(seq_lens, seed=5) - scale = 0.1 - # Any cache contents must flow through V untouched: compare against - # the dense oracle computed from the exact same (arbitrary) cache. - latent_view = torch.zeros(1, 64, _DIM, device="cuda", dtype=cache.dtype) - for blk in range(64 // 16): - latent_view[0, blk * 16 : (blk + 1) * 16] = cache[int(block_table[0, blk])] + @requires_native_e4m3 + def test_full_qkpv_nvfp4_cosine(self): + """fp32-carrier Q + on-read K/V + fused P, all NVFP4, from a RAW cache.""" + seq_lens = [96] + q, latent, cache, block_table, seq_t = _make_decode_inputs( + seq_lens, dtype=torch.bfloat16, seed=8 + ) + scale = _DIM**-0.5 + q_carrier = nvfp4_fake_quant(q.float(), block_axis=-1) # caller-side Q QDQ (fp32) out, _ = mla_attention_decode( - q, + q_carrier, cache, block_table, seq_t, softmax_scale=scale, kv_lora_rank=_RANK, qk_rope_head_dim=_ROPE, + k_qdq="nvfp4", + v_qdq="nvfp4", + p_qdq="nvfp4", ) - ref, _ = _dense_decode(q, latent_view, seq_t, scale) - torch.testing.assert_close(out.float(), ref, rtol=5e-3, atol=5e-3) + ref, _ = _dense_decode(q, latent, seq_t, scale) + assert torch.isfinite(out.float()).all() + assert _cos(out, ref) > 0.98 class TestMLADecodeErrors: diff --git a/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py index 2040da85eb8..f2440b37d39 100644 --- a/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py +++ b/tests/gpu_vllm/torch/sparsity/attention_sparsity/test_vllm_mla_runtime.py @@ -112,12 +112,10 @@ def test_nvfp4_install_configures_quantizers_and_impl(self, patched_parallel_sta assert float(amax) == 6.0 * 448.0 assert attention._query_quant_in_kernel is True assert not hasattr(attention, "_value_quant_in_kernel") - # Latent QDQ is moved to the impl's cache-write hook (single-quant - # prefill): the module flag skips the module-side kv_c/k_pe quant, and - # the impl carries the quantizer refs its override applies. - assert attention._kv_quant_in_cache_write is True - assert attention.impl._kv_c_quantizer is attention.kv_c_bmm_quantizer - assert attention.impl._k_pe_quantizer is attention.k_pe_bmm_quantizer + # Quantized-BMM model: the module flag keeps the latent cache raw by + # skipping the module-side kv_c/k_pe quant; operands are quantized + # in-kernel (prefill projected K/V; decode on-read latent K/V). + assert attention._skip_module_kv_quant is True assert attention.impl.quant_kw.prefill == { "q_quant": "nvfp4", "q_amax": None, @@ -128,7 +126,14 @@ def test_nvfp4_install_configures_quantizers_and_impl(self, patched_parallel_sta "v_quant": "nvfp4", "v_amax": 6.0 * 448.0, } - assert attention.impl.quant_kw.decode == {"p_qdq": "nvfp4", "p_qdq_amax": 1.0} + assert attention.impl.quant_kw.decode == { + "p_qdq": "nvfp4", + "p_qdq_amax": 1.0, + "k_qdq": "nvfp4", + "k_qdq_amax": 6.0 * 448.0, + "v_qdq": "nvfp4", + "v_qdq_amax": 6.0 * 448.0, + } assert attention.impl.sparse_kw == {} assert report.installed_layers == ("mla_attn",) assert report.quantized_layers == ("mla_attn",) @@ -227,7 +232,14 @@ def _bare_mla_impl(*, p_qdq="nvfp4", prefill_active=True, sparse_kw=None): "v_quant": mode, "v_amax": None, }, - decode={"p_qdq": p_qdq, "p_qdq_amax": 1.0}, + decode={ + "p_qdq": p_qdq, + "p_qdq_amax": 1.0, + "k_qdq": mode, + "k_qdq_amax": None, + "v_qdq": mode, + "v_qdq_amax": None, + }, ) impl.sparse_kw = dict(sparse_kw or {}) return impl @@ -274,6 +286,9 @@ def _poison(self, *args, **kwargs): assert recorded["num_kv_splits"] == 32 assert recorded["p_qdq"] == "nvfp4" assert recorded["p_qdq_amax"] == 1.0 + # Decode quantizes K (feature) and V (token) on read from the raw cache. + assert recorded["k_qdq"] == "nvfp4" + assert recorded["v_qdq"] == "nvfp4" assert recorded["kv_lora_rank"] == 128 assert recorded["qk_rope_head_dim"] == 64 assert recorded["out_dtype"] == cache.dtype @@ -347,54 +362,9 @@ def _fake_super(self, q, kv_c, k_pe, cache, attn_metadata, k_scale, output, outp assert seen["backend_during_call"] is base_backend # untouched -class _RecorderQuantizer: - """Stand-in TensorQuantizer that records calls and returns a marked tensor.""" - - def __init__(self, *, is_enabled=True): - self.is_enabled = is_enabled - self.calls = [] - - def __call__(self, x): - self.calls.append(x) - return f"quant({x})" # distinct object => out-of-place - - -class TestMLACacheWriteQuant: - def test_do_kv_cache_update_quantizes_latent_out_of_place(self, monkeypatch): - impl = _bare_mla_impl() - impl._kv_c_quantizer = _RecorderQuantizer() - impl._k_pe_quantizer = _RecorderQuantizer() - seen = {} - - def _fake_super(self, kv_c_normed, k_pe, *args, **kwargs): - seen["kv_c"] = kv_c_normed - seen["k_pe"] = k_pe - - monkeypatch.setattr(TritonMLAImpl, "do_kv_cache_update", _fake_super) - - impl.do_kv_cache_update("kvc", "kpe", "cache", "slot", "auto", "scale") - - # The latent QDQ is applied here (cache write), not skipped. - assert impl._kv_c_quantizer.calls == ["kvc"] - assert impl._k_pe_quantizer.calls == ["kpe"] - # The quantized (new) tensors are what gets written to cache. - assert seen["kv_c"] == "quant(kvc)" - assert seen["k_pe"] == "quant(kpe)" - - def test_do_kv_cache_update_skips_disabled_quantizers(self, monkeypatch): - impl = _bare_mla_impl() - impl._kv_c_quantizer = _RecorderQuantizer(is_enabled=False) - impl._k_pe_quantizer = None - seen = {} - - def _fake_super(self, kv_c_normed, k_pe, *args, **kwargs): - seen["kv_c"] = kv_c_normed - seen["k_pe"] = k_pe - - monkeypatch.setattr(TritonMLAImpl, "do_kv_cache_update", _fake_super) - - impl.do_kv_cache_update("kvc", "kpe", "cache", "slot", "auto", "scale") - - assert impl._kv_c_quantizer.calls == [] # disabled => passthrough - assert seen["kv_c"] == "kvc" - assert seen["k_pe"] == "kpe" +def test_impl_keeps_cache_raw_no_update_override(): + """Quantized-BMM model: the latent cache stays RAW, so the impl must NOT + override do_kv_cache_update (which would re-introduce a cache-write quant). + Decode instead quantizes K/V on read; prefill quantizes projected operands. + """ + assert vllm_mla.ModelOptMLAImpl.do_kv_cache_update is TritonMLAImpl.do_kv_cache_update From f0683c88d47f7839b0e1f7b638c3c39120e332e4 Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Mon, 24 Aug 2026 17:33:37 -0700 Subject: [PATCH 5/5] Add CHANGELOG entry for MLA NVFP4/FP8 + 2:4 attention Signed-off-by: Kai Xu --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4580a045f39..588529cc6ea 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,8 @@ Changelog - Add the ``nvfp4_act_headroom`` calibration algorithm for NVFP4 **activation** global scales. Instead of setting the global scale from the largest per-block amax seen during calibration (plain ``max``, which leaves no room above it so any larger activation saturates), it anchors the scale to a low percentile of the per-block amax distribution, leaving the rest of the FP8 block-scale range as headroom: ``amax = max(rho * anchor, upper)``, where ``anchor`` and ``upper`` are the per-block amaxes at ``anchor_percentile`` (default 1) and ``upper_percentile`` (default 99.99; set to 100 to never clip calibration data), and ``rho`` (default 16384) is the headroom factor. Applies only to NVFP4 dynamic-block input quantizers; ``SequentialQuantizer`` activation quantizers raise. Weight scales are an orthogonal axis selected by a nested ``weight_scale_algorithm`` (``max`` by default, or ``mse`` / ``local_hessian``), so one recipe can combine a weight calibration with this activation policy in a single pass. Ships ``modelopt_recipes/general/ptq/nvfp4_act_headroom-kv_fp8_cast.yaml``, which mirrors ``nvfp4_default-kv_fp8_cast`` with only the calibration algorithm swapped and exports a standard NVFP4 checkpoint. +- Add fused NVFP4/FP8 Q/K/P/V and 2:4 attention quantization for MLA models (DeepSeek-family) on the vLLM ``TRITON_MLA`` backend, served through ``examples/vllm_serve`` (``QuantSparseAttnWorker`` / ``install_vllm_nvfp4_attention``). Each attention BMM operand is fake-quantized in-kernel from a raw latent cache — decode quantizes K (feature axis) and V (token axis) on read and P after the softmax row-sum; prefill quantizes the projected operands. Optional checkpoint N:M score sparsity applies to prefill new-token attention. Requires ``--attention-backend TRITON_MLA`` and ``--enforce-eager``. + *Megatron Framework (M-LM / M-Bridge)* - Add SFT-masked data support to ``examples/megatron_bridge/distill.py``: ``--sft --sft_dataset_root `` distills on raw prompt-completion JSONL (``{"input", "output"}`` records) with the loss masked to the response tokens, using Megatron-Bridge's ``FinetuningDatasetConfig`` and the model's own HuggingFace tokenizer instead of the pretraining ``GPTDataset`` and ``NullTokenizer``.