[Kernel] Vectorized fp32 `moe_sum` reduction and support any topk (#46643)

Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>

Michael Goin committed Jun 25, 2026 at 15:02 UTC e8c24a769576fa318ca93fbd927128246a534325
2 files changed +177 -51
csrc/libtorch_stable/moe/moe_align_sum_kernels.cu
+161 -47
@@ -11,6 +11,7 @@
11 #include "../../cuda_compat.h"
12 #include "libtorch_stable/core/math.hpp"
13 #include "libtorch_stable/dispatch_utils.h"
14 +#include "libtorch_stable/quantization/vectorization.cuh"
15 #include "libtorch_stable/torch_utils.h"
16
17 #define CEILDIV(x, y) (((x) + (y) - 1) / (y))
@@ -349,19 +350,102 @@ __global__ void count_and_sort_expert_tokens_kernel(
350 max_num_tokens_padded, nullptr, 0, topk_num, has_expert_map);
351 }
352
353 +// Reduce the topk expert outputs per token (summed in fp32). The output is
354 +// dense [num_tokens, d]; the input is addressed by its strides so non-
355 +// contiguous inputs work without a copy. A 16B-vectorized path is used when
356 +// the hidden dim is contiguous (innermost stride 1) and aligned; otherwise a
357 +// scalar kernel reads via arbitrary strides. topk is a compile-time constant
358 +// for common values and runtime otherwise.
359 +
360 +// Elements per 16-byte vector (8 for bf16/fp16, 4 for fp32).
361 +template <typename scalar_t>
362 +constexpr int MOE_SUM_VEC = 16 / sizeof(scalar_t);
363 +
364 template <typename scalar_t, int TOPK>
353 -__global__ void moe_sum_kernel(
354 - scalar_t* __restrict__ out, // [..., d]
355 - const scalar_t* __restrict__ input, // [..., topk, d]
356 - const int d) {
357 - const int64_t token_idx = blockIdx.x;
358 - for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) {
359 - scalar_t x = 0.0;
365 +__global__ void moe_sum_vec_kernel(
366 + scalar_t* __restrict__ out, // [num_tokens, d], contiguous
367 + const scalar_t* __restrict__ input, // [num_tokens, topk, d], d contiguous
368 + const int64_t num_tokens, const int d, const int64_t stride_token,
369 + const int64_t stride_topk) {
370 + using vec_t = vllm::vec_n_t<scalar_t, MOE_SUM_VEC<scalar_t>>; // 16-byte pack
371 + constexpr int VEC = MOE_SUM_VEC<scalar_t>;
372 + const int64_t n_vec = d / VEC;
373 + const int64_t total = num_tokens * n_vec;
374 + for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total;
375 + i += (int64_t)gridDim.x * blockDim.x) {
376 + const int64_t token = i / n_vec;
377 + const int64_t v = i % n_vec;
378 + const scalar_t* in_tok = input + token * stride_token + v * VEC;
379 +
380 + float acc[VEC];
381 +#pragma unroll
382 + for (int j = 0; j < VEC; ++j) acc[j] = 0.f;
383 +
384 #pragma unroll
385 for (int k = 0; k < TOPK; ++k) {
362 - x += VLLM_LDG(&input[token_idx * TOPK * d + k * d + idx]);
386 + vec_t packed = *reinterpret_cast<const vec_t*>(in_tok + k * stride_topk);
387 +#pragma unroll
388 + for (int j = 0; j < VEC; ++j) acc[j] += static_cast<float>(packed.val[j]);
389 + }
390 +
391 + vec_t outp;
392 +#pragma unroll
393 + for (int j = 0; j < VEC; ++j) outp.val[j] = static_cast<scalar_t>(acc[j]);
394 + *reinterpret_cast<vec_t*>(out + token * d + v * VEC) = outp;
395 + }
396 +}
397 +
398 +// Runtime-topk variant of the above.
399 +template <typename scalar_t>
400 +__global__ void moe_sum_vec_dynamic_kernel(
401 + scalar_t* __restrict__ out, // [num_tokens, d], contiguous
402 + const scalar_t* __restrict__ input, // [num_tokens, topk, d], d contiguous
403 + const int64_t num_tokens, const int d, const int topk,
404 + const int64_t stride_token, const int64_t stride_topk) {
405 + using vec_t = vllm::vec_n_t<scalar_t, MOE_SUM_VEC<scalar_t>>;
406 + constexpr int VEC = MOE_SUM_VEC<scalar_t>;
407 + const int64_t n_vec = d / VEC;
408 + const int64_t total = num_tokens * n_vec;
409 + for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total;
410 + i += (int64_t)gridDim.x * blockDim.x) {
411 + const int64_t token = i / n_vec;
412 + const int64_t v = i % n_vec;
413 + const scalar_t* in_tok = input + token * stride_token + v * VEC;
414 +
415 + float acc[VEC];
416 +#pragma unroll
417 + for (int j = 0; j < VEC; ++j) acc[j] = 0.f;
418 +
419 + for (int k = 0; k < topk; ++k) {
420 + vec_t packed = *reinterpret_cast<const vec_t*>(in_tok + k * stride_topk);
421 +#pragma unroll
422 + for (int j = 0; j < VEC; ++j) acc[j] += static_cast<float>(packed.val[j]);
423 }
364 - out[token_idx * d + idx] = x;
424 +
425 + vec_t outp;
426 +#pragma unroll
427 + for (int j = 0; j < VEC; ++j) outp.val[j] = static_cast<scalar_t>(acc[j]);
428 + *reinterpret_cast<vec_t*>(out + token * d + v * VEC) = outp;
429 + }
430 +}
431 +
432 +// Stride-aware scalar fallback: handles unaligned/non-vectorizable hidden dims
433 +// (including a non-contiguous hidden stride) via per-element strided reads.
434 +template <typename scalar_t>
435 +__global__ void moe_sum_scalar_kernel(
436 + scalar_t* __restrict__ out, // [num_tokens, d], contiguous
437 + const scalar_t* __restrict__ input, // [num_tokens, topk, d]
438 + const int d, const int topk, const int64_t stride_token,
439 + const int64_t stride_topk, const int64_t stride_hidden) {
440 + const int64_t token_idx = blockIdx.x;
441 + const scalar_t* in_tok = input + token_idx * stride_token;
442 + for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) {
443 + float x = 0.f;
444 + for (int k = 0; k < topk; ++k) {
445 + x += static_cast<float>(
446 + VLLM_LDG(&in_tok[k * stride_topk + idx * stride_hidden]));
447 + }
448 + out[token_idx * d + idx] = static_cast<scalar_t>(x);
449 }
450 }
451
@@ -626,52 +710,82 @@ void batched_moe_align_block_size(int64_t max_tokens_per_batch,
710 void moe_sum(torch::stable::Tensor& input, // [num_tokens, topk, hidden_size]
711 torch::stable::Tensor& output) // [num_tokens, hidden_size]
712 {
713 + // Output is dense and written in place, so it must be contiguous. The input
714 + // is read by its strides (no copy); only the hidden dim needs to be
715 + // contiguous to take the vectorized path.
716 + STD_TORCH_CHECK(output.is_contiguous(),
717 + "moe_sum expects a contiguous output");
718 +
719 const int hidden_size = input.size(-1);
630 - const auto num_tokens = output.numel() / hidden_size;
720 + const int64_t num_tokens = output.numel() / hidden_size;
721 const int topk = input.size(1);
722 + const int64_t stride_token = input.stride(0);
723 + const int64_t stride_topk = input.stride(1);
724 + const int64_t stride_hidden = input.stride(2);
725
633 - dim3 grid(num_tokens);
634 - dim3 block(std::min(hidden_size, 1024));
726 const torch::stable::accelerator::DeviceGuard device_guard(
727 output.get_device_index());
728 const cudaStream_t stream =
729 get_current_cuda_stream(output.get_device_index());
730
640 - switch (topk) {
641 - case 2:
642 - VLLM_STABLE_DISPATCH_FLOATING_TYPES(
643 - input.scalar_type(), "moe_sum_kernel", [&] {
644 - vllm::moe::moe_sum_kernel<scalar_t, 2><<<grid, block, 0, stream>>>(
645 - reinterpret_cast<scalar_t*>(output.mutable_data_ptr()),
646 - reinterpret_cast<const scalar_t*>(input.const_data_ptr()),
647 - hidden_size);
648 - });
649 - break;
650 -
651 - case 3:
652 - VLLM_STABLE_DISPATCH_FLOATING_TYPES(
653 - input.scalar_type(), "moe_sum_kernel", [&] {
654 - vllm::moe::moe_sum_kernel<scalar_t, 3><<<grid, block, 0, stream>>>(
655 - reinterpret_cast<scalar_t*>(output.mutable_data_ptr()),
656 - reinterpret_cast<const scalar_t*>(input.const_data_ptr()),
657 - hidden_size);
658 - });
659 - break;
660 -
661 - case 4:
662 - VLLM_STABLE_DISPATCH_FLOATING_TYPES(
663 - input.scalar_type(), "moe_sum_kernel", [&] {
664 - vllm::moe::moe_sum_kernel<scalar_t, 4><<<grid, block, 0, stream>>>(
665 - reinterpret_cast<scalar_t*>(output.mutable_data_ptr()),
666 - reinterpret_cast<const scalar_t*>(input.const_data_ptr()),
667 - hidden_size);
668 - });
669 - break;
670 -
671 - default:
672 - torch::stable::sum_out(output, input, std::array<int64_t, 1>{1});
673 - break;
674 - }
731 +#define LAUNCH_MOE_SUM_VEC(TOPK) \
732 + vllm::moe::moe_sum_vec_kernel<scalar_t, TOPK> \
733 + <<<grid, dim3(block), 0, stream>>>( \
734 + out_ptr, in_ptr, num_tokens, hidden_size, stride_token, stride_topk)
735 +
736 + VLLM_STABLE_DISPATCH_FLOATING_TYPES(input.scalar_type(), "moe_sum", [&] {
737 + constexpr int VEC = vllm::moe::MOE_SUM_VEC<scalar_t>;
738 + constexpr int WIDTH = VEC * sizeof(scalar_t); // 16 bytes
739 + auto* out_ptr = reinterpret_cast<scalar_t*>(output.mutable_data_ptr());
740 + auto* in_ptr = reinterpret_cast<const scalar_t*>(input.const_data_ptr());
741 +
742 + // Vectorize along hidden only when it is contiguous (innermost stride 1),
743 + // a whole number of vectors, and every row offset stays 16B-aligned.
744 + const bool can_vec = (stride_hidden == 1) && (hidden_size % VEC == 0) &&
745 + (stride_token % VEC == 0) &&
746 + (stride_topk % VEC == 0) &&
747 + (reinterpret_cast<uintptr_t>(in_ptr) % WIDTH == 0) &&
748 + (reinterpret_cast<uintptr_t>(out_ptr) % WIDTH == 0);
749 + if (can_vec) {
750 + const int64_t n_vec = hidden_size / VEC;
751 + const int64_t total = num_tokens * n_vec;
752 + const int block = 256;
753 + const dim3 grid(std::min<int64_t>((total + block - 1) / block, 65535));
754 + switch (topk) {
755 + case 1:
756 + LAUNCH_MOE_SUM_VEC(1);
757 + break;
758 + case 2:
759 + LAUNCH_MOE_SUM_VEC(2);
760 + break;
761 + case 4:
762 + LAUNCH_MOE_SUM_VEC(4);
763 + break;
764 + case 6:
765 + LAUNCH_MOE_SUM_VEC(6);
766 + break;
767 + case 8:
768 + LAUNCH_MOE_SUM_VEC(8);
769 + break;
770 + case 9:
771 + LAUNCH_MOE_SUM_VEC(9);
772 + break;
773 + default:
774 + vllm::moe::moe_sum_vec_dynamic_kernel<scalar_t>
775 + <<<grid, dim3(block), 0, stream>>>(out_ptr, in_ptr, num_tokens,
776 + hidden_size, topk,
777 + stride_token, stride_topk);
778 + break;
779 + }
780 + } else {
781 + dim3 grid(num_tokens);
782 + dim3 block(std::min(hidden_size, 1024));
783 + vllm::moe::moe_sum_scalar_kernel<scalar_t><<<grid, block, 0, stream>>>(
784 + out_ptr, in_ptr, hidden_size, topk, stride_token, stride_topk,
785 + stride_hidden);
786 + }
787 + });
788 +#undef LAUNCH_MOE_SUM_VEC
789 }
790
791 void moe_lora_align_block_size(
tests/kernels/moe/test_moe.py
+16 -4
@@ -1243,15 +1243,27 @@ def test_batched_moe_align_block_size_opcheck():
1243 )
1244
1245
1246 +# topk=8 covers topk > 4; k=511 covers the non-vectorized scalar path. The
1247 +# layouts exercise contiguous input plus the two non-contiguous cases: a
1248 +# transpose (strided hidden -> scalar gather) and a topk-slice (hidden still
1249 +# contiguous -> vectorized).
1250 @pytest.mark.parametrize("m", [1, 33, 222])
1247 -@pytest.mark.parametrize("topk", TOP_KS)
1251 +@pytest.mark.parametrize("topk", [*TOP_KS, 8])
1252 @pytest.mark.parametrize("k", [128, 511, 1024])
1253 @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
1250 -def test_moe_sum(m: int, topk: int, k: int, dtype: torch.dtype):
1251 - input = torch.randn((m, topk, k), device="cuda", dtype=dtype)
1254 +@pytest.mark.parametrize("layout", ["contig", "transpose", "slice"])
1255 +def test_moe_sum(m: int, topk: int, k: int, dtype: torch.dtype, layout: str):
1256 + if layout == "transpose":
1257 + input = torch.randn((m, k, topk), device="cuda", dtype=dtype).transpose(1, 2)
1258 + elif layout == "slice":
1259 + input = torch.randn((m, 2 * topk, k), device="cuda", dtype=dtype)[:, ::2, :]
1260 + else:
1261 + input = torch.randn((m, topk, k), device="cuda", dtype=dtype)
1262 + assert input.is_contiguous() == (layout == "contig")
1263 actual = torch.empty((m, k), device="cuda", dtype=dtype)
1264
1254 - expected = input.sum(dim=1)
1265 + # Reduction accumulates in fp32.
1266 + expected = input.float().sum(dim=1).to(dtype)
1267 torch.ops._moe_C.moe_sum(input, actual)
1268
1269 torch.testing.assert_close(actual, expected, atol=2e-2, rtol=0)