[Bugfix] Fix SM100 fp8_ds_mla cache scales (#49435)
Signed-off-by: Ariel Bereslavsky <arielb@getimpala.ai> Signed-off-by: Ariel Bereslavsky <ariel@getimpala.ai> Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Ariel Bereslavsky <arielb@getimpala.ai> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Misha Goin <mgoin64@gmail.com> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Ariel Bereslavsky committed
Sep 20, 2026 at 03:15 UTC
a7fda4c88bfc421d31e33acc5e01e86ebe467ad8
8 files changed
+76
-38
csrc/libtorch_stable/cache_kernels.cu
+3
-3
@@ -19,7 +19,6 @@
19
20
#include <algorithm>
21
#include <cassert>
22
-#include <cfloat>
22
#include <cstdlib>
23
24
#ifdef USE_ROCM
@@ -589,8 +588,9 @@ __global__ void concat_and_cache_ds_mla_kernel(
588
max_abs = fmaxf(max_abs, VLLM_SHFL_XOR_SYNC_WIDTH(max_abs, offset, 16));
589
}
590
592
- // Compute the scale for the tile
593
- float tile_scale = fmaxf(max_abs / kFp8ScaleDivisor, FLT_MIN);
591
+ // Both SM90 and SM100 readers preserve power-of-two fp32 scales exactly.
592
+ float tile_scale = fmaxf(max_abs / kFp8ScaleDivisor, 1e-4f);
593
+ tile_scale = exp2f(ceilf(log2f(tile_scale)));
594
595
// The first lane of each half-warp writes the scale to kv_cache
596
if ((lane_idx == 0) || (lane_idx == 16)) {
csrc/libtorch_stable/fused_kimi_k3_mla_key_concat_kv_cache_kernel.cu
+10
-9
@@ -35,8 +35,9 @@
35
*
36
* fp8_ds_mla (fused_kimi_k3_mla_key_concat_ds_mla_insert):
37
* - full key concat (bf16), and cache insert in DeepSeek's 656-byte
38
- * block-scaled layout (NoPE fp8 in 4 tiles of 128 with per-tile dynamic
39
- * scales, RoPE bf16), bit-compatible with concat_and_cache_ds_mla_kernel.
38
+ * block-scaled layout (NoPE fp8 in 4 tiles of 128 with per-tile
39
+ * power-of-two scales, RoPE bf16), bit-compatible with
40
+ * concat_and_cache_ds_mla_kernel.
41
*
42
* Both use Programmatic Dependent Launch (PDL) to overlap the tail of the
43
* producing GEMMs on sm_90+, and are structured after
@@ -68,7 +69,6 @@
69
#include "../quantization/w8a8/fp8/amd/quant_utils.cuh"
70
#endif
71
#include <cuda_runtime.h>
71
-#include <cfloat>
72
#include <type_traits>
73
74
#ifdef USE_ROCM
@@ -111,9 +111,9 @@ constexpr float kFp8Max = 224.0f;
111
#else
112
constexpr float kFp8Max = 448.0f;
113
#endif
114
-// Divisor for fp8_ds_mla per-tile dynamic scales (matches cache_kernels.cu).
115
-// fp8_ds_mla 656B entry: [0,512) NoPE fp8 (4 tiles of 128), [512,528) 4 fp32
116
-// tile scales, [528,656) RoPE 64 bf16.
114
+// Divisor for fp8_ds_mla per-tile power-of-two scales (matches
115
+// cache_kernels.cu). fp8_ds_mla 656B entry: [0,512) NoPE fp8 (4 tiles of 128),
116
+// [512,528) 4 fp32 tile scales, [528,656) RoPE 64 bf16.
117
constexpr float kFp8ScaleDivisor = kFp8Max;
118
119
// Copy 8 source elements (one uint4 of bf16/fp16) to `dst`. FP8=false stores a
@@ -325,7 +325,7 @@ __device__ __forceinline__ void writeLatent576(
325
}
326
327
// Write [kv_c | k_pe] into the fp8_ds_mla 656B entry using one warp: NoPE 512
328
-// as fp8 in 4 tiles of 128 (per-tile dynamic absmax scale, 4 fp32 scales at
328
+// as fp8 in 4 tiles of 128 (per-tile power-of-two scale, 4 fp32 scales at
329
// [512,528)), RoPE 64 as bf16 at [528,656). Bit-compatible with
330
// concat_and_cache_ds_mla_kernel.
331
template <typename scalar_t, bool APPLY_ROPE = false>
@@ -349,7 +349,8 @@ __device__ __forceinline__ void writeDsMlaCache(
349
for (int offset = 4; offset > 0; offset /= 2) {
350
max_abs = fmaxf(max_abs, VLLM_SHFL_XOR_SYNC_WIDTH(max_abs, offset, 8));
351
}
352
- float const tile_scale = fmaxf(max_abs / kFp8ScaleDivisor, FLT_MIN);
352
+ float tile_scale = fmaxf(max_abs / kFp8ScaleDivisor, 1e-4f);
353
+ tile_scale = exp2f(ceilf(log2f(tile_scale)));
354
if ((laneId & 7) == 0) {
355
reinterpret_cast<float*>(row)[kKvLoraRank / 4 + tile] = tile_scale;
356
}
@@ -598,7 +599,7 @@ __global__ void fusedKimiK3MLAKVConcatPackKernel(
599
// ds_mla variant: concat full key (bf16) + fp8_ds_mla latent cache insert
600
//
601
// Cache entry (656 bytes), matching concat_and_cache_ds_mla_kernel:
601
-// [0, 512) NoPE 512 vals as fp8, 4 tiles of 128, each dynamically scaled
602
+// [0, 512) NoPE 512 vals as fp8, 4 tiles of 128, power-of-two scaled
603
// [512, 528) 4 fp32 per-tile scales
604
// [528, 656) RoPE 64 vals as bf16 (unquantized)
605
// The cache slot uses one warp: lane L quantizes NoPE elems [L*16, L*16+16)
tests/kernels/attention/test_cache.py
+5
-5
@@ -960,14 +960,14 @@ def test_concat_and_cache_ds_mla(
960
tile_end = (tile_idx + 1) * 128
961
tile_data[:] = kv_c_data[tile_start:tile_end]
962
963
- # tile_scale = tile_data.amax().to(torch.float32) / 448.
964
- # NOTE: Using torch's amax() gives different results,
965
- # so this must be manually computed.
963
+ # Using torch's amax() gives different results, so this must be
964
+ # manually computed.
965
tile_data_float = tile_data.to(torch.float32)
966
manual_max = abs(tile_data_float[0])
967
for j in range(1, 128):
968
manual_max = max(manual_max, abs(tile_data_float[j]))
970
- tile_scale = manual_max / 448.0
969
+ raw_scale = torch.clamp(manual_max / 448.0, min=1e-4)
970
+ tile_scale = torch.exp2(torch.ceil(torch.log2(raw_scale)))
971
972
ref_cache_32bit[kv_lora_rank // 4 + tile_idx] = tile_scale
973
@@ -1008,7 +1008,7 @@ def test_concat_and_cache_ds_mla(
1008
ref_rope = ref_cache_slice.view(dtype)[kv_lora_rank // 2 + 8 :]
1009
1010
torch.testing.assert_close(kv_nope, ref_nope, atol=0.001, rtol=0.1)
1011
- torch.testing.assert_close(kv_scales, ref_scales, atol=0.001, rtol=0.1)
1011
+ torch.testing.assert_close(kv_scales, ref_scales, atol=0, rtol=0)
1012
torch.testing.assert_close(kv_rope, ref_rope, atol=0.001, rtol=0.1)
1013
1014
tests/kernels/attention/test_flashmla_sparse.py
+31
-8
@@ -109,8 +109,9 @@ def test_sparse_flashmla_metadata_smoke():
109
assert num_splits is None
110
111
112
-def test_sparse_flashmla_decode_smoke():
112
+def test_sparse_flashmla_decode_matches_cache_writer_scales():
113
import vllm.v1.attention.ops.flashmla as fm
114
+ from vllm import _custom_ops as ops
115
116
ok, reason = fm.is_flashmla_sparse_supported()
117
if not ok:
@@ -130,7 +131,7 @@ def test_sparse_flashmla_decode_smoke():
131
# Metadata
132
q_seq_per_hk = seqlen_q * num_heads_q // num_heads_k
133
# q_heads_per_hk = num_heads_q // num_heads_k
133
- cache_seqlens = torch.zeros(batch_size, dtype=torch.int32, device=device)
134
+ cache_seqlens = torch.ones(batch_size, dtype=torch.int32, device=device)
135
tile_md, num_splits = fm.get_mla_metadata(
136
cache_seqlens,
137
q_seq_per_hk,
@@ -146,14 +147,33 @@ def test_sparse_flashmla_decode_smoke():
147
dtype=torch.bfloat16,
148
device=device,
149
)
149
- k_cache = torch.zeros(
150
- (1, page_block_size, num_heads_k, bytes_per_token),
151
- dtype=torch.uint8,
152
- device=device,
150
+ cache_rows = torch.zeros(
151
+ (1, page_block_size, bytes_per_token), dtype=torch.uint8, device=device
152
)
154
- indices = torch.zeros(
155
- (batch_size, seqlen_q, topk), dtype=torch.int32, device=device
153
+ # 336 / 448 = 0.75 distinguishes arbitrary FP32 from power-of-two scales.
154
+ kv_c = torch.full((1, 512), 336.0, dtype=torch.bfloat16, device=device)
155
+ kv_c[:, -128:] = 0
156
+ ops.concat_and_cache_mla(
157
+ kv_c,
158
+ torch.zeros((1, 64), dtype=torch.bfloat16, device=device),
159
+ cache_rows,
160
+ torch.zeros(1, dtype=torch.int64, device=device),
161
+ "fp8_ds_mla",
162
+ torch.ones(1, dtype=torch.float32, device=device),
163
)
164
+ scales = cache_rows[0, 0].view(torch.float32)[128:132]
165
+ expected_scales = torch.tensor([1.0, 1.0, 1.0, 2**-13], device=device)
166
+ torch.testing.assert_close(scales, expected_scales, rtol=0, atol=0)
167
+ cached_nope = cache_rows[0, 0, :512].view(torch.float8_e4m3fn).float()
168
+ expected = (cached_nope * scales.repeat_interleave(128)).to(torch.bfloat16)
169
+
170
+ k_cache = cache_rows.unsqueeze(2)
171
+ indices = torch.full(
172
+ (batch_size, seqlen_q, topk), -1, dtype=torch.int32, device=device
173
+ )
174
+ indices[..., 0] = 0
175
+ # SM90 sparse decode only supports a fixed top-k width. Padding with -1
176
+ # exercises the same cache data on SM90 and SM100 without topk_length.
177
178
block_table = torch.zeros((batch_size, 128), dtype=torch.int32, device=device)
179
out, lse = fm.flash_mla_with_kvcache(
@@ -170,6 +190,9 @@ def test_sparse_flashmla_decode_smoke():
190
assert out.shape[0] == batch_size
191
assert out.shape[-1] == head_dim_v
192
assert lse.shape[0] == batch_size
193
+ torch.testing.assert_close(
194
+ out, expected.view(1, 1, 1, 512).expand_as(out), rtol=0, atol=0
195
+ )
196
197
198
@pytest.mark.parametrize("h_q", [64, 128])
tests/kernels/attention/test_kimi_k3_mla_key_concat_kv_cache.py
+16
-2
@@ -222,7 +222,8 @@ def test_decode_concat_ignores_negative_slots_for_cache(cache_format: str) -> No
222
_assert_cache_matches_reference(mixed_cache, reference_cache, initial_cache)
223
224
225
-def test_ds_mla_cache_insert_bit_compatible_with_reference() -> None:
225
+@pytest.mark.parametrize("decode", [False, True])
226
+def test_ds_mla_cache_insert_bit_compatible_with_reference(decode: bool) -> None:
227
"""Fused ds_mla insertion must match the reference bit-for-bit."""
228
torch.manual_seed(0)
229
num_tokens, num_blocks = 33, 16
@@ -242,5 +243,18 @@ def test_ds_mla_cache_insert_bit_compatible_with_reference() -> None:
243
got = ref.clone()
244
scale = torch.ones(1, device="cuda", dtype=torch.float32)
245
ops.concat_and_cache_mla(kv_c, k_pe.squeeze(1), ref, slots, "fp8_ds_mla", scale)
245
- fused_mla_key_concat_ds_mla_insert(q, k_nope, k_pe, kv_c, got, slots)
246
+ if decode:
247
+ ql_nope = torch.randn(
248
+ num_tokens, NUM_HEADS, KV_LORA_RANK, device="cuda", dtype=dt
249
+ )
250
+ fused_mla_decode_q_concat_kv_cache_insert(
251
+ ql_nope, q[..., -ROPE_HEAD_DIM:], kv_c, k_pe, got, slots, ds_mla=True
252
+ )
253
+ else:
254
+ fused_mla_key_concat_ds_mla_insert(q, k_nope, k_pe, kv_c, got, slots)
255
assert torch.equal(ref, got)
256
+ rows = got.view(-1, DS_MLA_CACHE_ENTRY)[slots[slots >= 0]]
257
+ scales = rows[:, KV_LORA_RANK : KV_LORA_RANK + 16].view(torch.float32)
258
+ torch.testing.assert_close(
259
+ torch.log2(scales), torch.log2(scales).round(), rtol=0, atol=0
260
+ )
tests/kernels/test_fused_deepseek_v32_norm_rope.py
+5
-4
@@ -507,8 +507,8 @@ def test_fused_norm_rope_materializes_pcp_cache_inputs(has_indexer: bool):
507
def test_fused_norm_rope_ds_mla(num_tokens: int):
508
"""fp8_ds_mla MLA cache layout (FlashMLA sparse, bf16-query path; SM90/SM100).
509
510
- Per-token 656-byte entry: 512 fp8 NoPE (4 per-128 tiles, dynamic float32
511
- scale) | 4 float32 scales | 64 bf16 (unquantized) RoPE.
510
+ Per-token 656-byte entry: 512 fp8 NoPE (4 per-128 tiles, power-of-two scales
511
+ stored as float32) | 4 float32 scales | 64 bf16 (unquantized) RoPE.
512
"""
513
torch.manual_seed(5)
514
dev = "cuda"
@@ -557,7 +557,8 @@ def test_fused_norm_rope_ds_mla(num_tokens: int):
557
kv_ref = rms_norm(kv_c, kvw) # [N, 512] fp32
558
kpe_ref = rope(k_pe.float(), pos, mla_cos_sin, interleave=True) # [N, 64]
559
tiles = kv_ref.view(num_tokens, 4, 128)
560
- ref_scale = torch.clamp(tiles.abs().amax(dim=-1) / FP8_MAX, min=1.1754944e-38)
560
+ ref_scale = torch.clamp(tiles.abs().amax(dim=-1) / FP8_MAX, min=1e-4)
561
+ ref_scale = torch.exp2(torch.ceil(torch.log2(ref_scale)))
562
ref_nope = (tiles / ref_scale[..., None]).reshape(num_tokens, KV_LORA).to(FP8)
563
564
cache = mla_cache[0, :num_tokens] # [N, 656] uint8
@@ -566,7 +567,7 @@ def test_fused_norm_rope_ds_mla(num_tokens: int):
567
rope_off = KV_LORA // 2 + 8
568
rope_vals = cache.view(torch.bfloat16)[:, rope_off : rope_off + ROPE_DIM]
569
569
- torch.testing.assert_close(scales, ref_scale, rtol=1e-2, atol=1e-6)
570
+ torch.testing.assert_close(scales, ref_scale, rtol=0, atol=0)
571
assert_fp8(nope, ref_nope, "ds_mla NoPE fp8")
572
assert_bf16(rope_vals, kpe_ref, "ds_mla RoPE bf16")
573
# No indexer on this call: top-k buffer must be untouched.
vllm/models/deepseek_v32/common/kernels.py
+3
-4
@@ -315,7 +315,7 @@ def _fused_norm_rope_kernel(
315
if MLA_CACHE_DS_MLA:
316
# fp8_ds_mla layout (DeepSeek-V3.2, KV_DIM == 512): per-128-element
317
# tile of the NoPE is dynamically quantized to fp8 with its own
318
- # float32 scale; the RoPE tail is stored unquantized in bf16.
318
+ # power-of-two scale stored as float32; the RoPE tail is bf16.
319
# bytes [0, KV_DIM) : KV_DIM fp8 NoPE values
320
# bytes [KV_DIM, KV_DIM + 16) : MLA_NUM_TILES float32 scales
321
# bytes [KV_DIM + 16, ...) : 2 * KPE_HALF_ROT_DIM bf16 RoPE
@@ -327,9 +327,8 @@ def _fused_norm_rope_kernel(
327
)
328
kv_2d = tl.reshape(kv_c, (MLA_NUM_TILES, MLA_TILE_DIM))
329
tile_amax = tl.max(tl.abs(kv_2d), axis=1, keep_dims=True)
330
- # scale = amax / 448 (fp8 e4m3 max), matching the reference
331
- # concat_and_cache_ds_mla kernel; floored to FLT_MIN.
332
- tile_scale = tl.maximum(tile_amax * (1.0 / 448.0), 1.1754944e-38)
330
+ tile_scale = tl.maximum(tile_amax * (1.0 / 448.0), 1e-4)
331
+ tile_scale = tl.math.exp2(tl.math.ceil(tl.math.log2(tile_scale)))
332
kv_c_fp8 = tl.reshape((kv_2d / tile_scale).to(tl.float8e4nv), (KV_DIM,))
333
tl.store(mla_cache_ptr + byte_base + kv_block, kv_c_fp8)
334
tile_off = tl.arange(0, MLA_NUM_TILES)
vllm/models/kimi_k3/nvidia/ops/fused_mla_key_concat_kv_cache.py
+3
-3
@@ -76,9 +76,9 @@ def fused_mla_key_concat_ds_mla_insert(
76
"""Concat full K (bf16) and insert the latent in the fp8_ds_mla layout.
77
78
The cache uses DeepSeek's 656-byte block-scaled layout (NoPE in 4 tiles of
79
- 128 with per-tile dynamic fp8 scales, RoPE as bf16) -- self-scaling, so no
80
- scale argument. Returns the bf16 full key; optionally rotates ``q`` and
81
- writes ``kv_cache`` in place.
79
+ 128 with per-tile power-of-two scales stored as float32, RoPE as bf16) --
80
+ self-scaling, so no scale argument. Returns the bf16 full key; optionally
81
+ rotates ``q`` and writes ``kv_cache`` in place.
82
"""
83
k_pe = k_pe.reshape(k_pe.shape[0], -1)
84
tp, num_heads, qk_nope_head_dim = k_nope.shape