[Attention][DSA] Enable W4A16 DSA (#51724)
Signed-off-by: Shiyang Chen <shiychen@nvidia.com>
sychen52 committed
Aug 31, 2026 at 21:37 UTC
7c5dc571cbd1064ecc8a9b1045637ff647aa22cb
17 files changed
+824
-22
CMakeLists.txt
+2
-1
@@ -1042,7 +1042,8 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
1042
"csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu"
1043
"csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu"
1044
"csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu"
1045
- "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu")
1045
+ "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu"
1046
+ "csrc/libtorch_stable/nvfp4_ds_mla_cache_kernels.cu")
1047
if(NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.9)
1048
message(STATUS
1049
"Building mxfp4_experts_quant unsupported stubs because CUDA compiler version is not >= 12.9 (found ${CMAKE_CUDA_COMPILER_VERSION}).")
cmake/external_projects/flashmla.cmake
+2
-1
@@ -19,7 +19,7 @@ else()
19
FetchContent_Declare(
20
flashmla
21
GIT_REPOSITORY https://github.com/vllm-project/FlashMLA
22
- GIT_TAG 0397728d511c4e3d94ea3a01d8dda8654525a611
22
+ GIT_TAG 6bc49418c5ead572ff0339191ddf3b155749e183
23
GIT_PROGRESS TRUE
24
CONFIGURE_COMMAND ""
25
BUILD_COMMAND ""
@@ -111,6 +111,7 @@ if(FLASH_MLA_ARCHS)
111
# sm100 sparse decode
112
${flashmla_SOURCE_DIR}/csrc/sm100/decode/head64/instantiations/v32.cu
113
${flashmla_SOURCE_DIR}/csrc/sm100/decode/head64/instantiations/model1.cu
114
+ ${flashmla_SOURCE_DIR}/csrc/sm100/decode/head64/instantiations/v32_nvfp4_fp8rope.cu
115
${flashmla_SOURCE_DIR}/csrc/sm100/prefill/sparse/fwd_for_small_topk/head128/instantiations/phase1_decode_k512.cu
116
)
117
csrc/libtorch_stable/cache_kernels.cu
+104
@@ -7,6 +7,10 @@
7
#include "quantization/vectorization_utils.cuh"
8
#include "concat_mla_q.cuh"
9
10
+#if !defined(USE_ROCM) && defined(ENABLE_NVFP4_SM100) && ENABLE_NVFP4_SM100
11
+ #include "nvfp4_ds_mla_cache.h"
12
+#endif
13
+
14
#ifdef USE_ROCM
15
#include "../quantization/w8a8/fp8/amd/quant_utils.cuh"
16
#else
@@ -917,6 +921,7 @@ void concat_and_cache_mla(
921
int pe_dim = k_pe.size(1);
922
int block_size = kv_cache.size(1);
923
924
+ const bool is_nvfp4_ds_mla = kv_cache_dtype == "nvfp4_ds_mla";
925
if (kv_cache_dtype == "fp8_ds_mla") {
926
STD_TORCH_CHECK(kv_lora_rank == 512,
927
"kv_lora_rank must be 512 for fp8_ds_mla");
@@ -927,6 +932,21 @@ void concat_and_cache_mla(
932
"kv_c.element_size() must be 2 for fp8_ds_mla");
933
STD_TORCH_CHECK(k_pe.element_size() == 2,
934
"k_pe.element_size() must be 2 for fp8_ds_mla");
935
+ } else if (is_nvfp4_ds_mla) {
936
+ constexpr int bytes_per_token = 352;
937
+ STD_TORCH_CHECK(kv_lora_rank == 512, "kv_lora_rank must be 512 for ",
938
+ kv_cache_dtype);
939
+ STD_TORCH_CHECK(pe_dim == 64, "pe_dim must be 64 for ", kv_cache_dtype);
940
+ STD_TORCH_CHECK(
941
+ kv_cache.size(2) == bytes_per_token / kv_cache.element_size(),
942
+ "kv_cache.size(2) must be ", bytes_per_token, " bytes for ",
943
+ kv_cache_dtype);
944
+ STD_TORCH_CHECK(
945
+ kv_c.scalar_type() == torch::headeronly::ScalarType::BFloat16,
946
+ "kv_c must be bfloat16 for ", kv_cache_dtype);
947
+ STD_TORCH_CHECK(
948
+ k_pe.scalar_type() == torch::headeronly::ScalarType::BFloat16,
949
+ "k_pe must be bfloat16 for ", kv_cache_dtype);
950
} else {
951
STD_TORCH_CHECK(kv_cache.size(2) == kv_lora_rank + pe_dim);
952
}
@@ -950,6 +970,24 @@ void concat_and_cache_mla(
970
dim3 block(96);
971
DISPATCH_BY_KV_CACHE_DTYPE(kv_c.scalar_type(), kv_cache_dtype,
972
CALL_CONCAT_AND_CACHE_DS_MLA);
973
+ } else if (is_nvfp4_ds_mla) {
974
+ // The kernels live in nvfp4_ds_mla_cache_kernels.cu, which is only built
975
+ // when the target list contains an SM100 arch.
976
+#if !defined(USE_ROCM) && defined(ENABLE_NVFP4_SM100) && ENABLE_NVFP4_SM100
977
+ const cudaDeviceProp* props = get_device_prop();
978
+ STD_TORCH_CHECK(props->major == 10,
979
+ "nvfp4_ds_mla KV cache requires SM100 (Blackwell); "
980
+ "got SM",
981
+ props->major, props->minor);
982
+ vllm::launch_concat_and_cache_nvfp4_ds_mla(
983
+ kv_c.data_ptr(), k_pe.data_ptr(), kv_cache.data_ptr(),
984
+ slot_mapping.const_data_ptr<int64_t>(), block_stride, entry_stride,
985
+ kv_c_stride, k_pe_stride, block_size, num_tokens, stream);
986
+#else
987
+ STD_TORCH_CHECK(false,
988
+ "the nvfp4 ds_mla kv-cache format requires a CUDA build "
989
+ "targeting SM100 (Blackwell)");
990
+#endif
991
} else {
992
dim3 grid(num_tokens);
993
dim3 block(std::min(kv_lora_rank, 512));
@@ -1659,6 +1697,72 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1697
dst_entry_stride, seq_starts_ptr);
1698
}
1699
1700
+void cp_gather_and_upconvert_nvfp4_kv_cache(
1701
+ torch::stable::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE,
1702
+ // 352]
1703
+ torch::stable::Tensor const& dst, // [TOT_TOKENS, 576]
1704
+ torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
1705
+ torch::stable::Tensor const& workspace_starts, // [BATCH]
1706
+ int64_t batch_size) {
1707
+ // The kernel lives in nvfp4_ds_mla_cache_kernels.cu, which is only built when
1708
+ // the target list contains an SM100 arch.
1709
+#if !defined(USE_ROCM) && defined(ENABLE_NVFP4_SM100) && ENABLE_NVFP4_SM100
1710
+ torch::stable::accelerator::DeviceGuard device_guard(
1711
+ src_cache.get_device_index());
1712
+ const cudaStream_t stream = get_current_cuda_stream();
1713
+
1714
+ const cudaDeviceProp* props = get_device_prop();
1715
+ STD_TORCH_CHECK(props->major == 10,
1716
+ "nvfp4_ds_mla KV cache requires SM100 (Blackwell); "
1717
+ "got SM",
1718
+ props->major, props->minor);
1719
+
1720
+ int32_t block_size = src_cache.size(1);
1721
+ int32_t head_dim = dst.size(1);
1722
+
1723
+ STD_TORCH_CHECK(
1724
+ block_table.scalar_type() == torch::headeronly::ScalarType::Int,
1725
+ "block_table must be int32");
1726
+ STD_TORCH_CHECK(
1727
+ workspace_starts.scalar_type() == torch::headeronly::ScalarType::Int,
1728
+ "workspace_starts must be int32");
1729
+ STD_TORCH_CHECK(src_cache.device() == dst.device(),
1730
+ "src_cache and dst must be on the same device");
1731
+ STD_TORCH_CHECK(src_cache.device() == block_table.device(),
1732
+ "src_cache and block_table must be on the same device");
1733
+ STD_TORCH_CHECK(src_cache.device() == workspace_starts.device(),
1734
+ "src_cache and workspace_starts must be on the same device");
1735
+ STD_TORCH_CHECK(
1736
+ src_cache.scalar_type() == torch::headeronly::ScalarType::Byte,
1737
+ "src_cache must be uint8 for the nvfp4 ds_mla format, but got ",
1738
+ src_cache.scalar_type());
1739
+ STD_TORCH_CHECK(dst.scalar_type() == torch::headeronly::ScalarType::BFloat16,
1740
+ "dst must be bfloat16");
1741
+ STD_TORCH_CHECK(head_dim == 576, "head_dim must be 576 for MLA");
1742
+ STD_TORCH_CHECK(src_cache.size(2) == 352,
1743
+ "src_cache.size(2) must be 352 for the nvfp4 ds_mla format");
1744
+
1745
+ int64_t block_table_stride = block_table.stride(0);
1746
+ int64_t cache_block_stride = src_cache.stride(0);
1747
+ int64_t cache_entry_stride = src_cache.stride(1);
1748
+ int64_t dst_entry_stride = dst.stride(0);
1749
+
1750
+ const int total_tokens = dst.size(0);
1751
+
1752
+ vllm::launch_cp_gather_and_upconvert_nvfp4_kv_cache(
1753
+ src_cache.const_data_ptr<uint8_t>(), dst.data_ptr(),
1754
+ block_table.const_data_ptr<int32_t>(),
1755
+ workspace_starts.const_data_ptr<int32_t>(),
1756
+ static_cast<int32_t>(batch_size), block_size, total_tokens,
1757
+ block_table_stride, cache_block_stride, cache_entry_stride,
1758
+ dst_entry_stride, stream);
1759
+#else
1760
+ STD_TORCH_CHECK(false,
1761
+ "the nvfp4 ds_mla kv-cache format requires a CUDA build "
1762
+ "targeting SM100 (Blackwell)");
1763
+#endif
1764
+}
1765
+
1766
// Macro to dispatch the kernel based on the data type.
1767
#define CALL_INDEXER_K_QUANT_AND_CACHE(KV_T, CACHE_T, KV_DTYPE) \
1768
vllm::indexer_k_quant_and_cache_kernel<KV_T, CACHE_T, KV_DTYPE> \
csrc/libtorch_stable/nvfp4_ds_mla_cache.h
new
+37
@@ -0,0 +1,37 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+#pragma once
4
+
5
+#include <cuda_runtime_api.h>
6
+#include <cstdint>
7
+
8
+// Launchers for the nvfp4_ds_mla KV cache kernels. Those kernels live in
9
+// their own TU (nvfp4_ds_mla_cache_kernels.cu) so that only that TU is built
10
+// for the arch-conditional Blackwell targets their e2m1 conversions require;
11
+// these declarations are what keeps cache_kernels.cu generic.
12
+//
13
+// Only defined when the build has an SM100 target (-DENABLE_NVFP4_SM100=1);
14
+// callers must guard on the same macro.
15
+namespace vllm {
16
+
17
+// concat_and_cache_mla for the nvfp4_ds_mla layout.
18
+// kv_c: bf16 [num_tokens, 512]
19
+// k_pe: bf16 [num_tokens, 64]
20
+// kv_cache: uint8 [num_blocks, block_size, 352]
21
+void launch_concat_and_cache_nvfp4_ds_mla(const void* kv_c, const void* k_pe,
22
+ void* kv_cache,
23
+ const int64_t* slot_mapping,
24
+ int block_stride, int entry_stride,
25
+ int kv_c_stride, int k_pe_stride,
26
+ int block_size, int num_tokens,
27
+ cudaStream_t stream);
28
+
29
+// Gather an nvfp4_ds_mla cache into a bf16 [total_tokens, 576] workspace.
30
+void launch_cp_gather_and_upconvert_nvfp4_kv_cache(
31
+ const uint8_t* src_cache, void* dst, const int32_t* block_table,
32
+ const int32_t* workspace_starts, int32_t num_reqs, int32_t block_size,
33
+ int32_t total_tokens, int64_t block_table_stride,
34
+ int64_t cache_block_stride, int64_t cache_entry_stride,
35
+ int64_t dst_entry_stride, cudaStream_t stream);
36
+
37
+} // namespace vllm
csrc/libtorch_stable/nvfp4_ds_mla_cache_kernels.cu
new
+281
@@ -0,0 +1,281 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+// nvfp4_ds_mla KV cache writer and gather/upconvert reader.
5
+//
6
+// Separate TU because its e2m1 conversions are only accepted by ptxas for a
7
+// suffixed Blackwell target (sm_100a / sm_100f); only this file is compiled
8
+// with the suffixed arch list.
9
+
10
+#include "nvfp4_ds_mla_cache.h"
11
+
12
+#include "quantization/fp4/nvfp4_utils.cuh" // vllm::fp32_vec16_to_e2m1()
13
+
14
+#include <cuda_bf16.h>
15
+#include <cuda_fp8.h>
16
+
17
+#if defined(CUDART_VERSION) && CUDART_VERSION >= 12080
18
+ #include <cuda_fp4.h>
19
+ #define VLLM_HAS_CUDA_FP4 1
20
+#else
21
+ #define VLLM_HAS_CUDA_FP4 0
22
+#endif
23
+
24
+// __CUDA_ARCH_FAMILY_SPECIFIC__ is defined only for the suffixed targets. The
25
+// non-native paths trap rather than emulate; the host entry points reject this
26
+// format off SM100, so they are unreachable.
27
+#if VLLM_HAS_CUDA_FP4 && defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1000 && \
28
+ defined(__CUDA_ARCH_FAMILY_SPECIFIC__)
29
+ #define VLLM_NVFP4_NATIVE_CVT 1
30
+#else
31
+ #define VLLM_NVFP4_NATIVE_CVT 0
32
+#endif
33
+
34
+namespace vllm {
35
+
36
+// nvfp4_ds_mla cache format, 352 B/token:
37
+// [0, 256) 512 x e2m1 NoPE, packed 2/byte (low nibble = even element)
38
+// [256, 320) 64 x e4m3 RoPE (unscaled: e4m3's 4 exponent bits span the
39
+// RoPE range unaided)
40
+// [320, 352) 32 x e4m3 NoPE scale factors (one per 16 elements), permuted
41
+// Quantization: sf = e4m3(max(amax_16 / 6, 2^-9)), q = round(x / float(sf))
42
+// Dequantization: x = float(q) * float(sf)
43
+//
44
+// The scale-factor region is an 8x4 -> 4x8 transpose: element block s -- NoPE
45
+// dims [16s, 16s + 16) -- has its scale at byte kSfNopeOff + nvfp4_sf_byte(s),
46
+// and byte p holds the scale of element block 4 * (p & 7) + (p >> 3). Keep
47
+// nvfp4_sf_byte() in lockstep with its counterpart in FlashMLA
48
+// (csrc/sm100/decode/head64/config.h and tests/quant.py).
49
+constexpr int kSfNopeOff = 256 + 64;
50
+
51
+__host__ __device__ constexpr int nvfp4_sf_byte(int s) {
52
+ return 8 * (s & 3) + (s >> 2);
53
+}
54
+
55
+// Thread q's scale groups {4c + q} must land on the contiguous bytes 8q + c.
56
+// This also makes it a permutation of [0, 32), since both sides cover that
57
+// range exactly once.
58
+constexpr bool nvfp4_sf_perm_ok() {
59
+ for (int q = 0; q < 4; ++q) {
60
+ for (int c = 0; c < 8; ++c) {
61
+ if (nvfp4_sf_byte(4 * c + q) != 8 * q + c) {
62
+ return false;
63
+ }
64
+ }
65
+ }
66
+ return true;
67
+}
68
+static_assert(nvfp4_sf_perm_ok(),
69
+ "NVFP4 SF byte order must match FlashMLA's nvfp4_sf_byte()");
70
+
71
+// `cvt.rn.satfinite.e2m1x2.f32 d, a, b` puts a in d's HIGH nibble and b in its
72
+// LOW nibble, so the odd element of a pair is the first source.
73
+__device__ __forceinline__ uint64_t
74
+nvfp4_pack16_e2m1_rn(const float (&vals)[16], float inv_sf) {
75
+#if VLLM_NVFP4_NATIVE_CVT
76
+ float2 pairs[8];
77
+ #pragma unroll
78
+ for (int i = 0; i < 8; i++) {
79
+ pairs[i] = make_float2(vals[2 * i] * inv_sf, vals[2 * i + 1] * inv_sf);
80
+ }
81
+ const u32x2 packed = fp32_vec16_to_e2m1(pairs);
82
+ return (static_cast<uint64_t>(packed.hi) << 32) | packed.lo;
83
+#else
84
+ __trap();
85
+ return 0;
86
+#endif
87
+}
88
+
89
+// The e2m1 data and its e4m3 scale both convert to f16 and multiply there; the
90
+// product then goes f16 -> f32 -> bf16, as no f16 -> bf16 instruction exists.
91
+__device__ __forceinline__ void nvfp4_unpack16_e2m1(uint64_t raw,
92
+ uint8_t sf_byte,
93
+ __nv_bfloat16 (&out)[16]) {
94
+#if VLLM_NVFP4_NATIVE_CVT
95
+ const __half2_raw sf2_raw = __nv_cvt_fp8x2_to_halfraw2(
96
+ static_cast<__nv_fp8x2_storage_t>(sf_byte * 0x0101u), __NV_E4M3);
97
+ const __half2 sf2 = *reinterpret_cast<const __half2*>(&sf2_raw);
98
+ __nv_bfloat162* out2 = reinterpret_cast<__nv_bfloat162*>(out);
99
+ #pragma unroll
100
+ for (int i = 0; i < 8; i++) {
101
+ // low nibble -> .x, so .x = element 2i, matching the writer.
102
+ const __half2_raw v_raw = __nv_cvt_fp4x2_to_halfraw2(
103
+ static_cast<__nv_fp4x2_storage_t>(raw >> (8 * i)), __NV_E2M1);
104
+ const __half2 v = __hmul2(*reinterpret_cast<const __half2*>(&v_raw), sf2);
105
+ out2[i] = __float22bfloat162_rn(__half22float2(v));
106
+ }
107
+#else
108
+ __trap();
109
+#endif
110
+}
111
+
112
+// Rounded UP to the next e4m3 value so amax / sf never exceeds the payload
113
+// range: round-to-nearest can land up to 33% low near e4m3's 2^-9 floor, which
114
+// would saturate the tile's largest values.
115
+__device__ __forceinline__ float nvfp4_tile_scale(const float* vals,
116
+ float max_val,
117
+ uint8_t* sf_out) {
118
+ float amax = 0.0f;
119
+#pragma unroll
120
+ for (int i = 0; i < 16; i++) {
121
+ amax = fmaxf(amax, fabsf(vals[i]));
122
+ }
123
+ const float sf_f = fmaxf(amax / max_val, 0.001953125f); // 2^-9
124
+ __nv_fp8_e4m3 sf8(sf_f);
125
+ uint8_t sf_bits = *reinterpret_cast<const uint8_t*>(&sf8);
126
+ if (float(sf8) < sf_f && sf_bits < 0x7E) {
127
+ // Bump to the next e4m3 value (bit patterns of positive e4m3 values are
128
+ // monotonic; 0x7E = 448 is the max finite value).
129
+ sf_bits += 1;
130
+ sf8 = *reinterpret_cast<const __nv_fp8_e4m3*>(&sf_bits);
131
+ }
132
+ *sf_out = sf_bits;
133
+ return float(sf8);
134
+}
135
+
136
+// One CUDA block (64 threads) per token.
137
+// Warp 0: 32 threads, one 16-element NoPE tile each.
138
+// Warp 1: 32 threads, two RoPE elements each.
139
+__global__ void concat_and_cache_nvfp4_ds_mla_kernel(
140
+ const __nv_bfloat16* __restrict__ kv_c, // [num_tokens, 512]
141
+ const __nv_bfloat16* __restrict__ k_pe, // [num_tokens, 64]
142
+ uint8_t* __restrict__ kv_cache, // [num_blocks, block_size, 352]
143
+ const int64_t* __restrict__ slot_mapping, // [num_tokens]
144
+ const int block_stride, //
145
+ const int entry_stride, //
146
+ const int kv_c_stride, //
147
+ const int k_pe_stride, //
148
+ const int block_size) {
149
+ const int64_t token_idx = blockIdx.x;
150
+ const int64_t slot_idx = slot_mapping[token_idx];
151
+ // NOTE: slot_idx can be -1 if the token is padded
152
+ if (slot_idx < 0) {
153
+ return;
154
+ }
155
+ const int64_t block_idx = slot_idx / block_size;
156
+ const int64_t block_offset = slot_idx % block_size;
157
+ uint8_t* token_ptr =
158
+ kv_cache + block_idx * block_stride + block_offset * entry_stride;
159
+
160
+ if (threadIdx.x < 32) {
161
+ const int tile = threadIdx.x;
162
+ const __nv_bfloat16* src = kv_c + token_idx * kv_c_stride + tile * 16;
163
+ float vals[16];
164
+#pragma unroll
165
+ for (int i = 0; i < 16; i++) {
166
+ vals[i] = __bfloat162float(src[i]);
167
+ }
168
+ // `tile` is the element-block index; its scale byte is permuted.
169
+ const float sf = nvfp4_tile_scale(
170
+ vals, 6.0f, token_ptr + kSfNopeOff + nvfp4_sf_byte(tile));
171
+ const float inv_sf = 1.0f / sf;
172
+ *reinterpret_cast<uint64_t*>(token_ptr + tile * 8) =
173
+ nvfp4_pack16_e2m1_rn(vals, inv_sf);
174
+ } else {
175
+ // Plain e4m3, unscaled. All 32 lanes take 2 elements each, matching the
176
+ // reader and concat_and_cache_ds_mla_kernel.
177
+ const int lane = threadIdx.x - 32;
178
+ const __nv_bfloat16* src = k_pe + token_idx * k_pe_stride + lane * 2;
179
+ uint8_t q[2];
180
+#pragma unroll
181
+ for (int i = 0; i < 2; i++) {
182
+ const __nv_fp8_e4m3 v8(__bfloat162float(src[i]));
183
+ q[i] = *reinterpret_cast<const uint8_t*>(&v8);
184
+ }
185
+ *reinterpret_cast<uint16_t*>(token_ptr + 256 + lane * 2) =
186
+ *reinterpret_cast<const uint16_t*>(q);
187
+ }
188
+}
189
+
190
+// One warp per token (mirrors cp_gather_and_upconvert_fp8_kv_cache).
191
+__global__ void cp_gather_and_upconvert_nvfp4_kv_cache_kernel(
192
+ const uint8_t* __restrict__ src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 352]
193
+ __nv_bfloat16* __restrict__ dst, // [total_tokens, 576]
194
+ const int32_t* __restrict__ block_table, // [num_reqs, BLOCK_INDICES]
195
+ const int32_t* __restrict__ workspace_starts, // [num_reqs]
196
+ const int32_t num_reqs, const int32_t block_size,
197
+ const int32_t total_tokens, const int64_t block_table_stride,
198
+ const int64_t cache_block_stride, const int64_t cache_entry_stride,
199
+ const int64_t dst_entry_stride) {
200
+ const int flat_warp_id = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
201
+ if (flat_warp_id >= total_tokens) return;
202
+ const int lane_id = threadIdx.x & 31;
203
+
204
+ // Binary search to find which request owns this output token
205
+ int lo = 0, hi = num_reqs - 1;
206
+ while (lo < hi) {
207
+ int mid = (lo + hi + 1) >> 1;
208
+ if (workspace_starts[mid] <= flat_warp_id)
209
+ lo = mid;
210
+ else
211
+ hi = mid - 1;
212
+ }
213
+ const int req_id = lo;
214
+
215
+ const int out_token_id = flat_warp_id;
216
+ const int token_offset = out_token_id - workspace_starts[req_id];
217
+ const int cache_block_idx = token_offset / block_size;
218
+ const int offset_in_block = token_offset % block_size;
219
+ const int physical_block =
220
+ block_table[req_id * block_table_stride + cache_block_idx];
221
+
222
+ const uint8_t* token_ptr = src_cache + physical_block * cache_block_stride +
223
+ offset_in_block * cache_entry_stride;
224
+ __nv_bfloat16* dst_ptr = dst + out_token_id * dst_entry_stride;
225
+
226
+ // `lane_id` is the element-block index, so its scale byte is permuted.
227
+ {
228
+ const uint64_t raw =
229
+ *reinterpret_cast<const uint64_t*>(token_ptr + lane_id * 8);
230
+ const uint8_t sf_byte = token_ptr[kSfNopeOff + nvfp4_sf_byte(lane_id)];
231
+ __nv_bfloat16 out[16];
232
+ nvfp4_unpack16_e2m1(raw, sf_byte, out);
233
+ int4* nope_dst = reinterpret_cast<int4*>(dst_ptr) + lane_id * 2;
234
+ nope_dst[0] = *reinterpret_cast<const int4*>(&out[0]);
235
+ nope_dst[1] = *reinterpret_cast<const int4*>(&out[8]);
236
+ }
237
+
238
+ // RoPE is unscaled e4m3.
239
+ {
240
+ const __nv_fp8_e4m3* raw =
241
+ reinterpret_cast<const __nv_fp8_e4m3*>(token_ptr + 256 + lane_id * 2);
242
+ const __nv_bfloat16 out[2] = {__float2bfloat16_rn(float(raw[0])),
243
+ __float2bfloat16_rn(float(raw[1]))};
244
+ *reinterpret_cast<uint32_t*>(dst_ptr + 512 + lane_id * 2) =
245
+ *reinterpret_cast<const uint32_t*>(out);
246
+ }
247
+}
248
+
249
+void launch_concat_and_cache_nvfp4_ds_mla(const void* kv_c, const void* k_pe,
250
+ void* kv_cache,
251
+ const int64_t* slot_mapping,
252
+ int block_stride, int entry_stride,
253
+ int kv_c_stride, int k_pe_stride,
254
+ int block_size, int num_tokens,
255
+ cudaStream_t stream) {
256
+ dim3 grid(num_tokens);
257
+ dim3 block(64); // warp 0: 32 NoPE tiles; warp 1: 4 RoPE tiles
258
+ concat_and_cache_nvfp4_ds_mla_kernel<<<grid, block, 0, stream>>>(
259
+ reinterpret_cast<const __nv_bfloat16*>(kv_c),
260
+ reinterpret_cast<const __nv_bfloat16*>(k_pe),
261
+ reinterpret_cast<uint8_t*>(kv_cache), slot_mapping, block_stride,
262
+ entry_stride, kv_c_stride, k_pe_stride, block_size);
263
+}
264
+
265
+void launch_cp_gather_and_upconvert_nvfp4_kv_cache(
266
+ const uint8_t* src_cache, void* dst, const int32_t* block_table,
267
+ const int32_t* workspace_starts, int32_t num_reqs, int32_t block_size,
268
+ int32_t total_tokens, int64_t block_table_stride,
269
+ int64_t cache_block_stride, int64_t cache_entry_stride,
270
+ int64_t dst_entry_stride, cudaStream_t stream) {
271
+ constexpr int warps_per_block = 8;
272
+ const int grid_size = (total_tokens + warps_per_block - 1) / warps_per_block;
273
+ const int block_size_threads = warps_per_block * 32; // 256 threads
274
+ cp_gather_and_upconvert_nvfp4_kv_cache_kernel<<<grid_size, block_size_threads,
275
+ 0, stream>>>(
276
+ src_cache, reinterpret_cast<__nv_bfloat16*>(dst), block_table,
277
+ workspace_starts, num_reqs, block_size, total_tokens, block_table_stride,
278
+ cache_block_stride, cache_entry_stride, dst_entry_stride);
279
+}
280
+
281
+} // namespace vllm
csrc/libtorch_stable/ops.h
+9
@@ -672,6 +672,15 @@ void cp_gather_and_upconvert_fp8_kv_cache(
672
int64_t batch_size,
673
std::optional<torch::stable::Tensor> seq_starts = std::nullopt);
674
675
+// Gather and upconvert an nvfp4_ds_mla KV cache to a BF16 workspace
676
+void cp_gather_and_upconvert_nvfp4_kv_cache(
677
+ torch::stable::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE,
678
+ // 352]
679
+ torch::stable::Tensor const& dst, // [TOT_TOKENS, 576]
680
+ torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
681
+ torch::stable::Tensor const& workspace_starts, // [BATCH]
682
+ int64_t batch_size);
683
+
684
// Indexer K quantization and cache function
685
void indexer_k_quant_and_cache(
686
torch::stable::Tensor& k, // [num_tokens, head_dim]
csrc/libtorch_stable/torch_bindings.cpp
+6
@@ -996,6 +996,10 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C_cache_ops, ops) {
996
"Tensor block_table, Tensor workspace_starts, int batch_size, Tensor? "
997
"seq_starts) -> ()");
998
999
+ ops.def(
1000
+ "cp_gather_and_upconvert_nvfp4_kv_cache(Tensor src_cache, Tensor! dst, "
1001
+ "Tensor block_table, Tensor workspace_starts, int batch_size) -> ()");
1002
+
1003
ops.def(
1004
"indexer_k_quant_and_cache(Tensor k, Tensor! kv_cache, Tensor "
1005
"slot_mapping, "
@@ -1066,6 +1070,8 @@ STABLE_TORCH_LIBRARY_IMPL(_C_cache_ops, CUDA, ops) {
1070
ops.impl("cp_gather_cache", TORCH_BOX(&cp_gather_cache));
1071
ops.impl("cp_gather_and_upconvert_fp8_kv_cache",
1072
TORCH_BOX(&cp_gather_and_upconvert_fp8_kv_cache));
1073
+ ops.impl("cp_gather_and_upconvert_nvfp4_kv_cache",
1074
+ TORCH_BOX(&cp_gather_and_upconvert_nvfp4_kv_cache));
1075
ops.impl("indexer_k_quant_and_cache", TORCH_BOX(&indexer_k_quant_and_cache));
1076
ops.impl("concat_mla_q", TORCH_BOX(&concat_mla_q));
1077
ops.impl("cp_gather_indexer_k_quant_cache",
tests/kernels/attention/test_cache.py
+151
@@ -907,6 +907,157 @@ def test_concat_and_cache_ds_mla(
907
torch.testing.assert_close(kv_rope, ref_rope, atol=0.001, rtol=0.1)
908
909
910
+# Bytes per token for the nvfp4_ds_mla cache layout (see flashmla_sparse.py).
911
+NVFP4_DS_MLA_ENTRY_SIZE = 352
912
+
913
+# e2m1 magnitude table; the sign lives in bit 3 of each 4-bit code.
914
+E2M1_VALUES = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
915
+
916
+
917
+def _unpack_e2m1(packed: torch.Tensor) -> torch.Tensor:
918
+ """Unpack uint8 bytes holding two e2m1 codes each into float32 values.
919
+
920
+ The low nibble holds the even element, the high nibble the odd one.
921
+ """
922
+ low = packed & 0x0F
923
+ high = packed >> 4
924
+ codes = torch.stack([low, high], dim=-1).reshape(-1).long()
925
+ table = torch.tensor(E2M1_VALUES, dtype=torch.float32, device=packed.device)
926
+ magnitude = table[codes & 0x7]
927
+ sign = torch.where((codes & 0x8) != 0, -1.0, 1.0)
928
+ return sign * magnitude
929
+
930
+
931
+def _ref_nvfp4_sf_bytes(vals: torch.Tensor, divisor: float) -> torch.Tensor:
932
+ """Reference e4m3 scale-factor bytes: e4m3(max(amax_per_16 / divisor, 2^-9)).
933
+
934
+ In element-block order (byte s scales elements [16s, 16s + 16)).
935
+ """
936
+ tiles = vals.to(torch.float32).reshape(-1, 16)
937
+ sf = torch.clamp(tiles.abs().amax(dim=1) / divisor, min=2.0**-9)
938
+ return sf.to(torch.float8_e4m3fn).view(torch.uint8)
939
+
940
+
941
+def _nvfp4_sf_element_order(wire_sf_bytes: torch.Tensor) -> torch.Tensor:
942
+ """Stored NVFP4 scale-factor byte order -> element-block order.
943
+
944
+ The 32 NoPE scale-factor bytes of an nvfp4_ds_mla entry are stored
945
+ permuted (an 8x4 -> 4x8 transpose): the scale for element block s lives at
946
+ byte 8 * (s & 3) + (s >> 2), so that the 8 scales one FlashMLA dequant
947
+ thread needs are contiguous. This undoes that permutation.
948
+ """
949
+ num_sf = wire_sf_bytes.shape[-1]
950
+ return wire_sf_bytes.unflatten(-1, (4, num_sf // 4)).transpose(-1, -2).flatten(-2)
951
+
952
+
953
+@pytest.mark.parametrize("kv_lora_rank", KV_LORA_RANKS)
954
+@pytest.mark.parametrize("qk_rope_head_dim", QK_ROPE_HEAD_DIMS)
955
+@pytest.mark.parametrize("num_tokens", NUM_TOKENS_MLA)
956
+@pytest.mark.parametrize("block_size", BLOCK_SIZES_MLA)
957
+@pytest.mark.parametrize("num_blocks", NUM_BLOCKS_MLA)
958
+@pytest.mark.parametrize("seed", SEEDS)
959
+@pytest.mark.parametrize("device", CUDA_DEVICES)
960
+@torch.inference_mode()
961
+def test_concat_and_cache_nvfp4_ds_mla(
962
+ kv_lora_rank: int,
963
+ qk_rope_head_dim: int,
964
+ num_tokens: int,
965
+ block_size: int,
966
+ num_blocks: int,
967
+ seed: int,
968
+ device: str,
969
+) -> None:
970
+ if current_platform.is_rocm():
971
+ pytest.skip("concat_and_cache_mla doesn't support NVFP4 DS-MLA on ROCm")
972
+ device_capability = current_platform.get_device_capability()
973
+ if device_capability is None or device_capability.major != 10:
974
+ pytest.skip("The NVFP4 DS-MLA kv-cache dtype requires SM 10.x")
975
+ if kv_lora_rank != 512:
976
+ pytest.skip("The NVFP4 DS-MLA layout requires kv_lora_rank == 512")
977
+ dtype = torch.bfloat16
978
+ set_random_seed(seed)
979
+ torch.set_default_device(device)
980
+ torch.accelerator.set_device_index(device)
981
+
982
+ kv_cache_dtype = "nvfp4_ds_mla"
983
+ entry_size = NVFP4_DS_MLA_ENTRY_SIZE
984
+
985
+ total_slots = num_blocks * block_size
986
+ slot_mapping_lst = random.sample(range(total_slots), num_tokens)
987
+ slot_mapping = torch.tensor(slot_mapping_lst, dtype=torch.long, device=device)
988
+
989
+ kv_c = torch.randn(num_tokens, kv_lora_rank, dtype=dtype, device=device)
990
+ k_pe = torch.randn(num_tokens, qk_rope_head_dim, dtype=dtype, device=device)
991
+
992
+ scale = torch.tensor(1.0, dtype=torch.float32, device=device)
993
+ kv_cache = _create_mla_cache(
994
+ num_blocks,
995
+ block_size,
996
+ entry_size,
997
+ dtype=torch.uint8,
998
+ kv_cache_dtype=kv_cache_dtype,
999
+ device=device,
1000
+ )
1001
+
1002
+ opcheck(
1003
+ torch.ops._C_cache_ops.concat_and_cache_mla,
1004
+ (kv_c, k_pe, kv_cache, slot_mapping, kv_cache_dtype, scale),
1005
+ test_utils=DEFAULT_OPCHECK_TEST_UTILS,
1006
+ )
1007
+
1008
+ ops.concat_and_cache_mla(kv_c, k_pe, kv_cache, slot_mapping, kv_cache_dtype, scale)
1009
+
1010
+ # Byte layout of a single cache entry:
1011
+ # [0, 256) 512 e2m1 NoPE values packed 2/byte
1012
+ # [256, 320) 64 unscaled e4m3 RoPE values
1013
+ # [nope_sf_off, 352) 32 e4m3 NoPE SFs (one per 16 elements), stored
1014
+ # permuted (see _nvfp4_sf_element_order)
1015
+ nope_bytes = kv_lora_rank // 2
1016
+ rope_bytes = qk_rope_head_dim
1017
+ nope_sf_off = nope_bytes + rope_bytes
1018
+ num_nope_sf = kv_lora_rank // 16
1019
+ assert nope_sf_off + num_nope_sf == entry_size
1020
+
1021
+ for i in range(num_tokens):
1022
+ slot = slot_mapping[i].item()
1023
+ block_idx = slot // block_size
1024
+ block_offset = slot % block_size
1025
+ entry = kv_cache[block_idx, block_offset]
1026
+
1027
+ kv_c_ref = kv_c[i].to(torch.float32)
1028
+ k_pe_ref = k_pe[i].to(torch.float32)
1029
+
1030
+ # Scale-factor bytes must match e4m3(max(amax_per_16 / divisor, 2^-9))
1031
+ # computed in python, allowing a 1-ulp e4m3 difference (all SFs are
1032
+ # positive, so e4m3 bit patterns are monotonic and adjacent codes
1033
+ # differ by 1).
1034
+ nope_sf_bytes = _nvfp4_sf_element_order(
1035
+ entry[nope_sf_off : nope_sf_off + num_nope_sf]
1036
+ )
1037
+ ref_nope_sf_bytes = _ref_nvfp4_sf_bytes(kv_c_ref, 6.0)
1038
+ assert (
1039
+ (nope_sf_bytes.to(torch.int16) - ref_nope_sf_bytes.to(torch.int16)).abs()
1040
+ <= 1
1041
+ ).all()
1042
+
1043
+ # Dequantize with the *stored* scale factors: x = float(code) * float(sf).
1044
+ nope_sf = nope_sf_bytes.view(torch.float8_e4m3fn).to(torch.float32)
1045
+ nope_sf_per_elem = nope_sf.repeat_interleave(16)
1046
+ nope_vals = _unpack_e2m1(entry[:nope_bytes]) * nope_sf_per_elem
1047
+ # e2m1 error bound: the widest grid spacing is 2 (between codes 4 and
1048
+ # 6), so round-to-nearest is off by at most 1.0*sf; the e4m3 rounding
1049
+ # of the SF itself adds at most ~2^-4 relative, so 1.5*sf is safe for
1050
+ # either encode convention (quantized or unquantized SF).
1051
+ assert ((nope_vals - kv_c_ref).abs() <= 1.5 * nope_sf_per_elem).all()
1052
+
1053
+ # Plain unscaled e4m3: ~2^-4 relative rounding, with a small absolute
1054
+ # floor for values in the subnormal range.
1055
+ rope_payload = entry[nope_bytes:nope_sf_off]
1056
+ rope_vals = rope_payload.view(torch.float8_e4m3fn).to(torch.float32)
1057
+ rope_tol = k_pe_ref.abs() * 2.0**-3 + 2.0**-9
1058
+ assert ((rope_vals - k_pe_ref).abs() <= rope_tol).all()
1059
+
1060
+
1061
@pytest.mark.parametrize("kv_lora_rank", KV_LORA_RANKS)
1062
@pytest.mark.parametrize("qk_rope_head_dim", QK_ROPE_HEAD_DIMS)
1063
@pytest.mark.parametrize("block_size", BLOCK_SIZES_MLA)
tests/v1/attention/test_mla_backends.py
+12
-1
@@ -505,7 +505,10 @@ def create_and_prepopulate_kv_cache(
505
block_table = common_attn_metadata.block_table_tensor
506
slot_mapping = common_attn_metadata.slot_mapping
507
508
- fp8_attention = kv_cache_dtype and kv_cache_dtype.startswith("fp8")
508
+ use_nvfp4_ds_mla = kv_cache_dtype == "nvfp4_ds_mla"
509
+ fp8_attention = (
510
+ bool(kv_cache_dtype and kv_cache_dtype.startswith("fp8")) or use_nvfp4_ds_mla
511
+ )
512
use_fp8_ds_mla = kv_cache_dtype == "fp8_ds_mla"
513
514
if fp8_attention:
@@ -515,6 +518,14 @@ def create_and_prepopulate_kv_cache(
518
# 4 * 4: 4 float32 scale values for 128-element tiles
519
# 2 * rope_dim: 16-bit RoPE values
520
kv_entry_size = kv_lora_rank + 4 * 4 + 2 * rope_dim
521
+ elif use_nvfp4_ds_mla:
522
+ kv_lora_rank = kv_c_contexts[0].shape[-1]
523
+ rope_dim = k_pe_contexts[0].shape[-1]
524
+ # e2m1-packed NoPE + unscaled e4m3 rope + per-16 e4m3 NoPE SFs,
525
+ # padded to 16B
526
+ kv_entry_size = (
527
+ cdiv(kv_lora_rank // 2 + rope_dim + kv_lora_rank // 16, 16) * 16
528
+ )
529
else:
530
kv_entry_size = head_size
531
tests/v1/attention/test_sparse_mla_backends.py
+101
-1
@@ -40,6 +40,9 @@ if not current_platform.is_cuda():
40
allow_module_level=True,
41
)
42
43
+from vllm.model_executor.layers.attention.mla_attention import (
44
+ _canonicalize_sparse_mla_kv_cache_dtype,
45
+)
46
from vllm.utils.math_utils import cdiv
47
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
48
FlashInferMLASparseTRTLLMBackend,
@@ -184,13 +187,89 @@ def _quantize_dequantize_fp8_ds_mla(
187
return dequant_kv_c, dequant_k_pe
188
189
190
+_E2M1_TABLE = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
191
+
192
+
193
+def _quantize_dequantize_nvfp4_ds_mla(
194
+ kv_c: torch.Tensor,
195
+ k_pe: torch.Tensor,
196
+ block_size: int,
197
+ kv_cache_dtype: str,
198
+ scale: torch.Tensor,
199
+) -> tuple[torch.Tensor, torch.Tensor]:
200
+ """Round-trip kv_c/k_pe through the nvfp4_ds_mla cache layout.
201
+
202
+ Layout (per token, uint8): [e2m1-packed NoPE | unscaled e4m3 RoPE |
203
+ per-16 e4m3 NoPE SFs]. The SF bytes are stored permuted (an 8x4 -> 4x8
204
+ transpose: the scale for element block s lives at byte 8 * (s & 3) +
205
+ (s >> 2)) so that the 8 scales one FlashMLA dequant thread needs are
206
+ contiguous. Dequant: x = value * float(sf), which is exact in bf16, so
207
+ this python dequant matches the kernels bit-for-bit.
208
+ """
209
+ if kv_c.numel() == 0:
210
+ return kv_c.clone(), k_pe.clone()
211
+
212
+ kv_lora_rank = kv_c.shape[-1]
213
+ rope_dim = k_pe.shape[-1]
214
+ num_tokens = kv_c.shape[0]
215
+ num_blocks = max(1, math.ceil(num_tokens / block_size))
216
+ entry_size = (
217
+ math.ceil((kv_lora_rank // 2 + rope_dim + kv_lora_rank // 16) / 16) * 16
218
+ )
219
+ sf_nope_off = kv_lora_rank // 2 + rope_dim
220
+
221
+ tmp_cache = torch.zeros(
222
+ num_blocks, block_size, entry_size, dtype=torch.uint8, device=kv_c.device
223
+ )
224
+ slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_c.device)
225
+ ops.concat_and_cache_mla(
226
+ kv_c, k_pe, tmp_cache, slot_mapping, kv_cache_dtype=kv_cache_dtype, scale=scale
227
+ )
228
+
229
+ tokens = tmp_cache.view(-1, entry_size)[:num_tokens]
230
+ table = torch.tensor(
231
+ _E2M1_TABLE + [-v for v in _E2M1_TABLE],
232
+ dtype=torch.float32,
233
+ device=kv_c.device,
234
+ )
235
+
236
+ def unpack_e2m1(packed: torch.Tensor) -> torch.Tensor:
237
+ lo = (packed & 0xF).long()
238
+ hi = (packed >> 4).long()
239
+ return table[torch.stack([lo, hi], dim=-1).flatten(-2)]
240
+
241
+ nope_vals = unpack_e2m1(tokens[:, : kv_lora_rank // 2])
242
+ num_nope_sf = kv_lora_rank // 16
243
+ nope_sf = (
244
+ # undo the on-wire SF permutation -> element-block order
245
+ tokens[:, sf_nope_off : sf_nope_off + num_nope_sf]
246
+ .unflatten(-1, (4, num_nope_sf // 4))
247
+ .transpose(-1, -2)
248
+ .flatten(-2)
249
+ .view(torch.float8_e4m3fn)
250
+ .float()
251
+ )
252
+ dequant_kv_c = (
253
+ (nope_vals.unflatten(-1, (-1, 16)) * nope_sf.unsqueeze(-1)).flatten(-2)
254
+ ).to(kv_c.dtype)
255
+
256
+ # RoPE: plain e4m3, no scale factor
257
+ rope_raw = tokens[:, kv_lora_rank // 2 : sf_nope_off]
258
+ dequant_k_pe = rope_raw.view(torch.float8_e4m3fn).to(k_pe.dtype)
259
+
260
+ return dequant_kv_c, dequant_k_pe
261
+
262
+
263
@pytest.mark.parametrize(
264
"backend_cls",
265
[FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend],
266
ids=["FlashMLA", "FlashInferTRTLLM"],
267
)
268
@pytest.mark.parametrize("batch_name", list(SPARSE_BACKEND_BATCH_SPECS.keys()))
193
-@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8", "fp8_ds_mla"])
269
+@pytest.mark.parametrize(
270
+ "kv_cache_dtype",
271
+ ["auto", "fp8", "fp8_ds_mla", "nvfp4_ds_mla"],
272
+)
273
@pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
274
@pytest.mark.parametrize("block_size", [32, 64])
275
@pytest.mark.parametrize(("q_scale", "k_scale"), [(1.0, 1.0), (2.0, 3.0)])
@@ -219,6 +298,17 @@ def test_sparse_backend_decode_correctness(
298
"fp8_ds_mla kv-cache dtype"
299
)
300
301
+ if kv_cache_dtype == "nvfp4_ds_mla":
302
+ # Unlike "fp8" (an alias canonicalized to "fp8_ds_mla" above), the
303
+ # NVFP4 DS-MLA dtype must reach the backend unchanged.
304
+ assert (
305
+ _canonicalize_sparse_mla_kv_cache_dtype(backend_cls, kv_cache_dtype)
306
+ == kv_cache_dtype
307
+ )
308
+ device_capability = current_platform.get_device_capability()
309
+ if device_capability is None or device_capability.major != 10:
310
+ pytest.skip("The NVFP4 DS-MLA kv-cache dtype requires SM 10.x")
311
+
312
supported_block_sizes = backend_cls.get_supported_kernel_block_sizes()
313
if block_size not in supported_block_sizes:
314
pytest.skip(
@@ -238,6 +328,7 @@ def test_sparse_backend_decode_correctness(
328
329
batch_spec = SPARSE_BACKEND_BATCH_SPECS[batch_name]
330
use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla"
331
+ use_nvfp4_ds_mla_quantization = kv_cache_dtype == "nvfp4_ds_mla"
332
333
device = torch.device(DEVICE_TYPE)
334
dtype = torch.bfloat16
@@ -381,6 +472,15 @@ def test_sparse_backend_decode_correctness(
472
simulate_sm100_e8m0_scales=is_sm100,
473
)
474
k_pe_full = k_pe_squeezed.unsqueeze(1)
475
+ elif use_nvfp4_ds_mla_quantization:
476
+ kv_c_full, k_pe_squeezed = _quantize_dequantize_nvfp4_ds_mla(
477
+ kv_c_full,
478
+ k_pe_full.squeeze(1),
479
+ block_size=block_size,
480
+ kv_cache_dtype=kv_cache_dtype,
481
+ scale=kv_cache_scale,
482
+ )
483
+ k_pe_full = k_pe_squeezed.unsqueeze(1)
484
485
q_nope, q_pe = q_c.split([qk_nope_head_dim, qk_rope_head_dim], dim=-1)
486
ql_nope = torch.einsum("qnh,lnh->qnl", q_nope, W_UK)
vllm/_custom_ops.py
+25
@@ -3082,6 +3082,31 @@ def cp_gather_and_upconvert_fp8_kv_cache(
3082
)
3083
3084
3085
+def cp_gather_and_upconvert_nvfp4_kv_cache(
3086
+ src_cache: torch.Tensor,
3087
+ dst: torch.Tensor,
3088
+ block_table: torch.Tensor,
3089
+ workspace_starts: torch.Tensor,
3090
+ batch_size: int,
3091
+) -> None:
3092
+ """Gather and upconvert an nvfp4_ds_mla KV cache to a BF16 workspace.
3093
+
3094
+ Args:
3095
+ src_cache: NVFP4 KV cache [num_blocks, block_size, 352] (uint8)
3096
+ dst: BF16 output workspace [total_tokens, 576]
3097
+ block_table: Block indices [num_reqs, max_blocks]
3098
+ workspace_starts: Workspace start offsets [num_reqs]
3099
+ batch_size: Number of requests
3100
+ """
3101
+ torch.ops._C_cache_ops.cp_gather_and_upconvert_nvfp4_kv_cache(
3102
+ src_cache,
3103
+ dst,
3104
+ block_table,
3105
+ workspace_starts,
3106
+ batch_size,
3107
+ )
3108
+
3109
+
3110
def concat_mla_q(
3111
ql_nope: torch.Tensor,
3112
q_pe: torch.Tensor,
vllm/config/cache.py
+1
@@ -45,6 +45,7 @@ CacheDType = Literal[
45
"fp8_e5m2",
46
"fp8_inc",
47
"fp8_ds_mla",
48
+ "nvfp4_ds_mla",
49
"turboquant_k8v4",
50
"turboquant_4bit_nc",
51
"turboquant_k3v4_nc",
vllm/config/vllm.py
+5
-1
@@ -2821,14 +2821,18 @@ class VllmConfig:
2821
def validate_nvfp4_kv_cache_with_mla(self) -> "VllmConfig":
2822
if self.model_config is None:
2823
return self
2824
+ # The ds_mla layouts are MLA-only by construction; the plain nvfp4
2825
+ # layout (head_size//2 + head_size//16) does not apply to MLA.
2826
if (
2827
self.cache_config.cache_dtype.startswith("nvfp4")
2828
+ and not self.cache_config.cache_dtype.endswith("_ds_mla")
2829
and self.model_config.use_mla
2830
):
2831
raise ValueError(
2832
"nvfp4 KV cache is not supported with MLA (Multi-head Latent "
2833
"Attention) backends. Please use a different --kv-cache-dtype "
2831
- "(e.g., 'fp8' or 'auto') for MLA models such as DeepSeek."
2834
+ "(e.g., 'fp8', 'auto', or 'nvfp4_ds_mla' with a sparse MLA "
2835
+ "backend) for MLA models such as DeepSeek."
2836
)
2837
return self
2838
vllm/model_executor/layers/attention/mla_attention.py
+15
-4
@@ -352,6 +352,10 @@ def _canonicalize_sparse_mla_kv_cache_dtype(
352
) -> CacheDType:
353
backend_name = attn_backend.get_name()
354
if backend_name == "FLASHMLA_SPARSE" and is_quantized_kv_cache(kv_cache_dtype):
355
+ # The NVFP4 DS-MLA format is used as-is; any other quantized dtype
356
+ # (fp8, fp8_e4m3, ...) is served via the fp8_ds_mla format.
357
+ if kv_cache_dtype == "nvfp4_ds_mla":
358
+ return kv_cache_dtype
359
return "fp8_ds_mla"
360
if backend_name == "FLASHINFER_MLA_SPARSE_SM120" and kv_cache_dtype in (
361
"auto",
@@ -861,7 +865,11 @@ class MLAAttention(nn.Module, AttentionLayerBase):
865
k_c_normed = k_c_normed[:num_actual_toks, ...]
866
k_pe = k_pe[:num_actual_toks, ...]
867
864
- if fp8_attention and self.kv_cache_dtype != "fp8_ds_mla":
868
+ if fp8_attention and self.kv_cache_dtype not in (
869
+ # Opaque per-token byte formats stay as raw uint8
870
+ "fp8_ds_mla",
871
+ "nvfp4_ds_mla",
872
+ ):
873
kv_cache = kv_cache.view(current_platform.fp8_dtype())
874
875
assert (
@@ -1262,9 +1270,12 @@ class MLAAttention(nn.Module, AttentionLayerBase):
1270
dtype=kv_cache_dtype,
1271
cache_dtype_str=self.kv_cache_dtype,
1272
kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
1265
- # fp8_ds_mla: 656-byte custom layout (kv_lora_rank=512 +
1266
- # qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py.
1267
- state_content_bytes=656 if self.kv_cache_dtype == "fp8_ds_mla" else None,
1273
+ # ds_mla layouts pack NoPE + RoPE + scales into one opaque per-token
1274
+ # blob, so the size is not derivable from head_size.
1275
+ # See flashmla_sparse.py.
1276
+ state_content_bytes={"fp8_ds_mla": 656, "nvfp4_ds_mla": 352}.get(
1277
+ self.kv_cache_dtype
1278
+ ),
1279
)
1280
if self.sliding_window is not None:
1281
return SlidingWindowMLASpec(
vllm/utils/torch_utils.py
+1
@@ -45,6 +45,7 @@ STR_DTYPE_TO_TORCH_DTYPE = {
45
"fp8_per_token_head": torch.uint8,
46
"fp8_inc": torch.float8_e4m3fn,
47
"fp8_ds_mla": torch.uint8,
48
+ "nvfp4_ds_mla": torch.uint8,
49
"turboquant_k8v4": torch.uint8,
50
"turboquant_4bit_nc": torch.uint8,
51
"turboquant_k3v4_nc": torch.uint8,
vllm/v1/attention/backends/mla/flashmla_sparse.py
+66
-13
@@ -84,8 +84,28 @@ Bytes, structured as:
84
- **Last 8 bytes:** Scale factors, containing 7 `ue8m0` values + 1B pad.
85
The first `ue8m0` is the scale for the first 64 `float8_e4m3` values,
86
the second for the next 64, and so on.
87
+
88
+In the "nvfp4_ds_mla" format (SM100 only, DeepSeek V3.2 geometry), each
89
+token's KV cache is 352 Bytes, structured as:
90
+- **First 256 bytes:** 512 `e2m1` NoPE values packed 2/byte (low nibble =
91
+ even element).
92
+- **Next 64 bytes:** 64 `float8_e4m3` RoPE values. These carry no scale
93
+ factor: `e4m3`'s 4 exponent bits span the RoPE magnitude range unaided.
94
+- **Last 32 bytes:** 32 `float8_e4m3` NoPE scale factors, one per 16
95
+ elements, stored permuted (an 8x4 -> 4x8 transpose: the scale for element
96
+ block `s` lives at byte `8 * (s & 3) + (s >> 2)`) so that the 8 scales one
97
+ FlashMLA dequant thread needs are contiguous. See the layout comment in
98
+ `csrc/libtorch_stable/cache_kernels.cu`.
99
+
100
"""
101
102
+# Quantized DS-MLA cache formats served by the FP8/NVFP4 sparse decode kernel
103
+# path (as opposed to the plain bf16 cache). FlashMLA infers which of these the
104
+# cache holds from its bytes-per-token, so nothing else needs to be passed down.
105
+QUANTIZED_DS_MLA_CACHE_FORMATS: frozenset[str] = frozenset(
106
+ {"fp8_ds_mla", "nvfp4_ds_mla"}
107
+)
108
+
109
110
class FlashMLASparseBackend(AttentionBackend):
111
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
@@ -94,6 +114,7 @@ class FlashMLASparseBackend(AttentionBackend):
114
"bfloat16",
115
"fp8_ds_mla",
116
"fp8", # alias for fp8_ds_mla
117
+ "nvfp4_ds_mla", # NVFP4 NoPE + FP8 RoPE (SM100 only)
118
]
119
120
@staticmethod
@@ -129,6 +150,26 @@ class FlashMLASparseBackend(AttentionBackend):
150
def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
151
return capability.major in [9, 10]
152
153
+ @classmethod
154
+ def supports_combination(
155
+ cls,
156
+ head_size: int,
157
+ dtype: torch.dtype,
158
+ kv_cache_dtype: "CacheDType | None",
159
+ block_size: int | None,
160
+ use_mla: bool,
161
+ has_sink: bool,
162
+ use_sparse: bool,
163
+ use_mm_prefix: bool,
164
+ device_capability: DeviceCapability,
165
+ ) -> str | None:
166
+ if kv_cache_dtype == "nvfp4_ds_mla" and device_capability.major != 10:
167
+ return (
168
+ f"FLASHMLA_SPARSE only supports the {kv_cache_dtype} kv-cache "
169
+ "dtype on SM100 (Blackwell)"
170
+ )
171
+ return None
172
+
173
174
@dataclass
175
class FlashMLASparseMetadata(AttentionMetadata):
@@ -258,7 +299,9 @@ class FlashMLASparseMetadataBuilder(
299
FlashMLASparseImpl._compute_fp8_decode_padded_heads(self.num_heads)
300
)
301
261
- self.use_fp8_kv_cache = cache_config.cache_dtype == "fp8_ds_mla"
302
+ self.use_fp8_kv_cache = (
303
+ cache_config.cache_dtype in QUANTIZED_DS_MLA_CACHE_FORMATS
304
+ )
305
max_num_seqs = vllm_config.scheduler_config.max_num_seqs
306
# Shape: [max_num_seqs], all elements = topk_tokens (constant for full-CG)
307
self.topk_tokens_tensor = torch.full(
@@ -596,9 +639,10 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
639
)
640
q_concat_shape = (max_tokens, q_concat_heads, head_size)
641
if is_quantized_kv_cache(kv_cache_dtype):
599
- assert kv_cache_dtype == "fp8_ds_mla", (
600
- "FlashMLA Sparse Attention backend fp8 only supports "
601
- "fp8_ds_mla kv-cache dtype"
642
+ assert kv_cache_dtype in QUANTIZED_DS_MLA_CACHE_FORMATS, (
643
+ "FlashMLA Sparse Attention backend only supports the "
644
+ f"{sorted(QUANTIZED_DS_MLA_CACHE_FORMATS)} quantized kv-cache "
645
+ f"dtypes, got {kv_cache_dtype}"
646
)
647
648
if self.need_to_return_lse_for_decode and not is_quantized_kv_cache(
@@ -609,7 +653,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
653
"the bf16 sparse path is not supported under DCP."
654
)
655
612
- if kv_cache_dtype == "fp8_ds_mla":
656
+ if kv_cache_dtype in QUANTIZED_DS_MLA_CACHE_FORMATS:
657
# Reserve workspace during initialization
658
assert vllm_config is not None and vllm_config.model_config is not None
659
prefill_workspace_size = get_prefill_workspace_size(
@@ -749,13 +793,22 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
793
assert fp8_metadata.prefill is not None
794
for chunk in fp8_metadata.prefill.chunks:
795
chunk_workspace = self.prefill_bf16_workspace[: chunk.chunk_tot_seqlen]
752
- ops.cp_gather_and_upconvert_fp8_kv_cache(
753
- kv_c_and_k_pe_cache,
754
- chunk_workspace,
755
- chunk.block_table,
756
- chunk.workspace_starts,
757
- len(chunk.block_table),
758
- )
796
+ if self.kv_cache_dtype == "fp8_ds_mla":
797
+ ops.cp_gather_and_upconvert_fp8_kv_cache(
798
+ kv_c_and_k_pe_cache,
799
+ chunk_workspace,
800
+ chunk.block_table,
801
+ chunk.workspace_starts,
802
+ len(chunk.block_table),
803
+ )
804
+ else:
805
+ ops.cp_gather_and_upconvert_nvfp4_kv_cache(
806
+ kv_c_and_k_pe_cache.view(torch.uint8),
807
+ chunk_workspace,
808
+ chunk.block_table,
809
+ chunk.workspace_starts,
810
+ len(chunk.block_table),
811
+ )
812
813
chunk_q = q[chunk.tokens_slice]
814
chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice]
@@ -953,7 +1006,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1006
assert self.topk_indices_buffer is not None
1007
topk_indices = self.topk_indices_buffer[:num_actual_toks]
1008
956
- use_fp8_cache = self.kv_cache_dtype == "fp8_ds_mla"
1009
+ use_fp8_cache = self.kv_cache_dtype in QUANTIZED_DS_MLA_CACHE_FORMATS
1010
1011
lse: torch.Tensor | None = None
1012
vllm/v1/kv_cache_interface.py
+6
@@ -54,6 +54,7 @@ class KVQuantMode(IntEnum):
54
TURBOQUANT_4BIT_NC = 7
55
TURBOQUANT_K3V4_NC = 8
56
TURBOQUANT_3BIT_NC = 9
57
+ NVFP4_DS_MLA = 10 # opaque-bytes NVFP4 DS-MLA layouts (FlashMLA sparse)
58
59
@property
60
def is_per_token_head(self) -> bool:
@@ -88,6 +89,11 @@ def get_kv_quant_mode(kv_cache_dtype: str) -> KVQuantMode:
89
return KVQuantMode.INT8_PER_TOKEN_HEAD
90
if kv_cache_dtype == "fp8_per_token_head":
91
return KVQuantMode.FP8_PER_TOKEN_HEAD
92
+ # Must precede the ``nvfp4`` prefix test below, which would otherwise match.
93
+ if kv_cache_dtype == "nvfp4_ds_mla":
94
+ # Page size is keyed on cache_dtype_str in the MLA specs, not
95
+ # nvfp4_kv_cache_full_dim.
96
+ return KVQuantMode.NVFP4_DS_MLA
97
if kv_cache_dtype.startswith("nvfp4"):
98
return KVQuantMode.NVFP4
99
if isinstance(kv_cache_dtype, str) and kv_cache_dtype.startswith("turboquant_"):