@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 3e41992fec
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 11, 15:00 UTC

[Attention] Use sparse prefill kernel for fp8 kv-cache in DeepSeek-v3.2 (#27532)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>

Lucas Wilkinson committed Dec 12, 2025 at 08:57 UTC 3e41992fecdc31ee60715bb350f18fec18ed6680
30 files changed +1372 -256
csrc/cache.h
+11 -1
@@ -1,6 +1,7 @@
1 #pragma once
2
3 #include <torch/all.h>
4 +#include <c10/util/Optional.h>
5
6 #include <map>
7 #include <vector>
@@ -58,6 +59,15 @@ void cp_gather_cache(
59 torch::Tensor const& cu_seq_lens, // [BATCH+1]
60 int64_t batch_size, std::optional<torch::Tensor> seq_starts = std::nullopt);
61
62 +// Gather and upconvert FP8 KV cache to BF16 workspace
63 +void cp_gather_and_upconvert_fp8_kv_cache(
64 + torch::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 656]
65 + torch::Tensor const& dst, // [TOT_TOKENS, 576]
66 + torch::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
67 + torch::Tensor const& seq_lens, // [BATCH]
68 + torch::Tensor const& workspace_starts, // [BATCH]
69 + int64_t batch_size);
70 +
71 // Indexer K quantization and cache function
72 void indexer_k_quant_and_cache(
73 torch::Tensor& k, // [num_tokens, head_dim]
@@ -72,4 +82,4 @@ void cp_gather_indexer_k_quant_cache(
82 torch::Tensor& dst_k, // [num_tokens, head_dim]
83 torch::Tensor& dst_scale, // [num_tokens, head_dim / quant_block_size * 4]
84 const torch::Tensor& block_table, // [batch_size, num_blocks]
75 - const torch::Tensor& cu_seq_lens); // [batch_size + 1]
\ No newline at end of file
85 + const torch::Tensor& cu_seq_lens); // [batch_size + 1]
csrc/cache_kernels.cu
+130 -1
@@ -2,6 +2,7 @@
2 #include <ATen/cuda/CUDAContext.h>
3 #include <c10/cuda/CUDAGuard.h>
4 #include <c10/cuda/CUDAException.h>
5 +#include <c10/util/Optional.h>
6
7 #include "cuda_utils.h"
8 #include "cuda_compat.h"
@@ -514,7 +515,8 @@ __global__ void indexer_k_quant_and_cache_kernel(
515 const int quant_block_size, // quantization block size
516 const int cache_block_size, // cache block size
517 const int cache_stride, // stride for each token in kv_cache
517 - const bool use_ue8m0 // use ue8m0 scale format
518 +
519 + const bool use_ue8m0 // use ue8m0 scale format
520 ) {
521 constexpr int VEC_SIZE = 4;
522 const int64_t token_idx = blockIdx.x;
@@ -1061,6 +1063,82 @@ void gather_and_maybe_dequant_cache(
1063 }
1064
1065 namespace vllm {
1066 +
1067 +// Gather and upconvert FP8 KV cache tokens to BF16 workspace
1068 +// Similar to cp_gather_cache but specifically for FP8->BF16 conversion
1069 +__global__ void cp_gather_and_upconvert_fp8_kv_cache(
1070 + const uint8_t* __restrict__ src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 656]
1071 + __nv_bfloat16* __restrict__ dst, // [TOT_TOKENS, 576]
1072 + const int32_t* __restrict__ block_table, // [BATCH, BLOCK_INDICES]
1073 + const int32_t* __restrict__ seq_lens, // [BATCH]
1074 + const int32_t* __restrict__ workspace_starts, // [BATCH]
1075 + const int32_t block_size, const int32_t head_dim,
1076 + const int64_t block_table_stride, const int64_t cache_block_stride,
1077 + const int64_t cache_entry_stride, const int64_t dst_entry_stride) {
1078 + const int64_t bid = blockIdx.x; // Batch ID
1079 + const int32_t num_splits = gridDim.y;
1080 + const int32_t split = blockIdx.y;
1081 + const int32_t seq_start = workspace_starts[bid];
1082 + const int32_t seq_len = seq_lens[bid];
1083 + const int32_t tot_slots = seq_len;
1084 + const int32_t split_slots = cuda_utils::ceil_div(tot_slots, num_splits);
1085 +
1086 + const int32_t split_start = split * split_slots;
1087 + const int32_t split_end = min((split + 1) * split_slots, tot_slots);
1088 +
1089 + const bool is_active_split = (split_start < tot_slots);
1090 +
1091 + if (!is_active_split) return;
1092 +
1093 + // Adjust the pointer for the block_table for this batch
1094 + const int32_t batch_offset = bid * block_table_stride;
1095 + int32_t offset = split_start;
1096 + int32_t offset_div = offset / block_size;
1097 + offset = offset % block_size;
1098 + const int32_t* batch_block_table = block_table + batch_offset;
1099 +
1100 + // Adjust dst pointer based on the cumulative sequence lengths
1101 + dst += seq_start * dst_entry_stride;
1102 +
1103 + const int tid = threadIdx.x;
1104 +
1105 + // Process each token in this split
1106 + for (int pid = split_start; pid < split_end; ++pid) {
1107 + auto block_id = batch_block_table[offset_div];
1108 + const uint8_t* token_ptr =
1109 + src_cache + block_id * cache_block_stride + offset * cache_entry_stride;
1110 + __nv_bfloat16* dst_ptr = dst + pid * dst_entry_stride;
1111 +
1112 + // FP8 format: 512 bytes fp8 + 16 bytes scales + 128 bytes rope (64 bf16)
1113 + const uint8_t* no_pe_ptr = token_ptr;
1114 + const float* scales_ptr = reinterpret_cast<const float*>(token_ptr + 512);
1115 + const __nv_bfloat16* rope_ptr =
1116 + reinterpret_cast<const __nv_bfloat16*>(token_ptr + 512 + 16);
1117 +
1118 + // Parallelize fp8 dequant (512 elements) and rope copy (64 elements)
1119 + if (tid < 512) {
1120 + // FP8 dequantization
1121 + const int tile = tid >> 7; // each tile is 128 elements
1122 + const float scale = scales_ptr[tile];
1123 + const uint8_t val = no_pe_ptr[tid];
1124 + dst_ptr[tid] =
1125 + fp8::scaled_convert<__nv_bfloat16, uint8_t,
1126 + vllm::Fp8KVCacheDataType::kFp8E4M3>(val, scale);
1127 + } else if (tid < 576) {
1128 + // Rope copy (64 bf16 elements)
1129 + const int rope_idx = tid - 512;
1130 + dst_ptr[512 + rope_idx] = rope_ptr[rope_idx];
1131 + }
1132 +
1133 + // Move to next token
1134 + offset += 1;
1135 + if (offset == block_size) {
1136 + offset_div += 1;
1137 + offset = 0;
1138 + }
1139 + }
1140 +}
1141 +
1142 template <typename scalar_t>
1143 // Note(hc): The cp_gather_cache allows seq_starts to no longer be divisible by
1144 // block_size.
@@ -1202,6 +1280,57 @@ void cp_gather_cache(
1280 }
1281 }
1282
1283 +void cp_gather_and_upconvert_fp8_kv_cache(
1284 + torch::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 656]
1285 + torch::Tensor const& dst, // [TOT_TOKENS, 576]
1286 + torch::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
1287 + torch::Tensor const& seq_lens, // [BATCH]
1288 + torch::Tensor const& workspace_starts, // [BATCH]
1289 + int64_t batch_size) {
1290 + at::cuda::OptionalCUDAGuard device_guard(src_cache.device());
1291 + const cudaStream_t stream = at::cuda::getCurrentCUDAStream();
1292 +
1293 + int32_t block_size = src_cache.size(1);
1294 + int32_t head_dim = dst.size(1);
1295 +
1296 + TORCH_CHECK(block_table.dtype() == torch::kInt32,
1297 + "block_table must be int32");
1298 + TORCH_CHECK(seq_lens.dtype() == torch::kInt32, "seq_lens must be int32");
1299 + TORCH_CHECK(workspace_starts.dtype() == torch::kInt32,
1300 + "workspace_starts must be int32");
1301 +
1302 + TORCH_CHECK(src_cache.device() == dst.device(),
1303 + "src_cache and dst must be on the same device");
1304 + TORCH_CHECK(src_cache.device() == block_table.device(),
1305 + "src_cache and block_table must be on the same device");
1306 + TORCH_CHECK(src_cache.device() == seq_lens.device(),
1307 + "src_cache and seq_lens must be on the same device");
1308 + TORCH_CHECK(src_cache.device() == workspace_starts.device(),
1309 + "src_cache and workspace_starts must be on the same device");
1310 +
1311 + TORCH_CHECK(src_cache.dtype() == torch::kUInt8, "src_cache must be uint8");
1312 + TORCH_CHECK(dst.dtype() == torch::kBFloat16, "dst must be bfloat16");
1313 + TORCH_CHECK(head_dim == 576, "head_dim must be 576 for MLA");
1314 +
1315 + int64_t block_table_stride = block_table.stride(0);
1316 + int64_t cache_block_stride = src_cache.stride(0);
1317 + int64_t cache_entry_stride = src_cache.stride(1);
1318 + int64_t dst_entry_stride = dst.stride(0);
1319 +
1320 + // Decide on the number of splits based on the batch size
1321 + int num_splits = batch_size > 128 ? 2 : batch_size > 64 ? 4 : 16;
1322 + dim3 grid(batch_size, num_splits);
1323 + dim3 block(576);
1324 +
1325 + vllm::cp_gather_and_upconvert_fp8_kv_cache<<<grid, block, 0, stream>>>(
1326 + src_cache.data_ptr<uint8_t>(),
1327 + reinterpret_cast<__nv_bfloat16*>(dst.data_ptr()),
1328 + block_table.data_ptr<int32_t>(), seq_lens.data_ptr<int32_t>(),
1329 + workspace_starts.data_ptr<int32_t>(), block_size, head_dim,
1330 + block_table_stride, cache_block_stride, cache_entry_stride,
1331 + dst_entry_stride);
1332 +}
1333 +
1334 // Macro to dispatch the kernel based on the data type.
1335 #define CALL_INDEXER_K_QUANT_AND_CACHE(KV_T, CACHE_T, KV_DTYPE) \
1336 vllm::indexer_k_quant_and_cache_kernel<KV_T, CACHE_T, KV_DTYPE> \
csrc/torch_bindings.cpp
+7
@@ -754,6 +754,13 @@ TORCH_LIBRARY_EXPAND(CONCAT(TORCH_EXTENSION_NAME, _cache_ops), cache_ops) {
754 "Tensor cu_seq_lens, int batch_size, Tensor? seq_starts) -> ()");
755 cache_ops.impl("cp_gather_cache", torch::kCUDA, &cp_gather_cache);
756
757 + cache_ops.def(
758 + "cp_gather_and_upconvert_fp8_kv_cache(Tensor src_cache, Tensor! dst, "
759 + "Tensor block_table, Tensor seq_lens, Tensor workspace_starts, int "
760 + "batch_size) -> ()");
761 + cache_ops.impl("cp_gather_and_upconvert_fp8_kv_cache", torch::kCUDA,
762 + &cp_gather_and_upconvert_fp8_kv_cache);
763 +
764 cache_ops.def(
765 "indexer_k_quant_and_cache(Tensor k, Tensor! kv_cache, Tensor "
766 "slot_mapping, "
tests/conftest.py
+21
@@ -202,6 +202,27 @@ def cleanup_fixture(should_do_global_cleanup_after_test: bool):
202 cleanup_dist_env_and_memory()
203
204
205 +@pytest.fixture
206 +def workspace_init():
207 + """Initialize the workspace manager for tests that need it.
208 +
209 + This fixture initializes the workspace manager with a CUDA device
210 + if available, and resets it after the test completes. Tests that
211 + create a full vLLM engine should NOT use this fixture as the engine
212 + will initialize the workspace manager itself.
213 + """
214 + from vllm.v1.worker.workspace import (
215 + init_workspace_manager,
216 + reset_workspace_manager,
217 + )
218 +
219 + if torch.cuda.is_available():
220 + device = torch.device("cuda:0")
221 + init_workspace_manager(device)
222 + yield
223 + reset_workspace_manager()
224 +
225 +
226 @pytest.fixture(autouse=True)
227 def dynamo_reset():
228 yield
tests/kernels/moe/test_batched_deepgemm.py
+1 -1
@@ -27,7 +27,7 @@ BLOCK_SIZE = [128, 128]
27 @pytest.mark.parametrize("N", [512, 1024]) # intermediate dim per expert
28 @pytest.mark.parametrize("topk", [2, 4])
29 def test_batched_deepgemm_vs_triton(
30 - E: int, T: int, K: int, N: int, topk: int, monkeypatch
30 + E: int, T: int, K: int, N: int, topk: int, monkeypatch, workspace_init
31 ):
32 """Compare BatchedDeepGemmExperts to BatchedTritonExperts."""
33
tests/kernels/moe/test_batched_moe.py
+1
@@ -248,6 +248,7 @@ def test_fused_moe_batched_experts(
248 per_act_token_quant: bool,
249 block_shape: list[int] | None,
250 input_scales: bool,
251 + workspace_init,
252 ):
253 """Note: float8_e4m3fn is not supported on CUDA architecture < 89,
254 and those tests will be skipped on unsupported hardware."""
tests/kernels/moe/test_block_fp8.py
+1 -1
@@ -137,7 +137,7 @@ def setup_cuda():
137 @pytest.mark.parametrize("seed", SEEDS)
138 @torch.inference_mode()
139 def test_w8a8_block_fp8_fused_moe(
140 - M, N, K, E, topk, block_size, dtype, seed, monkeypatch
140 + M, N, K, E, topk, block_size, dtype, seed, monkeypatch, workspace_init
141 ):
142 if topk > E:
143 pytest.skip(f"Skipping test; topk={topk} > E={E}")
tests/kernels/moe/test_cutlass_moe.py
+25 -2
@@ -274,6 +274,7 @@ def test_cutlass_moe_8_bit_no_graph(
274 per_act_token: bool,
275 per_out_ch: bool,
276 monkeypatch,
277 + workspace_init,
278 ep_size: int | None = None,
279 ):
280 current_platform.seed_everything(7)
@@ -329,6 +330,7 @@ def test_cutlass_moe_8_bit_cuda_graph(
330 per_act_token: bool,
331 per_out_ch: bool,
332 monkeypatch,
333 + workspace_init,
334 ):
335 current_platform.seed_everything(7)
336 monkeypatch.setenv("VLLM_FUSED_MOE_CHUNK_SIZE", "8192")
@@ -385,9 +387,19 @@ def test_cutlass_moe_8_bit_EP(
387 per_out_channel: bool,
388 ep_size: int,
389 monkeypatch,
390 + workspace_init,
391 ):
392 test_cutlass_moe_8_bit_no_graph(
390 - m, n, k, e, topk, per_act_token, per_out_channel, monkeypatch, ep_size
393 + m,
394 + n,
395 + k,
396 + e,
397 + topk,
398 + per_act_token,
399 + per_out_channel,
400 + monkeypatch,
401 + workspace_init,
402 + ep_size,
403 )
404
405
@@ -419,9 +431,19 @@ def test_cutlass_moe_8_bit_EP_large(
431 per_out_channel: bool,
432 ep_size: int,
433 monkeypatch,
434 + workspace_init,
435 ):
436 test_cutlass_moe_8_bit_no_graph(
424 - m, n, k, e, topk, per_act_token, per_out_channel, monkeypatch, ep_size
437 + m,
438 + n,
439 + k,
440 + e,
441 + topk,
442 + per_act_token,
443 + per_out_channel,
444 + monkeypatch,
445 + workspace_init,
446 + ep_size,
447 )
448
449
@@ -445,6 +467,7 @@ def test_run_cutlass_moe_fp8(
467 per_act_token: bool,
468 per_out_channel: bool,
469 ep_size: int,
470 + workspace_init,
471 ):
472 current_platform.seed_everything(7)
473 with set_current_vllm_config(vllm_config):
tests/kernels/moe/test_deepep_deepgemm_moe.py
+6
@@ -29,6 +29,7 @@ from vllm.utils.deep_gemm import (
29 is_deep_gemm_supported,
30 )
31 from vllm.utils.import_utils import has_deep_ep, has_deep_gemm
32 +from vllm.v1.worker.workspace import init_workspace_manager
33
34 from ...utils import multi_gpu_test
35 from .parallel_utils import ProcessGroupInfo, parallel_launch
@@ -363,6 +364,9 @@ def _test_deepep_deepgemm_moe(
364 w1_scale: torch.Tensor,
365 w2_scale: torch.Tensor,
366 ):
367 + device = torch.device(f"cuda:{pgi.local_rank}")
368 + init_workspace_manager(device)
369 +
370 current_platform.seed_everything(pgi.rank)
371
372 w1 = w1.to(device=torch.cuda.current_device())
@@ -445,6 +449,7 @@ def test_ht_deepep_deepgemm_moe(
449 topk: int,
450 world_dp_size: tuple[int, int],
451 disable_deepgemm_ue8m0,
452 + workspace_init,
453 ):
454 """
455 Tests for High-Throughput DeepEP + DeepGemm integration.
@@ -518,6 +523,7 @@ def test_ll_deepep_deepgemm_moe(
523 block_size: list[int],
524 world_dp_size: tuple[int, int],
525 disable_deepgemm_ue8m0,
526 + workspace_init,
527 ):
528 """
529 Tests for Low-Latency DeepEP + DeepGemm integration.
tests/kernels/moe/test_deepep_moe.py
+6
@@ -22,6 +22,7 @@ from vllm.model_executor.layers.quantization.utils.fp8_utils import (
22 )
23 from vllm.platforms import current_platform
24 from vllm.utils.import_utils import has_deep_ep
25 +from vllm.v1.worker.workspace import init_workspace_manager
26
27 from ...utils import multi_gpu_test
28 from .parallel_utils import ProcessGroupInfo, parallel_launch
@@ -342,6 +343,9 @@ def _deep_ep_moe(
343 use_fp8_dispatch: bool,
344 per_act_token_quant: bool,
345 ):
346 + device = torch.device(f"cuda:{pgi.local_rank}")
347 + init_workspace_manager(device)
348 +
349 if not low_latency_mode:
350 assert not use_fp8_dispatch, (
351 "FP8 dispatch interface is available only in low-latency mode"
@@ -437,6 +441,7 @@ def test_deep_ep_moe(
441 topk: int,
442 world_dp_size: tuple[int, int],
443 per_act_token_quant: bool,
444 + workspace_init,
445 ):
446 low_latency_mode = False
447 use_fp8_dispatch = False
@@ -492,6 +497,7 @@ def test_low_latency_deep_ep_moe(
497 topk: int,
498 world_dp_size: tuple[int, int],
499 use_fp8_dispatch: bool,
500 + workspace_init,
501 ):
502 low_latency_mode = True
503
tests/kernels/moe/test_deepgemm.py
+1 -1
@@ -143,7 +143,7 @@ NUM_EXPERTS = [32]
143 @pytest.mark.parametrize("topk", TOPKS)
144 @pytest.mark.parametrize("num_experts", NUM_EXPERTS)
145 @pytest.mark.skipif(not is_deep_gemm_supported(), reason="Requires deep_gemm kernels")
146 -def test_deepgemm_vs_triton(m, n, k, topk, num_experts, monkeypatch):
146 +def test_deepgemm_vs_triton(m, n, k, topk, num_experts, monkeypatch, workspace_init):
147 with monkeypatch.context() as mp:
148 mp.setenv("VLLM_USE_DEEP_GEMM", "1")
149
tests/kernels/moe/test_flashinfer.py
+1
@@ -206,6 +206,7 @@ def test_flashinfer_cutlass_moe_fp8_no_graph(
206 topk: int,
207 activation: str,
208 monkeypatch,
209 + workspace_init,
210 ):
211 current_platform.seed_everything(7)
212 monkeypatch.setenv("VLLM_FUSED_MOE_CHUNK_SIZE", "8192")
tests/kernels/moe/test_flashinfer_moe.py
+8 -1
@@ -51,7 +51,14 @@ MNK_FACTORS = [
51 @pytest.mark.parametrize("activation", ["silu_and_mul", "relu2"])
52 @torch.inference_mode()
53 def test_flashinfer_fp4_moe_no_graph(
54 - m: int, n: int, k: int, e: int, topk: int, dtype: torch.dtype, activation: str
54 + m: int,
55 + n: int,
56 + k: int,
57 + e: int,
58 + topk: int,
59 + dtype: torch.dtype,
60 + activation: str,
61 + workspace_init,
62 ):
63 current_platform.seed_everything(7)
64 with set_current_vllm_config(
tests/kernels/moe/test_gpt_oss_triton_kernels.py
+1 -1
@@ -269,7 +269,7 @@ class Case:
269 )
270 @pytest.mark.parametrize("num_token", [2])
271 @pytest.mark.parametrize("tp", [1, 2, 4, 8])
272 -def test_equiv(num_token, a_dtype, w_dtype, tp):
272 +def test_equiv(num_token, a_dtype, w_dtype, tp, workspace_init):
273 from triton_kernels.tensor_details import layout
274
275 if not hasattr(layout, "make_default_matmul_mxfp4_w_layout"):
tests/kernels/moe/test_modular_kernel_combinations.py
+6
@@ -16,6 +16,7 @@ from vllm.platforms import current_platform
16 from vllm.utils.flashinfer import has_flashinfer_cutlass_fused_moe
17 from vllm.utils.import_utils import has_deep_ep, has_deep_gemm, has_pplx
18 from vllm.utils.torch_utils import cuda_device_count_stateless
19 +from vllm.v1.worker.workspace import init_workspace_manager
20
21 from .modular_kernel_tools.common import (
22 Config,
@@ -77,6 +78,10 @@ def rank_worker(
78 weights: WeightTensors,
79 verbose: bool,
80 ):
81 + # Initialize workspace manager in child process
82 + device = torch.device(f"cuda:{pgi.local_rank}")
83 + init_workspace_manager(device)
84 +
85 current_platform.seed_everything(pgi.rank)
86
87 # sanity check
@@ -300,6 +305,7 @@ def test_modular_kernel_combinations_singlegpu(
305 chunk_size: int | None,
306 world_size: int,
307 pytestconfig,
308 + workspace_init,
309 ):
310 """Note: float8_e4m3fn is not supported on CUDA architecture < 89,
311 and those tests will be skipped on unsupported hardware."""
tests/kernels/moe/test_modular_oai_triton_moe.py
+1
@@ -209,6 +209,7 @@ def test_oai_triton_moe(
209 num_experts: int,
210 topk: int,
211 unfused: bool,
212 + workspace_init,
213 ):
214 current_platform.seed_everything(0)
215 (
tests/kernels/moe/test_moe.py
+1
@@ -231,6 +231,7 @@ def test_fused_moe(
231 padding: bool,
232 chunk_size: int,
233 monkeypatch,
234 + workspace_init,
235 ):
236 current_platform.seed_everything(7)
237
tests/kernels/moe/test_nvfp4_moe.py
+1 -1
@@ -40,7 +40,7 @@ MNK_FACTORS = [
40 @pytest.mark.parametrize("dtype", [torch.bfloat16])
41 @torch.inference_mode()
42 def test_cutlass_fp4_moe_no_graph(
43 - m: int, n: int, k: int, e: int, topk: int, dtype: torch.dtype
43 + m: int, n: int, k: int, e: int, topk: int, dtype: torch.dtype, workspace_init
44 ):
45 current_platform.seed_everything(7)
46 with set_current_vllm_config(
tests/kernels/moe/test_pplx_moe.py
+5
@@ -46,6 +46,7 @@ from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
46 )
47 from vllm.platforms import current_platform
48 from vllm.utils.math_utils import round_up
49 +from vllm.v1.worker.workspace import init_workspace_manager
50
51 from ...utils import multi_gpu_test
52 from .parallel_utils import ProcessGroupInfo, parallel_launch
@@ -181,6 +182,7 @@ def test_fused_moe_batched_experts(
182 e: int,
183 topk: int,
184 dtype: torch.dtype,
185 + workspace_init,
186 ):
187 current_platform.seed_everything(7)
188
@@ -863,6 +865,9 @@ def _pplx_test_loop(
865 make_weights: bool,
866 test_fn: Callable,
867 ):
868 + device = torch.device(f"cuda:{pgi.local_rank}")
869 + init_workspace_manager(device)
870 +
871 def format_result(msg, ex=None):
872 if ex is not None:
873 x = str(ex)
tests/v1/attention/test_sparse_mla_backends.py
+215 -36
@@ -22,10 +22,14 @@ from tests.v1.attention.utils import (
22 )
23 from vllm import _custom_ops as ops
24 from vllm.attention.ops import flashmla
25 +from vllm.config import set_current_vllm_config
26 from vllm.model_executor.layers.linear import ColumnParallelLinear
27 from vllm.utils.math_utils import cdiv
27 -from vllm.v1.attention.backends.mla.flashmla_sparse import FlashMLASparseBackend
28 -from vllm.v1.attention.backends.mla.indexer import split_prefill_chunks
28 +from vllm.v1.attention.backends.mla.flashmla_sparse import (
29 + FlashMLASparseBackend,
30 + triton_convert_req_index_to_global_index,
31 +)
32 +from vllm.v1.attention.backends.utils import split_prefill_chunks
33
34 SPARSE_BACKEND_BATCH_SPECS = {
35 name: BATCH_SPECS[name]
@@ -114,8 +118,12 @@ def _quantize_dequantize_fp8_ds_mla(
118 @pytest.mark.parametrize("batch_name", list(SPARSE_BACKEND_BATCH_SPECS.keys()))
119 @pytest.mark.parametrize("kv_cache_dtype", ["fp8_ds_mla", "auto"])
120 @pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
121 +@pytest.mark.skipif(
122 + torch.cuda.get_device_capability() < (9, 0),
123 + reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
124 +)
125 def test_sparse_backend_decode_correctness(
118 - dist_init, batch_name, kv_cache_dtype, tensor_parallel_size
126 + dist_init, batch_name, kv_cache_dtype, tensor_parallel_size, workspace_init
127 ):
128 if not torch.cuda.is_available():
129 pytest.skip("CUDA is required for sparse MLA decode test")
@@ -320,28 +328,29 @@ def test_sparse_backend_decode_correctness(
328 mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
329
330 impl_cls = FlashMLASparseBackend.get_impl_cls()
323 - impl = impl_cls(
324 - num_heads=num_heads,
325 - head_size=head_size,
326 - scale=scale,
327 - num_kv_heads=1,
328 - alibi_slopes=None,
329 - sliding_window=None,
330 - kv_cache_dtype=vllm_config.cache_config.cache_dtype,
331 - logits_soft_cap=None,
332 - attn_type="decoder",
333 - kv_sharing_target_layer_name=None,
334 - q_lora_rank=None,
335 - kv_lora_rank=kv_lora_rank,
336 - qk_nope_head_dim=qk_nope_head_dim,
337 - qk_rope_head_dim=qk_rope_head_dim,
338 - qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
339 - v_head_dim=v_head_dim,
340 - kv_b_proj=mock_kv_b_proj,
341 - indexer=mock_indexer,
342 - )
331 + with set_current_vllm_config(vllm_config):
332 + impl = impl_cls(
333 + num_heads=num_heads,
334 + head_size=head_size,
335 + scale=scale,
336 + num_kv_heads=1,
337 + alibi_slopes=None,
338 + sliding_window=None,
339 + kv_cache_dtype=vllm_config.cache_config.cache_dtype,
340 + logits_soft_cap=None,
341 + attn_type="decoder",
342 + kv_sharing_target_layer_name=None,
343 + q_lora_rank=None,
344 + kv_lora_rank=kv_lora_rank,
345 + qk_nope_head_dim=qk_nope_head_dim,
346 + qk_rope_head_dim=qk_rope_head_dim,
347 + qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
348 + v_head_dim=v_head_dim,
349 + kv_b_proj=mock_kv_b_proj,
350 + indexer=mock_indexer,
351 + )
352
344 - impl.process_weights_after_loading(dtype)
353 + impl.process_weights_after_loading(dtype)
354
355 layer = MockAttentionLayer(device)
356 out_buffer = torch.empty(
@@ -366,22 +375,192 @@ def test_sparse_backend_decode_correctness(
375 torch.testing.assert_close(backend_output, sdpa_reference, rtol=0.5, atol=0.5)
376
377
378 +def _triton_convert_reference_impl(
379 + req_ids: torch.Tensor,
380 + block_table: torch.Tensor,
381 + token_indices: torch.Tensor,
382 + block_size: int,
383 + num_topk_tokens: int,
384 + HAS_PREFILL_WORKSPACE: bool = False,
385 + prefill_workspace_request_ids: torch.Tensor | None = None,
386 + prefill_workspace_starts: torch.Tensor | None = None,
387 +) -> torch.Tensor:
388 + """Reference implementation for triton_convert_req_index_to_global_index."""
389 + num_tokens = req_ids.shape[0]
390 + max_blocks_per_req = block_table.shape[1]
391 + result = torch.empty(
392 + num_tokens, num_topk_tokens, dtype=torch.int32, device=req_ids.device
393 + )
394 +
395 + for token_id in range(num_tokens):
396 + req_id = req_ids[token_id].item()
397 +
398 + # Determine if this token uses workspace or paged cache
399 + use_prefill_workspace = False
400 + workspace_start = 0
401 + if HAS_PREFILL_WORKSPACE and prefill_workspace_request_ids is not None:
402 + assert prefill_workspace_starts is not None
403 + prefill_req_id = prefill_workspace_request_ids[token_id].item()
404 + if prefill_req_id >= 0:
405 + use_prefill_workspace = True
406 + workspace_start = prefill_workspace_starts[prefill_req_id].item()
407 +
408 + for idx_id in range(num_topk_tokens):
409 + token_idx = token_indices[token_id, idx_id].item()
410 +
411 + if token_idx == -1:
412 + result[token_id, idx_id] = -1
413 + elif use_prefill_workspace:
414 + # Prefill + using prefill workspace: map to workspace offset
415 + result[token_id, idx_id] = workspace_start + token_idx
416 + else:
417 + # Decode: map to paged cache
418 + block_id = token_idx // block_size
419 + if block_id >= max_blocks_per_req:
420 + result[token_id, idx_id] = -1
421 + else:
422 + block_num = block_table[req_id, block_id].item()
423 + offset = token_idx % block_size
424 + result[token_id, idx_id] = block_num * block_size + offset
425 +
426 + return result
427 +
428 +
429 +@pytest.mark.parametrize("block_size", [16, 64, 128])
430 +@pytest.mark.parametrize("num_topk_tokens", [128, 256, 512])
431 +@pytest.mark.skipif(
432 + torch.cuda.get_device_capability() < (9, 0),
433 + reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
434 +)
435 +def test_triton_convert_req_index_to_global_index_decode_only(
436 + block_size, num_topk_tokens
437 +):
438 + device = torch.device("cuda")
439 + num_tokens = 8
440 + num_requests = 4
441 + max_blocks_per_req = 10
442 +
443 + req_id = torch.randint(
444 + 0, num_requests, (num_tokens,), dtype=torch.int32, device=device
445 + )
446 + block_table = torch.randint(
447 + 0, 100, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
448 + )
449 +
450 + token_indices = torch.randint(
451 + 0,
452 + block_size * max_blocks_per_req,
453 + (num_tokens, num_topk_tokens),
454 + dtype=torch.int32,
455 + device=device,
456 + )
457 +
458 + # Set some to -1 to test masking
459 + token_indices[0, :10] = -1
460 + token_indices[3, 50:60] = -1
461 +
462 + # Set some to out of bounds
463 + token_indices[2, 100:110] = max_blocks_per_req * block_size
464 + token_indices[6, 150:160] = max_blocks_per_req * block_size
465 +
466 + result = triton_convert_req_index_to_global_index(
467 + req_id,
468 + block_table,
469 + token_indices,
470 + BLOCK_SIZE=block_size,
471 + NUM_TOPK_TOKENS=num_topk_tokens,
472 + )
473 +
474 + reference_result = _triton_convert_reference_impl(
475 + req_id,
476 + block_table,
477 + token_indices,
478 + block_size,
479 + num_topk_tokens,
480 + )
481 +
482 + torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
483 +
484 +
485 +@pytest.mark.parametrize("block_size", [16])
486 +@pytest.mark.skipif(
487 + torch.cuda.get_device_capability() < (9, 0),
488 + reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
489 +)
490 +def test_triton_convert_req_index_to_global_index_with_prefill_workspace(block_size):
491 + device = torch.device("cuda")
492 + num_requests = 4
493 + max_blocks_per_req = 8
494 + num_topk_tokens = 128
495 +
496 + # First 6 tokens are decode (reqs 0, 1), last 6 are prefill (reqs 2, 3)
497 + req_id = torch.tensor(
498 + [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], dtype=torch.int32, device=device
499 + )
500 + prefill_workspace_request_ids = torch.tensor(
501 + [-1, -1, -1, -1, -1, -1, 0, 0, 0, 1, 1, 1], dtype=torch.int32, device=device
502 + )
503 +
504 + # Workspace starts for the 2 prefill reqs: req 2 starts at 0, req 3 starts at 100
505 + prefill_workspace_starts = torch.tensor([0, 100], dtype=torch.int32, device=device)
506 +
507 + block_table = torch.randint(
508 + 0, 50, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
509 + )
510 + token_indices = torch.randint(
511 + 0,
512 + block_size * max_blocks_per_req,
513 + (req_id.shape[0], num_topk_tokens),
514 + dtype=torch.int32,
515 + device=device,
516 + )
517 +
518 + # Set some to -1 to test masking
519 + token_indices[0, :10] = -1
520 + token_indices[3, 50:60] = -1
521 +
522 + # Set some to out of bounds
523 + token_indices[2, 100:110] = max_blocks_per_req * block_size
524 + token_indices[6, 150:160] = max_blocks_per_req * block_size
525 +
526 + result = triton_convert_req_index_to_global_index(
527 + req_id,
528 + block_table,
529 + token_indices,
530 + BLOCK_SIZE=block_size,
531 + NUM_TOPK_TOKENS=num_topk_tokens,
532 + HAS_PREFILL_WORKSPACE=True,
533 + prefill_workspace_request_ids=prefill_workspace_request_ids,
534 + prefill_workspace_starts=prefill_workspace_starts,
535 + )
536 +
537 + reference_result = _triton_convert_reference_impl(
538 + req_id,
539 + block_table,
540 + token_indices,
541 + block_size,
542 + num_topk_tokens,
543 + HAS_PREFILL_WORKSPACE=True,
544 + prefill_workspace_request_ids=prefill_workspace_request_ids,
545 + prefill_workspace_starts=prefill_workspace_starts,
546 + )
547 +
548 + torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
549 +
550 +
551 @pytest.mark.parametrize(
370 - "seq_lens,max_buf,start,expected",
552 + "seq_lens,max_buf,expected",
553 [
554 # Basic split: totals per chunk ≤ max_buf
373 - (torch.tensor([2, 3, 4, 2]), 5, 0, [(0, 2), (2, 3), (3, 4)]),
374 - # Non-zero start index
375 - (torch.tensor([2, 3, 4, 2]), 5, 1, [(1, 2), (2, 3), (3, 4)]),
376 - # Exact fits should split between items when adding the next would
377 - # overflow
378 - (torch.tensor([5, 5, 5]), 5, 0, [(0, 1), (1, 2), (2, 3)]),
555 + (torch.tensor([2, 3, 4, 2]), 5, [(0, 2), (2, 3), (3, 4)]),
556 + # Exact fits should split between items when adding the next would overflow
557 + (torch.tensor([5, 5, 5]), 5, [(0, 1), (1, 2), (2, 3)]),
558 # All requests fit in a single chunk
380 - (torch.tensor([1, 1, 1]), 10, 0, [(0, 3)]),
381 - # Large buffer with non-zero start
382 - (torch.tensor([4, 4, 4]), 100, 1, [(1, 3)]),
559 + (torch.tensor([1, 1, 1]), 10, [(0, 3)]),
560 + # Large buffer
561 + (torch.tensor([4, 4, 4]), 100, [(0, 3)]),
562 ],
563 )
385 -def test_split_prefill_chunks(seq_lens, max_buf, start, expected):
386 - out = split_prefill_chunks(seq_lens, max_buf, start)
564 +def test_split_prefill_chunks(seq_lens, max_buf, expected):
565 + out = split_prefill_chunks(seq_lens, max_buf)
566 assert out == expected
vllm/_custom_ops.py
+23
@@ -2403,6 +2403,29 @@ def cp_gather_cache(
2403 )
2404
2405
2406 +def cp_gather_and_upconvert_fp8_kv_cache(
2407 + src_cache: torch.Tensor,
2408 + dst: torch.Tensor,
2409 + block_table: torch.Tensor,
2410 + seq_lens: torch.Tensor,
2411 + workspace_starts: torch.Tensor,
2412 + batch_size: int,
2413 +) -> None:
2414 + """Gather and upconvert FP8 KV cache to BF16 workspace.
2415 +
2416 + Args:
2417 + src_cache: FP8 KV cache [num_blocks, block_size, 656]
2418 + dst: BF16 output workspace [total_tokens, 576]
2419 + block_table: Block indices [num_reqs, max_blocks]
2420 + seq_lens: Sequence lengths [num_reqs]
2421 + workspace_starts: Workspace start offsets [num_reqs]
2422 + batch_size: Number of requests
2423 + """
2424 + torch.ops._C_cache_ops.cp_gather_and_upconvert_fp8_kv_cache(
2425 + src_cache, dst, block_table, seq_lens, workspace_starts, batch_size
2426 + )
2427 +
2428 +
2429 def indexer_k_quant_and_cache(
2430 k: torch.Tensor,
2431 kv_cache: torch.Tensor,
vllm/envs.py
+4
@@ -239,6 +239,7 @@ if TYPE_CHECKING:
239 VLLM_NCCL_INCLUDE_PATH: str | None = None
240 VLLM_USE_FBGEMM: bool = False
241 VLLM_GC_DEBUG: str = ""
242 + VLLM_DEBUG_WORKSPACE: bool = False
243 VLLM_DISABLE_SHARED_EXPERTS_STREAM: bool = False
244 VLLM_SHARED_EXPERTS_STREAM_TOKEN_THRESHOLD: int = 256
245 VLLM_COMPILE_CACHE_SAVE_FORMAT: Literal["binary", "unpacked"] = "binary"
@@ -1537,6 +1538,9 @@ environment_variables: dict[str, Callable[[], Any]] = {
1538 # - VLLM_GC_DEBUG='{"top_objects":5}': enable GC debugger with
1539 # top 5 collected objects
1540 "VLLM_GC_DEBUG": lambda: os.getenv("VLLM_GC_DEBUG", ""),
1541 + # Debug workspace allocations.
1542 + # logging of workspace resize operations.
1543 + "VLLM_DEBUG_WORKSPACE": lambda: bool(int(os.getenv("VLLM_DEBUG_WORKSPACE", "0"))),
1544 # Disables parallel execution of shared_experts via separate cuda stream
1545 "VLLM_DISABLE_SHARED_EXPERTS_STREAM": lambda: bool(
1546 int(os.getenv("VLLM_DISABLE_SHARED_EXPERTS_STREAM", "0"))
vllm/model_executor/layers/fused_moe/modular_kernel.py
+16 -57
@@ -22,12 +22,12 @@ from vllm.model_executor.layers.fused_moe.utils import (
22 from vllm.platforms import current_platform
23 from vllm.utils.math_utils import cdiv
24 from vllm.v1.worker.ubatching import (
25 - dbo_current_ubatch_id,
25 dbo_enabled,
26 dbo_maybe_run_recv_hook,
27 dbo_register_recv_hook,
28 dbo_yield,
29 )
30 +from vllm.v1.worker.workspace import current_workspace_manager
31
32 logger = init_logger(__name__)
33
@@ -661,25 +661,6 @@ def _slice_scales(
661 return None
662
663
664 -class SharedResizableBuffer:
665 - def __init__(self):
666 - self.buffer = None
667 -
668 - def get(
669 - self, shape: tuple[int, ...], device: torch.device, dtype: torch.dtype
670 - ) -> torch.Tensor:
671 - assert shape != ()
672 - shape_numel = prod(shape)
673 - if (
674 - self.buffer is None
675 - or self.buffer.numel() < shape_numel
676 - or self.buffer.device != device
677 - or self.buffer.dtype != dtype
678 - ):
679 - self.buffer = torch.empty(shape_numel, device=device, dtype=dtype)
680 - return self.buffer[:shape_numel].view(*shape)
681 -
682 -
664 @final
665 class FusedMoEModularKernel(torch.nn.Module):
666 """
@@ -694,22 +675,6 @@ class FusedMoEModularKernel(torch.nn.Module):
675 objects.
676 """
677
697 - class SharedBuffers:
698 - def __init__(self) -> None:
699 - self.fused_out = SharedResizableBuffer()
700 - self.workspace13 = SharedResizableBuffer()
701 - self.workspace2 = SharedResizableBuffer()
702 -
703 - # Persistent buffers that are shared across `FusedMoEModularKernel`
704 - # instances (layers), to save memory and allocattions.
705 - #
706 - # We have two sets of buffers to support dual batch overlap (DBO) where each
707 - # microbatch (ubatch) should use its own set of buffers to avoid
708 - # cross-ubatch contimination.
709 - # NOTE that memory is lazily allocated for these buffers, meaning that if
710 - # DBO isn't being used, the second SharedBuffers will be empty.
711 - shared_buffers: list[SharedBuffers] = [SharedBuffers(), SharedBuffers()]
712 -
678 def __init__(
679 self,
680 prepare_finalize: FusedMoEPrepareAndFinalize,
@@ -806,10 +771,6 @@ class FusedMoEModularKernel(torch.nn.Module):
771 assert M_full > 0 and M_chunk > 0
772
773 num_chunks, _ = self._chunk_info(M_full)
809 -
810 - # select per-ubatch buffers to avoid cross-ubatch reuse under DBO
811 - ubatch_idx = dbo_current_ubatch_id()
812 - buffers = self.shared_buffers[ubatch_idx]
774 workspace_dtype = self.fused_experts.workspace_dtype(out_dtype)
775
776 # Force worst-case allocation in profiling run for
@@ -832,14 +793,11 @@ class FusedMoEModularKernel(torch.nn.Module):
793 expert_tokens_meta,
794 )
795 )
835 - buffers.workspace13.get(
836 - max_workspace_13, device=device, dtype=workspace_dtype
837 - )
838 - buffers.workspace2.get(
839 - max_workspace_2, device=device, dtype=workspace_dtype
840 - )
841 - buffers.fused_out.get(
842 - max_fused_out_shape, device=device, dtype=workspace_dtype
796 +
797 + current_workspace_manager().get_simultaneous(
798 + (max_workspace_13, workspace_dtype),
799 + (max_workspace_2, workspace_dtype),
800 + (max_fused_out_shape, out_dtype),
801 )
802
803 # Get intermediate workspace shapes based off the chunked M size.
@@ -866,22 +824,23 @@ class FusedMoEModularKernel(torch.nn.Module):
824
825 # We can reuse the memory between cache1 and cache3 because by the
826 # time we need cache3, we're done with cache1.
869 - workspace13 = buffers.workspace13.get(
870 - workspace13_shape, device=device, dtype=workspace_dtype
871 - )
872 - workspace2 = buffers.workspace2.get(
873 - workspace2_shape, device=device, dtype=workspace_dtype
874 - )
875 -
827 # Construct the entire output that can then be processed in chunks.
828 # Reuse workspace13 for the output in the non-chunked case as long
829 # as it is large enough. This will not always be the case for standard
830 # format experts and with experts that have empty workspaces.
831 if num_chunks == 1 and prod(workspace13_shape) >= prod(fused_out_shape):
832 + workspace13, workspace2 = current_workspace_manager().get_simultaneous(
833 + (workspace13_shape, workspace_dtype),
834 + (workspace2_shape, workspace_dtype),
835 + )
836 fused_out = _resize_cache(workspace13, fused_out_shape)
837 else:
883 - fused_out = buffers.fused_out.get(
884 - fused_out_shape, device=device, dtype=out_dtype
838 + workspace13, workspace2, fused_out = (
839 + current_workspace_manager().get_simultaneous(
840 + (workspace13_shape, workspace_dtype),
841 + (workspace2_shape, workspace_dtype),
842 + (fused_out_shape, out_dtype),
843 + )
844 )
845
846 return workspace13, workspace2, fused_out
vllm/model_executor/models/deepseek_v2.py
+18 -19
@@ -83,6 +83,7 @@ from vllm.v1.attention.backends.mla.indexer import (
83 DeepseekV32IndexerMetadata,
84 )
85 from vllm.v1.kv_cache_interface import KVCacheSpec, MLAAttentionSpec
86 +from vllm.v1.worker.workspace import current_workspace_manager
87
88 from .interfaces import MixtureOfExperts, SupportsEagle, SupportsLoRA, SupportsPP
89 from .utils import (
@@ -616,8 +617,15 @@ def sparse_attn_indexer(
617 # careful! this will be None in dummy run
618 attn_metadata = get_forward_context().attn_metadata
619 fp8_dtype = current_platform.fp8_dtype()
620 +
621 # assert isinstance(attn_metadata, dict)
622 if not isinstance(attn_metadata, dict):
623 + # Reserve workspace for indexer during profiling run
624 + current_workspace_manager().get_simultaneous(
625 + ((total_seq_lens, head_dim), torch.float8_e4m3fn),
626 + ((total_seq_lens, 4), torch.uint8),
627 + )
628 +
629 return sparse_attn_indexer_fake(
630 hidden_states,
631 k_cache_prefix,
@@ -651,17 +659,17 @@ def sparse_attn_indexer(
659 topk_indices_buffer[: hidden_states.shape[0]] = -1
660 if has_prefill:
661 prefill_metadata = attn_metadata.prefill
662 +
663 + # Get the full shared workspace buffers once (will allocate on first use)
664 + workspace_manager = current_workspace_manager()
665 + k_fp8_full, k_scale_full = workspace_manager.get_simultaneous(
666 + ((total_seq_lens, head_dim), fp8_dtype),
667 + ((total_seq_lens, 4), torch.uint8),
668 + )
669 +
670 for chunk in prefill_metadata.chunks:
655 - k_fp8 = torch.empty(
656 - [chunk.total_seq_lens, head_dim],
657 - device=k.device,
658 - dtype=fp8_dtype,
659 - )
660 - k_scale = torch.empty(
661 - [chunk.total_seq_lens, 4],
662 - device=k.device,
663 - dtype=torch.uint8,
664 - )
671 + k_fp8 = k_fp8_full[: chunk.total_seq_lens]
672 + k_scale = k_scale_full[: chunk.total_seq_lens]
673 ops.cp_gather_indexer_k_quant_cache(
674 kv_cache,
675 k_fp8,
@@ -777,15 +785,6 @@ def sparse_attn_indexer_fake(
785 total_seq_lens: int,
786 topk_indices_buffer: torch.Tensor | None,
787 ) -> torch.Tensor:
780 - # profile run
781 - # NOTE(Chen): create the max possible flattened_kv. So that
782 - # profile_run can get correct memory usage.
783 - _flattened_kv = torch.empty(
784 - [total_seq_lens, head_dim + 4], device=k.device, dtype=torch.uint8
785 - )
786 - fp8_dtype = current_platform.fp8_dtype()
787 - _k_fp8 = _flattened_kv[..., :head_dim].view(fp8_dtype).contiguous()
788 - _k_scale = _flattened_kv[..., head_dim:].view(torch.float32).contiguous()
788 return topk_indices_buffer
789
790
vllm/v1/attention/backends/mla/flashmla_sparse.py
+567 -98
@@ -18,7 +18,7 @@ from vllm.attention.ops.flashmla import (
18 flash_mla_with_kvcache,
19 get_mla_metadata,
20 )
21 -from vllm.config import VllmConfig
21 +from vllm.config import VllmConfig, get_current_vllm_config
22 from vllm.config.cache import CacheDType
23 from vllm.logger import init_logger
24 from vllm.platforms import current_platform
@@ -30,13 +30,31 @@ from vllm.v1.attention.backends.utils import (
30 AttentionCGSupport,
31 AttentionMetadataBuilder,
32 CommonAttentionMetadata,
33 + reshape_attn_output_for_spec_decode,
34 + reshape_query_for_spec_decode,
35 + split_decodes_and_prefills,
36 + split_prefill_chunks,
37 )
38 from vllm.v1.kv_cache_interface import AttentionSpec
39 +from vllm.v1.worker.workspace import current_workspace_manager
40
41 if TYPE_CHECKING:
42 from vllm.model_executor.models.deepseek_v2 import Indexer
43
44 logger = init_logger(__name__)
45 +
46 +# For FP8 sparse attention we have two impelementations:
47 +# 1. Mixed batch mode: use the FP8 decode kernel for both prefill and decode this is
48 +# done by treating all tokens as single batch.
49 +# 2. Separate prefill and decode mode: use the BF16 prefill kernel for prefill
50 +# (upconverting the FP8 cache to BF16 then calling the prefill kernel) and using
51 +# the FP8 decode kernel for decode.
52 +# Currently we use #1 when the number of heads per rank is low (i.e. TP) since the BF16
53 +# prefill kernel requires padding the numer of heads to 128 while the decode does not
54 +# so when the per ranke head count is below MIN_HEADS_FOR_BF16_PREFILL we use the mixed
55 +# batch mode (#2).
56 +MIN_HEADS_FOR_BF16_PREFILL = 32
57 +
58 """
59 NOTE: FlashMLA Sparse uses an fp8 cache with the following format
60
@@ -127,19 +145,72 @@ class FlashMLASparseMetadata:
145 dummy_block_table: torch.Tensor
146 cache_lens: torch.Tensor
147
130 - fp8_extra_metadata: FP8KernelMetadata | None = None
131 -
132 -
148 + @dataclass
149 + class FP8SeperatePrefillDecode:
150 + @dataclass
151 + class Decode:
152 + kernel_metadata: "FlashMLASparseMetadata.FP8KernelMetadata"
153 + decode_query_len: int # needed for reshape in spec decode
154 +
155 + @dataclass
156 + class Prefill:
157 + # Sequence lengths (context + query) for prefill requests
158 + # Shape: [num_prefill_reqs]
159 + seq_lens: torch.Tensor
160 +
161 + # Request ID for each token: -1 for decode tokens, request index
162 + # (0, 1, 2, ...) for prefill tokens.
163 + # Shape: [num_actual_tokens]
164 + request_ids: torch.Tensor
165 +
166 + # Workspace start offsets for all prefill requests
167 + # Shape: [num_prefill_reqs], adjusted in-place per chunk to be
168 + # 0-indexed within each chunk. Used to map prefill tokens to workspace
169 + # offsets in convert_logical_index_to_physical_index
170 + workspace_starts: torch.Tensor
171 +
172 + @dataclass
173 + class Chunk:
174 + """Metadata for a chunk of prefill requests.
175 +
176 + Prefill requests may be chunked to fit within the fixed workspace size.
177 + """
178 +
179 + seq_lens: torch.Tensor
180 + tokens_slice: slice
181 + block_table: torch.Tensor
182 + req_start_idx: int
183 + workspace_starts: torch.Tensor
184 + chunk_tot_seqlen: int
185 +
186 + chunks: list[Chunk]
187 +
188 + num_prefills: int = 0
189 + num_decodes: int = 0
190 + num_prefill_tokens: int = 0
191 + num_decode_tokens: int = 0
192 +
193 + decode: Decode | None = None
194 + prefill: Prefill | None = None
195 +
196 + fp8_extra_metadata: FP8SeperatePrefillDecode | FP8KernelMetadata | None = None
197 + fp8_use_mixed_batch: bool = False
198 +
199 +
200 +# Kernel with prefill workspace support
201 @triton.jit
202 def _convert_req_index_to_global_index_kernel(
203 req_id_ptr, # int32 [num_tokens]
204 block_table_ptr, # int32 [num_requests, max_num_blocks_per_req]
205 token_indices_ptr, # int32 [num_tokens, NUM_TOPK_TOKENS]
206 out_ptr, # int32 [num_tokens, NUM_TOPK_TOKENS]
207 + prefill_request_id_ptr, # int32 [num_tokens], -1 for decode, >=0 for prefill
208 + workspace_starts_ptr, # int32 [num_prefill_reqs+1] or nullptr
209 # shapes (compile-time where possible)
210 max_num_blocks_per_req: tl.constexpr,
211 BLOCK_SIZE: tl.constexpr,
212 BLOCK_N: tl.constexpr, # tile width along columns
213 + HAS_PREFILL: tl.constexpr,
214 # strides (in elements)
215 bt_stride0,
216 bt_stride1,
@@ -165,7 +236,10 @@ def _convert_req_index_to_global_index_kernel(
236
237 # Only token == -1 should propagate as -1
238 is_invalid_tok = tok < 0
168 -
239 + is_prefill = False
240 + if HAS_PREFILL:
241 + prefill_req_id = tl.load(prefill_request_id_ptr + token_id)
242 + is_prefill = prefill_req_id >= 0
243 # Compute block id and in-block offset
244 block_id = tok // BLOCK_SIZE
245 inblock_off = tok % BLOCK_SIZE
@@ -173,12 +247,18 @@ def _convert_req_index_to_global_index_kernel(
247 # Guard block_table access
248 valid_block = (block_id < max_num_blocks_per_req) & (block_id >= 0)
249 bt_ptr = block_table_ptr + req * bt_stride0 + block_id * bt_stride1
176 - base = tl.load(bt_ptr, mask=valid_block, other=0)
177 -
178 - # If token == -1 OR block_id OOB, output -1; else base * BLOCK_SIZE + offset
179 - out_val = tl.where(
180 - is_invalid_tok | (~valid_block), -1, base * BLOCK_SIZE + inblock_off
181 - )
250 + is_invalid_tok |= ~valid_block
251 + base = tl.load(bt_ptr, mask=valid_block & ~is_prefill, other=0)
252 + out_val = base * BLOCK_SIZE + inblock_off
253 +
254 + # Override with prefill output if prefill is enabled
255 + if HAS_PREFILL:
256 + workspace_start = tl.load(
257 + workspace_starts_ptr + prefill_req_id, mask=is_prefill, other=0
258 + )
259 + prefill_out = workspace_start + tok
260 + out_val = tl.where(is_prefill, prefill_out, out_val)
261 + out_val = tl.where(is_invalid_tok, -1, out_val)
262
263 # Store results
264 out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1
@@ -192,6 +272,9 @@ def triton_convert_req_index_to_global_index(
272 BLOCK_SIZE: int = 64,
273 NUM_TOPK_TOKENS: int = 2048,
274 BLOCK_N: int = 128, # tile width along columns
275 + HAS_PREFILL_WORKSPACE: bool = False,
276 + prefill_workspace_request_ids: torch.Tensor | None = None,
277 + prefill_workspace_starts: torch.Tensor | None = None,
278 ):
279 """
280 out[token_id, indice_id] =
@@ -202,17 +285,32 @@ def triton_convert_req_index_to_global_index(
285 Only when token_indices[token_id, indice_id] == -1 do we output -1.
286 For safety, we also output -1 if the derived block_id would be
287 out-of-bounds.
288 +
289 + When HAS_PREFILL_WORKSPACE is True, prefill tokens are mapped to workspace offsets
290 + instead of global cache slots. prefill_workspace_request_ids and
291 + prefill_workspace_starts must be provided.
292 +
293 + prefill_workspace_request_ids: int32 [num_tokens], -1 for decode else
294 + prefill request index (maps to prefill_workspace_starts)
295 + prefill_workspace_starts: int32 [num_prefills], 0-indexed workspace
296 + starts for each prefill request
297 """
298 assert req_id.dtype == torch.int32
299 assert block_table.dtype == torch.int32
300 assert token_indices.dtype == torch.int32
301 assert token_indices.shape[1] == NUM_TOPK_TOKENS
302 assert NUM_TOPK_TOKENS % BLOCK_N == 0, (
211 - f"NUM_TOPK_TOKENS ({NUM_TOPK_TOKENS}) must be divisible byBLOCK_N ({BLOCK_N})"
303 + f"NUM_TOPK_TOKENS ({NUM_TOPK_TOKENS}) must be divisible by BLOCK_N ({BLOCK_N})"
304 )
305
306 + if HAS_PREFILL_WORKSPACE:
307 + assert prefill_workspace_request_ids is not None
308 + assert prefill_workspace_starts is not None
309 + assert prefill_workspace_request_ids.dtype == torch.int32
310 + assert prefill_workspace_starts.dtype == torch.int32
311 +
312 num_tokens = req_id.shape[0]
215 - num_requests, max_num_blocks_per_req = block_table.shape
313 + max_num_blocks_per_req = block_table.shape[1]
314 tiles_per_row = NUM_TOPK_TOKENS // BLOCK_N
315
316 # Ensure contiguous tensors on the same device
@@ -226,6 +324,13 @@ def triton_convert_req_index_to_global_index(
324 ti_stride0, ti_stride1 = token_indices_c.stride()
325 out_stride0, out_stride1 = out.stride()
326
327 + # Prepare prefill pointers
328 + if HAS_PREFILL_WORKSPACE:
329 + assert prefill_workspace_request_ids is not None # for mypy
330 + assert prefill_workspace_starts is not None # for mypy
331 + assert prefill_workspace_request_ids.is_contiguous()
332 + assert prefill_workspace_starts.is_contiguous()
333 +
334 # Exact 2D grid: tokens × column tiles
335 grid = (num_tokens, tiles_per_row)
336
@@ -234,10 +339,13 @@ def triton_convert_req_index_to_global_index(
339 block_table_c,
340 token_indices_c,
341 out,
342 + prefill_workspace_request_ids,
343 + prefill_workspace_starts,
344 # shapes / constexprs
345 max_num_blocks_per_req,
346 BLOCK_SIZE,
347 BLOCK_N,
348 + HAS_PREFILL_WORKSPACE,
349 # strides
350 bt_stride0,
351 bt_stride1,
@@ -249,7 +357,16 @@ def triton_convert_req_index_to_global_index(
357 return out
358
359
252 -@dataclass
360 +def get_prefill_workspace_size(max_model_len: int):
361 + # NOTE(Lucas): 5 is a magic number for controlling the prefill buffer size.
362 + # May be tuned later.
363 + # Memory usage: 5 * max_model_len * 576 * 2 bytes
364 + # Example: DeepSeek-V3.2 with max_model_len=163840 ->
365 + # 5 * 163840 * 576 * 2 = ~900 MB
366 + # This fits nicely below the typical MoE workspace size of >2GB so this is "free"
367 + return max_model_len * 5
368 +
369 +
370 class FlashMLASparseMetadataBuilder(AttentionMetadataBuilder[FlashMLASparseMetadata]):
371 _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
372
@@ -259,29 +376,42 @@ class FlashMLASparseMetadataBuilder(AttentionMetadataBuilder[FlashMLASparseMetad
376 layer_names: list[str],
377 vllm_config: VllmConfig,
378 device: torch.device,
262 - ):
379 + ) -> None:
380 + self.vllm_config = vllm_config
381 + self.layer_names = layer_names
382 cache_config = vllm_config.cache_config
383 self.kv_cache_spec = kv_cache_spec
384 self.model_config = vllm_config.model_config
385 parallel_config = vllm_config.parallel_config
386 self.device = device
387
388 + # Treat requests with query length <= 1 as decodes to match the
389 + # DeepGEMM indexer constraint (fp8_paged_mqa_logits only supports next_n <= 2)
390 + self._init_reorder_batch_threshold(1, supports_spec_as_decode=True)
391 +
392 props = torch.cuda.get_device_properties(device)
393 sm_count = props.multi_processor_count
394
395 self.num_heads = self.model_config.get_num_attention_heads(parallel_config)
396 self.mla_dims = get_mla_dims(self.model_config)
397 +
398 self.topk_tokens = vllm_config.model_config.hf_config.index_topk
399 self.use_fp8_kv_cache = cache_config.cache_dtype == "fp8_ds_mla"
276 - self.topk_tokens_tensor = torch.tensor(
277 - [self.topk_tokens], device=device, dtype=torch.int32
400 + max_num_seqs = vllm_config.scheduler_config.max_num_seqs
401 + # Shape: [max_num_seqs], all elements = topk_tokens (constant for full-CG)
402 + self.topk_tokens_tensor = torch.full(
403 + (max_num_seqs,), self.topk_tokens, device=device, dtype=torch.int32
404 )
279 - self.max_model_len_tensor = torch.tensor(
280 - [self.model_config.max_model_len], device=device, dtype=torch.int32
405 + # Shape: [max_num_seqs], all elements = max_model_len
406 + self.max_model_len_tensor = torch.full(
407 + (max_num_seqs,),
408 + self.model_config.max_model_len,
409 + device=device,
410 + dtype=torch.int32,
411 )
412 # this is ignored by `flash_mla_with_kvcache` if indices not None
413 self.dummy_block_table = torch.empty(
284 - (1, 1), dtype=torch.int32, device=self.device
414 + (max_num_seqs, 1), dtype=torch.int32, device=self.device
415 )
416
417 # Equation taken from FlashMLA/csrc/pybind.cpp
@@ -299,10 +429,9 @@ class FlashMLASparseMetadataBuilder(AttentionMetadataBuilder[FlashMLASparseMetad
429 dtype=torch.int32,
430 device=device,
431 )
432 + # Sized for per-request batching (num_decodes + 1)
433 self.num_splits_buffer = torch.empty(
303 - # We pack all the tokens into one batch for sparse attention.
304 - # Otherwise, we can exceed the sm of `get_mla_metadata`.
305 - (2,),
434 + (max_num_seqs + 1,),
435 dtype=torch.int32,
436 device=device,
437 )
@@ -312,30 +441,171 @@ class FlashMLASparseMetadataBuilder(AttentionMetadataBuilder[FlashMLASparseMetad
441 device=device,
442 )
443
315 - def build(
444 + def _build_fp8_mixed_decode_prefill(
445 self,
317 - common_prefix_len: int,
446 common_attn_metadata: CommonAttentionMetadata,
319 - fast_build: bool = False,
320 - ) -> FlashMLASparseMetadata:
447 + ) -> "FlashMLASparseMetadata.FP8KernelMetadata":
448 + """Build FP8 metadata treating all tokens as one mixed batch.
449 +
450 + This matches main branch's approach and avoids the BF16 prefill kernel
451 + which has head padding overhead when num_heads is small (high TP case).
452 + """
453 num_tokens = common_attn_metadata.num_actual_tokens
322 - starts = np.asarray(common_attn_metadata.query_start_loc_cpu, dtype=np.int32)
323 - seg_lengths = np.diff(starts)
324 - req_id_per_token = np.repeat(
325 - np.arange(seg_lengths.shape[0], dtype=np.int32), seg_lengths
454 +
455 + # Build metadata for all tokens as a single batch
456 + tile_scheduler_metadata, num_splits = get_mla_metadata(
457 + cache_seqlens=self.topk_tokens_tensor[:1], # Single batch
458 + num_q_tokens_per_head_k=num_tokens * self.num_heads,
459 + topk=self.topk_tokens,
460 + num_heads_q=self.num_heads,
461 + num_heads_k=1,
462 + is_fp8_kvcache=True,
463 )
327 - # Zero-fill for cudagraphs
328 - self.req_id_per_token_buffer.fill_(0)
329 - self.req_id_per_token_buffer[: req_id_per_token.shape[0]].copy_(
330 - torch.from_numpy(req_id_per_token), non_blocking=True
464 +
465 + num_sm_parts = tile_scheduler_metadata.size(0)
466 + tile_scheduler_metadata_buffer = self.tile_scheduler_metadata_buffer[
467 + :num_sm_parts
468 + ]
469 + tile_scheduler_metadata_buffer.copy_(tile_scheduler_metadata)
470 + num_splits_view = self.num_splits_buffer[:2]
471 + num_splits_view.copy_(num_splits)
472 +
473 + fp8_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
474 + scheduler_metadata=tile_scheduler_metadata_buffer,
475 + num_splits=num_splits_view,
476 + cache_lens=self.max_model_len_tensor[:1],
477 + dummy_block_table=self.dummy_block_table[:1],
478 )
332 - req_id_per_token = self.req_id_per_token_buffer[:num_tokens]
479
334 - fp8_extra_metadata = None
335 - if self.use_fp8_kv_cache:
480 + return fp8_metadata
481 +
482 + def _build_fp8_separate_prefill_decode(
483 + self,
484 + common_attn_metadata: CommonAttentionMetadata,
485 + ) -> "FlashMLASparseMetadata.FP8SeperatePrefillDecode":
486 + num_tokens = common_attn_metadata.num_actual_tokens
487 +
488 + (num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens) = (
489 + split_decodes_and_prefills(
490 + common_attn_metadata,
491 + decode_threshold=self.reorder_batch_threshold or 1,
492 + require_uniform=True,
493 + )
494 + )
495 +
496 + FP8Meta = FlashMLASparseMetadata.FP8SeperatePrefillDecode
497 + fp8_metadata = FP8Meta(
498 + num_decodes=num_decodes,
499 + num_prefills=num_prefills,
500 + num_decode_tokens=num_decode_tokens,
501 + num_prefill_tokens=num_prefill_tokens,
502 + )
503 +
504 + # Extract prefill sequence lengths (context + query, not just query)
505 + # Decode requests come first in the batch, prefill requests follow
506 + prefill_seq_lens = None
507 + prefill_request_id = None
508 + prefill_workspace_starts = None
509 + prefill_chunks = None
510 +
511 + # For pure decode batches, prefill_request_id will be None
512 + # For mixed batches, it will have -1 for decode and request_id for prefill
513 + if num_prefills > 0:
514 + seq_lens_cpu = common_attn_metadata.seq_lens_cpu
515 + seq_lens = common_attn_metadata.seq_lens
516 + query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
517 +
518 + prefill_seq_lens_cpu = seq_lens_cpu[num_decodes:]
519 + prefill_seq_lens = seq_lens[num_decodes:]
520 +
521 + # Build prefill_request_id: -1 for decode, request index for
522 + # prefill. This enables a single
523 + # convert_logical_index_to_physical_index call for all tokens
524 + prefill_request_id = torch.full(
525 + (num_tokens,), -1, dtype=torch.int32, device=self.device
526 + )
527 + # Map prefill tokens to their request IDs (0, 1, 2, ...)
528 + for req_idx in range(num_prefills):
529 + # Get query token range for this prefill request
530 + global_req_idx = num_decodes + req_idx
531 + req_query_start = query_start_loc_cpu[global_req_idx]
532 + req_query_end = query_start_loc_cpu[global_req_idx + 1]
533 + prefill_request_id[req_query_start:req_query_end] = req_idx
534 +
535 + # will be adjusted by chunk loop
536 + prefill_workspace_starts_cpu = torch.zeros(
537 + num_prefills, dtype=torch.int32, pin_memory=True
538 + )
539 + prefill_workspace_starts_cpu[1:] = torch.cumsum(
540 + prefill_seq_lens_cpu[:-1], dim=0
541 + )
542 + # populated by non-blocking copy after prefill_workspace_starts_cpu is
543 + # updated by each chunk
544 + prefill_workspace_starts = torch.empty(
545 + num_prefills, dtype=torch.int32, device=self.device
546 + )
547 +
548 + # Chunk prefill requests to fit within workspace size
549 + max_prefill_buffer_size = get_prefill_workspace_size(
550 + self.vllm_config.model_config.max_model_len
551 + )
552 + chunk_bounds = split_prefill_chunks(
553 + prefill_seq_lens_cpu, max_prefill_buffer_size
554 + )
555 +
556 + prefill_chunks = []
557 + for chunk_start, chunk_end in chunk_bounds:
558 + # Adjust workspace_starts in-place per chunk to be
559 + # 0-indexed within each chunk
560 + # Example: seq_lens=[10,15,20,5], chunks=[[0,2],[2,4]]
561 + # Initial: workspace_starts=[0,10,25,45]
562 + # After: workspace_starts=[0,10,0,20]
563 + # (chunk 0 starts at 0, chunk 1 starts at 0)
564 + offset = prefill_workspace_starts_cpu[chunk_start].item()
565 + prefill_workspace_starts_cpu[chunk_start:chunk_end] -= offset
566 +
567 + chunk_seq_lens = prefill_seq_lens[chunk_start:chunk_end]
568 + chunk_tot_seqlen = prefill_seq_lens_cpu[chunk_start:chunk_end].sum()
569 + token_start = query_start_loc_cpu[num_decodes + chunk_start].item()
570 + token_end = query_start_loc_cpu[num_decodes + chunk_end].item()
571 + tokens_slice = slice(token_start, token_end)
572 +
573 + # Create chunk view of gpu tensor
574 + chunk_workspace_starts = prefill_workspace_starts[chunk_start:chunk_end]
575 + chunk_block_table = common_attn_metadata.block_table_tensor[
576 + num_decodes + chunk_start : num_decodes + chunk_end
577 + ]
578 +
579 + prefill_chunks.append(
580 + FP8Meta.Prefill.Chunk(
581 + seq_lens=chunk_seq_lens,
582 + tokens_slice=tokens_slice,
583 + block_table=chunk_block_table,
584 + req_start_idx=chunk_start,
585 + workspace_starts=chunk_workspace_starts,
586 + chunk_tot_seqlen=chunk_tot_seqlen,
587 + )
588 + )
589 +
590 + prefill_workspace_starts.copy_(
591 + prefill_workspace_starts_cpu, non_blocking=True
592 + )
593 +
594 + fp8_metadata.prefill = FP8Meta.Prefill(
595 + seq_lens=prefill_seq_lens,
596 + request_ids=prefill_request_id,
597 + workspace_starts=prefill_workspace_starts,
598 + chunks=prefill_chunks,
599 + )
600 +
601 + if num_decodes > 0:
602 + # Compute decode_query_len for spec decode (uniform due to require_uniform)
603 + query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
604 + decode_query_len = (query_start_loc_cpu[1] - query_start_loc_cpu[0]).item()
605 +
606 tile_scheduler_metadata, num_splits = get_mla_metadata(
337 - cache_seqlens=self.topk_tokens_tensor,
338 - num_q_tokens_per_head_k=num_tokens * self.num_heads,
607 + cache_seqlens=self.topk_tokens_tensor[:num_decodes],
608 + num_q_tokens_per_head_k=decode_query_len * self.num_heads,
609 topk=self.topk_tokens,
610 num_heads_q=self.num_heads,
611 num_heads_k=1,
@@ -348,33 +618,70 @@ class FlashMLASparseMetadataBuilder(AttentionMetadataBuilder[FlashMLASparseMetad
618 :num_sm_parts
619 ]
620 tile_scheduler_metadata_buffer.copy_(tile_scheduler_metadata)
351 - self.num_splits_buffer.copy_(num_splits)
621 + # num_splits has size [num_decodes + 1]
622 + num_splits_view = self.num_splits_buffer[: num_decodes + 1]
623 + num_splits_view.copy_(num_splits)
624
353 - fp8_extra_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
625 + kernel_meta = FlashMLASparseMetadata.FP8KernelMetadata(
626 scheduler_metadata=tile_scheduler_metadata_buffer,
355 - num_splits=self.num_splits_buffer,
356 - # cache_lens and block_table are basically unused in sparse case
357 - # but the decode kernel will treat -1 and indices >= cache_lens
358 - # as invalid so we make sure cache_lens is large enough to not
359 - # accidentally mark indices invalid, we will use -1 exclusively
360 - # to mark invalid indices
361 - cache_lens=self.max_model_len_tensor,
362 - dummy_block_table=self.dummy_block_table,
627 + num_splits=num_splits_view,
628 + dummy_block_table=self.dummy_block_table[:num_decodes],
629 + cache_lens=self.max_model_len_tensor[:num_decodes],
630 + )
631 + fp8_metadata.decode = FP8Meta.Decode(
632 + kernel_metadata=kernel_meta,
633 + decode_query_len=decode_query_len,
634 )
635
636 + return fp8_metadata
637 +
638 + def build(
639 + self,
640 + common_prefix_len: int,
641 + common_attn_metadata: CommonAttentionMetadata,
642 + fast_build: bool = False,
643 + ) -> FlashMLASparseMetadata:
644 + cm = common_attn_metadata
645 + num_tokens = cm.num_actual_tokens
646 + starts = np.asarray(cm.query_start_loc_cpu, dtype=np.int32)
647 + seg_lengths = np.diff(starts)
648 + req_id_per_token = np.repeat(
649 + np.arange(seg_lengths.shape[0], dtype=np.int32), seg_lengths
650 + )
651 + # Zero-fill for cudagraphs
652 + self.req_id_per_token_buffer.fill_(0)
653 + self.req_id_per_token_buffer[: req_id_per_token.shape[0]].copy_(
654 + torch.from_numpy(req_id_per_token), non_blocking=True
655 + )
656 + req_id_per_token = self.req_id_per_token_buffer[:num_tokens]
657 +
658 + fp8_extra_metadata: (
659 + FlashMLASparseMetadata.FP8SeperatePrefillDecode
660 + | FlashMLASparseMetadata.FP8KernelMetadata
661 + | None
662 + ) = None
663 + fp8_use_mixed_batch = self.num_heads < MIN_HEADS_FOR_BF16_PREFILL
664 + if self.use_fp8_kv_cache:
665 + if fp8_use_mixed_batch:
666 + fp8_extra_metadata = self._build_fp8_mixed_decode_prefill(cm)
667 + else:
668 + fp8_extra_metadata = self._build_fp8_separate_prefill_decode(cm)
669 +
670 metadata = FlashMLASparseMetadata(
366 - num_reqs=common_attn_metadata.num_reqs,
367 - max_query_len=common_attn_metadata.max_query_len,
368 - max_seq_len=common_attn_metadata.max_seq_len,
369 - num_actual_tokens=common_attn_metadata.num_actual_tokens,
370 - query_start_loc=common_attn_metadata.query_start_loc,
371 - slot_mapping=common_attn_metadata.slot_mapping,
372 - block_table=common_attn_metadata.block_table_tensor,
671 + num_reqs=cm.num_reqs,
672 + max_query_len=cm.max_query_len,
673 + max_seq_len=cm.max_seq_len,
674 + num_actual_tokens=cm.num_actual_tokens,
675 + query_start_loc=cm.query_start_loc,
676 + slot_mapping=cm.slot_mapping,
677 + block_table=cm.block_table_tensor,
678 req_id_per_token=req_id_per_token,
679 block_size=self.kv_cache_spec.block_size,
680 topk_tokens=self.topk_tokens,
681 fp8_extra_metadata=fp8_extra_metadata,
682 + fp8_use_mixed_batch=fp8_use_mixed_batch,
683 )
684 +
685 return metadata
686
687
@@ -414,12 +721,204 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
721 self.topk_indices_buffer = indexer.topk_indices_buffer
722 self.padding = 128 if current_platform.is_device_capability(100) else 64
723
724 + if kv_cache_dtype == "fp8_ds_mla":
725 + # Reserve workspace during initialization
726 + vllm_config = get_current_vllm_config()
727 + assert vllm_config is not None and vllm_config.model_config is not None
728 + prefill_workspace_size = get_prefill_workspace_size(
729 + vllm_config.model_config.max_model_len
730 + )
731 + self.prefill_workspace_shape = (prefill_workspace_size, head_size)
732 + (self.prefill_bf16_workspace,) = (
733 + current_workspace_manager().get_simultaneous(
734 + (self.prefill_workspace_shape, torch.bfloat16)
735 + )
736 + )
737 +
738 def _forward_bf16_kv(
739 self,
740 q: torch.Tensor,
741 kv_c_and_k_pe_cache: torch.Tensor,
742 topk_indices: torch.Tensor,
743 attn_metadata: FlashMLASparseMetadata,
744 + ) -> torch.Tensor:
745 + # Convert per-request indices to global slots (decode) or workspace
746 + # offsets (prefill).
747 + topk_indices = triton_convert_req_index_to_global_index(
748 + attn_metadata.req_id_per_token,
749 + attn_metadata.block_table,
750 + topk_indices,
751 + BLOCK_SIZE=attn_metadata.block_size,
752 + NUM_TOPK_TOKENS=topk_indices.shape[1],
753 + )
754 +
755 + return self._bf16_flash_mla_kernel(q, kv_c_and_k_pe_cache, topk_indices)
756 +
757 + def _forward_fp8_kv_separate_prefill_decode(
758 + self,
759 + q: torch.Tensor,
760 + kv_c_and_k_pe_cache: torch.Tensor,
761 + topk_indices: torch.Tensor,
762 + attn_metadata: FlashMLASparseMetadata,
763 + ) -> torch.Tensor:
764 + fp8_metadata = attn_metadata.fp8_extra_metadata
765 + assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8SeperatePrefillDecode)
766 + num_decodes = fp8_metadata.num_decodes
767 +
768 + prefill_request_ids = None
769 + prefill_workspace_starts = None
770 + has_prefill_workspace = False
771 + if fp8_metadata.prefill is not None:
772 + prefill_request_ids = fp8_metadata.prefill.request_ids
773 + prefill_workspace_starts = fp8_metadata.prefill.workspace_starts
774 + has_prefill_workspace = True
775 +
776 + # Convert per-request indices to global slots (decode) or workspace
777 + # offsets (prefill).
778 + # For FP8 cache: prefill uses workspace mapping (upconverted to BF16)
779 + # For BF16 cache: always use global cache slots (no workspace)
780 + # prefill_workspace_starts has been adjusted in-place per chunk so
781 + # prefill indices automatically come out chunk-local
782 + topk_indices = triton_convert_req_index_to_global_index(
783 + attn_metadata.req_id_per_token,
784 + attn_metadata.block_table,
785 + topk_indices,
786 + BLOCK_SIZE=attn_metadata.block_size,
787 + NUM_TOPK_TOKENS=topk_indices.shape[1],
788 + HAS_PREFILL_WORKSPACE=has_prefill_workspace,
789 + prefill_workspace_request_ids=prefill_request_ids,
790 + prefill_workspace_starts=prefill_workspace_starts,
791 + )
792 +
793 + fp8_metadata = attn_metadata.fp8_extra_metadata
794 + assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8SeperatePrefillDecode)
795 +
796 + def _fp8_decode(q: torch.Tensor, topk_indices: torch.Tensor) -> torch.Tensor:
797 + # Reshape q: (num_decode_tokens, num_heads, head_dim)
798 + # -> (num_decodes, seq_len, num_heads, head_dim)
799 + q = reshape_query_for_spec_decode(q, num_decodes)
800 + seq_len = q.shape[1]
801 + # Reshape topk_indices: (num_decode_tokens, topk)
802 + # -> (num_decodes, seq_len, topk)
803 + topk_indices = topk_indices.view(num_decodes, seq_len, -1)
804 + assert fp8_metadata.decode is not None
805 + attn_out, _ = self._fp8_flash_mla_kernel(
806 + q=q,
807 + kv_c_and_k_pe_cache=kv_c_and_k_pe_cache,
808 + topk_indices=topk_indices,
809 + kernel_metadata=fp8_metadata.decode.kernel_metadata,
810 + )
811 + # Reshape output: (num_decodes, seq_len, num_heads, head_dim_v)
812 + # -> (num_decode_tokens, num_heads, head_dim_v)
813 + return reshape_attn_output_for_spec_decode(attn_out)
814 +
815 + num_decode_tokens = fp8_metadata.num_decode_tokens
816 + num_prefill_tokens = fp8_metadata.num_prefill_tokens
817 +
818 + # Pure decode: direct call without allocation
819 + if num_decode_tokens > 0 and num_prefill_tokens == 0:
820 + assert fp8_metadata.decode is not None
821 + attn_out = _fp8_decode(q, topk_indices)
822 + else:
823 + # Mixed or pure prefill: allocate output tensor
824 + attn_out = q.new_empty(
825 + (attn_metadata.num_actual_tokens, self.num_heads, self.kv_lora_rank),
826 + dtype=q.dtype,
827 + device=q.device,
828 + )
829 +
830 + if num_decode_tokens > 0:
831 + attn_out[:num_decode_tokens] = _fp8_decode(
832 + q[:num_decode_tokens], topk_indices[:num_decode_tokens]
833 + )
834 +
835 + assert fp8_metadata.prefill is not None
836 + for chunk in fp8_metadata.prefill.chunks:
837 + chunk_workspace = self.prefill_bf16_workspace[: chunk.chunk_tot_seqlen]
838 + ops.cp_gather_and_upconvert_fp8_kv_cache(
839 + kv_c_and_k_pe_cache,
840 + chunk_workspace,
841 + chunk.block_table,
842 + chunk.seq_lens,
843 + chunk.workspace_starts,
844 + len(chunk.block_table),
845 + )
846 +
847 + chunk_q = q[chunk.tokens_slice]
848 + chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice]
849 +
850 + attn_out[chunk.tokens_slice] = self._bf16_flash_mla_kernel(
851 + chunk_q,
852 + chunk_workspace,
853 + chunk_topk_indices_workspace,
854 + )
855 +
856 + return attn_out
857 +
858 + def _forward_fp8_kv_mixed_batch(
859 + self,
860 + q: torch.Tensor,
861 + kv_c_and_k_pe_cache: torch.Tensor,
862 + topk_indices: torch.Tensor,
863 + attn_metadata: FlashMLASparseMetadata,
864 + ) -> torch.Tensor:
865 + """Mixed batch FP8 forward path that treats all tokens as one batch.
866 +
867 + This is equivalent to main branch's approach and avoids the BF16
868 + prefill kernel which has head padding overhead when num_heads is small.
869 + Used when use_mixed_batch is True.
870 + """
871 + # Convert per-request indices to global slots (decode) or workspace
872 + # offsets (prefill).
873 + topk_indices = triton_convert_req_index_to_global_index(
874 + attn_metadata.req_id_per_token,
875 + attn_metadata.block_table,
876 + topk_indices,
877 + BLOCK_SIZE=attn_metadata.block_size,
878 + NUM_TOPK_TOKENS=topk_indices.shape[1],
879 + )
880 +
881 + assert attn_metadata.fp8_extra_metadata is not None
882 + assert isinstance(
883 + attn_metadata.fp8_extra_metadata, FlashMLASparseMetadata.FP8KernelMetadata
884 + )
885 + fp8_metadata = attn_metadata.fp8_extra_metadata
886 +
887 + _attn_out, _ = self._fp8_flash_mla_kernel(
888 + q=q.unsqueeze(0), # unsqueeze to add batch_dim: (T, H, D) -> (1, T, H, D)
889 + kv_c_and_k_pe_cache=kv_c_and_k_pe_cache,
890 + topk_indices=topk_indices.unsqueeze(0), # (T, topk) -> (1, T, topk)
891 + kernel_metadata=fp8_metadata,
892 + )
893 +
894 + # Output is (1, T, H, D_v), squeeze back to (T, H, D_v)
895 + return _attn_out.squeeze(0)
896 +
897 + def _fp8_flash_mla_kernel(
898 + self,
899 + q: torch.Tensor,
900 + kv_c_and_k_pe_cache: torch.Tensor,
901 + topk_indices: torch.Tensor,
902 + kernel_metadata: FlashMLASparseMetadata.FP8KernelMetadata,
903 + ) -> torch.Tensor:
904 + return flash_mla_with_kvcache(
905 + q=q,
906 + k_cache=kv_c_and_k_pe_cache.view(torch.uint8).unsqueeze(-2),
907 + block_table=kernel_metadata.dummy_block_table,
908 + head_dim_v=512,
909 + cache_seqlens=kernel_metadata.cache_lens,
910 + tile_scheduler_metadata=kernel_metadata.scheduler_metadata,
911 + num_splits=kernel_metadata.num_splits,
912 + is_fp8_kvcache=True,
913 + indices=topk_indices,
914 + softmax_scale=self.softmax_scale,
915 + )
916 +
917 + def _bf16_flash_mla_kernel(
918 + self,
919 + q: torch.Tensor,
920 + kv_c_and_k_pe_cache: torch.Tensor,
921 + topk_indices: torch.Tensor,
922 ) -> torch.Tensor:
923 num_tokens = q.shape[0]
924 kv_c_and_k_pe_cache = kv_c_and_k_pe_cache.view(
@@ -445,31 +944,6 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
944 output = output[:, : self.num_heads, :]
945 return output
946
448 - def _forward_fp8_kv(
449 - self,
450 - q: torch.Tensor,
451 - kv_c_and_k_pe_cache: torch.Tensor,
452 - topk_indices: torch.Tensor,
453 - attn_metadata: FlashMLASparseMetadata,
454 - ) -> torch.Tensor:
455 - assert attn_metadata.fp8_extra_metadata is not None
456 - extra_metadata = attn_metadata.fp8_extra_metadata
457 -
458 - _attn_out, _ = flash_mla_with_kvcache(
459 - q=q.unsqueeze(0), # unsqueeze to add batch_dim
460 - k_cache=kv_c_and_k_pe_cache.view(torch.uint8).unsqueeze(-2),
461 - block_table=extra_metadata.dummy_block_table,
462 - head_dim_v=512,
463 - cache_seqlens=extra_metadata.cache_lens,
464 - tile_scheduler_metadata=extra_metadata.scheduler_metadata,
465 - num_splits=extra_metadata.num_splits,
466 - is_fp8_kvcache=True,
467 - indices=topk_indices.unsqueeze(0), # unsqueeze to add batch_dim
468 - softmax_scale=self.softmax_scale,
469 - )
470 -
471 - return _attn_out
472 -
947 def forward(
948 self,
949 layer: AttentionLayer,
@@ -477,7 +951,7 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
951 k_c_normed: torch.Tensor, # key in unified attn
952 k_pe: torch.Tensor, # value in unified attn
953 kv_cache: torch.Tensor,
480 - attn_metadata: FlashMLASparseMetadata,
954 + attn_metadata: FlashMLASparseMetadata | None,
955 output: torch.Tensor | None = None,
956 output_scale: torch.Tensor | None = None,
957 output_block_scale: torch.Tensor | None = None,
@@ -493,6 +967,7 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
967 )
968
969 if attn_metadata is None:
970 + # Dummy run - no need to allocate buffers
971 # The zero fill is required when used with DP + EP
972 # to ensure all ranks within a DP group compute the
973 # same expert outputs.
@@ -505,6 +980,7 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
980 q = q[:num_actual_toks, ...]
981 k_c_normed = k_c_normed[:num_actual_toks, ...]
982 k_pe = k_pe[:num_actual_toks, ...]
983 + topk_indices = self.topk_indices_buffer[:num_actual_toks]
984
985 q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
986 # Convert from (B, N, P) to (N, B, P)
@@ -514,16 +990,7 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
990 # Convert from (N, B, L) to (B, N, L)
991 ql_nope = ql_nope.transpose(0, 1)
992
517 - topk_indices = self.topk_indices_buffer[:num_actual_toks]
518 -
519 - # TODO: handle index / kv_cache correctly
520 - topk_indices_global = triton_convert_req_index_to_global_index(
521 - attn_metadata.req_id_per_token,
522 - attn_metadata.block_table,
523 - topk_indices,
524 - BLOCK_SIZE=attn_metadata.block_size,
525 - NUM_TOPK_TOKENS=attn_metadata.topk_tokens,
526 - )
993 + use_fp8_cache = self.kv_cache_dtype == "fp8_ds_mla"
994
995 q = torch.cat([ql_nope, q_pe], dim=-1)
996
@@ -538,13 +1005,15 @@ class FlashMLASparseImpl(MLACommonBaseImpl[FlashMLASparseMetadata]):
1005 scale=layer._k_scale,
1006 )
1007
541 - if self.kv_cache_dtype != "fp8_ds_mla":
542 - attn_out = self._forward_bf16_kv(
543 - q, kv_cache, topk_indices_global, attn_metadata
1008 + if not use_fp8_cache:
1009 + attn_out = self._forward_bf16_kv(q, kv_cache, topk_indices, attn_metadata)
1010 + elif attn_metadata.fp8_use_mixed_batch:
1011 + attn_out = self._forward_fp8_kv_mixed_batch(
1012 + q, kv_cache, topk_indices, attn_metadata
1013 )
1014 else:
546 - attn_out = self._forward_fp8_kv(
547 - q, kv_cache, topk_indices_global, attn_metadata
1015 + attn_out = self._forward_fp8_kv_separate_prefill_decode(
1016 + q, kv_cache, topk_indices, attn_metadata
1017 )
1018
1019 self._v_up_proj(attn_out, out=output[:num_actual_toks])
vllm/v1/attention/backends/mla/indexer.py
+12 -36
@@ -18,6 +18,7 @@ from vllm.v1.attention.backends.utils import (
18 AttentionMetadataBuilder,
19 CommonAttentionMetadata,
20 split_decodes_and_prefills,
21 + split_prefill_chunks,
22 )
23
24 logger = init_logger(__name__)
@@ -176,40 +177,15 @@ def kv_spans_from_batches(
177
178 def get_max_prefill_buffer_size(vllm_config: VllmConfig):
179 max_model_len = vllm_config.model_config.max_model_len
179 - # NOTE(Chen): 2 is a magic number for controlling the prefill buffer size.
180 - # May be tuned later.
181 - return max_model_len * 2
182 -
183 -
184 -def split_prefill_chunks(
185 - seq_lens_cpu: torch.Tensor, max_prefill_buffer_size: int, reqs_start: int
186 -) -> list[tuple[int, int]]:
187 - """
188 - Split the prefill chunks into a list of tuples of (reqs_start, reqs_end)
189 - such that the total sequence length of each chunk is less than the
190 - maximum prefill buffer size.
191 -
192 - Args:
193 - seq_lens_cpu: The sequence lengths of the prefill requests.
194 - max_prefill_buffer_size: The maximum prefill buffer size.
195 - reqs_start: The start index of the prefill requests.
196 -
197 - Returns:
198 - A list of tuples of (reqs_start, reqs_end).
199 - """
200 - chunk_seq_ids = []
201 - total_seq_lens = 0
202 - for i in range(reqs_start, len(seq_lens_cpu)):
203 - cur_seq_len = seq_lens_cpu[i].item()
204 - assert cur_seq_len <= max_prefill_buffer_size
205 - total_seq_lens += cur_seq_len
206 - if total_seq_lens > max_prefill_buffer_size:
207 - chunk_seq_ids.append((reqs_start, i))
208 - reqs_start = i
209 - total_seq_lens = cur_seq_len
210 - if total_seq_lens > 0:
211 - chunk_seq_ids.append((reqs_start, len(seq_lens_cpu)))
212 - return chunk_seq_ids
180 + # NOTE(Chen): 40 is a magic number for controlling the prefill buffer size.
181 + # Each entry is 128 fp8 bytes and 4 scale bytes for a total of 132 bytes.
182 + # The flashmla_sparse backend uses a workspace size of 5 * max_model_len.
183 + # The memory usage of the workspace there is 576 * 2 bytes; so we size this as
184 + # (576 * 2 // 132) * 5 = 40 to maximize this workspace size while still fitting
185 + # within the flashmla_sparse workspace.
186 + # For DeepSeek-V3.2, the max_model_len is 163840.
187 + # 40 * 163840 * 132 = 865075200 bytes = 825 MB
188 + return max_model_len * 40
189
190
191 class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
@@ -302,9 +278,9 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
278 prefill_metadata = None
279 if num_prefills > 0:
280 chunk_seq_ids = split_prefill_chunks(
305 - common_attn_metadata.seq_lens_cpu,
281 + common_attn_metadata.seq_lens_cpu[num_decodes:],
282 self.max_prefill_buffer_size,
307 - num_decodes,
283 + request_offset=num_decodes,
284 )
285 chunks = [
286 self.build_one_prefill_chunk(
vllm/v1/attention/backends/utils.py
+27
@@ -937,6 +937,33 @@ def split_decodes_and_prefills(
937 return (num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens)
938
939
940 +def split_prefill_chunks(
941 + seq_lens_cpu: torch.Tensor, workspace_size: int, request_offset: int = 0
942 +) -> list[tuple[int, int]]:
943 + """
944 + Split the prefill requests into chunks such that the total sequence length
945 + of each chunk is less than or equal to the workspace size.
946 +
947 + Args:
948 + seq_lens_cpu: The sequence lengths of the prefill requests on CPU.
949 + workspace_size: The maximum workspace size (in tokens) per chunk.
950 + request_offset: The offset to add to the request indices.
951 + Returns:
952 + A list of tuples of (reqs_start, reqs_end) representing chunk boundaries.
953 + """
954 + chunk_bounds = []
955 + i, n = 0, len(seq_lens_cpu)
956 + assert torch.all(seq_lens_cpu <= workspace_size).item()
957 +
958 + while i < n:
959 + start, chunk_total = i, 0
960 + while i < n and (chunk_total + (s := seq_lens_cpu[i].item())) <= workspace_size:
961 + chunk_total += s
962 + i += 1
963 + chunk_bounds.append((start + request_offset, i + request_offset))
964 + return chunk_bounds
965 +
966 +
967 def reorder_batch_to_split_decodes_and_prefills(
968 input_batch: "InputBatch",
969 scheduler_output: "SchedulerOutput",
vllm/v1/worker/gpu_model_runner.py
+6
@@ -162,6 +162,7 @@ from vllm.v1.worker.ubatch_utils import (
162 maybe_create_ubatch_slices,
163 )
164 from vllm.v1.worker.utils import is_residual_scattered_for_sp
165 +from vllm.v1.worker.workspace import lock_workspace
166
167 from .utils import (
168 AttentionGroup,
@@ -297,6 +298,7 @@ class GPUModelRunner(
298 self.device = device
299 self.pin_memory = is_pin_memory_available()
300 self.dtype = self.model_config.dtype
301 +
302 self.kv_cache_dtype = kv_cache_dtype_str_to_dtype(
303 cache_config.cache_dtype, self.model_config
304 )
@@ -4597,6 +4599,10 @@ class GPUModelRunner(
4599 # after here.
4600 set_cudagraph_capturing_enabled(False)
4601
4602 + # Lock workspace to prevent resizing during execution.
4603 + # Max workspace sizes should have been captured during warmup/profiling.
4604 + lock_workspace()
4605 +
4606 end_time = time.perf_counter()
4607 elapsed_time = end_time - start_time
4608 cuda_graph_size = start_free_gpu_memory - end_free_gpu_memory
vllm/v1/worker/gpu_worker.py
+5
@@ -54,6 +54,7 @@ from vllm.v1.outputs import (
54 from vllm.v1.utils import report_usage_stats
55 from vllm.v1.worker.utils import is_residual_scattered_for_sp
56 from vllm.v1.worker.worker_base import WorkerBase
57 +from vllm.v1.worker.workspace import init_workspace_manager
58
59 logger = init_logger(__name__)
60
@@ -255,6 +256,10 @@ class Worker(WorkerBase):
256 else:
257 raise RuntimeError(f"Not support device type: {self.device_config.device}")
258
259 + # Initialize workspace manager
260 + num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
261 + init_workspace_manager(self.device, num_ubatches)
262 +
263 # Construct the model runner
264 if self.use_v2_model_runner:
265 from vllm.v1.worker.gpu.model_runner import (
vllm/v1/worker/workspace.py new
+245
@@ -0,0 +1,245 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import inspect
5 +import os
6 +from itertools import accumulate
7 +from math import prod
8 +from typing import Optional
9 +
10 +import torch
11 +
12 +import vllm.envs as envs
13 +from vllm.logger import init_logger
14 +from vllm.utils.math_utils import round_up
15 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
16 +
17 +logger = init_logger(__name__)
18 +
19 +
20 +def _compute_bytes(shape: tuple[int, ...], dtype: torch.dtype) -> int:
21 + return prod(shape) * dtype.itemsize
22 +
23 +
24 +# Constants
25 +_MB = 1024**2
26 +_GiB = 1024**3
27 +
28 +# Global workspace manager instance
29 +_manager: Optional["WorkspaceManager"] = None
30 +
31 +
32 +class WorkspaceManager:
33 + """Manager for workspace allocation.
34 +
35 + Manages workspace buffers for DBO (Dual Batch Overlap) execution.
36 + Can be locked to prevent further growth during execution.
37 + """
38 +
39 + def __init__(self, device: torch.device, num_ubatches: int | None = None):
40 + self._device = device
41 + # Cache num ubatches at init based on configuration (default to 1)
42 + self._num_ubatches = num_ubatches if num_ubatches is not None else 1
43 + self._current_workspaces: list[torch.Tensor | None] = [None, None]
44 + self._locked: bool = False
45 +
46 + @staticmethod
47 + def _workspace_size_bytes(workspace: torch.Tensor | None) -> int:
48 + """Get size of workspace in bytes."""
49 + if workspace is None:
50 + return 0
51 + return workspace.numel() * workspace.element_size()
52 +
53 + def lock(self) -> None:
54 + """Lock the workspace to prevent further growth.
55 +
56 + After locking, any attempt to allocate a larger workspace will raise
57 + an assertion error. This ensures workspace size is fixed during execution.
58 + """
59 + self._locked = True
60 + if envs.VLLM_DEBUG_WORKSPACE:
61 + logger.info(
62 + "[WORKSPACE DEBUG] Workspace locked. Current sizes: %s",
63 + [
64 + self._workspace_size_bytes(ws) / _MB
65 + for ws in self._current_workspaces
66 + if ws is not None
67 + ],
68 + )
69 +
70 + def is_locked(self) -> bool:
71 + """Check if workspace is locked."""
72 + return self._locked
73 +
74 + def get_simultaneous(
75 + self, *shapes_and_dtypes: tuple[tuple[int, ...], torch.dtype]
76 + ) -> list[torch.Tensor]:
77 + """Get multiple workspace tensors simultaneously from a single allocation.
78 +
79 + Args:
80 + *shapes_and_dtypes: One or more (shape, dtype) tuples.
81 +
82 + Returns:
83 + List of tensor views into the workspace buffer, one per shape/dtype pair.
84 + """
85 + actual_bytes = [_compute_bytes(s, d) for s, d in shapes_and_dtypes]
86 + aligned_bytes = [round_up(actual, 256) for actual in actual_bytes]
87 + total_bytes = sum(aligned_bytes)
88 +
89 + # Calculate cumulative offsets using itertools.accumulate
90 + offsets = list(accumulate([0] + aligned_bytes[:-1]))
91 +
92 + current_workspace = self._ensure_workspace_size(total_bytes)
93 +
94 + return [
95 + current_workspace[offsets[i] : offsets[i] + actual_bytes[i]]
96 + .view(shapes_and_dtypes[i][1])
97 + .reshape(shapes_and_dtypes[i][0])
98 + for i in range(len(shapes_and_dtypes))
99 + ]
100 +
101 + def _ensure_workspace_size(self, required_bytes: int) -> torch.Tensor:
102 + """Ensure workspace is allocated and large enough, return current workspace.
103 +
104 + Args:
105 + required_bytes: The number of bytes required.
106 +
107 + Returns:
108 + The current workspace tensor.
109 + """
110 + ubatch_id = dbo_current_ubatch_id()
111 + current_workspace = self._current_workspaces[ubatch_id]
112 + current_size = self._workspace_size_bytes(current_workspace)
113 +
114 + if current_size < required_bytes:
115 +
116 + def get_caller_info() -> str:
117 + """Find first frame outside WorkspaceManager."""
118 + curr_frame = inspect.currentframe()
119 + if curr_frame is None:
120 + return "unknown"
121 + # Walk up the stack skipping WorkspaceManager frames
122 + curr_frame = curr_frame.f_back
123 + while curr_frame is not None:
124 + # TODO: This only catches instance methods (self), missing
125 + # classmethods and staticmethods. Once Python 3.11+ is the
126 + # minimum supported version, use co_qualname instead:
127 + # qualname = curr_frame.f_code.co_qualname
128 + # if qualname.startswith("WorkspaceManager."):
129 + if isinstance(curr_frame.f_locals.get("self"), WorkspaceManager):
130 + curr_frame = curr_frame.f_back
131 + continue
132 + filename = os.path.basename(curr_frame.f_code.co_filename)
133 + return (
134 + f"{filename}:{curr_frame.f_lineno}:{curr_frame.f_code.co_name}"
135 + )
136 + return "unknown"
137 +
138 + if self._locked:
139 + raise AssertionError(
140 + f"Workspace is locked but allocation from '{get_caller_info()}' "
141 + f"requires {required_bytes / _MB:.2f} MB, current size is "
142 + f"{current_size / _MB:.2f} MB. "
143 + "Workspace growth is not allowed after locking."
144 + )
145 +
146 + for ubatch_id in range(self._num_ubatches):
147 + current_workspace = self._current_workspaces[ubatch_id]
148 + if current_workspace is None:
149 + self._current_workspaces[ubatch_id] = torch.empty(
150 + (required_bytes,), dtype=torch.uint8, device=self._device
151 + )
152 + elif self._workspace_size_bytes(current_workspace) < required_bytes:
153 + current_workspace.resize_(required_bytes)
154 +
155 + if envs.VLLM_DEBUG_WORKSPACE:
156 + logger.info(
157 + "[WORKSPACE DEBUG] Resized workspace from '%s': %.2f MB -> "
158 + "%.2f MB (%d ubatches, total memory %.2f MB)",
159 + get_caller_info(),
160 + current_size / _MB,
161 + required_bytes / _MB,
162 + self._num_ubatches,
163 + required_bytes * self._num_ubatches / _MB,
164 + )
165 +
166 + current_workspace = self._current_workspaces[dbo_current_ubatch_id()]
167 +
168 + return current_workspace
169 +
170 +
171 +def is_workspace_manager_initialized() -> bool:
172 + """Check if workspace manager has been initialized.
173 +
174 + Returns:
175 + True if workspace manager is initialized, False otherwise.
176 + """
177 + return _manager is not None
178 +
179 +
180 +def current_workspace_manager() -> "WorkspaceManager":
181 + """Get the current workspace manager instance.
182 +
183 + Raises:
184 + AssertionError: If workspace manager has not been initialized.
185 + """
186 + assert _manager is not None, (
187 + "WorkspaceManager not initialized. Call init_workspace_manager() "
188 + "with a device before using workspace functions."
189 + )
190 + return _manager
191 +
192 +
193 +def init_workspace_manager(
194 + device: torch.device, num_ubatches: int | None = None
195 +) -> None:
196 + """Initialize the workspace manager with a device.
197 +
198 + Must be called before using any workspace functions. Typically called
199 + from GPUModelRunner.__init__.
200 +
201 + Args:
202 + device: The device to allocate workspace on.
203 + num_ubatches: Number of micro-batches. Defaults to 1.
204 + """
205 + global _manager
206 + if _manager is not None:
207 + logger.warning(
208 + "WorkspaceManager already initialized on device %s, "
209 + "reinitializing on device %s",
210 + _manager._device,
211 + device,
212 + )
213 + _manager = WorkspaceManager(device, num_ubatches)
214 +
215 +
216 +def lock_workspace() -> None:
217 + """Lock the workspace to prevent further growth.
218 +
219 + After calling this function, any attempt to allocate a workspace larger
220 + than the current size will raise an AssertionError. This ensures that
221 + workspace size is fixed during execution and prevents unexpected memory
222 + allocations in the hot path.
223 +
224 + Example:
225 + # During initialization
226 + init_workspace_manager(device)
227 + reserve_workspace(shape1, dtype1)
228 + reserve_workspace(shape2, dtype2)
229 +
230 + # Lock after warmup/profiling
231 + lock_workspace()
232 +
233 + # Now all get_workspace calls must fit in pre-allocated size
234 + """
235 + current_workspace_manager().lock()
236 +
237 +
238 +def reset_workspace_manager() -> None:
239 + """Reset the workspace manager to uninitialized state.
240 +
241 + This is primarily intended for testing purposes to allow tests
242 + to reinitialize the workspace manager cleanly.
243 + """
244 + global _manager
245 + _manager = None