[Quantization][Humming] Support MXFP4 weight + block-FP8 activation for MoE (#51332)

Signed-off-by: Elvir Crncevic <elvircrn@gmail.com> Signed-off-by: Tyler Michael Smith <tlrmchlsmth@gmail.com> Signed-off-by: Elvir Crnčević <elvircrn@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Tyler Michael Smith <tlrmchlsmth@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Elvir Crnčević committed Aug 25, 2026 at 16:21 UTC ca29cd48ba05ac9ae1c9fa29271e9f899bc4ce56
17 files changed +1599 -176
csrc/libtorch_stable/activation_kernels.cu
+401 -14
@@ -1,9 +1,17 @@
1 #include <cuda.h>
2 +#include <cuda_runtime.h>
3 #include <torch/csrc/stable/tensor.h>
4 +#include <torch/headeronly/util/Float8_e4m3fn.h>
5 +
6 +#ifndef USE_ROCM
7 + #include <cuda_fp8.h>
8 +#endif
9
10 #include <cmath>
11 +#include <type_traits>
12
13 #include "../cuda_compat.h"
14 +#include "async_util.cuh"
15 #include "cuda_vec_utils.cuh"
16 #include "dispatch_utils.h"
17 #include "torch_utils.h"
@@ -471,15 +479,31 @@ __global__ void swigluoai_and_mul_kernel(
479 // gate_out = beta * tanh(gate / beta) * sigmoid(gate)
480 // up_out = (linear_beta > 0) ? linear_beta * tanh(up / linear_beta) : up
481 // out = gate_out * up_out
474 -// Compute is done in fp32 and written straight to `out` -- no intermediate
475 -// tensors and no full-tensor fp32 upcast (the pure-torch forward_native
476 -// allocated ~8 fp32 temporaries per call, which blows up MoE profiling).
482 +__device__ __forceinline__ float situ_tanh(float x) { return tanhf(x); }
483 +
484 +// Kimi-K3 SITU params; baked into the fused LDG kernel to fold at compile time.
485 +static constexpr float SITU_BETA = 4.0f;
486 +static constexpr float SITU_LINEAR_BETA = 25.0f;
487 +__device__ __forceinline__ float situ_activation(float g, float u, float beta,
488 + float linear_beta,
489 + bool clamp_up, float inv_beta,
490 + float inv_linear_beta) {
491 + // sigmoid(g) == (1 + tanh(g/2)) / 2.
492 + const float gate_out =
493 + (0.5f * beta) * situ_tanh(g * inv_beta) * (1.0f + situ_tanh(g * 0.5f));
494 + const float up_out =
495 + clamp_up ? linear_beta * situ_tanh(u * inv_linear_beta) : u;
496 + return gate_out * up_out;
497 +}
498 +
499 template <typename scalar_t>
500 __global__ void situ_and_mul_kernel(
501 scalar_t* __restrict__ out, // [..., d]
502 const scalar_t* __restrict__ input, // [..., 2, d]
481 - const int d, const float beta, const float linear_beta) {
503 + const int d, const float beta, const float linear_beta,
504 + const int64_t* __restrict__ valid_rows_ptr) {
505 const int64_t row = blockIdx.x;
506 + if (valid_rows_ptr != nullptr && row >= *valid_rows_ptr) return;
507 const scalar_t* gate_ptr = input + row * 2 * d;
508 const scalar_t* up_ptr = gate_ptr + d;
509 scalar_t* out_ptr = out + row * d;
@@ -489,10 +513,258 @@ __global__ void situ_and_mul_kernel(
513 for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) {
514 const float g = (float)VLLM_LDG(&gate_ptr[idx]);
515 const float u = (float)VLLM_LDG(&up_ptr[idx]);
492 - const float gate_out = beta * tanhf(g * inv_beta) / (1.0f + expf(-g));
493 - const float up_out =
494 - clamp_up ? linear_beta * tanhf(u * inv_linear_beta) : u;
495 - out_ptr[idx] = (scalar_t)(gate_out * up_out);
516 + out_ptr[idx] = (scalar_t)situ_activation(g, u, beta, linear_beta, clamp_up,
517 + inv_beta, inv_linear_beta);
518 + }
519 +}
520 +
521 +// Match Humming's hardware FP8 conversion. c10::Float8_e4m3fn's software cast
522 +// can round ties differently from `cvt.rn.satfinite.e4m3.f32`.
523 +__device__ __forceinline__ c10::Float8_e4m3fn cvt_fp8_hw(float x) {
524 +#if !defined(USE_ROCM) && defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 800
525 + return c10::Float8_e4m3fn(__nv_cvt_float_to_fp8(x, __NV_SATFINITE, __NV_E4M3),
526 + c10::Float8_e4m3fn::from_bits());
527 +#else
528 + return static_cast<c10::Float8_e4m3fn>(x);
529 +#endif
530 +}
531 +
532 +template <typename fp8_type>
533 +__device__ __forceinline__ fp8_type quant_to_fp8(float val, float inv_scale,
534 + float fp8_max) {
535 + float x = val * inv_scale;
536 + x = fmaxf(-fp8_max, fminf(x, fp8_max));
537 + if constexpr (std::is_same_v<fp8_type, c10::Float8_e4m3fn>) {
538 + return cvt_fp8_hw(x);
539 + } else {
540 + return static_cast<fp8_type>(x);
541 + }
542 +}
543 +
544 +__device__ __forceinline__ float warp_reduce_max(float v) {
545 +#pragma unroll
546 + for (int offset = WARP_SIZE / 2; offset > 0; offset >>= 1) {
547 + v = fmaxf(v, VLLM_SHFL_XOR_SYNC(v, offset));
548 + }
549 + return v;
550 +}
551 +
552 +__device__ __forceinline__ float warp_reduce_absmax(float v) {
553 + return warp_reduce_max(v);
554 +}
555 +
556 +template <typename scalar_t, typename fp8_type, int THREADS, int NUM_STAGES,
557 + int GROUP_SIZE, int GRID_DIM, int D>
558 +__global__ void situ_and_mul_quant_group_pipelined_kernel(
559 + fp8_type* __restrict__ out, float* __restrict__ scale_out,
560 + const scalar_t* __restrict__ input, const int64_t num_rows,
561 + const int32_t* __restrict__ valid_rows_ptr, const int64_t topk) {
562 + const int tid = threadIdx.x;
563 + const int warp_id = tid / WARP_SIZE;
564 + const int lane_id = tid % WARP_SIZE;
565 + static constexpr int NUM_WARPS = THREADS / WARP_SIZE;
566 + static constexpr int NUM_GROUPS = D / GROUP_SIZE;
567 + const int64_t row_bound =
568 + valid_rows_ptr != nullptr
569 + ? max((int64_t)0, min((int64_t)(*valid_rows_ptr) * topk, num_rows))
570 + : num_rows;
571 +
572 + static_assert(NUM_GROUPS % 4 == 0,
573 + "float4 scale fill needs NUM_GROUPS % 4 == 0");
574 + static constexpr int NG4 = NUM_GROUPS / 4;
575 + const int64_t pad_start4 = row_bound * NG4;
576 + const int64_t pad_end4 = num_rows * NG4;
577 + float4* scale4 = reinterpret_cast<float4*>(scale_out);
578 + constexpr float4 ones{1.0f, 1.0f, 1.0f, 1.0f};
579 + for (int64_t i = pad_start4 + (int64_t)blockIdx.x * THREADS + tid;
580 + i < pad_end4; i += (int64_t)GRID_DIM * THREADS) {
581 + scale4[i] = ones;
582 + }
583 +
584 + if constexpr (sizeof(scalar_t) == 2) {
585 + static constexpr int LD_ELTS = 16 / sizeof(scalar_t);
586 + static constexpr int ELTS_PER_LANE = GROUP_SIZE / WARP_SIZE;
587 + static constexpr int STAGE_ELTS = 2 * GROUP_SIZE;
588 +
589 + extern __shared__ __align__(16) unsigned char smem_raw[];
590 + scalar_t* warp_smem = reinterpret_cast<scalar_t*>(smem_raw) +
591 + (size_t)warp_id * NUM_STAGES * STAGE_ELTS;
592 +
593 + static constexpr float beta = SITU_BETA;
594 + static constexpr float linear_beta = SITU_LINEAR_BETA;
595 + static constexpr bool clamp_up = linear_beta > 0.0f;
596 + static constexpr float inv_beta = 1.0f / beta;
597 + static constexpr float inv_linear_beta =
598 + clamp_up ? 1.0f / linear_beta : 0.0f;
599 + static constexpr float FP8_MAX =
600 + std::is_same_v<fp8_type, c10::Float8_e4m3fn> ? 448.0f : 224.0f;
601 + static constexpr float inv_fp8_max = 1.0f / FP8_MAX;
602 +
603 + static_assert(
604 + NUM_GROUPS % NUM_WARPS == 0,
605 + "constexpr num_iters requires groups evenly split across warps");
606 + static constexpr int num_iters = NUM_GROUPS / NUM_WARPS;
607 +
608 + const bool up_half = lane_id >= WARP_SIZE / 2;
609 + const int lane_l = up_half ? lane_id - WARP_SIZE / 2 : lane_id;
610 + const int lane_src_off = (up_half ? D : 0) + lane_l * LD_ELTS;
611 + const int lane_dst_off = (up_half ? GROUP_SIZE : 0) + lane_l * LD_ELTS;
612 + static constexpr int warp_stride = NUM_WARPS * GROUP_SIZE;
613 +
614 + const scalar_t* src_ptr = input + warp_id * GROUP_SIZE + lane_src_off;
615 + static constexpr int src_row_stride = GRID_DIM * 2 * D;
616 + const scalar_t* row_src = src_ptr + blockIdx.x * 2 * D;
617 +
618 + uint32_t* out_ptr =
619 + reinterpret_cast<uint32_t*>(out + warp_id * GROUP_SIZE) + lane_id;
620 + static constexpr int out_row_stride = GRID_DIM * D / 4;
621 + uint32_t* row_out = out_ptr + blockIdx.x * (D / 4);
622 + float* scale_ptr = scale_out + warp_id;
623 + static constexpr int scale_row_stride = GRID_DIM * NUM_GROUPS;
624 + float* row_scale = scale_ptr + blockIdx.x * NUM_GROUPS;
625 +
626 +#pragma unroll 1
627 + for (int64_t row = blockIdx.x; row < row_bound; row += GRID_DIM,
628 + row_src += src_row_stride, row_out += out_row_stride,
629 + row_scale += scale_row_stride) {
630 + const scalar_t* src = row_src;
631 +
632 + auto issue_load = [&](int it, int slot) {
633 + if (it < num_iters) {
634 + cuda_async::cp_async_shared_global_16_cg(
635 + warp_smem + (size_t)slot * STAGE_ELTS + lane_dst_off, src);
636 + src += warp_stride;
637 + }
638 + cuda_async::cp_async_commit_group();
639 + };
640 +
641 + int load_slot = 0;
642 + auto bump = [](int s) { return s + 1 == NUM_STAGES ? 0 : s + 1; };
643 +#pragma unroll
644 + for (int s = 0; s < NUM_STAGES - 1; s++) {
645 + issue_load(s, load_slot);
646 + load_slot = bump(load_slot);
647 + }
648 +
649 + uint32_t* out_st = row_out;
650 + float* scale_st = row_scale;
651 +
652 + int comp_slot = 0;
653 + for (int it = 0; it < num_iters; it++) {
654 + issue_load(it + NUM_STAGES - 1, load_slot);
655 + load_slot = bump(load_slot);
656 + cuda_async::cp_async_wait_group<NUM_STAGES - 1>();
657 +
658 + const scalar_t* stage = warp_smem + (size_t)comp_slot * STAGE_ELTS;
659 + comp_slot = bump(comp_slot);
660 + static_assert(ELTS_PER_LANE == 4,
661 + "expects GROUP_SIZE == 4 * WARP_SIZE");
662 + union V {
663 + float2 f2;
664 + scalar_t s[ELTS_PER_LANE];
665 + };
666 + const float2* gate2 = reinterpret_cast<const float2*>(stage);
667 + const float2* up2 = reinterpret_cast<const float2*>(stage + GROUP_SIZE);
668 + const V gv{gate2[lane_id]};
669 + const V uv{up2[lane_id]};
670 + const scalar_t* gs = gv.s;
671 + const scalar_t* us = uv.s;
672 +
673 + float acts[ELTS_PER_LANE];
674 + float thread_max = 0.0f;
675 +#pragma unroll
676 + for (int e = 0; e < ELTS_PER_LANE; e++) {
677 + acts[e] = (float)(scalar_t)situ_activation(
678 + (float)gs[e], (float)us[e], beta, linear_beta, clamp_up, inv_beta,
679 + inv_linear_beta);
680 + thread_max = fmaxf(thread_max, fabsf(acts[e]));
681 + }
682 + const float absmax = fmaxf(warp_reduce_absmax(thread_max), 1e-30f);
683 + const float scale = absmax * inv_fp8_max;
684 + if (lane_id == 0) *scale_st = scale;
685 + scale_st += NUM_WARPS;
686 + const float inv_scale = __fdividef(1.0f, scale);
687 +
688 + union O {
689 + uint32_t u;
690 + fp8_type f[ELTS_PER_LANE];
691 + };
692 + O o;
693 +#pragma unroll
694 + for (int e = 0; e < ELTS_PER_LANE; e++) {
695 + o.f[e] = quant_to_fp8<fp8_type>(acts[e], inv_scale, FP8_MAX);
696 + }
697 + out_st[0] = o.u;
698 + out_st += NUM_WARPS * GROUP_SIZE / 4;
699 + }
700 + cuda_async::cp_async_wait_group<0>();
701 + }
702 + }
703 +}
704 +
705 +// Scalar fallback for the group path (odd d or the fp32 dispatch branch).
706 +template <typename scalar_t, typename fp8_type, int GROUP_SIZE>
707 +__global__ void situ_and_mul_quant_group_scalar_kernel(
708 + fp8_type* __restrict__ out, // [num_tokens, d]
709 + float* __restrict__ scale_out, // [num_tokens, num_groups]
710 + const scalar_t* __restrict__ input, // [num_tokens, 2, d]
711 + const int d, const int num_groups, const float beta,
712 + const float linear_beta, const int64_t num_rows,
713 + const int32_t* __restrict__ valid_rows_ptr, const int64_t topk) {
714 + static constexpr int ELTS_PER_LANE = GROUP_SIZE / WARP_SIZE;
715 + const int tid = threadIdx.x;
716 + const int warp_id = tid / WARP_SIZE;
717 + const int lane_id = tid % WARP_SIZE;
718 + const int num_warps = blockDim.x / WARP_SIZE;
719 + const int64_t row_bound =
720 + valid_rows_ptr != nullptr
721 + ? max((int64_t)0, min((int64_t)(*valid_rows_ptr) * topk, num_rows))
722 + : num_rows;
723 +
724 + const bool clamp_up = linear_beta > 0.0f;
725 + const float inv_beta = __fdividef(1.0f, beta);
726 + const float inv_linear_beta = clamp_up ? __fdividef(1.0f, linear_beta) : 0.0f;
727 + static constexpr float FP8_MAX =
728 + std::is_same_v<fp8_type, c10::Float8_e4m3fn> ? 448.0f : 224.0f;
729 + static constexpr float inv_fp8_max = 1.0f / FP8_MAX;
730 +
731 + for (int64_t row = blockIdx.x; row < row_bound; row += gridDim.x) {
732 + const scalar_t* gate_ptr = input + row * 2 * (int64_t)d;
733 + const scalar_t* up_ptr = gate_ptr + d;
734 + fp8_type* out_ptr = out + row * (int64_t)d;
735 + float* scale_row = scale_out + row * (int64_t)num_groups;
736 +
737 + for (int g = warp_id; g < num_groups; g += num_warps) {
738 + const int base = g * GROUP_SIZE;
739 + float acts[ELTS_PER_LANE];
740 + float thread_max = 0.0f;
741 +#pragma unroll
742 + for (int e = 0; e < ELTS_PER_LANE; e++) {
743 + const int idx = base + e * WARP_SIZE + lane_id;
744 + const float gv = (float)VLLM_LDG(&gate_ptr[idx]);
745 + const float uv = (float)VLLM_LDG(&up_ptr[idx]);
746 + acts[e] = (float)(scalar_t)situ_activation(
747 + gv, uv, beta, linear_beta, clamp_up, inv_beta, inv_linear_beta);
748 + thread_max = fmaxf(thread_max, fabsf(acts[e]));
749 + }
750 + const float absmax = fmaxf(warp_reduce_max(thread_max), 1e-30f);
751 + const float scale = absmax * inv_fp8_max;
752 + if (lane_id == 0) scale_row[g] = scale;
753 + const float inv_scale = __fdividef(1.0f, scale);
754 +#pragma unroll
755 + for (int e = 0; e < ELTS_PER_LANE; e++) {
756 + const int idx = base + e * WARP_SIZE + lane_id;
757 + out_ptr[idx] = quant_to_fp8<fp8_type>(acts[e], inv_scale, FP8_MAX);
758 + }
759 + }
760 + }
761 +
762 + // Fill skipped padding-row scales with 1 so they don't feed NaN into w2.
763 + const int64_t pad_start = row_bound * (int64_t)num_groups;
764 + const int64_t pad_end = num_rows * (int64_t)num_groups;
765 + for (int64_t i = pad_start + (int64_t)blockIdx.x * blockDim.x + tid;
766 + i < pad_end; i += (int64_t)gridDim.x * blockDim.x) {
767 + scale_out[i] = 1.0f;
768 }
769 }
770
@@ -519,10 +791,8 @@ __global__ void masked_situ_and_mul_kernel(
791 scalar_t* out_ptr = out + row * d;
792 const float g = (float)VLLM_LDG(&gate_ptr[idx]);
793 const float u = (float)VLLM_LDG(&up_ptr[idx]);
522 - const float gate_out = beta * tanhf(g * inv_beta) / (1.0f + expf(-g));
523 - const float up_out =
524 - clamp_up ? linear_beta * tanhf(u * inv_linear_beta) : u;
525 - out_ptr[idx] = (scalar_t)(gate_out * up_out);
794 + out_ptr[idx] = (scalar_t)situ_activation(g, u, beta, linear_beta, clamp_up,
795 + inv_beta, inv_linear_beta);
796 }
797 }
798
@@ -620,7 +890,8 @@ void swigluoai_and_mul(torch::stable::Tensor& out, // [..., d]
890 // through), matching SituAndMul(linear_beta=None) on the Python side.
891 void situ_and_mul(torch::stable::Tensor& out, // [..., d]
892 torch::stable::Tensor& input, // [..., 2 * d]
623 - double beta, double linear_beta) {
893 + double beta, double linear_beta,
894 + std::optional<torch::stable::Tensor> valid_rows) {
895 int d = input.size(-1) / 2;
896 int64_t num_tokens = input.numel() / input.size(-1);
897 if (num_tokens == 0) {
@@ -628,6 +899,14 @@ void situ_and_mul(torch::stable::Tensor& out, // [..., d]
899 }
900 dim3 grid(num_tokens);
901 dim3 block(std::min(d, 1024));
902 + const int64_t* valid_rows_ptr = nullptr;
903 + if (valid_rows.has_value()) {
904 + STD_TORCH_CHECK(valid_rows->is_cuda() && valid_rows->get_device_index() ==
905 + input.get_device_index(),
906 + "situ_and_mul: valid_rows must be a CUDA tensor on the "
907 + "same device as input");
908 + valid_rows_ptr = valid_rows->const_data_ptr<int64_t>();
909 + }
910 const torch::stable::accelerator::DeviceGuard device_guard(
911 input.get_device_index());
912 const cudaStream_t stream = get_current_cuda_stream();
@@ -635,7 +914,7 @@ void situ_and_mul(torch::stable::Tensor& out, // [..., d]
914 input.scalar_type(), "situ_and_mul_kernel", [&] {
915 vllm::situ_and_mul_kernel<scalar_t><<<grid, block, 0, stream>>>(
916 out.mutable_data_ptr<scalar_t>(), input.const_data_ptr<scalar_t>(),
638 - d, (float)beta, (float)linear_beta);
917 + d, (float)beta, (float)linear_beta, valid_rows_ptr);
918 });
919 }
920
@@ -663,6 +942,114 @@ void masked_situ_and_mul(torch::stable::Tensor& out, // [E, T, d]
942 (float)beta, (float)linear_beta);
943 });
944 }
945 +
946 +// Fused SITU activation and block-FP8 quantization for Humming w2.
947 +// `valid_rows` is the int32 DeepEP token count (psum[-1]); rows = it * `topk`,
948 +// so padding is excluded on-device and skipped rows receive scale 1.
949 +void situ_and_mul_quant(torch::stable::Tensor& out, // [..., d] (fp8)
950 + torch::stable::Tensor& scale, // [..., 1 or d/128]
951 + torch::stable::Tensor& input, // [..., 2 * d]
952 + double beta, double linear_beta, int64_t group_size,
953 + std::optional<torch::stable::Tensor> valid_rows,
954 + int64_t topk) {
955 + STD_TORCH_CHECK(
956 + out.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fn ||
957 + out.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fnuz,
958 + "situ_and_mul_quant output must be FP8 e4m3");
959 + STD_TORCH_CHECK(
960 + input.scalar_type() == torch::headeronly::ScalarType::Half ||
961 + input.scalar_type() == torch::headeronly::ScalarType::BFloat16,
962 + "situ_and_mul_quant input must be FP16 or BF16");
963 + STD_TORCH_CHECK(scale.scalar_type() == torch::headeronly::ScalarType::Float,
964 + "situ_and_mul_quant scale must be float32");
965 + STD_TORCH_CHECK(input.is_cuda() && out.is_cuda() && scale.is_cuda() &&
966 + out.get_device_index() == input.get_device_index() &&
967 + scale.get_device_index() == input.get_device_index(),
968 + "situ_and_mul_quant: input, out and scale must be CUDA "
969 + "tensors on the same device");
970 + int d = input.size(-1) / 2;
971 + int64_t num_tokens = input.numel() / input.size(-1);
972 + if (num_tokens == 0) {
973 + return;
974 + }
975 + STD_TORCH_CHECK(out.size(-1) == d && out.numel() == num_tokens * (int64_t)d,
976 + "situ_and_mul_quant: out shape must be [num_tokens, d]");
977 + const int32_t* valid_rows_ptr = nullptr;
978 + if (valid_rows.has_value()) {
979 + STD_TORCH_CHECK(valid_rows->is_cuda() && valid_rows->get_device_index() ==
980 + input.get_device_index(),
981 + "situ_and_mul_quant: valid_rows must be a CUDA tensor on "
982 + "the same device as input");
983 + STD_TORCH_CHECK(
984 + valid_rows->scalar_type() == torch::headeronly::ScalarType::Int,
985 + "situ_and_mul_quant: valid_rows must be int32 (psum count)");
986 + valid_rows_ptr = valid_rows->const_data_ptr<int32_t>();
987 + }
988 + const torch::stable::accelerator::DeviceGuard device_guard(
989 + input.get_device_index());
990 + const cudaStream_t stream = get_current_cuda_stream();
991 + static constexpr int BLOCKS_PER_SM = 8; // matches kernel __launch_bounds__
992 + static constexpr int SM_COUNT = 132; // H200 (GH100, 132 SMs)
993 + static constexpr int GRID_DIM = SM_COUNT * BLOCKS_PER_SM;
994 +
995 + STD_TORCH_CHECK(group_size == 128,
996 + "situ_and_mul_quant: only group_size 128 (block-FP8) "
997 + "supported, got ",
998 + group_size);
999 + STD_TORCH_CHECK(d % group_size == 0, "situ_and_mul_quant: d (", d,
1000 + ") must be divisible by group_size ", group_size);
1001 + {
1002 + const int num_groups = d / (int)group_size;
1003 + STD_TORCH_CHECK(scale.size(-1) == num_groups &&
1004 + scale.numel() == num_tokens * (int64_t)num_groups,
1005 + "situ_and_mul_quant: scale shape must be "
1006 + "[num_tokens, d/group_size]");
1007 + dim3 grid(GRID_DIM);
1008 + VLLM_STABLE_DISPATCH_FLOATING_TYPES(
1009 + input.scalar_type(), "situ_and_mul_quant_group_kernel", [&] {
1010 + VLLM_STABLE_DISPATCH_FP8_TYPES(
1011 + out.scalar_type(), "situ_and_mul_quant_group_kernel_fp8", [&] {
1012 +#ifndef USE_ROCM
1013 + // The pipelined kernel's float2-per-lane geometry assumes a
1014 + // 32-lane warp (GROUP_SIZE == 4 * WARP_SIZE); on HIP (64-lane)
1015 + // fall back to the WARP_SIZE-generic scalar kernel.
1016 + constexpr int THREADS = 256;
1017 + constexpr int SITU_D = 3072; // Kimi-K3 fused w2 input dim
1018 + if constexpr (sizeof(scalar_t) == 2) {
1019 + if (d == SITU_D && (float)beta == vllm::SITU_BETA &&
1020 + (float)linear_beta == vllm::SITU_LINEAR_BETA) {
1021 + constexpr int D = SITU_D;
1022 + constexpr int GROUP_STAGES = 4;
1023 + constexpr int NUM_WARPS = THREADS / 32;
1024 + constexpr int STAGE_ELTS = 2 * 128;
1025 + dim3 block(THREADS);
1026 + size_t smem_bytes = (size_t)NUM_WARPS * GROUP_STAGES *
1027 + STAGE_ELTS * sizeof(scalar_t);
1028 + vllm::situ_and_mul_quant_group_pipelined_kernel<
1029 + scalar_t, fp8_t, THREADS, GROUP_STAGES, 128, GRID_DIM,
1030 + D><<<grid, block, smem_bytes, stream>>>(
1031 + out.mutable_data_ptr<fp8_t>(),
1032 + scale.mutable_data_ptr<float>(),
1033 + input.const_data_ptr<scalar_t>(), num_tokens,
1034 + valid_rows_ptr, topk);
1035 + return;
1036 + }
1037 + }
1038 +#endif
1039 + const int num_warps = std::min(num_groups, 1024 / WARP_SIZE);
1040 + dim3 block(num_warps * WARP_SIZE);
1041 + vllm::situ_and_mul_quant_group_scalar_kernel<scalar_t, fp8_t,
1042 + 128>
1043 + <<<grid, block, 0, stream>>>(
1044 + out.mutable_data_ptr<fp8_t>(),
1045 + scale.mutable_data_ptr<float>(),
1046 + input.const_data_ptr<scalar_t>(), d, num_groups,
1047 + (float)beta, (float)linear_beta, num_tokens,
1048 + valid_rows_ptr, topk);
1049 + });
1050 + });
1051 + }
1052 +}
1053 namespace vllm {
1054
1055 // Element-wise activation kernel template.
csrc/libtorch_stable/ops.h
+10 -2
@@ -526,8 +526,16 @@ void fatrelu_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input,
526 double threshold);
527 void swigluoai_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input,
528 double alpha = 1.702, double limit = 7.0);
529 -void situ_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input,
530 - double beta = 1.0, double linear_beta = -1.0);
529 +void situ_and_mul(
530 + torch::stable::Tensor& out, torch::stable::Tensor& input, double beta = 1.0,
531 + double linear_beta = -1.0,
532 + std::optional<torch::stable::Tensor> valid_rows = std::nullopt);
533 +void situ_and_mul_quant(
534 + torch::stable::Tensor& out, torch::stable::Tensor& scale,
535 + torch::stable::Tensor& input, double beta = 1.0, double linear_beta = -1.0,
536 + int64_t group_size = 0,
537 + std::optional<torch::stable::Tensor> valid_rows = std::nullopt,
538 + int64_t topk = 1);
539 void masked_situ_and_mul(torch::stable::Tensor& out,
540 torch::stable::Tensor& input,
541 const torch::stable::Tensor& expert_num_tokens,
csrc/libtorch_stable/torch_bindings.cpp
+10 -1
@@ -611,7 +611,15 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
611 // SituGLU implementation used in Kimi models.
612 ops.def(
613 "situ_and_mul(Tensor! out, Tensor input, float beta=1.0, float "
614 - "linear_beta=-1.0) -> ()");
614 + "linear_beta=-1.0, Tensor? valid_rows=None) -> ()");
615 + // Fused SituGLU activation + dynamic FP8 quantization for the Humming w2 path
616 + // (writes the fp8 down input and its float32 scale). group_size=0 ->
617 + // per-token scale [.., 1]; group_size=128 -> k-major block-FP8 scale [..,
618 + // d/128].
619 + ops.def(
620 + "situ_and_mul_quant(Tensor! out, Tensor! scale, Tensor input, "
621 + "float beta=1.0, float linear_beta=-1.0, int group_size=0, "
622 + "Tensor? valid_rows=None, int topk=1) -> ()");
623 ops.def(
624 "masked_situ_and_mul(Tensor! out, Tensor input, Tensor "
625 "expert_num_tokens, float beta=1.0, float linear_beta=-1.0) -> ()");
@@ -830,6 +838,7 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
838 ops.impl("fatrelu_and_mul", TORCH_BOX(&fatrelu_and_mul));
839 ops.impl("swigluoai_and_mul", TORCH_BOX(&swigluoai_and_mul));
840 ops.impl("situ_and_mul", TORCH_BOX(&situ_and_mul));
841 + ops.impl("situ_and_mul_quant", TORCH_BOX(&situ_and_mul_quant));
842 ops.impl("masked_situ_and_mul", TORCH_BOX(&masked_situ_and_mul));
843 ops.impl("gelu_new", TORCH_BOX(&gelu_new));
844 ops.impl("gelu_fast", TORCH_BOX(&gelu_fast));
tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming-act-fp8-block.yaml new
+11
@@ -0,0 +1,11 @@
1 +model_name: "nm-testing/Qwen3-30B-A3B-MXFP4A16"
2 +accuracy_threshold: 0.86
3 +num_questions: 1319
4 +num_fewshot: 5
5 +server_args: >-
6 + --enforce-eager
7 + --max-model-len 8192
8 + --quantization humming
9 + --linear-backend humming
10 +env:
11 + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3","group_size":128}'
tests/evals/gsm8k/configs/humming/config-act-fp8.txt
+1
@@ -1,5 +1,6 @@
1 gpt-oss-20b-humming-act-fp8.yaml
2 Qwen3-30B-A3B-MXFP4A16-humming-act-fp8.yaml
3 +Qwen3-30B-A3B-MXFP4A16-humming-act-fp8-block.yaml
4 Qwen2-1.5B-Instruct-FP8W8-humming-act-fp8.yaml
5 Qwen3-0.6B-MXFP8-humming-act-fp8.yaml
6 Qwen3-30B-A3B-Fp8-v1-humming-act-fp8.yaml
tests/kernels/moe/test_moe.py
+1
@@ -1408,6 +1408,7 @@ def test_humming_delegates_to_instance_activation():
1408 activation=MoEActivation.SWIGLUOAI_UNINTERLEAVE,
1409 input=input,
1410 output=output,
1411 + valid_rows=None,
1412 )
1413
1414
tests/kernels/moe/test_moe_fused_mul_sum.py new
+406
@@ -0,0 +1,406 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the fused MoE topk weight-and-reduce kernel.
4 +
5 +Run `pytest tests/kernels/moe/test_moe_fused_mul_sum.py`.
6 +"""
7 +
8 +import pytest
9 +import torch
10 +
11 +from vllm.model_executor.layers.fused_moe.moe_fused_mul_sum import (
12 + moe_fused_mul_sum,
13 +)
14 +from vllm.utils.torch_utils import set_random_seed
15 +
16 +NUM_TOKENS = [1, 17, 256]
17 +TOP_KS = [2, 8]
18 +HIDDEN_SIZE = 512
19 +# One BLOCK_K tile, a partial trailing tile, and Kimi-K3's real dims
20 +# (moe_intermediate_size 3072, hidden_size 7168) -- exercises the per-tile loop
21 +# and its tail mask (BLOCK_K caps at 512 for <=2-byte dtypes).
22 +HIDDEN_SIZES = [512, 1000, 3072, 7168]
23 +DTYPES = [torch.bfloat16, torch.float32]
24 +NUM_EXPERTS = 16
25 +
26 +
27 +def _reference(
28 + inputs: torch.Tensor,
29 + topk_weights: torch.Tensor,
30 + topk_ids: torch.Tensor,
31 + expert_map: torch.Tensor | None,
32 +) -> torch.Tensor:
33 + valid = topk_ids >= 0
34 + if expert_map is not None:
35 + valid &= expert_map[topk_ids.clamp(min=0)] >= 0
36 + weights = torch.where(valid, topk_weights, torch.zeros_like(topk_weights))
37 + return (inputs.float() * weights.float().unsqueeze(-1)).sum(dim=1)
38 +
39 +
40 +@pytest.mark.parametrize("num_tokens", NUM_TOKENS)
41 +@pytest.mark.parametrize("top_k", TOP_KS)
42 +@pytest.mark.parametrize("use_expert_map", [True, False])
43 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
44 +def test_invalid_slots_excluded(num_tokens: int, top_k: int, use_expert_map: bool):
45 + """Slots marked -1 must not contribute, and must not be dereferenced.
46 +
47 + The expert GEMM never writes rows for topk slots whose expert id is -1
48 + (a non-local expert under EP, or an all2all padding row), so those rows
49 + hold stale workspace data. Filling them with NaN here means any failure to
50 + mask them shows up as NaN in the output rather than a small numeric drift.
51 + Indexing `expert_map` with -1 is also an out-of-bounds read.
52 + """
53 + set_random_seed(0)
54 + device = "cuda"
55 +
56 + inputs = torch.randn(
57 + num_tokens, top_k, HIDDEN_SIZE, dtype=torch.bfloat16, device=device
58 + )
59 + topk_weights = torch.rand(num_tokens, top_k, dtype=torch.bfloat16, device=device)
60 + topk_ids = torch.randint(
61 + 0, NUM_EXPERTS, (num_tokens, top_k), dtype=torch.int32, device=device
62 + )
63 +
64 + # Mark roughly half the slots invalid and poison the rows behind them.
65 + invalid = torch.rand(num_tokens, top_k, device=device) < 0.5
66 + topk_ids[invalid] = -1
67 + inputs[invalid] = float("nan")
68 +
69 + expert_map = None
70 + if use_expert_map:
71 + # Second half of the experts is non-local, so valid ids can still map
72 + # to -1 -- exercising the second masking condition.
73 + expert_map = torch.full((NUM_EXPERTS,), -1, dtype=torch.int32, device=device)
74 + local = NUM_EXPERTS // 2
75 + expert_map[:local] = torch.arange(local, dtype=torch.int32, device=device)
76 + inputs[(topk_ids >= 0) & (expert_map[topk_ids.clamp(min=0)] < 0)] = float("nan")
77 +
78 + # The _reference implementation zeros out outputs. We need to match that here,
79 + # otherwise the test fails.
80 + outputs = torch.zeros(num_tokens, HIDDEN_SIZE, dtype=torch.bfloat16, device=device)
81 + out = moe_fused_mul_sum(
82 + inputs=inputs,
83 + topk_weights=topk_weights,
84 + outputs=outputs,
85 + topk_ids=topk_ids,
86 + expert_map=expert_map,
87 + )
88 +
89 + assert not out.isnan().any(), "invalid slots leaked into the reduction"
90 + ref = _reference(inputs.nan_to_num(), topk_weights, topk_ids, expert_map)
91 + torch.testing.assert_close(out.float(), ref, atol=1e-2, rtol=1e-2)
92 +
93 +
94 +def _local_expert_map(device: str) -> torch.Tensor:
95 + """First half of the experts is local, second half maps to -1 (non-local)."""
96 + expert_map = torch.full((NUM_EXPERTS,), -1, dtype=torch.int32, device=device)
97 + local = NUM_EXPERTS // 2
98 + expert_map[:local] = torch.arange(local, dtype=torch.int32, device=device)
99 + return expert_map
100 +
101 +
102 +SENTINEL = 42.0 # exact in bf16; marks output the kernel must leave untouched
103 +
104 +
105 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
106 +def test_nonlocal_row_is_zeroed():
107 + """A real row whose positive ids all map to non-local experts is written 0.
108 +
109 + Such a row is not padding (its ids are >= 0, so row_valid > 0 and the block
110 + is not early-returned). Every slot is then masked out by expert_map, so the
111 + accumulator stays 0 and 0 is stored -- the row must not be left holding stale
112 + buffer data, because downstream reduction treats it as a real token.
113 + """
114 + set_random_seed(0)
115 + device = "cuda"
116 + num_tokens, top_k = 17, 8
117 + local = NUM_EXPERTS // 2
118 + expert_map = _local_expert_map(device)
119 +
120 + # Poison inputs so any failure to mask the non-local slots surfaces as NaN.
121 + inputs = torch.full(
122 + (num_tokens, top_k, HIDDEN_SIZE),
123 + float("nan"),
124 + dtype=torch.bfloat16,
125 + device=device,
126 + )
127 + topk_weights = torch.rand(num_tokens, top_k, dtype=torch.bfloat16, device=device)
128 + # Every id lands in the non-local half [local, NUM_EXPERTS).
129 + topk_ids = torch.randint(
130 + local, NUM_EXPERTS, (num_tokens, top_k), dtype=torch.int32, device=device
131 + )
132 +
133 + outputs = torch.full(
134 + (num_tokens, HIDDEN_SIZE), SENTINEL, dtype=torch.bfloat16, device=device
135 + )
136 + out = moe_fused_mul_sum(
137 + inputs=inputs,
138 + topk_weights=topk_weights,
139 + outputs=outputs,
140 + topk_ids=topk_ids,
141 + expert_map=expert_map,
142 + )
143 +
144 + assert not out.isnan().any(), "non-local row leaked NaN into the output"
145 + torch.testing.assert_close(out.float(), torch.zeros_like(out.float()))
146 +
147 +
148 +@pytest.mark.parametrize("use_expert_map", [True, False])
149 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
150 +def test_padding_row_left_untouched(use_expert_map: bool):
151 + """An all -1 padding row is skipped: its output row is left untouched.
152 +
153 + This is the CUDA-graph decode contract -- padding rows past num_recv are
154 + never read downstream, so the kernel elides them (whole-padding blocks
155 + early-return) rather than zeroing. The skip is gated on topk_ids alone, so
156 + it must hold whether or not expert_map is passed (the humming path passes
157 + None); we pin both.
158 + """
159 + set_random_seed(0)
160 + device = "cuda"
161 + num_tokens, top_k = 17, 8
162 + expert_map = _local_expert_map(device) if use_expert_map else None
163 +
164 + inputs = torch.full(
165 + (num_tokens, top_k, HIDDEN_SIZE),
166 + float("nan"),
167 + dtype=torch.bfloat16,
168 + device=device,
169 + )
170 + topk_weights = torch.rand(num_tokens, top_k, dtype=torch.bfloat16, device=device)
171 + topk_ids = torch.full((num_tokens, top_k), -1, dtype=torch.int32, device=device)
172 +
173 + outputs = torch.full(
174 + (num_tokens, HIDDEN_SIZE), SENTINEL, dtype=torch.bfloat16, device=device
175 + )
176 + out = moe_fused_mul_sum(
177 + inputs=inputs,
178 + topk_weights=topk_weights,
179 + outputs=outputs,
180 + topk_ids=topk_ids,
181 + expert_map=expert_map,
182 + )
183 +
184 + assert torch.all(out == SENTINEL), "padding row output was modified"
185 +
186 +
187 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
188 +def test_nonlocal_and_padding_adjacent():
189 + """A non-local real row next to an all -1 padding row.
190 +
191 + Each CTA owns one token row: the non-local row must be zeroed (its slots are
192 + all masked by expert_map) while the padding row beside it is left untouched.
193 + Guards against a change that lets one row's fate leak into its neighbour.
194 + """
195 + set_random_seed(0)
196 + device = "cuda"
197 + num_tokens, top_k = 17, 8
198 + local = NUM_EXPERTS // 2
199 + expert_map = _local_expert_map(device)
200 +
201 + inputs = torch.full(
202 + (num_tokens, top_k, HIDDEN_SIZE),
203 + float("nan"),
204 + dtype=torch.bfloat16,
205 + device=device,
206 + )
207 + topk_weights = torch.rand(num_tokens, top_k, dtype=torch.bfloat16, device=device)
208 + # All rows are non-local real rows except row 1, an all -1 padding row.
209 + topk_ids = torch.randint(
210 + local, NUM_EXPERTS, (num_tokens, top_k), dtype=torch.int32, device=device
211 + )
212 + topk_ids[1] = -1
213 +
214 + outputs = torch.full(
215 + (num_tokens, HIDDEN_SIZE), SENTINEL, dtype=torch.bfloat16, device=device
216 + )
217 + out = moe_fused_mul_sum(
218 + inputs=inputs,
219 + topk_weights=topk_weights,
220 + outputs=outputs,
221 + topk_ids=topk_ids,
222 + expert_map=expert_map,
223 + )
224 +
225 + padding = torch.zeros(num_tokens, dtype=torch.bool, device=device)
226 + padding[1] = True
227 + assert torch.all(out[1] == SENTINEL), "padding row beside a real row was modified"
228 + written = out[~padding].float()
229 + assert not written.isnan().any(), "non-local row leaked NaN into the output"
230 + torch.testing.assert_close(written, torch.zeros_like(written))
231 +
232 +
233 +@pytest.mark.parametrize("num_tokens", NUM_TOKENS)
234 +@pytest.mark.parametrize("top_k", TOP_KS)
235 +@pytest.mark.parametrize("hidden_size", HIDDEN_SIZES)
236 +@pytest.mark.parametrize("dtype", DTYPES)
237 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
238 +def test_weighted_sum_matches_reference(
239 + num_tokens: int, top_k: int, hidden_size: int, dtype: torch.dtype
240 +):
241 + """Weighted sum over live slots matches the reference across hidden tiles.
242 +
243 + Covers the deployed humming path (globalized ids, expert_map=None): a random
244 + -1 mix with NaN-poisoned dead slots, over hidden sizes that span a single
245 + tile, a partial trailing tile, and many tiles, in both bf16 and fp32.
246 + """
247 + set_random_seed(0)
248 + device = "cuda"
249 +
250 + inputs = torch.randn(num_tokens, top_k, hidden_size, dtype=dtype, device=device)
251 + topk_weights = torch.rand(num_tokens, top_k, dtype=dtype, device=device)
252 + topk_ids = torch.randint(
253 + 0, NUM_EXPERTS, (num_tokens, top_k), dtype=torch.int32, device=device
254 + )
255 + invalid = torch.rand(num_tokens, top_k, device=device) < 0.5
256 + topk_ids[invalid] = -1
257 + inputs[invalid] = float("nan")
258 +
259 + # All-padding rows early-return untouched, so pre-zero to match the reference.
260 + outputs = torch.zeros(num_tokens, hidden_size, dtype=dtype, device=device)
261 + out = moe_fused_mul_sum(
262 + inputs=inputs,
263 + topk_weights=topk_weights,
264 + outputs=outputs,
265 + topk_ids=topk_ids,
266 + expert_map=None,
267 + )
268 +
269 + assert not out.isnan().any(), "invalid slots leaked into the reduction"
270 + ref = _reference(inputs.nan_to_num(), topk_weights, topk_ids, expert_map=None)
271 + atol, rtol = (1e-2, 1e-2) if dtype == torch.bfloat16 else (1e-4, 1e-4)
272 + torch.testing.assert_close(out.float(), ref, atol=atol, rtol=rtol)
273 +
274 +
275 +@pytest.mark.parametrize("dtype", DTYPES)
276 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
277 +def test_no_topk_ids_sums_all_slots(dtype: torch.dtype):
278 + """With topk_ids=None every slot is live: plain weighted sum over top_k."""
279 + set_random_seed(0)
280 + device = "cuda"
281 + num_tokens, top_k, hidden_size = 17, 8, 1000
282 +
283 + inputs = torch.randn(num_tokens, top_k, hidden_size, dtype=dtype, device=device)
284 + topk_weights = torch.rand(num_tokens, top_k, dtype=dtype, device=device)
285 + out = moe_fused_mul_sum(inputs=inputs, topk_weights=topk_weights)
286 +
287 + ref = (inputs.float() * topk_weights.float().unsqueeze(-1)).sum(dim=1)
288 + atol, rtol = (1e-2, 1e-2) if dtype == torch.bfloat16 else (1e-4, 1e-4)
289 + torch.testing.assert_close(out.float(), ref, atol=atol, rtol=rtol)
290 +
291 +
292 +@pytest.mark.parametrize("hidden_size", [512, 7168])
293 +@pytest.mark.parametrize("dtype", DTYPES)
294 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
295 +def test_expert_map_is_required_for_nonlocal_ids(hidden_size: int, dtype: torch.dtype):
296 + """expert_map is load-bearing, not redundant: dropping it leaks non-local ids.
297 +
298 + A positive id whose expert is non-local marks a slot the GEMM never wrote, so
299 + its `inputs` row is stale. `topk_ids >= 0` alone cannot tell it apart from a
300 + real slot -- only expert_map can. This pins the contract the humming path
301 + relies on (globalized ids are still routed through expert_map as a safety
302 + net): with the map the stale slot is masked; without it, it corrupts the sum.
303 + Regression guard against re-dropping expert_map as an optimization.
304 + """
305 + set_random_seed(0)
306 + device = "cuda"
307 + num_tokens, top_k = 8, 8
308 + local = NUM_EXPERTS // 2
309 + expert_map = _local_expert_map(device)
310 +
311 + inputs = torch.randn(num_tokens, top_k, hidden_size, dtype=dtype, device=device)
312 + topk_weights = torch.rand(num_tokens, top_k, dtype=dtype, device=device)
313 + # Each row keeps at least one local slot (so the mapped output is finite) and
314 + # at least one non-local slot whose stale row is poisoned with NaN.
315 + half = top_k // 2
316 + topk_ids = torch.empty(num_tokens, top_k, dtype=torch.int32, device=device)
317 + topk_ids[:, :half] = torch.randint(
318 + 0, local, (num_tokens, half), dtype=torch.int32, device=device
319 + )
320 + topk_ids[:, half:] = torch.randint(
321 + local, NUM_EXPERTS, (num_tokens, top_k - half), dtype=torch.int32, device=device
322 + )
323 + inputs[:, half:] = float("nan")
324 +
325 + atol, rtol = (1e-2, 1e-2) if dtype == torch.bfloat16 else (1e-4, 1e-4)
326 +
327 + mapped = moe_fused_mul_sum(
328 + inputs=inputs,
329 + topk_weights=topk_weights,
330 + outputs=torch.zeros(num_tokens, hidden_size, dtype=dtype, device=device),
331 + topk_ids=topk_ids,
332 + expert_map=expert_map,
333 + )
334 + assert not mapped.isnan().any(), "expert_map failed to mask stale non-local slot"
335 + ref = _reference(inputs.nan_to_num(), topk_weights, topk_ids, expert_map)
336 + torch.testing.assert_close(mapped.float(), ref, atol=atol, rtol=rtol)
337 +
338 + # Same call without the map: the non-local slot is summed and the NaN leaks.
339 + unmapped = moe_fused_mul_sum(
340 + inputs=inputs,
341 + topk_weights=topk_weights,
342 + outputs=torch.zeros(num_tokens, hidden_size, dtype=dtype, device=device),
343 + topk_ids=topk_ids,
344 + expert_map=None,
345 + )
346 + assert unmapped.isnan().any(), "expert_map is not redundant: dropping it must leak"
347 +
348 +
349 +@pytest.mark.parametrize("dtype", DTYPES)
350 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
351 +def test_num_valid_tokens_bounds_stale_padding(dtype: torch.dtype):
352 + """Rows past num_valid_tokens are left untouched, even with stale ids/NaN.
353 +
354 + Under cudagraph decode the grid is the static padded token count and the
355 + tail rows past num_recv hold stale (non -1) ids and stale NaN in `inputs`
356 + from a prior replay -- the -1 mask alone cannot spot them. Bounding on
357 + num_valid_tokens must skip the tail before any load so its NaN never reaches
358 + the output; without the bound it is summed and a masked finalize can then
359 + propagate the NaN. Regression guard for the decode accuracy fix.
360 + """
361 + set_random_seed(0)
362 + device = "cuda"
363 + num_tokens, num_recv, top_k, hidden_size = 17, 10, 8, 512
364 +
365 + inputs = torch.randn(num_tokens, top_k, hidden_size, dtype=dtype, device=device)
366 + topk_weights = torch.rand(num_tokens, top_k, dtype=dtype, device=device)
367 + # All ids valid (>= 0): the stale tail is indistinguishable by the -1 mask.
368 + topk_ids = torch.randint(
369 + 0, NUM_EXPERTS, (num_tokens, top_k), dtype=torch.int32, device=device
370 + )
371 + inputs[num_recv:] = float("nan") # stale down_output behind the padding tail
372 + num_valid = torch.tensor([num_recv], dtype=torch.int32, device=device)
373 +
374 + atol, rtol = (1e-2, 1e-2) if dtype == torch.bfloat16 else (1e-4, 1e-4)
375 +
376 + bounded = moe_fused_mul_sum(
377 + inputs=inputs,
378 + topk_weights=topk_weights,
379 + outputs=torch.full(
380 + (num_tokens, hidden_size), SENTINEL, dtype=dtype, device=device
381 + ),
382 + topk_ids=topk_ids,
383 + num_valid_tokens=num_valid,
384 + )
385 + assert torch.all(bounded[num_recv:] == SENTINEL), (
386 + "padding tail past num_recv written"
387 + )
388 + assert not bounded[:num_recv].isnan().any(), "real rows leaked NaN"
389 + ref = _reference(
390 + inputs[:num_recv].nan_to_num(),
391 + topk_weights[:num_recv],
392 + topk_ids[:num_recv],
393 + expert_map=None,
394 + )
395 + torch.testing.assert_close(bounded[:num_recv].float(), ref, atol=atol, rtol=rtol)
396 +
397 + # Without the bound the stale tail is summed and leaks NaN into its rows.
398 + unbounded = moe_fused_mul_sum(
399 + inputs=inputs,
400 + topk_weights=topk_weights,
401 + outputs=torch.full(
402 + (num_tokens, hidden_size), SENTINEL, dtype=dtype, device=device
403 + ),
404 + topk_ids=topk_ids,
405 + )
406 + assert unbounded[num_recv:].isnan().any(), "num_valid_tokens bound is load-bearing"
tests/kernels/moe/test_situ_mul_fp8_quant.py new
+224
@@ -0,0 +1,224 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Correctness tests for the persistent cp.async-pipelined SITU + block-FP8
4 +quant kernel (``situ_and_mul_quant``).
5 +
6 +Mirrors ``test_silu_mul_fp8_quant_deep_gemm.py`` in structure: build a reference
7 +activation + block-FP8 quant, drive random per-row scale magnitudes, exercise
8 +the masked (``valid_rows``) contiguous layout, and compare per valid row.
9 +
10 +The kernel computes SITU in fp32 and converts to fp8 with hardware rounding,
11 +which torch cannot reproduce bit-exactly. It is therefore validated against the
12 +*ideal* FP8 quantizer of the exact fp64 activation: the difference must stay
13 +within the unavoidable FP8 block-quant error (within 1 fp8 code for ~all
14 +elements, never off by more than 2).
15 +"""
16 +
17 +import pytest
18 +import torch
19 +
20 +from vllm.model_executor.layers.fused_moe.activation import situ_and_mul_quant
21 +from vllm.platforms import current_platform
22 +from vllm.utils.torch_utils import set_random_seed
23 +
24 +fp8_dtype = current_platform.fp8_dtype()
25 +DEVICE = current_platform.device_type
26 +FP8_MAX = torch.finfo(fp8_dtype).max
27 +
28 +# The kernel uses fp8 e4m3 conversion, so it is CUDA-only and needs SM89+
29 +# (Ada/Hopper). There is no Triton fallback.
30 +pytestmark = pytest.mark.skipif(
31 + not current_platform.is_cuda() or not current_platform.has_device_capability(89),
32 + reason="situ_and_mul_quant persistent kernel requires CUDA SM89+ (fp8 e4m3).",
33 +)
34 +
35 +# Kimi-K3 baked SITU params. Only (d=3072, beta=4.0, linear_beta=25.0,
36 +# group_size=128, fp16/bf16) routes to the persistent cp.async-pipelined kernel;
37 +# anything else falls back to the scalar group kernel.
38 +SITU_BETA = 4.0
39 +SITU_LINEAR_BETA = 25.0
40 +SITU_D = 3072
41 +GROUP_SIZE = 128
42 +
43 +# (num_tokens, valid_rows). valid_rows=None => all rows valid. 2048/4096 rows
44 +# span multiple grid-stride waves of the persistent grid (GRID_DIM = 132*8).
45 +CASES = [
46 + (1, None),
47 + (17, None),
48 + (128, None),
49 + (2048, None),
50 + (4096, None),
51 + (2048, 1500), # DeepEP v2 contiguous padding
52 + (2048, 1),
53 + (4096, 4095),
54 + (512, 0), # whole tensor is padding
55 +]
56 +
57 +
58 +def token_random(num_tokens: int, twod: int, dtype: torch.dtype) -> torch.Tensor:
59 + """Per-row random exponent so groups span many scale magnitudes."""
60 + base = torch.randn(num_tokens, twod, dtype=torch.float32, device=DEVICE)
61 + exps = torch.randint(1, 13, (num_tokens, 1), device=DEVICE).float()
62 + return (base * (2.0**exps)).to(dtype)
63 +
64 +
65 +def ref_situ_block_fp8_quant(
66 + inp: torch.Tensor, beta: float, linear_beta: float, group_size: int
67 +) -> tuple[torch.Tensor, torch.Tensor]:
68 + """Ideal FP8 block-quant of the exact fp64 SITU activation.
69 +
70 + The kernel rounds the activation to ``scalar_t`` before quantizing (it stores
71 + ``(float)(scalar_t)situ(...)``), so the reference does the same before taking
72 + the per-group absmax.
73 + """
74 + d = inp.shape[-1] // 2
75 + gate, up = inp.double().chunk(2, dim=-1)
76 + gate_out = beta * torch.tanh(gate / beta) * torch.sigmoid(gate)
77 + up_out = linear_beta * torch.tanh(up / linear_beta) if linear_beta > 0 else up
78 + act = (gate_out * up_out).to(inp.dtype).float()
79 +
80 + m = act.shape[0]
81 + ng = d // group_size
82 + a = act.view(m, ng, group_size)
83 + scale = a.abs().amax(dim=2).clamp_min(1e-30) / FP8_MAX
84 + q = (a / scale.unsqueeze(2)).clamp(-FP8_MAX, FP8_MAX).to(fp8_dtype)
85 + return q.view(m, d), scale
86 +
87 +
88 +def fp8_code_dist(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
89 + """Signed-magnitude fp8 code distance (valid for e4m3fn and e4m3fnuz)."""
90 +
91 + def ordered(x: torch.Tensor) -> torch.Tensor:
92 + x = x.view(torch.uint8).to(torch.int32)
93 + return torch.where((x >> 7) == 1, -(x & 0x7F), x & 0x7F)
94 +
95 + return (ordered(a) - ordered(b)).abs()
96 +
97 +
98 +@pytest.mark.parametrize("num_tokens,valid", CASES)
99 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.half])
100 +@torch.inference_mode()
101 +def test_situ_and_mul_quant_pipelined(
102 + num_tokens: int, valid: int | None, dtype: torch.dtype
103 +) -> None:
104 + set_random_seed(42)
105 + d = SITU_D
106 + inp = token_random(num_tokens, 2 * d, dtype)
107 +
108 + # Sentinels: padding rows must keep their bytes (out) and get scale 1.0.
109 + out = torch.full((num_tokens, d), 17.0, device=DEVICE).to(fp8_dtype)
110 + scale = torch.full(
111 + (num_tokens, d // GROUP_SIZE), -1.0, dtype=torch.float32, device=DEVICE
112 + )
113 + out_sentinel = out.clone()
114 + valid_rows = (
115 + None if valid is None else torch.tensor(valid, dtype=torch.int32, device=DEVICE)
116 + )
117 +
118 + situ_and_mul_quant(
119 + out,
120 + scale,
121 + inp,
122 + beta=SITU_BETA,
123 + linear_beta=SITU_LINEAR_BETA,
124 + group_size=GROUP_SIZE,
125 + valid_rows=valid_rows,
126 + )
127 +
128 + # Padding contract: skipped rows keep their bytes; their scale becomes 1.0.
129 + if valid is not None and valid < num_tokens:
130 + assert torch.equal(scale[valid:], torch.ones_like(scale[valid:]))
131 + assert torch.equal(
132 + out[valid:].view(torch.uint8), out_sentinel[valid:].view(torch.uint8)
133 + )
134 +
135 + rows = num_tokens if valid is None else valid
136 + if rows == 0:
137 + return
138 +
139 + ref_q, ref_s = ref_situ_block_fp8_quant(
140 + inp[:rows], SITU_BETA, SITU_LINEAR_BETA, GROUP_SIZE
141 + )
142 +
143 + assert torch.isfinite(scale[:rows]).all()
144 + assert not torch.isnan(out[:rows].float()).any()
145 +
146 + # Scales: the kernel's fp32 SITU shifts the per-group absmax only
147 + # marginally vs the fp64 reference.
148 + torch.testing.assert_close(scale[:rows], ref_s, rtol=3e-2, atol=1e-5)
149 +
150 + # FP8 codes: must land within 1 code of the ideal quantizer for ~all
151 + # elements, never off by more than 2.
152 + dist = fp8_code_dist(out[:rows], ref_q)
153 + assert dist.max().item() <= 2
154 + assert (dist <= 1).float().mean().item() >= 0.99
155 +
156 +
157 +@pytest.mark.parametrize("tokens,topk", [(300, 8), (1, 4), (256, 6)])
158 +@torch.inference_mode()
159 +def test_situ_and_mul_quant_topk_row_bound(tokens: int, topk: int) -> None:
160 + """valid_rows is a token count; the kernel bounds rows at tokens*topk."""
161 + set_random_seed(7)
162 + d = SITU_D
163 + num_tokens = tokens * topk + 37 # padding tail past the valid rows
164 + inp = token_random(num_tokens, 2 * d, torch.bfloat16)
165 +
166 + out = torch.full((num_tokens, d), 17.0, device=DEVICE).to(fp8_dtype)
167 + scale = torch.full(
168 + (num_tokens, d // GROUP_SIZE), -1.0, dtype=torch.float32, device=DEVICE
169 + )
170 + out_sentinel = out.clone()
171 + valid_rows = torch.tensor(tokens, dtype=torch.int32, device=DEVICE)
172 +
173 + situ_and_mul_quant(
174 + out,
175 + scale,
176 + inp,
177 + beta=SITU_BETA,
178 + linear_beta=SITU_LINEAR_BETA,
179 + group_size=GROUP_SIZE,
180 + valid_rows=valid_rows,
181 + topk=topk,
182 + )
183 +
184 + rows = tokens * topk
185 + assert torch.equal(scale[rows:], torch.ones_like(scale[rows:]))
186 + assert torch.equal(
187 + out[rows:].view(torch.uint8), out_sentinel[rows:].view(torch.uint8)
188 + )
189 +
190 + ref_q, ref_s = ref_situ_block_fp8_quant(
191 + inp[:rows], SITU_BETA, SITU_LINEAR_BETA, GROUP_SIZE
192 + )
193 + torch.testing.assert_close(scale[:rows], ref_s, rtol=3e-2, atol=1e-5)
194 + assert fp8_code_dist(out[:rows], ref_q).max().item() <= 2
195 +
196 +
197 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.half])
198 +@torch.inference_mode()
199 +def test_situ_and_mul_quant_pipelined_deterministic(dtype: torch.dtype) -> None:
200 + """Two runs on the same input must be byte-identical."""
201 + set_random_seed(0)
202 + d = SITU_D
203 + num_tokens = 2048
204 + inp = token_random(num_tokens, 2 * d, dtype)
205 +
206 + def run() -> tuple[torch.Tensor, torch.Tensor]:
207 + out = torch.empty(num_tokens, d, dtype=fp8_dtype, device=DEVICE)
208 + scale = torch.empty(
209 + num_tokens, d // GROUP_SIZE, dtype=torch.float32, device=DEVICE
210 + )
211 + situ_and_mul_quant(
212 + out,
213 + scale,
214 + inp,
215 + beta=SITU_BETA,
216 + linear_beta=SITU_LINEAR_BETA,
217 + group_size=GROUP_SIZE,
218 + )
219 + return out, scale
220 +
221 + out1, s1 = run()
222 + out2, s2 = run()
223 + assert torch.equal(out1.view(torch.uint8), out2.view(torch.uint8))
224 + assert torch.equal(s1, s2)
tests/quantization/test_humming_mxfp4_block_fp8.py new
+122
@@ -0,0 +1,122 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for Humming MXFP4-weight GEMM paired with a block-FP8 activation.
4 +
5 +The Humming kernel already implements MXFP4 weight (group-32 e8m0) with a
6 +grouped FP8 activation via WGMMA software dequant -- see the grouped-fp8
7 +`test_mxfp4.py` cases in the humming repo, which run on SM90 (H200). Enabling
8 +it in vLLM comes down to two things, both exercised here:
9 +
10 + 1. A grouped FP8 activation must classify to a float32-scaled key
11 + (kFp8Dynamic128Sym), not a uint8 microscale key. MX microscale
12 + activations (group size 32) keep their e8m0 (uint8) scale.
13 + 2. (kMxfp4Static, kFp8Dynamic128Sym) must be an allowed MoE
14 + (weight, activation) pair.
15 +"""
16 +
17 +import pytest
18 +
19 +from vllm.model_executor.layers.fused_moe.experts.fused_humming_moe import (
20 + HummingExpertsBase,
21 +)
22 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
23 + kFp8Dynamic128Sym,
24 + kFp8DynamicTokenSym,
25 + kMxfp4Static,
26 + kMxfp8Dynamic,
27 +)
28 +from vllm.utils.import_utils import has_humming
29 +
30 +# make_humming_moe_quant_config stores per-sublayer humming LayerConfigs but the
31 +# config-plumbing asserts below never read them, so a placeholder satisfies the
32 +# required arg without pulling in the humming package.
33 +_PLACEHOLDER_HUMMING_CONFIGS = {"w13": None, "w2": None}
34 +
35 +
36 +def test_mxfp4_weight_with_block_fp8_activation_is_supported():
37 + """MXFP4 weight + block-FP8 (group-128) activation is an allowed pair."""
38 + assert HummingExpertsBase._supports_quant_scheme(kMxfp4Static, kFp8Dynamic128Sym)
39 +
40 +
41 +def test_mxfp4_weight_with_per_token_fp8_activation_still_supported():
42 + """The pre-existing per-token FP8 pairing must keep working."""
43 + assert HummingExpertsBase._supports_quant_scheme(kMxfp4Static, kFp8DynamicTokenSym)
44 +
45 +
46 +@pytest.mark.skipif(not has_humming(), reason="humming is not installed")
47 +@pytest.mark.parametrize(
48 + "group_size,expected_key",
49 + [
50 + # per-token FP8 -> float32 per-token scale
51 + (0, kFp8DynamicTokenSym),
52 + # block FP8 -> float32 group-128 scale (the newly-fixed case)
53 + (128, kFp8Dynamic128Sym),
54 + # MXFP8 microscale -> e8m0 (uint8) group-32 scale (must be preserved)
55 + (32, kMxfp8Dynamic),
56 + ],
57 +)
58 +def test_humming_fp8_input_schema_to_quant_key(group_size, expected_key):
59 + from vllm.model_executor.layers.quantization.utils.humming_utils import (
60 + _humming_input_schema_to_quant_key,
61 + )
62 + from vllm.utils.humming import HummingInputSchema
63 + from vllm.utils.humming import dtypes as humming_dtypes
64 +
65 + schema = HummingInputSchema(
66 + a_dtype=humming_dtypes.float8e4m3,
67 + input_scale_group_size=group_size,
68 + )
69 + assert _humming_input_schema_to_quant_key(schema) == expected_key
70 +
71 +
72 +@pytest.mark.skipif(not has_humming(), reason="humming is not installed")
73 +def test_humming_bf16_input_schema_is_unquantized():
74 + from vllm.model_executor.layers.quantization.utils.humming_utils import (
75 + _humming_input_schema_to_quant_key,
76 + )
77 + from vllm.utils.humming import HummingInputSchema
78 +
79 + # No a_dtype -> unquantized (bf16/fp16) inputs -> None.
80 + assert _humming_input_schema_to_quant_key(HummingInputSchema()) is None
81 +
82 +
83 +def test_block_fp8_activation_quant_config_is_block_quantized():
84 + """A block-FP8 activation group shape yields a block-quantized MoE config
85 + (FP8 dtype, [1, 128] block, not per-act-token). This is what makes the
86 + DeepEP prepare/finalize step quantize activations to block FP8 *before* the
87 + all-to-all dispatch instead of deferring to Humming -- and what makes
88 + HummingExpertsBase.expects_unquantized_inputs return False."""
89 + from vllm.model_executor.layers.quantization.utils.humming_utils import (
90 + make_humming_moe_quant_config,
91 + )
92 + from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
93 + from vllm.platforms import current_platform
94 +
95 + qc = make_humming_moe_quant_config(
96 + quant_dtype=current_platform.fp8_dtype(),
97 + weight_dtype="float4e2m1",
98 + activation_group_shape=GroupShape(row=1, col=128),
99 + humming_configs=_PLACEHOLDER_HUMMING_CONFIGS,
100 + )
101 + assert qc.is_block_quantized
102 + assert qc.block_shape == [1, 128]
103 + assert not qc.per_act_token_quant
104 + assert qc.quant_dtype == current_platform.fp8_dtype()
105 +
106 +
107 +def test_default_activation_quant_config_defers_to_humming():
108 + """Without a block activation shape the config stays per-token (the deferred
109 + path): Humming quantizes internally, preserving pre-existing behavior for
110 + every non-block-FP8 scheme."""
111 + from vllm.model_executor.layers.quantization.utils.humming_utils import (
112 + make_humming_moe_quant_config,
113 + )
114 + from vllm.platforms import current_platform
115 +
116 + qc = make_humming_moe_quant_config(
117 + quant_dtype=current_platform.fp8_dtype(),
118 + weight_dtype="float4e2m1",
119 + humming_configs=_PLACEHOLDER_HUMMING_CONFIGS,
120 + )
121 + assert not qc.is_block_quantized
122 + assert qc.per_act_token_quant
vllm/model_executor/layers/fused_moe/activation.py
+44
@@ -201,6 +201,7 @@ def apply_moe_activation(
201 activation_config: ApplyMoEActivationConfig | None = None,
202 topk_ids: torch.Tensor | None = None,
203 expert_map: torch.Tensor | None = None,
204 + valid_rows: torch.Tensor | None = None,
205 ) -> torch.Tensor:
206 """Apply MoE activation function.
207
@@ -256,6 +257,7 @@ def apply_moe_activation(
257 -1.0
258 if config.activation_situ_linear_beta is None
259 else config.activation_situ_linear_beta,
260 + valid_rows,
261 )
262 elif activation == MoEActivation.SWIGLUOAI:
263 torch.ops._C.swigluoai_and_mul(output, input)
@@ -286,3 +288,45 @@ def apply_moe_activation(
288 raise ValueError(f"Unsupported FusedMoe activation: {activation}")
289
290 return output
291 +
292 +
293 +def situ_and_mul_quant(
294 + output: torch.Tensor,
295 + scale: torch.Tensor,
296 + input: torch.Tensor,
297 + *,
298 + beta: float,
299 + linear_beta: float | None,
300 + group_size: int = 0,
301 + valid_rows: torch.Tensor | None = None,
302 + topk: int = 1,
303 +) -> None:
304 + """Fused Kimi SITU activation + dynamic FP8 quantization.
305 +
306 + Writes the quantized fp8 down-projection input into ``output`` [M, d] and its
307 + float32 scale into ``scale`` (dequant = q * scale), fusing the SITU
308 + activation with the FP8 quant that Humming's w2 GEMM would otherwise perform
309 + in a separate pass -- this avoids materializing (and rounding through) the
310 + bf16 activation_output buffer.
311 +
312 + ``group_size`` selects the quant granularity: ``0`` gives per-token scales
313 + ``scale`` [M, 1]; ``128`` gives k-major block-FP8 group scales ``scale``
314 + [M, d // 128], matching humming ``quant_input(group_size=128, float32)``.
315 +
316 + ``linear_beta`` <= 0 (or None) means "unset" (up passed through), matching
317 + ``SituAndMul(linear_beta=None)``. ``valid_rows`` (int32 scalar tensor) is the
318 + DeepEP v2 contiguous-layout valid *token* count (e.g. ``psum[-1:]``); the
319 + kernel multiplies it by ``topk`` to bound rows, so no host-side cast/multiply
320 + is needed. Padding rows are skipped and receive a benign scale of 1.0. Kept
321 + in this module so that every ``torch.ops._C.situ*`` call lives in one file.
322 + """
323 + torch.ops._C.situ_and_mul_quant(
324 + output,
325 + scale,
326 + input,
327 + beta,
328 + -1.0 if linear_beta is None else linear_beta,
329 + group_size,
330 + valid_rows,
331 + topk,
332 + )
vllm/model_executor/layers/fused_moe/deep_gemm_utils.py
+3 -1
@@ -525,7 +525,9 @@ def deepgemm_moe_permute(
525 expert_num_tokens = None
526 if expert_tokens_meta is not None:
527 expert_num_tokens = expert_tokens_meta.expert_num_tokens
528 - else:
528 + if expert_num_tokens is None:
529 + # A cudagraph-decode carrier may hold only psum_recv_per_rank and leave
530 + # expert_num_tokens None; count locally as in the meta-absent case.
531 expert_num_tokens = count_expert_num_tokens(
532 topk_ids, local_num_experts, expert_map
533 )
vllm/model_executor/layers/fused_moe/experts/fused_batched_moe.py
+1
@@ -668,6 +668,7 @@ class NaiveBatchedExperts(mk.FusedMoEExpertsModular):
668 assert hidden_states.dim() == 3
669 assert expert_tokens_meta is not None
670 expert_num_tokens = expert_tokens_meta.expert_num_tokens
671 + assert expert_num_tokens is not None
672
673 num_local_experts = w1.size(0)
674 assert num_local_experts == w1.size(0), f"{num_local_experts} == {w1.size(0)}"
vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py
+224 -35
@@ -119,6 +119,37 @@ def get_humming_moe_gemm_type() -> str:
119 return gemm_type
120
121
122 +def _fixup_moe_tuning_config(tuning_config: list, max_k_block: int = 128) -> None:
123 + """Fix up each MoE tile in place: cap the K-block and widen warp-N.
124 +
125 + - block_shape[2] (K-block) > ``max_k_block``: the driver rejects the TMA
126 + descriptor at launch (CUDA_ERROR_MISALIGNED_ADDRESS). Cap at 128, which
127 + Humming already uses for larger M.
128 + - warp_shape[1] (warp-N) < 32: block-FP8 (group-128) activations route the
129 + w13 (gate/up) GEMM to a tuning table that pins warp-N to 16, under-filling
130 + the Hopper WGMMA N dimension and corrupting the GEMM output (gsm8k
131 + 0.94 -> 0.89). Widen to 32 whenever block_n % 32 == 0 -- on every tile,
132 + not just the K-capped ones. w2 (down) already uses warp_n=32.
133 + """
134 + logger.info_once("Attempting to override humming GEMM config")
135 + for entry in tuning_config:
136 + config = entry[2]
137 + block_shape = config.get("block_shape")
138 + if not (block_shape and len(block_shape) == 3):
139 + continue
140 + block_m, block_n, block_k = block_shape
141 +
142 + logger.info_once(f"Overriding humming GEMM config. Previous config\n: {config}")
143 + if block_k > max_k_block:
144 + config["block_shape"] = [block_m, block_n, max_k_block]
145 +
146 + warp_shape = config.get("warp_shape")
147 + if warp_shape and warp_shape[1] < 32 and block_n % 32 == 0:
148 + config["warp_shape"] = [warp_shape[0], 32, warp_shape[2]]
149 +
150 + logger.info_once(f"Overridden humming GEMM config. Current config\n: {config}")
151 +
152 +
153 class HummingExpertsBase(mk.FusedMoEExpertsModular):
154 def __init__(
155 self,
@@ -170,6 +201,10 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
201 gemm_type=self.humming_gemm_type(),
202 )
203 self.compute_config_str = json.dumps(self.compute_config)
204 + # Fix up the heuristic-chosen tiles before freezing to JSON -- see
205 + # _fixup_moe_tuning_config.
206 + _fixup_moe_tuning_config(self.w13_tuning_config)
207 + _fixup_moe_tuning_config(self.w2_tuning_config)
208 self.w13_tuning_config_str = json.dumps(self.w13_tuning_config)
209 self.w2_tuning_config_str = json.dumps(self.w2_tuning_config)
210
@@ -178,12 +213,17 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
213 sublayer_name: str,
214 inputs: torch.Tensor,
215 quanted_input: torch.Tensor | None,
216 + input_scale: torch.Tensor | None = None,
217 ) -> tuple[torch.Tensor, torch.Tensor | None]:
218 from vllm.utils.humming import may_quant_input
219
220 + # input_scale is set for block-FP8 (group-128) activations that were
221 + # quantized before the EP dispatch: may_quant_input then skips the
222 + # redundant w13 quantization and forwards the pre-computed scale.
223 return may_quant_input(
224 self.humming_configs[sublayer_name],
225 inputs=inputs,
226 + input_scale=input_scale,
227 quanted_input=quanted_input,
228 )
229
@@ -263,6 +303,10 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
303 (kMxfp4Static, kMxfp4Dynamic),
304 (kMxfp4Static, kMxfp8Dynamic),
305 (kMxfp4Static, kFp8DynamicTokenSym),
306 + # MXFP4 weight (group-32 e8m0) with block-FP8 activation
307 + # (group-128 float32). Runs via WGMMA software dequant, so it
308 + # works on Hopper (SM90/H200) as well as Blackwell.
309 + (kMxfp4Static, kFp8Dynamic128Sym),
310 (kNvfp4Static, None),
311 (kNvfp4Static, kFp8DynamicTokenSym),
312 (kMxfp8Static, None),
@@ -299,21 +343,43 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
343 and _is_supported_wna16_weight_key(weight_key)
344 )
345
346 + def _prequantizes_dispatch_activation(self) -> bool:
347 + """
348 + Whether the prepare/finalize step should quantize activations before
349 + the (EP all-to-all) dispatch instead of leaving it to Humming.
350 +
351 + This is enabled only for block-FP8 (group-128) activations: quantizing
352 + to FP8 before dispatch sends FP8 rather than BF16 over the interconnect,
353 + and Humming then consumes the pre-quantized FP8 + scale directly (see
354 + the apply() methods, which forward the dispatch scale into
355 + HummingMethod.may_quant_input, and may_quant_input itself, which is a
356 + no-op when an input scale is already supplied). The scale layout
357 + produced by vLLM's block-FP8 quantization ([M, K // 128] float32,
358 + row-major) matches what the Humming WGMMA grouped GEMM expects.
359 + """
360 + quant_config = self.quant_config
361 + return (
362 + quant_config.is_block_quantized
363 + and quant_config.quant_dtype == current_platform.fp8_dtype()
364 + )
365 +
366 @property
367 def expects_unquantized_inputs(self) -> bool:
368 """
305 - Humming kernels handle input quantization internally in apply().
306 -
307 - This property tells the prepare/finalize step to skip input
308 - quantization (by setting defer_input_quant=True) and pass
309 - unquantized inputs to the experts. This prevents double
310 - quantization: once in prepare and once in Humming's apply().
311 -
312 - Returns:
313 - True to indicate that this expert expects unquantized inputs
314 - and will handle quantization internally.
369 + Whether the prepare/finalize step should defer input quantization to
370 + the experts (by setting defer_input_quant=True and passing unquantized
371 + inputs).
372 +
373 + Humming normally quantizes inputs internally via
374 + HummingMethod.may_quant_input() in apply(), so we defer quantization
375 + (return True) to avoid quantizing twice -- once in prepare and once in
376 + Humming's apply().
377 +
378 + The exception is block-FP8 (group-128) activations, which are quantized
379 + before the dispatch to save interconnect bandwidth (see
380 + _prequantizes_dispatch_activation): for those we must NOT defer.
381 """
316 - return True
382 + return not self._prequantizes_dispatch_activation()
383
384 @staticmethod
385 def _supports_current_device() -> bool:
@@ -585,6 +651,7 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
651 activation: MoEActivation,
652 output: torch.Tensor,
653 input: torch.Tensor,
654 + valid_rows: torch.Tensor | None = None,
655 ) -> None:
656 activation_config = self.activation_config
657 if (
@@ -601,8 +668,84 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
668 activation=activation,
669 input=input,
670 output=output,
671 + valid_rows=valid_rows,
672 )
673
674 + def fused_situ_quant_enabled(self, activation: MoEActivation) -> bool:
675 + """Whether the SITU activation + w2 quant can be fused into one kernel.
676 +
677 + Fused only for k-major block-FP8 group-128 e4m3 with float32 scales --
678 + the sole layout situ_and_mul_quant supports. A float32 as_dtype rules out
679 + MXMMA (which uses e8m0 m-major scales), so a group-128 scale is
680 + guaranteed k-major. Everything else (m-major MXFP8, other group sizes,
681 + 16-bit passthrough, non-SITU) falls back to the separate
682 + situ_and_mul + quantize_input path.
683 + """
684 + if activation != MoEActivation.SITU:
685 + return False
686 + w2cfg = self.humming_configs["w2"]
687 + # Report the specific blocker once so a silent fallback to the unfused
688 + # situ_and_mul + quant pair is never a mystery in a trace.
689 + reason: str | None = None
690 + if not (w2cfg.a_dtype.num_bits == 8 and str(w2cfg.a_dtype) == "float8e4m3"):
691 + reason = f"w2 a_dtype is {w2cfg.a_dtype} (need float8e4m3)"
692 + elif w2cfg.input_scale_group_size != 128:
693 + reason = (
694 + f"w2 input_scale_group_size is {w2cfg.input_scale_group_size} "
695 + "(need 128)"
696 + )
697 + elif not (w2cfg.as_dtype is not None and str(w2cfg.as_dtype) == "float32"):
698 + reason = f"w2 as_dtype is {w2cfg.as_dtype} (need float32, k-major)"
699 + if reason is not None:
700 + logger.warning_once(
701 + "Humming fused SITU+FP8 quant disabled, using unfused "
702 + "situ_and_mul + quant: %s",
703 + reason,
704 + )
705 + return False
706 + return True
707 +
708 + def fused_situ_quant(
709 + self,
710 + gate_up_output: torch.Tensor,
711 + quanted_down_input: torch.Tensor,
712 + valid_rows: torch.Tensor | None,
713 + topk: int = 1,
714 + ) -> tuple[torch.Tensor, torch.Tensor]:
715 + """Run the fused SITU activation + FP8 quant for the w2 input.
716 +
717 + Returns ``(quanted_down_input, input_scale)`` in the same layout the
718 + unfused ``apply_activation`` + ``quantize_input("w2")`` pair produces: an
719 + fp8 [M, d] tensor plus a k-major block-FP8 float32 [M, d // 128] group
720 + scale. Only reached when ``fused_situ_quant_enabled`` (group_size 128).
721 + """
722 + from vllm.model_executor.layers.fused_moe.activation import (
723 + situ_and_mul_quant,
724 + )
725 +
726 + cfg = self.activation_config
727 + assert cfg.activation_situ_beta is not None, (
728 + "SITU requires activation_situ_beta from FusedMoEConfig"
729 + )
730 + group_size = self.humming_configs["w2"].input_scale_group_size
731 + m, d = quanted_down_input.size(0), quanted_down_input.size(1)
732 + input_scale = torch.empty(
733 + (m, d // group_size),
734 + dtype=torch.float32,
735 + device=quanted_down_input.device,
736 + )
737 + situ_and_mul_quant(
738 + quanted_down_input,
739 + input_scale,
740 + gate_up_output,
741 + beta=cfg.activation_situ_beta,
742 + linear_beta=cfg.activation_situ_linear_beta,
743 + group_size=group_size,
744 + valid_rows=valid_rows,
745 + topk=topk,
746 + )
747 + return quanted_down_input, input_scale
748 +
749
750 class HummingIndexedExperts(HummingExpertsBase):
751 def finalize_weight_and_reduce_impl(self) -> mk.TopKWeightAndReduce:
@@ -684,10 +827,12 @@ class HummingIndexedExperts(HummingExpertsBase):
827 """
828 Standard apply implementation for Humming indexed experts.
829
687 - Humming performs activation quantization internally and consumes the
688 - weights supplied by the modular kernel interface.
689 - Results are written to the output buffer supplied by the modular
690 - kernel, which may alias workspace13.
830 + Note: Humming kernels handle weights internally through the layer
831 + object, so w1, w2, a2_scale are unused. a1q_scale is None on the usual
832 + path (Humming quantizes the w13 input itself); for block-FP8 activations
833 + it carries the scale computed before dispatch, which is forwarded to
834 + may_quant_input so Humming skips the redundant w13 quantization. The
835 + output is written into workspace13 via the buffer management.
836 """
837 assert not apply_router_weight_on_input
838
@@ -710,6 +855,7 @@ class HummingIndexedExperts(HummingExpertsBase):
855 inputs, input_scale = self.quantize_input(
856 "w13",
857 inputs=hidden_states,
858 + input_scale=a1q_scale,
859 quanted_input=buffers.get("quanted_gate_up_input", None),
860 )
861
@@ -722,17 +868,45 @@ class HummingIndexedExperts(HummingExpertsBase):
868 **moe_kwargs1,
869 )
870
725 - self.apply_activation(
726 - activation=activation,
727 - input=buffers["gate_up_output"],
728 - output=buffers["activation_output"],
729 - )
871 + # psum[-1:] is the DeepEP valid *token* count as a zero-cost int32 view.
872 + # situ (rows = tokens*topk) multiplies by topk on-device; mul_sum bounds
873 + # on tokens, so both read this pointer directly -- no host cast/multiply.
874 + valid_tokens = None
875 + topk = topk_ids.size(1)
876 + if (
877 + expert_tokens_meta is not None
878 + and expert_tokens_meta.psum_recv_per_rank is not None
879 + ):
880 + valid_tokens = expert_tokens_meta.psum_recv_per_rank[-1:]
881 +
882 + if self.fused_situ_quant_enabled(activation):
883 + # Fused SITU + FP8 quant (per-token or block-FP8 group-128) straight
884 + # into the w2 input, skipping the bf16 activation_output round-trip.
885 + inputs, input_scale = self.fused_situ_quant(
886 + gate_up_output=buffers["gate_up_output"],
887 + quanted_down_input=buffers["quanted_down_input"],
888 + valid_rows=valid_tokens,
889 + topk=topk,
890 + )
891 + else:
892 + # Fallback situ_and_mul takes int64 row counts (tokens*topk).
893 + valid_rows = (
894 + valid_tokens.to(torch.int64) * topk
895 + if valid_tokens is not None
896 + else None
897 + )
898 + self.apply_activation(
899 + activation=activation,
900 + input=buffers["gate_up_output"],
901 + output=buffers["activation_output"],
902 + valid_rows=valid_rows,
903 + )
904
731 - inputs, input_scale = self.quantize_input(
732 - "w2",
733 - inputs=buffers["activation_output"],
734 - quanted_input=buffers.get("quanted_down_input", None),
735 - )
905 + inputs, input_scale = self.quantize_input(
906 + "w2",
907 + inputs=buffers["activation_output"],
908 + quanted_input=buffers.get("quanted_down_input", None),
909 + )
910
911 self.humming_forward(
912 "w2",
@@ -743,12 +917,16 @@ class HummingIndexedExperts(HummingExpertsBase):
917 **moe_kwargs2,
918 )
919
920 + # expert_map masks any non-local id; num_valid_tokens bounds the
921 + # persistent kernel to the real token rows [0, num_recv) so the padding
922 + # tail is never iterated (CUDA-graph-safe device scalar).
923 moe_fused_mul_sum(
924 inputs=buffers["down_output"].view(*topk_ids.shape, -1),
925 topk_weights=topk_weights,
926 topk_ids=topk_ids,
927 expert_map=expert_map,
928 outputs=output,
929 + num_valid_tokens=valid_tokens,
930 )
931
932
@@ -787,10 +965,13 @@ class HummingGroupedExperts(HummingExpertsBase):
965 """
966 Standard apply implementation for Humming grouped experts.
967
790 - Humming performs activation quantization internally and consumes the
791 - weights supplied by the modular kernel interface.
792 - Results are written to the output buffer supplied by the modular
793 - kernel, which may alias workspace13.
968 + Note: Humming kernels handle weights internally through the layer
969 + object, so w1, w2, a2_scale are unused. a1q_scale is None on the usual
970 + path (Humming quantizes the w13 input itself); for block-FP8 activations
971 + it carries the scale computed before dispatch. It is permuted alongside
972 + the tokens by moe_permute and forwarded to may_quant_input so Humming
973 + skips the redundant w13 quantization. The output is written into
974 + workspace13 via the buffer management.
975 """
976 assert not apply_router_weight_on_input
977
@@ -805,9 +986,9 @@ class HummingGroupedExperts(HummingExpertsBase):
986 )
987 buffers["output"] = output
988
808 - hidden_states, _, expert_first_token_offset, inv_perm, _ = moe_permute(
989 + hidden_states, a1q_scale, expert_first_token_offset, inv_perm, _ = moe_permute(
990 hidden_states=hidden_states,
810 - a1q_scale=None,
991 + a1q_scale=a1q_scale,
992 topk_ids=topk_ids,
993 n_expert=global_num_experts,
994 n_local_expert=self.num_experts,
@@ -818,6 +999,7 @@ class HummingGroupedExperts(HummingExpertsBase):
999 inputs, input_scale = self.quantize_input(
1000 "w13",
1001 inputs=hidden_states,
1002 + input_scale=a1q_scale,
1003 quanted_input=buffers.get("quanted_gate_up_input", None),
1004 )
1005
@@ -901,15 +1083,21 @@ class BatchedHummingGroupedExperts(HummingExpertsBase):
1083 """
1084 Standard apply implementation for Humming batched grouped experts.
1085
904 - Humming performs activation quantization internally and consumes the
905 - weights supplied by the modular kernel interface.
906 - Results are written to the output buffer supplied by the modular
907 - kernel, which may alias workspace13.
1086 + Note: Humming kernels handle weights internally through the layer
1087 + object, so w1, w2, a2_scale are unused. a1q_scale is None on the usual
1088 + path (Humming quantizes the w13 input itself); for block-FP8 activations
1089 + it carries the scale computed before dispatch, which is forwarded to
1090 + may_quant_input so Humming skips the redundant w13 quantization. The
1091 + output is written into workspace13 via the buffer management.
1092 """
1093 assert not apply_router_weight_on_input
1094 assert expert_tokens_meta is not None
1095
1096 hidden_states = hidden_states.view(-1, hidden_states.size(-1))
1097 + # Keep the (batched) block-FP8 scale row-aligned with the flattened
1098 + # [num_experts * max_tokens, K] hidden states above.
1099 + if a1q_scale is not None and a1q_scale.dim() == 3:
1100 + a1q_scale = a1q_scale.view(-1, a1q_scale.size(-1))
1101 valid_shape_m = self.estimate_local_valid_shape_m(topk_ids)
1102 expert_num_tokens = expert_tokens_meta.expert_num_tokens
1103
@@ -925,6 +1113,7 @@ class BatchedHummingGroupedExperts(HummingExpertsBase):
1113 inputs, input_scale = self.quantize_input(
1114 "w13",
1115 inputs=hidden_states,
1116 + input_scale=a1q_scale,
1117 quanted_input=buffers.get("quanted_gate_up_input", None),
1118 )
1119
vllm/model_executor/layers/fused_moe/modular_kernel.py
+4 -1
@@ -100,8 +100,9 @@ class ExpertTokensMetadata:
100 Metadata regarding expert-token routing.
101 """
102
103 - expert_num_tokens: torch.Tensor
103 + expert_num_tokens: torch.Tensor | None
104 expert_num_tokens_cpu: torch.Tensor | None
105 + psum_recv_per_rank: torch.Tensor | None = None
106
107 @staticmethod
108 def make_from_list(
@@ -901,6 +902,7 @@ class FusedMoEExpertsModular(FusedMoEExperts):
902 *,
903 topk_ids: torch.Tensor | None = None,
904 expert_map: torch.Tensor | None = None,
905 + valid_rows: torch.Tensor | None = None,
906 ) -> None:
907 apply_moe_activation(
908 activation,
@@ -909,6 +911,7 @@ class FusedMoEExpertsModular(FusedMoEExperts):
911 activation_config=self.activation_config,
912 topk_ids=topk_ids,
913 expert_map=expert_map,
914 + valid_rows=valid_rows,
915 )
916
917 @abstractmethod
vllm/model_executor/layers/fused_moe/moe_fused_mul_sum.py
+90 -120
@@ -3,7 +3,6 @@
3 import torch
4 from torch._subclasses.fake_tensor import FakeTensor
5
6 -from vllm.platforms import current_platform
6 from vllm.triton_utils import tl, triton
7
8
@@ -14,122 +13,75 @@ def moe_fused_mul_sum_kernel(
13 outputs_ptr,
14 top_ids_ptr,
15 expert_map_ptr,
17 - num_tokens,
16 + num_valid_tokens_ptr,
17 stride_m,
18 + has_topk_ids: tl.constexpr,
19 has_expert_map: tl.constexpr,
20 + has_num_valid: tl.constexpr,
21 top_k: tl.constexpr,
21 - size: tl.constexpr,
22 - BLOCK_M: tl.constexpr,
22 + hidden_size: tl.constexpr,
23 BLOCK_K: tl.constexpr,
24 ):
25 - pid_k = tl.program_id(0)
26 - pid_m = tl.program_id(1)
27 -
28 - offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
29 - offs_k = pid_k * BLOCK_K + tl.arange(0, BLOCK_K)
30 -
31 - m_mask = offs_m < num_tokens
32 - k_mask = offs_k < size
33 - mask = m_mask[:, None] & k_mask[None, :]
34 -
35 - a_base = inputs_ptr + (offs_m * stride_m)[:, None] + offs_k[None, :]
36 - b_base = topk_weights_ptr + offs_m * top_k
37 -
38 - acc = tl.zeros((BLOCK_M, BLOCK_K), dtype=tl.float32)
39 -
40 - for n in tl.static_range(top_k):
41 - b_val = tl.load(b_base + n, mask=m_mask, other=0.0).to(tl.float32)
42 - if has_expert_map:
43 - id_val = tl.load(top_ids_ptr + offs_m * top_k + n, mask=m_mask, other=0)
44 - expert_mask = tl.load(expert_map_ptr + id_val) >= 0
45 - a_vec = tl.load(
46 - a_base + n * size,
47 - mask=mask & expert_mask[:, None],
48 - other=0.0,
49 - ).to(tl.float32)
50 - else:
51 - a_vec = tl.load(
52 - a_base + n * size,
53 - mask=mask,
54 - other=0.0,
55 - ).to(tl.float32)
56 - acc += a_vec * b_val[:, None]
57 -
58 - out_ptrs = outputs_ptr + (offs_m * size)[:, None] + offs_k[None, :]
59 - tl.store(
60 - out_ptrs,
61 - acc.to(outputs_ptr.dtype.element_ty),
62 - mask=mask,
63 - )
25 + # One CTA owns one token's output row. Resolve which slots to sum once per
26 + # row (the decision is invariant over hidden tiles); the `if take` branch is
27 + # block-uniform, so padding slots are skipped rather than masked-and-loaded.
28 + pid_m = tl.program_id(0)
29 + # Bound to the real recv rows [0, num_recv). Under cudagraph decode the grid
30 + # is the static padded token count, and padding rows past num_recv carry
31 + # stale ids and stale NaN in `inputs`; returning before any load leaves their
32 + # output rows untouched instead of summing that garbage. The constexpr guard
33 + # short-circuits, so num_valid_tokens_ptr is only read when it is provided.
34 + if has_num_valid and pid_m >= tl.load(num_valid_tokens_ptr).to(tl.int32):
35 + return
36 + w_row = topk_weights_ptr + pid_m * top_k
37 +
38 + takes = ()
39 + weights = ()
40 + if has_topk_ids:
41 + any_valid = 0
42 + for n in tl.static_range(top_k):
43 + id_val = tl.load(top_ids_ptr + pid_m * top_k + n)
44 + valid = id_val >= 0
45 + any_valid += valid.to(tl.int32)
46 + take = valid
47 + if has_expert_map:
48 + local_id = tl.load(expert_map_ptr + tl.where(valid, id_val, 0))
49 + take = valid & (local_id >= 0)
50 + takes += (take,) # type: ignore[assignment]
51 + weights += (tl.load(w_row + n).to(tl.float32),) # type: ignore[assignment]
52 + # All-padding rows early-return untouched (decode contract past num_recv).
53 + if any_valid == 0:
54 + return
55 + else:
56 + for n in tl.static_range(top_k):
57 + takes += (True,) # type: ignore[assignment]
58 + weights += (tl.load(w_row + n).to(tl.float32),) # type: ignore[assignment]
59 +
60 + n_tiles: tl.constexpr = (hidden_size + BLOCK_K - 1) // BLOCK_K
61 + a_row = inputs_ptr + pid_m * stride_m
62 + out_row = outputs_ptr + pid_m * hidden_size
63 +
64 + for t in tl.range(0, n_tiles):
65 + offs_k = t * BLOCK_K + tl.arange(0, BLOCK_K)
66 + kmask = offs_k < hidden_size
67 + acc = tl.zeros((BLOCK_K,), dtype=tl.float32)
68 + for n in tl.static_range(top_k):
69 + if takes[n]:
70 + a = tl.load(a_row + n * hidden_size + offs_k, mask=kmask, other=0.0)
71 + acc += a.to(tl.float32) * weights[n]
72 + tl.store(out_row + offs_k, acc.to(outputs_ptr.dtype.element_ty), mask=kmask)
73
74
75 def _heuristic_config(
67 - num_tokens: int,
68 - top_k: int,
69 - size: int,
76 + hidden_size: int,
77 element_size: int,
78 ):
79 is_fp32 = element_size > 2
73 - is_sm90_plus = current_platform.has_device_capability(90)
74 - is_sm80_before = not current_platform.has_device_capability(80)
75 -
76 - if current_platform.has_device_capability(90):
77 - # SM90/SM100+: prefer small tiles + many CTAs.
78 - if is_fp32:
79 - BLOCK_M = 1 if num_tokens <= 4 else 2
80 - else:
81 - if num_tokens <= 4:
82 - BLOCK_M = 1
83 - elif num_tokens <= 128:
84 - BLOCK_M = 2
85 - else:
86 - BLOCK_M = 4
87 - elif is_fp32:
88 - if num_tokens <= 4:
89 - BLOCK_M = 1
90 - elif num_tokens <= 32:
91 - BLOCK_M = 2
92 - elif num_tokens <= 128:
93 - BLOCK_M = 4
94 - else:
95 - BLOCK_M = 4
96 - else:
97 - if num_tokens <= 4:
98 - BLOCK_M = 1
99 - elif num_tokens <= 32:
100 - BLOCK_M = 2
101 - elif num_tokens <= 128:
102 - BLOCK_M = 4
103 - elif num_tokens <= 1024:
104 - BLOCK_M = 16
105 - else:
106 - BLOCK_M = 8
107 -
108 - if is_fp32:
109 - max_block_k = 256
110 - elif is_sm80_before or is_sm90_plus:
111 - max_block_k = 512
112 - else:
113 - max_block_k = 1024
114 - BLOCK_K = min(triton.next_power_of_2(size), max_block_k)
115 - BLOCK_K = max(BLOCK_K, 256)
116 -
117 - total = BLOCK_M * BLOCK_K
118 - if is_fp32:
119 - num_warps = max(8, min(16, total // 64))
120 - else:
121 - num_warps = max(4, min(16, total // 256))
122 -
123 - if is_sm80_before:
124 - num_warps = min(num_warps, 8)
125 - num_stages = 2
126 - elif is_sm90_plus:
127 - num_warps = min(num_warps, 8)
128 - num_stages = 4 if total <= 2048 else 2
129 - else:
130 - num_stages = 4 if total <= 2048 else 2
131 -
132 - return BLOCK_M, BLOCK_K, num_warps, num_stages
80 + max_block_k = 256 if is_fp32 else 512
81 + BLOCK_K = max(128, min(triton.next_power_of_2(hidden_size), max_block_k))
82 + num_warps = 4 if is_fp32 else 2
83 + num_stages = 3
84 + return BLOCK_K, num_warps, num_stages
85
86
87 def moe_fused_mul_sum(
@@ -138,6 +90,7 @@ def moe_fused_mul_sum(
90 outputs: torch.Tensor | None = None,
91 topk_ids: torch.Tensor | None = None,
92 expert_map: torch.Tensor | None = None,
93 + num_valid_tokens: torch.Tensor | None = None,
94 ) -> torch.Tensor:
95 """
96 Fused kernel for MoE (Mixture of Experts) to perform weighted summation
@@ -150,10 +103,21 @@ def moe_fused_mul_sum(
103 Shape: (num_tokens, top_k).
104 outputs: Optional pre-allocated output tensor.
105 Shape: (num_tokens, hidden_size).
153 - topk_ids: Optional indices of the top-k experts. Used when
154 - `expert_map` is provided. Shape: (num_tokens, top_k).
106 + topk_ids: Optional indices of the top-k experts. Shape:
107 + (num_tokens, top_k). A value of -1 marks a slot the expert GEMM
108 + skipped; those slots are excluded from the sum. When provided, rows
109 + with all top ids < 0 (worst-case padding) are skipped and their
110 + output rows left untouched. Required when `expert_map` is provided.
111 expert_map: Optional mapping for Expert Parallelism. A value < 0
156 - indicates an invalid token/expert pair that will be skipped.
112 + indicates an invalid token/expert pair that will be skipped. Only
113 + needed when `topk_ids` may contain non-local expert ids; if every
114 + non-(-1) id is already a local expert, leave it None to skip the
115 + redundant per-slot lookup.
116 + num_valid_tokens: Optional device scalar (1-element tensor) holding the
117 + number of real token rows (num_recv for a decode dispatch). When
118 + provided, rows past it are left untouched, so the static cudagraph
119 + grid never sums stale padding rows. Pass the token count, not
120 + token*top_k.
121
122 Returns:
123 The fused weighted sum of expert outputs.
@@ -166,34 +130,40 @@ def moe_fused_mul_sum(
130 assert inputs.dtype in (torch.float32, torch.float16, torch.bfloat16)
131 assert topk_weights.dtype in (torch.float32, torch.float16, torch.bfloat16)
132
169 - num_tokens, top_k, size = inputs.shape
170 - output_shape = (num_tokens, size)
133 + num_tokens, top_k, hidden_size = inputs.shape
134 + output_shape = (num_tokens, hidden_size)
135 if outputs is None:
136 outputs = torch.empty(output_shape, dtype=inputs.dtype, device=inputs.device)
137
138 assert outputs.shape == output_shape
139 assert topk_weights.shape == (num_tokens, top_k)
140 + assert expert_map is None or topk_ids is not None, (
141 + "topk_ids is required to interpret expert_map"
142 + )
143 + if topk_ids is not None:
144 + assert topk_ids.shape == (num_tokens, top_k)
145 + assert topk_ids.is_contiguous()
146 + assert topk_ids.dtype in (torch.int32, torch.int64)
147
148 if not isinstance(inputs, FakeTensor):
178 - BLOCK_M, BLOCK_K, num_warps, num_stages = _heuristic_config(
179 - num_tokens,
180 - top_k,
181 - size,
149 + BLOCK_K, num_warps, num_stages = _heuristic_config(
150 + hidden_size,
151 inputs.element_size(),
152 )
184 - grid = (triton.cdiv(size, BLOCK_K), triton.cdiv(num_tokens, BLOCK_M))
153 + grid = (num_tokens,)
154 moe_fused_mul_sum_kernel[grid](
155 inputs,
156 topk_weights,
157 outputs,
158 topk_ids,
159 expert_map,
191 - num_tokens,
192 - top_k * size,
160 + num_valid_tokens,
161 + top_k * hidden_size,
162 + topk_ids is not None,
163 expert_map is not None,
164 + num_valid_tokens is not None,
165 top_k,
195 - size,
196 - BLOCK_M,
166 + hidden_size,
167 BLOCK_K,
168 num_warps=num_warps,
169 num_stages=num_stages,
vllm/model_executor/layers/fused_moe/prepare_finalize/deepep_v2.py
+13 -1
@@ -32,7 +32,7 @@ class DeepEPV2PrepareAndFinalize(mk.FusedMoEPrepareAndFinalizeModular):
32 - Worst-case tensor allocation; padding rows zeroed via
33 handle.psum_num_recv_tokens_per_scaleup_rank
34 - Fully cudagraph-capturable
35 - - Expert kernel sorts internally (expert_tokens_meta=None)
35 + - Expert kernel sorts internally (expert_tokens_meta carries no counts)
36
37 **Prefill mode (use_cudagraph=False):**
38 - do_expand=True, do_cpu_sync=True
@@ -266,6 +266,18 @@ class DeepEPV2PrepareAndFinalize(mk.FusedMoEPrepareAndFinalizeModular):
266 if recv_topk_weights is not None and recv_topk_weights.ndim == 1:
267 recv_topk_weights = recv_topk_weights.unsqueeze(1)
268
269 + if self.use_cudagraph:
270 + # Carry the per-rank prefix sum so SiTU can skip padding rows.
271 + # expert_num_tokens stays None: count-based consumers (DeepGEMM,
272 + # Triton) must treat a None field as "no counts" and derive their
273 + # own, exactly as in the meta-absent decode case.
274 + if expert_tokens_meta is None:
275 + expert_tokens_meta = mk.ExpertTokensMetadata(
276 + expert_num_tokens=None,
277 + expert_num_tokens_cpu=None,
278 + )
279 + expert_tokens_meta.psum_recv_per_rank = psum_recv_per_rank
280 +
281 if not quant_config.is_block_quantized and not defer_input_quant:
282 expert_x_scale = None
283 if expert_x.numel() != 0:
vllm/model_executor/layers/quantization/utils/humming_utils.py
+34 -1
@@ -307,7 +307,18 @@ def _humming_input_schema_to_quant_key(
307 gs = schema.input_scale_group_size
308 group_shape = GroupShape(row=1, col=gs) if gs > 0 else GroupShape.PER_TOKEN
309
310 - scale_dtype = MXFP_SCALE_DTYPE if gs > 0 else torch.float32
310 + # Pick the scale dtype the Humming kernel actually consumes. An explicit
311 + # input_scale_dtype always wins. Otherwise infer from the grouping: MX
312 + # microscale activations (group size 32, e.g. MXFP8) carry an e8m0 (uint8)
313 + # scale, while block-FP8 (group size 128) and per-token FP8/int8 carry a
314 + # float32 scale. Getting this right lets a grouped FP8 activation match
315 + # kFp8Dynamic128Sym instead of an unmatchable uint8-scaled key.
316 + if schema.input_scale_dtype is not None:
317 + scale_dtype = _HUMMING_TO_SCALE_DTYPE[schema.input_scale_dtype]
318 + elif gs == 32:
319 + scale_dtype = MXFP_SCALE_DTYPE
320 + else:
321 + scale_dtype = torch.float32
322
323 scale = ScaleDesc(dtype=scale_dtype, static=False, group_shape=group_shape)
324
@@ -553,6 +564,7 @@ def make_humming_moe_quant_config(
564 quant_dtype: torch.dtype | str | None,
565 weight_dtype: torch.dtype | str | None,
566 weight_group_shape: GroupShape | None = None,
567 + activation_group_shape: GroupShape | None = None,
568 w1_scale: torch.Tensor | None = None,
569 w2_scale: torch.Tensor | None = None,
570 w1_zp: torch.Tensor | None = None,
@@ -569,7 +581,14 @@ def make_humming_moe_quant_config(
581 assert humming_configs is not None
582 if quant_dtype is None:
583 a_quant_desc = FusedMoEQuantDesc(dtype=None)
584 + elif activation_group_shape is not None:
585 + # Pre-dispatch quantization.
586 + a_quant_desc = FusedMoEQuantDesc(
587 + dtype=quant_dtype, shape=activation_group_shape
588 + )
589 else:
590 + # Deferred path: Humming quantizes the activation internally, so the
591 + # descriptor only needs a non-None dtype to mark it as quantized.
592 shape = GroupShape(row=1, col=-1)
593 a_quant_desc = FusedMoEQuantDesc(dtype=quant_dtype, shape=shape)
594
@@ -621,6 +640,19 @@ def get_humming_moe_quant_config(
640 else:
641 q_dtype = str(input_schema.a_dtype)
642
643 + # Block-FP8 (group-128) activations are quantized *before* the EP all-to-all
644 + # dispatch (so FP8 rather than BF16 crosses the interconnect) and consumed by
645 + # Humming as-is.
646 + activation_group_shape: GroupShape | None = None
647 + input_scale_group_size = getattr(input_schema, "input_scale_group_size", 0) or 0
648 + if (
649 + q_dtype is not None
650 + and q_dtype.startswith("float8")
651 + and input_scale_group_size == 128
652 + ):
653 + q_dtype = _HUMMING_TO_QUANT_DTYPE.get(input_schema.a_dtype, FP8_DTYPE)
654 + activation_group_shape = GroupShape(row=1, col=input_scale_group_size)
655 +
656 weight_scale_group_size = weight_schema.weight_scale_group_size
657 weight_scale_group_size_n = weight_schema.weight_scale_group_size_n
658 weight_group_shape: tuple[int, ...] = ()
@@ -638,6 +670,7 @@ def get_humming_moe_quant_config(
670 quant_dtype=q_dtype,
671 weight_dtype=str(weight_schema.b_dtype),
672 weight_group_shape=weight_group_shape,
673 + activation_group_shape=activation_group_shape,
674 w1_scale=getattr(layer, "w13_weight_scale", None),
675 w1_gscale=getattr(layer, "w13_weight_scale_2", None),
676 w1_zp=getattr(layer, "w13_zero_point", None),