[Kimi-K3] DCP support (#50484)

Signed-off-by: girasoley <girasoley@inferact.ai> Signed-off-by: Summer Yang <girasoleyang@gmail.com> Signed-off-by: 云挚 <ningyunxiao.nyx@antgroup.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: 云挚 <ningyunxiao.nyx@antgroup.com> Co-authored-by: foraxe <73625538+foraxe@users.noreply.github.com> Co-authored-by: Woosuk Kwon <woosuk@inferact.ai>

Summer Yang committed Aug 10, 2026 at 12:54 UTC 63ac04a61e6272b746d82e180e7f6276b3ee1ede
19 files changed +3529 -82
CMakeLists.txt
+5
@@ -474,6 +474,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
474 endif()
475
476 if(VLLM_GPU_LANG STREQUAL "CUDA")
477 + list(APPEND VLLM_STABLE_EXT_SRC
478 + "csrc/libtorch_stable/attention/dcp_utils/dcp_direct_a2a_lse_reduce.cu"
479 + "csrc/libtorch_stable/attention/dcp_utils/dcp_direct_kv_gather.cu"
480 + "csrc/libtorch_stable/attention/dcp_utils/dcp_direct_q_gather.cu")
481 +
482 SET(CUTLASS_ENABLE_HEADERS_ONLY ON CACHE BOOL "Enable only the header library")
483
484 # Set CUTLASS_REVISION. Used for FetchContent. Also fixes some bogus messages when building.
csrc/libtorch_stable/attention/dcp_utils/dcp_direct_a2a_lse_reduce.cu new
+381
@@ -0,0 +1,381 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +// Direct symmetric-memory DCP A2A LSE reduction.
4 +
5 +#include <cuda_bf16.h>
6 +#include <cuda_fp16.h>
7 +#include <math_constants.h>
8 +#include <torch/csrc/stable/library.h>
9 +#include <torch/headeronly/core/ScalarType.h>
10 +
11 +#include <cstdio>
12 +#include <optional>
13 +#include <type_traits>
14 +
15 +#include "dcp_direct_common.cuh"
16 +
17 +namespace {
18 +
19 +using vllm::direct_dcp::check_cuda_launch;
20 +using vllm::direct_dcp::get_peer_ptr;
21 +using vllm::direct_dcp::increment_epoch_kernel;
22 +using vllm::direct_dcp::store_release_system;
23 +using vllm::direct_dcp::wait_for_epoch;
24 +
25 +__device__ __forceinline__ int64_t find_sequence(const int32_t* query_start_loc,
26 + int64_t token_idx,
27 + int64_t num_seqs) {
28 + int64_t left = 0;
29 + int64_t right = num_seqs;
30 + while (left < right) {
31 + int64_t mid = (left + right) / 2;
32 + if (query_start_loc[mid] <= token_idx) {
33 + left = mid + 1;
34 + } else {
35 + right = mid;
36 + }
37 + }
38 + return left - 1;
39 +}
40 +
41 +template <typename scalar_t>
42 +__device__ __forceinline__ float to_float(scalar_t value) {
43 + if constexpr (std::is_same_v<scalar_t, float>) {
44 + return value;
45 + } else if constexpr (std::is_same_v<scalar_t, __half>) {
46 + return __half2float(value);
47 + } else {
48 + return __bfloat162float(value);
49 + }
50 +}
51 +
52 +template <typename scalar_t>
53 +__device__ __forceinline__ scalar_t from_float(float value) {
54 + if constexpr (std::is_same_v<scalar_t, __half>) {
55 + return __float2half_rn(value);
56 + } else {
57 + return __float2bfloat16_rn(value);
58 + }
59 +}
60 +
61 +// Send each destination's head slice and LSE to its symmetric buffer. Mask
62 +// undefined empty-shard rows at the source so peers give them zero weight.
63 +template <typename lse_t>
64 +__global__ void dispatch_output_lse_kernel(
65 + const uint4* partial_output, const lse_t* partial_lse,
66 + const int32_t* seq_lens, const int32_t* query_start_loc,
67 + const int64_t* peer_output_ptrs, const int64_t* peer_lse_ptrs,
68 + const int64_t* epoch_ptr, int64_t world_size, int64_t rank,
69 + int64_t num_tokens, int64_t max_num_tokens, int64_t num_seqs,
70 + int64_t heads_per_rank, int64_t head_dim, int64_t output_token_stride,
71 + int64_t lse_token_stride) {
72 + int64_t item = static_cast<int64_t>(blockIdx.x);
73 + int64_t destination_rank = item / num_tokens;
74 + int64_t token_idx = item - destination_rank * num_tokens;
75 +
76 + __shared__ bool empty_kv;
77 + if (threadIdx.x == 0) {
78 + empty_kv = false;
79 + if (seq_lens != nullptr) {
80 + int64_t seq_idx = find_sequence(query_start_loc, token_idx, num_seqs);
81 + empty_kv = seq_lens[seq_idx] == 0;
82 + }
83 + }
84 + __syncthreads();
85 +
86 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
87 + int64_t parity = static_cast<int64_t>(epoch & 1u);
88 + int64_t destination_item =
89 + ((parity * world_size + rank) * max_num_tokens + token_idx) *
90 + heads_per_rank;
91 + int64_t source_head = destination_rank * heads_per_rank;
92 +
93 + if (!empty_kv) {
94 + uint4* peer_output =
95 + get_peer_ptr<uint4>(peer_output_ptrs, destination_rank);
96 + int64_t vectors_per_item = heads_per_rank * head_dim / 8;
97 + int64_t source_vector =
98 + (token_idx * output_token_stride + source_head * head_dim) / 8;
99 + int64_t destination_vector = destination_item * head_dim / 8;
100 + for (int64_t vector_idx = threadIdx.x; vector_idx < vectors_per_item;
101 + vector_idx += blockDim.x) {
102 + peer_output[destination_vector + vector_idx] =
103 + partial_output[source_vector + vector_idx];
104 + }
105 + }
106 +
107 + float* peer_lse = get_peer_ptr<float>(peer_lse_ptrs, destination_rank);
108 + int64_t source_lse = token_idx * lse_token_stride + source_head;
109 + for (int64_t head_idx = threadIdx.x; head_idx < heads_per_rank;
110 + head_idx += blockDim.x) {
111 + peer_lse[destination_item + head_idx] =
112 + empty_kv ? -CUDART_INF_F : to_float(partial_lse[source_lse + head_idx]);
113 + }
114 +}
115 +
116 +// Publish completion after the stream-ordered payload writes.
117 +__global__ void signal_kernel(const int64_t* peer_signal_ptrs,
118 + const int64_t* epoch_ptr, int64_t world_size,
119 + int64_t rank) {
120 + int64_t destination_rank =
121 + static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x;
122 + if (destination_rank >= world_size) {
123 + return;
124 + }
125 +
126 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
127 + int64_t parity = static_cast<int64_t>(epoch & 1u);
128 + uint32_t* peer_signal =
129 + get_peer_ptr<uint32_t>(peer_signal_ptrs, destination_rank);
130 + int64_t signal_item = parity * world_size + rank;
131 + store_release_system(peer_signal + signal_item, epoch);
132 +}
133 +
134 +// Form stable base-2 LSE weights and combine in FP32.
135 +template <typename scalar_t>
136 +__global__ void wait_lse_combine_kernel(
137 + const scalar_t* received_output, const float* received_lse,
138 + const uint32_t* received_signal, const int64_t* epoch_ptr,
139 + scalar_t* combined_output, int64_t world_size, int64_t num_tokens,
140 + int64_t max_num_tokens, int64_t heads_per_rank, int64_t head_dim,
141 + bool is_lse_base_on_e) {
142 + extern __shared__ float weights[];
143 +
144 + int64_t item = static_cast<int64_t>(blockIdx.x);
145 + int64_t token_idx = item / heads_per_rank;
146 + int64_t head_idx = item - token_idx * heads_per_rank;
147 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
148 + int64_t parity = static_cast<int64_t>(epoch & 1u);
149 +
150 + if (threadIdx.x == 0) {
151 + float lse_max = -CUDART_INF_F;
152 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
153 + int64_t source_item =
154 + ((parity * world_size + source_rank) * max_num_tokens + token_idx) *
155 + heads_per_rank +
156 + head_idx;
157 + int64_t signal_item = parity * world_size + source_rank;
158 + if (!wait_for_epoch(received_signal + signal_item, epoch)) {
159 + printf(
160 + "direct DCP A2A timeout source=%lld token=%lld head=%lld "
161 + "epoch=%u\n",
162 + static_cast<long long>(source_rank),
163 + static_cast<long long>(token_idx), static_cast<long long>(head_idx),
164 + epoch);
165 + asm volatile("trap;");
166 + }
167 + float value = received_lse[source_item];
168 + if (isnan(value) || value == CUDART_INF_F) {
169 + value = -CUDART_INF_F;
170 + }
171 + if (is_lse_base_on_e) {
172 + value *= CUDART_L2E_F;
173 + }
174 + weights[source_rank] = value;
175 + lse_max = fmaxf(lse_max, value);
176 + }
177 + if (lse_max == -CUDART_INF_F) {
178 + lse_max = 0.0f;
179 + }
180 +
181 + float lse_sum = 0.0f;
182 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
183 + lse_sum += exp2f(weights[source_rank] - lse_max);
184 + }
185 + float inverse_lse_sum = lse_sum > 0.0f ? 1.0f / lse_sum : 0.0f;
186 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
187 + weights[source_rank] =
188 + exp2f(weights[source_rank] - lse_max) * inverse_lse_sum;
189 + }
190 + }
191 + __syncthreads();
192 +
193 + for (int64_t dim_idx = threadIdx.x; dim_idx < head_dim;
194 + dim_idx += blockDim.x) {
195 + float accumulator = 0.0f;
196 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
197 + // Empty shards leave stale payloads, so never read zero-weight sources.
198 + float weight = weights[source_rank];
199 + if (weight == 0.0f) {
200 + continue;
201 + }
202 + int64_t source_item =
203 + ((parity * world_size + source_rank) * max_num_tokens + token_idx) *
204 + heads_per_rank +
205 + head_idx;
206 + accumulator +=
207 + to_float(received_output[source_item * head_dim + dim_idx]) * weight;
208 + }
209 + combined_output[item * head_dim + dim_idx] =
210 + from_float<scalar_t>(accumulator);
211 + }
212 +}
213 +
214 +void direct_dcp_a2a_lse_reduce(
215 + const torch::stable::Tensor& partial_output,
216 + const torch::stable::Tensor& partial_lse,
217 + const std::optional<torch::stable::Tensor>& seq_lens,
218 + const std::optional<torch::stable::Tensor>& query_start_loc,
219 + const torch::stable::Tensor& peer_output_ptrs,
220 + const torch::stable::Tensor& peer_lse_ptrs,
221 + const torch::stable::Tensor& peer_signal_ptrs,
222 + torch::stable::Tensor& received_output, torch::stable::Tensor& received_lse,
223 + torch::stable::Tensor& received_signal, torch::stable::Tensor& epoch,
224 + torch::stable::Tensor& combined_output, int64_t world_size, int64_t rank,
225 + int64_t max_num_tokens, bool is_lse_base_on_e) {
226 + STD_TORCH_CHECK(partial_output.is_cuda() && partial_lse.is_cuda(),
227 + "partial output and LSE must be CUDA tensors");
228 + auto output_dtype = partial_output.scalar_type();
229 + STD_TORCH_CHECK(output_dtype == torch::headeronly::ScalarType::Half ||
230 + output_dtype == torch::headeronly::ScalarType::BFloat16,
231 + "direct DCP A2A only supports FP16 and BF16 output");
232 + auto lse_dtype = partial_lse.scalar_type();
233 + STD_TORCH_CHECK(lse_dtype == torch::headeronly::ScalarType::Float ||
234 + lse_dtype == torch::headeronly::ScalarType::Half ||
235 + lse_dtype == torch::headeronly::ScalarType::BFloat16,
236 + "partial LSE must be FP32, FP16, or BF16");
237 + STD_TORCH_CHECK(partial_output.dim() == 3 && partial_lse.dim() == 2,
238 + "expected output [T,H,D] and LSE [T,H]");
239 + STD_TORCH_CHECK(world_size > 1, "world_size must be greater than 1");
240 + STD_TORCH_CHECK(rank >= 0 && rank < world_size, "invalid rank");
241 +
242 + int64_t num_tokens = partial_output.size(0);
243 + int64_t total_heads = partial_output.size(1);
244 + int64_t head_dim = partial_output.size(2);
245 + int64_t output_token_stride = partial_output.stride(0);
246 + int64_t lse_token_stride = partial_lse.stride(0);
247 + STD_TORCH_CHECK(
248 + partial_output.stride(2) == 1 && partial_output.stride(1) == head_dim &&
249 + output_token_stride >= total_heads * head_dim &&
250 + output_token_stride % 8 == 0,
251 + "partial output must have packed heads and an aligned token stride");
252 + STD_TORCH_CHECK(partial_lse.stride(1) == 1 && lse_token_stride >= total_heads,
253 + "partial LSE must have packed heads");
254 + STD_TORCH_CHECK(num_tokens > 0 && num_tokens <= max_num_tokens,
255 + "token count exceeds symmetric buffer capacity");
256 + STD_TORCH_CHECK(total_heads % world_size == 0,
257 + "attention heads must divide evenly across DCP ranks");
258 + STD_TORCH_CHECK(
259 + partial_lse.size(0) == num_tokens && partial_lse.size(1) == total_heads,
260 + "LSE shape must match attention output");
261 + STD_TORCH_CHECK(head_dim % 8 == 0,
262 + "head_dim must be divisible by 8 for 16-byte stores");
263 + int64_t heads_per_rank = total_heads / world_size;
264 + STD_TORCH_CHECK(combined_output.scalar_type() == output_dtype &&
265 + combined_output.is_contiguous() &&
266 + combined_output.is_cuda(),
267 + "combined output must match the contiguous CUDA input");
268 + STD_TORCH_CHECK(combined_output.size(0) == num_tokens &&
269 + combined_output.size(1) == heads_per_rank &&
270 + combined_output.size(2) == head_dim,
271 + "combined output has the wrong shape");
272 + STD_TORCH_CHECK(
273 + received_output.scalar_type() == output_dtype &&
274 + received_lse.scalar_type() == torch::headeronly::ScalarType::Float &&
275 + received_signal.scalar_type() == torch::headeronly::ScalarType::Int,
276 + "invalid symmetric staging dtypes");
277 + STD_TORCH_CHECK(
278 + peer_output_ptrs.scalar_type() == torch::headeronly::ScalarType::Long &&
279 + peer_lse_ptrs.scalar_type() == torch::headeronly::ScalarType::Long &&
280 + peer_signal_ptrs.scalar_type() == torch::headeronly::ScalarType::Long,
281 + "peer pointer tables must be int64");
282 + const int32_t* seq_lens_ptr = nullptr;
283 + const int32_t* query_start_loc_ptr = nullptr;
284 + int64_t num_seqs = 0;
285 + STD_TORCH_CHECK(seq_lens.has_value() == query_start_loc.has_value(),
286 + "seq_lens and query_start_loc must be provided together");
287 + if (seq_lens.has_value() && query_start_loc.has_value()) {
288 + STD_TORCH_CHECK(
289 + seq_lens->is_cuda() && seq_lens->dim() == 1 &&
290 + seq_lens->stride(0) == 1 &&
291 + seq_lens->scalar_type() == torch::headeronly::ScalarType::Int,
292 + "seq_lens must be a contiguous 1-D int32 CUDA tensor");
293 + STD_TORCH_CHECK(
294 + query_start_loc->is_cuda() && query_start_loc->dim() == 1 &&
295 + query_start_loc->stride(0) == 1 &&
296 + query_start_loc->scalar_type() ==
297 + torch::headeronly::ScalarType::Int,
298 + "query_start_loc must be a contiguous 1-D int32 CUDA tensor");
299 + num_seqs = seq_lens->size(0);
300 + STD_TORCH_CHECK(num_seqs > 0 && query_start_loc->size(0) == num_seqs + 1,
301 + "query_start_loc must contain one boundary per sequence");
302 + seq_lens_ptr = seq_lens->const_data_ptr<int32_t>();
303 + query_start_loc_ptr = query_start_loc->const_data_ptr<int32_t>();
304 + }
305 +
306 + const torch::stable::accelerator::DeviceGuard device_guard(
307 + partial_output.get_device_index());
308 + cudaStream_t stream = get_current_cuda_stream();
309 + constexpr int kExchangeThreads = 256;
310 + constexpr int kCombineThreads = 128;
311 +
312 + increment_epoch_kernel<<<1, 1, 0, stream>>>(
313 + epoch.mutable_data_ptr<int64_t>());
314 + check_cuda_launch("direct DCP A2A");
315 + int64_t dispatch_blocks = world_size * num_tokens;
316 + auto launch_dispatch = [&]<typename lse_t>() {
317 + dispatch_output_lse_kernel<lse_t>
318 + <<<dispatch_blocks, kExchangeThreads, 0, stream>>>(
319 + reinterpret_cast<const uint4*>(partial_output.data_ptr()),
320 + reinterpret_cast<const lse_t*>(partial_lse.data_ptr()),
321 + seq_lens_ptr, query_start_loc_ptr,
322 + peer_output_ptrs.const_data_ptr<int64_t>(),
323 + peer_lse_ptrs.const_data_ptr<int64_t>(),
324 + epoch.const_data_ptr<int64_t>(), world_size, rank, num_tokens,
325 + max_num_tokens, num_seqs, heads_per_rank, head_dim,
326 + output_token_stride, lse_token_stride);
327 + };
328 + if (lse_dtype == torch::headeronly::ScalarType::Float) {
329 + launch_dispatch.operator()<float>();
330 + } else if (lse_dtype == torch::headeronly::ScalarType::BFloat16) {
331 + launch_dispatch.operator()<__nv_bfloat16>();
332 + } else {
333 + launch_dispatch.operator()<__half>();
334 + }
335 + check_cuda_launch("direct DCP A2A");
336 + int64_t signal_items = world_size;
337 + int64_t signal_blocks =
338 + (signal_items + kExchangeThreads - 1) / kExchangeThreads;
339 + signal_kernel<<<signal_blocks, kExchangeThreads, 0, stream>>>(
340 + peer_signal_ptrs.const_data_ptr<int64_t>(),
341 + epoch.const_data_ptr<int64_t>(), world_size, rank);
342 + check_cuda_launch("direct DCP A2A");
343 + int64_t combine_blocks = num_tokens * heads_per_rank;
344 + size_t shared_memory_bytes = world_size * sizeof(float);
345 + auto launch_combine = [&]<typename scalar_t>() {
346 + wait_lse_combine_kernel<scalar_t>
347 + <<<combine_blocks, kCombineThreads, shared_memory_bytes, stream>>>(
348 + reinterpret_cast<const scalar_t*>(received_output.data_ptr()),
349 + received_lse.const_data_ptr<float>(),
350 + reinterpret_cast<const uint32_t*>(
351 + received_signal.const_data_ptr<int32_t>()),
352 + epoch.const_data_ptr<int64_t>(),
353 + reinterpret_cast<scalar_t*>(combined_output.mutable_data_ptr()),
354 + world_size, num_tokens, max_num_tokens, heads_per_rank, head_dim,
355 + is_lse_base_on_e);
356 + };
357 + if (output_dtype == torch::headeronly::ScalarType::BFloat16) {
358 + launch_combine.operator()<__nv_bfloat16>();
359 + } else {
360 + launch_combine.operator()<__half>();
361 + }
362 + check_cuda_launch("direct DCP A2A");
363 +}
364 +
365 +} // namespace
366 +
367 +STABLE_TORCH_LIBRARY_FRAGMENT(_C, direct_dcp_a2a_ops) {
368 + direct_dcp_a2a_ops.def(
369 + "direct_dcp_a2a_lse_reduce("
370 + "Tensor partial_output, Tensor partial_lse, Tensor? seq_lens, "
371 + "Tensor? query_start_loc, Tensor peer_output_ptrs, "
372 + "Tensor peer_lse_ptrs, Tensor peer_signal_ptrs, Tensor! received_output, "
373 + "Tensor! received_lse, Tensor! received_signal, Tensor! epoch, "
374 + "Tensor! combined_output, int world_size, int rank, "
375 + "int max_num_tokens, bool is_lse_base_on_e) -> ()");
376 +}
377 +
378 +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, direct_dcp_a2a_ops) {
379 + direct_dcp_a2a_ops.impl("direct_dcp_a2a_lse_reduce",
380 + TORCH_BOX(&direct_dcp_a2a_lse_reduce));
381 +}
csrc/libtorch_stable/attention/dcp_utils/dcp_direct_common.cuh new
+92
@@ -0,0 +1,92 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +#pragma once
5 +
6 +#include <cuda_runtime.h>
7 +
8 +#include <cstdint>
9 +#include <string>
10 +
11 +#include "../../torch_utils.h"
12 +
13 +namespace vllm::direct_dcp {
14 +
15 +constexpr uint64_t kSpinLimit = 100000000;
16 +
17 +// Advance the invocation ID; its low bit selects one of two staging slots.
18 +static __global__ void increment_epoch_kernel(int64_t* epoch) {
19 + if (blockIdx.x == 0 && threadIdx.x == 0) {
20 + epoch[0] += 1;
21 + }
22 +}
23 +
24 +template <typename T>
25 +__device__ __forceinline__ T* get_peer_ptr(const int64_t* peer_ptrs,
26 + int64_t peer) {
27 + return reinterpret_cast<T*>(static_cast<uintptr_t>(peer_ptrs[peer]));
28 +}
29 +
30 +// Replicate one 16-byte payload to every symmetric-buffer replica.
31 +__device__ __forceinline__ void multimem_store_16(uint4* mc_ptr, uint4 value) {
32 +#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
33 + asm volatile("multimem.st.relaxed.sys.global.v4.f32 [%0], {%1,%2,%3,%4};"
34 + :
35 + : "l"(mc_ptr), "r"(value.x), "r"(value.y), "r"(value.z),
36 + "r"(value.w)
37 + : "memory");
38 +#else
39 + asm volatile("trap;");
40 +#endif
41 +}
42 +
43 +// Publish prior system-scope writes and signal every replica.
44 +__device__ __forceinline__ void multimem_store_release_system(uint32_t* mc_ptr,
45 + uint32_t value) {
46 +#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
47 + asm volatile("multimem.st.release.sys.global.u32 [%0], %1;"
48 + :
49 + : "l"(mc_ptr), "r"(value)
50 + : "memory");
51 +#else
52 + asm volatile("trap;");
53 +#endif
54 +}
55 +
56 +__device__ __forceinline__ void store_release_system(uint32_t* ptr,
57 + uint32_t value) {
58 + uint64_t address = reinterpret_cast<uint64_t>(ptr);
59 + asm volatile("st.global.release.sys.u32 [%0], %1;"
60 + :
61 + : "l"(address), "r"(value)
62 + : "memory");
63 +}
64 +
65 +__device__ __forceinline__ uint32_t load_acquire_system(const uint32_t* ptr) {
66 + uint32_t value;
67 + uint64_t address = reinterpret_cast<uint64_t>(ptr);
68 + asm volatile("ld.global.acquire.sys.u32 %0, [%1];"
69 + : "=r"(value)
70 + : "l"(address)
71 + : "memory");
72 + return value;
73 +}
74 +
75 +__device__ __forceinline__ bool wait_for_epoch(const uint32_t* ptr,
76 + uint32_t epoch) {
77 + for (uint64_t spins = 0; spins < kSpinLimit; ++spins) {
78 + if (load_acquire_system(ptr) == epoch) {
79 + return true;
80 + }
81 + }
82 + return false;
83 +}
84 +
85 +inline void check_cuda_launch(const char* operation) {
86 + cudaError_t error = cudaGetLastError();
87 + STD_TORCH_CHECK(error == cudaSuccess,
88 + std::string(operation) +
89 + " kernel launch failed: " + cudaGetErrorString(error));
90 +}
91 +
92 +} // namespace vllm::direct_dcp
csrc/libtorch_stable/attention/dcp_utils/dcp_direct_kv_gather.cu new
+209
@@ -0,0 +1,209 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +// Direct symmetric-memory DCP KV gather.
4 +
5 +#include <torch/csrc/stable/library.h>
6 +#include <torch/headeronly/core/ScalarType.h>
7 +
8 +#include <cstdio>
9 +
10 +#include "dcp_direct_common.cuh"
11 +
12 +namespace {
13 +
14 +using vllm::direct_dcp::check_cuda_launch;
15 +using vllm::direct_dcp::increment_epoch_kernel;
16 +using vllm::direct_dcp::multimem_store_16;
17 +using vllm::direct_dcp::multimem_store_release_system;
18 +using vllm::direct_dcp::wait_for_epoch;
19 +
20 +constexpr int kThreads = 256;
21 +// KV chunks need many blocks in flight to saturate the fabric.
22 +constexpr int64_t kMaxMulticastBlocks = 128;
23 +constexpr int64_t kMaxCopyBlocks = 1024;
24 +
25 +// Multicast each rank's KV slice and completion epoch to every replica.
26 +__global__ void direct_dcp_kv_gather_multimem_kernel(
27 + const uint4* local_kv, uint4* mc_kv, uint32_t* mc_signal,
28 + const uint32_t* received_signal, const int64_t* epoch_ptr,
29 + uint32_t* completion, int64_t world_size, int64_t rank, int64_t slice_bytes,
30 + int64_t slot_stride_bytes) {
31 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
32 + int64_t buffer_slot = static_cast<int64_t>(epoch & 1u);
33 + int64_t item_count = slice_bytes / sizeof(uint4);
34 + mc_kv += buffer_slot * slot_stride_bytes / sizeof(uint4);
35 + mc_kv += rank * item_count;
36 +
37 + int64_t item_stride = static_cast<int64_t>(gridDim.x) * blockDim.x;
38 + for (int64_t item =
39 + static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x;
40 + item < item_count; item += item_stride) {
41 + multimem_store_16(mc_kv + item, local_kv[item]);
42 + }
43 +
44 + // Publish all multicast writes before incrementing completion.
45 + __threadfence_system();
46 + __syncthreads();
47 + if (threadIdx.x != 0) {
48 + return;
49 + }
50 +
51 + uint32_t completed = atomicAdd(completion + buffer_slot, 1u);
52 + if (completed + 1u != gridDim.x) {
53 + return;
54 + }
55 + atomicExch(completion + buffer_slot, 0u);
56 +
57 + multimem_store_release_system(mc_signal + buffer_slot * world_size + rank,
58 + epoch);
59 +
60 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
61 + int64_t signal_item = buffer_slot * world_size + source_rank;
62 + if (!wait_for_epoch(received_signal + signal_item, epoch)) {
63 + printf("direct DCP kv-gather multimem timeout source=%lld epoch=%u\n",
64 + static_cast<long long>(source_rank), epoch);
65 + asm volatile("trap;");
66 + }
67 + }
68 +}
69 +
70 +// Materialize the acquired slot into the caller's workspace.
71 +template <typename copy_t>
72 +__global__ void materialize_kv_gather_kernel(const copy_t* received_kv,
73 + const int64_t* epoch_ptr,
74 + copy_t* gathered_kv,
75 + int64_t gathered_item_count,
76 + int64_t slot_stride_items) {
77 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
78 + int64_t buffer_slot = static_cast<int64_t>(epoch & 1u);
79 + received_kv += buffer_slot * slot_stride_items;
80 + int64_t item = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x;
81 + int64_t item_stride = static_cast<int64_t>(gridDim.x) * blockDim.x;
82 + for (; item < gathered_item_count; item += item_stride) {
83 + gathered_kv[item] = received_kv[item];
84 + }
85 +}
86 +
87 +void direct_dcp_kv_gather(
88 + const torch::stable::Tensor& local_kv, torch::stable::Tensor& received_kv,
89 + torch::stable::Tensor& received_signal, torch::stable::Tensor& completion,
90 + torch::stable::Tensor& epoch, torch::stable::Tensor& gathered_kv,
91 + int64_t world_size, int64_t rank, int64_t max_gathered_tokens,
92 + int64_t kv_mc_ptr, int64_t signal_mc_ptr) {
93 + using torch::headeronly::ScalarType;
94 +
95 + STD_TORCH_CHECK(local_kv.is_cuda(), "local kv must be a CUDA tensor");
96 + ScalarType dtype = local_kv.scalar_type();
97 + STD_TORCH_CHECK(dtype == ScalarType::Half || dtype == ScalarType::BFloat16 ||
98 + dtype == ScalarType::Float8_e4m3fn,
99 + "direct DCP kv-gather only supports FP16, BF16, and FP8");
100 + STD_TORCH_CHECK(local_kv.dim() == 2 && local_kv.is_contiguous(),
101 + "local kv must be a contiguous [T,D] tensor");
102 + STD_TORCH_CHECK(world_size > 1, "world_size must be greater than 1");
103 + STD_TORCH_CHECK(rank >= 0 && rank < world_size, "invalid rank");
104 +
105 + int64_t num_tokens = local_kv.size(0);
106 + int64_t token_dim = local_kv.size(1);
107 + int64_t element_size = local_kv.element_size();
108 + STD_TORCH_CHECK(num_tokens > 0 && token_dim > 0,
109 + "local kv dimensions must be positive");
110 + STD_TORCH_CHECK(num_tokens * world_size <= max_gathered_tokens,
111 + "gathered kv exceeds symmetric kv-gather buffer capacity");
112 +
113 + STD_TORCH_CHECK(received_kv.is_cuda() && received_kv.scalar_type() == dtype &&
114 + received_kv.is_contiguous() && received_kv.dim() == 3 &&
115 + received_kv.size(0) == 2 &&
116 + received_kv.size(1) == max_gathered_tokens &&
117 + received_kv.size(2) == token_dim,
118 + "received kv has the wrong symmetric buffer layout");
119 + STD_TORCH_CHECK(
120 + received_signal.is_cuda() && received_signal.is_contiguous() &&
121 + received_signal.scalar_type() == ScalarType::Int &&
122 + received_signal.dim() == 2 && received_signal.size(0) == 2 &&
123 + received_signal.size(1) == world_size,
124 + "received signal has the wrong symmetric buffer layout");
125 + STD_TORCH_CHECK(completion.is_cuda() && completion.is_contiguous() &&
126 + completion.scalar_type() == ScalarType::Int &&
127 + completion.numel() == 2,
128 + "completion counter must be a two-element CUDA int32 tensor");
129 + STD_TORCH_CHECK(epoch.is_cuda() && epoch.is_contiguous() &&
130 + epoch.scalar_type() == ScalarType::Long &&
131 + epoch.numel() == 1,
132 + "epoch must be a one-element CUDA int64 tensor");
133 + STD_TORCH_CHECK(gathered_kv.is_cuda() && gathered_kv.scalar_type() == dtype &&
134 + gathered_kv.is_contiguous() && gathered_kv.dim() == 2 &&
135 + gathered_kv.size(0) == num_tokens * world_size &&
136 + gathered_kv.size(1) == token_dim,
137 + "gathered kv must be contiguous with shape [world_size*T,D]");
138 + int64_t device_index = local_kv.get_device_index();
139 + STD_TORCH_CHECK(
140 + received_kv.get_device_index() == device_index &&
141 + received_signal.get_device_index() == device_index &&
142 + completion.get_device_index() == device_index &&
143 + epoch.get_device_index() == device_index &&
144 + gathered_kv.get_device_index() == device_index,
145 + "direct DCP kv-gather tensors must be on the same CUDA device");
146 +
147 + int64_t slice_bytes = num_tokens * token_dim * element_size;
148 + int64_t slot_stride_bytes = max_gathered_tokens * token_dim * element_size;
149 + bool vectorized =
150 + reinterpret_cast<uintptr_t>(local_kv.data_ptr()) % alignof(uint4) == 0 &&
151 + reinterpret_cast<uintptr_t>(received_kv.data_ptr()) % alignof(uint4) ==
152 + 0 &&
153 + reinterpret_cast<uintptr_t>(gathered_kv.data_ptr()) % alignof(uint4) ==
154 + 0 &&
155 + slice_bytes % sizeof(uint4) == 0 &&
156 + slot_stride_bytes % sizeof(uint4) == 0;
157 + STD_TORCH_CHECK(
158 + vectorized,
159 + "direct DCP kv-gather requires 16-byte-aligned pointers and strides");
160 + STD_TORCH_CHECK(kv_mc_ptr != 0 && signal_mc_ptr != 0,
161 + "direct DCP kv-gather requires multicast pointers");
162 +
163 + const torch::stable::accelerator::DeviceGuard device_guard(device_index);
164 + cudaStream_t stream = get_current_cuda_stream();
165 + increment_epoch_kernel<<<1, 1, 0, stream>>>(
166 + epoch.mutable_data_ptr<int64_t>());
167 + check_cuda_launch("direct DCP kv-gather");
168 +
169 + int64_t item_count = slice_bytes / sizeof(uint4);
170 + int64_t blocks = (item_count + kThreads - 1) / kThreads;
171 + blocks = blocks < kMaxMulticastBlocks ? blocks : kMaxMulticastBlocks;
172 + direct_dcp_kv_gather_multimem_kernel<<<blocks, kThreads, 0, stream>>>(
173 + reinterpret_cast<const uint4*>(local_kv.data_ptr()),
174 + reinterpret_cast<uint4*>(static_cast<uintptr_t>(kv_mc_ptr)),
175 + reinterpret_cast<uint32_t*>(static_cast<uintptr_t>(signal_mc_ptr)),
176 + reinterpret_cast<const uint32_t*>(
177 + received_signal.const_data_ptr<int32_t>()),
178 + epoch.const_data_ptr<int64_t>(),
179 + reinterpret_cast<uint32_t*>(completion.mutable_data_ptr<int32_t>()),
180 + world_size, rank, slice_bytes, slot_stride_bytes);
181 + check_cuda_launch("direct DCP kv-gather");
182 +
183 + int64_t gathered_item_count = world_size * item_count;
184 + int64_t copy_blocks = (gathered_item_count + kThreads - 1) / kThreads;
185 + copy_blocks = copy_blocks < kMaxCopyBlocks ? copy_blocks : kMaxCopyBlocks;
186 + materialize_kv_gather_kernel<uint4><<<copy_blocks, kThreads, 0, stream>>>(
187 + reinterpret_cast<const uint4*>(received_kv.data_ptr()),
188 + epoch.const_data_ptr<int64_t>(),
189 + reinterpret_cast<uint4*>(gathered_kv.mutable_data_ptr()),
190 + gathered_item_count, slot_stride_bytes / sizeof(uint4));
191 + check_cuda_launch("direct DCP kv-gather");
192 +}
193 +
194 +} // namespace
195 +
196 +STABLE_TORCH_LIBRARY_FRAGMENT(_C, direct_dcp_kv_gather_ops) {
197 + direct_dcp_kv_gather_ops.def(
198 + "direct_dcp_kv_gather("
199 + "Tensor local_kv, Tensor! received_kv, Tensor! received_signal, "
200 + "Tensor! completion, "
201 + "Tensor! epoch, Tensor! gathered_kv, "
202 + "int world_size, int rank, int max_gathered_tokens, "
203 + "int kv_mc_ptr, int signal_mc_ptr) -> ()");
204 +}
205 +
206 +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, direct_dcp_kv_gather_ops) {
207 + direct_dcp_kv_gather_ops.impl("direct_dcp_kv_gather",
208 + TORCH_BOX(&direct_dcp_kv_gather));
209 +}
csrc/libtorch_stable/attention/dcp_utils/dcp_direct_q_gather.cu new
+197
@@ -0,0 +1,197 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +// Direct symmetric-memory DCP query gather into the consumer-final buffer.
4 +
5 +#include <torch/csrc/stable/library.h>
6 +#include <torch/headeronly/core/ScalarType.h>
7 +
8 +#include <cstdio>
9 +
10 +#include "dcp_direct_common.cuh"
11 +
12 +namespace {
13 +
14 +using vllm::direct_dcp::check_cuda_launch;
15 +using vllm::direct_dcp::increment_epoch_kernel;
16 +using vllm::direct_dcp::multimem_store_16;
17 +using vllm::direct_dcp::multimem_store_release_system;
18 +using vllm::direct_dcp::wait_for_epoch;
19 +
20 +// Multicast each rank's head slice directly into every consumer's final query
21 +// buffer. Reuse is ordered by the downstream DCP output synchronization.
22 +__global__ void direct_dcp_q_gather_multimem_kernel(
23 + const uint4* local_query, uint4* mc_final_query, uint32_t* mc_signal,
24 + const uint32_t* received_signal, int64_t* epoch_ptr, uint32_t* completion,
25 + int64_t world_size, int64_t rank, int64_t num_tokens,
26 + int64_t bytes_per_token, int64_t query_token_stride_bytes,
27 + int64_t destination_token_stride_bytes) {
28 + // The common one-token decode case uses one block. Fold the epoch update
29 + // into that publication kernel to avoid an otherwise separate tiny launch.
30 + if (gridDim.x == 1 && threadIdx.x == 0) {
31 + epoch_ptr[0] += 1;
32 + }
33 + __syncthreads();
34 + uint32_t epoch = static_cast<uint32_t>(epoch_ptr[0]);
35 + int64_t signal_slot = static_cast<int64_t>(epoch & 1u);
36 +
37 + int64_t items_per_token = bytes_per_token / sizeof(uint4);
38 + int64_t source_token_stride = query_token_stride_bytes / sizeof(uint4);
39 + int64_t destination_token_stride =
40 + destination_token_stride_bytes / sizeof(uint4);
41 + int64_t destination_head_offset = rank * items_per_token;
42 + for (int64_t token_idx = blockIdx.x; token_idx < num_tokens;
43 + token_idx += gridDim.x) {
44 + for (int64_t token_item = threadIdx.x; token_item < items_per_token;
45 + token_item += blockDim.x) {
46 + multimem_store_16(
47 + mc_final_query + token_idx * destination_token_stride +
48 + destination_head_offset + token_item,
49 + local_query[token_idx * source_token_stride + token_item]);
50 + }
51 + }
52 +
53 + // Publish all multicast writes before incrementing completion.
54 + __threadfence_system();
55 + __syncthreads();
56 + if (threadIdx.x != 0) {
57 + return;
58 + }
59 +
60 + if (gridDim.x > 1) {
61 + uint32_t completed = atomicAdd(completion, 1u);
62 + if (completed + 1u != gridDim.x) {
63 + return;
64 + }
65 + atomicExch(completion, 0u);
66 + }
67 +
68 + multimem_store_release_system(mc_signal + signal_slot * world_size + rank,
69 + epoch);
70 +
71 + for (int64_t source_rank = 0; source_rank < world_size; ++source_rank) {
72 + int64_t signal_item = signal_slot * world_size + source_rank;
73 + if (!wait_for_epoch(received_signal + signal_item, epoch)) {
74 + printf("direct DCP q-gather multimem timeout source=%lld epoch=%u\n",
75 + static_cast<long long>(source_rank), epoch);
76 + asm volatile("trap;");
77 + }
78 + }
79 +}
80 +
81 +void direct_dcp_q_gather(const torch::stable::Tensor& local_query,
82 + torch::stable::Tensor& final_query,
83 + torch::stable::Tensor& received_signal,
84 + torch::stable::Tensor& completion,
85 + torch::stable::Tensor& epoch, int64_t world_size,
86 + int64_t rank, int64_t max_num_tokens,
87 + int64_t padded_num_heads, int64_t query_mc_ptr,
88 + int64_t signal_mc_ptr) {
89 + using torch::headeronly::ScalarType;
90 +
91 + STD_TORCH_CHECK(local_query.is_cuda(), "local query must be a CUDA tensor");
92 + ScalarType dtype = local_query.scalar_type();
93 + STD_TORCH_CHECK(local_query.dim() == 3,
94 + "local query must have shape [T,H,D]");
95 + STD_TORCH_CHECK(world_size > 1, "world_size must be greater than 1");
96 + STD_TORCH_CHECK(rank >= 0 && rank < world_size, "invalid rank");
97 +
98 + int64_t num_tokens = local_query.size(0);
99 + int64_t heads_per_rank = local_query.size(1);
100 + int64_t head_dim = local_query.size(2);
101 + int64_t gathered_num_heads = world_size * heads_per_rank;
102 + int64_t element_size = local_query.element_size();
103 + STD_TORCH_CHECK(num_tokens > 0 && num_tokens <= max_num_tokens,
104 + "token count exceeds symmetric q-gather buffer capacity");
105 + STD_TORCH_CHECK(heads_per_rank > 0 && head_dim > 0,
106 + "query head dimensions must be positive");
107 + STD_TORCH_CHECK(padded_num_heads >= gathered_num_heads,
108 + "padded query heads must cover all gathered heads");
109 + STD_TORCH_CHECK(local_query.stride(2) == 1 &&
110 + local_query.stride(1) == head_dim &&
111 + local_query.stride(0) >= heads_per_rank * head_dim,
112 + "local query must have packed heads");
113 +
114 + STD_TORCH_CHECK(
115 + final_query.is_cuda() && final_query.scalar_type() == dtype &&
116 + final_query.is_contiguous() && final_query.dim() == 3 &&
117 + final_query.size(0) == num_tokens &&
118 + final_query.size(1) == gathered_num_heads &&
119 + final_query.size(2) == head_dim,
120 + "final query must be contiguous with shape [T,world_size*H,D]");
121 + STD_TORCH_CHECK(
122 + received_signal.is_cuda() && received_signal.is_contiguous() &&
123 + received_signal.scalar_type() == ScalarType::Int &&
124 + received_signal.dim() == 2 && received_signal.size(0) == 2 &&
125 + received_signal.size(1) == world_size,
126 + "received signal has the wrong symmetric buffer layout");
127 + STD_TORCH_CHECK(completion.is_cuda() && completion.is_contiguous() &&
128 + completion.scalar_type() == ScalarType::Int &&
129 + completion.numel() == 1,
130 + "completion counter must be one CUDA int32 tensor");
131 + STD_TORCH_CHECK(epoch.is_cuda() && epoch.is_contiguous() &&
132 + epoch.scalar_type() == ScalarType::Long &&
133 + epoch.numel() == 1,
134 + "epoch must be a one-element CUDA int64 tensor");
135 + int64_t device_index = local_query.get_device_index();
136 + STD_TORCH_CHECK(
137 + final_query.get_device_index() == device_index &&
138 + received_signal.get_device_index() == device_index &&
139 + completion.get_device_index() == device_index &&
140 + epoch.get_device_index() == device_index,
141 + "direct DCP q-gather tensors must be on the same CUDA device");
142 +
143 + int64_t query_token_stride_bytes = local_query.stride(0) * element_size;
144 + int64_t bytes_per_token = heads_per_rank * head_dim * element_size;
145 + int64_t gathered_token_stride_bytes =
146 + gathered_num_heads * head_dim * element_size;
147 + bool vectorized =
148 + reinterpret_cast<uintptr_t>(local_query.data_ptr()) % alignof(uint4) ==
149 + 0 &&
150 + reinterpret_cast<uintptr_t>(final_query.data_ptr()) % alignof(uint4) ==
151 + 0 &&
152 + query_token_stride_bytes % sizeof(uint4) == 0 &&
153 + bytes_per_token % sizeof(uint4) == 0;
154 + STD_TORCH_CHECK(
155 + vectorized,
156 + "direct DCP q-gather requires 16-byte-aligned pointers and strides");
157 + STD_TORCH_CHECK(query_mc_ptr != 0 && signal_mc_ptr != 0,
158 + "direct DCP q-gather requires multicast pointers");
159 +
160 + const torch::stable::accelerator::DeviceGuard device_guard(device_index);
161 + cudaStream_t stream = get_current_cuda_stream();
162 + constexpr int kThreads = 256;
163 + int64_t blocks = num_tokens < world_size ? num_tokens : world_size;
164 + if (blocks > 1) {
165 + increment_epoch_kernel<<<1, 1, 0, stream>>>(
166 + epoch.mutable_data_ptr<int64_t>());
167 + check_cuda_launch("direct DCP q-gather epoch");
168 + }
169 +
170 + direct_dcp_q_gather_multimem_kernel<<<blocks, kThreads, 0, stream>>>(
171 + reinterpret_cast<const uint4*>(local_query.data_ptr()),
172 + reinterpret_cast<uint4*>(static_cast<uintptr_t>(query_mc_ptr)),
173 + reinterpret_cast<uint32_t*>(static_cast<uintptr_t>(signal_mc_ptr)),
174 + reinterpret_cast<const uint32_t*>(
175 + received_signal.const_data_ptr<int32_t>()),
176 + epoch.mutable_data_ptr<int64_t>(),
177 + reinterpret_cast<uint32_t*>(completion.mutable_data_ptr<int32_t>()),
178 + world_size, rank, num_tokens, bytes_per_token, query_token_stride_bytes,
179 + gathered_token_stride_bytes);
180 + check_cuda_launch("direct DCP q-gather");
181 +}
182 +
183 +} // namespace
184 +
185 +STABLE_TORCH_LIBRARY_FRAGMENT(_C, direct_dcp_q_gather_ops) {
186 + direct_dcp_q_gather_ops.def(
187 + "direct_dcp_q_gather("
188 + "Tensor local_query, Tensor! final_query, Tensor! received_signal, "
189 + "Tensor! completion, Tensor! epoch, "
190 + "int world_size, int rank, int max_num_tokens, "
191 + "int padded_num_heads, int query_mc_ptr, int signal_mc_ptr) -> ()");
192 +}
193 +
194 +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, direct_dcp_q_gather_ops) {
195 + direct_dcp_q_gather_ops.impl("direct_dcp_q_gather",
196 + TORCH_BOX(&direct_dcp_q_gather));
197 +}
tests/distributed/test_dcp_a2a.py
+106 -2
@@ -222,6 +222,49 @@ class TestLSEWeightedCombine:
222 assert result.shape == (B, H, D)
223 torch.testing.assert_close(result, outputs[1], atol=1e-5, rtol=1e-5)
224
225 + def test_empty_shard_ignores_undefined_output(self):
226 + from vllm.v1.attention.ops.dcp_alltoall import _lse_weighted_combine
227 +
228 + outputs = torch.tensor([[[[float("nan")]]], [[[3.0]]]])
229 + lses = torch.tensor([[[-float("inf")]], [[0.0]]])
230 +
231 + result = _lse_weighted_combine(outputs, lses)
232 +
233 + torch.testing.assert_close(result, outputs[1])
234 +
235 + def test_ag_rs_masks_empty_shard_and_padded_lse(self, monkeypatch):
236 + import vllm.v1.attention.ops.common as common
237 +
238 + class FakeGroup:
239 + world_size = 2
240 + rank_in_group = 0
241 +
242 + def all_gather(self, tensor, dim):
243 + assert dim == 0
244 + return torch.cat((tensor, tensor), dim=dim)
245 +
246 + monkeypatch.setattr(
247 + common,
248 + "correct_attn_out",
249 + lambda output, lses, *args, **kwargs: (output, lses[0]),
250 + )
251 + output = torch.ones(7, 1, 1)
252 + lse = torch.ones(7, 1)
253 + seq_lens = torch.tensor([0, 2], dtype=torch.int32)
254 + query_start_loc = torch.tensor([0, 1, 5], dtype=torch.int32)
255 +
256 + _, masked_lse = common._cp_lse_common(
257 + output,
258 + lse,
259 + FakeGroup(),
260 + seq_lens=seq_lens,
261 + query_start_loc=query_start_loc,
262 + )
263 +
264 + assert torch.isneginf(masked_lse[:1]).all()
265 + torch.testing.assert_close(masked_lse[1:5], torch.ones_like(masked_lse[1:5]))
266 + assert torch.isneginf(masked_lse[5:]).all()
267 +
268 def test_mathematically_correct(self):
269 """Verify mathematical correctness of LSE combination."""
270 from vllm.v1.attention.ops.dcp_alltoall import _lse_weighted_combine
@@ -344,7 +387,7 @@ class TestPackedA2AKernels:
387 world_size, B, h_per_rank, D = 4, 7, 2, 32
388 H = world_size * h_per_rank
389 cp_attn_out = torch.randn(B, H, D, device=device, dtype=dtype)
347 - cp_attn_lse = torch.randn(B, H, device=device, dtype=torch.float32)
390 + cp_attn_lse = torch.randn(B, H, device=device, dtype=dtype)
391 lse_pack_dim = _dcp_a2a_lse_pack_dim(dtype)
392 send_buffer = torch.empty(
393 (world_size, B, h_per_rank, D + lse_pack_dim),
@@ -375,6 +418,67 @@ class TestPackedA2AKernels:
418 else:
419 _assert_packed_a2a_close(actual, expected_out, dtype)
420
421 + @pytest.mark.skipif(
422 + torch.accelerator.device_count() < 1, reason="CUDA is required."
423 + )
424 + def test_empty_seq_lens_ignore_undefined_output(self):
425 + from vllm.v1.attention.ops.dcp_alltoall import (
426 + _dcp_a2a_lse_pack_dim,
427 + _dcp_a2a_pack_send,
428 + _dcp_a2a_unpack_combine,
429 + )
430 +
431 + device = torch.device("cuda")
432 + world_size, num_tokens, h_per_rank, head_dim = 2, 5, 1, 32
433 + num_heads = world_size * h_per_rank
434 + output = torch.randn(
435 + num_tokens,
436 + num_heads,
437 + head_dim,
438 + device=device,
439 + dtype=torch.bfloat16,
440 + )
441 + output[:1] = float("nan")
442 + lse = torch.randn(num_tokens, num_heads, device=device, dtype=output.dtype)
443 + seq_lens = torch.tensor([0, 2], device=device, dtype=torch.int32)
444 + query_start_loc = torch.tensor([0, 1, 5], device=device, dtype=torch.int32)
445 + lse_pack_dim = _dcp_a2a_lse_pack_dim(output.dtype)
446 + send_buffer = torch.empty(
447 + (
448 + world_size,
449 + num_tokens,
450 + h_per_rank,
451 + head_dim + lse_pack_dim,
452 + ),
453 + device=device,
454 + dtype=output.dtype,
455 + )
456 +
457 + _dcp_a2a_pack_send(
458 + output,
459 + lse,
460 + send_buffer,
461 + world_size,
462 + h_per_rank,
463 + head_dim,
464 + lse_pack_dim,
465 + seq_lens=seq_lens,
466 + query_start_loc=query_start_loc,
467 + )
468 + actual_output, actual_lse = _dcp_a2a_unpack_combine(
469 + send_buffer,
470 + head_dim,
471 + lse_pack_dim,
472 + return_lse=True,
473 + is_lse_base_on_e=True,
474 + )
475 +
476 + torch.testing.assert_close(
477 + actual_output[:1], torch.zeros_like(actual_output[:1])
478 + )
479 + assert torch.isneginf(actual_lse[:1]).all()
480 + assert torch.isfinite(actual_output[1:]).all()
481 +
482
483 def _distributed_packed_a2a_worker(env: dict[str, str]) -> None:
484 update_environment_variables(env)
@@ -417,7 +521,7 @@ def _distributed_packed_a2a_worker(env: dict[str, str]) -> None:
521 B,
522 H,
523 device=f"cuda:{local_rank}",
420 - dtype=torch.float32,
524 + dtype=dtype,
525 generator=generator,
526 )
527 actual = dcp_a2a_lse_reduce(
tests/distributed/test_dcp_direct_a2a_lse_reduce.py new
+1077
@@ -0,0 +1,1077 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for direct symmetric-memory DCP collectives."""
4 +
5 +import functools
6 +from unittest.mock import MagicMock
7 +
8 +import multiprocess as mp
9 +import pytest
10 +import torch
11 +import torch.distributed as dist
12 +
13 +import vllm.v1.attention.ops.dcp_utils as dcp_utils
14 +from vllm.utils.network_utils import get_open_port
15 +from vllm.utils.system_utils import update_environment_variables
16 +
17 +mp.set_start_method("spawn", force=True)
18 +
19 +
20 +def _has_multicast_support() -> bool:
21 + if not torch.cuda.is_available():
22 + return False
23 + try:
24 + from torch._C._autograd import DeviceType
25 + from torch._C._distributed_c10d import _SymmetricMemory
26 +
27 + return _SymmetricMemory.has_multicast_support(DeviceType.CUDA, 0)
28 + except Exception:
29 + return False
30 +
31 +
32 +def _dtype_from_name(dtype_name: str) -> torch.dtype:
33 + return {
34 + "float16": torch.float16,
35 + "bfloat16": torch.bfloat16,
36 + "float8_e4m3fn": torch.float8_e4m3fn,
37 + "float32": torch.float32,
38 + }[dtype_name]
39 +
40 +
41 +def _assert_close(
42 + actual: torch.Tensor,
43 + expected: torch.Tensor,
44 + dtype: torch.dtype,
45 +) -> None:
46 + torch.testing.assert_close(actual.float(), expected.float(), rtol=3e-2, atol=3e-2)
47 +
48 +
49 +def _q_gather_reference(
50 + local_query: torch.Tensor,
51 + world_size: int,
52 + padded_num_heads: int | None,
53 +) -> torch.Tensor:
54 + num_tokens, heads_per_rank, head_dim = local_query.shape
55 + gathered = torch.empty(
56 + (world_size * num_tokens, heads_per_rank, head_dim),
57 + dtype=local_query.dtype,
58 + device=local_query.device,
59 + )
60 + dist.all_gather_into_tensor(gathered, local_query.contiguous())
61 + expected = (
62 + gathered.view(world_size, num_tokens, heads_per_rank, head_dim)
63 + .movedim(0, 1)
64 + .reshape(num_tokens, world_size * heads_per_rank, head_dim)
65 + )
66 + if padded_num_heads is not None:
67 + reserved = expected.new_empty((num_tokens, padded_num_heads, head_dim))
68 + reserved.resize_(expected.shape)
69 + reserved.copy_(expected)
70 + expected = reserved
71 + return expected
72 +
73 +
74 +def _assert_q_gather_matches_reference(
75 + actual: torch.Tensor,
76 + local_query: torch.Tensor,
77 + world_size: int,
78 + padded_num_heads: int | None,
79 +) -> None:
80 + expected = _q_gather_reference(local_query, world_size, padded_num_heads)
81 + assert actual.shape == expected.shape
82 + assert actual.dtype == expected.dtype
83 + assert actual.is_contiguous()
84 + assert actual.stride() == expected.stride()
85 + assert torch.equal(actual.view(torch.uint8), expected.view(torch.uint8))
86 +
87 + storage_num_heads = padded_num_heads or world_size * local_query.shape[1]
88 + remaining_storage_bytes = (
89 + actual.untyped_storage().nbytes()
90 + - actual.storage_offset() * actual.element_size()
91 + )
92 + required_storage_bytes = (
93 + local_query.shape[0]
94 + * storage_num_heads
95 + * local_query.shape[2]
96 + * actual.element_size()
97 + )
98 + assert remaining_storage_bytes >= required_storage_bytes
99 +
100 +
101 +def _distributed_run(fn, world_size: int, extra_env: dict[str, str]) -> None:
102 + port = str(get_open_port())
103 + processes: list[mp.Process] = []
104 + for rank in range(world_size):
105 + env = {
106 + "RANK": str(rank),
107 + "LOCAL_RANK": str(rank),
108 + "WORLD_SIZE": str(world_size),
109 + "LOCAL_WORLD_SIZE": str(world_size),
110 + "MASTER_ADDR": "localhost",
111 + "MASTER_PORT": port,
112 + **extra_env,
113 + }
114 + process = mp.Process(target=fn, args=(env,))
115 + processes.append(process)
116 + process.start()
117 +
118 + for process in processes:
119 + process.join(timeout=120)
120 +
121 + for process in processes:
122 + if process.is_alive():
123 + process.kill()
124 + process.join()
125 + assert process.exitcode == 0
126 +
127 +
128 +class _FakeGroupCoordinator:
129 + device_group = None
130 + cpu_group = None
131 + world_size = 4
132 +
133 +
134 +class _FakeProcessGroup:
135 + def size(self) -> int:
136 + return 4
137 +
138 + def rank(self) -> int:
139 + return 0
140 +
141 +
142 +class TestDirectDCPGating:
143 + def test_env_disabled_returns_none(self, monkeypatch):
144 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
145 + dcp_utils.get_direct_dcp_a2a_workspace.cache_clear()
146 + workspace = dcp_utils.get_direct_dcp_a2a_workspace(
147 + _FakeGroupCoordinator(), torch.device("cpu"), 16, 2, 32, torch.bfloat16, 1
148 + )
149 + assert workspace is None
150 +
151 + def test_forced_with_unsupported_dtype_raises(self, monkeypatch):
152 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
153 + dcp_utils.get_direct_dcp_a2a_workspace.cache_clear()
154 + with pytest.raises(ValueError, match="does not support"):
155 + dcp_utils.get_direct_dcp_a2a_workspace(
156 + _FakeGroupCoordinator(),
157 + torch.device("cpu"),
158 + 16,
159 + 2,
160 + 32,
161 + torch.float32,
162 + 1,
163 + )
164 +
165 + def test_zero_ubatches_raises(self):
166 + with pytest.raises(ValueError, match="ubatch"):
167 + dcp_utils.DirectDCPA2AWorkspace(
168 + None, torch.device("cpu"), 16, 2, 32, torch.bfloat16, num_ubatches=0
169 + )
170 +
171 + def test_auto_with_unsupported_dtype_returns_none(self, monkeypatch):
172 + monkeypatch.delenv("VLLM_USE_DIRECT_DCP_A2A", raising=False)
173 + dcp_utils.get_direct_dcp_a2a_workspace.cache_clear()
174 + workspace = dcp_utils.get_direct_dcp_a2a_workspace(
175 + _FakeGroupCoordinator(), torch.device("cpu"), 16, 2, 32, torch.float32, 1
176 + )
177 + assert workspace is None
178 +
179 + def test_q_gather_env_disabled_returns_none(self, monkeypatch):
180 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_Q_GATHER", "0")
181 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
182 + dcp_utils.get_direct_dcp_q_gather_workspace.cache_clear()
183 + workspace = dcp_utils.get_direct_dcp_q_gather_workspace(
184 + _FakeGroupCoordinator(),
185 + torch.device("cpu"),
186 + 16,
187 + 2,
188 + 32,
189 + torch.bfloat16,
190 + 1,
191 + )
192 + assert workspace is None
193 +
194 + def test_q_gather_flag_is_independent(self, monkeypatch):
195 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_Q_GATHER", "1")
196 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
197 + monkeypatch.setattr(dcp_utils, "_symm_mem_spans_group", lambda group: True)
198 + dcp_utils.get_direct_dcp_q_gather_workspace.cache_clear()
199 + workspace = object()
200 + init_workspace = MagicMock(return_value=workspace)
201 + monkeypatch.setattr(
202 + dcp_utils,
203 + "DirectDCPQGatherWorkspace",
204 + init_workspace,
205 + )
206 +
207 + result = dcp_utils.get_direct_dcp_q_gather_workspace(
208 + _FakeGroupCoordinator(),
209 + torch.device("cpu"),
210 + 16,
211 + 2,
212 + 32,
213 + torch.float32,
214 + 1,
215 + )
216 +
217 + assert result is workspace
218 + assert init_workspace.call_args.args[5] == torch.float32
219 +
220 + def test_kv_gather_env_disabled_returns_none(self, monkeypatch):
221 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_KV_GATHER", "0")
222 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
223 + dcp_utils.get_direct_dcp_kv_gather_workspace.cache_clear()
224 + workspace = dcp_utils.get_direct_dcp_kv_gather_workspace(
225 + _FakeGroupCoordinator(), torch.device("cpu"), 64, 576, torch.bfloat16, 1
226 + )
227 + assert workspace is None
228 +
229 + def test_kv_gather_flag_is_independent(self, monkeypatch):
230 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_KV_GATHER", "1")
231 + monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
232 + monkeypatch.setattr(dcp_utils, "_symm_mem_spans_group", lambda group: True)
233 + dcp_utils.get_direct_dcp_kv_gather_workspace.cache_clear()
234 + workspace = object()
235 + init_workspace = MagicMock(return_value=workspace)
236 + monkeypatch.setattr(
237 + dcp_utils,
238 + "DirectDCPKVGatherWorkspace",
239 + init_workspace,
240 + )
241 +
242 + result = dcp_utils.get_direct_dcp_kv_gather_workspace(
243 + _FakeGroupCoordinator(), torch.device("cpu"), 64, 576, torch.bfloat16, 1
244 + )
245 +
246 + assert result is workspace
247 +
248 + @pytest.mark.parametrize(
249 + ("flag_name", "factory_name", "factory_args"),
250 + [
251 + (
252 + "VLLM_USE_DIRECT_DCP_Q_GATHER",
253 + "get_direct_dcp_q_gather_workspace",
254 + (16, 2, 32, torch.bfloat16, 1),
255 + ),
256 + (
257 + "VLLM_USE_DIRECT_DCP_KV_GATHER",
258 + "get_direct_dcp_kv_gather_workspace",
259 + (64, 576, torch.bfloat16, 1),
260 + ),
261 + ],
262 + )
263 + def test_gather_requires_multicast(
264 + self,
265 + monkeypatch,
266 + flag_name,
267 + factory_name,
268 + factory_args,
269 + ):
270 + factory = getattr(dcp_utils, factory_name)
271 + monkeypatch.setenv(flag_name, "1")
272 + monkeypatch.setattr(dcp_utils, "_symm_mem_spans_group", lambda group: False)
273 + factory.cache_clear()
274 +
275 + assert (
276 + factory(
277 + _FakeGroupCoordinator(),
278 + torch.device("cpu"),
279 + *factory_args,
280 + )
281 + is None
282 + )
283 +
284 + def test_kv_gather_rejects_invalid_workspace_geometry(self):
285 + with pytest.raises(ValueError, match="ubatch"):
286 + dcp_utils.DirectDCPKVGatherWorkspace(
287 + None, torch.device("cpu"), 64, 576, num_ubatches=0
288 + )
289 + with pytest.raises(ValueError, match="divide evenly"):
290 + dcp_utils.DirectDCPKVGatherWorkspace(
291 + _FakeProcessGroup(), torch.device("cpu"), 63, 576
292 + )
293 + with pytest.raises(ValueError, match="16-byte"):
294 + dcp_utils.DirectDCPKVGatherWorkspace(
295 + _FakeProcessGroup(), torch.device("cpu"), 64, 3
296 + )
297 +
298 + def test_q_gather_rejects_invalid_workspace_geometry(self):
299 + with pytest.raises(ValueError, match="ubatch"):
300 + dcp_utils.DirectDCPQGatherWorkspace(
301 + None, torch.device("cpu"), 16, 2, 32, num_ubatches=0
302 + )
303 + with pytest.raises(ValueError, match="padded heads"):
304 + dcp_utils.DirectDCPQGatherWorkspace(
305 + _FakeProcessGroup(),
306 + torch.device("cpu"),
307 + 16,
308 + 2,
309 + 32,
310 + padded_num_heads=7,
311 + )
312 + with pytest.raises(ValueError, match="16-byte"):
313 + dcp_utils.DirectDCPQGatherWorkspace(
314 + _FakeProcessGroup(),
315 + torch.device("cpu"),
316 + 16,
317 + 1,
318 + 3,
319 + )
320 +
321 +
322 +def _manager_config(dcp_comm_backend: str = "a2a"):
323 + config = MagicMock()
324 + config.parallel_config.num_ubatches = 1
325 + config.parallel_config.dcp_comm_backend = dcp_comm_backend
326 + config.scheduler_config.max_num_batched_tokens = 16
327 + config.scheduler_config.max_num_seqs = 4
328 + config.num_speculative_tokens = 0
329 + config.speculative_config = None
330 + config.compilation_config.max_cudagraph_capture_size = 0
331 + return config
332 +
333 +
334 +def test_mla_dcp_manager_selects_direct_backends(monkeypatch):
335 + import vllm.v1.attention.ops.dcp_utils as dcp_manager
336 +
337 + group = MagicMock(world_size=2)
338 + monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: group)
339 + direct_a2a = MagicMock()
340 + direct_query = MagicMock()
341 + direct_kv = MagicMock()
342 + monkeypatch.setattr(
343 + dcp_manager, "get_direct_dcp_a2a_workspace", MagicMock(return_value=direct_a2a)
344 + )
345 + monkeypatch.setattr(
346 + dcp_manager,
347 + "get_direct_dcp_q_gather_workspace",
348 + MagicMock(return_value=direct_query),
349 + )
350 + monkeypatch.setattr(
351 + dcp_manager,
352 + "get_direct_dcp_kv_gather_workspace",
353 + MagicMock(return_value=direct_kv),
354 + )
355 +
356 + manager = dcp_manager.MLADCPManager(
357 + vllm_config=_manager_config(),
358 + device=torch.device("cpu"),
359 + num_heads=2,
360 + query_head_dim=8,
361 + output_head_dim=4,
362 + query_dtype=torch.bfloat16,
363 + output_dtype=torch.bfloat16,
364 + padded_num_heads=None,
365 + is_lse_base_on_e=False,
366 + use_pcp=False,
367 + )
368 + workspace = torch.empty(96, 8)
369 +
370 + assert manager.query_gather == direct_query.gather
371 + manager.init_kv_gather(workspace, 64)
372 + gathered_kv, local_kv = torch.empty(4, 8), torch.empty(2, 8)
373 + manager.kv_gather(gathered_kv, local_kv)
374 + direct_kv.gather.assert_called_once_with(gathered_kv, local_kv)
375 + output, lse = torch.empty(1), torch.empty(1)
376 + seq_lens = torch.ones(1, dtype=torch.int32)
377 + query_start_loc = torch.tensor([0, 1], dtype=torch.int32)
378 + manager.combine(
379 + output,
380 + lse,
381 + seq_lens=seq_lens,
382 + query_start_loc=query_start_loc,
383 + )
384 + direct_a2a.lse_reduce.assert_called_once_with(
385 + output,
386 + lse,
387 + seq_lens=seq_lens,
388 + query_start_loc=query_start_loc,
389 + is_lse_base_on_e=False,
390 + )
391 +
392 +
393 +def test_mla_dcp_manager_selects_fallback_backends(monkeypatch):
394 + import vllm.v1.attention.ops.dcp_utils as dcp_manager
395 +
396 + group = MagicMock(world_size=2)
397 + gathered_query = torch.empty(1, 4, 8)
398 + group.all_gather.return_value = gathered_query
399 + monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: group)
400 + monkeypatch.setattr(
401 + dcp_manager, "get_direct_dcp_a2a_workspace", MagicMock(return_value=None)
402 + )
403 + monkeypatch.setattr(
404 + dcp_manager, "get_direct_dcp_q_gather_workspace", MagicMock(return_value=None)
405 + )
406 + monkeypatch.setattr(
407 + dcp_manager, "get_direct_dcp_kv_gather_workspace", MagicMock(return_value=None)
408 + )
409 + fallback_combine = MagicMock(return_value=torch.empty(1))
410 + monkeypatch.setattr(dcp_manager, "dcp_a2a_lse_reduce", fallback_combine)
411 +
412 + manager = dcp_manager.MLADCPManager(
413 + vllm_config=_manager_config(),
414 + device=torch.device("cpu"),
415 + num_heads=2,
416 + query_head_dim=8,
417 + output_head_dim=4,
418 + query_dtype=torch.bfloat16,
419 + output_dtype=torch.bfloat16,
420 + padded_num_heads=None,
421 + is_lse_base_on_e=True,
422 + use_pcp=False,
423 + )
424 +
425 + all_gather = MagicMock()
426 + monkeypatch.setattr(torch.distributed, "all_gather_into_tensor", all_gather)
427 + workspace = torch.empty(96, 8)
428 + manager.init_kv_gather(workspace, 64)
429 + output, local = torch.empty(4, 8), torch.empty(2, 8)
430 + manager.kv_gather(output, local)
431 + all_gather.assert_called_once_with(output, local, group=group.device_group)
432 +
433 + query = torch.empty(1, 2, 8)
434 + assert manager.query_gather is not None
435 + assert manager.query_gather(query) is gathered_query
436 + group.all_gather.assert_called_once_with(query, dim=1)
437 +
438 + partial_output, partial_lse = torch.empty(1), torch.empty(1)
439 + seq_lens = torch.ones(1, dtype=torch.int32)
440 + query_start_loc = torch.tensor([0, 1], dtype=torch.int32)
441 + manager.combine(
442 + partial_output,
443 + partial_lse,
444 + seq_lens=seq_lens,
445 + query_start_loc=query_start_loc,
446 + )
447 + fallback_combine.assert_called_once_with(
448 + partial_output,
449 + partial_lse,
450 + seq_lens=seq_lens,
451 + query_start_loc=query_start_loc,
452 + cp_group=group,
453 + is_lse_base_on_e=True,
454 + )
455 +
456 +
457 +def test_dcp_workspace_covers_parallel_drafting():
458 + config = _manager_config()
459 + config.scheduler_config.max_num_batched_tokens = 128
460 + config.num_speculative_tokens = 3
461 + config.speculative_config = MagicMock(parallel_drafting=True)
462 +
463 + assert dcp_utils.get_dcp_workspace_max_num_tokens(config) == 28
464 +
465 +
466 +def test_mla_dcp_manager_selects_pcp_combine(monkeypatch):
467 + import vllm.v1.attention.ops.dcp_utils as dcp_manager
468 +
469 + monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: MagicMock(world_size=2))
470 + manager = dcp_manager.MLADCPManager(
471 + vllm_config=_manager_config(dcp_comm_backend="ag_rs"),
472 + device=torch.device("cpu"),
473 + num_heads=2,
474 + query_head_dim=8,
475 + output_head_dim=4,
476 + query_dtype=torch.bfloat16,
477 + output_dtype=torch.bfloat16,
478 + padded_num_heads=None,
479 + is_lse_base_on_e=True,
480 + use_pcp=True,
481 + )
482 +
483 + assert isinstance(manager.combine, functools.partial)
484 + assert manager.combine.func is dcp_manager.cp_lse_ag_out_ar
485 + assert manager.query_gather is None
486 +
487 +
488 +def test_dcp_chunk_workspace_alignment_covers_interleave():
489 + from vllm.model_executor.layers.attention.mla_attention import (
490 + align_mla_chunked_context_workspace_size,
491 + )
492 +
493 + config = MagicMock()
494 + config.cache_config.block_size = 32
495 + config.parallel_config.decode_context_parallel_size = 8
496 + config.parallel_config.cp_kv_cache_interleave_size = 8
497 + config.scheduler_config.max_num_seqs = 3
498 +
499 + assert align_mla_chunked_context_workspace_size(config, 100) == 192
500 +
501 +
502 +def test_sparse_mla_builder_initializes_dcp_manager(monkeypatch):
503 + import vllm.model_executor.layers.attention.sparse_mla_attention as sparse_mla
504 +
505 + monkeypatch.setattr(
506 + sparse_mla.AttentionMetadataBuilder,
507 + "__init__",
508 + lambda *args, **kwargs: None,
509 + )
510 + monkeypatch.setattr(
511 + sparse_mla,
512 + "get_dcp_group",
513 + lambda: MagicMock(world_size=2),
514 + )
515 + monkeypatch.setattr(
516 + sparse_mla,
517 + "get_mla_dims",
518 + lambda _: MagicMock(kv_lora_rank=8, qk_rope_head_dim=4),
519 + )
520 +
521 + manager = object.__new__(dcp_utils.MLADCPManager)
522 + manager.init_kv_gather = MagicMock()
523 + layer = MagicMock(dcp_manager=manager)
524 + config = MagicMock()
525 + config.model_config.dtype = torch.bfloat16
526 + config.model_config.max_model_len = 64
527 + config.model_config.hf_config.index_topk = 8
528 + config.scheduler_config.max_num_batched_tokens = 64
529 + config.scheduler_config.max_num_seqs = 2
530 + config.cache_config.block_size = 4
531 + config.parallel_config.prefill_context_parallel_size = 1
532 + config.parallel_config.decode_context_parallel_size = 2
533 + config.parallel_config.cp_kv_cache_interleave_size = 1
534 + config.compilation_config.static_forward_context = {"layer": layer}
535 +
536 + builder = sparse_mla.SparseMLACommonMetadataBuilder(
537 + MagicMock(),
538 + ["layer"],
539 + config,
540 + torch.device("cpu"),
541 + )
542 +
543 + assert builder.dcp_manager is manager
544 + manager.init_kv_gather.assert_called_once_with(
545 + builder.chunked_prefill_workspace,
546 + builder.chunked_prefill_workspace_size,
547 + )
548 +
549 +
550 +def test_sparse_mla_workspace_preserves_non_dcp_size():
551 + from vllm.model_executor.layers.attention.sparse_mla_attention import (
552 + SparseMLACommonMetadataBuilder,
553 + )
554 +
555 + config = MagicMock()
556 + config.model_config.max_model_len = 1
557 + config.model_config.hf_config.index_topk = 7
558 + config.scheduler_config.max_num_seqs = 3
559 + config.cache_config.block_size = 4
560 + config.parallel_config.decode_context_parallel_size = 1
561 +
562 + assert (
563 + SparseMLACommonMetadataBuilder.determine_chunked_prefill_workspace_size(config)
564 + == 21
565 + )
566 +
567 +
568 +def _distributed_direct_q_gather_worker(env: dict[str, str]) -> None:
569 + update_environment_variables(env)
570 + local_rank = int(env["LOCAL_RANK"])
571 + device = torch.device(f"cuda:{local_rank}")
572 + torch.accelerator.set_device_index(local_rank)
573 + dist.init_process_group(backend="nccl")
574 + try:
575 + rank = dist.get_rank()
576 + world_size = dist.get_world_size()
577 + heads_per_rank, head_dim, max_num_tokens = 6, 576, 128
578 + padded_num_heads = 128 if world_size == 4 else None
579 + active_ubatch = [0]
580 + dcp_utils.dbo_current_ubatch_id = lambda: active_ubatch[0]
581 + for dtype_idx, dtype_name in enumerate(
582 + ("bfloat16", "float8_e4m3fn", "float32")
583 + ):
584 + dtype = _dtype_from_name(dtype_name)
585 + workspace = dcp_utils.DirectDCPQGatherWorkspace(
586 + dist.group.WORLD,
587 + device,
588 + max_num_tokens,
589 + heads_per_rank,
590 + head_dim,
591 + dtype,
592 + num_ubatches=2,
593 + padded_num_heads=padded_num_heads,
594 + )
595 +
596 + cases = (
597 + ((1, False), (128, True), (17, True), (5, False))
598 + if dtype == torch.bfloat16
599 + else ((17, True),)
600 + )
601 + for iteration, (num_tokens, noncontiguous) in enumerate(cases):
602 + generator = torch.Generator(device=device)
603 + generator.manual_seed(9000 + rank * 101 + dtype_idx * 1009 + iteration)
604 + source_num_heads = (
605 + heads_per_rank + 2 if noncontiguous else heads_per_rank
606 + )
607 + query_storage = torch.randn(
608 + num_tokens,
609 + source_num_heads,
610 + head_dim,
611 + device=device,
612 + dtype=torch.bfloat16,
613 + generator=generator,
614 + ).to(dtype)
615 + local_query = query_storage[:, :heads_per_rank]
616 + assert local_query.is_contiguous() is not noncontiguous
617 +
618 + active_ubatch[0] = iteration % 2
619 + actual = workspace.gather(local_query)
620 + torch.accelerator.synchronize()
621 +
622 + assert (
623 + actual.data_ptr()
624 + == workspace.final_query[active_ubatch[0]].data_ptr()
625 + )
626 + assert workspace.completion[active_ubatch[0]].numel() == 1
627 + _assert_q_gather_matches_reference(
628 + actual, local_query, world_size, padded_num_heads
629 + )
630 +
631 + if env.get("TEST_CUDA_GRAPH") != "1" or dtype != torch.bfloat16:
632 + continue
633 +
634 + capture_num_tokens = 17
635 + capture_storage = torch.empty(
636 + capture_num_tokens,
637 + heads_per_rank + 2,
638 + head_dim,
639 + dtype=dtype,
640 + device=device,
641 + )
642 + captured_input = capture_storage[:, :heads_per_rank]
643 + assert not captured_input.is_contiguous()
644 + input_pattern = (
645 + torch.arange(captured_input.numel(), device=device, dtype=torch.int32)
646 + .remainder(31)
647 + .view(captured_input.shape)
648 + .to(dtype)
649 + )
650 +
651 + active_ubatch[0] = 1
652 + torch.accelerator.synchronize()
653 + graph = torch.cuda.CUDAGraph()
654 + with torch.cuda.graph(graph):
655 + captured_output = workspace.gather(captured_input)
656 + torch.accelerator.synchronize()
657 + dist.barrier()
658 +
659 + eager_input = torch.full(
660 + captured_input.shape,
661 + 96 + rank,
662 + dtype=dtype,
663 + device=device,
664 + )
665 + eager_output = workspace.gather(eager_input)
666 + torch.accelerator.synchronize()
667 + _assert_q_gather_matches_reference(
668 + eager_output, eager_input, world_size, padded_num_heads
669 + )
670 + epoch_before_replays = int(workspace.epoch[1].item())
671 +
672 + for replay in range(2):
673 + captured_input.copy_(input_pattern + rank * 32 + replay)
674 + torch.accelerator.synchronize()
675 + graph.replay()
676 + torch.accelerator.synchronize()
677 + assert int(workspace.epoch[1].item()) == (
678 + epoch_before_replays + replay + 1
679 + )
680 + _assert_q_gather_matches_reference(
681 + captured_output,
682 + captured_input,
683 + world_size,
684 + padded_num_heads,
685 + )
686 +
687 + interleaved_eager_input = torch.full(
688 + captured_input.shape,
689 + 160 + rank,
690 + dtype=dtype,
691 + device=device,
692 + )
693 + interleaved_eager_output = workspace.gather(interleaved_eager_input)
694 + torch.accelerator.synchronize()
695 + _assert_q_gather_matches_reference(
696 + interleaved_eager_output,
697 + interleaved_eager_input,
698 + world_size,
699 + padded_num_heads,
700 + )
701 +
702 + captured_input.copy_(input_pattern + rank * 32 + 2)
703 + torch.accelerator.synchronize()
704 + epoch_before_mixed_replay = int(workspace.epoch[1].item())
705 + graph.replay()
706 + torch.accelerator.synchronize()
707 + assert int(workspace.epoch[1].item()) == epoch_before_mixed_replay + 1
708 + _assert_q_gather_matches_reference(
709 + captured_output, captured_input, world_size, padded_num_heads
710 + )
711 + finally:
712 + dist.destroy_process_group()
713 +
714 +
715 +@pytest.mark.parametrize(
716 + "world_size",
717 + [
718 + pytest.param(
719 + 4,
720 + marks=pytest.mark.skipif(
721 + torch.accelerator.device_count() < 4 or not _has_multicast_support(),
722 + reason="Need 4 GPUs with symmetric-memory multicast.",
723 + ),
724 + ),
725 + ],
726 +)
727 +def test_distributed_direct_q_gather_cuda_graph_replay(world_size: int):
728 + _distributed_run(
729 + _distributed_direct_q_gather_worker,
730 + world_size=world_size,
731 + extra_env={"TEST_CUDA_GRAPH": "1"},
732 + )
733 +
734 +
735 +def _distributed_direct_kv_gather_worker(env: dict[str, str]) -> None:
736 + update_environment_variables(env)
737 + local_rank = int(env["LOCAL_RANK"])
738 + device = torch.device(f"cuda:{local_rank}")
739 + torch.accelerator.set_device_index(local_rank)
740 + dist.init_process_group(backend="nccl")
741 + try:
742 + rank = dist.get_rank()
743 + world_size = dist.get_world_size()
744 + token_dim = 576
745 + max_gathered_tokens = 128 * world_size
746 + active_ubatch = [0]
747 + dcp_utils.dbo_current_ubatch_id = lambda: active_ubatch[0]
748 +
749 + for dtype_idx, dtype_name in enumerate(("bfloat16", "float16")):
750 + dtype = _dtype_from_name(dtype_name)
751 + workspace = dcp_utils.DirectDCPKVGatherWorkspace(
752 + dist.group.WORLD,
753 + device,
754 + max_gathered_tokens,
755 + token_dim,
756 + dtype,
757 + num_ubatches=2,
758 + )
759 +
760 + # Use disjoint slices of one persistent chunked-context workspace.
761 + storage = torch.zeros(
762 + (world_size + 1) * 128, token_dim, device=device, dtype=dtype
763 + )
764 + for iteration, num_tokens in enumerate((1, 128, 17)):
765 + generator = torch.Generator(device=device)
766 + generator.manual_seed(7000 + rank * 101 + dtype_idx * 977 + iteration)
767 + local_kv = storage[:num_tokens]
768 + local_kv.copy_(
769 + torch.randn(
770 + num_tokens,
771 + token_dim,
772 + device=device,
773 + dtype=torch.float32,
774 + generator=generator,
775 + ).to(dtype)
776 + )
777 + gathered = storage[128 : 128 + num_tokens * world_size]
778 + active_ubatch[0] = iteration % 2
779 + workspace.gather(gathered, local_kv)
780 + torch.accelerator.synchronize()
781 +
782 + expected = torch.empty_like(gathered)
783 + dist.all_gather_into_tensor(expected, local_kv.contiguous())
784 + assert torch.equal(
785 + gathered.view(torch.uint8), expected.view(torch.uint8)
786 + )
787 + finally:
788 + dist.destroy_process_group()
789 +
790 +
791 +@pytest.mark.parametrize(
792 + "world_size",
793 + [
794 + pytest.param(
795 + 4,
796 + marks=pytest.mark.skipif(
797 + torch.accelerator.device_count() < 4 or not _has_multicast_support(),
798 + reason="Need 4 GPUs with symmetric-memory multicast.",
799 + ),
800 + ),
801 + ],
802 +)
803 +def test_distributed_direct_kv_gather_matches_reference(world_size: int):
804 + _distributed_run(
805 + _distributed_direct_kv_gather_worker,
806 + world_size=world_size,
807 + extra_env={},
808 + )
809 +
810 +
811 +def _distributed_direct_a2a_worker(env: dict[str, str]) -> None:
812 + update_environment_variables(env)
813 + local_rank = int(env["LOCAL_RANK"])
814 + device = torch.device(f"cuda:{local_rank}")
815 + torch.accelerator.set_device_index(local_rank)
816 + dist.init_process_group(backend="nccl")
817 + try:
818 + from vllm.v1.attention.ops.dcp_alltoall import _lse_weighted_combine
819 +
820 + rank = dist.get_rank()
821 + world_size = dist.get_world_size()
822 + dtype = _dtype_from_name(env["TEST_DTYPE"])
823 + lse_dtype = _dtype_from_name(env["TEST_LSE_DTYPE"])
824 + is_lse_base_on_e = env["LSE_BASE_E"] == "1"
825 + # Match Kimi-K3's six heads per DCP rank.
826 + heads_per_rank, head_dim, max_num_tokens = 6, 512, 128
827 + total_heads = world_size * heads_per_rank
828 + active_ubatch = [0]
829 + dcp_utils.dbo_current_ubatch_id = lambda: active_ubatch[0]
830 + workspace = dcp_utils.DirectDCPA2AWorkspace(
831 + dist.group.WORLD,
832 + device,
833 + max_num_tokens,
834 + heads_per_rank,
835 + head_dim,
836 + dtype,
837 + num_ubatches=2,
838 + )
839 +
840 + def check(num_tokens: int, iteration: int, padded: bool) -> None:
841 + generator = torch.Generator(device=device)
842 + generator.manual_seed(1234 + rank + iteration * world_size)
843 + storage_heads = 128 if padded else total_heads
844 + partial_output_storage = torch.randn(
845 + num_tokens,
846 + storage_heads,
847 + head_dim,
848 + device=device,
849 + dtype=dtype,
850 + generator=generator,
851 + )
852 + partial_lse_storage = torch.randn(
853 + num_tokens,
854 + storage_heads,
855 + device=device,
856 + dtype=lse_dtype,
857 + generator=generator,
858 + )
859 + partial_output = partial_output_storage[:, :total_heads, :]
860 + partial_lse = partial_lse_storage[:, :total_heads]
861 + if padded:
862 + assert not partial_output.is_contiguous()
863 + assert not partial_lse.is_contiguous()
864 + active_ubatch[0] = iteration % 2
865 + actual = workspace.lse_reduce(partial_output, partial_lse, is_lse_base_on_e)
866 + torch.accelerator.synchronize()
867 +
868 + reference_output = partial_output.contiguous()
869 + reference_lse = partial_lse.contiguous()
870 + gathered_output = [
871 + torch.empty_like(reference_output) for _ in range(world_size)
872 + ]
873 + gathered_lse = [torch.empty_like(reference_lse) for _ in range(world_size)]
874 + dist.all_gather(gathered_output, reference_output)
875 + dist.all_gather(gathered_lse, reference_lse)
876 + outputs = torch.stack(
877 + [
878 + value[
879 + :,
880 + rank * heads_per_rank : (rank + 1) * heads_per_rank,
881 + :,
882 + ]
883 + for value in gathered_output
884 + ]
885 + ).float()
886 + lses = torch.stack(
887 + [
888 + value[:, rank * heads_per_rank : (rank + 1) * heads_per_rank]
889 + for value in gathered_lse
890 + ]
891 + )
892 + expected = _lse_weighted_combine(
893 + outputs, lses, is_lse_base_on_e=is_lse_base_on_e
894 + )
895 + _assert_close(actual, expected, dtype)
896 +
897 + def check_empty_shards(query_lens: list[int], iteration: int) -> None:
898 + """Verify empty local shards contribute zero weight."""
899 + num_seqs = len(query_lens)
900 + num_tokens = sum(query_lens)
901 + generator = torch.Generator(device=device)
902 + generator.manual_seed(777 + rank + iteration * world_size)
903 + partial_output = torch.randn(
904 + num_tokens,
905 + total_heads,
906 + head_dim,
907 + device=device,
908 + dtype=dtype,
909 + generator=generator,
910 + )
911 + partial_lse = torch.randn(
912 + num_tokens,
913 + total_heads,
914 + device=device,
915 + dtype=lse_dtype,
916 + generator=generator,
917 + )
918 +
919 + # Cover globally empty, rank-local empty, and non-empty sequences.
920 + def is_empty(seq_idx: int, source_rank: int) -> bool:
921 + return seq_idx == 0 or seq_idx == 1 + source_rank
922 +
923 + seq_lens = torch.tensor(
924 + [
925 + 0 if is_empty(seq_idx, rank) else seq_idx + 3
926 + for seq_idx in range(num_seqs)
927 + ],
928 + dtype=torch.int32,
929 + device=device,
930 + )
931 + query_lens_tensor = torch.tensor(
932 + query_lens,
933 + dtype=torch.int32,
934 + device=device,
935 + )
936 + query_start_loc = torch.cat(
937 + (
938 + query_lens_tensor.new_zeros(1),
939 + query_lens_tensor.cumsum(0),
940 + )
941 + )
942 + empty_rows = torch.repeat_interleave(seq_lens == 0, query_lens_tensor)
943 + # Model undefined attention rows with NaNs.
944 + partial_output[empty_rows] = float("nan")
945 + partial_lse[empty_rows] = float("nan")
946 +
947 + active_ubatch[0] = iteration % 2
948 + actual = workspace.lse_reduce(
949 + partial_output,
950 + partial_lse,
951 + is_lse_base_on_e,
952 + seq_lens=seq_lens,
953 + query_start_loc=query_start_loc,
954 + )
955 + torch.accelerator.synchronize()
956 +
957 + gathered_output = [
958 + torch.empty_like(partial_output) for _ in range(world_size)
959 + ]
960 + gathered_lse = [torch.empty_like(partial_lse) for _ in range(world_size)]
961 + dist.all_gather(gathered_output, partial_output.contiguous())
962 + dist.all_gather(gathered_lse, partial_lse.contiguous())
963 + head_slice = slice(rank * heads_per_rank, (rank + 1) * heads_per_rank)
964 + outputs = torch.stack(
965 + [value[:, head_slice, :] for value in gathered_output]
966 + ).float()
967 + lses = torch.stack([value[:, head_slice] for value in gathered_lse])
968 + for source_rank in range(world_size):
969 + source_empty = torch.tensor(
970 + [is_empty(seq_idx, source_rank) for seq_idx in range(num_seqs)],
971 + device=device,
972 + ).repeat_interleave(query_lens_tensor)
973 + outputs[source_rank][source_empty] = 0.0
974 + lses[source_rank][source_empty] = float("-inf")
975 + expected = _lse_weighted_combine(
976 + outputs, lses, is_lse_base_on_e=is_lse_base_on_e
977 + )
978 + all_empty = torch.tensor(
979 + [
980 + all(is_empty(seq_idx, r) for r in range(world_size))
981 + for seq_idx in range(num_seqs)
982 + ],
983 + device=device,
984 + ).repeat_interleave(query_lens_tensor)
985 + assert torch.equal(actual[all_empty], torch.zeros_like(actual[all_empty]))
986 + assert not torch.isnan(actual.float()).any()
987 + _assert_close(actual, expected, dtype)
988 +
989 + cases = ((1, False), (17, True), (128, True))
990 + for iteration, (num_tokens, padded) in enumerate(cases):
991 + check(num_tokens, iteration, padded)
992 + check_empty_shards(
993 + query_lens=[1, 3, 2, *([1] * (world_size - 1))],
994 + iteration=len(cases),
995 + )
996 + generator = torch.Generator(device=device)
997 + generator.manual_seed(4321 + rank)
998 + partial_output_storage = torch.randn(
999 + 128,
1000 + 128,
1001 + head_dim,
1002 + device=device,
1003 + dtype=dtype,
1004 + generator=generator,
1005 + )
1006 + partial_lse_storage = torch.randn(
1007 + 128,
1008 + 128,
1009 + device=device,
1010 + dtype=lse_dtype,
1011 + generator=generator,
1012 + )
1013 + partial_output = partial_output_storage[:, :total_heads, :]
1014 + partial_lse = partial_lse_storage[:, :total_heads]
1015 + assert not partial_output.is_contiguous()
1016 + assert not partial_lse.is_contiguous()
1017 + torch.accelerator.synchronize()
1018 + active_ubatch[0] = 1
1019 + graph = torch.cuda.CUDAGraph()
1020 + with torch.cuda.graph(graph):
1021 + actual = workspace.lse_reduce(partial_output, partial_lse, is_lse_base_on_e)
1022 + for _ in range(3):
1023 + graph.replay()
1024 + torch.accelerator.synchronize()
1025 +
1026 + reference_output = partial_output.contiguous()
1027 + reference_lse = partial_lse.contiguous()
1028 + gathered_output = [
1029 + torch.empty_like(reference_output) for _ in range(world_size)
1030 + ]
1031 + gathered_lse = [torch.empty_like(reference_lse) for _ in range(world_size)]
1032 + dist.all_gather(gathered_output, reference_output)
1033 + dist.all_gather(gathered_lse, reference_lse)
1034 + head_slice = slice(rank * heads_per_rank, (rank + 1) * heads_per_rank)
1035 + outputs = torch.stack(
1036 + [value[:, head_slice, :] for value in gathered_output]
1037 + ).float()
1038 + lses = torch.stack([value[:, head_slice] for value in gathered_lse])
1039 + expected = _lse_weighted_combine(
1040 + outputs, lses, is_lse_base_on_e=is_lse_base_on_e
1041 + )
1042 + _assert_close(actual, expected, dtype)
1043 + finally:
1044 + dist.destroy_process_group()
1045 +
1046 +
1047 +@pytest.mark.parametrize(
1048 + "world_size",
1049 + [
1050 + pytest.param(
1051 + 2,
1052 + marks=pytest.mark.skipif(
1053 + torch.accelerator.device_count() < 2, reason="Need at least 2 GPUs."
1054 + ),
1055 + ),
1056 + pytest.param(
1057 + 4,
1058 + marks=pytest.mark.skipif(
1059 + torch.accelerator.device_count() < 4, reason="Need at least 4 GPUs."
1060 + ),
1061 + ),
1062 + ],
1063 +)
1064 +def test_distributed_direct_a2a_matches_reference(world_size: int):
1065 + _distributed_run(
1066 + _distributed_direct_a2a_worker,
1067 + world_size=world_size,
1068 + extra_env={
1069 + "TEST_DTYPE": "bfloat16",
1070 + "TEST_LSE_DTYPE": "bfloat16",
1071 + "LSE_BASE_E": "0",
1072 + },
1073 + )
1074 +
1075 +
1076 +if __name__ == "__main__":
1077 + pytest.main([__file__, "-v"])
tests/distributed/test_kimi_linear_context_parallel.py new
+128
@@ -0,0 +1,128 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import math
5 +
6 +import pytest
7 +
8 +from tests.conftest import VllmRunner
9 +from tests.utils import create_new_process_for_each_test
10 +from vllm import SamplingParams, TokensPrompt
11 +
12 +MODEL = "moonshotai/Kimi-Linear-48B-A3B-Instruct"
13 +
14 +
15 +def _make_tiny_overrides() -> dict:
16 + linear_attn_config = {
17 + "full_attn_layers": [4],
18 + "head_dim": 128,
19 + "kda_layers": [1, 2, 3],
20 + "num_heads": 8,
21 + "short_conv_kernel_size": 4,
22 + "use_full_rank_gate": True,
23 + }
24 +
25 + return {
26 + "bos_token_id": 1,
27 + "eos_token_id": 2,
28 + "pad_token_id": 0,
29 + "vocab_size": 256,
30 + "hidden_size": 256,
31 + "head_dim": 32,
32 + "intermediate_size": 512,
33 + "num_hidden_layers": 4,
34 + "num_attention_heads": 8,
35 + "num_key_value_heads": 8,
36 + "num_experts": None,
37 + "num_experts_per_token": None,
38 + "num_shared_experts": 0,
39 + "q_lora_rank": 128,
40 + "kv_lora_rank": 512,
41 + "qk_nope_head_dim": 128,
42 + "qk_rope_head_dim": 64,
43 + "v_head_dim": 128,
44 + "mla_use_nope": True,
45 + "mla_use_output_gate": True,
46 + "linear_attn_config": linear_attn_config,
47 + "max_position_embeddings": 512,
48 + "model_max_length": 512,
49 + }
50 +
51 +
52 +def _run_tiny_model(
53 + vllm_runner: type[VllmRunner], dcp_size: int
54 +) -> list[tuple[list[int], list[float]]]:
55 + with vllm_runner(
56 + model_name=MODEL,
57 + skip_tokenizer_init=True,
58 + load_format="dummy",
59 + hf_overrides=_make_tiny_overrides(),
60 + tensor_parallel_size=2,
61 + decode_context_parallel_size=dcp_size,
62 + cp_kv_cache_interleave_size=1,
63 + distributed_executor_backend="mp",
64 + dtype="bfloat16",
65 + seed=0,
66 + enforce_eager=True,
67 + max_model_len=512,
68 + max_num_seqs=8,
69 + max_num_batched_tokens=1024,
70 + gpu_memory_utilization=0.25,
71 + enable_chunked_prefill=True,
72 + enable_prefix_caching=False,
73 + ) as runner:
74 + lengths = [1, 32, 33, 129]
75 + prompts = [
76 + TokensPrompt(
77 + prompt_token_ids=[
78 + 3 + ((request_idx + token_idx) % 251) for token_idx in range(length)
79 + ]
80 + )
81 + for request_idx, length in enumerate(lengths)
82 + ]
83 + outputs = runner.llm.generate(
84 + prompts,
85 + SamplingParams(
86 + temperature=0,
87 + max_tokens=8,
88 + seed=0,
89 + ignore_eos=True,
90 + logprobs=20,
91 + ),
92 + use_tqdm=False,
93 + )
94 +
95 + results = []
96 + for request_output in outputs:
97 + completion = request_output.outputs[0]
98 + token_ids = list(completion.token_ids)
99 + assert completion.logprobs is not None
100 + selected_logprobs = [
101 + step_logprobs[token_id].logprob
102 + for token_id, step_logprobs in zip(token_ids, completion.logprobs)
103 + ]
104 + results.append((token_ids, selected_logprobs))
105 + return results
106 +
107 +
108 +@create_new_process_for_each_test()
109 +@pytest.mark.distributed(num_gpus=2)
110 +def test_kimi_linear_dcp_tiny(
111 + vllm_runner: type[VllmRunner],
112 + num_gpus_available: int,
113 +) -> None:
114 + if num_gpus_available < 2:
115 + pytest.skip("Need at least 2 GPUs")
116 +
117 + baseline = _run_tiny_model(vllm_runner, dcp_size=1)
118 + dcp = _run_tiny_model(vllm_runner, dcp_size=2)
119 +
120 + assert [tokens for tokens, _ in dcp] == [tokens for tokens, _ in baseline]
121 + logprob_drifts = []
122 + for (_, baseline_logprobs), (_, dcp_logprobs) in zip(baseline, dcp):
123 + for baseline_logprob, dcp_logprob in zip(baseline_logprobs, dcp_logprobs):
124 + assert math.isfinite(baseline_logprob)
125 + assert math.isfinite(dcp_logprob)
126 + logprob_drifts.append(abs(baseline_logprob - dcp_logprob))
127 +
128 + assert max(logprob_drifts) <= 1e-2
tests/kernels/attention/test_kimi_k3_mla_key_concat_kv_cache.py new
+246
@@ -0,0 +1,246 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +import torch
6 +
7 +import vllm._custom_ops as ops
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_qkv_quant_kv_cache_fp8_insert,
13 +)
14 +from vllm.platforms import current_platform
15 +
16 +NUM_TOKENS = 4
17 +NUM_HEADS = 2
18 +KV_LORA_RANK = 512
19 +NOPE_HEAD_DIM = 128
20 +ROPE_HEAD_DIM = 64
21 +QK_HEAD_DIM = NOPE_HEAD_DIM + ROPE_HEAD_DIM
22 +V_HEAD_DIM = 128
23 +CACHE_ENTRY = KV_LORA_RANK + ROPE_HEAD_DIM
24 +DS_MLA_CACHE_ENTRY = 656
25 +BLOCK_SIZE = 8
26 +OWNED_TOKEN_INDICES = [1, 3]
27 +
28 +pytestmark = pytest.mark.skipif(
29 + not current_platform.is_cuda(), reason="Kimi-K3 fused MLA ops require CUDA"
30 +)
31 +
32 +
33 +def _inputs() -> dict[str, torch.Tensor]:
34 + torch.manual_seed(0)
35 +
36 + def make(*shape: int) -> torch.Tensor:
37 + return torch.randn(*shape, device="cuda", dtype=torch.bfloat16)
38 +
39 + return {
40 + "q": make(NUM_TOKENS, NUM_HEADS, QK_HEAD_DIM),
41 + "k_nope": make(NUM_TOKENS, NUM_HEADS, NOPE_HEAD_DIM),
42 + "k_pe": make(NUM_TOKENS, ROPE_HEAD_DIM),
43 + "kv_c": make(NUM_TOKENS, KV_LORA_RANK),
44 + "v": make(NUM_TOKENS, NUM_HEADS, V_HEAD_DIM),
45 + "ql_nope": make(NUM_TOKENS, NUM_HEADS, KV_LORA_RANK),
46 + "q_pe": make(NUM_TOKENS, NUM_HEADS, ROPE_HEAD_DIM),
47 + }
48 +
49 +
50 +def _slot_mapping() -> torch.Tensor:
51 + return torch.tensor([-1, 0, -1, 5], device="cuda", dtype=torch.int64)
52 +
53 +
54 +def _owned(tensor: torch.Tensor) -> torch.Tensor:
55 + return tensor[OWNED_TOKEN_INDICES]
56 +
57 +
58 +def _full_key(k_nope: torch.Tensor, k_pe: torch.Tensor) -> torch.Tensor:
59 + shared_pe = k_pe[:, None, :].expand(-1, k_nope.shape[1], -1)
60 + return torch.cat((k_nope, shared_pe), dim=-1)
61 +
62 +
63 +def _latent_query(ql_nope: torch.Tensor, q_pe: torch.Tensor) -> torch.Tensor:
64 + return torch.cat((ql_nope, q_pe), dim=-1)
65 +
66 +
67 +def _make_cache(cache_format: str) -> torch.Tensor:
68 + if cache_format == "bf16":
69 + return torch.full(
70 + (1, BLOCK_SIZE, CACHE_ENTRY),
71 + -7,
72 + device="cuda",
73 + dtype=torch.bfloat16,
74 + )
75 + if cache_format == "fp8":
76 + return torch.full(
77 + (1, BLOCK_SIZE, CACHE_ENTRY),
78 + 1.0,
79 + device="cuda",
80 + dtype=torch.float32,
81 + ).to(torch.float8_e4m3fn)
82 + assert cache_format == "fp8_ds_mla"
83 + return torch.full(
84 + (1, BLOCK_SIZE, DS_MLA_CACHE_ENTRY),
85 + 165,
86 + device="cuda",
87 + dtype=torch.uint8,
88 + )
89 +
90 +
91 +def _reference_cache(
92 + cache_format: str,
93 + inputs: dict[str, torch.Tensor],
94 + initial_cache: torch.Tensor,
95 + slots: torch.Tensor,
96 +) -> torch.Tensor:
97 + reference_cache = initial_cache.clone()
98 + valid_slots = _owned(slots)
99 + if cache_format == "fp8_ds_mla":
100 + ops.concat_and_cache_mla(
101 + _owned(inputs["kv_c"]),
102 + _owned(inputs["k_pe"]),
103 + reference_cache,
104 + valid_slots,
105 + cache_format,
106 + torch.ones(1, device="cuda", dtype=torch.float32),
107 + )
108 + return reference_cache
109 +
110 + latent = torch.cat(
111 + (_owned(inputs["kv_c"]), _owned(inputs["k_pe"])),
112 + dim=-1,
113 + )
114 + flat_reference_cache = reference_cache.view(-1, CACHE_ENTRY)
115 + for row, slot in zip(latent, valid_slots.tolist()):
116 + flat_reference_cache[slot].copy_(row.to(reference_cache.dtype))
117 + return reference_cache
118 +
119 +
120 +def _assert_cache_matches_reference(
121 + cache: torch.Tensor,
122 + reference_cache: torch.Tensor,
123 + initial_cache: torch.Tensor,
124 +) -> None:
125 + assert torch.equal(cache, reference_cache)
126 + for slot in (0, 5):
127 + assert not torch.equal(cache[0, slot], initial_cache[0, slot])
128 + for slot in (1, 2, 3, 4, 6, 7):
129 + assert torch.equal(cache[0, slot], initial_cache[0, slot])
130 +
131 +
132 +@pytest.mark.parametrize("cache_format", ["bf16", "fp8_ds_mla"])
133 +def test_prefill_concat_ignores_negative_slots_for_cache(cache_format: str) -> None:
134 + inputs = _inputs()
135 + slots = _slot_mapping()
136 + initial_cache = _make_cache(cache_format)
137 + mixed_cache = initial_cache.clone()
138 + reference_cache = _reference_cache(cache_format, inputs, initial_cache, slots)
139 +
140 + op = (
141 + fused_mla_key_concat_kv_cache_insert
142 + if cache_format == "bf16"
143 + else fused_mla_key_concat_ds_mla_insert
144 + )
145 + output = op(
146 + inputs["q"],
147 + inputs["k_nope"],
148 + inputs["k_pe"],
149 + inputs["kv_c"],
150 + mixed_cache,
151 + slots,
152 + )
153 +
154 + torch.testing.assert_close(
155 + output, _full_key(inputs["k_nope"], inputs["k_pe"]), rtol=0, atol=0
156 + )
157 + _assert_cache_matches_reference(mixed_cache, reference_cache, initial_cache)
158 +
159 +
160 +def test_fp8_prefill_outputs_ignore_negative_slots_for_cache() -> None:
161 + inputs = _inputs()
162 + slots = _slot_mapping()
163 + initial_cache = _make_cache("fp8")
164 + mixed_cache = initial_cache.clone()
165 + reference_cache = _reference_cache("fp8", inputs, initial_cache, slots)
166 + scale_inv = torch.ones(1, device="cuda", dtype=torch.float32)
167 +
168 + q, k, v = fused_mla_qkv_quant_kv_cache_fp8_insert(
169 + inputs["q"],
170 + inputs["k_nope"],
171 + inputs["k_pe"],
172 + inputs["kv_c"],
173 + inputs["v"],
174 + mixed_cache,
175 + slots,
176 + scale_inv,
177 + scale_inv,
178 + scale_inv,
179 + scale_inv,
180 + )
181 +
182 + expected = (
183 + inputs["q"].to(torch.float8_e4m3fn),
184 + _full_key(inputs["k_nope"], inputs["k_pe"]).to(torch.float8_e4m3fn),
185 + inputs["v"].to(torch.float8_e4m3fn),
186 + )
187 + for actual, reference in zip((q, k, v), expected):
188 + torch.testing.assert_close(actual.float(), reference.float(), rtol=0, atol=0)
189 + _assert_cache_matches_reference(mixed_cache, reference_cache, initial_cache)
190 +
191 +
192 +@pytest.mark.parametrize("cache_format", ["bf16", "fp8", "fp8_ds_mla"])
193 +def test_decode_concat_ignores_negative_slots_for_cache(cache_format: str) -> None:
194 + inputs = _inputs()
195 + slots = _slot_mapping()
196 + initial_cache = _make_cache(cache_format)
197 + mixed_cache = initial_cache.clone()
198 + reference_cache = _reference_cache(cache_format, inputs, initial_cache, slots)
199 + scale_inv = torch.ones(1, device="cuda", dtype=torch.float32)
200 + kwargs = {
201 + "ds_mla": cache_format == "fp8_ds_mla",
202 + "q_scale_inv": scale_inv if cache_format == "fp8" else None,
203 + "cache_scale_inv": scale_inv if cache_format == "fp8" else None,
204 + }
205 +
206 + output = fused_mla_decode_q_concat_kv_cache_insert(
207 + inputs["ql_nope"],
208 + inputs["q_pe"],
209 + inputs["kv_c"],
210 + inputs["k_pe"],
211 + mixed_cache,
212 + slots,
213 + **kwargs,
214 + )
215 +
216 + expected = _latent_query(inputs["ql_nope"], inputs["q_pe"])
217 + if cache_format == "fp8":
218 + expected = expected.to(torch.float8_e4m3fn)
219 + torch.testing.assert_close(output.float(), expected.float(), rtol=0, atol=0)
220 + else:
221 + torch.testing.assert_close(output, expected, rtol=0, atol=0)
222 + _assert_cache_matches_reference(mixed_cache, reference_cache, initial_cache)
223 +
224 +
225 +def test_ds_mla_cache_insert_bit_compatible_with_reference() -> None:
226 + """Fused ds_mla insertion must match the reference bit-for-bit."""
227 + torch.manual_seed(0)
228 + num_tokens, num_blocks = 33, 16
229 + dt = torch.bfloat16
230 + kv_c = torch.randn(num_tokens, KV_LORA_RANK, device="cuda", dtype=dt)
231 + k_pe = torch.randn(num_tokens, 1, ROPE_HEAD_DIM, device="cuda", dtype=dt)
232 + k_nope = torch.randn(num_tokens, NUM_HEADS, NOPE_HEAD_DIM, device="cuda", dtype=dt)
233 + q = torch.randn(num_tokens, NUM_HEADS, QK_HEAD_DIM, device="cuda", dtype=dt)
234 + slots = torch.randperm(num_blocks * BLOCK_SIZE, device="cuda")[:num_tokens].long()
235 + slots[torch.rand(num_tokens, device="cuda") < 0.3] = -1
236 + ref = torch.full(
237 + (num_blocks, BLOCK_SIZE, DS_MLA_CACHE_ENTRY),
238 + 165,
239 + device="cuda",
240 + dtype=torch.uint8,
241 + )
242 + got = ref.clone()
243 + scale = torch.ones(1, device="cuda", dtype=torch.float32)
244 + ops.concat_and_cache_mla(kv_c, k_pe.squeeze(1), ref, slots, "fp8_ds_mla", scale)
245 + fused_mla_key_concat_ds_mla_insert(q, k_nope, k_pe, kv_c, got, slots)
246 + assert torch.equal(ref, got)
tests/v1/attention/test_flashinfer_mla_dcp.py new
+80
@@ -0,0 +1,80 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from unittest.mock import MagicMock
5 +
6 +import pytest
7 +import torch
8 +
9 +from vllm.platforms import current_platform
10 +
11 +requires_flashinfer_mla = pytest.mark.skipif(
12 + not current_platform.has_device_capability(100),
13 + reason="FlashInfer MLA requires compute capability 10 or above.",
14 +)
15 +
16 +
17 +def test_mla_dcp_gathered_query_reserves_backend_head_storage():
18 + from vllm.v1.attention.ops.dcp_utils import reserve_query_head_storage
19 +
20 + query = torch.randn(3, 24, 576, dtype=torch.bfloat16)
21 +
22 + padded = reserve_query_head_storage(query, 128)
23 +
24 + assert padded.shape == query.shape
25 + assert padded.stride() == query.stride()
26 + assert padded.untyped_storage().nbytes() >= 3 * 128 * 576 * 2
27 + assert torch.equal(padded, query)
28 +
29 +
30 +@requires_flashinfer_mla
31 +def test_flashinfer_mla_selects_backend_for_gathered_heads():
32 + from vllm.v1.attention.backends.mla.flashinfer_mla import (
33 + _select_mla_decode_backend,
34 + )
35 +
36 + assert _select_mla_decode_backend(6) is None
37 + assert _select_mla_decode_backend(24) == "cute-dsl"
38 +
39 +
40 +@requires_flashinfer_mla
41 +def test_flashinfer_mla_forward_uses_gathered_head_count(monkeypatch):
42 + import vllm.v1.attention.backends.mla.flashinfer_mla as flashinfer_mla
43 +
44 + impl = MagicMock()
45 + impl.bmm1_scale = 1.0
46 + impl.bmm2_scale = 1.0
47 + impl.need_to_return_lse_for_decode = True
48 + impl.num_heads = 6
49 + impl.qk_nope_head_dim = 128
50 + impl.kv_lora_rank = 512
51 + impl.qk_rope_head_dim = 64
52 + impl.kv_cache_dtype = "auto"
53 +
54 + attn_metadata = MagicMock()
55 + attn_metadata.causal = True
56 + attn_metadata.num_decode_tokens = 2
57 + attn_metadata.num_decodes = 2
58 + attn_metadata.max_seq_len = 1
59 + attn_metadata.decode.block_table = torch.zeros(2, 1, dtype=torch.int32)
60 + attn_metadata.decode.seq_lens = torch.ones(2, dtype=torch.int32)
61 +
62 + query = torch.ones(2, 24, 576, dtype=torch.bfloat16)
63 + kv_cache = torch.ones(1, 128, 576, dtype=torch.bfloat16)
64 + kernel = MagicMock(
65 + return_value=(
66 + torch.ones(2, 1, 24, 512, dtype=torch.bfloat16),
67 + torch.ones(2, 24, dtype=torch.float32),
68 + )
69 + )
70 + monkeypatch.setattr(flashinfer_mla, "_get_workspace_buffer", MagicMock())
71 + monkeypatch.setattr(flashinfer_mla, "trtllm_batch_decode_with_kv_cache_mla", kernel)
72 + layer = MagicMock()
73 +
74 + output, lse = flashinfer_mla.FlashInferMLAImpl.forward_mqa(
75 + impl, query, kv_cache, attn_metadata, layer
76 + )
77 +
78 + assert kernel.call_args.kwargs["backend"] == "cute-dsl"
79 + assert output.shape == (2, 24, 512)
80 + assert lse is not None and lse.shape == (2, 24)
vllm/envs.py
+13
@@ -190,6 +190,9 @@ if TYPE_CHECKING:
190 VLLM_USE_DEEP_GEMM_E8M0: bool = True
191 VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES: bool = True
192 VLLM_DCP_Q_REPLICATE: bool = False
193 + VLLM_USE_DIRECT_DCP_A2A: bool | None = None
194 + VLLM_USE_DIRECT_DCP_Q_GATHER: bool | None = None
195 + VLLM_USE_DIRECT_DCP_KV_GATHER: bool | None = None
196 VLLM_DEEP_GEMM_WARMUP: Literal[
197 "skip",
198 "full",
@@ -2057,6 +2060,16 @@ environment_variables: dict[str, Callable[[], Any]] = {
2060 "VLLM_USE_SIMPLE_KV_OFFLOAD": lambda: bool(
2061 int(os.getenv("VLLM_USE_SIMPLE_KV_OFFLOAD", "0"))
2062 ),
2063 + # Direct DCP ops default on when applicable; set to 1 to enforce or 0 to disable.
2064 + "VLLM_USE_DIRECT_DCP_A2A": lambda: maybe_convert_bool(
2065 + os.getenv("VLLM_USE_DIRECT_DCP_A2A")
2066 + ),
2067 + "VLLM_USE_DIRECT_DCP_Q_GATHER": lambda: maybe_convert_bool(
2068 + os.getenv("VLLM_USE_DIRECT_DCP_Q_GATHER")
2069 + ),
2070 + "VLLM_USE_DIRECT_DCP_KV_GATHER": lambda: maybe_convert_bool(
2071 + os.getenv("VLLM_USE_DIRECT_DCP_KV_GATHER")
2072 + ),
2073 # Whether to enable dual cuda streams for LoRA computation
2074 # (used by both BaseLinearLayerWithLoRA and FusedMoEWithLoRA to
2075 # overlap the base layer compute with the LoRA fast path).
vllm/model_executor/layers/attention/mla_attention.py
+87 -48
@@ -206,6 +206,7 @@ from abc import abstractmethod
206 from collections.abc import Callable
207 from dataclasses import dataclass
208 from enum import Enum
209 +from math import lcm
210 from typing import ClassVar, Generic, TypeVar, cast
211
212 import numpy as np
@@ -222,7 +223,6 @@ from vllm.config import (
223 ModelConfig,
224 VllmConfig,
225 get_current_vllm_config,
225 - get_current_vllm_config_or_none,
226 )
227 from vllm.config.cache import CacheDType
228 from vllm.distributed.parallel_state import (
@@ -264,7 +264,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
264 from vllm.model_executor.utils import replace_parameter
265 from vllm.platforms import current_platform
266 from vllm.utils.flashinfer import has_flashinfer
267 -from vllm.utils.math_utils import cdiv, round_down
267 +from vllm.utils.math_utils import cdiv, round_down, round_up
268 from vllm.utils.torch_utils import (
269 LayerNameType,
270 _encode_layer_name,
@@ -292,8 +292,7 @@ from vllm.v1.attention.backends.utils import (
292 get_num_attention_heads_from_layers,
293 split_decodes_and_prefills,
294 )
295 -from vllm.v1.attention.ops.common import cp_lse_ag_out_ar, cp_lse_ag_out_rs
296 -from vllm.v1.attention.ops.dcp_alltoall import dcp_a2a_lse_reduce
295 +from vllm.v1.attention.ops.dcp_utils import MLADCPManager
296 from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
297 from vllm.v1.attention.selector import get_attn_backend
298 from vllm.v1.kv_cache_interface import (
@@ -579,12 +578,27 @@ class MLAAttention(nn.Module, AttentionLayerBase):
578
579 self.use_sparse = use_sparse
580
582 - _vllm_config = get_current_vllm_config_or_none()
583 - self.dcp_a2a = (
584 - _vllm_config is not None
585 - and _vllm_config.parallel_config.decode_context_parallel_size > 1
586 - and _vllm_config.parallel_config.dcp_comm_backend == "a2a"
587 - )
581 + self.dcp_manager: MLADCPManager | None = None
582 + if self.impl.dcp_world_size > 1:
583 + query_dtype = (
584 + current_platform.fp8_dtype()
585 + if is_quantized_kv_cache(self.kv_cache_dtype)
586 + and self.kv_cache_dtype != "fp8_ds_mla"
587 + and self.impl.supports_quant_query_input
588 + else dtype
589 + )
590 + self.dcp_manager = MLADCPManager(
591 + vllm_config=vllm_config,
592 + device=next(kv_b_proj.parameters()).device,
593 + num_heads=self.num_heads,
594 + query_head_dim=self.kv_lora_rank + self.qk_rope_head_dim,
595 + output_head_dim=self.kv_lora_rank,
596 + query_dtype=query_dtype,
597 + output_dtype=dtype,
598 + padded_num_heads=self.q_pad_num_heads,
599 + is_lse_base_on_e=self.impl.lse_base_on_e,
600 + use_pcp=self.use_pcp,
601 + )
602
603 self.is_aiter_triton_fp8_bmm_enabled = rocm_aiter_ops.is_fp8bmm_enabled()
604
@@ -753,9 +767,6 @@ class MLAAttention(nn.Module, AttentionLayerBase):
767 return quant_output.fill_(0)
768 return output.fill_(0)
769
756 - if self.impl.dcp_world_size == -1:
757 - self.impl.dcp_world_size = get_dcp_group().world_size
758 -
770 fp8_attention = is_quantized_kv_cache(self.kv_cache_dtype)
771
772 num_actual_toks = attn_metadata.num_actual_tokens
@@ -914,6 +925,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
925 mqa_q = (mqa_ql_nope, mqa_q_pe)
926 # concatenate nope + pe -> (B, N, L + P) (fp8 op above may have fused)
927 if self.impl.dcp_world_size > 1:
928 + assert self.dcp_manager is not None
929 if self.use_pcp:
930 if self.impl.dcp_world_size > self.impl.pcp_world_size:
931 if isinstance(mqa_q, tuple):
@@ -924,8 +936,8 @@ class MLAAttention(nn.Module, AttentionLayerBase):
936 # concatenate mqa_ql_nope and mqa_q_pe -> (B, N, L + P)
937 mqa_q = torch.cat(mqa_q, dim=-1)
938 if not qrep_decode:
927 - # mqa_q do allgather in head dim.
928 - mqa_q = get_dcp_group().all_gather(mqa_q, dim=1)
939 + assert self.dcp_manager.query_gather is not None
940 + mqa_q = self.dcp_manager.query_gather(mqa_q)
941
942 # call decode attn
943 if not self.impl.is_sparse:
@@ -935,27 +947,23 @@ class MLAAttention(nn.Module, AttentionLayerBase):
947 # correct dcp attn_out with lse.
948 if self.impl.dcp_world_size > 1:
949 assert lse is not None
938 - if self.dcp_a2a:
939 - attn_out = dcp_a2a_lse_reduce(
940 - attn_out,
941 - lse,
942 - get_dcp_group(),
943 - is_lse_base_on_e=self.impl.lse_base_on_e,
944 - )
945 - elif self.use_pcp:
946 - attn_out = cp_lse_ag_out_ar(
947 - attn_out,
948 - lse,
949 - get_dcp_group(),
950 - is_lse_base_on_e=self.impl.lse_base_on_e,
951 - )
952 - else:
953 - attn_out = cp_lse_ag_out_rs(
954 - attn_out,
955 - lse,
956 - get_dcp_group(),
957 - is_lse_base_on_e=self.impl.lse_base_on_e,
958 - )
950 + assert self.dcp_manager is not None
951 + seq_lens = (
952 + attn_metadata.decode.seq_lens
953 + if attn_metadata.decode is not None
954 + else cast(torch.Tensor, attn_metadata.seq_lens)[ # type: ignore[attr-defined]
955 + : attn_metadata.num_decodes
956 + ]
957 + )
958 + query_start_loc = attn_metadata.query_start_loc[
959 + : attn_metadata.num_decodes + 1
960 + ]
961 + attn_out = self.dcp_manager.combine(
962 + attn_out,
963 + lse,
964 + seq_lens=seq_lens,
965 + query_start_loc=query_start_loc,
966 + )
967 if self.use_pcp:
968 attn_out = finalize_mla_pcp_decode(attn_out, self.num_heads)
969
@@ -1427,6 +1435,7 @@ class MLACommonPrefillMetadata:
1435 chunks: "list[MLACommonPrefillMetadata.ContextChunk]"
1436 context_lens_list: list[int]
1437 empty_token_slices: list[slice]
1438 + dcp_manager: MLADCPManager | None = None
1439
1440 block_table: torch.Tensor
1441 query_start_loc: torch.Tensor
@@ -1684,6 +1693,25 @@ def _flat_int32(values: list[int] | np.ndarray) -> torch.Tensor:
1693 return np_to_pinned_tensor(np.asarray(values, dtype=np.int32))
1694
1695
1696 +def align_mla_chunked_context_workspace_size(
1697 + vllm_config: VllmConfig,
1698 + workspace_size: int,
1699 +) -> int:
1700 + parallel_config = vllm_config.parallel_config
1701 + alignment = vllm_config.cache_config.block_size
1702 + if parallel_config.decode_context_parallel_size > 1:
1703 + alignment = lcm(
1704 + alignment,
1705 + parallel_config.decode_context_parallel_size
1706 + * parallel_config.cp_kv_cache_interleave_size,
1707 + )
1708 + workspace_size = max(
1709 + workspace_size,
1710 + vllm_config.scheduler_config.max_num_seqs * alignment,
1711 + )
1712 + return round_up(workspace_size, alignment)
1713 +
1714 +
1715 def build_mla_chunked_context_metadata(
1716 *,
1717 context_lens_cpu: torch.Tensor,
@@ -1696,6 +1724,7 @@ def build_mla_chunked_context_metadata(
1724 dcp_world_size: int,
1725 dcp_local_block_size: int,
1726 dcp_virtual_block_size: int,
1727 + dcp_manager: MLADCPManager | None = None,
1728 ) -> "MLACommonPrefillMetadata.ChunkedContextMetadata | None":
1729 """Build chunked-context metadata for an MLA prefill.
1730
@@ -1714,6 +1743,7 @@ def build_mla_chunked_context_metadata(
1743 dcp_world_size: Decode-context-parallel world size (1 if disabled).
1744 dcp_local_block_size: Per-rank interleave block size for DCP.
1745 dcp_virtual_block_size: ``dcp_local_block_size * dcp_world_size``.
1746 + dcp_manager: Shared MLA DCP collective manager.
1747
1748 Returns:
1749 The chunked-context metadata, or None when no prefill has any context.
@@ -1902,6 +1932,7 @@ def build_mla_chunked_context_metadata(
1932 chunks=chunks,
1933 context_lens_list=context_lens,
1934 empty_token_slices=empty_token_slices,
1935 + dcp_manager=dcp_manager,
1936 )
1937
1938
@@ -1954,7 +1985,10 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1985 64 * 1024,
1986 )
1987
1957 - return max(chunked_prefill_workspace_size, cache_config.block_size)
1988 + return align_mla_chunked_context_workspace_size(
1989 + vllm_config,
1990 + chunked_prefill_workspace_size,
1991 + )
1992
1993 @staticmethod
1994 def determine_prefill_query_data_type(
@@ -2033,6 +2067,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2067 self.q_data_type = self.determine_prefill_query_data_type(
2068 vllm_config, self.model_config.dtype
2069 )
2070 + attention_layer = self.compilation_config.static_forward_context[layer_names[0]]
2071
2072 try:
2073 self.dcp_world_size = get_dcp_group().world_size
@@ -2050,6 +2085,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2085 )
2086
2087 use_packed_fp8_cache = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
2088 + self.dcp_manager: MLADCPManager | None = None
2089 if self.dcp_world_size > 1:
2090 # Note(hc): The local kvcache is incomplete when DCP is triggered,
2091 # an additional kvcache allgather across the DCP group is therefore
@@ -2067,6 +2103,12 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2103 else self.model_config.dtype,
2104 device=device,
2105 )
2106 + self.dcp_manager = getattr(attention_layer, "dcp_manager", None)
2107 + assert isinstance(self.dcp_manager, MLADCPManager)
2108 + self.dcp_manager.init_kv_gather(
2109 + self.chunked_prefill_workspace,
2110 + self.chunked_prefill_workspace_size,
2111 + )
2112 else:
2113 self.chunked_prefill_workspace = torch.empty(
2114 (
@@ -2080,9 +2122,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2122 # Metadata builders are created per ubatch when DBO is enabled. MLA
2123 # prefill backends keep the prepared metadata on the backend object, so
2124 # each builder needs its own backend instance to avoid cross-ubatch races.
2083 - self._prefill_backend = self.compilation_config.static_forward_context[
2084 - layer_names[0]
2085 - ].prefill_backend.clone()
2125 + self._prefill_backend = attention_layer.prefill_backend.clone()
2126
2127 supports_spec_decode = self.query_len_support != QueryLenSupport.SINGLE_ONLY
2128 self._init_reorder_batch_threshold(
@@ -2223,6 +2263,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2263 dcp_world_size=self.dcp_world_size,
2264 dcp_local_block_size=self.dcp_local_block_size,
2265 dcp_virtual_block_size=self.dcp_virtual_block_size,
2266 + dcp_manager=self.dcp_manager,
2267 )
2268
2269 prefill_metadata = MLACommonPrefillMetadata(
@@ -2660,9 +2701,8 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2701 ]
2702 assert toks * dcp_world_size <= cur_allgather_workspace.shape[0]
2703 cur_allgather_kvcache = cur_allgather_workspace[: toks * dcp_world_size]
2663 - cur_allgather_kvcache.copy_(
2664 - get_dcp_group().all_gather(local_gathered_kvcache, dim=0)
2665 - )
2704 + dcp_manager = cast(MLADCPManager, chunked_context.dcp_manager)
2705 + dcp_manager.kv_gather(cur_allgather_kvcache, local_gathered_kvcache)
2706 assert (
2707 cur_allgather_kvcache.shape[-1]
2708 == self.kv_lora_rank + self.qk_rope_head_dim
@@ -2732,8 +2772,6 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2772 output_scale: torch.Tensor | None = None,
2773 ) -> None:
2774 assert attn_metadata.prefill is not None
2735 - assert self.dcp_world_size != -1
2736 -
2775 prefill_metadata = attn_metadata.prefill
2776 assert prefill_metadata.prefill_backend is not None
2777 use_fp8_prefill = prefill_metadata.q_data_type == current_platform.fp8_dtype()
@@ -2877,10 +2915,11 @@ class MLACommonImpl(MLACommonBaseImpl[M], Generic[M]):
2915 and (self.qk_rope_head_dim == 64)
2916 )
2917
2880 - self.dcp_world_size: int = -1
2881 -
2918 + parallel_config = get_current_vllm_config().parallel_config
2919 + # Avoid requiring an initialized DCP group in tests.
2920 + self.dcp_world_size: int = parallel_config.decode_context_parallel_size
2921 self.cp_kv_cache_interleave_size: int = (
2883 - get_current_vllm_config().parallel_config.cp_kv_cache_interleave_size
2922 + parallel_config.cp_kv_cache_interleave_size
2923 )
2924
2925 @abstractmethod
vllm/model_executor/layers/attention/sparse_mla_attention.py
+22 -3
@@ -20,6 +20,7 @@ from vllm.model_executor.layers.attention.mla_attention import (
20 MLACommonMetadata,
21 MLACommonPrefillMetadata,
22 accumulate_mla_context_chunk,
23 + align_mla_chunked_context_workspace_size,
24 build_mla_chunked_context_metadata,
25 get_mla_dims,
26 init_mla_context_partial,
@@ -31,6 +32,7 @@ from vllm.utils.torch_utils import is_quantized_kv_cache, np_to_pinned_tensor
32 from vllm.v1.attention.backend import AttentionMetadata, AttentionMetadataBuilder
33 from vllm.v1.attention.backends.fa_utils import get_flash_attn_version
34 from vllm.v1.attention.backends.utils import split_decodes_and_prefills
35 +from vllm.v1.attention.ops.dcp_utils import MLADCPManager
36 from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
37
38 if TYPE_CHECKING:
@@ -162,9 +164,19 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
164 dtype=torch.int32,
165 device=device,
166 )
165 - layer_prefill_backend = vllm_config.compilation_config.static_forward_context[
167 + attention_layer = vllm_config.compilation_config.static_forward_context[
168 layer_names[0]
167 - ].prefill_backend
169 + ]
170 + layer_prefill_backend = attention_layer.prefill_backend
171 + self.dcp_manager: MLADCPManager | None = None
172 + if self.dcp_world_size > 1:
173 + self.dcp_manager = getattr(attention_layer, "dcp_manager", None)
174 + assert isinstance(self.dcp_manager, MLADCPManager)
175 + if layer_prefill_backend is not None:
176 + self.dcp_manager.init_kv_gather(
177 + self.chunked_prefill_workspace,
178 + self.chunked_prefill_workspace_size,
179 + )
180 self._prefill_backend = (
181 layer_prefill_backend.clone() if layer_prefill_backend is not None else None
182 )
@@ -184,7 +196,13 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
196 64 * 1024,
197 scheduler_config.max_num_seqs * topk_tokens,
198 )
187 - return max(workspace_size, cache_config.block_size)
199 + workspace_size = max(
200 + workspace_size,
201 + scheduler_config.max_num_seqs * cache_config.block_size,
202 + )
203 + if vllm_config.parallel_config.decode_context_parallel_size > 1:
204 + return align_mla_chunked_context_workspace_size(vllm_config, workspace_size)
205 + return workspace_size
206
207 def _build_req_id_per_token(
208 self,
@@ -232,6 +250,7 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
250 dcp_world_size=self.dcp_world_size,
251 dcp_local_block_size=self.dcp_local_block_size,
252 dcp_virtual_block_size=self.dcp_virtual_block_size,
253 + dcp_manager=self.dcp_manager,
254 )
255
256 def build(
vllm/models/kimi_k3/nvidia/mla.py
+69 -11
@@ -24,8 +24,8 @@ KV cache, and absorbs ``kv_b_proj`` into ``W_UK_T`` / ``W_UV`` -- mirroring the
24 K3 specifics: optional rotary embedding (disabled for the target model's NoPE
25 layers, enabled for DSpark) and an optional sigmoid output gate (``g_proj``).
26
27 -Out of scope (extension points, not wired here): context parallelism (DCP/PCP),
28 -sparse/indexer MLA, and the ROCm/aiter fp8/fp4 BMM fast paths.
27 +Out of scope (extension points, not wired here): prefill context parallelism
28 +(PCP), sparse/indexer MLA, and the ROCm/aiter fp8/fp4 BMM fast paths.
29 """
30
31 import math
@@ -35,7 +35,11 @@ import torch
35 from torch import nn
36
37 from vllm.compilation.breakable_cudagraph import eager_break_during_capture
38 -from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
38 +from vllm.config import (
39 + CacheConfig,
40 + VllmConfig,
41 + get_current_vllm_config,
42 +)
43 from vllm.distributed import get_tensor_model_parallel_world_size
44 from vllm.forward_context import get_forward_context
45 from vllm.logger import init_logger
@@ -78,6 +82,7 @@ from vllm.v1.attention.backend import (
82 MLAAttentionImpl,
83 )
84 from vllm.v1.attention.backends.mla.prefill import get_mla_prefill_backend
85 +from vllm.v1.attention.ops.dcp_utils import MLADCPManager
86 from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
87 from vllm.v1.attention.selector import get_attn_backend
88 from vllm.v1.kv_cache_interface import KVCacheSpec, MLAAttentionSpec, get_kv_quant_mode
@@ -310,10 +315,36 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
315
316 vllm_config = get_current_vllm_config()
317 parallel_config = vllm_config.parallel_config
313 - assert (
314 - parallel_config.decode_context_parallel_size <= 1
315 - and parallel_config.prefill_context_parallel_size <= 1
316 - ), "Kimi-K3 MultiHeadLatentAttention does not support context parallelism."
318 + assert parallel_config.prefill_context_parallel_size == 1, (
319 + "Kimi-K3 MultiHeadLatentAttention does not support prefill context "
320 + "parallelism."
321 + )
322 + self.dcp_world_size = parallel_config.decode_context_parallel_size
323 + assert self.dcp_world_size <= 1 or self.rotary_emb is None, (
324 + "Kimi-K3 MultiHeadLatentAttention does not support RoPE with decode "
325 + "context parallelism because gathered queries require gathered "
326 + "positions."
327 + )
328 + self.dcp_manager: MLADCPManager | None = None
329 + if self.dcp_world_size > 1:
330 + query_dtype = (
331 + torch.float8_e4m3fn
332 + if is_quantized_kv_cache(self.kv_cache_dtype)
333 + and self.kv_cache_dtype != "fp8_ds_mla"
334 + else dtype
335 + )
336 + self.dcp_manager = MLADCPManager(
337 + vllm_config=vllm_config,
338 + device=next(self.kv_b_proj.parameters()).device,
339 + num_heads=self.num_local_heads,
340 + query_head_dim=self.head_size,
341 + output_head_dim=self.kv_lora_rank,
342 + query_dtype=query_dtype,
343 + output_dtype=dtype,
344 + padded_num_heads=self.q_pad_num_heads,
345 + is_lse_base_on_e=self.impl.lse_base_on_e,
346 + use_pcp=False,
347 + )
348 self.prefill_backend = get_mla_prefill_backend(vllm_config)(
349 num_heads=self.num_local_heads,
350 scale=self.scale,
@@ -584,9 +615,25 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
615 cos_sin_cache,
616 slot_mapping[:num_mqa_tokens],
617 )
587 - latent_out, _lse = self.impl.forward_mqa( # type: ignore[attr-defined]
618 + if self.dcp_world_size > 1:
619 + assert self.dcp_manager is not None
620 + assert self.dcp_manager.query_gather is not None
621 + mqa_q = self.dcp_manager.query_gather(mqa_q)
622 + latent_out, lse = self.impl.forward_mqa( # type: ignore[attr-defined]
623 mqa_q, self._attn_read_kv_cache(), attn_metadata, self
624 )
625 + if self.dcp_world_size > 1:
626 + assert lse is not None
627 + assert self.dcp_manager is not None
628 + assert attn_metadata.decode is not None
629 + latent_out = self.dcp_manager.combine(
630 + latent_out,
631 + lse,
632 + seq_lens=attn_metadata.decode.seq_lens,
633 + query_start_loc=attn_metadata.query_start_loc[
634 + : attn_metadata.num_decodes + 1
635 + ],
636 + )
637 self._v_up_proj(latent_out, out=attn_out[:num_mqa_tokens])
638
639 def _decode_concat_cache(
@@ -752,9 +799,20 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
799 )
800
801 if has_context:
755 - context_output, context_lse = self.impl._compute_prefill_context( # type: ignore[attr-defined]
756 - q, self._attn_read_kv_cache(), attn_metadata, self._k_scale
757 - )
802 + if self.dcp_world_size > 1:
803 + context_output, context_lse = (
804 + self.impl._context_parallel_compute_prefill_context( # type: ignore[attr-defined]
805 + q,
806 + self._attn_read_kv_cache(),
807 + attn_metadata,
808 + k_scale=self._k_scale,
809 + dcp_world_size=self.dcp_world_size,
810 + )
811 + )
812 + else:
813 + context_output, context_lse = self.impl._compute_prefill_context( # type: ignore[attr-defined]
814 + q, self._attn_read_kv_cache(), attn_metadata, self._k_scale
815 + )
816 suffix_output, suffix_lse = output_prefill
817 out = out.view(-1, self.num_local_heads, self.v_head_dim)
818 merge_attn_states(
vllm/v1/attention/backends/mla/flashinfer_mla.py
+4 -2
@@ -309,9 +309,11 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
309
310 return_lse = self.need_to_return_lse_for_decode
311 workspace_buffer = _get_workspace_buffer(return_lse)
312 + # Parallel gathers can change the runtime Q heads from TP-local num_heads.
313 + runtime_num_heads = q.shape[-2]
314 # trtllm-gen rejects MLA head counts it can't tile (e.g. 96);
315 # fall back to cute-dsl for those.
314 - decode_backend = _select_mla_decode_backend(self.num_heads)
316 + decode_backend = _select_mla_decode_backend(runtime_num_heads)
317 extra_kwargs = {}
318 if decode_backend:
319 extra_kwargs["backend"] = decode_backend
@@ -326,7 +328,7 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
328 if self._mla_counter_bytes is None:
329 self._mla_counter_bytes = get_trtllm_gen_multi_ctas_kv_counter_bytes(
330 self._mla_counter_max_batch,
329 - self.num_heads,
331 + runtime_num_heads,
332 get_device_sm_count(q.device),
333 )
334 extra_kwargs["multi_ctas_kv_counter_buffer"] = (
vllm/v1/attention/backends/mla/tokenspeed_mla.py
+2 -1
@@ -289,8 +289,9 @@ class TokenspeedMLAImpl(MLACommonImpl[MLACommonMetadata]):
289 self.output_scale = layer._k_scale_float
290
291 if self._workspace_buffer is None:
292 + # Parallelism can change the runtime query head count.
293 self._workspace_buffer = _get_workspace(
293 - q.device, self.num_heads, self.kv_lora_rank
294 + q.device, q.shape[-2], self.kv_lora_rank
295 )
296
297 # vLLM kv_c_and_k_pe_cache is already (num_blocks, block_size, head_size).
vllm/v1/attention/ops/common.py
+51 -4
@@ -6,6 +6,34 @@ from vllm.distributed.parallel_state import GroupCoordinator
6 from vllm.triton_utils import tl, triton
7
8
9 +def mask_dcp_empty_shards_(
10 + lse: torch.Tensor,
11 + seq_lens: torch.Tensor | None,
12 + query_start_loc: torch.Tensor | None,
13 +) -> None:
14 + if seq_lens is None and query_start_loc is None:
15 + return
16 + if seq_lens is None or query_start_loc is None:
17 + raise ValueError("seq_lens and query_start_loc must be provided together")
18 + if (
19 + seq_lens.ndim != 1
20 + or query_start_loc.ndim != 1
21 + or query_start_loc.shape[0] != seq_lens.shape[0] + 1
22 + ):
23 + raise ValueError("query_start_loc must contain one boundary per sequence")
24 +
25 + row_indices = torch.arange(
26 + lse.shape[0], device=lse.device, dtype=query_start_loc.dtype
27 + )
28 + sequence_indices = torch.searchsorted(
29 + query_start_loc[1:], row_indices, right=True
30 + ).clamp_max(seq_lens.shape[0] - 1)
31 + empty_rows = (row_indices >= query_start_loc[-1]) | (
32 + seq_lens[sequence_indices] == 0
33 + )
34 + lse.masked_fill_(empty_rows[:, None], float("-inf"))
35 +
36 +
37 @triton.jit
38 def _correct_attn_cp_out_kernel(
39 outputs_ptr,
@@ -51,7 +79,7 @@ def _correct_attn_cp_out_kernel(
79 )
80
81 # calc final lse
54 - lse = tl.load(lses_ptr + lse_offsets)
82 + lse = tl.load(lses_ptr + lse_offsets).to(tl.float32)
83 lse = tl.where((lse != lse) | (lse == float("inf")), -float("inf"), lse)
84 lse_max = tl.max(lse, axis=0)
85 lse_max = tl.where(lse_max == -float("inf"), 0, lse_max)
@@ -80,7 +108,7 @@ def _correct_attn_cp_out_kernel(
108 lse_offset = (
109 lse_idx * lses_stride_N + batch_idx * lses_stride_B + head_idx * lses_stride_H
110 )
83 - lse_tmp = tl.load(lses_ptr + lse_offset)
111 + lse_tmp = tl.load(lses_ptr + lse_offset).to(tl.float32)
112 lse_finally = lse_tmp - lse
113 lse_finally = tl.where(
114 (lse_finally != lse_finally) | (lse_finally == float("inf")),
@@ -185,6 +213,8 @@ def _cp_lse_common(
213 cp_group: GroupCoordinator,
214 ctx: CPTritonContext | None = None,
215 is_lse_base_on_e=True,
216 + seq_lens: torch.Tensor | None = None,
217 + query_start_loc: torch.Tensor | None = None,
218 ):
219 """
220 cp_attn_out: [ B, H, D ]
@@ -197,6 +227,7 @@ def _cp_lse_common(
227 ctx = CPTritonContext()
228
229 cp_attn_lse = cp_attn_lse.contiguous()
230 + mask_dcp_empty_shards_(cp_attn_lse, seq_lens, query_start_loc)
231 lses = cp_group.all_gather(cp_attn_lse, dim=0).reshape(
232 (cp_group.world_size,) + cp_attn_lse.shape
233 )
@@ -217,13 +248,21 @@ def cp_lse_ag_out_rs(
248 ctx: CPTritonContext | None = None,
249 return_lse: bool = False,
250 is_lse_base_on_e=True,
251 + seq_lens: torch.Tensor | None = None,
252 + query_start_loc: torch.Tensor | None = None,
253 ):
254 """
255 cp_attn_out: [ B, H, D ]
256 cp_attn_lse: [ B, H ]
257 """
258 out, lse = _cp_lse_common(
226 - cp_attn_out, cp_attn_lse, cp_group, ctx=ctx, is_lse_base_on_e=is_lse_base_on_e
259 + cp_attn_out,
260 + cp_attn_lse,
261 + cp_group,
262 + ctx=ctx,
263 + is_lse_base_on_e=is_lse_base_on_e,
264 + seq_lens=seq_lens,
265 + query_start_loc=query_start_loc,
266 )
267 out = cp_group.reduce_scatter(out, dim=1)
268
@@ -242,13 +281,21 @@ def cp_lse_ag_out_ar(
281 ctx: CPTritonContext | None = None,
282 return_lse: bool = False,
283 is_lse_base_on_e=True,
284 + seq_lens: torch.Tensor | None = None,
285 + query_start_loc: torch.Tensor | None = None,
286 ):
287 """
288 cp_attn_out: [ B, H, D ]
289 cp_attn_lse: [ B, H ]
290 """
291 out, lse = _cp_lse_common(
251 - cp_attn_out, cp_attn_lse, cp_group, ctx=ctx, is_lse_base_on_e=is_lse_base_on_e
292 + cp_attn_out,
293 + cp_attn_lse,
294 + cp_group,
295 + ctx=ctx,
296 + is_lse_base_on_e=is_lse_base_on_e,
297 + seq_lens=seq_lens,
298 + query_start_loc=query_start_loc,
299 )
300 out = cp_group.all_reduce(out)
301
vllm/v1/attention/ops/dcp_alltoall.py
+20 -11
@@ -26,6 +26,7 @@ import torch
26 import torch.distributed as dist
27
28 from vllm.triton_utils import tl, triton
29 +from vllm.v1.attention.ops.common import mask_dcp_empty_shards_
30
31 if TYPE_CHECKING:
32 from vllm.distributed.parallel_state import GroupCoordinator
@@ -87,7 +88,9 @@ def _lse_weighted_combine(
88 weights = weights / weight_sum.clamp(min=1e-10) # [N, B, H]
89
90 # Weighted combination: sum over N dimension
90 - result = (outputs * weights.unsqueeze(-1)).sum(dim=0) # [B, H, D]
91 + weights = weights.unsqueeze(-1)
92 + outputs = torch.where(weights == 0, torch.zeros_like(outputs), outputs)
93 + result = (outputs * weights).sum(dim=0) # [B, H, D]
94
95 if return_lse:
96 if is_lse_base_on_e:
@@ -172,7 +175,7 @@ def _dcp_a2a_pack_send_kernel(
175
176 lse_val = tl.load(
177 lse_ptr + batch_idx * lse_stride_B + src_head_idx * lse_stride_H
175 - )
178 + ).to(tl.float32)
179 if LSE_PACK_DIM == 1:
180 tl.store(
181 send_ptr + send_base + HEAD_DIM * send_stride_D,
@@ -301,10 +304,11 @@ def _dcp_a2a_unpack_combine_kernel(
304 else:
305 weight = tl.exp2(lse_val - global_lse)
306 weight = tl.where(weight != weight, 0.0, weight)
304 - acc += (
305 - tl.load(recv_ptr + recv_base + d_offsets * recv_stride_D).to(tl.float32)
306 - * weight
307 + partial = tl.load(recv_ptr + recv_base + d_offsets * recv_stride_D).to(
308 + tl.float32
309 )
310 + partial = tl.where(weight == 0.0, 0.0, partial)
311 + acc += partial * weight
312
313 final_offsets = (
314 batch_idx * out_stride_B + head_idx * out_stride_H + d_offsets * out_stride_D
@@ -324,7 +328,10 @@ def _dcp_a2a_pack_send(
328 h_per_rank: int,
329 head_dim: int,
330 lse_pack_dim: int,
331 + seq_lens: torch.Tensor | None = None,
332 + query_start_loc: torch.Tensor | None = None,
333 ) -> None:
334 + mask_dcp_empty_shards_(cp_attn_lse, seq_lens, query_start_loc)
335 grid = (cp_attn_out.shape[0], h_per_rank, 1)
336 _dcp_a2a_pack_send_kernel[grid](
337 cp_attn_out,
@@ -396,20 +403,24 @@ def dcp_a2a_lse_reduce(
403 ctx: CPTritonContext | None = None,
404 return_lse: bool = False,
405 is_lse_base_on_e: bool = True,
406 + seq_lens: torch.Tensor | None = None,
407 + query_start_loc: torch.Tensor | None = None,
408 ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
409 """
410 Combine partial attention outputs across DCP ranks using All-to-All.
411
403 - The output and fp32 LSE are packed into a single output-dtype buffer, sent
412 + The output and LSE are packed into a single output-dtype buffer, sent
413 with one All-to-All, then unpacked and combined with exact LSE weighting.
414
415 Args:
416 cp_attn_out: [B, H, D] where B=num_tokens, H=total_heads, D=head_dim
408 - cp_attn_lse: [B, H] log-sum-exp values (fp32)
417 + cp_attn_lse: [B, H] floating-point log-sum-exp values
418 cp_group: GroupCoordinator for DCP communication
419 ctx: CPTritonContext (unused, for signature compatibility)
420 return_lse: If True, also return the combined global LSE
421 is_lse_base_on_e: If True, LSE is base e; if False, base 2
422 + seq_lens: Local KV lengths. Empty shards contribute zero weight.
423 + query_start_loc: Cumulative query-token offsets for each request.
424
425 Returns:
426 Combined output [B, H/N, D] (head-scattered)
@@ -426,10 +437,6 @@ def dcp_a2a_lse_reduce(
437 if H % world_size != 0:
438 raise ValueError(f"H={H} must be divisible by DCP world size {world_size}.")
439 H_per_rank = H // world_size
429 - # The pack kernel bit-casts the LSE as fp32; some MLA backends return it in
430 - # the activation dtype (bf16/fp16), so enforce the documented fp32 contract.
431 - if cp_attn_lse.dtype != torch.float32:
432 - cp_attn_lse = cp_attn_lse.to(torch.float32)
440 lse_pack_dim = _dcp_a2a_lse_pack_dim(cp_attn_out.dtype)
441
442 send_buffer, recv_buffer = _dcp_a2a_send_recv_buffers(
@@ -446,6 +453,8 @@ def dcp_a2a_lse_reduce(
453 H_per_rank,
454 D,
455 lse_pack_dim,
456 + seq_lens=seq_lens,
457 + query_start_loc=query_start_loc,
458 )
459
460 work = dist.all_to_all_single(
vllm/v1/attention/ops/dcp_utils.py new
+740
@@ -0,0 +1,740 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""MLA DCP collective selection and direct symmetric-memory implementations."""
4 +
5 +from __future__ import annotations
6 +
7 +import functools
8 +from collections.abc import Callable
9 +from typing import TYPE_CHECKING, Any, Protocol
10 +
11 +import torch
12 +
13 +import vllm.envs as envs
14 +from vllm.config import VllmConfig
15 +from vllm.distributed import get_dcp_group
16 +from vllm.distributed.parallel_state import in_the_same_node_as
17 +from vllm.logger import init_logger
18 +from vllm.platforms import current_platform
19 +from vllm.v1.attention.ops.common import cp_lse_ag_out_ar, cp_lse_ag_out_rs
20 +from vllm.v1.attention.ops.dcp_alltoall import dcp_a2a_lse_reduce
21 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
22 +
23 +logger = init_logger(__name__)
24 +
25 +if TYPE_CHECKING:
26 + from torch.distributed import ProcessGroup
27 +
28 + from vllm.distributed.parallel_state import GroupCoordinator
29 +
30 +try:
31 + import torch.distributed._symmetric_memory as symm_mem
32 +
33 + symm_mem_available = True
34 +except ImportError:
35 + symm_mem = None # type: ignore[assignment]
36 + symm_mem_available = False
37 +
38 +
39 +@functools.cache
40 +def _symm_mem_spans_group(group: GroupCoordinator) -> bool:
41 + """Probe whether the group has NVLS symmetric memory."""
42 + if not symm_mem_available:
43 + return False
44 + try:
45 + from torch._C._autograd import DeviceType
46 + from torch._C._distributed_c10d import _SymmetricMemory
47 +
48 + device = torch.device("cuda", torch.accelerator.current_device_index())
49 + if not _SymmetricMemory.has_multicast_support(DeviceType.CUDA, device.index):
50 + return False
51 + probe = symm_mem.empty(8, dtype=torch.uint8, device=device)
52 + probe.zero_()
53 + torch.accelerator.synchronize()
54 + handle = symm_mem.rendezvous(probe, group.device_group.group_name)
55 + spans = handle is not None and handle.multicast_ptr != 0
56 + except Exception as error:
57 + logger.debug("Direct DCP symmetric-memory probe failed: %s", error)
58 + return False
59 + logger.debug_once(
60 + "Direct DCP symmetric memory across %d ranks: %s",
61 + group.world_size,
62 + "available" if spans else "unavailable",
63 + )
64 + return spans
65 +
66 +
67 +def _direct_dcp_enabled(
68 + group: GroupCoordinator,
69 + dtype: torch.dtype,
70 + use_direct: bool | None,
71 + supported_dtypes: tuple[torch.dtype, ...] | None = None,
72 +) -> bool:
73 + if use_direct is not None:
74 + return use_direct
75 + return (
76 + symm_mem_available
77 + and current_platform.is_cuda()
78 + and (supported_dtypes is None or dtype in supported_dtypes)
79 + and (
80 + all(in_the_same_node_as(group.cpu_group, source_rank=0))
81 + or _symm_mem_spans_group(group)
82 + )
83 + )
84 +
85 +
86 +def _direct_dcp_multicast_enabled(
87 + group: GroupCoordinator,
88 + dtype: torch.dtype,
89 + use_direct: bool | None,
90 + supported_dtypes: tuple[torch.dtype, ...] | None = None,
91 +) -> bool:
92 + return _direct_dcp_enabled(
93 + group, dtype, use_direct, supported_dtypes
94 + ) and _symm_mem_spans_group(group)
95 +
96 +
97 +def get_dcp_workspace_max_num_tokens(vllm_config: VllmConfig) -> int:
98 + scheduler_config = vllm_config.scheduler_config
99 + speculative_config = vllm_config.speculative_config
100 + speculative_tokens = vllm_config.num_speculative_tokens
101 + tokens_per_seq = (
102 + 1
103 + + (
104 + 2
105 + if speculative_config is not None and speculative_config.parallel_drafting
106 + else 1
107 + )
108 + * speculative_tokens
109 + )
110 + return min(
111 + scheduler_config.max_num_batched_tokens,
112 + max(
113 + scheduler_config.max_num_seqs * tokens_per_seq,
114 + vllm_config.compilation_config.max_cudagraph_capture_size or 0,
115 + ),
116 + )
117 +
118 +
119 +class _DirectDCPWorkspace:
120 + def __init__(
121 + self,
122 + group: ProcessGroup,
123 + device: torch.device,
124 + num_ubatches: int,
125 + ) -> None:
126 + self.group = group
127 + self.world_size = group.size()
128 + self.rank = group.rank()
129 + self.device = torch.device(device)
130 + self.num_ubatches = num_ubatches
131 + self.epoch = torch.zeros(num_ubatches, dtype=torch.int64, device=self.device)
132 + self._allocations: list[tuple[torch.Tensor, Any, list[torch.Tensor]]] = []
133 +
134 + def _allocate(
135 + self, shape: tuple[int, ...], dtype: torch.dtype
136 + ) -> tuple[torch.Tensor, torch.Tensor]:
137 + storage = symm_mem.empty(shape, device=self.device, dtype=dtype)
138 + storage.zero_()
139 + torch.accelerator.synchronize()
140 + handle = symm_mem.rendezvous(storage, self.group.group_name)
141 + assert handle is not None, "DCP symmetric memory rendezvous returned None"
142 + handle.barrier()
143 + views = [
144 + handle.get_buffer(peer, list(shape), dtype, 0)
145 + for peer in range(self.world_size)
146 + ]
147 + self.device = storage.device
148 + peer_ptrs = torch.tensor(
149 + [
150 + [view[ubatch].data_ptr() for view in views]
151 + for ubatch in range(self.num_ubatches)
152 + ],
153 + dtype=torch.int64,
154 + device=self.device,
155 + )
156 + self._allocations.append((storage, handle, views))
157 + return storage, peer_ptrs
158 +
159 + def _multicast_ptrs(self, storage: torch.Tensor) -> list[int]:
160 + disabled = [0] * self.num_ubatches
161 + for allocated, handle, _ in self._allocations:
162 + if allocated is storage:
163 + break
164 + else:
165 + return disabled
166 + try:
167 + from torch._C._autograd import DeviceType
168 + from torch._C._distributed_c10d import _SymmetricMemory
169 +
170 + if not _SymmetricMemory.has_multicast_support(
171 + DeviceType.CUDA, storage.device.index
172 + ):
173 + return disabled
174 + multicast_base = handle.multicast_ptr
175 + except Exception:
176 + return disabled
177 + if not multicast_base:
178 + return disabled
179 + storage_base = storage.data_ptr()
180 + return [
181 + multicast_base + (storage[ubatch].data_ptr() - storage_base)
182 + for ubatch in range(self.num_ubatches)
183 + ]
184 +
185 +
186 +def reserve_query_head_storage(
187 + query: torch.Tensor, padded_num_heads: int
188 +) -> torch.Tensor:
189 + """Reserve backing storage for fixed-head decode kernels."""
190 + assert query.ndim == 3
191 + assert query.shape[1] <= padded_num_heads
192 + padded = query.new_empty((query.shape[0], padded_num_heads, query.shape[2]))
193 + padded.resize_(query.shape)
194 + padded.copy_(query)
195 + return padded
196 +
197 +
198 +_A2A_SUPPORTED_DTYPES = (torch.float16, torch.bfloat16)
199 +
200 +
201 +class DirectDCPA2AWorkspace(_DirectDCPWorkspace):
202 + """Persistent symmetric buffers for direct DCP output exchange."""
203 +
204 + def __init__(
205 + self,
206 + group: ProcessGroup,
207 + device: torch.device,
208 + max_num_tokens: int,
209 + heads_per_rank: int,
210 + head_dim: int,
211 + dtype: torch.dtype = torch.bfloat16,
212 + num_ubatches: int = 1,
213 + ) -> None:
214 + if dtype not in _A2A_SUPPORTED_DTYPES:
215 + raise ValueError(f"Direct DCP A2A does not support {dtype}")
216 + if num_ubatches < 1:
217 + raise ValueError(
218 + f"Direct DCP A2A requires at least one ubatch slot, got {num_ubatches}"
219 + )
220 + super().__init__(group, device, num_ubatches)
221 + self.max_num_tokens = max_num_tokens
222 + self.heads_per_rank = heads_per_rank
223 + self.head_dim = head_dim
224 +
225 + output_shape = (
226 + num_ubatches,
227 + 2,
228 + self.world_size,
229 + max_num_tokens,
230 + heads_per_rank,
231 + head_dim,
232 + )
233 + lse_shape = (
234 + num_ubatches,
235 + 2,
236 + self.world_size,
237 + max_num_tokens,
238 + heads_per_rank,
239 + )
240 + signal_shape = (num_ubatches, 2, self.world_size)
241 + self.received_output, self.peer_output_ptrs = self._allocate(
242 + output_shape, dtype
243 + )
244 + self.received_lse, self.peer_lse_ptrs = self._allocate(lse_shape, torch.float32)
245 + self.received_signal, self.peer_signal_ptrs = self._allocate(
246 + signal_shape, torch.int32
247 + )
248 +
249 + def lse_reduce(
250 + self,
251 + partial_output: torch.Tensor,
252 + partial_lse: torch.Tensor,
253 + is_lse_base_on_e: bool,
254 + seq_lens: torch.Tensor | None = None,
255 + query_start_loc: torch.Tensor | None = None,
256 + ) -> torch.Tensor:
257 + ubatch = dbo_current_ubatch_id()
258 + num_tokens = partial_output.shape[0]
259 + output = partial_output.new_empty(
260 + (num_tokens, self.heads_per_rank, self.head_dim)
261 + )
262 + torch.ops._C.direct_dcp_a2a_lse_reduce(
263 + partial_output,
264 + partial_lse,
265 + seq_lens,
266 + query_start_loc,
267 + self.peer_output_ptrs[ubatch],
268 + self.peer_lse_ptrs[ubatch],
269 + self.peer_signal_ptrs[ubatch],
270 + self.received_output[ubatch],
271 + self.received_lse[ubatch],
272 + self.received_signal[ubatch],
273 + self.epoch[ubatch : ubatch + 1],
274 + output,
275 + self.world_size,
276 + self.rank,
277 + self.max_num_tokens,
278 + is_lse_base_on_e,
279 + )
280 + return output
281 +
282 +
283 +@functools.cache
284 +def get_direct_dcp_a2a_workspace(
285 + group: GroupCoordinator,
286 + device: torch.device,
287 + max_num_tokens: int,
288 + heads_per_rank: int,
289 + head_dim: int,
290 + dtype: torch.dtype,
291 + num_ubatches: int,
292 +) -> DirectDCPA2AWorkspace | None:
293 + if not _direct_dcp_enabled(
294 + group, dtype, envs.VLLM_USE_DIRECT_DCP_A2A, _A2A_SUPPORTED_DTYPES
295 + ):
296 + return None
297 + return DirectDCPA2AWorkspace(
298 + group.device_group,
299 + device,
300 + max_num_tokens,
301 + heads_per_rank,
302 + head_dim,
303 + dtype,
304 + num_ubatches,
305 + )
306 +
307 +
308 +def _q_gather_layout_supported(
309 + world_size: int,
310 + heads_per_rank: int,
311 + head_dim: int,
312 + dtype: torch.dtype,
313 + padded_num_heads: int | None,
314 +) -> bool:
315 + element_size = torch.empty((), dtype=dtype).element_size()
316 + gathered_num_heads = world_size * heads_per_rank
317 + storage_num_heads = (
318 + gathered_num_heads if padded_num_heads is None else padded_num_heads
319 + )
320 + return (
321 + heads_per_rank * head_dim * element_size % 16 == 0
322 + and storage_num_heads * head_dim * element_size % 16 == 0
323 + )
324 +
325 +
326 +class DirectDCPQGatherWorkspace(_DirectDCPWorkspace):
327 + """Publish query shards directly into the consumer-final symmetric buffer.
328 +
329 + The final buffer is reusable after the downstream DCP output combine. That
330 + combine orders all ranks after attention has consumed the gathered query.
331 + """
332 +
333 + def __init__(
334 + self,
335 + group: ProcessGroup,
336 + device: torch.device,
337 + max_num_tokens: int,
338 + heads_per_rank: int,
339 + head_dim: int,
340 + dtype: torch.dtype = torch.bfloat16,
341 + num_ubatches: int = 1,
342 + padded_num_heads: int | None = None,
343 + ) -> None:
344 + if num_ubatches < 1:
345 + raise ValueError(
346 + "Direct DCP q-gather requires at least one ubatch slot, "
347 + f"got {num_ubatches}"
348 + )
349 + if max_num_tokens < 1 or heads_per_rank < 1 or head_dim < 1:
350 + raise ValueError(
351 + "Direct DCP q-gather dimensions must be positive, got "
352 + f"T={max_num_tokens}, H={heads_per_rank}, D={head_dim}"
353 + )
354 + gathered_num_heads = group.size() * heads_per_rank
355 + if not _q_gather_layout_supported(
356 + group.size(), heads_per_rank, head_dim, dtype, padded_num_heads
357 + ):
358 + raise ValueError("Direct DCP q-gather requires 16-byte-aligned query rows.")
359 + super().__init__(group, device, num_ubatches)
360 + if self.world_size <= 1:
361 + raise ValueError("Direct DCP q-gather requires at least two ranks")
362 + self.max_num_tokens = max_num_tokens
363 + self.heads_per_rank = heads_per_rank
364 + self.gathered_num_heads = gathered_num_heads
365 + self.padded_num_heads = (
366 + self.gathered_num_heads if padded_num_heads is None else padded_num_heads
367 + )
368 + if self.padded_num_heads < self.gathered_num_heads:
369 + raise ValueError(
370 + "Direct DCP q-gather padded heads must cover gathered heads: "
371 + f"{self.padded_num_heads} < {self.gathered_num_heads}"
372 + )
373 + self.head_dim = head_dim
374 +
375 + query_shape = (
376 + num_ubatches,
377 + max_num_tokens,
378 + self.padded_num_heads,
379 + head_dim,
380 + )
381 + signal_shape = (num_ubatches, 2, self.world_size)
382 + self.final_query, _ = self._allocate(query_shape, dtype)
383 + self.received_signal, _ = self._allocate(signal_shape, torch.int32)
384 + query_multicast_ptrs = self._multicast_ptrs(self.final_query)
385 + signal_multicast_ptrs = self._multicast_ptrs(self.received_signal)
386 + self.multicast_ptrs = list(
387 + zip(query_multicast_ptrs, signal_multicast_ptrs, strict=True)
388 + )
389 + if not all(
390 + query_ptr and signal_ptr for query_ptr, signal_ptr in self.multicast_ptrs
391 + ):
392 + raise RuntimeError(
393 + "Direct DCP q-gather requires NVLS symmetric-memory multicast."
394 + )
395 + self.completion = self.received_signal.new_zeros((num_ubatches, 1))
396 + torch.accelerator.synchronize()
397 +
398 + def gather(self, local_query: torch.Tensor) -> torch.Tensor:
399 + ubatch = dbo_current_ubatch_id()
400 + if not 0 <= ubatch < self.num_ubatches:
401 + raise ValueError(
402 + f"DCP q-gather ubatch {ubatch} exceeds {self.num_ubatches} slots"
403 + )
404 + if local_query.ndim == 3 and local_query.shape[1] != self.heads_per_rank:
405 + raise ValueError(
406 + f"DCP q-gather expected {self.heads_per_rank} local query heads, "
407 + f"got {local_query.shape[1]}"
408 + )
409 +
410 + num_tokens = local_query.shape[0]
411 + output = torch.as_strided(
412 + self.final_query[ubatch],
413 + size=(num_tokens, self.gathered_num_heads, self.head_dim),
414 + stride=(
415 + self.gathered_num_heads * self.head_dim,
416 + self.head_dim,
417 + 1,
418 + ),
419 + )
420 + query_multicast_ptr, signal_multicast_ptr = self.multicast_ptrs[ubatch]
421 + torch.ops._C.direct_dcp_q_gather(
422 + local_query,
423 + output,
424 + self.received_signal[ubatch],
425 + self.completion[ubatch],
426 + self.epoch[ubatch : ubatch + 1],
427 + self.world_size,
428 + self.rank,
429 + self.max_num_tokens,
430 + self.padded_num_heads,
431 + query_multicast_ptr,
432 + signal_multicast_ptr,
433 + )
434 + return output
435 +
436 +
437 +@functools.cache
438 +def get_direct_dcp_q_gather_workspace(
439 + group: GroupCoordinator,
440 + device: torch.device,
441 + max_num_tokens: int,
442 + heads_per_rank: int,
443 + head_dim: int,
444 + dtype: torch.dtype,
445 + num_ubatches: int,
446 + padded_num_heads: int | None = None,
447 +) -> DirectDCPQGatherWorkspace | None:
448 + if not _direct_dcp_multicast_enabled(
449 + group, dtype, envs.VLLM_USE_DIRECT_DCP_Q_GATHER
450 + ):
451 + return None
452 + if not _q_gather_layout_supported(
453 + group.world_size, heads_per_rank, head_dim, dtype, padded_num_heads
454 + ):
455 + return None
456 + return DirectDCPQGatherWorkspace(
457 + group.device_group,
458 + device,
459 + max_num_tokens,
460 + heads_per_rank,
461 + head_dim,
462 + dtype,
463 + num_ubatches,
464 + padded_num_heads,
465 + )
466 +
467 +
468 +_KV_GATHER_SUPPORTED_DTYPES = (
469 + torch.float16,
470 + torch.bfloat16,
471 + torch.float8_e4m3fn,
472 +)
473 +
474 +
475 +def _kv_gather_layout_supported(token_dim: int, dtype: torch.dtype) -> bool:
476 + return token_dim * torch.empty((), dtype=dtype).element_size() % 16 == 0
477 +
478 +
479 +class DirectDCPKVGatherWorkspace(_DirectDCPWorkspace):
480 + """Persistent symmetric buffers for direct DCP KV gather."""
481 +
482 + def __init__(
483 + self,
484 + group: ProcessGroup,
485 + device: torch.device,
486 + max_gathered_tokens: int,
487 + token_dim: int,
488 + dtype: torch.dtype = torch.bfloat16,
489 + num_ubatches: int = 1,
490 + ) -> None:
491 + if dtype not in _KV_GATHER_SUPPORTED_DTYPES:
492 + raise ValueError(f"Direct DCP kv-gather does not support {dtype}")
493 + if num_ubatches < 1:
494 + raise ValueError(
495 + "Direct DCP kv-gather requires at least one ubatch slot, "
496 + f"got {num_ubatches}"
497 + )
498 + if max_gathered_tokens < 1 or token_dim < 1:
499 + raise ValueError(
500 + "Direct DCP kv-gather dimensions must be positive, got "
501 + f"T={max_gathered_tokens}, D={token_dim}"
502 + )
503 + if not _kv_gather_layout_supported(token_dim, dtype):
504 + raise ValueError("Direct DCP kv-gather requires 16-byte-aligned KV rows.")
505 + super().__init__(group, device, num_ubatches)
506 + if self.world_size <= 1:
507 + raise ValueError("Direct DCP kv-gather requires at least two ranks")
508 + if max_gathered_tokens % self.world_size != 0:
509 + raise ValueError(
510 + "Direct DCP kv-gather capacity must divide evenly across "
511 + f"ranks: {max_gathered_tokens} % {self.world_size} != 0"
512 + )
513 + self.max_gathered_tokens = max_gathered_tokens
514 +
515 + kv_shape = (num_ubatches, 2, max_gathered_tokens, token_dim)
516 + signal_shape = (num_ubatches, 2, self.world_size)
517 + self.received_kv, _ = self._allocate(kv_shape, dtype)
518 + self.received_signal, _ = self._allocate(signal_shape, torch.int32)
519 + kv_multicast_ptrs = self._multicast_ptrs(self.received_kv)
520 + signal_multicast_ptrs = self._multicast_ptrs(self.received_signal)
521 + self.multicast_ptrs = list(
522 + zip(kv_multicast_ptrs, signal_multicast_ptrs, strict=True)
523 + )
524 + if not all(kv_ptr and signal_ptr for kv_ptr, signal_ptr in self.multicast_ptrs):
525 + raise RuntimeError(
526 + "Direct DCP kv-gather requires NVLS symmetric-memory multicast."
527 + )
528 + self.completion = self.received_signal.new_zeros((num_ubatches, 2))
529 + torch.accelerator.synchronize()
530 +
531 + def gather(self, gathered_kv: torch.Tensor, local_kv: torch.Tensor) -> None:
532 + ubatch = dbo_current_ubatch_id()
533 + if not 0 <= ubatch < self.num_ubatches:
534 + raise ValueError(
535 + f"DCP kv-gather ubatch {ubatch} exceeds {self.num_ubatches} slots"
536 + )
537 + kv_multicast_ptr, signal_multicast_ptr = self.multicast_ptrs[ubatch]
538 + torch.ops._C.direct_dcp_kv_gather(
539 + local_kv,
540 + self.received_kv[ubatch],
541 + self.received_signal[ubatch],
542 + self.completion[ubatch],
543 + self.epoch[ubatch : ubatch + 1],
544 + gathered_kv,
545 + self.world_size,
546 + self.rank,
547 + self.max_gathered_tokens,
548 + kv_multicast_ptr,
549 + signal_multicast_ptr,
550 + )
551 +
552 +
553 +@functools.cache
554 +def get_direct_dcp_kv_gather_workspace(
555 + group: GroupCoordinator,
556 + device: torch.device,
557 + max_gathered_tokens: int,
558 + token_dim: int,
559 + dtype: torch.dtype,
560 + num_ubatches: int,
561 +) -> DirectDCPKVGatherWorkspace | None:
562 + if not _direct_dcp_multicast_enabled(
563 + group,
564 + dtype,
565 + envs.VLLM_USE_DIRECT_DCP_KV_GATHER,
566 + _KV_GATHER_SUPPORTED_DTYPES,
567 + ):
568 + return None
569 + if not _kv_gather_layout_supported(token_dim, dtype):
570 + return None
571 + return DirectDCPKVGatherWorkspace(
572 + group.device_group,
573 + device,
574 + max_gathered_tokens,
575 + token_dim,
576 + dtype,
577 + num_ubatches,
578 + )
579 +
580 +
581 +class DCPCombine(Protocol):
582 + def __call__(
583 + self,
584 + partial_output: torch.Tensor,
585 + partial_lse: torch.Tensor,
586 + *,
587 + seq_lens: torch.Tensor,
588 + query_start_loc: torch.Tensor,
589 + ) -> torch.Tensor: ...
590 +
591 +
592 +class MLADCPManager:
593 + """Select and own layer-level collective implementations for MLA DCP."""
594 +
595 + _kv_gather: Callable[[torch.Tensor, torch.Tensor], object]
596 +
597 + def __init__(
598 + self,
599 + vllm_config: VllmConfig,
600 + device: torch.device,
601 + num_heads: int,
602 + query_head_dim: int,
603 + output_head_dim: int,
604 + query_dtype: torch.dtype,
605 + output_dtype: torch.dtype,
606 + padded_num_heads: int | None,
607 + is_lse_base_on_e: bool,
608 + use_pcp: bool,
609 + ) -> None:
610 + parallel_config = vllm_config.parallel_config
611 + self.group = get_dcp_group()
612 + self.device = torch.device(device)
613 + self.num_ubatches = max(parallel_config.num_ubatches, 1)
614 + self.max_num_tokens = get_dcp_workspace_max_num_tokens(vllm_config)
615 + self.use_a2a = parallel_config.dcp_comm_backend == "a2a"
616 + self.padded_num_heads = padded_num_heads
617 +
618 + self.combine = self._init_combine(
619 + num_heads,
620 + output_head_dim,
621 + output_dtype,
622 + is_lse_base_on_e,
623 + use_pcp,
624 + )
625 + self.query_gather = (
626 + None
627 + if use_pcp
628 + else self._init_query_gather(
629 + num_heads,
630 + query_head_dim,
631 + query_dtype,
632 + )
633 + )
634 +
635 + def _init_combine(
636 + self,
637 + num_heads: int,
638 + head_dim: int,
639 + dtype: torch.dtype,
640 + is_lse_base_on_e: bool,
641 + use_pcp: bool,
642 + ) -> DCPCombine:
643 + direct_workspace = None
644 + if self.use_a2a:
645 + direct_workspace = get_direct_dcp_a2a_workspace(
646 + self.group,
647 + self.device,
648 + self.max_num_tokens,
649 + num_heads,
650 + head_dim,
651 + dtype,
652 + self.num_ubatches,
653 + )
654 + if direct_workspace is not None:
655 + logger.info_once("Using direct symmetric-memory DCP A2A for MLA.")
656 + return functools.partial(
657 + direct_workspace.lse_reduce,
658 + is_lse_base_on_e=is_lse_base_on_e,
659 + )
660 +
661 + combine_fn = (
662 + dcp_a2a_lse_reduce
663 + if self.use_a2a
664 + else cp_lse_ag_out_ar
665 + if use_pcp
666 + else cp_lse_ag_out_rs
667 + )
668 + return functools.partial(
669 + combine_fn,
670 + cp_group=self.group,
671 + is_lse_base_on_e=is_lse_base_on_e,
672 + )
673 +
674 + def _init_query_gather(
675 + self,
676 + num_heads: int,
677 + head_dim: int,
678 + dtype: torch.dtype,
679 + ) -> Callable[[torch.Tensor], torch.Tensor]:
680 + direct_workspace = get_direct_dcp_q_gather_workspace(
681 + self.group,
682 + self.device,
683 + self.max_num_tokens,
684 + num_heads,
685 + head_dim,
686 + dtype,
687 + self.num_ubatches,
688 + self.padded_num_heads,
689 + )
690 + if direct_workspace is not None:
691 + logger.info_once("Using direct symmetric-memory DCP query gather for MLA.")
692 + return direct_workspace.gather
693 + return self._gather_query
694 +
695 + def _gather_query(self, query: torch.Tensor) -> torch.Tensor:
696 + query = self.group.all_gather(query, dim=1)
697 + if self.padded_num_heads is not None:
698 + query = reserve_query_head_storage(query, self.padded_num_heads)
699 + return query
700 +
701 + def init_kv_gather(
702 + self,
703 + workspace: torch.Tensor,
704 + max_gathered_tokens: int,
705 + ) -> None:
706 + world_size = self.group.world_size
707 + assert max_gathered_tokens > 0
708 + assert max_gathered_tokens % world_size == 0
709 + assert workspace.ndim == 2
710 + assert workspace.is_contiguous()
711 + assert workspace.shape[0] == (
712 + max_gathered_tokens + max_gathered_tokens // world_size
713 + )
714 + assert workspace.shape[1] > 0
715 +
716 + direct_workspace = get_direct_dcp_kv_gather_workspace(
717 + self.group,
718 + workspace.device,
719 + max_gathered_tokens,
720 + workspace.shape[1],
721 + workspace.dtype,
722 + self.num_ubatches,
723 + )
724 + if direct_workspace is not None:
725 + logger.info_once(
726 + "Using direct symmetric-memory DCP chunked-context KV gather for MLA."
727 + )
728 + self._kv_gather = direct_workspace.gather
729 + else:
730 + self._kv_gather = functools.partial(
731 + torch.distributed.all_gather_into_tensor,
732 + group=self.group.device_group,
733 + )
734 +
735 + def kv_gather(
736 + self,
737 + gathered_kv: torch.Tensor,
738 + local_kv: torch.Tensor,
739 + ) -> object:
740 + return self._kv_gather(gathered_kv, local_kv)