[Core][Kernel] Share persistent workspaces for Marlin and Humming (#57421)

Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: ErinYin <liang.yin@daocloud.io>

Misha Goin committed Sep 20, 2026 at 05:28 UTC e5fce7b56b07cdf992febd0862bfd599d6dca3fd
21 files changed +598 -211
tests/kernels/moe/test_moe.py
+17 -3
@@ -1384,24 +1384,33 @@ def test_humming_global_valid_shape_m(
1384 assert result == expected
1385
1386
1387 -def test_humming_permute_scratch_is_keyed_by_runtime_topk(
1387 +def test_humming_permute_scratch_is_shared_by_config(
1388 monkeypatch: pytest.MonkeyPatch,
1389 ):
1390 from types import SimpleNamespace
1391 from unittest.mock import Mock
1392
1393 import vllm.model_executor.layers.fused_moe.experts.fused_humming_moe as humming
1394 + import vllm.model_executor.layers.fused_moe.moe_permute_unpermute as permute
1395 + import vllm.v1.worker.workspace as workspace
1396 +
1397 + manager = workspace.WorkspaceManager(torch.device("cpu"))
1398 + monkeypatch.setattr(workspace, "_manager", manager)
1399
1400 scratch_topk6 = Mock()
1401 scratch_topk1 = Mock()
1402 scratch_type = Mock(side_effect=[scratch_topk6, scratch_topk1])
1403 monkeypatch.setattr(humming, "moe_permute_unpermute_supported", lambda: True)
1399 - monkeypatch.setattr(humming, "MoEPermuteScratch", scratch_type)
1404 + monkeypatch.setattr(permute, "MoEPermuteScratch", scratch_type)
1405 moe_config = make_dummy_moe_config(max_num_tokens=512, experts_per_token=6)
1406 moe_config.moe_parallel_config.dp_size = 2
1402 - experts = SimpleNamespace(_permute_scratch={}, moe_config=moe_config)
1407 + experts = SimpleNamespace(moe_config=moe_config)
1408 + other_layer = SimpleNamespace(moe_config=moe_config)
1409
1410 assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
1411 + assert (
1412 + humming.HummingExpertsBase._get_permute_scratch(other_layer, 6) is scratch_topk6
1413 + )
1414 assert humming.HummingExpertsBase._get_permute_scratch(experts, 1) is scratch_topk1
1415 assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
1416
@@ -1412,6 +1421,11 @@ def test_humming_permute_scratch_is_keyed_by_runtime_topk(
1421 assert second_call.kwargs["max_num_tokens"] == 6144
1422 assert second_call.kwargs["topk"] == 1
1423
1424 + manager.lock()
1425 + assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
1426 + with pytest.raises(AssertionError, match="was not allocated during warmup"):
1427 + humming.HummingExpertsBase._get_permute_scratch(experts, 2)
1428 +
1429
1430 def test_humming_delegates_to_instance_activation():
1431 from types import SimpleNamespace
tests/kernels/moe/test_moe_permute_unpermute.py
+163 -12
@@ -15,12 +15,14 @@ from vllm.model_executor.layers.fused_moe.expert_map_manager import (
15 )
16 from vllm.model_executor.layers.fused_moe.moe_permute_unpermute import (
17 MoEPermuteScratch,
18 + get_moe_permute_scratch,
19 moe_permute,
20 moe_permute_unpermute_supported,
21 moe_unpermute,
22 )
23 from vllm.platforms import current_platform
24 from vllm.utils.torch_utils import set_random_seed
25 +from vllm.v1.worker.workspace import current_workspace_manager
26
27 NUM_EXPERTS = [16, 64, 256]
28 TOP_KS = [2, 6, 8]
@@ -120,6 +122,7 @@ def torch_unpermute(
122 @pytest.mark.parametrize("topk", TOP_KS)
123 @pytest.mark.parametrize("dtype", [torch.bfloat16])
124 @pytest.mark.parametrize("ep_size", EP_SIZE)
125 +@pytest.mark.parametrize("use_scratch", [False, True])
126 def test_moe_permute_unpermute(
127 n_token: int,
128 n_hidden: int,
@@ -127,6 +130,8 @@ def test_moe_permute_unpermute(
130 n_expert: int,
131 ep_size: int,
132 dtype: torch.dtype,
133 + use_scratch: bool,
134 + workspace_init,
135 ):
136 if not moe_permute_unpermute_supported():
137 pytest.skip("moe_permute_unpermute is not supported on this platform.")
@@ -160,6 +165,18 @@ def test_moe_permute_unpermute(
165 expert_map=expert_map,
166 )
167
168 + scratch = None
169 + if use_scratch:
170 + scratch = get_moe_permute_scratch(
171 + max_num_tokens=n_token,
172 + topk=topk,
173 + num_experts=n_expert,
174 + num_local_experts=n_local_expert,
175 + device=hidden_states.device,
176 + hidden_size=n_hidden,
177 + hidden_dtype=dtype,
178 + )
179 +
180 (
181 permuted_hidden_states,
182 _,
@@ -173,6 +190,7 @@ def test_moe_permute_unpermute(
190 n_expert=n_expert,
191 n_local_expert=n_local_expert,
192 expert_map=expert_map,
193 + scratch=scratch,
194 )
195
196 # check expert_first_token_offset
@@ -212,20 +230,22 @@ def test_moe_permute_unpermute(
230 torch.testing.assert_close(result4, gold4, atol=2e-2, rtol=0)
231
232
215 -@pytest.mark.parametrize("dtype", [torch.bfloat16])
216 -def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
233 +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
234 +@pytest.mark.parametrize("n_token", [1, 33, 128])
235 +@pytest.mark.parametrize("topk", [1, 6, 8])
236 +def test_moe_permute_reuses_scratch_buffers(
237 + dtype: torch.dtype, n_token: int, topk: int, workspace_init
238 +):
239 if not moe_permute_unpermute_supported():
240 pytest.skip("moe_permute_unpermute is not supported on this platform.")
241
220 - n_token = 64
242 n_hidden = 2048
243 n_expert = 16
223 - topk = 4
244 hidden_states = torch.randn((n_token, n_hidden), device="cuda").to(dtype)
245 gating_output = torch.randn((n_token, n_expert), device="cuda").to(dtype)
246 _, topk_ids, _ = fused_topk(hidden_states, gating_output, topk, False)
247
228 - scratch = MoEPermuteScratch(
248 + scratch_config = dict(
249 max_num_tokens=n_token,
250 topk=topk,
251 num_experts=n_expert,
@@ -234,6 +254,7 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
254 hidden_size=n_hidden,
255 hidden_dtype=hidden_states.dtype,
256 )
257 + scratch = get_moe_permute_scratch(**scratch_config)
258
259 first = moe_permute(
260 hidden_states=hidden_states,
@@ -242,13 +263,26 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
263 n_expert=n_expert,
264 scratch=scratch,
265 )
245 - second = moe_permute(
246 - hidden_states=hidden_states,
247 - a1q_scale=None,
248 - topk_ids=topk_ids,
249 - n_expert=n_expert,
250 - scratch=scratch,
251 - )
266 + current_workspace_manager().lock()
267 + assert get_moe_permute_scratch(**scratch_config) is scratch
268 + graph = torch.cuda.CUDAGraph()
269 + with torch.cuda.graph(graph):
270 + second = moe_permute(
271 + hidden_states=hidden_states,
272 + a1q_scale=None,
273 + topk_ids=topk_ids,
274 + n_expert=n_expert,
275 + scratch=get_moe_permute_scratch(**scratch_config),
276 + )
277 +
278 + for _ in range(2):
279 + hidden_states.add_(1)
280 + topk_ids.copy_(topk_ids.roll(1, 0))
281 + graph.replay()
282 + expected = moe_permute(hidden_states, None, topk_ids, n_expert)
283 + for actual, reference in zip(second, expected):
284 + if actual is not None:
285 + torch.testing.assert_close(actual, reference)
286
287 (
288 permuted_hidden_states_1,
@@ -288,6 +322,123 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
322 )
323
324
325 +def test_moe_permute_scratch_reused_across_graph_sizes(workspace_init) -> None:
326 + """Switching graph sizes must not expose metadata left by a larger batch."""
327 + if not moe_permute_unpermute_supported():
328 + pytest.skip("moe_permute_unpermute is not supported on this platform.")
329 +
330 + device = torch.device("cuda:0")
331 + scratch = get_moe_permute_scratch(
332 + max_num_tokens=128,
333 + topk=6,
334 + num_experts=16,
335 + num_local_experts=16,
336 + device=device,
337 + hidden_size=2048,
338 + hidden_dtype=torch.bfloat16,
339 + )
340 + current_workspace_manager().lock()
341 + runs = []
342 + for n_token in (128, 1, 33):
343 + hidden = torch.randn((n_token, 2048), dtype=torch.bfloat16, device=device)
344 + topk_ids = torch.randint(16, (n_token, 6), device=device)
345 + moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
346 + graph = torch.cuda.CUDAGraph()
347 + with torch.cuda.graph(graph):
348 + output = moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
349 + runs.append((graph, hidden, topk_ids, output))
350 +
351 + for graph, hidden, topk_ids, output in runs + runs[::-1]:
352 + hidden.add_(1)
353 + topk_ids.copy_((topk_ids + 1) % 16)
354 + graph.replay()
355 + expected = moe_permute(hidden, None, topk_ids, 16)
356 + for actual, reference in zip(output, expected):
357 + if actual is not None:
358 + torch.testing.assert_close(actual, reference)
359 +
360 +
361 +def test_moe_permute_scratch_isolated_across_execution_slots(monkeypatch) -> None:
362 + """Concurrent graph replays in different ubatches/lanes cannot share scratch."""
363 + if not moe_permute_unpermute_supported():
364 + pytest.skip("moe_permute_unpermute is not supported on this platform.")
365 +
366 + from vllm.v1.worker import workspace
367 +
368 + device = torch.device("cuda:0")
369 + manager = workspace.WorkspaceManager(device, num_ubatches=2, num_lanes=2)
370 + monkeypatch.setattr(workspace, "_manager", manager)
371 + ubatch = 0
372 + monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: ubatch)
373 + scratch_config = dict(
374 + max_num_tokens=64,
375 + topk=6,
376 + num_experts=16,
377 + num_local_experts=16,
378 + device=device,
379 + hidden_size=2048,
380 + hidden_dtype=torch.bfloat16,
381 + )
382 + runs = []
383 + for i, n_token in enumerate((1, 7, 33, 64)):
384 + ubatch = i // 2
385 + stream = torch.cuda.Stream()
386 + stream.wait_stream(torch.cuda.current_stream())
387 + with workspace.use_workspace_lane(i % 2), torch.cuda.stream(stream):
388 + hidden = torch.randn((n_token, 2048), dtype=torch.bfloat16, device=device)
389 + topk_ids = torch.randint(16, (n_token, 6), device=device)
390 + scratch = get_moe_permute_scratch(**scratch_config)
391 + moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
392 + graph = torch.cuda.CUDAGraph()
393 + with torch.cuda.graph(graph, stream=stream):
394 + output = moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
395 + runs.append((stream, graph, hidden, topk_ids, output, scratch))
396 + torch.cuda.current_stream().wait_stream(stream)
397 +
398 + assert len({run[-1].permuted_hidden_states.data_ptr() for run in runs}) == 4
399 + manager.lock()
400 + for _ in range(3):
401 + for i, (stream, graph, hidden, topk_ids, _, scratch) in enumerate(runs):
402 + ubatch = i // 2
403 + with workspace.use_workspace_lane(i % 2), torch.cuda.stream(stream):
404 + assert get_moe_permute_scratch(**scratch_config) is scratch
405 + hidden.add_(1)
406 + topk_ids.copy_((topk_ids + 1) % 16)
407 + graph.replay()
408 + for stream, _, hidden, topk_ids, output, _ in runs:
409 + torch.cuda.current_stream().wait_stream(stream)
410 + expected = moe_permute(hidden, None, topk_ids, 16)
411 + for actual, reference in zip(output, expected):
412 + if actual is not None:
413 + torch.testing.assert_close(actual, reference)
414 +
415 +
416 +def test_moe_permute_scratch_without_manager(monkeypatch) -> None:
417 + """Standalone calls get independent scratch with initialized row indices."""
418 + if not moe_permute_unpermute_supported():
419 + pytest.skip("moe_permute_unpermute is not supported on this platform.")
420 +
421 + from vllm.v1.worker import workspace
422 +
423 + monkeypatch.setattr(workspace, "_manager", None)
424 + config = dict(
425 + max_num_tokens=4,
426 + topk=2,
427 + num_experts=4,
428 + num_local_experts=4,
429 + device=torch.device("cuda"),
430 + )
431 + first = get_moe_permute_scratch(**config)
432 + second = get_moe_permute_scratch(**config)
433 +
434 + assert (
435 + first.token_expert_indices.data_ptr() != second.token_expert_indices.data_ptr()
436 + )
437 + torch.testing.assert_close(
438 + first.token_expert_indices, torch.arange(8, dtype=torch.int32, device="cuda")
439 + )
440 +
441 +
442 def test_moe_permute_ignores_invalid_expert_ids_with_scratch() -> None:
443 if not moe_permute_unpermute_supported():
444 pytest.skip("moe_permute_unpermute is not supported on this platform.")
tests/kernels/quantization/test_marlin_gemm.py
+58
@@ -17,6 +17,7 @@ from vllm.model_executor.layers.quantization.utils.int8_utils import (
17 per_token_quant_int8,
18 )
19 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
20 + get_marlin_workspace,
21 marlin_make_empty,
22 marlin_make_workspace_new,
23 marlin_permute_bias,
@@ -43,6 +44,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
44 )
45 from vllm.platforms import current_platform
46 from vllm.scalar_type import scalar_types
47 +from vllm.v1.worker.workspace import current_workspace_manager
48
49 if current_platform.is_rocm():
50 pytest.skip(
@@ -477,6 +479,62 @@ def test_marlin_gemm(
479 assert max_diff < 0.04
480
481
482 +def test_marlin_persistent_locks_cuda_graph_streams(workspace_init):
483 + """Compiled GEMMs select stream-local locks at runtime and survive replay."""
484 + size_m, size_k, size_n = 32, 1024, 2048
485 + inputs = [rand_data((size_m, size_k)) for _ in range(2)]
486 + weight = rand_data((size_k, size_n))
487 + quant_type = scalar_types.uint4b8
488 + w_ref, qweight, scales = marlin_quantize(weight, quant_type, 128)
489 + zeros = marlin_make_empty(scales.device)
490 + streams = [torch.cuda.Stream() for _ in inputs]
491 + graphs = [torch.cuda.CUDAGraph() for _ in inputs]
492 + locks = []
493 + outputs = []
494 +
495 + @torch.compile(backend="eager", fullgraph=True)
496 + def run(x):
497 + return torch.ops.vllm.marlin_gemm(
498 + x,
499 + qweight,
500 + None,
501 + scales,
502 + None,
503 + None,
504 + zeros,
505 + None,
506 + quant_type.id,
507 + size_m,
508 + size_n,
509 + size_k,
510 + use_atomic_add=False,
511 + use_fp32_reduce=True,
512 + )
513 +
514 + for stream, graph, x in zip(streams, graphs, inputs):
515 + stream.wait_stream(torch.cuda.current_stream())
516 + with torch.cuda.stream(stream):
517 + locks.append(get_marlin_workspace(x.device))
518 + run(x)
519 + with torch.cuda.graph(graph, stream=stream):
520 + outputs.append(run(x))
521 + torch.cuda.current_stream().wait_stream(stream)
522 +
523 + assert locks[0].data_ptr() != locks[1].data_ptr()
524 + current_workspace_manager().lock()
525 + for _ in range(3):
526 + for stream, graph, x, lock in zip(streams, graphs, inputs, locks):
527 + with torch.cuda.stream(stream):
528 + assert get_marlin_workspace(x.device) is lock
529 + x.add_(0.1)
530 + graph.replay()
531 + for stream in streams:
532 + torch.cuda.current_stream().wait_stream(stream)
533 + for x, output, lock in zip(inputs, outputs, locks):
534 + assert compute_max_diff(output, x @ w_ref) < 0.04
535 + assert torch.count_nonzero(lock) == 0
536 +
537 +
538 def test_marlin_gemm_subset_input():
539 quant_type = scalar_types.uint4b8
540 group_size = 128
tests/kernels/quantization/test_marlin_tile_padding.py
+7 -6
@@ -14,6 +14,7 @@ from vllm import _custom_ops as ops
14 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
15 GPTQ_MARLIN_TILE,
16 apply_gptq_marlin_linear,
17 + get_marlin_workspace,
18 marlin_make_empty,
19 marlin_make_workspace_new,
20 marlin_moe_padded_intermediate,
@@ -225,7 +226,7 @@ def test_fp8_marlin_padded_round_trip(shape, use_bias):
226 input=x,
227 weight=layer.weight,
228 weight_scale=layer.weight_scale,
228 - workspace=layer.workspace,
229 + workspace=get_marlin_workspace(layer.weight.device),
230 size_n=size_n,
231 size_k=size_k,
232 bias=layer.bias if use_bias else None,
@@ -288,7 +289,7 @@ def test_nvfp4_marlin_padded_round_trip(shape):
289 weight=layer.weight,
290 weight_scale=layer.weight_scale,
291 weight_global_scale=layer.weight_global_scale,
291 - workspace=layer.workspace,
292 + workspace=get_marlin_workspace(layer.weight.device),
293 size_n=size_n,
294 size_k=size_k,
295 )
@@ -392,7 +393,7 @@ def test_fp8_block_marlin_padded_round_trip(shape):
393 input=x,
394 weight=layer.weight,
395 weight_scale=layer.weight_scale_inv,
395 - workspace=layer.workspace,
396 + workspace=get_marlin_workspace(layer.weight.device),
397 size_n=size_n,
398 size_k=size_k,
399 bias=None,
@@ -440,7 +441,7 @@ def test_mxfp8_marlin_padded_round_trip(shape):
441 input=x,
442 weight=layer.weight,
443 weight_scale=layer.weight_scale,
443 - workspace=layer.workspace,
444 + workspace=get_marlin_workspace(layer.weight.device),
445 size_n=size_n,
446 size_k=size_k,
447 )
@@ -756,7 +757,7 @@ def test_fp8_marlin_moe_padded_round_trip(shape, quant):
757 topk_ids,
758 quant_type_id=scalar_types.float8_e4m3fn.id,
759 global_num_experts=e,
759 - workspace=layer.workspace,
760 + workspace=get_marlin_workspace(device),
761 )
762 with set_current_vllm_config(VllmConfig()):
763 ref = torch_experts(
@@ -833,7 +834,7 @@ def test_mxfp8_marlin_moe_padded_round_trip(shape):
834 topk_ids,
835 quant_type_id=scalar_types.float8_e4m3fn.id,
836 global_num_experts=e,
836 - workspace=layer.workspace,
837 + workspace=get_marlin_workspace(device),
838 )
839 with set_current_vllm_config(VllmConfig()):
840 ref = torch_experts(
tests/model_executor/model_loader/test_reload.py
+44 -23
@@ -330,8 +330,8 @@ def _load_marlin_checkpoint_format_weights(layer):
330 )
331
332
333 -def test_marlin_post_load_preserves_runtime_tensor_addresses(monkeypatch, dist_init):
334 - """Marlin must reuse the workspace storage captured by CUDA graphs."""
333 +def test_marlin_post_load_does_not_own_workspace(monkeypatch, dist_init):
334 + """Weight reload must not create layer-owned Marlin lock storage."""
335 _stub_marlin_ops(monkeypatch)
336 kernel = _make_marlin_kernel()
337
@@ -339,19 +339,17 @@ def test_marlin_post_load_preserves_runtime_tensor_addresses(monkeypatch, dist_i
339 _load_marlin_checkpoint_format_weights(layer)
340 kernel.process_weights_after_loading(layer)
341
342 - workspace_ptr = kernel.workspace.data_ptr()
342 + assert not hasattr(kernel, "workspace")
343
344 _load_marlin_checkpoint_format_weights(layer)
345 kernel.process_weights_after_loading(layer)
346
347 - assert kernel.workspace.data_ptr() == workspace_ptr
348 - assert torch.all(kernel.workspace == 0)
347 + assert not hasattr(kernel, "workspace")
348
349
350 @pytest.mark.parametrize("variant", ["fp8", "mxfp8", "nvfp4"])
352 -def test_marlin_prepare_layer_preserves_workspace_address(monkeypatch, variant):
353 - """The Marlin fallback prepare_* functions rerun on weight reload and must
354 - reuse the workspace storage whose address captured CUDA graphs hold."""
351 +def test_marlin_prepare_layer_does_not_own_workspace(monkeypatch, variant):
352 + """Weight preparation must not attach runtime workspace to model layers."""
353 from vllm import _custom_ops as ops
354 from vllm.model_executor.layers.quantization.utils import (
355 marlin_utils,
@@ -417,34 +415,57 @@ def test_marlin_prepare_layer_preserves_workspace_address(monkeypatch, variant):
415
416 load_checkpoint_format_weights()
417 prepare(layer)
420 - workspace_ptr = layer.workspace.data_ptr()
418 + assert not hasattr(layer, "workspace")
419
420 # Reload: fresh checkpoint-format tensors, prepare runs again
421 load_checkpoint_format_weights()
422 prepare(layer)
423
426 - assert layer.workspace.data_ptr() == workspace_ptr
427 - assert torch.all(layer.workspace == 0)
424 + assert not hasattr(layer, "workspace")
425
426
430 -def test_marlin_make_workspace_new_rejects_incompatible_existing(monkeypatch):
431 - """An incompatible existing workspace means the address captured by CUDA
432 - graphs is already unusable; allocating a replacement would hide that."""
427 +def test_marlin_workspace_uses_persistent_workspace_manager(monkeypatch):
428 + """Calls reuse initialized locks; independent streams get separate storage."""
429 from vllm.model_executor.layers.quantization.utils import marlin_utils
430 + from vllm.utils import torch_utils
431 + from vllm.v1.worker import workspace as workspace_module
432
433 monkeypatch.setattr(marlin_utils, "num_compute_units", lambda _: 4)
434 device = torch.device("cpu")
435 + manager = workspace_module.WorkspaceManager(device)
436 + monkeypatch.setattr(workspace_module, "_manager", manager)
437 + stream = "main"
438 + monkeypatch.setattr(torch_utils, "current_stream", lambda: stream)
439
438 - workspace = marlin_utils.marlin_make_workspace_new(device)
439 - reused = marlin_utils.marlin_make_workspace_new(device, existing=workspace)
440 - assert reused is workspace
440 + workspace = marlin_utils.get_marlin_workspace(device)
441 + assert workspace.shape == (4 * marlin_utils.MARLIN_MAX_BLOCKS_PER_SM,)
442 + assert workspace.dtype == torch.int32
443 + assert torch.count_nonzero(workspace) == 0
444
442 - with pytest.raises(ValueError, match="incompatible"):
443 - marlin_utils.marlin_make_workspace_new(device, 4, existing=workspace)
444 - with pytest.raises(ValueError, match="incompatible"):
445 - marlin_utils.marlin_make_workspace_new(
446 - device, existing=workspace.to(torch.int64)
447 - )
445 + workspace.fill_(1)
446 + assert marlin_utils.get_marlin_workspace(device) is workspace
447 + assert torch.all(workspace == 1)
448 +
449 + stream = "aux"
450 + aux_workspace = marlin_utils.get_marlin_workspace(device)
451 + assert aux_workspace.data_ptr() != workspace.data_ptr()
452 + assert torch.count_nonzero(aux_workspace) == 0
453 +
454 + manager.lock()
455 + assert marlin_utils.get_marlin_workspace(device) is aux_workspace
456 +
457 +
458 +def test_marlin_workspace_without_manager(monkeypatch):
459 + from vllm.model_executor.layers.quantization.utils import marlin_utils
460 + from vllm.v1.worker import workspace as workspace_module
461 +
462 + monkeypatch.setattr(workspace_module, "_manager", None)
463 + monkeypatch.setattr(marlin_utils, "num_compute_units", lambda _: 4)
464 + first = marlin_utils.get_marlin_workspace(torch.device("cpu"))
465 + second = marlin_utils.get_marlin_workspace(torch.device("cpu"))
466 + assert first.data_ptr() != second.data_ptr()
467 + assert first.shape == (4 * marlin_utils.MARLIN_MAX_BLOCKS_PER_SM,)
468 + assert torch.count_nonzero(first) == 0
469
470
471 def test_model_cleanup(dist_init, default_vllm_config):
tests/v1/worker/test_workspace.py
+63
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from typing import cast
5 +from unittest.mock import Mock
6
7 import pytest
8 import torch
@@ -124,3 +125,65 @@ def test_workspace_lane_validation(monkeypatch) -> None:
125
126 with pytest.raises(ValueError, match="at least one"):
127 workspace.WorkspaceManager(torch.device("cpu"), num_lanes=0)
128 +
129 +
130 +def test_persistent_resources_are_lazy_and_isolated(monkeypatch) -> None:
131 + """Only the requesting slot allocates; each ubatch/lane owns its resource."""
132 + active_ubatch = 0
133 + monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: active_ubatch)
134 + manager = workspace.WorkspaceManager(
135 + torch.device("cpu"), num_ubatches=2, num_lanes=2
136 + )
137 + factory = Mock(side_effect=lambda: {"buffer": torch.zeros(8)})
138 + resources = []
139 + for ubatch in range(2):
140 + active_ubatch = ubatch
141 + for lane in range(2):
142 + with workspace.use_workspace_lane(lane):
143 + resource = manager.get_persistent_resource("scratch", factory)
144 + resources.append(resource)
145 + resource["buffer"].fill_(len(resources))
146 + assert manager.get_persistent_resource("scratch", factory) is resource
147 + assert factory.call_count == len(resources)
148 +
149 + assert len({r["buffer"].data_ptr() for r in resources}) == 4
150 + for i, resource in enumerate(resources, 1):
151 + assert torch.all(resource["buffer"] == i)
152 +
153 +
154 +def test_persistent_resource_lock_applies_to_each_slot(monkeypatch) -> None:
155 + monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
156 + manager = workspace.WorkspaceManager(torch.device("cpu"), num_lanes=2)
157 + factory = Mock(side_effect=object)
158 + first = manager.get_persistent_resource("scratch", factory)
159 + manager.lock()
160 + assert manager.get_persistent_resource("scratch", factory) is first
161 + with pytest.raises(AssertionError, match="was not allocated during warmup"):
162 + manager.get_persistent_resource("new", factory)
163 + with workspace.use_workspace_lane(1):
164 + with pytest.raises(AssertionError, match="was not allocated during warmup"):
165 + manager.get_persistent_resource("scratch", factory)
166 + assert factory.call_count == 1
167 + manager.unlock()
168 + assert manager.get_persistent_resource("scratch", factory) is not first
169 + assert manager.get_persistent_resource("scratch", factory) is first
170 +
171 +
172 +def test_persistent_tensor_preserves_contents_and_rejects_changes() -> None:
173 + """Initialization happens once and transient scratch cannot overwrite locks."""
174 + manager = workspace.WorkspaceManager(torch.device("cpu"))
175 + first = manager.get_persistent("locks", (8,), torch.int32, zero_init=True)
176 + assert torch.count_nonzero(first) == 0
177 + first.fill_(1)
178 + (scratch,) = manager.get_simultaneous(((1024,), torch.uint8))
179 + scratch.zero_()
180 + assert manager.get_persistent("locks", (8,), torch.int32, zero_init=True) is first
181 + assert torch.all(first == 1)
182 + with pytest.raises(ValueError, match="requested shape"):
183 + manager.get_persistent("locks", (16,), torch.int32)
184 + with pytest.raises(ValueError, match="requested shape"):
185 + manager.get_persistent("locks", (8,), torch.int64)
186 + manager.lock()
187 + assert manager.get_persistent("locks", (8,), torch.int32) is first
188 + with pytest.raises(AssertionError, match="was not allocated during warmup"):
189 + manager.get_persistent("new", (8,), torch.int32)
vllm/model_executor/kernels/linear/mixed_precision/marlin.py
+1 -7
@@ -10,7 +10,6 @@ from vllm.model_executor.layers.quantization.utils.marlin_utils import (
10 apply_gptq_marlin_linear,
11 marlin_act_int8_process_scales,
12 marlin_make_empty,
13 - marlin_make_workspace_new,
13 marlin_pad_dim,
14 marlin_pad_qweight,
15 marlin_pad_scales,
@@ -91,11 +90,6 @@ class MarlinLinearKernel(MPLinearKernel):
90 size_k, size_n = c.partition_weight_shape
91 padded_n, padded_k = marlin_padded_nk(size_n, size_k, c.group_size)
92
94 - # Allocate marlin workspace, reusing existing storage on reload.
95 - self.workspace = marlin_make_workspace_new(
96 - device, existing=getattr(self, "workspace", None)
97 - )
98 -
93 # Default name since marlin requires empty parameter for zp,
94 # TODO: remove this requirement from marlin (allow optional tensors)
95 if self.w_zp_name is None:
@@ -197,7 +191,7 @@ class MarlinLinearKernel(MPLinearKernel):
191 weight=w_q,
192 weight_scale=w_s,
193 weight_zp=w_zp, # type: ignore
200 - workspace=self.workspace,
194 + workspace=None,
195 wtype=c.weight_type,
196 input_size_per_partition=c.partition_weight_shape[0],
197 output_size_per_partition=c.partition_weight_shape[1],
vllm/model_executor/kernels/linear/mxfp4/marlin.py
+1 -1
@@ -58,7 +58,7 @@ class MarlinMxFp4LinearKernel(MxFp4LinearKernel):
58 weight=layer.weight,
59 weight_scale=layer.weight_scale,
60 weight_global_scale=None,
61 - workspace=layer.workspace,
61 + workspace=None,
62 size_n=layer.output_size_per_partition,
63 size_k=layer.input_size_per_partition,
64 bias=bias,
vllm/model_executor/kernels/linear/mxfp8/marlin.py
+1 -1
@@ -46,7 +46,7 @@ class MarlinMxfp8LinearKernel(Mxfp8LinearKernel):
46 input=x,
47 weight=layer.weight,
48 weight_scale=layer.weight_scale,
49 - workspace=layer.workspace,
49 + workspace=None,
50 size_n=layer.output_size_per_partition,
51 size_k=layer.input_size_per_partition,
52 bias=bias,
vllm/model_executor/kernels/linear/nvfp4/marlin.py
+1 -1
@@ -50,7 +50,7 @@ class MarlinNvFp4LinearKernel(NvFp4LinearKernel):
50 weight=layer.weight,
51 weight_scale=layer.weight_scale,
52 weight_global_scale=layer.weight_global_scale,
53 - workspace=layer.workspace,
53 + workspace=None,
54 size_n=layer.output_size_per_partition,
55 size_k=layer.input_size_per_partition,
56 bias=bias,
vllm/model_executor/kernels/linear/scaled_mm/marlin.py
+1 -1
@@ -93,7 +93,7 @@ class MarlinFP8ScaledMMLinearKernel(FP8ScaledMMLinearKernel):
93 input=x,
94 weight=layer.weight,
95 weight_scale=weight_scale,
96 - workspace=layer.workspace,
96 + workspace=None,
97 size_n=layer.output_size_per_partition,
98 size_k=layer.input_size_per_partition,
99 input_dtype=self.marlin_input_dtype,
vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py
+15 -19
@@ -27,6 +27,7 @@ from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
27 from vllm.model_executor.layers.fused_moe.moe_fused_mul_sum import moe_fused_mul_sum
28 from vllm.model_executor.layers.fused_moe.moe_permute_unpermute import (
29 MoEPermuteScratch,
30 + get_moe_permute_scratch,
31 moe_permute,
32 moe_permute_unpermute_supported,
33 moe_unpermute,
@@ -176,7 +177,6 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
177 max_num_tokens=max_num_tokens,
178 num_dispatchers=num_dispatchers,
179 )
179 - self._permute_scratch: dict[int, MoEPermuteScratch] = {}
180
181 def init_humming_moe(self):
182 from vllm.utils.humming import get_heuristics_config
@@ -259,24 +259,20 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
259 if not moe_permute_unpermute_supported():
260 return None
261
262 - scratch = self._permute_scratch.get(topk)
263 - if scratch is None:
264 - max_expanded_rows = (
265 - self.moe_config.max_num_tokens
266 - * self.moe_config.dp_size
267 - * self.moe_config.experts_per_token
268 - )
269 - scratch = MoEPermuteScratch(
270 - max_num_tokens=math.ceil(max_expanded_rows / topk),
271 - topk=topk,
272 - num_experts=self.moe_config.num_experts,
273 - num_local_experts=self.moe_config.num_local_experts,
274 - device=torch.device(self.moe_config.device),
275 - hidden_size=self.moe_config.hidden_dim,
276 - hidden_dtype=self.moe_config.in_dtype,
277 - )
278 - self._permute_scratch[topk] = scratch
279 - return scratch
262 + max_expanded_rows = (
263 + self.moe_config.max_num_tokens
264 + * self.moe_config.dp_size
265 + * self.moe_config.experts_per_token
266 + )
267 + return get_moe_permute_scratch(
268 + max_num_tokens=math.ceil(max_expanded_rows / topk),
269 + topk=topk,
270 + num_experts=self.moe_config.num_experts,
271 + num_local_experts=self.moe_config.num_local_experts,
272 + device=torch.device(self.moe_config.device),
273 + hidden_size=self.moe_config.hidden_dim,
274 + hidden_dtype=self.moe_config.in_dtype,
275 + )
276
277 def get_global_valid_shape_m(self, topk_ids: torch.Tensor):
278 ctx = get_forward_context()
vllm/model_executor/layers/fused_moe/experts/marlin_moe.py
+2 -2
@@ -35,7 +35,7 @@ from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
35 from vllm.model_executor.layers.fused_moe.utils import _resize_cache
36 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
37 get_marlin_input_dtype,
38 - marlin_make_workspace_new,
38 + get_marlin_workspace,
39 marlin_moe_intermediate_size,
40 marlin_quant_input,
41 )
@@ -95,7 +95,7 @@ def _fused_marlin_moe(
95 N = marlin_moe_intermediate_size(w1, w2)
96 w13_num_shards = 2 if activation.is_gated else 1
97 if workspace is None:
98 - workspace = marlin_make_workspace_new(hidden_states.device, 4)
98 + workspace = get_marlin_workspace(hidden_states.device)
99
100 if intermediate_cache13 is None:
101 intermediate_cache13 = torch.empty(
vllm/model_executor/layers/fused_moe/moe_permute_unpermute.py
+47
@@ -5,6 +5,11 @@ from dataclasses import dataclass, field
5
6 import torch
7
8 +from vllm.v1.worker.workspace import (
9 + current_workspace_manager,
10 + is_workspace_manager_initialized,
11 +)
12 +
13
14 @dataclass
15 class MoEPermuteScratch:
@@ -102,6 +107,48 @@ class MoEPermuteScratch:
107 return topk_ids_int32
108
109
110 +def get_moe_permute_scratch(
111 + *,
112 + max_num_tokens: int,
113 + topk: int,
114 + num_experts: int,
115 + num_local_experts: int,
116 + device: torch.device,
117 + hidden_size: int | None = None,
118 + hidden_dtype: torch.dtype | None = None,
119 +) -> MoEPermuteScratch:
120 + """Share scratch across sequential layers in the current ubatch and lane.
121 +
122 + Without a workspace manager, allocate new scratch for each call.
123 + """
124 +
125 + def create_scratch() -> MoEPermuteScratch:
126 + return MoEPermuteScratch(
127 + max_num_tokens=max_num_tokens,
128 + topk=topk,
129 + num_experts=num_experts,
130 + num_local_experts=num_local_experts,
131 + device=device,
132 + hidden_size=hidden_size,
133 + hidden_dtype=hidden_dtype,
134 + )
135 +
136 + if not is_workspace_manager_initialized():
137 + return create_scratch()
138 +
139 + key = (
140 + MoEPermuteScratch,
141 + max_num_tokens,
142 + topk,
143 + num_experts,
144 + num_local_experts,
145 + device,
146 + hidden_size,
147 + hidden_dtype,
148 + )
149 + return current_workspace_manager().get_persistent_resource(key, create_scratch)
150 +
151 +
152 def moe_permute(
153 hidden_states: torch.Tensor,
154 a1q_scale: torch.Tensor | None,
vllm/model_executor/layers/quantization/auto_awq.py
-4
@@ -48,7 +48,6 @@ from vllm.model_executor.layers.quantization.utils.marlin_utils import (
48 check_marlin_supports_layer,
49 check_moe_marlin_supports_layer,
50 get_marlin_input_dtype,
51 - marlin_make_workspace_new,
51 verify_marlin_supported,
52 )
53 from vllm.model_executor.layers.quantization.utils.quant_utils import (
@@ -642,9 +641,6 @@ class AutoAWQMoEMethod(FusedMoEMethodBase):
641 layer.register_parameter("w2_qzeros", w2_qzeros)
642 set_weight_attrs(w2_qzeros, extra_weight_attrs)
643
645 - device = layer.w13_qweight.device
646 - layer.workspace = marlin_make_workspace_new(device, 4)
647 -
644 def process_weights_after_loading(self, layer: RoutedExperts) -> None:
645 converted = convert_to_wna16_moe_kernel_format(
646 backend=self.wna16_moe_backend,
vllm/model_executor/layers/quantization/auto_gptq.py
-8
@@ -16,7 +16,6 @@ from vllm.model_executor.kernels.linear import (
16 )
17 from vllm.model_executor.layers.fused_moe import (
18 FusedMoEConfig,
19 - FusedMoEExpertsModular,
19 FusedMoEMethodBase,
20 FusedMoEQuantConfig,
21 FusedMoeWeightScaleSupported,
@@ -46,7 +45,6 @@ from vllm.model_executor.layers.quantization.utils.gptq_utils import (
45 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
46 check_moe_marlin_supports_layer,
47 get_marlin_input_dtype,
49 - marlin_make_workspace_new,
48 marlin_repeat_scales_on_all_ranks,
49 verify_marlin_supported,
50 )
@@ -601,12 +599,6 @@ class AutoGPTQMoEMethod(FusedMoEMethodBase):
599 layer.register_parameter("w2_bias", w2_bias)
600 set_weight_attrs(w2_bias, extra_weight_attrs)
601
604 - if self.experts_cls is not None and issubclass(
605 - self.experts_cls, FusedMoEExpertsModular
606 - ):
607 - device = layer.w13_qweight.device
608 - layer.workspace = marlin_make_workspace_new(device, 4)
609 -
602 def process_weights_after_loading(self, layer: RoutedExperts) -> None:
603 def replace_or_register(name: str, val: torch.Tensor | None):
604 if val is None:
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_wna16.py
-14
@@ -11,7 +11,6 @@ from compressed_tensors.quantization import (
11
12 from vllm.logger import init_logger
13 from vllm.model_executor.layers.fused_moe import (
14 - FusedMoEExpertsModular,
14 RoutedExperts,
15 SharedExperts,
16 )
@@ -36,7 +35,6 @@ from vllm.model_executor.layers.quantization.compressed_tensors.schemes.compress
35 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
36 check_moe_marlin_supports_config,
37 get_marlin_input_dtype,
39 - marlin_make_workspace_new,
38 )
39 from vllm.model_executor.layers.quantization.utils.quant_utils import (
40 GroupShape,
@@ -466,18 +464,6 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
464 torch.nn.Parameter(w2_input_global_scale, requires_grad=False),
465 )
466
469 - # Marlin workspace — only needed for Marlin-family backends, not emulation.
470 - if (
471 - self.experts_cls is not None
472 - and issubclass(self.experts_cls, FusedMoEExpertsModular)
473 - and self.wna16_backend != WNA16MoEBackend.EMULATION
474 - ):
475 - layer.workspace = marlin_make_workspace_new(
476 - layer.w13_weight_packed.device,
477 - 4,
478 - existing=getattr(layer, "workspace", None),
479 - )
480 -
467 # Alias packed weights to w13_weight/w2_weight for the modular kernel interface
468 layer.w13_weight = layer.w13_weight_packed
469 layer.w2_weight = layer.w2_weight_packed
vllm/model_executor/layers/quantization/utils/marlin_utils.py
+91 -24
@@ -8,7 +8,6 @@ import numpy
8 import torch
9
10 import vllm.envs as envs
11 -from vllm import _custom_ops as ops
11 from vllm.distributed.utils import verify_group_size_divides_partition
12 from vllm.logger import init_logger
13 from vllm.model_executor.layers.fused_moe import RoutedExperts
@@ -23,6 +22,7 @@ from vllm.platforms import current_platform
22 from vllm.scalar_type import ScalarType, scalar_types
23 from vllm.utils.math_utils import round_up
24 from vllm.utils.platform_utils import num_compute_units
25 +from vllm.utils.torch_utils import direct_register_custom_op
26
27 from .quant_utils import pack_cols, unpack_cols
28
@@ -32,6 +32,7 @@ GPTQ_MARLIN_TILE = 16
32 GPTQ_MARLIN_MIN_THREAD_N = 64
33 GPTQ_MARLIN_MIN_THREAD_K = 128
34 GPTQ_MARLIN_MAX_PARALLEL = 16
35 +MARLIN_MAX_BLOCKS_PER_SM = 4
36
37 MARLIN_SUPPORTED_GROUP_SIZES = [-1, 32, 64, 128]
38
@@ -407,29 +408,96 @@ def marlin_moe_intermediate_size(w1_packed: torch.Tensor, w2_packed: torch.Tenso
408 def marlin_make_workspace_new(
409 device: torch.device,
410 max_blocks_per_sm: int = 1,
410 - existing: torch.Tensor | None = None,
411 ) -> torch.Tensor:
412 # In the new marlin kernel, we use the num of threadblocks as workspace
413 # size. The num of threadblocks is sms_count * max_blocks_per_sm.
414 sms = num_compute_units(device.index)
415 - size = sms * max_blocks_per_sm
416 - # On weight reload, reuse the existing storage so the workspace address
417 - # captured by CUDA graphs stays valid.
418 - if existing is not None:
419 - if (
420 - existing.device != device
421 - or existing.dtype != torch.int
422 - or existing.numel() != size
423 - ):
424 - raise ValueError(
425 - f"Existing Marlin workspace is incompatible "
426 - f"(device={existing.device}, dtype={existing.dtype}, "
427 - f"numel={existing.numel()}; expected device={device}, "
428 - f"dtype={torch.int}, numel={size}). Reload must reuse the "
429 - f"workspace storage captured by CUDA graphs."
430 - )
431 - return existing.zero_()
432 - return torch.zeros(size, dtype=torch.int, device=device, requires_grad=False)
415 + return torch.zeros(sms * max_blocks_per_sm, dtype=torch.int, device=device)
416 +
417 +
418 +def get_marlin_workspace(device: torch.device) -> torch.Tensor:
419 + """Get zero-initialized locks for the current stream, ubatch, and lane.
420 +
421 + Marlin resets its locks after each call, so ordered calls can reuse them.
422 + """
423 + from vllm.utils.torch_utils import current_stream
424 + from vllm.v1.worker.workspace import (
425 + current_workspace_manager,
426 + is_workspace_manager_initialized,
427 + )
428 +
429 + if not is_workspace_manager_initialized():
430 + return marlin_make_workspace_new(device, MARLIN_MAX_BLOCKS_PER_SM)
431 +
432 + return current_workspace_manager().get_persistent(
433 + ("marlin_locks", current_stream()),
434 + (num_compute_units(device.index) * MARLIN_MAX_BLOCKS_PER_SM,),
435 + torch.int,
436 + zero_init=True,
437 + )
438 +
439 +
440 +def _marlin_gemm(
441 + a: torch.Tensor,
442 + b_q_weight: torch.Tensor,
443 + b_bias: torch.Tensor | None,
444 + b_scales: torch.Tensor,
445 + a_scales: torch.Tensor | None,
446 + global_scale: torch.Tensor | None,
447 + b_zeros: torch.Tensor | None,
448 + workspace: torch.Tensor | None,
449 + b_q_type_id: int,
450 + size_m: int,
451 + size_n: int,
452 + size_k: int,
453 + use_atomic_add: bool = False,
454 + use_fp32_reduce: bool = False,
455 + is_zp_float: bool = False,
456 +) -> torch.Tensor:
457 + if workspace is None:
458 + workspace = get_marlin_workspace(a.device)
459 + return torch.ops._C.marlin_gemm(
460 + a,
461 + None,
462 + b_q_weight,
463 + b_bias,
464 + b_scales,
465 + a_scales,
466 + global_scale,
467 + b_zeros,
468 + workspace,
469 + b_q_type_id,
470 + size_m,
471 + size_n,
472 + size_k,
473 + use_atomic_add,
474 + use_fp32_reduce,
475 + is_zp_float,
476 + )
477 +
478 +
479 +def _marlin_gemm_fake(
480 + a: torch.Tensor,
481 + b_q_weight: torch.Tensor,
482 + b_bias: torch.Tensor | None,
483 + b_scales: torch.Tensor,
484 + a_scales: torch.Tensor | None,
485 + global_scale: torch.Tensor | None,
486 + b_zeros: torch.Tensor | None,
487 + workspace: torch.Tensor | None,
488 + b_q_type_id: int,
489 + size_m: int,
490 + size_n: int,
491 + size_k: int,
492 + use_atomic_add: bool = False,
493 + use_fp32_reduce: bool = False,
494 + is_zp_float: bool = False,
495 +) -> torch.Tensor:
496 + dtype = a.dtype if a.dtype in (torch.float16, torch.bfloat16) else b_scales.dtype
497 + return torch.empty((size_m, size_n), device=a.device, dtype=dtype)
498 +
499 +
500 +direct_register_custom_op("marlin_gemm", _marlin_gemm, fake_impl=_marlin_gemm_fake)
501
502
503 def marlin_repeat_scales_on_all_ranks(group_size: int, is_row_parallel: bool) -> bool:
@@ -686,7 +754,7 @@ def apply_gptq_marlin_linear(
754 weight: torch.Tensor,
755 weight_scale: torch.Tensor,
756 weight_zp: torch.Tensor,
689 - workspace: torch.Tensor,
757 + workspace: torch.Tensor | None,
758 wtype: ScalarType,
759 output_size_per_partition: int,
760 input_size_per_partition: int,
@@ -723,9 +791,8 @@ def apply_gptq_marlin_linear(
791
792 reshaped_x, a_scales = marlin_quant_input(reshaped_x, input_dtype)
793
726 - output = ops.marlin_gemm(
794 + output = torch.ops.vllm.marlin_gemm(
795 reshaped_x,
728 - None,
796 weight,
797 bias,
798 weight_scale,
@@ -733,7 +800,7 @@ def apply_gptq_marlin_linear(
800 None,
801 weight_zp,
802 workspace,
736 - wtype,
803 + wtype.id,
804 size_m=reshaped_x.shape[0],
805 size_n=padded_n,
806 size_k=padded_k,
vllm/model_executor/layers/quantization/utils/marlin_utils_fp4.py
+3 -23
@@ -10,7 +10,6 @@ from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts
10 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
11 USE_FP32_REDUCE_DEFAULT,
12 get_marlin_input_dtype,
13 - marlin_make_workspace_new,
13 marlin_pad_dim,
14 marlin_pad_qweight,
15 marlin_pad_scales,
@@ -161,7 +160,7 @@ def apply_fp4_marlin_linear(
160 weight: torch.Tensor,
161 weight_scale: torch.Tensor,
162 weight_global_scale: torch.Tensor | None,
164 - workspace: torch.Tensor,
163 + workspace: torch.Tensor | None,
164 size_n: int,
165 size_k: int,
166 bias: torch.Tensor | None = None,
@@ -196,9 +195,8 @@ def apply_fp4_marlin_linear(
195
196 inputs, a_scales = marlin_quant_input(inputs, torch.float8_e4m3fn)
197
199 - output = ops.marlin_gemm(
198 + output = torch.ops.vllm.marlin_gemm(
199 a=inputs,
201 - c=None,
200 b_q_weight=weight,
201 b_bias=bias,
202 b_scales=weight_scale,
@@ -206,7 +204,7 @@ def apply_fp4_marlin_linear(
204 global_scale=weight_global_scale,
205 b_zeros=None,
206 workspace=workspace,
209 - b_q_type=scalar_types.float4_e2m1f,
207 + b_q_type_id=scalar_types.float4_e2m1f.id,
208 size_m=reshaped_x.size(0),
209 size_n=padded_n,
210 size_k=padded_k,
@@ -237,13 +235,6 @@ def prepare_fp4_layer_for_marlin(
235
236 assert layer.weight.shape == (part_size_n, part_size_k // 2)
237
240 - device = layer.weight.device
241 -
242 - # WORKSPACE
243 - layer.workspace = marlin_make_workspace_new(
244 - device, existing=getattr(layer, "workspace", None)
245 - )
246 -
238 # WEIGHT
239 # Repack weights to marlin format
240 qweight = layer.weight.view(torch.int32).T.contiguous()
@@ -389,15 +380,9 @@ def prepare_nvfp4_moe_layer_for_marlin(
380 return x
381 return torch.nn.functional.pad(x, (0, (padded_N - N) // packing))
382
392 - device = w13.device
383 param_dtype = layer.params_dtype
384 is_a_8bit = input_dtype is not None and input_dtype.itemsize == 1
385
396 - # WORKSPACE
397 - layer.workspace = marlin_make_workspace_new(
398 - device, 4, existing=getattr(layer, "workspace", None)
399 - )
400 -
386 # WEIGHT
387 # Repack weights to marlin format
388 def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
@@ -481,12 +466,7 @@ def prepare_moe_fp4_layer_for_marlin(
466 k = layer.moe_config.hidden_dim
467 n = layer.moe_config.intermediate_size_per_partition
468
484 - # WORKSPACE
485 - device = layer.w13_weight.device
469 param_dtype = layer.params_dtype
487 - layer.workspace = marlin_make_workspace_new(
488 - device, 4, existing=getattr(layer, "workspace", None)
489 - )
470 is_a_8bit = input_dtype is not None and input_dtype.itemsize == 1
471
472 # WEIGHT
vllm/model_executor/layers/quantization/utils/marlin_utils_fp8.py
+8 -41
@@ -9,7 +9,6 @@ from vllm.logger import init_logger
9 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
10 USE_FP32_REDUCE_DEFAULT,
11 get_marlin_input_dtype,
12 - marlin_make_workspace_new,
12 marlin_moe_padded_intermediate,
13 marlin_pad_dim,
14 marlin_pad_qweight,
@@ -50,7 +49,7 @@ def apply_fp8_marlin_linear(
49 input: torch.Tensor,
50 weight: torch.Tensor,
51 weight_scale: torch.Tensor,
53 - workspace: torch.Tensor,
52 + workspace: torch.Tensor | None,
53 size_n: int,
54 size_k: int,
55 bias: torch.Tensor | None,
@@ -80,9 +79,8 @@ def apply_fp8_marlin_linear(
79 # inputs, a_scales = marlin_quant_input(inputs, torch.float8_e4m3fn)
80 raise RuntimeError("Marlin W8A8 is not supported.")
81
83 - output = ops.marlin_gemm(
82 + output = torch.ops.vllm.marlin_gemm(
83 a=inputs,
85 - c=None,
84 b_q_weight=weight,
85 b_bias=bias,
86 b_scales=weight_scale,
@@ -90,7 +88,7 @@ def apply_fp8_marlin_linear(
88 global_scale=None,
89 b_zeros=None,
90 workspace=workspace,
93 - b_q_type=scalar_types.float8_e4m3fn,
91 + b_q_type_id=scalar_types.float8_e4m3fn.id,
92 size_m=reshaped_x.size(0),
93 size_n=padded_n,
94 size_k=padded_k,
@@ -127,13 +125,6 @@ def prepare_fp8_layer_for_marlin(
125 else:
126 assert layer.weight.shape == (part_size_n, part_size_k)
127
130 - device = layer.weight.device
131 -
132 - # WORKSPACE
133 - layer.workspace = marlin_make_workspace_new(
134 - device, existing=getattr(layer, "workspace", None)
135 - )
136 -
128 # WEIGHT
129 # Repack weights to marlin format
130 qweight = pack_fp8_to_int32(layer.weight, size_k_first)
@@ -241,12 +232,7 @@ def prepare_fp8_moe_layer_for_marlin(
232 w13_weight_scale: torch.Tensor,
233 w2_weight_scale: torch.Tensor,
234 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
244 - """Shuffle weights and scales into marlin format.
245 -
246 - Note that this function has the side effect of adding a `workspace`
247 - attribute to the layer. This `workspace` does not need to be
248 - registered as a Parameter as it is not used during weight reloading.
249 - """
235 + """Shuffle weights and scales into marlin format."""
236 logger.warning_once(
237 "Your GPU does not have native support for FP8 computation but "
238 "FP8 quantization is being used. Weight-only FP8 compression will "
@@ -272,12 +258,6 @@ def prepare_fp8_moe_layer_for_marlin(
258 w13_weight = _moe_pad_shard_rows(w13_weight, n, padded_n)
259 w2_weight = _moe_pad_last(w2_weight, n, padded_n)
260
275 - # WORKSPACE
276 - device = layer.w13_weight.device
277 - layer.workspace = marlin_make_workspace_new(
278 - device, 4, existing=getattr(layer, "workspace", None)
279 - )
280 -
261 # WEIGHT
262 # Repack weights to marlin format
263 def repack_weight(name: str, weight: torch.Tensor) -> torch.Tensor:
@@ -396,7 +376,7 @@ def apply_mxfp8_marlin_linear(
376 input: torch.Tensor,
377 weight: torch.Tensor,
378 weight_scale: torch.Tensor,
399 - workspace: torch.Tensor,
379 + workspace: torch.Tensor | None,
380 size_n: int,
381 size_k: int,
382 bias: torch.Tensor | None = None,
@@ -416,9 +396,8 @@ def apply_mxfp8_marlin_linear(
396 dtype=input.dtype,
397 )
398
419 - output = ops.marlin_gemm(
399 + output = torch.ops.vllm.marlin_gemm(
400 a=reshaped_x,
421 - c=None,
401 b_q_weight=weight,
402 b_bias=bias,
403 b_scales=weight_scale,
@@ -426,7 +405,7 @@ def apply_mxfp8_marlin_linear(
405 global_scale=None,
406 b_zeros=None,
407 workspace=workspace,
429 - b_q_type=scalar_types.float8_e4m3fn,
408 + b_q_type_id=scalar_types.float8_e4m3fn.id,
409 size_m=reshaped_x.size(0),
410 size_n=padded_n,
411 size_k=padded_k,
@@ -451,13 +430,6 @@ def prepare_mxfp8_layer_for_marlin(layer: torch.nn.Module) -> None:
430 group_size = 32 # MX standard block size
431 padded_n, padded_k = marlin_padded_nk(part_size_n, part_size_k, group_size)
432
454 - device = layer.weight.device
455 -
456 - # WORKSPACE
457 - layer.workspace = marlin_make_workspace_new(
458 - device, existing=getattr(layer, "workspace", None)
459 - )
460 -
433 # WEIGHT - repack FP8 weights to Marlin format
434 qweight = pack_fp8_to_int32(layer.weight, size_k_first=False)
435 qweight = qweight.T.contiguous()
@@ -512,7 +484,7 @@ def prepare_mxfp8_moe_layer_for_marlin(
484 """Repack MXFP8 MoE weights and scales into Marlin kernel format.
485
486 Args:
515 - layer: MoE layer (used to read params_dtype and attach workspace).
487 + layer: MoE layer.
488 w13: [E, 2*N, K] float8_e4m3fn weights.
489 w2: [E, K, N] float8_e4m3fn weights.
490 w13_scale: [E, 2*N, K//32] uint8 e8m0 scales.
@@ -537,13 +509,8 @@ def prepare_mxfp8_moe_layer_for_marlin(
509 n = padded_n
510 w13_n = w13.shape[1]
511
540 - device = w13.device
512 param_dtype = torch.get_default_dtype()
513
543 - layer.workspace = marlin_make_workspace_new(
544 - device, 4, existing=getattr(layer, "workspace", None)
545 - )
546 -
514 def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
515 if "w13" in name:
516 size_n, size_k = w13_n, k
vllm/v1/worker/workspace.py
+75 -21
@@ -3,11 +3,12 @@
3
4 import inspect
5 import os
6 -from collections.abc import Iterator
6 +from collections.abc import Callable, Hashable, Iterator
7 from contextlib import contextmanager
8 from contextvars import ContextVar
9 from itertools import accumulate
10 from math import prod
11 +from typing import TypeVar, cast
12
13 import torch
14
@@ -17,6 +18,7 @@ from vllm.utils.math_utils import round_up
18 from vllm.v1.worker.ubatching import dbo_current_ubatch_id
19
20 logger = init_logger(__name__)
21 +T = TypeVar("T")
22
23
24 def _compute_bytes(shape: tuple[int, ...], dtype: torch.dtype) -> int:
@@ -47,8 +49,8 @@ def use_workspace_lane(lane: int) -> Iterator[None]:
49 class WorkspaceManager:
50 """Manager for workspace allocation.
51
50 - Manages one workspace buffer per active ``(ubatch, lane)`` slot.
51 - Can be locked to prevent further growth during execution.
52 + Manages shared scratch and keyed persistent resources per ``(ubatch, lane)``.
53 + Can be locked to prevent growth or new resources during execution.
54 """
55
56 def __init__(
@@ -66,6 +68,9 @@ class WorkspaceManager:
68 self._current_workspaces: list[torch.Tensor | None] = [None] * (
69 self._num_ubatches * self._num_lanes
70 )
71 + self._persistent_resources: list[dict[Hashable, object]] = [
72 + {} for _ in self._current_workspaces
73 + ]
74 self._locked: bool = False
75
76 @staticmethod
@@ -76,10 +81,10 @@ class WorkspaceManager:
81 return workspace.numel() * workspace.element_size()
82
83 def lock(self) -> None:
79 - """Lock the workspace to prevent further growth.
84 + """Lock the workspace to prevent growth or new persistent resources.
85
81 - After locking, any attempt to allocate a larger workspace will raise
82 - an assertion error. This ensures workspace size is fixed during execution.
86 + Larger scratch requests and new persistent resources raise an assertion
87 + error. Existing resources retain their storage throughout execution.
88 """
89 self._locked = True
90 if envs.VLLM_DEBUG_WORKSPACE:
@@ -113,6 +118,63 @@ class WorkspaceManager:
118 """Check if workspace is locked."""
119 return self._locked
120
121 + def _get_workspace_id(self) -> int:
122 + lane = _workspace_lane.get()
123 + if lane >= self._num_lanes:
124 + raise RuntimeError(
125 + f"Workspace lane {lane} is not configured; manager has "
126 + f"{self._num_lanes} lane(s)."
127 + )
128 + return dbo_current_ubatch_id() * self._num_lanes + lane
129 +
130 + def get_persistent_resource(self, key: Hashable, factory: Callable[[], T]) -> T:
131 + """Return the resource cached for ``key`` in the current ubatch and lane.
132 +
133 + The first request calls ``factory`` and stores its result for the lifetime
134 + of this manager. Later requests return the same object without calling
135 + the factory again. Each ubatch/lane pair has a separate cache. Once the
136 + manager is locked, requesting a key missing from that cache raises an
137 + AssertionError.
138 +
139 + Keys must identify the resource type and configuration. Callers must not
140 + resize or replace tensor storage held by a cached resource. Uses of the
141 + same resource must not overlap; include the CUDA stream in the key when
142 + streams can execute concurrently.
143 +
144 + With torch.compile, call this inside a runtime custom op so resource
145 + selection happens during execution.
146 + """
147 + resources = self._persistent_resources[self._get_workspace_id()]
148 + if key not in resources:
149 + if self._locked:
150 + raise AssertionError(
151 + f"Workspace is locked but {key!r} was not allocated during "
152 + "warmup for the current ubatch and lane."
153 + )
154 + resources[key] = factory()
155 + return cast(T, resources[key])
156 +
157 + def get_persistent(
158 + self,
159 + key: Hashable,
160 + shape: tuple[int, ...],
161 + dtype: torch.dtype,
162 + *,
163 + zero_init: bool = False,
164 + ) -> torch.Tensor:
165 + """Get a fixed-shape tensor, optionally zeroed on first allocation only."""
166 + factory = torch.zeros if zero_init else torch.empty
167 + workspace = self.get_persistent_resource(
168 + (torch.Tensor, key),
169 + lambda: factory(shape, dtype=dtype, device=self._device),
170 + )
171 + if workspace.shape != shape or workspace.dtype != dtype:
172 + raise ValueError(
173 + f"Persistent workspace {key!r} has shape={workspace.shape}, "
174 + f"dtype={workspace.dtype}; requested shape={shape}, dtype={dtype}."
175 + )
176 + return workspace
177 +
178 def get_simultaneous(
179 self, *shapes_and_dtypes: tuple[tuple[int, ...], torch.dtype]
180 ) -> list[torch.Tensor]:
@@ -151,14 +213,7 @@ class WorkspaceManager:
213 The current workspace tensor.
214
215 """
154 - ubatch_id = dbo_current_ubatch_id()
155 - lane = _workspace_lane.get()
156 - if lane >= self._num_lanes:
157 - raise RuntimeError(
158 - f"Workspace lane {lane} is not configured; manager has "
159 - f"{self._num_lanes} lane(s)."
160 - )
161 - workspace_id = ubatch_id * self._num_lanes + lane
216 + workspace_id = self._get_workspace_id()
217 current_workspace = self._current_workspaces[workspace_id]
218 current_size = self._workspace_size_bytes(current_workspace)
219
@@ -218,8 +273,8 @@ class WorkspaceManager:
273 get_caller_info(),
274 current_size / _MB,
275 required_bytes / _MB,
221 - ubatch_id,
222 - lane,
276 + workspace_id // self._num_lanes,
277 + workspace_id % self._num_lanes,
278 )
279
280 return current_workspace
@@ -277,12 +332,11 @@ def init_workspace_manager(
332
333
334 def lock_workspace() -> None:
280 - """Lock the workspace to prevent further growth.
335 + """Lock the workspace to prevent growth or new persistent resources.
336
282 - After calling this function, any attempt to allocate a workspace larger
283 - than the current size will raise an AssertionError. This ensures that
284 - workspace size is fixed during execution and prevents unexpected memory
285 - allocations in the hot path.
337 + Larger scratch requests and new persistent resources raise an AssertionError.
338 + All required resources must be initialized in each execution slot during
339 + warmup, so their storage remains fixed during execution.
340
341 Example:
342 # During initialization