fused_moe: add VLLM_TRITON_USE_TD tensor-descriptor path (#42436)
Signed-off-by: Artur Fierka <artur.fierka@intel.com> Signed-off-by: Lena Onyshchenko <162571002+oonyshch@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Lena Onyshchenko <162571002+oonyshch@users.noreply.github.com>
Artur Fierka committed
Jul 29, 2026 at 07:15 UTC
6f00a1ae3bd4b86168667bce673998218f461c0f
4 files changed
+179
-21
tests/kernels/moe/test_moe.py
+24
-7
@@ -36,6 +36,9 @@ from vllm.model_executor.layers.fused_moe.experts.marlin_moe import (
36
batched_fused_marlin_moe,
37
fused_marlin_moe,
38
)
39
+from vllm.model_executor.layers.fused_moe.utils import (
40
+ moe_use_td_hw_supported,
41
+)
42
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
43
marlin_permute_bias,
44
)
@@ -53,9 +56,12 @@ from vllm.model_executor.layers.quantization.utils.marlin_utils_test import (
56
from vllm.model_executor.layers.quantization.utils.quant_utils import quantize_weights
57
from vllm.platforms import current_platform
58
from vllm.scalar_type import ScalarType, scalar_types
59
+from vllm.triton_utils import tl
60
from vllm.utils.math_utils import next_power_of_2
61
from vllm.utils.torch_utils import set_random_seed
62
63
+DEVICE_TYPE = current_platform.device_type
64
+
65
66
def iterative_moe(
67
hidden_states: torch.Tensor,
@@ -289,6 +295,7 @@ def run_moe_test(
295
@pytest.mark.parametrize("ep_size", EP_SIZE)
296
@pytest.mark.parametrize("dtype", [torch.bfloat16])
297
@pytest.mark.parametrize("padding", [True, False])
298
+@pytest.mark.parametrize("use_td", [False, True])
299
def test_fused_moe(
300
m: int,
301
n: int,
@@ -298,9 +305,19 @@ def test_fused_moe(
305
ep_size: int,
306
dtype: torch.dtype,
307
padding: bool,
308
+ use_td: bool,
309
monkeypatch,
310
workspace_init,
311
):
312
+ if use_td and not hasattr(tl, "make_tensor_descriptor"):
313
+ pytest.skip("Triton < 3.6 lacks tl.make_tensor_descriptor")
314
+ if use_td and not moe_use_td_hw_supported():
315
+ pytest.skip(
316
+ "tensor_descriptor.gather requires XPU or NVIDIA Blackwell "
317
+ "(sm100+); lowers to tile::gather4 (tcgen05/TMEM), which ptxas "
318
+ "rejects on Hopper (sm90) and earlier"
319
+ )
320
+ monkeypatch.setenv("VLLM_TRITON_USE_TD", "1" if use_td else "0")
321
set_random_seed(7)
322
323
#
@@ -311,17 +328,17 @@ def test_fused_moe(
328
# Setup test data
329
#
330
314
- a = torch.randn((m, k), device="cuda", dtype=dtype) / 10
315
- w1 = torch.randn((e, 2 * n, k), device="cuda", dtype=dtype) / 10
316
- w2 = torch.randn((e, k, n), device="cuda", dtype=dtype) / 10
331
+ a = torch.randn((m, k), device=DEVICE_TYPE, dtype=dtype) / 10
332
+ w1 = torch.randn((e, 2 * n, k), device=DEVICE_TYPE, dtype=dtype) / 10
333
+ w2 = torch.randn((e, k, n), device=DEVICE_TYPE, dtype=dtype) / 10
334
318
- score = torch.randn((m, e), device="cuda", dtype=dtype)
335
+ score = torch.randn((m, e), device=DEVICE_TYPE, dtype=dtype)
336
337
if ep_size > 1:
338
local_e = e // ep_size
322
- e_ids = torch.randint(0, e, (local_e,), device="cuda", dtype=torch.int32)
323
- e_map = torch.full((e,), -1, device="cuda", dtype=torch.int32)
324
- e_map[e_ids] = torch.arange(local_e, device="cuda", dtype=torch.int32)
339
+ e_ids = torch.randint(0, e, (local_e,), device=DEVICE_TYPE, dtype=torch.int32)
340
+ e_map = torch.full((e,), -1, device=DEVICE_TYPE, dtype=torch.int32)
341
+ e_map[e_ids] = torch.arange(local_e, device=DEVICE_TYPE, dtype=torch.int32)
342
w1 = w1[e_ids]
343
w2 = w2[e_ids]
344
else:
vllm/model_executor/layers/fused_moe/fused_moe.py
+67
-14
@@ -28,9 +28,12 @@ from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
28
from vllm.model_executor.layers.fused_moe.utils import (
29
enable_swap_ab,
30
moe_kernel_quantize_input,
31
+ resolve_moe_use_td,
32
+ warn_if_moe_use_td_ineffective,
33
)
34
from vllm.platforms import current_platform
35
from vllm.triton_utils import tl, triton
36
+from vllm.triton_utils.allocation import set_triton_allocator
37
from vllm.utils.math_utils import next_power_of_2
38
from vllm.utils.platform_utils import get_device_name_as_file_name
39
from vllm.utils.torch_utils import direct_register_custom_op
@@ -347,6 +350,8 @@ def fused_moe_kernel(
350
per_channel_quant: tl.constexpr,
351
HAS_BIAS: tl.constexpr,
352
SWAP_AB: tl.constexpr,
353
+ # Tensor-descriptor path for the A gather and B load in the K-loop.
354
+ USE_TD: tl.constexpr = False,
355
):
356
"""
357
Implements the fused computation for a Mixture of Experts (MOE) using
@@ -436,7 +441,25 @@ def fused_moe_kernel(
441
442
offs_bn = (pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N).to(tl.int64)) % N
443
offs_k = tl.arange(0, BLOCK_SIZE_K)
439
- if SWAP_AB:
444
+ # TD gather and the SWAP_AB accumulator layout are mutually exclusive.
445
+ tl.static_assert(not (USE_TD and SWAP_AB))
446
+ if USE_TD:
447
+ # ``tt.descriptor_gather`` requires block_shape[0] == 1 and i32 idx.
448
+ m_td = num_valid_tokens // top_k
449
+ a_desc = tl.make_tensor_descriptor(
450
+ base=a_ptr,
451
+ shape=(m_td, K),
452
+ strides=(stride_am, stride_ak),
453
+ block_shape=(1, BLOCK_SIZE_K),
454
+ )
455
+ b_desc = tl.make_tensor_descriptor(
456
+ base=b_ptr + off_experts * stride_be,
457
+ shape=(N, K),
458
+ strides=(stride_bn, stride_bk),
459
+ block_shape=(BLOCK_SIZE_N, BLOCK_SIZE_K),
460
+ )
461
+ gather_idx = (offs_token // top_k).to(tl.int32)
462
+ elif SWAP_AB:
463
a_ptrs = a_ptr + (
464
offs_k[:, None] * stride_ak + offs_token[None, :] // top_k * stride_am
465
)
@@ -454,7 +477,6 @@ def fused_moe_kernel(
477
+ off_experts * stride_be
478
+ (offs_k[:, None] * stride_bk + offs_bn[None, :] * stride_bn)
479
)
457
-
480
if use_int8_w8a16:
481
b_scale_ptrs = (
482
b_scale_ptr + off_experts * stride_bse + offs_bn[None, :] * stride_bsn
@@ -498,18 +520,21 @@ def fused_moe_kernel(
520
for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)):
521
# Load the next block of A and B, generate a mask by checking the
522
# K dimension.
501
- if SWAP_AB:
523
+ if USE_TD:
524
+ a = a_desc.gather(gather_idx, k * BLOCK_SIZE_K)
525
+ b = b_desc.load([pid_n * BLOCK_SIZE_N, k * BLOCK_SIZE_K]).T
526
+ elif SWAP_AB:
527
a_mask = (offs_k[:, None] < K - k * BLOCK_SIZE_K) & token_mask[None, :]
528
b_mask = offs_k[None, :] < K - k * BLOCK_SIZE_K
529
+ a = tl.load(a_ptrs, mask=a_mask, other=0.0)
530
+ b = tl.load(b_ptrs, mask=b_mask, other=0.0)
531
else:
505
- a_mask = token_mask[:, None] & (offs_k[None, :] < K - k * BLOCK_SIZE_K)
506
- b_mask = offs_k[:, None] < K - k * BLOCK_SIZE_K
507
- a = tl.load(
508
- a_ptrs,
509
- mask=a_mask,
510
- other=0.0,
511
- )
512
- b = tl.load(b_ptrs, mask=b_mask, other=0.0)
532
+ a = tl.load(
533
+ a_ptrs,
534
+ mask=token_mask[:, None] & (offs_k[None, :] < K - k * BLOCK_SIZE_K),
535
+ other=0.0,
536
+ )
537
+ b = tl.load(b_ptrs, mask=offs_k[:, None] < K - k * BLOCK_SIZE_K, other=0.0)
538
# We accumulate along the K dimension.
539
if use_int8_w8a16:
540
accumulator = tl.dot(a, b.to(compute_type), acc=accumulator)
@@ -536,9 +561,10 @@ def fused_moe_kernel(
561
accumulator += tl.dot(a, b)
562
else:
563
accumulator += tl.dot(a, b)
539
- # Advance the ptrs to the next K block.
540
- a_ptrs += BLOCK_SIZE_K * stride_ak
541
- b_ptrs += BLOCK_SIZE_K * stride_bk
564
+ if not USE_TD:
565
+ # Advance the ptrs to the next K block.
566
+ a_ptrs += BLOCK_SIZE_K * stride_ak
567
+ b_ptrs += BLOCK_SIZE_K * stride_bk
568
569
if SWAP_AB:
570
accumulator = tl.trans(accumulator, (1, 0))
@@ -765,6 +791,19 @@ def invoke_fused_moe_triton_kernel(
791
else:
792
SWAP_AB = False
793
794
+ # Quantized weights always carry a B_scale (see the asserts below); key off
795
+ # that rather than enumerating quant flags, which misses w8a16-fp8/nvfp4/etc.
796
+ is_quantized = B_scale is not None
797
+ warn_if_moe_use_td_ineffective("TRITON", is_quantized=is_quantized)
798
+
799
+ # TD path is unvalidated under quantization; fall back to the pointer path.
800
+ use_td = resolve_moe_use_td() and not is_quantized
801
+ if use_td:
802
+ # The TD path builds a tensor descriptor inside the kernel, which
803
+ # requires a PyTorch-backed scratch allocator to be registered
804
+ # (Triton raises "no allocator was set" otherwise on CUDA).
805
+ set_triton_allocator(A.device)
806
+
807
if use_fp8_w8a8 or use_int8_w8a8:
808
assert B_scale is not None
809
assert block_shape is None or triton.cdiv(
@@ -805,6 +844,19 @@ def invoke_fused_moe_triton_kernel(
844
BLOCK_SIZE_K = config.pop("BLOCK_SIZE_K")
845
if block_shape is not None:
846
BLOCK_SIZE_K = min(BLOCK_SIZE_K, min(block_shape[0], block_shape[1]))
847
+ if use_td and A.size(1) % BLOCK_SIZE_K != 0:
848
+ # TD gather/load feeding tl.dot with a non-block-aligned K
849
+ # miscompiles (~74% of output elements wrong) on real HW;
850
+ # this is a compiler-codegen issue, not a Python-maskable
851
+ # boundary gap. Fall back to the pointer-arith path.
852
+ logger.warning_once(
853
+ "Disabling VLLM_TRITON_USE_TD for this MoE launch: K=%d is not "
854
+ "a multiple of BLOCK_SIZE_K=%d, which triggers a known "
855
+ "Triton tensor-descriptor + tl.dot miscompilation.",
856
+ A.size(1),
857
+ BLOCK_SIZE_K,
858
+ )
859
+ use_td = False
860
fused_moe_kernel[grid](
861
A,
862
B,
@@ -847,6 +899,7 @@ def invoke_fused_moe_triton_kernel(
899
HAS_BIAS=HAS_BIAS,
900
BLOCK_SIZE_K=BLOCK_SIZE_K,
901
SWAP_AB=SWAP_AB,
902
+ USE_TD=use_td,
903
**config,
904
)
905
vllm/model_executor/layers/fused_moe/oracle/unquantized.py
+7
@@ -362,6 +362,13 @@ def make_unquantized_moe_kernel(
362
experts_cls: type[mk.FusedMoEExperts],
363
routing_tables: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None,
364
) -> mk.FusedMoEKernel:
365
+ from vllm.model_executor.layers.fused_moe.utils import (
366
+ warn_if_moe_use_td_ineffective,
367
+ )
368
+
369
+ # Warn against the selected backend, not each probed candidate.
370
+ warn_if_moe_use_td_ineffective(backend.value, is_quantized=False)
371
+
372
# Create Prepare/Finalize
373
is_monolithic = issubclass(experts_cls, mk.FusedMoEExpertsMonolithic)
374
prepare_finalize = maybe_make_prepare_finalize(
vllm/model_executor/layers/fused_moe/utils.py
+81
@@ -7,7 +7,9 @@ from typing import TYPE_CHECKING
7
import torch
8
import torch.nn.functional as F
9
10
+import vllm.envs as envs
11
from vllm import _custom_ops as ops
12
+from vllm.logger import init_logger
13
from vllm.model_executor.layers.quantization.utils.fp8_utils import (
14
per_token_group_quant_fp8,
15
)
@@ -39,6 +41,8 @@ from vllm.utils.math_utils import cdiv
41
if TYPE_CHECKING:
42
from vllm.model_executor.layers.fused_moe.config import FusedMoEConfig
43
44
+logger = init_logger(__name__)
45
+
46
47
@triton.jit
48
def _count_expert_num_tokens(
@@ -585,3 +589,80 @@ def enable_swap_ab(BLOCK_SIZE_M: int, BLOCK_SIZE_N: int) -> bool:
589
and BLOCK_SIZE_M < 64
590
and BLOCK_SIZE_N >= 64
591
)
592
+
593
+
594
+def moe_use_td_hw_supported() -> bool:
595
+ """Whether the current device can run the TD (gather) path of
596
+ ``fused_moe_kernel`` (ignores the ``VLLM_TRITON_USE_TD`` override).
597
+
598
+ The A-load uses ``tensor_descriptor.gather``, which lowers to the PTX
599
+ ``tile::gather4`` instruction. That instruction is part of the
600
+ ``tcgen05``/Tensor Memory (TMEM) family introduced with Blackwell and has
601
+ no Hopper (sm90) equivalent -- ptxas rejects it there ("Feature
602
+ '.tile::gather4 ...' requires .target sm_100 or higher"). Unlike
603
+ ``scatter4``, ``gather4`` is supported across the whole sm100+ range
604
+ including consumer Blackwell (sm120/sm121): see triton-lang/triton#8498,
605
+ which enables ``gather4`` on sm120/sm121 while leaving ``scatter4``
606
+ unsupported there. So this gates on a blanket ``has_device_capability(100)``
607
+ rather than the sm100 *family* check used for the scatter store path.
608
+ """
609
+ if current_platform.is_xpu():
610
+ return True
611
+ if current_platform.is_cuda():
612
+ return current_platform.has_device_capability(100)
613
+ return False
614
+
615
+
616
+def resolve_moe_use_td() -> bool:
617
+ """Tri-state resolver for ``VLLM_TRITON_USE_TD``.
618
+
619
+ Unset auto-selects the TD path on XPU only, mirroring the attention
620
+ dispatcher in ``triton_attn.py``. ``1``/``0`` force it on/off regardless
621
+ of hardware; forcing ``1`` where it cannot compile (see
622
+ ``moe_use_td_hw_supported``) fails at ptxas. Blackwell CUDA (sm100+) can
623
+ compile it but is opt-in only, pending validation.
624
+ """
625
+ override = envs.VLLM_TRITON_USE_TD
626
+ if override is None:
627
+ return current_platform.is_xpu()
628
+ return override
629
+
630
+
631
+_warned_moe_use_td_ineffective = False
632
+
633
+
634
+def warn_if_moe_use_td_ineffective(
635
+ active_backend: str, is_quantized: bool = False
636
+) -> None:
637
+ """One-shot warning when ``VLLM_TRITON_USE_TD`` is set but ignored.
638
+
639
+ Fires when the user set the env explicitly and either (a) the active
640
+ MoE backend is not the fused Triton kernel, or (b) the model is
641
+ quantized (the TD path falls back to the pointer path under any
642
+ quantization).
643
+ """
644
+ global _warned_moe_use_td_ineffective
645
+ if _warned_moe_use_td_ineffective:
646
+ return
647
+ if envs.VLLM_TRITON_USE_TD is None:
648
+ return
649
+ is_triton = active_backend.upper() == "TRITON"
650
+ if is_triton and not is_quantized:
651
+ return
652
+ if not is_triton:
653
+ reason = (
654
+ f"the active MoE backend is {active_backend!r}; pass "
655
+ "`--moe-backend triton` to enable the tensor-descriptor path"
656
+ )
657
+ else:
658
+ reason = (
659
+ "the model uses quantized MoE weights; the TD path is "
660
+ "currently restricted to non-quantized weights and falls "
661
+ "back to the pointer path"
662
+ )
663
+ logger.warning(
664
+ "VLLM_TRITON_USE_TD is set to %s but %s.",
665
+ envs.VLLM_TRITON_USE_TD,
666
+ reason,
667
+ )
668
+ _warned_moe_use_td_ineffective = True