[Attention][MLA] Fuse Kimi-K3 chunked-context K/V packing (#51772)

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Yongye Zhu committed Aug 12, 2026 at 22:42 UTC 903d2efe7eb6d045d03c0a8192b1ac7bec50d168
15 files changed +996 -35
csrc/libtorch_stable/fused_kimi_k3_mla_key_concat_kv_cache_kernel.cu
+275 -6
@@ -25,6 +25,14 @@
25 * matching MLA's _q_scale / _k_scale / _v_scale (the cache latent uses a
26 * separate cache scale). Per-tensor E4M3.
27 *
28 + * K/V pack (fused_kimi_k3_mla_kv_concat{,_quant_fp8}):
29 + * - k_out[t, h] = [k_nope[t, h] | k_pe[t]], cast to fp8 by the _quant_fp8
30 + * variant, which also writes v_fp8[t, h] = cast(v[t, h])
31 + * The chunked-context epilogue: no cache insert, no q, no RoPE (the cached
32 + * k_pe is already rotated) and no scaling. `k_nope`/`v` are strided views
33 + * of one kv_b_proj output and `k_pe` may already be fp8 (a plain fp8 cache is
34 + * gathered without dequantizing); the outputs are contiguous.
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
@@ -183,6 +191,36 @@ __device__ __forceinline__ void copyChunk8(void* dst, const scalar_t* src,
191 }
192 }
193
194 +// Cast 8 source elements (one uint4 of bf16/fp16) to E4M3 with no scaling,
195 +// using the native pairwise converters so the result is bit-identical to
196 +// `src.to(torch.float8_e4m3fn)`.
197 +template <typename scalar_t>
198 +__device__ __forceinline__ void copyChunk8UnitFp8(uint8_t* dst,
199 + const scalar_t* src) {
200 +#ifndef USE_ROCM
201 + uint4 const input = *reinterpret_cast<const uint4*>(src);
202 + using Converter = vllm::_typeConvert<scalar_t>;
203 + auto const* input2 =
204 + reinterpret_cast<typename Converter::packed_hip_type const*>(&input);
205 + uint2 output;
206 + auto* output2 = reinterpret_cast<__nv_fp8x2_storage_t*>(&output);
207 + #pragma unroll
208 + for (int i = 0; i < 4; ++i) {
209 + if constexpr (std::is_same_v<scalar_t, c10::BFloat16>) {
210 + output2[i] = __nv_cvt_bfloat16raw2_to_fp8x2(
211 + static_cast<__nv_bfloat162_raw>(input2[i]), __NV_SATFINITE,
212 + __NV_E4M3);
213 + } else {
214 + output2[i] = __nv_cvt_halfraw2_to_fp8x2(
215 + static_cast<__half2_raw>(input2[i]), __NV_SATFINITE, __NV_E4M3);
216 + }
217 + }
218 + *reinterpret_cast<uint2*>(dst) = output;
219 +#else
220 + copyChunk8<scalar_t, true>(dst, src, 1.0f);
221 +#endif
222 +}
223 +
224 // Concat + store one head's full key: dst[e] = [k_nope | k_pe], e in [0, 192).
225 // FP8 dst is byte-addressed; bf16 dst is scalar_t-addressed (dst_elem_size).
226 template <typename scalar_t, bool FP8, bool APPLY_ROPE = false>
@@ -202,6 +240,38 @@ __device__ __forceinline__ void writeFullKey(void* dst, const scalar_t* k_nope,
240 }
241 }
242
243 +// Unscaled full-key writer for the chunked-context pack, driven by a half warp
244 +// (16 lanes x 8 elems covers NoPE 128; 8 of them cover RoPE 64). Unlike
245 +// `writeFullKey` there is no scale and no RoPE (a gathered k_pe is already
246 +// rotated), and KPE_FP8 handles a k_pe that the gather left in the fp8 cache
247 +// layout -- so neither dtype needs an intermediate cast or a broadcast copy.
248 +template <typename scalar_t, bool FP8, bool KPE_FP8>
249 +__device__ __forceinline__ void writeFullKeyPack(void* dst,
250 + const scalar_t* k_nope,
251 + const void* k_pe, int laneId) {
252 + static_assert(!KPE_FP8 || FP8, "an fp8 k_pe requires an fp8 key output");
253 + constexpr int kElemSize = FP8 ? 1 : sizeof(scalar_t);
254 + auto* d = reinterpret_cast<uint8_t*>(dst);
255 + for (int e = laneId * kVecElems; e < kQkHeadDim; e += 16 * kVecElems) {
256 + void* out = d + e * kElemSize;
257 + const scalar_t* src = k_nope + e;
258 + if (e >= kQkNopeHeadDim) {
259 + int const rope_e = e - kQkNopeHeadDim;
260 + if constexpr (KPE_FP8) {
261 + *reinterpret_cast<uint2*>(out) = *reinterpret_cast<const uint2*>(
262 + reinterpret_cast<const uint8_t*>(k_pe) + rope_e);
263 + continue;
264 + }
265 + src = reinterpret_cast<const scalar_t*>(k_pe) + rope_e;
266 + }
267 + if constexpr (FP8) {
268 + copyChunk8UnitFp8(reinterpret_cast<uint8_t*>(out), src);
269 + } else {
270 + *reinterpret_cast<uint4*>(out) = *reinterpret_cast<const uint4*>(src);
271 + }
272 + }
273 +}
274 +
275 // Store a prefill query, rotating only q[..., 128:192]. For bf16 dst may alias
276 // q (in-place); fp8 writes the quantized query directly to its output.
277 template <typename scalar_t, bool FP8, bool APPLY_ROPE = false>
@@ -457,6 +527,72 @@ __global__ void fusedKimiK3MLAQKVQuantKVCacheFp8Kernel(
527 #endif
528 }
529
530 +// ────────────────────────────────────────────────────────────────────────────
531 +// K/V pack variant (chunked context): concatenate the strided kv_b_proj output
532 +// with k_pe into a contiguous row-major key, casting to E4M3 (and casting V
533 +// into its own contiguous output) when FP8. Unlike the other kernels this one
534 +// has no per-token cache slot, so a warp handles two (token, head) rows -- 16
535 +// lanes each -- and the grid is capped so a long context becomes a grid-stride
536 +// loop instead of a huge launch.
537 +// ────────────────────────────────────────────────────────────────────────────
538 +template <typename scalar_t, bool FP8, bool KPE_FP8>
539 +__global__ void fusedKimiK3MLAKVConcatPackKernel(
540 + const scalar_t* __restrict__ k_nope, int64_t const kn_tok_stride,
541 + int64_t const kn_head_stride, const void* __restrict__ k_pe,
542 + int64_t const k_pe_tok_stride, const scalar_t* __restrict__ v,
543 + int64_t const v_tok_stride, int64_t const v_head_stride,
544 + void* __restrict__ k_out, int64_t const ko_tok_stride,
545 + int64_t const ko_head_stride, uint8_t* __restrict__ v_fp8,
546 + int64_t const vo_tok_stride, int64_t const vo_head_stride,
547 + int const num_tokens, int const num_heads) {
548 + int const warpsPerBlock = blockDim.x / 32;
549 + int const laneId = threadIdx.x % 16;
550 + int const rowInWarp = (threadIdx.x % 32) / 16;
551 + int64_t const globalWarpIdx =
552 + static_cast<int64_t>(blockIdx.x) * warpsPerBlock + threadIdx.x / 32;
553 + int64_t const totalRows = static_cast<int64_t>(num_tokens) * num_heads;
554 + if (globalWarpIdx * 2 >= totalRows) return;
555 +
556 +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
557 + cudaGridDependencySynchronize();
558 +#endif
559 +
560 + constexpr int kOutElemSize = FP8 ? 1 : sizeof(scalar_t);
561 + int64_t const gridStride =
562 + static_cast<int64_t>(gridDim.x) * warpsPerBlock * 2;
563 + for (int64_t row = globalWarpIdx * 2 + rowInWarp; row < totalRows;
564 + row += gridStride) {
565 + int const tokenIdx = static_cast<int>(row / num_heads);
566 + int const headIdx = static_cast<int>(row % num_heads);
567 + const void* pe;
568 + if constexpr (KPE_FP8) {
569 + pe = reinterpret_cast<const uint8_t*>(k_pe) + tokenIdx * k_pe_tok_stride;
570 + } else {
571 + pe = reinterpret_cast<const scalar_t*>(k_pe) + tokenIdx * k_pe_tok_stride;
572 + }
573 + writeFullKeyPack<scalar_t, FP8, KPE_FP8>(
574 + reinterpret_cast<uint8_t*>(k_out) +
575 + (tokenIdx * ko_tok_stride + headIdx * ko_head_stride) *
576 + kOutElemSize,
577 + k_nope + tokenIdx * kn_tok_stride + headIdx * kn_head_stride, pe,
578 + laneId);
579 +
580 + // bf16 V needs no cast, so it stays a strided view of the kv_b_proj output.
581 + if constexpr (FP8) {
582 + const scalar_t* vh =
583 + v + tokenIdx * v_tok_stride + headIdx * v_head_stride;
584 + uint8_t* vo = v_fp8 + tokenIdx * vo_tok_stride + headIdx * vo_head_stride;
585 + for (int e = laneId * kVecElems; e < kVHeadDim; e += 16 * kVecElems) {
586 + copyChunk8UnitFp8(vo + e, vh + e);
587 + }
588 + }
589 + }
590 +
591 +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
592 + cudaTriggerProgrammaticLaunchCompletion();
593 +#endif
594 +}
595 +
596 // ────────────────────────────────────────────────────────────────────────────
597 // ds_mla variant: concat full key (bf16) + fp8_ds_mla latent cache insert
598 //
@@ -648,16 +784,20 @@ __global__ void fusedKimiK3MLADecodeQConcatDsMlaKernel(
784 #endif
785 }
786
651 -// PDL-aware launch of a (token, num_heads + 1)-warp grid.
787 +// PDL-aware launch of a (token, slots_per_token)-row grid, `rows_per_warp` rows
788 +// to a warp. `max_blocks > 0` caps the grid, leaving the kernel's grid-stride
789 +// loop to cover the remaining rows.
790 template <typename KernelT, typename... Args>
653 -static void launchPdl(KernelT kernel, int num_tokens, int num_heads,
654 - cudaStream_t stream, Args... args) {
791 +static void launchPdlSlots(KernelT kernel, int num_tokens, int slots_per_token,
792 + int rows_per_warp, int max_blocks,
793 + cudaStream_t stream, Args... args) {
794 constexpr int kBlockSize = 256;
795 constexpr int kWarpsPerBlock = kBlockSize / 32;
657 - int64_t const total_warps =
658 - static_cast<int64_t>(num_tokens) * (num_heads + 1);
659 - int const grid =
796 + int64_t const total_rows = static_cast<int64_t>(num_tokens) * slots_per_token;
797 + int64_t const total_warps = (total_rows + rows_per_warp - 1) / rows_per_warp;
798 + int grid =
799 static_cast<int>((total_warps + kWarpsPerBlock - 1) / kWarpsPerBlock);
800 + if (max_blocks > 0 && grid > max_blocks) grid = max_blocks;
801 #ifndef USE_ROCM
802 static int const sm_version = getSMVersion();
803 cudaLaunchConfig_t config;
@@ -681,6 +821,13 @@ static void launchPdl(KernelT kernel, int num_tokens, int num_heads,
821 #endif
822 }
823
824 +// Cache-inserting kernels use one extra warp per token for the cache slot.
825 +template <typename KernelT, typename... Args>
826 +static void launchPdl(KernelT kernel, int num_tokens, int num_heads,
827 + cudaStream_t stream, Args... args) {
828 + launchPdlSlots(kernel, num_tokens, num_heads + 1, 1, 0, stream, args...);
829 +}
830 +
831 void checkBfloat16Support(torch::headeronly::ScalarType dtype) {
832 #ifndef USE_ROCM
833 if (dtype == torch::headeronly::ScalarType::BFloat16) {
@@ -913,6 +1060,128 @@ void fused_kimi_k3_mla_key_concat_ds_mla_insert(
1060 });
1061 }
1062
1063 +namespace {
1064 +// Shared checks for the chunked-context K/V pack ops. `k_nope` / `v` are
1065 +// strided views of one kv_b_proj output and `k_pe` is a strided view of the
1066 +// gathered context workspace, so only a unit last-dim stride (not full
1067 +// contiguity) is required of the inputs. Returns true when `k_pe` is still in
1068 +// the fp8 cache layout.
1069 +bool check_kv_concat_inputs(torch::stable::Tensor const& k_nope,
1070 + torch::stable::Tensor const& k_pe,
1071 + torch::stable::Tensor const& k_out,
1072 + torch::headeronly::ScalarType out_dtype) {
1073 + using torch::headeronly::ScalarType;
1074 + ScalarType const dt = k_nope.scalar_type();
1075 + STD_TORCH_CHECK(k_nope.device().is_cuda() && k_nope.dim() == 3 &&
1076 + k_nope.size(2) == 128 && k_nope.stride(2) == 1,
1077 + "k_nope must be CUDA [T, H, 128] with unit last stride");
1078 + bool const k_pe_is_fp8 = k_pe.scalar_type() == ScalarType::Float8_e4m3fn;
1079 + STD_TORCH_CHECK(
1080 + k_pe.device() == k_nope.device() && k_pe.dim() == 2 &&
1081 + k_pe.size(1) == 64 && k_pe.stride(1) == 1 &&
1082 + (k_pe.scalar_type() == dt ||
1083 + (k_pe_is_fp8 && out_dtype == ScalarType::Float8_e4m3fn)),
1084 + "k_pe must be CUDA [T, 64] with unit last-dim stride and use the input "
1085 + "dtype, or float8_e4m3fn when the key output is fp8");
1086 + STD_TORCH_CHECK(k_out.device() == k_nope.device() && k_out.is_contiguous() &&
1087 + k_out.scalar_type() == out_dtype && k_out.dim() == 3 &&
1088 + k_out.size(2) == 192 && k_out.size(0) == k_nope.size(0) &&
1089 + k_out.size(1) == k_nope.size(1) &&
1090 + k_pe.size(0) == k_nope.size(0),
1091 + "k_out must be a contiguous CUDA [T, H, 192] tensor of the "
1092 + "output dtype matching k_nope's token and head dimensions");
1093 + vllm::kimi_k3_fused_ops::checkBfloat16Support(dt);
1094 + return k_pe_is_fp8;
1095 +}
1096 +} // namespace
1097 +
1098 +void fused_kimi_k3_mla_kv_concat(
1099 + torch::stable::Tensor const& k_nope, // [T, H, 128] bf16/fp16
1100 + torch::stable::Tensor const& k_pe, // [T, 64] same dtype as k_nope
1101 + torch::stable::Tensor& k_out) { // [T, H, 192] same dtype, written
1102 + namespace kk3 = vllm::kimi_k3_fused_ops;
1103 + torch::headeronly::ScalarType const dt = k_nope.scalar_type();
1104 + check_kv_concat_inputs(k_nope, k_pe, k_out, dt);
1105 +
1106 + int const num_tokens = static_cast<int>(k_nope.size(0));
1107 + int const num_heads = static_cast<int>(k_nope.size(1));
1108 + if (num_tokens == 0) return;
1109 +
1110 + const torch::stable::accelerator::DeviceGuard device_guard(
1111 + k_nope.get_device_index());
1112 + const cudaStream_t stream =
1113 + get_current_cuda_stream(k_nope.get_device_index());
1114 +
1115 + VLLM_STABLE_DISPATCH_HALF_TYPES(dt, "fused_kimi_k3_mla_kv_concat", [&] {
1116 + kk3::launchPdlSlots(
1117 + kk3::fusedKimiK3MLAKVConcatPackKernel<scalar_t, false, false>,
1118 + num_tokens, num_heads, 2, get_device_prop()->multiProcessorCount * 8,
1119 + stream, reinterpret_cast<scalar_t const*>(k_nope.const_data_ptr()),
1120 + k_nope.stride(0), k_nope.stride(1), k_pe.const_data_ptr(),
1121 + k_pe.stride(0), static_cast<scalar_t const*>(nullptr), int64_t{0},
1122 + int64_t{0}, k_out.mutable_data_ptr(), k_out.stride(0), k_out.stride(1),
1123 + static_cast<uint8_t*>(nullptr), int64_t{0}, int64_t{0}, num_tokens,
1124 + num_heads);
1125 + });
1126 +}
1127 +
1128 +void fused_kimi_k3_mla_kv_concat_quant_fp8(
1129 + torch::stable::Tensor const& k_nope, // [T, H, 128] bf16/fp16
1130 + torch::stable::Tensor const& k_pe, // [T, 64] bf16/fp16/fp8
1131 + torch::stable::Tensor const& v, // [T, H, 128] bf16/fp16
1132 + torch::stable::Tensor& k_fp8, // [T, H, 192] fp8, written
1133 + torch::stable::Tensor& v_fp8) { // [T, H, 128] fp8, written
1134 + using torch::headeronly::ScalarType;
1135 + namespace kk3 = vllm::kimi_k3_fused_ops;
1136 + ScalarType const dt = k_nope.scalar_type();
1137 + bool const k_pe_is_fp8 =
1138 + check_kv_concat_inputs(k_nope, k_pe, k_fp8, ScalarType::Float8_e4m3fn);
1139 + STD_TORCH_CHECK(v.device() == k_nope.device() && v.scalar_type() == dt &&
1140 + v.dim() == 3 && v.size(2) == 128 && v.stride(2) == 1 &&
1141 + v.size(0) == k_nope.size(0) &&
1142 + v.size(1) == k_nope.size(1),
1143 + "v must match k_nope's dtype and token/head dimensions and "
1144 + "have shape [T, H, 128] with unit last stride");
1145 + STD_TORCH_CHECK(v_fp8.device() == k_nope.device() && v_fp8.is_contiguous() &&
1146 + v_fp8.scalar_type() == ScalarType::Float8_e4m3fn &&
1147 + v_fp8.dim() == 3 && v_fp8.size(2) == 128 &&
1148 + v_fp8.size(0) == k_nope.size(0) &&
1149 + v_fp8.size(1) == k_nope.size(1),
1150 + "v_fp8 must be a contiguous CUDA [T, H, 128] fp8 tensor "
1151 + "matching k_nope's token and head dimensions");
1152 +
1153 + int const num_tokens = static_cast<int>(k_nope.size(0));
1154 + int const num_heads = static_cast<int>(k_nope.size(1));
1155 + if (num_tokens == 0) return;
1156 +
1157 + const torch::stable::accelerator::DeviceGuard device_guard(
1158 + k_nope.get_device_index());
1159 + const cudaStream_t stream =
1160 + get_current_cuda_stream(k_nope.get_device_index());
1161 +
1162 + VLLM_STABLE_DISPATCH_HALF_TYPES(
1163 + dt, "fused_kimi_k3_mla_kv_concat_quant_fp8", [&] {
1164 + auto launch = [&](auto kernel) {
1165 + kk3::launchPdlSlots(
1166 + kernel, num_tokens, num_heads, 2,
1167 + get_device_prop()->multiProcessorCount * 8, stream,
1168 + reinterpret_cast<scalar_t const*>(k_nope.const_data_ptr()),
1169 + k_nope.stride(0), k_nope.stride(1), k_pe.const_data_ptr(),
1170 + k_pe.stride(0),
1171 + reinterpret_cast<scalar_t const*>(v.const_data_ptr()),
1172 + v.stride(0), v.stride(1), k_fp8.mutable_data_ptr(),
1173 + k_fp8.stride(0), k_fp8.stride(1),
1174 + reinterpret_cast<uint8_t*>(v_fp8.mutable_data_ptr()),
1175 + v_fp8.stride(0), v_fp8.stride(1), num_tokens, num_heads);
1176 + };
1177 + if (k_pe_is_fp8) {
1178 + launch(kk3::fusedKimiK3MLAKVConcatPackKernel<scalar_t, true, true>);
1179 + } else {
1180 + launch(kk3::fusedKimiK3MLAKVConcatPackKernel<scalar_t, true, false>);
1181 + }
1182 + });
1183 +}
1184 +
1185 void fused_kimi_k3_mla_qkv_quant_kv_cache_fp8_insert(
1186 torch::stable::Tensor const& q, // [Tp, H, 192] bf16
1187 torch::stable::Tensor const& k_nope, // [Tp, H, 128] bf16
csrc/libtorch_stable/ops.h
+10
@@ -300,6 +300,16 @@ void fused_kimi_k3_mla_key_concat_ds_mla_insert(
300 std::optional<torch::stable::Tensor> position_ids,
301 std::optional<torch::stable::Tensor> cos_sin_cache);
302
303 +void fused_kimi_k3_mla_kv_concat(torch::stable::Tensor const& k_nope,
304 + torch::stable::Tensor const& k_pe,
305 + torch::stable::Tensor& k_out);
306 +
307 +void fused_kimi_k3_mla_kv_concat_quant_fp8(torch::stable::Tensor const& k_nope,
308 + torch::stable::Tensor const& k_pe,
309 + torch::stable::Tensor const& v,
310 + torch::stable::Tensor& k_fp8,
311 + torch::stable::Tensor& v_fp8);
312 +
313 void fused_kimi_k3_mla_qkv_quant_kv_cache_fp8_insert(
314 torch::stable::Tensor const& q, torch::stable::Tensor const& k_nope,
315 torch::stable::Tensor const& k_pe, torch::stable::Tensor const& kv_c_normed,
csrc/libtorch_stable/torch_bindings.cpp
+11
@@ -466,6 +466,13 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
466 "Tensor! k_out, Tensor! k_cache, Tensor slot_mapping, "
467 "int cache_block_size, Tensor? position_ids=None, "
468 "Tensor? cos_sin_cache=None) -> ()");
469 + ops.def(
470 + "fused_kimi_k3_mla_kv_concat(Tensor k_nope, Tensor k_pe, Tensor! k_out) "
471 + "-> ()");
472 + ops.def(
473 + "fused_kimi_k3_mla_kv_concat_quant_fp8("
474 + "Tensor k_nope, Tensor k_pe, Tensor v, Tensor! k_fp8, Tensor! v_fp8) "
475 + "-> ()");
476 ops.def(
477 "fused_kimi_k3_mla_qkv_quant_kv_cache_fp8_insert("
478 "Tensor q, Tensor k_nope, Tensor k_pe, Tensor kv_c_normed, Tensor v, "
@@ -769,6 +776,10 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
776 TORCH_BOX(&fused_kimi_k3_mla_key_concat_kv_cache_insert));
777 ops.impl("fused_kimi_k3_mla_key_concat_ds_mla_insert",
778 TORCH_BOX(&fused_kimi_k3_mla_key_concat_ds_mla_insert));
779 + ops.impl("fused_kimi_k3_mla_kv_concat",
780 + TORCH_BOX(&fused_kimi_k3_mla_kv_concat));
781 + ops.impl("fused_kimi_k3_mla_kv_concat_quant_fp8",
782 + TORCH_BOX(&fused_kimi_k3_mla_kv_concat_quant_fp8));
783 ops.impl("fused_kimi_k3_mla_qkv_quant_kv_cache_fp8_insert",
784 TORCH_BOX(&fused_kimi_k3_mla_qkv_quant_kv_cache_fp8_insert));
785 ops.impl("fused_kimi_k3_mla_decode_q_concat_kv_cache_insert",
tests/kernels/attention/test_kimi_k3_mla_fused_epilogue.py
+68 -2
@@ -9,6 +9,8 @@ from vllm.models.kimi_k3.nvidia.ops.fused_mla_key_concat_kv_cache import (
9 fused_mla_decode_q_concat_kv_cache_insert,
10 fused_mla_key_concat_ds_mla_insert,
11 fused_mla_key_concat_kv_cache_insert,
12 + fused_mla_kv_concat,
13 + fused_mla_kv_concat_quant_fp8,
14 fused_mla_qkv_quant_kv_cache_fp8_insert,
15 )
16 from vllm.platforms import current_platform
@@ -25,8 +27,8 @@ _POSITIONS = (1, 7, 13)
27 _SLOTS = (0, 3, 9)
28
29
28 -def _randn(*shape: int) -> torch.Tensor:
29 - return torch.randn(*shape, device="cuda", dtype=_DTYPE) * 0.2
30 +def _randn(*shape: int, dtype: torch.dtype = _DTYPE) -> torch.Tensor:
31 + return torch.randn(*shape, device="cuda", dtype=dtype) * 0.2
32
33
34 def _rope_cache(max_position: int = 32) -> torch.Tensor:
@@ -66,6 +68,70 @@ def _assert_fp8_close(actual: torch.Tensor, expected: torch.Tensor) -> None:
68 )
69
70
71 +def _strided_context_inputs(
72 + num_tokens: int, num_heads: int, dtype: torch.dtype, k_pe_fp8: bool
73 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
74 + """The real prefill-context layouts: ``k_nope``/``v`` as the two strided
75 + halves of one kv_b_proj output and ``k_pe`` as a column slice of the gather
76 + workspace (left in the fp8 cache layout for a plain fp8 cache)."""
77 + kv_nope = _randn(num_tokens, num_heads, 256, dtype=dtype)
78 + k_nope, v = kv_nope.split((128, 128), dim=-1)
79 + workspace = _randn(num_tokens, 576, dtype=dtype)
80 + if k_pe_fp8:
81 + workspace = workspace.to(torch.float8_e4m3fn)
82 + return k_nope, workspace[:, 512:].unsqueeze(1), v
83 +
84 +
85 +@pytest.mark.parametrize("num_tokens", [0, _NUM_TOKENS])
86 +@pytest.mark.parametrize("num_heads", [3, _NUM_HEADS])
87 +@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float16])
88 +@torch.inference_mode()
89 +def test_context_kv_concat_accepts_strided_inputs(
90 + num_tokens: int, num_heads: int, input_dtype: torch.dtype
91 +) -> None:
92 + k_nope, k_pe, _ = _strided_context_inputs(
93 + num_tokens, num_heads, input_dtype, k_pe_fp8=False
94 + )
95 +
96 + k = fused_mla_kv_concat(k_nope, k_pe)
97 +
98 + assert k.is_contiguous()
99 + assert k.dtype == input_dtype
100 + torch.testing.assert_close(k[..., :128], k_nope, atol=0, rtol=0)
101 + torch.testing.assert_close(
102 + k[..., 128:], k_pe.expand(-1, num_heads, -1), atol=0, rtol=0
103 + )
104 +
105 +
106 +@pytest.mark.parametrize("k_pe_dtype", ["input", "fp8"])
107 +@pytest.mark.parametrize("num_tokens", [0, _NUM_TOKENS])
108 +@pytest.mark.parametrize("num_heads", [3, _NUM_HEADS])
109 +@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float16])
110 +@torch.inference_mode()
111 +def test_context_kv_pack_quantizes_strided_inputs(
112 + k_pe_dtype: str,
113 + num_tokens: int,
114 + num_heads: int,
115 + input_dtype: torch.dtype,
116 +) -> None:
117 + k_nope, k_pe, v = _strided_context_inputs(
118 + num_tokens, num_heads, input_dtype, k_pe_fp8=k_pe_dtype == "fp8"
119 + )
120 +
121 + k_actual, v_actual = fused_mla_kv_concat_quant_fp8(k_nope, k_pe, v)
122 +
123 + fp8 = torch.float8_e4m3fn
124 + k_expected = torch.empty_like(k_actual)
125 + k_expected[..., :128] = k_nope.to(fp8)
126 + k_expected[..., 128:] = k_pe.to(fp8)
127 + assert k_actual.is_contiguous()
128 + assert v_actual.is_contiguous()
129 + # The pack casts with the native pairwise converters, so it must be
130 + # bit-identical to torch's `.to(fp8)` rather than merely close.
131 + torch.testing.assert_close(k_actual.float(), k_expected.float(), atol=0, rtol=0)
132 + torch.testing.assert_close(v_actual.float(), v.to(fp8).float(), atol=0, rtol=0)
133 +
134 +
135 @pytest.mark.parametrize("cache_kind", ["bf16", "fp8", "fp8_ds_mla"])
136 @torch.inference_mode()
137 def test_prefill_epilogue_fuses_gptj_rope(cache_kind: str) -> None:
tests/models/kimi_k3/test_mla_prefill_context.py new
+319
@@ -0,0 +1,319 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""K3's fused chunked-context prefill must match the generic MLA impl.
4 +
5 +The layer owns its own context loop so it can fuse the per-chunk K/V pack and
6 +skip re-quantizing an already-quantized query. That is only safe if it feeds the
7 +prefill backend exactly what ``MLACommonBaseImpl._compute_prefill_context``
8 +would, chunk for chunk.
9 +"""
10 +
11 +from types import SimpleNamespace
12 +
13 +import pytest
14 +import torch
15 +
16 +from vllm.model_executor.layers.attention.mla_attention import (
17 + MLACommonBaseImpl,
18 + MLACommonPrefillMetadata,
19 + build_mla_chunked_context_metadata,
20 +)
21 +from vllm.models.kimi_k3.nvidia.mla import MultiHeadLatentAttention
22 +from vllm.platforms import current_platform
23 +
24 +pytestmark = pytest.mark.skipif(
25 + not current_platform.is_cuda(), reason="Kimi-K3 fused MLA requires CUDA"
26 +)
27 +
28 +_KV_LORA_RANK = 512
29 +_QK_NOPE = 128
30 +_QK_ROPE = 64
31 +_V_HEAD_DIM = 128
32 +_ENTRY = _KV_LORA_RANK + _QK_ROPE
33 +_NUM_HEADS = 2
34 +_BLOCK_SIZE = 16
35 +_WORKSPACE_TOKENS = 128
36 +# Splits one long request across chunks, packs short ones together, and leaves
37 +# the last request without any context.
38 +_CONTEXT_LENS = [200, 48, 32, 0]
39 +_QUERY_LENS = [8, 4, 6, 5]
40 +
41 +
42 +class _RecordingPrefillBackend:
43 + """Records what each chunk is asked to attend over.
44 +
45 + ``honors_out`` mimics the two backend families: one that writes into a
46 + caller-provided ``out`` (trtllm_ragged, flashinfer, tokenspeed) and one that
47 + always returns its own buffer (flash_attn with a padded V, aiter).
48 + """
49 +
50 + def __init__(self, honors_out: bool = False) -> None:
51 + self.calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = []
52 + self.out_destinations: list[torch.Tensor | None] = []
53 + self._honors_out = honors_out
54 +
55 + @staticmethod
56 + def get_name() -> str:
57 + return "recording"
58 +
59 + def supports_out(self) -> bool:
60 + return self._honors_out
61 +
62 + def run_prefill_context_chunk(self, *, chunk, q, k, v, out=None):
63 + self.calls.append((q.float().clone(), k.float().clone(), v.float().clone()))
64 + self.out_destinations.append(out)
65 + assert out is None or self._honors_out
66 + # Fold K/V into the partial so any packing difference shows up in the
67 + # merged context output, not just in the recorded calls.
68 + digest = (k.float().mean() + v.float().mean()).item()
69 + num_q = q.shape[0]
70 + if out is None:
71 + out = torch.empty(
72 + (num_q, _NUM_HEADS, _V_HEAD_DIM), device=q.device, dtype=torch.bfloat16
73 + )
74 + else:
75 + assert out.shape == (num_q, _NUM_HEADS, _V_HEAD_DIM)
76 + out.fill_(digest)
77 + lse = torch.full(
78 + (_NUM_HEADS, num_q),
79 + 1.0 + chunk.index,
80 + device=q.device,
81 + dtype=torch.float32,
82 + )
83 + return out, lse
84 +
85 +
86 +class _KVBProj(torch.nn.Module):
87 + """Stand-in for the layer's ``kv_b_proj`` (returns an (out, bias) tuple).
88 +
89 + Enforces the same input contract as the real linear methods, because that is
90 + what decides whether the gathered latent needs a cast:
91 +
92 + * an fp8 weight consumes the fp8 latent directly and dequantizes internally,
93 + so it takes fp8 or bf16;
94 + * a bf16 weight -- what a stock K3 checkpoint carries -- is a plain
95 + ``F.linear`` and rejects anything but bf16, exactly as torch does.
96 +
97 + Either weight dtype produces a bf16 output.
98 + """
99 +
100 + def __init__(self, device: torch.device, weight_dtype: torch.dtype) -> None:
101 + super().__init__()
102 + weight = (
103 + torch.randn(
104 + _NUM_HEADS * (_QK_NOPE + _V_HEAD_DIM),
105 + _KV_LORA_RANK,
106 + device=device,
107 + dtype=torch.bfloat16,
108 + )
109 + * 0.05
110 + )
111 + self.register_buffer("weight", weight.to(weight_dtype))
112 +
113 + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, None]:
114 + if self.weight.dtype == torch.bfloat16 and x.dtype != torch.bfloat16:
115 + raise RuntimeError(
116 + "a bfloat16 kv_b_proj cannot consume the gathered latent as "
117 + f"{x.dtype}; it must be cast first"
118 + )
119 + return torch.nn.functional.linear(
120 + x.to(torch.bfloat16), self.weight.to(torch.bfloat16)
121 + ), None
122 +
123 +
124 +class _FusedLayer:
125 + """Only the attributes K3's context loop reads."""
126 +
127 + _compute_prefill_context = MultiHeadLatentAttention._compute_prefill_context
128 + _gather_context_latent = MultiHeadLatentAttention._gather_context_latent
129 + _attn_read_kv_cache = MultiHeadLatentAttention._attn_read_kv_cache
130 +
131 + def __init__(self, kv_b_proj, kv_cache, kv_cache_dtype, k_scale) -> None:
132 + self.kv_b_proj = kv_b_proj
133 + self.kv_cache = kv_cache
134 + self.kv_cache_dtype = kv_cache_dtype
135 + self._k_scale = k_scale
136 + self.kv_lora_rank = _KV_LORA_RANK
137 + self.num_local_heads = _NUM_HEADS
138 + self.qk_nope_head_dim = _QK_NOPE
139 + self.v_head_dim = _V_HEAD_DIM
140 +
141 +
142 +class _ReferenceImpl:
143 + """Only the attributes the generic context loop reads."""
144 +
145 + _compute_prefill_context = MLACommonBaseImpl._compute_prefill_context
146 + _concat_k_nope_k_pe = MLACommonBaseImpl._concat_k_nope_k_pe
147 + _use_flashinfer_concat_mla_k = False
148 +
149 + def __init__(self, kv_b_proj, kv_cache_dtype) -> None:
150 + self.kv_b_proj = kv_b_proj
151 + self.kv_cache_dtype = kv_cache_dtype
152 + self.kv_lora_rank = _KV_LORA_RANK
153 + self.num_heads = _NUM_HEADS
154 + self.qk_nope_head_dim = _QK_NOPE
155 + self.qk_rope_head_dim = _QK_ROPE
156 + self.v_head_dim = _V_HEAD_DIM
157 +
158 +
159 +def _build_prefill_metadata(
160 + device: torch.device,
161 + workspace_dtype: torch.dtype,
162 + q_data_type: torch.dtype,
163 + backend: _RecordingPrefillBackend,
164 +) -> MLACommonPrefillMetadata:
165 + query_start_loc_cpu = torch.zeros(len(_QUERY_LENS) + 1, dtype=torch.int32)
166 + query_start_loc_cpu[1:] = torch.tensor(_QUERY_LENS, dtype=torch.int32).cumsum(0)
167 + workspace = torch.empty(
168 + (_WORKSPACE_TOKENS, _ENTRY), dtype=workspace_dtype, device=device
169 + )
170 + chunked_context = build_mla_chunked_context_metadata(
171 + context_lens_cpu=torch.tensor(_CONTEXT_LENS, dtype=torch.int32),
172 + prefill_query_start_loc_cpu=query_start_loc_cpu,
173 + chunked_prefill_workspace=workspace,
174 + chunked_prefill_workspace_size=_WORKSPACE_TOKENS,
175 + block_size=_BLOCK_SIZE,
176 + align_chunk_to_block=True,
177 + device=device,
178 + dcp_world_size=1,
179 + dcp_local_block_size=1,
180 + dcp_virtual_block_size=1,
181 + )
182 + assert chunked_context is not None
183 + assert len(chunked_context.chunks) > 1, "the batch must exercise accumulation"
184 +
185 + max_blocks = (max(_CONTEXT_LENS) + max(_QUERY_LENS)) // _BLOCK_SIZE + 1
186 + num_blocks = max_blocks * len(_CONTEXT_LENS)
187 + block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).view(
188 + len(_CONTEXT_LENS), max_blocks
189 + )
190 + return MLACommonPrefillMetadata(
191 + block_table=block_table,
192 + query_start_loc=query_start_loc_cpu.to(device),
193 + max_query_len=max(_QUERY_LENS),
194 + chunked_context=chunked_context,
195 + q_data_type=q_data_type,
196 + output_dtype=torch.bfloat16,
197 + prefill_backend=backend,
198 + ), num_blocks
199 +
200 +
201 +@pytest.mark.parametrize("honors_out", [False, True], ids=["copy_out", "writes_out"])
202 +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
203 +@pytest.mark.parametrize(
204 + "kv_b_proj_quantized", [True, False], ids=["fp8_kv_b_proj", "bf16_kv_b_proj"]
205 +)
206 +@torch.inference_mode()
207 +def test_fused_context_matches_generic_impl(
208 + kv_b_proj_quantized: bool, kv_cache_dtype: str, honors_out: bool
209 +) -> None:
210 + """Cache dtype and ``kv_b_proj`` dtype vary independently.
211 +
212 + A stock K3 checkpoint pairs a bf16 ``kv_b_proj`` with an fp8 cache, so the
213 + fused loop cannot assume the gathered latent is already in the dtype
214 + ``kv_b_proj`` accepts.
215 + """
216 + torch.manual_seed(0)
217 + device = torch.device("cuda")
218 + fp8 = current_platform.fp8_dtype()
219 + quantized = kv_cache_dtype == "fp8"
220 + # An fp8 cache is read by an fp8 query, so the workspace keeps the fp8
221 + # layout; a bf16 cache dequantizes into a bf16 workspace.
222 + q_data_type = fp8 if quantized else torch.bfloat16
223 + workspace_dtype = q_data_type
224 +
225 + kv_b_proj = _KVBProj(
226 + device, weight_dtype=fp8 if kv_b_proj_quantized else torch.bfloat16
227 + )
228 + k_scale = torch.ones(1, dtype=torch.float32, device=device)
229 +
230 + backend_fused = _RecordingPrefillBackend(honors_out=honors_out)
231 + # The reference impl never passes `out`, so it always allocates its own.
232 + backend_ref = _RecordingPrefillBackend()
233 + prefill_fused, num_blocks = _build_prefill_metadata(
234 + device, workspace_dtype, q_data_type, backend_fused
235 + )
236 + prefill_ref, _ = _build_prefill_metadata(
237 + device, workspace_dtype, q_data_type, backend_ref
238 + )
239 +
240 + cache = torch.randn(
241 + (num_blocks, _BLOCK_SIZE, _ENTRY), device=device, dtype=torch.bfloat16
242 + )
243 + kv_cache = cache.to(fp8) if quantized else cache
244 + q = (
245 + torch.randn(
246 + (sum(_QUERY_LENS), _NUM_HEADS, _QK_NOPE + _QK_ROPE),
247 + device=device,
248 + dtype=torch.bfloat16,
249 + )
250 + * 0.2
251 + ).to(q_data_type)
252 +
253 + layer = _FusedLayer(kv_b_proj, kv_cache, kv_cache_dtype, k_scale)
254 + fused_out, fused_lse = layer._compute_prefill_context(
255 + q, SimpleNamespace(prefill=prefill_fused)
256 + )
257 +
258 + impl = _ReferenceImpl(kv_b_proj, kv_cache_dtype)
259 + ref_out, ref_lse = impl._compute_prefill_context(
260 + q, kv_cache, SimpleNamespace(prefill=prefill_ref), k_scale
261 + )
262 +
263 + assert len(backend_fused.calls) == len(backend_ref.calls)
264 + for chunk_idx, (fused_call, ref_call) in enumerate(
265 + zip(backend_fused.calls, backend_ref.calls, strict=True)
266 + ):
267 + for name, fused_t, ref_t in zip(
268 + ("q", "k", "v"), fused_call, ref_call, strict=True
269 + ):
270 + torch.testing.assert_close(
271 + fused_t,
272 + ref_t,
273 + atol=0,
274 + rtol=0,
275 + msg=lambda m, n=name, i=chunk_idx: f"chunk {i} {n} differs: {m}",
276 + )
277 + torch.testing.assert_close(fused_out, ref_out, atol=0, rtol=0)
278 + torch.testing.assert_close(fused_lse, ref_lse, atol=0, rtol=0)
279 +
280 + # Every chunk but the continuation should have been written in place, i.e.
281 + # straight into the returned accumulator, with no intermediate copy.
282 + wrote_in_place = [
283 + out is not None and out.data_ptr() == fused_out[chunk.token_slice].data_ptr()
284 + for out, chunk in zip(
285 + backend_fused.out_destinations,
286 + prefill_fused.chunked_context.chunks,
287 + strict=True,
288 + )
289 + ]
290 + continuations = [c.is_continuation for c in prefill_fused.chunked_context.chunks]
291 + assert any(continuations), "the batch must exercise a continuation chunk"
292 + if honors_out:
293 + assert wrote_in_place == [not c for c in continuations]
294 + else:
295 + assert not any(wrote_in_place)
296 +
297 +
298 +@torch.inference_mode()
299 +def test_fused_context_rejects_an_unquantized_query() -> None:
300 + """The fp8 query is produced by the new-token epilogue, not re-cast here."""
301 + torch.manual_seed(0)
302 + device = torch.device("cuda")
303 + fp8 = current_platform.fp8_dtype()
304 + prefill, num_blocks = _build_prefill_metadata(
305 + device, fp8, fp8, _RecordingPrefillBackend()
306 + )
307 + layer = _FusedLayer(
308 + _KVBProj(device, weight_dtype=fp8),
309 + torch.zeros((num_blocks, _BLOCK_SIZE, _ENTRY), device=device, dtype=fp8),
310 + "fp8",
311 + torch.ones(1, dtype=torch.float32, device=device),
312 + )
313 + q = torch.zeros(
314 + (sum(_QUERY_LENS), _NUM_HEADS, _QK_NOPE + _QK_ROPE),
315 + device=device,
316 + dtype=torch.bfloat16,
317 + )
318 + with pytest.raises(AssertionError, match="new-token epilogue"):
319 + layer._compute_prefill_context(q, SimpleNamespace(prefill=prefill))
tests/v1/attention/test_mla_prefill_registry.py
+2 -2
@@ -24,7 +24,7 @@ class CustomMLAPrefillBackend(MLAPrefillBackend):
24 def run_prefill_new_tokens(self, q, k, v, return_softmax_lse):
25 raise NotImplementedError
26
27 - def run_prefill_context_chunk(self, chunk, q, k, v):
27 + def run_prefill_context_chunk(self, chunk, q, k, v, out=None):
28 raise NotImplementedError
29
30
@@ -112,7 +112,7 @@ def test_register_custom_backend_as_decorator():
112 def run_prefill_new_tokens(self, q, k, v, return_softmax_lse):
113 raise NotImplementedError
114
115 - def run_prefill_context_chunk(self, chunk, q, k, v):
115 + def run_prefill_context_chunk(self, chunk, q, k, v, out=None):
116 raise NotImplementedError
117
118 assert MLAPrefillBackendEnum.CUSTOM.is_overridden()
vllm/model_executor/layers/attention/mla_attention.py
+32 -6
@@ -2414,6 +2414,22 @@ def reorg_kvcache(
2414 return reorganized_kv_c_normed, reorganized_k_pe
2415
2416
2417 +def neutralize_empty_context_partials(
2418 + chunked_context: "MLACommonPrefillMetadata.ChunkedContextMetadata",
2419 + output: torch.Tensor,
2420 + output_lse: torch.Tensor,
2421 +) -> None:
2422 + """Neutralize the partial of every prefill that no chunk covers.
2423 +
2424 + A prefill without context is never gathered, so nothing would write its rows;
2425 + a zero output with an ``-inf`` lse carries no weight into the final merge
2426 + against the suffix partial.
2427 + """
2428 + for token_slice in chunked_context.empty_token_slices:
2429 + output[token_slice].zero_()
2430 + output_lse[:, token_slice].fill_(float("-inf"))
2431 +
2432 +
2433 def init_mla_context_partial(
2434 chunked_context: "MLACommonPrefillMetadata.ChunkedContextMetadata",
2435 attn_output: torch.Tensor,
@@ -2423,7 +2439,9 @@ def init_mla_context_partial(
2439 """Allocate the running context partial over all prefill tokens.
2440
2441 Laid out like the chunk partials so the final whole-batch merge against the
2426 - suffix partial sees matching head strides.
2442 + suffix partial sees matching head strides. Callers whose backend honors an
2443 + ``out`` tensor already know that layout and can allocate directly, pairing it
2444 + with ``neutralize_empty_context_partials``.
2445 """
2446 output = torch.empty(
2447 (num_tokens, *attn_output.shape[1:]),
@@ -2435,10 +2453,7 @@ def init_mla_context_partial(
2453 dtype=attn_softmax_lse.dtype,
2454 device=attn_softmax_lse.device,
2455 )
2438 - # No chunk covers a prefill without context, so neutralize its partial.
2439 - for token_slice in chunked_context.empty_token_slices:
2440 - output[token_slice].zero_()
2441 - output_lse[:, token_slice].fill_(float("-inf"))
2456 + neutralize_empty_context_partials(chunked_context, output, output_lse)
2457 return output, output_lse
2458
2459
@@ -2448,16 +2463,26 @@ def accumulate_mla_context_chunk(
2463 attn_softmax_lse: torch.Tensor,
2464 output: torch.Tensor,
2465 output_lse: torch.Tensor,
2466 + output_written: bool = False,
2467 ) -> None:
2468 """Fold one chunk's partial into the running context partial.
2469
2470 Only the first request may be a continuation; its tokens are merged and the
2471 remaining token range is initialized.
2472 +
2473 + Args:
2474 + output_written: The chunk's attention output already landed in
2475 + ``output[chunk.token_slice]`` because the backend was handed it as
2476 + ``out``, leaving only the lse to fold. Invalid for a continuation
2477 + chunk, whose leading tokens must be merged rather than overwritten.
2478 """
2479 token_start = chunk.token_slice.start
2480 token_end = chunk.token_slice.stop
2481 init_start = token_start
2482 if chunk.is_continuation:
2483 + assert not output_written, (
2484 + "a continuation chunk must not write over the partial it merges with"
2485 + )
2486 init_start = chunk.continuation_token_end
2487 num_merged = init_start - token_start
2488 merge_attn_states(
@@ -2470,7 +2495,8 @@ def accumulate_mla_context_chunk(
2495 )
2496 if init_start < token_end:
2497 written = init_start - token_start
2473 - output[init_start:token_end].copy_(attn_output[written:])
2498 + if not output_written:
2499 + output[init_start:token_end].copy_(attn_output[written:])
2500 output_lse[:, init_start:token_end].copy_(attn_softmax_lse[:, written:])
2501
2502
vllm/models/kimi_k3/nvidia/mla.py
+192 -6
@@ -8,8 +8,9 @@ This is a self-contained MLA layer that owns the full attention path:
8 -> fused pre-attention ops (fused_qkv_a_proj / norms / q_b_proj)
9 -> explicit prefill / decode split
10 prefill: fused key-concat + cache-insert kernel -> run_prefill_new_tokens
11 - (+ chunked-context merge); dispatched by cache dtype
12 - (bf16 / plain fp8 / fp8_ds_mla)
11 + (+ chunked-context merge, whose per-chunk gather -> kv_b_proj
12 + -> fused K/V pack loop this layer owns); dispatched by cache
13 + dtype (bf16 / plain fp8 / fp8_ds_mla)
14 decode : W_UK absorb (BMM1) -> fused q-concat + cache-insert kernel
15 -> impl.forward_mqa -> W_UV up-proj (MQA)
16 -> optional output gate
@@ -34,6 +35,7 @@ from typing import TYPE_CHECKING, cast
35 import torch
36 from torch import nn
37
38 +from vllm import _custom_ops as ops
39 from vllm.compilation.breakable_cudagraph import eager_break_during_capture
40 from vllm.config import (
41 CacheConfig,
@@ -48,6 +50,12 @@ from vllm.model_executor.layers.attention.attention import (
50 set_default_quant_scales,
51 should_load_quant_weights,
52 )
53 +from vllm.model_executor.layers.attention.mla_attention import (
54 + _get_kv_b_proj_input_dtype,
55 + accumulate_mla_context_chunk,
56 + init_mla_context_partial,
57 + neutralize_empty_context_partials,
58 +)
59 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
60 from vllm.model_executor.layers.layernorm import RMSNorm
61 from vllm.model_executor.layers.linear import (
@@ -67,6 +75,8 @@ from vllm.models.kimi_k3.nvidia.ops.fused_mla_key_concat_kv_cache import (
75 fused_mla_decode_q_concat_kv_cache_insert,
76 fused_mla_key_concat_ds_mla_insert,
77 fused_mla_key_concat_kv_cache_insert,
78 + fused_mla_kv_concat,
79 + fused_mla_kv_concat_quant_fp8,
80 fused_mla_qkv_quant_kv_cache_fp8_insert,
81 )
82 from vllm.platforms import current_platform
@@ -696,6 +706,181 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
706 cos_sin_cache=cos_sin_cache,
707 )
708
709 + def _compute_prefill_context(
710 + self,
711 + q: torch.Tensor,
712 + attn_metadata: "MLACommonMetadata",
713 + ) -> tuple[torch.Tensor, torch.Tensor]:
714 + """Chunked-context prefill, K3-fused. Replaces the impl's version.
715 +
716 + Per chunk the impl gathers the paged latent, up-projects it, then casts
717 + and concatenates K (and casts V) in two or three more launches. Here that
718 + tail is one fused kernel per chunk -- ``fused_mla_kv_concat`` for a bf16
719 + query, ``fused_mla_kv_concat_quant_fp8`` when the query is fp8 -- reading
720 + the strided ``kv_b_proj`` output in place and writing a contiguous key, so
721 + only the gather and ``kv_b_proj`` remain.
722 +
723 + The impl's query cast is gone as well: ``q`` already carries
724 + ``prefill.q_data_type`` because the new-token epilogue quantized it. The
725 + gathered latent still gets the impl's cast to whatever ``kv_b_proj``
726 + consumes -- free (a no-op ``.to``) for a checkpoint whose ``kv_b_proj``
727 + takes the fp8 latent directly, and required for a bf16 one, which is
728 + what a stock K3 checkpoint carries. Its output is bf16 either way.
729 +
730 + The gathered ``k_pe`` is likewise used as-is (fp8 for a plain fp8 cache)
731 + and needs no RoPE: it was rotated on the way in.
732 +
733 + Chunk partials are written straight into the accumulating context partial
734 + when the prefill backend honors ``out``, so only the (64x smaller) lse is
735 + copied per chunk.
736 +
737 + Decode context parallelism keeps using
738 + ``impl._context_parallel_compute_prefill_context``; its extra allgather
739 + and reorg are not fused here.
740 + """
741 + prefill = attn_metadata.prefill
742 + assert prefill is not None
743 + prefill_backend = prefill.prefill_backend
744 + assert prefill_backend is not None
745 + chunked_context = prefill.chunked_context
746 + assert chunked_context is not None
747 + assert q.dtype == prefill.q_data_type, (
748 + "Kimi-K3 chunked context expects the new-token epilogue to have "
749 + f"produced a {prefill.q_data_type} query; got {q.dtype}."
750 + )
751 +
752 + fp8_prefill = q.dtype == current_platform.fp8_dtype()
753 + workspace = chunked_context.workspace
754 + kv_cache = self._attn_read_kv_cache()
755 + kv_b_proj_input_dtype = _get_kv_b_proj_input_dtype(self.kv_b_proj, fp8_prefill)
756 +
757 + def run_chunk(
758 + chunk, out: torch.Tensor | None = None
759 + ) -> tuple[torch.Tensor, torch.Tensor]:
760 + self._gather_context_latent(chunk, kv_cache, prefill, fp8_prefill)
761 + gathered = workspace[: chunk.num_context_tokens]
762 + kv_c_normed = gathered[..., : self.kv_lora_rank]
763 + if kv_b_proj_input_dtype is not None:
764 + kv_c_normed = kv_c_normed.to(kv_b_proj_input_dtype)
765 + kv_nope = self.kv_b_proj(kv_c_normed)[0].view(
766 + -1, self.num_local_heads, self.qk_nope_head_dim + self.v_head_dim
767 + )
768 + k_nope, v = kv_nope.split([self.qk_nope_head_dim, self.v_head_dim], dim=-1)
769 + k_pe = gathered[..., self.kv_lora_rank :]
770 + if fp8_prefill:
771 + k, v = fused_mla_kv_concat_quant_fp8(k_nope, k_pe, v)
772 + else:
773 + k = fused_mla_kv_concat(k_nope, k_pe)
774 + attn_output, attn_lse = prefill_backend.run_prefill_context_chunk(
775 + chunk=chunk, q=q[chunk.token_slice], k=k, v=v, out=out
776 + )
777 + assert out is None or attn_output.data_ptr() == out.data_ptr(), (
778 + f"{prefill_backend.get_name()} reports supports_out() but did not "
779 + "write the context chunk into the `out` it was given."
780 + )
781 + return attn_output, attn_lse
782 +
783 + chunks = chunked_context.chunks
784 + if len(chunks) == 1 and not chunked_context.empty_token_slices:
785 + # One chunk covering every prefill token: its partial *is* the context
786 + # partial, so it needs neither an accumulator nor a copy.
787 + return run_chunk(chunks[0])
788 +
789 + # A backend honoring `out` writes each chunk's partial straight into the
790 + # accumulator, so the per-chunk output copy disappears -- and because that
791 + # contract fixes the trailing shape, the accumulator can be sized before
792 + # any chunk runs. Otherwise the shape is only knowable from a real partial,
793 + # so the first chunk runs ahead of the loop and is copied in.
794 + writes_out = prefill_backend.supports_out()
795 + if writes_out:
796 + assert prefill.output_dtype is not None
797 + output = torch.empty(
798 + (q.shape[0], self.num_local_heads, self.v_head_dim),
799 + dtype=prefill.output_dtype,
800 + device=q.device,
801 + )
802 + output_lse = torch.empty(
803 + (self.num_local_heads, q.shape[0]),
804 + dtype=torch.float32,
805 + device=q.device,
806 + )
807 + neutralize_empty_context_partials(chunked_context, output, output_lse)
808 + else:
809 + attn_output, attn_lse = run_chunk(chunks[0])
810 + output, output_lse = init_mla_context_partial(
811 + chunked_context, attn_output, attn_lse, num_tokens=q.shape[0]
812 + )
813 + accumulate_mla_context_chunk(
814 + chunks[0], attn_output, attn_lse, output, output_lse
815 + )
816 + chunks = chunks[1:]
817 +
818 + for chunk in chunks:
819 + # A continuation chunk's leading tokens have to be merged with the
820 + # partial already sitting there, so it cannot write in place.
821 + out = (
822 + output[chunk.token_slice]
823 + if writes_out and not chunk.is_continuation
824 + else None
825 + )
826 + attn_output, attn_lse = run_chunk(chunk, out=out)
827 + accumulate_mla_context_chunk(
828 + chunk,
829 + attn_output,
830 + attn_lse,
831 + output,
832 + output_lse,
833 + output_written=out is not None,
834 + )
835 + return output, output_lse
836 +
837 + def _gather_context_latent(
838 + self,
839 + chunk,
840 + kv_cache: torch.Tensor,
841 + prefill,
842 + fp8_prefill: bool,
843 + ) -> None:
844 + """Gather one chunk's paged context latent into the workspace.
845 +
846 + Dispatched exactly as in ``impl._compute_prefill_context``: an fp8 query
847 + reads the plain fp8 cache in its stored layout, anything else lands in the
848 + workspace as the model dtype.
849 + """
850 + workspace = prefill.chunked_context.workspace
851 + toks = chunk.num_context_tokens
852 + block_table = prefill.block_table[chunk.request_slice]
853 + if self.kv_cache_dtype == "fp8_ds_mla":
854 + ops.cp_gather_and_upconvert_fp8_kv_cache(
855 + src_cache=kv_cache,
856 + dst=workspace[:toks],
857 + block_table=block_table,
858 + workspace_starts=chunk.cu_seq_lens,
859 + batch_size=chunk.num_requests,
860 + seq_starts=chunk.starts,
861 + )
862 + elif not fp8_prefill:
863 + ops.gather_and_maybe_dequant_cache(
864 + src_cache=kv_cache,
865 + dst=workspace,
866 + block_table=block_table,
867 + cu_seq_lens=chunk.cu_seq_lens,
868 + token_to_seq=chunk.token_to_seq,
869 + num_tokens=toks,
870 + kv_cache_dtype=self.kv_cache_dtype,
871 + scale=self._k_scale,
872 + seq_starts=chunk.starts,
873 + )
874 + else:
875 + ops.cp_gather_cache(
876 + src_cache=kv_cache,
877 + dst=workspace[:toks],
878 + block_table=block_table,
879 + cu_seq_lens=chunk.cu_seq_lens,
880 + batch_size=chunk.num_requests,
881 + seq_starts=chunk.starts,
882 + )
883 +
884 def _forward_prefill_fused(
885 self,
886 q: torch.Tensor,
@@ -710,8 +895,9 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
895 """Prefill using the fused key-concat + cache-insert kernel.
896
897 Replaces ``_concat_k_nope_k_pe`` and the prefill cache write with one
713 - fused kernel launch, dispatched by cache dtype. The chunked context
714 - gather + online-softmax merge are delegated to the impl.
898 + fused kernel launch, dispatched by cache dtype. Chunked context runs
899 + through this layer's ``_compute_prefill_context``, except under DCP where
900 + it is delegated to the impl.
901
902 Supported configs (K3 fp8 policy):
903 - bf16 cache -> bf16 prefill query
@@ -812,8 +998,8 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
998 )
999 )
1000 else:
815 - context_output, context_lse = self.impl._compute_prefill_context( # type: ignore[attr-defined]
816 - q, self._attn_read_kv_cache(), attn_metadata, self._k_scale
1001 + context_output, context_lse = self._compute_prefill_context(
1002 + q, attn_metadata
1003 )
1004 suffix_output, suffix_lse = output_prefill
1005 out = out.view(-1, self.num_local_heads, self.v_head_dim)
vllm/models/kimi_k3/nvidia/ops/fused_mla_key_concat_kv_cache.py
+56
@@ -12,6 +12,9 @@ mirror ``fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_{bf16,fp8}_insert``.
12 - ``fused_mla_qkv_quant_kv_cache_fp8_insert`` (fp8): additionally quantize
13 ``q``/``k``/``v`` to E4M3 with ``q_scale`` / ``k_scale`` / ``v_scale`` (the
14 cache shares ``k_scale``, as in ``concat_and_cache_mla``).
15 +- ``fused_mla_kv_concat`` / ``fused_mla_kv_concat_quant_fp8`` (chunked context):
16 + the same K concat (plus the fp8 K/V cast) without the cache insert, for context
17 + chunks whose latent was gathered back out of the paged cache.
18
19 The optional ``positions`` / ``cos_sin_cache`` pair enables GPT-J-style RoPE
20 inside the epilogue. Omitting both keeps the K3 NoPE fast path. The kernels use
@@ -156,6 +159,59 @@ def fused_mla_qkv_quant_kv_cache_fp8_insert(
159 return q_fp8, k_fp8, v_fp8
160
161
162 +def _empty_full_key(
163 + k_nope: torch.Tensor, k_pe: torch.Tensor, dtype: torch.dtype
164 +) -> torch.Tensor:
165 + num_tokens, num_heads, qk_nope_head_dim = k_nope.shape
166 + return torch.empty(
167 + (num_tokens, num_heads, qk_nope_head_dim + k_pe.shape[1]),
168 + dtype=dtype,
169 + device=k_nope.device,
170 + )
171 +
172 +
173 +def fused_mla_kv_concat(
174 + k_nope: torch.Tensor, # [T, H, qk_nope_head_dim], may be strided
175 + k_pe: torch.Tensor, # [T, rope] or [T, 1, rope], same dtype as k_nope
176 +) -> torch.Tensor:
177 + """Concat ``k = [k_nope | k_pe]`` into a contiguous key, in one launch.
178 +
179 + The chunked-context counterpart of ``fused_mla_key_concat_kv_cache_insert``:
180 + no query, no cache insert and no RoPE (the gathered ``k_pe`` is already
181 + rotated). ``k_nope`` is a strided half of one ``kv_b_proj`` output and
182 + ``k_pe`` a strided view of the gather workspace, so neither has to be made
183 + contiguous first.
184 + """
185 + k_pe = k_pe.reshape(k_pe.shape[0], k_pe.shape[-1])
186 + k = _empty_full_key(k_nope, k_pe, k_nope.dtype)
187 + if k.shape[0]:
188 + torch.ops._C.fused_kimi_k3_mla_kv_concat(k_nope, k_pe, k)
189 + return k
190 +
191 +
192 +def fused_mla_kv_concat_quant_fp8(
193 + k_nope: torch.Tensor, # [T, H, qk_nope_head_dim], may be strided
194 + k_pe: torch.Tensor, # [T, rope] or [T, 1, rope], k_nope's dtype or fp8
195 + v: torch.Tensor, # [T, H, v_head_dim], may be strided
196 +) -> tuple[torch.Tensor, torch.Tensor]:
197 + """``fused_mla_kv_concat`` plus an fp8 cast of the key and of ``v``.
198 +
199 + ``k_pe`` may already be fp8: a plain fp8 cache is gathered without
200 + dequantizing, and those bytes are copied through as-is.
201 +
202 + Returns contiguous ``(k_fp8, v_fp8)``.
203 + """
204 + k_pe = k_pe.reshape(k_pe.shape[0], k_pe.shape[-1])
205 + fp8 = torch.float8_e4m3fn
206 + k_fp8 = _empty_full_key(k_nope, k_pe, fp8)
207 + v_fp8 = torch.empty(v.shape, dtype=fp8, device=v.device)
208 + if k_fp8.shape[0]:
209 + torch.ops._C.fused_kimi_k3_mla_kv_concat_quant_fp8(
210 + k_nope, k_pe, v, k_fp8, v_fp8
211 + )
212 + return k_fp8, v_fp8
213 +
214 +
215 def fused_mla_decode_q_concat_kv_cache_insert(
216 ql_nope: torch.Tensor, # [B, H, kv_lora_rank] (BMM1 output, absorbed q)
217 q_pe: torch.Tensor, # [B, H, qk_rope_head_dim]
vllm/v1/attention/backends/mla/prefill/aiter_flash_attn.py
+5
@@ -106,7 +106,12 @@ class AiterFlashAttnPrefillBackend(MLAPrefillBackend):
106 q: torch.Tensor,
107 k: torch.Tensor,
108 v: torch.Tensor,
109 + out: torch.Tensor | None = None,
110 ) -> tuple[torch.Tensor, torch.Tensor]:
111 + assert out is None, (
112 + "AiterFlashAttnPrefillBackend does not report supports_out(), so it "
113 + "is never given a context-chunk `out` to write into."
114 + )
115 out, lse = self.flash_attn_varlen_func(
116 q=q,
117 k=k,
vllm/v1/attention/backends/mla/prefill/base.py
+9 -5
@@ -74,13 +74,16 @@ class MLAPrefillBackend(ABC):
74 return False
75
76 def supports_out(self) -> bool:
77 - """Whether `run_prefill_new_tokens` honors a caller-provided `out`
78 - tensor of shape `[num_tokens, num_heads, v_head_dim]`, writing the
79 - final result into it in place.
77 + """Whether `run_prefill_new_tokens` and `run_prefill_context_chunk` honor
78 + a caller-provided `out` tensor of shape
79 + `[num_tokens, num_heads, v_head_dim]`, writing the result into it in place
80 + and returning it.
81
82 When True, callers may pass `out` and skip the post-hoc
82 - slice/flatten/copy. False for backends that ignore `out` or emit a
83 - padded (`qk_head_dim`) output. Overridden by backends that support it.
83 + slice/flatten/copy -- and, for context chunks, size the accumulating
84 + partial before running any chunk. False for backends that ignore `out` or
85 + emit a padded (`qk_head_dim`) output. Overridden by backends that support
86 + it.
87 """
88 return False
89
@@ -179,5 +182,6 @@ class MLAPrefillBackend(ABC):
182 q: torch.Tensor,
183 k: torch.Tensor,
184 v: torch.Tensor,
185 + out: torch.Tensor | None = None,
186 ) -> tuple[torch.Tensor, torch.Tensor]:
187 raise NotImplementedError
vllm/v1/attention/backends/mla/prefill/flash_attn.py
+2
@@ -477,6 +477,7 @@ class FlashAttnPrefillBackend(MLAPrefillBackend):
477 q: torch.Tensor,
478 k: torch.Tensor,
479 v: torch.Tensor,
480 + out: torch.Tensor | None = None,
481 ) -> tuple[torch.Tensor, torch.Tensor]:
482 return self._flash_attn_varlen_diff_headdims(
483 q=q,
@@ -489,6 +490,7 @@ class FlashAttnPrefillBackend(MLAPrefillBackend):
490 softmax_scale=self.scale,
491 causal=False, # Context is unmasked
492 return_softmax_lse=True,
493 + out=out,
494 )
495
496
vllm/v1/attention/backends/mla/prefill/flashinfer.py
+2
@@ -228,11 +228,13 @@ class FlashInferPrefillBackend(MLAPrefillBackend):
228 q: torch.Tensor,
229 k: torch.Tensor,
230 v: torch.Tensor,
231 + out: torch.Tensor | None = None,
232 ) -> tuple[torch.Tensor, torch.Tensor]:
233 attn_out, lse = self._prefill_chunks[chunk.index].run(
234 q=q,
235 k=k,
236 v=v,
237 + out=out,
238 return_lse=True,
239 )
240
vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py
+4 -1
@@ -166,11 +166,13 @@ class TokenspeedMLAPrefillBackend(MLAPrefillBackend):
166 q: torch.Tensor,
167 k: torch.Tensor,
168 v: torch.Tensor,
169 + out: torch.Tensor | None = None,
170 ) -> tuple[torch.Tensor, torch.Tensor]:
171 from tokenspeed_mla import tokenspeed_mla_prefill
172
173 # See note in run_prefill_new_tokens — `v` is a split-view of `kv_nope`
173 - # in `_compute_prefill_context` and arrives non-contiguous.
174 + # in the generic `_compute_prefill_context` and arrives non-contiguous.
175 + # (K3's fused fp8 K/V pack already hands over a contiguous `v`.)
176 v = v.contiguous()
177
178 attn_out, lse = tokenspeed_mla_prefill(
@@ -187,6 +189,7 @@ class TokenspeedMLAPrefillBackend(MLAPrefillBackend):
189 cum_seq_lens_q=chunk.query_start_loc,
190 max_seq_len_q=chunk.max_query_len,
191 enable_pdl=False,
192 + out=out,
193 )
194
195 # Convert from (q_len, num_heads) to (num_heads, q_len)
vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py
+9 -7
@@ -147,16 +147,18 @@ class TrtllmRaggedPrefillBackend(MLAPrefillBackend):
147 q: torch.Tensor,
148 k: torch.Tensor,
149 v: torch.Tensor,
150 + out: torch.Tensor | None = None,
151 ) -> tuple[torch.Tensor, torch.Tensor]:
152 from flashinfer.prefill import trtllm_ragged_attention_deepseek
153
153 - out = torch.empty(
154 - q.shape[0],
155 - q.shape[1],
156 - v.shape[2],
157 - device=q.device,
158 - dtype=self._prefill_metadata.output_dtype,
159 - )
154 + if out is None:
155 + out = torch.empty(
156 + q.shape[0],
157 + q.shape[1],
158 + v.shape[2],
159 + device=q.device,
160 + dtype=self._prefill_metadata.output_dtype,
161 + )
162
163 attn_out, lse = trtllm_ragged_attention_deepseek(
164 query=q,