Compare commits

...
Author SHA1 Message Date
Bugen Zhao 76c973e13c refactor & simplify
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-06-05 06:39:10 +00:00
Bugen Zhao 53275a22d6 bridge rust tool parser to python
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-06-05 05:57:20 +00:00
Bugen Zhao 9729e05917 fix minimax m3 tool parser rust unit test
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-06-05 05:52:34 +00:00
Bugen ZhaoandGitHub be37de73a9 Python ame tool/reasoning parser (#9)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-06-04 10:33:55 +08:00
Hongxia YangandGitHub e633514f50 [ROCm] minimax-m3 initial enablement on ROCm (#25)
* initial enablement on ROCm

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* fuse gemma_rmsnorm, swiglu-oai Triton kernels and tune MXFP8 GEMMs for ROCm

Replace per-element PyTorch fallbacks with single-pass Triton kernels for
the AMD/ROCm MiniMax-M3 path:

- gemma_rmsnorm / gemma_fused_add_rmsnorm: fp32 normalize+scale in one
  kernel, handles non-contiguous qkv.split views (q_norm/k_norm).
- swiglu_oai_split: fused gate*sigmoid(alpha*gate)*(up+beta) with optional
  clamp, used in both dense MLP and native MXFP8 MoE.
- MXFP8 GEMM tuning: set num_warps=8 for both the linear and MoE
  dot_scaled kernels; use hidden_states.dtype instead of hardcoded bf16.

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* add unit tests for ROCm fused kernels (rmsnorm, swiglu, mxfp8)

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* [ROCm] Add NATIVE_MXFP8 backend, use supports_mx() for arch gating, fix lint

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* add adaptive tile to swiglu-oai kernal

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* [ROCm] mimimax-m3: enable vision-language support, mirroring nvda path

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* [ROCm] minimax-m3: review feedback on SWIGLUOAI_UNINTERLEAVE and tidy MXFP8 MoE oracle

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* revert changes to nvda mxfp8 oracle to minimize changes

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

* minor

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>

---------

Signed-off-by: Hongxia Yang <hongxia.yang@amd.com>
2026-06-03 22:04:27 -04:00
Jee Jee LiandGitHub 6647a1a88b Reduce dtype conversions for AR fusion (#40)
* Init

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* move

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

---------

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
2026-06-04 00:43:45 +08:00
Jee Jee LiandGitHub 56afa45bf7 Fuse qknorm+rope+insert (#20)
* Init

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* Delete comments

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* NHD layout

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* torch stable abi

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* torch stable abi

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* FMT

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* FMT

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

---------

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
2026-06-03 23:10:47 +08:00
Jiangyun ZhuandGitHub 41cf8bbd60 enable breakable cudagraph by default (#39)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
2026-06-03 20:17:46 +08:00
Bugen ZhaoandGitHub 427b2793f0 Rust frontend: renderer fixes, tool parser, reasoning parser (#8)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Fix several hf chat template rendering issues (#44311)
2026-06-03 15:12:19 +08:00
Thien TranandGitHub f4ab9994b5 Simple improvement to Indexer score prefill (#37)
* improve

* remove masked load for K

* add comment
2026-06-03 14:59:15 +08:00
8c9b156eca HND KV cache layout (#29)
* HND layout

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* refactor: integrate sparse KV cache into stride-order layout contract

Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>

---------

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>
2026-06-03 08:59:46 +08:00
738af995a7 [MiniMax-M3] Enable EAGLE3 speculative decoding (#24)
* [MiniMax-M3] Enable EAGLE3 speculative decoding

Signed-off-by: zixi-qi <zixi@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [MiniMax-M3] Document that PP is unsupported for EAGLE3 aux indices

Signed-off-by: zixi-qi <zixi@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: zixi-qi <zixi@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:54:29 -04:00
Jee Jee LiandGitHub 293c3895e2 Done (#32)
Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
2026-06-02 20:43:48 +08:00
e7fef86e50 [CI/Build] Bump flashinfer to v0.6.12 (#31)
(cherry picked from commit ef2acc7fb7)

Signed-off-by: Vadim Gimpelson <vadim.gimpelson@gmail.com>
Co-authored-by: Vadim Gimpelson <vadim.gimpelson@gmail.com>
2026-06-02 16:13:25 +08:00
Thien TranandGitHub 9cb8ed5008 Remove torch.full() in indexer kernels (#30)
Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
2026-06-02 15:26:17 +08:00
c5905f7760 [MiniMax M3] Vision-language (ViT) support + flashinfer_cudnn & DP-encoder features (#16)
* [Model] Add MiniMax M3 text backbone skeleton + SwiGLU-OAI clamp activation

Port the MiniMax M3 (text backbone) into vLLM's custom model layout:

- Add MiniMaxM3SparseForCausalLM under vllm/models/minimax_m3/nvidia with the
  decoder/model/causal-LM wiring; attention and MoE bodies plus weight loading
  are left as stubs. Dense MiniMaxM3MLP is fully ported.
- Add MiniMaxM3SparseForConditionalGeneration as a minimal LM-routing wrapper
  (KimiK25-style init_vllm_registered_model on text_config) and register both
  architectures.
- Add MiniMaxM3Config (model_type minimax_m3_vl) wrapping MiniMaxM3TextConfig
  so config.get_text_config() extracts the backbone; register in the config
  registries.

Generalize silu_and_mul_with_clamp to SwiGLU-OAI:

- Add alpha (scales the activation's sigmoid) and beta (added to the
  non-activated half) to the CUDA kernel, ops.h, and torch_bindings schema.
  Defaults alpha=1.0, beta=0.0 are bitwise-identical to the previous
  silu(gate)*up, so existing callers (DeepSeek V4) are unaffected.
- SiluAndMulWithClamp(alpha, beta) used by MiniMaxM3MLP with alpha=swiglu_alpha,
  beta=1.0, matching the reference gate*sigmoid(alpha*gate)*(up+1).

AI assistance (Claude) was used for this change.

Signed-off-by: Yongye Zhu <yongye@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [Model] MiniMax M3: implement MoE block + weight-name mapping

Implement the sigmoid-routed MoE block for MiniMax M3 and map module
names to the checkpoint so weight loading works for the ported modules.

MoE block (MiniMaxM3MoE):
- fp32 router via GateLinear (bf16 activations upcast to fp32; fp32
  weights and logits), matching minimax_m2/sglang precision.
- FusedTopKBiasRouter routing (scoring_func from config, sigmoid +
  e_score_correction_bias + renormalize), verified to match sglang's
  TopK (select-with-bias, weight-without-bias, routed_scaling on output).
- swigluoai activation (from config.hidden_act) + swiglu_limit; shared
  expert fused into FusedMoE so the shared partial is reduced with the
  routed output.

Weight loading:
- Name the MoE submodule `block_sparse_moe` (dense stays `mlp`) to match
  the checkpoint; decoder forward selects per layer.
- load_weights handles gate_up fusion (dense MLP + shared experts) and
  expert w1/w2/w3 -> w13/w2 fusion; wrappers delegate via
  AutoWeightsLoader, skipping vision/mm/mtp. Not-yet-ported modules
  (attention) are skipped until they land.

The expert GEMM/activation kernel correctness and attention/weight
loading for the remaining modules are not part of this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Port attention modules and wire up MXFP8 checkpoint loading

Port the weight-bearing dense (MiniMaxM3Attention) and sparse
(MiniMaxM3SparseAttention) attention modules so the checkpoint's
self_attn.* tensors map onto real params (forward still stubbed;
this targets weight loading). Add qkv stacked mapping and the
weight_scale_inv -> weight_scale remap in load_weights.

Load MiniMax-style MXFP8 checkpoints (quant_method: "mxfp8" +
ignored_layers) via the ModelOpt MXFP8 config: register "mxfp8" in
method_to_config and normalize the minimal checkpoint schema to the
ModelOpt schema in ModelOptMxFp8Config.from_config (same on-disk
format). Use setdefault for online shorthands so the checkpoint
config wins over the "mxfp8" online shorthand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Add DeepGEMM MXFP8 MoE backend with swigluoai support

Extend DeepGemmExperts to support MXFP8 activations (FP8 e4m3 + UE8M0
1x32 block scales) via the grouped GEMM with recipe (1, 32), reusing the
oracle/fp8 weight-conversion path. Generalize
deepgemm_post_process_fp8_weight_block to derive the transform recipe
from the block shape ((1, 1, 32) for MXFP8) and accept uint8 E8M0 scales.

Unify the fused gated-activation+quant triton kernels around
y = (up + beta) * gate * sigmoid(alpha * gate): silu is alpha=1, beta=0
(bit-identical to before); swigluoai uses alpha/beta from config. Thread
gemm1_alpha/gemm1_beta from the FusedMoE layer through the MXFP8 quant
config into the kernels, and add swiglu_alpha/swiglu_beta to the layer
and MiniMax M3 config/model (beta sourced from config, not hardcoded).

Wire Fp8MoeBackend.DEEPGEMM into the MXFP8 oracle (selectable via
--moe-backend deep_gemm), resolving directly to DeepGemmExperts (the
Triton fallback cannot handle the 1x32 scheme). Advertise SWIGLUOAI in
_supports_activation so swigluoai selects DeepGEMM rather than falling
through to another backend; gate the MXFP8 scheme to Blackwell (SM100).

Verified on GB200: packed-kernel parity (silu defaults unchanged,
swigluoai matches torch ref), (1,32) weight-prep transform, and a TP=4
launch selecting the DEEPGEMM MXFP8 backend with full weight load (the
run then stops at the still-stubbed attention forward, as expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Implement dense attention forward

Implement MiniMaxM3Attention.forward (dense path): qkv projection, split,
per-head QK norm (GemmaRMSNorm, qk_norm_type="per_head"), partial RoPE,
attention, and output projection. Mirrors the sglang reference dense path
and vLLM's canonical per-head-norm convention. attention_output_gate is
False for M3, so the gate branch is omitted.

The sparse attention forward (index branch) remains stubbed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Drop dead index value/output projection branch

For M3, sparse_disable_index_value matches sparse_attention_freq exactly
([0,0,0,1,...,1]): the only layers with the flag unset (0-2) are the
non-sparse layers built as MiniMaxM3Attention. Every layer that constructs
MiniMaxM3SparseAttention therefore always disables the index value/output
projections, so index_{v,o}_proj are never created.

Remove the unreachable else branch, the disable_index_value parameter and
field, and the now-unused _disable_index_value_layer_ids helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Add sparse-attention backend + merged attention layer

Scaffold the lightning-indexer sparse-attention path:

- MiniMaxM3SparseBackend (registered as MINIMAX_M3_SPARSE): block-sparse GQA
  backend; get_kv_cache_shape serves both the main K/V cache and the
  single-vector index-key side cache.
- MiniMaxM3IndexerCache: side KV cache for per-token index keys, key-only so it
  uses a single-vector MLAAttentionSpec rather than a K+V FullAttentionSpec.
- MiniMaxM3SparseMetadata (+ prefill/decode sub-metadata) and its builder,
  splitting the batch via split_decodes_and_prefills.
- MiniMaxM3SparseImpl: subclasses AttentionImplBase so it can take a custom
  forward(query, index_query, kv_cache, index_kv_cache); no alibi / sliding
  window / logits soft cap. forward is a stub pending the kernel port.

MiniMaxM3SparseAttention is merged into a single AttentionLayerBase: it owns the
projections, per-head QK norm and RoPE, binds the backend + impl, registers the
main K/V cache, and holds the index cache. Its forward computes q/k/v and the
index q/k, pre-inserts K/V and index-K into their caches, then calls the sparse
impl with only the queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Implement sparse-attention forward + MXFP8 DeepGEMM MoE e2e

Port the MiniMax M3 lightning-indexer sparse attention to a Triton backend and
fix the MXFP8 DeepGEMM MoE path so the model runs end to end.

Attention (vllm/v1/attention/ops/minimax_m3_sparse_ops.py + backend):
- Triton kernels (paged, page == sparse block == 128): index block-score +
  bitonic top-k, and GQA block-sparse flash attention over the selected blocks.
- MiniMaxM3SparseImpl.forward: decode-first split, dispatching the same kernels
  per phase (a decode token is a 1-token prefill). Index and main caches use
  separate block tables.
- Dedicated MiniMaxM3IndexerBackend for the key-only index cache so the main
  GQA cache (num_kv_heads==1 at TP>=4) is not mistaken for the index layout.
- get_supported_kernel_block_sizes()==[128] (one sparse block per KV page).

MXFP8 DeepGEMM MoE:
- Prepare-phase activation quant emits float32 per-(1,32) group scales for the
  DeepGEMM backend (use_deep_gemm_packed_mxfp8 on the quant config), matching
  the FP8 128-block path with group=32.
- deepgemm_moe_permute / ep_scatter take a block_size so the activation-scale
  group (32) is honored through the expert permute.
- workspace_shapes uses the contiguous-layout M alignment (not block_shape[0],
  which is 1 for MXFP8 and under-sized the workspace).

GSM8K (5-shot, TP=4) flexible-extract 0.921 / strict 0.919.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* draft vl impl

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* try to load

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* don't rename o_proj

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* fix vit loading

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* ooops

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* ooops

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* [MiniMax M3] Enable decode CUDA graphs + dedicated split-K decode kernels (#7)

Two changes to the sparse-attention backend:

1. Full decode CUDA-graph support. The metadata builder now declares
   AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE and precomputes all
   per-step kernel args (cu_seqlens_q, prefix_lens, max_query_len,
   num_actual_tokens) in build(), removing the .item() host sync and the
   per-step torch.zeros/cumsum/diff from the impl forward. Derived decode
   prefix lengths are written into a persistent buffer so the captured
   graph reads stable addresses across replays.

2. Dedicated split-K decode kernels (mirroring the sglang reference)
   instead of reusing the prefill kernels with BLOCK_SIZE_Q=1, which left
   the GPU idle at decode (one query token per request). The index score
   now splits over seq blocks and the GQA attention splits over the
   selected top-k blocks with an LSE merge (flash-decoding). Chunk counts
   depend only on shape constants, so the grid is fixed within a CUDA
   graph.

Verified: a parity test against the prior (GSM8K 92.1) prefill-as-decode
path matches exactly on top-k selection and on attention output (bf16
noise) across seq lengths 128-2048.

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3 VL] Multimodal (ViT) support: vendored processor, ViT fixes, encoder features

Make MiniMaxAI/Minimax-M3-preview serve as a VL model:
- registry: move ...ForConditionalGeneration to _MULTIMODAL_MODELS
- vendor the HF processor (image/video/composite) so no --trust-remote-code,
  constructed directly in get_hf_processor; Qwen-style smart_resize
- vision_tower: disable post_layernorm (matches reference), fp32 RoPE,
  backend-aware encoder metadata enabling flashinfer_cudnn ViT
- model: supports_encoder_tp_data + fix --mm-encoder-tp-mode data DP branch
- mm_preprocess: cap dummy video frames; smart_resize token counting

AI-assisted (Claude Code); WIP, pending human review + test re-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [MiniMax M3 VL] Align video timestamps with the MiniMax reference

Request video metadata (fps + sampled frame indices) via
MultiModalDataParser(video_needs_metadata=True) and forward it as
VideoMetadata so the processor emits per-frame "]<]X.X seconds[>[" markers.
_get_prompt_updates reconstructs the same markers from the metadata (using the
HF formula frames_indices[frame*temporal_patch_size]/fps) so the prompt
replacement stays byte-aligned with the processor output. Falls back to no
timestamps when metadata is absent (dummy/profiling videos), keeping both
paths consistent.

Verified: processor emits the expected timestamps; the piecewise replacement
exactly matches the tokenized video region; server video requests succeed
(no placeholder mismatch) and image inference is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* m3 video loader and processor cleanup

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

* Fix pre-commit lint/type errors in MiniMax M3 VL files

The new MiniMax M3 VL files live outside vllm/model_executor/models (which
mypy excludes), so they are linted and type-checked in CI. Make all hooks
pass:

- ruff: reorder default_weight_loader import (isort); drop trailing
  whitespace in vision_tower.py.
- typos: allowlist `tpos` (temporal position id, parallels the existing
  hpos/wpos vision-RoPE naming).
- mypy:
  - annotate round/ceil/floor_by_factor as `int | float` (matches
    ernie45_vl); they are called with float values.
  - `# type: ignore[call-arg]` on the ImagesKwargs/VideosKwargs/
    ProcessingKwargs `total=False` subclasses (matches ovis/isaac/etc.).
  - cast dummy-option overrides to ImageDummyOptions/VideoDummyOptions and
    the videos mm_data value to `list` before iterating.
  - assert multimodal_config is not None before reading mm_encoder_tp_mode.
  - skip None results when building mm_input_by_modality so the strict
    `dict[str, dict]` annotation holds and no None reaches the embedders.

Verified locally: ruff-check, ruff-format, typos, check-spdx-header,
check-root-lazy-imports, and mypy (3.10/3.11/3.12/3.13) all pass on the
changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Roger Wang <hey@rogerw.io>

---------

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
Signed-off-by: Roger Wang <hey@rogerw.io>
Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>
Co-authored-by: Isotr0py <Isotr0py@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:08:42 -07:00
22c6542fa7 [FlashInfer] Support page size >= 128 for trtllm-gen GQA/MQA (#26)
Add power-of-2 page sizes >= 128 (128/256/512/1024) to the FlashInfer
backend's supported kernel block sizes. These are served only by the
trtllm-gen dynamic kernel, which requires Blackwell + GQA/MQA
(num_qo_heads // num_kv_heads > 1), not MHA.

Fail fast in FlashInferMetadataBuilder.__init__ when page_size >= 128 is
requested without a usable trtllm-gen path (trtllm disabled, not
Blackwell, or MHA) instead of silently hitting the native wrappers, and
force the prefill path to trtllm so it does not fall back to FA2.

Regenerate docs/design/attention_backends.md for the new block sizes.

AI assistance (Claude) was used for this change.

Co-authored-by: Claude

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-06-02 00:30:09 -04:00
Yongye ZhuandGitHub 483bda03a7 [MiniMax-M3] Fuse indexer q/k into qkv_proj; support TP > kv heads (#23)
The sparse-attention layers ran three separate projections off the same
hidden_states (qkv_proj + index_q_proj + index_k_proj) and broke for
tensor-parallel sizes greater than num_key_value_heads (4): num_idx_heads
collapsed to 0 and the index_q ColumnParallelLinear could not split 4 heads
across >4 ranks.

Add MinimaxM3QKVParallelLinearWithIndexer (in layers/linear.py), a
QKVParallelLinear subclass that emits [q | k | v | index_q | index_k] from a
single column-parallel GEMM. Since sparse_num_index_heads == num_key_value_heads
and index_head_dim == head_dim, index_q shards exactly like K/V and rides the
existing num_kv_head_replicas path, so TP > kv-heads works (replication) and the
num_idx_heads == num_kv_heads indexer invariant holds at every TP. index_k is a
single shared head replicated to every rank. MXFP8 weight_scale shards via the
same per-output-row offsets (block_shape [1, 32]). The class is M3-specific; it
lives in linear.py only to reuse QKVParallelLinear's sharding/loading machinery.

Wire MiniMaxM3SparseAttention to the fused linear (single projection + 5-way
split) and fold index_q/index_k_proj weights into qkv_proj in the loader; these
mapping entries never match dense layers, which keep plain QKVParallelLinear.

Verified TP=4 on GB200: weights load clean, GSM8K 5-shot exact_match
flexible-extract 0.9189, strict-match 0.9166.

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-06-01 22:39:20 -04:00
92b3a243d5 [MiniMax-M3] Add SWIGLUOAI_UNINTERLEAVE activation and fix MXFP8 Marlin MoE (#22)
MiniMax-M3 loads w13 packed ([all gates; all ups]) via
MergedColumnParallelLinear, but the existing SWIGLUOAI activation assumes
gpt-oss-style interleaved w13. Add a SWIGLUOAI_UNINTERLEAVE variant that runs
the same SwiGLU-OAI math on packed w13 via silu_and_mul_with_clamp, and wire it
through Marlin and deepgemm.

- activation: add SWIGLUOAI_UNINTERLEAVE; extend apply_moe_activation with
  clamp_limit/alpha/beta and fuse the SILU+clamp (swiglu_limit_func) path into
  silu_and_mul_with_clamp.
- marlin_moe: route the new activation, plumb gemm1_alpha/beta from the quant
  config.
- modular_kernel: forward the gate params through activation().
- deep_gemm_moe: switch its packed gated path to SWIGLUOAI_UNINTERLEAVE.
- oracle/fp8 + config: fp8_w8a16_moe_quant_config (MARLIN/CPU branch) was
  dropping swiglu_limit/alpha/beta, leaving gemm1_clamp_limit=None on the MXFP8
  Marlin path; forward them.
- minimax_m3 model: emit "swigluoai_uninterleave".
- minimax_m3 config: swiglu_beta default 1.0 (the (up+1) OAI bias; the
  checkpoint omits it). With beta=0 the +1 term is dropped on every FFN/MoE
  layer.

gsm8k (MiniMax-M3-preview, TP=4, Hopper/Marlin MXFP8): 0.0008 -> 0.92.

Signed-off-by: yongye <yongye@inferact.ai>
Co-authored-by: yongye <yongye@inferact.ai>
2026-06-01 20:04:13 -04:00
14682903c8 Improve silu_mul_quant_fp8 (#17)
* Optimize packed SiLU FP8 quant tile

Record MiniMax M3 workload benchmark results.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Record power-of-four SiLU FP8 workload sweep

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Compare power-of-four SiLU FP8 sweep

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Use one-row CTAs for packed SiLU FP8 quant

Record CUPTI benchmark comparison for the one-row CTA variant.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Use persistent row CTAs for packed SiLU FP8 quant

Cap row CTAs and stride across M to avoid oversized grids while retaining large-shape throughput.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Use flat persistent loads for packed SiLU FP8 quant

Record latest CUPTI progression and rejected cache/load experiments.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* final kernel

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* Remove SiLU FP8 worklog from tracking

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* rename

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

---------

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
2026-06-02 03:42:36 +08:00
cbfaaeceeb [MiniMax-M3] Fuse MXFP8 UE8M0 scale packing into ep_scatter (#21)
Remove the `use_deep_gemm_packed_mxfp8` flag and the special MXFP8
quantization branch in `moe_kernel_quantize_input`. MXFP8 activations are
now always quantized to the plain non-swizzled (M, K/32) uint8 UE8M0 scale
layout, and the pack into DeepGEMM's consumed scale layout (int32, MN-major,
TMA-aligned, 4 UE8M0 per int32) is fused directly into the expert-permute
scatter instead of relying on the GEMM's internal repack.

- utils.py: drop the flag + branch; mxfp8 always uses _mxfp8_e4m3_quantize
  (non-swizzled).
- config.py / no_dp_ep.py / oracle/fp8.py: remove the flag plumbing.
- deep_gemm_utils.py: add a PACK_UE8M0 path to _fwd_kernel_ep_scatter_2 that
  concatenates 4 UE8M0 bytes per int32 and stores MN-major; deepgemm_moe_permute
  allocates the TMA-aligned int32 buffer for the uint8 path. Float32 (FP8/FP4)
  scales keep the row-major path unchanged.

mm1 now feeds the grouped GEMM pre-packed int32 scales with recipe_a=(1,32),
matching what the mm2 activation-quant path already does (validate-only
transform).

AI assistance (Claude Code) was used for this change.

Tests run on GB200 (SM100):
- Numerical unit test: fused packed scatter matches a torch reference for
  data placement and byte-packing; output layout/stride is identical to
  per_token_group_quant_fp8_packed_for_deepgemm.
- gsm8k 5-shot, MiniMax-M3-preview:
    TP=4 monolithic : exact_match 0.9249 +/- 0.0073
    DP=4 + EP       : exact_match 0.9325 +/- 0.0069


Co-authored-by: Claude

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:06:21 -04:00
cf6c0d2518 Add AR GemmaRMSNorm fusion (#11)
* [Model] Add MiniMax M3 text backbone skeleton + SwiGLU-OAI clamp activation

Port the MiniMax M3 (text backbone) into vLLM's custom model layout:

- Add MiniMaxM3SparseForCausalLM under vllm/models/minimax_m3/nvidia with the
  decoder/model/causal-LM wiring; attention and MoE bodies plus weight loading
  are left as stubs. Dense MiniMaxM3MLP is fully ported.
- Add MiniMaxM3SparseForConditionalGeneration as a minimal LM-routing wrapper
  (KimiK25-style init_vllm_registered_model on text_config) and register both
  architectures.
- Add MiniMaxM3Config (model_type minimax_m3_vl) wrapping MiniMaxM3TextConfig
  so config.get_text_config() extracts the backbone; register in the config
  registries.

Generalize silu_and_mul_with_clamp to SwiGLU-OAI:

- Add alpha (scales the activation's sigmoid) and beta (added to the
  non-activated half) to the CUDA kernel, ops.h, and torch_bindings schema.
  Defaults alpha=1.0, beta=0.0 are bitwise-identical to the previous
  silu(gate)*up, so existing callers (DeepSeek V4) are unaffected.
- SiluAndMulWithClamp(alpha, beta) used by MiniMaxM3MLP with alpha=swiglu_alpha,
  beta=1.0, matching the reference gate*sigmoid(alpha*gate)*(up+1).

AI assistance (Claude) was used for this change.

Signed-off-by: Yongye Zhu <yongye@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [Model] MiniMax M3: implement MoE block + weight-name mapping

Implement the sigmoid-routed MoE block for MiniMax M3 and map module
names to the checkpoint so weight loading works for the ported modules.

MoE block (MiniMaxM3MoE):
- fp32 router via GateLinear (bf16 activations upcast to fp32; fp32
  weights and logits), matching minimax_m2/sglang precision.
- FusedTopKBiasRouter routing (scoring_func from config, sigmoid +
  e_score_correction_bias + renormalize), verified to match sglang's
  TopK (select-with-bias, weight-without-bias, routed_scaling on output).
- swigluoai activation (from config.hidden_act) + swiglu_limit; shared
  expert fused into FusedMoE so the shared partial is reduced with the
  routed output.

Weight loading:
- Name the MoE submodule `block_sparse_moe` (dense stays `mlp`) to match
  the checkpoint; decoder forward selects per layer.
- load_weights handles gate_up fusion (dense MLP + shared experts) and
  expert w1/w2/w3 -> w13/w2 fusion; wrappers delegate via
  AutoWeightsLoader, skipping vision/mm/mtp. Not-yet-ported modules
  (attention) are skipped until they land.

The expert GEMM/activation kernel correctness and attention/weight
loading for the remaining modules are not part of this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Port attention modules and wire up MXFP8 checkpoint loading

Port the weight-bearing dense (MiniMaxM3Attention) and sparse
(MiniMaxM3SparseAttention) attention modules so the checkpoint's
self_attn.* tensors map onto real params (forward still stubbed;
this targets weight loading). Add qkv stacked mapping and the
weight_scale_inv -> weight_scale remap in load_weights.

Load MiniMax-style MXFP8 checkpoints (quant_method: "mxfp8" +
ignored_layers) via the ModelOpt MXFP8 config: register "mxfp8" in
method_to_config and normalize the minimal checkpoint schema to the
ModelOpt schema in ModelOptMxFp8Config.from_config (same on-disk
format). Use setdefault for online shorthands so the checkpoint
config wins over the "mxfp8" online shorthand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Add DeepGEMM MXFP8 MoE backend with swigluoai support

Extend DeepGemmExperts to support MXFP8 activations (FP8 e4m3 + UE8M0
1x32 block scales) via the grouped GEMM with recipe (1, 32), reusing the
oracle/fp8 weight-conversion path. Generalize
deepgemm_post_process_fp8_weight_block to derive the transform recipe
from the block shape ((1, 1, 32) for MXFP8) and accept uint8 E8M0 scales.

Unify the fused gated-activation+quant triton kernels around
y = (up + beta) * gate * sigmoid(alpha * gate): silu is alpha=1, beta=0
(bit-identical to before); swigluoai uses alpha/beta from config. Thread
gemm1_alpha/gemm1_beta from the FusedMoE layer through the MXFP8 quant
config into the kernels, and add swiglu_alpha/swiglu_beta to the layer
and MiniMax M3 config/model (beta sourced from config, not hardcoded).

Wire Fp8MoeBackend.DEEPGEMM into the MXFP8 oracle (selectable via
--moe-backend deep_gemm), resolving directly to DeepGemmExperts (the
Triton fallback cannot handle the 1x32 scheme). Advertise SWIGLUOAI in
_supports_activation so swigluoai selects DeepGEMM rather than falling
through to another backend; gate the MXFP8 scheme to Blackwell (SM100).

Verified on GB200: packed-kernel parity (silu defaults unchanged,
swigluoai matches torch ref), (1,32) weight-prep transform, and a TP=4
launch selecting the DEEPGEMM MXFP8 backend with full weight load (the
run then stops at the still-stubbed attention forward, as expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Implement dense attention forward

Implement MiniMaxM3Attention.forward (dense path): qkv projection, split,
per-head QK norm (GemmaRMSNorm, qk_norm_type="per_head"), partial RoPE,
attention, and output projection. Mirrors the sglang reference dense path
and vLLM's canonical per-head-norm convention. attention_output_gate is
False for M3, so the gate branch is omitted.

The sparse attention forward (index branch) remains stubbed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Drop dead index value/output projection branch

For M3, sparse_disable_index_value matches sparse_attention_freq exactly
([0,0,0,1,...,1]): the only layers with the flag unset (0-2) are the
non-sparse layers built as MiniMaxM3Attention. Every layer that constructs
MiniMaxM3SparseAttention therefore always disables the index value/output
projections, so index_{v,o}_proj are never created.

Remove the unreachable else branch, the disable_index_value parameter and
field, and the now-unused _disable_index_value_layer_ids helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Add sparse-attention backend + merged attention layer

Scaffold the lightning-indexer sparse-attention path:

- MiniMaxM3SparseBackend (registered as MINIMAX_M3_SPARSE): block-sparse GQA
  backend; get_kv_cache_shape serves both the main K/V cache and the
  single-vector index-key side cache.
- MiniMaxM3IndexerCache: side KV cache for per-token index keys, key-only so it
  uses a single-vector MLAAttentionSpec rather than a K+V FullAttentionSpec.
- MiniMaxM3SparseMetadata (+ prefill/decode sub-metadata) and its builder,
  splitting the batch via split_decodes_and_prefills.
- MiniMaxM3SparseImpl: subclasses AttentionImplBase so it can take a custom
  forward(query, index_query, kv_cache, index_kv_cache); no alibi / sliding
  window / logits soft cap. forward is a stub pending the kernel port.

MiniMaxM3SparseAttention is merged into a single AttentionLayerBase: it owns the
projections, per-head QK norm and RoPE, binds the backend + impl, registers the
main K/V cache, and holds the index cache. Its forward computes q/k/v and the
index q/k, pre-inserts K/V and index-K into their caches, then calls the sparse
impl with only the queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Implement sparse-attention forward + MXFP8 DeepGEMM MoE e2e

Port the MiniMax M3 lightning-indexer sparse attention to a Triton backend and
fix the MXFP8 DeepGEMM MoE path so the model runs end to end.

Attention (vllm/v1/attention/ops/minimax_m3_sparse_ops.py + backend):
- Triton kernels (paged, page == sparse block == 128): index block-score +
  bitonic top-k, and GQA block-sparse flash attention over the selected blocks.
- MiniMaxM3SparseImpl.forward: decode-first split, dispatching the same kernels
  per phase (a decode token is a 1-token prefill). Index and main caches use
  separate block tables.
- Dedicated MiniMaxM3IndexerBackend for the key-only index cache so the main
  GQA cache (num_kv_heads==1 at TP>=4) is not mistaken for the index layout.
- get_supported_kernel_block_sizes()==[128] (one sparse block per KV page).

MXFP8 DeepGEMM MoE:
- Prepare-phase activation quant emits float32 per-(1,32) group scales for the
  DeepGEMM backend (use_deep_gemm_packed_mxfp8 on the quant config), matching
  the FP8 128-block path with group=32.
- deepgemm_moe_permute / ep_scatter take a block_size so the activation-scale
  group (32) is honored through the expert permute.
- workspace_shapes uses the contiguous-layout M alignment (not block_shape[0],
  which is 1 for MXFP8 and under-sized the workspace).

GSM8K (5-shot, TP=4) flexible-extract 0.921 / strict 0.919.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* [MiniMax M3] Enable decode CUDA graphs + dedicated split-K decode kernels (#7)

Two changes to the sparse-attention backend:

1. Full decode CUDA-graph support. The metadata builder now declares
   AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE and precomputes all
   per-step kernel args (cu_seqlens_q, prefix_lens, max_query_len,
   num_actual_tokens) in build(), removing the .item() host sync and the
   per-step torch.zeros/cumsum/diff from the impl forward. Derived decode
   prefix lengths are written into a persistent buffer so the captured
   graph reads stable addresses across replays.

2. Dedicated split-K decode kernels (mirroring the sglang reference)
   instead of reusing the prefill kernels with BLOCK_SIZE_Q=1, which left
   the GPU idle at decode (one query token per request). The index score
   now splits over seq blocks and the GQA attention splits over the
   selected top-k blocks with an LSE merge (flash-decoding). Chunk counts
   depend only on shape constants, so the grid is fixed within a CUDA
   graph.

Verified: a parity test against the prior (GSM8K 92.1) prefill-as-decode
path matches exactly on top-k selection and on attention output (bf16
noise) across seq lengths 128-2048.

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>

* Init

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* Move

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* FIX

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* Addresss conflict

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

---------

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>
2026-06-01 14:14:39 -04:00
Jee Jee LiandGitHub 1b563d1134 Implement MiniMAXGemmaRMSNorm (#19)
* Init

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

* Delete comments

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>

---------

Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
2026-06-02 01:06:48 +08:00
Thien TranandGitHub cbdfa83c84 remove unnecessary padding (#18)
Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
2026-06-01 12:14:52 -04:00
Thien TranandGitHub 12846bbf88 use FlashInfer RMSNorm (#15)
* use FlashInfer RMSNorm

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* restore original GemmaRMSNorm module

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

---------

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
2026-06-01 11:21:08 +08:00
f074bd6cef [MiniMax-M3] Group full-attn + MLA-indexer KV in one group; NIXL non-MLA heterogeneous support (#14)
* [MiniMax-M3] Group full-attn + MLA-indexer KV in one group; NIXL non-MLA heterogeneous support

MiniMax-M3 registers full-attention K/V layers plus a smaller MLA "lightning
indexer" cache. These share the token-slot invariant, so they belong in one
UniformTypeKVCacheSpecs group (as DeepSeek-3.2 does for its all-MLA main+indexer),
keeping each layer's tensor at its native page size.

- kv_cache_interface.is_uniform_type: accept a mix of FullAttentionSpec +
  MLAAttentionSpec with the same block_size in one group.
- nixl/worker.register_kv_caches: drop the non-MLA "all tensors same size"
  assert; per-layer sizes are carried in block_len_per_layer and the descriptor
  builders already index them per layer (num_descs uses a common num_blocks).
- nixl/worker.add_remote_agent: for the heterogeneous non-MLA case, validate each
  layer's block_len against the remote (like the MLA path) and raise
  NotImplementedError for tp_ratio != 1 (head-splitting assumes uniform size).

Verified: single-node M3 TP4 (1 group, registration OK with heterogeneous
tensors, APC works, GSM8K 0.96) and 1P1D Slurm PD via NixlConnector (both
engines 1 group, NIXL KV transfers succeed, 8/8 bench requests, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address comments

Signed-off-by: Dao Le <daole@inferact.ai>

---------

Signed-off-by: Dao Le <daole@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:16:25 -07:00
Thien TranandGitHub 579d7b3705 Prefill sparse attention CuteDSL kernel (#10)
* Add MiniMax M3 CuteDSL sparse prefill attention

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

* rename

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

---------

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
2026-06-01 06:19:03 +08:00
cb7eb5c9b4 [MiniMax M3] Relocate sparse attention into the model dir + port decode top-k (#12)
Move the MiniMax M3 sparse-attention backend and Triton kernels out of the
shared attention tree into the model definition (mirroring deepseek_v4), split
the kernels by role, and tighten the backend, then port the dedicated split-K
decode top-k from the sglang reference.

Structure:
- vllm/v1/attention/backends/minimax_m3_sparse.py
    -> vllm/models/minimax_m3/common/sparse_attention.py
- vllm/v1/attention/ops/minimax_m3_sparse_ops.py split into
    common/ops/index_topk.py  (index-score + top-k kernels)
    common/ops/sparse_attn.py (block-sparse GQA attention kernels)
  Pure-Triton/cross-platform, so under common/ (not nvidia/).
- registry enum + doc generator RELEVANT_PATTERNS repointed; the auto-generated
  attention_backends.md now has a dedicated "MiniMax M3 Sparse Attention" section.

Backend cleanups:
- Drop redundant metadata fields (sparse-selection params, decode max_query_len,
  decode cu_seqlens_q/context_lens) and read them from the layer/impl instead.
- Impl ctor takes explicit named args instead of kwargs.get lookups.
- Cudagraph support UNIFORM_SINGLE_TOKEN_DECODE -> UNIFORM_BATCH; reorder
  threshold and decode buffer scale by 1 + num_speculative_tokens.
- Unify the per-request context-length buffer (max_num_batched_tokens).
- Declare bf16-only KV cache.

Decode top-k (ported from sglang minimax_sparse_ops/decode):
- Split-K top-k: per-chunk partial top-k (_topk_index_partial_kernel) + merge
  (_topk_index_merge_kernel), replacing the single-program prefill top-k reuse.
- init/local block forcing moved into the decode score kernel (matches sglang).
- Verified against a torch reference (selected-block sets match across varied
  seq lengths, with/without init+local forcing, and short sequences).

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:19:23 -04:00
Yongye Zhu 4f3c528941 [MiniMax M3] Enable decode CUDA graphs + dedicated split-K decode kernels (#7)
Two changes to the sparse-attention backend:

1. Full decode CUDA-graph support. The metadata builder now declares
   AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE and precomputes all
   per-step kernel args (cu_seqlens_q, prefix_lens, max_query_len,
   num_actual_tokens) in build(), removing the .item() host sync and the
   per-step torch.zeros/cumsum/diff from the impl forward. Derived decode
   prefix lengths are written into a persistent buffer so the captured
   graph reads stable addresses across replays.

2. Dedicated split-K decode kernels (mirroring the sglang reference)
   instead of reusing the prefill kernels with BLOCK_SIZE_Q=1, which left
   the GPU idle at decode (one query token per request). The index score
   now splits over seq blocks and the GQA attention splits over the
   selected top-k blocks with an LSE merge (flash-decoding). Chunk counts
   depend only on shape constants, so the grid is fixed within a CUDA
   graph.

Verified: a parity test against the prior (GSM8K 92.1) prefill-as-decode
path matches exactly on top-k selection and on attention output (bf16
noise) across seq lengths 128-2048.

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:03:41 +00:00
Yongye ZhuandClaude Opus 4.8 0f854f78e8 [MiniMax M3] Implement sparse-attention forward + MXFP8 DeepGEMM MoE e2e
Port the MiniMax M3 lightning-indexer sparse attention to a Triton backend and
fix the MXFP8 DeepGEMM MoE path so the model runs end to end.

Attention (vllm/v1/attention/ops/minimax_m3_sparse_ops.py + backend):
- Triton kernels (paged, page == sparse block == 128): index block-score +
  bitonic top-k, and GQA block-sparse flash attention over the selected blocks.
- MiniMaxM3SparseImpl.forward: decode-first split, dispatching the same kernels
  per phase (a decode token is a 1-token prefill). Index and main caches use
  separate block tables.
- Dedicated MiniMaxM3IndexerBackend for the key-only index cache so the main
  GQA cache (num_kv_heads==1 at TP>=4) is not mistaken for the index layout.
- get_supported_kernel_block_sizes()==[128] (one sparse block per KV page).

MXFP8 DeepGEMM MoE:
- Prepare-phase activation quant emits float32 per-(1,32) group scales for the
  DeepGEMM backend (use_deep_gemm_packed_mxfp8 on the quant config), matching
  the FP8 128-block path with group=32.
- deepgemm_moe_permute / ep_scatter take a block_size so the activation-scale
  group (32) is honored through the expert permute.
- workspace_shapes uses the contiguous-layout M alignment (not block_shape[0],
  which is 1 for MXFP8 and under-sized the workspace).

GSM8K (5-shot, TP=4) flexible-extract 0.921 / strict 0.919.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:03:39 +00:00
Yongye ZhuandClaude Opus 4.8 a3028cebbf [MiniMax M3] Add sparse-attention backend + merged attention layer
Scaffold the lightning-indexer sparse-attention path:

- MiniMaxM3SparseBackend (registered as MINIMAX_M3_SPARSE): block-sparse GQA
  backend; get_kv_cache_shape serves both the main K/V cache and the
  single-vector index-key side cache.
- MiniMaxM3IndexerCache: side KV cache for per-token index keys, key-only so it
  uses a single-vector MLAAttentionSpec rather than a K+V FullAttentionSpec.
- MiniMaxM3SparseMetadata (+ prefill/decode sub-metadata) and its builder,
  splitting the batch via split_decodes_and_prefills.
- MiniMaxM3SparseImpl: subclasses AttentionImplBase so it can take a custom
  forward(query, index_query, kv_cache, index_kv_cache); no alibi / sliding
  window / logits soft cap. forward is a stub pending the kernel port.

MiniMaxM3SparseAttention is merged into a single AttentionLayerBase: it owns the
projections, per-head QK norm and RoPE, binds the backend + impl, registers the
main K/V cache, and holds the index cache. Its forward computes q/k/v and the
index q/k, pre-inserts K/V and index-K into their caches, then calls the sparse
impl with only the queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:56 +00:00
Yongye ZhuandClaude Opus 4.8 e327716282 [MiniMax M3] Drop dead index value/output projection branch
For M3, sparse_disable_index_value matches sparse_attention_freq exactly
([0,0,0,1,...,1]): the only layers with the flag unset (0-2) are the
non-sparse layers built as MiniMaxM3Attention. Every layer that constructs
MiniMaxM3SparseAttention therefore always disables the index value/output
projections, so index_{v,o}_proj are never created.

Remove the unreachable else branch, the disable_index_value parameter and
field, and the now-unused _disable_index_value_layer_ids helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:10 +00:00
Yongye ZhuandClaude Opus 4.8 de1828b58f [MiniMax M3] Implement dense attention forward
Implement MiniMaxM3Attention.forward (dense path): qkv projection, split,
per-head QK norm (GemmaRMSNorm, qk_norm_type="per_head"), partial RoPE,
attention, and output projection. Mirrors the sglang reference dense path
and vLLM's canonical per-head-norm convention. attention_output_gate is
False for M3, so the gate branch is omitted.

The sparse attention forward (index branch) remains stubbed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:09 +00:00
Yongye ZhuandClaude Opus 4.8 6542ed479c [MiniMax M3] Add DeepGEMM MXFP8 MoE backend with swigluoai support
Extend DeepGemmExperts to support MXFP8 activations (FP8 e4m3 + UE8M0
1x32 block scales) via the grouped GEMM with recipe (1, 32), reusing the
oracle/fp8 weight-conversion path. Generalize
deepgemm_post_process_fp8_weight_block to derive the transform recipe
from the block shape ((1, 1, 32) for MXFP8) and accept uint8 E8M0 scales.

Unify the fused gated-activation+quant triton kernels around
y = (up + beta) * gate * sigmoid(alpha * gate): silu is alpha=1, beta=0
(bit-identical to before); swigluoai uses alpha/beta from config. Thread
gemm1_alpha/gemm1_beta from the FusedMoE layer through the MXFP8 quant
config into the kernels, and add swiglu_alpha/swiglu_beta to the layer
and MiniMax M3 config/model (beta sourced from config, not hardcoded).

Wire Fp8MoeBackend.DEEPGEMM into the MXFP8 oracle (selectable via
--moe-backend deep_gemm), resolving directly to DeepGemmExperts (the
Triton fallback cannot handle the 1x32 scheme). Advertise SWIGLUOAI in
_supports_activation so swigluoai selects DeepGEMM rather than falling
through to another backend; gate the MXFP8 scheme to Blackwell (SM100).

Verified on GB200: packed-kernel parity (silu defaults unchanged,
swigluoai matches torch ref), (1,32) weight-prep transform, and a TP=4
launch selecting the DEEPGEMM MXFP8 backend with full weight load (the
run then stops at the still-stubbed attention forward, as expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:09 +00:00
Yongye ZhuandClaude Opus 4.8 485f33d796 [MiniMax M3] Port attention modules and wire up MXFP8 checkpoint loading
Port the weight-bearing dense (MiniMaxM3Attention) and sparse
(MiniMaxM3SparseAttention) attention modules so the checkpoint's
self_attn.* tensors map onto real params (forward still stubbed;
this targets weight loading). Add qkv stacked mapping and the
weight_scale_inv -> weight_scale remap in load_weights.

Load MiniMax-style MXFP8 checkpoints (quant_method: "mxfp8" +
ignored_layers) via the ModelOpt MXFP8 config: register "mxfp8" in
method_to_config and normalize the minimal checkpoint schema to the
ModelOpt schema in ModelOptMxFp8Config.from_config (same on-disk
format). Use setdefault for online shorthands so the checkpoint
config wins over the "mxfp8" online shorthand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yongye Zhu <yongye@inferact.ai>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:09 +00:00
Yongye ZhuandClaude Opus 4.8 1cad156ac5 [Model] MiniMax M3: implement MoE block + weight-name mapping
Implement the sigmoid-routed MoE block for MiniMax M3 and map module
names to the checkpoint so weight loading works for the ported modules.

MoE block (MiniMaxM3MoE):
- fp32 router via GateLinear (bf16 activations upcast to fp32; fp32
  weights and logits), matching minimax_m2/sglang precision.
- FusedTopKBiasRouter routing (scoring_func from config, sigmoid +
  e_score_correction_bias + renormalize), verified to match sglang's
  TopK (select-with-bias, weight-without-bias, routed_scaling on output).
- swigluoai activation (from config.hidden_act) + swiglu_limit; shared
  expert fused into FusedMoE so the shared partial is reduced with the
  routed output.

Weight loading:
- Name the MoE submodule `block_sparse_moe` (dense stays `mlp`) to match
  the checkpoint; decoder forward selects per layer.
- load_weights handles gate_up fusion (dense MLP + shared experts) and
  expert w1/w2/w3 -> w13/w2 fusion; wrappers delegate via
  AutoWeightsLoader, skipping vision/mm/mtp. Not-yet-ported modules
  (attention) are skipped until they land.

The expert GEMM/activation kernel correctness and attention/weight
loading for the remaining modules are not part of this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:09 +00:00
Yongye ZhuandClaude Opus 4.8 99bd07204b [Model] Add MiniMax M3 text backbone skeleton + SwiGLU-OAI clamp activation
Port the MiniMax M3 (text backbone) into vLLM's custom model layout:

- Add MiniMaxM3SparseForCausalLM under vllm/models/minimax_m3/nvidia with the
  decoder/model/causal-LM wiring; attention and MoE bodies plus weight loading
  are left as stubs. Dense MiniMaxM3MLP is fully ported.
- Add MiniMaxM3SparseForConditionalGeneration as a minimal LM-routing wrapper
  (KimiK25-style init_vllm_registered_model on text_config) and register both
  architectures.
- Add MiniMaxM3Config (model_type minimax_m3_vl) wrapping MiniMaxM3TextConfig
  so config.get_text_config() extracts the backbone; register in the config
  registries.

Generalize silu_and_mul_with_clamp to SwiGLU-OAI:

- Add alpha (scales the activation's sigmoid) and beta (added to the
  non-activated half) to the CUDA kernel, ops.h, and torch_bindings schema.
  Defaults alpha=1.0, beta=0.0 are bitwise-identical to the previous
  silu(gate)*up, so existing callers (DeepSeek V4) are unaffected.
- SiluAndMulWithClamp(alpha, beta) used by MiniMaxM3MLP with alpha=swiglu_alpha,
  beta=1.0, matching the reference gate*sigmoid(alpha*gate)*(up+1).

AI assistance (Claude) was used for this change.

Signed-off-by: Yongye Zhu <yongye@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com>
2026-05-31 16:02:09 +00:00
123 changed files with 24536 additions and 360 deletions
+3 -1
View File
@@ -315,7 +315,8 @@ set(VLLM_EXT_SRC
if(VLLM_GPU_LANG STREQUAL "CUDA")
list(APPEND VLLM_EXT_SRC
"csrc/minimax_reduce_rms_kernel.cu")
"csrc/minimax_reduce_rms_kernel.cu"
"csrc/minimax_m3_build_k2q_csr.cu")
SET(CUTLASS_ENABLE_HEADERS_ONLY ON CACHE BOOL "Enable only the header library")
@@ -637,6 +638,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
"csrc/libtorch_stable/quantization/gguf/gguf_kernel.cu"
"csrc/libtorch_stable/pos_encoding_kernels.cu"
"csrc/libtorch_stable/fused_qknorm_rope_kernel.cu"
"csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu"
"csrc/libtorch_stable/layernorm_kernels.cu"
"csrc/libtorch_stable/layernorm_quant_kernels.cu"
"csrc/libtorch_stable/quantization/fused_kernels/fused_layernorm_dynamic_per_token_quant.cu"
+75 -41
View File
@@ -10,11 +10,20 @@
namespace vllm {
template <typename scalar_t, scalar_t (*ACT_FN)(const scalar_t&),
// `alpha` and `beta` are applied to opposite operands:
// - alpha lives INSIDE the activation (the activated half): the gated
// activation computes act_half * sigmoid(alpha * act_half).
// - beta is added to the OTHER (non-activated) half before the multiply.
// So the result is always ACT(act_half, alpha) * (other_half + beta).
// Which half is which depends on `act_first` (see below). Defaults
// alpha=1.0, beta=0.0 reproduce the plain SwiGLU/GeGLU behavior.
template <typename scalar_t, scalar_t (*ACT_FN)(const scalar_t&, const float),
bool act_first, bool HAS_CLAMP>
__device__ __forceinline__ scalar_t compute(const scalar_t& x,
const scalar_t& y,
const float limit) {
const float limit,
const float alpha,
const float beta) {
if constexpr (act_first) {
scalar_t gate = x;
scalar_t up = y;
@@ -22,7 +31,9 @@ __device__ __forceinline__ scalar_t compute(const scalar_t& x,
gate = (scalar_t)fminf((float)gate, limit);
up = (scalar_t)fmaxf(fminf((float)up, limit), -limit);
}
return ACT_FN(gate) * up;
// act_first: gate is the activated half -> alpha applies to gate;
// beta is added to up (the non-activated half).
return ACT_FN(gate, alpha) * (scalar_t)((float)up + beta);
} else {
scalar_t gate = x;
scalar_t up = y;
@@ -30,55 +41,66 @@ __device__ __forceinline__ scalar_t compute(const scalar_t& x,
gate = (scalar_t)fmaxf(fminf((float)gate, limit), -limit);
up = (scalar_t)fminf((float)up, limit);
}
return gate * ACT_FN(up);
// !act_first: up is the activated half -> alpha applies to up;
// beta is added to gate (the non-activated half).
return (scalar_t)((float)gate + beta) * ACT_FN(up, alpha);
}
}
template <typename packed_t, packed_t (*PACKED_ACT_FN)(const packed_t&),
template <typename packed_t,
packed_t (*PACKED_ACT_FN)(const packed_t&, const float),
bool act_first, bool HAS_CLAMP>
__device__ __forceinline__ packed_t packed_compute(const packed_t& x,
const packed_t& y,
const float limit) {
const float limit,
const float alpha,
const float beta) {
if constexpr (act_first) {
packed_t gate = x;
packed_t up = y;
float2 u = cast_to_float2(up);
if constexpr (HAS_CLAMP) {
float2 g = cast_to_float2(gate);
float2 u = cast_to_float2(up);
g.x = fminf(g.x, limit);
g.y = fminf(g.y, limit);
u.x = fmaxf(fminf(u.x, limit), -limit);
u.y = fmaxf(fminf(u.y, limit), -limit);
gate = cast_to_packed<packed_t>(g);
up = cast_to_packed<packed_t>(u);
}
return packed_mul(PACKED_ACT_FN(gate), up);
// act_first: gate is the activated half -> alpha applies to gate;
// beta is added to up (the non-activated half).
u.x += beta;
u.y += beta;
return packed_mul(PACKED_ACT_FN(gate, alpha), cast_to_packed<packed_t>(u));
} else {
packed_t gate = x;
packed_t up = y;
float2 g = cast_to_float2(gate);
if constexpr (HAS_CLAMP) {
float2 g = cast_to_float2(gate);
float2 u = cast_to_float2(up);
g.x = fmaxf(fminf(g.x, limit), -limit);
g.y = fmaxf(fminf(g.y, limit), -limit);
u.x = fminf(u.x, limit);
u.y = fminf(u.y, limit);
gate = cast_to_packed<packed_t>(g);
up = cast_to_packed<packed_t>(u);
}
return packed_mul(gate, PACKED_ACT_FN(up));
// !act_first: up is the activated half -> alpha applies to up;
// beta is added to gate (the non-activated half).
g.x += beta;
g.y += beta;
return packed_mul(cast_to_packed<packed_t>(g), PACKED_ACT_FN(up, alpha));
}
}
// Activation and gating kernel template.
template <typename scalar_t, typename packed_t,
scalar_t (*ACT_FN)(const scalar_t&),
packed_t (*PACKED_ACT_FN)(const packed_t&), bool act_first,
bool use_vec, bool HAS_CLAMP, bool use_256b = false>
scalar_t (*ACT_FN)(const scalar_t&, const float),
packed_t (*PACKED_ACT_FN)(const packed_t&, const float),
bool act_first, bool use_vec, bool HAS_CLAMP, bool use_256b = false>
__global__ void act_and_mul_kernel(
scalar_t* __restrict__ out, // [..., d]
const scalar_t* __restrict__ input, // [..., 2, d]
const int d, const float limit) {
const int d, const float limit, const float alpha, const float beta) {
const scalar_t* x_ptr = input + blockIdx.x * 2 * d;
const scalar_t* y_ptr = x_ptr + d;
scalar_t* out_ptr = out + blockIdx.x * d;
@@ -105,7 +127,7 @@ __global__ void act_and_mul_kernel(
for (int j = 0; j < pvec_t::NUM_ELTS; j++) {
x.elts[j] =
packed_compute<packed_t, PACKED_ACT_FN, act_first, HAS_CLAMP>(
x.elts[j], y.elts[j], limit);
x.elts[j], y.elts[j], limit, alpha, beta);
}
if constexpr (use_256b) {
st256(x, &out_vec[i]);
@@ -118,29 +140,34 @@ __global__ void act_and_mul_kernel(
for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) {
const scalar_t x = VLLM_LDG(&x_ptr[idx]);
const scalar_t y = VLLM_LDG(&y_ptr[idx]);
out_ptr[idx] =
compute<scalar_t, ACT_FN, act_first, HAS_CLAMP>(x, y, limit);
out_ptr[idx] = compute<scalar_t, ACT_FN, act_first, HAS_CLAMP>(
x, y, limit, alpha, beta);
}
}
}
// Gated activations take an `alpha` argument that scales the sigmoid input
// (`x * sigmoid(alpha * x)`). alpha defaults to 1.0 at all call sites, which
// is exactly SiLU; only the clamp path (silu_and_mul_with_clamp) passes a
// non-default alpha. Activations that do not use alpha simply ignore it.
template <typename T>
__device__ __forceinline__ T silu_kernel(const T& x) {
// x * sigmoid(x)
return (T)(((float)x) / (1.0f + expf((float)-x)));
__device__ __forceinline__ T silu_kernel(const T& x, const float alpha) {
// x * sigmoid(alpha * x)
return (T)(((float)x) / (1.0f + expf((float)-x * alpha)));
}
template <typename packed_t>
__device__ __forceinline__ packed_t packed_silu_kernel(const packed_t& val) {
// x * sigmoid(x)
__device__ __forceinline__ packed_t packed_silu_kernel(const packed_t& val,
const float alpha) {
// x * sigmoid(alpha * x)
float2 fval = cast_to_float2(val);
fval.x = fval.x / (1.0f + expf(-fval.x));
fval.y = fval.y / (1.0f + expf(-fval.y));
fval.x = fval.x / (1.0f + expf(-fval.x * alpha));
fval.y = fval.y / (1.0f + expf(-fval.y * alpha));
return cast_to_packed<packed_t>(fval);
}
template <typename T>
__device__ __forceinline__ T gelu_kernel(const T& x) {
__device__ __forceinline__ T gelu_kernel(const T& x, const float /*alpha*/) {
// Equivalent to PyTorch GELU with 'none' approximation.
// Refer to:
// https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L36-L38
@@ -150,7 +177,8 @@ __device__ __forceinline__ T gelu_kernel(const T& x) {
}
template <typename packed_t>
__device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val) {
__device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val,
const float /*alpha*/) {
// Equivalent to PyTorch GELU with 'none' approximation.
// Refer to:
// https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L36-L38
@@ -162,7 +190,8 @@ __device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val) {
}
template <typename T>
__device__ __forceinline__ T gelu_tanh_kernel(const T& x) {
__device__ __forceinline__ T gelu_tanh_kernel(const T& x,
const float /*alpha*/) {
// Equivalent to PyTorch GELU with 'tanh' approximation.
// Refer to:
// https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L25-L30
@@ -176,7 +205,7 @@ __device__ __forceinline__ T gelu_tanh_kernel(const T& x) {
template <typename packed_t>
__device__ __forceinline__ packed_t
packed_gelu_tanh_kernel(const packed_t& val) {
packed_gelu_tanh_kernel(const packed_t& val, const float /*alpha*/) {
// Equivalent to PyTorch GELU with 'tanh' approximation.
// Refer to:
// https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L25-L30
@@ -202,7 +231,7 @@ packed_gelu_tanh_kernel(const packed_t& val) {
// clamped (max only) and up input is clamped (both sides) before the
// activation function is applied.
#define LAUNCH_ACTIVATION_GATE_KERNEL(KERNEL, PACKED_KERNEL, ACT_FIRST, \
HAS_CLAMP, LIMIT) \
HAS_CLAMP, LIMIT, ALPHA, BETA) \
auto dtype = input.scalar_type(); \
int d = input.size(-1) / 2; \
int64_t num_tokens = input.numel() / input.size(-1); \
@@ -230,7 +259,7 @@ packed_gelu_tanh_kernel(const packed_t& val) {
PACKED_KERNEL<typename vllm::PackedTypeConverter<scalar_t>::Type>, \
ACT_FIRST, true, HAS_CLAMP, true><<<grid, block, 0, stream>>>( \
out.mutable_data_ptr<scalar_t>(), \
input.const_data_ptr<scalar_t>(), d, LIMIT); \
input.const_data_ptr<scalar_t>(), d, LIMIT, ALPHA, BETA); \
}); \
} else { \
VLLM_STABLE_DISPATCH_FLOATING_TYPES(dtype, "act_and_mul_kernel", [&] { \
@@ -240,7 +269,7 @@ packed_gelu_tanh_kernel(const packed_t& val) {
PACKED_KERNEL<typename vllm::PackedTypeConverter<scalar_t>::Type>, \
ACT_FIRST, true, HAS_CLAMP, false><<<grid, block, 0, stream>>>( \
out.mutable_data_ptr<scalar_t>(), \
input.const_data_ptr<scalar_t>(), d, LIMIT); \
input.const_data_ptr<scalar_t>(), d, LIMIT, ALPHA, BETA); \
}); \
} \
} else { \
@@ -252,7 +281,7 @@ packed_gelu_tanh_kernel(const packed_t& val) {
PACKED_KERNEL<typename vllm::PackedTypeConverter<scalar_t>::Type>, \
ACT_FIRST, false, HAS_CLAMP><<<grid, block, 0, stream>>>( \
out.mutable_data_ptr<scalar_t>(), input.const_data_ptr<scalar_t>(), \
d, LIMIT); \
d, LIMIT, ALPHA, BETA); \
}); \
}
@@ -260,14 +289,18 @@ void silu_and_mul(torch::stable::Tensor& out, // [..., d]
torch::stable::Tensor& input) // [..., 2 * d]
{
LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel,
true, false, 0.0f);
true, false, 0.0f, 1.0f, 0.0f);
}
void silu_and_mul_clamp(torch::stable::Tensor& out, // [..., d]
torch::stable::Tensor& input, // [..., 2 * d]
double limit) {
double limit, double alpha, double beta) {
// out = (gate.clamp(max=limit) * sigmoid(alpha * gate.clamp(max=limit)))
// * (up.clamp(+-limit) + beta)
// alpha=1.0, beta=0.0 reduce this to silu(gate) * up.
LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel,
true, true, (float)limit);
true, true, (float)limit, (float)alpha,
(float)beta);
}
void mul_and_silu(torch::stable::Tensor& out, // [..., d]
@@ -276,21 +309,22 @@ void mul_and_silu(torch::stable::Tensor& out, // [..., d]
// The difference between mul_and_silu and silu_and_mul is that mul_and_silu
// applies the silu to the latter half of the input.
LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel,
false, false, 0.0f);
false, false, 0.0f, 1.0f, 0.0f);
}
void gelu_and_mul(torch::stable::Tensor& out, // [..., d]
torch::stable::Tensor& input) // [..., 2 * d]
{
LAUNCH_ACTIVATION_GATE_KERNEL(vllm::gelu_kernel, vllm::packed_gelu_kernel,
true, false, 0.0f);
true, false, 0.0f, 1.0f, 0.0f);
}
void gelu_tanh_and_mul(torch::stable::Tensor& out, // [..., d]
torch::stable::Tensor& input) // [..., 2 * d]
{
LAUNCH_ACTIVATION_GATE_KERNEL(
vllm::gelu_tanh_kernel, vllm::packed_gelu_tanh_kernel, true, false, 0.0f);
LAUNCH_ACTIVATION_GATE_KERNEL(vllm::gelu_tanh_kernel,
vllm::packed_gelu_tanh_kernel, true, false,
0.0f, 1.0f, 0.0f);
}
namespace vllm {
@@ -0,0 +1,615 @@
/*
* SPDX-License-Identifier: Apache-2.0
* SPDX-FileCopyrightText: Copyright contributors to the vLLM project
*
* Horizontally-fused MiniMax-M3 attention pre-processing kernel.
*
* Replaces the per-token Python sequence in
* ``MiniMaxM3SparseAttention.forward`` / ``MiniMaxM3Attention.forward``:
*
* q = q_norm(q); k = k_norm(k); q, k = rotary_emb(pos, q, k)
* index_q = index_q_norm(index_q); index_k = index_k_norm(index_k)
* index_q, index_k = rotary_emb(pos, index_q, index_k)
* _insert_kv(k, v, index_k)
*
* All branches share head_dim=128 and the *same* partial-NeoX RoPE table
* (``rotary_dim`` rotated, the trailing dims pass through). The four norms
* are Gemma-style RMSNorm (``x * rsqrt(mean(x^2)+eps) * (1 + weight)``) with
* independent weights.
*
* Everything lives in a single fused ``qkv`` tensor. The sparse layer's
* fused projection (MinimaxM3QKVParallelLinearWithIndexer) emits, per token::
*
* [ q | k | v | index_q | index_k ] (the "5 results")
*
* while the dense layer emits just ``[ q | k | v ]``. The kernel reads the
* index branch straight out of that packed row -- no separate index tensors.
*
* One kernel, one grid; each warp owns one (token, head-slot) pair. Slot
* enumeration per token:
* [0, nq) Q heads -> norm(q_w) + RoPE, write
* qkv [nq, nq+nkv) K heads -> norm(k_w) + RoPE, write
* qkv
* (+ insert into key cache)
* [nq+nkv, nq+2*nkv) V heads -> insert into value cache
* IQ heads (niq) -> norm(iq_w) + RoPE, write iq
* IK (1) -> norm(ik_w) + RoPE
* (+ insert into index cache)
*
* The IQ/IK warps address the index_q/index_k sub-blocks *inside* qkv at the
* fixed physical offsets (nq+2*nkv)*128 and (nq+2*nkv+niq)*128.
*
* Dense vs sparse is a compile-time choice via the ``kIsSparse``/``kInsertKV``
* template bools (3 instantiations: dense <false,false>, sparse-profiling
* <true,false>, sparse-serving <true,true>), so the index slots, the V slots
* and the cache inserts fold away entirely on paths that don't use them. The
* dense layer passes no caches/index: norm+RoPE happens in place and the
* generic ``Attention`` layer owns the cache write.
*
* Q/K and (sparse) index_q/index_k are all rewritten in place inside the fused
* ``qkv`` tensor. Caches (bf16) are scatter-written by slot.
*/
#include <cmath>
#include <cuda_runtime.h>
#include <type_traits>
#include "torch_utils.h"
#include "../cuda_compat.h"
#include "../type_convert.cuh"
#include "dispatch_utils.h"
#ifndef FINAL_MASK
#ifdef USE_ROCM
#define FINAL_MASK 0xffffffffffffffffULL
#else
#define FINAL_MASK 0xffffffffu
#endif
#endif
namespace vllm {
namespace minimax_m3_fused_ops {
namespace {
inline int getSMVersion() {
auto* props = get_device_prop();
return props->major * 10 + props->minor;
}
} // namespace
// ────────────────────────────────────────────────────────────────────────────
// Constants (hard-coded for MiniMax-M3-preview).
// ────────────────────────────────────────────────────────────────────────────
constexpr int kHeadDim = 128;
constexpr int kNumLanes = 32;
constexpr int kElemsPerLane = kHeadDim / kNumLanes; // 4
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
__device__ __forceinline__ float warpReduceSum(float val) {
#pragma unroll
for (int mask = 16; mask > 0; mask >>= 1) {
val += __shfl_xor_sync(FINAL_MASK, val, mask, 32);
}
return val;
}
// Gemma RMSNorm over the full head (no-op when ``weight == nullptr``) followed
// by partial NeoX RoPE on the leading ``rotary_dim`` dims, all in fp32. Each
// lane owns ``kElemsPerLane`` contiguous dims [laneId*4, laneId*4+4).
template <typename scalar_t>
__device__ __forceinline__ void normAndRope(
float (&elems)[kElemsPerLane], int const laneId, float const eps,
scalar_t const* __restrict__ weight, // [kHeadDim] or nullptr (no norm)
bool const do_rope, int const rotary_dim,
scalar_t const* __restrict__ cos_ptr, // cos_sin_cache + pos*rotary_dim
bool const apply_norm) {
// ── Gemma RMSNorm: x * rsqrt(mean(x^2)+eps) * (1 + w) ──────────────────
if (apply_norm) {
float sumsq = 0.0f;
#pragma unroll
for (int i = 0; i < kElemsPerLane; i++) sumsq += elems[i] * elems[i];
sumsq = warpReduceSum(sumsq);
float const rms_rcp = rsqrtf(sumsq / static_cast<float>(kHeadDim) + eps);
#pragma unroll
for (int i = 0; i < kElemsPerLane; i++) {
int const dim = laneId * kElemsPerLane + i;
float const w = 1.0f + static_cast<float>(weight[dim]);
elems[i] = elems[i] * rms_rcp * w;
}
}
// ── Partial NeoX RoPE on dims [0, rotary_dim) ──────────────────────────
// half = rotary_dim/2. Pair (i, i+half) for i in [0, half). Lane L owns
// dims [4L, 4L+4); since half is a multiple of 4, a lane lies wholly in the
// first half (own=x[i]) or second half (own=x[i+half]); its partner lives
// ``half/4`` lanes away (XOR with that distance).
if (do_rope) {
int const half = rotary_dim / 2;
int const dim0 = laneId * kElemsPerLane;
bool const in_rope = dim0 < rotary_dim;
int const lane_xor = half / kElemsPerLane; // partner-lane distance
float partner[kElemsPerLane];
#pragma unroll
for (int i = 0; i < kElemsPerLane; i++) {
partner[i] = __shfl_xor_sync(FINAL_MASK, elems[i], lane_xor, 32);
}
if (in_rope) {
bool const first_half = dim0 < half;
int const i_base = first_half ? dim0 : (dim0 - half); // cos/sin index
scalar_t const* sin_ptr = cos_ptr + half;
#pragma unroll
for (int i = 0; i < kElemsPerLane; i++) {
float const c = static_cast<float>(cos_ptr[i_base + i]);
float const s = static_cast<float>(sin_ptr[i_base + i]);
if (first_half) {
elems[i] = elems[i] * c - partner[i] * s;
} else {
elems[i] = elems[i] * c + partner[i] * s;
}
}
}
}
}
// Load 4 contiguous bf16 -> 4 fp32 registers.
template <typename scalar_t>
__device__ __forceinline__ void loadElems(scalar_t const* __restrict__ src,
float (&elems)[kElemsPerLane]) {
using Converter = vllm::_typeConvert<scalar_t>;
uint2 v = *reinterpret_cast<uint2 const*>(src);
auto const* p =
reinterpret_cast<typename Converter::packed_hip_type const*>(&v);
#pragma unroll
for (int i = 0; i < kElemsPerLane / 2; i++) {
float2 f2 = Converter::convert(p[i]);
elems[2 * i] = f2.x;
elems[2 * i + 1] = f2.y;
}
}
// Store 4 fp32 registers -> 4 contiguous bf16.
template <typename scalar_t>
__device__ __forceinline__ void storeElems(
scalar_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) {
using Converter = vllm::_typeConvert<scalar_t>;
uint2 v;
auto* p = reinterpret_cast<typename Converter::packed_hip_type*>(&v);
#pragma unroll
for (int i = 0; i < kElemsPerLane / 2; i++) {
p[i] = Converter::convert(make_float2(elems[2 * i], elems[2 * i + 1]));
}
*reinterpret_cast<uint2*>(dst) = v;
}
// ────────────────────────────────────────────────────────────────────────────
// Kernel
// ────────────────────────────────────────────────────────────────────────────
// Grid: 1D, ceil(num_tokens * slots_per_token / warps_per_block).
// Each warp = one (token, slot).
//
// `kIsSparse` and `kInsertKV` are compile-time template bools, so all the
// branch decisions that distinguish the dense layer from the sparse layer
// (index slots, KV/index inserts, V slots) fold away per instantiation.
// Three instantiations are built: dense <false,false>, sparse-profiling
// <true,false> and sparse-serving <true,true>. Slots per token:
// Q : nq (always — norm+RoPE)
// K : nkv (always — norm+RoPE; +K-cache insert)
// V : nkv only if kInsertKV (V-cache insert; no warps in dense)
// IQ: niq only if kIsSparse (norm+RoPE)
// IK: 1 only if kIsSparse (norm+RoPE; +index-cache insert)
template <typename scalar_t, bool kIsSparse, bool kInsertKV>
__global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
scalar_t* __restrict__ index_q_out, // [N, niq*128] contiguous, or nullptr
scalar_t const* __restrict__ q_norm_w,
scalar_t const* __restrict__ k_norm_w,
scalar_t const* __restrict__ iq_norm_w,
scalar_t const* __restrict__ ik_norm_w,
scalar_t const* __restrict__ cos_sin_cache, // [max_pos, rotary_dim]
int64_t const* __restrict__ positions, // [N] i64
int64_t const* __restrict__ slot_mapping, // [N] i64 or nullptr
scalar_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr
scalar_t* __restrict__ index_cache, // [nb*bs, 128] or nullptr
float const eps, int const rotary_dim, int const num_tokens, int const nq,
int const nkv, int const niq, int const block_size,
// kv_cache strides (in elements) for logical shape [nb, 2, bs, nkv, 128].
// The head_dim (last) dim is always innermost-contiguous (stride 1), so the
// NHD/HND layout choice is fully captured by these four strides: NHD keeps
// s_token < s_head, HND swaps them. dim_base addresses head_dim directly.
int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
int64_t const kv_s_head) {
#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM)
// _typeConvert<BFloat16> is unavailable on pre-Ampere; the M3 kernel only
// runs with bf16/fp16 inputs in practice. Discard the bf16 body there.
if constexpr (std::is_same_v<scalar_t, c10::BFloat16>) {
return;
} else {
#endif
int const warpsPerBlock = blockDim.x / 32;
int const laneId = threadIdx.x % 32;
int const globalWarpIdx = blockIdx.x * warpsPerBlock + (threadIdx.x / 32);
// Slot layout (compile-time gated: dense has neither V nor index slots).
int const v_slots = kInsertKV ? nkv : 0;
int const idx_slots = kIsSparse ? niq + 1 : 0;
int const slots_per_token = nq + nkv + v_slots + idx_slots;
int const tokenIdx = globalWarpIdx / slots_per_token;
int const slot = globalWarpIdx % slots_per_token;
if (tokenIdx >= num_tokens) return;
// Slot boundaries.
int const k_begin = nq;
int const v_begin = nq + nkv; // valid only when kInsertKV
int const iq_begin = nq + nkv + v_slots; // index block start
int const ik_slot = iq_begin + niq; // valid only when kIsSparse
bool const isQ = slot < k_begin;
bool const isK = slot >= k_begin && slot < v_begin;
bool isV = false;
if constexpr (kInsertKV) isV = slot >= v_begin && slot < v_begin + nkv;
bool isIQ = false, isIK = false;
if constexpr (kIsSparse) {
isIQ = slot >= iq_begin && slot < ik_slot;
isIK = slot == ik_slot;
}
int const dim_base = laneId * kElemsPerLane;
// Physical row width of qkv: the dense layer packs [q|k|v]; the sparse
// layer additionally packs [index_q (niq heads) | index_k (1 head)].
int const qkv_row = (nq + 2 * nkv + (kIsSparse ? (niq + 1) : 0)) * kHeadDim;
// ── Resolve source pointer + per-branch parameters. ────────────────────
scalar_t* row_ptr = nullptr; // in-place output location
scalar_t const* norm_w = nullptr; // nullptr -> skip norm (V)
bool do_rope = true;
int head = 0; // kv head index for inserts
if (isQ) {
row_ptr =
qkv + static_cast<int64_t>(tokenIdx) * qkv_row + slot * kHeadDim;
norm_w = q_norm_w;
} else if (isK) {
head = slot - k_begin;
row_ptr =
qkv + static_cast<int64_t>(tokenIdx) * qkv_row + slot * kHeadDim;
norm_w = k_norm_w;
} else if (isV) {
// qkv V section starts at slot index (nq + nkv): slot * kHeadDim is the
// correct in-tensor offset.
head = slot - v_begin;
row_ptr =
qkv + static_cast<int64_t>(tokenIdx) * qkv_row + slot * kHeadDim;
norm_w = nullptr; // V: no norm, no rope
do_rope = false;
} else if (isIQ) {
// index_q sub-block lives at physical offset (nq+2*nkv)*128 in qkv.
int const ih = slot - iq_begin;
row_ptr = qkv + static_cast<int64_t>(tokenIdx) * qkv_row +
(nq + 2 * nkv + ih) * kHeadDim;
norm_w = iq_norm_w;
} else { // isIK -- single shared index key at (nq+2*nkv+niq)*128.
row_ptr = qkv + static_cast<int64_t>(tokenIdx) * qkv_row +
(nq + 2 * nkv + niq) * kHeadDim;
norm_w = ik_norm_w;
}
// Store destination. Q and index_q are gathered into dedicated contiguous
// output buffers (when provided) so the downstream SM100 sparse kernel's
// flat TMA descriptor can address them as [tokens*heads, head_dim]; this
// folds the de-interleaving into the store the kernel already does, instead
// of a separate q.contiguous() copy. Everything else stays in place.
scalar_t* store_ptr = row_ptr;
if (isQ && q_out != nullptr) {
store_ptr = q_out + static_cast<int64_t>(tokenIdx) * nq * kHeadDim +
slot * kHeadDim;
} else if (isIQ && index_q_out != nullptr) {
store_ptr = index_q_out +
static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
(slot - iq_begin) * kHeadDim;
}
// PDL: wait for the predecessor kernel (the qkv-projection GEMM that
// produces ``qkv``) to finish before touching any global memory. No-op
// when PDL is not enabled on the launch. The CUDA runtime wrapper emits
// the griddepcontrol.wait PTX with the required memory clobber internally.
#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
cudaGridDependencySynchronize();
#endif
// ── Load -> norm+rope (fp32) -> store back in place. ───────────────────
float elems[kElemsPerLane];
loadElems<scalar_t>(row_ptr + dim_base, elems);
if (!isV) {
int64_t const pos = positions[tokenIdx];
scalar_t const* cos_ptr = cos_sin_cache + pos * rotary_dim;
normAndRope<scalar_t>(elems, laneId, eps, norm_w, do_rope, rotary_dim,
cos_ptr, /*apply_norm=*/norm_w != nullptr);
storeElems<scalar_t>(store_ptr + dim_base, elems);
}
// ── Cache inserts (sparse serving only). ───────────────────────────────
if constexpr (kInsertKV) {
// Guard (not early-return) so every thread reaches the PDL trigger below.
int64_t const sm = (isK || isV || isIK) ? slot_mapping[tokenIdx] : -1;
if (sm >= 0) { // skip padded / unscheduled tokens
if (isIK) {
scalar_t* dst = index_cache + sm * kHeadDim + dim_base;
storeElems<scalar_t>(dst, elems);
} else if (isK || isV) {
// kv_cache logical shape [num_blocks, 2, block_size, nkv, head_dim].
// Paging is logical (block = sm/block_size, token = sm%block_size);
// the physical NHD/HND layout is honoured via the passed strides.
int64_t const b = sm / block_size;
int64_t const t = sm % block_size;
int const kv = isK ? 0 : 1;
int64_t const off =
b * kv_s_block + kv * kv_s_kv + t * kv_s_token + head * kv_s_head;
storeElems<scalar_t>(kv_cache + off + dim_base, elems);
}
}
}
// PDL: signal that this kernel is done so a dependent successor may launch
// early. No-op when PDL is not enabled on the launch.
#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
cudaTriggerProgrammaticLaunchCompletion();
#endif
#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM)
}
#endif
}
// ────────────────────────────────────────────────────────────────────────────
// Launch wrapper
// ────────────────────────────────────────────────────────────────────────────
template <typename scalar_t>
void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
scalar_t const* q_norm_w, scalar_t const* k_norm_w,
scalar_t const* iq_norm_w, scalar_t const* ik_norm_w,
scalar_t const* cos_sin_cache,
int64_t const* positions, int64_t const* slot_mapping,
scalar_t* kv_cache, scalar_t* index_cache,
float const eps, int const rotary_dim,
int const num_tokens, int const nq, int const nkv,
int const niq, int const block_size,
int64_t const kv_s_block, int64_t const kv_s_kv,
int64_t const kv_s_token, int64_t const kv_s_head,
bool const has_index, bool const insert_kv,
cudaStream_t stream) {
// Slot count must match the kernel's compile-time gating.
int const v_slots = insert_kv ? nkv : 0;
int const idx_slots = has_index ? niq + 1 : 0;
int const slots_per_token = nq + nkv + v_slots + idx_slots;
constexpr int kBlockSize = 256;
constexpr int kWarpsPerBlock = kBlockSize / 32;
int64_t const total_warps =
static_cast<int64_t>(num_tokens) * slots_per_token;
int const grid =
static_cast<int>((total_warps + kWarpsPerBlock - 1) / kWarpsPerBlock);
if (grid == 0) return;
#ifndef USE_ROCM
// PDL: enable programmatic stream serialization whenever the hardware
// supports it (SM90+). On pre-Hopper GPUs the attribute is unavailable, so
// leave numAttrs = 0 and launch as a regular kernel via cudaLaunchKernelEx.
static int const sm_version = getSMVersion();
cudaLaunchConfig_t config;
config.gridDim = dim3(grid);
config.blockDim = dim3(kBlockSize);
config.dynamicSmemBytes = 0;
config.stream = stream;
cudaLaunchAttribute attrs[1];
attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
attrs[0].val.programmaticStreamSerializationAllowed = 1;
config.attrs = attrs;
config.numAttrs = (sm_version >= 90) ? 1 : 0;
#define LAUNCH(IS_SPARSE, INSERT) \
cudaLaunchKernelEx( \
&config, \
fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, IS_SPARSE, INSERT>, \
qkv, q_out, index_q_out, q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, \
cos_sin_cache, positions, slot_mapping, kv_cache, index_cache, eps, \
rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_kv, \
kv_s_token, kv_s_head)
#else
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
// clang-format off
#define LAUNCH(IS_SPARSE, INSERT) \
fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, IS_SPARSE, INSERT> \
<<<grid, kBlockSize, 0, stream>>>( \
qkv, q_out, index_q_out, q_norm_w, k_norm_w, iq_norm_w, \
ik_norm_w, cos_sin_cache, positions, slot_mapping, kv_cache, \
index_cache, eps, rotary_dim, num_tokens, nq, nkv, niq, \
block_size, kv_s_block, kv_s_kv, kv_s_token, kv_s_head)
// clang-format on
#endif
if (has_index) {
if (insert_kv) {
LAUNCH(true, true); // sparse serving
} else {
LAUNCH(true, false); // sparse profiling
}
} else {
// Dense layer: never has an index branch and never inserts here (the
// generic Attention layer owns the KV insert).
LAUNCH(false, false);
}
#undef LAUNCH
}
} // namespace minimax_m3_fused_ops
} // namespace vllm
// ────────────────────────────────────────────────────────────────────────────
// Torch op wrapper
// ────────────────────────────────────────────────────────────────────────────
void fused_minimax_m3_qknorm_rope_kv_insert(
torch::stable::Tensor& qkv, // [N, qkv_row] (packs index if sparse)
torch::stable::Tensor const& q_norm_weight, // [128]
torch::stable::Tensor const& k_norm_weight, // [128]
torch::stable::Tensor const& cos_sin_cache, // [max_pos, rotary_dim]
torch::stable::Tensor const& positions, // [N] i64
int64_t num_heads, int64_t num_kv_heads, int64_t rotary_dim, double eps,
std::optional<torch::stable::Tensor> index_q_norm_weight, // [128]
std::optional<torch::stable::Tensor> index_k_norm_weight, // [128]
int64_t num_index_heads, // niq; 0 => dense
std::optional<torch::stable::Tensor> slot_mapping, // [N] i64
std::optional<torch::stable::Tensor> kv_cache, // [nb,2,bs,nkv,128]
std::optional<torch::stable::Tensor> index_cache, // [nb,bs,128]
int64_t block_size,
std::optional<torch::stable::Tensor> q_out, // [N, nq*128] contiguous
std::optional<torch::stable::Tensor>
index_q_out) { // [N, niq*128] contiguous
STD_TORCH_CHECK(qkv.is_cuda() && qkv.is_contiguous(),
"qkv must be contiguous CUDA");
STD_TORCH_CHECK(
positions.is_cuda() &&
positions.scalar_type() == torch::headeronly::ScalarType::Long,
"positions must be int64 CUDA");
STD_TORCH_CHECK(cos_sin_cache.is_cuda() && cos_sin_cache.is_contiguous(),
"cos_sin_cache must be contiguous CUDA");
STD_TORCH_CHECK(cos_sin_cache.scalar_type() == qkv.scalar_type(),
"cos_sin_cache dtype must match qkv");
STD_TORCH_CHECK(
cos_sin_cache.dim() == 2 && cos_sin_cache.size(1) == rotary_dim,
"cos_sin_cache shape [max_pos, rotary_dim]");
STD_TORCH_CHECK(q_norm_weight.scalar_type() == qkv.scalar_type() &&
k_norm_weight.scalar_type() == qkv.scalar_type(),
"q/k norm weight dtype must match qkv");
STD_TORCH_CHECK(
q_norm_weight.numel() == vllm::minimax_m3_fused_ops::kHeadDim &&
k_norm_weight.numel() == vllm::minimax_m3_fused_ops::kHeadDim,
"q/k norm weight must have 128 elements");
STD_TORCH_CHECK(rotary_dim > 0 && rotary_dim % 8 == 0 &&
rotary_dim <= vllm::minimax_m3_fused_ops::kHeadDim,
"rotary_dim must be a positive multiple of 8 and <= 128");
int const num_tokens = static_cast<int>(qkv.size(0));
int const nq = static_cast<int>(num_heads);
int const nkv = static_cast<int>(num_kv_heads);
int const niq = static_cast<int>(num_index_heads);
// The sparse layer packs the index branch ([index_q (niq heads) | index_k
// (1 head)]) right after [q|k|v] in the same row; the dense layer does not.
bool const has_index = niq > 0;
bool const insert_kv = kv_cache.has_value();
int const kHeadDim = vllm::minimax_m3_fused_ops::kHeadDim;
int const expected_row =
(nq + 2 * nkv + (has_index ? niq + 1 : 0)) * kHeadDim;
STD_TORCH_CHECK(qkv.size(1) == expected_row,
"qkv last dim must be (num_heads + 2*num_kv_heads"
" + num_index_heads + 1) * 128 for sparse, "
"(num_heads + 2*num_kv_heads) * 128 for dense");
// Only the sparse layer inserts here (dense lets the generic Attention layer
// own the KV write); there is no dense+insert kernel instantiation.
STD_TORCH_CHECK(
!insert_kv || has_index,
"insert mode (kv_cache) requires the index branch (sparse layer)");
if (has_index) {
STD_TORCH_CHECK(
index_q_norm_weight.has_value() && index_k_norm_weight.has_value(),
"index branch requires both index norm weights");
STD_TORCH_CHECK(index_q_norm_weight->scalar_type() == qkv.scalar_type() &&
index_k_norm_weight->scalar_type() == qkv.scalar_type(),
"index norm weights dtype must match qkv");
STD_TORCH_CHECK(index_q_norm_weight->numel() == kHeadDim &&
index_k_norm_weight->numel() == kHeadDim,
"index norm weights must have 128 elements");
}
// kv_cache strides (logical shape [nb, 2, bs, nkv, head_dim]). Read straight
// off the tensor so the kernel honours whatever physical layout the attention
// backend allocated (NHD: stride order (0,1,2,3,4); HND: (0,1,3,2,4)). No new
// op argument is needed -- the strides ride along with the tensor itself.
int64_t kv_s_block = 0, kv_s_kv = 0, kv_s_token = 0, kv_s_head = 0;
if (insert_kv) {
STD_TORCH_CHECK(
slot_mapping.has_value() &&
slot_mapping->scalar_type() == torch::headeronly::ScalarType::Long,
"insert mode requires int64 slot_mapping");
STD_TORCH_CHECK(kv_cache->scalar_type() == qkv.scalar_type(),
"kv_cache dtype must match qkv (bf16 cache only)");
STD_TORCH_CHECK(index_cache.has_value() &&
index_cache->scalar_type() == qkv.scalar_type(),
"insert mode requires matching index_cache");
STD_TORCH_CHECK(kv_cache->dim() == 5 && kv_cache->stride(4) == 1,
"kv_cache must be [nb,2,bs,nkv,head_dim] with contiguous "
"head_dim (stride(4)==1)");
kv_s_block = kv_cache->stride(0);
kv_s_kv = kv_cache->stride(1);
kv_s_token = kv_cache->stride(2);
kv_s_head = kv_cache->stride(3);
}
// Optional contiguous gather targets: when given, the normed/roped q (and
// index_q) are written here instead of in place, so callers avoid a separate
// .contiguous() copy. index_q_out only makes sense on the sparse path.
if (q_out.has_value()) {
STD_TORCH_CHECK(
q_out->is_cuda() && q_out->is_contiguous() &&
q_out->scalar_type() == qkv.scalar_type(),
"q_out must be a contiguous CUDA tensor matching qkv dtype");
STD_TORCH_CHECK(
q_out->numel() == static_cast<int64_t>(num_tokens) * nq * kHeadDim,
"q_out must have num_tokens * num_heads * 128 elements");
}
if (index_q_out.has_value()) {
STD_TORCH_CHECK(
has_index,
"index_q_out requires the index branch (num_index_heads > 0)");
STD_TORCH_CHECK(
index_q_out->is_cuda() && index_q_out->is_contiguous() &&
index_q_out->scalar_type() == qkv.scalar_type(),
"index_q_out must be a contiguous CUDA tensor matching qkv dtype");
STD_TORCH_CHECK(index_q_out->numel() ==
static_cast<int64_t>(num_tokens) * niq * kHeadDim,
"index_q_out must have num_tokens * num_index_heads * 128 "
"elements");
}
const torch::stable::accelerator::DeviceGuard device_guard(
qkv.get_device_index());
auto stream = get_current_cuda_stream(qkv.get_device_index());
VLLM_STABLE_DISPATCH_HALF_TYPES(
qkv.scalar_type(), "fused_minimax_m3_qknorm_rope_kv_insert", [&] {
using st = scalar_t;
vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st>(
reinterpret_cast<st*>(qkv.data_ptr()),
q_out.has_value() ? reinterpret_cast<st*>(q_out->data_ptr())
: nullptr,
index_q_out.has_value()
? reinterpret_cast<st*>(index_q_out->data_ptr())
: nullptr,
reinterpret_cast<st const*>(q_norm_weight.data_ptr()),
reinterpret_cast<st const*>(k_norm_weight.data_ptr()),
has_index
? reinterpret_cast<st const*>(index_q_norm_weight->data_ptr())
: nullptr,
has_index
? reinterpret_cast<st const*>(index_k_norm_weight->data_ptr())
: nullptr,
reinterpret_cast<st const*>(cos_sin_cache.data_ptr()),
reinterpret_cast<int64_t const*>(positions.data_ptr()),
insert_kv
? reinterpret_cast<int64_t const*>(slot_mapping->data_ptr())
: nullptr,
insert_kv ? reinterpret_cast<st*>(kv_cache->data_ptr()) : nullptr,
(insert_kv && has_index)
? reinterpret_cast<st*>(index_cache->data_ptr())
: nullptr,
static_cast<float>(eps), static_cast<int>(rotary_dim), num_tokens,
nq, nkv, niq, static_cast<int>(block_size), kv_s_block, kv_s_kv,
kv_s_token, kv_s_head, has_index, insert_kv, stream);
});
}
+19 -1
View File
@@ -231,6 +231,23 @@ void fused_qk_norm_rope(torch::stable::Tensor& qkv, int64_t num_heads_q,
torch::stable::Tensor& position_ids,
int64_t forced_token_heads_per_warp);
// Horizontally-fused MiniMax-M3 QK-norm + partial NeoX RoPE (+ optional KV /
// index-cache insert). Dense layer: norm+RoPE only; sparse layer: also packs
// the index branch and scatters k/v/index_k into their paged caches.
void fused_minimax_m3_qknorm_rope_kv_insert(
torch::stable::Tensor& qkv, torch::stable::Tensor const& q_norm_weight,
torch::stable::Tensor const& k_norm_weight,
torch::stable::Tensor const& cos_sin_cache,
torch::stable::Tensor const& positions, int64_t num_heads,
int64_t num_kv_heads, int64_t rotary_dim, double eps,
std::optional<torch::stable::Tensor> index_q_norm_weight,
std::optional<torch::stable::Tensor> index_k_norm_weight,
int64_t num_index_heads, std::optional<torch::stable::Tensor> slot_mapping,
std::optional<torch::stable::Tensor> kv_cache,
std::optional<torch::stable::Tensor> index_cache, int64_t block_size,
std::optional<torch::stable::Tensor> q_out,
std::optional<torch::stable::Tensor> index_q_out);
// Sampler kernels (shared CUDA/ROCm)
void apply_repetition_penalties_(
torch::stable::Tensor& logits, const torch::stable::Tensor& prompt_mask,
@@ -276,7 +293,8 @@ void selective_scan_fwd(
// Activation kernels (shared CUDA/ROCm)
void silu_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input);
void silu_and_mul_clamp(torch::stable::Tensor& out,
torch::stable::Tensor& input, double limit);
torch::stable::Tensor& input, double limit,
double alpha = 1.0, double beta = 0.0);
void mul_and_silu(torch::stable::Tensor& out, torch::stable::Tensor& input);
void gelu_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input);
void gelu_tanh_and_mul(torch::stable::Tensor& out,
+17 -2
View File
@@ -337,6 +337,17 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
"bool is_neox, Tensor position_ids, "
"int forced_token_heads_per_warp=-1) -> ()");
// Horizontally-fused MiniMax-M3 QK-norm + partial NeoX RoPE + KV-insert.
ops.def(
"fused_minimax_m3_qknorm_rope_kv_insert("
"Tensor! qkv, Tensor q_norm_weight, Tensor k_norm_weight, "
"Tensor cos_sin_cache, Tensor positions, int num_heads, "
"int num_kv_heads, int rotary_dim, float eps, "
"Tensor? index_q_norm_weight, Tensor? index_k_norm_weight, "
"int num_index_heads, "
"Tensor? slot_mapping, Tensor!? kv_cache, Tensor!? index_cache, "
"int block_size, Tensor!? q_out, Tensor!? index_q_out) -> ()");
// Apply repetition penalties to logits in-place.
ops.def(
"apply_repetition_penalties_(Tensor! logits, Tensor prompt_mask, "
@@ -364,9 +375,11 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
ops.def("mul_and_silu(Tensor! out, Tensor input) -> ()");
// SwiGLU activation with input clamping.
// alpha scales the sigmoid (gate * sigmoid(alpha * gate)); beta is added to
// the up half (up + beta). Defaults alpha=1.0, beta=0.0 give silu(gate)*up.
ops.def(
"silu_and_mul_with_clamp(Tensor! result, Tensor input, float limit) "
"-> ()");
"silu_and_mul_with_clamp(Tensor! result, Tensor input, float limit, "
"float alpha=1.0, float beta=0.0) -> ()");
// Activation function used in GeGLU with `none` approximation.
ops.def("gelu_and_mul(Tensor! out, Tensor input) -> ()");
@@ -571,6 +584,8 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
// Positional encoding kernels (shared CUDA/ROCm)
ops.impl("rotary_embedding", TORCH_BOX(&rotary_embedding));
ops.impl("fused_qk_norm_rope", TORCH_BOX(&fused_qk_norm_rope));
ops.impl("fused_minimax_m3_qknorm_rope_kv_insert",
TORCH_BOX(&fused_minimax_m3_qknorm_rope_kv_insert));
// Sampler kernels (shared CUDA/ROCm)
ops.impl("apply_repetition_penalties_",
+742
View File
@@ -0,0 +1,742 @@
// CUDA C++ q2k -> k2q CSR builder.
//
// Five-stage pipeline. q-ascending order within each CSR row is preserved
// by partitioning q across (CTA, warp_in_CTA) units; each unit owns a
// contiguous q-sub-range and reserves a contiguous slot range per row via
// a precomputed exclusive prefix scan.
//
// M: build_row_map -- round-robin packing of rows across batches
// H: histogram + tile_counts
// PR: row prefix -- single block per head, row_counts -> row_ptr
// PT: tile prefix -- multi-block, scan tile_counts along (c, w) axis
// S: scatter (sorted) -- per-warp slot range, q-sequential within warp
//
// Per-warp partitioning: each CTA has kWarps warps; warp w of CTA c owns
// q-range [c*q_per_cta + w*q_per_warp, c*q_per_cta + (w+1)*q_per_warp).
// tile_counts is shaped [G * kWarps, H, total_rows]; the "row" dimension
// of the prefix scan is the flattened (c * kWarps + w) index, scanned in
// lexicographic order so that warp-local slot ranges concatenate to the
// global q-sorted output.
#include <torch/all.h>
#include <ATen/cuda/CUDAContext.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <algorithm>
#define CHECK_CUDA(x) TORCH_CHECK((x).is_cuda(), #x " must be CUDA")
#define CHECK_CONTIGUOUS(x) \
TORCH_CHECK((x).is_contiguous(), #x " must be contiguous")
#define CHECK_INT(x) \
TORCH_CHECK((x).scalar_type() == at::kInt, #x " must be int32")
#define CHECK_INPUT(x) \
CHECK_CUDA(x); \
CHECK_CONTIGUOUS(x); \
CHECK_INT(x)
namespace {
constexpr int kWarpSize = 32;
__device__ __forceinline__ void advance_batch_only(int const* __restrict__ cu_q,
int B, int q_abs, int& bi) {
while (bi < B && cu_q[bi + 1] <= q_abs) ++bi;
}
// Atomic increment of a 16-bit half within a 32-bit SMEM word; returns the
// OLD 16-bit value (slot). Per-warp count must stay < 32768 so the low
// half does not carry into the high half.
// base_int32 : int32 pointer; element i holds rows 2*i (low) and 2*i+1
// (high).
__device__ __forceinline__ int atomic_inc_int16_packed(int* base_int32,
int row) {
int idx = row >> 1;
int shift = (row & 1) << 4; // 0 or 16
int delta = 1 << shift;
int old = atomicAdd(&base_int32[idx], delta);
return (old >> shift) & 0xFFFF;
}
// Read 16-bit half from packed int32 storage.
__device__ __forceinline__ int read_int16_packed(int const* base_int32,
int row) {
int v = base_int32[row >> 1];
int shift = (row & 1) << 4;
return (v >> shift) & 0xFFFF;
}
// ---------------------------------------------------------------------------
// M: round-robin row map.
// ---------------------------------------------------------------------------
template <int kBlockK>
__global__ void k2q_build_row_map_kernel(int const* __restrict__ cu_k,
int* __restrict__ row_map,
int* __restrict__ row_coords, int B,
int max_kv_blocks) {
int level = blockIdx.x;
if (level >= max_kv_blocks) return;
if (threadIdx.x != 0) return;
int rows_before = 0;
for (int b = 0; b < B; ++b) {
int rb = (cu_k[b + 1] - cu_k[b] + kBlockK - 1) / kBlockK;
rows_before += (rb < level ? rb : level);
}
int active_before = 0;
for (int b = 0; b < B; ++b) {
int rb = (cu_k[b + 1] - cu_k[b] + kBlockK - 1) / kBlockK;
if (rb > level) {
int row_linear = rows_before + active_before;
row_map[(size_t)b * max_kv_blocks + level] = row_linear;
if (row_coords != nullptr) {
row_coords[(size_t)row_linear * 2] = b;
row_coords[(size_t)row_linear * 2 + 1] = level;
}
++active_before;
} else {
row_map[(size_t)b * max_kv_blocks + level] = -1;
}
}
}
// ---------------------------------------------------------------------------
// H: per-warp histogram + tile_counts.
// kWarps warps per CTA, each owns q-sub-range = q_per_cta / kWarps.
// SMEM hist[kWarps, total_rows] int32 (stored as packed int16 cursor:
// 2 entries per int32 word). Each warp counts to its own row.
// At end-of-CTA, write tile_counts[c*kWarps + w, h, r] = smem_hist[w, r]
// and atomicAdd(row_counts[h, r], sum over w of smem_hist[w, r]).
// ---------------------------------------------------------------------------
template <int kTopK, int kBlockK, int kWarps>
__global__ void k2q_hist_kernel(int const* __restrict__ q2k,
int const* __restrict__ cu_q,
int const* __restrict__ row_map,
int* __restrict__ row_counts,
int* __restrict__ tile_counts, int H, int B,
int S_Q, int total_rows, int max_kv_blocks,
int q_per_cta, int q_per_warp) {
constexpr int kThreads = kWarps * kWarpSize;
extern __shared__ int smem_hist_int[];
int* smem_hist = smem_hist_int;
int tid = threadIdx.x;
int warp_id = tid >> 5;
int lane = tid & 31;
int c = blockIdx.x;
int q_start_cta = c * q_per_cta;
int q_end_cta = min(q_start_cta + q_per_cta, S_Q);
int q_start_warp = min(q_start_cta + warp_id * q_per_warp, q_end_cta);
int q_end_warp = min(q_start_warp + q_per_warp, q_end_cta);
constexpr int kInt4PerToken = kTopK / 4;
int packed_per_warp = (total_rows + 1) >> 1;
int* my_hist = smem_hist + warp_id * packed_per_warp;
for (int h = 0; h < H; ++h) {
for (int i = lane; i < packed_per_warp; i += kWarpSize) my_hist[i] = 0;
__syncthreads();
if (q_start_warp < q_end_warp) {
int bi = 0;
int qi = q_start_warp + lane;
advance_batch_only(cu_q, B, qi, bi);
int4 const* head_topk4 =
reinterpret_cast<int4 const*>(q2k + (size_t)h * S_Q * kTopK);
for (; qi < q_end_warp; qi += kWarpSize) {
advance_batch_only(cu_q, B, qi, bi);
int const* my_row_map = row_map + (size_t)bi * max_kv_blocks;
int4 buf[kInt4PerToken];
#pragma unroll
for (int v = 0; v < kInt4PerToken; ++v) {
buf[v] = head_topk4[(size_t)qi * kInt4PerToken + v];
}
#pragma unroll
for (int t = 0; t < kTopK; ++t) {
int kvb_local = reinterpret_cast<int const*>(buf)[t];
if (kvb_local >= 0 && kvb_local < max_kv_blocks) {
int row = my_row_map[kvb_local];
if (row >= 0 && row < total_rows) {
atomic_inc_int16_packed(my_hist, row);
}
}
}
}
}
__syncthreads();
int* head_row_counts = row_counts + (size_t)h * total_rows;
// Each warp writes its own slice of tile_counts (full int32) by
// unpacking int16 entries from SMEM.
int* my_tile =
tile_counts + ((size_t)(c * kWarps + warp_id) * H + h) * total_rows;
for (int i = lane; i < total_rows; i += kWarpSize) {
my_tile[i] = read_int16_packed(my_hist, i);
}
__syncthreads();
// Sum across warps (int32 accumulator), atomicAdd to row_counts.
for (int i = tid; i < total_rows; i += kThreads) {
int sum = 0;
#pragma unroll
for (int w = 0; w < kWarps; ++w) {
sum += read_int16_packed(smem_hist + w * packed_per_warp, i);
}
if (sum > 0) atomicAdd(&head_row_counts[i], sum);
}
if (h + 1 < H) __syncthreads();
}
}
// ---------------------------------------------------------------------------
// PR: row prefix. One block per head.
// ---------------------------------------------------------------------------
template <int kThreads>
__global__ void k2q_row_prefix_kernel(int const* __restrict__ row_counts,
int* __restrict__ row_ptr,
int const* __restrict__ row_coords,
int* __restrict__ scheduler_metadata,
int* __restrict__ work_count,
int total_rows, int target_q_per_cta,
int work_capacity) {
int h = blockIdx.x;
int tid = threadIdx.x;
__shared__ int scan_buf[kThreads];
int const* head_counts = row_counts + (size_t)h * total_rows;
int* head_rowptr = row_ptr + (size_t)h * (total_rows + 1);
int chunk = (total_rows + kThreads - 1) / kThreads;
int lo = tid * chunk;
int hi = min(lo + chunk, total_rows);
int local_sum = 0;
for (int i = lo; i < hi; ++i) local_sum += head_counts[i];
scan_buf[tid] = local_sum;
__syncthreads();
for (int off = 1; off < kThreads; off <<= 1) {
int add = (tid >= off) ? scan_buf[tid - off] : 0;
__syncthreads();
scan_buf[tid] += add;
__syncthreads();
}
int running = scan_buf[tid] - local_sum;
for (int i = lo; i < hi; ++i) {
int row_count = head_counts[i];
running += row_count;
head_rowptr[i + 1] = running;
if (scheduler_metadata != nullptr && work_count != nullptr &&
row_count > 0) {
int num_chunks = (row_count + target_q_per_cta - 1) / target_q_per_cta;
int base = atomicAdd(work_count, num_chunks);
int batch_idx = row_coords[(size_t)i * 2];
int kv_block_idx = row_coords[(size_t)i * 2 + 1];
for (int c = 0; c < num_chunks; ++c) {
int work_idx = base + c;
if (work_idx < work_capacity) {
int q_begin = c * target_q_per_cta;
int q_count = min(target_q_per_cta, row_count - q_begin);
int* meta = scheduler_metadata + (size_t)work_idx * 6;
meta[0] = h;
meta[1] = i;
meta[2] = q_begin;
meta[3] = q_count;
meta[4] = batch_idx;
meta[5] = kv_block_idx;
}
}
}
}
}
// ---------------------------------------------------------------------------
// PT_smem: SMEM-staged tile prefix scan.
// Each block handles kRowsPerBlock rows for one head h. Cooperative load
// of tile_counts[*, h, base_r..base_r+M) into SMEM (better coalescing
// than per-warp uncoalesced stride reads), then per-warp scan in SMEM,
// then cooperative store back. Fuses row_ptr into the base.
// ---------------------------------------------------------------------------
template <int kThreads, int kRowsPerBlock>
__global__ void k2q_tile_prefix_smem_kernel(int* __restrict__ tile_counts,
int const* __restrict__ row_ptr,
int H, int total_rows,
int G_total) {
static_assert(kRowsPerBlock > 0, "kRowsPerBlock must be positive");
extern __shared__ int smem_tprefix[];
// smem layout: smem[r_off][g] for r_off in [0, M), g in [0, G_total).
int tid = threadIdx.x;
int lane = tid & 31;
int warp_id = tid >> 5;
// Grid: H * blocks_per_h. Each block stays within a single head h
// and processes kRowsPerBlock contiguous rows starting at b_in_h *
// kRowsPerBlock. (Earlier flat-grid mapping `h = block_job /
// total_rows; base_r = block_job - h*total_rows` skipped rows when
// total_rows was not a multiple of kRowsPerBlock and H > 1, because
// the last partial block of head h-1 left blocks of head h starting
// at a non-zero row offset.)
int blocks_per_h = (total_rows + kRowsPerBlock - 1) / kRowsPerBlock;
int h = blockIdx.x / blocks_per_h;
int b_in_h = blockIdx.x - h * blocks_per_h;
if (h >= H) return;
int base_r = b_in_h * kRowsPerBlock;
if (base_r >= total_rows) return;
int actual_M = min(kRowsPerBlock, total_rows - base_r);
size_t stride_g = (size_t)H * total_rows;
int* base_ptr = tile_counts + (size_t)h * total_rows + base_r;
int total_elems = G_total * actual_M;
// Cooperative load. Pattern: thread tid -> (r_off=tid%M, g=tid/M),
// then strided. 32 lanes hit M r's × (32/M) g's, giving 32/M cache
// lines per warp (vs 32 in the naive stride-along-g pattern).
for (int i = tid; i < total_elems; i += kThreads) {
int r_off = i % actual_M;
int g = i / actual_M;
smem_tprefix[r_off * G_total + g] = base_ptr[g * stride_g + r_off];
}
__syncthreads();
// Per-warp scan: warp w scans row (base_r + w) if w < actual_M.
if (warp_id < actual_M) {
int abs_r = base_r + warp_id;
int rp = row_ptr[(size_t)h * (total_rows + 1) + abs_r];
int* my_smem = smem_tprefix + warp_id * G_total;
int running = rp;
for (int g0 = 0; g0 < G_total; g0 += kWarpSize) {
int g = g0 + lane;
int v = (g < G_total) ? my_smem[g] : 0;
int x = v;
#pragma unroll
for (int off = 1; off < kWarpSize; off <<= 1) {
int nbr = __shfl_up_sync(0xFFFFFFFF, x, off);
if (lane >= off) x += nbr;
}
int excl = running + x - v;
if (g < G_total) my_smem[g] = excl;
int chunk_sum = __shfl_sync(0xFFFFFFFF, x, 31);
running += chunk_sum;
}
}
__syncthreads();
// Cooperative store back.
for (int i = tid; i < total_elems; i += kThreads) {
int r_off = i % actual_M;
int g = i / actual_M;
base_ptr[g * stride_g + r_off] = smem_tprefix[r_off * G_total + g];
}
}
// ---------------------------------------------------------------------------
// S: scatter. kWarps warps per CTA, each owns q-sub-range. Per-warp SMEM
// cursor and per-warp tile_offset slot range. Within a warp, q's are
// processed sequentially; lanes 0..kTopK-1 handle the topK slots in
// lockstep. Across distinct q's in the same warp, the lockstep ordering
// guarantees q-monotonic atomicAdd on smem_cursor[r].
// ---------------------------------------------------------------------------
// kQPerIter * kTopK lanes are active per warp iter; remaining lanes idle.
// For kTopK=16, kQPerIter=2 uses all 32 lanes; for kTopK=8, kQPerIter=4.
// CORRECTNESS NOTE: relies on lane-ordered SMEM atomicAdd return values
// within a single warp instruction (verified on B200; tests pass).
//
// SMEM cursor stored as packed int16 (two cursors per int32). Per-warp
// row count must stay < 32768 (~q_per_warp * kTopK at max sink), which
// holds for all task.md sizes up to 1024K.
template <int kTopK, int kBlockK, int kWarps>
__global__ void k2q_scatter_kernel(
int const* __restrict__ q2k, int const* __restrict__ cu_q,
int const* __restrict__ row_map, int const* __restrict__ abs_base,
int* __restrict__ q_idx, int* __restrict__ qsplit_idx,
int* __restrict__ split_counts, int H, int B, int S_Q, int total_rows,
int max_kv_blocks, int q_per_cta, int q_per_warp, int max_seqlen_q) {
constexpr int kQPerIter = kWarpSize / kTopK > 0 ? kWarpSize / kTopK : 1;
extern __shared__ int smem_cursor_int[];
int* smem_cursor = smem_cursor_int;
int tid = threadIdx.x;
int warp_id = tid >> 5;
int lane = tid & 31;
int c = blockIdx.x;
int q_start_cta = c * q_per_cta;
int q_end_cta = min(q_start_cta + q_per_cta, S_Q);
int q_start_warp = min(q_start_cta + warp_id * q_per_warp, q_end_cta);
int q_end_warp = min(q_start_warp + q_per_warp, q_end_cta);
int q_in_iter = lane / kTopK;
int slot_in_q = lane % kTopK;
bool lane_active = (lane < kQPerIter * kTopK);
// Per-warp packed cursor: total_rows int16 entries -> ceil(total_rows/2)
// int32.
int packed_per_warp = (total_rows + 1) >> 1;
int* my_cursor = smem_cursor + warp_id * packed_per_warp;
for (int h = 0; h < H; ++h) {
for (int i = lane; i < packed_per_warp; i += kWarpSize) my_cursor[i] = 0;
__syncwarp();
if (q_start_warp < q_end_warp) {
int bi = 0;
advance_batch_only(cu_q, B, q_start_warp, bi);
int const* head_q2k = q2k + (size_t)h * S_Q * kTopK;
int const* my_abs_base =
abs_base + ((size_t)(c * kWarps + warp_id) * H + h) * total_rows;
int* head_qidx = q_idx + (size_t)h * S_Q * kTopK;
// (Hot-row register cache experiment showed no measurable
// benefit; relying on L1 to keep row 0 / row total_rows-1
// hot since they're hit every iteration in sink workloads.)
constexpr int kUnroll = 16;
int qi_base = q_start_warp;
for (; qi_base + kUnroll * kQPerIter <= q_end_warp;
qi_base += kUnroll * kQPerIter) {
int kvb[kUnroll];
int qloc[kUnroll];
int batch[kUnroll];
int const* rmap[kUnroll];
#pragma unroll
for (int u = 0; u < kUnroll; ++u) {
int qi_u = qi_base + u * kQPerIter + q_in_iter;
kvb[u] = -1;
qloc[u] = 0;
batch[u] = 0;
if (lane_active) {
advance_batch_only(cu_q, B, qi_u, bi);
qloc[u] = qi_u - cu_q[bi];
batch[u] = bi;
kvb[u] = head_q2k[(size_t)qi_u * kTopK + slot_in_q];
}
rmap[u] = row_map + (size_t)bi * max_kv_blocks;
}
int row[kUnroll];
#pragma unroll
for (int u = 0; u < kUnroll; ++u) {
row[u] = -1;
if (lane_active && kvb[u] >= 0 && kvb[u] < max_kv_blocks)
row[u] = rmap[u][kvb[u]];
}
// Pre-issue all kUnroll abs_base loads in parallel before
// the atomic chain so memory pipeline runs concurrently
// with SMEM atomic-adds.
int abs_v[kUnroll];
#pragma unroll
for (int u = 0; u < kUnroll; ++u) {
abs_v[u] =
(row[u] >= 0 && row[u] < total_rows) ? my_abs_base[row[u]] : 0;
}
#pragma unroll
for (int u = 0; u < kUnroll; ++u) {
int r = row[u];
bool valid_edge = r >= 0 && r < total_rows;
unsigned int valid_mask = __ballot_sync(0xFFFFFFFFu, valid_edge);
unsigned int group_mask =
(kTopK == 32) ? 0xFFFFFFFFu
: (((1u << kTopK) - 1u) << (q_in_iter * kTopK));
unsigned int lower_lane_mask = lane == 0 ? 0u : ((1u << lane) - 1u);
int split_slot = __popc(valid_mask & group_mask & lower_lane_mask);
int valid_count = __popc(valid_mask & group_mask);
if (split_counts != nullptr && slot_in_q == 0) {
split_counts[((size_t)batch[u] * max_seqlen_q + qloc[u]) * H + h] =
valid_count;
}
if (valid_edge) {
int slot = atomic_inc_int16_packed(my_cursor, r);
int out_pos = abs_v[u] + slot;
head_qidx[out_pos] = qloc[u];
if (qsplit_idx != nullptr) {
qsplit_idx[(size_t)h * S_Q * kTopK + out_pos] =
qloc[u] | ((split_slot & 0xFF) << 24);
}
}
}
}
// Tail: 1-3 iters left.
for (; qi_base < q_end_warp; qi_base += kQPerIter) {
int my_qi = qi_base + q_in_iter;
bool valid_q = (my_qi < q_end_warp) && lane_active;
int kvb_local = -1;
int q_local = 0;
int batch_local = 0;
if (valid_q) {
advance_batch_only(cu_q, B, my_qi, bi);
batch_local = bi;
q_local = my_qi - cu_q[bi];
kvb_local = head_q2k[(size_t)my_qi * kTopK + slot_in_q];
}
int const* my_row_map = row_map + (size_t)bi * max_kv_blocks;
int row = -1;
if (valid_q && kvb_local >= 0 && kvb_local < max_kv_blocks) {
row = my_row_map[kvb_local];
}
bool valid_edge = row >= 0 && row < total_rows;
unsigned int valid_mask = __ballot_sync(0xFFFFFFFFu, valid_edge);
unsigned int group_mask =
(kTopK == 32) ? 0xFFFFFFFFu
: (((1u << kTopK) - 1u) << (q_in_iter * kTopK));
unsigned int lower_lane_mask = lane == 0 ? 0u : ((1u << lane) - 1u);
int split_slot = __popc(valid_mask & group_mask & lower_lane_mask);
int valid_count = __popc(valid_mask & group_mask);
if (split_counts != nullptr && valid_q && slot_in_q == 0) {
split_counts[((size_t)batch_local * max_seqlen_q + q_local) * H + h] =
valid_count;
}
if (valid_edge) {
int slot = atomic_inc_int16_packed(my_cursor, row);
int out_pos = my_abs_base[row] + slot;
head_qidx[out_pos] = q_local;
if (qsplit_idx != nullptr) {
qsplit_idx[(size_t)h * S_Q * kTopK + out_pos] =
q_local | ((split_slot & 0xFF) << 24);
}
}
}
}
if (h + 1 < H) __syncthreads();
}
}
} // anonymous namespace
// ===========================================================================
// Host orchestration
// ===========================================================================
template <int kTopK, int kBlockK>
static void launch_pipeline(torch::Tensor q2k, torch::Tensor cu_q,
torch::Tensor cu_k, torch::Tensor row_ptr,
torch::Tensor q_idx, int total_rows,
int max_kv_blocks,
torch::Tensor scheduler_metadata = torch::Tensor(),
torch::Tensor work_count = torch::Tensor(),
torch::Tensor qsplit_idx = torch::Tensor(),
torch::Tensor split_counts = torch::Tensor(),
int target_q_per_cta = 1, int work_capacity = 0,
int max_seqlen_q = 0) {
int H = (int)q2k.size(0);
int S_Q = (int)q2k.size(1);
int topK = (int)q2k.size(2);
TORCH_CHECK(topK == kTopK, "topK runtime != template kTopK");
int B = (int)cu_q.size(0) - 1;
auto device = q2k.device();
cudaStream_t stream = at::cuda::getCurrentCUDAStream();
AT_CUDA_CHECK(cudaMemsetAsync(row_ptr.data_ptr<int>(), 0,
(size_t)H * (total_rows + 1) * sizeof(int),
stream));
AT_CUDA_CHECK(cudaMemsetAsync(q_idx.data_ptr<int>(), 0xFF,
(size_t)H * S_Q * kTopK * sizeof(int), stream));
auto opts = torch::TensorOptions().dtype(torch::kInt32).device(device);
auto row_counts = torch::zeros({H, total_rows}, opts);
auto row_map = torch::empty({B, max_kv_blocks}, opts);
bool emit_schedule = scheduler_metadata.defined();
auto row_coords =
emit_schedule ? torch::empty({total_rows, 2}, opts) : torch::Tensor();
int* scheduler_metadata_ptr =
emit_schedule ? scheduler_metadata.data_ptr<int>() : nullptr;
int* work_count_ptr = emit_schedule ? work_count.data_ptr<int>() : nullptr;
int* qsplit_idx_ptr = emit_schedule ? qsplit_idx.data_ptr<int>() : nullptr;
int* split_counts_ptr =
emit_schedule ? split_counts.data_ptr<int>() : nullptr;
int* row_coords_ptr = emit_schedule ? row_coords.data_ptr<int>() : nullptr;
if (emit_schedule) {
AT_CUDA_CHECK(cudaMemsetAsync(work_count_ptr, 0, sizeof(int), stream));
AT_CUDA_CHECK(cudaMemsetAsync(scheduler_metadata_ptr, 0,
(size_t)work_capacity * 6 * sizeof(int),
stream));
}
int dev = q2k.get_device();
int num_sms = 0;
AT_CUDA_CHECK(
cudaDeviceGetAttribute(&num_sms, cudaDevAttrMultiProcessorCount, dev));
// -- Pick kWarps per CTA based on SMEM budget for cursor/hist ---------
// SMEM per CTA = kWarps * total_rows * sizeof(int) (for both H and S).
// Want at least 2 CTAs/SM for memory parallelism. SM100 SMEM = 228KB.
// Pick the largest kWarps that fits two CTAs/SM, capped at 4.
// SMEM cursor packed as int16 (2 entries per int32 word):
int per_warp_smem = ((total_rows + 1) >> 1) * (int)sizeof(int);
int kWarps_pick = 4;
while (kWarps_pick > 1 && (kWarps_pick * per_warp_smem) * 2 > 228 * 1024) {
kWarps_pick >>= 1;
}
if (kWarps_pick < 1) kWarps_pick = 1;
// -- Pick G (CTAs) ----------------------------------------------------
// For each (kWarps, per_warp_smem) pair, the SMEM-bound occupancy is
// 228KB / (kWarps*per_warp_smem) CTAs/SM. We size G as
// num_sms * occupancy so a single resident wave covers all CTAs and
// the memory pipeline runs at peak.
int per_cta_smem_bytes = kWarps_pick * per_warp_smem;
int max_ctas_per_sm =
std::max(1, (228 * 1024) / std::max(1, per_cta_smem_bytes));
if (max_ctas_per_sm > 8) max_ctas_per_sm = 8;
constexpr int kMinQPerCta = 256;
// Cap target_g at num_sms * 3 — empirically this balances
// per-CTA work-size against parallelism. Higher caps regress
// mid-size cases due to row_counts atomicAdd contention and
// smaller q_per_cta. SMEM-bound configurations naturally cap
// lower if max_ctas_per_sm < 3.
int target_g = num_sms * std::min(max_ctas_per_sm, 3);
int max_g_for_q = (S_Q + kMinQPerCta - 1) / kMinQPerCta;
int G = std::min({target_g, max_g_for_q, S_Q});
if (G < 1) G = 1;
int q_per_cta = (S_Q + G - 1) / G;
G = (S_Q + q_per_cta - 1) / q_per_cta;
int q_per_warp = (q_per_cta + kWarps_pick - 1) / kWarps_pick;
int G_total = G * kWarps_pick;
auto tile_counts = torch::empty({G_total, H, total_rows}, opts);
// -- Compile-time switch on kWarps for the templated kernels ---------
auto rmap_fn = k2q_build_row_map_kernel<kBlockK>;
auto rprefix_fn = k2q_row_prefix_kernel<1024>;
constexpr int kPtRowsPerBlock = 8;
constexpr int kPtThreads = 256;
auto tprefix_smem_fn =
k2q_tile_prefix_smem_kernel<kPtThreads, kPtRowsPerBlock>;
if (max_kv_blocks > 0) {
rmap_fn<<<max_kv_blocks, 32, 0, stream>>>(cu_k.data_ptr<int>(),
row_map.data_ptr<int>(),
row_coords_ptr, B, max_kv_blocks);
}
auto launch_hist_scatter = [&](auto kWarps_const) {
constexpr int W = decltype(kWarps_const)::value;
size_t smem_bytes = (size_t)W * per_warp_smem;
auto hist_fn = k2q_hist_kernel<kTopK, kBlockK, W>;
auto scat_fn = k2q_scatter_kernel<kTopK, kBlockK, W>;
AT_CUDA_CHECK(cudaFuncSetAttribute(
hist_fn, cudaFuncAttributeMaxDynamicSharedMemorySize, (int)smem_bytes));
AT_CUDA_CHECK(cudaFuncSetAttribute(
scat_fn, cudaFuncAttributeMaxDynamicSharedMemorySize, (int)smem_bytes));
hist_fn<<<G, W * kWarpSize, smem_bytes, stream>>>(
q2k.data_ptr<int>(), cu_q.data_ptr<int>(), row_map.data_ptr<int>(),
row_counts.data_ptr<int>(), tile_counts.data_ptr<int>(), H, B, S_Q,
total_rows, max_kv_blocks, q_per_cta, q_per_warp);
rprefix_fn<<<H, 1024, 0, stream>>>(
row_counts.data_ptr<int>(), row_ptr.data_ptr<int>(),
emit_schedule ? row_coords.data_ptr<int>() : nullptr,
scheduler_metadata_ptr, work_count_ptr, total_rows, target_q_per_cta,
work_capacity);
// Grid is H * blocks_per_h so each block stays within a single
// head; flat (H*total_rows) grid would skip rows when total_rows
// is not a multiple of kPtRowsPerBlock.
int blocks_per_h = (total_rows + kPtRowsPerBlock - 1) / kPtRowsPerBlock;
int pt_grid = H * blocks_per_h;
if (pt_grid < 1) pt_grid = 1;
size_t pt_smem = (size_t)kPtRowsPerBlock * G_total * sizeof(int);
AT_CUDA_CHECK(cudaFuncSetAttribute(
tprefix_smem_fn, cudaFuncAttributeMaxDynamicSharedMemorySize,
(int)pt_smem));
tprefix_smem_fn<<<pt_grid, kPtThreads, pt_smem, stream>>>(
tile_counts.data_ptr<int>(), row_ptr.data_ptr<int>(), H, total_rows,
G_total);
scat_fn<<<G, W * kWarpSize, smem_bytes, stream>>>(
q2k.data_ptr<int>(), cu_q.data_ptr<int>(), row_map.data_ptr<int>(),
tile_counts.data_ptr<int>(), q_idx.data_ptr<int>(), qsplit_idx_ptr,
split_counts_ptr, H, B, S_Q, total_rows, max_kv_blocks, q_per_cta,
q_per_warp, max_seqlen_q);
};
if (kWarps_pick == 4) {
launch_hist_scatter(std::integral_constant<int, 4>{});
} else if (kWarps_pick == 2) {
launch_hist_scatter(std::integral_constant<int, 2>{});
} else {
launch_hist_scatter(std::integral_constant<int, 1>{});
}
}
void run_minimax_m3_build_k2q_csr_with_schedule(
torch::Tensor q2k, torch::Tensor cu_q, torch::Tensor cu_k,
torch::Tensor row_ptr, torch::Tensor q_idx,
torch::Tensor scheduler_metadata, torch::Tensor work_count,
torch::Tensor qsplit_idx, torch::Tensor split_counts, int64_t topk,
int64_t blk_kv, int64_t total_rows, int64_t max_kv_blocks,
int64_t target_q_per_cta, int64_t work_capacity, int64_t max_seqlen_q) {
CHECK_INPUT(q2k);
CHECK_INPUT(cu_q);
CHECK_INPUT(cu_k);
CHECK_INPUT(row_ptr);
CHECK_INPUT(q_idx);
CHECK_INPUT(scheduler_metadata);
CHECK_INPUT(work_count);
CHECK_INPUT(qsplit_idx);
CHECK_INPUT(split_counts);
TORCH_CHECK(blk_kv == 128, "build_k2q_csr only supports blk_kv == 128");
int H = (int)q2k.size(0);
int S_Q = (int)q2k.size(1);
int tr = (int)total_rows;
int mkv = (int)max_kv_blocks;
int target = (int)target_q_per_cta;
int capacity = (int)work_capacity;
int max_sq = (int)max_seqlen_q;
TORCH_CHECK(tr >= 0 && mkv >= 0 && target > 0 && capacity > 0 && max_sq >= 0,
"invalid schedule sizing arguments");
TORCH_CHECK(row_ptr.size(0) == H && row_ptr.size(1) == tr + 1,
"row_ptr shape mismatch");
TORCH_CHECK(q_idx.size(0) == H && q_idx.size(1) == (int64_t)S_Q * (int)topk,
"q_idx shape mismatch");
TORCH_CHECK(qsplit_idx.sizes() == q_idx.sizes(), "qsplit_idx shape mismatch");
TORCH_CHECK(
scheduler_metadata.size(0) == capacity && scheduler_metadata.size(1) == 6,
"scheduler_metadata shape mismatch");
TORCH_CHECK(work_count.numel() == 1,
"work_count must have one int32 element");
TORCH_CHECK(split_counts.dim() == 3 &&
split_counts.size(0) == cu_q.size(0) - 1 &&
split_counts.size(1) == max_sq && split_counts.size(2) == H,
"split_counts shape mismatch");
if (S_Q == 0 || tr == 0 || H == 0 || mkv == 0) {
cudaStream_t stream = at::cuda::getCurrentCUDAStream();
AT_CUDA_CHECK(cudaMemsetAsync(row_ptr.data_ptr<int>(), 0,
(size_t)H * (tr + 1) * sizeof(int), stream));
AT_CUDA_CHECK(cudaMemsetAsync(q_idx.data_ptr<int>(), 0xFF,
(size_t)H * S_Q * (int)topk * sizeof(int),
stream));
AT_CUDA_CHECK(
cudaMemsetAsync(work_count.data_ptr<int>(), 0, sizeof(int), stream));
if (split_counts.numel() > 0) {
AT_CUDA_CHECK(cudaMemsetAsync(split_counts.data_ptr<int>(), 0,
(size_t)split_counts.numel() * sizeof(int),
stream));
}
return;
}
if (topk == 16) {
launch_pipeline<16, 128>(q2k, cu_q, cu_k, row_ptr, q_idx, tr, mkv,
scheduler_metadata, work_count, qsplit_idx,
split_counts, target, capacity, max_sq);
} else if (topk == 8) {
launch_pipeline<8, 128>(q2k, cu_q, cu_k, row_ptr, q_idx, tr, mkv,
scheduler_metadata, work_count, qsplit_idx,
split_counts, target, capacity, max_sq);
} else if (topk == 32) {
launch_pipeline<32, 128>(q2k, cu_q, cu_k, row_ptr, q_idx, tr, mkv,
scheduler_metadata, work_count, qsplit_idx,
split_counts, target, capacity, max_sq);
} else if (topk == 4) {
launch_pipeline<4, 128>(q2k, cu_q, cu_k, row_ptr, q_idx, tr, mkv,
scheduler_metadata, work_count, qsplit_idx,
split_counts, target, capacity, max_sq);
} else {
TORCH_CHECK(false, "unsupported topK ", topk,
" (expected 4, 8, 16, or 32)");
}
}
+10 -1
View File
@@ -62,7 +62,8 @@ void rotary_embedding(torch::Tensor& positions, torch::Tensor& query,
void silu_and_mul(torch::Tensor& out, torch::Tensor& input);
void silu_and_mul_clamp(torch::Tensor& out, torch::Tensor& input, double limit);
void silu_and_mul_clamp(torch::Tensor& out, torch::Tensor& input, double limit,
double alpha = 1.0, double beta = 0.0);
void silu_and_mul_quant(torch::Tensor& out, torch::Tensor& input,
torch::Tensor& scale);
@@ -146,4 +147,12 @@ std::tuple<torch::Tensor, torch::Tensor> minimax_allreduce_rms_qk(
torch::Tensor const& norm_weight_k, torch::Tensor workspace,
int64_t const q_size, int64_t const kv_size, int64_t const rank,
int64_t const nranks, double const eps);
void run_minimax_m3_build_k2q_csr_with_schedule(
torch::Tensor q2k, torch::Tensor cu_q, torch::Tensor cu_k,
torch::Tensor row_ptr, torch::Tensor q_idx,
torch::Tensor scheduler_metadata, torch::Tensor work_count,
torch::Tensor qsplit_idx, torch::Tensor split_counts, int64_t topk,
int64_t blk_kv, int64_t total_rows, int64_t max_kv_blocks,
int64_t target_q_per_cta, int64_t work_capacity, int64_t max_seqlen_q);
#endif
+21
View File
@@ -187,6 +187,27 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
"float eps) -> (Tensor, Tensor)");
ops.impl("minimax_allreduce_rms_qk", torch::kCUDA, &minimax_allreduce_rms_qk);
ops.def(
"minimax_m3_build_k2q_csr_with_schedule("
"Tensor q2k,"
"Tensor cu_q,"
"Tensor cu_k,"
"Tensor! row_ptr,"
"Tensor! q_idx,"
"Tensor! scheduler_metadata,"
"Tensor! work_count,"
"Tensor! qsplit_idx,"
"Tensor! split_counts,"
"int topk,"
"int blk_kv,"
"int total_rows,"
"int max_kv_blocks,"
"int target_q_per_cta,"
"int work_capacity,"
"int max_seqlen_q) -> ()");
ops.impl("minimax_m3_build_k2q_csr_with_schedule", torch::kCUDA,
&run_minimax_m3_build_k2q_csr_with_schedule);
// conditionally compiled so impl in source file
#endif
}
+1 -1
View File
@@ -757,7 +757,7 @@ RUN --mount=type=cache,target=/opt/uv/cache \
# Install FlashInfer JIT cache (requires CUDA-version-specific index URL)
# https://docs.flashinfer.ai/installation.html
# From versions.json: .flashinfer.version
ARG FLASHINFER_VERSION=0.6.11.post2
ARG FLASHINFER_VERSION=0.6.12
RUN --mount=type=cache,target=/opt/uv/cache \
uv pip install --system flashinfer-jit-cache==${FLASHINFER_VERSION} \
--extra-index-url https://flashinfer.ai/whl/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.')
+2 -2
View File
@@ -256,13 +256,13 @@ RUN pip install setuptools==75.6.0 packaging==23.2 ninja==1.11.1.3 build==1.2.2.
# build flashinfer for torch nightly from source around 10 mins
# release version: v0.6.11.post2
# release version: v0.6.12
# todo(elainewy): cache flashinfer build result for faster build
ENV CCACHE_DIR=/root/.cache/ccache
RUN --mount=type=cache,target=/root/.cache/ccache \
--mount=type=cache,target=/root/.cache/uv \
echo "git clone flashinfer..." \
&& git clone --depth 1 --branch v0.6.11.post2 --recursive https://github.com/flashinfer-ai/flashinfer.git \
&& git clone --depth 1 --branch v0.6.12 --recursive https://github.com/flashinfer-ai/flashinfer.git \
&& cd flashinfer \
&& git submodule update --init --recursive \
&& echo "finish git clone flashinfer..." \
+1 -1
View File
@@ -68,7 +68,7 @@
"default": "true"
},
"FLASHINFER_VERSION": {
"default": "0.6.11.post2"
"default": "0.6.12"
},
"GDRCOPY_CUDA_VERSION": {
"default": "12.8"
+14 -2
View File
@@ -170,8 +170,8 @@ Priority is **1 = highest** (tried first).
| Backend | Version | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | MM Prefix | DCP | Attention Types | Compute Cap. |
| ------- | ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | --------- | --- | --------------- | ------------ |
| `CPU_ATTN` | | fp16, bf16, fp32 | `auto`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | 32, 64, 80, 96, 112, 128, 160, 192, 224, 256, 512 | ❌ | ❌ | ❌ | ❌ | All | N/A |
| `FLASHINFER` | Native† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32, 64 | 64, 128, 256, 512 | ❌ | ❌ | ❌ | ✅ | Decoder | 7.x-9.x |
| `FLASHINFER` | TRTLLM† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `nvfp4` | 16, 32, 64 | 64, 128, 256, 512 | ✅ | ❌ | ❌ | ✅ | Decoder | 10.x |
| `FLASHINFER` | Native† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32, 64, 128, 256, 512, 1024 | 64, 128, 256, 512 | ❌ | ❌ | ❌ | ✅ | Decoder | 7.x-9.x |
| `FLASHINFER` | TRTLLM† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `nvfp4` | 16, 32, 64, 128, 256, 512, 1024 | 64, 128, 256, 512 | ✅ | ❌ | ❌ | ✅ | Decoder | 10.x |
| `FLASH_ATTN` | FA2* | fp16, bf16 | `auto`, `float16`, `bfloat16` | %16 | Any | ❌ | ✅ | ❌ | ✅ | All | ≥8.0 |
| `FLASH_ATTN` | FA3* | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | Any | ✅ | ✅ | ❌ | ✅ | All | 9.x |
| `FLASH_ATTN` | FA4* | fp16, bf16 | `auto`, `float16`, `bfloat16` | %16 | Any | ✅ | ✅ | ❌ | ✅ | All | ≥10.0 |
@@ -187,6 +187,18 @@ Priority is **1 = highest** (tried first).
>
> **\*** Specify the FlashAttention version via `--attention-config.flash_attn_version=2`, `3`, or `4`. Default is FA4 on SM100+ (Blackwell), FA3 on SM90 (Hopper), FA2 otherwise.
## MiniMax M3 Sparse Attention Backends
Block-sparse GQA backend used by MiniMax M3 sparse ("lightning indexer")
layers. It is wired in directly by the model and is not part of the
automatic priority lists above. A lightning indexer scores KV blocks, the
top-k blocks (plus fixed init/local blocks) are selected, and attention
attends only to those blocks; index keys live in a separate side cache.
| Backend | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | MM Prefix | DCP | Attention Types | Compute Cap. |
| ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | --------- | --- | --------------- | ------------ |
| `MINIMAX_M3_SPARSE` | bf16, fp16 | `bfloat16` | 128 | 128 | ❌ | ❌ | ❌ | ❌ | Decoder | Any |
## MLA (Multi-head Latent Attention) Backends
MLA uses separate backends for prefill and decode phases.
+5
View File
@@ -162,7 +162,12 @@ dout = "dout"
Pn = "Pn"
arange = "arange"
thw = "thw"
# temporal position ids (parallels hpos/wpos in vision RoPE)
tpos = "tpos"
subtile = "subtile"
subtiles = "subtiles"
reord = "reord"
Ot = "Ot"
HSA = "HSA"
setp = "setp"
CPY = "CPY"
+1
View File
@@ -29,6 +29,7 @@ xgrammar >= 0.2.0, < 1.0.0; platform_machine == "x86_64" or platform_machine ==
typing_extensions >= 4.10
filelock >= 3.16.1 # need to contain https://github.com/tox-dev/filelock/pull/317
partial-json-parser # used for parsing partial JSON outputs
jsonschema >= 4.23.0 # required for MiniMax M3 tool schema validation
pyzmq >= 25.0.0
msgspec
gguf >= 0.17.0
+2 -2
View File
@@ -9,8 +9,8 @@ torchaudio==2.11.0
# These must be updated alongside torch
torchvision==0.26.0 # Required for phi3v processor. See https://github.com/pytorch/vision?tab=readme-ov-file#installation for corresponding version
# FlashInfer should be updated together with the Dockerfile
flashinfer-python==0.6.11.post2
flashinfer-cubin==0.6.11.post2
flashinfer-python==0.6.12
flashinfer-cubin==0.6.12
apache-tvm-ffi==0.1.9
tilelang==0.1.9
# Cap nvidia-cudnn-frontend (transitive dep of flashinfer) due to
+1
View File
@@ -360,6 +360,7 @@ jsonpointer==3.0.0
# via jsonschema
jsonschema==4.23.0
# via
# -c requirements/common.txt
# hypothesis-jsonschema
# mistral-common
# ray
+2
View File
@@ -440,6 +440,8 @@ jsonpointer==3.1.0
# via jsonschema
jsonschema==4.26.0
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
# hypothesis-jsonschema
# mcp
# mistral-common
+1
View File
@@ -229,6 +229,7 @@ jsonlines==4.0.0
# via lm-eval
jsonschema==4.26.0
# via
# -c requirements/common.txt
# hypothesis-jsonschema
# mistral-common
# schemathesis
+87
View File
@@ -3458,6 +3458,75 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "pyo3"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pythonize"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95"
dependencies = [
"pyo3",
"serde",
"serde_json",
]
[[package]]
name = "qoi"
version = "0.4.1"
@@ -4669,6 +4738,12 @@ dependencies = [
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "task-local"
version = "0.1.1"
@@ -5622,6 +5697,7 @@ dependencies = [
"expect-test",
"futures",
"half",
"indexmap 2.13.0",
"itertools 0.14.0",
"llm-multimodal",
"minijinja",
@@ -5900,6 +5976,17 @@ dependencies = [
"winnow",
]
[[package]]
name = "vllm-tool-parser-py"
version = "0.1.0"
dependencies = [
"pyo3",
"pythonize",
"serde_json",
"thiserror-ext",
"vllm-tool-parser",
]
[[package]]
name = "walkdir"
version = "2.5.0"
+5 -1
View File
@@ -12,6 +12,7 @@ members = [
"src/text",
"src/tokenizer",
"src/tool-parser",
"src/tool-parser/python",
]
resolver = "3"
@@ -43,6 +44,7 @@ half = { version = "2.7.1", features = ["bytemuck"] }
hex = "0.4.3"
hf-hub = { version = "0.5.0", features = ["tokio"] }
http-body = "1.0.1"
indexmap = "2.13.0"
itertools = "0.14.0"
libc = "0.2.177"
llm-multimodal = { git = "https://github.com/vllm-project/llm-multimodal", rev = "5b558989844d1c7af3e43d0f604069ffd9c06320" }
@@ -59,6 +61,8 @@ prometheus-client = "0.24.0"
prometheus-client-derive-encode = "0.5.0"
prost = "0.14.3"
prost-types = "0.14.3"
pyo3 = "0.28.3"
pythonize = "0.28.0"
rand = "0.9.2"
reasoning-parser = "1.2.2"
reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls"] }
@@ -69,7 +73,7 @@ rustc-hash = "1.1.0"
serde = { version = "1.0.228", features = ["derive"] }
serde-json-fmt = "0.1.0"
serde_default = "0.2.0"
serde_json = { version = "1.0.145", features = ["arbitrary_precision", "preserve_order"] }
serde_json = { version = "1.0.145", features = ["preserve_order"] }
serde_repr = "0.1.20"
serde_tuple = "1.1.3"
serde_with = "3.18.0"
+1
View File
@@ -10,6 +10,7 @@ asynk-strim-attr.workspace = true
easy-ext.workspace = true
futures.workspace = true
half.workspace = true
indexmap.workspace = true
itertools.workspace = true
llm-multimodal.workspace = true
minijinja.workspace = true
+1 -1
View File
@@ -233,7 +233,7 @@ mod tests {
)
.unwrap_err();
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
}
#[test]
+7 -2
View File
@@ -5,8 +5,9 @@ use std::sync::LazyLock;
pub use vllm_reasoning_parser::{
CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
DeepSeekV4ReasoningParser, Gemma4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser,
KimiReasoningParser, MiniMaxM2ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser,
ReasoningDelta, ReasoningError, ReasoningParser, Step3ReasoningParser,
KimiReasoningParser, MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser,
NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningError,
ReasoningParser, Step3ReasoningParser,
};
use vllm_tokenizer::DynTokenizer;
@@ -23,6 +24,7 @@ pub mod names {
pub const KIMI: &str = "kimi";
pub const KIMI_K2: &str = "kimi_k2";
pub const MINIMAX_M2: &str = "minimax_m2";
pub const MINIMAX_M3: &str = "minimax_m3";
pub const NEMOTRON_V3: &str = "nemotron_v3";
pub const QWEN3: &str = "qwen3";
pub const STEP3: &str = "step3";
@@ -59,6 +61,7 @@ impl ReasoningParserFactory {
.register_parser::<KimiReasoningParser>(names::KIMI)
.register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
.register_parser::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
.register_parser::<MiniMaxM3ReasoningParser>(names::MINIMAX_M3)
.register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
.register_parser::<Qwen3ReasoningParser>(names::QWEN3)
.register_parser::<Step3ReasoningParser>(names::STEP3);
@@ -78,6 +81,8 @@ impl ReasoningParserFactory {
.register_pattern("kimi-k2", names::KIMI_K2)
.register_pattern("kimi", names::KIMI)
.register_pattern("step3", names::STEP3)
.register_pattern("minimax-m3", names::MINIMAX_M3)
.register_pattern("mm-m3", names::MINIMAX_M3)
.register_pattern("minimax", names::MINIMAX_M2)
.register_pattern("mm-m2", names::MINIMAX_M2)
.register_pattern("cohere", names::COHERE_CMD)
@@ -32,8 +32,10 @@ fn factory_contains_and_lists_registered_parsers() {
let factory = ReasoningParserFactory::new();
assert!(factory.contains(names::QWEN3));
assert!(factory.contains(names::DEEPSEEK_V4));
assert!(factory.contains(names::MINIMAX_M3));
assert!(factory.list().contains(&names::QWEN3.to_string()));
assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string()));
assert!(factory.list().contains(&names::MINIMAX_M3.to_string()));
}
#[test]
@@ -49,6 +51,19 @@ fn factory_resolves_deepseek_v4_to_qwen3_alias() {
);
}
#[test]
fn factory_resolves_minimax_m3_before_generic_minimax() {
let factory = ReasoningParserFactory::new();
assert_eq!(
factory.resolve_name_for_model("MiniMaxAI/Minimax-M3-preview"),
Some(names::MINIMAX_M3)
);
assert_eq!(
factory.resolve_name_for_model("mm-m3"),
Some(names::MINIMAX_M3)
);
}
#[test]
fn factory_rejects_unknown_parser_names() {
let tokenizer = Arc::new(FakeTokenizer);
+7 -3
View File
@@ -5,9 +5,9 @@ use std::sync::LazyLock;
pub use vllm_tool_parser::{
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, HyV3ToolParser,
KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser, MistralToolParser,
Qwen3CoderToolParser, Qwen3XmlToolParser, ToolCallDelta, ToolParser, ToolParserError,
ToolParserOutput,
KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser, MinimaxM3ToolParser,
MistralToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser, ToolCallDelta, ToolParser,
ToolParserError, ToolParserOutput,
};
use crate::parser::ParserFactory;
@@ -28,6 +28,7 @@ pub mod names {
pub const LLAMA3_JSON: &str = "llama3_json";
pub const LLAMA4_JSON: &str = "llama4_json";
pub const MINIMAX_M2: &str = "minimax_m2";
pub const MINIMAX_M3: &str = "minimax_m3";
pub const MISTRAL: &str = "mistral";
pub const QWEN3_CODER: &str = "qwen3_coder";
pub const QWEN3_XML: &str = "qwen3_xml";
@@ -66,6 +67,7 @@ impl ToolParserFactory {
.register_parser::<Llama3JsonToolParser>(names::LLAMA3_JSON)
.register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
.register_parser::<MinimaxM2ToolParser>(names::MINIMAX_M2)
.register_parser::<MinimaxM3ToolParser>(names::MINIMAX_M3)
.register_parser::<MistralToolParser>(names::MISTRAL)
.register_parser::<Qwen3XmlToolParser>(names::QWEN3_XML)
.register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER);
@@ -96,6 +98,8 @@ impl ToolParserFactory {
.register_pattern("gemma4", names::GEMMA4)
.register_pattern("gemma-4", names::GEMMA4)
.register_pattern("kimi-k2", names::KIMI_K2)
.register_pattern("minimax-m3", names::MINIMAX_M3)
.register_pattern("mm-m3", names::MINIMAX_M3)
.register_pattern("minimax", names::MINIMAX_M2)
.register_pattern("mm-m2", names::MINIMAX_M2);
+8
View File
@@ -153,6 +153,14 @@ fn factory_new_resolves_default_patterns() {
factory.resolve_name_for_model("tencent/Hy3-preview"),
Some(names::HY_V3)
);
assert_eq!(
factory.resolve_name_for_model("MiniMax/MiniMax-M3-Text"),
Some(names::MINIMAX_M3)
);
assert_eq!(
factory.resolve_name_for_model("org/mm-m3-base"),
Some(names::MINIMAX_M3)
);
assert_eq!(
factory.resolve_name_for_model("MiniMax/MiniMax-M2-01"),
Some(names::MINIMAX_M2)
+33 -7
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use serde::Serialize;
use serde_json::Value;
use serde_json::Value as JsonValue;
use thiserror_ext::AsReport as _;
use tracing::{info, trace, warn};
use vllm_text::Prompt;
@@ -13,6 +13,7 @@ use self::format::{
ChatTemplateContentFormat, ChatTemplateContentFormatOption as ContentFormatOption,
};
use self::template::{CompiledChatTemplate, TemplateContext};
use self::value::{TemplateValue, to_template_value};
use super::{ChatRenderer, RenderedPrompt};
use crate::error::Result;
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
@@ -24,6 +25,7 @@ mod error;
mod format;
mod template;
mod tojson;
mod value;
pub use template::{load_chat_template, resolve_chat_template};
@@ -38,7 +40,7 @@ pub struct MultimodalRenderInfo {
/// state.
pub struct HfChatRenderer {
default_template: Option<CompiledChatTemplate>,
default_template_kwargs: HashMap<String, Value>,
default_template_kwargs: HashMap<String, JsonValue>,
content_format: ContentFormatOption,
special_tokens: Option<HfSpecialTokens>,
multimodal: Option<MultimodalRenderInfo>,
@@ -48,7 +50,7 @@ impl HfChatRenderer {
/// Create a renderer from the given template string.
pub fn new(
template: Option<String>,
default_template_kwargs: HashMap<String, Value>,
default_template_kwargs: HashMap<String, JsonValue>,
content_format: ContentFormatOption,
) -> Result<Self> {
Ok(Self {
@@ -245,7 +247,7 @@ struct TemplateToolCall {
#[derive(Debug, Serialize)]
struct TemplateToolFunction {
name: String,
arguments: Value,
arguments: TemplateValue,
}
#[derive(Debug, Serialize)]
@@ -259,7 +261,7 @@ pub(super) struct TemplateTool {
struct TemplateToolDefinition {
name: String,
description: Option<String>,
parameters: Value,
parameters: TemplateValue,
strict: Option<bool>,
}
@@ -345,13 +347,14 @@ fn to_template_tool_calls(
let mut tool_calls = Vec::new();
for tool_call in content.tool_calls() {
let arguments = serde_json::from_str::<Value>(&tool_call.arguments).map_err(|error| {
let arguments = serde_json::from_str(&tool_call.arguments).map_err(|error| {
Error::ChatTemplate(format!(
"assistant tool call `{}` has invalid JSON arguments: {}",
tool_call.id,
error.as_report()
))
})?;
let arguments = to_template_value(arguments);
tool_calls.push(TemplateToolCall {
id: tool_call.id.clone(),
@@ -434,7 +437,7 @@ fn to_template_tools(tools: &[ChatTool]) -> Vec<TemplateTool> {
function: TemplateToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.parameters.clone(),
parameters: to_template_value(tool.parameters.clone()),
strict: tool.strict,
},
})
@@ -909,6 +912,29 @@ mod tests {
assert_eq!(rendered, "get_weather|Paris|call_1|Sunny");
}
#[test]
fn chat_template_tool_call_argument_items_method_is_not_shadowed_by_field() {
let request = sample_request(vec![ChatMessage::assistant_blocks(vec![
AssistantContentBlock::ToolCall(crate::AssistantToolCall {
id: "call_1".to_string(),
name: "add".to_string(),
arguments: r#"{"items":"operands","x":2,"y":1.0}"#.to_string(),
}),
])]);
let rendered = render(
Some(
"{%- set arguments = messages[0].tool_calls[0].function.arguments -%}
{%- for key, value in arguments.items() -%}{{ key }}={{ value }};{%- endfor -%}
|{{ arguments['items'] }}",
),
&request,
)
.unwrap();
assert_eq!(rendered, "items=operands;x=2;y=1.0;|operands");
}
#[test]
fn qwen35_template_renders_prefilled_reasoning_start_when_thinking_enabled() {
let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
+18 -2
View File
@@ -208,11 +208,27 @@ mod tests {
}
#[test]
fn tojson_preserves_arbitrary_precision_number_spelling() {
fn tojson_uses_standard_serde_json_number_spelling() {
let payload = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
let rendered = render("{{ payload|tojson }}", payload);
assert_eq!(rendered, "{\"x\": 2, \"y\": 1.00}");
// TODO: we cannot preserve the original number precision by enabling `serde_json`'s
// `arbitrary_precision` feature, otherwise the following test
// `serialized_json_numbers_do_not_leak_serde_private_representation` will fail.
// See issue: https://github.com/mitsuhiko/minijinja/issues/641
assert_eq!(rendered, "{\"x\": 2, \"y\": 1.0}");
}
#[test]
fn serialized_json_numbers_do_not_leak_serde_private_representation() {
let payload: serde_json::Value = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
let rendered = render("{{ payload }}", payload);
// TODO: we cannot preserve the original number precision by enabling `serde_json`'s
// `arbitrary_precision` feature, otherwise this will fail.
// See issue: https://github.com/mitsuhiko/minijinja/issues/641
assert!(!rendered.contains("$serde_json::private::Number"));
assert_eq!(rendered, r#"{"x": 2, "y": 1.0}"#);
}
#[test]
+77
View File
@@ -0,0 +1,77 @@
use std::sync::Arc;
use indexmap::IndexMap;
use minijinja::value::{Enumerator, Object, ObjectExt, ObjectRepr};
use minijinja::{Error as TemplateError, ErrorKind as TemplateErrorKind, State};
use serde::Serialize;
use serde_json::Value as JsonValue;
/// A wrapper around `minijinja::Value` that can be constructed with `to_template_value` and used
/// as a value in the chat template.
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub(super) struct TemplateValue(minijinja::Value);
pub(super) fn to_template_value(value: JsonValue) -> TemplateValue {
TemplateValue(match value {
JsonValue::Array(values) => values
.into_iter()
.map(to_template_value)
.map(|value| value.0)
.collect::<minijinja::Value>(),
JsonValue::Object(values) => minijinja::Value::from_object(TemplateMap(
values
.into_iter()
.map(|(key, value)| (key, to_template_value(value).0))
.collect(),
)),
// For primitive values, directly convert them to `minijinja::Value` using `from_serialize`.
value => minijinja::Value::from_serialize(value),
})
}
/// A custom map type that always returns `UnknownMethod` for method calls, so that pycompat can
/// always handle dict methods through the unknown-method callback.
///
/// Use `IndexMap` to preserve the original key order when iterating.
///
/// MiniJinja's default map can resolve a same-named field before Python dict methods. HF templates
/// commonly call `dict.items()`, which would fail if the map had an `items` field.
/// See issue: https://github.com/mitsuhiko/minijinja/issues/903
#[derive(Debug)]
struct TemplateMap(IndexMap<String, minijinja::Value>);
impl Object for TemplateMap {
fn repr(self: &Arc<Self>) -> ObjectRepr {
ObjectRepr::Map
}
fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
self.0.get(key.as_str()?).cloned()
}
fn get_value_by_str(self: &Arc<Self>, key: &str) -> Option<minijinja::Value> {
self.0.get(key).cloned()
}
fn enumerate(self: &Arc<Self>) -> Enumerator {
self.mapped_rev_enumerator(|this| {
Box::new(this.0.keys().map(|key| minijinja::Value::from(key.as_str())))
})
}
fn enumerator_len(self: &Arc<Self>) -> Option<usize> {
Some(self.0.len())
}
fn call_method(
self: &Arc<Self>,
_state: &State<'_, '_>,
_method: &str,
_args: &[minijinja::Value],
) -> std::result::Result<minijinja::Value, TemplateError> {
// Always return `UnknownMethod` for method calls,
// so that pycompat can handle dict methods through the unknown-method callback.
Err(TemplateError::from(TemplateErrorKind::UnknownMethod))
}
}
+12 -7
View File
@@ -183,7 +183,7 @@ async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> {
"roundtrip-reasoning-tools",
vec![ChatMessage::text(
ChatRole::User,
"Check Shanghai weather and add 1.00 plus 2.",
"Check Shanghai weather and add 1.0 plus 2.",
)],
test_tools(),
);
@@ -210,9 +210,10 @@ async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> {
AssistantContentBlock::ToolCall(AssistantToolCall {
id: "functions.add:1".to_string(),
name: "add".to_string(),
// Intentionally use a non-lexical order of keys and a different number
// formatting style to verify text-level fidelity of the roundtrip.
arguments: r#"{"y":1.00,"x":2}"#.to_string(),
// Intentionally use a non-lexical order of keys to verify text-level
// fidelity of the roundtrip where JSON formatting remains stable. The
// `items` key also exercises templates that call `arguments.items()`.
arguments: r#"{"y":1.0,"x":2,"items":["left","right"]}"#.to_string(),
}),
],
},
@@ -240,7 +241,7 @@ async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> {
assert_eq!(tool_calls[1].name, "add");
assert_eq!(
tool_calls[1].arguments,
expected_arguments(&case, r#"{"y": 1.00, "x": 2}"#)?,
expected_arguments(&case, r#"{"y": 1.0, "x": 2, "items": ["left", "right"]}"#)?,
);
assert_eq!(
@@ -531,9 +532,13 @@ fn test_tools() -> Vec<ChatTool> {
"type": "object",
"properties": {
"y": { "type": "number" },
"x": { "type": "number" }
"x": { "type": "number" },
"items": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["y", "x"]
"required": ["y", "x", "items"]
}),
strict: None,
},
+2
View File
@@ -19,6 +19,7 @@ mod deepseek_r1;
mod delimited;
mod gemma4;
mod kimi;
mod minimax_m3;
mod qwen3;
use thiserror::Error;
@@ -29,6 +30,7 @@ pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
pub(crate) use self::delimited::DelimitedReasoningParser;
pub use self::gemma4::Gemma4ReasoningParser;
pub use self::kimi::KimiReasoningParser;
pub use self::minimax_m3::MiniMaxM3ReasoningParser;
pub use self::qwen3::Qwen3ReasoningParser;
/// DeepSeek V3 currently shares the standard `<think>...</think>` parser.
@@ -0,0 +1,43 @@
use vllm_tokenizer::DynTokenizer;
use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
/// Reasoning parser for MiniMax M3 style outputs.
///
/// MiniMax M3 uses `<mm:think>...</mm:think>` delimiters. Its chat template may
/// prefill either delimiter depending on the requested thinking mode, so the
/// shared delimited parser derives the starting state from the rendered prompt.
pub struct MiniMaxM3ReasoningParser {
inner: DelimitedReasoningParser,
}
impl MiniMaxM3ReasoningParser {
/// Create a MiniMax M3 parser backed by the shared delimited state machine.
pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
Ok(Self {
inner: DelimitedReasoningParser::new(tokenizer, "<mm:think>", "</mm:think>", false)?,
})
}
}
impl ReasoningParser for MiniMaxM3ReasoningParser {
fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
where
Self: Sized + 'static,
{
Ok(Box::new(Self::new(tokenizer)?))
}
fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
self.inner.initialize(prompt_token_ids);
Ok(())
}
fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
Ok(self.inner.push(delta))
}
fn finish(&mut self) -> Result<ReasoningDelta> {
Ok(self.inner.finish())
}
}
+36 -1
View File
@@ -3,7 +3,8 @@ use std::sync::Arc;
use vllm_tokenizer::Tokenizer;
use super::{
DeepSeekR1ReasoningParser, DelimitedReasoningParser, Qwen3ReasoningParser, ReasoningParser,
DeepSeekR1ReasoningParser, DelimitedReasoningParser, MiniMaxM3ReasoningParser,
Qwen3ReasoningParser, ReasoningParser,
};
struct FakeTokenizer;
@@ -32,6 +33,8 @@ impl Tokenizer for FakeTokenizer {
"<|END_THINKING|>" => Some(4),
"◁think▷" => Some(5),
"◁/think▷" => Some(6),
"<mm:think>" => Some(8),
"</mm:think>" => Some(9),
_ => None,
}
}
@@ -159,3 +162,35 @@ fn deepseek_r1_stops_scanning_at_last_special_token() {
assert_eq!(delta.reasoning.as_deref(), Some("reason"));
assert_eq!(delta.content.as_deref(), Some("answer"));
}
#[test]
fn minimax_m3_handles_explicit_think_delimiters() {
let tokenizer = Arc::new(FakeTokenizer);
let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
let delta = parser.push("<mm:think>reason</mm:think>answer").unwrap();
assert_eq!(delta.reasoning.as_deref(), Some("reason"));
assert_eq!(delta.content.as_deref(), Some("answer"));
}
#[test]
fn minimax_m3_uses_prompt_prefilled_start_marker() {
let tokenizer = Arc::new(FakeTokenizer);
let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
parser.initialize(&[8]).unwrap();
let delta = parser.push("reason</mm:think>answer").unwrap();
assert_eq!(delta.reasoning.as_deref(), Some("reason"));
assert_eq!(delta.content.as_deref(), Some("answer"));
}
#[test]
fn minimax_m3_uses_prompt_prefilled_end_marker() {
let tokenizer = Arc::new(FakeTokenizer);
let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
parser.initialize(&[9]).unwrap();
let delta = parser.push("answer").unwrap();
assert_eq!(delta.reasoning, None);
assert_eq!(delta.content.as_deref(), Some("answer"));
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "vllm-tool-parser-py"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "_rust_tool_parser"
crate-type = ["cdylib", "rlib"]
[features]
default = []
extension-module = ["pyo3/extension-module"]
[dependencies]
pyo3.workspace = true
pythonize = { workspace = true, features = ["serde_json"] }
serde_json.workspace = true
thiserror-ext.workspace = true
vllm-tool-parser.workspace = true
[lints]
workspace = true
+367
View File
@@ -0,0 +1,367 @@
//! Thin PyO3 bindings for `vllm_tool_parser`.
//!
//! This crate exposes the Rust tool parser trait and data shapes to Python
//! while keeping parser state, grammar, and schema-aware argument conversion in
//! Rust. Python callers should use this module as a typed bridge and keep any
//! vLLM protocol adaptation outside the binding.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyModule};
use pythonize::{depythonize, pythonize};
use serde_json::Value;
use thiserror_ext::AsReport as _;
use vllm_tool_parser::{Tool, ToolCallDelta, ToolParser, ToolParserOutput};
macro_rules! tool_parser_factory {
($($parser:ident),+ $(,)?) => {
fn create_tool_parser(
name: &str,
tools: &[Tool],
) -> PyResult<Box<dyn ToolParser>> {
match name {
$(
stringify!($parser) => {
<vllm_tool_parser::$parser as ToolParser>::create(tools)
}
)+
_ => {
return Err(PyValueError::new_err(format!(
"unsupported tool parser `{name}`"
)));
}
}
.map_err(|error| PyValueError::new_err(error.to_report_string()))
}
};
}
// Export a tool parser to Python by registering it here.
tool_parser_factory! {
DeepSeekV4ToolParser,
MinimaxM3ToolParser,
}
#[pyclass(name = "Tool", module = "vllm._rust_tool_parser", skip_from_py_object)]
#[derive(Clone)]
struct PyTool(Tool);
#[pymethods]
impl PyTool {
#[new]
#[pyo3(signature = (name, description, parameters, strict=None))]
fn new(
name: String,
description: Option<String>,
parameters: &Bound<'_, PyAny>,
strict: Option<bool>,
) -> PyResult<Self> {
let parameters = depythonize::<Value>(parameters).map_err(|error| {
PyValueError::new_err(format!(
"failed to convert tool parameters from Python to JSON: {error}"
))
})?;
Ok(Self(Tool {
name,
description,
parameters,
strict,
}))
}
#[getter]
fn name(&self) -> &str {
&self.0.name
}
#[getter]
fn description(&self) -> Option<&str> {
self.0.description.as_deref()
}
#[getter]
fn parameters(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
pythonize(py, &self.0.parameters).map(Bound::unbind).map_err(|error| {
PyValueError::new_err(format!(
"failed to convert tool parameters from JSON to Python: {error}"
))
})
}
#[getter]
fn strict(&self) -> Option<bool> {
self.0.strict
}
}
#[pyclass(
name = "ToolCallDelta",
module = "vllm._rust_tool_parser",
skip_from_py_object
)]
#[derive(Clone)]
struct PyToolCallDelta(ToolCallDelta);
#[pymethods]
impl PyToolCallDelta {
#[new]
#[pyo3(signature = (tool_index, name, arguments))]
fn new(tool_index: usize, name: Option<String>, arguments: String) -> Self {
Self(ToolCallDelta {
tool_index,
name,
arguments,
})
}
#[getter]
fn tool_index(&self) -> usize {
self.0.tool_index
}
#[getter]
fn name(&self) -> Option<&str> {
self.0.name.as_deref()
}
#[getter]
fn arguments(&self) -> &str {
&self.0.arguments
}
}
#[pyclass(
name = "ToolParserOutput",
module = "vllm._rust_tool_parser",
skip_from_py_object
)]
#[derive(Clone)]
struct PyToolParserOutput(ToolParserOutput);
#[pymethods]
impl PyToolParserOutput {
#[new]
#[pyo3(signature = (normal_text="", calls=None))]
fn new(py: Python<'_>, normal_text: &str, calls: Option<Vec<Py<PyToolCallDelta>>>) -> Self {
let calls =
calls.unwrap_or_default().iter().map(|call| call.borrow(py).0.clone()).collect();
Self(ToolParserOutput {
normal_text: normal_text.to_owned(),
calls,
})
}
#[getter]
fn normal_text(&self) -> &str {
&self.0.normal_text
}
#[getter]
fn calls(&self) -> Vec<PyToolCallDelta> {
self.0.calls.iter().cloned().map(PyToolCallDelta).collect()
}
fn append(&mut self, other: PyRef<'_, PyToolParserOutput>) {
self.0.append(other.0.clone());
}
fn coalesce_calls(&self) -> Self {
Self(self.0.clone().coalesce_calls())
}
}
#[pyclass(name = "ToolParser", module = "vllm._rust_tool_parser", unsendable)]
struct PyToolParser(Box<dyn ToolParser>);
impl PyToolParser {
fn parse_into_output(&mut self, chunk: &str, output: &mut PyToolParserOutput) -> PyResult<()> {
self.0
.parse_into(chunk, &mut output.0)
.map_err(|error| PyValueError::new_err(error.to_report_string()))
}
}
#[pymethods]
impl PyToolParser {
#[new]
fn new(py: Python<'_>, parser_name: &str, tools: Vec<Py<PyTool>>) -> PyResult<Self> {
let tools = tools.iter().map(|tool| tool.borrow(py).0.clone()).collect::<Vec<_>>();
create_tool_parser(parser_name, &tools).map(Self)
}
fn parse_into(
&mut self,
chunk: &str,
mut output: PyRefMut<'_, PyToolParserOutput>,
) -> PyResult<()> {
self.parse_into_output(chunk, &mut output)
}
fn finish(&mut self) -> PyResult<PyToolParserOutput> {
self.0
.finish()
.map(PyToolParserOutput)
.map_err(|error| PyValueError::new_err(error.to_report_string()))
}
fn reset(&mut self) -> String {
self.0.reset()
}
fn preserve_special_tokens(&self) -> bool {
self.0.preserve_special_tokens()
}
}
#[pymodule]
fn _rust_tool_parser(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTool>()?;
m.add_class::<PyToolCallDelta>()?;
m.add_class::<PyToolParserOutput>()?;
m.add_class::<PyToolParser>()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn with_python<R>(f: impl for<'py> FnOnce(Python<'py>) -> R) -> R {
Python::initialize();
Python::attach(f)
}
fn tool_schema() -> Value {
json!({
"type": "object",
"properties": {
"user_id": {"type": "integer"},
"shipping": {
"type": "object",
"properties": {
"city": {"type": "string"},
"zip": {"type": "integer"}
}
}
}
})
}
fn build_call() -> String {
r#"<DSMLtool_calls>
<DSMLinvoke name="create_order">
<DSMLparameter name="user_id" string="false">42</DSMLparameter>
<DSMLparameter name="shipping" string="false">{"city":"Singapore","zip":18956}</DSMLparameter>
</DSMLinvoke>
</DSMLtool_calls>"#
.to_owned()
}
fn make_py_tool(py: Python<'_>) -> PyResult<Py<PyTool>> {
let parameters = pythonize(py, &tool_schema()).map_err(|error| {
PyValueError::new_err(format!(
"failed to convert test schema from JSON to Python: {error}"
))
})?;
Py::new(
py,
PyTool::new(
"create_order".to_owned(),
Some("Create an order".to_owned()),
&parameters,
None,
)?,
)
}
#[test]
fn tool_round_trips_typed_fields() {
with_python(|py| {
let tool = make_py_tool(py)?;
let borrowed = tool.borrow(py);
assert_eq!(borrowed.name(), "create_order");
assert_eq!(borrowed.description(), Some("Create an order"));
assert_eq!(borrowed.strict(), None);
let parameters = borrowed.parameters(py)?;
let parameters = depythonize::<Value>(parameters.bind(py))?;
assert_eq!(parameters, tool_schema());
PyResult::Ok(())
})
.unwrap();
}
#[test]
fn output_append_and_coalesce_calls() {
with_python(|py| {
let first = Py::new(
py,
PyToolCallDelta::new(0, Some("create_order".to_owned()), "{\"a\"".to_owned()),
)?;
let second = Py::new(py, PyToolCallDelta::new(0, None, ":1}".to_owned()))?;
let mut output = PyToolParserOutput::new(py, "text", Some(vec![first]));
let other = Py::new(py, PyToolParserOutput::new(py, "", Some(vec![second])))?;
output.append(other.borrow(py));
let coalesced = output.coalesce_calls();
assert_eq!(coalesced.normal_text(), "text");
let calls = coalesced.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].tool_index(), 0);
assert_eq!(calls[0].name(), Some("create_order"));
assert_eq!(calls[0].arguments(), "{\"a\":1}");
PyResult::Ok(())
})
.unwrap();
}
#[test]
fn parser_parse_finish_and_preserve_special_tokens() {
with_python(|py| {
let tool = make_py_tool(py)?;
let mut parser = PyToolParser::new(py, "DeepSeekV4ToolParser", vec![tool])?;
assert!(parser.preserve_special_tokens());
let mut output = PyToolParserOutput::new(py, "", None);
parser.parse_into_output(&build_call(), &mut output)?;
let finish = Py::new(py, parser.finish()?)?;
output.append(finish.borrow(py));
let output = output.coalesce_calls();
assert_eq!(output.normal_text(), "");
let calls = output.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name(), Some("create_order"));
assert_eq!(
serde_json::from_str::<Value>(calls[0].arguments()).unwrap(),
json!({
"user_id": 42,
"shipping": {
"city": "Singapore",
"zip": 18956
}
})
);
assert_eq!(parser.reset(), "");
PyResult::Ok(())
})
.unwrap();
}
#[test]
fn parser_errors_for_unknown_name() {
with_python(|py| {
let tool = make_py_tool(py)?;
let error = match PyToolParser::new(py, "missing", vec![tool]) {
Ok(_) => panic!("missing parser name unexpectedly succeeded"),
Err(error) => error,
};
let message = format!("{error}");
assert!(message.contains("unsupported tool parser `missing`"));
PyResult::Ok(())
})
.unwrap();
}
}
@@ -104,7 +104,7 @@ impl DeepSeekDsmlToolParser {
self.tool_parameters.convert_param_with_schema(
&name,
&param.name,
&param.value,
param.value,
)
};
arguments.insert(param.name, value);
+2
View File
@@ -10,6 +10,7 @@ mod hy_v3;
mod json;
mod kimi_k2;
mod minimax_m2;
mod minimax_m3;
mod parameters;
mod qwen_coder;
#[cfg(any(test, feature = "test-util"))]
@@ -27,6 +28,7 @@ pub use hy_v3::HyV3ToolParser;
pub use json::{HermesToolParser, Llama3JsonToolParser, MistralToolParser, Qwen3XmlToolParser};
pub use kimi_k2::KimiK2ToolParser;
pub use minimax_m2::MinimaxM2ToolParser;
pub use minimax_m3::MinimaxM3ToolParser;
pub use qwen_coder::Qwen3CoderToolParser;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+812
View File
@@ -0,0 +1,812 @@
use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
use winnow::error::{ContextError, ErrMode};
use winnow::prelude::*;
use winnow::stream::Partial;
use winnow::token::{literal, rest, take_until};
use super::parameters::{ParamElement, ParamInput, ToolSchemas};
use super::utils::{parse_buffered_event, safe_text_len};
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
use crate::Tool;
const NAMESPACE: &str = "]<]minimax[>[";
const TOOL_CALL_START: &str = "]<]minimax[>[<tool_call>";
const TOOL_CALL_END: &str = "]<]minimax[>[</tool_call>";
const INVOKE_START: &str = "]<]minimax[>[<invoke";
const INVOKE_END: &str = "]<]minimax[>[</invoke>";
const ELEMENT_START: &str = "]<]minimax[>[<";
const ELEMENT_END_START: &str = "]<]minimax[>[</";
const MIXED_TEXT_FIELD: &str = "$text";
type MinimaxM3Input<'i> = Partial<&'i str>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MinimaxM3Mode {
Text,
ToolBlock,
Done,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MinimaxM3Event {
Text {
len: usize,
},
ToolBlockStart,
Invoke {
name: String,
params: Vec<(String, ParamInput)>,
},
ToolBlockEnd,
IgnoredRest,
}
/// Tool parser for MiniMax M3 namespace-delimited XML-style tool calls.
///
/// Example tool call content with recursive parameters:
///
/// ```text
/// ]<]minimax[>[<tool_call>
/// ]<]minimax[>[<invoke name="create_order">
/// ]<]minimax[>[<user_id>42]<]minimax[>[</user_id>
/// ]<]minimax[>[<shipping>
/// ]<]minimax[>[<city>Singapore]<]minimax[>[</city>
/// ]<]minimax[>[<zip>018956]<]minimax[>[</zip>
/// ]<]minimax[>[</shipping>
/// ]<]minimax[>[<items>
/// ]<]minimax[>[<item>
/// ]<]minimax[>[<sku>book-001]<]minimax[>[</sku>
/// ]<]minimax[>[<qty>2]<]minimax[>[</qty>
/// ]<]minimax[>[</item>
/// ]<]minimax[>[</items>
/// ]<]minimax[>[</invoke>
/// ]<]minimax[>[</tool_call>
/// ```
///
/// With a schema where `shipping` is an object and `items` is an array of
/// objects, recursive parameter conversion produces:
///
/// ```json
/// {
/// "user_id": 42,
/// "shipping": {
/// "city": "Singapore",
/// "zip": 18956
/// },
/// "items": [
/// {
/// "sku": "book-001",
/// "qty": 2
/// }
/// ]
/// }
/// ```
///
/// MiniMax M3 emits the namespace marker `]<]minimax[>[` before each structural
/// tag. Arguments are emitted only after a full `<invoke>` block is parsed.
pub struct MinimaxM3ToolParser {
buffer: String,
mode: MinimaxM3Mode,
emitted_tool_count: usize,
tool_parameters: ToolSchemas,
}
impl MinimaxM3ToolParser {
/// Create a MiniMax M3 tool parser.
pub fn new(tools: &[Tool]) -> Self {
Self {
buffer: String::new(),
mode: MinimaxM3Mode::Text,
emitted_tool_count: 0,
tool_parameters: ToolSchemas::from_tools(tools),
}
}
/// Apply one parsed MiniMax M3 event to parser state and output.
fn apply_event(&mut self, event: MinimaxM3Event, output: &mut ToolParserOutput) -> Result<()> {
match event {
MinimaxM3Event::Text { len: consumed_len } => {
output.normal_text.push_str(&self.buffer[..consumed_len]);
}
MinimaxM3Event::ToolBlockStart => self.mode = MinimaxM3Mode::ToolBlock,
MinimaxM3Event::Invoke { name, params } => {
let arguments = self.tool_parameters.convert_params_with_schema(&name, params);
let arguments = serde_json::to_string(&arguments)
.map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?;
output.calls.push(ToolCallDelta {
tool_index: self.emitted_tool_count,
name: Some(name),
arguments,
});
self.emitted_tool_count += 1;
}
MinimaxM3Event::ToolBlockEnd => self.mode = MinimaxM3Mode::Done,
MinimaxM3Event::IgnoredRest => {}
}
Ok(())
}
}
impl ToolParser for MinimaxM3ToolParser {
fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
where
Self: Sized + 'static,
{
Ok(Box::new(Self::new(tools)))
}
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
self.buffer.push_str(chunk);
while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
parse_next_minimax_m3_event(input, self.mode)
})? {
self.apply_event(event, output)?;
self.buffer.drain(..consumed_len);
}
Ok(())
}
fn finish(&mut self) -> Result<ToolParserOutput> {
let mut output = ToolParserOutput::default();
match self.mode {
MinimaxM3Mode::Text => {
output.normal_text.push_str(&self.buffer);
}
MinimaxM3Mode::ToolBlock => {
return Err(parsing_failed!("incomplete MiniMax M3 tool call"));
}
MinimaxM3Mode::Done => {}
}
let _ = self.reset();
Ok(output)
}
fn reset(&mut self) -> String {
self.mode = MinimaxM3Mode::Text;
self.emitted_tool_count = 0;
std::mem::take(&mut self.buffer)
}
}
/// Parse a MiniMax M3 event for the current parser mode.
fn parse_next_minimax_m3_event(
input: &mut MinimaxM3Input<'_>,
mode: MinimaxM3Mode,
) -> ModalResult<MinimaxM3Event> {
match mode {
MinimaxM3Mode::Text => parse_text_event(input),
MinimaxM3Mode::ToolBlock => parse_tool_block_event(input),
MinimaxM3Mode::Done => ignored_rest_event(input),
}
}
/// Parse a text-mode MiniMax M3 event.
fn parse_text_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
alt((tool_block_start_event, safe_text_event)).parse_next(input)
}
/// Parse a MiniMax M3 tool-block start marker.
fn tool_block_start_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
literal(TOOL_CALL_START).value(MinimaxM3Event::ToolBlockStart).parse_next(input)
}
/// Parse a safe text run before the next MiniMax M3 marker.
fn safe_text_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
safe_text_len(input, TOOL_CALL_START).map(|len| MinimaxM3Event::Text { len })
}
/// Parse one event inside a MiniMax M3 tool block.
fn parse_tool_block_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
alt((tool_block_end_event, invoke_event)).parse_next(input)
}
/// Parse a MiniMax M3 tool-block end marker.
fn tool_block_end_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
(ws0, literal(TOOL_CALL_END))
.value(MinimaxM3Event::ToolBlockEnd)
.parse_next(input)
}
/// Parse a complete MiniMax M3 invoke block.
fn invoke_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
let (name, body) = seq!(
_: ws0,
_: literal(INVOKE_START),
_: (ws1, literal("name=")),
partial_attr_value,
_: literal(">"),
take_until(0.., INVOKE_END),
_: literal(INVOKE_END),
)
.parse_next(input)?;
let params = parse_invoke_params(body)?;
Ok(MinimaxM3Event::Invoke {
name: name.trim().to_string(),
params,
})
}
/// Parse all parameter elements inside a complete MiniMax M3 invoke body.
fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<(String, ParamInput)>> {
let mut input = invoke_body;
let elements: Vec<ParamElement> =
delimited(ws0, repeat(0.., terminated(parameter_element, ws0)), eof)
.parse_next(&mut input)?;
Ok(elements.into_iter().map(|element| (element.name, element.value)).collect())
}
/// Parse a MiniMax M3 parameter element.
fn parameter_element(input: &mut &str) -> ModalResult<ParamElement> {
let name = open_element_tag(input)?.to_string();
let value = element_body(input, &name)?;
close_element_tag(input, &name)?;
Ok(ParamElement { name, value })
}
/// Parse a MiniMax M3 opening element tag.
fn open_element_tag<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
let name = seq!(
_: literal(ELEMENT_START),
take_until(1.., ">"),
_: literal(">"),
)
.parse_next(input)?;
let name = name.0;
if name.starts_with('/') || name.trim().is_empty() {
return malformed();
}
Ok(name)
}
/// Parse a MiniMax M3 closing element tag.
fn close_element_tag(input: &mut &str, name: &str) -> ModalResult<()> {
literal(ELEMENT_END_START).void().parse_next(input)?;
literal(name).void().parse_next(input)?;
literal(">").void().parse_next(input)
}
/// Parse the body of one MiniMax M3 element.
fn element_body(input: &mut &str, closing_name: &str) -> ModalResult<ParamInput> {
let close_tag = format!("{ELEMENT_END_START}{closing_name}>");
let mut text = String::new();
let mut elements = Vec::new();
loop {
text.push_str(text_until_namespace(input)?);
if input.starts_with(&close_tag) {
// Close tag reached, end of element body.
break;
}
if input.starts_with(ELEMENT_START) {
// Child element start reached, parse child element recursively.
elements.push(parameter_element(input)?);
continue;
}
if input.starts_with(NAMESPACE) {
// Unexpected namespace marker.
return malformed();
}
}
if elements.is_empty() {
Ok(ParamInput::Text(text))
} else {
if !text.trim().is_empty() {
push_mixed_text_element(&mut elements, text);
}
Ok(ParamInput::Elements(elements))
}
}
/// Parse text until the next MiniMax M3 namespace marker.
fn text_until_namespace<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
take_until(0.., NAMESPACE).parse_next(input)
}
/// Preserve mixed text content under a reserved object field.
///
/// By default, the field name is `$text`, but if that collides with an existing
/// child element name, prepend `$` until there is no collision.
fn push_mixed_text_element(elements: &mut Vec<ParamElement>, text: String) {
let mut name = MIXED_TEXT_FIELD.to_string();
while elements.iter().any(|element| element.name == name) {
name.insert(0, '$');
}
elements.push(ParamElement {
name,
value: ParamInput::Text(text),
});
}
/// Parse a quoted or unquoted XML attribute value from partial streaming input.
fn partial_attr_value<'i>(input: &mut MinimaxM3Input<'i>) -> ModalResult<&'i str> {
alt((
delimited(literal("\""), take_until(1.., "\""), literal("\"")),
delimited(literal("'"), take_until(1.., "'"), literal("'")),
take_until(1.., ">"),
))
.parse_next(input)
}
/// Parse ignored rest after the MiniMax M3 tool block ends.
fn ignored_rest_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
rest.value(MinimaxM3Event::IgnoredRest).parse_next(input)
}
fn malformed<T>() -> ModalResult<T> {
Err(ErrMode::Cut(ContextError::new()))
}
#[cfg(test)]
mod tests {
use expect_test::expect;
use serde_json::{Value, json};
use thiserror_ext::AsReport;
use super::{
ELEMENT_END_START, ELEMENT_START, INVOKE_END, INVOKE_START, MinimaxM3ToolParser,
TOOL_CALL_END, TOOL_CALL_START, ToolParser,
};
use crate::test_utils::{collect_stream, split_by_chars, test_tools};
use crate::{Tool, ToolParserTestExt as _};
fn element(name: &str, body: &str) -> String {
format!("{ELEMENT_START}{name}>{body}{ELEMENT_END_START}{name}>")
}
fn invoke(function_name: &str, body: &str) -> String {
format!("{INVOKE_START} name=\"{function_name}\">{body}{INVOKE_END}")
}
fn build_tool_block(invokes: &[(&str, String)]) -> String {
let invokes = invokes
.iter()
.map(|(function_name, body)| invoke(function_name, body))
.collect::<Vec<_>>()
.join("\n");
format!("{TOOL_CALL_START}\n{invokes}\n{TOOL_CALL_END}")
}
fn m3_test_tools() -> Vec<Tool> {
let mut tools = test_tools();
tools.push(Tool {
name: "create_order".to_string(),
description: None,
parameters: json!({
"type": "object",
"properties": {
"user_id": { "type": "integer" },
"urgent": { "type": "boolean" },
"note": { "type": "string" },
"shipping": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": "integer" }
}
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer" }
}
}
},
"metadata": {
"type": "object",
"additionalProperties": { "type": "integer" }
},
"duplicate_demo": {
"type": "object",
"properties": {
"tag": { "type": "string" }
}
},
"schema_mismatch_array": {
"type": "array",
"items": { "type": "integer" }
}
}
}),
strict: None,
});
tools
}
fn order_arguments() -> String {
let shipping = element(
"shipping",
&format!(
"{}{}",
element("city", "Singapore"),
element("zip", "018956")
),
);
let first_item = element(
"item",
&format!("{}{}", element("sku", "book-001"), element("qty", "2")),
);
let second_item = element(
"item",
&format!("{}{}", element("sku", "pen-007"), element("qty", "5")),
);
let items = element("items", &format!("{first_item}{second_item}"));
let metadata = element(
"metadata",
&format!("{}{}", element("score", "42"), element("rank", "7")),
);
let duplicate_demo = element(
"duplicate_demo",
&format!("{}{}", element("tag", "a"), element("tag", "b")),
);
let schema_mismatch_array = element(
"schema_mismatch_array",
&format!("{}{}", element("x", "1"), element("x", "2")),
);
[
element("user_id", "42"),
element("urgent", "true"),
element("note", "Please leave at front desk."),
shipping,
items,
metadata,
duplicate_demo,
schema_mismatch_array,
element(
"unknown_struct",
&format!("{}{}", element("a", "1"), element("a", "2")),
),
]
.join("")
}
#[test]
fn minimax_m3_parse_complete_without_tool_call_keeps_text() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser.parse_complete("Hello, world!").unwrap();
assert_eq!(output.normal_text, "Hello, world!");
assert!(output.calls.is_empty());
}
#[test]
fn minimax_m3_parse_complete_extracts_single_tool_call() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_complete(&build_tool_block(&[(
"get_weather",
format!("{}{}", element("city", "Seattle"), element("days", "5")),
)]))
.unwrap();
assert!(output.normal_text.is_empty());
assert_eq!(output.calls.len(), 1);
assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({ "city": "Seattle", "days": 5 })
);
}
#[test]
fn minimax_m3_parse_complete_preserves_prefix_and_ignores_trailing_text() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = format!(
"Let me check. {} This trailing text is ignored.",
build_tool_block(&[("get_weather", element("city", "Seattle"))])
);
let output = parser.parse_complete(&output).unwrap();
assert_eq!(output.normal_text, "Let me check. ");
assert_eq!(output.calls.len(), 1);
}
#[test]
fn minimax_m3_parse_complete_extracts_multiple_invokes() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_complete(&build_tool_block(&[
("get_weather", element("city", "Seattle")),
("get_weather", element("city", "NYC")),
]))
.unwrap();
assert_eq!(output.calls.len(), 2);
assert_eq!(output.calls[0].tool_index, 0);
assert_eq!(output.calls[1].tool_index, 1);
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({ "city": "Seattle" })
);
assert_eq!(
serde_json::from_str::<Value>(&output.calls[1].arguments).unwrap(),
json!({ "city": "NYC" })
);
}
#[test]
fn minimax_m3_parse_complete_converts_schema_types() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_complete(&build_tool_block(&[(
"convert",
[
element("whole", "5.0"),
element("flag", "true"),
element("payload", r#"{"nested":true}"#),
element("items", "[1,2]"),
element("empty", "42"),
]
.join(""),
)]))
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({
"whole": 5.0,
"flag": true,
"payload": { "nested": true },
"items": [1, 2],
"empty": "42",
})
);
}
#[test]
fn minimax_m3_parse_complete_converts_nested_arguments() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_complete(&build_tool_block(&[("create_order", order_arguments())]))
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({
"user_id": 42,
"urgent": true,
"note": "Please leave at front desk.",
"shipping": {
"city": "Singapore",
"zip": 18956
},
"items": [
{
"sku": "book-001",
"qty": 2
},
{
"sku": "pen-007",
"qty": 5
}
],
"metadata": {
"score": 42,
"rank": 7
},
"duplicate_demo": {
"tag": ["a", "b"]
},
"schema_mismatch_array": [1, 2],
"unknown_struct": {
"a": ["1", "2"]
}
})
);
}
#[test]
fn minimax_m3_parse_complete_handles_multiline_leaf_parameters() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_complete(&build_tool_block(&[(
"calculate_area",
[
element("shape", "\nrectangle\n"),
element("dimensions", r#"{"width":10,"height":20}"#),
element("precision", "2"),
]
.join(""),
)]))
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({
"shape": "\nrectangle\n",
"dimensions": { "width": 10, "height": 20 },
"precision": 2,
})
);
}
#[test]
fn minimax_m3_streaming_extracts_single_tool_call() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(
&mut parser,
&[
TOOL_CALL_START,
&invoke("get_weather", &element("city", "Seattle")),
TOOL_CALL_END,
],
);
assert!(output.normal_text.is_empty());
assert_eq!(output.calls.len(), 1);
assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({ "city": "Seattle" })
);
}
#[test]
fn minimax_m3_streaming_preserves_prefix_text() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(
&mut parser,
&[
"Let me check. ",
TOOL_CALL_START,
&invoke("get_weather", &element("city", "Seattle")),
TOOL_CALL_END,
],
);
assert_eq!(output.normal_text, "Let me check. ");
assert_eq!(output.calls.len(), 1);
}
#[test]
fn minimax_m3_streaming_without_tool_call_emits_text_incrementally() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(&mut parser, &["Hello, ", "world!"]);
assert_eq!(output.normal_text, "Hello, world!");
assert!(output.calls.is_empty());
}
#[test]
fn minimax_m3_streaming_handles_marker_split_across_chunks() {
let text = build_tool_block(&[("get_weather", element("city", "Seattle"))]);
let chunks = split_by_chars(&text, 3);
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(&mut parser, &chunks);
assert_eq!(output.calls.len(), 1);
assert!(output.normal_text.is_empty());
}
#[test]
fn minimax_m3_streaming_extracts_multiple_invokes_in_order() {
let text = build_tool_block(&[
("get_weather", element("city", "Seattle")),
("get_weather", element("city", "NYC")),
]);
let chunks = split_by_chars(&text, 7);
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(&mut parser, &chunks);
assert_eq!(output.calls.len(), 2);
assert_eq!(output.calls[0].tool_index, 0);
assert_eq!(output.calls[1].tool_index, 1);
}
#[test]
fn minimax_m3_streaming_does_not_emit_incomplete_tool_call() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = parser
.parse_chunk(&format!(
"{TOOL_CALL_START}{INVOKE_START} name=\"get_weather\">"
))
.unwrap();
assert!(output.normal_text.is_empty());
assert!(output.calls.is_empty());
}
#[test]
fn minimax_m3_streaming_ignores_text_after_tool_block() {
let text = format!(
"{} ignored",
build_tool_block(&[("get_weather", element("city", "Seattle"))])
);
let chunks = split_by_chars(&text, 5);
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let output = collect_stream(&mut parser, &chunks);
assert!(output.normal_text.is_empty());
assert_eq!(output.calls.len(), 1);
}
#[test]
fn minimax_m3_finish_fails_incomplete_tool_call() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
parser
.parse_chunk(&format!(
"{TOOL_CALL_START}{INVOKE_START} name=\"get_weather\">"
))
.unwrap();
assert!(parser.finish().is_err());
}
#[test]
fn minimax_m3_finish_fails_after_bare_tool_block_start() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
parser.parse_chunk(TOOL_CALL_START).unwrap();
assert!(parser.finish().is_err());
}
#[test]
fn minimax_m3_malformed_tool_call_fails_fast() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let error = parser
.parse_chunk(&format!(
"{TOOL_CALL_START}{ELEMENT_START}bad>{TOOL_CALL_END}"
))
.unwrap_err();
expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string());
}
#[test]
fn minimax_m3_mixed_content_is_preserved_as_text_field() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let body = element(
"payload",
&format!("text before {} text after", element("child", "value")),
);
let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({
"payload": {
"child": "value",
"$text": "text before text after"
}
})
);
}
#[test]
fn minimax_m3_mixed_text_field_avoids_child_name_collision() {
let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
let body = element(
"payload",
&format!(
"text{}{}",
element("$text", "child text"),
element("child", "value")
),
);
let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
json!({
"payload": {
"$text": "child text",
"$$text": "text",
"child": "value"
}
})
);
}
}
+452 -90
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use serde_json::{Number, Value};
use serde_json::{Map, Number, Value};
use crate::Tool;
@@ -21,6 +21,29 @@ pub(super) struct ToolSchema {
params: BTreeMap<String, JsonParamType>,
}
/// Parameter input for schema-aware conversion.
///
/// It can be either a raw text string, or a structured input with named child elements.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ParamInput {
Text(String),
#[allow(dead_code)]
Elements(Vec<ParamElement>),
}
impl From<String> for ParamInput {
fn from(value: String) -> Self {
Self::Text(value)
}
}
/// One named structured parameter child.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ParamElement {
pub name: String,
pub value: ParamInput,
}
/// Normalized JSON parameter type used for raw string coercion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum JsonParamType {
@@ -28,8 +51,13 @@ pub(super) enum JsonParamType {
Integer,
Number,
Boolean,
Object,
Array,
Object {
properties: BTreeMap<String, JsonParamType>,
additional_properties: Option<Box<JsonParamType>>,
},
Array {
items: Option<Box<JsonParamType>>,
},
Null,
OneOf(Vec<JsonParamType>),
}
@@ -45,33 +73,39 @@ impl ToolSchemas {
Self { tools }
}
/// Convert raw string parameter values for one named tool.
/// Convert parameter values for one named tool.
///
/// Unknown tool names use an empty schema, so all parameters fall back to
/// strings.
pub(super) fn convert_params_with_schema(
/// strings or object-like JSON for structured inputs.
pub(super) fn convert_params_with_schema<P>(
&self,
function_name: &str,
params: Vec<(String, String)>,
) -> serde_json::Map<String, Value> {
params: Vec<(String, P)>,
) -> Map<String, Value>
where
P: Into<ParamInput>,
{
let tool_schema = self.tools.get(function_name).unwrap_or(ToolSchema::empty());
let mut converted = serde_json::Map::with_capacity(params.len());
let mut converted = Map::with_capacity(params.len());
for (name, value) in params {
let value = tool_schema.convert(&name, &value);
let value = tool_schema.convert(&name, value.into());
converted.insert(name, value);
}
converted
}
/// Convert one raw string parameter value for one named tool.
pub(super) fn convert_param_with_schema(
/// Convert one parameter value for one named tool.
pub(super) fn convert_param_with_schema<P>(
&self,
function_name: &str,
name: &str,
value: &str,
) -> Value {
value: P,
) -> Value
where
P: Into<ParamInput>,
{
let tool_schema = self.tools.get(function_name).unwrap_or(ToolSchema::empty());
tool_schema.convert(name, value)
tool_schema.convert(name, value.into())
}
}
@@ -101,21 +135,13 @@ impl ToolSchema {
Self { params }
}
/// Convert one raw parameter value using its normalized schema type.
/// Convert one parameter value using its normalized schema type.
///
/// If the parameter name is unknown, or we don't have a schema for it, or
/// the value fails to convert, this falls back to returning the raw
/// string as a JSON string value.
fn convert(&self, name: &str, value: &str) -> Value {
if value.eq_ignore_ascii_case("null") {
return Value::Null;
}
let Some(param_type) = self.params.get(name) else {
return Value::String(value.to_string());
};
convert_value(param_type, value).unwrap_or_else(|| Value::String(value.to_string()))
/// string as a JSON string value, or object-like JSON for structured input.
fn convert(&self, name: &str, input: ParamInput) -> Value {
convert_with_optional_schema(self.params.get(name), &input)
}
}
@@ -125,7 +151,7 @@ impl JsonParamType {
let schema = schema.as_object()?;
if let Some(type_value) = schema.get("type") {
return Self::from_type_value(type_value);
return Self::from_type_value(type_value, schema);
}
if let Some(composite) = schema.get("anyOf").or_else(|| schema.get("oneOf")) {
@@ -134,32 +160,34 @@ impl JsonParamType {
.map(|schemas| schemas.iter().filter_map(Self::from_schema).collect::<Vec<_>>())
.filter(|types| !types.is_empty())
.map(Self::one_of)
.unwrap_or(Self::Object);
.unwrap_or_else(|| Self::object_from_schema(Some(schema)));
return Some(param_type);
}
// Typically, these types are already handled by checking the "type" field, but
// we can also infer them from their characteristic fields if "type" is missing.
if schema.contains_key("enum") {
return Some(Self::String);
}
if schema.contains_key("items") {
return Some(Self::Array);
return Some(Self::array_from_schema(Some(schema)));
}
if schema.contains_key("properties") {
return Some(Self::Object);
if schema.contains_key("properties") || schema.contains_key("additionalProperties") {
return Some(Self::object_from_schema(Some(schema)));
}
None
}
/// Normalize a JSON schema `type` value.
fn from_type_value(type_value: &Value) -> Option<Self> {
fn from_type_value(type_value: &Value, schema: &Map<String, Value>) -> Option<Self> {
match type_value {
Value::String(kind) => Self::from_type_name(kind),
Value::String(kind) => Self::from_type_name(kind, Some(schema)),
Value::Array(kinds) => {
let types = kinds
.iter()
.filter_map(Value::as_str)
.filter_map(Self::from_type_name)
.filter_map(|kind| Self::from_type_name(kind, Some(schema)))
.collect::<Vec<_>>();
if types.is_empty() {
None
@@ -172,15 +200,15 @@ impl JsonParamType {
}
/// Normalize one JSON schema type name.
fn from_type_name(kind: &str) -> Option<Self> {
fn from_type_name(kind: &str, schema: Option<&Map<String, Value>>) -> Option<Self> {
let kind = kind.trim().to_ascii_lowercase();
match kind.as_str() {
"string" | "str" | "text" | "varchar" | "char" | "enum" => Some(Self::String),
"integer" | "int" => Some(Self::Integer),
"number" | "float" | "double" => Some(Self::Number),
"boolean" | "bool" | "binary" => Some(Self::Boolean),
"object" | "dict" | "map" => Some(Self::Object),
"array" | "arr" | "list" | "sequence" => Some(Self::Array),
"object" | "dict" | "map" => Some(Self::object_from_schema(schema)),
"array" | "arr" | "list" | "sequence" => Some(Self::array_from_schema(schema)),
"null" => Some(Self::Null),
_ if kind.starts_with("int")
|| kind.starts_with("uint")
@@ -191,12 +219,52 @@ impl JsonParamType {
Some(Self::Integer)
}
_ if kind.starts_with("num") || kind.starts_with("float") => Some(Self::Number),
_ if kind.starts_with("dict") => Some(Self::Object),
_ if kind.starts_with("list") => Some(Self::Array),
_ if kind.starts_with("dict") => Some(Self::object_from_schema(schema)),
_ if kind.starts_with("list") => Some(Self::array_from_schema(schema)),
_ => None,
}
}
/// Normalize object schema fields.
fn object_from_schema(schema: Option<&Map<String, Value>>) -> Self {
let properties = schema
.and_then(|schema| schema.get("properties"))
.and_then(Value::as_object)
.map(|properties| {
properties
.iter()
.filter_map(|(name, schema)| {
Self::from_schema(schema).map(|param_type| (name.clone(), param_type))
})
.collect()
})
.unwrap_or_default();
let additional_properties =
schema.and_then(|schema| schema.get("additionalProperties")).and_then(|schema| {
if schema.is_object() {
Self::from_schema(schema).map(Box::new)
} else {
None
}
});
Self::Object {
properties,
additional_properties,
}
}
/// Normalize array schema fields.
fn array_from_schema(schema: Option<&Map<String, Value>>) -> Self {
let items = schema
.and_then(|schema| schema.get("items"))
.and_then(Self::from_schema)
.map(Box::new);
Self::Array { items }
}
/// Collapse a candidate type list into one normalized type.
fn one_of(mut types: Vec<Self>) -> Self {
if types.len() == 1 {
@@ -207,23 +275,126 @@ impl JsonParamType {
}
}
/// Convert one raw string value to a normalized JSON type.
fn convert_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
match param_type {
JsonParamType::String => Some(Value::String(value.to_string())),
JsonParamType::Integer => value.parse::<i64>().ok().map(Number::from).map(Value::Number),
JsonParamType::Number => convert_number(value),
JsonParamType::Boolean => convert_boolean(value),
JsonParamType::Object | JsonParamType::Array => serde_json::from_str(value).ok(),
JsonParamType::Null => value.eq_ignore_ascii_case("null").then_some(Value::Null),
JsonParamType::OneOf(types) => {
types.iter().find_map(|param_type| convert_value(param_type, value))
/// Convert one parameter input to a normalized JSON value.
fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value {
// For literal `null`, always convert to JSON null value.
if let ParamInput::Text(value) = input
&& value.eq_ignore_ascii_case("null")
{
return Value::Null;
}
// If we have a schema, try to convert the value using it.
if let Some(param_type) = param_type
&& let Some(value) = try_convert_value(param_type, input)
{
return value;
}
// We don't have a schema, or conversion failed, use fallback logic.
match input {
ParamInput::Text(value) => Value::String(value.clone()),
ParamInput::Elements(elements) => {
// Convert structured input to object without a schema.
Value::Object(convert_elements_to_object(elements, &BTreeMap::new(), None))
}
}
}
/// Convert one parameter input to a normalized JSON type.
fn try_convert_value(param_type: &JsonParamType, input: &ParamInput) -> Option<Value> {
match input {
ParamInput::Text(value) => try_convert_text_value(param_type, value),
ParamInput::Elements(elements) => try_convert_elements_value(param_type, elements),
}
}
/// Convert one raw string value to a normalized JSON type.
fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
match param_type {
JsonParamType::String => Some(Value::String(value.to_string())),
JsonParamType::Integer => value.parse::<i64>().ok().map(Number::from).map(Value::Number),
JsonParamType::Number => try_convert_number(value),
JsonParamType::Boolean => try_convert_boolean(value),
JsonParamType::Object { .. } if value.is_empty() => Some(Value::Object(Map::new())),
JsonParamType::Array { .. } if value.is_empty() => Some(Value::Array(Vec::new())),
JsonParamType::Object { .. } | JsonParamType::Array { .. } => {
// For composite types with string input, simply interpret the string as JSON.
serde_json::from_str(value).ok()
}
JsonParamType::Null => value.eq_ignore_ascii_case("null").then_some(Value::Null),
JsonParamType::OneOf(types) => {
types.iter().find_map(|param_type| try_convert_text_value(param_type, value))
}
}
}
/// Convert one structured parameter input to a normalized JSON type.
fn try_convert_elements_value(
param_type: &JsonParamType,
elements: &[ParamElement],
) -> Option<Value> {
match param_type {
JsonParamType::Object {
properties,
additional_properties,
} => Some(Value::Object(convert_elements_to_object(
elements,
properties,
additional_properties.as_deref(),
))),
JsonParamType::Array { items } => Some(Value::Array(
// Collect all child elements into an array, regardless of their names.
elements
.iter()
.map(|element| convert_with_optional_schema(items.as_deref(), &element.value))
.collect(),
)),
JsonParamType::OneOf(types) => types
.iter()
.find_map(|param_type| try_convert_elements_value(param_type, elements)),
// Primitive types can't be converted from structured input.
JsonParamType::String
| JsonParamType::Integer
| JsonParamType::Number
| JsonParamType::Boolean
| JsonParamType::Null => None,
}
}
/// Convert structured elements to an object, using field schemas when present.
fn convert_elements_to_object(
elements: &[ParamElement],
properties: &BTreeMap<String, JsonParamType>,
additional_properties: Option<&JsonParamType>,
) -> Map<String, Value> {
let mut object = Map::with_capacity(elements.len());
for element in elements {
let param_type = properties.get(&element.name).or(additional_properties);
let value = convert_with_optional_schema(param_type, &element.value);
insert_object_value(&mut object, element.name.clone(), value);
}
object
}
/// Insert an object field while preserving duplicate keys as arrays.
fn insert_object_value(object: &mut Map<String, Value>, key: String, value: Value) {
if let Some(existing) = object.get_mut(&key) {
match existing {
// Collect values under the same key into an array.
Value::Array(values) => values.push(value),
existing => {
let first = std::mem::replace(existing, Value::Null);
*existing = Value::Array(vec![first, value]);
}
}
} else {
object.insert(key, value);
}
}
/// Convert one raw string value to a JSON number.
fn convert_number(value: &str) -> Option<Value> {
fn try_convert_number(value: &str) -> Option<Value> {
serde_json::from_str::<Number>(value)
.or_else(|_| value.parse::<i64>().map(Number::from))
.or_else(|_| value.parse::<f64>().ok().and_then(Number::from_f64).ok_or(()))
@@ -232,7 +403,7 @@ fn convert_number(value: &str) -> Option<Value> {
}
/// Convert one raw string value to a boolean.
fn convert_boolean(value: &str) -> Option<Value> {
fn try_convert_boolean(value: &str) -> Option<Value> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(Value::Bool(true)),
"false" | "0" => Some(Value::Bool(false)),
@@ -242,9 +413,9 @@ fn convert_boolean(value: &str) -> Option<Value> {
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{Value, json};
use super::{ToolSchema, ToolSchemas};
use super::{ParamElement, ParamInput, ToolSchema, ToolSchemas};
use crate::Tool;
fn test_tool(name: &str, parameters: serde_json::Value) -> Tool {
@@ -260,8 +431,8 @@ mod tests {
fn invalid_schema_converts_everything_as_string() {
let params = ToolSchema::from_schema(&json!({ "type": "object" }));
assert_eq!(params.convert("count", "42"), json!("42"));
assert_eq!(params.convert("count", "null"), json!(null));
assert_eq!(params.convert("count", text("42")), json!("42"));
assert_eq!(params.convert("count", text("null")), json!(null));
}
#[test]
@@ -275,9 +446,9 @@ mod tests {
}
}));
assert_eq!(params.convert("unknown_schema", "42"), json!("42"));
assert_eq!(params.convert("unknown_type", "42"), json!("42"));
assert_eq!(params.convert("known", "42"), json!(42));
assert_eq!(params.convert("unknown_schema", text("42")), json!("42"));
assert_eq!(params.convert("unknown_type", text("42")), json!("42"));
assert_eq!(params.convert("known", text("42")), json!(42));
}
#[test]
@@ -298,16 +469,25 @@ mod tests {
}
}));
assert_eq!(params.convert("text", "42"), json!("42"));
assert_eq!(params.convert("count", "42"), json!(42));
assert_eq!(params.convert("size", "5.0"), json!(5.0));
assert_eq!(params.convert("ratio", "2.5"), json!(2.5));
assert_eq!(params.convert("enabled", "1"), json!(true));
assert_eq!(params.convert("payload", r#"{"k":1}"#), json!({ "k": 1 }));
assert_eq!(params.convert("mapping", r#"{"k":1}"#), json!({ "k": 1 }));
assert_eq!(params.convert("items", "[1,2]"), json!([1, 2]));
assert_eq!(params.convert("names", r#"["a","b"]"#), json!(["a", "b"]));
assert_eq!(params.convert("nothing", "null"), json!(null));
assert_eq!(params.convert("text", text("42")), json!("42"));
assert_eq!(params.convert("count", text("42")), json!(42));
assert_eq!(params.convert("size", text("5.0")), json!(5.0));
assert_eq!(params.convert("ratio", text("2.5")), json!(2.5));
assert_eq!(params.convert("enabled", text("1")), json!(true));
assert_eq!(
params.convert("payload", text(r#"{"k":1}"#)),
json!({ "k": 1 })
);
assert_eq!(
params.convert("mapping", text(r#"{"k":1}"#)),
json!({ "k": 1 })
);
assert_eq!(params.convert("items", text("[1,2]")), json!([1, 2]));
assert_eq!(
params.convert("names", text(r#"["a","b"]"#)),
json!(["a", "b"])
);
assert_eq!(params.convert("nothing", text("null")), json!(null));
}
#[test]
@@ -321,19 +501,40 @@ mod tests {
assert_eq!(converted_number_text(&params, "5"), "5");
assert_eq!(converted_number_text(&params, "5.0"), "5.0");
assert_eq!(converted_number_text(&params, "5.00"), "5.00");
assert_eq!(converted_number_text(&params, "1e0"), "1e+0");
assert_eq!(converted_number_text(&params, "5."), "5.0");
assert_eq!(converted_number_text(&params, "+1"), "1");
assert_eq!(converted_number_text(&params, "+1.0"), "1.0");
assert_eq!(
converted_number_text(&params, "9223372036854775807.5"),
"9223372036854775807.5"
);
// TODO: we cannot preserve the original number precision by enabling `serde_json`'s
// `arbitrary_precision` feature, otherwise the test
// `serialized_json_numbers_do_not_leak_serde_private_representation` will fail.
// See issue: https://github.com/mitsuhiko/minijinja/issues/641
// assert_eq!(converted_number_text(&params, "5.00"), "5.00");
// assert_eq!(converted_number_text(&params, "1e0"), "1e+0");
// assert_eq!(
// converted_number_text(&params, "9223372036854775807.5"),
// "9223372036854775807.5"
// );
}
fn converted_number_text(params: &ToolSchema, value: &str) -> String {
serde_json::to_string(&params.convert("value", value)).unwrap()
serde_json::to_string(&params.convert("value", text(value))).unwrap()
}
fn text(value: &str) -> ParamInput {
ParamInput::Text(value.to_string())
}
fn elem(name: &str, value: ParamInput) -> ParamElement {
ParamElement {
name: name.to_string(),
value,
}
}
fn elements(elements: Vec<ParamElement>) -> ParamInput {
ParamInput::Elements(elements)
}
#[test]
@@ -350,12 +551,12 @@ mod tests {
}
}));
assert_eq!(params.convert("s", "x"), json!("x"));
assert_eq!(params.convert("i", "7"), json!(7));
assert_eq!(params.convert("n", "7.5"), json!(7.5));
assert_eq!(params.convert("b", "true"), json!(true));
assert_eq!(params.convert("a", "[1]"), json!([1]));
assert_eq!(params.convert("o", r#"{"x":1}"#), json!({ "x": 1 }));
assert_eq!(params.convert("s", text("x")), json!("x"));
assert_eq!(params.convert("i", text("7")), json!(7));
assert_eq!(params.convert("n", text("7.5")), json!(7.5));
assert_eq!(params.convert("b", text("true")), json!(true));
assert_eq!(params.convert("a", text("[1]")), json!([1]));
assert_eq!(params.convert("o", text(r#"{"x":1}"#)), json!({ "x": 1 }));
}
#[test]
@@ -373,8 +574,8 @@ mod tests {
}
}));
assert_eq!(integer_first.convert("value", "42"), json!(42));
assert_eq!(string_first.convert("value", "42"), json!("42"));
assert_eq!(integer_first.convert("value", text("42")), json!(42));
assert_eq!(string_first.convert("value", text("42")), json!("42"));
}
#[test]
@@ -396,9 +597,9 @@ mod tests {
}
}));
assert_eq!(params.convert("choice", "42"), json!(42));
assert_eq!(params.convert("choice", text("42")), json!(42));
assert_eq!(
params.convert("fallback_object", r#"{"x":1}"#),
params.convert("fallback_object", text(r#"{"x":1}"#)),
json!({ "x": 1 })
);
}
@@ -414,9 +615,12 @@ mod tests {
}
}));
assert_eq!(params.convert("choice", "a"), json!("a"));
assert_eq!(params.convert("items", "[1,2]"), json!([1, 2]));
assert_eq!(params.convert("payload", r#"{"x":1}"#), json!({ "x": 1 }));
assert_eq!(params.convert("choice", text("a")), json!("a"));
assert_eq!(params.convert("items", text("[1,2]")), json!([1, 2]));
assert_eq!(
params.convert("payload", text(r#"{"x":1}"#)),
json!({ "x": 1 })
);
}
#[test]
@@ -518,4 +722,162 @@ mod tests {
assert_eq!(converted.get("topn"), Some(&json!("5")));
assert_eq!(converted.get("nullish"), Some(&json!(null)));
}
#[test]
fn converts_structured_inputs_with_recursive_schema() {
let schemas = ToolSchemas::from_tools(&[test_tool(
"create_order",
json!({
"type": "object",
"properties": {
"user_id": { "type": "integer" },
"urgent": { "type": "boolean" },
"note": { "type": "string" },
"nil": { "type": "string" },
"shipping": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": "integer" }
}
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer" }
}
}
},
"metadata": {
"type": "object",
"additionalProperties": { "type": "integer" }
},
"duplicate_demo": {
"type": "object",
"properties": {
"tag": { "type": "string" }
}
},
"schema_mismatch_array": {
"type": "array",
"items": { "type": "integer" }
},
"closed_object": {
"type": "object",
"additionalProperties": false
},
"open_object": {
"type": "object",
"additionalProperties": true
},
"payload_text": { "type": "object" },
"items_text": { "type": "array" }
}
}),
)]);
let converted = schemas.convert_params_with_schema(
"create_order",
vec![
("user_id".to_string(), text("42")),
("urgent".to_string(), text("true")),
("note".to_string(), text("Please leave at front desk.")),
("nil".to_string(), text("NULL")),
(
"shipping".to_string(),
elements(vec![
elem("city", text("Singapore")),
elem("zip", text("018956")),
]),
),
(
"items".to_string(),
elements(vec![
elem(
"item1",
elements(vec![elem("sku", text("book-001")), elem("qty", text("2"))]),
),
elem(
"item2",
elements(vec![elem("sku", text("pen-007")), elem("qty", text("5"))]),
),
]),
),
(
"metadata".to_string(),
elements(vec![elem("score", text("42")), elem("rank", text("7"))]),
),
(
"duplicate_demo".to_string(),
elements(vec![elem("tag", text("a")), elem("tag", text("b"))]),
),
(
"closed_object".to_string(),
elements(vec![elem("unknown", text("x"))]),
),
(
"open_object".to_string(),
elements(vec![elem("unknown", text("y"))]),
),
("payload_text".to_string(), text(r#"{"x":1}"#)),
("items_text".to_string(), text("[1,2]")),
(
"unknown_struct".to_string(),
elements(vec![
elem("a", text("1")),
elem("a", text("2")),
elem("nil", text("null")),
]),
),
],
);
assert_eq!(
Value::Object(converted),
json!({
"user_id": 42,
"urgent": true,
"note": "Please leave at front desk.",
"nil": null,
"shipping": {
"city": "Singapore",
"zip": 18956
},
"items": [
{
"sku": "book-001",
"qty": 2
},
{
"sku": "pen-007",
"qty": 5
}
],
"metadata": {
"score": 42,
"rank": 7
},
"duplicate_demo": {
"tag": ["a", "b"]
},
"closed_object": {
"unknown": "x"
},
"open_object": {
"unknown": "y"
},
"payload_text": {
"x": 1
},
"items_text": [1, 2],
"unknown_struct": {
"a": ["1", "2"],
"nil": null
}
})
);
}
}
+91 -21
View File
@@ -36,6 +36,8 @@ ROOT_DIR = Path(__file__).parent
logger = logging.getLogger(__name__)
PRECOMPILED_RUST_FRONTEND_PATH = ROOT_DIR / "vllm" / "vllm-rs"
PRECOMPILED_RUST_EXTENSION_GLOB = "_rust_*.so"
PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX = re.compile(r"vllm/_rust_[^/]*\.so$")
# cannot import envs directly because it depends on vllm,
# which is not installed yet
@@ -54,6 +56,59 @@ def should_require_rust_frontend() -> bool:
return value.lower() not in ("", "0", "false", "no")
# Rust frontend binary, built via setuptools-rust and installed into the
# package directory alongside the Python modules.
# TODO: we may use `RustBin` to directly install it into `bin` directory, but this
# requires extra work on using precompiled binaries.
rust_extensions = [
RustExtension(
target="vllm.vllm-rs",
path="rust/src/cmd/Cargo.toml",
args=["--bin", "vllm-rs"],
features=["native-tls-vendored"],
binding=Binding.Exec,
optional=not should_require_rust_frontend(),
),
RustExtension(
target="vllm._rust_tool_parser",
path="rust/src/tool-parser/python/Cargo.toml",
features=["extension-module"],
binding=Binding.PyO3,
optional=not should_require_rust_frontend(),
),
]
def get_precompiled_rust_extension_paths() -> list[Path]:
return sorted((ROOT_DIR / "vllm").glob(PRECOMPILED_RUST_EXTENSION_GLOB))
def get_expected_rust_extension_module_names() -> list[str]:
"""Return configured PyO3 Rust extension module names under ``vllm``."""
module_names = []
for rust_extension in rust_extensions:
if rust_extension.binding != Binding.PyO3:
continue
for target_name in rust_extension.target.values():
if target_name.startswith("vllm._rust_"):
module_names.append(target_name.rsplit(".", 1)[-1])
return module_names
def get_missing_precompiled_rust_extension_modules() -> list[str]:
missing = []
for module_name in get_expected_rust_extension_module_names():
if not list((ROOT_DIR / "vllm").glob(f"{module_name}*.so")):
missing.append(module_name)
return missing
def has_precompiled_rust_extensions() -> bool:
return not get_missing_precompiled_rust_extension_modules()
if sys.platform.startswith("darwin") and VLLM_TARGET_DEVICE != "cpu":
logger.warning("VLLM_TARGET_DEVICE automatically set to `cpu` due to macOS")
VLLM_TARGET_DEVICE = "cpu"
@@ -421,19 +476,33 @@ class precompiled_build_ext(build_ext):
class precompiled_build_rust(build_rust):
"""Skips local Rust builds when the precompiled wheel already ships vllm-rs."""
"""Skips local Rust builds when all precompiled Rust artifacts are present."""
def run(self) -> None:
if PRECOMPILED_RUST_FRONTEND_PATH.exists():
if (
PRECOMPILED_RUST_FRONTEND_PATH.exists()
and has_precompiled_rust_extensions()
):
logger.info(
"Skipping local Rust build: using precompiled %s",
"Skipping local Rust build: using precompiled %s and %s",
PRECOMPILED_RUST_FRONTEND_PATH,
get_precompiled_rust_extension_paths(),
)
return
missing = []
if not PRECOMPILED_RUST_FRONTEND_PATH.exists():
missing.append(str(PRECOMPILED_RUST_FRONTEND_PATH))
missing_rust_extensions = get_missing_precompiled_rust_extension_modules()
if missing_rust_extensions:
missing.extend(
str(ROOT_DIR / "vllm" / f"{module_name}*.so")
for module_name in missing_rust_extensions
)
logger.warning(
"Precompiled wheel did not provide %s; falling back to local Rust build.",
PRECOMPILED_RUST_FRONTEND_PATH,
"Precompiled wheel did not provide all Rust artifacts (%s); "
"falling back to local Rust build.",
", ".join(missing),
)
super().run()
@@ -756,6 +825,14 @@ class precompiled_wheel_utils:
if member.filename in exact_members:
file_members.append(member)
continue
if (
extract_rust_frontend
and PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX.match(
member.filename
)
):
file_members.append(member)
continue
if not extract_extensions:
continue
@@ -1127,6 +1204,10 @@ if PRECOMPILED_RUST_FRONTEND_PATH.exists():
vllm_files = package_data.setdefault("vllm", [])
if "vllm-rs" not in vllm_files:
vllm_files.append("vllm-rs")
vllm_files = package_data.setdefault("vllm", [])
for rust_extension_path in get_precompiled_rust_extension_paths():
if rust_extension_path.name not in vllm_files:
vllm_files.append(rust_extension_path.name)
if _no_device():
ext_modules = []
@@ -1139,24 +1220,13 @@ else:
if USE_PRECOMPILED_EXTENSIONS
else cmake_build_ext,
}
if USE_PRECOMPILED_RUST_FRONTEND or PRECOMPILED_RUST_FRONTEND_PATH.exists():
if (
USE_PRECOMPILED_RUST_FRONTEND
or PRECOMPILED_RUST_FRONTEND_PATH.exists()
or has_precompiled_rust_extensions()
):
cmdclass["build_rust"] = precompiled_build_rust
# Rust frontend binary, built via setuptools-rust and installed into the
# package directory alongside the Python modules.
# TODO: we may use `RustBin` to directly install it into `bin` directory, but this
# requires extra work on using precompiled binaries.
rust_extensions = [
RustExtension(
target="vllm.vllm-rs",
path="rust/src/cmd/Cargo.toml",
args=["--bin", "vllm-rs"],
features=["native-tls-vendored"],
binding=Binding.Exec,
optional=not should_require_rust_frontend(),
),
]
setup(
# static metadata should rather go in pyproject.toml
version=get_vllm_version(),
+810
View File
@@ -0,0 +1,810 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Correctness tests for MiniMax M3 sparse prefill attention kernels."""
import pytest
import torch
from vllm import _custom_ops as ops
from vllm.models.minimax_m3.common.ops.index_topk import (
minimax_m3_index_topk,
minimax_m3_index_topk_decode,
)
from vllm.models.minimax_m3.common.ops.sparse_attn import (
minimax_m3_sparse_attn,
minimax_m3_sparse_attn_decode,
)
from vllm.models.minimax_m3.common.sparse_attention import (
MiniMaxM3IndexerBackend,
MiniMaxM3SparseBackend,
)
from vllm.platforms import current_platform
from vllm.utils.import_utils import has_cutedsl
from vllm.v1.attention.backends.utils import set_kv_cache_layout
from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec
from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
from vllm.v1.worker.utils import AttentionGroup
if not current_platform.is_cuda():
pytest.skip("MiniMax M3 attention kernels require CUDA.", allow_module_level=True)
@pytest.fixture
def kv_layout(request):
"""Set the global KV cache layout for one test and restore it after."""
set_kv_cache_layout(request.param)
try:
yield request.param
finally:
set_kv_cache_layout(None)
def _stride_order_for(backend: type[MiniMaxM3SparseBackend], ndim: int) -> tuple:
"""Mirror the allocator's stride-order resolution (identity fallback)."""
try:
stride_order = backend.get_kv_cache_stride_order()
assert len(stride_order) == ndim
except (AttributeError, NotImplementedError):
stride_order = tuple(range(ndim))
return stride_order
def _allocate_main_kv_via_contract(
num_pages: int, device: torch.device | str = "cuda"
) -> torch.Tensor:
"""Build the main KV cache exactly as the production allocator does for the
currently active layout: allocate the physical (permuted) tensor, then
expose the inverse-permuted logical-NHD view the backend sees."""
logical_shape = MiniMaxM3SparseBackend.get_kv_cache_shape(
num_pages, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
)
stride_order = _stride_order_for(MiniMaxM3SparseBackend, len(logical_shape))
physical_shape = tuple(logical_shape[i] for i in stride_order)
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
raw = torch.randn(physical_shape, device=device, dtype=DTYPE)
return raw.permute(*inv_order)
NUM_Q_HEADS = 32
NUM_KV_HEADS = 2
HEAD_DIM = 128
BLOCK_SIZE = 128
DTYPE = torch.bfloat16
SM_SCALE = HEAD_DIM**-0.5
TOPK = 16
# Index top-k kernels.
def _reference_index_topk(
idx_q: torch.Tensor,
index_kv_cache: torch.Tensor,
block_table: torch.Tensor,
q_lens: torch.Tensor,
seq_lens: torch.Tensor,
prefix_lens: torch.Tensor,
topk: int,
init_blocks: int,
local_blocks: int,
sm_scale: float,
) -> torch.Tensor:
total_q, num_idx_heads, _ = idx_q.shape
out = torch.full(
(num_idx_heads, total_q, topk), -1, device=idx_q.device, dtype=torch.int32
)
q_start = 0
for req_id, (q_len, seq_len, prefix_len) in enumerate(
zip(q_lens.tolist(), seq_lens.tolist(), prefix_lens.tolist())
):
q_end = q_start + q_len
q = idx_q[q_start:q_end]
num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
pages = block_table[req_id, :num_blocks]
k = index_kv_cache[pages].reshape(num_blocks * BLOCK_SIZE, -1)
score = torch.einsum("qhd,kd->hqk", q.float(), k.float()) * sm_scale
q_pos = prefix_len + torch.arange(q_len, device=idx_q.device)
k_pos = torch.arange(k.shape[0], device=idx_q.device)
score.masked_fill_(k_pos[None, :] > q_pos[:, None], -float("inf"))
score = score.reshape(num_idx_heads, q_len, num_blocks, BLOCK_SIZE)
score_tensor = score.max(dim=3).values
valid_blocks = (q_pos + BLOCK_SIZE) // BLOCK_SIZE
for local_q, num_valid_blocks in enumerate(valid_blocks.tolist()):
end = min(init_blocks, num_valid_blocks)
score_tensor[:, local_q, :end] = 1e30
start = max(0, num_valid_blocks - local_blocks)
score_tensor[:, local_q, start:num_valid_blocks] = 1e29
k = min(topk, num_valid_blocks)
topk_idx = score_tensor[:, local_q].topk(k, dim=1).indices
out[:, q_start + local_q, :k] = topk_idx
q_start = q_end
return out
def test_prefill_index_topk_correctness():
topk = 6
init_blocks = 0
local_blocks = 1
num_idx_heads = 2
head_dim = 16
q_lens = torch.tensor((4, 3), device="cuda", dtype=torch.int32)
prefix_lens = torch.tensor((0, 1024), device="cuda", dtype=torch.int32)
seq_lens = prefix_lens + q_lens
batch = q_lens.numel()
max_seq_len = seq_lens.max().item()
max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
num_pages = batch * max_blocks
cu_seqlens = torch.zeros(batch + 1, device="cuda", dtype=torch.int32)
cu_seqlens[1:] = q_lens.cumsum(0)
block_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape(
batch, max_blocks
)
idx_q = torch.ones(q_lens.sum().item(), num_idx_heads, head_dim, device="cuda")
index_kv_cache = torch.empty(num_pages, BLOCK_SIZE, head_dim, device="cuda")
for req_id in range(batch):
for block_id in range(max_blocks):
page = block_table[req_id, block_id]
index_kv_cache[page].fill_(block_id + 1)
actual = minimax_m3_index_topk(
idx_q,
index_kv_cache,
block_table,
cu_seqlens,
seq_lens,
prefix_lens,
max_query_len=q_lens.max().item(),
max_seq_len=max_seq_len,
topk=topk,
init_blocks=init_blocks,
local_blocks=local_blocks,
num_kv_heads=num_idx_heads,
sm_scale=head_dim**-0.5,
)
expected = _reference_index_topk(
idx_q,
index_kv_cache,
block_table,
q_lens,
seq_lens,
prefix_lens,
topk,
init_blocks,
local_blocks,
head_dim**-0.5,
)
assert torch.equal(actual, expected)
def test_decode_index_topk_correctness():
topk = 6
init_blocks = 0
local_blocks = 1
num_idx_heads = 2
head_dim = 16
seq_lens = torch.tensor((7, 129, 1025), device="cuda", dtype=torch.int32)
q_lens = torch.ones_like(seq_lens)
prefix_lens = seq_lens - 1
batch = seq_lens.numel()
max_seq_len = seq_lens.max().item()
max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
num_pages = batch * max_blocks
block_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape(
batch, max_blocks
)
idx_q = torch.ones(batch, num_idx_heads, head_dim, device="cuda")
index_kv_cache = torch.empty(num_pages, BLOCK_SIZE, head_dim, device="cuda")
for req_id in range(batch):
for block_id in range(max_blocks):
page = block_table[req_id, block_id]
index_kv_cache[page].fill_(block_id + 1)
actual = minimax_m3_index_topk_decode(
idx_q,
index_kv_cache,
block_table,
seq_lens,
max_seq_len=max_seq_len,
topk=topk,
init_blocks=init_blocks,
local_blocks=local_blocks,
num_kv_heads=num_idx_heads,
sm_scale=head_dim**-0.5,
)
expected = _reference_index_topk(
idx_q,
index_kv_cache,
block_table,
q_lens,
seq_lens,
prefix_lens,
topk,
init_blocks,
local_blocks,
head_dim**-0.5,
)
assert torch.equal(actual, expected)
# Sparse attention kernels.
def _reference_sparse_attn(
q: torch.Tensor,
kv_cache: torch.Tensor,
topk_idx: torch.Tensor,
block_table: torch.Tensor,
q_lens: torch.Tensor,
seq_lens: torch.Tensor,
prefix_lens: torch.Tensor,
) -> torch.Tensor:
out = torch.empty_like(q, dtype=torch.float32)
gqa_group_size = NUM_Q_HEADS // NUM_KV_HEADS
q_start = 0
for req_id, (q_len, seq_len, prefix_len) in enumerate(
zip(q_lens.tolist(), seq_lens.tolist(), prefix_lens.tolist())
):
q_end = q_start + q_len
q_req = q[q_start:q_end]
positions = torch.arange(seq_len, device="cuda")
pages = block_table[req_id, positions // BLOCK_SIZE]
rows = positions % BLOCK_SIZE
k_req = kv_cache[pages, 0, rows]
v_req = kv_cache[pages, 1, rows].float()
q_pos = prefix_len + torch.arange(q_len, device="cuda")
key_blocks = positions // BLOCK_SIZE
causal_mask = positions.unsqueeze(0) <= q_pos.unsqueeze(1)
for kv_head in range(NUM_KV_HEADS):
selected = topk_idx[kv_head, q_start:q_end]
selected_mask = (key_blocks[None, :, None] == selected[:, None, :]).any(-1)
mask = causal_mask & selected_mask
head_start = kv_head * gqa_group_size
head_end = head_start + gqa_group_size
q_heads = q_req[:, head_start:head_end].transpose(0, 1)
k_head = k_req[:, kv_head].T.expand(gqa_group_size, -1, -1)
scores = torch.bmm(q_heads, k_head, out_dtype=torch.float32)
scores = scores.transpose(0, 1) * SM_SCALE
probs = torch.softmax(
scores.masked_fill(~mask[:, None, :], -float("inf")), -1
)
out[q_start:q_end, head_start:head_end] = torch.einsum(
"qhk,kd->qhd", probs, v_req[:, kv_head]
)
q_start += q_len
return out.to(q.dtype)
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
@pytest.mark.parametrize("backend", ["triton", "cutedsl"])
@pytest.mark.parametrize(
("q_lens", "kv_lens"),
[
((129, 257), (129, 257)),
((65, 129, 257), (129, 257, 385)),
],
)
def test_prefill_sparse_attention_correctness(
kv_layout: str,
backend: str,
q_lens: tuple[int, ...],
kv_lens: tuple[int, ...],
):
if backend == "cutedsl":
if not current_platform.is_device_capability_family(100):
pytest.skip("MiniMax M3 CuteDSL prefill requires CUDA SM10x.")
if not has_cutedsl():
pytest.skip("cutedsl (cutlass) is not installed")
assert len(q_lens) == len(kv_lens)
assert all(kv_len >= q_len for q_len, kv_len in zip(q_lens, kv_lens))
# Build paged-KV metadata, including a non-identity page order.
batch = len(q_lens)
pages_per_req = [(kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE for kv_len in kv_lens]
max_blocks = max(pages_per_req)
num_pages = sum(pages_per_req)
physical_pages = torch.randperm(num_pages, device="cuda", dtype=torch.int32)
block_table = torch.zeros(batch, max_blocks, device="cuda", dtype=torch.int32)
base_page = 0
for req_id, num_req_pages in enumerate(pages_per_req):
block_table[req_id, :num_req_pages] = physical_pages[
base_page : base_page + num_req_pages
]
base_page += num_req_pages
q_lens_t = torch.tensor(q_lens, device="cuda", dtype=torch.int32)
seq_lens = torch.tensor(kv_lens, device="cuda", dtype=torch.int32)
prefix_lens = seq_lens - q_lens_t
cu_seqlens = torch.zeros(batch + 1, device="cuda", dtype=torch.int32)
cu_seqlens[1:] = q_lens_t.cumsum(0)
cu_seqlens_k = torch.zeros(batch + 1, device="cuda", dtype=torch.int32)
cu_seqlens_k[1:] = seq_lens.cumsum(0)
total_q = sum(q_lens)
max_seqlen_q = max(q_lens)
max_seqlen_k = max(kv_lens)
q_shape = (total_q, NUM_Q_HEADS, HEAD_DIM)
q = torch.randn(q_shape, device="cuda", dtype=DTYPE)
# Allocate the main KV cache through the backend layout contract so the
# physical storage matches the active layout (contiguous NHD or strided
# HND), while the kernels and reference see the logical-NHD view.
kv_cache = _allocate_main_kv_via_contract(num_pages)
# Build sparse block indices with the same contract as the real M3 indexer:
# one forced local block, then score-selected older causal blocks.
topk_shape = (NUM_KV_HEADS, total_q, TOPK)
topk_idx = torch.full(topk_shape, -1, device="cuda", dtype=torch.int32)
q_start = 0
for q_len, prefix_len in zip(q_lens_t.tolist(), prefix_lens.tolist()):
for local_q in range(q_len):
current_block = (prefix_len + local_q) // BLOCK_SIZE
older_blocks = torch.randperm(
current_block, device="cuda", dtype=torch.int32
)
selected = torch.cat(
[
torch.tensor([current_block], device="cuda", dtype=torch.int32),
older_blocks[: TOPK - 1],
]
)
topk_idx[:, q_start + local_q, : selected.numel()] = selected
q_start += q_len
actual = torch.empty_like(q)
if backend == "triton":
minimax_m3_sparse_attn(
q,
kv_cache,
topk_idx,
block_table,
cu_seqlens,
seq_lens,
prefix_lens,
max_seqlen_q,
NUM_KV_HEADS,
SM_SCALE,
actual,
)
else:
from vllm.models.minimax_m3.nvidia.ops.prefill_gqa_sparse import (
minimax_m3_sparse_attn_cutedsl,
)
minimax_m3_sparse_attn_cutedsl(
q,
kv_cache,
topk_idx,
block_table,
cu_seqlens,
cu_seqlens_k,
seq_lens,
max_seqlen_q,
max_seqlen_k,
NUM_KV_HEADS,
SM_SCALE,
actual,
total_kv_blocks=num_pages,
)
expected = _reference_sparse_attn(
q,
kv_cache,
topk_idx,
block_table,
q_lens_t,
seq_lens,
prefix_lens,
)
torch.accelerator.synchronize()
error = (actual.float() - expected.float()).abs()
assert error.mean().item() < 2.5e-4
assert error.max().item() < 1.7e-2
def test_main_backend_layout_contract():
"""The main sparse backend exposes the logical-NHD shape and the
flash_attn-style stride order for each layout."""
nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
assert logical == (nb, 2, bs, h, d)
# The old HND-ordered shape is no longer the logical shape.
assert logical != (nb, 2, h, bs, d)
try:
set_kv_cache_layout("HND")
assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 3, 2, 4)
set_kv_cache_layout("NHD")
assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3, 4)
finally:
set_kv_cache_layout(None)
for layout in ("NHD", "HND"):
try:
set_kv_cache_layout(layout)
order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
# Valid permutation: no duplicates, covers every axis.
assert set(order) == set(range(len(order)))
# M3 has no cross-layer KV blocks.
with pytest.raises(NotImplementedError):
MiniMaxM3SparseBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
def test_main_backend_unknown_layout_raises(monkeypatch):
"""An unrecognized layout (injected past env-var validation) is rejected."""
import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
monkeypatch.setattr(sparse_attn_mod, "get_kv_cache_layout", lambda: "BOGUS")
with pytest.raises(ValueError, match="Unknown cache layout format"):
MiniMaxM3SparseBackend.get_kv_cache_stride_order()
def test_indexer_backend_stride_order_is_identity():
"""The 3-dim indexer cache must not inherit the parent's 5-element stride
order; it overrides to the 3-element identity so the allocator keeps the
contiguous layout."""
assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2)
# Cross-layer (per-layer-stacked) KV blocks are not supported.
with pytest.raises(NotImplementedError):
MiniMaxM3IndexerBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
# The stride order matches the 3-dim indexer shape rank.
indexer_shape = MiniMaxM3IndexerBackend.get_kv_cache_shape(
5, BLOCK_SIZE, 1, HEAD_DIM
)
assert len(indexer_shape) == 3
assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2)
def test_hnd_allocation_is_byte_identical_to_transpose():
"""Under HND the backend-visible logical view is byte-identical to the
pre-change allocate-HND-then-transpose(2, 3) workaround."""
nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
try:
set_kv_cache_layout("HND")
stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
physical_shape = tuple(logical[i] for i in stride_order)
# The physical (permuted) shape equals the old hardcoded HND shape.
assert physical_shape == (nb, 2, h, bs, d)
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
view = raw.permute(*inv_order)
expected = raw.view((nb, 2, h, bs, d)).transpose(2, 3)
assert view.shape == expected.shape
assert view.stride() == expected.stride()
assert view.storage_offset() == expected.storage_offset()
# Negative: the identity (wrong) stride order under HND does not reproduce
# the transpose view.
wrong_view = raw.view(logical)
assert wrong_view.stride() != expected.stride()
def test_main_cache_is_block_first_and_unpadded():
"""The allocator's contiguous-view branch (not the padded-strided branch)
is used for the main GQA cache: its spec is unpadded and the physical
layout keeps num_blocks as the first dimension under both layouts."""
from vllm.v1.kv_cache_interface import FullAttentionSpec
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
# Unpadded -> allocator uses kv_tensor.view(...) rather than as_strided().
assert spec.page_size_padded is None
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(
4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
)
for layout in ("NHD", "HND"):
try:
set_kv_cache_layout(layout)
order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
inv_order = [order.index(i) for i in range(len(order))]
# Physical first dim is num_blocks (block-first); required by the
# padded-strided branch's block-first assumption if it were ever taken.
assert inv_order[0] == 0
assert logical[order[0]] == logical[0]
def _build_decode_inputs(seq_lens_list: tuple[int, ...]):
"""Shared decode setup: one query token per request at position seq_len-1,
a non-identity block table, and topk indices selecting the current block
plus older causal blocks."""
batch = len(seq_lens_list)
pages_per_req = [(s + BLOCK_SIZE - 1) // BLOCK_SIZE for s in seq_lens_list]
max_blocks = max(pages_per_req)
num_pages = sum(pages_per_req)
physical_pages = torch.randperm(num_pages, device="cuda", dtype=torch.int32)
block_table = torch.zeros(batch, max_blocks, device="cuda", dtype=torch.int32)
base_page = 0
for req_id, num_req_pages in enumerate(pages_per_req):
block_table[req_id, :num_req_pages] = physical_pages[
base_page : base_page + num_req_pages
]
base_page += num_req_pages
seq_lens = torch.tensor(seq_lens_list, device="cuda", dtype=torch.int32)
q = torch.randn(batch, NUM_Q_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE)
topk_idx = torch.full(
(NUM_KV_HEADS, batch, TOPK), -1, device="cuda", dtype=torch.int32
)
for req_id, seq_len in enumerate(seq_lens_list):
current_block = (seq_len - 1) // BLOCK_SIZE
older_blocks = torch.randperm(current_block, device="cuda", dtype=torch.int32)
selected = torch.cat(
[
torch.tensor([current_block], device="cuda", dtype=torch.int32),
older_blocks[: TOPK - 1],
]
)
topk_idx[:, req_id, : selected.numel()] = selected
return q, block_table, seq_lens, topk_idx, num_pages
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
@pytest.mark.parametrize(
"seq_lens_list",
[(130, 257), (129, 200, 384)],
)
def test_decode_sparse_attention_correctness(
kv_layout: str,
seq_lens_list: tuple[int, ...],
):
"""Decode (split-K) parity under both layouts: this is the only coverage of
the decode-site cache feed, and the strided HND case fails if the kernel
ignores the cache strides."""
torch.manual_seed(0)
q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(seq_lens_list)
kv_cache = _allocate_main_kv_via_contract(num_pages)
actual = torch.empty_like(q)
minimax_m3_sparse_attn_decode(
q,
kv_cache,
topk_idx,
block_table,
seq_lens,
NUM_KV_HEADS,
SM_SCALE,
actual,
)
# Reuse the prefill reference: each request is a single query token at
# position seq_len-1 (q_len == 1, prefix_len == seq_len-1).
q_lens_t = torch.ones(len(seq_lens_list), device="cuda", dtype=torch.int32)
prefix_lens = seq_lens - q_lens_t
expected = _reference_sparse_attn(
q, kv_cache, topk_idx, block_table, q_lens_t, seq_lens, prefix_lens
)
torch.accelerator.synchronize()
error = (actual.float() - expected.float()).abs()
assert error.mean().item() < 2.5e-4
assert error.max().item() < 1.7e-2
def test_decode_wrong_layout_breaks_parity():
"""Negative (AC-3/AC-5): consuming the physical HND buffer as if it were
already contiguous-NHD (i.e. skipping the allocator's inverse permute)
reorders the K/V content, so the decode output no longer matches the
reference computed on the correct logical view. The mislabeled tensor keeps
the same shape as the correct view, so the kernel stays in bounds."""
torch.manual_seed(0)
seq_lens_list = (130, 257)
q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(seq_lens_list)
# Physical HND storage [blocks, 2, heads, block, dim].
phys = torch.randn(
(num_pages, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM), device="cuda", dtype=DTYPE
)
# Correct logical-NHD view (strided) vs. the same bytes mislabeled as a
# contiguous-NHD cache — same shape, different content mapping.
correct = phys.permute(0, 1, 3, 2, 4)
wrong = phys.reshape(num_pages, 2, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM)
q_lens_t = torch.ones(len(seq_lens_list), device="cuda", dtype=torch.int32)
prefix_lens = seq_lens - q_lens_t
expected = _reference_sparse_attn(
q, correct, topk_idx, block_table, q_lens_t, seq_lens, prefix_lens
)
actual = torch.empty_like(q)
minimax_m3_sparse_attn_decode(
q, wrong, topk_idx, block_table, seq_lens, NUM_KV_HEADS, SM_SCALE, actual
)
torch.accelerator.synchronize()
assert (actual.float() - expected.float()).abs().max().item() > 1.7e-2
def _make_attn_group(backend, spec):
return AttentionGroup(
backend=backend,
layer_names=["main"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
def test_main_cache_byte_identical_through_production_allocator():
"""AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main
`FullAttentionSpec` under HND and assert the backend-visible view has the
same shape, stride, and storage offset as the pre-change
allocate-HND-then-transpose path; the indexer `MLAAttentionSpec` allocates
through the same path to its 3-dim shape."""
nb = 4
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
group = _make_attn_group(MiniMaxM3SparseBackend, spec)
try:
set_kv_cache_layout("HND")
kv_caches = _reshape_kv_cache([group], {"main": raw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
view = kv_caches["main"]
oracle = raw.view(DTYPE).view((nb, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM))
oracle = oracle.transpose(2, 3)
assert tuple(view.shape) == tuple(oracle.shape)
assert view.stride() == oracle.stride()
assert view.storage_offset() == oracle.storage_offset()
# Indexer cache allocates through the same path under both layouts.
ispec = MLAAttentionSpec(
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
)
for layout in ("NHD", "HND"):
iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
igroup = AttentionGroup(
backend=MiniMaxM3IndexerBackend,
layer_names=["idx"],
kv_cache_spec=ispec,
kv_cache_group_id=0,
)
try:
set_kv_cache_layout(layout)
iout = _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
assert tuple(iout["idx"].shape) == (nb, BLOCK_SIZE, HEAD_DIM)
def test_indexer_inherited_stride_order_trips_allocator_assert():
"""AC-4 negative: without the indexer override, the inherited 5-element
stride order trips the allocator's `len(stride_order) == len(shape)` assert
for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the
allocator's `(AttributeError, NotImplementedError)` fallback."""
class _BrokenIndexerBackend(MiniMaxM3IndexerBackend):
# Simulate inheriting the parent's 5-element stride order.
get_kv_cache_stride_order = staticmethod(
MiniMaxM3SparseBackend.get_kv_cache_stride_order
)
nb = 4
ispec = MLAAttentionSpec(
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
)
iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
igroup = AttentionGroup(
backend=_BrokenIndexerBackend,
layer_names=["idx"],
kv_cache_spec=ispec,
kv_cache_group_id=0,
)
try:
set_kv_cache_layout("HND")
with pytest.raises(AssertionError):
_reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
def test_padded_main_cache_is_flagged():
"""AC-2.1 negative: the M3 main cache relies on the allocator's
contiguous-view branch (`page_size_padded is None`). A spec that sets
`page_size_padded` is explicitly flagged rather than silently wrong-strided."""
def _require_unpadded_block_first(spec, stride_order):
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
assert spec.page_size_padded is None, (
"main GQA cache must be unpadded to use the contiguous-view "
"allocator branch"
)
assert inv_order[0] == 0, "main GQA cache must remain block-first"
try:
set_kv_cache_layout("HND")
stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
good = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
_require_unpadded_block_first(good, stride_order) # passes
padded = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
page_size_padded=good.page_size_bytes + 128,
)
with pytest.raises(AssertionError):
_require_unpadded_block_first(padded, stride_order)
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
def test_reshape_and_cache_flash_write_persists(kv_layout: str):
"""AC-5 write path: the `reshape_and_cache_flash` write site now consumes
`self.kv_cache.unbind(1)` directly. Writing through those views must persist
into the bound storage (read back through an independent logical view) under
both layouts — a `.contiguous()` copy of the unbind slice would leave the
bound storage unchanged."""
torch.manual_seed(0)
num_pages = 4
kv_cache = _allocate_main_kv_via_contract(num_pages)
with torch.no_grad():
kv_cache.zero_()
# Exactly the production write-site code under test.
key_cache, value_cache = kv_cache.unbind(1)
num_tokens = 12
slot_mapping = torch.randperm(num_pages * BLOCK_SIZE, device="cuda")[
:num_tokens
].to(torch.int64)
key = torch.randn(num_tokens, NUM_KV_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE)
value = torch.randn(num_tokens, NUM_KV_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE)
scale = torch.ones((), device="cuda")
ops.reshape_and_cache_flash(
key, value, key_cache, value_cache, slot_mapping, "auto", scale, scale
)
torch.accelerator.synchronize()
# Read back through the independent logical view; proves the writes landed
# in the engine-bound storage, not a detached copy.
for t in range(num_tokens):
slot = int(slot_mapping[t].item())
blk, intra = divmod(slot, BLOCK_SIZE)
torch.testing.assert_close(kv_cache[blk, 0, intra], key[t])
torch.testing.assert_close(kv_cache[blk, 1, intra], value[t])
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the manual AllReduce + GemmaRMSNorm fusion used by MiniMax M3.
``fused_allreduce_gemma_rms_norm`` must match the unfused model path, i.e.
``GemmaRMSNorm(all_reduce(partial), residual)``, both on the flashinfer fast
path (TP>1 with flashinfer + NVSwitch) and on the eager fallback (TP==1, or when
flashinfer is unavailable / the GPU has no NVSwitch).
"""
import pytest
import torch
from torch.multiprocessing import spawn
from tests.utils import ensure_current_vllm_config, init_test_distributed_environment
from vllm.distributed import cleanup_dist_env_and_memory
from vllm.distributed.communication_op import tensor_model_parallel_all_reduce
from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
fused_allreduce_gemma_rms_norm,
)
from vllm.model_executor.layers.layernorm import GemmaRMSNorm
from vllm.platforms import current_platform
from vllm.utils.network_utils import get_open_port
from vllm.utils.torch_utils import set_random_seed
@ensure_current_vllm_config()
def _worker_fused_ar_norm(
local_rank,
world_size,
port,
num_tokens,
hidden_size,
dtype,
seed,
eps,
):
"""Per-rank worker: compare the fused helper vs all_reduce + GemmaRMSNorm."""
device = torch.device(f"cuda:{local_rank}")
torch.accelerator.set_device_index(device)
init_test_distributed_environment(
world_size, 1, local_rank, port, local_rank=local_rank
)
# Norm weights are identical across ranks (replicated GemmaRMSNorm).
set_random_seed(seed)
norm = GemmaRMSNorm(hidden_size, eps=eps).cuda().to(dtype)
with torch.no_grad():
norm.weight.normal_(mean=0.0, std=0.1)
# Residual is shared across ranks; the partial o_proj output differs per rank
# (each rank holds a partial sum that all_reduce combines).
torch.manual_seed(seed + 7)
residual = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
torch.manual_seed(seed + 1000 + local_rank)
partial = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
# Reference: the unfused model path.
reduced = tensor_model_parallel_all_reduce(partial.clone())
ref_out, ref_res = norm(reduced, residual.clone())
# Fused helper (flashinfer fast path when available, else fallback).
out, res = fused_allreduce_gemma_rms_norm(partial.clone(), residual.clone(), norm)
torch.accelerator.synchronize()
torch.testing.assert_close(out, ref_out, atol=2e-2, rtol=2e-2)
torch.testing.assert_close(res, ref_res, atol=2e-2, rtol=2e-2)
cleanup_dist_env_and_memory()
@pytest.mark.skipif(
not current_platform.is_cuda(),
reason="CUDA required",
)
# world_size=1 exercises the TP==1 identity branch on a single GPU; >1 exercises
# the all_reduce + GemmaRMSNorm equivalence (flashinfer kernel or fallback).
@pytest.mark.parametrize("world_size", [1, 2, 4])
@pytest.mark.parametrize("num_tokens", [1, 128, 333])
@pytest.mark.parametrize("hidden_size", [2048, 4096])
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("eps", [1e-6])
@pytest.mark.parametrize("seed", [42])
def test_fused_allreduce_gemma_rms_norm(
world_size,
num_tokens,
hidden_size,
dtype,
eps,
seed,
):
num_gpus = current_platform.device_count()
if num_gpus < world_size:
pytest.skip(f"Need >= {world_size} GPUs, have {num_gpus}")
port = str(get_open_port())
spawn(
_worker_fused_ar_norm,
args=(
world_size,
port,
num_tokens,
hidden_size,
dtype,
seed,
eps,
),
nprocs=world_size,
join=True,
)
@@ -0,0 +1,243 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit test for the horizontally-fused MiniMax-M3 attention pre-processing
kernel:
fused_minimax_m3_qknorm_rope_kv_insert
- q / k / index_q / index_k: Gemma RMSNorm + partial NeoX RoPE (in place)
- sparse (insert) mode: scatter k/v into the paged bf16 KV cache and the
index key into the index cache by slot_mapping.
Reference: PyTorch GemmaRMSNorm + RotaryEmbedding.forward_static (neox style).
"""
import pytest
import torch
import vllm._custom_ops as ops
HEAD_DIM = 128
ROTARY_DIM = 64
def _op_available() -> bool:
return hasattr(torch.ops._C, "fused_minimax_m3_qknorm_rope_kv_insert")
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available() or not _op_available(),
reason="CUDA not available or fused MiniMax-M3 op not built in",
)
def make_cos_sin_cache(max_pos, rotary_dim, base, dtype, device):
inv_freq = 1.0 / (
base
** (
torch.arange(0, rotary_dim, 2, dtype=torch.float32, device=device)
/ rotary_dim
)
)
t = torch.arange(max_pos, dtype=torch.float32, device=device)
freqs = torch.einsum("i,j->ij", t, inv_freq) # [max_pos, rotary_dim/2]
cache = torch.cat((freqs.cos(), freqs.sin()), dim=-1) # [max_pos, rotary_dim]
return cache.to(dtype)
def gemma_rmsnorm(x, weight, eps):
"""x: [..., 128] fp32; weight: [128]. Returns fp32 (one round happens in
the caller, matching the kernel's single final cast)."""
xf = x.float()
var = xf.pow(2).mean(dim=-1, keepdim=True)
out = xf * torch.rsqrt(var + eps)
return out * (1.0 + weight.float())
def apply_rope_neox_partial(x, positions, cos_sin_cache, rotary_dim):
"""NeoX-style RoPE on the leading rotary_dim dims; rest pass through.
x: [num_tokens, num_heads, head_dim] fp32
cos_sin_cache: [max_pos, rotary_dim] (cos||sin), read as float (matches the
kernel, which loads the bf16 cache and converts to fp32).
"""
half = rotary_dim // 2
cs = cos_sin_cache[positions].float() # [num_tokens, rotary_dim]
cos = cs[..., :half].unsqueeze(1) # [nt, 1, half]
sin = cs[..., half:].unsqueeze(1)
rot = x[..., :rotary_dim]
x1 = rot[..., :half]
x2 = rot[..., half:]
o1 = x1 * cos - x2 * sin
o2 = x2 * cos + x1 * sin
out = x.clone()
out[..., :half] = o1
out[..., half:rotary_dim] = o2
return out
def norm_rope_ref(x, weight, positions, cos_sin_cache, eps, dtype):
"""[nt, nheads, 128] -> Gemma norm + neox partial rope, rounded once."""
normed = gemma_rmsnorm(x.float(), weight, eps)
roped = apply_rope_neox_partial(normed, positions, cos_sin_cache, ROTARY_DIM)
return roped.to(dtype)
# ── Test 1: dense mode (norm+rope only, no index, no insert) ─────────────────
@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513])
@pytest.mark.parametrize("num_heads,num_kv_heads", [(8, 2), (16, 4), (64, 4)])
def test_dense_norm_rope(num_tokens, num_heads, num_kv_heads):
torch.manual_seed(0)
device, dtype, eps = "cuda", torch.bfloat16, 1e-6
base, max_pos = 5_000_000.0, 4096
q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device)
positions = torch.randint(
0, max_pos, (num_tokens,), dtype=torch.int64, device=device
)
qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM
qkv = torch.randn(num_tokens, qsz + 2 * kvsz, dtype=dtype, device=device)
qkv_orig = qkv.clone()
ops.fused_minimax_m3_qknorm_rope_kv_insert(
qkv, q_w, k_w, cos_sin, positions, num_heads, num_kv_heads, ROTARY_DIM, eps
)
q_out, k_out, v_out = qkv.split([qsz, kvsz, kvsz], dim=-1)
q_in, k_in, v_in = qkv_orig.split([qsz, kvsz, kvsz], dim=-1)
q_ref = norm_rope_ref(
q_in.view(num_tokens, num_heads, HEAD_DIM), q_w, positions, cos_sin, eps, dtype
).view(num_tokens, qsz)
k_ref = norm_rope_ref(
k_in.view(num_tokens, num_kv_heads, HEAD_DIM),
k_w,
positions,
cos_sin,
eps,
dtype,
).view(num_tokens, kvsz)
torch.testing.assert_close(q_out, q_ref, rtol=1e-2, atol=1e-2)
torch.testing.assert_close(k_out, k_ref, rtol=1e-2, atol=1e-2)
# V is untouched.
torch.testing.assert_close(v_out, v_in, rtol=0, atol=0)
# ── Test 2: sparse mode (full: index branch + cache inserts) ─────────────────
@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513])
@pytest.mark.parametrize("block_size", [16, 64])
def test_sparse_full(num_tokens, block_size):
torch.manual_seed(1)
device, dtype, eps = "cuda", torch.bfloat16, 1e-6
base, max_pos = 5_000_000.0, 4096
num_heads, num_kv_heads, num_idx_heads = 16, 4, 4
q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
iq_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
ik_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device)
positions = torch.randint(
0, max_pos, (num_tokens,), dtype=torch.int64, device=device
)
qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM
iqsz, iksz = num_idx_heads * HEAD_DIM, HEAD_DIM
# Single fused tensor packing [q | k | v | index_q | index_k].
qkv = torch.randn(
num_tokens, qsz + 2 * kvsz + iqsz + iksz, dtype=dtype, device=device
)
qkv_orig = qkv.clone()
splits = [qsz, kvsz, kvsz, iqsz, iksz]
num_blocks = (num_tokens + block_size - 1) // block_size + 1
kv_cache = torch.zeros(
num_blocks, 2, block_size, num_kv_heads, HEAD_DIM, dtype=dtype, device=device
)
index_cache = torch.zeros(
num_blocks, block_size, HEAD_DIM, dtype=dtype, device=device
)
slot_mapping = torch.randperm(
num_blocks * block_size, dtype=torch.int64, device=device
)[:num_tokens]
# Contiguous gather targets: the kernel writes the normed/roped q and
# index_q here (de-interleaved from the packed qkv); k/v/index_k stay in
# place inside qkv and are scatter-inserted into the caches.
q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device)
index_q = torch.empty(num_tokens, iqsz, dtype=dtype, device=device)
ops.fused_minimax_m3_qknorm_rope_kv_insert(
qkv,
q_w,
k_w,
cos_sin,
positions,
num_heads,
num_kv_heads,
ROTARY_DIM,
eps,
iq_w,
ik_w,
num_idx_heads,
slot_mapping,
kv_cache,
index_cache,
block_size,
q_out,
index_q,
)
# ── norm+rope parity. q/index_q land in their gather buffers; k/index_k are
# rewritten in place inside qkv. ──
_, k_out, _, _, index_k = qkv.split(splits, dim=-1)
q_in, k_in, v_in, iq_orig, ik_orig = qkv_orig.split(splits, dim=-1)
q_ref = norm_rope_ref(
q_in.view(num_tokens, num_heads, HEAD_DIM), q_w, positions, cos_sin, eps, dtype
).view(num_tokens, qsz)
k_ref = norm_rope_ref(
k_in.view(num_tokens, num_kv_heads, HEAD_DIM),
k_w,
positions,
cos_sin,
eps,
dtype,
).view(num_tokens, kvsz)
iq_ref = norm_rope_ref(
iq_orig.view(num_tokens, num_idx_heads, HEAD_DIM),
iq_w,
positions,
cos_sin,
eps,
dtype,
).view(num_tokens, num_idx_heads * HEAD_DIM)
ik_ref = norm_rope_ref(
ik_orig.view(num_tokens, 1, HEAD_DIM), ik_w, positions, cos_sin, eps, dtype
).view(num_tokens, HEAD_DIM)
torch.testing.assert_close(q_out, q_ref, rtol=1e-2, atol=1e-2)
torch.testing.assert_close(k_out, k_ref, rtol=1e-2, atol=1e-2)
torch.testing.assert_close(index_q, iq_ref, rtol=1e-2, atol=1e-2)
torch.testing.assert_close(index_k, ik_ref, rtol=1e-2, atol=1e-2)
# ── Cache inserts. ──
# Main cache layout is [num_blocks, 2, block_size, num_kv_heads, head_dim]
# (the K/V axis sits *before* block_size); index cache is [nb, bs, head_dim].
idx_flat = index_cache.view(num_blocks * block_size, HEAD_DIM)
k_ref_h = k_ref.view(num_tokens, num_kv_heads, HEAD_DIM)
v_ref_h = v_in.view(num_tokens, num_kv_heads, HEAD_DIM) # v is raw (no norm/rope)
for t in range(num_tokens):
s = slot_mapping[t].item()
b, pos = s // block_size, s % block_size
torch.testing.assert_close(
kv_cache[b, 0, pos], k_ref_h[t], rtol=1e-2, atol=1e-2
)
torch.testing.assert_close(kv_cache[b, 1, pos], v_ref_h[t], rtol=0, atol=0)
torch.testing.assert_close(idx_flat[s], ik_ref[t], rtol=1e-2, atol=1e-2)
+279
View File
@@ -0,0 +1,279 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Reference-vs-optimized unit tests for the MiniMax-M3 AMD/ROCm fused kernels.
Each optimized kernel added for the ROCm port has a slow PyTorch reference; the
tests assert the two agree within tolerance:
* Gemma RMSNorm (plain + fused-add-residual) -> fp32 PyTorch normalize
* SwiGLU-OAI (split layout) -> fp32 PyTorch elementwise
* Fused MXFP8 activation quant (Triton) -> _mxfp8_e4m3_quantize_torch
* Native MXFP8 linear (dot_scaled) -> dequant-to-bf16 @ matmul
* Native MXFP8 MoE (dot_scaled grouped GEMM) -> dequant-to-bf16 MoE math
The native MXFP8 GEMMs also guard the ``dot_scaled`` rhs-scale orientation: the
scale is loaded ``[N, K//32]`` and passed WITHOUT transpose; a stray ``.T``
makes the shape ``[K//32, N]`` and Triton raises before producing output, so any
regression there fails these tests loudly.
Hardware scope: the whole module is ROCm-only (these are the AMD path; NVIDIA
uses the FlashInfer kernels). The norm/activation/quant kernels run on any ROCm
arch; the native MXFP8 ``dot_scaled`` linear/MoE tests are additionally gated to
CDNA4 gfx95x (``@requires_gfx950``) since gfx942 uses the BF16 emulation path.
Run: pytest tests/kernels/test_minimax_m3_amd_ops.py -v
"""
import pytest
import torch
from vllm.platforms import current_platform
if not current_platform.is_rocm():
pytest.skip(
"MiniMax-M3 AMD fused ops require ROCm.", allow_module_level=True
)
if not torch.cuda.is_available():
pytest.skip("Requires a GPU.", allow_module_level=True)
from vllm.model_executor.layers.quantization.utils.mxfp8_utils import ( # noqa: E402
_mxfp8_e4m3_quantize_torch,
_mxfp8_e4m3_quantize_triton,
dequant_mxfp8_to_bf16,
)
from vllm.models.minimax_m3.amd.ops import ( # noqa: E402
gemma_fused_add_rmsnorm,
gemma_rmsnorm,
swiglu_oai_split,
)
from vllm.models.minimax_m3.amd.ops.gemma_rmsnorm import _num_warps # noqa: E402
DEVICE = "cuda"
EPS = 1e-6
def _gcn_arch() -> str:
try:
return torch.cuda.get_device_properties(0).gcnArchName
except Exception: # pragma: no cover - no device / non-AMD
return ""
# The pure-Triton norm/activation/quant kernels run on any ROCm arch (CDNA3
# gfx942 and CDNA4 gfx950). The native MXFP8 ``dot_scaled`` GEMMs (linear + MoE)
# use CDNA4 hardware microscaling and are gated to gfx95x in the source
# (``RocmDotScaledMxfp8LinearKernel.is_supported``; the MoE oracle routes gfx942
# to the BF16 emulation path instead) — so those tests are gfx950-only.
requires_gfx950 = pytest.mark.skipif(
"gfx95" not in _gcn_arch(),
reason="native MXFP8 dot_scaled is a CDNA4 (gfx95x) feature; "
"gfx942 uses the BF16 emulation path instead.",
)
def _relerr(a: torch.Tensor, b: torch.Tensor) -> float:
a = a.float()
b = b.float()
return ((a - b).norm() / (b.norm() + 1e-8)).item()
# --------------------------------------------------------------------------- #
# Gemma RMSNorm
# --------------------------------------------------------------------------- #
def _ref_gemma_rmsnorm(x, w, eps, residual=None):
orig_dtype = x.dtype
xf = x.float()
res_out = None
if residual is not None:
xf = xf + residual.float()
res_out = xf.to(orig_dtype)
xf = xf * torch.rsqrt(xf.pow(2).mean(dim=-1, keepdim=True) + eps)
xf = xf * (1.0 + w.float())
out = xf.to(orig_dtype)
return out if residual is None else (out, res_out)
@pytest.mark.parametrize("shape", [(1, 4096), (37, 6144), (128, 2048)])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@pytest.mark.parametrize("seed", [0, 1234])
@torch.inference_mode()
def test_gemma_rmsnorm(shape, dtype, seed):
torch.manual_seed(seed)
x = torch.randn(*shape, device=DEVICE, dtype=dtype)
w = (torch.randn(shape[-1], device=DEVICE, dtype=dtype) * 0.1)
got = gemma_rmsnorm(x, w, EPS)
ref = _ref_gemma_rmsnorm(x, w, EPS)
assert got.shape == x.shape
assert _relerr(got, ref) < 5e-3
@pytest.mark.parametrize("shape", [(1, 6144), (64, 4096)])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@torch.inference_mode()
def test_gemma_fused_add_rmsnorm(shape, dtype):
torch.manual_seed(0)
x = torch.randn(*shape, device=DEVICE, dtype=dtype)
res = torch.randn(*shape, device=DEVICE, dtype=dtype)
w = torch.randn(shape[-1], device=DEVICE, dtype=dtype) * 0.1
got_out, got_res = gemma_fused_add_rmsnorm(x, res, w, EPS)
ref_out, ref_res = _ref_gemma_rmsnorm(x, w, EPS, residual=res)
assert _relerr(got_out, ref_out) < 5e-3
# residual_out is the pre-norm sum (x + res): bit-for-bit identical cast.
assert torch.equal(got_res, ref_res)
@torch.inference_mode()
def test_gemma_rmsnorm_per_head_strided():
"""q_norm/k_norm normalize a non-contiguous ``qkv.split`` slice over head_dim."""
torch.manual_seed(0)
T, H, D, kv = 7, 48, 128, 8
total = (H + 2 * kv) * D
qkv = torch.randn(T, total, device=DEVICE, dtype=torch.bfloat16)
q = qkv[..., : H * D] # non-contiguous view (row stride == total)
q_by_head = q.view(T, H, D)
assert not q_by_head.is_contiguous()
w = torch.randn(D, device=DEVICE, dtype=torch.bfloat16) * 0.1
got = gemma_rmsnorm(q_by_head, w, EPS)
ref = _ref_gemma_rmsnorm(q_by_head, w, EPS)
assert got.shape == q_by_head.shape
assert _relerr(got, ref) < 5e-3
def test_num_warps_monotonic():
assert _num_warps(128) <= _num_warps(2048) <= _num_warps(8192)
# --------------------------------------------------------------------------- #
# SwiGLU-OAI (split layout)
# --------------------------------------------------------------------------- #
def _ref_swiglu(gate_up, alpha, beta, limit):
d = gate_up.shape[-1] // 2
gate = gate_up[..., :d].float()
up = gate_up[..., d:].float()
if limit is not None:
gate = gate.clamp(max=limit)
up = up.clamp(min=-limit, max=limit)
return (gate * torch.sigmoid(alpha * gate) * (up + beta)).to(gate_up.dtype)
@pytest.mark.parametrize("m,inter", [(1, 768), (64, 1536), (128, 1024)])
@pytest.mark.parametrize("limit", [7.0, None])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@torch.inference_mode()
def test_swiglu_oai_split(m, inter, limit, dtype):
torch.manual_seed(0)
gate_up = torch.randn(m, 2 * inter, device=DEVICE, dtype=dtype)
got = swiglu_oai_split(gate_up, alpha=1.702, beta=1.0, limit=limit)
ref = _ref_swiglu(gate_up, 1.702, 1.0, limit)
assert got.shape == (m, inter)
assert _relerr(got, ref) < 5e-3
# --------------------------------------------------------------------------- #
# Fused MXFP8 activation quant (Triton vs torch reference)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("shape", [(64, 4096), (1, 6144), (333, 2048)])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@torch.inference_mode()
def test_mxfp8_quant_triton_matches_torch(shape, dtype):
torch.manual_seed(0)
x = torch.randn(*shape, device=DEVICE, dtype=dtype)
xq_t, s_t = _mxfp8_e4m3_quantize_torch(x, is_sf_swizzled_layout=False)
xq_k, s_k = _mxfp8_e4m3_quantize_triton(x)
assert s_k.shape == s_t.shape == (shape[0], shape[1] // 32)
# E8M0 block exponents share the floor(log2(amax))+127 algorithm; allow at
# most a 1-step difference at exact powers of two.
assert (s_k.int() - s_t.int()).abs().max().item() <= 1
# Dequantized values agree to fp8 granularity.
deq_t = dequant_mxfp8_to_bf16(xq_t, s_t)
deq_k = dequant_mxfp8_to_bf16(xq_k, s_k)
assert _relerr(deq_k, deq_t) < 1e-2
# --------------------------------------------------------------------------- #
# Native MXFP8 linear (dot_scaled) vs dequant-to-bf16 matmul
# --------------------------------------------------------------------------- #
@requires_gfx950
@pytest.mark.parametrize("m,n,k", [(64, 256, 128), (37, 512, 256), (1, 6144, 4096)])
@torch.inference_mode()
def test_mxfp8_native_linear(m, n, k):
from vllm.model_executor.kernels.linear.mxfp8.rocm_native import (
_mxfp8_dot_scaled_linear,
)
torch.manual_seed(0)
w_bf16 = torch.randn(n, k, device=DEVICE, dtype=torch.bfloat16) * 0.1
w_fp8, w_scale = _mxfp8_e4m3_quantize_torch(w_bf16, is_sf_swizzled_layout=False)
x = torch.randn(m, k, device=DEVICE, dtype=torch.bfloat16) * 0.5
got = _mxfp8_dot_scaled_linear(x, w_fp8, w_scale)
# Reference: consume the SAME quantized weights (isolates activation-quant
# noise) -> dequant to bf16, plain matmul.
w_deq = dequant_mxfp8_to_bf16(w_fp8, w_scale)
ref = torch.nn.functional.linear(x, w_deq).to(x.dtype)
assert got.shape == (m, n)
# Only the activation is re-quantized inside the kernel -> small MX noise.
assert _relerr(got, ref) < 5e-2
# --------------------------------------------------------------------------- #
# Native MXFP8 MoE (dot_scaled grouped GEMM) vs dequant-to-bf16 MoE math
# --------------------------------------------------------------------------- #
def _ref_moe(x, w13, w2, topk_weights, topk_ids, alpha, beta, limit):
T, H = x.shape
inter = w2.shape[-1]
top_k = topk_ids.shape[1]
out = torch.zeros(T, H, device=x.device, dtype=torch.float32)
for t in range(T):
for j in range(top_k):
e = int(topk_ids[t, j].item())
g1 = x[t].float() @ w13[e].float().T # [2I]
gate = g1[:inter]
up = g1[inter:]
if limit is not None:
gate = gate.clamp(max=limit)
up = up.clamp(min=-limit, max=limit)
act = gate * torch.sigmoid(alpha * gate) * (up + beta)
g2 = act @ w2[e].float().T # [H]
out[t] += topk_weights[t, j].float() * g2
return out.to(x.dtype)
@requires_gfx950
@pytest.mark.parametrize(
"T,H,inter,E,top_k", [(8, 256, 512, 8, 2), (1, 512, 256, 16, 4)]
)
@torch.inference_mode()
def test_mxfp8_native_moe(T, H, inter, E, top_k):
from vllm.model_executor.layers.fused_moe.experts.mxfp8_native_moe import (
fused_moe_mxfp8_native,
)
torch.manual_seed(0)
alpha, beta, limit = 1.702, 1.0, 7.0
w13_bf16 = torch.randn(E, 2 * inter, H, device=DEVICE, dtype=torch.bfloat16) * 0.1
w2_bf16 = torch.randn(E, H, inter, device=DEVICE, dtype=torch.bfloat16) * 0.1
w13_fp8, w13_scale = _mxfp8_e4m3_quantize_torch(
w13_bf16, is_sf_swizzled_layout=False
)
w2_fp8, w2_scale = _mxfp8_e4m3_quantize_torch(
w2_bf16, is_sf_swizzled_layout=False
)
x = torch.randn(T, H, device=DEVICE, dtype=torch.bfloat16) * 0.5
logits = torch.randn(T, E, device=DEVICE, dtype=torch.float32)
topk_weights, topk_ids = logits.softmax(dim=-1).topk(top_k, dim=-1)
topk_weights = topk_weights.to(torch.float32)
topk_ids = topk_ids.to(torch.int32)
got = fused_moe_mxfp8_native(
x, w13_fp8, w13_scale, w2_fp8, w2_scale, topk_weights, topk_ids,
alpha=alpha, beta=beta, limit=limit,
global_num_experts=E, expert_map=None,
)
# Reference consumes the dequantized weights (same bits the kernel reads).
w13_deq = dequant_mxfp8_to_bf16(w13_fp8, w13_scale)
w2_deq = dequant_mxfp8_to_bf16(w2_fp8, w2_scale)
ref = _ref_moe(x, w13_deq, w2_deq, topk_weights, topk_ids, alpha, beta, limit)
assert got.shape == (T, H)
assert _relerr(got, ref) < 5e-2
@@ -0,0 +1,278 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import string
from collections.abc import Sequence
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.reasoning import ReasoningParserManager
from vllm.reasoning.minimax_m3_reasoning_parser import MiniMaxM3ReasoningParser
pytestmark = pytest.mark.skip_global_cleanup
class MiniMaxM3Tokenizer:
"""Small tokenizer with MiniMax M3 reasoning tags as special tokens."""
special_tokens = ("<mm:think>", "</mm:think>")
def __init__(self):
self._token_to_id: dict[str, int] = {}
self._id_to_token: dict[int, str] = {}
for token in self.special_tokens:
self._add_token(token)
for char in string.printable:
self._add_token(char)
def _add_token(self, token: str) -> int:
token_id = self._token_to_id.get(token)
if token_id is None:
token_id = len(self._token_to_id) + 1
self._token_to_id[token] = token_id
self._id_to_token[token_id] = token
return token_id
def get_vocab(self) -> dict[str, int]:
return dict(self._token_to_id)
def encode(
self,
text: str,
truncation: bool | None = None,
max_length: int | None = None,
add_special_tokens: bool = True,
) -> list[int]:
return [self._add_token(token) for token in self.tokenize(text)]
def decode(
self, ids: Sequence[int] | int, skip_special_tokens: bool = False
) -> str:
if isinstance(ids, int):
ids = [ids]
return "".join(self._id_to_token[token_id] for token_id in ids)
def tokenize(self, text: str) -> list[str]:
tokens: list[str] = []
pos = 0
while pos < len(text):
for special_token in self.special_tokens:
if text.startswith(special_token, pos):
tokens.append(special_token)
pos += len(special_token)
break
else:
tokens.append(text[pos])
pos += 1
return tokens
def convert_ids_to_tokens(
self,
ids: Sequence[int],
skip_special_tokens: bool = False,
) -> list[str]:
return [self._id_to_token[token_id] for token_id in ids]
def convert_tokens_to_ids(self, tokens: str | list[str]) -> int | list[int]:
if isinstance(tokens, str):
return self._add_token(tokens)
return [self._add_token(token) for token in tokens]
def convert_tokens_to_string(self, tokens: list[str]) -> str:
return "".join(tokens)
def make_parser(
chat_template_kwargs: dict[str, str] | None = None,
) -> tuple[MiniMaxM3ReasoningParser, MiniMaxM3Tokenizer]:
tokenizer = MiniMaxM3Tokenizer()
return (
MiniMaxM3ReasoningParser(
tokenizer, chat_template_kwargs=chat_template_kwargs
),
tokenizer,
)
def run_streaming(
parser: MiniMaxM3ReasoningParser,
tokenizer: MiniMaxM3Tokenizer,
chunks: list[str],
) -> tuple[str | None, str | None, list[bool]]:
previous_text = ""
previous_token_ids: list[int] = []
reasoning_parts: list[str] = []
content_parts: list[str] = []
reasoning_end_states: list[bool] = []
for chunk in chunks:
delta_token_ids = tokenizer.encode(chunk, add_special_tokens=False)
current_text = previous_text + chunk
current_token_ids = previous_token_ids + delta_token_ids
delta = parser.extract_reasoning_streaming(
previous_text=previous_text,
current_text=current_text,
delta_text=chunk,
previous_token_ids=previous_token_ids,
current_token_ids=current_token_ids,
delta_token_ids=delta_token_ids,
)
reasoning_end_states.append(
parser.is_reasoning_end_streaming(current_token_ids, delta_token_ids)
)
if delta is not None:
if delta.reasoning is not None:
reasoning_parts.append(delta.reasoning)
if delta.content is not None:
content_parts.append(delta.content)
previous_text = current_text
previous_token_ids = current_token_ids
return (
"".join(reasoning_parts) or None,
"".join(content_parts) or None,
reasoning_end_states,
)
def test_parser_registration():
parser_cls = ReasoningParserManager.get_reasoning_parser("minimax_m3")
assert parser_cls is MiniMaxM3ReasoningParser
def test_nonstreaming_extracts_explicit_reasoning_block():
parser, _ = make_parser()
request = ChatCompletionRequest(messages=[], model="test-model")
reasoning, content = parser.extract_reasoning(
"<mm:think>plan</mm:think>answer", request
)
assert reasoning == "plan"
assert content == "answer"
def test_nonstreaming_without_start_tag_is_content():
parser, _ = make_parser()
request = ChatCompletionRequest(messages=[], model="test-model")
reasoning, content = parser.extract_reasoning("plain answer", request)
assert reasoning is None
assert content == "plain answer"
def test_nonstreaming_enabled_mode_starts_in_reasoning():
parser, _ = make_parser(chat_template_kwargs={"thinking_mode": "enabled"})
request = ChatCompletionRequest(messages=[], model="test-model")
reasoning, content = parser.extract_reasoning("plan</mm:think>answer", request)
assert reasoning == "plan"
assert content == "answer"
def test_nonstreaming_open_reasoning_block():
parser, _ = make_parser()
request = ChatCompletionRequest(messages=[], model="test-model")
reasoning, content = parser.extract_reasoning("<mm:think>still thinking", request)
assert reasoning == "still thinking"
assert content is None
def test_streaming_reasoning_tags_are_not_returned():
parser, tokenizer = make_parser()
reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["<mm:think>", "plan", "</mm:think>", "answer"],
)
assert reasoning == "plan"
assert content == "answer"
assert end_states == [False, False, True, True]
def test_streaming_boundary_can_emit_reasoning_and_content():
parser, tokenizer = make_parser()
reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["<mm:think>plan</mm:think>answer"],
)
assert reasoning == "plan"
assert content == "answer"
assert end_states == [True]
def test_streaming_enabled_mode_starts_in_reasoning():
parser, tokenizer = make_parser(
chat_template_kwargs={"thinking_mode": "enabled"}
)
reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["plan", "</mm:think>", "answer"],
)
assert reasoning == "plan"
assert content == "answer"
assert end_states == [False, True, True]
def test_streaming_plain_content_ends_reasoning_phase():
parser, tokenizer = make_parser()
reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["plain ", "answer"],
)
assert reasoning is None
assert content == "plain answer"
assert end_states == [True, True]
def test_token_id_helpers():
parser, tokenizer = make_parser()
output_ids = tokenizer.encode(
"<mm:think>abc</mm:think>def", add_special_tokens=False
)
open_reasoning_ids = tokenizer.encode("<mm:think>abc", add_special_tokens=False)
content_ids = tokenizer.encode("plain", add_special_tokens=False)
assert parser.is_reasoning_end(output_ids)
assert not parser.is_reasoning_end(open_reasoning_ids)
assert not parser.is_reasoning_end(content_ids)
assert tokenizer.decode(parser.extract_content_ids(output_ids)) == "def"
assert parser.extract_content_ids(open_reasoning_ids) == []
assert parser.extract_content_ids(content_ids) == content_ids
assert parser.count_reasoning_tokens(output_ids) == len(tokenizer.encode("abc"))
def test_token_id_helpers_enabled_mode():
parser, tokenizer = make_parser(
chat_template_kwargs={"thinking_mode": "enabled"}
)
output_ids = tokenizer.encode("abc</mm:think>def", add_special_tokens=False)
open_reasoning_ids = tokenizer.encode("abc", add_special_tokens=False)
assert parser.is_reasoning_end(output_ids)
assert not parser.is_reasoning_end(open_reasoning_ids)
assert tokenizer.decode(parser.extract_content_ids(output_ids)) == "def"
assert parser.extract_content_ids(open_reasoning_ids) == []
assert parser.count_reasoning_tokens(output_ids) == len(tokenizer.encode("abc"))
assert parser.count_reasoning_tokens(open_reasoning_ids) == len(
tokenizer.encode("abc")
)
@@ -0,0 +1,261 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from typing import Any
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
FunctionDefinition,
)
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
from vllm.tool_parsers import ToolParserManager
from vllm.tool_parsers.minimax_m3_tool_parser import MinimaxM3ToolParser
pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup]
NS = "]<]minimax[>["
EOS_ID = 99
class FakeTokenizer:
"""Minimal fake tokenizer for unit tests."""
def __init__(self):
self.model_tokenizer = True
self.vocab: dict[str, int] = {}
def get_vocab(self) -> dict[str, int]:
return self.vocab
def sample_tools() -> list[ChatCompletionToolsParam]:
return [
ChatCompletionToolsParam(
function=FunctionDefinition(
name="create_order",
parameters={
"type": "object",
"properties": {
"user_id": {"type": "integer"},
"urgent": {"type": "boolean"},
"note": {"type": "string"},
"shipping": {
"type": "object",
"properties": {
"city": {"type": "string"},
"zip": {"type": "integer"},
},
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"qty": {"type": "integer"},
},
},
},
"metadata": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"duplicate_demo": {"type": "object"},
},
},
),
)
]
@pytest.fixture
def parser() -> MinimaxM3ToolParser:
return MinimaxM3ToolParser(FakeTokenizer(), tools=sample_tools())
def build_order_call() -> str:
return (
f"{NS}<tool_call>\n"
f'{NS}<invoke name="create_order">'
f"{NS}<user_id>42{NS}</user_id>"
f"{NS}<urgent>true{NS}</urgent>"
f"{NS}<note>Please leave at front desk.{NS}</note>"
f"{NS}<shipping>"
f"{NS}<city>Singapore{NS}</city>"
f"{NS}<zip>018956{NS}</zip>"
f"{NS}</shipping>"
f"{NS}<items>"
f"{NS}<item>{NS}<sku>book-001{NS}</sku>{NS}<qty>2{NS}</qty>{NS}</item>"
f"{NS}<item>{NS}<sku>pen-007{NS}</sku>{NS}<qty>5{NS}</qty>{NS}</item>"
f"{NS}</items>"
f"{NS}<metadata>"
f"{NS}<source>mobile{NS}</source>"
f"{NS}<campaign>may-launch{NS}</campaign>"
f"{NS}</metadata>"
f"{NS}<duplicate_demo>"
f"{NS}<tag>a{NS}</tag>"
f"{NS}<tag>b{NS}</tag>"
f"{NS}</duplicate_demo>"
f"{NS}</invoke>\n"
f"{NS}</tool_call>"
)
def build_order_invocation(user_id: int) -> str:
return (
f'{NS}<invoke name="create_order">'
f"{NS}<user_id>{user_id}{NS}</user_id>"
f"{NS}</invoke>"
)
def build_multiple_order_call() -> str:
return (
f"{NS}<tool_call>\n"
f"{build_order_invocation(1)}\n"
f"{build_order_invocation(2)}\n"
f"{NS}</tool_call>"
)
def _feed(
parser: MinimaxM3ToolParser, chunks: list[str | tuple[str, list[int]]]
) -> list[DeltaMessage]:
previous = ""
results: list[DeltaMessage] = []
for chunk in chunks:
if isinstance(chunk, tuple):
delta, delta_ids = chunk
else:
delta = chunk
delta_ids = []
current = previous + delta
result = parser.extract_tool_calls_streaming(
previous_text=previous,
current_text=current,
delta_text=delta,
previous_token_ids=[],
current_token_ids=[],
delta_token_ids=delta_ids,
request=None,
)
if result is not None:
results.append(result)
previous = current
return results
def _collect_content(results: list[DeltaMessage]) -> str:
return "".join(result.content for result in results if result.content)
def _collect_tool_calls(results: list[DeltaMessage]) -> dict[int, dict[str, Any]]:
tool_calls: dict[int, dict[str, Any]] = {}
for result in results:
for tool_call in result.tool_calls or []:
tool_calls.setdefault(
tool_call.index,
{"id": None, "name": "", "arguments": ""},
)
if tool_call.id:
tool_calls[tool_call.index]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[tool_call.index]["name"] += tool_call.function.name
if tool_call.function.arguments:
tool_calls[tool_call.index]["arguments"] += (
tool_call.function.arguments
)
return tool_calls
def test_minimax_m3_parser_registered():
assert ToolParserManager.get_tool_parser("minimax_m3") is MinimaxM3ToolParser
def test_non_streaming_nested_tool_call(parser):
result = parser.extract_tool_calls(
"I will create it.\n" + build_order_call(),
request=None,
)
assert result.tools_called
assert result.content == "I will create it.\n"
assert len(result.tool_calls) == 1
tool_call = result.tool_calls[0]
assert tool_call.function.name == "create_order"
assert json.loads(tool_call.function.arguments) == {
"user_id": 42,
"urgent": True,
"note": "Please leave at front desk.",
"shipping": {"city": "Singapore", "zip": 18956},
"items": [
{"sku": "book-001", "qty": 2},
{"sku": "pen-007", "qty": 5},
],
"metadata": {
"source": "mobile",
"campaign": "may-launch",
},
"duplicate_demo": {"tag": ["a", "b"]},
}
def test_non_streaming_without_tool_call_keeps_content(parser):
result = parser.extract_tool_calls("plain response", request=None)
assert not result.tools_called
assert result.tool_calls == []
assert result.content == "plain response"
def test_non_streaming_multiple_tool_calls(parser):
result = parser.extract_tool_calls(build_multiple_order_call(), request=None)
assert result.tools_called
assert result.content is None
assert [tool_call.function.name for tool_call in result.tool_calls] == [
"create_order",
"create_order",
]
assert [
json.loads(tool_call.function.arguments)["user_id"]
for tool_call in result.tool_calls
] == [1, 2]
def test_streaming_without_tool_call_emits_text(parser):
results = _feed(parser, ["plain ", "response"])
assert _collect_content(results) == "plain response"
assert _collect_tool_calls(results) == {}
def test_streaming_nested_tool_call(parser):
tool_call_text = build_order_call()
results = _feed(
parser,
[
"I will create it.\n",
tool_call_text[:5],
tool_call_text[5:17],
tool_call_text[17:120],
tool_call_text[120:],
("", [EOS_ID]),
],
)
assert _collect_content(results) == "I will create it.\n"
tool_calls = _collect_tool_calls(results)
assert len(tool_calls) == 1
assert tool_calls[0]["name"] == "create_order"
assert tool_calls[0]["id"] is not None
assert json.loads(tool_calls[0]["arguments"]) == json.loads(
parser.streamed_args_for_tool[0]
)
assert json.loads(parser.prev_tool_call_arr[0]["arguments"])["items"][1]["qty"] == 5
assert results[-1].content == ""
@@ -30,6 +30,7 @@ REPO_ROOT = Path(__file__).parent.parent.parent
RELEVANT_PATTERNS = [
"vllm/v1/attention/backends/*.py",
"vllm/v1/attention/backends/**/*.py",
"vllm/models/minimax_m3/common/sparse_attention.py",
"vllm/model_executor/layers/attention/mla_attention.py",
"vllm/platforms/cuda.py",
"tools/pre_commit/generate_attention_backend_docs.py",
@@ -1615,6 +1616,24 @@ def generate_mla_section(
return "\n".join(lines)
def generate_minimax_section(backends: list[dict[str, Any]]) -> str:
"""Generate the MiniMax M3 sparse attention section."""
lines = [
"## MiniMax M3 Sparse Attention Backends",
"",
'Block-sparse GQA backend used by MiniMax M3 sparse ("lightning indexer")',
"layers. It is wired in directly by the model and is not part of the",
"automatic priority lists above. A lightning indexer scores KV blocks, the",
"top-k blocks (plus fixed init/local blocks) are selected, and attention",
"attends only to those blocks; index keys live in a separate side cache.",
"",
]
columns = _build_columns(is_mla=False, has_versions=False)
lines.extend(_render_table(columns, backends))
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Top-level orchestration
# ---------------------------------------------------------------------------
@@ -1651,9 +1670,16 @@ def generate_docs() -> str:
if fi_features:
all_backends = _expand_flashinfer_variants(all_backends, fi_features)
# Split into MLA and non-MLA
# Split into MLA, MiniMax M3 sparse, and standard (MHA/MQA/GQA) backends.
mla_backends = [b for b in all_backends if b["is_mla"]]
non_mla_backends = [b for b in all_backends if not b["is_mla"]]
minimax_backends = [
b for b in all_backends if not b["is_mla"] and b["name"].startswith("MINIMAX")
]
non_mla_backends = [
b
for b in all_backends
if not b["is_mla"] and not b["name"].startswith("MINIMAX")
]
# Generate documentation
script_path = "tools/pre_commit/generate_attention_backend_docs.py"
@@ -1702,6 +1728,10 @@ def generate_docs() -> str:
if footnotes:
doc_lines.append("\n>\n".join(footnotes) + "\n")
# Add MiniMax M3 sparse section (separate category after standard GQA)
if minimax_backends:
doc_lines.append(generate_minimax_section(minimax_backends))
# Add MLA section with prefill and decode backends
doc_lines.append(generate_mla_section(mla_prefill_backends, mla_backends))
+61
View File
@@ -2680,6 +2680,67 @@ def reshape_and_cache_flash(
)
def fused_minimax_m3_qknorm_rope_kv_insert(
qkv: torch.Tensor,
q_norm_weight: torch.Tensor,
k_norm_weight: torch.Tensor,
cos_sin_cache: torch.Tensor,
positions: torch.Tensor,
num_heads: int,
num_kv_heads: int,
rotary_dim: int,
eps: float,
index_q_norm_weight: torch.Tensor | None = None,
index_k_norm_weight: torch.Tensor | None = None,
num_index_heads: int = 0,
slot_mapping: torch.Tensor | None = None,
kv_cache: torch.Tensor | None = None,
index_cache: torch.Tensor | None = None,
block_size: int = 0,
q_out: torch.Tensor | None = None,
index_q_out: torch.Tensor | None = None,
) -> None:
"""Fused MiniMax-M3 attention pre-processing (in-place).
Applies Gemma RMSNorm + partial NeoX RoPE to ``qkv`` in place. ``qkv`` is a
single fused tensor:
- dense layer (``num_index_heads == 0``): ``[q | k | v]``;
- sparse layer (``num_index_heads > 0``): ``[q | k | v | index_q |
index_k]`` — the index branch is read straight out of ``qkv``.
When ``kv_cache`` is given (sparse serving), also scatter-inserts the
normed/roped k & v into the paged bf16 KV cache and the index key into
``index_cache`` by ``slot_mapping``.
If ``q_out`` / ``index_q_out`` (contiguous ``[N, nq*128]`` / ``[N,
niq*128]``) are given, the normed/roped q / index_q are written there
instead of in place — folding the de-interleave into this kernel's store so
callers skip a separate ``.contiguous()`` copy before the SM100 sparse
attention's flat TMA descriptor.
"""
torch.ops._C.fused_minimax_m3_qknorm_rope_kv_insert(
qkv,
q_norm_weight,
k_norm_weight,
cos_sin_cache,
positions,
num_heads,
num_kv_heads,
rotary_dim,
eps,
index_q_norm_weight,
index_k_norm_weight,
num_index_heads,
slot_mapping,
kv_cache,
index_cache,
block_size,
q_out,
index_q_out,
)
def concat_and_cache_mla(
kv_c: torch.Tensor,
k_pe: torch.Tensor,
@@ -132,6 +132,7 @@ if flashinfer_comm is not None:
quant_out: torch.Tensor | None = None,
scale_out: torch.Tensor | None = None,
scale_factor: torch.Tensor | None = None,
weight_bias: float = 0.0,
) -> None:
num_tokens, hidden_size = allreduce_in.shape
element_size = allreduce_in.element_size()
@@ -209,6 +210,7 @@ if flashinfer_comm is not None:
use_oneshot=use_oneshot,
fp32_acc=fp32_acc,
trigger_completion_at_end=num_tokens > PDL_ADVANCE_LAUNCH_TOKENS,
weight_bias=weight_bias,
)
def call_trtllm_fused_allreduce_norm_fake(
@@ -225,6 +227,7 @@ if flashinfer_comm is not None:
quant_out: torch.Tensor | None = None,
scale_out: torch.Tensor | None = None,
scale_factor: torch.Tensor | None = None,
weight_bias: float = 0.0,
) -> None:
pass
+9 -3
View File
@@ -1051,20 +1051,26 @@ class VllmConfig:
)
self.compilation_config.mode = CompilationMode.NONE
# DeepSeek V4's model classes don't carry @support_torch_compile —
# For model classes don't carry @support_torch_compile —
# the breakable cudagraph is the supported PIECEWISE path. Auto-enable
# it unless the user has explicitly opted out via the env var.
if (
self.model_config is not None
and "VLLM_USE_BREAKABLE_CUDAGRAPH" not in os.environ
and any(
a in ("DeepseekV4ForCausalLM", "DeepSeekV4MTPModel")
a
in (
"DeepseekV4ForCausalLM",
"DeepSeekV4MTPModel",
"MiniMaxM3SparseForCausalLM",
"MiniMaxM3SparseForConditionalGeneration",
)
for a in self.model_config.architectures
)
):
os.environ["VLLM_USE_BREAKABLE_CUDAGRAPH"] = "1"
logger.info_once(
"Auto-enabling VLLM_USE_BREAKABLE_CUDAGRAPH=1 for DeepSeek V4. "
"Auto-enabling VLLM_USE_BREAKABLE_CUDAGRAPH=1. "
"Set VLLM_USE_BREAKABLE_CUDAGRAPH=0 to opt out."
)
@@ -922,12 +922,9 @@ class NixlConnectorWorker:
f"{self.transfer_topo.is_kv_layout_blocks_first}"
)
if not self.use_mla:
# Different kv cache shape is not supported by HeteroTP.
# This must also hold true for Mamba-like models.
assert tensor_size_bytes == curr_tensor_size_bytes, (
"All kv cache tensors must have the same size"
)
# Allow heterogeneous per-layer KV tensor sizes (non-MLA), e.g.
# MiniMax-M3 full-attn + MLA indexer; per-layer sizes live in
# block_len_per_layer. Equal-TP enforced at handshake.
# Need to make sure the device ID is non-negative for NIXL,
# Torch uses -1 to indicate CPU tensors.
self.device_id = max(cache.get_device(), 0)
@@ -1524,6 +1521,34 @@ class NixlConnectorWorker:
self.block_len_per_layer[i] // block_size_ratio
== nixl_agent_meta.block_lens[i]
), "KV cache sizes must match between P and D when replicated"
elif (
len(set(self.block_len_per_layer)) > 1
or len(set(nixl_agent_meta.block_lens)) > 1
):
# Non-MLA, non-replicated, HETEROGENEOUS per-layer block lengths
# (e.g. MiniMax-M3: full-attn K/V layers + smaller MLA lightning-
# indexer layers grouped together). Check either side so a P/D pair
# with one homogeneous side still validates per-layer. Only equal-TP
# is supported: the linear tp_ratio scaling assumes a uniform
# block_len, which does not hold across heterogeneous layers.
assert len(self.block_len_per_layer) == len(nixl_agent_meta.block_lens), (
"Number of KV layers must match between prefill and decode"
)
if abs(tp_ratio) != 1 or block_size_ratio != 1:
raise NotImplementedError(
"Non-MLA heterogeneous KV cache (mixed full-attention + MLA "
"layers) requires equal tensor-parallel and block size "
"between prefill and decode; got tp_ratio="
f"{tp_ratio}, block_size_ratio={block_size_ratio}."
)
if not self._has_mamba:
# Validate each layer independently (like the MLA/replicated
# path); the descriptor builders index block_lens[i] per layer.
for i in range(len(self.block_len_per_layer)):
assert (
self.block_len_per_layer[i] // block_size_ratio
== nixl_agent_meta.block_lens[i]
), "Per-layer KV block_len mismatch between P and D"
else:
# When MLA is not used, this is a list of the same block length
for block_len in nixl_agent_meta.block_lens:
@@ -90,6 +90,9 @@ from vllm.model_executor.kernels.linear.mxfp8.flashinfer import (
from vllm.model_executor.kernels.linear.mxfp8.marlin import (
MarlinMxfp8LinearKernel,
)
from vllm.model_executor.kernels.linear.mxfp8.rocm_native import (
RocmDotScaledMxfp8LinearKernel,
)
from vllm.model_executor.kernels.linear.mxfp8.xpu import (
XPUMxFp8LinearKernel,
)
@@ -372,6 +375,9 @@ _POSSIBLE_MXFP8_KERNELS: dict[PlatformEnum, list[type[Mxfp8LinearKernel]]] = {
EmulationMxfp8LinearKernel,
],
PlatformEnum.ROCM: [
# Native CDNA4 (gfx950) MX linear; is_supported() gates to gfx95x and
# falls through to BF16 emulation (hipBLASLt) elsewhere / on regression.
RocmDotScaledMxfp8LinearKernel,
EmulationMxfp8LinearKernel,
],
PlatformEnum.XPU: [
@@ -56,8 +56,6 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
input_shape = x.shape
input_2d = x.view(-1, K)
M_orig = input_2d.shape[0]
min_dim = 128
assert min_dim <= K, (
@@ -72,11 +70,6 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
f"out_features is too small for mm_mxfp8."
)
M_padded = ((M_orig + min_dim - 1) // min_dim) * min_dim
if M_padded != M_orig:
pad_rows = M_padded - M_orig
input_2d = torch.nn.functional.pad(input_2d, (0, 0, 0, pad_rows))
input_mxfp8, input_scale = mxfp8_e4m3_quantize(
input_2d, is_sf_swizzled_layout=True
)
@@ -93,9 +86,6 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
backend="cutlass",
)
if M_padded != M_orig:
output = output[:M_orig, :]
if bias is not None:
output = output + bias
@@ -0,0 +1,140 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Native MXFP8 linear GEMM for AMD CDNA4 (gfx950) via Triton ``tl.dot_scaled``.
Consumes the FP8 E4M3 weights + E8M0 block scales directly (no dequant-to-BF16);
activations are MXFP8-quantized per token. Uses the CDNA4 hardware microscaling
matrix cores. Falls back (via the kernel selector) to the BF16
``EmulationMxfp8LinearKernel`` on archs without native MX or for shapes with
``K % 128 != 0``.
"""
import torch
from torch.nn.parameter import Parameter
from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
MXFP8_BLOCK_SIZE,
MXFP8_SCALE_DTYPE,
dequant_mxfp8_to_bf16,
mxfp8_e4m3_quantize,
)
from vllm.platforms import current_platform
from vllm.triton_utils import tl, triton
from .Mxfp8LinearKernel import Mxfp8LinearKernel, Mxfp8LinearLayerConfig
@triton.jit
def _mxfp8_linear_kernel(
x_ptr, xs_ptr, w_ptr, ws_ptr, out_ptr,
M, N, K,
stride_xm, stride_xk, stride_xsm, stride_xsk,
stride_wn, stride_wk, stride_wsn, stride_wsk,
stride_om, stride_on,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
offs_k = tl.arange(0, BLOCK_K)
offs_sk = tl.arange(0, BLOCK_K // 32)
m_mask = offs_m < M
n_mask = offs_n < N
x_ptrs = x_ptr + offs_m[:, None] * stride_xm + offs_k[None, :] * stride_xk
xs_ptrs = xs_ptr + offs_m[:, None] * stride_xsm + offs_sk[None, :] * stride_xsk
w_ptrs = w_ptr + offs_n[:, None] * stride_wn + offs_k[None, :] * stride_wk
ws_ptrs = ws_ptr + offs_n[:, None] * stride_wsn + offs_sk[None, :] * stride_wsk
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
for _ in range(0, tl.cdiv(K, BLOCK_K)):
x = tl.load(x_ptrs, mask=m_mask[:, None], other=0.0)
w = tl.load(w_ptrs, mask=n_mask[:, None], other=0.0)
xs = tl.load(xs_ptrs, mask=m_mask[:, None], other=0)
ws = tl.load(ws_ptrs, mask=n_mask[:, None], other=0)
acc += tl.dot_scaled(x, xs, "e4m3", w.T, ws, "e4m3")
x_ptrs += BLOCK_K * stride_xk
w_ptrs += BLOCK_K * stride_wk
xs_ptrs += (BLOCK_K // 32) * stride_xsk
ws_ptrs += (BLOCK_K // 32) * stride_wsk
o_ptrs = out_ptr + offs_m[:, None] * stride_om + offs_n[None, :] * stride_on
tl.store(o_ptrs, acc.to(out_ptr.dtype.element_ty),
mask=m_mask[:, None] & n_mask[None, :])
def _mxfp8_dot_scaled_linear(
x: torch.Tensor, # [M, K] bf16/fp16
w: torch.Tensor, # [N, K] fp8 e4m3
w_scale: torch.Tensor, # [N, K//32] uint8 (E8M0)
) -> torch.Tensor:
M, K = x.shape
N = w.shape[0]
x_q, x_scale = mxfp8_e4m3_quantize(x)
out = torch.empty((M, N), dtype=x.dtype, device=x.device)
BLOCK_M, BLOCK_N, BLOCK_K = 64, 128, 128
grid = (triton.cdiv(M, BLOCK_M), triton.cdiv(N, BLOCK_N))
_mxfp8_linear_kernel[grid](
x_q, x_scale, w, w_scale, out,
M, N, K,
x_q.stride(0), x_q.stride(1), x_scale.stride(0), x_scale.stride(1),
w.stride(0), w.stride(1), w_scale.stride(0), w_scale.stride(1),
out.stride(0), out.stride(1),
BLOCK_M=BLOCK_M, BLOCK_N=BLOCK_N, BLOCK_K=BLOCK_K,
num_warps=8,
)
return out
class RocmDotScaledMxfp8LinearKernel(Mxfp8LinearKernel):
"""Native CDNA4 (gfx950) MXFP8 linear via Triton ``tl.dot_scaled``."""
@classmethod
def is_supported(
cls, compute_capability: int | None = None
) -> tuple[bool, str | None]:
if not current_platform.is_rocm():
return False, "not ROCm"
# supports_mx() == gfx95x (CDNA4 native microscaling hardware). On other
# archs dot_scaled would upcast to BF16, so the kernel selector falls
# through to the BF16 emulation (hipBLASLt) path instead.
if not current_platform.supports_mx():
return False, "native MX requires CDNA4 (gfx95x)"
return True, None
@classmethod
def can_implement(cls, c: Mxfp8LinearLayerConfig) -> tuple[bool, str | None]:
return True, None
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
weight = layer.weight.data # [N, K] fp8
N, K = weight.shape
scale_k = K // MXFP8_BLOCK_SIZE
weight_scale = layer.weight_scale.data[:N, :scale_k].contiguous()
layer.weight = Parameter(weight.contiguous(), requires_grad=False)
layer.weight_scale = Parameter(weight_scale, requires_grad=False)
def apply_weights(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None = None,
) -> torch.Tensor:
if layer.weight_scale.dtype != MXFP8_SCALE_DTYPE:
raise ValueError(
f"Expected {MXFP8_SCALE_DTYPE} weight_scale, got "
f"{layer.weight_scale.dtype}."
)
out_shape = (*x.shape[:-1], layer.weight.shape[0])
x2d = x.reshape(-1, x.shape[-1])
if x2d.shape[-1] % 128 == 0:
out = _mxfp8_dot_scaled_linear(x2d, layer.weight, layer.weight_scale)
else:
# dot_scaled tiling needs K % 128 == 0; dequantize fallback otherwise.
w_bf16 = dequant_mxfp8_to_bf16(layer.weight, layer.weight_scale)
out = torch.nn.functional.linear(x2d, w_bf16).to(x.dtype)
out = out.reshape(out_shape)
if bias is not None:
out = out + bias
return out
+22 -5
View File
@@ -158,17 +158,28 @@ class SiluAndMulWithClamp(CustomOp):
Computes:
gate = clamp(x[..., :d], max=swiglu_limit)
up = clamp(x[..., d:], min=-swiglu_limit, max=swiglu_limit)
out = silu(gate) * up
where d = x.shape[-1] // 2.
out = gate * sigmoid(alpha * gate) * (up + beta)
where d = x.shape[-1] // 2. The defaults alpha=1.0, beta=0.0 reduce this to
``silu(gate) * up``; SwiGLU-OAI style models pass alpha (sigmoid scale) and
beta=1.0 (up bias).
Shapes:
x: (num_tokens, 2 * d) or (batch_size, seq_len, 2 * d)
return: (num_tokens, d) or (batch_size, seq_len, d)
"""
def __init__(self, swiglu_limit: float, *, compile_native: bool = True):
def __init__(
self,
swiglu_limit: float,
alpha: float = 1.0,
beta: float = 0.0,
*,
compile_native: bool = True,
):
super().__init__(compile_native=compile_native)
self.swiglu_limit = float(swiglu_limit)
self.alpha = float(alpha)
self.beta = float(beta)
if current_platform.is_rocm() or current_platform.is_xpu():
self._forward_method = self.forward_native
elif current_platform.is_cuda_alike():
@@ -180,18 +191,24 @@ class SiluAndMulWithClamp(CustomOp):
d = x.shape[-1] // 2
gate = torch.clamp(x[..., :d], max=self.swiglu_limit)
up = torch.clamp(x[..., d:], min=-self.swiglu_limit, max=self.swiglu_limit)
return F.silu(gate) * up
return gate * torch.sigmoid(self.alpha * gate) * (up + self.beta)
def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
d = x.shape[-1] // 2
output_shape = x.shape[:-1] + (d,)
out = torch.empty(output_shape, dtype=x.dtype, device=x.device)
self.op(out, x, self.swiglu_limit)
self.op(out, x, self.swiglu_limit, self.alpha, self.beta)
return out
def forward_xpu(self, x: torch.Tensor) -> torch.Tensor:
return self.forward_native(x)
def extra_repr(self) -> str:
return (
f"swiglu_limit={self.swiglu_limit!r}, "
f"alpha={self.alpha!r}, beta={self.beta!r}"
)
# --8<-- [start:mul_and_silu]
@CustomOp.register("mul_and_silu")
@@ -0,0 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Manual fusion of tensor-parallel all-reduce with the following GemmaRMSNorm.
Under tensor parallelism a ``RowParallelLinear`` (e.g. attention ``o_proj``)
produces a per-rank partial sum that is all-reduced, and the result is then fed
into a ``GemmaRMSNorm`` that adds the residual and normalizes. flashinfer ships a
kernel that fuses all-reduce + residual-add + RMSNorm into a single launch; this
helper drives it directly (no torch.compile pass) for models that run eager.
Scope: attention output only, no quantization. When the flashinfer fast path is
not applicable (TP==1, flashinfer/NVSwitch unavailable, unsupported dtype, or an
oversize batch) it falls back to ``all_reduce`` + ``GemmaRMSNorm``, which is
numerically identical to the unfused model path.
"""
import torch
from vllm.distributed.communication_op import tensor_model_parallel_all_reduce
from vllm.distributed.parallel_state import (
get_tensor_model_parallel_rank,
get_tensor_model_parallel_world_size,
get_tp_group,
)
from vllm.model_executor.layers.layernorm import GemmaRMSNorm
MiB = 1024 * 1024
# flashinfer fused all-reduce + RMSNorm is wired as a registered custom op in
# allreduce_rms_fusion; both that op and the workspace helpers only exist when
# flashinfer.comm.allreduce_fusion is importable.
try:
from vllm.compilation.passes.fusion.allreduce_rms_fusion import (
flashinfer_trtllm_fused_allreduce_norm,
)
from vllm.distributed.device_communicators.flashinfer_all_reduce import (
flashinfer_comm,
get_fi_ar_workspace,
)
_AR_RESIDUAL_RMS_NORM = (
flashinfer_comm.AllReduceFusionPattern.kARResidualRMSNorm
if flashinfer_comm is not None
else None
)
except ImportError:
flashinfer_trtllm_fused_allreduce_norm = None # type: ignore[assignment]
get_fi_ar_workspace = None # type: ignore[assignment]
_AR_RESIDUAL_RMS_NORM = None
_FI_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16)
def _max_token_num(tp_size: int, hidden_size: int, dtype: torch.dtype) -> int | None:
"""Workspace token budget for flashinfer fused all-reduce, or None if the
current world size / device is unsupported. Mirrors ``FlashInferAllReduce``."""
from vllm.config.compilation import PassConfig
max_size_mb = PassConfig.default_fi_allreduce_fusion_max_size_mb().get(tp_size)
if not max_size_mb:
return None
element_size = torch.tensor([], dtype=dtype).element_size()
return int(max_size_mb * MiB) // (hidden_size * element_size)
def _can_use_flashinfer(hidden_states: torch.Tensor, tp_size: int) -> tuple[bool, int]:
"""Whether the flashinfer fused path applies; returns (ok, max_token_num)."""
if (
flashinfer_trtllm_fused_allreduce_norm is None
or get_fi_ar_workspace is None
or _AR_RESIDUAL_RMS_NORM is None
):
return False, 0
if (
not hidden_states.is_cuda
or hidden_states.dim() != 2
or not hidden_states.is_contiguous()
or hidden_states.dtype not in _FI_SUPPORTED_DTYPES
):
return False, 0
num_tokens, hidden_size = hidden_states.shape
max_token_num = _max_token_num(tp_size, hidden_size, hidden_states.dtype)
if max_token_num is None or num_tokens > max_token_num:
return False, 0
# Lazily create / fetch the (globally cached) workspace; returns None on
# GPUs without NVSwitch, in which case we fall back gracefully.
workspace = get_fi_ar_workspace(
world_size=tp_size,
rank=get_tensor_model_parallel_rank(),
max_token_num=max_token_num,
hidden_dim=hidden_size,
dtype=hidden_states.dtype,
group=get_tp_group().device_group,
)
if workspace is None:
return False, 0
return True, max_token_num
def fused_allreduce_gemma_rms_norm(
hidden_states: torch.Tensor,
residual: torch.Tensor,
norm: GemmaRMSNorm,
) -> tuple[torch.Tensor, torch.Tensor]:
"""All-reduce ``hidden_states`` + add ``residual`` + GemmaRMSNorm, fused.
``hidden_states`` is the per-rank *partial* (un-reduced) output of a
row-parallel linear; ``norm`` is the GemmaRMSNorm applied right after.
Returns ``(normed_output, new_residual)``, equivalent to
``norm(all_reduce(hidden_states), residual)``.
"""
tp_size = get_tensor_model_parallel_world_size()
if tp_size == 1:
# No all-reduce needed; identical to the unfused path.
return norm(hidden_states, residual)
ok, max_token_num = _can_use_flashinfer(hidden_states, tp_size)
if ok:
norm_out = torch.empty_like(hidden_states)
# With norm_out provided, the kernel writes the new residual
# (all_reduce(hidden_states) + residual) into the hidden_states buffer
# and the normalized result into norm_out, leaving `residual` untouched.
flashinfer_trtllm_fused_allreduce_norm(
allreduce_in=hidden_states,
residual=residual,
rms_gamma=norm.weight,
rms_eps=norm.variance_epsilon,
world_size=tp_size,
weight_bias=1.0, # GemmaRMSNorm-style
launch_with_pdl=True,
fp32_acc=True,
max_token_num=max_token_num,
pattern_code=_AR_RESIDUAL_RMS_NORM,
norm_out=norm_out,
)
return norm_out, hidden_states
# Fallback: explicit all-reduce + GemmaRMSNorm (matches the unfused model).
reduced = tensor_model_parallel_all_reduce(hidden_states)
return norm(reduced, residual)
@@ -17,7 +17,12 @@ class MoEActivation(Enum):
GELU = "gelu"
GELU_TANH = "gelu_tanh"
RELU2 = "relu2"
# SWIGLUOAI expects gate/up *interleaved* in w13 ([gate0, up0, gate1, ...]),
# as in gpt-oss checkpoints. SWIGLUOAI_UNINTERLEAVE has identical math but
# expects the *packed* layout ([all gates; all ups]), as produced by a
# MergedColumnParallelLinear gate_up_proj (e.g. MiniMax-M3).
SWIGLUOAI = "swigluoai"
SWIGLUOAI_UNINTERLEAVE = "swigluoai_uninterleave"
SWIGLUSTEP = "swiglustep"
# Non-gated activations (no mul with gate) expect input of shape [..., d]
@@ -73,6 +78,7 @@ _CUSTOM_OP_NAMES: dict[MoEActivation, str] = {
MoEActivation.GELU: "gelu_and_mul",
MoEActivation.GELU_TANH: "gelu_tanh_and_mul",
MoEActivation.SWIGLUOAI: "swigluoai_and_mul",
MoEActivation.SWIGLUOAI_UNINTERLEAVE: "silu_and_mul_with_clamp",
MoEActivation.SWIGLUSTEP: "swiglustep_and_mul",
MoEActivation.RELU2: "relu2",
MoEActivation.SILU_NO_MUL: "silu_and_mul",
@@ -105,8 +111,17 @@ def apply_moe_activation(
activation: MoEActivation,
output: torch.Tensor,
input: torch.Tensor,
*,
clamp_limit: float | None = None,
alpha: float = 1.0,
beta: float = 0.0,
) -> torch.Tensor:
"""Apply MoE activation function."""
"""Apply MoE activation function.
``clamp_limit``/``alpha``/``beta`` (from the quant config) drive the clamped
SwiGLU kernels: ``SILU`` + ``clamp_limit`` and ``SWIGLUOAI_UNINTERLEAVE`` both
map to ``silu_and_mul_with_clamp``. Other activations ignore them.
"""
assert input.dim() == 2, "Input must be 2D"
assert output.dim() == 2, "Output must be 2D"
if activation.is_gated:
@@ -122,13 +137,21 @@ def apply_moe_activation(
# Activations with gated multiplication (gate × activation(up))
if activation == MoEActivation.SILU:
torch.ops._C.silu_and_mul(output, input)
if clamp_limit is not None:
# Fused silu(clamp(gate)) * clamp(up); equivalent to swiglu_limit_func.
torch.ops._C.silu_and_mul_with_clamp(output, input, clamp_limit, 1.0, 0.0)
else:
torch.ops._C.silu_and_mul(output, input)
elif activation == MoEActivation.GELU:
torch.ops._C.gelu_and_mul(output, input)
elif activation == MoEActivation.GELU_TANH:
torch.ops._C.gelu_tanh_and_mul(output, input)
elif activation == MoEActivation.SWIGLUOAI:
torch.ops._C.swigluoai_and_mul(output, input)
elif activation == MoEActivation.SWIGLUOAI_UNINTERLEAVE:
# SwiGLU-OAI on packed w13 (gate = first half, up = second half).
assert clamp_limit is not None, "SWIGLUOAI_UNINTERLEAVE requires clamp_limit"
torch.ops._C.silu_and_mul_with_clamp(output, input, clamp_limit, alpha, beta)
elif activation == MoEActivation.SWIGLUSTEP:
from vllm.model_executor.layers.activation import swiglustep_and_mul_triton
@@ -895,6 +895,9 @@ def fp8_w8a16_moe_quant_config(
w1_bias: torch.Tensor | None = None,
w2_bias: torch.Tensor | None = None,
block_shape: list[int] | None = None,
gemm1_alpha: float | None = None,
gemm1_beta: float | None = None,
gemm1_clamp_limit: float | None = None,
) -> FusedMoEQuantConfig:
"""
Construct a quant config for 16-bit float activations and fp8 weights.
@@ -920,6 +923,9 @@ def fp8_w8a16_moe_quant_config(
None,
w2_bias,
),
gemm1_alpha=gemm1_alpha,
gemm1_beta=gemm1_beta,
gemm1_clamp_limit=gemm1_clamp_limit,
)
@@ -130,6 +130,9 @@ def _fwd_kernel_ep_scatter_2(
HIDDEN_SIZE_PAD: tl.constexpr,
SCALE_HIDDEN_SIZE: tl.constexpr,
SCALE_HIDDEN_SIZE_PAD: tl.constexpr,
PACK_UE8M0: tl.constexpr,
SCALE_PACKED_SIZE: tl.constexpr,
SCALE_PACKED_SIZE_PAD: tl.constexpr,
):
start_token_id = tl.program_id(0)
grid_num = tl.num_programs(0)
@@ -137,16 +140,47 @@ def _fwd_kernel_ep_scatter_2(
offset_in = tl.arange(0, HIDDEN_SIZE_PAD)
mask = offset_in < HIDDEN_SIZE
offset_in_s = tl.arange(0, SCALE_HIDDEN_SIZE_PAD)
mask_s = offset_in_s < SCALE_HIDDEN_SIZE
output_tensor_stride0 = output_tensor_stride0.to(tl.int64)
if PACK_UE8M0:
# One int32 per 4 consecutive 32-wide UE8M0 groups, stored MN-major.
offs_pk = tl.arange(0, SCALE_PACKED_SIZE_PAD)
mask_pk = offs_pk < SCALE_PACKED_SIZE
else:
offset_in_s = tl.arange(0, SCALE_HIDDEN_SIZE_PAD)
mask_s = offset_in_s < SCALE_HIDDEN_SIZE
for token_id in range(start_token_id, total_token_num, grid_num):
to_copy = tl.load(recv_x + token_id * recv_x_stride0 + offset_in, mask=mask)
to_copy_s = tl.load(
recv_x_scale + token_id * recv_x_scale_stride0 + offset_in_s, mask=mask_s
)
if PACK_UE8M0:
# Pack 4 UE8M0 bytes into one int32 (byte j = group 4*pk+j).
base_s = recv_x_scale + token_id * recv_x_scale_stride0
g0, g1 = offs_pk * 4, offs_pk * 4 + 1
g2, g3 = offs_pk * 4 + 2, offs_pk * 4 + 3
b0 = tl.load(
base_s + g0 * recv_x_scale_stride1, mask=g0 < SCALE_HIDDEN_SIZE
)
b1 = tl.load(
base_s + g1 * recv_x_scale_stride1, mask=g1 < SCALE_HIDDEN_SIZE
)
b2 = tl.load(
base_s + g2 * recv_x_scale_stride1, mask=g2 < SCALE_HIDDEN_SIZE
)
b3 = tl.load(
base_s + g3 * recv_x_scale_stride1, mask=g3 < SCALE_HIDDEN_SIZE
)
packed_s = (
b0.to(tl.int32)
| (b1.to(tl.int32) << 8)
| (b2.to(tl.int32) << 16)
| (b3.to(tl.int32) << 24)
)
else:
to_copy_s = tl.load(
recv_x_scale + token_id * recv_x_scale_stride0 + offset_in_s,
mask=mask_s,
)
for topk_index in tl.range(0, topk_num, 1, num_stages=4):
expert_id = tl.load(recv_topk + token_id * recv_topk_stride0 + topk_index)
@@ -164,11 +198,21 @@ def _fwd_kernel_ep_scatter_2(
output_tensor_ptr = (
output_tensor + dest_token_index_i64 * output_tensor_stride0
)
tl.store(output_tensor_ptr + offset_in, to_copy, mask=mask)
output_tensor_scale_ptr = (
output_tensor_scale + dest_token_index * output_tensor_scale_stride0
)
tl.store(output_tensor_ptr + offset_in, to_copy, mask=mask)
tl.store(output_tensor_scale_ptr + offset_in_s, to_copy_s, mask=mask_s)
if PACK_UE8M0:
tl.store(
output_tensor_scale_ptr + offs_pk * output_tensor_scale_stride1,
packed_s,
mask=mask_pk,
)
else:
tl.store(
output_tensor_scale_ptr + offset_in_s, to_copy_s, mask=mask_s
)
@torch.no_grad()
@@ -183,9 +227,11 @@ def ep_scatter(
output_tensor_scale: torch.Tensor,
m_indices: torch.Tensor,
output_index: torch.Tensor,
block_size: int = 128,
pack_ue8m0: bool = False,
):
BLOCK_E = 128 # token num of per expert is aligned to 128
BLOCK_D = 128 # block size of quantization
BLOCK_D = block_size # block size of activation-scale quantization
num_warps = 8
num_experts = num_recv_tokens_per_expert.shape[0]
hidden_size = recv_x.shape[1]
@@ -195,6 +241,10 @@ def ep_scatter(
assert m_indices.shape[0] % BLOCK_E == 0
assert expert_start_loc.shape[0] == num_experts
# pack_ue8m0: scatter packs 4 UE8M0 bytes per int32; else copies scales as-is.
scale_hidden_size = hidden_size // BLOCK_D
scale_packed_size = (scale_hidden_size + 3) // 4 if pack_ue8m0 else 1
_fwd_kernel_ep_scatter_1[(grid,)](
num_recv_tokens_per_expert,
expert_start_loc,
@@ -234,8 +284,11 @@ def ep_scatter(
num_warps=num_warps,
HIDDEN_SIZE=hidden_size,
HIDDEN_SIZE_PAD=triton.next_power_of_2(hidden_size),
SCALE_HIDDEN_SIZE=hidden_size // BLOCK_D,
SCALE_HIDDEN_SIZE_PAD=triton.next_power_of_2(hidden_size // BLOCK_D),
SCALE_HIDDEN_SIZE=scale_hidden_size,
SCALE_HIDDEN_SIZE_PAD=triton.next_power_of_2(scale_hidden_size),
PACK_UE8M0=pack_ue8m0,
SCALE_PACKED_SIZE=scale_packed_size,
SCALE_PACKED_SIZE_PAD=triton.next_power_of_2(scale_packed_size),
)
return
@@ -352,6 +405,7 @@ def deepgemm_moe_permute(
expert_map: torch.Tensor | None,
expert_tokens_meta: mk.ExpertTokensMetadata | None,
aq_out: torch.Tensor | None = None,
block_size: int | None = None,
):
assert aq.ndim == 2
assert topk_ids.dtype.is_signed, "The kernel uses -1 to represent invalid topk_ids"
@@ -359,6 +413,10 @@ def deepgemm_moe_permute(
device = aq.device
block_m, block_k = get_mk_alignment_for_contiguous_layout()
# The activation-scale group size may differ from the M/K tile alignment
# (e.g. MXFP8 uses a 32-element scale group while block_k stays 128).
if block_size is not None:
block_k = block_size
M_sum = compute_aligned_M(
M=topk_ids.size(0),
@@ -376,9 +434,21 @@ def deepgemm_moe_permute(
if aq_out is None:
aq_out = torch.empty((M_sum, H), device=device, dtype=aq.dtype)
aq_scale_out = torch.empty(
(M_sum, H // block_k), device=device, dtype=torch.float32
)
# uint8 UE8M0 (MXFP8) -> scatter packs into DeepGEMM's int32 MN-major
# TMA-aligned layout; float32 (FP8/FP4) scattered row-major as-is.
pack_ue8m0 = aq_scale.dtype == torch.uint8
sf_k = H // block_k
if pack_ue8m0:
packed_sf_k = (sf_k + 3) // 4
tma_aligned_mn = round_up(M_sum, 4)
aq_scale_out = torch.empty_strided(
(M_sum, packed_sf_k),
(1, tma_aligned_mn),
device=device,
dtype=torch.int32,
)
else:
aq_scale_out = torch.empty((M_sum, sf_k), device=device, dtype=torch.float32)
# DeepGEMM uses negative values in m_indices (here expert_ids) to mark
# completely invalid / padded blocks that should be skipped. We always
@@ -412,6 +482,8 @@ def deepgemm_moe_permute(
output_tensor_scale=aq_scale_out,
m_indices=expert_ids,
output_index=inv_perm,
block_size=block_k,
pack_ue8m0=pack_ue8m0,
)
return aq_out, aq_scale_out, expert_ids, inv_perm
@@ -33,7 +33,10 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
kFp8Dynamic128Sym,
kFp8Static128BlockSym,
kMxfp4Static,
kMxfp8Dynamic,
kMxfp8Static,
)
from vllm.platforms import current_platform
from vllm.utils.deep_gemm import (
DeepGemmQuantScaleFMT,
get_mk_alignment_for_contiguous_layout,
@@ -123,12 +126,26 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
def __init__(self, moe_config: FusedMoEConfig, quant_config: FusedMoEQuantConfig):
super().__init__(moe_config=moe_config, quant_config=quant_config)
assert quant_config.block_shape == get_mk_alignment_for_contiguous_layout()
assert quant_config.quant_dtype == torch.float8_e4m3fn
# MXFP8: FP8 e4m3 values + UE8M0 1x32 block scales (Blackwell). Reuses
# the same grouped GEMM (aliased to fp8_fp4) with recipe (1, 32).
self.mxfp8 = quant_config.block_shape == [1, 32]
if self.mxfp8:
assert quant_config.quant_dtype == "mxfp8"
else:
assert quant_config.block_shape == get_mk_alignment_for_contiguous_layout()
assert quant_config.quant_dtype == torch.float8_e4m3fn
assert not quant_config.per_act_token_quant
assert not quant_config.per_out_ch_quant
self.gemm1_clamp_limit = quant_config.gemm1_clamp_limit
# Gated-activation params: silu == swigluoai with alpha=1, beta=0.
# FP8 (silu) configs leave these None, reproducing plain silu.
self.gemm1_alpha = (
quant_config.gemm1_alpha if quant_config.gemm1_alpha is not None else 1.0
)
self.gemm1_beta = (
quant_config.gemm1_beta if quant_config.gemm1_beta is not None else 0.0
)
@staticmethod
def activation_format() -> mk.FusedMoEActivationFormat:
@@ -147,14 +164,25 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
weight_key: QuantKey | None,
activation_key: QuantKey | None,
) -> bool:
SUPPORTED_W_A = [
(kFp8Static128BlockSym, kFp8Dynamic128Sym),
]
return (weight_key, activation_key) in SUPPORTED_W_A
if (weight_key, activation_key) == (kFp8Static128BlockSym, kFp8Dynamic128Sym):
return True
# MXFP8 1x32 uses the fp8_fp4 grouped GEMM with recipe (1, 32) — only
# available on Blackwell (SM100).
if (weight_key, activation_key) == (kMxfp8Static, kMxfp8Dynamic):
return current_platform.is_device_capability_family(100)
return False
@staticmethod
def _supports_activation(activation: MoEActivation) -> bool:
return activation in [MoEActivation.SILU, MoEActivation.SWIGLUSTEP]
# silu/swigluoai go through the fused alpha/beta kernel; swiglustep
# uses the unfused activation path. The fused kernel reads packed w13
# (gate = first half, up = second half), so it implements the
# *uninterleaved* SwiGLU-OAI variant.
return activation in [
MoEActivation.SILU,
MoEActivation.SWIGLUSTEP,
MoEActivation.SWIGLUOAI_UNINTERLEAVE,
]
@staticmethod
def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool:
@@ -179,7 +207,9 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
activation: MoEActivation,
) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
assert self.block_shape is not None
block_m = self.block_shape[0]
# Use the contiguous-layout M alignment (matches apply()); block_shape[0]
# is the quant block (1 for MXFP8) and would under-size the workspace.
block_m = get_mk_alignment_for_contiguous_layout()[0]
M_sum = compute_aligned_M(
M, topk, local_num_experts, block_m, expert_tokens_meta
)
@@ -201,14 +231,24 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
M_sum, N = input.size()
activation_out_dim = self.adjust_N_for_activation(N, activation)
# 1. DeepGemm UE8M0: fused SiLU+mul+clamp+quant+pack
# silu and swigluoai are both expressible by the fused gated kernel via
# (alpha, beta): silu uses alpha=1, beta=0; swigluoai uses config values.
# The fused kernel reads packed w13, hence SWIGLUOAI_UNINTERLEAVE.
fused_gated = activation in (
MoEActivation.SILU,
MoEActivation.SWIGLUOAI_UNINTERLEAVE,
)
# 1. DeepGemm UE8M0: fused gate+mul+clamp+quant+pack
if scale_fmt == DeepGemmQuantScaleFMT.UE8M0:
if activation == MoEActivation.SILU:
if fused_gated:
return fused_silu_mul_fp8_quant_packed(
input=input,
output_q=output,
group_size=block_k,
clamp_limit=self.gemm1_clamp_limit,
alpha=self.gemm1_alpha,
beta=self.gemm1_beta,
)
act_out = torch.empty(
(M_sum, activation_out_dim), dtype=input.dtype, device=input.device
@@ -221,14 +261,17 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
)
return a2q, a2q_scale
# 2. Hopper / nonE8M0: prefer the fused SiLU+mul+quant kernel
if activation == MoEActivation.SILU:
# 2. Hopper / nonE8M0: prefer the fused gate+mul+quant kernel
if fused_gated:
use_ue8m0 = scale_fmt == DeepGemmQuantScaleFMT.FLOAT32_CEIL_UE8M0
return silu_mul_per_token_group_quant_fp8_colmajor(
input=input,
output=output,
use_ue8m0=use_ue8m0,
clamp_limit=self.gemm1_clamp_limit,
group_size=block_k,
alpha=self.gemm1_alpha,
beta=self.gemm1_beta,
)
# 3. fallback path for non-SiLU activations in nonUE8M0 cases.
@@ -292,12 +335,23 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
expert_map=expert_map,
expert_tokens_meta=expert_tokens_meta,
aq_out=a1q_perm,
# MXFP8 uses a 32-element activation-scale group (block_shape[1]);
# FP8-block keeps the default (128) alignment.
block_size=self.block_shape[1] if self.mxfp8 else None,
)
assert a1q.size(0) == M_sum
# MXFP8 (1x32) drives the fp8_fp4-aliased grouped GEMM with recipe
# (1, 32); the FP8 block path keeps the default (128) recipe.
gemm_kwargs = (
{"recipe_a": (1, self.block_shape[1]), "recipe_b": (1, self.block_shape[1])}
if self.mxfp8
else {}
)
mm1_out = _resize_cache(workspace2, (M_sum, N))
m_grouped_fp8_gemm_nt_contiguous(
(a1q, a1q_scale), (w1, self.w1_scale), mm1_out, expert_ids
(a1q, a1q_scale), (w1, self.w1_scale), mm1_out, expert_ids, **gemm_kwargs
)
activation_out_dim = self.adjust_N_for_activation(N, activation)
@@ -310,7 +364,7 @@ class DeepGemmExperts(mk.FusedMoEExpertsModular):
mm2_out = _resize_cache(workspace2, (M_sum, K))
m_grouped_fp8_gemm_nt_contiguous(
(a2q, a2q_scale), (w2, self.w2_scale), mm2_out, expert_ids
(a2q, a2q_scale), (w2, self.w2_scale), mm2_out, expert_ids, **gemm_kwargs
)
if apply_router_weight_on_input:
@@ -28,10 +28,7 @@ from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
TopKWeightAndReduceDelegate,
TopKWeightAndReduceNoOP,
)
from vllm.model_executor.layers.fused_moe.utils import (
_resize_cache,
swiglu_limit_func,
)
from vllm.model_executor.layers.fused_moe.utils import _resize_cache
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
get_marlin_input_dtype,
marlin_make_workspace_new,
@@ -72,9 +69,7 @@ def _fused_marlin_moe(
expert_ids: torch.Tensor,
num_tokens_post_padded: torch.Tensor,
activation: MoEActivation = MoEActivation.SILU,
activation_func: Callable[
[MoEActivation, torch.Tensor, torch.Tensor], None
] = apply_moe_activation,
activation_func: Callable[..., None] = apply_moe_activation,
input_global_scale1: torch.Tensor | None = None,
input_global_scale2: torch.Tensor | None = None,
global_scale1: torch.Tensor | None = None,
@@ -92,6 +87,8 @@ def _fused_marlin_moe(
input_dtype: torch.dtype | None = None,
is_k_full: bool = True,
clamp_limit: float | None = None,
gemm1_alpha: float = 1.0,
gemm1_beta: float = 0.0,
) -> torch.Tensor:
assert hidden_states.ndim == 2
M, K = hidden_states.size()
@@ -159,18 +156,16 @@ def _fused_marlin_moe(
use_fp32_reduce=True,
is_zp_float=False,
)
if clamp_limit is not None and activation == MoEActivation.SILU:
swiglu_limit_func(
intermediate_cache2,
intermediate_cache1.view(-1, w13_num_shards * N),
clamp_limit,
)
else:
activation_func(
activation,
intermediate_cache2,
intermediate_cache1.view(-1, w13_num_shards * N),
)
# apply_moe_activation fuses the clamp/gate params: SILU + clamp_limit and
# SWIGLUOAI_UNINTERLEAVE both map to the silu_and_mul_with_clamp kernel.
activation_func(
activation,
intermediate_cache2,
intermediate_cache1.view(-1, w13_num_shards * N),
clamp_limit=clamp_limit,
alpha=gemm1_alpha,
beta=gemm1_beta,
)
if output is None:
output = intermediate_cache3
@@ -236,9 +231,7 @@ def fused_marlin_moe(
apply_router_weight_on_input: bool = False,
global_num_experts: int = -1,
activation: MoEActivation = MoEActivation.SILU,
activation_func: Callable[
[MoEActivation, torch.Tensor, torch.Tensor], None
] = apply_moe_activation,
activation_func: Callable[..., None] = apply_moe_activation,
moe_sum: Callable[[torch.Tensor, torch.Tensor], None] | None = None,
expert_map: torch.Tensor | None = None,
input_global_scale1: torch.Tensor | None = None,
@@ -258,6 +251,8 @@ def fused_marlin_moe(
output: torch.Tensor | None = None,
input_dtype: torch.dtype | None = None,
clamp_limit: float | None = None,
gemm1_alpha: float = 1.0,
gemm1_beta: float = 0.0,
) -> torch.Tensor:
"""
This function computes a Mixture of Experts (MoE) layer using two sets of
@@ -371,6 +366,8 @@ def fused_marlin_moe(
input_dtype=input_dtype,
is_k_full=is_k_full,
clamp_limit=clamp_limit,
gemm1_alpha=gemm1_alpha,
gemm1_beta=gemm1_beta,
).view(-1, topk, K)
if output is None:
@@ -413,6 +410,8 @@ def batched_fused_marlin_moe(
output: torch.Tensor | None = None,
input_dtype: torch.dtype | None = None,
clamp_limit: float | None = None,
gemm1_alpha: float = 1.0,
gemm1_beta: float = 0.0,
) -> torch.Tensor:
"""
This function massages the inputs so the batched hidden_states can be
@@ -542,6 +541,8 @@ def batched_fused_marlin_moe(
input_dtype=input_dtype,
is_k_full=is_k_full,
clamp_limit=clamp_limit,
gemm1_alpha=gemm1_alpha,
gemm1_beta=gemm1_beta,
)
output = output.view(B, BATCH_TOKENS_MAX, K)
@@ -577,6 +578,15 @@ class MarlinExpertsBase(mk.FusedMoEExpertsModular):
self.is_k_full = is_k_full
self.input_dtype = get_marlin_input_dtype()
self.gemm1_clamp_limit = quant_config.gemm1_clamp_limit
# Gated-activation params (used by SWIGLUOAI_UNINTERLEAVE on packed w13).
# silu == swigluoai with alpha=1, beta=0; configs that don't set these
# (plain silu) fall back to the silu identity.
self.gemm1_alpha = (
quant_config.gemm1_alpha if quant_config.gemm1_alpha is not None else 1.0
)
self.gemm1_beta = (
quant_config.gemm1_beta if quant_config.gemm1_beta is not None else 0.0
)
super().__init__(
moe_config=moe_config,
@@ -623,6 +633,7 @@ class MarlinExpertsBase(mk.FusedMoEExpertsModular):
MoEActivation.GELU,
MoEActivation.GELU_TANH,
MoEActivation.SWIGLUOAI,
MoEActivation.SWIGLUOAI_UNINTERLEAVE,
MoEActivation.SWIGLUSTEP,
MoEActivation.SILU_NO_MUL,
MoEActivation.GELU_NO_MUL,
@@ -783,6 +794,8 @@ class MarlinExperts(LoRAExpertsMixin, MarlinExpertsBase):
is_k_full=self.is_k_full,
input_dtype=self.input_dtype,
clamp_limit=self.gemm1_clamp_limit,
gemm1_alpha=self.gemm1_alpha,
gemm1_beta=self.gemm1_beta,
)
return
@@ -801,6 +814,10 @@ class MarlinExperts(LoRAExpertsMixin, MarlinExpertsBase):
act_enum: MoEActivation,
act_output: torch.Tensor,
act_input: torch.Tensor,
*,
clamp_limit: float | None = None,
alpha: float = 1.0,
beta: float = 0.0,
) -> None:
# act_input = intermediate_cache1 (M*topk, 2N for gated)
# act_output = intermediate_cache2 (M*topk, N)
@@ -830,7 +847,14 @@ class MarlinExperts(LoRAExpertsMixin, MarlinExpertsBase):
"tlm": token_lora_mapping,
}
)
self.activation(act_enum, act_output, act_input)
self.activation(
act_enum,
act_output,
act_input,
clamp_limit=clamp_limit,
alpha=alpha,
beta=beta,
)
lora_state["cache2"] = act_output
def moe_sum_with_lora(moe_out: torch.Tensor, out: torch.Tensor) -> None:
@@ -884,6 +908,8 @@ class MarlinExperts(LoRAExpertsMixin, MarlinExpertsBase):
is_k_full=self.is_k_full,
input_dtype=self.input_dtype,
clamp_limit=self.gemm1_clamp_limit,
gemm1_alpha=self.gemm1_alpha,
gemm1_beta=self.gemm1_beta,
)
def moe_sum(self, input: torch.Tensor, output: torch.Tensor) -> None:
@@ -992,4 +1018,6 @@ class BatchedMarlinExperts(MarlinExpertsBase):
input_dtype=self.input_dtype,
is_k_full=self.is_k_full,
clamp_limit=self.gemm1_clamp_limit,
gemm1_alpha=self.gemm1_alpha,
gemm1_beta=self.gemm1_beta,
)
@@ -0,0 +1,158 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MXFP8 (1x32 block, E8M0 scale) MoE experts on Triton.
``Mxfp8TritonExpertsBase`` stashes E8M0 weight scales for checkpoint layout.
``Mxfp8EmulationTritonExperts`` dequantizes to BF16 and runs ``TritonExperts``
for devices without a native MXFP8 MoE kernel (e.g. ROCm gfx942 / MI300).
"""
import torch
import vllm.model_executor.layers.fused_moe.modular_kernel as mk
from vllm.logger import init_logger
from vllm.model_executor.layers.fused_moe.config import (
FusedMoEConfig,
FusedMoEQuantConfig,
)
from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts
from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
dequant_mxfp8_to_bf16,
)
from vllm.model_executor.layers.quantization.utils.quant_utils import (
QuantKey,
kMxfp8Dynamic,
kMxfp8Static,
)
logger = init_logger(__name__)
class Mxfp8TritonExpertsBase(TritonExperts):
"""Shared MXFP8 MoE setup: stash E8M0 scales, clear scales on ``quant_config``."""
def __init__(
self,
moe_config: FusedMoEConfig,
quant_config: FusedMoEQuantConfig,
):
super().__init__(moe_config, quant_config)
self.w1_scale_val = self.quant_config.w1_scale
self.w2_scale_val = self.quant_config.w2_scale
self.quant_config._w1.scale = None
self.quant_config._w2.scale = None
@staticmethod
def _supports_quant_scheme(
weight_key: QuantKey | None,
activation_key: QuantKey | None,
) -> bool:
return (weight_key, activation_key) == (kMxfp8Static, kMxfp8Dynamic)
@staticmethod
def _supports_activation(activation) -> bool:
from vllm.model_executor.layers.fused_moe.activation import MoEActivation
if activation == MoEActivation.SWIGLUOAI_UNINTERLEAVE:
return True
return TritonExperts._supports_activation(activation)
class Mxfp8EmulationTritonExperts(Mxfp8TritonExpertsBase):
"""Dequantize MXFP8 weights to BF16 on the fly and run ``TritonExperts``."""
def __init__(
self,
moe_config: FusedMoEConfig,
quant_config: FusedMoEQuantConfig,
):
super().__init__(moe_config, quant_config)
logger.warning_once(
"Using Mxfp8EmulationTritonExperts MoE backend. Weights are "
"dequantized to BF16 on the fly; this is slower than a native "
"MXFP8 MoE kernel and is intended for devices without one."
)
@property
def quant_dtype(self) -> torch.dtype | str | None:
# BF16 fallback: do not MXFP8-quantize activations in ``TritonExperts``.
return None
@property
def block_shape(self) -> list[int] | None:
return None
@property
def expects_unquantized_inputs(self) -> bool:
return True
@staticmethod
def _supports_current_device() -> bool:
return True
def activation(self, activation, output: torch.Tensor, input: torch.Tensor):
"""Apply GEMM1 activation with quant-config alpha/beta/clamp."""
from vllm.model_executor.layers.fused_moe.activation import (
MoEActivation,
apply_moe_activation,
)
if activation == MoEActivation.SWIGLUOAI_UNINTERLEAVE:
limit = self.quant_config.gemm1_clamp_limit
if limit is None:
raise ValueError(
"SWIGLUOAI_UNINTERLEAVE requires gemm1_clamp_limit"
)
alpha = self.quant_config.gemm1_alpha
alpha = 1.702 if alpha is None else float(alpha)
beta = self.quant_config.gemm1_beta
beta = 1.0 if beta is None else float(beta)
apply_moe_activation(
activation,
output,
input,
clamp_limit=float(limit),
alpha=alpha,
beta=beta,
)
return
super().activation(activation, output, input)
def apply(
self,
output: torch.Tensor,
hidden_states: torch.Tensor,
w1: torch.Tensor,
w2: torch.Tensor,
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
activation,
global_num_experts: int,
expert_map: torch.Tensor | None,
a1q_scale: torch.Tensor | None,
a2_scale: torch.Tensor | None,
workspace13: torch.Tensor,
workspace2: torch.Tensor,
expert_tokens_meta: mk.ExpertTokensMetadata | None,
apply_router_weight_on_input: bool,
):
w1_bf16 = dequant_mxfp8_to_bf16(w1, self.w1_scale_val).to(hidden_states.dtype)
w2_bf16 = dequant_mxfp8_to_bf16(w2, self.w2_scale_val).to(hidden_states.dtype)
super().apply(
output=output,
hidden_states=hidden_states,
w1=w1_bf16,
w2=w2_bf16,
topk_weights=topk_weights,
topk_ids=topk_ids,
activation=activation,
global_num_experts=global_num_experts,
expert_map=expert_map,
a1q_scale=None,
a2_scale=None,
workspace13=workspace13,
workspace2=workspace2,
expert_tokens_meta=expert_tokens_meta,
apply_router_weight_on_input=apply_router_weight_on_input,
)
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Native MXFP8 (1x32 block, E8M0 scale) MoE for AMD CDNA4 (gfx950) via Triton
``tl.dot_scaled`` (hardware microscaling matmul).
The expert GEMMs consume the FP8 E4M3 weights and their E8M0 block scales
directly (no dequant-to-BF16), and activations are MXFP8-quantized per token.
On CDNA4 ``dot_scaled`` maps to the native MX matrix-core ops; on other archs
Triton upcasts to BF16 (so this stays correct, just not faster) but the
oracle only selects this path on gfx950 and routes everything else to the
BF16 ``Mxfp8EmulationTritonExperts`` fallback.
Structure mirrors vLLM's ``fused_moe_kernel``: tokens are sorted by expert
(``moe_align_block_size``); each program computes a ``[BLOCK_M, BLOCK_N]`` tile
for one expert, accumulating over K with ``dot_scaled``. SwiGLU-OAI activation
and the top-k weighted reduction run in PyTorch between/after the two GEMMs.
"""
import torch
import vllm.model_executor.layers.fused_moe.modular_kernel as mk
from vllm.logger import init_logger
from vllm.model_executor.layers.fused_moe.config import (
FusedMoEConfig,
FusedMoEQuantConfig,
)
from vllm.model_executor.layers.fused_moe.experts.mxfp8_emulation_moe import (
Mxfp8TritonExpertsBase,
)
from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
moe_align_block_size,
)
from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
MXFP8_BLOCK_SIZE,
mxfp8_e4m3_quantize,
)
from vllm.platforms import current_platform
from vllm.triton_utils import tl, triton
logger = init_logger(__name__)
@triton.jit
def _mxfp8_grouped_gemm_kernel(
a_ptr,
a_scale_ptr,
b_ptr,
b_scale_ptr,
c_ptr,
topk_weights_ptr,
sorted_token_ids_ptr,
expert_ids_ptr,
num_tokens_post_padded_ptr,
N,
K,
num_valid_tokens,
top_k,
stride_am,
stride_ak,
stride_asm,
stride_ask,
stride_be,
stride_bn,
stride_bk,
stride_bse,
stride_bsn,
stride_bsk,
stride_cm,
stride_cn,
A_DIV: tl.constexpr,
MUL_WEIGHT: tl.constexpr,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
BLOCK_K: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
num_post = tl.load(num_tokens_post_padded_ptr)
if pid_m * BLOCK_M >= num_post:
return
offs_tid = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_token = tl.load(sorted_token_ids_ptr + offs_tid).to(tl.int64)
token_mask = offs_token < num_valid_tokens
off_e = tl.load(expert_ids_ptr + pid_m).to(tl.int64)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
offs_k = tl.arange(0, BLOCK_K)
offs_sk = tl.arange(0, BLOCK_K // 32)
a_row = offs_token // A_DIV
a_ptrs = a_ptr + a_row[:, None] * stride_am + offs_k[None, :] * stride_ak
as_ptrs = a_scale_ptr + a_row[:, None] * stride_asm + offs_sk[None, :] * stride_ask
b_ptrs = b_ptr + off_e * stride_be + offs_n[:, None] * stride_bn + \
offs_k[None, :] * stride_bk
bs_ptrs = b_scale_ptr + off_e * stride_bse + offs_n[:, None] * stride_bsn + \
offs_sk[None, :] * stride_bsk
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
n_mask = offs_n < N
for _ in range(0, tl.cdiv(K, BLOCK_K)):
a = tl.load(a_ptrs, mask=token_mask[:, None], other=0.0)
b = tl.load(b_ptrs, mask=n_mask[:, None], other=0.0)
asc = tl.load(as_ptrs, mask=token_mask[:, None], other=0)
bsc = tl.load(bs_ptrs, mask=n_mask[:, None], other=0)
acc += tl.dot_scaled(a, asc, "e4m3", b.T, bsc, "e4m3")
a_ptrs += BLOCK_K * stride_ak
b_ptrs += BLOCK_K * stride_bk
as_ptrs += (BLOCK_K // 32) * stride_ask
bs_ptrs += (BLOCK_K // 32) * stride_bsk
if MUL_WEIGHT:
w = tl.load(topk_weights_ptr + offs_token, mask=token_mask, other=0.0)
acc = acc * w[:, None]
c_ptrs = c_ptr + offs_token[:, None] * stride_cm + offs_n[None, :] * stride_cn
tl.store(c_ptrs, acc.to(c_ptr.dtype.element_ty),
mask=token_mask[:, None] & n_mask[None, :])
def _grouped_gemm_mxfp8(
a_q: torch.Tensor, # [M, K] fp8 e4m3
a_scale: torch.Tensor, # [M, K//32] uint8 (E8M0)
w: torch.Tensor, # [E, N, K] fp8 e4m3
w_scale: torch.Tensor, # [E, N, K//32] uint8 (E8M0)
sorted_token_ids: torch.Tensor,
expert_ids: torch.Tensor,
num_tokens_post_padded: torch.Tensor,
num_valid_tokens: int,
top_k: int,
block_m: int,
out_dtype: torch.dtype,
a_div: int,
mul_weight_by: torch.Tensor | None = None,
) -> torch.Tensor:
M_routed = num_valid_tokens
E, N, K = w.shape
assert K % 128 == 0, f"MXFP8 native MoE requires K%128==0, got K={K}"
out = torch.zeros((M_routed, N), dtype=out_dtype, device=a_q.device)
BLOCK_N = 128
BLOCK_K = 128
grid = (triton.cdiv(sorted_token_ids.shape[0], block_m), triton.cdiv(N, BLOCK_N))
_mxfp8_grouped_gemm_kernel[grid](
a_q, a_scale, w, w_scale, out,
mul_weight_by if mul_weight_by is not None else a_q,
sorted_token_ids, expert_ids, num_tokens_post_padded,
N, K, num_valid_tokens, top_k,
a_q.stride(0), a_q.stride(1), a_scale.stride(0), a_scale.stride(1),
w.stride(0), w.stride(1), w.stride(2),
w_scale.stride(0), w_scale.stride(1), w_scale.stride(2),
out.stride(0), out.stride(1),
A_DIV=a_div,
MUL_WEIGHT=mul_weight_by is not None,
BLOCK_M=block_m, BLOCK_N=BLOCK_N, BLOCK_K=BLOCK_K,
num_warps=8,
)
return out
def fused_moe_mxfp8_native(
hidden_states: torch.Tensor, # [T, H] bf16
w13: torch.Tensor, # [E, 2I, H] fp8
w13_scale: torch.Tensor, # [E, 2I, H//32] uint8
w2: torch.Tensor, # [E, H, I] fp8
w2_scale: torch.Tensor, # [E, H, I//32] uint8
topk_weights: torch.Tensor, # [T, top_k]
topk_ids: torch.Tensor, # [T, top_k] (global expert ids)
*,
alpha: float,
beta: float,
limit: float | None,
global_num_experts: int,
expert_map: torch.Tensor | None,
) -> torch.Tensor:
T, H = hidden_states.shape
top_k = topk_ids.shape[1]
M = T * top_k
block_m = 64
sorted_ids, expert_ids, num_post = moe_align_block_size(
topk_ids, block_m, global_num_experts, expert_map
)
# GEMM1: x (mxfp8) @ w13^T -> [M, 2I]
a_q, a_s = mxfp8_e4m3_quantize(hidden_states)
g1 = _grouped_gemm_mxfp8(
a_q, a_s, w13, w13_scale, sorted_ids, expert_ids, num_post,
M, top_k, block_m, hidden_states.dtype, a_div=top_k,
) # [M, 2I]
# SwiGLU-OAI (split layout): gate=g1[:, :I], up=g1[:, I:] — fp32 Triton pass.
# Not the #22 ``silu_and_mul_with_clamp`` op: it is built on ROCm but rounds
# intermediates to bf16 (rel ~3e-3 vs ~1e-6 here), and this feeds the GEMM2
# MXFP8 quant. Lazy import: the amd.ops package pulls in the minimax_m3
# platform dispatch, only resolvable after the model module finishes loading.
from vllm.models.minimax_m3.amd.ops import swiglu_oai_split
act = swiglu_oai_split(
g1, alpha=alpha, beta=beta, limit=limit, out_dtype=hidden_states.dtype
) # [M, I]
# GEMM2: act (mxfp8) @ w2^T -> [M, H], weighted by topk_weights, then reduce.
act_q, act_s = mxfp8_e4m3_quantize(act)
g2 = _grouped_gemm_mxfp8(
act_q, act_s, w2, w2_scale, sorted_ids, expert_ids, num_post,
M, top_k, block_m, torch.float32, a_div=1,
mul_weight_by=topk_weights.reshape(-1).to(torch.float32),
) # [M, H] == [T*top_k, H]
return g2.view(T, top_k, H).sum(dim=1).to(hidden_states.dtype)
class Mxfp8NativeTritonExperts(Mxfp8TritonExpertsBase):
"""Native MXFP8 MoE (CDNA4 ``dot_scaled``) on gfx950."""
@property
def quant_dtype(self) -> torch.dtype | str | None:
return self.quant_config.quant_dtype
@property
def block_shape(self) -> list[int] | None:
return self.quant_config.block_shape
@property
def expects_unquantized_inputs(self) -> bool:
# Activations are MXFP8-quantized inside ``fused_moe_mxfp8_native``.
return True
@staticmethod
def _supports_current_device() -> bool:
return current_platform.is_rocm() and current_platform.supports_mx()
def apply(
self,
output: torch.Tensor,
hidden_states: torch.Tensor,
w1: torch.Tensor,
w2: torch.Tensor,
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
activation,
global_num_experts: int,
expert_map: torch.Tensor | None,
a1q_scale: torch.Tensor | None,
a2_scale: torch.Tensor | None,
workspace13: torch.Tensor,
workspace2: torch.Tensor,
expert_tokens_meta: mk.ExpertTokensMetadata | None,
apply_router_weight_on_input: bool,
):
alpha = self.quant_config.gemm1_alpha
alpha = 1.702 if alpha is None else float(alpha)
beta = self.quant_config.gemm1_beta
beta = 1.0 if beta is None else float(beta)
limit = self.quant_config.gemm1_clamp_limit
limit = None if limit is None else float(limit)
out = fused_moe_mxfp8_native(
hidden_states, w1, self.w1_scale_val, w2, self.w2_scale_val,
topk_weights, topk_ids,
alpha=alpha, beta=beta, limit=limit,
global_num_experts=global_num_experts, expert_map=expert_map,
)
output.copy_(out)
@@ -120,6 +120,8 @@ class FusedMoE(PluggableLayer):
scoring_func: str = "softmax",
routed_scaling_factor: float = 1.0,
swiglu_limit: float | None = None,
swiglu_alpha: float | None = None,
swiglu_beta: float | None = None,
e_score_correction_bias: torch.Tensor | None = None,
apply_router_weight_on_input: bool = False,
activation: str = "silu",
@@ -149,6 +151,10 @@ class FusedMoE(PluggableLayer):
vllm_config = get_current_vllm_config()
self.vllm_config = vllm_config
self.swiglu_limit = swiglu_limit
# SwiGLU/swigluoai gate params (alpha, beta); read by quant configs
# (e.g. ModelOptMxFp8FusedMoE) to plumb into the fused activation kernel.
self.swiglu_alpha = swiglu_alpha
self.swiglu_beta = swiglu_beta
# FIXME (varun): We should have a better way of inferring the activation
# datatype. This works for now as the tensor datatype entering the MoE
@@ -880,9 +880,18 @@ class FusedMoEExpertsModular(FusedMoEExperts):
return N if not activation.is_gated else N // 2
def activation(
self, activation: MoEActivation, output: torch.Tensor, input: torch.Tensor
self,
activation: MoEActivation,
output: torch.Tensor,
input: torch.Tensor,
*,
clamp_limit: float | None = None,
alpha: float = 1.0,
beta: float = 0.0,
) -> None:
apply_moe_activation(activation, output, input)
apply_moe_activation(
activation, output, input, clamp_limit=clamp_limit, alpha=alpha, beta=beta
)
@abstractmethod
def finalize_weight_and_reduce_impl(self) -> TopKWeightAndReduce:
@@ -53,6 +53,13 @@ class Fp8MoeBackend(Enum):
BATCHED_VLLM_CUTLASS = "BATCHED_VLLM_CUTLASS"
XPU = "XPU"
CPU = "CPU"
# Dequantize-to-BF16 emulation for MXFP8 on devices without a native
# MXFP8 MoE kernel (e.g. ROCm). Weights pass through unchanged here.
EMULATION = "EMULATION"
# MXFP8 MoE via a Triton ``dot_scaled`` kernel that lowers to CDNA4
# (gfx950) native MX matrix-core ops. Weights stay in MXFP8 (no load-time
# format conversion); the FP8 values + E8M0 scales are consumed directly.
NATIVE_MXFP8 = "NATIVE_MXFP8"
def _get_priority_backends(
@@ -499,6 +506,10 @@ def convert_to_fp8_moe_kernel_format(
Fp8MoeBackend.VLLM_CUTLASS,
Fp8MoeBackend.BATCHED_VLLM_CUTLASS,
Fp8MoeBackend.XPU,
# EMULATION dequantizes weights at runtime; NATIVE_MXFP8 consumes
# the MXFP8 weights as-is — neither needs a load-time layout change.
Fp8MoeBackend.EMULATION,
Fp8MoeBackend.NATIVE_MXFP8,
]:
raise ValueError(f"Unsupported FP8 MoE backend: {fp8_backend.value}")
@@ -517,6 +528,8 @@ def make_fp8_moe_quant_config(
per_act_token_quant: bool = False,
per_out_ch_quant: bool = False,
swiglu_limit: float | None = None,
gemm1_alpha: float | None = None,
gemm1_beta: float | None = None,
) -> FusedMoEQuantConfig:
"""
Create FusedMoEQuantConfig for the specified FP8 Backend.
@@ -539,6 +552,9 @@ def make_fp8_moe_quant_config(
w1_bias=w1_bias,
w2_bias=w2_bias,
block_shape=block_shape,
gemm1_alpha=gemm1_alpha,
gemm1_beta=gemm1_beta,
gemm1_clamp_limit=swiglu_limit,
)
# Flashinfer CUTLASS per-tensor uses single dq scale
@@ -558,10 +574,9 @@ def make_fp8_moe_quant_config(
g2_alphas=(w2_scale * a2_scale).squeeze(),
gemm1_clamp_limit=swiglu_limit,
)
# MXFP8 uses "mxfp8" quant_dtype so the prepare step dispatches to
# _mxfp8_e4m3_quantize rather than standard FP8 block quantization.
# Non-swizzled layout is required since the TRTLLM kernel expects
# scales in (num_tokens, hidden_dim // 32) format.
# MXFP8 (block [1, 32]) dispatches to the mxfp8 activation quant. Scales are
# the non-swizzled (num_tokens, hidden_dim // 32) uint8 UE8M0 layout for all
# backends; the DeepGEMM expert permute repacks them for the grouped GEMM.
if block_shape == [1, 32]:
return FusedMoEQuantConfig.make(
"mxfp8",
@@ -573,6 +588,8 @@ def make_fp8_moe_quant_config(
a2_scale=a2_scale,
block_shape=block_shape,
is_scale_swizzled=False,
gemm1_alpha=gemm1_alpha,
gemm1_beta=gemm1_beta,
gemm1_clamp_limit=swiglu_limit,
)
@@ -12,22 +12,43 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
kMxfp8Dynamic,
kMxfp8Static,
)
from vllm.platforms import current_platform
logger = init_logger(__name__)
_SUPPORTED_BACKENDS = (
Fp8MoeBackend.FLASHINFER_TRTLLM,
Fp8MoeBackend.DEEPGEMM,
Fp8MoeBackend.MARLIN,
Fp8MoeBackend.XPU,
)
_BACKEND_NAME_MAP: dict[str, Fp8MoeBackend] = {
"flashinfer_trtllm": Fp8MoeBackend.FLASHINFER_TRTLLM,
"deep_gemm": Fp8MoeBackend.DEEPGEMM,
"marlin": Fp8MoeBackend.MARLIN,
"xpu": Fp8MoeBackend.XPU,
}
def _mxfp8_backend_to_kernel_cls(
backend: Fp8MoeBackend,
) -> list[type[mk.FusedMoEExperts]]:
"""Resolve the MXFP8 expert classes for a backend.
DeepGEMM resolves directly to ``DeepGemmExperts`` (not the
``TritonOrDeepGemmExperts`` wrapper, whose Triton fallback cannot handle the
MXFP8 1x32 scheme); all other backends defer to the FP8 resolver.
"""
if backend == Fp8MoeBackend.DEEPGEMM:
from vllm.model_executor.layers.fused_moe.experts.deep_gemm_moe import (
DeepGemmExperts,
)
return [DeepGemmExperts]
return backend_to_kernel_cls(backend)
def _select_kernel_cls(
backend: Fp8MoeBackend,
config: FusedMoEConfig,
@@ -39,7 +60,7 @@ def _select_kernel_cls(
else mk.FusedMoEActivationFormat.Standard
)
last_reason: str | None = None
for cls in backend_to_kernel_cls(backend):
for cls in _mxfp8_backend_to_kernel_cls(backend):
supported, reason = cls.is_supported_config(
cls,
config,
@@ -55,6 +76,29 @@ def _select_kernel_cls(
)
def _select_rocm_mxfp8_backend() -> tuple[Fp8MoeBackend, type[mk.FusedMoEExperts]]:
"""ROCm fallback when vendor MXFP8 backends are unavailable."""
if current_platform.supports_mx():
from vllm.model_executor.layers.fused_moe.experts.mxfp8_native_moe import (
Mxfp8NativeTritonExperts,
)
logger.info_once(
"Using native CDNA4 (gfx950) MXFP8 dot_scaled MoE backend."
)
return Fp8MoeBackend.NATIVE_MXFP8, Mxfp8NativeTritonExperts
from vllm.model_executor.layers.fused_moe.experts.mxfp8_emulation_moe import (
Mxfp8EmulationTritonExperts,
)
logger.info_once(
"No native MXFP8 MoE backend available; using BF16 dequant emulation."
)
return Fp8MoeBackend.EMULATION, Mxfp8EmulationTritonExperts
def select_mxfp8_moe_backend(
config: FusedMoEConfig,
) -> tuple[Fp8MoeBackend, type[mk.FusedMoEExperts]]:
@@ -88,4 +132,8 @@ def select_mxfp8_moe_backend(
logger.info_once("Using '%s' MxFp8 MoE backend.", backend.value)
return backend, experts_cls
raise ValueError("No MXFP8 MoE backends available.")
# simplify the logic for rocm, refactor later when more backends are supported
if current_platform.is_rocm():
return _select_rocm_mxfp8_backend()
raise ValueError("No MXFP8 MoE backends available.")
@@ -313,6 +313,8 @@ def moe_kernel_quantize_input(
"moe_kernel_quantize_input does not support quant_dtype='mxfp8' MOE "
"quantization emulation. Please open an issue."
)
# Non-swizzled (M, K/32) uint8 UE8M0 scales; deepgemm_moe_permute packs
# them for DeepGEMM, TRTLLM takes them as-is.
return _mxfp8_e4m3_quantize(
A,
A_scale,
+186
View File
@@ -1163,6 +1163,7 @@ class QKVParallelLinear(ColumnParallelLinear):
shard_offset = self._get_shard_offset_mapping(loaded_shard_id)
shard_size = self._get_shard_size_mapping(loaded_shard_id)
assert shard_offset is not None and shard_size is not None
if isinstance(param, BlockQuantScaleParameter):
weight_block_size = getattr(self, "weight_block_size", None)
@@ -1384,6 +1385,191 @@ class QKVParallelLinear(ColumnParallelLinear):
param_data.copy_(loaded_weight)
class MinimaxM3QKVParallelLinearWithIndexer(QKVParallelLinear):
"""QKV projection fused with a lightning-indexer's index_q/index_k.
NOTE: MiniMax-M3-specific. This is tailored to the M3 sparse-attention
layers (it assumes the indexer's head count equals the KV head count and
shares the main head_dim); it is not a general-purpose linear layer. It
lives here only to sit alongside QKVParallelLinear, whose sharding /
weight-loading machinery it reuses.
A single column-parallel GEMM emits, per rank::
[q | k | v | index_q | index_k]
``index_q`` must have the same head count as the KV heads
(``total_num_index_heads == total_num_kv_heads``) and ``index_head_size ==
head_size``, so it shards exactly like K/V -- including the KV-head
*replication* path when ``tp_size > total_num_kv_heads`` (this is what makes
a TP size greater than the KV-head count work). ``index_k`` is a single
shared head, replicated to every rank.
"""
def __init__(
self,
hidden_size: int,
head_size: int,
total_num_heads: int,
total_num_kv_heads: int,
total_num_index_heads: int,
index_head_size: int,
bias: bool = False,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
# index_q rides the KV-head sharding/replication path, so its head count
# must match the KV heads.
assert total_num_index_heads == total_num_kv_heads, (
"MinimaxM3QKVParallelLinearWithIndexer requires "
"total_num_index_heads == total_num_kv_heads"
)
self.hidden_size = hidden_size
self.head_size = head_size
self.v_head_size = head_size
self.total_num_heads = total_num_heads
self.total_num_kv_heads = total_num_kv_heads
self.total_num_index_heads = total_num_index_heads
self.index_head_size = index_head_size
tp_size = get_tensor_model_parallel_world_size()
self.num_heads = divide(self.total_num_heads, tp_size)
if tp_size >= self.total_num_kv_heads:
self.num_kv_heads = 1
self.num_kv_head_replicas = divide(tp_size, self.total_num_kv_heads)
else:
self.num_kv_heads = divide(self.total_num_kv_heads, tp_size)
self.num_kv_head_replicas = 1
# index_q shards identically to the KV heads.
self.num_index_heads = self.num_kv_heads
# Global per-group sizes (replicated groups counted x tp_size, matching
# the QKVParallelLinear convention). index_k is a single replicated head.
q = self.num_heads * self.head_size
kv = self.num_kv_heads * self.head_size
iq = self.num_index_heads * self.index_head_size
ik = self.index_head_size
self.output_sizes = [
q * tp_size, # q
kv * tp_size, # k
kv * tp_size, # v
iq * tp_size, # index_q
ik * tp_size, # index_k (replicated)
]
# Skip QKVParallelLinear.__init__ (3-group layout); build the 5-group
# column-parallel weight directly.
ColumnParallelLinear.__init__(
self,
input_size=self.hidden_size,
output_size=sum(self.output_sizes),
bias=bias,
gather_output=False,
quant_config=quant_config,
prefix=prefix,
)
def validate_shard_id(self, loaded_shard_id: str | None) -> None:
if loaded_shard_id is None:
return
if loaded_shard_id not in ("q", "k", "v", "index_q", "index_k"):
raise ValueError(
"Shard id for MinimaxM3QKVParallelLinearWithIndexer must be one of "
"'q', 'k', 'v', 'index_q', 'index_k'; got "
f"{loaded_shard_id}."
)
def _get_shard_offset_mapping(self, loaded_shard_id: str) -> int | None:
h = self.head_size
nq, nkv, nidx = self.num_heads, self.num_kv_heads, self.num_index_heads
return {
"q": 0,
"k": nq * h,
"v": (nq + nkv) * h,
"index_q": (nq + 2 * nkv) * h,
"index_k": (nq + 2 * nkv + nidx) * h,
}.get(loaded_shard_id)
def _get_shard_size_mapping(self, loaded_shard_id: str) -> int | None:
h = self.head_size
return {
"q": self.num_heads * h,
"k": self.num_kv_heads * h,
"v": self.num_kv_heads * h,
"index_q": self.num_index_heads * h,
"index_k": self.index_head_size,
}.get(loaded_shard_id)
def weight_loader_v2(
self,
param: BasevLLMParameter,
loaded_weight: torch.Tensor,
loaded_shard_id: str | None = None,
) -> None:
self.validate_shard_id(loaded_shard_id)
# Index checkpoints are never pre-fused on disk; a shard id is always given.
assert loaded_shard_id in ("q", "k", "v", "index_q", "index_k")
shard_offset = self._get_shard_offset_mapping(loaded_shard_id)
shard_size = self._get_shard_size_mapping(loaded_shard_id)
assert shard_offset is not None and shard_size is not None
if isinstance(param, BlockQuantScaleParameter):
weight_block_size = getattr(self, "weight_block_size", None)
shard_size, shard_offset = adjust_block_scale_shard(
weight_block_size, shard_size, shard_offset
)
# index_k is fully replicated: num_heads == tp_size makes
# load_qkv_weight pick shard_id_int == 0 on every rank. q/k/v/index_q ride
# the KV-head replication factor.
num_heads = (
self.tp_size if loaded_shard_id == "index_k" else self.num_kv_head_replicas
)
param.load_qkv_weight(
loaded_weight=loaded_weight,
num_heads=num_heads,
shard_id=loaded_shard_id,
shard_offset=shard_offset,
shard_size=shard_size,
tp_rank=self.tp_rank,
)
def weight_loader(
self,
param: Parameter,
loaded_weight: torch.Tensor,
loaded_shard_id: str | None = None,
) -> None:
# Unquantized (bf16) path. MXFP8 checkpoints use weight_loader_v2; this
# keeps an unquantized load correct too.
self.validate_shard_id(loaded_shard_id)
assert loaded_shard_id in ("q", "k", "v", "index_q", "index_k")
output_dim = getattr(param, "output_dim", None)
assert output_dim is not None
shard_offset = self._get_shard_offset_mapping(loaded_shard_id)
shard_size = self._get_shard_size_mapping(loaded_shard_id)
assert shard_offset is not None and shard_size is not None
if isinstance(param, BlockQuantScaleParameter):
weight_block_size = getattr(self, "weight_block_size", None)
shard_size, shard_offset = adjust_block_scale_shard(
weight_block_size, shard_size, shard_offset
)
param_data = param.data.narrow(output_dim, shard_offset, shard_size)
if loaded_shard_id == "q":
shard_rank = self.tp_rank
elif loaded_shard_id == "index_k":
shard_rank = 0 # replicated to every rank
else:
shard_rank = self.tp_rank // self.num_kv_head_replicas
loaded_weight = loaded_weight.narrow(
output_dim, shard_rank * shard_size, shard_size
)
assert param_data.shape == loaded_weight.shape
param_data.copy_(loaded_weight)
# --8<-- [start:row_parallel_linear]
@PluggableLayer.register("row_parallel_linear")
class RowParallelLinear(LinearBase):
@@ -165,17 +165,18 @@ def get_quantization_config(quantization: str) -> type[QuantizationConfig]:
"deepseek_v4_fp8": DeepseekV4FP8Config,
"humming": HummingConfig,
"online": OnlineQuantizationConfig,
# MiniMax-style checkpoints tag `quant_method: "mxfp8"`; load with the
# ModelOpt MXFP8 config (same format). The "mxfp8" online shorthand
# below only applies to the `--quantization mxfp8` CLI path.
"mxfp8": ModelOptMxFp8Config,
}
# Register online shorthands as quantization methods so the user can
# specify "LLM(..., quantization='fp8_per_tensor')" as shorthand for
# creating a more complicated online quant config object.
# Register online shorthands (e.g. "fp8_per_tensor") as quant methods.
# setdefault so a shorthand that is also a checkpoint method (e.g. "mxfp8")
# keeps its checkpoint config; the shorthand still works via the
# `--quantization` CLI path in `resolve_quantization_config`.
for shorthand in _ONLINE_SHORTHANDS:
assert shorthand not in method_to_config, (
f"Online quant shorthand {shorthand!r} conflicts with an "
f"existing quantization method"
)
method_to_config[shorthand] = OnlineQuantizationConfig
method_to_config.setdefault(shorthand, OnlineQuantizationConfig)
# Update the `method_to_config` with customized quantization methods.
method_to_config.update(_CUSTOMIZED_METHOD_TO_QUANT_CONFIG)
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from fnmatch import fnmatch
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import torch
from torch.nn.parameter import Parameter
@@ -1714,6 +1714,22 @@ class ModelOptMxFp8Config(ModelOptQuantConfigBase):
return "modelopt_mxfp8"
return None
@classmethod
def from_config(cls, config: dict[str, Any]) -> "ModelOptMxFp8Config":
# MiniMax-style checkpoints tag `quant_method: "mxfp8"` + `ignored_layers`
# (same on-disk format as ModelOpt MXFP8); normalize to the ModelOpt
# schema and reuse the shared parser.
if "quantization" not in config and not config.get("quant_algo"):
config = {
"quant_method": "modelopt",
"quantization": {
"quant_algo": "MXFP8",
"kv_cache_quant_algo": config.get("kv_cache_quant_algo"),
"exclude_modules": config.get("ignored_layers", []) or [],
},
}
return cast("ModelOptMxFp8Config", super().from_config(config))
@classmethod
def _from_config(
cls,
@@ -2125,6 +2141,9 @@ class ModelOptMxFp8FusedMoE(FusedMoEMethodBase):
a1_scale=None,
a2_scale=None,
block_shape=self.weight_block_size,
swiglu_limit=getattr(layer, "swiglu_limit", None),
gemm1_alpha=getattr(layer, "swiglu_alpha", None),
gemm1_beta=getattr(layer, "swiglu_beta", None),
)
def apply_monolithic(
@@ -159,75 +159,104 @@ def _silu_mul_quant_fp8_packed_kernel(
output_q_stride_m,
output_scale_stride_k,
clamp_limit,
alpha,
beta,
N: tl.constexpr,
NUM_GROUPS: tl.constexpr,
GROUPS_PER_ROW: tl.constexpr,
PACKS_PER_ROW: tl.constexpr,
fp8_min: tl.constexpr,
fp8_max: tl.constexpr,
GROUP_SIZE: tl.constexpr,
PACKS_PER_CTA: tl.constexpr,
BLOCK_M: tl.constexpr,
HAS_CLAMP: tl.constexpr,
):
N_2: tl.constexpr = N // 2
GROUPS_PER_PACK: tl.constexpr = 4
hidden_size: tl.constexpr = N // 2
pid_pack = tl.program_id(0)
pid_m = tl.program_id(1)
m_offset = pid_m.to(tl.int64) * BLOCK_M
pack_tile = tl.program_id(0)
row_start = tl.program_id(1).to(tl.int64) * BLOCK_M
row_step = tl.num_programs(1).to(tl.int64) * BLOCK_M
if m_offset >= M:
return
groups_per_cta: tl.constexpr = PACKS_PER_CTA * GROUPS_PER_PACK
elems_per_cta: tl.constexpr = groups_per_cta * GROUP_SIZE
col_start = pack_tile * elems_per_cta
col_offsets = tl.arange(0, elems_per_cta)
row_offsets = tl.arange(0, BLOCK_M)
pack_offsets = tl.arange(0, PACKS_PER_CTA)
offs_m = tl.arange(0, BLOCK_M)
offs_n = tl.arange(0, GROUP_SIZE)
row_mask = (m_offset + offs_m) < M
col_mask = (col_start + col_offsets) < (GROUPS_PER_ROW * GROUP_SIZE)
base_row_offset = (m_offset + offs_m[:, None]) * input_stride_m
base_out_offset = (m_offset + offs_m[:, None]) * output_q_stride_m
# persistent with grid_m-stride loop
while row_start < M:
rows = row_start + row_offsets
row_mask = rows < M
input_row_start = rows[:, None] * input_stride_m
output_row_start = rows[:, None] * output_q_stride_m
packed_scale = tl.zeros((BLOCK_M,), dtype=tl.int32)
gate_flat = tl.load(
input_ptr + input_row_start + col_start + col_offsets[None, :],
mask=row_mask[:, None] & col_mask[None, :],
other=0.0,
)
up_flat = tl.load(
input_ptr
+ input_row_start
+ hidden_size
+ col_start
+ col_offsets[None, :],
mask=row_mask[:, None] & col_mask[None, :],
other=0.0,
)
for pack_idx in tl.static_range(4):
group_id = pid_pack * 4 + pack_idx
gate = tl.reshape(gate_flat, (BLOCK_M, groups_per_cta, GROUP_SIZE)).to(
tl.float32
)
up = tl.reshape(up_flat, (BLOCK_M, groups_per_cta, GROUP_SIZE)).to(tl.float32)
if group_id < NUM_GROUPS:
n_offset = group_id * GROUP_SIZE
if HAS_CLAMP:
gate = tl.minimum(gate, clamp_limit)
up = tl.clamp(up, -clamp_limit, clamp_limit)
act_ptrs = input_ptr + base_row_offset + n_offset + offs_n[None, :]
act_in = tl.load(act_ptrs, mask=row_mask[:, None], other=0.0)
# Unified gated activation: silu == swigluoai with alpha=1, beta=0.
# glu = gate * sigmoid(alpha * gate); y = (up + beta) * glu
glu = gate / (1.0 + tl.exp(-gate * alpha))
y = (up + beta) * glu
# Round through bf16 to match unfused precision path
y = y.to(tl.bfloat16).to(tl.float32)
mul_ptrs = act_ptrs + N_2
mul_in = tl.load(mul_ptrs, mask=row_mask[:, None], other=0.0)
absmax = tl.max(tl.abs(y), axis=2)
scale_raw = tl.maximum(absmax / fp8_max, 1e-10)
exponent = tl.ceil(tl.log2(scale_raw))
scale = tl.math.exp2(exponent)
act_f32 = act_in.to(tl.float32)
mul_f32 = mul_in.to(tl.float32)
y_q = tl.clamp(y / scale[:, :, None], fp8_min, fp8_max)
if HAS_CLAMP:
act_f32 = tl.minimum(act_f32, clamp_limit)
mul_f32 = tl.clamp(mul_f32, -clamp_limit, clamp_limit)
y_q_flat = tl.reshape(y_q, (BLOCK_M, elems_per_cta))
tl.store(
output_q_ptr + output_row_start + col_start + col_offsets[None, :],
y_q_flat.to(output_q_ptr.dtype.element_ty),
mask=row_mask[:, None] & col_mask[None, :],
)
y = (act_f32 / (1.0 + tl.exp(-act_f32))) * mul_f32
# Round through bf16 to match unfused precision path
y = y.to(tl.bfloat16).to(tl.float32)
scale_byte = tl.clamp(exponent + 127.0, 0.0, 255.0).to(tl.int32)
scale_bytes = tl.reshape(scale_byte, (BLOCK_M, PACKS_PER_CTA, GROUPS_PER_PACK))
shifts = tl.arange(0, GROUPS_PER_PACK) * 8
packed_scale = tl.sum(scale_bytes << shifts[None, None, :], axis=2)
absmax = tl.max(tl.abs(y), axis=1)
scale_pack = pack_tile * PACKS_PER_CTA + pack_offsets
scale_ptrs = (
output_scale_ptr
+ scale_pack[None, :] * output_scale_stride_k
+ rows[:, None]
)
tl.store(
scale_ptrs,
packed_scale,
mask=row_mask[:, None] & (scale_pack[None, :] < PACKS_PER_ROW),
)
scale_raw = tl.maximum(absmax / fp8_max, 1e-10)
exponent = tl.ceil(tl.log2(scale_raw))
scale = tl.math.exp2(exponent)
y_q = tl.clamp(y / scale[:, None], fp8_min, fp8_max)
out_q_ptrs = output_q_ptr + base_out_offset + n_offset + offs_n[None, :]
tl.store(
out_q_ptrs,
y_q.to(output_q_ptr.dtype.element_ty),
mask=row_mask[:, None],
)
exponent_biased = tl.clamp(exponent + 127.0, 0.0, 255.0).to(tl.int32)
packed_scale = packed_scale | (exponent_biased << (pack_idx * 8))
scale_ptrs = output_scale_ptr + pid_pack * output_scale_stride_k + m_offset + offs_m
tl.store(scale_ptrs, packed_scale, mask=row_mask)
row_start += row_step
def silu_mul_quant_fp8_packed_triton(
@@ -235,37 +264,49 @@ def silu_mul_quant_fp8_packed_triton(
group_size: int = 128,
output_q: torch.Tensor | None = None,
clamp_limit: float | None = None,
alpha: float = 1.0,
beta: float = 0.0,
) -> tuple[torch.Tensor, torch.Tensor]:
assert input.dim() == 2
assert input.is_contiguous()
M, N = input.shape
N_2 = N // 2
hidden_size = N // 2
assert N_2 % group_size == 0
assert hidden_size % group_size == 0
fp8_dtype = torch.float8_e4m3fn
finfo = torch.finfo(fp8_dtype)
fp8_min, fp8_max = finfo.min, finfo.max
num_groups_per_row = N_2 // group_size
num_packed_groups = (num_groups_per_row + 3) // 4
tma_aligned_M = ((M + 3) // 4) * 4
groups_per_row = hidden_size // group_size
groups_per_pack = 4 # pack 4 UE8M0 scales to a single INT32
assert groups_per_row % groups_per_pack == 0
packs_per_row = groups_per_row // groups_per_pack
if output_q is None:
output_q = torch.empty((M, N_2), dtype=fp8_dtype, device=input.device)
output_q = torch.empty((M, hidden_size), dtype=fp8_dtype, device=input.device)
aligned_m = triton.cdiv(M, 4) * 4
output_scale_packed = torch.empty(
(num_packed_groups, tma_aligned_M),
(packs_per_row, aligned_m),
dtype=torch.int32,
device=input.device,
).T[:M, :]
BLOCK_M = 8
grid = (num_packed_groups, (M + BLOCK_M - 1) // BLOCK_M)
num_warps = max(4, group_size // 32)
# Tuned for group_size=32 (MXFP8) and group_size=128 (DeepSeek-V4)
num_warps = 4
num_stages = 2
if group_size < 128:
BM = 1
packs_per_cta = 8
else:
BM = 1 if M < 512 else 4
packs_per_cta = 2 if M < 512 else 1
grid_n = triton.cdiv(packs_per_row, packs_per_cta)
grid_m = min(triton.cdiv(M, BM), 4096)
grid = (grid_n, grid_m)
has_clamp = clamp_limit is not None
_silu_mul_quant_fp8_packed_kernel[grid](
@@ -277,12 +318,16 @@ def silu_mul_quant_fp8_packed_triton(
output_q.stride(0),
output_scale_packed.stride(1),
clamp_limit if has_clamp else 0.0,
alpha,
beta,
N=N,
NUM_GROUPS=num_groups_per_row,
GROUPS_PER_ROW=groups_per_row,
PACKS_PER_ROW=packs_per_row,
fp8_min=fp8_min,
fp8_max=fp8_max,
GROUP_SIZE=group_size,
BLOCK_M=BLOCK_M,
PACKS_PER_CTA=packs_per_cta,
BLOCK_M=BM,
HAS_CLAMP=has_clamp,
num_warps=num_warps,
num_stages=num_stages,
@@ -303,6 +348,8 @@ def _silu_mul_per_token_group_quant_fp8_colmajor(
# Information for float8
eps,
clamp_limit,
alpha,
beta,
fp8_min: tl.constexpr,
fp8_max: tl.constexpr,
use_ue8m0: tl.constexpr,
@@ -348,10 +395,14 @@ def _silu_mul_per_token_group_quant_fp8_colmajor(
mul_in = tl.clamp(mul_in.to(tl.float32), -clamp_limit, clamp_limit).to(
y_ptr.dtype.element_ty
)
# Unified gated activation: silu == swigluoai with alpha=1, beta=0.
# glu = gate * sigmoid(alpha * gate); y = (up + beta) * glu
# Keep glu/up at input precision (narrow before the mul) so the alpha=1,
# beta=0 defaults match the C++ silu_and_mul path bit-for-bit.
act_in = act_in.to(tl.float32)
one_f32 = tl.cast(1, tl.float32)
silu_out = (act_in / (one_f32 + tl.exp(-act_in))).to(y_ptr.dtype.element_ty)
y = (silu_out * mul_in).to(tl.float32)
glu = (act_in / (1.0 + tl.exp(-act_in * alpha))).to(y_ptr.dtype.element_ty)
up = (mul_in.to(tl.float32) + beta).to(y_ptr.dtype.element_ty)
y = (glu * up).to(tl.float32)
# quant
_absmax = tl.maximum(tl.max(tl.abs(y), axis=1), eps)
@@ -379,11 +430,15 @@ def silu_mul_per_token_group_quant_fp8_colmajor(
use_ue8m0: bool | None = None,
eps: float = 1e-10,
clamp_limit: float | None = None,
group_size: int = 128,
alpha: float = 1.0,
beta: float = 0.0,
):
"""
silu+mul + block-fp8 quant with group size 128.
Gated activation + block-fp8 quant. ``alpha``/``beta`` select the gate
(silu: alpha=1, beta=0; swigluoai: alpha, beta from config).
"""
GROUP_SIZE = 128
GROUP_SIZE = group_size
assert input.ndim == 2
if output is not None:
assert output.ndim == 2
@@ -431,6 +486,8 @@ def silu_mul_per_token_group_quant_fp8_colmajor(
output_scales.stride(-1),
eps,
clamp_limit if has_clamp else 0.0,
alpha,
beta,
fp8_min,
fp8_max,
use_ue8m0,
@@ -1020,9 +1077,10 @@ def deepgemm_post_process_fp8_weight_block(
f"to be torch.float8_e4m3fn, got {wq.dtype} instead."
)
if ws.dtype == torch.float8_e8m0fnu:
# Scales already in E8M0 from checkpoint — upcast to fp32
# and skip requantization (weights already have power-of-two scales).
if ws.dtype in (torch.float8_e8m0fnu, torch.uint8):
# Scales already in E8M0 from checkpoint (float8_e8m0fnu, or raw E8M0
# bits as uint8 for MXFP8) — upcast to fp32 and skip requantization
# (weights already have power-of-two scales).
ws = _upcast_e8m0_to_fp32(ws)
else:
assert ws.dtype == torch.float32, (
@@ -1062,7 +1120,8 @@ def deepgemm_post_process_fp8_weight_block(
ws = ws.unsqueeze(0)
# From https://github.com/deepseek-ai/DeepGEMM/blob/c9f8b34dcdacc20aa746b786f983492c51072870/csrc/utils/layout.hpp#L46
recipe = (1, 128, 128)
# (1, block_n, block_k): (1, 128, 128) for FP8 block, (1, 1, 32) for MXFP8.
recipe = (1, quant_block_shape[0], quant_block_shape[1])
# Ref : https://github.com/deepseek-ai/DeepGEMM/blob/c9f8b34dcdacc20aa746b786f983492c51072870/csrc/apis/gemm.hpp
# DeepGemm uses the `transform_sf_into_required_layout` function to
@@ -84,6 +84,73 @@ def _mxfp8_e4m3_quantize_torch(
return x_fp8, scales_uint8
def _mxfp8_quant_triton_kernel():
"""Lazily-built Triton kernel: per-32-block E8M0 scale + FP8-E4M3 quant.
Fuses what ``_mxfp8_e4m3_quantize_torch`` does in several elementwise passes
into one launch. Each program handles ``[BLOCK_M, 32]`` (one MX block).
"""
from vllm.triton_utils import tl, triton
@triton.jit
def _kernel(
x_ptr, xq_ptr, s_ptr, M, K,
sxm, sxk, sqm, sqk, ssm, ssk,
BLOCK_M: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_b = tl.program_id(1) # which 32-element block along K
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_k = pid_b * 32 + tl.arange(0, 32)
m_mask = offs_m < M
x = tl.load(
x_ptr + offs_m[:, None] * sxm + offs_k[None, :] * sxk,
mask=m_mask[:, None], other=0.0,
).to(tl.float32)
amax = tl.maximum(tl.max(tl.abs(x), axis=1), 1e-30) # [BLOCK_M]
sb = tl.floor(tl.log2(amax)) + 127.0
sb = tl.minimum(tl.maximum(sb, 0.0), 254.0)
descale = tl.exp2(sb - 127.0)
xq = (x / descale[:, None]).to(xq_ptr.dtype.element_ty)
tl.store(
xq_ptr + offs_m[:, None] * sqm + offs_k[None, :] * sqk, xq,
mask=m_mask[:, None],
)
tl.store(s_ptr + offs_m * ssm + pid_b * ssk, sb.to(tl.uint8), mask=m_mask)
return _kernel
_MXFP8_QUANT_KERNEL = None
def _mxfp8_e4m3_quantize_triton(
x: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Fused 2D MXFP8 quant (non-swizzled, row-major [M, K//32] scales)."""
from vllm.triton_utils import triton
global _MXFP8_QUANT_KERNEL
if _MXFP8_QUANT_KERNEL is None:
_MXFP8_QUANT_KERNEL = _mxfp8_quant_triton_kernel()
M, K = x.shape
x = x.contiguous()
xq = torch.empty((M, K), dtype=MXFP8_VALUE_DTYPE, device=x.device)
scales = torch.empty(
(M, K // MXFP8_BLOCK_SIZE), dtype=MXFP8_SCALE_DTYPE, device=x.device
)
BLOCK_M = 64
grid = (triton.cdiv(M, BLOCK_M), K // MXFP8_BLOCK_SIZE)
_MXFP8_QUANT_KERNEL[grid](
x, xq, scales, M, K,
x.stride(0), x.stride(1), xq.stride(0), xq.stride(1),
scales.stride(0), scales.stride(1),
BLOCK_M=BLOCK_M,
)
return xq, scales
def _mxfp8_e4m3_quantize_impl(
x: torch.Tensor,
is_sf_swizzled_layout: bool = False,
@@ -103,6 +170,17 @@ def _mxfp8_e4m3_quantize_impl(
x_scales = x_scales.view(x.size(0), -1)
return x_q, x_scales
# ROCm: a single fused Triton kernel beats the multi-pass torch path for the
# common 2D, non-swizzled activation-quant case (used by the native MX
# linear/MoE). Falls back to torch otherwise (3D weights, swizzled layout).
if (
current_platform.is_rocm()
and not is_sf_swizzled_layout
and x.ndim == 2
and x.shape[-1] % MXFP8_BLOCK_SIZE == 0
):
return _mxfp8_e4m3_quantize_triton(x)
return _mxfp8_e4m3_quantize_torch(x, is_sf_swizzled_layout)
+8
View File
@@ -165,6 +165,10 @@ _TEXT_GENERATION_MODELS = {
"MiniMaxText01ForCausalLM": ("minimax_text_01", "MiniMaxText01ForCausalLM"),
"MiniMaxM1ForCausalLM": ("minimax_text_01", "MiniMaxText01ForCausalLM"),
"MiniMaxM2ForCausalLM": ("minimax_m2", "MiniMaxM2ForCausalLM"),
"MiniMaxM3SparseForCausalLM": (
"vllm.models.minimax_m3",
"MiniMaxM3SparseForCausalLM",
),
"Ministral3ForCausalLM": ("mistral", "MistralForCausalLM"),
"MistralForCausalLM": ("mistral", "MistralForCausalLM"),
"MistralLarge3ForCausalLM": ("mistral_large_3", "MistralLarge3ForCausalLM"),
@@ -477,6 +481,10 @@ _MULTIMODAL_MODELS = {
"MantisForConditionalGeneration": ("llava", "MantisForConditionalGeneration"),
"MiDashengLMModel": ("midashenglm", "MiDashengLMModel"),
"MiMoV2OmniForCausalLM": ("mimo_v2_omni", "MiMoV2OmniForCausalLM"),
"MiniMaxM3SparseForConditionalGeneration": (
"vllm.models.minimax_m3",
"MiniMaxM3SparseForConditionalGeneration",
),
"MiniMaxVL01ForConditionalGeneration": (
"minimax_vl_01",
"MiniMaxVL01ForConditionalGeneration",
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax M3 model — hardware-isolated entry point.
The implementation lives under ``nvidia/`` and ``amd/``; this module picks the
right one for the current platform and re-exports the public classes used by
the model registry. (Mirrors ``vllm.models.deepseek_v4``.)
"""
from typing import TYPE_CHECKING
from vllm.platforms import current_platform
# The NVIDIA branch is the static default that type-checkers see; the ROCm
# branch overrides it at runtime (kept type-compatible via type: ignore).
if TYPE_CHECKING or not current_platform.is_rocm():
from .nvidia.model import (
MiniMaxM3SparseForCausalLM,
MiniMaxM3SparseForConditionalGeneration,
)
else:
from .amd.model import ( # type: ignore[assignment]
MiniMaxM3SparseForCausalLM,
MiniMaxM3SparseForConditionalGeneration,
)
__all__ = [
"MiniMaxM3SparseForCausalLM",
"MiniMaxM3SparseForConditionalGeneration",
]
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""AMD/ROCm fused Triton ops for MiniMax-M3.
These replace per-element PyTorch fallbacks (FlashInfer / fused HIP kernels are
unavailable on ROCm) with single-pass Triton kernels to cut launch overhead and
intermediate-tensor traffic during decode.
"""
from vllm.models.minimax_m3.amd.ops.gemma_rmsnorm import (
gemma_fused_add_rmsnorm,
gemma_rmsnorm,
)
from vllm.models.minimax_m3.amd.ops.swiglu_oai import swiglu_oai_split
__all__ = [
"gemma_rmsnorm",
"gemma_fused_add_rmsnorm",
"swiglu_oai_split",
]
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Fused Gemma-style RMSNorm for AMD ROCm via Triton.
Gemma RMSNorm = normalize(x) * (1 + weight), computed in fp32. FlashInfer's
``gemma_rmsnorm`` / ``gemma_fused_add_rmsnorm`` CUDA kernels are unavailable on
ROCm, so the AMD path previously used a ~8-op PyTorch sequence (float cast, add,
pow, mean, rsqrt, two muls, cast) each a separate kernel launch materializing
fp32 intermediates. These kernels collapse that into a single pass per row.
Two entry points:
* ``gemma_rmsnorm(x, w, eps)`` -> normalized tensor
* ``gemma_fused_add_rmsnorm(x, res, w, eps)`` -> (normalized, x + res)
Both normalize over the last dim and broadcast ``weight`` (shape [N]) over it,
so they serve both the full-hidden norms (input/post-attn/final) and the
per-head q_norm/k_norm (N == head_dim). Inputs may be non-contiguous views
(e.g. ``qkv.split`` slices); strides are passed through and outputs are written
contiguous.
"""
import torch
from vllm.triton_utils import tl, triton
@triton.jit
def _gemma_rmsnorm_kernel(
x_ptr,
w_ptr,
out_ptr,
n_cols,
stride_row,
stride_col,
eps,
BLOCK_N: tl.constexpr,
):
row = tl.program_id(0)
cols = tl.arange(0, BLOCK_N)
mask = cols < n_cols
x = tl.load(
x_ptr + row * stride_row + cols * stride_col, mask=mask, other=0.0
).to(tl.float32)
var = tl.sum(x * x, axis=0) / n_cols
rstd = 1.0 / tl.sqrt(var + eps)
w = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
out = x * rstd * (1.0 + w)
tl.store(
out_ptr + row * n_cols + cols,
out.to(out_ptr.dtype.element_ty),
mask=mask,
)
@triton.jit
def _gemma_fused_add_rmsnorm_kernel(
x_ptr,
res_ptr,
w_ptr,
out_ptr,
res_out_ptr,
n_cols,
stride_xrow,
stride_xcol,
stride_rrow,
stride_rcol,
eps,
BLOCK_N: tl.constexpr,
):
row = tl.program_id(0)
cols = tl.arange(0, BLOCK_N)
mask = cols < n_cols
x = tl.load(
x_ptr + row * stride_xrow + cols * stride_xcol, mask=mask, other=0.0
).to(tl.float32)
r = tl.load(
res_ptr + row * stride_rrow + cols * stride_rcol, mask=mask, other=0.0
).to(tl.float32)
s = x + r
# residual_out is the pre-norm sum (consumed by the next layer's add).
tl.store(
res_out_ptr + row * n_cols + cols,
s.to(res_out_ptr.dtype.element_ty),
mask=mask,
)
var = tl.sum(s * s, axis=0) / n_cols
rstd = 1.0 / tl.sqrt(var + eps)
w = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
out = s * rstd * (1.0 + w)
tl.store(
out_ptr + row * n_cols + cols,
out.to(out_ptr.dtype.element_ty),
mask=mask,
)
def _num_warps(block_n: int) -> int:
if block_n >= 4096:
return 16
if block_n >= 1024:
return 8
return 4
def gemma_rmsnorm(
x: torch.Tensor, weight: torch.Tensor, eps: float
) -> torch.Tensor:
orig_shape = x.shape
n = orig_shape[-1]
x2 = x.reshape(-1, n)
m = x2.shape[0]
out = torch.empty((m, n), dtype=x.dtype, device=x.device)
block_n = triton.next_power_of_2(n)
_gemma_rmsnorm_kernel[(m,)](
x2,
weight,
out,
n,
x2.stride(0),
x2.stride(1),
eps,
BLOCK_N=block_n,
num_warps=_num_warps(block_n),
)
return out.reshape(orig_shape)
def gemma_fused_add_rmsnorm(
x: torch.Tensor,
residual: torch.Tensor,
weight: torch.Tensor,
eps: float,
) -> tuple[torch.Tensor, torch.Tensor]:
orig_shape = x.shape
n = orig_shape[-1]
x2 = x.reshape(-1, n)
r2 = residual.reshape(-1, n)
m = x2.shape[0]
out = torch.empty((m, n), dtype=x.dtype, device=x.device)
res_out = torch.empty((m, n), dtype=x.dtype, device=x.device)
block_n = triton.next_power_of_2(n)
_gemma_fused_add_rmsnorm_kernel[(m,)](
x2,
r2,
weight,
out,
res_out,
n,
x2.stride(0),
x2.stride(1),
r2.stride(0),
r2.stride(1),
eps,
BLOCK_N=block_n,
num_warps=_num_warps(block_n),
)
return out.reshape(orig_shape), res_out.reshape(orig_shape)
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Fused SwiGLU-OAI activation (split layout) for AMD ROCm via Triton.
SwiGLU-OAI on a ``[*, 2I]`` split-layout input (gate = first half, up = second
half):
gate = clamp(gate, max=limit)
up = clamp(up, -limit, +limit)
out = gate * sigmoid(alpha * gate) * (up + beta)
On ROCm the dense MLP and the native MXFP8 MoE (between its two GEMMs) fell back
to a chain of elementwise PyTorch ops with fp32 intermediates: vLLM's shared
``SiluAndMulWithClamp`` blanket-routes ROCm to ``forward_native``, and the MoE
applies the activation inline in PyTorch. This Triton kernel collapses that into
a single pass producing the ``[*, I]`` output directly, and computes in fp32
(rel ~1e-6 vs reference).
Note: the vectorized ``torch.ops._C.silu_and_mul_with_clamp`` op IS built on
ROCm and is ~1.2-2.2x faster in isolation, but the win is launch overhead that
HIP graphs already eliminate measured end-to-end throughput is identical
(within noise), so we keep the fp32-accurate Triton kernel.
"""
import torch
from vllm.triton_utils import tl, triton
@triton.jit
def _swiglu_oai_kernel(
g_ptr,
out_ptr,
n_inter,
stride_gm,
stride_gn,
stride_om,
stride_on,
alpha,
beta,
limit,
HAS_LIMIT: tl.constexpr,
BLOCK_I: tl.constexpr,
):
row = tl.program_id(0)
pid_i = tl.program_id(1)
cols = pid_i * BLOCK_I + tl.arange(0, BLOCK_I)
mask = cols < n_inter
gate = tl.load(
g_ptr + row * stride_gm + cols * stride_gn, mask=mask, other=0.0
).to(tl.float32)
up = tl.load(
g_ptr + row * stride_gm + (n_inter + cols) * stride_gn,
mask=mask,
other=0.0,
).to(tl.float32)
if HAS_LIMIT:
gate = tl.minimum(gate, limit)
up = tl.minimum(tl.maximum(up, -limit), limit)
out = gate * tl.sigmoid(alpha * gate) * (up + beta)
tl.store(
out_ptr + row * stride_om + cols * stride_on,
out.to(out_ptr.dtype.element_ty),
mask=mask,
)
def swiglu_oai_split(
gate_up: torch.Tensor,
alpha: float,
beta: float,
limit: float | None,
out_dtype: torch.dtype | None = None,
) -> torch.Tensor:
"""SwiGLU-OAI on a split-layout ``[*, 2I]`` tensor -> ``[*, I]``."""
orig_shape = gate_up.shape
two_i = orig_shape[-1]
n_inter = two_i // 2
x2 = gate_up.reshape(-1, two_i)
m = x2.shape[0]
dt = out_dtype if out_dtype is not None else gate_up.dtype
out = torch.empty((m, n_inter), dtype=dt, device=gate_up.device)
# Tile tuned on gfx950. The SwiGLU intermediate is sharded across tensor
# parallel ranks (per-rank n_inter = I / tp: dense I=12288, MoE I=3072), and
# a 512-wide tile (4 warps, ~2 elems/lane) only helps once the per-rank slice
# is large enough to be bandwidth-bound — at TP=1 prefill that is ~1.25-1.35x
# faster than 256. For small sharded slices (high TP) the kernel is launch-
# bound (~12us) and a wide tile can slightly regress, so fall back to 256.
# Decode is launch-bound at every TP. num_warps=8 underfills this tile, so it
# is pinned to 4.
block_i = 512 if n_inter >= 2048 else 256
grid = (m, triton.cdiv(n_inter, block_i))
_swiglu_oai_kernel[grid](
x2,
out,
n_inter,
x2.stride(0),
x2.stride(1),
out.stride(0),
out.stride(1),
float(alpha),
float(beta),
0.0 if limit is None else float(limit),
HAS_LIMIT=limit is not None,
BLOCK_I=block_i,
num_warps=4,
)
return out.reshape(*orig_shape[:-1], n_inter)
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -0,0 +1,462 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
from collections.abc import Mapping, Sequence
from typing import cast
import torch
from transformers import BatchFeature
from transformers.video_utils import VideoMetadata
from vllm.config.multimodal import (
BaseDummyOptions,
ImageDummyOptions,
VideoDummyOptions,
)
from vllm.inputs import MultiModalDataDict
from vllm.multimodal.inputs import (
MultiModalFieldConfig,
MultiModalKwargsItems,
)
from vllm.multimodal.parse import (
ImageSize,
MultiModalDataItems,
MultiModalDataParser,
)
from vllm.multimodal.processing import (
BaseDummyInputsBuilder,
BaseMultiModalProcessor,
BaseProcessingInfo,
PromptReplacement,
PromptUpdate,
PromptUpdateDetails,
)
from vllm.multimodal.video import (
VIDEO_LOADER_REGISTRY,
VideoBackend,
VideoSourceMetadata,
VideoTargetMetadata,
)
from vllm.transformers_utils.configs.minimax_m3 import MiniMaxM3Config
from vllm.transformers_utils.processors.minimax_m3 import (
MiniMaxM3VLImageProcessor,
MiniMaxM3VLVideoProcessor,
MiniMaxVLProcessor,
smart_resize,
)
# Upper bound on the number of frames used to build the dummy video during
# memory profiling. Without this cap, ``_get_max_video_frames(seq_len)`` with
# M3's large ``max_model_len`` yields ~1800 frames, producing a multi-GB dummy
# tensor that overflows the multimodal processor cache. Mirrors Qwen2-VL's
# ``_MAX_FRAMES_PER_VIDEO``.
_MAX_FRAMES_PER_VIDEO = 14
class MiniMaxM3VLProcessingInfo(BaseProcessingInfo):
IMAGE_TOKEN = "]<]image[>["
VIDEO_TOKEN = "]<]video[>["
VISION_START_TOKEN = "]<]start of image[>["
VISION_END_TOKEN = "]<]end of image[>["
def get_hf_config(self) -> MiniMaxM3Config:
return self.ctx.get_hf_config(MiniMaxM3Config)
def get_hf_processor(self, **kwargs: object) -> MiniMaxVLProcessor:
# The released checkpoint only ships the processor as remote code
# (via ``auto_map``). Construct the vendored processor directly so the
# model loads without ``--trust-remote-code``.
return self.ctx.get_hf_processor(MiniMaxVLProcessor, **kwargs)
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
return {"image": None, "video": None}
def get_mm_max_tokens_per_item(
self,
seq_len: int,
mm_counts: Mapping[str, int],
) -> Mapping[str, int]:
return {
"image": self.get_max_image_tokens(),
"video": self.get_max_video_tokens(seq_len, mm_counts),
}
def get_image_processor(self, **kwargs: object) -> MiniMaxM3VLImageProcessor:
return self.get_hf_processor(**kwargs).image_processor
def get_video_processor(self, **kwargs: object) -> MiniMaxM3VLVideoProcessor:
return self.get_hf_processor(**kwargs).video_processor
def _get_vision_info(
self,
*,
image_width: int,
image_height: int,
num_frames: int,
image_processor,
) -> tuple[ImageSize, int]:
"""Compute resized image size and number of vision tokens.
Mirrors the processor's Qwen-style ``smart_resize`` (area bound by
``max_pixels``) so token counts match the actual processor output.
"""
patch_size: int = image_processor.patch_size
merge_size: int = image_processor.merge_size
temporal_patch_size: int = image_processor.temporal_patch_size
factor = patch_size * merge_size
max_pixels: int = image_processor.max_pixels
new_h, new_w = smart_resize(
image_height, image_width, factor=factor, max_pixels=max_pixels
)
grid_h = new_h // patch_size
grid_w = new_w // patch_size
# Pad frames to be divisible by temporal_patch_size
padded_frames = num_frames + (-num_frames % temporal_patch_size)
grid_t = max(padded_frames // temporal_patch_size, 1)
num_tokens = grid_t * grid_h * grid_w // (merge_size**2)
return ImageSize(width=new_w, height=new_h), num_tokens
def get_num_image_tokens(
self,
*,
image_width: int,
image_height: int,
image_processor,
mm_kwargs: Mapping[str, object],
) -> int:
_, n = self._get_vision_info(
image_width=image_width,
image_height=image_height,
num_frames=1,
image_processor=image_processor,
)
return n
def get_num_video_tokens(
self,
*,
image_width: int,
image_height: int,
num_frames: int,
image_processor,
mm_kwargs: Mapping[str, object],
) -> int:
_, n = self._get_vision_info(
image_width=image_width,
image_height=image_height,
num_frames=num_frames,
image_processor=image_processor,
)
return n
def get_image_size_with_most_features(self) -> ImageSize:
# Largest square (a multiple of patch_size*merge_size) whose area is
# within the image processor's ``max_pixels`` bound — this yields the
# most vision tokens for one image.
image_processor = self.get_image_processor()
factor = image_processor.patch_size * image_processor.merge_size
side = max(factor, (math.isqrt(image_processor.max_pixels) // factor) * factor)
return ImageSize(width=side, height=side)
def get_max_image_tokens(self) -> int:
image_processor = self.get_image_processor()
size = self.get_image_size_with_most_features()
return self.get_num_image_tokens(
image_width=size.width,
image_height=size.height,
image_processor=image_processor,
mm_kwargs={},
)
def _get_max_video_frames(self, max_tokens: int) -> int:
image_processor = self.get_image_processor()
size = self.get_image_size_with_most_features()
num_frames = 1
while True:
next_n = self.get_num_video_tokens(
image_width=size.width,
image_height=size.height,
num_frames=num_frames + 1,
image_processor=image_processor,
mm_kwargs={},
)
if next_n > max_tokens:
break
num_frames += 1
return num_frames
def get_num_frames_with_most_features(
self,
seq_len: int,
mm_counts: Mapping[str, int],
max_frames_per_video: int = _MAX_FRAMES_PER_VIDEO,
) -> int:
max_videos = mm_counts.get("video", 0)
max_total_frames = self._get_max_video_frames(seq_len)
max_frames_per_video = min(
max_total_frames // max(max_videos, 1), max_frames_per_video
)
return max(max_frames_per_video, 1)
def get_max_video_tokens(
self,
seq_len: int,
mm_counts: Mapping[str, int],
) -> int:
image_processor = self.get_image_processor()
size = self.get_image_size_with_most_features()
return self.get_num_video_tokens(
image_width=size.width,
image_height=size.height,
num_frames=self.get_num_frames_with_most_features(seq_len, mm_counts),
image_processor=image_processor,
mm_kwargs={},
)
class MiniMaxM3VLDummyInputsBuilder(BaseDummyInputsBuilder[MiniMaxM3VLProcessingInfo]):
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
num_images = mm_counts.get("image", 0)
num_videos = mm_counts.get("video", 0)
image_token: str = self.info.IMAGE_TOKEN
video_token: str = self.info.VIDEO_TOKEN
return image_token * num_images + video_token * num_videos
def get_dummy_mm_data(
self,
seq_len: int,
mm_counts: Mapping[str, int],
mm_options: Mapping[str, BaseDummyOptions],
) -> MultiModalDataDict:
size = self.info.get_image_size_with_most_features()
num_frames = self.info.get_num_frames_with_most_features(seq_len, mm_counts)
return {
"image": self._get_dummy_images(
width=size.width,
height=size.height,
num_images=mm_counts.get("image", 0),
overrides=cast(ImageDummyOptions | None, mm_options.get("image")),
),
"video": self._get_dummy_videos(
width=size.width,
height=size.height,
num_frames=num_frames,
num_videos=mm_counts.get("video", 0),
overrides=cast(VideoDummyOptions | None, mm_options.get("video")),
),
}
class MiniMaxM3VLMultiModalProcessor(
BaseMultiModalProcessor[MiniMaxM3VLProcessingInfo]
):
def _get_data_parser(self) -> MultiModalDataParser:
# Request video metadata (fps + sampled frame indices) so the HF
# processor can emit per-frame ``]<]X.X seconds[>[`` timestamp markers,
# matching MiniMax's reference video token stream. ``_get_prompt_updates``
# reconstructs the same markers from the metadata to keep the prompt
# replacement aligned with the processor output.
return MultiModalDataParser(video_needs_metadata=True)
def _call_hf_processor(
self,
prompt: str,
mm_data: Mapping[str, object],
mm_kwargs: Mapping[str, object],
tok_kwargs: Mapping[str, object],
) -> BatchFeature:
mm_data = dict(mm_data)
# With ``video_needs_metadata=True`` each video arrives as a
# ``(frames, metadata)`` tuple. Split the frames back out and forward the
# metadata as ``VideoMetadata`` so the processor emits timestamps.
videos = cast(list | None, mm_data.get("videos"))
video_metadata: list[VideoMetadata] | None = None
if videos:
frames_only = []
video_metadata = []
for item in videos:
if isinstance(item, tuple) and len(item) == 2:
frames, meta = item
else:
frames, meta = item, {}
frames_only.append(frames)
meta = {
k: v for k, v in (meta or {}).items() if k != "do_sample_frames"
}
# VideoMetadata requires total_num_frames; derive it for
# dummy/profiling videos whose metadata omits it. fps and
# frames_indices default to None there → no timestamps, which
# stays consistent with _get_prompt_updates.
meta.setdefault("total_num_frames", len(frames))
video_metadata.append(VideoMetadata(**meta))
mm_data["videos"] = frames_only
# Override the video processor's default do_resize=False (set for
# SGLang's pre-resized pipeline) to True for vLLM's raw-frame inputs.
merged = dict(do_resize=True, **mm_kwargs, **tok_kwargs)
data = dict(text=prompt, **mm_data)
if video_metadata is not None:
data["video_metadata"] = video_metadata
return self.info.ctx.call_hf_processor(
self.info.get_hf_processor(**mm_kwargs),
data,
merged,
)
def _get_mm_fields_config(
self,
hf_inputs: BatchFeature,
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
image_grid_thw = hf_inputs.get("image_grid_thw")
video_grid_thw = hf_inputs.get("video_grid_thw")
# Total patches per item (grid_t * grid_h * grid_w)
image_grid_sizes = (
image_grid_thw.prod(-1)
if image_grid_thw is not None
else torch.empty(0, dtype=torch.long)
)
video_grid_sizes = (
video_grid_thw.prod(-1)
if video_grid_thw is not None
else torch.empty(0, dtype=torch.long)
)
return {
"pixel_values": MultiModalFieldConfig.flat_from_sizes(
"image", image_grid_sizes
),
"image_grid_thw": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
"pixel_values_videos": MultiModalFieldConfig.flat_from_sizes(
"video", video_grid_sizes
),
"video_grid_thw": MultiModalFieldConfig.batched("video", keep_on_cpu=True),
}
def _get_prompt_updates(
self,
mm_items: MultiModalDataItems,
hf_processor_mm_kwargs: Mapping[str, object],
out_mm_kwargs: MultiModalKwargsItems,
) -> Sequence[PromptUpdate]:
hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
tokenizer = self.info.get_tokenizer()
vocab = tokenizer.get_vocab()
image_token_id: int = vocab[self.info.IMAGE_TOKEN]
video_token_id: int = vocab[self.info.VIDEO_TOKEN]
start_token_id: int = vocab[self.info.VISION_START_TOKEN]
end_token_id: int = vocab[self.info.VISION_END_TOKEN]
merge_length: int = hf_processor.image_processor.merge_size**2
def get_image_replacement(item_idx: int):
grid_thw: torch.Tensor = out_mm_kwargs["image"][item_idx][
"image_grid_thw"
].data
# grid_thw shape: (3,) = [1, grid_h, grid_w]
N = int(grid_thw.prod().item()) // merge_length
full = [start_token_id] + [image_token_id] * N + [end_token_id]
return PromptUpdateDetails.select_token_id(full, image_token_id)
# Per-video metadata (fps + sampled frame indices) is carried on the
# parsed video items; used to reproduce the HF processor's timestamps.
video_items = mm_items.get("video")
video_metadata = getattr(video_items, "metadata", None)
temporal_patch_size: int = hf_processor.video_processor.temporal_patch_size
def get_video_replacement(item_idx: int):
grid_thw: torch.Tensor = out_mm_kwargs["video"][item_idx][
"video_grid_thw"
].data
# grid_thw shape: (3,) = [grid_t, grid_h, grid_w]
# HF model uses VIDEO_TOKEN (not IMAGE_TOKEN) for video frame content:
# processing_minimax.py L245: replace(placeholder, self.VIDEO_TOKEN)
T = int(grid_thw[0].item())
M = int(grid_thw[1].item() * grid_thw[2].item()) // merge_length
# Reproduce the HF processor's per-frame timestamp markers
# (processing_minimax.py: ts = frames_indices[frame*tps] / fps,
# rendered as "]<]X.X seconds[>["). Falls back to no timestamps when
# metadata is unavailable (keeping the replacement aligned with the
# processor output in both cases).
meta = (
video_metadata[item_idx]
if video_metadata is not None and item_idx < len(video_metadata)
else None
)
fps = meta.get("fps") if meta else None
frames_indices = meta.get("frames_indices") if meta else None
full: list[int] = []
for frame_idx in range(T):
if fps is not None and frames_indices is not None:
idx = min(frame_idx * temporal_patch_size, len(frames_indices) - 1)
ts = frames_indices[idx] / fps
full += tokenizer.encode(
f"]<]{ts:.1f} seconds[>[", add_special_tokens=False
)
full += [start_token_id] + [video_token_id] * M + [end_token_id]
return PromptUpdateDetails.select_token_id(full, video_token_id)
return [
PromptReplacement(
modality="image",
target=[image_token_id],
replacement=get_image_replacement,
),
PromptReplacement(
modality="video",
target=[video_token_id],
replacement=get_video_replacement,
),
]
# TODO(Isotr0py): Tie with MinimaxVideoProcessor
# after https://github.com/vllm-project/vllm/pull/44126
@VIDEO_LOADER_REGISTRY.register("minimax_m3_vl")
class MiniMaxM3VideoBackend(VideoBackend):
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames = source.total_frames_num
video_fps = source.original_fps
fps = target.fps
if total_frames <= 0 or video_fps <= 0 or fps <= 0:
return [0] if total_frames > 0 else []
read_time_interval = 1.0 / fps
eps = 1e-4
indices: list[int] = []
prev_kept_ts = -float("inf")
while True:
if not indices:
target_frame = 0
else:
target_ts = prev_kept_ts + read_time_interval - eps
target_frame = math.ceil(target_ts * video_fps)
target_frame = max(target_frame, indices[-1] + 1)
if target_frame >= total_frames:
break
indices.append(target_frame)
prev_kept_ts = target_frame / video_fps
last_frame_idx = total_frames - 1
last_ts = last_frame_idx / video_fps
if indices and indices[-1] != last_frame_idx and last_ts - prev_kept_ts > eps:
indices.append(last_frame_idx)
if not indices:
indices = [0]
return indices
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Cross-platform (Triton) kernels for MiniMax M3 sparse attention."""
from .index_topk import minimax_m3_index_topk, minimax_m3_index_topk_decode
from .sparse_attn import minimax_m3_sparse_attn, minimax_m3_sparse_attn_decode
__all__ = [
"minimax_m3_index_topk",
"minimax_m3_index_topk_decode",
"minimax_m3_sparse_attn",
"minimax_m3_sparse_attn_decode",
]
@@ -0,0 +1,819 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Triton kernels for MiniMax M3 lightning-indexer block scoring + top-k.
Index queries score each 128-token block of index keys (max over the block),
then the top-k blocks (plus forced init/local blocks) are selected per query
token. Ported from the sglang reference (minimax_sparse_ops), adapted to vLLM's
paged KV cache: the KV page size is forced to equal the sparse block size (128),
so one sparse block maps to exactly one page.
Index-K cache layout (vLLM): ``(num_blocks, 128, idx_head_dim)`` (single head).
Only the paths MiniMax M3 uses are implemented: score_type="max", index value
disabled (score-only indexer), single shared index head. The selected block ids
feed the block-sparse attention kernels in ``sparse_attn``.
"""
import torch
from vllm.triton_utils import tl, triton
# One sparse block == one KV page.
SPARSE_BLOCK_SIZE = 128
# ---------------------------------------------------------------------------
# Bitonic top-k helpers (layout-agnostic; ported verbatim from sglang).
# ---------------------------------------------------------------------------
@triton.jit
def _compare_and_swap(x, ids, flip, i: tl.constexpr, n_dims: tl.constexpr):
n_outer: tl.constexpr = x.numel >> n_dims
shape: tl.constexpr = [n_outer * 2**i, 2, 2 ** (n_dims - i - 1)]
y = tl.reshape(x, shape)
mask = tl.arange(0, 2)[None, :, None]
left = tl.broadcast_to(tl.sum(y * (1 - mask), 1)[:, None, :], shape).to(y.dtype)
right = tl.broadcast_to(tl.sum(y * mask, 1)[:, None, :], shape).to(y.dtype)
left = tl.reshape(left, x.shape)
right = tl.reshape(right, x.shape)
y_idx = tl.reshape(ids, shape)
left_idx = tl.broadcast_to(tl.sum(y_idx * (1 - mask), 1)[:, None, :], shape)
right_idx = tl.broadcast_to(tl.sum(y_idx * mask, 1)[:, None, :], shape)
left_idx = tl.reshape(left_idx, x.shape).to(y_idx.dtype)
right_idx = tl.reshape(right_idx, x.shape).to(y_idx.dtype)
idtype = tl.core.get_int_dtype(bitwidth=x.dtype.primitive_bitwidth, signed=True)
ileft = left.to(idtype, bitcast=True)
iright = right.to(idtype, bitcast=True)
ix = x.to(idtype, bitcast=True)
cond = (left > right) != flip
ret = ix ^ tl.where(cond, ileft ^ iright, tl.zeros_like(ix))
new_ids = ids ^ tl.where(cond, left_idx ^ right_idx, tl.zeros_like(ids))
return ret.to(x.dtype, bitcast=True), new_ids
@triton.jit
def _bitonic_merge(
x, ids, stage: tl.constexpr, order: tl.constexpr, n_dims: tl.constexpr
):
n_outer: tl.constexpr = x.numel >> n_dims
tl.static_assert(stage <= n_dims)
if order == 2:
shape: tl.constexpr = [n_outer * 2 ** (n_dims - 1 - stage), 2, 2**stage]
flip = tl.reshape(
tl.broadcast_to(tl.arange(0, 2)[None, :, None], shape), x.shape
)
else:
flip = order
for i in tl.static_range(stage):
x, ids = _compare_and_swap(x, ids, flip, i + (n_dims - stage), n_dims)
return x, ids
# ---------------------------------------------------------------------------
# Index block-score kernel (paged). score[h, token, block] = max over the
# 128-token block of (idx_q . index_k), causal-masked. BLOCK_SIZE_K == 128 so
# each K-tile is exactly one page (BLOCKS_PER_K_BLOCK == 1).
# ---------------------------------------------------------------------------
@triton.jit
def _index_block_score_kernel(
q_ptr, # idx_q: [total_q, num_idx_heads, head_dim]
ik_cache_ptr, # index-K cache: [num_blocks, 128, head_dim]
score_ptr, # [num_idx_heads, total_q, max_block]
block_table_ptr, # [num_reqs, max_blocks]
cu_seqlens, # [batch+1] query start offsets
seq_lens, # [batch] total K length
prefix_lens, # [batch] context length before this chunk's queries
num_idx_heads,
head_dim: tl.constexpr,
sm_scale,
stride_q_n,
stride_q_h,
stride_q_d,
stride_ik_blk,
stride_ik_pos,
stride_ik_d,
stride_s_h,
stride_s_n,
stride_s_k,
stride_bt_b,
BLOCK_SIZE_Q: tl.constexpr,
BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
):
sm_scale_log2e = sm_scale * 1.4426950409
pid_q = tl.program_id(0)
pid_bh = tl.program_id(1)
pid_b = pid_bh // num_idx_heads
pid_h = pid_bh % num_idx_heads
seq_start = tl.load(cu_seqlens + pid_b)
q_len = tl.load(cu_seqlens + pid_b + 1) - seq_start
seq_len = tl.load(seq_lens + pid_b)
prefix_len = tl.load(prefix_lens + pid_b)
if BLOCK_SIZE_Q * pid_q >= q_len:
return
q_ptrs = tl.make_block_ptr(
base=q_ptr + seq_start * stride_q_n + pid_h * stride_q_h,
shape=(q_len, head_dim),
strides=(stride_q_n, stride_q_d),
offsets=(pid_q * BLOCK_SIZE_Q, 0),
block_shape=(BLOCK_SIZE_Q, head_dim),
order=(1, 0),
)
q = tl.load(q_ptrs, boundary_check=(0,), padding_option="zero")
q_start = prefix_len + pid_q * BLOCK_SIZE_Q
off_q = tl.arange(0, BLOCK_SIZE_Q) + pid_q * BLOCK_SIZE_Q + prefix_len
off_k = tl.arange(0, BLOCK_SIZE_K)
off_d = tl.arange(0, head_dim)
# Block table row for this request.
bt_row = block_table_ptr + pid_b * stride_bt_b
# Causal window: only blocks up to the last query token's position.
hi = min(seq_len, prefix_len + (pid_q + 1) * BLOCK_SIZE_Q)
for i in tl.range(0, hi, BLOCK_SIZE_K):
blk = i // BLOCK_SIZE_K
page = tl.load(bt_row + blk).to(tl.int64)
pos = i + off_k
# index-K for this page: [BLOCK_SIZE_D, BLOCK_SIZE_K] (transposed)
# we don't need masked load for K, because KV cache ensures
# allocation is multiple of BLOCK_SIZE_K.
# for tokens beyond seqlen, they will be masked in qk later.
k = tl.load(
ik_cache_ptr
+ page * stride_ik_blk
+ off_k[None, :] * stride_ik_pos
+ off_d[:, None] * stride_ik_d,
)
qk = tl.dot(q, k) * sm_scale_log2e
# apply causal mask as needed
if q_start < i + BLOCK_SIZE_K:
qk = tl.where(off_q[:, None] >= pos[None, :], qk, float("-inf"))
# one sparse block per K-tile -> max over the 128 positions
score = tl.max(qk, axis=1) # [BLOCK_SIZE_Q]
s_ptrs = (
score_ptr
+ pid_h * stride_s_h
+ (seq_start + pid_q * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q))
* stride_s_n
+ blk * stride_s_k
)
q_store_mask = (pid_q * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q)) < q_len
tl.store(s_ptrs, score, mask=q_store_mask)
# ---------------------------------------------------------------------------
# Top-k selection over per-token block scores (layout-agnostic). block_size_q
# is 1 for M3, so top-k is computed per query token.
# ---------------------------------------------------------------------------
@triton.heuristics({"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"])})
@triton.autotune(
configs=[
triton.Config({"BLOCK_SIZE_K": 2048}, num_warps=8, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 1024}, num_warps=8, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 512}, num_warps=8, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 256}, num_warps=8, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 64}, num_warps=2, num_stages=2),
],
key=["BLOCK_SIZE_T"],
)
@triton.jit
def _topk_index_kernel(
s_ptr, # [num_heads, total_q, max_block]
ti_ptr, # [num_heads, total_q, topk]
sample_interval: tl.constexpr, # block_size_q (1 for M3)
block_size: tl.constexpr, # sparse block size (128)
cu_seqlens,
cu_seqblocks_q,
prefix_lens,
topk,
init_blocks: tl.constexpr,
local_blocks: tl.constexpr,
stride_s_h,
stride_s_n,
stride_s_k,
stride_ti_h,
stride_ti_n,
stride_ti_t,
BLOCK_SIZE_K: tl.constexpr,
BLOCK_SIZE_T: tl.constexpr,
MASK_INIT: tl.constexpr,
MASK_LOCAL: tl.constexpr,
):
tl.static_assert(BLOCK_SIZE_K > BLOCK_SIZE_T)
pid_q = tl.program_id(0)
pid_b = tl.program_id(1)
pid_h = tl.program_id(2)
seq_start = tl.load(cu_seqlens + pid_b)
block_start = tl.load(cu_seqblocks_q + pid_b)
block_num = tl.load(cu_seqblocks_q + pid_b + 1) - block_start
prefix_len = tl.load(prefix_lens + pid_b)
if pid_q >= block_num:
return
off_k = tl.arange(0, BLOCK_SIZE_K)
off_t = tl.arange(0, BLOCK_SIZE_T)
s_ptrs = (
s_ptr
+ (seq_start + pid_q * sample_interval) * stride_s_n
+ pid_h * stride_s_h
+ off_k * stride_s_k
)
topk_score = tl.full((BLOCK_SIZE_K,), -1e30, dtype=tl.float32)
topk_idx = tl.full((BLOCK_SIZE_K,), 0, dtype=tl.int32)
left_half_mask = tl.arange(0, BLOCK_SIZE_K) < BLOCK_SIZE_K // 2
valid_blocks = (prefix_len + pid_q * sample_interval + block_size) // block_size
for i in tl.range(0, valid_blocks, BLOCK_SIZE_K):
causal_mask = i + off_k < valid_blocks
local_mask = i + off_k >= max(0, valid_blocks - local_blocks)
init_mask = i + off_k < init_blocks
score = tl.load(s_ptrs, mask=causal_mask, other=-1e30).to(tl.float32)
score = tl.where(score != score, -1e30, score)
s_ptrs = s_ptrs + stride_s_k * BLOCK_SIZE_K
if MASK_INIT:
score = tl.where(causal_mask & init_mask, score - 1e29, score)
else:
score = tl.where(causal_mask & init_mask, 1e30, score)
if MASK_LOCAL:
score = tl.where(causal_mask & local_mask, score - 1e28, score)
else:
score = tl.where(causal_mask & local_mask, 1e29, score)
topk_score, last_topk_score = score, topk_score
topk_idx, last_topk_idx = (tl.where(causal_mask, i + off_k + 1, 0), topk_idx)
n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
for j in tl.static_range(1, n_dims):
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), j, 2, n_dims
)
if i != 0:
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), n_dims, False, n_dims
)
topk_score_new = last_topk_score * left_half_mask + topk_score * (
1 - left_half_mask
)
topk_idx_new = last_topk_idx * left_half_mask + topk_idx * (
1 - left_half_mask
)
topk_score, topk_idx = _bitonic_merge(
topk_score_new, topk_idx_new.to(tl.int32), n_dims, True, n_dims
)
else:
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), n_dims, True, n_dims
)
topk_mask = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
topk_idx = tl.sum(
topk_mask[:, None]
* tl.reshape(topk_idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
axis=0,
)
ti_ptrs = (
ti_ptr
+ (block_start + pid_q) * stride_ti_n
+ pid_h * stride_ti_h
+ off_t * stride_ti_t
)
store_mask = off_t < topk
valid_mask = off_t < valid_blocks
topk_idx = tl.where(store_mask & valid_mask, topk_idx, -1)
tl.store(ti_ptrs, topk_idx.to(ti_ptrs.dtype.element_ty), mask=store_mask)
# ---------------------------------------------------------------------------
# Decode index-score kernel (split-K over seq blocks). Decode == one query
# token per request, so this parallelizes over the KV dimension instead of the
# query dimension. Chunk counts depend only on shape constants so the grid is
# fixed within a cuda graph. Base-2 (exp2/log2) softmax matches prefill.
# ---------------------------------------------------------------------------
@triton.heuristics(
{"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"])}
)
@triton.jit
def _decode_index_score_kernel(
q_ptr, # idx_q: [total_q (== batch), num_idx_heads, head_dim]
ik_cache_ptr, # index-K cache: [num_blocks, 128, head_dim]
score_ptr, # [num_idx_heads, total_q, max_block]
block_table_ptr, # [num_reqs, max_blocks]
seq_lens, # [batch]
num_idx_heads,
batch_size,
head_dim,
init_blocks,
local_blocks,
sm_scale,
stride_q_n,
stride_q_h,
stride_q_d,
stride_ik_blk,
stride_ik_pos,
stride_ik_d,
stride_s_h,
stride_s_n,
stride_s_k,
stride_bt_b,
BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
NUM_KV_CHUNKS: tl.constexpr,
BLOCK_SIZE_D: tl.constexpr,
):
sm_scale_log2e = sm_scale * 1.4426950409
pid_bc, pid_h = tl.program_id(0), tl.program_id(1)
pid_b = pid_bc % batch_size
pid_c = pid_bc // batch_size
seq_len = tl.load(seq_lens + pid_b)
num_blocks = (seq_len + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
# block-aligned fixed-count split: grid independent of seq_len (cuda graph).
chunk_size_blocks = (num_blocks + NUM_KV_CHUNKS - 1) // NUM_KV_CHUNKS
chunk_start_block = pid_c * chunk_size_blocks
chunk_end_block = tl.minimum(chunk_start_block + chunk_size_blocks, num_blocks)
if chunk_start_block >= chunk_end_block:
return
off_k = tl.arange(0, BLOCK_SIZE_K) # positions within a 128-block
off_d = tl.arange(0, BLOCK_SIZE_D)
d_mask = off_d < head_dim
bt_row = block_table_ptr + pid_b * stride_bt_b
# Force-select init (1e30) and local (1e29, higher priority) blocks.
local_start = tl.maximum(0, num_blocks - local_blocks)
# single query vector for this (token, index head)
q = tl.load(
q_ptr + pid_b * stride_q_n + pid_h * stride_q_h + off_d * stride_q_d,
mask=d_mask,
other=0.0,
).to(tl.float32) # [D]
for blk in tl.range(chunk_start_block, chunk_end_block):
page = tl.load(bt_row + blk).to(tl.int64)
pos = blk * BLOCK_SIZE_K + off_k
pos_mask = pos < seq_len
k = tl.load(
ik_cache_ptr
+ page * stride_ik_blk
+ off_k[None, :] * stride_ik_pos
+ off_d[:, None] * stride_ik_d,
mask=d_mask[:, None] & pos_mask[None, :],
other=0.0,
).to(tl.float32) # [D, N]
qk = tl.sum(q[:, None] * k, axis=0) * sm_scale_log2e # [N]
qk = tl.where(pos_mask, qk, float("-inf"))
score = tl.max(qk, axis=0) # one score for this 128-block
is_init = blk < init_blocks
is_local = (blk >= local_start) & (blk < num_blocks)
score = tl.where(is_local, 1e29, tl.where(is_init, 1e30, score))
tl.store(
score_ptr + pid_h * stride_s_h + pid_b * stride_s_n + blk * stride_s_k,
score,
)
# ---------------------------------------------------------------------------
# Decode top-k (split-K): per-chunk partial top-k + merge. Forced init/local
# blocks are already encoded in the scores. Ported from the sglang reference.
# ---------------------------------------------------------------------------
@triton.heuristics({"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"])})
@triton.autotune(
configs=[
triton.Config({"BLOCK_SIZE_K": 256}, num_warps=8, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 256}, num_warps=4, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=2),
triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=3),
triton.Config({"BLOCK_SIZE_K": 64}, num_warps=2, num_stages=2),
],
key=["topk"],
)
@triton.jit
def _topk_index_partial_kernel(
s_ptr, # score: [num_idx_heads, batch, max_block]
ts_partial_ptr, # partial scores out: [NUM_TOPK_CHUNKS, num_idx_heads, batch, T]
ti_partial_ptr, # partial idx out (1-indexed global, 0=invalid): same shape
seq_lens, # [batch]
block_size: tl.constexpr, # sparse block size (128)
topk: tl.constexpr,
chunk_blocks: tl.constexpr, # how many score-blocks each chunk owns
stride_s_h,
stride_s_b,
stride_s_k,
stride_ts_c,
stride_ts_h,
stride_ts_b,
stride_ts_t,
stride_ti_c,
stride_ti_h,
stride_ti_b,
stride_ti_t,
BLOCK_SIZE_K: tl.constexpr,
BLOCK_SIZE_T: tl.constexpr,
):
tl.static_assert(topk < BLOCK_SIZE_K)
pid_b = tl.program_id(0)
pid_h = tl.program_id(1)
pid_chunk = tl.program_id(2)
seq_len = tl.load(seq_lens + pid_b)
num_blocks = (seq_len + block_size - 1) // block_size
# Slice this chunk owns within [0, num_blocks).
chunk_start = pid_chunk * chunk_blocks
chunk_end = tl.minimum(chunk_start + chunk_blocks, num_blocks)
chunk_actual = tl.maximum(chunk_end - chunk_start, 0)
off_k = tl.arange(0, BLOCK_SIZE_K)
off_t = tl.arange(0, BLOCK_SIZE_T)
s_ptrs = (
s_ptr
+ pid_b * stride_s_b
+ pid_h * stride_s_h
+ (chunk_start + off_k) * stride_s_k
)
topk_score = tl.full((BLOCK_SIZE_K,), -1e30, dtype=tl.float32)
topk_idx = tl.full((BLOCK_SIZE_K,), 0, dtype=tl.int32)
left_half_mask = tl.arange(0, BLOCK_SIZE_K) < BLOCK_SIZE_K // 2
# Streaming top-K within this chunk. tl.range(0, 0) is a no-op so empty
# chunks (chunk_actual == 0) skip the body and store sentinel -1e30 / 0.
for i in tl.range(0, chunk_actual, BLOCK_SIZE_K):
mask = off_k < chunk_actual - i
score = tl.load(s_ptrs, mask=mask, other=-1e30).to(tl.float32)
score = tl.where(score != score, -1e30, score)
s_ptrs = s_ptrs + stride_s_k * BLOCK_SIZE_K
topk_score, last_topk_score = score, topk_score
topk_idx, last_topk_idx = (
tl.where(mask, chunk_start + i + off_k + 1, 0), # 1-indexed global
topk_idx,
)
n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
for j in tl.static_range(1, n_dims):
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), j, 2, n_dims
)
if i != 0:
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), n_dims, False, n_dims
)
topk_score_new = last_topk_score * left_half_mask + topk_score * (
1 - left_half_mask
)
topk_idx_new = last_topk_idx * left_half_mask + topk_idx * (
1 - left_half_mask
)
topk_score, topk_idx = _bitonic_merge(
topk_score_new, topk_idx_new.to(tl.int32), n_dims, True, n_dims
)
else:
topk_score, topk_idx = _bitonic_merge(
topk_score, topk_idx.to(tl.int32), n_dims, True, n_dims
)
# Extract first BLOCK_SIZE_T entries (top-K of this chunk after the sort).
topk_mask_extract = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
final_score = tl.sum(
topk_mask_extract[:, None]
* tl.reshape(topk_score, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
axis=0,
)
final_idx = tl.sum(
topk_mask_extract[:, None]
* tl.reshape(topk_idx, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
axis=0,
)
# Always write all BLOCK_SIZE_T slots — invalid slots carry -1e30 / 0
# sentinels and lose to real scores in the merge stage.
ts_ptrs = (
ts_partial_ptr
+ pid_chunk * stride_ts_c
+ pid_b * stride_ts_b
+ pid_h * stride_ts_h
+ off_t * stride_ts_t
)
ti_ptrs = (
ti_partial_ptr
+ pid_chunk * stride_ti_c
+ pid_b * stride_ti_b
+ pid_h * stride_ti_h
+ off_t * stride_ti_t
)
tl.store(ts_ptrs, final_score)
tl.store(ti_ptrs, final_idx)
@triton.heuristics(
{
"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"]),
"BLOCK_SIZE_K": lambda args: triton.next_power_of_2(
args["NUM_TOPK_CHUNKS"] * triton.next_power_of_2(args["topk"])
),
}
)
@triton.jit
def _topk_index_merge_kernel(
ts_partial_ptr, # partial scores: [NUM_TOPK_CHUNKS, num_idx_heads, batch, T]
ti_partial_ptr, # partial idx (1-indexed global, 0=invalid): same shape
ti_final_ptr, # final idx (0-indexed, -1=invalid): [num_idx_heads, batch, topk]
seq_lens, # [batch]
block_size: tl.constexpr, # sparse block size (128)
topk: tl.constexpr,
stride_ts_c,
stride_ts_h,
stride_ts_b,
stride_ts_t,
stride_ti_c,
stride_ti_h,
stride_ti_b,
stride_ti_t,
stride_tif_h,
stride_tif_b,
stride_tif_t,
NUM_TOPK_CHUNKS: tl.constexpr,
BLOCK_SIZE_K: tl.constexpr,
BLOCK_SIZE_T: tl.constexpr,
):
pid_b = tl.program_id(0)
pid_h = tl.program_id(1)
seq_len = tl.load(seq_lens + pid_b)
num_blocks = (seq_len + block_size - 1) // block_size
# Load NUM_TOPK_CHUNKS * BLOCK_SIZE_T candidates, padded to BLOCK_SIZE_K.
# Candidate at flat position p comes from chunk = p // BLOCK_SIZE_T,
# in_chunk = p % BLOCK_SIZE_T.
off = tl.arange(0, BLOCK_SIZE_K)
chunk_idx = off // BLOCK_SIZE_T
in_chunk_idx = off % BLOCK_SIZE_T
valid = chunk_idx < NUM_TOPK_CHUNKS
score_offset = (
chunk_idx * stride_ts_c
+ pid_h * stride_ts_h
+ pid_b * stride_ts_b
+ in_chunk_idx * stride_ts_t
)
idx_offset = (
chunk_idx * stride_ti_c
+ pid_h * stride_ti_h
+ pid_b * stride_ti_b
+ in_chunk_idx * stride_ti_t
)
score = tl.load(ts_partial_ptr + score_offset, mask=valid, other=-1e30).to(
tl.float32
)
score = tl.where(score != score, -1e30, score)
idx = tl.load(ti_partial_ptr + idx_offset, mask=valid, other=0).to(tl.int32)
# Full bitonic descending sort of BLOCK_SIZE_K items.
n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
for j in tl.static_range(1, n_dims):
score, idx = _bitonic_merge(score, idx.to(tl.int32), j, 2, n_dims)
score, idx = _bitonic_merge(score, idx.to(tl.int32), n_dims, True, n_dims)
# Extract first BLOCK_SIZE_T positions — these are the global top-K.
extract_mask = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
topk_idx_final = tl.sum(
extract_mask[:, None]
* tl.reshape(idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
axis=0,
)
off_t = tl.arange(0, BLOCK_SIZE_T)
tif_ptrs = (
ti_final_ptr
+ pid_h * stride_tif_h
+ pid_b * stride_tif_b
+ off_t * stride_tif_t
)
store_mask = off_t < topk
topk_idx_final = tl.where(off_t < tl.minimum(topk, num_blocks), topk_idx_final, -1)
tl.store(
tif_ptrs, topk_idx_final.to(ti_final_ptr.dtype.element_ty), mask=store_mask
)
# ---------------------------------------------------------------------------
# Python wrappers
# ---------------------------------------------------------------------------
@torch.no_grad()
def minimax_m3_index_topk(
idx_q: torch.Tensor, # [total_q, num_idx_heads, head_dim]
index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
block_table: torch.Tensor, # [batch, max_blocks]
cu_seqlens_q: torch.Tensor, # [batch+1] int32
seq_lens: torch.Tensor, # [batch] int32
prefix_lens: torch.Tensor, # [batch] int32
max_query_len: int,
max_seq_len: int,
topk: int,
init_blocks: int,
local_blocks: int,
num_kv_heads: int,
sm_scale: float,
) -> torch.Tensor:
"""Index block-score + top-k selection. block_size_q == 1 (per-token).
Returns topk_idx [num_kv_heads, total_q, topk] of 0-indexed block ids
(right-padded with -1). M3 has num_idx_heads == num_kv_heads, so the
per-index-head top-k maps 1:1 to kv heads (no index-head reduction needed).
"""
total_q, num_idx_heads, head_dim = idx_q.shape
assert num_idx_heads == num_kv_heads, (
"M3 expects num_idx_heads == num_kv_heads (no topk index reduce)"
)
batch = cu_seqlens_q.shape[0] - 1
max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
score = torch.empty(
(num_idx_heads, total_q, max_block),
dtype=torch.float32,
device=idx_q.device,
)
BLOCK_SIZE_Q = 64
grid_score = (triton.cdiv(max_query_len, BLOCK_SIZE_Q), batch * num_idx_heads)
_index_block_score_kernel[grid_score](
idx_q,
index_kv_cache,
score,
block_table,
cu_seqlens_q,
seq_lens,
prefix_lens,
num_idx_heads,
head_dim,
sm_scale,
idx_q.stride(0),
idx_q.stride(1),
idx_q.stride(2),
index_kv_cache.stride(0),
index_kv_cache.stride(1),
index_kv_cache.stride(2),
score.stride(0),
score.stride(1),
score.stride(2),
block_table.stride(0),
BLOCK_SIZE_Q=BLOCK_SIZE_Q,
BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
)
topk_idx = torch.empty(
(num_idx_heads, total_q, topk),
dtype=torch.int32,
device=idx_q.device,
)
# block_size_q == 1 -> query blocks coincide with query tokens.
grid_topk = (max_query_len, batch, num_idx_heads)
_topk_index_kernel[grid_topk](
score,
topk_idx,
1, # sample_interval (block_size_q)
SPARSE_BLOCK_SIZE,
cu_seqlens_q,
cu_seqlens_q, # cu_seqblocks_q == cu_seqlens_q when block_size_q == 1
prefix_lens,
topk,
init_blocks,
local_blocks,
score.stride(0),
score.stride(1),
score.stride(2),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
MASK_INIT=False,
MASK_LOCAL=False,
)
return topk_idx
@torch.no_grad()
def minimax_m3_index_topk_decode(
idx_q: torch.Tensor, # [batch, num_idx_heads, head_dim]
index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
block_table: torch.Tensor, # [batch, max_blocks]
seq_lens: torch.Tensor, # [batch] int32
max_seq_len: int,
topk: int,
init_blocks: int,
local_blocks: int,
num_kv_heads: int,
sm_scale: float,
) -> torch.Tensor:
"""Decode index block-score + top-k, both split-K (cudagraph-safe).
Returns topk_idx [num_kv_heads, batch, topk] (0-indexed block ids, -1 pad).
"""
total_q, num_idx_heads, head_dim = idx_q.shape
assert num_idx_heads == num_kv_heads, (
"M3 expects num_idx_heads == num_kv_heads (no topk index reduce)"
)
batch = total_q
max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
score = torch.empty(
(num_idx_heads, total_q, max_block),
dtype=torch.float32,
device=idx_q.device,
)
# split-K over seq blocks; chunk count depends only on shape constants so
# the grid is fixed within a cuda graph.
TARGET_GRID = 4096
MAX_NUM_KV_CHUNKS = 256
target = max(
1, min(MAX_NUM_KV_CHUNKS, TARGET_GRID // max(1, batch * num_idx_heads))
)
num_kv_chunks = 1 << (target.bit_length() - 1)
grid_score = (batch * num_kv_chunks, num_idx_heads)
_decode_index_score_kernel[grid_score](
idx_q,
index_kv_cache,
score,
block_table,
seq_lens,
num_idx_heads,
batch,
head_dim,
init_blocks,
local_blocks,
sm_scale,
idx_q.stride(0),
idx_q.stride(1),
idx_q.stride(2),
index_kv_cache.stride(0),
index_kv_cache.stride(1),
index_kv_cache.stride(2),
score.stride(0),
score.stride(1),
score.stride(2),
block_table.stride(0),
BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
NUM_KV_CHUNKS=num_kv_chunks,
)
topk_idx = torch.empty(
(num_idx_heads, total_q, topk),
dtype=torch.int32,
device=idx_q.device,
)
# Chunk count is shape-constant (cudagraph-safe), capped so the merge sorts
# pow2(num_topk_chunks * pow2(topk)) candidates.
TOPK_TARGET_GRID = 64
MAX_NUM_TOPK_CHUNKS = 16
topk_target = max(
1, min(MAX_NUM_TOPK_CHUNKS, TOPK_TARGET_GRID // max(1, batch * num_idx_heads))
)
num_topk_chunks = 1 << (topk_target.bit_length() - 1)
block_size_t = triton.next_power_of_2(topk)
chunk_blocks = (max_block + num_topk_chunks - 1) // num_topk_chunks
topk_score_partial = torch.empty(
num_topk_chunks,
num_idx_heads,
batch,
block_size_t,
dtype=torch.float32,
device=idx_q.device,
)
topk_idx_partial = torch.empty(
num_topk_chunks,
num_idx_heads,
batch,
block_size_t,
dtype=torch.int32,
device=idx_q.device,
)
_topk_index_partial_kernel[(batch, num_idx_heads, num_topk_chunks)](
score,
topk_score_partial,
topk_idx_partial,
seq_lens,
SPARSE_BLOCK_SIZE,
topk,
chunk_blocks,
score.stride(0),
score.stride(1),
score.stride(2),
topk_score_partial.stride(0),
topk_score_partial.stride(1),
topk_score_partial.stride(2),
topk_score_partial.stride(3),
topk_idx_partial.stride(0),
topk_idx_partial.stride(1),
topk_idx_partial.stride(2),
topk_idx_partial.stride(3),
)
_topk_index_merge_kernel[(batch, num_idx_heads)](
topk_score_partial,
topk_idx_partial,
topk_idx,
seq_lens,
SPARSE_BLOCK_SIZE,
topk,
topk_score_partial.stride(0),
topk_score_partial.stride(1),
topk_score_partial.stride(2),
topk_score_partial.stride(3),
topk_idx_partial.stride(0),
topk_idx_partial.stride(1),
topk_idx_partial.stride(2),
topk_idx_partial.stride(3),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
NUM_TOPK_CHUNKS=num_topk_chunks,
)
return topk_idx
@@ -0,0 +1,510 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Triton kernels for MiniMax M3 block-sparse GQA attention.
The main heads attend only to the blocks selected by the lightning indexer (see
``index_topk``). Ported from the sglang reference (minimax_sparse_ops), adapted
to vLLM's paged KV cache: the KV page size is forced to equal the sparse block
size (128), so one selected block maps to exactly one page.
Main K/V cache layout (vLLM):
``(num_blocks, 2, 128, num_kv_heads, head_dim)`` K=[:,0] V=[:,1]
Only the paths MiniMax M3 uses are implemented: no attention sink, base-2
(exp2/log2) softmax. The decode kernels use split-K (flash-decoding) over the
selected blocks with a separate merge step, since one query token per request
leaves the prefill kernels (which parallelize over the query dim) idle.
"""
import torch
from vllm.triton_utils import tl, triton
# One sparse block == one KV page.
SPARSE_BLOCK_SIZE = 128
# ---------------------------------------------------------------------------
# GQA block-sparse attention (paged). Main heads attend only to the selected
# blocks. BLOCK_SIZE_K == 128 so each selected block is one page.
# ---------------------------------------------------------------------------
@triton.heuristics(
{
"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"]),
"BLOCK_SIZE_H": lambda args: triton.next_power_of_2(args["gqa_group_size"]),
"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["max_topk"]),
"BLOCK_SIZE_QH": lambda args: args["BLOCK_SIZE_Q"]
* triton.next_power_of_2(args["gqa_group_size"]),
}
)
@triton.jit
def _gqa_sparse_fwd_kernel(
q_ptr, # [total_q, num_heads, head_dim]
kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
o_ptr, # [total_q, num_heads, head_dim]
block_table_ptr, # [num_reqs, max_blocks]
cu_seqlens_q,
cu_seqblocks_q,
seq_lens,
prefix_lens,
num_kv_heads,
gqa_group_size,
head_dim,
max_topk,
num_q_loop,
sm_scale,
stride_qn,
stride_qh,
stride_qd,
stride_kv_blk,
stride_kv_kv,
stride_kv_pos,
stride_kv_h,
stride_kv_d,
stride_th,
stride_tn,
stride_tk,
stride_on,
stride_oh,
stride_od,
stride_bt_b,
BLOCK_SIZE_Q: tl.constexpr,
BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
BLOCK_SIZE_D: tl.constexpr,
BLOCK_SIZE_H: tl.constexpr,
BLOCK_SIZE_T: tl.constexpr,
BLOCK_SIZE_QH: tl.constexpr,
):
sm_scale_log2e = sm_scale * 1.4426950409
pid_q = tl.program_id(0)
pid_kh = tl.program_id(1)
pid_b = tl.program_id(2)
pid_h = pid_kh * gqa_group_size
q_start = tl.load(cu_seqlens_q + pid_b)
q_len = tl.load(cu_seqlens_q + pid_b + 1) - q_start
q_block_start = tl.load(cu_seqblocks_q + pid_b)
q_block_len = tl.load(cu_seqblocks_q + pid_b + 1) - q_block_start
seq_len = tl.load(seq_lens + pid_b)
prefix_len = tl.load(prefix_lens + pid_b)
if pid_q * num_q_loop >= q_block_len:
return
real_q_loop = min(num_q_loop, q_block_len - pid_q * num_q_loop)
bt_row = block_table_ptr + pid_b * stride_bt_b
off_n = tl.arange(0, BLOCK_SIZE_K)
off_d = tl.arange(0, BLOCK_SIZE_D)
d_mask = off_d < head_dim
for j in range(real_q_loop):
pid_q_j = pid_q * num_q_loop + j
t_ptr_j = t_ptr + (q_block_start + pid_q_j) * stride_tn + pid_kh * stride_th
off_t = tl.arange(0, BLOCK_SIZE_T)
topk_idx = tl.load(t_ptr_j + off_t * stride_tk, mask=off_t < max_topk, other=-1)
real_topk = tl.sum((topk_idx >= 0).to(tl.int32), axis=0)
q_ptrs = tl.make_block_ptr(
base=q_ptr + q_start * stride_qn + pid_h * stride_qh,
shape=(q_len, gqa_group_size, head_dim),
strides=(stride_qn, stride_qh, stride_qd),
offsets=(pid_q_j * BLOCK_SIZE_Q, 0, 0),
block_shape=(BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D),
order=(2, 1, 0),
)
q = tl.load(q_ptrs, boundary_check=(0, 1, 2), padding_option="zero")
off_q = (
tl.arange(0, BLOCK_SIZE_Q)[:, None]
+ pid_q_j * BLOCK_SIZE_Q
+ prefix_len
- tl.arange(0, BLOCK_SIZE_K)[None, :]
)
m_i = tl.full((BLOCK_SIZE_QH,), float("-inf"), dtype=tl.float32)
lse_i = tl.full((BLOCK_SIZE_QH,), float("-inf"), dtype=tl.float32)
acc_o = tl.zeros((BLOCK_SIZE_QH, BLOCK_SIZE_D), dtype=tl.float32)
q = tl.reshape(q, BLOCK_SIZE_QH, BLOCK_SIZE_D)
for _ in range(real_topk):
blk = tl.load(t_ptr_j).to(tl.int32)
t_ptr_j = t_ptr_j + stride_tk
c = blk * BLOCK_SIZE_K
page = tl.load(bt_row + blk).to(tl.int64)
pos = c + off_n
pos_mask = pos < seq_len
k = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
+ 0 * stride_kv_kv
+ off_n[None, :] * stride_kv_pos
+ pid_kh * stride_kv_h
+ off_d[:, None] * stride_kv_d,
mask=d_mask[:, None] & pos_mask[None, :],
other=0.0,
)
qk = tl.zeros((BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32)
# causal: q_abs_pos - k_off >= block_start (c)
qk += tl.where(off_q[:, None, :] >= c, 0, float("-inf"))
qk = tl.reshape(qk, BLOCK_SIZE_QH, BLOCK_SIZE_K)
qk += tl.dot(q, k) * sm_scale_log2e
qk += tl.where(pos_mask[None, :], 0, float("-inf"))
m_ij = tl.maximum(m_i, tl.max(qk, axis=1))
p = tl.exp2(qk - m_ij[:, None])
l_ij = tl.sum(p, axis=1)
acc_o = acc_o * tl.exp2(m_i - m_ij)[:, None]
v = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
+ 1 * stride_kv_kv
+ off_n[:, None] * stride_kv_pos
+ pid_kh * stride_kv_h
+ off_d[None, :] * stride_kv_d,
mask=pos_mask[:, None] & d_mask[None, :],
other=0.0,
)
acc_o += tl.dot(p.to(v.dtype), v)
m_i = m_ij
lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
acc_o = acc_o * tl.exp2(m_i - lse_i)[:, None]
acc_o = tl.reshape(acc_o, BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D)
o_ptrs = tl.make_block_ptr(
base=o_ptr + q_start * stride_on + pid_h * stride_oh,
shape=(q_len, gqa_group_size, head_dim),
strides=(stride_on, stride_oh, stride_od),
offsets=(pid_q_j * BLOCK_SIZE_Q, 0, 0),
block_shape=(BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D),
order=(2, 1, 0),
)
tl.store(o_ptrs, acc_o.to(o_ptr.dtype.element_ty), boundary_check=(0, 1, 2))
# ---------------------------------------------------------------------------
# Decode kernels (split-K). Decode == one query token per request, so the
# prefill kernel (which parallelizes over the query dim) leaves the GPU idle.
# This instead parallelizes over the selected top-k blocks, producing partials
# that the merge kernel combines (flash-decoding). All chunk counts depend only
# on shape constants so the grid is fixed within a cuda graph. Base-2
# (exp2/log2) softmax matches the prefill kernel.
# ---------------------------------------------------------------------------
@triton.heuristics(
{
"BLOCK_SIZE_H": lambda args: max(
16, triton.next_power_of_2(args["gqa_group_size"])
),
"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"]),
"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["max_topk"]),
}
)
@triton.jit
def _gqa_sparse_decode_kernel(
q_ptr, # [total_q (== batch), num_heads, head_dim]
kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
t_ptr, # topk_idx: [num_kv_heads, batch, topk]
o_ptr, # partial out: [NUM_TOPK_CHUNKS, batch, num_heads, head_dim]
lse_ptr, # partial lse (log2): [NUM_TOPK_CHUNKS, batch, num_heads]
block_table_ptr, # [num_reqs, max_blocks]
seq_lens, # [batch]
batch_size,
gqa_group_size,
head_dim,
max_topk,
sm_scale,
stride_qn,
stride_qh,
stride_qd,
stride_kv_blk,
stride_kv_kv,
stride_kv_pos,
stride_kv_h,
stride_kv_d,
stride_th,
stride_tn,
stride_tk,
stride_o_c,
stride_o_b,
stride_o_h,
stride_o_d,
stride_l_c,
stride_l_b,
stride_l_h,
stride_bt_b,
BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
NUM_TOPK_CHUNKS: tl.constexpr,
BLOCK_SIZE_H: tl.constexpr,
BLOCK_SIZE_D: tl.constexpr,
BLOCK_SIZE_T: tl.constexpr,
):
sm_scale_log2e = sm_scale * 1.4426950409
# split-K over the topk dimension: pid(0) folds (batch, chunk) together.
pid_bc, pid_kh = tl.program_id(0), tl.program_id(1)
pid_b = pid_bc % batch_size
pid_c = pid_bc // batch_size
pid_h = pid_kh * gqa_group_size
chunk_size_topk = (max_topk + NUM_TOPK_CHUNKS - 1) // NUM_TOPK_CHUNKS
chunk_start_topk = pid_c * chunk_size_topk
chunk_end_compiletime = chunk_start_topk + chunk_size_topk
seq_len = tl.load(seq_lens + pid_b)
# number of valid (non-padded) selected blocks for this request
off_t = tl.arange(0, BLOCK_SIZE_T)
idx_base = t_ptr + pid_kh * stride_th + pid_b * stride_tn
topk_idx = tl.load(idx_base + off_t * stride_tk, mask=off_t < max_topk, other=-1)
real_topk = tl.sum((topk_idx >= 0).to(tl.int32), axis=0)
chunk_end_topk = tl.minimum(chunk_end_compiletime, real_topk)
off_n = tl.arange(0, BLOCK_SIZE_K)
off_d = tl.arange(0, BLOCK_SIZE_D)
d_mask = off_d < head_dim
bt_row = block_table_ptr + pid_b * stride_bt_b
m_i = tl.full((BLOCK_SIZE_H,), float("-inf"), dtype=tl.float32)
lse_i = tl.full((BLOCK_SIZE_H,), float("-inf"), dtype=tl.float32)
acc_o = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_D), dtype=tl.float32)
q_ptrs = tl.make_block_ptr(
base=q_ptr + pid_b * stride_qn + pid_h * stride_qh,
shape=(gqa_group_size, head_dim),
strides=(stride_qh, stride_qd),
offsets=(0, 0),
block_shape=(BLOCK_SIZE_H, BLOCK_SIZE_D),
order=(1, 0),
)
q = tl.load(q_ptrs, boundary_check=(0, 1), padding_option="zero")
cur_idx_ptr = idx_base + chunk_start_topk * stride_tk
for _ in tl.range(chunk_start_topk, chunk_end_topk):
blk = tl.load(cur_idx_ptr).to(tl.int32)
cur_idx_ptr = cur_idx_ptr + stride_tk
c = blk * BLOCK_SIZE_K
page = tl.load(bt_row + blk).to(tl.int64)
pos = c + off_n
pos_mask = pos < seq_len # decode query is the last token: attend all valid
k = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
+ 0 * stride_kv_kv
+ off_n[None, :] * stride_kv_pos
+ pid_kh * stride_kv_h
+ off_d[:, None] * stride_kv_d,
mask=d_mask[:, None] & pos_mask[None, :],
other=0.0,
)
qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32)
qk += tl.where(pos_mask[None, :], 0, float("-inf"))
qk += tl.dot(q, k) * sm_scale_log2e
m_ij = tl.maximum(m_i, tl.max(qk, axis=1))
p = tl.exp2(qk - m_ij[:, None])
l_ij = tl.sum(p, axis=1)
acc_o = acc_o * tl.exp2(m_i - m_ij)[:, None]
v = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
+ 1 * stride_kv_kv
+ off_n[:, None] * stride_kv_pos
+ pid_kh * stride_kv_h
+ off_d[None, :] * stride_kv_d,
mask=pos_mask[:, None] & d_mask[None, :],
other=0.0,
)
acc_o += tl.dot(p.to(v.dtype), v)
m_i = m_ij
lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
# empty chunks (chunk_start >= real_topk) keep lse_i = -inf -> weight 0 in merge
scale = tl.where(lse_i > float("-inf"), tl.exp2(m_i - lse_i), tl.zeros_like(lse_i))
acc_o = acc_o * scale[:, None]
o_ptrs = tl.make_block_ptr(
base=o_ptr + pid_c * stride_o_c + pid_b * stride_o_b + pid_h * stride_o_h,
shape=(gqa_group_size, head_dim),
strides=(stride_o_h, stride_o_d),
offsets=(0, 0),
block_shape=(BLOCK_SIZE_H, BLOCK_SIZE_D),
order=(1, 0),
)
tl.store(o_ptrs, acc_o.to(o_ptr.dtype.element_ty), boundary_check=(0, 1))
lse_ptrs = tl.make_block_ptr(
base=lse_ptr + pid_c * stride_l_c + pid_b * stride_l_b + pid_h * stride_l_h,
shape=(gqa_group_size,),
strides=(stride_l_h,),
offsets=(0,),
block_shape=(BLOCK_SIZE_H,),
order=(0,),
)
tl.store(lse_ptrs, lse_i.to(lse_ptr.dtype.element_ty), boundary_check=(0,))
@triton.heuristics(
{"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"])}
)
@triton.jit
def _merge_topk_attn_out_kernel(
o_ptr, # partials: [NUM_TOPK_CHUNKS, batch, num_heads, head_dim]
lse_ptr, # partials (log2): [NUM_TOPK_CHUNKS, batch, num_heads]
out_ptr, # merged out: [total_q (== batch), num_heads, head_dim]
head_dim,
stride_o_c,
stride_o_b,
stride_o_h,
stride_o_d,
stride_l_c,
stride_l_b,
stride_l_h,
stride_out_n,
stride_out_h,
stride_out_d,
NUM_TOPK_CHUNKS: tl.constexpr,
BLOCK_SIZE_D: tl.constexpr,
):
pid_b, pid_h = tl.program_id(0), tl.program_id(1)
off_c = tl.arange(0, NUM_TOPK_CHUNKS)
off_d = tl.arange(0, BLOCK_SIZE_D)
o_ptrs = tl.make_block_ptr(
base=o_ptr + pid_b * stride_o_b + pid_h * stride_o_h,
shape=(NUM_TOPK_CHUNKS, head_dim),
strides=(stride_o_c, stride_o_d),
offsets=(0, 0),
block_shape=(NUM_TOPK_CHUNKS, BLOCK_SIZE_D),
order=(1, 0),
)
lse_ptrs = lse_ptr + pid_b * stride_l_b + pid_h * stride_l_h + off_c * stride_l_c
o = tl.load(o_ptrs, boundary_check=(0, 1), padding_option="zero")
lse = tl.load(lse_ptrs) # empty chunks contribute -inf -> weight 0
lse_max = tl.max(lse, axis=0)
weights = tl.exp2(lse - lse_max)
weights = weights / tl.sum(weights, axis=0)
o_merged = tl.sum(o * weights[:, None], axis=0)
out_ptrs = (
out_ptr + pid_b * stride_out_n + pid_h * stride_out_h + off_d * stride_out_d
)
tl.store(out_ptrs, o_merged.to(out_ptr.dtype.element_ty), mask=off_d < head_dim)
# ---------------------------------------------------------------------------
# Python wrappers
# ---------------------------------------------------------------------------
@torch.no_grad()
def minimax_m3_sparse_attn(
q: torch.Tensor, # [total_q, num_heads, head_dim]
kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
block_table: torch.Tensor, # [batch, max_blocks]
cu_seqlens_q: torch.Tensor, # [batch+1] int32
seq_lens: torch.Tensor, # [batch] int32
prefix_lens: torch.Tensor, # [batch] int32
max_query_len: int,
num_kv_heads: int,
sm_scale: float,
output: torch.Tensor, # [total_q, num_heads, head_dim]
) -> None:
"""GQA block-sparse attention over the selected blocks. block_size_q == 1."""
total_q, num_heads, head_dim = q.shape
batch = cu_seqlens_q.shape[0] - 1
topk = topk_idx.shape[-1]
gqa_group_size = num_heads // num_kv_heads
grid = (max_query_len, num_kv_heads, batch)
_gqa_sparse_fwd_kernel[grid](
q,
kv_cache,
topk_idx,
output,
block_table,
cu_seqlens_q,
cu_seqlens_q, # cu_seqblocks_q == cu_seqlens_q when block_size_q == 1
seq_lens,
prefix_lens,
num_kv_heads,
gqa_group_size,
head_dim,
topk,
1, # num_q_loop
sm_scale,
q.stride(0),
q.stride(1),
q.stride(2),
kv_cache.stride(0),
kv_cache.stride(1),
kv_cache.stride(2),
kv_cache.stride(3),
kv_cache.stride(4),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
output.stride(0),
output.stride(1),
output.stride(2),
block_table.stride(0),
BLOCK_SIZE_Q=1,
BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
)
@torch.no_grad()
def minimax_m3_sparse_attn_decode(
q: torch.Tensor, # [batch, num_heads, head_dim]
kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
topk_idx: torch.Tensor, # [num_kv_heads, batch, topk]
block_table: torch.Tensor, # [batch, max_blocks]
seq_lens: torch.Tensor, # [batch] int32
num_kv_heads: int,
sm_scale: float,
output: torch.Tensor, # [batch, num_heads, head_dim]
) -> None:
"""GQA block-sparse attention for decode (split-K over the top-k blocks)."""
batch, num_heads, head_dim = q.shape
max_topk = topk_idx.shape[-1]
gqa_group_size = num_heads // num_kv_heads
# split-K over the selected blocks; chunk count is shape-constant (cuda graph).
TARGET_GRID = 256
target = max(1, min(max_topk, TARGET_GRID // max(1, batch * num_kv_heads)))
num_topk_chunks = 1 << (target.bit_length() - 1)
o_partial = torch.empty(
num_topk_chunks, batch, num_heads, head_dim, dtype=q.dtype, device=q.device
)
lse_partial = torch.empty(
num_topk_chunks, batch, num_heads, dtype=torch.float32, device=q.device
)
grid = (batch * num_topk_chunks, num_kv_heads)
_gqa_sparse_decode_kernel[grid](
q,
kv_cache,
topk_idx,
o_partial,
lse_partial,
block_table,
seq_lens,
batch,
gqa_group_size,
head_dim,
max_topk,
sm_scale,
q.stride(0),
q.stride(1),
q.stride(2),
kv_cache.stride(0),
kv_cache.stride(1),
kv_cache.stride(2),
kv_cache.stride(3),
kv_cache.stride(4),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
o_partial.stride(0),
o_partial.stride(1),
o_partial.stride(2),
o_partial.stride(3),
lse_partial.stride(0),
lse_partial.stride(1),
lse_partial.stride(2),
block_table.stride(0),
BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
NUM_TOPK_CHUNKS=num_topk_chunks,
)
merge_grid = (batch, num_heads)
_merge_topk_attn_out_kernel[merge_grid](
o_partial,
lse_partial,
output,
head_dim,
o_partial.stride(0),
o_partial.stride(1),
o_partial.stride(2),
o_partial.stride(3),
lse_partial.stride(0),
lse_partial.stride(1),
lse_partial.stride(2),
output.stride(0),
output.stride(1),
output.stride(2),
NUM_TOPK_CHUNKS=num_topk_chunks,
)
@@ -0,0 +1,605 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Attention backend for MiniMax M3 sparse ("lightning indexer") attention.
MiniMax M3 sparse layers run GQA attention restricted to a small set of KV
blocks chosen by a lightning indexer: index heads score KV blocks, the top-k
blocks (plus fixed init/local blocks) are selected, and the main attention
attends only to those blocks. Index keys live in a separate side cache
(``MiniMaxM3IndexerCache``).
"""
from dataclasses import dataclass
from typing import ClassVar
import torch
from torch import nn
from vllm.compilation.breakable_cudagraph import eager_break_during_capture
from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
from vllm.config.cache import CacheDType
from vllm.forward_context import get_forward_context
from vllm.logger import init_logger
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
from vllm.models.minimax_m3.common.ops.index_topk import (
minimax_m3_index_topk,
minimax_m3_index_topk_decode,
)
from vllm.models.minimax_m3.common.ops.sparse_attn import (
SPARSE_BLOCK_SIZE,
minimax_m3_sparse_attn,
minimax_m3_sparse_attn_decode,
)
from vllm.platforms import current_platform
from vllm.utils.import_utils import has_cutedsl
from vllm.v1.attention.backend import (
AttentionBackend,
AttentionCGSupport,
AttentionImplBase,
AttentionLayer,
AttentionMetadata,
AttentionMetadataBuilder,
CommonAttentionMetadata,
MultipleOf,
)
from vllm.v1.attention.backends.utils import (
get_kv_cache_layout,
split_decodes_and_prefills,
)
from vllm.v1.kv_cache_interface import (
AttentionSpec,
KVCacheSpec,
MLAAttentionSpec,
)
logger = init_logger(__name__)
class MiniMaxM3SparseBackend(AttentionBackend):
"""Block-sparse GQA backend for MiniMax M3 sparse attention layers."""
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16, torch.float16]
# Sparse kernels operate on a bf16 KV cache only.
supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = ["bfloat16"]
@staticmethod
def get_name() -> str:
return "MINIMAX_M3_SPARSE"
@staticmethod
def get_impl_cls() -> type["MiniMaxM3SparseImpl"]:
return MiniMaxM3SparseImpl
@staticmethod
def get_builder_cls() -> type["MiniMaxM3SparseMetadataBuilder"]:
return MiniMaxM3SparseMetadataBuilder
@classmethod
def get_supported_head_sizes(cls) -> list[int]:
return [128]
@staticmethod
def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
# Page size must equal the sparse block size so one sparse block maps
# to one KV page (see common.ops.sparse_attn).
return [128]
@classmethod
def is_sparse(cls) -> bool:
return True
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, 2, block_size, num_kv_heads, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
# `stride_order` indicates the permutation that gets us from
# `get_kv_cache_shape` to the actual memory layout we want.
if include_num_layers_dimension:
# M3 does not use cross-layer (per-layer-stacked) KV blocks for now.
raise NotImplementedError
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD":
stride_order = (0, 1, 2, 3, 4)
elif cache_layout == "HND":
stride_order = (0, 1, 3, 2, 4)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
return stride_order
class MiniMaxM3IndexerBackend(MiniMaxM3SparseBackend):
"""Backend for the lightning-indexer side cache (key-only, single vector).
Shares the impl/builder/metadata with the main sparse backend but stores a
single index-key vector per token, matching the MLAAttentionSpec page.
"""
@staticmethod
def get_name() -> str:
return "MINIMAX_M3_SPARSE_INDEXER"
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
if include_num_layers_dimension:
# M3 does not use cross-layer (per-layer-stacked) KV blocks.
raise NotImplementedError
return (0, 1, 2)
class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
"""Side KV cache for the lightning indexer's per-token index keys.
Stores a single index-key vector per token (no value: M3 disables the index
value projection). Modeled on ``DeepseekV32IndexerCache``; registers itself
in the static forward context so the KV-cache manager allocates its cache.
"""
def __init__(
self,
head_dim: int,
dtype: torch.dtype,
prefix: str,
cache_config: CacheConfig | None = None,
) -> None:
super().__init__()
self.kv_cache = torch.tensor([])
self.head_dim = head_dim
self.dtype = dtype
self.prefix = prefix
self.cache_config = cache_config
compilation_config = get_current_vllm_config().compilation_config
if prefix in compilation_config.static_forward_context:
raise ValueError(f"Duplicate layer name: {prefix}")
compilation_config.static_forward_context[prefix] = self
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
# Key-only cache: one vector per token. MLAAttentionSpec budgets a
# single vector; FullAttentionSpec would reserve 2x for K+V.
return MLAAttentionSpec(
block_size=vllm_config.cache_config.block_size,
num_kv_heads=1,
head_size=self.head_dim,
dtype=self.dtype,
)
def forward(self) -> None: ...
def get_attn_backend(self) -> type[AttentionBackend]:
return MiniMaxM3IndexerBackend
@dataclass
class MiniMaxM3SparsePrefillMetadata:
"""Per-prefill state for index scoring + block-sparse attention.
``cu_seqlens_q``/``context_lens`` are precomputed in the builder so the
forward path stays free of host syncs and per-step tensor allocations.
"""
cu_seqlens_q: torch.Tensor # [num_prefills + 1] int32, rebased to 0
cu_seqlens_k: torch.Tensor # [num_prefills + 1] int32, cumulative KV lengths
seq_lens: torch.Tensor # [num_prefills] int32, total KV lengths
context_lens: torch.Tensor # [num_prefills] int32 (cached/context tokens)
block_table: torch.Tensor
max_query_len: int
max_seq_len: int
total_kv_blocks: int
@dataclass
class MiniMaxM3SparseDecodeMetadata:
"""Per-decode state (cudagraph-safe); split-K kernels key only on seq_lens."""
seq_lens: torch.Tensor # [num_decodes] int32
block_table: torch.Tensor
@dataclass
class MiniMaxM3SparseMetadata(AttentionMetadata):
"""Sparse-attention metadata, split into prefill and decode sub-metadata."""
seq_lens: torch.Tensor
max_seq_len: int
slot_mapping: torch.Tensor
# Total query tokens in the (decode-first) batch.
num_actual_tokens: int
# Split counts (batch is reordered decode-first).
num_decodes: int
num_decode_tokens: int
num_prefills: int
num_prefill_tokens: int
prefill: MiniMaxM3SparsePrefillMetadata | None = None
decode: MiniMaxM3SparseDecodeMetadata | None = None
class MiniMaxM3SparseMetadataBuilder(AttentionMetadataBuilder[MiniMaxM3SparseMetadata]):
# Full cudagraphs for uniform single-query decode batches.
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
# The split-K decode kernel doesn't support spec decode yet (it handles one
# query token per request only). Keep the threshold at 1 so multi-query
# verify batches route to the prefill kernels instead of the decode kernel.
reorder_batch_threshold: int = 1
def __init__(
self,
kv_cache_spec: AttentionSpec,
layer_names: list[str],
vllm_config: VllmConfig,
device: torch.device,
) -> None:
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
# Stable per-request context-length buffer for decode cudagraph replays.
# Sized to max_num_batched_tokens (>= num_reqs).
self.context_len_buffer = torch.empty(
vllm_config.scheduler_config.max_num_batched_tokens,
dtype=torch.int32,
device=device,
)
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
) -> MiniMaxM3SparseMetadata:
num_reqs = common_attn_metadata.num_reqs
num_tokens = common_attn_metadata.num_actual_tokens
query_start_loc = common_attn_metadata.query_start_loc
seq_lens = common_attn_metadata.seq_lens
block_table = common_attn_metadata.block_table_tensor
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
split_decodes_and_prefills(
common_attn_metadata,
decode_threshold=self.reorder_batch_threshold,
)
)
assert num_decodes + num_prefills == num_reqs
assert num_decode_tokens + num_prefill_tokens == num_tokens
# Per-request context tokens (seq_len - query_len) into the stable
# buffer; batch is decode-first ([:num_decodes] decode, rest prefill).
context_lens = self.context_len_buffer[:num_reqs]
context_lens.copy_(
common_attn_metadata.compute_num_computed_tokens(), non_blocking=True
)
prefill_metadata: MiniMaxM3SparsePrefillMetadata | None = None
if num_prefills > 0:
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
assert seq_lens_cpu is not None
prefill_seq_lens_cpu = seq_lens_cpu[num_decodes:]
prefill_total_kv_blocks = (
((prefill_seq_lens_cpu + SPARSE_BLOCK_SIZE - 1) // SPARSE_BLOCK_SIZE)
.sum()
.item()
)
prefill_kv_lens = seq_lens[num_decodes:]
prefill_cu_seqlens_k = torch.empty(
num_prefills + 1, dtype=torch.int32, device=seq_lens.device
)
prefill_cu_seqlens_k[0] = 0
torch.cumsum(prefill_kv_lens, dim=0, out=prefill_cu_seqlens_k[1:])
prefill_metadata = MiniMaxM3SparsePrefillMetadata(
cu_seqlens_q=(query_start_loc[num_decodes:] - num_decode_tokens).to(
torch.int32
),
cu_seqlens_k=prefill_cu_seqlens_k,
seq_lens=prefill_kv_lens,
context_lens=context_lens[num_decodes:],
block_table=block_table[num_decodes:],
max_query_len=common_attn_metadata.max_query_len,
max_seq_len=common_attn_metadata.max_seq_len,
total_kv_blocks=prefill_total_kv_blocks,
)
decode_metadata: MiniMaxM3SparseDecodeMetadata | None = None
if num_decodes > 0:
decode_metadata = MiniMaxM3SparseDecodeMetadata(
seq_lens=seq_lens[:num_decodes],
block_table=block_table[:num_decodes],
)
return MiniMaxM3SparseMetadata(
seq_lens=seq_lens,
max_seq_len=common_attn_metadata.max_seq_len,
slot_mapping=common_attn_metadata.slot_mapping,
num_actual_tokens=num_tokens,
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
num_prefills=num_prefills,
num_prefill_tokens=num_prefill_tokens,
prefill=prefill_metadata,
decode=decode_metadata,
)
class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
"""Block-sparse GQA attention for MiniMax M3 (forward not yet implemented).
Inherits ``AttentionImplBase`` (not ``AttentionImpl``) because the sparse
path needs a custom forward signature: the owning layer pre-inserts K/V and
index-K into their caches, so forward takes only the queries.
"""
def __init__(
self,
num_heads: int,
head_size: int,
scale: float,
num_kv_heads: int | None = None,
kv_cache_dtype: str = "auto",
*,
topk_blocks: int,
sparse_block_size: int,
num_index_heads: int,
index_head_dim: int,
init_blocks: int = 0,
local_blocks: int = 0,
score_type: str = "max",
) -> None:
self.num_heads = num_heads
self.head_size = head_size
self.scale = scale
self.num_kv_heads = num_kv_heads if num_kv_heads is not None else num_heads
self.kv_cache_dtype = kv_cache_dtype
# Sparse selection parameters and index-branch dims.
self.topk_blocks = topk_blocks
self.block_size = sparse_block_size
self.init_blocks = init_blocks
self.local_blocks = local_blocks
self.score_type = score_type
self.num_index_heads = num_index_heads
self.index_head_dim = index_head_dim
can_run_prefill_cutedsl = (
current_platform.is_cuda()
and current_platform.is_device_capability_family(100)
and has_cutedsl()
and self.head_size == 128
and self.block_size == 128
and self.topk_blocks in (4, 8, 16, 32)
)
self._prefill_gqa_sparse = (
self._prefill_gqa_sparse_cutedsl
if can_run_prefill_cutedsl
else self._prefill_gqa_sparse_triton
)
def _run_prefill(
self,
q: torch.Tensor, # [tot, num_heads, head_dim]
iq: torch.Tensor, # [tot, num_idx_heads, head_dim]
out: torch.Tensor, # [tot, num_heads, head_dim]
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
seq_lens: torch.Tensor,
context_lens: torch.Tensor,
main_block_table: torch.Tensor,
index_block_table: torch.Tensor,
max_query_len: int,
max_seq_len: int,
total_kv_blocks: int,
) -> None:
# 1. Index block-score + top-k (reads the index-K cache).
topk_idx = minimax_m3_index_topk(
iq,
self._index_kv_cache,
index_block_table,
cu_seqlens_q,
seq_lens,
context_lens,
max_query_len,
max_seq_len,
self.topk_blocks,
self.init_blocks,
self.local_blocks,
self.num_kv_heads,
self.scale,
)
# 2. GQA block-sparse attention over the selected blocks (main cache).
self._prefill_gqa_sparse(
q,
out,
topk_idx,
cu_seqlens_q,
cu_seqlens_k,
seq_lens,
context_lens,
main_block_table,
max_query_len,
max_seq_len,
total_kv_blocks,
)
def _prefill_gqa_sparse_triton(
self,
q: torch.Tensor,
out: torch.Tensor,
topk_idx: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
seq_lens: torch.Tensor,
context_lens: torch.Tensor,
main_block_table: torch.Tensor,
max_query_len: int,
max_seq_len: int,
total_kv_blocks: int,
) -> None:
minimax_m3_sparse_attn(
q,
self._kv_cache,
topk_idx,
main_block_table,
cu_seqlens_q,
seq_lens,
context_lens,
max_query_len,
self.num_kv_heads,
self.scale,
out,
)
def _prefill_gqa_sparse_cutedsl(
self,
q: torch.Tensor,
out: torch.Tensor,
topk_idx: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
seq_lens: torch.Tensor,
context_lens: torch.Tensor,
main_block_table: torch.Tensor,
max_query_len: int,
max_seq_len: int,
total_kv_blocks: int,
) -> None:
from vllm.models.minimax_m3.nvidia.ops.prefill_gqa_sparse import (
minimax_m3_sparse_attn_cutedsl,
)
minimax_m3_sparse_attn_cutedsl(
q,
self._kv_cache,
topk_idx,
main_block_table,
cu_seqlens_q,
cu_seqlens_k,
seq_lens,
max_query_len,
max_seq_len,
self.num_kv_heads,
self.scale,
out,
total_kv_blocks=total_kv_blocks,
)
def _run_decode(
self,
q: torch.Tensor, # [batch, num_heads, head_dim]
iq: torch.Tensor, # [batch, num_idx_heads, head_dim]
out: torch.Tensor, # [batch, num_heads, head_dim]
seq_lens: torch.Tensor,
main_block_table: torch.Tensor,
index_block_table: torch.Tensor,
max_seq_len: int,
) -> None:
# Split-K decode kernels (parallelize over KV; one query token/request).
# 1. Index block-score + top-k.
topk_idx = minimax_m3_index_topk_decode(
iq,
self._index_kv_cache,
index_block_table,
seq_lens,
max_seq_len,
self.topk_blocks,
self.init_blocks,
self.local_blocks,
self.num_kv_heads,
self.scale,
)
# 2. GQA block-sparse attention (split-K over the selected blocks).
minimax_m3_sparse_attn_decode(
q,
self._kv_cache,
topk_idx,
main_block_table,
seq_lens,
self.num_kv_heads,
self.scale,
out,
)
@eager_break_during_capture
def forward(
self,
layer: AttentionLayer,
query: torch.Tensor,
index_query: torch.Tensor,
kv_cache: torch.Tensor,
index_kv_cache: torch.Tensor,
output: torch.Tensor,
) -> torch.Tensor:
# K/V and index-K are pre-inserted into their caches; per-request
# metadata comes from the forward context. Only queries are passed in.
attn_metadata = get_forward_context().attn_metadata
if not isinstance(attn_metadata, dict):
# Profiling run: caches unbound, output left as-is.
return output
main_md = attn_metadata[layer.layer_name] # type: ignore[attr-defined]
index_md = attn_metadata[layer.index_cache.prefix] # type: ignore[attr-defined]
assert isinstance(main_md, MiniMaxM3SparseMetadata)
assert isinstance(index_md, MiniMaxM3SparseMetadata)
nd = main_md.num_decode_tokens
num_tokens = main_md.num_actual_tokens
hd = self.head_size
q = query[:num_tokens].view(-1, self.num_heads, hd)
iq = index_query[:num_tokens].view(
-1, self.num_index_heads, self.index_head_dim
)
out = output[:num_tokens].view(-1, self.num_heads, hd)
# Stash caches for _run_phase (avoid threading through every call).
self._kv_cache = kv_cache
self._index_kv_cache = index_kv_cache
# Decode slice [:nd]: each token is a 1-token "prefill" at seq_len-1.
# All kernel args are precomputed in the builder (cudagraph-safe).
if main_md.num_decodes > 0:
d, idx_d = main_md.decode, index_md.decode
assert d is not None and idx_d is not None
self._run_decode(
q[:nd],
iq[:nd],
out[:nd],
d.seq_lens,
d.block_table,
idx_d.block_table,
main_md.max_seq_len,
)
# Prefill slice [nd:]: cu_seqlens_q already rebased to 0.
if main_md.num_prefills > 0:
p, idx_p = main_md.prefill, index_md.prefill
assert p is not None and idx_p is not None
self._run_prefill(
q[nd:],
iq[nd:],
out[nd:],
p.cu_seqlens_q,
p.cu_seqlens_k,
p.seq_lens,
p.context_lens,
p.block_table,
idx_p.block_table,
p.max_query_len,
p.max_seq_len,
p.total_kv_blocks,
)
return output
@@ -0,0 +1,695 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable
import numpy as np
import torch
import torch.nn as nn
from einops import rearrange
from transformers import PretrainedConfig
from vllm.distributed import parallel_state
from vllm.distributed import utils as dist_utils
from vllm.model_executor.layers.activation import get_act_fn
from vllm.model_executor.layers.attention.mm_encoder_attention import (
MMEncoderAttention,
)
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
QKVParallelLinear,
RowParallelLinear,
)
from vllm.model_executor.layers.quantization import QuantizationConfig
from vllm.model_executor.layers.rotary_embedding.common import ApplyRotaryEmb
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.model_executor.models.utils import maybe_prefix
from vllm.model_executor.models.vision import (
get_vit_attn_backend,
is_vit_use_data_parallel,
)
class MiniMaxVLPatchEmbed(nn.Module):
"""Conv3d-based patch embedding.
Takes flat tokens of shape (N, C * temporal_patch_size * patch_size²)
and projects each to a hidden-size embedding.
"""
def __init__(self, config: PretrainedConfig) -> None:
super().__init__()
compression = config.img_token_compression_config
temporal_patch_size = compression.get("temporal_patch_size", 2)
patch_size = config.patch_size
num_channels = config.num_channels
self.patch_size = patch_size
self.temporal_patch_size = temporal_patch_size
self.num_channels = num_channels
self.hidden_size = config.hidden_size
self.patch_embedding = nn.Conv3d(
in_channels=num_channels,
out_channels=config.hidden_size,
kernel_size=(temporal_patch_size, patch_size, patch_size),
stride=(temporal_patch_size, patch_size, patch_size),
bias=False,
)
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
# pixel_values: (N, C * temporal_patch_size * patch_size²)
if self.patch_embedding.weight.dtype != pixel_values.dtype:
self.patch_embedding = self.patch_embedding.to(pixel_values.dtype)
x = pixel_values.reshape(
pixel_values.shape[0],
self.num_channels,
self.temporal_patch_size,
self.patch_size,
self.patch_size,
)
return self.patch_embedding(x).reshape(x.shape[0], -1)
class MiniMaxVLAttention(nn.Module):
"""Multi-head attention with MiniMax's partial 3D RoPE.
Partial means only the first ``rot_dim`` (< head_dim) dimensions of
Q and K are rotated; the remaining dims are passed through unchanged.
"""
def __init__(
self,
embed_dim: int,
num_heads: int,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
use_data_parallel = is_vit_use_data_parallel()
self.tp_size = (
1
if use_data_parallel
else parallel_state.get_tensor_model_parallel_world_size()
)
self.head_dim = embed_dim // num_heads
self.num_heads_per_partition = dist_utils.divide(num_heads, self.tp_size)
self.qkv_proj = QKVParallelLinear(
hidden_size=embed_dim,
head_size=self.head_dim,
total_num_heads=num_heads,
total_num_kv_heads=num_heads,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.qkv_proj",
disable_tp=use_data_parallel,
)
self.out_proj = RowParallelLinear(
input_size=embed_dim,
output_size=embed_dim,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.out_proj",
disable_tp=use_data_parallel,
)
self.attn = MMEncoderAttention(
num_heads=self.num_heads_per_partition,
head_size=self.head_dim,
prefix=f"{prefix}.attn",
)
# ApplyRotaryEmb handles the internal cos/sin repeat and partial
# rotation (ro_dim = half_rot_dim * 2 < head_dim for MiniMax).
# enable_fp32_compute=True runs the rotation in fp32 (q/k upcast,
# fp32 cos/sin), matching the SGLang reference's _minimax_rope_applier.
self.apply_rotary_emb = ApplyRotaryEmb(
enforce_enable=True, enable_fp32_compute=True
)
def forward(
self,
x: torch.Tensor,
cu_seqlens: torch.Tensor,
rotary_cos: torch.Tensor,
rotary_sin: torch.Tensor,
max_seqlen: torch.Tensor,
sequence_lengths: torch.Tensor | None = None,
) -> torch.Tensor:
# x: (N, 1, embed_dim) [seq=N, batch=1, chan=embed_dim]
x_qkv, _ = self.qkv_proj(x) # (N, 1, 3 * heads_per_part * head_dim)
seq_len, batch_size, _ = x_qkv.shape
# Rearrange to (b=1, N, 3, heads, head_dim) — same as Qwen2_5_VisionAttention
qkv = rearrange(
x_qkv,
"s b (three head d) -> b s three head d",
three=3,
head=self.num_heads_per_partition,
)
qk, v = qkv[:, :, :2], qkv[:, :, 2] # (b,N,2,h,d) and (b,N,h,d)
# Stack q/k → (2*b, N, heads, head_dim) for joint RoPE application.
# rotary_cos/sin: (N, half_rot_dim) — ApplyRotaryEmb expands internally
# and rotates only the first 2*half_rot_dim dims, passing the rest through.
qk_reshaped = rearrange(qk, "b s two h d -> (two b) s h d", two=2).contiguous()
qk_rotated = self.apply_rotary_emb(qk_reshaped, rotary_cos, rotary_sin)
qk_rotated = qk_rotated.view(
2, batch_size, seq_len, self.num_heads_per_partition, self.head_dim
)
q, k = qk_rotated.unbind(dim=0) # each (b=1, N, heads, head_dim)
# Flash attention → (b, N, heads, head_dim)
context = self.attn(
query=q,
key=k,
value=v,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
sequence_lengths=sequence_lengths,
)
# Back to (N, 1, embed_dim)
context = rearrange(context, "b s h d -> s b (h d)", b=batch_size)
output, _ = self.out_proj(context)
return output
class MiniMaxVLEncoderLayer(nn.Module):
"""Single CLIP-style transformer block."""
def __init__(
self,
config: PretrainedConfig,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
embed_dim = config.hidden_size
self.layer_norm1 = nn.LayerNorm(embed_dim, eps=config.layer_norm_eps)
self.self_attn = MiniMaxVLAttention(
embed_dim=embed_dim,
num_heads=config.num_attention_heads,
quant_config=quant_config,
prefix=f"{prefix}.self_attn",
)
self.layer_norm2 = nn.LayerNorm(embed_dim, eps=config.layer_norm_eps)
use_data_parallel = is_vit_use_data_parallel()
self.fc1 = ColumnParallelLinear(
config.hidden_size,
config.intermediate_size,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.fc1",
disable_tp=use_data_parallel,
)
self.act = get_act_fn(getattr(config, "hidden_act", "gelu"))
self.fc2 = RowParallelLinear(
config.intermediate_size,
config.hidden_size,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.fc2",
disable_tp=use_data_parallel,
)
def forward(
self,
x: torch.Tensor,
cu_seqlens: torch.Tensor,
rotary_cos: torch.Tensor,
rotary_sin: torch.Tensor,
max_seqlen: torch.Tensor,
sequence_lengths: torch.Tensor | None = None,
) -> torch.Tensor:
# x: (N, 1, hidden_size)
x = x + self.self_attn(
self.layer_norm1(x),
cu_seqlens,
rotary_cos,
rotary_sin,
max_seqlen,
sequence_lengths,
)
residual = x
x, _ = self.fc1(self.layer_norm2(x))
x = self.act(x)
x, _ = self.fc2(x)
return residual + x
class MiniMaxVLEncoder(nn.Module):
def __init__(
self,
config: PretrainedConfig,
num_hidden_layers_override: int | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
n = (
config.num_hidden_layers
if num_hidden_layers_override is None
else num_hidden_layers_override
)
self.layers = nn.ModuleList(
[
MiniMaxVLEncoderLayer(
config=config,
quant_config=quant_config,
prefix=f"{prefix}.layers.{i}",
)
for i in range(n)
]
)
def forward(
self,
x: torch.Tensor,
cu_seqlens: torch.Tensor,
rotary_cos: torch.Tensor,
rotary_sin: torch.Tensor,
max_seqlen: torch.Tensor,
sequence_lengths: torch.Tensor | None = None,
) -> torch.Tensor:
for layer in self.layers:
x = layer(
x, cu_seqlens, rotary_cos, rotary_sin, max_seqlen, sequence_lengths
)
return x
class MiniMaxVLVisionTransformer(nn.Module):
"""CLIP-based ViT with 3D RoPE (t/h/w decomposed).
Faithfully mirrors SGLang's ``MiniMaxVLVisionTransformer``.
FLASHINFER backend is not supported; standard flash-attn is used.
"""
def __init__(
self,
config: PretrainedConfig,
num_hidden_layers_override: int | None = None,
require_post_norm: bool | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
compression = config.img_token_compression_config
self.spatial_merge_size: int = compression.get("spatial_merge_size", 2)
self.temporal_patch_size: int = compression.get("temporal_patch_size", 2)
self.vision_segment_max_frames: int | None = getattr(
config, "vision_segment_max_frames", None
)
self.use_data_parallel = is_vit_use_data_parallel()
embed_dim = config.hidden_size
head_dim = embed_dim // config.num_attention_heads
# Backend selection + sharding info for building encoder metadata.
# Defaults to FLASH_ATTN on SM80+; --mm-encoder-attn-backend FLASHINFER
# selects the cuDNN ViT prefill path.
self.hidden_size = embed_dim
self.tp_size = (
1
if self.use_data_parallel
else parallel_state.get_tensor_model_parallel_world_size()
)
self.attn_backend = get_vit_attn_backend(
head_size=head_dim, dtype=torch.get_default_dtype()
)
rope_dims = 2 * (head_dim // 2)
# Split rope dims evenly across t/h/w (same formula as SGLang)
self.t_dim = int(2 * ((rope_dims // 3) // 2))
self.h_dim = int(2 * ((rope_dims // 3) // 2))
self.w_dim = int(2 * ((rope_dims // 3) // 2))
# rot_dim = t_dim + h_dim + w_dim (may be < head_dim)
rope_theta: float = getattr(config, "rope_theta", 10000.0)
inv_freq_t = 1.0 / (
rope_theta
** (torch.arange(0, self.t_dim, 2, dtype=torch.float32) / self.t_dim)
)
inv_freq_h = 1.0 / (
rope_theta
** (torch.arange(0, self.h_dim, 2, dtype=torch.float32) / self.h_dim)
)
inv_freq_w = 1.0 / (
rope_theta
** (torch.arange(0, self.w_dim, 2, dtype=torch.float32) / self.w_dim)
)
self.register_buffer("inv_freq_t", inv_freq_t, persistent=False)
self.register_buffer("inv_freq_h", inv_freq_h, persistent=False)
self.register_buffer("inv_freq_w", inv_freq_w, persistent=False)
self.embeddings = MiniMaxVLPatchEmbed(config)
self.pre_layrnorm = nn.LayerNorm(embed_dim, eps=config.layer_norm_eps)
n_layers = config.num_hidden_layers
if num_hidden_layers_override is None:
num_hidden_layers_override = n_layers
self.encoder = MiniMaxVLEncoder(
config=config,
num_hidden_layers_override=num_hidden_layers_override,
quant_config=quant_config,
prefix=f"{prefix}.encoder",
)
if require_post_norm is None:
require_post_norm = num_hidden_layers_override == n_layers
self.post_layernorm = (
nn.LayerNorm(embed_dim, eps=config.layer_norm_eps)
if require_post_norm
else None
)
# out_hidden_size needed by run_dp_sharded_mrope_vision_model
self.out_hidden_size = embed_dim
# ── RoPE helpers ─────────────────────────────────────────────────────
def _get_3d_rope_embed(
self, grid_t: int, grid_h: int, grid_w: int, spatial_merge_size: int
) -> torch.Tensor:
"""Compute 3D RoPE frequencies for a single (T, H, W) grid.
Returns (T*H*W, half_rot_dim) on the same device as inv_freq buffers.
Mirrors SGLang's ``_get_3d_rope_embed`` exactly.
"""
tokens_per_frame = grid_h * grid_w
tpos_ids = (
torch.arange(grid_t, device=self.inv_freq_t.device)
.unsqueeze(1)
.expand(-1, tokens_per_frame)
.flatten()
)
hpos_ids = (
torch.arange(grid_h, device=self.inv_freq_h.device)
.unsqueeze(1)
.expand(-1, grid_w)
.reshape(
grid_h // spatial_merge_size,
spatial_merge_size,
grid_w // spatial_merge_size,
spatial_merge_size,
)
.permute(0, 2, 1, 3)
.unsqueeze(0)
.expand(grid_t, -1, -1, -1, -1)
.flatten()
)
wpos_ids = (
torch.arange(grid_w, device=self.inv_freq_w.device)
.unsqueeze(0)
.expand(grid_h, -1)
.reshape(
grid_h // spatial_merge_size,
spatial_merge_size,
grid_w // spatial_merge_size,
spatial_merge_size,
)
.permute(0, 2, 1, 3)
.unsqueeze(0)
.expand(grid_t, -1, -1, -1, -1)
.flatten()
)
max_t = max(grid_t, 1)
max_hw = max(grid_h, grid_w)
seq_t = torch.arange(
max_t, device=self.inv_freq_t.device, dtype=self.inv_freq_t.dtype
)
seq_hw = torch.arange(
max_hw, device=self.inv_freq_h.device, dtype=self.inv_freq_h.dtype
)
freqs_t = torch.outer(seq_t, self.inv_freq_t) # (max_t, t_dim/2)
freqs_h = torch.outer(seq_hw, self.inv_freq_h) # (max_hw, h_dim/2)
freqs_w = torch.outer(seq_hw, self.inv_freq_w) # (max_hw, w_dim/2)
return torch.cat(
[freqs_t[tpos_ids], freqs_h[hpos_ids], freqs_w[wpos_ids]], dim=-1
) # (T*H*W, half_rot_dim)
def _get_rope_embed_3d(
self, grid_thw: list[list[int]], spatial_merge_size: int
) -> torch.Tensor:
embeds = [
self._get_3d_rope_embed(t, h, w, spatial_merge_size) for t, h, w in grid_thw
]
return torch.cat(embeds, dim=0) # (total_N, half_rot_dim)
# ── Frame-limit helper (mirrors SGLang) ──────────────────────────────
def _apply_max_frames_limit(self, grid_thw: list[list[int]]) -> list[list[int]]:
if self.vision_segment_max_frames is None:
return grid_thw
max_f = self.vision_segment_max_frames
out: list[list[int]] = []
for t, h, w in grid_thw:
if t <= max_f:
out.append([t, h, w])
else:
for i in range(0, t, max_f):
out.append([min(max_f, t - i), h, w])
return out
# ── Forward ──────────────────────────────────────────────────────────
def forward(
self,
pixel_values: torch.Tensor,
grid_thw: list[list[int]],
) -> torch.Tensor:
# pixel_values: (total_N, C * temporal_patch_size * patch_size²)
# Output: (total_N, hidden_size)
hidden = self.embeddings(pixel_values) # (total_N, hidden_size)
hidden = self.pre_layrnorm(hidden)
limited = self._apply_max_frames_limit(grid_thw)
# Token-level cumulative sequence lengths (one segment per limited grid).
lens = [t * h * w for t, h, w in limited]
cu_seqlens_np = np.zeros(len(lens) + 1, dtype=np.int32)
np.cumsum(np.array(lens, dtype=np.int32), out=cu_seqlens_np[1:])
# Backend-specific encoder metadata. For FLASH_ATTN this returns the raw
# token cu_seqlens, the max segment length, and sequence_lengths=None;
# for FLASHINFER (cuDNN) it repacks cu_seqlens into element-offset
# indptrs, buckets max_seqlen, and builds padded per-sequence lengths.
sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(
self.attn_backend, cu_seqlens_np, hidden.device
)
max_seqlen = torch.tensor(
MMEncoderAttention.compute_max_seqlen(self.attn_backend, cu_seqlens_np),
dtype=torch.int32,
)
cu_seqlens = MMEncoderAttention.maybe_recompute_cu_seqlens(
self.attn_backend,
cu_seqlens_np,
self.hidden_size,
self.tp_size,
hidden.device,
)
# 3D RoPE: (total_N, half_rot_dim); ApplyRotaryEmb expands internally
freqs = self._get_rope_embed_3d(limited, self.spatial_merge_size)
freqs = freqs.to(device=hidden.device)
# Keep cos/sin in fp32; ApplyRotaryEmb(enable_fp32_compute=True) runs the
# rotation in fp32 to match the SGLang reference precision.
rotary_cos, rotary_sin = freqs.cos(), freqs.sin()
# Encoder expects (N, 1, hidden_size) — add batch dim
hidden = hidden.unsqueeze(1)
hidden = self.encoder(
hidden,
cu_seqlens,
rotary_cos,
rotary_sin,
max_seqlen,
sequence_lengths=sequence_lengths,
)
hidden = hidden.squeeze(1) # back to (total_N, hidden_size)
if self.post_layernorm is not None:
hidden = self.post_layernorm(hidden)
return hidden
class MiniMaxVLMultiModalProjector(nn.Module):
"""Two-layer MLP projector: vision_hidden → text_hidden."""
def __init__(
self,
vision_hidden_size: int,
text_hidden_size: int,
projector_hidden_size: int | None,
multimodal_projector_bias: bool,
projector_hidden_act: str = "gelu",
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
mid = projector_hidden_size if projector_hidden_size else text_hidden_size
use_dp = is_vit_use_data_parallel()
self.linear_1 = ColumnParallelLinear(
vision_hidden_size,
mid,
bias=multimodal_projector_bias,
quant_config=quant_config,
prefix=f"{prefix}.linear_1",
disable_tp=use_dp,
)
self.act = get_act_fn(projector_hidden_act)
self.linear_2 = RowParallelLinear(
mid,
text_hidden_size,
bias=multimodal_projector_bias,
quant_config=quant_config,
prefix=f"{prefix}.linear_2",
disable_tp=use_dp,
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x, _ = self.linear_1(x)
x = self.act(x)
x, _ = self.linear_2(x)
return x
class MiniMaxVLPatchMerger(nn.Module):
def __init__(
self,
spatial_merge_size: int,
text_hidden_size: int,
projector_hidden_size: int | None,
patch_merge_bias: bool,
projector_hidden_act: str = "gelu",
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
self.spatial_merge_size = spatial_merge_size
mid = projector_hidden_size if projector_hidden_size else text_hidden_size
merge_in = text_hidden_size * spatial_merge_size**2
use_dp = is_vit_use_data_parallel()
self.linear_1 = ColumnParallelLinear(
merge_in,
mid,
bias=patch_merge_bias,
quant_config=quant_config,
prefix=f"{prefix}.linear_1",
disable_tp=use_dp,
)
self.act = get_act_fn(projector_hidden_act)
self.linear_2 = RowParallelLinear(
mid,
text_hidden_size,
bias=patch_merge_bias,
quant_config=quant_config,
prefix=f"{prefix}.linear_2",
disable_tp=use_dp,
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (N, text_hidden_size) → (N // merge_size², text_hidden_size)
x = x.reshape(x.shape[0] // (self.spatial_merge_size**2), -1)
x, _ = self.linear_1(x)
x = self.act(x)
x, _ = self.linear_2(x)
return x
class MiniMaxVLVisionModel(nn.Module):
"""Full vision model: ViT → projector → patch merger."""
def __init__(
self,
config: PretrainedConfig,
text_hidden_size: int,
projector_hidden_size: int | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
compression = config.img_token_compression_config
spatial_merge_size: int = compression.get("spatial_merge_size", 2)
self.spatial_merge_size = spatial_merge_size
self.use_data_parallel = is_vit_use_data_parallel()
# The released checkpoint ships no ``post_layernorm`` weights and
# uses ``vision_feature_layer=-1`` with ``vision_feature_select_strategy
# ="full"``, i.e. the raw last encoder hidden state (CLIP's
# ``last_hidden_state`` is taken before the post layernorm). Applying an
# untrained post layernorm here would corrupt the visual features.
self.vision_model = MiniMaxVLVisionTransformer(
config=config,
require_post_norm=False,
quant_config=quant_config,
prefix=maybe_prefix(prefix, "vision_model"),
)
self.multi_modal_projector = MiniMaxVLMultiModalProjector(
vision_hidden_size=config.hidden_size,
text_hidden_size=text_hidden_size,
projector_hidden_size=projector_hidden_size,
multimodal_projector_bias=getattr(
config, "multimodal_projector_bias", True
),
projector_hidden_act=getattr(config, "projector_hidden_act", "gelu"),
quant_config=quant_config,
prefix=maybe_prefix(prefix, "multi_modal_projector"),
)
self.patch_merge_mlp = MiniMaxVLPatchMerger(
spatial_merge_size=spatial_merge_size,
text_hidden_size=text_hidden_size,
projector_hidden_size=projector_hidden_size,
patch_merge_bias=getattr(config, "patch_merge_bias", True),
projector_hidden_act=getattr(config, "projector_hidden_act", "gelu"),
quant_config=quant_config,
prefix=maybe_prefix(prefix, "patch_merge_mlp"),
)
self.dtype = self.vision_model.embeddings.patch_embedding.weight.dtype
self.out_hidden_size = text_hidden_size
def forward(
self,
pixel_values: torch.Tensor,
grid_thw: list[list[int]],
) -> torch.Tensor:
hidden = self.vision_model(pixel_values=pixel_values, grid_thw=grid_thw)
if hidden.dim() == 3:
hidden = hidden.squeeze(0)
hidden = self.multi_modal_projector(hidden)
hidden = self.patch_merge_mlp(hidden)
return hidden
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
stacked_params_mapping = [
# (param_name, shard_name, shard_id)
("qkv_proj.", "q_proj.", "q"),
("qkv_proj.", "k_proj.", "k"),
("qkv_proj.", "v_proj.", "v"),
]
params_dict = dict(self.named_parameters(remove_duplicate=False))
loaded_params: set[str] = set()
for name, loaded_weight in weights:
for param_name, weight_name, shard_id in stacked_params_mapping:
if weight_name not in name:
continue
name = name.replace(weight_name, param_name)
param = params_dict[name]
weight_loader = param.weight_loader
weight_loader(param, loaded_weight, shard_id)
break
else:
param = params_dict[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
loaded_params.add(name)
return loaded_params
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax M3 CuteDSL sparse prefill attention wrapper."""
import torch
from .interface import sparse_atten_func
from .sm100.prepare_k2q_csr import build_k2q_csr_with_schedule_sm100
SPARSE_BLOCK_SIZE = 128
def minimax_m3_sparse_attn_cutedsl(
q: torch.Tensor,
kv_cache: torch.Tensor,
topk_idx: torch.Tensor,
block_table: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
seq_lens: torch.Tensor,
max_query_len: int,
max_seq_len: int,
num_kv_heads: int,
sm_scale: float,
out: torch.Tensor,
*,
total_kv_blocks: int,
) -> None:
"""Run CuteDSL sparse attention directly over vLLM's paged KV cache."""
if kv_cache.shape[2] != SPARSE_BLOCK_SIZE:
raise ValueError("MiniMax M3 CuteDSL path requires block_size == 128")
topk = topk_idx.shape[-1]
k_cache = kv_cache[:, 0]
v_cache = kv_cache[:, 1]
k2q_row_ptr, k2q_q_indices, schedule = build_k2q_csr_with_schedule_sm100(
topk_idx,
cu_seqlens_q,
cu_seqlens_k,
blk_kv=SPARSE_BLOCK_SIZE,
max_seqlen_k=max_seq_len,
max_seqlen_q=max_query_len,
total_rows=total_kv_blocks,
qhead_per_kv=q.shape[1] // num_kv_heads,
)
sparse_atten_func(
q,
k_cache,
v_cache,
k2q_row_ptr,
k2q_q_indices,
topK=topk,
blk_kv=SPARSE_BLOCK_SIZE,
causal=True,
softmax_scale=sm_scale,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=max_query_len,
page_table=block_table,
seqused_k=seq_lens,
schedule=schedule,
out=out,
)
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
# mypy: ignore-errors
import os
import pathlib
from dataclasses import dataclass, fields
from functools import lru_cache, partial
import torch
try:
from triton.tools.disasm import extract
except ImportError:
extract = None
import cutlass
import cutlass.cute as cute
from cutlass.base_dsl.typing import JitArgument
from cutlass.cute.runtime import from_dlpack
from cutlass.cutlass_dsl import NumericMeta
StaticTypes = (cutlass.Constexpr, NumericMeta, int, bool, str, float, type(None))
load_cubin_module_data_og = cutlass.base_dsl.runtime.cuda.load_cubin_module_data
cute_compile_og = cute.compile
torch2cute_dtype_map = {
torch.float16: cutlass.Float16,
torch.bfloat16: cutlass.BFloat16,
torch.float32: cutlass.Float32,
}
@lru_cache
def get_max_active_clusters(cluster_size):
return cutlass.utils.HardwareInfo().get_max_active_clusters(
cluster_size=cluster_size
)
@lru_cache
def get_device_capacity(device: torch.device = None) -> tuple[int, int]:
return torch.cuda.get_device_capability(device)
@dataclass
class ArgumentsBase(JitArgument):
def __c_pointers__(self):
all_fields = [getattr(self, field.name) for field in fields(self)]
non_constexpr_fields = [f for f in all_fields if not isinstance(f, StaticTypes)]
c_ptrs = []
for obj in non_constexpr_fields:
if hasattr(obj, "__c_pointers__"):
c_ptrs.extend(obj.__c_pointers__())
return c_ptrs
def __get_mlir_types__(self):
all_fields = [getattr(self, field.name) for field in fields(self)]
non_constexpr_fields = [f for f in all_fields if not isinstance(f, StaticTypes)]
types, self._values_pos = [], []
for obj in non_constexpr_fields:
if hasattr(obj, "__get_mlir_types__"):
obj_types = obj.__get_mlir_types__()
types.extend(obj_types)
self._values_pos.append(len(obj_types))
else:
self._values_pos.append(0)
return types
def __new_from_mlir_values__(self, values):
all_fields = {field.name: getattr(self, field.name) for field in fields(self)}
constexpr_fields = {
n: f for n, f in all_fields.items() if isinstance(f, StaticTypes)
}
non_constexpr_fields = {
n: f for n, f in all_fields.items() if not isinstance(f, StaticTypes)
}
for (name, field), n_items in zip(
non_constexpr_fields.items(), self._values_pos
):
non_constexpr_fields[name] = cutlass.new_from_mlir_values(
field, values[:n_items]
)
values = values[n_items:]
return self.__class__(**non_constexpr_fields, **constexpr_fields)
def load_cubin_module_data_patched(cubin_data, filepath):
pathlib.Path(filepath).write_bytes(cubin_data)
return load_cubin_module_data_og(cubin_data)
def cute_compile_patched(*args, **kwargs):
"""A patched version of cute.compile that dump the SASS to a file if CUTE_CUBIN_PATH is set."""
cubin_path = os.getenv("CUTE_CUBIN_PATH", None)
if cubin_path is not None:
cutlass.base_dsl.runtime.cuda.load_cubin_module_data = partial(
load_cubin_module_data_patched, filepath=cubin_path
)
output = cute_compile_og(*args, **kwargs)
if cubin_path is not None:
cutlass.base_dsl.runtime.cuda.load_cubin_module_data = load_cubin_module_data_og
if extract is not None:
sass = extract(cubin_path, None)
pathlib.Path(cubin_path).with_suffix(".annotated.sass").write_text(sass)
return output
def assume_strides_aligned(t):
"""Assume all strides except the last are divisible by 128 bits.
Python int strides (e.g., stride=0 from GQA expand) are kept as-is
since they're static and don't need alignment assumptions.
"""
divby = 128 // t.element_type.width
strides = tuple(
s if isinstance(s, int) else cute.assume(s, divby=divby) for s in t.stride[:-1]
)
return (*strides, t.stride[-1])
def assume_tensor_aligned(t):
"""Rebuild a tensor with 128-bit aligned stride assumptions. Passes through None."""
if t is None:
return None
return cute.make_tensor(
t.iterator, cute.make_layout(t.shape, stride=assume_strides_aligned(t))
)
def to_cute_tensor(
t, assumed_align=16, leading_dim=-1, fully_dynamic=False, enable_tvm_ffi=True
):
"""Convert torch tensor to cute tensor for TVM FFI. leading_dim=-1 defaults to t.ndim-1."""
tensor = from_dlpack(
t.detach(), assumed_align=assumed_align, enable_tvm_ffi=enable_tvm_ffi
)
if fully_dynamic:
return tensor.mark_layout_dynamic()
if leading_dim == -1:
leading_dim = t.ndim - 1
return tensor.mark_layout_dynamic(leading_dim=leading_dim)
def to_cute_aux_tensor(t, enable_tvm_ffi=True):
"""Convert torch tensor to cute tensor for TVM FFI, tailored to FlexAttention aux tensors.
This allows the user to specify alignment and leading dimension for aux tensors used in
custom score_mod callables.
"""
assumed_align: int = getattr(t, "__assumed_align__", None)
leading_dim: int = getattr(t, "__leading_dim__", None)
fully_dynamic: bool = leading_dim is None
return to_cute_tensor(
t,
assumed_align=assumed_align,
leading_dim=leading_dim,
fully_dynamic=fully_dynamic,
enable_tvm_ffi=enable_tvm_ffi,
)
def get_broadcast_dims(tensor: torch.Tensor) -> tuple[bool, ...]:
"""Return tuple of bools indicating which dims have stride=0 (broadcast).
This is useful for compile keys since CuTe's mark_layout_dynamic() keeps
stride=0 as static, meaning kernels compiled with different broadcast
patterns are not interchangeable.
"""
return tuple(s == 0 for s in tensor.stride())
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
from collections.abc import Callable
from dataclasses import dataclass
from typing import TypeAlias
import cutlass
import cutlass.cute as cute
from cutlass import Float32, Int32, Uint32, const_expr
from . import utils
from .seqlen_info import SeqlenInfoQK
MaskGenFn: TypeAlias = Callable[[int], Uint32]
MASK_R2P_CHUNK_SIZE: int = 32
@cute.jit
def r2p_bitmask_below(limit: Int32, s: int) -> Uint32:
m = max((s + 1) * MASK_R2P_CHUNK_SIZE - limit, 0)
return utils.shr_u32(Uint32(0xFFFFFFFF), Uint32(m))
@cute.jit
def r2p_bitmask_above(limit: Int32, s: int) -> Uint32:
n = max(limit - s * MASK_R2P_CHUNK_SIZE, 0)
return utils.shl_u32(Uint32(0xFFFFFFFF), Uint32(n))
@cute.jit
def mask_r2p_lambda(
X: cute.Tensor,
mask_gen_fn: cutlass.Constexpr[MaskGenFn],
rank1: bool = False,
) -> None:
ncol = const_expr(
cute.size(X.shape[cute.rank(X) - 1]) if not rank1 else cute.size(X.shape)
)
for s in cutlass.range_constexpr(cute.ceil_div(ncol, MASK_R2P_CHUNK_SIZE)):
mask = mask_gen_fn(s)
for i in cutlass.range_constexpr(
min(MASK_R2P_CHUNK_SIZE, ncol - s * MASK_R2P_CHUNK_SIZE)
):
in_bound = cutlass.Boolean(mask & (Uint32(1) << i))
c = s * MASK_R2P_CHUNK_SIZE + i
if const_expr(rank1):
X[c] = X[c] if in_bound else -Float32.inf
else:
for r in cutlass.range_constexpr(cute.size(X.shape[0])):
X[r, c] = X[r, c] if in_bound else -Float32.inf
@cute.jit
def row_to_r2p_idx(x: Int32, num_rep: int, num_wg: int) -> Int32:
return x // (num_rep * num_wg) * num_rep + min(x % (num_rep * num_wg), num_rep)
@dataclass(frozen=True)
class AttentionMask:
tile_m: cutlass.Constexpr[int]
tile_n: cutlass.Constexpr[int]
seqlen_info: SeqlenInfoQK
qhead_per_kvhead_packgqa: cutlass.Constexpr[int] = 1
swap_AB: cutlass.Constexpr[bool] = False
@property
def seqlen_q(self) -> Int32:
return self.seqlen_info.seqlen_q
@property
def seqlen_k(self) -> Int32:
return self.seqlen_info.seqlen_k
@cute.jit
def apply_mask_sm100_transposed(
self,
acc_S: cute.Tensor,
tScS_t2r: cute.Tensor,
t0ScS_t2r: cute.Tensor,
m_block: cutlass.Int32,
n_block: cutlass.Int32,
mask_seqlen: cutlass.Constexpr,
mask_causal: cutlass.Constexpr,
is_full_block: bool = False,
check_m_boundary: bool = True,
) -> None:
del is_full_block, check_m_boundary
del t0ScS_t2r
row_axis = 0 if const_expr(not self.swap_AB) else 1
col_axis = 1 if const_expr(not self.swap_AB) else 0
thr_col_offset = tScS_t2r[0][col_axis]
seqlenk_col_limit = self.seqlen_k - n_block * self.tile_n - thr_col_offset
if const_expr(not mask_causal):
if const_expr(mask_seqlen) and seqlenk_col_limit <= 0:
for i in cutlass.range(cute.size(acc_S.shape), unroll_full=True):
acc_S[i] = -cutlass.Float32.inf
return
thr_row_offset = tScS_t2r[0][row_axis]
seqlenq_row_limit = self.seqlen_q - m_block * self.tile_m - thr_row_offset
row_limit_top = seqlenq_row_limit - seqlenk_col_limit
if const_expr(mask_seqlen) and seqlenk_col_limit <= 0:
row_limit_top = self.tile_m
num_rep = cute.size(tScS_t2r, mode=[0])
row_limit = row_to_r2p_idx(row_limit_top, num_rep, 2)
mask_r2p_lambda(
acc_S,
lambda s: r2p_bitmask_above(row_limit, s),
rank1=True,
)
@@ -0,0 +1,311 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
from enum import IntEnum
import cutlass
import cutlass.cute as cute
# ---------------------------------------------------------------------------
# Enumerations that match the HW encodings (values MUST stay identical)
# ---------------------------------------------------------------------------
class Major(IntEnum): # matrix "layout" in the ISA docs
K = 0
MN = 1
class ScaleIn(IntEnum): # negate flags
One = 0
Neg = 1
class Saturate(IntEnum):
False_ = 0
True_ = 1
class CFormat(IntEnum): # 2-bit field (bits 4-5)
F16 = 0
F32 = 1
S32 = 2
class F16F32Format(IntEnum): # 3-bit field (A/B element type)
F16 = 0
BF16 = 1
TF32 = 2
class S8Format(IntEnum):
UINT8 = 0
INT8 = 1
class MXF8F6F4Format(IntEnum):
E4M3 = 0
E5M2 = 1
E2M3 = 3
E3M2 = 4
E2M1 = 5
class MaxShift(IntEnum):
NoShift = 0
MaxShift8 = 1
MaxShift16 = 2
MaxShift32 = 3
# ---------------------------------------------------------------------------
# CUTLASS-type -> encoding helpers
# ---------------------------------------------------------------------------
def to_UMMA_format(cutlass_type) -> int:
"""
Map a CUTLASS scalar class to the 3-bit encoding for Matrix A/B.
"""
if cutlass_type is cutlass.Int8:
return S8Format.INT8
# Unsigned 8-bit (if available in your CUTLASS build)
if cutlass_type is cutlass.Uint8:
return S8Format.UINT8
# FP-16 / BF-16
if cutlass_type is cutlass.Float16:
return F16F32Format.F16
if cutlass_type is cutlass.BFloat16:
return F16F32Format.BF16
# TensorFloat-32 (8-bit exponent, 10-bit mantissa packed in 19 bits)
if cutlass_type is cutlass.TFloat32:
return F16F32Format.TF32
# Float-8 / Float-6 / Float-4 add whenever CUTLASS exposes them
if cutlass_type is cutlass.FloatE4M3FN:
return MXF8F6F4Format.E4M3
if cutlass_type is cutlass.FloatE5M2:
return MXF8F6F4Format.E5M2
raise TypeError(f"Unsupported CUTLASS scalar type for A/B: {cutlass_type!r}")
def to_C_format(cutlass_type) -> int:
"""
Map a CUTLASS scalar class to the 2-bit accumulator encoding.
"""
if cutlass_type is cutlass.Float16:
return CFormat.F16
if cutlass_type is cutlass.Float32:
return CFormat.F32
if cutlass_type is cutlass.Int32:
return CFormat.S32
raise TypeError(
f"Unsupported CUTLASS scalar type for accumulator: {cutlass_type!r}"
)
# ---------------------------------------------------------------------------
# The constructor accepts only CUTLASS scalar classes
# ---------------------------------------------------------------------------
def make_instr_desc(
a_type, # CUTLASS scalar class, e.g. cutlass.Int8
b_type,
c_type,
M: int, # 64, 128 or 256
N: int, # 8 … 256 (multiple of 8)
a_major: Major,
b_major: Major,
a_neg: ScaleIn = ScaleIn.One,
b_neg: ScaleIn = ScaleIn.One,
c_sat: Saturate = Saturate.False_,
is_sparse: bool = False,
max_shift: MaxShift = MaxShift.NoShift,
) -> int:
"""
Build the 32-bit instruction descriptor for Blackwell MMA.
All matrix/accumulator **types must be CUTLASS scalar classes**
passing integers is forbidden.
"""
# --- encode element formats -------------------------------------------------
a_fmt = int(to_UMMA_format(a_type))
b_fmt = int(to_UMMA_format(b_type))
c_fmt = int(to_C_format(c_type))
# --- range checks on M/N -----------------------------------------------------
if M not in (64, 128, 256):
raise ValueError("M must be 64, 128 or 256")
if N < 8 or N > 256 or (N & 7):
raise ValueError("N must be a multiple of 8 in the range 8…256")
m_dim = M >> 4 # 5-bit field
n_dim = N >> 3 # 6-bit field
# fmt: off
# --- pack the bit-fields -----------------------------------------------------
desc = 0
desc |= (0 & 0x3) << 0 # sparse_id2 (always 0 here)
desc |= (int(is_sparse) & 0x1) << 2 # sparse_flag
desc |= (int(c_sat) & 0x1) << 3 # saturate
desc |= (c_fmt & 0x3) << 4 # c_format
desc |= (a_fmt & 0x7) << 7 # a_format
desc |= (b_fmt & 0x7) << 10 # b_format
desc |= (int(a_neg) & 0x1) << 13 # a_negate
desc |= (int(b_neg) & 0x1) << 14 # b_negate
desc |= (int(a_major) & 0x1) << 15 # a_major
desc |= (int(b_major) & 0x1) << 16 # b_major
desc |= (n_dim & 0x3F) << 17 # n_dim (6 bits)
desc |= (m_dim & 0x1F) << 24 # m_dim (5 bits)
desc |= (int(max_shift) & 0x3) << 30 # max_shift (2 bits)
# fmt: on
return desc & 0xFFFF_FFFF # ensure 32-bit result
def mma_op_to_idesc(op: cute.nvgpu.tcgen05.mma.MmaOp):
return make_instr_desc(
op.a_dtype,
op.b_dtype,
op.acc_dtype,
op.shape_mnk[0],
op.shape_mnk[1],
Major.K
if op.a_major_mode == cute.nvgpu.tcgen05.mma.OperandMajorMode.K
else Major.MN,
Major.K
if op.b_major_mode == cute.nvgpu.tcgen05.mma.OperandMajorMode.K
else Major.MN,
)
class LayoutType(IntEnum): # occupies the top-3 bits [61:64)
SWIZZLE_NONE = 0 # (a.k.a. "INTERLEAVE" in older docs)
SWIZZLE_128B_BASE32B = 1
SWIZZLE_128B = 2
SWIZZLE_64B = 4
SWIZZLE_32B = 6
# values 3,5,7 are reserved / illegal for UMMA
# ---------------------------------------------------------------------------
# Helpers figure out the SWIZZLE_* family from the tensor layout
# ---------------------------------------------------------------------------
def _layout_type(swizzle: cute.Swizzle) -> LayoutType:
B, M, S = swizzle.num_bits, swizzle.num_base, swizzle.num_shift
if M == 4: # Swizzle<*,4,3>
if S != 3:
raise ValueError("Unexpected swizzle shift want S==3 for M==4")
return {
0: LayoutType.SWIZZLE_NONE,
1: LayoutType.SWIZZLE_32B,
2: LayoutType.SWIZZLE_64B,
3: LayoutType.SWIZZLE_128B,
}[B] # KeyError ⇒ invalid B→ raise
if M == 5: # Swizzle<2,5,2> (the only legal triple for M==5)
if (B, S) != (2, 2):
raise ValueError("Only Swizzle<2,5,2> supported for 128B_BASE32B")
return LayoutType.SWIZZLE_128B_BASE32B
# Any other (M,B,S) triple is not a UMMA-legal shared-memory layout
raise ValueError("Unsupported swizzle triple for UMMA smem descriptor")
def make_smem_desc_base(
layout: cute.Layout, swizzle: cute.Swizzle, major: Major
) -> int:
"""
Convert a 2-D *shared-memory* Cute layout into the Blackwell 64-bit
smem-descriptor, without the smem start address.
layout must correspond to layout of an uint128 tensor.
"""
# ------------------------------------------------------------------ meta
layout_type = _layout_type(swizzle) # resolve SWIZZLE_* family
VERSION = 1 # bits 4647
LBO_MODE = 0 # bit 52
BASE_OFFSET = 0 # bits 4951 (CUTLASS always 0)
# ---------------------------------------------------------- strides (units: uint128_t = 16 B)
swizzle_atom_mn_size = {
LayoutType.SWIZZLE_NONE: 1,
LayoutType.SWIZZLE_32B: 2,
LayoutType.SWIZZLE_64B: 4,
LayoutType.SWIZZLE_128B: 8,
LayoutType.SWIZZLE_128B_BASE32B: 8,
}[layout_type]
if major is Major.MN:
swizzle_atom_k_size = 4 if layout_type is LayoutType.SWIZZLE_128B_BASE32B else 8
canonical_layout = cute.logical_divide(
layout, (swizzle_atom_mn_size, swizzle_atom_k_size)
)
if not cute.is_congruent(canonical_layout, ((1, 1), (1, 1))):
raise ValueError(
"Not a canonical UMMA_MN Layout: Expected profile failure."
)
stride_00 = canonical_layout.stride[0][0]
if layout_type is not LayoutType.SWIZZLE_NONE and stride_00 != 1:
raise ValueError("Not a canonical UMMA_MN Layout: Expected stride failure.")
stride_10 = canonical_layout.stride[1][0]
if stride_10 != swizzle_atom_mn_size:
raise ValueError("Not a canonical UMMA_MN Layout: Expected stride failure.")
stride_01, stride_11 = (
canonical_layout.stride[0][1],
canonical_layout.stride[1][1],
)
if layout_type is LayoutType.SWIZZLE_NONE:
stride_byte_offset, leading_byte_offset = stride_01, stride_11
else:
stride_byte_offset, leading_byte_offset = stride_11, stride_01
else:
if layout_type == LayoutType.SWIZZLE_128B_BASE32B:
raise ValueError("SWIZZLE_128B_BASE32B is invalid for Major-K")
if not cute.size(layout.shape[0]) % 8 == 0:
raise ValueError(
"Not a canonical UMMA_K Layout: Expected MN-size multiple of 8."
)
canonical_layout = cute.logical_divide(layout, (8, 2))
if not cute.is_congruent(canonical_layout, ((1, 1), (1, 1))):
raise ValueError("Not a canonical UMMA_K Layout: Expected profile failure.")
stride_00 = canonical_layout.stride[0][0]
if stride_00 != swizzle_atom_mn_size:
raise ValueError("Not a canonical UMMA_K Layout: Expected stride failure.")
stride_10 = canonical_layout.stride[1][0]
if layout_type is not LayoutType.SWIZZLE_NONE and stride_10 != 1:
raise ValueError("Not a canonical UMMA_K Layout: Expected stride failure.")
stride_01 = canonical_layout.stride[0][1]
stride_byte_offset, leading_byte_offset = stride_01, stride_10
# ------------------------------------------------------------------ pack
desc = 0
# leading_byte_offset_ [16:30)
desc |= (leading_byte_offset & 0x3FFF) << 16
# stride_byte_offset_ [32:46)
desc |= (stride_byte_offset & 0x3FFF) << 32
# version_ [46:48)
desc |= (VERSION & 0x3) << 46
# base_offset_ [49:52)
desc |= (BASE_OFFSET & 0x7) << 49
# lbo_mode_ [52:53)
desc |= (LBO_MODE & 0x1) << 52
# layout_type_ [61:64)
desc |= (int(layout_type) & 0x7) << 61
return desc & 0xFFFF_FFFF_FFFF_FFFF # force 64-bit width
def make_smem_desc_start_addr(start_addr: cute.Pointer) -> cutlass.Int32:
# 14 bits, remove 4 LSB (bits 0-13 in desc)
return (start_addr.toint() & 0x3FFFF) >> 4
def smem_desc_base_from_tensor(sA: cute.Tensor, major: Major) -> int:
sA_swizzle = sA.iterator.type.swizzle_type
return make_smem_desc_base(
cute.recast_layout(128, sA.element_type.width, sA.layout[0]),
sA_swizzle,
major,
)
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
import enum
class NamedBarrierFwdSm100(enum.IntEnum):
Epilogue = enum.auto() # starts from 1 as barrier 0 is reserved for sync_threads()
TmemPtr = enum.auto()
SoftmaxStatsW0 = enum.auto()
SoftmaxStatsW1 = enum.auto()
SoftmaxStatsW2 = enum.auto()
SoftmaxStatsW3 = enum.auto()
SoftmaxStatsW4 = enum.auto()
SoftmaxStatsW5 = enum.auto()
SoftmaxStatsW6 = enum.auto()
SoftmaxStatsW7 = enum.auto()
LoadWG = enum.auto()
StoreEpilogue = enum.auto()
class NamedBarrierBwdSm100(enum.IntEnum):
EpilogueWG1 = enum.auto()
EpilogueWG2 = enum.auto()
Compute = enum.auto()
dQaccReduce = enum.auto()
TmemPtr = enum.auto()
@@ -0,0 +1,350 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
# mypy: ignore-errors
"""PackGQA primitives for GQA (grouped-query attention) tile layouts.
Contains:
- ``pack_gqa_layout`` / ``unpack_gqa_layout``: fold/unfold ``qhead_per_kvhead``
into the seqlen dimension of a tensor layout (zero-copy view).
- ``PackGQA``: base class with ``compute_ptr`` / ``load_Q`` / ``store_LSE`` /
``store_O`` helpers for kernels that treat ``(qhead_per_kvhead × seqlen_q)``
as a single packed row dimension.
- ``PackGQAComb``: subclass used by the K2 combine kernel; adds ``load_LSE``
for coalesced GMEMSMEM async copies when LSE_partial is laid out with H_q
innermost (stride-1).
"""
from dataclasses import dataclass
import cutlass
import cutlass.cute as cute
from cutlass import Float32, Int32, const_expr
from cutlass.cute import FastDivmodDivisor
from quack import layout_utils
from . import utils
def pack_gqa_layout(T, qhead_per_kvhead, nheads_kv, head_idx):
"""Reshape a tensor to fold qhead_per_kvhead into the seqlen dimension (mode 0).
The head dimension is at mode ``head_idx``. Modes before it (1..head_idx-1)
are kept as-is (e.g. headdim for Q/O tensors), and modes after it are kept
as-is (e.g. batch).
For Q/O tensors (head_idx=2):
(seqlen_q, headdim, nheads, batch, ...) -> ((qhead_per_kvhead, seqlen_q), headdim, nheads_kv, batch, ...)
For LSE tensors (head_idx=1):
(seqlen_q, nheads, batch, ...) -> ((qhead_per_kvhead, seqlen_q), nheads_kv, batch, ...)
"""
head_stride = T.stride[head_idx]
shape_packed = (
(qhead_per_kvhead, T.shape[0]),
*[T.shape[i] for i in range(1, head_idx)],
nheads_kv,
*[T.shape[i] for i in range(head_idx + 1, len(T.shape))],
)
stride_packed = (
(head_stride, T.stride[0]),
*[T.stride[i] for i in range(1, head_idx)],
head_stride * qhead_per_kvhead,
*[T.stride[i] for i in range(head_idx + 1, len(T.shape))],
)
return cute.make_tensor(
T.iterator, cute.make_layout(shape_packed, stride=stride_packed)
)
def unpack_gqa_layout(T, qhead_per_kvhead, head_idx):
"""Reverse of pack_gqa_layout: unfold qhead_per_kvhead from the seqlen dimension (mode 0).
The head dimension is at mode ``head_idx``. Modes before it (1..head_idx-1)
are kept as-is (e.g. headdim for Q/O tensors), and modes after it are kept
as-is (e.g. batch).
For Q/O tensors (head_idx=2):
((qhead_per_kvhead, seqlen_q), headdim, nheads_kv, batch, ...) -> (seqlen_q, headdim, nheads, batch, ...)
For LSE tensors (head_idx=1):
((qhead_per_kvhead, seqlen_q), nheads_kv, batch, ...) -> (seqlen_q, nheads, batch, ...)
"""
seqlen_stride = T.stride[0][1]
head_stride = T.stride[0][0]
shape_unpacked = (
T.shape[0][1],
*[T.shape[i] for i in range(1, head_idx)],
T.shape[head_idx] * qhead_per_kvhead,
*[T.shape[i] for i in range(head_idx + 1, len(T.shape))],
)
stride_unpacked = (
seqlen_stride,
*[T.stride[i] for i in range(1, head_idx)],
head_stride,
*[T.stride[i] for i in range(head_idx + 1, len(T.shape))],
)
return cute.make_tensor(
T.iterator, cute.make_layout(shape_unpacked, stride=stride_unpacked)
)
@dataclass
class PackGQA:
m_block_size: cutlass.Constexpr[int]
head_dim_padded: cutlass.Constexpr[int]
check_hdim_oob: cutlass.Constexpr[bool]
qhead_per_kvhead: cutlass.Constexpr[bool]
@cute.jit
def compute_ptr(
self,
tensor: cute.Tensor,
cRows: cute.Tensor,
tidx: cutlass.Int32,
block: cutlass.Int32,
threads_per_row: cutlass.Constexpr[int],
num_threads: cutlass.Constexpr[int],
):
num_ptr_per_thread = cute.ceil_div(cute.size(cRows), threads_per_row)
tPrPtr = cute.make_rmem_tensor(num_ptr_per_thread, cutlass.Int64)
for i in cutlass.range_constexpr(num_ptr_per_thread):
row = i * num_threads + cRows[tidx % threads_per_row][0]
idx = block * self.m_block_size + row
m_idx = idx // self.qhead_per_kvhead
h_idx = idx - m_idx * self.qhead_per_kvhead
tPrPtr[i] = utils.elem_pointer(tensor, ((h_idx, m_idx),)).toint()
return tPrPtr
@cute.jit
def load_Q(
self,
mQ: cute.Tensor, # ((qhead_per_kvhead, seqlen_q), headdim)
sQ: cute.Tensor, # (m_block_size, head_dim_padded)
gmem_tiled_copy: cute.TiledCopy,
tidx: cutlass.Int32,
block: cutlass.Int32,
seqlen: cutlass.Int32,
):
gmem_thr_copy = gmem_tiled_copy.get_slice(tidx)
cQ = cute.make_identity_tensor((self.m_block_size, self.head_dim_padded))
tQsQ = gmem_thr_copy.partition_D(sQ)
tQcQ = gmem_thr_copy.partition_S(cQ)
t0QcQ = gmem_thr_copy.get_slice(0).partition_S(cQ)
tQpQ = utils.predicate_k(tQcQ, limit=mQ.shape[1])
tQcQ_row = tQcQ[0, None, 0]
threads_per_row = gmem_tiled_copy.layout_tv_tiled.shape[0][0]
assert cute.arch.WARP_SIZE % threads_per_row == 0, (
"threads_per_row must divide WARP_SIZE"
)
num_threads = gmem_tiled_copy.size
tPrQPtr = self.compute_ptr(
mQ[None, 0], tQcQ_row, tidx, block, threads_per_row, num_threads
)
for m in cutlass.range_constexpr(cute.size(tQsQ.shape[1])):
q_ptr_i64 = utils.shuffle_sync(
tPrQPtr[m // threads_per_row],
m % threads_per_row,
width=threads_per_row,
)
q_gmem_ptr = cute.make_ptr(
mQ.element_type, q_ptr_i64, cute.AddressSpace.gmem, assumed_align=16
)
if (
t0QcQ[0, m, 0][0]
< seqlen * self.qhead_per_kvhead
- block * self.m_block_size
- tQcQ_row[0][0]
):
mQ_cur = cute.make_tensor(q_gmem_ptr, (self.head_dim_padded,))
elems_per_load = cute.size(tQsQ.shape[0][0])
mQ_cur_copy = cute.tiled_divide(mQ_cur, (elems_per_load,))
for k in cutlass.range_constexpr(cute.size(tQsQ.shape[2])):
ki = tQcQ[0, 0, k][1] // elems_per_load
cute.copy(
gmem_thr_copy,
mQ_cur_copy[None, ki],
tQsQ[None, m, k],
pred=tQpQ[None, m, k]
if cutlass.const_expr(self.check_hdim_oob)
else None,
)
@cute.jit
def store_LSE(
self,
mLSE: cute.Tensor, # (qhead_per_kvhead, seqlen_q)
tLSErLSE: cute.Tensor, # (m_block_size, head_dim_padded)
tiled_mma: cute.TiledMma,
tidx: cutlass.Int32,
block: cutlass.Int32,
seqlen: cutlass.Int32,
):
thr_mma = tiled_mma.get_slice(tidx)
caccO = cute.make_identity_tensor((self.m_block_size, self.head_dim_padded))
taccOcO = thr_mma.partition_C(caccO)
taccOcO_row = layout_utils.reshape_acc_to_mn(taccOcO)[None, 0]
assert cute.size(tLSErLSE) == cute.size(taccOcO_row)
threads_per_row = tiled_mma.tv_layout_C.shape[0][0]
assert cute.arch.WARP_SIZE % threads_per_row == 0, (
"threads_per_row must divide WARP_SIZE"
)
assert cute.size(tLSErLSE) <= threads_per_row
num_threads = tiled_mma.size
tPrLSEPtr = self.compute_ptr(
mLSE, taccOcO_row, tidx, block, threads_per_row, num_threads
)
for m in cutlass.range_constexpr(cute.size(tLSErLSE)):
lse_ptr_i64 = utils.shuffle_sync(
tPrLSEPtr[m // threads_per_row],
m % threads_per_row,
width=threads_per_row,
)
lse_gmem_ptr = cute.make_ptr(
mLSE.element_type, lse_ptr_i64, cute.AddressSpace.gmem, assumed_align=4
)
row = block * self.m_block_size + taccOcO_row[m][0]
# Only the thread corresponding to column 0 writes out the lse to gmem
if taccOcO[0][1] == 0 and row < seqlen * self.qhead_per_kvhead:
mLSE_copy = cute.make_tensor(lse_gmem_ptr, (1,))
mLSE_copy[0] = tLSErLSE[m]
@cute.jit
def store_O(
self,
mO: cute.Tensor, # ((qhead_per_kvhead, seqlen_q), headdim)
tOrO: cute.Tensor, # (m_block_size, head_dim_padded) split across threads according to gmem_tiled_copy
gmem_tiled_copy: cute.TiledCopy,
tidx: cutlass.Int32,
block: cutlass.Int32,
seqlen: cutlass.Int32,
):
gmem_thr_copy = gmem_tiled_copy.get_slice(tidx)
cO = cute.make_identity_tensor((self.m_block_size, self.head_dim_padded))
tOcO = gmem_thr_copy.partition_S(cO)
t0OcO = gmem_thr_copy.get_slice(0).partition_S(cO)
tOpO = utils.predicate_k(tOcO, limit=mO.shape[1])
tOcO_row = tOcO[0, None, 0]
threads_per_row = gmem_tiled_copy.layout_tv_tiled.shape[0][0]
assert cute.arch.WARP_SIZE % threads_per_row == 0, (
"threads_per_row must divide WARP_SIZE"
)
num_threads = gmem_tiled_copy.size
tPrOPtr = self.compute_ptr(
mO[None, 0], tOcO_row, tidx, block, threads_per_row, num_threads
)
for m in cutlass.range_constexpr(cute.size(tOrO.shape[1])):
o_ptr_i64 = utils.shuffle_sync(
tPrOPtr[m // threads_per_row],
m % threads_per_row,
width=threads_per_row,
)
o_gmem_ptr = cute.make_ptr(
mO.element_type, o_ptr_i64, cute.AddressSpace.gmem, assumed_align=16
)
if (
t0OcO[0, m, 0][0]
< seqlen * self.qhead_per_kvhead
- block * self.m_block_size
- tOcO_row[0][0]
):
mO_cur = cute.make_tensor(o_gmem_ptr, (self.head_dim_padded,))
elems_per_load = cute.size(tOrO.shape[0][0])
mO_cur_copy = cute.tiled_divide(mO_cur, (elems_per_load,))
for k in cutlass.range_constexpr(cute.size(tOrO.shape[2])):
ki = tOcO[0, 0, k][1] // elems_per_load
cute.copy(
gmem_thr_copy,
tOrO[None, m, k],
mO_cur_copy[None, ki],
pred=tOpO[None, m, k]
if cutlass.const_expr(self.check_hdim_oob)
else None,
)
@dataclass
class PackGQAComb(PackGQA):
"""PackGQA subclass for the K2 combine kernel.
Inherits ``compute_ptr`` / ``load_Q`` / ``store_LSE`` / ``store_O`` from
``PackGQA``. Adds ``load_LSE`` for coalesced GMEMSMEM async copies when
LSE_partial is laid out with H_q innermost.
K2 combine treats each query head independently (no GQA grouping in combine
itself), so ``qhead_per_kvhead`` is set to ``num_heads_q`` by the caller
all heads are folded into one "group" per Sq position.
"""
@cute.jit
def load_LSE(
self,
mLSE_partial: cute.Tensor,
# Packed layout after caller-side reshape:
# shape ((qhead_per_kvhead, seqlen_q), num_splits)
# stride ((1, qhead_per_kvhead), ...)
# — H_q is the innermost (stride-1) element of the packed first dim.
sLSE: cute.Tensor,
# SMEM destination: ``(max_splits, m_block_size)`` fp32.
max_splits: cutlass.Constexpr[int],
# Explicit max_splits so the identity tensor shape is a plain int,
# avoiding compound-shape traps from sLSE.shape[0] after tile_to_shape.
gmem_tiled_copy: cute.TiledCopy,
tidx: Int32,
block: Int32,
num_splits: Int32,
seqlen: Int32,
num_heads_divmod: FastDivmodDivisor,
mCounter: cute.Tensor | None = None,
batch_idx: Int32 | None = None,
qhead_per_kvhead: Int32 = Int32(1),
# divmod for ``m_pos = idx // qhead_per_kvhead``; passed explicitly so
# caller controls whether the divisor is constexpr or a runtime value.
):
"""Coalesced GMEM→SMEM async load of LSE_partial for one tile.
For each (split, row) slot this thread owns in the tile, compute the
GMEM coordinate ``(h_pos, m_pos)`` via PackGQA divmod and copy one fp32.
Out-of-bounds rows (``m_pos >= seqlen``) and splits (``si >= num_splits``)
are filled with ``-inf`` so they flow cleanly through downstream reductions.
Coalescing: adjacent thread rows correspond to adjacent ``h_pos`` values
(head varies fast under ``divmod(idx, qhead_per_kvhead)``), which map to
adjacent GMEM addresses when H_q is stride-1 one sector per warp.
"""
gmem_thr_copy = gmem_tiled_copy.get_slice(tidx)
cLSE = cute.make_identity_tensor((max_splits, self.m_block_size))
tLSEcLSE = gmem_thr_copy.partition_S(cLSE)
tLSEsLSE = gmem_thr_copy.partition_D(sLSE)
for m in cutlass.range(cute.size(tLSEcLSE, mode=[2]), unroll_full=True):
mi = tLSEcLSE[0, 0, m][1]
idx = block * self.m_block_size + mi
m_pos, h_pos = divmod(idx, num_heads_divmod)
if m_pos < seqlen:
row_count = (
mCounter[batch_idx, m_pos, h_pos // qhead_per_kvhead]
if const_expr(mCounter is not None)
else num_splits
)
for s in cutlass.range(cute.size(tLSEcLSE, mode=[1]), unroll_full=True):
si = tLSEcLSE[0, s, 0][0]
if si < num_splits and si < row_count:
# Build a 1-element GMEM tensor at ((h_pos, m_pos), si),
# matching PackGQA.store_LSE's ptr pattern so cute.copy
# receives a proper Tensor, not a scalar.
src_ptr_i64 = utils.elem_pointer(
mLSE_partial, ((h_pos, m_pos), si)
).toint()
src_ptr = cute.make_ptr(
Float32,
src_ptr_i64,
cute.AddressSpace.gmem,
assumed_align=4,
)
src_t = cute.make_tensor(src_ptr, (1,))
cute.copy(gmem_thr_copy, src_t, tLSEsLSE[None, s, m])
else:
tLSEsLSE[None, s, m].fill(-Float32.inf)
else:
for s in cutlass.range(cute.size(tLSEcLSE, mode=[1]), unroll_full=True):
tLSEsLSE[None, s, m].fill(-Float32.inf)
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
from dataclasses import dataclass
import cutlass
import cutlass.cute as cute
from cutlass import Int32, const_expr
@dataclass(frozen=True)
class PagedKVManager:
mPageTable: cute.Tensor
page_size: cutlass.Constexpr[int]
n_block_size: cutlass.Constexpr[int]
segment_rows: cutlass.Constexpr[int]
segments_per_block: cutlass.Constexpr[int]
blocks_per_page: cutlass.Constexpr[int]
@staticmethod
def create(
mPageTable: cute.Tensor,
*,
page_size: int,
n_block_size: int,
):
if page_size < 8:
raise ValueError(
f"page_size must be >= 8 for TMA segmented load, got {page_size}"
)
if page_size % n_block_size == 0:
segment_rows = n_block_size
segments_per_block = 1
blocks_per_page = page_size // n_block_size
elif n_block_size % page_size == 0:
segment_rows = page_size
segments_per_block = n_block_size // page_size
blocks_per_page = 1
else:
raise ValueError(
f"page_size ({page_size}) must divide blk_kv ({n_block_size}) "
f"or be divisible by it"
)
return PagedKVManager(
mPageTable,
page_size=page_size,
n_block_size=n_block_size,
segment_rows=segment_rows,
segments_per_block=segments_per_block,
blocks_per_page=blocks_per_page,
)
@cute.jit
def logical_length(
self,
batch_idx: Int32,
num_kv_blocks: Int32,
mSeqUsedK=None,
) -> Int32:
if const_expr(mSeqUsedK is not None):
return mSeqUsedK[batch_idx]
return num_kv_blocks * Int32(self.n_block_size)
@cute.jit
def valid_cols_in_block(
self,
batch_idx: Int32,
kv_block_idx: Int32,
num_kv_blocks: Int32,
mSeqUsedK=None,
) -> Int32:
seqlen_k = self.logical_length(batch_idx, num_kv_blocks, mSeqUsedK)
block_start = kv_block_idx * Int32(self.n_block_size)
remaining = seqlen_k - block_start
remaining = cutlass.max(remaining, Int32(0))
return cutlass.min(remaining, Int32(self.n_block_size))
@cute.jit
def physical_page_and_tile(
self,
batch_idx: Int32,
kv_block_idx: Int32,
seg_idx: Int32,
) -> tuple[Int32, Int32]:
if const_expr(self.page_size >= self.n_block_size):
logical_page = kv_block_idx // Int32(self.blocks_per_page)
page_tile_idx = kv_block_idx % Int32(self.blocks_per_page)
physical_page = self.mPageTable[batch_idx, logical_page]
return physical_page, page_tile_idx
logical_page = kv_block_idx * Int32(self.segments_per_block) + seg_idx
physical_page = self.mPageTable[batch_idx, logical_page]
return physical_page, Int32(0)
@cute.jit
def physical_block_index(
self,
batch_idx: Int32,
kv_block_idx: Int32,
) -> Int32:
physical_page, page_tile_idx = self.physical_page_and_tile(
batch_idx, kv_block_idx, Int32(0)
)
return physical_page * Int32(self.blocks_per_page) + page_tile_idx
@cute.jit
def physical_page_index(
self,
batch_idx: Int32,
kv_block_idx: Int32,
seg_idx: Int32,
) -> Int32:
physical_page, _ = self.physical_page_and_tile(batch_idx, kv_block_idx, seg_idx)
return physical_page
@cute.jit
def physical_row_start(
self,
batch_idx: Int32,
kv_block_idx: Int32,
seg_idx: Int32,
) -> Int32:
physical_page, page_tile_idx = self.physical_page_and_tile(
batch_idx, kv_block_idx, seg_idx
)
return physical_page * Int32(self.page_size) + page_tile_idx * Int32(
self.n_block_size
)
__all__ = ["PagedKVManager"]

Some files were not shown because too many files have changed in this diff Show More