From 20621aa14bda7726a8a968f326198eac61717fef Mon Sep 17 00:00:00 2001 From: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:27:23 +0800 Subject: [PATCH] [Model] Support Ling-3.0-flash (BailingMoeV3) (#33561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: JustinTong Signed-off-by: Xinyuan Tong Co-authored-by: luoyuan.luo Co-authored-by: 得泽 Co-authored-by: 翎悦 Co-authored-by: 羽癫 Co-authored-by: tiwei.btw Co-authored-by: Liangsheng Yin Co-authored-by: 文赋 Co-authored-by: JustinTong --- .../skills/sglang-runtime-context/SKILL.md | 3 +- .../kernels/fused_moe_triton/common_utils.py | 1 + .../aot/csrc/allreduce/custom_all_reduce.cuh | 3 + .../fla/fused_kda_conv_recurrent_verify.py | 489 ++++ .../ops/attention/fla/fused_norm_gate.py | 14 + .../ops/attention/fla/fused_recurrent.py | 6 +- .../fla/fused_sigmoid_gating_recurrent.py | 18 + .../kernels/ops/mamba/causal_conv1d_triton.py | 4 +- .../ops/moe/fused_moe_triton_kernels.py | 26 +- python/sglang/kernels/ops/moe/router.py | 125 ++ .../speculative/dspark/dspark_draft_model.py | 174 ++ python/sglang/srt/arg_groups/overrides.py | 2 + python/sglang/srt/configs/bailing_hybrid.py | 60 +- python/sglang/srt/configs/hybrid_arch.py | 4 +- python/sglang/srt/configs/model_config.py | 52 +- python/sglang/srt/environ.py | 6 + .../srt/function_call/function_call_parser.py | 2 + .../srt/function_call/glm4_moe_detector.py | 18 +- .../srt/function_call/ling3_detector.py | 28 + .../modules/deepseek_v2_attention_mla_npu.py | 20 +- .../fla/fused_sigmoid_gating_recurrent.py | 5 +- .../srt/layers/attention/base_attn_backend.py | 17 + .../attention/flashattention_backend.py | 2 + .../layers/attention/flashinfer_backend.py | 30 + .../attention/flashinfer_mla_backend.py | 125 +- .../attention/hybrid_linear_attn_backend.py | 32 +- .../layers/attention/linear/kda_backend.py | 223 +- .../attention/linear/kernels/kda_cutedsl.py | 7 +- .../attention/linear/kernels/kda_flashkda.py | 27 +- .../attention/linear/kernels/kda_triton.py | 6 +- .../linear/kernels/kernel_backend.py | 1 + .../layers/attention/trtllm_mla_backend.py | 3 + python/sglang/srt/layers/linear.py | 32 +- .../srt/layers/moe/fused_moe_triton/layer.py | 41 +- .../moe/moe_runner/flashinfer_cutlass.py | 10 +- .../moe/moe_runner/triton_utils/fused_moe.py | 81 +- python/sglang/srt/layers/moe/topk.py | 16 +- .../compressed_tensors/compressed_tensors.py | 40 +- .../schemes/compressed_tensors_wNa16_moe.py | 2 +- .../mxfp4_flashinfer_cutlass_moe.py | 18 +- .../mxfp4_flashinfer_trtllm_moe.py | 10 +- .../layers/quantization/mxfp4_marlin_moe.py | 2 + .../sglang/srt/layers/quantization/unquant.py | 2 +- .../srt/layers/radix_linear_attention.py | 3 +- python/sglang/srt/managers/schedule_batch.py | 28 +- .../srt/model_executor/pool_configurator.py | 6 +- .../runner/decode_cuda_graph_runner.py | 49 +- .../runner/metadata_glue_graph.py | 106 + python/sglang/srt/model_loader/loader.py | 1 - python/sglang/srt/models/bailing_moe_nextn.py | 74 +- python/sglang/srt/models/bailing_moe_v3.py | 1982 +++++++++++++++++ .../attention_forward_methods/forward_mha.py | 11 +- .../attention_forward_methods/forward_mla.py | 3 + .../forward_mla_fused_rope_cpu.py | 3 + .../forward_mla_fused_rope_rocm.py | 3 + .../sglang/srt/models/deepseek_v4_dspark.py | 4 +- python/sglang/srt/models/dspark.py | 105 +- python/sglang/srt/models/kimi_linear.py | 149 +- python/sglang/srt/parser/reasoning_parser.py | 52 +- python/sglang/srt/speculative/dflash_info.py | 18 +- .../dspark_components/dspark_draft.py | 20 +- .../dspark_components/dspark_draft_sampler.py | 59 +- .../eagle_draft_cuda_graph_runner.py | 14 +- test/registered/attention/test_kda_kernels.py | 72 +- .../attention/test_kda_prefill_flashkda.py | 9 +- .../test_fused_kda_conv_recurrent_verify.py | 187 ++ .../unit/configs/test_model_config_shapes.py | 65 +- .../entrypoints/openai/test_serving_chat.py | 23 + .../test_function_call_parser.py | 192 ++ .../moe/test_fused_shared_expert_scaling.py | 62 +- ..._compressed_tensors_wna16_moe_no_linear.py | 188 +- .../managers/test_mamba_checkpoint_depth.py | 3 + .../test_flashkda_strided_state_access.py | 3 +- .../test_shared_experts_fusion_gates.py | 134 +- .../unit/parser/test_reasoning_parser.py | 67 + test/registered/unit/test_model_overrides.py | 17 + 76 files changed, 5184 insertions(+), 315 deletions(-) create mode 100644 python/sglang/kernels/ops/attention/fla/fused_kda_conv_recurrent_verify.py create mode 100644 python/sglang/srt/function_call/ling3_detector.py create mode 100644 python/sglang/srt/model_executor/runner/metadata_glue_graph.py create mode 100644 python/sglang/srt/models/bailing_moe_v3.py create mode 100644 test/registered/kernels/test_fused_kda_conv_recurrent_verify.py diff --git a/.claude/skills/sglang-runtime-context/SKILL.md b/.claude/skills/sglang-runtime-context/SKILL.md index c76a56399..b4e996fb7 100644 --- a/.claude/skills/sglang-runtime-context/SKILL.md +++ b/.claude/skills/sglang-runtime-context/SKILL.md @@ -290,7 +290,8 @@ where an object was handed one; it is not a global accessor. theirs from `spec` / `schedule` / `exec.graph`. - **a value only the instance can compute** → the named accessor in `runtime_context`, which is the one module allowed to read the slot: - `mamba_cache_chunk_size()`, `uses_mla_backend()`, `process_model_config()`. + `mamba_cache_chunk_size()`, `mamba_state_chunk_size()`, `uses_mla_backend()`, + `process_model_config()`. These have no leaf to read — they combine several fields, the HF config, or a property with no bag of its own. A new derived member gets an accessor here rather than call sites reaching for the record, and only when the bag-derived diff --git a/benchmark/kernels/fused_moe_triton/common_utils.py b/benchmark/kernels/fused_moe_triton/common_utils.py index d0b9d623a..4c8b4625a 100644 --- a/benchmark/kernels/fused_moe_triton/common_utils.py +++ b/benchmark/kernels/fused_moe_triton/common_utils.py @@ -137,6 +137,7 @@ def get_model_config( "BailingMoEForCausalLM", "BailingMoeForCausalLM", "BailingMoeV2ForCausalLM", + "BailingMoeV3ForCausalLM", ]: E = config.num_experts // ep_size topk = config.num_experts_per_tok diff --git a/python/sglang/kernels/aot/csrc/allreduce/custom_all_reduce.cuh b/python/sglang/kernels/aot/csrc/allreduce/custom_all_reduce.cuh index 83476cb35..669a9914a 100644 --- a/python/sglang/kernels/aot/csrc/allreduce/custom_all_reduce.cuh +++ b/python/sglang/kernels/aot/csrc/allreduce/custom_all_reduce.cuh @@ -338,6 +338,9 @@ __global__ void __launch_bounds__(kMaxThreadsPerBlock, 1) cross_device_reduce_1s ((P*)result)[idx] = packed_reduce((const P**)&dp.ptrs[0], idx); #endif } +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaTriggerProgrammaticLaunchCompletion(); +#endif multi_gpu_barrier(sg, self_sg, rank); } diff --git a/python/sglang/kernels/ops/attention/fla/fused_kda_conv_recurrent_verify.py b/python/sglang/kernels/ops/attention/fla/fused_kda_conv_recurrent_verify.py new file mode 100644 index 000000000..dfb5bc0ef --- /dev/null +++ b/python/sglang/kernels/ops/attention/fla/fused_kda_conv_recurrent_verify.py @@ -0,0 +1,489 @@ +"""Fused KDA chain-verify kernel: causal-conv1d update + sigmoid-gating delta rule. + +Fuses the KDA (Kimi Delta Attention) MTP target_verify hot path + + causal_conv1d_update (chain mode, SAVE_INTERMEDIATE) + + fused_sigmoid_gating_delta_rule_update (T-step recurrence, + intermediate-state caching, state update disabled) + +into a single Triton kernel, removing per-layer-per-verify: one kernel +launch, the mixed_qkv HBM round-trip between conv and recurrence, and the +two transpose copies the unfused path needs to feed the conv kernel. + +Scope (v1): chain speculation only (``speculative_eagle_topk == 1``, i.e. +``retrieve_next_token is None``). The tree path keeps the unfused reference +kernels. Requires ``T >= kernel_width - 1`` (the rolled conv state is then +exactly the last ``kernel_width - 1`` input tokens, matching the reference +kernel's store). + +Numerics: deliberately bit-aligned with the unfused pair. The conv output is +rounded to the activation dtype (bf16) before entering the recurrence — +exactly what the unfused path does through its intermediate tensor — and all +expressions mirror the reference kernels line by line, with the same +num_warps so reduction order matches. +""" + +from typing import Optional + +import torch +import triton +import triton.language as tl + +from sglang.kernels.jit.utils import is_arch_support_pdl + +# V-tile width of the fused verify kernel. Tuned on B200 at T=5 with +# benchmark/kernels/bench_kda_verify_sweep.py; any power of two is +# numerics-safe at num_warps=4 (bit-exact vs the BV=32 original). +KDA_VERIFY_BLOCK_V = 4 + + +@triton.jit +def fused_kda_conv_gating_verify_kernel( + x, # [seq_len, dim] packed qkv, pre-conv + w, # [dim, W] conv weights + conv_bias, # [dim] or dummy + conv_state, # [lines, dim, state_len], dim contiguous + conv_state_indices, # [B] + inter_conv_window, # [lines, steps, dim, W-1] (as strides) + inter_state_indices, # [B] + a, # [seq_len, HV*K] gate input + b_gate, # [seq_len, HV] beta input + A_log, # [HV] + dt_bias, # [HV*K] + lower_bound, + softplus_beta, + softplus_threshold, + h0_source, # [slots, HV, V, K] fp32 ssm states + h0_indices, # [B] + inter_states, # [lines, cache_steps, HV, V, K] fp32 + o, # [seq_len, HV, V] + scale, + cache_steps, # allocated step-dim of inter_states + stride_x_tok, + stride_w_dim, + stride_cs_line, + stride_cs_tok, + stride_iw_line, + stride_iw_step, + stride_iw_dim, + stride_iw_win, + stride_a_tok, + stride_b_tok, + T: tl.constexpr, + W: tl.constexpr, + H: tl.constexpr, + HV: tl.constexpr, + K: tl.constexpr, + V: tl.constexpr, + BK: tl.constexpr, + BV: tl.constexpr, + HAS_BIAS: tl.constexpr, + USE_QK_L2NORM_IN_KERNEL: tl.constexpr, + USE_LOWER_BOUND: tl.constexpr, + SAVE_INTERMEDIATE_WINDOW: tl.constexpr, + CACHE_INTERMEDIATE_STATES: tl.constexpr, + USE_GDC: tl.constexpr = False, +): + # PDL: overlap prologue with the tail of the producer qkv-projection GEMM; + # every global load (conv_state_indices, mixed_qkv, weights) happens after + # the wait. The immediate trigger releases the LAUNCH of the PDL'd gated + # norm so its prologue overlaps this kernel's whole (long, latency-bound) + # body -- consumers' own gdc_wait still fences on full completion. Fired + # before the padded-slot early return so every CTA triggers explicitly. + if USE_GDC: + tl.extra.cuda.gdc_wait() + tl.extra.cuda.gdc_launch_dependents() + + i_v, i_nh = tl.program_id(0), tl.program_id(1) + i_n, i_hv = i_nh // HV, i_nh % HV + i_h = i_hv // (HV // H) + + bos = i_n * T + o_k = tl.arange(0, BK) + o_v = i_v * BV + tl.arange(0, BV) + mask_k = o_k < K + mask_v = o_v < V + mask_h = mask_k[:, None] & mask_v[None, :] + + # Packed channel offsets inside x / conv_state / weights. + q_ch = i_h * K + o_k + k_ch = H * K + i_h * K + o_k + v_ch = 2 * H * K + i_hv * V + o_v + + # The q/k channels of head i_h are shared by every (v-tile, hv) program + # mapping to it; exactly one of them owns the state/window writes so the + # shared channels are written once (values are identical either way). + is_qk_owner = (i_v == 0) & (i_hv % (HV // H) == 0) + + cs_idx = tl.load(conv_state_indices + i_n).to(tl.int64) + # Padded rows carry -1 slots; the reference conv kernel early-returns on + # them (their outputs are never consumed), so skip the whole program. + if cs_idx < 0: + return + cs_base = conv_state + cs_idx * stride_cs_line + + # Conv history (state_len = W-1 columns, oldest -> newest). Matches the + # reference kernel's col0..col2 preload (KERNEL_WIDTH == 4). + tl.static_assert(W == 4, "fused KDA verify kernel supports kernel width 4") + q_c0 = tl.load(cs_base + q_ch + 0 * stride_cs_tok, mask=mask_k, other=0.0) + q_c1 = tl.load(cs_base + q_ch + 1 * stride_cs_tok, mask=mask_k, other=0.0) + q_c2 = tl.load(cs_base + q_ch + 2 * stride_cs_tok, mask=mask_k, other=0.0) + k_c0 = tl.load(cs_base + k_ch + 0 * stride_cs_tok, mask=mask_k, other=0.0) + k_c1 = tl.load(cs_base + k_ch + 1 * stride_cs_tok, mask=mask_k, other=0.0) + k_c2 = tl.load(cs_base + k_ch + 2 * stride_cs_tok, mask=mask_k, other=0.0) + v_c0 = tl.load(cs_base + v_ch + 0 * stride_cs_tok, mask=mask_v, other=0.0) + v_c1 = tl.load(cs_base + v_ch + 1 * stride_cs_tok, mask=mask_v, other=0.0) + v_c2 = tl.load(cs_base + v_ch + 2 * stride_cs_tok, mask=mask_v, other=0.0) + + # Conv weights per channel group (column-major over width). + wq0 = tl.load(w + q_ch * stride_w_dim + 0, mask=mask_k, other=0.0) + wq1 = tl.load(w + q_ch * stride_w_dim + 1, mask=mask_k, other=0.0) + wq2 = tl.load(w + q_ch * stride_w_dim + 2, mask=mask_k, other=0.0) + wq3 = tl.load(w + q_ch * stride_w_dim + 3, mask=mask_k, other=0.0) + wk0 = tl.load(w + k_ch * stride_w_dim + 0, mask=mask_k, other=0.0) + wk1 = tl.load(w + k_ch * stride_w_dim + 1, mask=mask_k, other=0.0) + wk2 = tl.load(w + k_ch * stride_w_dim + 2, mask=mask_k, other=0.0) + wk3 = tl.load(w + k_ch * stride_w_dim + 3, mask=mask_k, other=0.0) + wv0 = tl.load(w + v_ch * stride_w_dim + 0, mask=mask_v, other=0.0) + wv1 = tl.load(w + v_ch * stride_w_dim + 1, mask=mask_v, other=0.0) + wv2 = tl.load(w + v_ch * stride_w_dim + 2, mask=mask_v, other=0.0) + wv3 = tl.load(w + v_ch * stride_w_dim + 3, mask=mask_v, other=0.0) + + if HAS_BIAS: + bias_q = tl.load(conv_bias + q_ch, mask=mask_k, other=0.0).to(tl.float32) + bias_k = tl.load(conv_bias + k_ch, mask=mask_k, other=0.0).to(tl.float32) + bias_v = tl.load(conv_bias + v_ch, mask=mask_v, other=0.0).to(tl.float32) + + # Recurrent state tile [BK, BV] over the [V, K]-major state layout. + b_h = tl.zeros([BK, BV], dtype=tl.float32) + h0_idx = tl.load(h0_indices + i_n) + if h0_idx >= 0: + p_h0 = ( + h0_source + + h0_idx.to(tl.int64) * HV * K * V + + i_hv * K * V + + o_v[None, :] * K + + o_k[:, None] + ) + b_h += tl.load(p_h0, mask=mask_h, other=0).to(tl.float32) + + cache_idx = -1 + if CACHE_INTERMEDIATE_STATES: + cache_idx = tl.load(inter_state_indices + i_n) + iw_idx = tl.zeros([], dtype=tl.int64) + if SAVE_INTERMEDIATE_WINDOW: + iw_idx = tl.load(inter_state_indices + i_n).to(tl.int64) + + b_A_log = tl.load(A_log + i_hv).to(tl.float32) + b_dt_bias = tl.load(dt_bias + i_hv * K + o_k, mask=mask_k, other=0.0).to(tl.float32) + + for t in tl.static_range(T): + # ---- inline causal conv (reference: bias + c0*w0 + c1*w1 + c2*w2 + x*w3, + # then silu; accumulation order and dtypes mirror the unfused kernel) ---- + x_q = tl.load(x + (bos + t) * stride_x_tok + q_ch, mask=mask_k, other=0.0) + x_k = tl.load(x + (bos + t) * stride_x_tok + k_ch, mask=mask_k, other=0.0) + x_v = tl.load(x + (bos + t) * stride_x_tok + v_ch, mask=mask_v, other=0.0) + + if HAS_BIAS: + acc_q = bias_q + acc_k = bias_k + acc_v = bias_v + else: + acc_q = tl.zeros([BK], dtype=tl.float32) + acc_k = tl.zeros([BK], dtype=tl.float32) + acc_v = tl.zeros([BV], dtype=tl.float32) + acc_q += q_c0 * wq0 + acc_q += q_c1 * wq1 + acc_q += q_c2 * wq2 + acc_q += x_q * wq3 + acc_k += k_c0 * wk0 + acc_k += k_c1 * wk1 + acc_k += k_c2 * wk2 + acc_k += x_k * wk3 + acc_v += v_c0 * wv0 + acc_v += v_c1 * wv1 + acc_v += v_c2 * wv2 + acc_v += x_v * wv3 + + # Slide the window (reference: col0=col1; col1=col2; col2=x). + q_c0 = q_c1 + q_c1 = q_c2 + q_c2 = x_q + k_c0 = k_c1 + k_c1 = k_c2 + k_c2 = x_k + v_c0 = v_c1 + v_c1 = v_c2 + v_c2 = x_v + + if SAVE_INTERMEDIATE_WINDOW: + iw_base = inter_conv_window + iw_idx * stride_iw_line + t * stride_iw_step + if is_qk_owner: + tl.store( + iw_base + q_ch * stride_iw_dim + 0 * stride_iw_win, + q_c0, + mask=mask_k, + ) + tl.store( + iw_base + q_ch * stride_iw_dim + 1 * stride_iw_win, + q_c1, + mask=mask_k, + ) + tl.store( + iw_base + q_ch * stride_iw_dim + 2 * stride_iw_win, + q_c2, + mask=mask_k, + ) + tl.store( + iw_base + k_ch * stride_iw_dim + 0 * stride_iw_win, + k_c0, + mask=mask_k, + ) + tl.store( + iw_base + k_ch * stride_iw_dim + 1 * stride_iw_win, + k_c1, + mask=mask_k, + ) + tl.store( + iw_base + k_ch * stride_iw_dim + 2 * stride_iw_win, + k_c2, + mask=mask_k, + ) + tl.store( + iw_base + v_ch * stride_iw_dim + 0 * stride_iw_win, v_c0, mask=mask_v + ) + tl.store( + iw_base + v_ch * stride_iw_dim + 1 * stride_iw_win, v_c1, mask=mask_v + ) + tl.store( + iw_base + v_ch * stride_iw_dim + 2 * stride_iw_win, v_c2, mask=mask_v + ) + + # SiLU, then round to the activation dtype: the unfused path stores the + # conv output to a bf16 tensor and reloads it for the recurrence; the + # explicit round-trip keeps the fused kernel bit-identical. + acc_q = acc_q / (1 + tl.exp(-acc_q)) + acc_k = acc_k / (1 + tl.exp(-acc_k)) + acc_v = acc_v / (1 + tl.exp(-acc_v)) + b_q = acc_q.to(o.dtype.element_ty).to(tl.float32) + b_k = acc_k.to(o.dtype.element_ty).to(tl.float32) + b_v = acc_v.to(o.dtype.element_ty).to(tl.float32) + + # ---- sigmoid-gating delta rule step (mirrors the reference kernel) ---- + b_b = tl.load(b_gate + (bos + t) * stride_b_tok + i_hv).to(tl.float32) + b_a = tl.load( + a + (bos + t) * stride_a_tok + i_hv * K + o_k, mask=mask_k, other=0.0 + ).to(tl.float32) + + gx = b_a + b_dt_bias + if USE_LOWER_BOUND: + b_g = lower_bound * tl.sigmoid(tl.exp(b_A_log) * gx) + else: + beta_x = softplus_beta * gx + softplus_x = tl.where( + beta_x <= softplus_threshold, + (1.0 / softplus_beta) * tl.log(1.0 + tl.exp(beta_x)), + gx, + ) + b_g = -tl.exp(b_A_log) * softplus_x + + b_beta = 1.0 / (1.0 + tl.exp(-b_b)) + + if USE_QK_L2NORM_IN_KERNEL: + b_q = b_q / (tl.sqrt(tl.sum(b_q * b_q) + 1e-6)) + b_k = b_k / (tl.sqrt(tl.sum(b_k * b_k) + 1e-6)) + + b_q = b_q * scale + + b_h *= tl.exp(b_g[:, None]) + b_v -= tl.sum(b_h * b_k[:, None], 0) + b_v *= b_beta + b_h += b_k[:, None] * b_v[None, :] + b_o = tl.sum(b_h * b_q[:, None], 0) + tl.store( + o + ((bos + t) * HV + i_hv) * V + o_v, + b_o.to(o.dtype.element_ty), + mask=mask_v, + ) + + if CACHE_INTERMEDIATE_STATES: + if cache_idx >= 0: + cache_ptr = ( + inter_states + + cache_idx.to(tl.int64) * cache_steps * HV * K * V + + t * HV * K * V + + i_hv * K * V + + o_v[None, :] * K + + o_k[:, None] + ) + tl.store(cache_ptr, b_h.to(cache_ptr.dtype.element_ty), mask=mask_h) + + # Rolled conv state after consuming T >= W-1 tokens is exactly the last + # W-1 input tokens — which are the current window registers. The verify + # pass never writes the ssm state back (rollback happens at commit). + if is_qk_owner: + tl.store(cs_base + q_ch + 0 * stride_cs_tok, q_c0, mask=mask_k) + tl.store(cs_base + q_ch + 1 * stride_cs_tok, q_c1, mask=mask_k) + tl.store(cs_base + q_ch + 2 * stride_cs_tok, q_c2, mask=mask_k) + tl.store(cs_base + k_ch + 0 * stride_cs_tok, k_c0, mask=mask_k) + tl.store(cs_base + k_ch + 1 * stride_cs_tok, k_c1, mask=mask_k) + tl.store(cs_base + k_ch + 2 * stride_cs_tok, k_c2, mask=mask_k) + tl.store(cs_base + v_ch + 0 * stride_cs_tok, v_c0, mask=mask_v) + tl.store(cs_base + v_ch + 1 * stride_cs_tok, v_c1, mask=mask_v) + tl.store(cs_base + v_ch + 2 * stride_cs_tok, v_c2, mask=mask_v) + + +def fused_kda_conv_gating_verify( + mixed_qkv: torch.Tensor, + conv_weight: torch.Tensor, + conv_bias: Optional[torch.Tensor], + conv_state: torch.Tensor, + conv_state_indices: torch.Tensor, + intermediate_conv_window: Optional[torch.Tensor], + intermediate_state_indices: Optional[torch.Tensor], + a: torch.Tensor, + b: torch.Tensor, + A_log: torch.Tensor, + dt_bias: torch.Tensor, + ssm_states: torch.Tensor, + cache_indices: torch.Tensor, + intermediate_states_buffer: Optional[torch.Tensor], + scale: float, + T: int, + num_q_heads: int, + num_v_heads: int, + head_k_dim: int, + head_v_dim: int, + lower_bound: Optional[float] = None, + softplus_beta: float = 1.0, + softplus_threshold: float = 20.0, + use_qk_l2norm_in_kernel: bool = True, + # num_warps=4 is ~1.3x faster than the unfused pair in-graph; the output, + # conv_state and conv-window caches stay bit-identical to the reference. + # Only the fp32 intermediate-ssm rollback cache differs: the tl.sum + # reduction-order delta (~1 ulp/step) compounds through the delta-rule + # recurrence — measured ~6e-8 at T=4 standard gate (the production MTP + # shape), ~1.5e-5 at T=4 safe gate, ~2e-3 at T=8 safe gate. num_warps=1 + # reproduces the reference reduction order exactly (all buffers + # bit-identical) but is ~2.4x slower in-graph — numerics debugging only. + num_warps: int = 4, +) -> torch.Tensor: + """Chain-verify fast path. Returns ``o`` of shape [1, seq_len, HV, V], + matching the unfused ``target_verify`` output layout.""" + H, HV, K, V = num_q_heads, num_v_heads, head_k_dim, head_v_dim + seq_len, dim = mixed_qkv.shape + B = seq_len // T + W = conv_weight.shape[1] + + assert mixed_qkv.stride(-1) == 1, "mixed_qkv must be contiguous in dim" + assert dim == 2 * H * K + HV * V, f"packed dim mismatch: {dim}" + assert W == 4, "fused KDA verify supports conv width 4 only" + assert T >= W - 1, "fused KDA verify requires T >= conv width - 1" + assert seq_len == B * T + assert conv_state.stride(1) == 1, "conv_state must be dim-contiguous" + assert conv_weight.stride(1) == 1 + assert ssm_states.is_contiguous() + BK = triton.next_power_of_2(K) + assert BK == K, "K must be a power of two (NK==1)" + # Smaller V tiles keep winning on this latency-bound grid (serial T-step + # recurrence per CTA; more CTAs = shorter per-step chains, and the + # duplicated per-head q/k conv work stays cheaper than the parallelism + # gain all the way down): B200 T=5 sweep (us/layer, warps=4) measured + # 4 -> 11.56, 8 -> 12.53, 16 -> 12.83, 32 -> 14.26, 64 -> 20.7, + # 128 -> 38 (benchmark/kernels/bench_kda_verify_sweep.py; H20-3e ranks + # 16 first but B200 is the production target). Bit-exact across BV at + # num_warps=4: the V tiling never touches the K-axis reduction order. + # BV=128 (the norm-fusion single-tile probe) measured 2x slower -- folding + # the gated RMSNorm into this kernel's epilogue is a dead end; it is + # PDL-chained behind this kernel instead (see fused_norm_gate.py). + BV = min(triton.next_power_of_2(V), KDA_VERIFY_BLOCK_V) + NV = triton.cdiv(V, BV) + + a2 = a.reshape(seq_len, HV * K) + b2 = b.reshape(seq_len, HV) + assert a2.stride(-1) == 1 and b2.stride(-1) == 1 + + o = mixed_qkv.new_empty(seq_len, HV, V) + + if intermediate_conv_window is not None: + s_iw = intermediate_conv_window.stride() + s_iw_line, s_iw_step, s_iw_dim, s_iw_win = s_iw[0], s_iw[1], s_iw[2], s_iw[3] + assert intermediate_state_indices is not None + else: + s_iw_line = s_iw_step = s_iw_dim = s_iw_win = 0 + + cache_steps = ( + intermediate_states_buffer.shape[1] + if intermediate_states_buffer is not None + else 0 + ) + if intermediate_states_buffer is not None: + assert intermediate_states_buffer.is_contiguous() + + grid = (NV, B * HV) + # PDL (sm90+): chain behind the producer qkv-projection GEMM and signal the + # downstream o_norm / o_proj. Scheduling only — bit-exactness unaffected. + pdl_kwargs = {"USE_GDC": True, "launch_pdl": True} if is_arch_support_pdl() else {} + fused_kda_conv_gating_verify_kernel[grid]( + x=mixed_qkv, + w=conv_weight, + conv_bias=conv_bias if conv_bias is not None else conv_weight, + conv_state=conv_state, + conv_state_indices=conv_state_indices, + inter_conv_window=( + intermediate_conv_window + if intermediate_conv_window is not None + else mixed_qkv + ), + inter_state_indices=( + intermediate_state_indices + if intermediate_state_indices is not None + else conv_state_indices + ), + a=a2, + b_gate=b2, + A_log=A_log.reshape(-1), + dt_bias=dt_bias.reshape(-1), + lower_bound=lower_bound, + softplus_beta=softplus_beta, + softplus_threshold=softplus_threshold, + h0_source=ssm_states, + h0_indices=cache_indices, + inter_states=( + intermediate_states_buffer + if intermediate_states_buffer is not None + else ssm_states + ), + o=o, + scale=scale, + cache_steps=cache_steps, + stride_x_tok=mixed_qkv.stride(0), + stride_w_dim=conv_weight.stride(0), + stride_cs_line=conv_state.stride(0), + stride_cs_tok=conv_state.stride(2), + stride_iw_line=s_iw_line, + stride_iw_step=s_iw_step, + stride_iw_dim=s_iw_dim, + stride_iw_win=s_iw_win, + stride_a_tok=a2.stride(0), + stride_b_tok=b2.stride(0), + T=T, + W=W, + H=H, + HV=HV, + K=K, + V=V, + BK=BK, + BV=BV, + HAS_BIAS=conv_bias is not None, + USE_QK_L2NORM_IN_KERNEL=use_qk_l2norm_in_kernel, + USE_LOWER_BOUND=lower_bound is not None, + SAVE_INTERMEDIATE_WINDOW=intermediate_conv_window is not None, + CACHE_INTERMEDIATE_STATES=intermediate_states_buffer is not None, + # num_warps=1 matches the reference kernels' reduction order exactly; + # higher values must be re-validated for bit-exactness before use. + num_warps=num_warps, + num_stages=3, + **pdl_kwargs, + ) + return o.view(1, seq_len, HV, V) diff --git a/python/sglang/kernels/ops/attention/fla/fused_norm_gate.py b/python/sglang/kernels/ops/attention/fla/fused_norm_gate.py index ddaded752..6514db459 100644 --- a/python/sglang/kernels/ops/attention/fla/fused_norm_gate.py +++ b/python/sglang/kernels/ops/attention/fla/fused_norm_gate.py @@ -7,6 +7,7 @@ import torch.nn as nn import triton import triton.language as tl +from sglang.kernels.jit.utils import is_arch_support_pdl from sglang.srt.utils import ( cdiv, cpu_has_amx_support, @@ -44,7 +45,14 @@ def layer_norm_gated_fwd_kernel( HAS_RESIDUAL: tl.constexpr, HAS_WEIGHT: tl.constexpr, HAS_BIAS: tl.constexpr, + USE_GDC: tl.constexpr = False, ): + # PDL: x is the producer's output (e.g. the fused KDA verify kernel, which + # triggers its dependents right after the o store), so every load sits + # behind the wait; the launch/prologue overlaps the producer's tail. + if USE_GDC: + tl.extra.cuda.gdc_wait() + i_t = tl.program_id(0) o_d = tl.arange(0, BD) @@ -100,6 +108,8 @@ def layer_norm_gated_fwd_kernel( # Write output p_y = tl.make_block_ptr(y, (T, D), (D, 1), (i_t * BT, 0), (BT, BD), (1, 0)) tl.store(p_y, b_y.to(p_y.dtype.element_ty), boundary_check=(0, 1)) + if USE_GDC: + tl.extra.cuda.gdc_launch_dependents() @triton.jit @@ -214,6 +224,9 @@ def layer_norm_gated_fwd( if D <= 512: BT = 32 + pdl_kwargs = ( + {"USE_GDC": True, "launch_pdl": True} if is_arch_support_pdl() else {} + ) layer_norm_gated_fwd_kernel[(cdiv(T, BT),)]( x=x, g=g, @@ -236,6 +249,7 @@ def layer_norm_gated_fwd( HAS_WEIGHT=weight is not None, HAS_BIAS=bias is not None, num_warps=4, + **pdl_kwargs, ) else: layer_norm_gated_fwd_kernel1[(T,)]( diff --git a/python/sglang/kernels/ops/attention/fla/fused_recurrent.py b/python/sglang/kernels/ops/attention/fla/fused_recurrent.py index 5ffdea776..d3ea8b821 100644 --- a/python/sglang/kernels/ops/attention/fla/fused_recurrent.py +++ b/python/sglang/kernels/ops/attention/fla/fused_recurrent.py @@ -409,12 +409,12 @@ def fused_recurrent_kda_packed_decode_kernel( b, A_log, dt_bias, + lower_bound, o, h0, ht, ssm_state_indices, scale, - lower_bound, stride_mixed_qkv_tok: tl.constexpr, stride_a_tok: tl.constexpr, stride_b_tok: tl.constexpr, @@ -533,6 +533,8 @@ def fused_recurrent_kda_packed_decode( out: ``[B, 1, HV, V]`` contiguous output buffer. ssm_state_indices: ``[B]`` per-request state slot indices (-1 = skip). use_qk_l2norm_in_kernel: apply per-head L2 norm to Q/K inside the kernel. + lower_bound: enable KDA safe gate when set, matching + ``fused_sigmoid_gating_delta_rule_update``. """ if mixed_qkv.ndim != 2: raise ValueError( @@ -679,12 +681,12 @@ def fused_recurrent_kda_packed_decode( b=b, A_log=A_log, dt_bias=dt_bias, + lower_bound=lower_bound, o=out, h0=initial_state, ht=initial_state, ssm_state_indices=ssm_state_indices, scale=scale, - lower_bound=lower_bound if lower_bound is not None else 0.0, stride_mixed_qkv_tok=stride_mixed_qkv_tok, stride_a_tok=stride_a_tok, stride_b_tok=stride_b_tok, diff --git a/python/sglang/kernels/ops/attention/fla/fused_sigmoid_gating_recurrent.py b/python/sglang/kernels/ops/attention/fla/fused_sigmoid_gating_recurrent.py index a7ffe49e6..38dcd162b 100644 --- a/python/sglang/kernels/ops/attention/fla/fused_sigmoid_gating_recurrent.py +++ b/python/sglang/kernels/ops/attention/fla/fused_sigmoid_gating_recurrent.py @@ -4,6 +4,8 @@ import torch import triton import triton.language as tl +from sglang.kernels.jit.utils import is_arch_support_pdl + @triton.jit(do_not_specialize=["T"]) def fused_sigmoid_gating_delta_rule_update_kernel( @@ -67,10 +69,20 @@ def fused_sigmoid_gating_delta_rule_update_kernel( stride_beta_slot: tl.constexpr = 0, MAX_CACHE_LEN: tl.constexpr = 0, CACHE_RING: tl.constexpr = False, + USE_GDC: tl.constexpr = False, ): """ Fused kernel that combines sigmoid gating computation with recurrent delta rule update. """ + # PDL: overlap this kernel's prologue with the producer (the KDA/GDN + # conv1d_update). All global loads below happen after the wait, so + # numerics are unchanged. The immediate trigger releases the LAUNCH of + # the next PDL kernel so its prologue overlaps this whole body; + # consumers' own gdc_wait still fences on full completion. + if USE_GDC: + tl.extra.cuda.gdc_wait() + tl.extra.cuda.gdc_launch_dependents() + i_k, i_v, i_nh = tl.program_id(0), tl.program_id(1), tl.program_id(2) i_n, i_hv = i_nh // HV, i_nh % HV i_h = i_hv // (HV // H) @@ -440,6 +452,11 @@ def fused_sigmoid_gating_delta_rule_update( max_cache_len = 0 stride_rawv_slot = stride_rawk_slot = stride_g_slot = stride_beta_slot = 0 + # PDL (sm90+): chain this kernel behind its producer conv1d_update, which + # already launches dependents. Bit-exact (scheduling only) — benefits both + # KDA and GDN recurrent paths. + pdl_kwargs = {"USE_GDC": True, "launch_pdl": True} if is_arch_support_pdl() else {} + fused_sigmoid_gating_delta_rule_update_kernel[grid]( A_log=A_log, a=a, @@ -501,6 +518,7 @@ def fused_sigmoid_gating_delta_rule_update( CACHE_RING=cache_ring, num_warps=num_warps, num_stages=num_stages, + **pdl_kwargs, ) o = o.squeeze(0) return o diff --git a/python/sglang/kernels/ops/mamba/causal_conv1d_triton.py b/python/sglang/kernels/ops/mamba/causal_conv1d_triton.py index f677e1518..be5cca881 100644 --- a/python/sglang/kernels/ops/mamba/causal_conv1d_triton.py +++ b/python/sglang/kernels/ops/mamba/causal_conv1d_triton.py @@ -642,6 +642,7 @@ def _causal_conv1d_update_kernel( # ruff: noqa: E501 if USE_GDC: tl.extra.cuda.gdc_wait() + tl.extra.cuda.gdc_launch_dependents() idx_seq = tl.program_id(0) if idx_seq >= batch: @@ -990,9 +991,6 @@ def _causal_conv1d_update_kernel( mask=mask_retrieve, ) - if USE_GDC: - tl.extra.cuda.gdc_launch_dependents() - def causal_conv1d_update( x: torch.Tensor, diff --git a/python/sglang/kernels/ops/moe/fused_moe_triton_kernels.py b/python/sglang/kernels/ops/moe/fused_moe_triton_kernels.py index 7e895045a..7fa26c7b3 100644 --- a/python/sglang/kernels/ops/moe/fused_moe_triton_kernels.py +++ b/python/sglang/kernels/ops/moe/fused_moe_triton_kernels.py @@ -8,6 +8,7 @@ import torch import triton import triton.language as tl +from sglang.kernels.jit.utils import is_arch_support_pdl from sglang.kernels.ops.quantization.fp8_kernel import ( per_token_group_quant_fp8, scaled_fp8_quant, @@ -384,6 +385,8 @@ def fused_moe_kernel( LORA_PRESERVE_BASE: tl.constexpr, ROUTER_TOPK: tl.constexpr, FUSE_SWIGLU: tl.constexpr = False, + USE_GDC: tl.constexpr = False, + GDC_EARLY: tl.constexpr = False, ): """ Implements the fused computation for a Mixture of Experts (MOE) using @@ -412,6 +415,11 @@ def fused_moe_kernel( BLOCK_SIZE_M, which is necessary to maintain consistency in block matrix multiplication across different blocks processed by the same expert. """ + if USE_GDC: + tl.extra.cuda.gdc_wait() + if GDC_EARLY: + tl.extra.cuda.gdc_launch_dependents() + # ----------------------------------------------------------- # Map program ids `pid` to the block of C it should compute. # This is done in a grouped ordering to promote L2 data reuse. @@ -706,6 +714,9 @@ def fused_moe_kernel( c_mask = token_mask[:, None] & (offs_cn[None, :] < N) tl.store(c_ptrs, accumulator, mask=c_mask) + if USE_GDC and not GDC_EARLY: + tl.extra.cuda.gdc_launch_dependents() + # ----------------------------------------------------------------------------- # TMA allocator: set once per process (avoid per-call triton.set_allocator) @@ -980,6 +991,11 @@ def invoke_fused_moe_kernel( else: b_desc = None + pdl_kwargs = ( + {"USE_GDC": True, "launch_pdl": True, "GDC_EARLY": A.shape[0] <= 512} + if is_arch_support_pdl() + else {} + ) fused_moe_kernel[grid]( A, a_desc, @@ -1028,9 +1044,10 @@ def invoke_fused_moe_kernel( FUSE_ADD_TO_OUTPUT=fuse_add_to_output, MASK_OUTPUT=mask_output, LORA_PRESERVE_BASE=lora_preserve_base, - FUSE_SWIGLU=fuse_swiglu, FUSE_SUM_ALL_REDUCE=fuse_sum_all_reduce, ROUTER_TOPK=router_topk, + FUSE_SWIGLU=fuse_swiglu, + **pdl_kwargs, **config, ) @@ -1177,6 +1194,7 @@ def _moe_sum_reduce_kernel( BLOCK_M: tl.constexpr, BLOCK_DIM: tl.constexpr, NUM_STAGE: tl.constexpr, + USE_GDC: tl.constexpr = False, ): input_stride_0 = tl.cast(input_stride_0, dtype=tl.int64) input_stride_1 = tl.cast(input_stride_1, dtype=tl.int64) @@ -1195,6 +1213,10 @@ def _moe_sum_reduce_kernel( accumulator = tl.zeros((BLOCK_M, BLOCK_DIM), dtype=tl.float32) + if USE_GDC: + tl.extra.cuda.gdc_wait() + tl.extra.cuda.gdc_launch_dependents() + for i in tl.range(0, topk_num, num_stages=NUM_STAGE): tile = tl.load( base_ptrs + i * input_stride_1, @@ -1232,6 +1254,7 @@ def moe_sum_reduce_triton( triton.cdiv(hidden_dim, BLOCK_DIM), ) + pdl_kwargs = {"USE_GDC": True, "launch_pdl": True} if is_arch_support_pdl() else {} _moe_sum_reduce_kernel[grid]( input, *input.stride(), @@ -1245,6 +1268,7 @@ def moe_sum_reduce_triton( BLOCK_DIM=BLOCK_DIM, NUM_STAGE=NUM_STAGE, num_warps=num_warps, + **pdl_kwargs, ) return diff --git a/python/sglang/kernels/ops/moe/router.py b/python/sglang/kernels/ops/moe/router.py index bce31503b..d6a0b2d33 100644 --- a/python/sglang/kernels/ops/moe/router.py +++ b/python/sglang/kernels/ops/moe/router.py @@ -4,6 +4,7 @@ import torch import triton import triton.language as tl +from sglang.kernels.jit.utils import is_arch_support_pdl from sglang.srt.utils import is_hip _is_hip = is_hip() @@ -385,3 +386,127 @@ def fused_moe_router_shim( moe_softcapping=moe_softcapping, correction_bias=correction_bias, ) + + +@triton.jit +def router_gate_matvec_kernel( + x_ptr, # (M, K) bf16/fp16/fp32, row-major + w_ptr, # (E, K) fp32/bf16/fp16, k-major + out_ptr, # (M, E) fp32 + K, + E, + stride_xm, + stride_we, + BLOCK_E: tl.constexpr, + BLOCK_K: tl.constexpr, + USE_GDC: tl.constexpr = False, +): + """Router-gate logits as a single matvec launch, fp32 accumulation for + any float weight dtype. BLOCK_K covers the whole K in one masked load + (single iteration for K <= BLOCK_K): a cold gate weight then costs one + HBM round trip per CTA instead of a serial dependent-load chain, which + is what dominates in the real model where ~94MB/layer of expert traffic + flushes L2 between gate calls. + """ + pid_m = tl.program_id(0) + pid_e = tl.program_id(1) + e_offs = pid_e * BLOCK_E + tl.arange(0, BLOCK_E) + e_mask = e_offs < E + + # First K tile, weight load ahead of the PDL wait (see docstring). + k_offs = tl.arange(0, BLOCK_K) + k_mask = k_offs < K + w = tl.load( + w_ptr + e_offs[:, None] * stride_we + k_offs[None, :], + mask=e_mask[:, None] & k_mask[None, :], + other=0.0, + ).to(tl.float32) + if USE_GDC: + tl.extra.cuda.gdc_wait() + tl.extra.cuda.gdc_launch_dependents() + x = tl.load(x_ptr + pid_m * stride_xm + k_offs, mask=k_mask, other=0.0).to( + tl.float32 + ) + acc = tl.sum(w * x[None, :], axis=1) + + for k0 in range(BLOCK_K, K, BLOCK_K): + k_offs = k0 + tl.arange(0, BLOCK_K) + k_mask = k_offs < K + x = tl.load(x_ptr + pid_m * stride_xm + k_offs, mask=k_mask, other=0.0).to( + tl.float32 + ) + w = tl.load( + w_ptr + e_offs[:, None] * stride_we + k_offs[None, :], + mask=e_mask[:, None] & k_mask[None, :], + other=0.0, + ).to(tl.float32) + acc += tl.sum(w * x[None, :], axis=1) + + tl.store( + out_ptr + pid_m * E + e_offs, acc.to(out_ptr.dtype.element_ty), mask=e_mask + ) + + +# Cold-cache tuned on H20-3e (41 rotating gate weights so each call misses L2, +# like the real model); expected to carry to B200 (more SMs favor the wide +# grid even more) — re-tune with benchmark/kernels/bench_router_gate_matvec.py. +ROUTER_GATE_MATVEC_BLOCK_E = 4 +ROUTER_GATE_MATVEC_NUM_WARPS = 8 +# Beyond this M the per-M re-reads of the gate weight outgrow the library +# GEMM (cold H20-3e: bf16 wins to M=12, fp32 to M=8; cap at the lower). +ROUTER_GATE_MATVEC_MAX_M = 8 + + +def router_gate_matvec( + hidden_states: torch.Tensor, weight: torch.Tensor +) -> torch.Tensor: + """Small-M router-gate logits: one triton launch replacing the library + path — for fp32 gate weights the eager upcast + fp32 GEMM + splitKreduce + triple, for bf16 the F.linear GEMV. Returns fp32 (M, E) logits with fp32 + accumulation (deterministic; for fp32 weights 0 top-8 routing flips over + 30104 random draws vs the fp32 reference; for bf16 weights this is + slightly MORE precise than the library GEMV, so near-tie logits can + round-trip differently — same order as the bf16-vs-fp32 gate change). + + Cold-cache (41 rotating weights, in-graph, H20-3e, E=513, K=2560), us/call: + + M lib bf16 matvec bf16 lib fp32 chain matvec fp32 + 1 4.2 4.3 6.4 6.2 + 2 13.8 4.5 14.6 6.4 + 4 14.0 5.4 20.7 10.4 + 8 14.6 10.4 20.1 18.1 + 16 14.6 18.9 (lib) 20.9 30.9 (lib) + + Callers must gate on M <= ROUTER_GATE_MATVEC_MAX_M; prefill-sized M + keeps the library GEMM.""" + assert ( + weight.dtype + in ( + torch.float32, + torch.bfloat16, + torch.float16, + ) + and weight.is_contiguous() + ) + M, K = hidden_states.shape + E = weight.shape[0] + out = torch.empty((M, E), dtype=torch.float32, device=hidden_states.device) + block_e = ROUTER_GATE_MATVEC_BLOCK_E + # Single k-iteration whenever K fits one block: no serial dependent-load + # chain on a cold weight. + block_k = min(4096, triton.next_power_of_2(K)) + pdl_kwargs = {"USE_GDC": True, "launch_pdl": True} if is_arch_support_pdl() else {} + router_gate_matvec_kernel[(M, triton.cdiv(E, block_e))]( + hidden_states, + weight, + out, + K, + E, + hidden_states.stride(0), + weight.stride(0), + BLOCK_E=block_e, + BLOCK_K=block_k, + num_warps=ROUTER_GATE_MATVEC_NUM_WARPS, + **pdl_kwargs, + ) + return out diff --git a/python/sglang/kernels/ops/speculative/dspark/dspark_draft_model.py b/python/sglang/kernels/ops/speculative/dspark/dspark_draft_model.py index 5915c090b..f8b26e8d0 100644 --- a/python/sglang/kernels/ops/speculative/dspark/dspark_draft_model.py +++ b/python/sglang/kernels/ops/speculative/dspark/dspark_draft_model.py @@ -200,6 +200,180 @@ def sample_step_tokens_triton( return next_tokens +_MARKOV_BLOCK_V = 256 +_MARKOV_BLOCK_R = 32 + + +class MarkovGreedyStep: + """One greedy markov draft step, fused. + + Computes ``argmax_v(base_logits[:, v] + dot(w2_weight[v, :], prev_embeds))`` + in a single pass over the (vocab x rank) weight: no full-vocab bias or + step-logits materialization, no separate GEMV / add / two-pass argmax + launches. Numerics: the dot and the add accumulate in fp32, while the eager + path rounds the GEMV output and the add to bf16 before argmax — near-tie + winners can differ on rare steps. Drafts are proposals only (target verify + guards output correctness), so the impact is bounded to accept-rate noise + on exact ties. + """ + + @classmethod + def execute( + cls, + *, + base_logits: torch.Tensor, + prev_embeds: torch.Tensor, + w2_weight: torch.Tensor, + ) -> torch.Tensor: + if base_logits.is_cuda: + return cls.triton( + base_logits=base_logits, prev_embeds=prev_embeds, w2_weight=w2_weight + ) + return cls.torch( + base_logits=base_logits, prev_embeds=prev_embeds, w2_weight=w2_weight + ) + + @classmethod + def torch( + cls, + *, + base_logits: torch.Tensor, + prev_embeds: torch.Tensor, + w2_weight: torch.Tensor, + ) -> torch.Tensor: + return markov_greedy_step( + base_logits=base_logits, prev_embeds=prev_embeds, w2_weight=w2_weight + ) + + @classmethod + def triton( + cls, + *, + base_logits: torch.Tensor, + prev_embeds: torch.Tensor, + w2_weight: torch.Tensor, + ) -> torch.Tensor: + return markov_greedy_step_triton( + base_logits=base_logits, prev_embeds=prev_embeds, w2_weight=w2_weight + ) + + +def markov_greedy_step( + *, + base_logits: torch.Tensor, + prev_embeds: torch.Tensor, + w2_weight: torch.Tensor, +) -> torch.Tensor: + step_logits = base_logits + F.linear(prev_embeds, w2_weight) + return torch.argmax(step_logits, dim=-1) + + +@triton.jit +def _markov_greedy_partial_kernel( + base_ptr, + embed_ptr, + w2_ptr, + tile_val_ptr, + tile_idx_ptr, + V, + R, + stride_base_row, + stride_embed_row, + stride_w2_v, + n_tiles, + BLOCK_V: tl.constexpr, + BLOCK_R: tl.constexpr, +): + row = tl.program_id(0) + tile = tl.program_id(1) + offs_v = tile * BLOCK_V + tl.arange(0, BLOCK_V) + mask_v = offs_v < V + acc = tl.zeros([BLOCK_V], dtype=tl.float32) + for r0 in range(0, R, BLOCK_R): + offs_r = r0 + tl.arange(0, BLOCK_R) + mask_r = offs_r < R + embed = tl.load( + embed_ptr + row * stride_embed_row + offs_r, mask=mask_r, other=0.0 + ).to(tl.float32) + w2 = tl.load( + w2_ptr + offs_v[:, None] * stride_w2_v + offs_r[None, :], + mask=mask_v[:, None] & mask_r[None, :], + other=0.0, + ).to(tl.float32) + acc += tl.sum(w2 * embed[None, :], axis=1) + base = tl.load( + base_ptr + row * stride_base_row + offs_v, mask=mask_v, other=float("-inf") + ).to(tl.float32) + score = tl.where(mask_v, base + acc, float("-inf")) + tile_best = tl.max(score, axis=0) + # First-index tie-break within the tile, matching torch.argmax. + idx = tl.where(score == tile_best, offs_v, _IDX_SENTINEL) + tl.store(tile_val_ptr + row * n_tiles + tile, tile_best) + tl.store(tile_idx_ptr + row * n_tiles + tile, tl.min(idx, axis=0)) + + +@triton.jit +def _markov_greedy_combine_kernel( + tile_val_ptr, + tile_idx_ptr, + next_tokens_ptr, + n_tiles, + BLOCK_TILES: tl.constexpr, +): + row = tl.program_id(0) + offs = tl.arange(0, BLOCK_TILES) + mask = offs < n_tiles + vals = tl.load(tile_val_ptr + row * n_tiles + offs, mask=mask, other=float("-inf")) + idxs = tl.load(tile_idx_ptr + row * n_tiles + offs, mask=mask, other=_IDX_SENTINEL) + best = tl.max(vals, axis=0) + # Lowest global index among equal-valued tiles, matching torch.argmax. + cand = tl.where(vals == best, idxs, _IDX_SENTINEL) + tl.store(next_tokens_ptr + row, tl.min(cand, axis=0).to(tl.int64)) + + +def markov_greedy_step_triton( + *, + base_logits: torch.Tensor, + prev_embeds: torch.Tensor, + w2_weight: torch.Tensor, +) -> torch.Tensor: + bs, vocab = base_logits.shape + rank = w2_weight.shape[1] + device = base_logits.device + assert base_logits.stride(1) == 1, "base_logits rows must be contiguous" + assert w2_weight.stride(1) == 1, "markov_w2 weight rows must be contiguous" + prev_embeds = prev_embeds.contiguous() + + n_tiles = triton.cdiv(vocab, _MARKOV_BLOCK_V) + tile_vals = torch.empty((bs, n_tiles), dtype=torch.float32, device=device) + tile_idxs = torch.empty((bs, n_tiles), dtype=torch.int32, device=device) + next_tokens = torch.empty((bs,), dtype=torch.int64, device=device) + + _markov_greedy_partial_kernel[(bs, n_tiles)]( + base_logits, + prev_embeds, + w2_weight, + tile_vals, + tile_idxs, + vocab, + rank, + base_logits.stride(0), + prev_embeds.stride(0), + w2_weight.stride(0), + n_tiles, + BLOCK_V=_MARKOV_BLOCK_V, + BLOCK_R=_MARKOV_BLOCK_R, + ) + _markov_greedy_combine_kernel[(bs,)]( + tile_vals, + tile_idxs, + next_tokens, + n_tiles, + BLOCK_TILES=triton.next_power_of_2(n_tiles), + ) + return next_tokens + + _STACKED_WEIGHT_CACHE: dict[int, _StackedWkvWeight] = {} diff --git a/python/sglang/srt/arg_groups/overrides.py b/python/sglang/srt/arg_groups/overrides.py index 645c7854e..880de9a7a 100644 --- a/python/sglang/srt/arg_groups/overrides.py +++ b/python/sglang/srt/arg_groups/overrides.py @@ -1759,6 +1759,7 @@ _MAMBA_RADIX_CACHE_ARCHS = frozenset( "KimiLinearForCausalLM", "KimiK3ForConditionalGeneration", "BailingMoeV2_5ForCausalLM", + "BailingMoeV3ForCausalLM", "Qwen3NextForCausalLM", "Qwen3_5MoeForConditionalGeneration", "InternS2PreviewForConditionalGeneration", @@ -1796,6 +1797,7 @@ _MAMBA_EXTRA_BUFFER_ARCHS = frozenset( "InternS2PreviewForConditionalGeneration", "MiniCPMV4_6ForConditionalGeneration", "BailingMoeV2_5ForCausalLM", + "BailingMoeV3ForCausalLM", "FalconH1ForCausalLM", "GraniteMoeHybridForCausalLM", "NemotronHForCausalLM", diff --git a/python/sglang/srt/configs/bailing_hybrid.py b/python/sglang/srt/configs/bailing_hybrid.py index f7a8be168..659b9af35 100644 --- a/python/sglang/srt/configs/bailing_hybrid.py +++ b/python/sglang/srt/configs/bailing_hybrid.py @@ -15,11 +15,17 @@ """BailingHybrid model configuration""" import enum +from typing import Union from transformers.configuration_utils import PretrainedConfig from transformers.utils import logging -from sglang.srt.configs.mamba_utils import Mamba2CacheParams, Mamba2StateShape +from sglang.srt.configs.mamba_utils import ( + KimiLinearCacheParams, + KimiLinearStateShape, + Mamba2CacheParams, + Mamba2StateShape, +) from sglang.srt.runtime_context import get_parallel logger = logging.get_logger(__name__) @@ -82,6 +88,14 @@ class BailingHybridConfig(PretrainedConfig): v_head_dim=128, qk_nope_head_dim=128, rope_interleave=True, + # KDA (Ling-V3) linear-attention variant. Absent from a V2.5 / + # lightning checkpoint, which keeps the Mamba2 branch below. + short_conv_kernel_size=None, + no_kda_lora=False, + kda_safe_gate=False, + kda_lower_bound=None, + # NoPE MLA: the rope half of the query/key is dropped entirely. + use_mla_nope=False, **kwargs, ): self.num_hidden_layers = num_hidden_layers @@ -110,7 +124,6 @@ class BailingHybridConfig(PretrainedConfig): self.moe_router_enable_expert_bias = moe_router_enable_expert_bias self.routed_scaling_factor = routed_scaling_factor - # MoE configs self.num_experts = num_experts self.num_shared_experts = num_shared_experts self.num_experts_per_tok = num_experts_per_tok @@ -120,12 +133,10 @@ class BailingHybridConfig(PretrainedConfig): self.first_k_dense_replace = first_k_dense_replace self.output_router_logits = output_router_logits - # Linear configs self.layer_group_size = layer_group_size self.group_norm_size = group_norm_size self.linear_silu = linear_silu self.num_linear_key_value_heads = num_attention_heads - # mla self.kv_lora_rank = kv_lora_rank self.q_lora_rank = q_lora_rank self.qk_rope_head_dim = qk_rope_head_dim @@ -133,6 +144,14 @@ class BailingHybridConfig(PretrainedConfig): self.qk_nope_head_dim = qk_nope_head_dim self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim self.rope_interleave = rope_interleave + self.short_conv_kernel_size = short_conv_kernel_size + # KDA is what distinguishes Ling-V3 from the V2.5 / lightning + # checkpoints; only the former carries a short conv. + self.use_kda = short_conv_kernel_size is not None + self.no_kda_lora = no_kda_lora + self.kda_safe_gate = kda_safe_gate + self.kda_lower_bound = kda_lower_bound if kda_safe_gate else None + self.use_mla_nope = use_mla_nope self.for_nextn_model = False super().__init__( pad_token_id=pad_token_id, @@ -148,11 +167,22 @@ class BailingHybridConfig(PretrainedConfig): layer_type_list = [] - for l in range(self.num_hidden_layers): - if (l + 1) % self.layer_group_size == 0: - layer_type_list.append(HybridLayerType.full_attention.value) - else: - layer_type_list.append(HybridLayerType.linear_attention.value) + if isinstance(self.layer_group_size, int): + for l in range(self.num_hidden_layers): + if (l + 1) % self.layer_group_size == 0: + layer_type_list.append(HybridLayerType.full_attention.value) + else: + layer_type_list.append(HybridLayerType.linear_attention.value) + else: + # Per-layer schedule: 1 marks a linear-attention layer. + assert ( + len(self.layer_group_size) == self.num_hidden_layers + ), "When layer_group_size is a list, its length must be equal to num_hidden_layers" + for l in range(self.num_hidden_layers): + if self.layer_group_size[l] == 1: + layer_type_list.append(HybridLayerType.linear_attention.value) + else: + layer_type_list.append(HybridLayerType.full_attention.value) return layer_type_list @@ -173,7 +203,17 @@ class BailingHybridConfig(PretrainedConfig): ] @property - def mamba2_cache_params(self) -> Mamba2CacheParams: + def mamba2_cache_params(self) -> Union[KimiLinearCacheParams, Mamba2CacheParams]: + + if self.use_kda: + shape = KimiLinearStateShape.create( + tp_world_size=get_parallel().attn_tp_size, + num_heads=self.num_attention_heads, + head_dim=self.head_dim, + conv_kernel_size=self.short_conv_kernel_size, + ) + + return KimiLinearCacheParams(shape=shape, layers=self.linear_layer_ids) shape = Mamba2StateShape.create( tp_world_size=get_parallel().attn_tp_size, diff --git a/python/sglang/srt/configs/hybrid_arch.py b/python/sglang/srt/configs/hybrid_arch.py index bafd9a5cc..5f1b3cc3c 100644 --- a/python/sglang/srt/configs/hybrid_arch.py +++ b/python/sglang/srt/configs/hybrid_arch.py @@ -41,7 +41,7 @@ def qwen3_next_config(model_config: ModelConfig): def hybrid_lightning_config(model_config: ModelConfig): config = model_config.hf_config - if isinstance(config, BailingHybridConfig): + if isinstance(config, BailingHybridConfig) and not config.use_kda: return config if isinstance(config, MiniCPMHybridConfig) and config.has_lightning_layers: return config @@ -105,6 +105,8 @@ def kimi_linear_config(model_config: ModelConfig): config = model_config.hf_config if isinstance(config, KimiLinearConfig): return config + if isinstance(config, BailingHybridConfig) and config.use_kda: + return config text_config = getattr(config, "text_config", None) if isinstance(text_config, KimiLinearConfig): return text_config diff --git a/python/sglang/srt/configs/model_config.py b/python/sglang/srt/configs/model_config.py index 5ec73e541..952dda79b 100644 --- a/python/sglang/srt/configs/model_config.py +++ b/python/sglang/srt/configs/model_config.py @@ -58,6 +58,12 @@ SWA_SINK_ARCHS = frozenset( ) +def _quant_config_to_dict(quant_config): + if quant_config is not None and not isinstance(quant_config, dict): + return quant_config.to_dict() + return quant_config + + def get_mimo_v2_fused_qkv_expected_tp_size(hf_config): layout = getattr(hf_config, "attention_projection_layout", None) if layout is None: @@ -391,11 +397,20 @@ class ModelConfig: # Config draft model self._config_draft_model() - # DSV4 expert layout: env (default True = mxfp4) applies only to V4. - # Other FP8 MoE models (for example DeepSeek V3.2) must keep the normal - # FP8 expert tensor layout. - self.is_fp4_experts: bool = False - if is_deepseek_v4(self.hf_config): + # Mixed FP8/MXFP4 ckpts mark mxfp4 routed experts via this key. + quantization_config = ( + _quant_config_to_dict(getattr(self.hf_config, "quantization_config", None)) + or {} + ) + routed_experts_quant_method = quantization_config.get( + "routed_experts_quant_method" + ) + self.is_fp4_experts: bool = routed_experts_quant_method == "mxfp4" + if self.is_fp4_experts: + logger.info("Detected mixed checkpoint layout: routed experts are MXFP4.") + + # DSV4 mxfp4 layout applies only when the ckpt does not opt in above. + if is_deepseek_v4(self.hf_config) and routed_experts_quant_method is None: self.is_fp4_experts = envs.SGLANG_DSV4_FP4_EXPERTS.get() if ( not envs.SGLANG_DSV4_FP4_EXPERTS.is_set() @@ -426,9 +441,9 @@ class ModelConfig: # Handle hybrid NVFP4 moe (nvidia/DeepSeek-V4-Pro-NVFP4) self.nvfp4_moe_meta: Optional[dict] = None - hybrid_quant_cfg = getattr(self.hf_config, "quantization_config", None) - if hybrid_quant_cfg is not None and not isinstance(hybrid_quant_cfg, dict): - hybrid_quant_cfg = hybrid_quant_cfg.to_dict() + hybrid_quant_cfg = _quant_config_to_dict( + getattr(self.hf_config, "quantization_config", None) + ) if ( hybrid_quant_cfg is not None and str(hybrid_quant_cfg.get("quant_algo", "")).upper() == "MIXED_PRECISION" @@ -715,6 +730,7 @@ class ModelConfig: "BailingMoeV2ForCausalLM", "BailingMoeForCausalLM", "BailingMoeV2_5ForCausalLM", + "BailingMoeV3ForCausalLM", ]: self.hf_config.architectures[0] = "BailingMoeForCausalLMNextN" if ( @@ -1015,6 +1031,16 @@ class ModelConfig: self.qk_rope_head_dim = self.hf_text_config.qk_rope_head_dim self.v_head_dim = self.hf_config.v_head_dim self._init_mla_scaling(self.hf_config.rope_scaling) + elif "BailingMoeV3ForCausalLM" in self.hf_config.architectures: + self.head_dim = 128 + self.attention_arch = AttentionArch.MLA + self.kv_lora_rank = self.hf_config.kv_lora_rank + self.qk_rope_head_dim = ( + 0 if self.hf_config.use_mla_nope else self.hf_config.qk_rope_head_dim + ) + self.v_head_dim = self.hf_config.v_head_dim + self.qk_nope_head_dim = self.hf_config.qk_nope_head_dim + self.scaling = 1 / math.sqrt(self.qk_nope_head_dim + self.qk_rope_head_dim) elif "SarvamMLAForCausalLM" in self.hf_config.architectures: self.head_dim = ( self.hf_config.qk_nope_head_dim + self.hf_config.qk_rope_head_dim @@ -1214,9 +1240,9 @@ class ModelConfig: # adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/config.py def _parse_quant_hf_config(self): - quant_cfg = getattr(self.hf_config, "quantization_config", None) - if quant_cfg is not None and not isinstance(quant_cfg, dict): - quant_cfg = quant_cfg.to_dict() + quant_cfg = _quant_config_to_dict( + getattr(self.hf_config, "quantization_config", None) + ) if quant_cfg is not None: # Identify modelopt quantization if ( @@ -1241,7 +1267,6 @@ class ModelConfig: if not is_local: # Conditional import based on SGLANG_USE_MODELSCOPE environment variable if envs.SGLANG_USE_MODELSCOPE.get(): - from modelscope import HubApi, model_file_download hf_api = HubApi() @@ -2079,8 +2104,7 @@ def compute_mla_mscale_scaling(rope_scaling: dict, base_scaling: float) -> float mscale_all_dim = rope_scaling.get("mscale_all_dim", False) if "factor" not in rope_scaling: logger.warning( - "rope_scaling missing 'factor', defaulting to 1.0. " - "Check model accuracy.", + "rope_scaling missing 'factor', defaulting to 1.0. Check model accuracy.", ) scaling_factor = rope_scaling.get("factor", 1.0) mscale = yarn_get_mscale(scaling_factor, float(mscale_all_dim)) diff --git a/python/sglang/srt/environ.py b/python/sglang/srt/environ.py index bd95cc16e..4e14098ac 100644 --- a/python/sglang/srt/environ.py +++ b/python/sglang/srt/environ.py @@ -500,6 +500,7 @@ class Envs: SGLANG_DSPARK_EMBED_IN_GRAPH = EnvBool(True) SGLANG_DSPARK_OPT_MARKOV_W2_BF16 = EnvBool(True) SGLANG_DSPARK_OPT_MARKOV_W2_TP_SHARD = EnvBool(True) + SGLANG_DSPARK_OPT_FUSED_GREEDY_MARKOV = EnvBool(False) SGLANG_DSPARK_ENABLE_MULTI_STREAM = EnvBool(True) SGLANG_DSPARK_CONFIDENCE_RELAY_LAG_STEPS = EnvInt(2) @@ -1144,6 +1145,11 @@ class Envs: # Speculative decoding # =================================================================== SGLANG_ENABLE_OVERLAP_PLAN_STREAM = EnvBool(False) + # Capture the per-replay attention-metadata prep (init_forward_metadata_out_graph) + # into a small CUDA graph, collapsing its host dispatch cost to one launch. + # Experimental; auto-falls back to eager if the backend's prep is not capturable. + SGLANG_ENABLE_METADATA_GLUE_GRAPH = EnvBool(False) + SGLANG_OPT_FUSED_KDA_VERIFY = EnvBool(False) # A/B: keep the DFLASH draft greedy head eager (not folded in-graph). SGLANG_DFLASH_EAGER_DRAFT_SAMPLER = EnvBool(False) SGLANG_RAGGED_VERIFY_MODE = EnvStr("static") diff --git a/python/sglang/srt/function_call/function_call_parser.py b/python/sglang/srt/function_call/function_call_parser.py index 535aaa8b1..090898b41 100644 --- a/python/sglang/srt/function_call/function_call_parser.py +++ b/python/sglang/srt/function_call/function_call_parser.py @@ -32,6 +32,7 @@ from sglang.srt.function_call.internlm_detector import InternlmDetector from sglang.srt.function_call.kimik2_detector import KimiK2Detector from sglang.srt.function_call.kimik3_detector import KimiK3Detector from sglang.srt.function_call.lfm2_detector import Lfm2Detector +from sglang.srt.function_call.ling3_detector import Ling3Detector from sglang.srt.function_call.llama32_detector import Llama32Detector from sglang.srt.function_call.mimo_detector import MiMoDetector from sglang.srt.function_call.minicpm5_detector import MiniCPM5Detector @@ -78,6 +79,7 @@ class FunctionCallParser: "kimi_k2": KimiK2Detector, "kimi_k3": KimiK3Detector, "lfm2": Lfm2Detector, + "ling3": Ling3Detector, "llama3": Llama32Detector, "mimo": MiMoDetector, "minicpm5": MiniCPM5Detector, diff --git a/python/sglang/srt/function_call/glm4_moe_detector.py b/python/sglang/srt/function_call/glm4_moe_detector.py index 0c29a39e7..3dc251302 100644 --- a/python/sglang/srt/function_call/glm4_moe_detector.py +++ b/python/sglang/srt/function_call/glm4_moe_detector.py @@ -162,6 +162,10 @@ class Glm4MoeDetector(BaseFormatDetector): Uses a streaming state machine to convert XML to JSON incrementally for maximum speed. """ + _STREAMING_PARTIAL_PATTERN = re.compile( + r"(.*?)(?:\\n|\n)(.*?)(|$)", re.DOTALL + ) + def __init__(self): super().__init__() self.bot_token = "" @@ -474,11 +478,7 @@ class Glm4MoeDetector(BaseFormatDetector): calls: list[ToolCallItem] = [] try: # Try to match a partial or complete tool call - partial_match = re.search( - pattern=r"(.*?)(?:\\n|\n)(.*?)(|$)", - string=current_text, - flags=re.DOTALL, - ) + partial_match = self._STREAMING_PARTIAL_PATTERN.search(current_text) if partial_match: func_name_raw = partial_match.group(1) func_args_raw = partial_match.group(2) @@ -525,7 +525,10 @@ class Glm4MoeDetector(BaseFormatDetector): "name": func_name, "arguments": {}, } - else: + + # The name and final tool-call marker can arrive in the same + # parse call, so continue into argument/finalization handling. + if self.current_tool_name_sent: # Process XML to JSON streaming current_raw_length = len(func_args_raw) @@ -566,6 +569,9 @@ class Glm4MoeDetector(BaseFormatDetector): ) ) self._last_arguments += empty_object + self.streamed_args_for_tool[ + self.current_tool_id + ] += empty_object elif not self._last_arguments.endswith("}"): closing_brace = "}" calls.append( diff --git a/python/sglang/srt/function_call/ling3_detector.py b/python/sglang/srt/function_call/ling3_detector.py new file mode 100644 index 000000000..db1380aa4 --- /dev/null +++ b/python/sglang/srt/function_call/ling3_detector.py @@ -0,0 +1,28 @@ +import re + +from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector + + +class Ling3Detector(Glm4MoeDetector): + """ + Detector for Ling3 tool calls. + + Ling3 uses the GLM-4.5 XML format, but model outputs may either put a newline + after the function name, emit the first argument tag immediately, or close a + no-argument tool call immediately. + """ + + _STREAMING_PARTIAL_PATTERN = re.compile( + r"\s*(.*?)" + r"(?:(?:\\n|\n)\s*|(?=)|(?=))" + r"(.*?)(|$)", + re.DOTALL, + ) + + def __init__(self): + super().__init__() + self.func_detail_regex = re.compile( + r"\s*(.*?)(?:(?:\\n|\n)\s*|(?=)|(?=))" + r"(.*?)?", + re.DOTALL, + ) diff --git a/python/sglang/srt/hardware_backend/npu/modules/deepseek_v2_attention_mla_npu.py b/python/sglang/srt/hardware_backend/npu/modules/deepseek_v2_attention_mla_npu.py index 7badaf1d4..da3f5643c 100644 --- a/python/sglang/srt/hardware_backend/npu/modules/deepseek_v2_attention_mla_npu.py +++ b/python/sglang/srt/hardware_backend/npu/modules/deepseek_v2_attention_mla_npu.py @@ -1,5 +1,5 @@ import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import torch import torch_npu @@ -135,9 +135,15 @@ def forward_mha_core_npu( k: torch.Tensor, v: torch.Tensor, forward_batch: "ForwardBatch", + # Gated attention (Ling-V3 / BailingMoeV3): the subclass appends its gate + # to inner_state, so every *_core dispatched from forward_core takes it as + # a trailing arg. None everywhere else. + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: attn_output = m.attn_mha(q, k, v, forward_batch, save_kv_cache=False) attn_output = attn_output.reshape(-1, m.num_local_heads * m.v_head_dim) + if gate is not None: + attn_output = m._apply_gated(attn_output, gate) output, _ = m.o_proj(attn_output) return output @@ -289,6 +295,10 @@ def forward_mla_core_npu( zero_allocator: "BumpAllocator", positions: torch.Tensor, topk_indices: torch.Tensor, + # Gated attention (Ling-V3 / BailingMoeV3): the subclass appends its gate + # to inner_state, so every *_core dispatched from forward_core takes it as + # a trailing arg. None everywhere else. + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: attn_output = m.attn_mqa( q_nope_out, @@ -326,6 +336,8 @@ def forward_mla_core_npu( ) attn_bmm_output = attn_bmm_output.reshape(-1, m.num_local_heads * m.v_head_dim) + if gate is not None: + attn_bmm_output = m._apply_gated(attn_bmm_output, gate) output, _ = m.o_proj(attn_bmm_output) return output @@ -483,6 +495,10 @@ def forward_dsa_core_npu( forward_batch: "ForwardBatch", zero_allocator: "BumpAllocator", positions: torch.Tensor, + # Gated attention (Ling-V3 / BailingMoeV3): the subclass appends its gate + # to inner_state, so every *_core dispatched from forward_core takes it as + # a trailing arg. None everywhere else. + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: attn_output = m.attn_mqa( q_nope_out.contiguous(), @@ -521,6 +537,8 @@ def forward_dsa_core_npu( attn_bmm_output = attn_bmm_output.reshape(-1, m.num_local_heads * m.v_head_dim) + if gate is not None: + attn_bmm_output = m._apply_gated(attn_bmm_output, gate) output, _ = m.o_proj(attn_bmm_output) if not m.next_skip_topk: return output, None diff --git a/python/sglang/srt/hardware_backend/xpu/kernels/fla/fused_sigmoid_gating_recurrent.py b/python/sglang/srt/hardware_backend/xpu/kernels/fla/fused_sigmoid_gating_recurrent.py index 083718ef9..253e69b2c 100644 --- a/python/sglang/srt/hardware_backend/xpu/kernels/fla/fused_sigmoid_gating_recurrent.py +++ b/python/sglang/srt/hardware_backend/xpu/kernels/fla/fused_sigmoid_gating_recurrent.py @@ -30,6 +30,7 @@ def fused_sigmoid_gating_delta_rule_update( intermediate_state_indices: Optional[torch.Tensor] = None, cache_steps: Optional[int] = None, retrieve_parent_token: Optional[torch.Tensor] = None, + lower_bound: Optional[float] = None, ): """ Fused triton implementation of sigmoid gating delta rule update. @@ -85,7 +86,7 @@ def fused_sigmoid_gating_delta_rule_update( dt_bias=dt_bias, softplus_beta=softplus_beta, softplus_threshold=softplus_threshold, - lower_bound=0.0, + lower_bound=lower_bound if lower_bound is not None else 0.0, q=q, k=k, v=v, @@ -119,10 +120,10 @@ def fused_sigmoid_gating_delta_rule_update( USE_QK_L2NORM_IN_KERNEL=use_qk_l2norm_in_kernel, IS_VARLEN=cu_seqlens is not None, IS_KDA=is_kda, - USE_LOWER_BOUND=False, DISABLE_STATE_UPDATE=disable_state_update, CACHE_INTERMEDIATE_STATES=intermediate_states_buffer is not None, HAS_EAGLE_TREE_CUSTOM_ATTN_MASK=retrieve_parent_token is not None, + USE_LOWER_BOUND=lower_bound is not None, num_warps=num_warps, num_stages=num_stages, ) diff --git a/python/sglang/srt/layers/attention/base_attn_backend.py b/python/sglang/srt/layers/attention/base_attn_backend.py index b9eb964c3..7838f1023 100644 --- a/python/sglang/srt/layers/attention/base_attn_backend.py +++ b/python/sglang/srt/layers/attention/base_attn_backend.py @@ -61,6 +61,23 @@ class AttentionBackend(ABC): decode_attention_backend_str: Optional[str] = None supports_ragged_verify_graph: bool = False + # Compute / KV-cache dtype. Only backends that need them (MLA/MHA fp8 + # fuse-rope checks) set these in __init__; declared here as None so callers + # can read them off ANY backend — including hybrid wrappers that don't set + # them — without defensive getattr. See trtllm_mla fuse-rope path. + data_type: Optional[torch.dtype] = None + kv_cache_dtype: Optional[torch.dtype] = None + + # Wrapper backends (e.g. HybridLinearAttnBackend) set this to their child + # backends; leaves keep None. Lets generic code (metadata glue graph) + # enumerate every backend whose python-side forward_metadata must be + # snapshotted/restored around a captured metadata-prep replay. + attn_backend_list: Optional[list] = None + + # Per-iter metadata produced by init_forward_metadata*; backends that use + # it assign their own type. Declared here so generic snapshot/restore code + # (metadata glue graph) can read it off any backend without hasattr. + forward_metadata: Optional[object] = None def init_forward_metadata(self, forward_batch: ForwardBatch): """Eager entry point. Default = ``_out_graph(fb) + _in_graph(fb)``. diff --git a/python/sglang/srt/layers/attention/flashattention_backend.py b/python/sglang/srt/layers/attention/flashattention_backend.py index 48bd00522..4df20c95c 100644 --- a/python/sglang/srt/layers/attention/flashattention_backend.py +++ b/python/sglang/srt/layers/attention/flashattention_backend.py @@ -3413,6 +3413,8 @@ class FlashAttentionMultiStepBackend: fa_impl_ver=fa_impl_ver, ) ) + self.attn_backend_list = self.attn_backends + self.forward_metadata = None def init_forward_metadata(self, forward_batch: ForwardBatch): for i in range(self.speculative_num_steps - 1): diff --git a/python/sglang/srt/layers/attention/flashinfer_backend.py b/python/sglang/srt/layers/attention/flashinfer_backend.py index 6d37cbb7c..78c00b847 100644 --- a/python/sglang/srt/layers/attention/flashinfer_backend.py +++ b/python/sglang/srt/layers/attention/flashinfer_backend.py @@ -810,6 +810,33 @@ class FlashInferAttnBackend(AttentionBackend): for w in self.draft_extend_cuda_graph_metadata[bs]: w.begin_forward = partial(fast_prefill_plan, w) + if ( + in_capture + and forward_mode.is_target_verify() + and spec_info is not None + and spec_info.spec_input_type == SpecInputType.DFLASH_VERIFY + and getattr(spec_info, "custom_mask", None) is None + and self.prefill_backend == "fa2" + # Host-rebuilt layout only matches full attention (single wrapper); + # SWA/cross-attn keep the plain plan(). + and self.dispatch_reason is None + ): + # DFLASH target-verify replays are shape-static per + # (bs, draft_token_num): qo_indptr is a constant arange stride of + # num_tokens_per_req, and the batch carries seq_lens_cpu = + # prefix + draft_token_num (dspark_draft._run_forward / + # dspark_verify.run_non_compact / dflash_worker_v2 all add the + # verify window host-side), which equals the device kv length + # generate_attn_arg_prefill produces. The host-kwargs assembly in + # call_begin_forward therefore applies verbatim; installing the + # sync-free plan removes three blocking .to("cpu") reads per + # replay that otherwise stall the CPU behind the in-flight graph. + # EAGLE target-verify keeps the plain plan(): its spec input is + # not DFLASH_VERIFY, and this branch keys off the capture-time + # spec_info of these per-bs wrappers. + for w in self.prefill_cuda_graph_metadata[bs]: + w.begin_forward = partial(fast_prefill_plan, w) + # Refill the SWA write-target buffer from the live out_cache_loc before # replay (bound onto the metadata at capture below). if self.use_sliding_window_kv_pool and forward_batch.out_cache_loc is not None: @@ -2190,6 +2217,9 @@ class FlashInferIndicesUpdaterPrefill: assert ( num_tokens_per_req is not None and num_tokens_per_req > 0 ), f"fast_prefill_plan replay requires num_tokens_per_req > 0 (got {num_tokens_per_req})" + assert ( + use_custom_mask is None + ), "fast_prefill_plan does not support custom_mask; keep the plain plan()" seq_lens_cpu_i32 = seq_lens_cpu.to(torch.int32) qo_indptr_host = torch.arange( 0, diff --git a/python/sglang/srt/layers/attention/flashinfer_mla_backend.py b/python/sglang/srt/layers/attention/flashinfer_mla_backend.py index ee563059a..49cf04220 100644 --- a/python/sglang/srt/layers/attention/flashinfer_mla_backend.py +++ b/python/sglang/srt/layers/attention/flashinfer_mla_backend.py @@ -42,7 +42,7 @@ from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph impo is_in_tc_piecewise_cuda_graph, ) from sglang.srt.runtime_context import get_buffer -from sglang.srt.speculative.spec_info import SpecInput +from sglang.srt.speculative.spec_info import SpecInput, SpecInputType from sglang.srt.speculative.spec_utils import ( draft_kv_indices_buffer_width, draft_kv_indices_used_len, @@ -395,8 +395,12 @@ class FlashInferMLAAttnBackend(AttentionBackend): forward_mode=forward_mode, spec_info=spec_info, seq_lens_cpu=seq_lens_cpu, + in_capture=True, ) - if forward_mode.is_target_verify(): + if forward_mode.is_target_verify() and ( + spec_info is None + or spec_info.spec_input_type != SpecInputType.DFLASH_VERIFY + ): # use sync-free fast_mla_prefill_plan for replay prefill_wrapper.plan = partial(fast_mla_prefill_plan, prefill_wrapper) else: @@ -531,6 +535,7 @@ class FlashInferMLAAttnBackend(AttentionBackend): forward_mode: ForwardMode, spec_info: Optional[SpecInput], seq_lens_cpu: Optional[torch.Tensor], + in_capture: bool = False, ): """Shared capture+replay body for the cuda-graph init path. @@ -573,6 +578,15 @@ class FlashInferMLAAttnBackend(AttentionBackend): self.fast_plan_kv_indptr_cpu[1 : bs + 1] = torch.cumsum( self.fast_plan_kv_len_arr_cpu[:bs], dim=0 ) + fast_verify_plan_kwargs = self._build_fast_verify_plan_kwargs( + bs=bs, + spec_info=spec_info, + seq_lens_cpu=seq_lens_cpu, + in_capture=in_capture, + ) + use_generic_fast_plan = ( + spec_info.spec_input_type != SpecInputType.DFLASH_VERIFY + ) self.indices_updater_prefill.update( req_pool_indices[:bs], seq_lens[:bs], @@ -583,13 +597,83 @@ class FlashInferMLAAttnBackend(AttentionBackend): ], use_ragged=False, spec_info=spec_info, - qo_indptr_cpu=self.fast_plan_qo_indptr_cpu[: bs + 1], - kv_indptr_cpu=self.fast_plan_kv_indptr_cpu[: bs + 1], - kv_len_arr_cpu=self.fast_plan_kv_len_arr_cpu[:bs], + fast_verify_plan_kwargs=fast_verify_plan_kwargs, + qo_indptr_cpu=( + self.fast_plan_qo_indptr_cpu[: bs + 1] + if use_generic_fast_plan + else None + ), + kv_indptr_cpu=( + self.fast_plan_kv_indptr_cpu[: bs + 1] + if use_generic_fast_plan + else None + ), + kv_len_arr_cpu=( + self.fast_plan_kv_len_arr_cpu[:bs] + if use_generic_fast_plan + else None + ), ) else: raise ValueError(f"Invalid forward mode: {forward_mode=}") + def _build_fast_verify_plan_kwargs( + self, + *, + bs: int, + spec_info: Optional[SpecInput], + seq_lens_cpu: Optional[torch.Tensor], + in_capture: bool, + ) -> Optional[dict]: + """Host-known plan inputs for the sync-free TARGET_VERIFY fast plan. + + Upstream ``BatchMLAPagedAttentionWrapper.plan`` issues three blocking + ``.to("cpu")`` copies per call (qo_indptr / kv_indptr / kv_len_arr); on + the graph-replay hot path each of those drains the whole GPU queue and + stalls the scheduler CPU behind the in-flight draft graph. All three + arrays are host-derivable, so we feed ``fast_mla_decode_plan`` directly. + + Returns None when the slow (device-fed) plan must run instead: at + capture (the real plan() populates ``_cached_module`` and the wrapper's + cuda-graph buffers), for non-DFLASH spec inputs, for ragged/compact + verify layouts or custom masks, under DCP, or when seq_lens_cpu is + unavailable. + + DFLASH invariant this relies on: the verify ForwardBatch carries + seq_lens_cpu = prefix + draft_token_num (dspark_verify.run_non_compact + and dflash_worker_v2 both add the verify window host-side before + prepare_for_verify), which equals the device kv length that + generate_attn_arg_prefill produces (seq_lens + draft_token_num). The + reserved_seq_lens_cpu fallback (an upper bound, not the exact value) is + only reachable when seq_lens_cpu is resolved as None, and this fast + path requires flashinfer's needs_cpu_seq_lens=True resolve, so the + exact value is always the one seen here. + """ + if in_capture or seq_lens_cpu is None or spec_info is None: + return None + if spec_info.spec_input_type != SpecInputType.DFLASH_VERIFY: + return None + if ( + spec_info.ragged_verify_layout is not None + or spec_info.custom_mask is not None + ): + return None + if get_parallel().dcp_enabled: + return None + draft_token_num = int(spec_info.draft_token_num) + kv_len_arr_cpu = seq_lens_cpu[:bs].to(torch.int32) + kv_indptr_cpu = torch.zeros(bs + 1, dtype=torch.int32) + torch.cumsum(kv_len_arr_cpu, dim=0, out=kv_indptr_cpu[1:]) + qo_indptr_cpu = torch.arange( + 0, (bs + 1) * draft_token_num, draft_token_num, dtype=torch.int32 + ) + return { + "qo_indptr_cpu": qo_indptr_cpu, + "kv_indptr_cpu": kv_indptr_cpu, + "kv_len_arr_cpu": kv_len_arr_cpu, + "kv_indices_buf": self.cuda_graph_kv_indices, + } + def get_cuda_graph_seq_len_fill_value(self): return 1 @@ -921,6 +1005,7 @@ class FlashInferMLAIndicesUpdaterPrefill: use_ragged: bool, spec_info: Optional[SpecInput] = None, attn_dcp_metadata: Optional[DecodeContextParallelMetadata] = None, + fast_verify_plan_kwargs: Optional[dict] = None, qo_indptr_cpu: Optional[torch.Tensor] = None, kv_indptr_cpu: Optional[torch.Tensor] = None, kv_len_arr_cpu: Optional[torch.Tensor] = None, @@ -945,6 +1030,7 @@ class FlashInferMLAIndicesUpdaterPrefill: use_ragged, spec_info, attn_dcp_metadata=attn_dcp_metadata, + fast_verify_plan_kwargs=fast_verify_plan_kwargs, qo_indptr_cpu=qo_indptr_cpu, kv_indptr_cpu=kv_indptr_cpu, kv_len_arr_cpu=kv_len_arr_cpu, @@ -964,6 +1050,7 @@ class FlashInferMLAIndicesUpdaterPrefill: use_ragged: bool, spec_info: Optional[SpecInput] = None, attn_dcp_metadata: Optional[DecodeContextParallelMetadata] = None, + fast_verify_plan_kwargs: Optional[dict] = None, qo_indptr_cpu: Optional[torch.Tensor] = None, kv_indptr_cpu: Optional[torch.Tensor] = None, kv_len_arr_cpu: Optional[torch.Tensor] = None, @@ -998,6 +1085,16 @@ class FlashInferMLAIndicesUpdaterPrefill: qo_indptr[1 : bs + 1] = torch.cumsum(seq_lens - prefix_lens, dim=0) qo_indptr = qo_indptr[: bs + 1] custom_mask = None + elif fast_verify_plan_kwargs is not None: + kv_indices, kv_indptr, qo_indptr, custom_mask = ( + spec_info.generate_attn_arg_prefill( + req_pool_indices, + paged_kernel_lens, + paged_kernel_lens_sum, + self.req_to_token, + kv_indices_buf=fast_verify_plan_kwargs["kv_indices_buf"], + ) + ) else: assert isinstance(spec_info, SpecInput) # TODO: Support topk > 1 with custom mask @@ -1022,6 +1119,22 @@ class FlashInferMLAIndicesUpdaterPrefill: q_data_type=self.q_data_type, causal=True, ) + elif fast_verify_plan_kwargs is not None: + fast_mla_decode_plan( + wrapper_paged, + fast_verify_plan_kwargs["qo_indptr_cpu"], + fast_verify_plan_kwargs["kv_indptr_cpu"], + kv_indices, + fast_verify_plan_kwargs["kv_len_arr_cpu"], + self.num_local_heads, + self.kv_lora_rank, + self.qk_rope_head_dim, + 1, + True, + sm_scale, + self.q_data_type, + self.data_type, + ) else: # mla paged prefill if attn_dcp_metadata is not None: @@ -1102,6 +1215,8 @@ class FlashInferMLAMultiStepDraftBackend: ) self.max_context_len = self.attn_backends[0].max_context_len + self.attn_backend_list = self.attn_backends + self.forward_metadata = None # Cached variables for generate_draft_decode_kv_indices self.req_to_token_pool = model_runner.req_to_token_pool diff --git a/python/sglang/srt/layers/attention/hybrid_linear_attn_backend.py b/python/sglang/srt/layers/attention/hybrid_linear_attn_backend.py index f842df037..a0be363b9 100644 --- a/python/sglang/srt/layers/attention/hybrid_linear_attn_backend.py +++ b/python/sglang/srt/layers/attention/hybrid_linear_attn_backend.py @@ -14,7 +14,6 @@ from sglang.kernels.ops.mamba.mamba_state_scatter_triton import ( track_mamba_states_all_layers, track_mamba_states_if_needed, ) -from sglang.srt.configs.hybrid_arch import mamba2_config from sglang.srt.layers.attention.base_attn_backend import ( AttentionBackend, SharedReadEnds, @@ -53,6 +52,11 @@ class MambaAttnBackendBase(AttentionBackend): self.req_to_token_pool: HybridReqToTokenPool = model_runner.req_to_token_pool self.token_to_kv_pool = model_runner.token_to_kv_pool self.enable_unified_memory = model_runner.server_args.enable_unified_memory + # model_config must not be touched here: backend selection reads the + # linear_attn_backends stamp first, and that guard test constructs + # backends on runners without a real model_config. + self._model_runner = model_runner + self._mamba_chunk_size: Optional[int] = None # Fused replay-prep state-indices fast path (fused_replay_state_indices): # requires the static hybrid pool whose v2p translate is the identity — # the unified pool overrides translate_mamba_indices with an allocator @@ -80,6 +84,14 @@ class MambaAttnBackendBase(AttentionBackend): self.cached_cuda_graph_verify_query_start_loc: torch.Tensor = None self.conv_states_shape: tuple[int, int] = None + @property + def mamba_chunk_size(self) -> int: + if self._mamba_chunk_size is None: + self._mamba_chunk_size = getattr( + self._model_runner.model_config.hf_text_config, "mamba_chunk_size", 64 + ) + return self._mamba_chunk_size + def _translate_mamba_indices(self, mamba_indices: torch.Tensor) -> torch.Tensor: """Virtual->physical mamba slot-id translate (identity for the non-unified pool). Must run everywhere mamba ids feed the SSM/conv kernels or mamba-pool @@ -324,7 +336,7 @@ class MambaAttnBackendBase(AttentionBackend): """src/dst indices to track SSM states for prefix caching: aligned seqs cache last_recurrent_state, unaligned cache intermediate `h` at the last chunk boundary.""" - chunk_size = mamba_cache_chunk_size() + state_chunk_size = self.mamba_chunk_size # CPU to avoid kernel launches for the masking ops mamba_track_mask = forward_batch.mamba_track_mask.cpu() extend_seq_lens = forward_batch.extend_seq_lens.cpu() @@ -334,9 +346,9 @@ class MambaAttnBackendBase(AttentionBackend): prefix_lens = forward_batch.extend_prefix_lens.cpu() if isinstance(self, Mamba2AttnBackend): - num_h_states = extend_seq_lens // chunk_size + num_h_states = extend_seq_lens // state_chunk_size else: - num_h_states = (extend_seq_lens - 1) // chunk_size + 1 + num_h_states = (extend_seq_lens - 1) // state_chunk_size + 1 track_ssm_src_offset = torch.zeros_like(num_h_states) track_ssm_src_offset[1:] = torch.cumsum(num_h_states[:-1], dim=0) @@ -346,17 +358,16 @@ class MambaAttnBackendBase(AttentionBackend): offset_masked = track_ssm_src_offset[mamba_track_mask] dst_masked = mamba_track_indices[mamba_track_mask] - is_aligned = (lens_masked % chunk_size) == 0 + is_aligned = (lens_masked % state_chunk_size) == 0 # Aligned: last_recurrent_state from ssm_states. track_ssm_final_src = mamba_cache_indices[mamba_track_mask][is_aligned] track_ssm_final_dst = dst_masked[is_aligned] # Unaligned: intermediate state from h. - # TODO: handle chunk_size % page size != 0 not_aligned = ~is_aligned track_ssm_h_src = offset_masked[not_aligned] + ( - lens_masked[not_aligned] // chunk_size + lens_masked[not_aligned] // state_chunk_size ) track_ssm_h_dst = dst_masked[not_aligned] @@ -837,9 +848,6 @@ class Mamba2AttnBackend(MambaAttnBackendBase): def __init__(self, model_runner: ModelRunner): super().__init__(model_runner) - config = mamba2_config(model_runner.model_config) - assert config is not None - self.mamba_chunk_size = config.mamba_chunk_size self.conv_states_shape = ( model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape ) @@ -988,6 +996,10 @@ class HybridLinearAttnBackend(AttentionBackend): and self.linear_attn_backend.supports_ragged_verify_graph ) + @property + def kv_cache_dtype(self): + return self.full_attn_backend.kv_cache_dtype + def _is_full_attn( self, layer: Optional[RadixAttention], layer_id: Optional[int] = None ) -> bool: diff --git a/python/sglang/srt/layers/attention/linear/kda_backend.py b/python/sglang/srt/layers/attention/linear/kda_backend.py index 406d1c591..f2c13b49c 100644 --- a/python/sglang/srt/layers/attention/linear/kda_backend.py +++ b/python/sglang/srt/layers/attention/linear/kda_backend.py @@ -8,6 +8,7 @@ from sglang.kernels.ops.mamba.causal_conv1d_triton import ( causal_conv1d_fn, causal_conv1d_update, ) +from sglang.srt.environ import envs from sglang.srt.layers.attention.hybrid_linear_attn_backend import MambaAttnBackendBase from sglang.srt.layers.attention.linear.kernels.kda_triton import TritonKDAKernel from sglang.srt.layers.attention.linear.utils import ( @@ -47,6 +48,7 @@ class KDAKernelDispatcher: ): self.verify_backend = verify_backend triton_kernel = TritonKDAKernel() + self.triton_kernel = triton_kernel helion_kernel = None if decode_backend.is_helion() or prefill_backend.is_helion(): if not is_cuda(): @@ -249,7 +251,12 @@ class KDAKernelDispatcher: query_start_loc: torch.Tensor, **kwargs, ) -> torch.Tensor: - return self.decode_kernel.decode( + kernel = self.decode_kernel + if kwargs.get("lower_bound") is not None and not getattr( + kernel, "supports_safe_gate", True + ): + kernel = self.triton_kernel + return kernel.decode( q, k, v, @@ -318,8 +325,13 @@ class KDAKernelDispatcher: cache_indices: torch.Tensor, query_start_loc: torch.Tensor, **kwargs, - ) -> torch.Tensor: - return self.extend_kernel.extend( + ) -> tuple[torch.Tensor, torch.Tensor | None]: + kernel = self.extend_kernel + if kwargs.get("lower_bound") is not None and not getattr( + kernel, "supports_safe_gate", True + ): + kernel = self.triton_kernel + return kernel.extend( q, k, v, @@ -402,6 +414,18 @@ class KDAAttnBackend(MambaAttnBackendBase): f"{decode_backend} only picks the fallback kernel for shapes " "the fused kernel does not cover." ) + self._fused_chain_verify_fn = None + if ( + envs.SGLANG_OPT_FUSED_KDA_VERIFY.get() + and verify_backend.is_triton() + and self.kernel_dispatcher.verify_kernel.supports_fused_chain_verify + ): + from sglang.kernels.ops.attention.fla.fused_kda_conv_recurrent_verify import ( + fused_kda_conv_gating_verify, + ) + + self._fused_chain_verify_fn = fused_kda_conv_gating_verify + rank0_log("KDA fused chain-verify kernel enabled (topk==1 path).") # Per-request row index into the speculative `intermediate_ssm` scratch, # used by the MTP / target_verify path (mirrors GDNAttnBackend). Sized # past the pool for attn_tp-padded warmup/MLP-sync batches (see helper). @@ -785,6 +809,58 @@ class KDAAttnBackend(MambaAttnBackendBase): ) if ragged_layout is None: batch_size = seq_len // draft_token_num + conv_state_indices = cache_indices[:batch_size] + # Fused chain-verify fast path: one kernel replaces the transpose-copy + + # conv1d + transpose-copy + recurrence sequence. Chain (topk==1) only -- + # retrieve_* are None there; the tree path and any unsupported shape keep + # the reference kernels. + if self._can_run_fused_chain_verify( + layer=layer, + mixed_qkv=mixed_qkv, + a=a, + b=b, + draft_token_num=draft_token_num, + conv_states=conv_states, + ssm_states=ssm_states, + intermediate_state_cache=intermediate_state_cache, + intermediate_conv_window_cache=intermediate_conv_window_cache, + cache_indices=conv_state_indices, + intermediate_state_indices=intermediate_state_indices[:batch_size], + retrieve_next_token=retrieve_next_token, + retrieve_next_sibling=retrieve_next_sibling, + retrieve_parent_token=retrieve_parent_token, + replayssm_rawv=replayssm_rawv, + ): + return self._fused_chain_verify_fn( + mixed_qkv=mixed_qkv, + conv_weight=layer.conv_weights, + conv_bias=layer.bias, + # Same [.., dim, width] view the reference causal_conv1d_update + # call below takes: upstream stores the persistent conv state + # width-major, and the kernel asserts the dim axis is + # contiguous. (intermediate_conv_window is transposed on both + # the fork and upstream, so it needs no extra adjustment.) + conv_state=conv_states.transpose(-1, -2), + conv_state_indices=conv_state_indices, + intermediate_conv_window=( + intermediate_conv_window_cache.transpose(-1, -2) + ), + intermediate_state_indices=intermediate_state_indices[:batch_size], + a=a, + b=b, + A_log=layer.A_log, + dt_bias=layer.dt_bias, + ssm_states=ssm_states, + cache_indices=conv_state_indices, + intermediate_states_buffer=intermediate_state_cache, + scale=layer.head_k_dim**-0.5, + T=draft_token_num, + num_q_heads=layer.num_q_heads, + num_v_heads=layer.num_v_heads, + head_k_dim=layer.head_k_dim, + head_v_dim=layer.head_v_dim, + lower_bound=layer.lower_bound, + ) dense_token_indices = None mixed_qkv_dense = mixed_qkv.view(batch_size, draft_token_num, -1) else: @@ -884,6 +960,147 @@ class KDAAttnBackend(MambaAttnBackendBase): core_attn_out = torch.where(covered.view(1, -1, 1, 1), core_attn_out, 0.0) return core_attn_out + def _can_run_fused_chain_verify( + self, + *, + layer: RadixLinearAttention, + mixed_qkv: torch.Tensor, + a: torch.Tensor, + b: torch.Tensor, + draft_token_num: int, + conv_states: torch.Tensor, + ssm_states: torch.Tensor, + intermediate_state_cache: Optional[torch.Tensor], + intermediate_conv_window_cache: torch.Tensor, + cache_indices: torch.Tensor, + intermediate_state_indices: torch.Tensor, + retrieve_next_token: Optional[torch.Tensor], + retrieve_next_sibling: Optional[torch.Tensor], + retrieve_parent_token: Optional[torch.Tensor], + replayssm_rawv: Optional[torch.Tensor], + ) -> bool: + if self._fused_chain_verify_fn is None or not mixed_qkv.is_cuda: + return False + if replayssm_rawv is not None or any( + value is not None + for value in ( + retrieve_next_token, + retrieve_next_sibling, + retrieve_parent_token, + ) + ): + return False + if draft_token_num < 3 or mixed_qkv.shape[0] % draft_token_num != 0: + return False + if ( + not isinstance(layer.conv_weights, torch.Tensor) + or layer.conv_weights.ndim != 2 + or layer.conv_weights.shape[1] != 4 + or layer.conv_weights.stride(1) != 1 + ): + return False + if ( + layer.num_q_heads != layer.num_k_heads + or layer.head_q_dim != layer.head_k_dim + or layer.head_k_dim & (layer.head_k_dim - 1) + ): + return False + + seq_len, dim = mixed_qkv.shape + batch_size = seq_len // draft_token_num + expected_dim = ( + 2 * layer.num_q_heads * layer.head_k_dim + + layer.num_v_heads * layer.head_v_dim + ) + if dim != expected_dim or layer.conv_weights.shape[0] != dim: + return False + if layer.bias is not None and ( + not isinstance(layer.bias, torch.Tensor) + or layer.bias.ndim != 1 + or layer.bias.shape[0] != dim + ): + return False + if not isinstance(layer.A_log, torch.Tensor) or not isinstance( + layer.dt_bias, torch.Tensor + ): + return False + if ( + a.ndim == 0 + or b.ndim == 0 + or mixed_qkv.dtype not in (torch.bfloat16, torch.float16) + or a.dtype != mixed_qkv.dtype + or b.dtype != mixed_qkv.dtype + or conv_states.dtype != mixed_qkv.dtype + or intermediate_conv_window_cache.dtype != mixed_qkv.dtype + or layer.conv_weights.dtype + not in (torch.bfloat16, torch.float16, torch.float32) + or (layer.bias is not None and layer.bias.dtype != layer.conv_weights.dtype) + ): + return False + if ( + layer.A_log.dtype != torch.float32 + or layer.dt_bias.dtype != torch.float32 + or ssm_states.dtype != torch.float32 + or intermediate_state_cache is None + or intermediate_state_cache.dtype != torch.float32 + ): + return False + if ( + mixed_qkv.stride(-1) != 1 + or a.stride(-1) != 1 + or b.stride(-1) != 1 + or not conv_states.is_contiguous() + or not ssm_states.is_contiguous() + or not intermediate_state_cache.is_contiguous() + ): + return False + if ( + a.numel() != seq_len * layer.num_v_heads * layer.head_k_dim + or b.numel() != seq_len * layer.num_v_heads + or layer.A_log.numel() != layer.num_v_heads + or layer.dt_bias.numel() != layer.num_v_heads * layer.head_k_dim + ): + return False + if ( + conv_states.ndim != 3 + or tuple(conv_states.shape[-2:]) != (3, dim) + or intermediate_conv_window_cache.ndim != 4 + or tuple(intermediate_conv_window_cache.shape[-2:]) != (3, dim) + or ssm_states.ndim != 4 + or tuple(ssm_states.shape[-3:]) + != (layer.num_v_heads, layer.head_v_dim, layer.head_k_dim) + or intermediate_state_cache.ndim != 5 + or intermediate_state_cache.shape[1] < draft_token_num + or tuple(intermediate_state_cache.shape[-3:]) + != (layer.num_v_heads, layer.head_v_dim, layer.head_k_dim) + ): + return False + if ( + cache_indices.ndim != 1 + or intermediate_state_indices.ndim != 1 + or cache_indices.numel() != batch_size + or intermediate_state_indices.numel() != batch_size + or cache_indices.dtype != torch.int32 + or intermediate_state_indices.dtype != torch.int32 + ): + return False + tensors = ( + layer.conv_weights, + layer.A_log, + layer.dt_bias, + a, + b, + conv_states, + ssm_states, + intermediate_state_cache, + intermediate_conv_window_cache, + cache_indices, + intermediate_state_indices, + ) + if layer.bias is not None: + tensors += (layer.bias,) + return all(tensor.device == mixed_qkv.device for tensor in tensors) + def _can_run_dspark_cutedsl_mtp( self, *, diff --git a/python/sglang/srt/layers/attention/linear/kernels/kda_cutedsl.py b/python/sglang/srt/layers/attention/linear/kernels/kda_cutedsl.py index e2323b6ae..b06e0c5a2 100644 --- a/python/sglang/srt/layers/attention/linear/kernels/kda_cutedsl.py +++ b/python/sglang/srt/layers/attention/linear/kernels/kda_cutedsl.py @@ -30,6 +30,8 @@ class CuteDSLKDAKernel(LinearAttnKernelBase): query :attr:`supports_prefill` and fall back to Triton. """ + supports_safe_gate: bool = False + def __init__(self): self.supports_prefill = _is_blackwell() self._extend_fn: Optional[callable] = None @@ -161,8 +163,9 @@ class CuteDSLKDAKernel(LinearAttnKernelBase): h0_indices=ssm_cache_indices, ) - # Match chunk_kda's output layout [1, T, HV, V]. - return o.unsqueeze(0) + # CuTeDSL does not emit intermediate chunk states; pairing with None + # keeps the upstream extra-buffer radix track contract. + return o.unsqueeze(0), None def target_verify(self, *args, **kwargs): raise NotImplementedError("CuteDSLKDAKernel does not support target_verify") diff --git a/python/sglang/srt/layers/attention/linear/kernels/kda_flashkda.py b/python/sglang/srt/layers/attention/linear/kernels/kda_flashkda.py index 8dd3b273f..9872cf547 100644 --- a/python/sglang/srt/layers/attention/linear/kernels/kda_flashkda.py +++ b/python/sglang/srt/layers/attention/linear/kernels/kda_flashkda.py @@ -139,18 +139,21 @@ class FlashKDAKernel(LinearAttnKernelBase): return_intermediate_states=return_intermediate_states, ) - return self._flashkda_extend( - q, - k, - v, - g, - beta, - ssm_states=ssm_states, - cache_indices=cache_indices, - query_start_loc=query_start_loc, - A_log=A_log, - dt_bias=dt_bias, - lower_bound=lower_bound, + return ( + self._flashkda_extend( + q, + k, + v, + g, + beta, + ssm_states=ssm_states, + cache_indices=cache_indices, + query_start_loc=query_start_loc, + A_log=A_log, + dt_bias=dt_bias, + lower_bound=lower_bound, + ), + None, ) @staticmethod diff --git a/python/sglang/srt/layers/attention/linear/kernels/kda_triton.py b/python/sglang/srt/layers/attention/linear/kernels/kda_triton.py index 85774ec36..400a5303c 100644 --- a/python/sglang/srt/layers/attention/linear/kernels/kda_triton.py +++ b/python/sglang/srt/layers/attention/linear/kernels/kda_triton.py @@ -27,6 +27,7 @@ class TritonKDAKernel(LinearAttnKernelBase): # non-packed Triton decode() path (fused_sigmoid_gating_delta_rule_update), # the same fallback CPU/NPU use. Batched decode is handled via query_start_loc. supports_packed_decode: bool = not is_cpu() and not is_npu() and not is_xpu() + supports_fused_chain_verify: bool = not is_cpu() and not is_npu() def packed_decode( self, @@ -66,7 +67,8 @@ class TritonKDAKernel(LinearAttnKernelBase): replayssm_write_pos = kwargs.get("replayssm_write_pos") replayssm_force_flush = kwargs.get("replayssm_force_flush") if ( - replayssm_d is not None + lower_bound is None + and replayssm_d is not None and replayssm_k is not None and replayssm_g is not None and replayssm_write_pos is not None @@ -229,7 +231,7 @@ class TritonKDAKernel(LinearAttnKernelBase): lower_bound: Optional[float] = None, return_intermediate_states: bool = False, **kwargs, - ) -> torch.Tensor: + ) -> tuple[torch.Tensor, torch.Tensor | None]: return chunk_kda( q=q, k=k, diff --git a/python/sglang/srt/layers/attention/linear/kernels/kernel_backend.py b/python/sglang/srt/layers/attention/linear/kernels/kernel_backend.py index 2a840ec35..539a9fd4a 100644 --- a/python/sglang/srt/layers/attention/linear/kernels/kernel_backend.py +++ b/python/sglang/srt/layers/attention/linear/kernels/kernel_backend.py @@ -11,6 +11,7 @@ class LinearAttnKernelBase(ABC): """ uses_state_checkpoints: bool = False + supports_fused_chain_verify: bool = False @abstractmethod def decode( diff --git a/python/sglang/srt/layers/attention/trtllm_mla_backend.py b/python/sglang/srt/layers/attention/trtllm_mla_backend.py index 9dee901dd..a64a60e4a 100755 --- a/python/sglang/srt/layers/attention/trtllm_mla_backend.py +++ b/python/sglang/srt/layers/attention/trtllm_mla_backend.py @@ -195,6 +195,9 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend): # [bs, draft_token_num] layout in forward_extend; metadata stays uniform. supports_ragged_verify_graph: bool = True + def update_verify_buffers_to_fill_after_draft(self, spec_info, cuda_graph_bs): + pass + def __init__( self, model_runner: ModelRunner, diff --git a/python/sglang/srt/layers/linear.py b/python/sglang/srt/layers/linear.py index c91458423..c900f533f 100644 --- a/python/sglang/srt/layers/linear.py +++ b/python/sglang/srt/layers/linear.py @@ -1693,6 +1693,10 @@ class MergedColumnParallelRepeatedLinear(LinearBase): skip_bias_add: If true, skip adding bias but instead return it. params_dtype: Data type for the parameters. quant_config: Quantization configure. + tp_rank: Rank to shard the column-parallel part on. Defaults to the + global TP rank; pass the attention-TP rank to shard on attn-TP + instead (see KimiDeltaAttention's shard_on_attn_tp). + tp_size: World size matching ``tp_rank``. Defaults to global TP size. """ def __init__( @@ -1704,6 +1708,8 @@ class MergedColumnParallelRepeatedLinear(LinearBase): params_dtype: Optional[torch.dtype] = None, quant_config: Optional[QuantizationConfig] = None, prefix: str = "", + tp_rank: Optional[int] = None, + tp_size: Optional[int] = None, ): output_size = sum(column_output_sizes) + sum(repeated_output_sizes) super().__init__( @@ -1715,8 +1721,11 @@ class MergedColumnParallelRepeatedLinear(LinearBase): prefix=prefix, ) self.num_column_parallel = len(column_output_sizes) - self.tp_rank = get_parallel().tp_rank - self.tp_size = get_parallel().tp_size + if tp_rank is None: + tp_rank = get_parallel().tp_rank + if tp_size is None: + tp_size = get_parallel().tp_size + self.tp_rank, self.tp_size = tp_rank, tp_size self.output_partition_sizes = [ divide(x, self.tp_size) for x in column_output_sizes @@ -1761,14 +1770,27 @@ class ColumnParallelBatchedLinear(nn.Module): input_size: input dimension of the linear layer. output_size: output dimension of the linear layer. dtype: Data type for the parameters. + tp_rank: Rank to shard the output dimension on. Defaults to the global + TP rank; pass the attention-TP rank to shard on attn-TP instead + (see KimiDeltaAttention's shard_on_attn_tp). + tp_size: World size matching ``tp_rank``. Defaults to global TP size. """ def __init__( - self, batch: int, input_size: int, output_size: int, dtype: torch.dtype + self, + batch: int, + input_size: int, + output_size: int, + dtype: torch.dtype, + tp_rank: Optional[int] = None, + tp_size: Optional[int] = None, ): super().__init__() - self.tp_rank = get_parallel().tp_rank - self.tp_size = get_parallel().tp_size + if tp_rank is None: + tp_rank = get_parallel().tp_rank + if tp_size is None: + tp_size = get_parallel().tp_size + self.tp_rank, self.tp_size = tp_rank, tp_size self.weight = nn.Parameter( torch.empty(batch, output_size // self.tp_size, input_size, dtype=dtype), requires_grad=False, diff --git a/python/sglang/srt/layers/moe/fused_moe_triton/layer.py b/python/sglang/srt/layers/moe/fused_moe_triton/layer.py index bd799ef22..97240babb 100644 --- a/python/sglang/srt/layers/moe/fused_moe_triton/layer.py +++ b/python/sglang/srt/layers/moe/fused_moe_triton/layer.py @@ -99,6 +99,29 @@ _use_aiter = get_bool_env_var("SGLANG_USE_AITER") and _is_hip _deferred_finalize_info_logged = False +def _fuses_routed_scaling_factor_in_topk(quant_method) -> bool: + return ( + getattr(quant_method, "fuse_routed_scaling_factor_in_topk", False) + or ( + isinstance(quant_method, ModelOptNvFp4FusedMoEMethod) + and not getattr( + quant_method, "_moe_runner_backend", get_moe_runner_backend() + ).is_marlin() + ) + or ( + isinstance(quant_method, Fp8MoEMethod) + and ( + get_moe_runner_backend().is_cutlass() + or get_moe_runner_backend().is_flashinfer_trtllm_routed() + ) + ) + or ( + isinstance(quant_method, UnquantizedFusedMoEMethod) + and get_moe_runner_backend().is_flashinfer_trtllm_routed() + ) + ) + + def _copy_weight_view_before_h2d(loaded_weight: torch.Tensor) -> torch.Tensor: """Copy a CPU tensor view into independent contiguous storage.""" if loaded_weight.device.type != "cpu": @@ -441,23 +464,7 @@ class FusedMoE(torch.nn.Module): self.moe_runner_config.inplace = False self.should_fuse_routed_scaling_factor_in_topk = ( - ( - isinstance(self.quant_method, ModelOptNvFp4FusedMoEMethod) - and not getattr( - self.quant_method, "_moe_runner_backend", get_moe_runner_backend() - ).is_marlin() - ) - or ( - isinstance(self.quant_method, Fp8MoEMethod) - and ( - get_moe_runner_backend().is_cutlass() - or get_moe_runner_backend().is_flashinfer_trtllm_routed() - ) - ) - or ( - isinstance(self.quant_method, UnquantizedFusedMoEMethod) - and get_moe_runner_backend().is_flashinfer_trtllm_routed() - ) + _fuses_routed_scaling_factor_in_topk(self.quant_method) ) self.routing_method_type = routing_method_type diff --git a/python/sglang/srt/layers/moe/moe_runner/flashinfer_cutlass.py b/python/sglang/srt/layers/moe/moe_runner/flashinfer_cutlass.py index 6a592fb5e..10fff8245 100644 --- a/python/sglang/srt/layers/moe/moe_runner/flashinfer_cutlass.py +++ b/python/sglang/srt/layers/moe/moe_runner/flashinfer_cutlass.py @@ -89,6 +89,10 @@ class FlashInferCutlassMxfp4MoeQuantInfo(MoeQuantInfo): swiglu_beta: Optional[torch.Tensor] = None swiglu_limit: Optional[torch.Tensor] = None + # Bailing clamps after SiLU, which the kernel only implements in its + # SwigluStep variant. + use_swiglu_step: bool = False + # TP/EP topology (forwarded to the FlashInfer kernel) moe_tp_size: int = 1 moe_tp_rank: int = 0 @@ -386,7 +390,11 @@ def fused_experts_none_to_flashinfer_mxfp4( ep_rank=quant_info.moe_ep_rank, use_w4_group_scaling=not use_mxfp8_act_scaling, use_mxfp8_act_scaling=use_mxfp8_act_scaling, - activation_type=ActivationType.Swiglu, + activation_type=( + ActivationType.SwigluStep + if quant_info.use_swiglu_step + else ActivationType.Swiglu + ), tune_max_num_tokens=next_power_of_2(x.shape[0]), output=out, use_fused_finalize=envs.SGLANG_FLASHINFER_MOE_FUSED_FINALIZE.get(), diff --git a/python/sglang/srt/layers/moe/moe_runner/triton_utils/fused_moe.py b/python/sglang/srt/layers/moe/moe_runner/triton_utils/fused_moe.py index f503fd1be..8cc0f4ff7 100644 --- a/python/sglang/srt/layers/moe/moe_runner/triton_utils/fused_moe.py +++ b/python/sglang/srt/layers/moe/moe_runner/triton_utils/fused_moe.py @@ -14,6 +14,7 @@ import torch import torch.nn.functional as F import triton.language as tl +from sglang.kernels.jit.utils import is_arch_support_pdl from sglang.kernels.ops.moe.fused_moe_triton_kernels import ( act_and_mul_triton, invoke_fused_moe_kernel, @@ -96,6 +97,32 @@ padding_size = get_moe_padding_size(_use_aiter) logger = logging.getLogger(__name__) +def _validate_fused_swiglu_interleaved( + *, + activation: str, + is_gated: bool, + has_gemm1_modifiers: bool, + has_bias: bool, + is_quantized: bool, + apply_router_weight_on_input: bool, + has_hooks: bool, + dtype: torch.dtype, +) -> None: + if not ( + activation == "silu" + and is_gated + and not has_gemm1_modifiers + and not has_bias + and not is_quantized + and not apply_router_weight_on_input + and not has_hooks + and dtype == torch.bfloat16 + ): + raise ValueError( + "fuse_swiglu_interleaved set on an incompatible fused_moe call" + ) + + def _use_moe_sum_reduce_torch_compile(num_tokens: int) -> bool: return num_tokens <= 32 and not is_batch_invariant_mode_enabled() @@ -566,27 +593,20 @@ def _fused_moe_kernel_sequence( ) if fuse_swiglu_interleaved: - # W13 rows are physically interleaved (permuted once at load), so the - # activation MUST come from the fused up-GEMM epilogue -- a standalone - # activation kernel would read them as halves and be silently wrong. - # Fail loudly on an incompatible call rather than produce garbage. - assert ( - activation == "silu" - and is_gated - and gemm1_alpha is None - and gemm1_limit is None - and swiglu_limit is None - and b1 is None - and not (use_fp8_w8a8 or use_int8_w8a8 or use_int8_w8a16 or use_int4_w4a16) - and not apply_router_weight_on_input - # LoRA injects its gate_up delta into the full-width pre-activation - # buffer that this path eliminates. - and hooks is None - and hidden_states.dtype == torch.bfloat16 - ), "fuse_swiglu_interleaved set on an incompatible fused_moe call" - # The epilogue applies silu(gate) * up in-register and writes the - # half-width activation directly, so intermediate_cache1 and the - # standalone activation launch are skipped entirely. + _validate_fused_swiglu_interleaved( + activation=activation, + is_gated=is_gated, + has_gemm1_modifiers=any( + value is not None for value in (gemm1_alpha, gemm1_limit, swiglu_limit) + ), + has_bias=b1 is not None, + is_quantized=any( + (use_fp8_w8a8, use_int8_w8a8, use_int8_w8a16, use_int4_w4a16) + ), + apply_router_weight_on_input=apply_router_weight_on_input, + has_hooks=hooks is not None, + dtype=hidden_states.dtype, + ) intermediate_cache1 = None gemm1_out = intermediate_cache2 = torch.empty( (total_tokens, N // 2), @@ -869,11 +889,18 @@ def _fused_moe_kernel_sequence( else: # According to micro benchmark results, torch.compile can get better performance for small token. if _use_moe_sum_reduce_torch_compile(num_tokens): - moe_sum_reduce_torch_compile( - intermediate_cache3.view(*intermediate_cache3.shape), - out_hidden_states, - routed_scaling_factor, - ) + if is_arch_support_pdl(): + moe_sum_reduce_triton( + intermediate_cache3.view(*intermediate_cache3.shape), + out_hidden_states, + routed_scaling_factor, + ) + else: + moe_sum_reduce_torch_compile( + intermediate_cache3.view(*intermediate_cache3.shape), + out_hidden_states, + routed_scaling_factor, + ) else: moe_sum_reduce( intermediate_cache3.view(*intermediate_cache3.shape), @@ -972,7 +999,7 @@ def fused_experts_impl( else: assert ( hidden_states.shape[1] == w1.shape[2] - padded_size - ), f"Hidden size mismatch" + ), "Hidden size mismatch" assert topk_weights.shape == topk_ids.shape, "topk shape mismatch" assert hidden_states.is_contiguous(), "Hidden_states must be contiguous" assert w1.is_contiguous(), "Expert weights1 must be contiguous" diff --git a/python/sglang/srt/layers/moe/topk.py b/python/sglang/srt/layers/moe/topk.py index bc8f14f52..8bfbfcd8f 100644 --- a/python/sglang/srt/layers/moe/topk.py +++ b/python/sglang/srt/layers/moe/topk.py @@ -395,7 +395,9 @@ class TopK(BaseFusedOp): --top_k: The all number of top experts selected per token, including the fused shared expert(s). --num_fused_shared_experts: num of shared experts, can be activate both in TP or EP mode. --routed_scaling_factor: the scaling factor for routed experts in topk_weights. - --fused_shared_experts_scaling_factor: scaling factor for fused shared experts on AMD-platform. + --fused_shared_experts_scaling_factor: scaling factor applied to the fused shared experts' + topk weight (models pass 1/ep_size under standard EP, where the per-rank shared-expert + outputs are all-reduced). """ def __init__( @@ -439,8 +441,8 @@ class TopK(BaseFusedOp): num_fused_shared_experts = 0 output_format = TopKOutputFormat.STANDARD - # flashinfer_mxfp4 backend only: True -> STANDARD (Mxfp4FlashinferTrtllmMoEMethod - # consumes), False -> BYPASSED (flashinfer's own mxfp4 kernel). No-op otherwise. + # Under the flashinfer_mxfp4 backend, fp4-expert ckpts take STANDARD + # (consumes topk_ids/weights); otherwise BYPASSED. No-op on other backends. self.is_fp4_experts = is_fp4_experts self.topk_config = TopKConfig( top_k=top_k, @@ -2155,6 +2157,14 @@ def _post_process_topk_ids( num_physical_routed_experts, topk_config, ) + elif ( + num_fused_shared_experts > 0 and fused_shared_experts_scaling_factor is not None + ): + # Standard EP all-reduces the per-rank shared-expert outputs; without the + # supplied 1/ep_size factor the shared contribution is summed ep_size times. + topk_weights[ + :, -num_fused_shared_experts: + ] *= fused_shared_experts_scaling_factor if _is_hip and not _skip_hip_pad_mask: # Shared-expert append/remap can introduce non-zero weights after the diff --git a/python/sglang/srt/layers/quantization/compressed_tensors/compressed_tensors.py b/python/sglang/srt/layers/quantization/compressed_tensors/compressed_tensors.py index 9b1aa4825..6cbae1622 100644 --- a/python/sglang/srt/layers/quantization/compressed_tensors/compressed_tensors.py +++ b/python/sglang/srt/layers/quantization/compressed_tensors/compressed_tensors.py @@ -69,7 +69,7 @@ from sglang.srt.layers.quantization.unquant import ( UnquantizedFusedMoEMethod, UnquantizedLinearMethod, ) -from sglang.srt.utils import is_cuda, is_hip, is_npu, is_xpu +from sglang.srt.utils import is_cuda, is_hip, is_npu, is_sm100_supported, is_xpu _is_cuda = is_cuda() _is_npu = is_npu() @@ -609,6 +609,16 @@ class CompressedTensorsConfig(QuantizationConfig): # checkpoints carry a weight zero-point. return is_channel_group and input_quant_none and is_static + def _is_wna16_triton_moe_supported(self, weight_quant: BaseModel) -> bool: + return ( + weight_quant.num_bits == 4 + and weight_quant.type == QuantizationType.INT + and weight_quant.strategy == QuantizationStrategy.GROUP.value + and weight_quant.group_size in (32, 128) + and weight_quant.symmetric + and not weight_quant.actorder + ) + def _is_mxint4a16(self, weight_quant: BaseModel, input_quant: BaseModel) -> bool: input_quant_none = input_quant is None is_symmetric = weight_quant.symmetric @@ -825,10 +835,26 @@ class CompressedTensorsConfig(QuantizationConfig): ) else: moe_backend = get_moe_runner_backend() - if moe_backend.is_triton(): + triton_supported = self._is_wna16_triton_moe_supported(weight_quant) + use_blackwell_triton = ( + moe_backend.is_auto() + and is_sm100_supported() + and triton_supported + ) + if moe_backend.is_triton() and not triton_supported: + raise ValueError( + "The Triton WNA16 MoE backend only supports symmetric " + "INT4 group quantization with group_size=32 or 128 and no " + "actorder." + ) + if moe_backend.is_triton() or use_blackwell_triton: + reason = ( + "SM100/SM103 auto default" + if use_blackwell_triton + else "moe_runner_backend=triton" + ) logger.info_once( - "Using CompressedTensorsWNA16TritonMoE " - "(moe_runner_backend=triton)" + f"Using CompressedTensorsWNA16TritonMoE ({reason})" ) return CompressedTensorsWNA16TritonMoE( self, weight_quant=weight_quant @@ -854,7 +880,7 @@ class CompressedTensorsConfig(QuantizationConfig): return NPUCompressedTensorsW8A8Int8DynamicMoE(weight_quant, input_quant) else: raise NotImplementedError( - f"The W8A8Int8 Fused MoE scheme is implemented only for NPU for now." + "The W8A8Int8 Fused MoE scheme is implemented only for NPU for now." ) elif self._is_wint4afp8(weight_quant, input_quant): # On NPU prefer the dedicated NPU W4A8Int8 path when activations are INT8. @@ -869,7 +895,7 @@ class CompressedTensorsConfig(QuantizationConfig): return NPUCompressedTensorsW4A8Int8DynamicMoE(self) else: raise NotImplementedError( - f"The W4A8Int8 Fused MoE scheme is implemented only for NPU for now." + "The W4A8Int8 Fused MoE scheme is implemented only for NPU for now." ) else: raise RuntimeError( @@ -1156,7 +1182,6 @@ class CompressedTensorsKVCacheMethod(BaseKVCacheMethod): class CompressedTensorsLinearMethod(LinearMethodBase): - def __init__(self, quantization_config: CompressedTensorsConfig): self.quantization_config = quantization_config self.quant_config = quantization_config @@ -1210,7 +1235,6 @@ class CompressedTensorsLinearMethod(LinearMethodBase): class CompressedTensorsFusedMoEMethod(FusedMoEMethodBase): - def __init__(self, quantization_config: CompressedTensorsConfig): self.quantization_config = quantization_config self.quant_config = quantization_config diff --git a/python/sglang/srt/layers/quantization/compressed_tensors/schemes/compressed_tensors_wNa16_moe.py b/python/sglang/srt/layers/quantization/compressed_tensors/schemes/compressed_tensors_wNa16_moe.py index cbdfe1144..c06834bf9 100644 --- a/python/sglang/srt/layers/quantization/compressed_tensors/schemes/compressed_tensors_wNa16_moe.py +++ b/python/sglang/srt/layers/quantization/compressed_tensors/schemes/compressed_tensors_wNa16_moe.py @@ -498,7 +498,7 @@ class CompressedTensorsWNA16MoE(CompressedTensorsMoEScheme): class CompressedTensorsWNA16TritonMoE(CompressedTensorsWNA16MoE): - """ROCm/HIP-compatible W4A16 MoE method using Triton kernels instead of Marlin. + """W4A16 MoE method using Triton kernels instead of Marlin. Inherits weight creation from CompressedTensorsWNA16MoE but converts weights to the uint8-packed format expected by the Triton fused MoE kernel diff --git a/python/sglang/srt/layers/quantization/mxfp4_flashinfer_cutlass_moe.py b/python/sglang/srt/layers/quantization/mxfp4_flashinfer_cutlass_moe.py index b79cb7cb5..d2e4efab2 100644 --- a/python/sglang/srt/layers/quantization/mxfp4_flashinfer_cutlass_moe.py +++ b/python/sglang/srt/layers/quantization/mxfp4_flashinfer_cutlass_moe.py @@ -32,6 +32,8 @@ _GROUP_SIZE = 32 class Mxfp4FlashinferCutlassMoEMethod: """FlashInfer MXFP4 MoE: W4A16 on SM90 and W4A8 on SM120.""" + fuse_routed_scaling_factor_in_topk = True + def __init__(self, fp8_method, prefix: str): if not is_flashinfer_available(): raise RuntimeError("Mxfp4FlashinferCutlassMoEMethod requires FlashInfer.") @@ -39,6 +41,7 @@ class Mxfp4FlashinferCutlassMoEMethod: self._fp8 = fp8_method self.prefix = prefix self._swiglu_limit_tensor: torch.Tensor | None = None + self._use_swiglu_step = False self._mxfp4_weight_global_scale_tensor: torch.Tensor | None = None @property @@ -89,10 +92,20 @@ class Mxfp4FlashinferCutlassMoEMethod: ) # FlashInfer defaults alpha/beta to 1/0, so DSv4 only supplies its clamp. + # Bailing clamps after SiLU (gemm1_clamp_limit), which the kernel only + # implements in its SwigluStep variant. swiglu_limit = getattr(moe_runner_config, "swiglu_limit", None) - if swiglu_limit is not None: + gemm1_clamp_limit = getattr(moe_runner_config, "gemm1_clamp_limit", None) + self._use_swiglu_step = ( + gemm1_clamp_limit is not None + and getattr(moe_runner_config, "gemm1_alpha", None) is None + ) + clamp_limit = ( + gemm1_clamp_limit if gemm1_clamp_limit is not None else swiglu_limit + ) + if clamp_limit is not None: self._swiglu_limit_tensor = torch.full( - (E,), float(swiglu_limit), dtype=torch.float32, device=device + (E,), float(clamp_limit), dtype=torch.float32, device=device ) else: self._swiglu_limit_tensor = None @@ -190,6 +203,7 @@ class Mxfp4FlashinferCutlassMoEMethod: swiglu_alpha=None, swiglu_beta=None, swiglu_limit=self._swiglu_limit_tensor, + use_swiglu_step=self._use_swiglu_step, moe_tp_size=layer.moe_tp_size, moe_tp_rank=layer.moe_tp_rank, moe_ep_size=layer.moe_ep_size, diff --git a/python/sglang/srt/layers/quantization/mxfp4_flashinfer_trtllm_moe.py b/python/sglang/srt/layers/quantization/mxfp4_flashinfer_trtllm_moe.py index 9b7c45c31..2ed4bf0c9 100644 --- a/python/sglang/srt/layers/quantization/mxfp4_flashinfer_trtllm_moe.py +++ b/python/sglang/srt/layers/quantization/mxfp4_flashinfer_trtllm_moe.py @@ -46,6 +46,7 @@ _USE_OFFICIAL_SHUFFLE = get_bool_env_var( class Mxfp4FlashinferTrtllmMoEMethod: + fuse_routed_scaling_factor_in_topk = True def __init__(self, fp8_method, prefix: str): self._fp8 = fp8_method @@ -58,9 +59,6 @@ class Mxfp4FlashinferTrtllmMoEMethod: self.moe_runner_config = moe_runner_config swiglu_limit = moe_runner_config.swiglu_limit - assert ( - swiglu_limit is not None - ), f"swiglu_limit must be non-None for DeepSeek V4 (got {swiglu_limit!r})" self._gemm1_clamp_limit_tensor = ( torch.full( (layer.num_local_experts,), @@ -400,8 +398,12 @@ def maybe_fuse_routed_scale_and_shared_add( ), ) if fused: + already_scaled = experts.should_fuse_routed_scaling_factor_in_topk if shared is not None: - return shared.add_(routed, alpha=routed_scaling_factor) + alpha = 1.0 if already_scaled else routed_scaling_factor + return shared.add_(routed, alpha=alpha) + if already_scaled: + return routed return routed.mul_(routed_scaling_factor) if shared is not None: routed += shared diff --git a/python/sglang/srt/layers/quantization/mxfp4_marlin_moe.py b/python/sglang/srt/layers/quantization/mxfp4_marlin_moe.py index 7826032ef..631d230c4 100644 --- a/python/sglang/srt/layers/quantization/mxfp4_marlin_moe.py +++ b/python/sglang/srt/layers/quantization/mxfp4_marlin_moe.py @@ -45,6 +45,8 @@ def build_marlin_moe_quant_info(layer: Module) -> MarlinMoeQuantInfo: class Mxfp4MarlinMoEMethod: """MXFP4 (E8M0 scales) MoE quantization method using the Marlin backend.""" + fuse_routed_scaling_factor_in_topk = True + def __init__(self, fp8_method, prefix: str): self._fp8 = fp8_method self.prefix = prefix diff --git a/python/sglang/srt/layers/quantization/unquant.py b/python/sglang/srt/layers/quantization/unquant.py index 795dfab39..8e7b934d9 100644 --- a/python/sglang/srt/layers/quantization/unquant.py +++ b/python/sglang/srt/layers/quantization/unquant.py @@ -600,7 +600,6 @@ class UnquantizedFusedMoEMethod(FusedMoEMethodBase, BaseFusedOp): layer.w2_kernel.process_weights_after_loading(layer, "w2") self._maybe_interleave_w13_for_fused_swiglu(layer) - return def _maybe_interleave_w13_for_fused_swiglu(self, layer: torch.nn.Module) -> None: @@ -633,6 +632,7 @@ class UnquantizedFusedMoEMethod(FusedMoEMethodBase, BaseFusedOp): and moe_runner_config.gemm1_alpha is None and moe_runner_config.gemm1_clamp_limit is None and moe_runner_config.swiglu_limit is None + and not moe_runner_config.apply_router_weight_on_input # The LoRA MoE hooks read and write the full-width pre-activation # buffer in halves layout; both assumptions break here. and not get_lora().enable_lora diff --git a/python/sglang/srt/layers/radix_linear_attention.py b/python/sglang/srt/layers/radix_linear_attention.py index 2dfc00d79..e59824cb1 100644 --- a/python/sglang/srt/layers/radix_linear_attention.py +++ b/python/sglang/srt/layers/radix_linear_attention.py @@ -55,6 +55,7 @@ class RadixLinearAttention(nn.Module): activation: str = "silu", A_log: Optional[torch.Tensor] = None, dt_bias: Optional[torch.Tensor] = None, + lower_bound: Optional[float] = None, ): super().__init__() self.layer_id = layer_id @@ -74,7 +75,7 @@ class RadixLinearAttention(nn.Module): self.A_log = A_log self.dt_bias = dt_bias - self.lower_bound = None + self.lower_bound = lower_bound def forward( self, diff --git a/python/sglang/srt/managers/schedule_batch.py b/python/sglang/srt/managers/schedule_batch.py index 4dd4cbad3..399e192ee 100755 --- a/python/sglang/srt/managers/schedule_batch.py +++ b/python/sglang/srt/managers/schedule_batch.py @@ -2693,25 +2693,27 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): self, req: Req, ) -> _MambaRadixCacheV2TrackEntry: - chunk_size = mamba_cache_chunk_size() - # The donated depth has to be a radix node boundary. Read the tree's own - # page rather than re-deriving how DCP widens it; the kernel still - # snapshots on the chunk_size grid. + cache_chunk_size = mamba_cache_chunk_size() + state_chunk_size = getattr( + self.model_config.hf_text_config, "mamba_chunk_size", 64 + ) + # Donated depth must land on the actual DCP-widened radix page, while + # kernel snapshots stay on the cache chunk grid. checkpoint_grid = mamba_checkpoint_grid(self.tree_cache.page_size) def _force_track_h(i: int) -> int: # h is indexed relative to the extend start, so check that offset. - assert (i - len(req.prefix_indices)) % chunk_size == 0, ( + assert (i - len(req.prefix_indices)) % cache_chunk_size == 0, ( f"The force track calculation only handles last-position or " f"unaligned seqlens, so it needs a chunk-aligned offset to " f"start from. But i={i} prefix_len={len(req.prefix_indices)} " - f"chunk_size={chunk_size} checkpoint_grid={checkpoint_grid}" + f"chunk_size={cache_chunk_size} checkpoint_grid={checkpoint_grid}" ) # There are 3 cases for mamba_track_seqlen passed to mamba_track_seqlens_cpu: - # 1) aligned with chunk_size-> retrieve from last_recurrent_state + # 1) aligned with cache_chunk_size-> retrieve from last_recurrent_state # a) is the last position -> retrieve from last_recurrent_state # b) is NOT the last position -> retrieve from h - # 2) unaligned with chunk_size -> retrieve from h + # 2) unaligned with cache_chunk_size -> retrieve from h # Currently, the math calculation only supports case 1a and 2. So for 1b, we need to add 1 # to force the math calculation to retrieve the correct mamba state from h. return i + 1 @@ -2736,13 +2738,11 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): + (req.extend_range.length // checkpoint_grid) * checkpoint_grid ) - # mamba_track_fla_chunk_aligned is the aligned seqlen based on chunk_size - # If mamba_track_fla_chunk_aligned != mamba_track_seqlen_aligned, which is true when - # checkpoint_grid is coarser than chunk_size, we need to force the math calculation to - # retrieve the correct mamba state from h by _force_track_h() + # A coarser checkpoint grid may not be a model-state boundary, so + # force retrieval from the intermediate h state in that case. mamba_track_fla_chunk_aligned = ( len(req.prefix_indices) - + (req.extend_range.length // chunk_size) * chunk_size + + (req.extend_range.length // state_chunk_size) * state_chunk_size ) if mamba_track_fla_chunk_aligned != mamba_track_seqlen_aligned: # We want to track mamba_track_seqlen_aligned, and it's not the last position, @@ -2764,7 +2764,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): # is within the current extend batch. branching_seqlen_aligned_mask = ( req.mamba_branching_seqlen - len(req.prefix_indices) - ) % chunk_size == 0 + ) % cache_chunk_size == 0 if ( req.mamba_branching_seqlen > len(req.prefix_indices) and req.mamba_branching_seqlen < mamba_track_seqlen diff --git a/python/sglang/srt/model_executor/pool_configurator.py b/python/sglang/srt/model_executor/pool_configurator.py index 444ab2248..fb6d4af6c 100644 --- a/python/sglang/srt/model_executor/pool_configurator.py +++ b/python/sglang/srt/model_executor/pool_configurator.py @@ -213,7 +213,11 @@ class DefaultPoolConfigurator(MemoryPoolConfigurator): self._cell_size * (1 + draft_num_layers / int(num_layers)) ) - # DFLASH/DSPARK: scale cell_size to account for draft model KV cache + # DFLASH/DSPARK: reserve the draft runner's *actual* per-token KV cost. + # The draft allocates its own KV pool at the target's + # max_total_num_tokens, whose per-token footprint can differ from the + # target's (e.g. an MLA-latent target paired with a full per-head K/V + # draft), so size from the draft config rather than the layer ratio. if kvc.spec_algorithm.is_dflash_family() and not kvc.is_draft_worker: from sglang.srt.speculative.dflash_utils import ( scale_kv_cell_size_per_token_for_dflash, diff --git a/python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py b/python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py index a954cd181..c3d311e4f 100644 --- a/python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py +++ b/python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py @@ -78,6 +78,7 @@ from sglang.srt.model_executor.runner.base_cuda_graph_runner import ( from sglang.srt.model_executor.runner.flashinfer_autotune import ( maybe_flashinfer_autotune_speculative_draft, ) +from sglang.srt.model_executor.runner.metadata_glue_graph import MetadataGlueGraph from sglang.srt.model_executor.runner.shape_key import ShapeKey from sglang.srt.model_executor.runner_backend.breakable_cuda_graph_backend import ( BreakableCudaGraphBackend, @@ -457,6 +458,25 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner): source=self.buffers, ) + # Captures the per-replay attention-metadata prep into a small CUDA + # graph; see metadata_glue_graph.py for the correctness contract. + # Force-off for DFlash-family spec: verify installs host-fed fast + # plans (sync-free begin_forward that recomputes plan inputs on the + # host every replay), and capturing one freezes the capture-time + # plan — drafts go stale and accept length collapses to ~1. + enable_metadata_glue = envs.SGLANG_ENABLE_METADATA_GLUE_GRAPH.get() + if enable_metadata_glue and model_runner.spec_algorithm.is_dflash_family(): + logger.warning( + "SGLANG_ENABLE_METADATA_GLUE_GRAPH is incompatible with " + "DFlash-family speculative decoding (host-fed fast verify " + "plans must re-run on the host every replay); disabling the " + "metadata glue graph." + ) + enable_metadata_glue = False + self._metadata_glue = ( + MetadataGlueGraph(self.device) if enable_metadata_glue else None + ) + # --- backend --------------------------------------------------- self.backend = resolve_decode_backend(self) @@ -1367,7 +1387,34 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner): capture_forward_mode=self.capture_forward_mode, is_encoder_decoder=self.is_encoder_decoder, ) - attn_backend.init_forward_metadata_out_graph(fb_view) + # Glue-graph fast path: pointer-stable prep (static buffers + pool + # tensors only) is captured per key; guards keep every python-visible + # branch inside the backends constant for that key. + if ( + self._metadata_glue is not None + and not self._metadata_glue.disabled + and raw_bs == bs + and not self.enable_two_batch_overlap + and not self.enable_pdmux + and self.model_runner.lora_manager is None + ): + # actual_forward_mode belongs in the key even though the captured + # graph always targets capture_forward_mode: DSV4's replay prep + # substitutes seq_lens / seq_lens_cpu / seq_lens_sum / + # req_pool_indices / out_cache_loc when the runtime mode is IDLE, + # so IDLE and active DECODE are different python branches and must + # not share a captured graph. + self._metadata_glue.run( + attn_backend, + fb_view, + ( + bs, + str(self.capture_forward_mode), + str(fb_view.actual_forward_mode), + ), + ) + else: + attn_backend.init_forward_metadata_out_graph(fb_view) self.raw_bs = raw_bs self.raw_num_token = raw_num_token diff --git a/python/sglang/srt/model_executor/runner/metadata_glue_graph.py b/python/sglang/srt/model_executor/runner/metadata_glue_graph.py new file mode 100644 index 000000000..1a6436b97 --- /dev/null +++ b/python/sglang/srt/model_executor/runner/metadata_glue_graph.py @@ -0,0 +1,106 @@ +"""Glue-graph capture of the per-replay attention-metadata prep. + +``decode_cuda_graph_runner.load_batch`` runs +``attn_backend.init_forward_metadata_out_graph(fb_view)`` eagerly on every +replay. At bs=1 spec decode this is an "op soup": dozens of tiny tensor ops +whose HOST dispatch cost dominates the inter-phase seam, while every device +input/output lives at a stable address — the replay fb view hands backends the +runner's static buffers, and pool tensors are persistent. Capturing the op +sequence once per replay key collapses the per-step host cost to a single +graph launch. + +Correctness contract: + +- The caller only routes here when the replay is padding-free + (raw_bs == padded bs) and TBO / pdmux / LoRA are off, so every + Python-visible branch inside the backends is constant per key. +- Python side effects (each backend's ``forward_metadata`` object) are + snapshotted at capture time and re-installed on every replay; the graph + replays only the device ops that refresh the tensors those objects point to. +- ``NUM_WARMUP`` eager runs precede capture so triton JIT compile / autotune + happen outside capture. +- Any capture failure (e.g. a backend syncing or reading host values inside + its prep) permanently disables the glue graph and falls back to eager. +- Backends whose prep computes values on the HOST each replay (e.g. the + DFlash-family host-fed fast verify plans) must never be glued: capture + records only device ops, so the host-written plan inputs would replay + frozen at their capture-time values. Note the failure is SILENT — capture + succeeds, outputs stay correct, only accept length collapses. Callers must + gate such configurations off before routing here + (``decode_cuda_graph_runner`` force-disables the glue for DFlash-family + spec). +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +import torch + +logger = logging.getLogger(__name__) + + +class MetadataGlueGraph: + NUM_WARMUP = 2 + + def __init__(self, device): + self.device = device + self.disabled = False + self._states: Dict[Any, dict] = {} + self._capture_stream = None + + def reset(self): + """Drop captured graphs (call when the runner recaptures its graphs — + static buffers and backend state may have been rebuilt).""" + self._states.clear() + + @staticmethod + def _leaves(attn_backend) -> List[Any]: + backends = [attn_backend] + if attn_backend.attn_backend_list is not None: + backends.extend(attn_backend.attn_backend_list) + return backends + + def run(self, attn_backend, fb_view, key) -> None: + """Run ``init_forward_metadata_out_graph`` for this replay, through the + captured glue graph once it is ready.""" + st = self._states.get(key) + if st is None: + st = {"warmups": 0, "graph": None, "meta": None} + self._states[key] = st + + if st["graph"] is not None: + for backend, metadata in st["meta"]: + backend.forward_metadata = metadata + st["graph"].replay() + return + + if st["warmups"] < self.NUM_WARMUP: + st["warmups"] += 1 + attn_backend.init_forward_metadata_out_graph(fb_view) + return + + if self._capture_stream is None: + self._capture_stream = torch.cuda.Stream() + graph = torch.cuda.CUDAGraph() + try: + with torch.cuda.graph(graph, stream=self._capture_stream): + attn_backend.init_forward_metadata_out_graph(fb_view) + except Exception: + logger.warning( + "Metadata glue-graph capture failed for key %s; falling back " + "to eager metadata prep permanently.", + key, + exc_info=True, + ) + self.disabled = True + # Ops under a failed capture were recorded, not executed — run + # this step's prep for real. + attn_backend.init_forward_metadata_out_graph(fb_view) + return + + st["meta"] = [(b, b.forward_metadata) for b in self._leaves(attn_backend)] + st["graph"] = graph + # Capture records without executing; replay once to do this step's prep. + graph.replay() diff --git a/python/sglang/srt/model_loader/loader.py b/python/sglang/srt/model_loader/loader.py index 23481b31f..dc68cef8a 100644 --- a/python/sglang/srt/model_loader/loader.py +++ b/python/sglang/srt/model_loader/loader.py @@ -211,7 +211,6 @@ def _get_quantization_config( # (yizhang2077) workaround for nvidia/Llama-4-Maverick-17B-128E-Eagle3 if quant_config is None: return None - # Carry DSV4 expert layout into quant configs so downstream readers don't read env. from sglang.srt.layers.quantization.fp8 import Fp8Config if isinstance(quant_config, Fp8Config): diff --git a/python/sglang/srt/models/bailing_moe_nextn.py b/python/sglang/srt/models/bailing_moe_nextn.py index fabf6cad6..9af8c922f 100644 --- a/python/sglang/srt/models/bailing_moe_nextn.py +++ b/python/sglang/srt/models/bailing_moe_nextn.py @@ -41,20 +41,35 @@ from sglang.srt.models.bailing_moe_linear import ( BailingMoELinearDecoderLayer, BailingMoeV2_5ForCausalLM, ) +from sglang.srt.models.bailing_moe_v3 import ( + BailingMoELinearDecoderLayer as BailingMoeV3DecoderLayer, +) +from sglang.srt.models.bailing_moe_v3 import ( + BailingMoeV3ForCausalLM, +) from sglang.srt.models.utils import WeightsMapper from sglang.srt.runtime_context import get_parallel from sglang.srt.utils import BumpAllocator, add_prefix -LoraConfig = None logger = logging.getLogger(__name__) +def _is_bailing_moe_v3_config(config: PretrainedConfig) -> bool: + """Ling-V3 (KDA + gated MLA) vs the V2.5 lightning checkpoint. + + ``use_kda`` is set by BailingHybridConfig from the presence of a short + conv, which is exactly what distinguishes the two. + """ + return config.model_type == "bailing_hybrid" and config.use_kda + + class BailingMoEModelNextN(nn.Module): def __init__( self, config: PretrainedConfig, quant_config: Optional[QuantizationConfig] = None, prefix: str = "", + num_fused_shared_experts: int = 0, ) -> None: super().__init__() self.layer_group_size = 1 @@ -95,19 +110,22 @@ class BailingMoEModelNextN(nn.Module): ) if self.is_hybrid: config.attention_type = 1 - self.decoder = BailingMoELinearDecoderLayer( - config, - quant_config=quant_config, - layer_id=0, - is_nextn=True, - prefix=add_prefix(f"layers.{config.num_hidden_layers}", prefix), - ) + decoder_layer_cls = BailingMoELinearDecoderLayer + decoder_kwargs = { + "quant_config": quant_config, + "layer_id": 0, + "is_nextn": True, + "prefix": add_prefix(f"layers.{config.num_hidden_layers}", prefix), + } + if _is_bailing_moe_v3_config(config): + decoder_layer_cls = BailingMoeV3DecoderLayer + decoder_kwargs["num_fused_shared_experts"] = num_fused_shared_experts + self.decoder = decoder_layer_cls(config, **decoder_kwargs) else: self.decoder = BailingMoEBlock( config, 0, quant_config=quant_config, - # is_nextn=True, prefix=add_prefix("decoder", prefix), ) @@ -174,18 +192,26 @@ class BailingMoEModelNextN(nn.Module): class BailingMoeForCausalLMNextN(nn.Module): - packed_modules_mapping = { "fused_qkv_a_proj_with_mqa": ["q_a_proj", "kv_a_proj_with_mqa"], "gate_up_proj": ["gate_proj", "up_proj"], } - # To ensure correct weight loading and mapping. hf_to_sglang_mapper = WeightsMapper( orig_to_new_substr={ "attention.dense": "attention.o_proj", }, ) + @classmethod + def shared_experts_fusion_disable_reason(cls, hf_config, quant_config): + if not _is_bailing_moe_v3_config(hf_config): + return None + return BailingMoeV3ForCausalLM.shared_experts_fusion_disable_reason( + hf_config, + quant_config, + expected_architecture="BailingMoeForCausalLMNextN", + ) + def __init__( self, config: PretrainedConfig, @@ -196,12 +222,19 @@ class BailingMoeForCausalLMNextN(nn.Module): self.config = config self.tp_size = get_parallel().tp_size self.quant_config = quant_config - if hasattr(self, "determine_num_fused_shared_experts"): + self.num_fused_shared_experts = 0 + is_bailing_moe_v3 = _is_bailing_moe_v3_config(config) + if is_bailing_moe_v3: + BailingMoeV3ForCausalLM.determine_num_fused_shared_experts(self) + elif hasattr(self, "determine_num_fused_shared_experts"): # Asystem has determine_num_fused_shared_experts but theta does not. self.determine_num_fused_shared_experts("BailingMoeForCausalLMNextN") self.model = BailingMoEModelNextN( - config, quant_config, prefix=add_prefix("model", prefix) + config, + quant_config, + prefix=add_prefix("model", prefix), + num_fused_shared_experts=self.num_fused_shared_experts, ) self.lm_head = ParallelLMHead( config.vocab_size, @@ -211,7 +244,10 @@ class BailingMoeForCausalLMNextN(nn.Module): use_attn_tp_group=get_parallel().config.enable_dp_lm_head, ) self.logits_processor = LogitsProcessor(config) - if hasattr(self.config, "model_type") and config.model_type == "bailing_hybrid": + if is_bailing_moe_v3: + self.base_load_weights_func = BailingMoeV3ForCausalLM.load_weights + self.post_load_weights_func = BailingMoeV3ForCausalLM.post_load_weights + elif config.model_type == "bailing_hybrid": self.base_load_weights_func = BailingMoeV2_5ForCausalLM.load_weights self.post_load_weights_func = BailingMoeV2_5ForCausalLM.post_load_weights else: @@ -219,6 +255,16 @@ class BailingMoeForCausalLMNextN(nn.Module): # V1 BailingMoeAttention is standard QKV (no kv_b_proj), no fixup needed. self.post_load_weights_func = None + @staticmethod + def weight_direct_load(param: torch.Tensor, loaded_weight: torch.Tensor): + # Defensive: V3's load_weights references `self.weight_direct_load` as the + # default in `getattr(param, "weight_loader", self.weight_direct_load)`, + # which is eagerly evaluated. Today the linear-attn branch that uses it is + # never reached on NextN (attention_type is forced to softmax and + # is_linear_layer(0, 1) is False), but keep this forward so a future change + # that enables KDA-style layers on NextN doesn't hit AttributeError. + BailingMoeV3ForCausalLM.weight_direct_load(param, loaded_weight) + @torch.no_grad() def forward( self, diff --git a/python/sglang/srt/models/bailing_moe_v3.py b/python/sglang/srt/models/bailing_moe_v3.py new file mode 100644 index 000000000..14317b338 --- /dev/null +++ b/python/sglang/srt/models/bailing_moe_v3.py @@ -0,0 +1,1982 @@ +# Copyright 2023 Antgroup and The HuggingFace Inc. team. All rights reserved. +from __future__ import annotations + +import copy +import logging +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union + +import torch +import torch.nn.functional as F +from torch import nn +from transformers import PretrainedConfig + +from sglang.kernels.ops.moe.router import ( + ROUTER_GATE_MATVEC_MAX_M, + router_gate_matvec, +) +from sglang.kernels.ops.quantization.fp8_kernel import ( + is_fp8_fnuz, +) +from sglang.srt.configs import KimiLinearConfig +from sglang.srt.distributed import ( + get_pp_group, + moe_expert_parallel_all_reduce, + moe_tensor_model_parallel_all_reduce, +) +from sglang.srt.environ import envs +from sglang.srt.eplb.expert_distribution import get_global_expert_distribution_recorder +from sglang.srt.eplb.expert_location_dispatch import ExpertLocationDispatchInfo +from sglang.srt.layers import deep_gemm_wrapper +from sglang.srt.layers.activation import SiluAndMul +from sglang.srt.layers.communicator import ( + LayerCommunicator, + LayerScatterModes, + enable_moe_dense_fully_dp, +) +from sglang.srt.layers.dp_attention import is_dp_attention_enabled +from sglang.srt.layers.layernorm import RMSNorm +from sglang.srt.layers.linear import ( + ColumnParallelLinear, + MergedColumnParallelLinear, + QKVParallelLinear, + RowParallelLinear, +) +from sglang.srt.layers.logits_processor import LogitsProcessor +from sglang.srt.layers.moe import ( + get_moe_a2a_backend, + should_skip_post_experts_all_reduce, +) +from sglang.srt.layers.moe.ep_moe.layer import get_moe_impl_class +from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE +from sglang.srt.layers.moe.topk import TopK +from sglang.srt.layers.moe.utils import is_shared_experts_fusion_disabled +from sglang.srt.layers.quantization.base_config import QuantizationConfig +from sglang.srt.layers.quantization.fp8_utils import ( + block_quant_dequant, + block_quant_to_tensor_quant, + channel_quant_to_tensor_quant, + normalize_e4m3fn_to_e4m3fnuz, +) +from sglang.srt.layers.quantization.int8_utils import ( + block_dequant as int8_block_dequant, +) +from sglang.srt.layers.radix_attention import RadixAttention +from sglang.srt.layers.rotary_embedding import get_rope +from sglang.srt.layers.utils import PPMissingLayer +from sglang.srt.layers.vocab_parallel_embedding import ( + ParallelLMHead, + VocabParallelEmbedding, +) +from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors +from sglang.srt.model_executor.runner import get_is_capture_mode +from sglang.srt.model_loader.weight_utils import ( + default_weight_loader, +) +from sglang.srt.models.deepseek_common.utils import ( + _is_cpu, + _is_cpu_amx_available, + _is_cuda, + _is_hip, +) +from sglang.srt.models.deepseek_v2 import DeepseekV2AttentionMLA +from sglang.srt.models.kimi_linear import KimiDeltaAttention +from sglang.srt.runtime_context import ( + get_forward, + get_parallel, + get_stream, +) +from sglang.srt.utils import ( + BumpAllocator, + add_prefix, + bind_or_assign, + is_cuda, + is_flashinfer_available, + is_sm100_supported, + log_info_on_rank0, + make_layers, +) + +_is_fp8_fnuz = is_fp8_fnuz() + +if _is_cuda: + from sgl_kernel import awq_dequantize +elif _is_cpu and _is_cpu_amx_available: + pass +elif _is_hip: + from sglang.kernels.ops.quantization.awq_triton import ( + awq_dequantize_triton as awq_dequantize, + ) + +elif not (_is_cpu and _is_cpu_amx_available): + from vllm._custom_ops import awq_dequantize + +_is_flashinfer_available = is_flashinfer_available() +_is_sm100_supported = is_cuda() and is_sm100_supported() + + +class DsV3MLA(DeepseekV2AttentionMLA): + def __init__( + self, + config: PretrainedConfig, + hidden_size: int, + num_heads: int, + qk_nope_head_dim: int, + qk_rope_head_dim: int, + v_head_dim: int, + q_lora_rank: int, + kv_lora_rank: int, + rope_theta: float = 10000, + rope_scaling: Optional[Dict[str, Any]] = None, + max_position_embeddings: int = 8192, + quant_config: Optional[QuantizationConfig] = None, + reduce_results: bool = True, + layer_id: int = None, + prefix: str = "", + alt_stream: Optional[torch.cuda.Stream] = None, + skip_rope: bool = False, + ) -> None: + super().__init__( + config, + hidden_size, + num_heads, + qk_nope_head_dim, + qk_rope_head_dim, + v_head_dim, + q_lora_rank, + kv_lora_rank, + rope_theta, + rope_scaling, + max_position_embeddings, + quant_config, + reduce_results, + layer_id, + prefix, + alt_stream, + skip_rope, + ) + attn_tp_rank = get_parallel().attn_tp_rank + attn_tp_size = get_parallel().attn_tp_size + self.gated_attention_proj_granularity_type = getattr( + config, "gated_attention_proj_granularity_type", None + ) + # gated_attn now not support NPU + if self.gated_attention_proj_granularity_type == "head_wise": + self.g_proj = ColumnParallelLinear( + self.hidden_size, + self.num_heads, + bias=False, + prefix=f"{prefix}.output_gate", + quant_config=None, + tp_rank=attn_tp_rank, + tp_size=attn_tp_size, + ) + elif self.gated_attention_proj_granularity_type == "element_wise": + self.g_proj = ColumnParallelLinear( + self.hidden_size, + self.num_heads * self.v_head_dim, + bias=False, + prefix=f"{prefix}.output_gate", + tp_rank=attn_tp_rank, + tp_size=attn_tp_size, + ) + else: + self.g_proj = None + + def forward( + self, + positions: torch.Tensor, + hidden_states: torch.Tensor, + forward_batch: ForwardBatch, + zero_allocator: BumpAllocator, + llama_4_scaling: Optional[torch.Tensor] = None, + ): + s = self.forward_prepare( + positions=positions, + hidden_states=hidden_states, + forward_batch=forward_batch, + zero_allocator=zero_allocator, + llama_4_scaling=llama_4_scaling, + ) + gate = self._forward_gated(hidden_states) + s = (s[0], s[1], s[2], s[3] + (gate,)) + return self.forward_core(s) + + def _forward_gated(self, hidden_states: torch.Tensor): + if self.g_proj: + gate, _ = self.g_proj(hidden_states) + gate = F.sigmoid(gate.float()).type_as(hidden_states) + return gate + else: + return None + + def _apply_gated(self, attn_output: torch.Tensor, gate: torch.Tensor): + if self.gated_attention_proj_granularity_type == "head_wise": + attn_output = ( + attn_output.view(-1, self.num_local_heads, self.v_head_dim) + * gate[:, :, None] + ) + attn_output = attn_output.view(-1, self.num_local_heads * self.v_head_dim) + else: + attn_output = attn_output * gate + return attn_output + + +logger = logging.getLogger(__name__) + + +def is_linear_layer(layer_idx, layer_group_size): + if layer_idx is None: + return False + if isinstance(layer_group_size, list): + return layer_group_size[layer_idx] == 1 + if layer_group_size > 0: + return (layer_idx + 1) % layer_group_size != 0 + else: + return False + + +_NEXTN_SPEC_WEIGHT_NAMES = ( + "final_layernorm", + "eh_proj", + "enorm", + "hnorm", +) + + +def resolve_nextn_layer_id(config: PretrainedConfig) -> int: + """Locate the nextn predict layer index in the HF checkpoint name space.""" + if not hasattr(config, "num_nextn_predict_layers"): + raise ValueError("num nextn_predict_layers is not in the config") + assert config.num_nextn_predict_layers == 1, "Only 1 nextn layer is supported" + return 0 if config.num_hidden_layers == 1 else config.num_hidden_layers + + +def rewrite_nextn_weight_name(name: str, nextn_layer_prefix: str) -> Optional[str]: + """Map a HF nextn-layer weight name onto the local NextN module namespace. + + The caller must already have ensured ``name.startswith(nextn_layer_prefix)``. + Returns the rewritten name, or None to signal the weight should be skipped + (e.g. shared head / embed tokens which are reused from the target model). + """ + if "shared_head.head" in name or "embed_tokens" in name: + return None + for spec in _NEXTN_SPEC_WEIGHT_NAMES: + if spec in name: + return name.replace(nextn_layer_prefix, "model") + return name.replace(nextn_layer_prefix, "model.decoder") + + +def is_pp_missing_parameter( + name: str, + model: torch.nn.Module, +) -> bool: + if isinstance(model, PPMissingLayer): + return True + return False + + +class BailingMLP(nn.Module): + def __init__( + self, + hidden_size: int, + intermediate_size: int, + config: PretrainedConfig, + reduce_results=True, + quant_config: Optional[QuantizationConfig] = None, + prefix: str = "", + swiglu_limit: Optional[float] = None, + padded_intermediate_size: Optional[int] = None, + tp_rank: Optional[int] = None, + tp_size: Optional[int] = None, + ) -> None: + super().__init__() + + self.config = config + self.swiglu_limit = swiglu_limit + self.tp_size = tp_size if tp_size is not None else get_parallel().tp_size + self.tp_rank = tp_rank if tp_rank is not None else get_parallel().tp_rank + + self.intermediate_size = intermediate_size + self.padded_intermediate_size = padded_intermediate_size or intermediate_size + + self.gate_up_proj = MergedColumnParallelLinear( + hidden_size, + [self.padded_intermediate_size] * 2, + bias=False, + quant_config=quant_config, + prefix=f"{prefix}.gate_up_proj", + tp_rank=tp_rank, + tp_size=tp_size, + ) + self.down_proj = RowParallelLinear( + self.padded_intermediate_size, + hidden_size, + bias=False, + quant_config=quant_config, + reduce_results=reduce_results, + prefix=f"{prefix}.down_proj", + tp_rank=tp_rank, + tp_size=tp_size, + ) + + if self.padded_intermediate_size > self.intermediate_size: + self.padded_size_per_partition = ( + self.padded_intermediate_size // self.tp_size + ) + self.effective_size_per_partition = self.intermediate_size // self.tp_size + self.pad_size_per_partition = ( + self.padded_size_per_partition - self.effective_size_per_partition + ) + else: + self.padded_size_per_partition = None + self.effective_size_per_partition = None + self.pad_size_per_partition = None + + self.act_fn = SiluAndMul() + + def forward( + self, + x, + forward_batch: Optional[ForwardBatch] = None, + ): + x, _ = self.gate_up_proj(x) + + if self.padded_size_per_partition is not None: + gate_padded = x[..., : self.padded_size_per_partition] + up_padded = x[..., self.padded_size_per_partition :] + + gate_effective = gate_padded[..., : self.effective_size_per_partition] + up_effective = up_padded[..., : self.effective_size_per_partition] + + if self.swiglu_limit is not None: + x = F.silu(gate_effective).clamp( + max=self.swiglu_limit + ) * up_effective.clamp(min=-self.swiglu_limit, max=self.swiglu_limit) + else: + x = F.silu(gate_effective) * up_effective + + x = F.pad(x, (0, self.pad_size_per_partition)) + else: + if self.swiglu_limit is not None: + d = x.shape[-1] // 2 + gate = F.silu(x[..., :d]).clamp(max=self.swiglu_limit) + up = x[..., d:].clamp(min=-self.swiglu_limit, max=self.swiglu_limit) + x = gate * up + else: + x = self.act_fn(x) + + x, _ = self.down_proj(x) + return x + + +class BailingMoEGate(nn.Module): + def __init__( + self, + config, + params_dtype: Optional[torch.dtype] = None, + prefix: str = "", + ): + super().__init__() + + if params_dtype is None: + params_dtype = torch.get_default_dtype() + self.params_dtype = params_dtype + self.weight = nn.Parameter( + torch.empty( + (config.num_experts, config.hidden_size), + dtype=self.params_dtype, + ), + ) + + if getattr(config, "moe_router_enable_expert_bias", False): + self.expert_bias = nn.Parameter( + torch.empty((config.num_experts,), dtype=torch.float32), + ) + else: + self.expert_bias = None + + def forward(self, hidden_states): + if ( + hidden_states.is_cuda + and 0 < hidden_states.shape[0] <= ROUTER_GATE_MATVEC_MAX_M + and self.weight.dtype in (torch.float32, torch.bfloat16) + ): + # Decode-sized M: one fp32-accumulating triton matvec for either + # gate dtype. Cold-cache (rotating weights) it beats the library + # path up to M=8 — by ~2.6-3x at the bs=1 verify shape (M=4) — + # and ties it at M=1; see router_gate_matvec for the numbers. + return router_gate_matvec(hidden_states, self.weight) + logits = F.linear(hidden_states.to(self.weight.dtype), self.weight, None) + return logits + + +class BailingMoE(nn.Module): + @staticmethod + def _get_swiglu_limit(limit_list, layer_num): + if limit_list is None or not 0 <= layer_num < len(limit_list): + return None + return limit_list[layer_num] or None + + def __init__( + self, + config: PretrainedConfig, + quant_config: Optional[QuantizationConfig] = None, + layer_id: int = 0, + prefix: str = "moe", + num_fused_shared_experts: int = 0, + alt_stream: Optional[torch.cuda.Stream] = None, + ): + super().__init__() + + self.layer_id = layer_id + self.alt_stream = alt_stream + + self.tp_size = get_parallel().tp_size + self.tp_rank = get_parallel().tp_rank + self.moe_ep_size = get_parallel().moe_ep_size + self.moe_tp_size = get_parallel().moe_tp_size + self.moe_tp_rank = get_parallel().moe_tp_rank + + self.top_k = config.num_experts_per_tok + self.norm_expert_prob = getattr(config, "norm_topk_prob", False) + self.hidden_size = config.hidden_size + self.intermediate_size = config.moe_intermediate_size + self.num_shared_experts = getattr(config, "num_shared_experts", 0) + self.routed_scaling_factor = getattr(config, "routed_scaling_factor", 1.0) + self.score_function = getattr(config, "score_function", None) + + self.num_fused_shared_experts = num_fused_shared_experts + + expert_swiglu_limit_list = getattr(config, "expert_swiglu_limit_list", None) + share_expert_swiglu_limit_list = getattr( + config, "share_expert_swiglu_limit_list", None + ) + self.expert_swiglu_limit = self._get_swiglu_limit( + expert_swiglu_limit_list, layer_id + ) + self.share_expert_swiglu_limit = self._get_swiglu_limit( + share_expert_swiglu_limit_list, layer_id + ) + + # Run the MoE gate in bf16. ling-v3's config sets router_dtype="fp32", + # but on Hopper that fp32 gate GEMM falls back to a slow sm80 (Ampere) + # no-tensor-core path (~7% of decode GPU time). bf16 uses Hopper tensor + # cores; the top-k selection re-casts gating logits back to fp32 + # (see layers/moe/topk.py), so routing stays fp32-stable. Validated on + # ling-v3 TP4: accuracy-neutral (greedy), ~17% bs=1 TPOT improvement. + self.router_dtype = torch.bfloat16 + + self.num_expert_group = getattr(config, "n_group", 0) + self.topk_group = getattr(config, "topk_group", 0) + if self.num_expert_group > 0 or self.topk_group > 0: + assert ( + self.num_expert_group > 0 + and 0 < self.topk_group <= self.num_expert_group + ) + self.use_grouped_topk = True + else: + self.num_expert_group = self.topk_group = None + self.use_grouped_topk = False + + self.num_experts = config.num_experts + + self.gate = BailingMoEGate( + config=config, + params_dtype=self.router_dtype, + prefix=add_prefix("gate", prefix), + ) + self.correction_bias = ( + self.gate.expert_bias.data if self.gate.expert_bias is not None else None + ) + + if self.score_function is not None: + assert ( + self.score_function == "softmax" and self.correction_bias is None + ) or ( + self.score_function == "sigmoid" and self.correction_bias is not None + ), "score_function and correction_bias should be in 2 combination (softmax, None) or (sigmoid, not None)" + + self._enable_a2a_moe = not get_moe_a2a_backend().is_none() + + # Scaling factor for fused shared experts in EP mode. + # Non-A2A EP (standard): each GPU computes shared expert, outputs are summed + # via all_reduce → scale down by 1/ep_size to avoid double counting. + # Note: A2A EP (DeepEP) + fused shared experts is impossible (expert routing + # breaks for the extra shared expert ID), so it's auto-disabled in + # determine_num_fused_shared_experts(). + fused_shared_experts_scaling_factor = None + if self.moe_ep_size > 1 and self.num_fused_shared_experts > 0: + fused_shared_experts_scaling_factor = 1.0 / float(self.moe_ep_size) + + self.experts = get_moe_impl_class(quant_config)( + num_experts=self.num_experts + self.num_fused_shared_experts, + top_k=self.top_k + self.num_fused_shared_experts, + num_fused_shared_experts=self.num_fused_shared_experts, + layer_id=self.layer_id, + hidden_size=self.hidden_size, + intermediate_size=self.intermediate_size, + quant_config=quant_config, + routed_scaling_factor=self.routed_scaling_factor, + prefix=f"{prefix}.experts", + gemm1_clamp_limit=self.expert_swiglu_limit, + ) + self.topk = TopK( + top_k=self.top_k + self.num_fused_shared_experts, + layer_id=self.layer_id, + use_grouped_topk=self.use_grouped_topk, + renormalize=self.norm_expert_prob, + num_expert_group=self.num_expert_group, + topk_group=self.topk_group, + correction_bias=self.correction_bias, + routed_scaling_factor=self.routed_scaling_factor, + apply_routed_scaling_factor_on_output=( + self.experts.should_fuse_routed_scaling_factor_in_topk + ), + num_fused_shared_experts=self.num_fused_shared_experts, + fused_shared_experts_scaling_factor=fused_shared_experts_scaling_factor, + is_fp4_experts=( + quant_config is not None + and getattr(quant_config, "is_fp4_experts", False) + ), + ) + + # Whether to apply routed_scaling_factor at model layer. + # For A2A MoE paths (e.g., DeepEP), the runner/post_permute does not apply it, + # so apply it here unless the selected runner fuses it into TopK weights. + # This scales only routed expert output; shared experts are added unscaled. + self._apply_routed_scaling_factor_on_output = ( + self._enable_a2a_moe + and not self.experts.should_fuse_routed_scaling_factor_in_topk + and self.routed_scaling_factor is not None + and self.routed_scaling_factor != 1.0 + ) + + if self.num_shared_experts > 0 and self.num_fused_shared_experts == 0: + intermediate_size = getattr( + config, + "moe_shared_expert_intermediate_size", + self.intermediate_size * self.num_shared_experts, + ) + # When DeepEP is enabled, shared experts should not be TP-sharded + # because MoE output is already complete after EP combine. + # Using tp_size=1 ensures shared output is also complete, + # so no all-reduce is needed at the MoE level. + shared_tp_kwargs = {} + shared_tp_size = self.tp_size + if self._enable_a2a_moe: + shared_tp_kwargs = dict(tp_rank=0, tp_size=1) + shared_tp_size = 1 + padded_intermediate_size = self._compute_padded_intermediate_size( + intermediate_size, quant_config, shared_tp_size + ) + self.shared_experts = BailingMLP( + hidden_size=self.hidden_size, + intermediate_size=intermediate_size, + config=config, + reduce_results=False, + quant_config=quant_config, + prefix=f"{prefix}.shared_experts", + swiglu_limit=self.share_expert_swiglu_limit, + padded_intermediate_size=padded_intermediate_size, + **shared_tp_kwargs, + ) + else: + self.shared_experts = None + + def _compute_padded_intermediate_size( + self, + intermediate_size: int, + quant_config: Optional[QuantizationConfig], + tp_size: int, + ) -> Optional[int]: + """Compute padded intermediate size to satisfy FP8 blockwise quantization alignment. + + FP8 blockwise quantization requires: + - output_partition_size % block_n == 0 (for column parallel) + - input_size_per_partition % block_k == 0 (for row parallel) + + When TP size is large, intermediate_size / tp_size may not satisfy these constraints. + We pad the intermediate_size to make it divisible by block_size * tp_size. + + Args: + intermediate_size: Original intermediate size + quant_config: Quantization configuration + tp_size: TP degree used by the MLP being padded + + Returns: + Padded intermediate size if padding is needed, None otherwise + """ + if quant_config is None: + return None + + if quant_config.get_name() != "fp8": + return None + + weight_block_size = getattr(quant_config, "weight_block_size", None) + if weight_block_size is None: + return None + + block_n = weight_block_size[0] + block_k = weight_block_size[1] + block_size = max(block_n, block_k) + + intermediate_size_per_partition = intermediate_size // tp_size + + if ( + intermediate_size_per_partition % block_n == 0 + and intermediate_size_per_partition % block_k == 0 + ): + return None + + alignment = block_size * tp_size + padded_intermediate_size = ( + (intermediate_size + alignment - 1) // alignment + ) * alignment + + log_info_on_rank0( + logger, + f"Padding shared_experts intermediate_size from {intermediate_size} to {padded_intermediate_size} " + f"to satisfy FP8 blockwise quantization alignment (block_size={block_size}, tp_size={tp_size}).", + ) + + return padded_intermediate_size + + def forward( + self, + hidden_states: torch.Tensor, + forward_batch: Optional[ForwardBatch] = None, + ) -> torch.Tensor: + if self._enable_a2a_moe: + return self.forward_deepep(hidden_states, forward_batch) + return self.forward_normal(hidden_states) + + def forward_normal( + self, + hidden_states: torch.Tensor, + ) -> torch.Tensor: + num_tokens, hidden_size = hidden_states.shape + hidden_states = hidden_states.view(-1, hidden_size) + + if num_tokens == 0: + shared_output = None + topk_output = self.topk.empty_topk_output(hidden_states.device) + final_hidden_states = self.experts(hidden_states, topk_output) + elif ( + self.num_fused_shared_experts == 0 + and self.shared_experts is not None + and self.alt_stream is not None + and get_is_capture_mode() + ): + final_hidden_states, shared_output = self.forward_normal_dual_stream( + hidden_states + ) + else: + shared_output = self._forward_shared_experts(hidden_states) + final_hidden_states = self._forward_router_experts(hidden_states) + + if shared_output is not None: + final_hidden_states = final_hidden_states + shared_output + + if self.moe_ep_size > 1 and not should_skip_post_experts_all_reduce( + is_tp_path=False, + ): + final_hidden_states = moe_expert_parallel_all_reduce(final_hidden_states) + + if self.moe_tp_size > 1 and not should_skip_post_experts_all_reduce( + is_tp_path=True, + ): + final_hidden_states = moe_tensor_model_parallel_all_reduce( + final_hidden_states + ) + return final_hidden_states + + def _forward_shared_experts( + self, hidden_states: torch.Tensor + ) -> Optional[torch.Tensor]: + if self.shared_experts is None: + return None + return self.shared_experts(hidden_states) + + def _forward_router_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: + router_logits = self.gate(hidden_states) + topk_output = self.topk(hidden_states, router_logits) + return self.experts(hidden_states, topk_output) + + def forward_normal_dual_stream( + self, + hidden_states: torch.Tensor, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + current_stream = torch.cuda.current_stream() + self.alt_stream.wait_stream(current_stream) + + shared_output = self._forward_shared_experts(hidden_states.clone()) + + with torch.cuda.stream(self.alt_stream): + final_hidden_states = self._forward_router_experts(hidden_states) + + current_stream.wait_stream(self.alt_stream) + return final_hidden_states, shared_output + + def forward_deepep( + self, + hidden_states: torch.Tensor, + forward_batch: ForwardBatch, + ) -> torch.Tensor: + assert forward_batch is not None, "forward_batch is required for DeepEP MoE" + num_tokens, hidden_size = hidden_states.shape + hidden_states = hidden_states.view(-1, hidden_size) + + if num_tokens == 0: + shared_output = None + topk_output = self.topk.empty_topk_output(hidden_states.device) + else: + shared_output = self._forward_shared_experts(hidden_states) + router_logits = self.gate(hidden_states) + topk_output = self.topk( + hidden_states, + router_logits, + num_token_non_padded=forward_batch.num_token_non_padded, + expert_location_dispatch_info=ExpertLocationDispatchInfo.init_new( + layer_id=self.layer_id, + ), + ) + final_hidden_states = self.experts(hidden_states, topk_output) + + # In DeepEP mode, MoE output is already complete after EP combine. + # Apply routed_scaling_factor here since the runner does not apply it + # for DeepEP paths (post_permute_deep_gemm_to_deepep_normal/ll). + if shared_output is not None: + if self._apply_routed_scaling_factor_on_output: + shared_output.add_( + final_hidden_states, alpha=self.routed_scaling_factor + ) + else: + shared_output.add_(final_hidden_states) + final_hidden_states = shared_output + elif self._apply_routed_scaling_factor_on_output: + final_hidden_states = final_hidden_states * self.routed_scaling_factor + + # No all-reduce needed: both MoE output (complete after EP combine) + # and shared output (tp_size=1, complete) are already full results. + return final_hidden_states + + +class BailingKDA(KimiDeltaAttention): + def __init__( + self, + layer_id: int, + hidden_size: int, + config: PretrainedConfig, + quant_config: Optional[QuantizationConfig] = None, + prefix: str = "", + reduce_results: bool = True, + **kwargs, + ) -> None: + kimi_linear_config = KimiLinearConfig( + linear_attn_config={ + "head_dim": config.head_dim, + "num_heads": config.num_attention_heads, + "short_conv_kernel_size": config.short_conv_kernel_size, + "kda_layers": [], + "full_attn_layers": [], + }, + v_head_dim=config.v_head_dim, + ) + super().__init__( + layer_id, + hidden_size, + kimi_linear_config, + quant_config, + prefix=prefix, + rms_norm_eps=1e-6, + no_kda_lora=config.no_kda_lora, + safe_gate=config.kda_safe_gate, + lower_bound=config.kda_lower_bound, + reduce_results=reduce_results, + # Ling-V3 shards KDA on the attention-TP group (DP attention) and + # carries its own value head dim; upstream Kimi-Linear does neither, + # so both are opt-in on KimiDeltaAttention. + shard_on_attn_tp=True, + v_head_dim=config.v_head_dim, + ) + + +class BailingMoEAttention(nn.Module): + def __init__( + self, + config: PretrainedConfig, + quant_config: Optional[QuantizationConfig] = None, + layer_id: int = None, + prefix: str = "mha", + ) -> None: + super().__init__() + self.config = config + self.layer_id = layer_id + + self.hidden_size = config.hidden_size + tp_size = get_parallel().attn_tp_size + self.total_num_heads = config.num_attention_heads + assert self.total_num_heads % tp_size == 0 + self.num_heads = self.total_num_heads // tp_size + self.total_num_kv_heads = config.num_key_value_heads + if self.total_num_kv_heads >= tp_size: + assert self.total_num_kv_heads % tp_size == 0 + else: + assert tp_size % self.total_num_kv_heads == 0 + self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size) + self.head_dim = getattr(config, "head_dim", None) + if self.head_dim is None: + self.head_dim = self.hidden_size // self.total_num_heads + + self.q_size = self.num_heads * self.head_dim + self.kv_size = self.num_kv_heads * self.head_dim + self.scaling = self.head_dim**-0.5 + + self.split_qkv = getattr(config, "using_split_qkv_in_self_attention", False) + assert not self.split_qkv, "split_qkv is not supported for now" + self.use_qk_norm = getattr(config, "use_qk_norm", False) + + self.query_key_value = QKVParallelLinear( + self.hidden_size, + self.head_dim, + self.total_num_heads, + self.total_num_kv_heads, + bias=(config.use_bias or config.use_qkv_bias), + quant_config=quant_config, + prefix=f"{prefix}.qkv_proj", + ) + if self.use_qk_norm: + self.query_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + self.key_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + + self.dense = RowParallelLinear( + self.total_num_heads * self.head_dim, + self.hidden_size, + bias=config.use_bias, + quant_config=quant_config, + prefix=f"{prefix}.o_proj", + ) + if hasattr(config, "rotary_dim"): + self.rotary_dim = config.rotary_dim + elif hasattr(config, "partial_rotary_factor"): + self.rotary_dim = int(self.head_dim * config.partial_rotary_factor) + else: + self.rotary_dim = self.head_dim + self.max_position_embeddings = config.max_position_embeddings + self.rotary_emb = get_rope( + self.head_dim, + rotary_dim=self.rotary_dim, + max_position=self.max_position_embeddings, + base=config.rope_parameters.get("rope_theta", 600000), + rope_scaling=config.rope_parameters, + dtype=torch.float32, + ) + self.attn = RadixAttention( + self.num_heads, + self.head_dim, + self.scaling, + num_kv_heads=self.num_kv_heads, + layer_id=layer_id, + quant_config=quant_config, + prefix=f"{prefix}.attn", + ) + + def _apply_qk_norm( + self, q: torch.Tensor, k: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + q_by_head = q.reshape(-1, self.head_dim) + q_by_head = self.query_layernorm(q_by_head) + q = q_by_head.view(q.shape) + k_by_head = k.reshape(-1, self.head_dim) + k_by_head = self.key_layernorm(k_by_head) + k = k_by_head.view(k.shape) + return q, k + + def forward( + self, + hidden_states: torch.Tensor, + positions: torch.Tensor, + forward_batch: ForwardBatch, + **kwargs, + ) -> torch.Tensor: + qkv, _ = self.query_key_value(hidden_states) + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) + if self.use_qk_norm: + q, k = self._apply_qk_norm(q, k) + q, k = self.rotary_emb(positions, q, k) + attn_output = self.attn(q, k, v, forward_batch) + output, _ = self.dense(attn_output) + return output + + +class BailingMoELinearDecoderLayer(nn.Module): + def __init__( + self, + config: PretrainedConfig, + quant_config: Optional[QuantizationConfig] = None, + layer_id: int = 0, + prefix: str = "layer", + num_fused_shared_experts: int = 0, + is_nextn: bool = False, + alt_stream: Optional[torch.cuda.Stream] = None, + ) -> None: + super().__init__() + self.layer_id = layer_id + self.use_mla = getattr(config, "full_attention_type", "mla") == "mla" + self.attention_type = config.attention_type + self.config = config + + if config.attention_type == 0: # Linear layer + self.attention = BailingKDA( + layer_id=self.layer_id, + hidden_size=config.hidden_size, + config=config, + quant_config=quant_config, + prefix=f"{prefix}.self_attn", + reduce_results=False, + ) + elif config.attention_type == 1: # softmax layer + if self.use_mla: + self.attention = DsV3MLA( + config=config, + hidden_size=config.hidden_size, + num_heads=config.num_attention_heads, + qk_nope_head_dim=config.qk_nope_head_dim, + qk_rope_head_dim=config.qk_rope_head_dim, + v_head_dim=config.v_head_dim, + q_lora_rank=( + config.q_lora_rank if hasattr(config, "q_lora_rank") else None + ), + kv_lora_rank=config.kv_lora_rank, + rope_theta=config.rope_parameters.get("rope_theta", 600000), + rope_scaling=config.rope_parameters, + max_position_embeddings=262144, + quant_config=quant_config, + layer_id=layer_id, + reduce_results=False, + prefix=add_prefix("attention", prefix), + alt_stream=alt_stream, + skip_rope=( + getattr(config, "use_mla_nope", False) + or config.qk_rope_head_dim == 0 + ), + ) + else: + self.attention = BailingMoEAttention( + config, + quant_config=quant_config, + layer_id=self.layer_id, + prefix=prefix + ".attention", + ) + else: + raise ValueError(f"Unsupported attention type: {config.attention_type}") + + self.expert_num = config.num_experts + self.hidden_size = config.hidden_size + is_moe_layer = is_nextn or ( + not (self.expert_num == 1) + and (self.layer_id >= config.first_k_dense_replace) + ) + self.is_layer_sparse = is_moe_layer + is_previous_moe_layer = not (self.expert_num == 1) and ( + self.layer_id - 1 >= config.first_k_dense_replace + ) + is_next_layer_sparse = not (self.expert_num == 1) and ( + self.layer_id + 1 >= config.first_k_dense_replace + ) + if enable_moe_dense_fully_dp(): + mlp_tp_rank, mlp_tp_size = 0, 1 + else: + mlp_tp_rank, mlp_tp_size = None, None + + if self.expert_num == 1: + self.mlp = BailingMLP( + hidden_size=self.hidden_size, + intermediate_size=config.intermediate_size, + config=config, + quant_config=quant_config, + prefix=prefix, + tp_rank=mlp_tp_rank, + tp_size=mlp_tp_size, + ) + else: + if is_nextn or self.layer_id >= config.first_k_dense_replace: + self.mlp = BailingMoE( + config, + quant_config=quant_config, + layer_id=self.layer_id, + prefix=prefix, + num_fused_shared_experts=num_fused_shared_experts, + alt_stream=alt_stream, + ) + else: + self.mlp = BailingMLP( + hidden_size=self.hidden_size, + intermediate_size=config.intermediate_size, + config=config, + quant_config=quant_config, + prefix=prefix, + tp_rank=mlp_tp_rank, + tp_size=mlp_tp_size, + ) + rms_norm_eps = float(getattr(config, "rms_norm_eps", 1e-5)) + self.input_layernorm = RMSNorm(self.hidden_size, eps=rms_norm_eps) + self.post_attention_layernorm = RMSNorm(self.hidden_size, eps=rms_norm_eps) + + self.layer_scatter_modes = LayerScatterModes.init_new( + layer_id=layer_id, + # NextN wraps a single decoder layer whose checkpoint prefix is the + # post-model layer id. Treat it as a one-layer model for scatter-mode + # planning so A2A/DeepEP outputs are gathered before logits. + num_layers=1 if is_nextn else config.num_hidden_layers, + is_layer_sparse=is_moe_layer, + is_previous_layer_sparse=is_previous_moe_layer, + is_next_layer_sparse=is_next_layer_sparse, + ) + + self.layer_communicator = LayerCommunicator( + layer_scatter_modes=self.layer_scatter_modes, + input_layernorm=self.input_layernorm, + post_attention_layernorm=self.post_attention_layernorm, + allow_reduce_scatter=True, + is_last_layer=(is_nextn or layer_id == config.num_hidden_layers - 1), + qkv_latent_func=( + self.attention.prepare_qkv_latent + if self.attention_type == 1 and self.use_mla + else None + ), + ) + + @torch.inference_mode() + def forward( + self, + hidden_states: torch.Tensor, + positions: torch.Tensor, + forward_batch: ForwardBatch, + residual: Optional[torch.Tensor], + zero_allocator: BumpAllocator, + **kwargs, + ) -> tuple[torch.Tensor, torch.Tensor]: + hidden_states, residual = self.layer_communicator.prepare_attn( + hidden_states, residual, forward_batch + ) + + if not forward_batch.forward_mode.is_idle(): + if self.attention_type == 0: + hidden_states = self.attention( + hidden_states=hidden_states, + positions=positions, + forward_batch=forward_batch, + zero_allocator=zero_allocator, + ) + elif self.use_mla: + hidden_states = self.attention( + positions=positions, + hidden_states=hidden_states, + forward_batch=forward_batch, + zero_allocator=zero_allocator, + ) + else: + hidden_states = self.attention( + hidden_states=hidden_states, + positions=positions, + forward_batch=forward_batch, + ) + + hidden_states, residual = self.layer_communicator.prepare_mlp( + hidden_states, residual, forward_batch + ) + + fuse_mlp_allreduce = ( + self.layer_communicator.should_fuse_mlp_allreduce_with_next_layer( + forward_batch + ) + ) + mlp_reduce_scatter = self.layer_communicator.should_use_reduce_scatter( + forward_batch + ) + + with get_forward().scoped( + fuse_mlp_allreduce=fuse_mlp_allreduce, + mlp_reduce_scatter=mlp_reduce_scatter, + ): + if not ( + enable_moe_dense_fully_dp() + and (not self.is_layer_sparse) + and hidden_states.shape[0] == 0 + ): + hidden_states = self.mlp( + hidden_states, + forward_batch=forward_batch, + ) + + if fuse_mlp_allreduce: + hidden_states._sglang_needs_allreduce_fusion = True + else: + hidden_states, residual = self.layer_communicator.postprocess_layer( + hidden_states, residual, forward_batch + ) + + return hidden_states, residual + + @staticmethod + def shared_moe_coefficient_loader( + param: torch.Tensor, loaded_weight: torch.Tensor + ) -> None: + assert param.size() == loaded_weight.size() + + param.data.copy_(loaded_weight.to(torch.float32)) + return + + +class BailingMoELinearModel(nn.Module): + def __init__( + self, + config: PretrainedConfig, + quant_config: Optional[QuantizationConfig] = None, + prefix: str = "", + num_fused_shared_experts: int = 0, + ) -> None: + super().__init__() + self.pp_group = get_pp_group() + self.config = config + self.vocab_size = config.vocab_size + self.embed_dim = config.hidden_size + self.num_layers = config.num_hidden_layers + + self.layer_group_size = getattr(config, "layer_group_size", 1) + self.decoder_attention_types = [ + 0 if is_linear_layer(i, self.layer_group_size) else 1 + for i in range(self.num_layers) + ] + logger.info( + f"attention type of layers:{self.decoder_attention_types}, 0 is linear layer and 1 is softmax layer!" + ) + + assert ( + isinstance(self.layer_group_size, list) + or self.num_layers % self.layer_group_size == 0 + ), f"num_layers={self.num_layers} must be divided by layer_group_size={self.layer_group_size}" + + if self.pp_group.is_first_rank: + self.word_embeddings = VocabParallelEmbedding( + self.vocab_size, + self.embed_dim, + enable_tp=not is_dp_attention_enabled(), + org_num_embeddings=self.vocab_size, + ) + else: + self.word_embeddings = PPMissingLayer() + + self.alt_stream = get_stream("alt") if _is_cuda else None + + def layer_fn(idx, prefix): + layer_idx = idx + layer_config = copy.deepcopy(config) + layer_config.attention_type = self.decoder_attention_types[layer_idx] + + decoder_kwargs = { + "quant_config": quant_config, + "layer_id": layer_idx, + "num_fused_shared_experts": num_fused_shared_experts, + } + return BailingMoELinearDecoderLayer( + layer_config, + **decoder_kwargs, + prefix=prefix, + alt_stream=self.alt_stream, + ) + + self.layers, self.start_layer, self.end_layer = make_layers( + self.num_layers, + layer_fn, + pp_rank=self.pp_group.rank_in_group, + pp_size=self.pp_group.world_size, + prefix=f"{prefix}.layers", + ) + + linear_layer_nums = sum( + 1 for i in range(self.num_layers) if self.decoder_attention_types[i] == 0 + ) + logger.info(f"linear_layer_nums={linear_layer_nums}") + + norm_kwargs = {} + if hasattr(config, "rms_norm_eps"): + norm_kwargs["eps"] = config.rms_norm_eps + if self.pp_group.is_last_rank: + self.norm = RMSNorm(config.hidden_size, **norm_kwargs) + else: + self.norm = PPMissingLayer() + self.embed_scale = 1.0 + + # Hidden-state capture for speculative decoding (DSpark / DFlash / EAGLE3). + # When set, ``forward`` appends each captured layer's final + # ``hidden_states + residual`` (shape ``[N, hidden_size]``) to an + # aux list and returns ``(hidden_states, aux_hidden_states)``. + self.layers_to_capture: Optional[List[int]] = None + self.capture_aux_hidden_states = False + return + + def forward( + self, + input_ids: Optional[torch.Tensor], + positions: torch.Tensor, + forward_batch: Optional[ForwardBatch] = None, + inputs_embeds: Optional[torch.Tensor] = None, + pp_proxy_tensors: Optional[PPProxyTensors] = None, + ) -> Union[torch.Tensor, PPProxyTensors]: + if self.pp_group.is_first_rank: + if inputs_embeds is None: + hidden_states = self.word_embeddings(input_ids) + else: + hidden_states = inputs_embeds + residual = None + else: + assert pp_proxy_tensors is not None + hidden_states = pp_proxy_tensors["hidden_states"] + residual = pp_proxy_tensors["residual"] + + total_num_layers = self.end_layer - self.start_layer + device = inputs_embeds.device if inputs_embeds is not None else input_ids.device + zero_allocator = BumpAllocator( + buffer_size=total_num_layers * 2 * (2 if forward_batch.can_run_tbo else 1), + dtype=torch.float32, + device=device, + ) + + # DSpark / DFlash capture relies on the per-layer eager loop exposing the + # completed hidden state of each layer, so it is only supported when this + # rank owns the final pipeline stage (the aux list is consumed there). + # Gate on capture_hidden_mode so plain (non-capturing) prefills / decodes + # keep the zero-overhead path and only the dspark target-prefill asks for + # aux hidden states. + capture_mode = forward_batch.capture_hidden_mode + capture_aux = ( + self.capture_aux_hidden_states + and self.pp_group.is_last_rank + and self.layers_to_capture is not None + and capture_mode is not None + and capture_mode.need_capture() + ) + if capture_aux: + dspark_aux_hidden_states: List[torch.Tensor] = [] + + for i in range(self.start_layer, self.end_layer): + with get_global_expert_distribution_recorder().with_current_layer(i): + layer = self.layers[i] + hidden_states, residual = layer( + hidden_states=hidden_states, + positions=positions, + forward_batch=forward_batch, + residual=residual, + zero_allocator=zero_allocator, + ) + if ( + capture_aux + and i in self.layers_to_capture + and hidden_states.shape[0] != 0 + ): + if residual is None: + dspark_aux_hidden_states.append(hidden_states) + else: + dspark_aux_hidden_states.append(hidden_states + residual) + + if not self.pp_group.is_last_rank: + return PPProxyTensors( + {"hidden_states": hidden_states, "residual": residual} + ) + else: + if not forward_batch.forward_mode.is_idle(): + if residual is None: + hidden_states = self.norm(hidden_states) + else: + hidden_states, _ = self.norm(hidden_states, residual) + if capture_aux and len(dspark_aux_hidden_states) > 0: + return hidden_states, dspark_aux_hidden_states + return hidden_states + + +class BailingMoeV3ForCausalLM(nn.Module): + def __init__( + self, + *, + config, + quant_config: Optional[QuantizationConfig] = None, + prefix: str = "", + ) -> None: + super().__init__() + + self.pp_group = get_pp_group() + self.config = config + self.quant_config = quant_config + self.tp_size = get_parallel().tp_size + + # Determine num_fused_shared_experts + self.determine_num_fused_shared_experts() + + self.model = BailingMoELinearModel( + self.config, + quant_config, + prefix=add_prefix("model", prefix), + num_fused_shared_experts=self.num_fused_shared_experts, + ) + + if self.pp_group.is_last_rank: + self.lm_head = ( + self.model.word_embeddings + if config.tie_word_embeddings + else ParallelLMHead( + config.vocab_size, + config.hidden_size, + # bf16 lm_head (was fp32): fp32 vocab GEMM uses a slow sm80 + # path on Hopper; logits are still cast to fp32 for sampling + # in the logits processor. Accuracy-neutral on ling-v3. + params_dtype=torch.bfloat16, + quant_config=quant_config, + use_attn_tp_group=get_parallel().enable_dp_lm_head, + ) + ) + self.logits_processor = LogitsProcessor(config) + else: + self.lm_head = PPMissingLayer() + + self.capture_aux_hidden_states = False + + @property + def start_layer(self): + return self.model.start_layer + + @property + def end_layer(self): + return self.model.end_layer + + @classmethod + def shared_experts_fusion_disable_reason( + cls, + hf_config, + quant_config, + expected_architecture="BailingMoeV3ForCausalLM", + ): + num_shared_experts = getattr(hf_config, "num_shared_experts", 0) + if num_shared_experts == 0: + return None + if not get_moe_a2a_backend().is_none(): + return ( + "A2A MoE backend (e.g., DeepEP) is enabled. Fused shared experts is " + "incompatible with A2A backends because the extra shared expert ID cannot " + "be correctly routed." + ) + if hf_config.architectures[0] != expected_architecture: + return "Config does not support fused shared expert(s)." + if (not _is_cuda or torch.cuda.get_device_capability("cuda") < (8, 0)) and ( + not _is_hip or torch.cuda.get_device_capability("cuda") < (9, 4) + ): + return ( + "Only Bailing MoE V3 on NV-platform with capability >= 80 " + "or AMD-platform with capability >= gfx942(MI30x) can use shared experts fusion optimization." + ) + if quant_config and ( + quant_config.get_name() == "w4afp8" + or getattr(quant_config, "is_fp4_experts", False) + ): + return ( + "Bailing MoE V3 uses different quant methods for routed experts " + "and shared experts." + ) + if quant_config and quant_config.get_name() == "compressed_tensors": + from sglang.srt.layers.quantization.compressed_tensors.utils import ( + should_ignore_layer, + ) + + ignore = getattr(quant_config, "ignore", ()) + fused_mapping = getattr(quant_config, "packed_modules_mapping", {}) + shared_ignored = should_ignore_layer( + "model.layers.0.mlp.shared_experts.gate_proj", + ignore=ignore, + fused_mapping=fused_mapping, + ) + routed_ignored = should_ignore_layer( + "model.layers.0.mlp.experts.0.gate_proj", + ignore=ignore, + fused_mapping=fused_mapping, + ) + if shared_ignored != routed_ignored: + return ( + "Bailing MoE V3 uses different quant methods for routed experts " + "and shared experts." + ) + shared_expert_intermediate_size = getattr( + hf_config, + "moe_shared_expert_intermediate_size", + hf_config.moe_intermediate_size * num_shared_experts, + ) + if shared_expert_intermediate_size != hf_config.moe_intermediate_size: + return ( + f"Shared experts have different intermediate_size ({shared_expert_intermediate_size}) " + f"from routed experts ({hf_config.moe_intermediate_size}). Fusion requires them to be equal." + ) + if quant_config and quant_config.get_name() == "fp8": + weight_block_size = getattr(quant_config, "weight_block_size", None) + if weight_block_size is not None: + block_n, block_k = weight_block_size + tp_size = get_parallel().tp_size + moe_ep_size = get_parallel().moe_ep_size + moe_tp_size = tp_size // moe_ep_size if moe_ep_size > 1 else tp_size + intermediate_size_per_partition = ( + hf_config.moe_intermediate_size // moe_tp_size + ) + if ( + intermediate_size_per_partition % block_n != 0 + or intermediate_size_per_partition % block_k != 0 + ): + return ( + "FP8 blockwise quantization requires " + f"intermediate_size_per_partition ({intermediate_size_per_partition}) " + f"to be divisible by block_n ({block_n}) and block_k ({block_k}). " + f"Current config: moe_intermediate_size={hf_config.moe_intermediate_size}, " + f"tp_size={tp_size}, moe_tp_size={moe_tp_size}. Consider using " + "--disable-shared-experts-fusion to use padding solution instead." + ) + return None + + def determine_num_fused_shared_experts(self): + self.num_fused_shared_experts = ( + 0 + if is_shared_experts_fusion_disabled() + else getattr(self.config, "num_shared_experts", 0) + ) + if self.num_fused_shared_experts == 0: + return + + # Safety check: current CUDA implementation only supports num_fused_shared_experts == 1. + # The grouped_topk_gpu and _post_process_topk_ids functions only handle the last column, + # which is incorrect when num_fused_shared_experts > 1. + # AMD platform with aiter handles this correctly via fused_append_shared_experts kernel. + if self.num_fused_shared_experts > 1 and not _is_hip: + raise ValueError( + f"num_fused_shared_experts > 1 ({self.num_fused_shared_experts}) is not " + f"supported on CUDA platform. The current TopK implementation only handles " + f"one fused shared expert. AMD platform with aiter supports multiple shared experts." + ) + + moe_ep_size = get_parallel().moe_ep_size + if moe_ep_size > 1: + log_info_on_rank0( + logger, + f"Shared experts fusion optimization is enabled with {self.num_fused_shared_experts} fused shared expert(s) under EP mode (ep_size={moe_ep_size}). " + f"Shared experts will be distributed across GPUs along with routed experts.", + ) + else: + log_info_on_rank0( + logger, + f"Shared experts fusion optimization is enabled with {self.num_fused_shared_experts} fused shared expert(s).", + ) + + def post_load_weights(self, is_nextn=False, weight_names=None): + if is_nextn: + layer_ids = [self.config.num_hidden_layers] + else: + if weight_names is None: + layer_ids = range(self.model.start_layer, self.model.end_layer) + else: + layer_ids = set() + for name in weight_names: + if "kv_b_proj" in name: + layer_id = int(name.split(".")[2]) + if ( + layer_id < self.model.end_layer + and layer_id >= self.model.start_layer + ): + layer_ids.add(layer_id) + for layer_id in layer_ids: + self_attn = ( + self.model.layers[layer_id].attention + if not is_nextn + else self.model.decoder.attention + ) + if not hasattr(self_attn, "kv_b_proj"): + continue + if hasattr(self_attn.kv_b_proj, "qweight"): + if _is_cuda or _is_hip: + w = awq_dequantize( + self_attn.kv_b_proj.qweight, + self_attn.kv_b_proj.scales, + self_attn.kv_b_proj.qzeros, + ).T + else: + w = awq_dequantize( + self_attn.kv_b_proj.qweight, + self_attn.kv_b_proj.scales, + self_attn.kv_b_proj.qzeros, + 0, + 0, + 0, + ).T + else: + w = self_attn.kv_b_proj.weight + # NOTE(HandH1998): Since `bmm_fp8` only supports per-tensor scale, we have to requantize `self_attn.kv_b_proj`. + # This may affect the accuracy of fp8 model. + # Fix deepseek v3 blockwise bmm by using deep_gemm + use_deep_gemm_bmm = False + + if w.dtype in ( + torch.float8_e4m3fn, + torch.float8_e4m3fnuz, + ): + if ( + hasattr(self.quant_config, "weight_block_size") + and self.quant_config.weight_block_size is not None + ): + weight_block_size = self.quant_config.weight_block_size + assert hasattr(self_attn.kv_b_proj, "weight_scale_inv") + if _is_fp8_fnuz: + weight, weight_scale, _ = normalize_e4m3fn_to_e4m3fnuz( + weight=w, + weight_scale=self_attn.kv_b_proj.weight_scale_inv, + input_scale=None, + ) + else: + weight = w + weight_scale = self_attn.kv_b_proj.weight_scale_inv + + if ( + _is_cuda + and weight_block_size[0] == 128 + and weight_block_size[1] == 128 + ): + if ( + deep_gemm_wrapper.ENABLE_JIT_DEEPGEMM + and not deep_gemm_wrapper.DEEPGEMM_BLACKWELL + and envs.SGLANG_USE_DEEPGEMM_BMM.get() + ): + block_scale = weight_scale + use_deep_gemm_bmm = True + else: + w = block_quant_dequant( + weight, + weight_scale, + weight_block_size, + torch.bfloat16, + ) + else: + w, scale = block_quant_to_tensor_quant( + weight, weight_scale, weight_block_size + ) + self_attn.w_scale = scale + else: + if _is_fp8_fnuz: + weight, weight_scale, _ = normalize_e4m3fn_to_e4m3fnuz( + weight=w, + weight_scale=self_attn.kv_b_proj.weight_scale, + input_scale=None, + ) + else: + weight = w + weight_scale = self_attn.kv_b_proj.weight_scale + + w, scale = channel_quant_to_tensor_quant(weight, weight_scale) + self_attn.w_scale = scale + + if w.dtype == torch.int8: + if hasattr(self.quant_config, "weight_block_size"): + weight_block_size = self.quant_config.weight_block_size + if weight_block_size is not None: + assert hasattr(self_attn.kv_b_proj, "weight_scale_inv") + weight = w + weight_scale = self_attn.kv_b_proj.weight_scale_inv + w = int8_block_dequant( + weight, weight_scale, weight_block_size + ).to(torch.bfloat16) + else: + w = w.to(torch.bfloat16) * self_attn.kv_b_proj.weight_scale.to( + torch.bfloat16 + ) + + w_kc, w_vc = w.unflatten( + 0, (-1, self_attn.qk_nope_head_dim + self_attn.v_head_dim) + ).split([self_attn.qk_nope_head_dim, self_attn.v_head_dim], dim=1) + if not use_deep_gemm_bmm: + self_attn.w_kc = bind_or_assign( + self_attn.w_kc, w_kc.transpose(1, 2).contiguous().transpose(1, 2) + ) + self_attn.w_vc = bind_or_assign( + self_attn.w_vc, w_vc.contiguous().transpose(1, 2) + ) + if ( + hasattr(self_attn.kv_b_proj, "weight_scale") + and self_attn.w_scale is None + ): + self_attn.w_scale = bind_or_assign( + self_attn.w_scale, self_attn.kv_b_proj.weight_scale + ) + if _is_hip: + self_attn.w_scale *= 2.0 + # TODO: remove this after adding FP8 support in bmm cpu kernel + if _is_cpu and _is_cpu_amx_available and w.dtype == torch.float8_e4m3fn: + self_attn.w_kc = ( + self_attn.w_kc.to(torch.bfloat16) * self_attn.w_scale + ) + self_attn.w_vc = ( + self_attn.w_vc.to(torch.bfloat16) * self_attn.w_scale + ) + else: + num_tiles_k = self_attn.qk_nope_head_dim // weight_block_size[1] + num_tiles_n = self_attn.v_head_dim // weight_block_size[0] + ws_kc, ws_vc = block_scale.unflatten( + 0, (-1, (num_tiles_k + num_tiles_n)) + ).split([num_tiles_k, num_tiles_n], dim=1) + self_attn.w_scale_k = bind_or_assign( + self_attn.w_scale_k, ws_kc.transpose(1, 2).contiguous() + ) + self_attn.w_scale_v = bind_or_assign( + self_attn.w_scale_v, ws_vc.contiguous() + ) + self_attn.w_kc = bind_or_assign( + self_attn.w_kc, w_kc.transpose(1, 2).contiguous() + ) + self_attn.w_vc = bind_or_assign(self_attn.w_vc, w_vc.contiguous()) + self_attn.use_deep_gemm_bmm = True + + # NOTE: no model-level ue8m0 requant here. Fp8LinearMethod / + # Fp8MoEMethod .process_weights_after_loading requant every + # block-fp8 weight for DeepGEMM (with the format_ue8m0 reentry + # guard); a second model-level pass double-packs the scales and + # crashes, which is why deepseek_v2 dropped its copy too. + + def get_decoder_attention_types(self): + return self.model.decoder_attention_types + + def get_input_embeddings(self) -> nn.Module: + return self.model.word_embeddings + + def set_dspark_layers_to_capture(self, layer_ids: List[int]) -> None: + """Configure per-layer hidden-state capture for DSpark. + + The target model exposes the final ``hidden_states + residual`` of each + requested layer so the DSpark draft can project them into its context KV + cache. ``layer_ids`` are raw target decoder layer indices (e.g. + ``[1, 11, 23, 29, 35]``); capture happens after layer ``i`` runs, so no + ``+1`` offset is applied (in contrast to the DFlash convention used by + models that capture before the layer runs). + """ + if not self.pp_group.is_last_rank: + return + if layer_ids is None: + raise ValueError( + "DSPARK requires explicit layer_ids for aux hidden capture." + ) + self.capture_aux_hidden_states = True + self.model.capture_aux_hidden_states = True + self.model.layers_to_capture = list(layer_ids) + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + forward_batch: ForwardBatch, + inputs_embeds: Optional[torch.Tensor] = None, + pp_proxy_tensors: Optional[PPProxyTensors] = None, + ) -> Union[torch.Tensor, PPProxyTensors]: + hidden_states = self.model( + input_ids=input_ids, + positions=positions, + inputs_embeds=inputs_embeds, + forward_batch=forward_batch, + pp_proxy_tensors=pp_proxy_tensors, + ) + if self.pp_group.is_last_rank: + aux_hidden_states = None + if self.capture_aux_hidden_states and isinstance(hidden_states, tuple): + hidden_states, aux_hidden_states = hidden_states + return self.logits_processor( + input_ids, + # keep hidden_states in bf16 so the lm_head matmul runs in bf16 + # (logits are cast to fp32 inside the logits processor) + hidden_states, + self.lm_head, + forward_batch, + aux_hidden_states=aux_hidden_states, + ) + else: + return hidden_states + + @staticmethod + def weight_direct_load(param: torch.Tensor, loaded_weight: torch.Tensor): + assert param.size() == loaded_weight.size() + param.data.copy_(loaded_weight) + + @classmethod + def get_model_config_for_expert_location(cls, config): + num_groups = getattr(config, "n_group", 0) + from sglang.srt.eplb.expert_location import ModelConfigForExpertLocation + + return ModelConfigForExpertLocation( + num_layers=config.num_hidden_layers, + num_logical_experts=config.num_experts, + num_groups=None if num_groups == 0 else num_groups, + ) + + def load_weights( + self, weights: Iterable[Tuple[str, torch.Tensor]], is_nextn=False + ) -> Set[str]: + def load_linear_attn_weight( + name: str, loaded_weight: torch.Tensor, self + ) -> None: + if is_pp_missing_parameter(name, self): + return + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", self.weight_direct_load) + if "A_log" in name: + # A_log param shape differs from Kimi's + loaded_weight = loaded_weight[None, None, :, None] + weight_loader(param, loaded_weight) + return + + stacked_params_mapping = [ + (".gate_up_proj", ".gate_proj", 0), + (".gate_up_proj", ".up_proj", 1), + (".fused_qkvbfg_proj", ".q_proj", 0), + (".fused_qkvbfg_proj", ".k_proj", 1), + (".fused_qkvbfg_proj", ".v_proj", 2), + (".fused_qkvbfg_proj", ".b_proj", 3), + (".fused_qkvbfg_proj", ".f_proj", 3), + (".fused_qkvbfg_proj", ".g_proj", 4), + (".fused_qkvbfg_a_proj", ".q_proj", 0), + (".fused_qkvbfg_a_proj", ".k_proj", 1), + (".fused_qkvbfg_a_proj", ".v_proj", 2), + (".fused_qkvbfg_a_proj", ".b_proj", 3), + (".fused_qkvbfg_a_proj", ".f_a_proj", 4), + (".fused_qkvbfg_a_proj", ".g_a_proj", 5), + (".fused_fg_b_proj", ".f_b_proj", 0), + (".fused_fg_b_proj", ".g_b_proj", 1), + (".qkv_proj", ".q_proj", "q"), + (".qkv_proj", ".k_proj", "k"), + (".qkv_proj", ".v_proj", "v"), + (".qkv_conv1d", ".q_conv1d", 0), + (".qkv_conv1d", ".k_conv1d", 1), + (".qkv_conv1d", ".v_conv1d", 2), + ] + expert_params_mapping = FusedMoE.make_expert_params_mapping( + ckpt_gate_proj_name="gate_proj", + ckpt_down_proj_name="down_proj", + ckpt_up_proj_name="up_proj", + num_experts=self.config.num_experts + self.num_fused_shared_experts, + ) + + if is_nextn: + nextn_layer_id = resolve_nextn_layer_id(self.config) + nextn_layer_prefix = f"model.layers.{nextn_layer_id}" + + params_dict = dict(self.named_parameters()) + loaded_params: Set[str] = set() + weight_names = [] + fuse_qkv_a_proj = hasattr(self.config, "q_lora_rank") and ( + self.config.q_lora_rank is not None + ) + cached_a_proj = {} if fuse_qkv_a_proj else None + + if self.num_fused_shared_experts > 0: + log_info_on_rank0(logger, "Shared experts fusion optimization enabled.") + + for name, loaded_weight in weights: + if name.startswith("model.mtp"): + continue + layer_idx = None + if "model.layers." in name: + layer_idx = int(name.split(".")[2]) + if not is_nextn and layer_idx >= self.config.num_hidden_layers: + continue + if ( + ("v_head" in name) + or ("inv_freq" in name) + or (self.config.tie_word_embeddings and "lm_head" in name) + ): + continue + + if is_nextn: + if not name.startswith(nextn_layer_prefix): + continue + rewritten = rewrite_nextn_weight_name(name, nextn_layer_prefix) + if rewritten is None: + continue + name = rewritten + layer_idx = 0 + + if self.num_fused_shared_experts > 0 and "mlp.shared_experts" in name: + name = name.replace( + "mlp.shared_experts", + f"mlp.experts.{self.config.num_experts}", + ) + + weight_names.append(name) + + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + if "mlp.experts" in name: + continue + if is_pp_missing_parameter(name, self): + continue + if param_name in { + ".fused_qkvbfg_a_proj", + ".fused_fg_b_proj", + ".fused_qkvbfg_proj", + }: + layer = ( + self.model.decoder + if is_nextn + else self.model.layers[int(name.split(".")[2])] + ) + if is_pp_missing_parameter(name, layer): + continue + layer_attn = layer.attention + if not getattr(layer_attn, "do_fuse_qkvbfg", False): + continue + if param_name == ".fused_qkvbfg_proj": + if not getattr(layer_attn, "no_kda_lora", False): + continue + if weight_name == ".b_proj" and not getattr( + layer_attn, "fuse_no_lora_beta", False + ): + continue + if weight_name in {".f_proj", ".g_proj"} and getattr( + layer_attn, "fuse_no_lora_beta", False + ): + shard_id += 1 + elif getattr(layer_attn, "no_kda_lora", False): + continue + + new_name = name.replace(weight_name, param_name) + if new_name not in params_dict: + continue + + param = params_dict[new_name] + weight_loader = param.weight_loader + weight_loader(param, loaded_weight, shard_id) + break + else: + for mapping in expert_params_mapping: + param_name, weight_name, expert_id, shard_id = mapping + if weight_name not in name: + continue + name = name.replace(weight_name, param_name) + + if name not in params_dict: + continue + if is_pp_missing_parameter(name, self): + continue + param = params_dict[name] + weight_loader = param.weight_loader + weight_loader( + param, + loaded_weight, + name, + shard_id=shard_id, + expert_id=expert_id, + ) + break + else: + if name.endswith(".bias") and name not in params_dict: + continue + if "slope" in name: + continue + + if fuse_qkv_a_proj and ( + "q_a_proj" in name or "kv_a_proj_with_mqa" in name + ): + cached_a_proj[name] = loaded_weight + q_a_proj_name = ( + name + if "q_a_proj" in name + else name.replace("kv_a_proj_with_mqa", "q_a_proj") + ) + kv_a_proj_name = ( + name + if "kv_a_proj_with_mqa" in name + else name.replace("q_a_proj", "kv_a_proj_with_mqa") + ) + + if ( + q_a_proj_name in cached_a_proj + and kv_a_proj_name in cached_a_proj + ): + q_a_proj_weight = cached_a_proj[q_a_proj_name] + kv_a_proj_weight = cached_a_proj[kv_a_proj_name] + cat_dim = 0 + if self.quant_config is not None and ( + self.quant_config.get_name() == "awq" + or self.quant_config.get_name() == "awq_marlin" + or self.quant_config.get_name() == "moe_wna16" + ): + cat_dim = 1 + fused_weight = torch.cat( + [q_a_proj_weight, kv_a_proj_weight], dim=cat_dim + ) + param_name = ( + name.replace("q_a_proj", "fused_qkv_a_proj_with_mqa") + if "q_a_proj" in name + else name.replace( + "kv_a_proj_with_mqa", + "fused_qkv_a_proj_with_mqa", + ) + ) + if param_name not in params_dict: + continue + param = params_dict[param_name] + weight_loader = getattr( + param, "weight_loader", default_weight_loader + ) + + weight_loader(param, fused_weight) + cached_a_proj.pop(q_a_proj_name) + cached_a_proj.pop(kv_a_proj_name) + else: + if name not in params_dict: + name = name.replace(".dense.", ".o_proj.") + if name not in params_dict: + continue + if is_pp_missing_parameter(name, self): + continue + if ( + "attention" in name + and "slope" not in name + and is_linear_layer(layer_idx, self.model.layer_group_size) + ): + load_linear_attn_weight(name, loaded_weight, self) + loaded_params.add(name) + continue + + param = params_dict[name] + weight_loader = getattr( + param, "weight_loader", default_weight_loader + ) + weight_loader(param, loaded_weight) + loaded_params.add(name) + self.post_load_weights(is_nextn=is_nextn, weight_names=weight_names) + + return loaded_params + + def post_process_weights_if_quant(self): + for name, module in self.named_modules(): + quant_method = getattr(module, "quant_method", None) + if quant_method is not None: + post_process = getattr( + quant_method, "process_weights_after_loading", None + ) + if post_process is not None: + post_process(module) + + def get_embed_and_head(self): + return self.model.word_embeddings.weight, self.lm_head.weight + + def set_embed_and_head(self, embed, head): + del self.model.word_embeddings.weight + del self.lm_head.weight + self.model.word_embeddings.weight = embed + self.lm_head.weight = head + torch.cuda.empty_cache() + + +EntryClass = [ + BailingMoeV3ForCausalLM, +] diff --git a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mha.py b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mha.py index 01e66499a..119f73a8c 100644 --- a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mha.py +++ b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mha.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import torch @@ -259,9 +259,12 @@ class DeepseekMHAForwardMixin: k: torch.Tensor, v: torch.Tensor, forward_batch: ForwardBatch, + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: attn_output = self.attn_mha(q, k, v, forward_batch, save_kv_cache=False) attn_output = attn_output.reshape(-1, self.num_local_heads * self.v_head_dim) + if gate is not None: + attn_output = self._apply_gated(attn_output, gate) output, _ = self.o_proj(attn_output) return output @@ -289,6 +292,7 @@ class DeepseekMHAForwardMixin: k: torch.Tensor, v: torch.Tensor, forward_batch: ForwardBatch, + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: has_extend_prefix = forward_batch.extend_prefix_lens_cpu is not None and any( forward_batch.extend_prefix_lens_cpu @@ -316,6 +320,8 @@ class DeepseekMHAForwardMixin: ) attn_output = attn_output.reshape(-1, self.num_local_heads * self.v_head_dim) + if gate is not None: + attn_output = self._apply_gated(attn_output, gate) output, _ = self.o_proj(attn_output) return output @@ -337,6 +343,7 @@ class DeepseekMHAForwardMixin: k: torch.Tensor, v: torch.Tensor, forward_batch: ForwardBatch, + gate: Optional[torch.Tensor] = None, ) -> torch.Tensor: has_extend_prefix = any(forward_batch.extend_prefix_lens_cpu) # Only initialize the info once @@ -347,7 +354,7 @@ class DeepseekMHAForwardMixin: forward_batch.mha_return_lse = False # Do mha for extended part without prefix forward_batch.set_attn_attend_prefix_cache(False) - return self.forward_normal_core(q, k, v, forward_batch) + return self.forward_normal_core(q, k, v, forward_batch, gate) def _chunked_prefix_attn_mha( self: DeepseekV2AttentionMLA, diff --git a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla.py b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla.py index 3e02ed9de..4a38d1b95 100644 --- a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla.py +++ b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla.py @@ -681,6 +681,7 @@ class DeepseekMLAForwardMixin: topk_indices, llama_4_scaling, fusion_plan: Optional[MlaBmmFusionPlan] = None, + gate: Optional[torch.Tensor] = None, ): save_kv_cache = True @@ -910,6 +911,8 @@ class DeepseekMLAForwardMixin: attn_bmm_output = apply_kv_b_lora_v_correction( self, attn_output, attn_bmm_output ) + if gate is not None: + attn_bmm_output = self._apply_gated(attn_bmm_output, gate) output, _ = self.o_proj(attn_bmm_output) if self.next_skip_topk is None: diff --git a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_cpu.py b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_cpu.py index 40420c5fe..34c0369b9 100644 --- a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_cpu.py +++ b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_cpu.py @@ -124,6 +124,7 @@ class DeepseekMLACpuForwardMixin: v_input, forward_batch, zero_allocator, + gate=None, ): assert self.q_lora_rank is not None and use_intel_amx_backend( self @@ -155,6 +156,8 @@ class DeepseekMLACpuForwardMixin: self.w_scale if self.qkv_proj_with_rope_is_fp8 else None, # scale ) attn_output = output + if gate is not None: + attn_output = self._apply_gated(attn_output, gate) output, _ = self.o_proj(attn_output) return output diff --git a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_rocm.py b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_rocm.py index d8bca2075..01fd424c1 100644 --- a/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_rocm.py +++ b/python/sglang/srt/models/deepseek_common/attention_forward_methods/forward_mla_fused_rope_rocm.py @@ -173,6 +173,7 @@ class DeepseekMLAFusedRopeRocmForwardMixin: k_input, forward_batch, zero_allocator, + gate=None, ): decode_attention_fwd_grouped_rope( q_input, @@ -224,6 +225,8 @@ class DeepseekMLAFusedRopeRocmForwardMixin: else: attn_bmm_output = torch.bmm(attn_output.transpose(0, 1), self.w_vc) attn_output = attn_bmm_output.transpose(0, 1).flatten(1, 2) + if gate is not None: + attn_output = self._apply_gated(attn_output, gate) output, _ = self.o_proj(attn_output) return output diff --git a/python/sglang/srt/models/deepseek_v4_dspark.py b/python/sglang/srt/models/deepseek_v4_dspark.py index 8e55b79ee..346b163eb 100644 --- a/python/sglang/srt/models/deepseek_v4_dspark.py +++ b/python/sglang/srt/models/deepseek_v4_dspark.py @@ -488,13 +488,15 @@ class DSparkV4MarkovHead(nn.Module): first_prev_tokens: torch.Tensor, hidden_states: Optional[torch.Tensor], sampler: StepSampler, - ) -> Tuple[torch.Tensor, torch.Tensor]: + collect_corrected: bool = True, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: return run_markov_block( self, base_logits, first_prev_tokens=first_prev_tokens, hidden_states=hidden_states, sampler=sampler, + collect_corrected=collect_corrected, ) diff --git a/python/sglang/srt/models/dspark.py b/python/sglang/srt/models/dspark.py index a0447e606..33e471124 100644 --- a/python/sglang/srt/models/dspark.py +++ b/python/sglang/srt/models/dspark.py @@ -7,6 +7,9 @@ import torch import torch.nn.functional as F from torch import nn +from sglang.kernels.ops.speculative.dspark.dspark_draft_model import ( + MarkovGreedyStep, +) from sglang.srt.distributed.communication_op import tensor_model_parallel_all_gather from sglang.srt.environ import envs from sglang.srt.layers.linear import ReplicatedLinear @@ -52,7 +55,8 @@ def run_markov_block( first_prev_tokens: torch.Tensor, hidden_states: Optional[torch.Tensor], sampler: StepSampler, -) -> Tuple[torch.Tensor, torch.Tensor]: + collect_corrected: bool = True, +) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: batch_size, proposal_len = base_logits.shape[:2] if proposal_len == 0: empty = torch.empty(batch_size, 0, dtype=torch.long, device=base_logits.device) @@ -70,11 +74,12 @@ def run_markov_block( ) next_tokens = sampler(step_logits, step_idx) sampled_tokens.append(next_tokens) - corrected_logits.append(step_logits.unsqueeze(1)) + if collect_corrected: + corrected_logits.append(step_logits.unsqueeze(1)) prev_tokens = next_tokens return ( torch.stack(sampled_tokens, dim=1), - torch.cat(corrected_logits, dim=1), + torch.cat(corrected_logits, dim=1) if collect_corrected else None, ) @@ -134,15 +139,51 @@ class VanillaMarkov(nn.Module): first_prev_tokens: torch.Tensor, hidden_states: Optional[torch.Tensor], sampler: StepSampler, - ) -> Tuple[torch.Tensor, torch.Tensor]: + collect_corrected: bool = True, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: return run_markov_block( self, base_logits, first_prev_tokens=first_prev_tokens, hidden_states=hidden_states, sampler=sampler, + collect_corrected=collect_corrected, ) + def sample_block_greedy_fused( + self, + base_logits: torch.Tensor, + *, + first_prev_tokens: torch.Tensor, + ) -> Optional[torch.Tensor]: + """Greedy-only draft-block sampling via the fused per-step + [bias-dot + add + argmax] kernel (see MarkovGreedyStep) — one pass over + markov_w2 per step instead of GEMV + add + two-pass argmax, and no + full-vocab bias/step-logits materialization. + + Only valid for the vanilla step bias (bias = w2 @ w1[prev]); subclasses + whose step bias depends on hidden state override this to return None so + the caller falls back to sample_block. + """ + if not base_logits.is_cuda: + return None + batch_size, proposal_len = base_logits.shape[:2] + if proposal_len == 0: + return torch.empty( + batch_size, 0, dtype=torch.long, device=base_logits.device + ) + sampled_tokens = [] + prev_tokens = first_prev_tokens.long() + for step_idx in range(proposal_len): + prev_embeds = self.get_prev_embeddings(prev_tokens) + prev_tokens = MarkovGreedyStep.execute( + base_logits=base_logits[:, step_idx, :], + prev_embeds=prev_embeds, + w2_weight=self.markov_w2.weight, + ) + sampled_tokens.append(prev_tokens) + return torch.stack(sampled_tokens, dim=1) + class Nemotron35VanillaMarkov(VanillaMarkov): """Checkpoint-quantized Markov head used only by Nemotron 3.5 DSpark.""" @@ -207,6 +248,16 @@ class GatedMarkovHead(VanillaMarkov): ) return self.project_bias(gate * prev_embeddings) + def sample_block_greedy_fused( + self, + base_logits: torch.Tensor, + *, + first_prev_tokens: torch.Tensor, + ) -> Optional[torch.Tensor]: + # The gated step bias depends on hidden state; the fused vanilla + # kernel does not apply. + return None + class RNNHead(VanillaMarkov): @@ -277,7 +328,8 @@ class RNNHead(VanillaMarkov): first_prev_tokens: torch.Tensor, hidden_states: Optional[torch.Tensor], sampler: StepSampler, - ) -> Tuple[torch.Tensor, torch.Tensor]: + collect_corrected: bool = True, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: if hidden_states is None: raise ValueError("RNNHead requires hidden_states.") batch_size, proposal_len = base_logits.shape[:2] @@ -302,13 +354,24 @@ class RNNHead(VanillaMarkov): step_logits = base_logits[:, step_idx, :] + bias next_tokens = sampler(step_logits, step_idx) sampled_tokens.append(next_tokens) - corrected_logits.append(step_logits.unsqueeze(1)) + if collect_corrected: + corrected_logits.append(step_logits.unsqueeze(1)) prev_tokens = next_tokens return ( torch.stack(sampled_tokens, dim=1), - torch.cat(corrected_logits, dim=1), + torch.cat(corrected_logits, dim=1) if collect_corrected else None, ) + def sample_block_greedy_fused( + self, + base_logits: torch.Tensor, + *, + first_prev_tokens: torch.Tensor, + ) -> Optional[torch.Tensor]: + # The recurrent step bias depends on hidden state; the fused vanilla + # kernel does not apply. + return None + def build_markov_head(config) -> Optional[nn.Module]: markov_rank = int(getattr(config, "markov_rank", 0)) @@ -441,6 +504,15 @@ class DSparkDraftMixin: self.markov_head = build_markov_head(config) self.confidence_head = build_confidence_head(config) self.lm_head: Optional[nn.Module] = None + # Expose the draft's own layer count so the draft ModelRunner sizes the + # draft KV pool correctly. Some DSpark draft checkpoints inherit the + # target's ``num_nextn_predict_layers`` (>0) on the config; without this + # attribute the runner's MTP heuristic (model_runner.py) would size the + # pool to ``num_nextn_predict_layers`` instead of the real draft depth and + # the per-layer ``set_kv_buffer`` in ``write_target_hidden_kv`` would go + # out of range. DSv4 (MoE) drafts expose this via ``num_stages``; mirror + # that convention for dense DSpark drafts. + self.num_stages = int(config.num_hidden_layers) def attach_shared_modules( self, *, embed_tokens: nn.Module, lm_head: nn.Module @@ -752,7 +824,6 @@ class DSparkDraftMixin: kv_all = F.linear(ctx_hidden, stacked["weight"], stacked["bias"]) kv_all = kv_all.view(tokens, num_layers, 2, kv_size) - # Batched per-head k-norm across layers (fp32 variance + weight, cast back). k32 = ( kv_all[:, :, 0, :] .reshape(tokens, num_layers, num_kv_heads, head_dim) @@ -762,11 +833,9 @@ class DSparkDraftMixin: k32 = k32 * torch.rsqrt(variance + stacked["eps"]) k32 = k32 * stacked["k_norm_weight"].view(1, num_layers, 1, head_dim) k_all = k32.to(ctx_hidden.dtype) - # One RoPE over all layers' heads (shared rotary params + positions). k_flat = k_all.reshape(tokens, num_layers * kv_size) dummy_q = k_flat.new_empty(k_flat.shape) _, k_flat = attn0.rotary_emb(positions, dummy_q, k_flat) - # [layers, tokens, heads, dim]: per-layer slices are contiguous views. k_all = ( k_flat.view(tokens, num_layers, num_kv_heads, head_dim) .permute(1, 0, 2, 3) @@ -796,4 +865,18 @@ class Qwen3DSparkModel(DSparkDraftModel): pass -EntryClass = [Qwen3DSparkModel, DSparkDraftModel] +class LingDSparkModel(DSparkDraftModel): + """Qwen3-shaped DSpark draft for Ling / Bailing-MoE target families. + + The DeepSpec Ling draft (``deepspec.modeling.dspark.ling``) is byte-for-byte a + Qwen3DSparkModel — a short stack of Qwen3 draft layers sharing the target + embedding / lm_head. The architecture tag ``LingDSparkModel`` on the draft + checkpoint only distinguishes the target family for resume / error messages + (see ``deepspec/modeling/dspark/ling/modeling.py``); the checkpoint weights + line up exactly with ``Qwen3DSparkModel``, so we reuse the same backbone. + """ + + pass + + +EntryClass = [Qwen3DSparkModel, LingDSparkModel, DSparkDraftModel] diff --git a/python/sglang/srt/models/kimi_linear.py b/python/sglang/srt/models/kimi_linear.py index 506353b0e..d1ab4a1f6 100644 --- a/python/sglang/srt/models/kimi_linear.py +++ b/python/sglang/srt/models/kimi_linear.py @@ -56,7 +56,7 @@ from sglang.srt.utils.common import BumpAllocator, add_prefix, set_weight_attrs def _get_kda_local_num_heads(num_heads: int, tp_size: int) -> int: if num_heads % tp_size != 0: raise ValueError( - f"KDA num_heads ({num_heads}) must be divisible by global tp_size ({tp_size})" + f"KDA num_heads ({num_heads}) must be divisible by shard tp_size ({tp_size})" ) return num_heads // tp_size @@ -191,11 +191,41 @@ class KimiDeltaAttention(nn.Module): quant_config: Optional[QuantizationConfig] = None, rms_norm_eps: float = 1e-5, prefix: str = "", + no_kda_lora: bool = False, + safe_gate: bool = False, + lower_bound: Optional[float] = None, + reduce_results: bool = True, + shard_on_attn_tp: bool = False, + v_head_dim: Optional[int] = None, **kwargs, ) -> None: + """Kimi Delta Attention. + + The keyword arguments after ``prefix`` exist so hybrid models (Ling-V3 / + BailingMoeV3) can reuse this module; every default reproduces the plain + Kimi-Linear behaviour exactly: + + no_kda_lora: fold the f/g low-rank (LoRA) projections away and fuse + q/k/v/beta/f/g into one column-parallel GEMM. + safe_gate / lower_bound: clamp the forget gate from below. ``lower_bound`` + is ignored unless ``safe_gate`` is set. + reduce_results: forwarded to ``o_proj``; set False when the caller does + its own all-reduce (e.g. a fused MoE/attention communicator). + shard_on_attn_tp: shard on the attention-TP group instead of the global + TP group. Required under DP attention, where attn_tp_size < tp_size. + v_head_dim: asymmetric value head dim; defaults to the key head dim. + """ super().__init__() self.tp_size = get_parallel().tp_size self.attn_tp_size = get_parallel().attn_tp_size + # Group the weights are sharded over. Defaults to the global TP group, + # which is what plain Kimi-Linear has always used. + if shard_on_attn_tp: + self.shard_tp_size = self.attn_tp_size + self.shard_tp_rank = get_parallel().attn_tp_rank + else: + self.shard_tp_size = self.tp_size + self.shard_tp_rank = get_parallel().tp_rank self.hidden_size = hidden_size self.config = config self.head_dim = config.linear_attn_config["head_dim"] @@ -203,18 +233,67 @@ class KimiDeltaAttention(nn.Module): self.num_k_heads = config.linear_attn_config["num_heads"] self.num_v_heads = config.linear_attn_config["num_heads"] self.head_k_dim = config.linear_attn_config["head_dim"] - self.head_v_dim = config.linear_attn_config["head_dim"] + self.head_v_dim = ( + v_head_dim + if v_head_dim is not None + else config.linear_attn_config["head_dim"] + ) self.layer_idx = layer_idx self.prefix = prefix - self.local_num_heads = _get_kda_local_num_heads(self.num_heads, self.tp_size) + self.safe_gate = safe_gate + self.lower_bound = lower_bound if safe_gate else None + self.local_num_heads = _get_kda_local_num_heads( + self.num_heads, self.shard_tp_size + ) projection_size = self.head_dim * self.num_heads self.conv_size = config.linear_attn_config["short_conv_kernel_size"] + self.no_kda_lora = no_kda_lora # TODO: support fusion with quant - self.do_fuse_qkvbfg = quant_config is None + self.do_fuse_qkvbfg = self.no_kda_lora or quant_config is None + # Beta joins the fused GEMM only when nothing is quantized. + self.fuse_no_lora_beta = self.no_kda_lora and quant_config is None - if self.do_fuse_qkvbfg: + if self.do_fuse_qkvbfg and self.no_kda_lora: + # No LoRA: f/g are full-rank, so q, k, v, (beta,) f, g all fuse into + # one column-parallel GEMM and the f_a/g_a/f_b/g_b pairs disappear. + self.qkvbfg_sizes = [ + projection_size, + projection_size, + projection_size, + *([self.num_heads] if self.fuse_no_lora_beta else []), + projection_size, + projection_size, + ] + self.fused_qkvbfg_proj = MergedColumnParallelLinear( + self.hidden_size, + self.qkvbfg_sizes, + bias=False, + quant_config=quant_config, + prefix=f"{prefix}.fused_qkvbfg_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, + ) + self.split_sizes = [3 * projection_size // self.shard_tp_size] + if self.fuse_no_lora_beta: + self.split_sizes.append(self.num_heads // self.shard_tp_size) + self.split_sizes.extend( + [ + projection_size // self.shard_tp_size, + projection_size // self.shard_tp_size, + ] + ) + if not self.fuse_no_lora_beta: + self.b_proj = ColumnParallelLinear( + self.hidden_size, + self.num_heads, + bias=False, + prefix=f"{prefix}.b_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, + ) + elif self.do_fuse_qkvbfg: # Fuse: q, k, v, beta (column parallel) + f_a, g_a (replicated) self.qkvb_sizes = [ projection_size, @@ -226,18 +305,25 @@ class KimiDeltaAttention(nn.Module): self.fused_qkvbfg_a_proj = MergedColumnParallelRepeatedLinear( self.hidden_size, - self.qkvb_sizes, # Column parallel - self.fg_sizes, # Replicated: f_a, g_a + self.qkvb_sizes, + self.fg_sizes, quant_config=quant_config, prefix=f"{prefix}.fused_qkvbfg_a_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) self.split_sizes = [ - 3 * projection_size // self.tp_size, # qkv - self.num_heads // self.tp_size, # beta - 2 * self.head_dim, # f_a, g_a + 3 * projection_size // self.shard_tp_size, + self.num_heads // self.shard_tp_size, + 2 * self.head_dim, ] self.fused_fg_b_proj = ColumnParallelBatchedLinear( - 2, self.head_dim, projection_size, dtype=config.dtype + 2, + self.head_dim, + projection_size, + dtype=config.dtype, + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) else: # Unfused path: separate QKVParallelLinear @@ -269,6 +355,8 @@ class KimiDeltaAttention(nn.Module): bias=False, quant_config=quant_config, prefix=f"{prefix}.f_b_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) self.b_proj = ColumnParallelLinear( @@ -277,6 +365,8 @@ class KimiDeltaAttention(nn.Module): bias=False, quant_config=quant_config, prefix=f"{prefix}.b_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) self.g_a_proj = ReplicatedLinear( @@ -292,10 +382,14 @@ class KimiDeltaAttention(nn.Module): bias=False, quant_config=quant_config, prefix=f"{prefix}.g_b_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) self.dt_bias = nn.Parameter( - torch.empty(divide(projection_size, self.tp_size), dtype=torch.float32) + torch.empty( + divide(projection_size, self.shard_tp_size), dtype=torch.float32 + ) ) set_weight_attrs(self.dt_bias, {"weight_loader": sharded_weight_loader(0)}) @@ -306,6 +400,8 @@ class KimiDeltaAttention(nn.Module): bias=False, params_dtype=torch.float32, prefix=f"{prefix}.qkv_conv1d", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, ) # unsqueeze to fit conv1d weights shape into the linear weights shape. # Can't do this in `weight_loader` since it already exists in @@ -327,6 +423,9 @@ class KimiDeltaAttention(nn.Module): bias=False, quant_config=quant_config, prefix=f"{prefix}.o_proj", + tp_rank=self.shard_tp_rank, + tp_size=self.shard_tp_size, + reduce_results=reduce_results, ) conv_weights = self.qkv_conv1d.weight.squeeze(1) @@ -334,9 +433,9 @@ class KimiDeltaAttention(nn.Module): self.attn = RadixLinearAttention( layer_id=self.layer_idx, - num_q_heads=_get_kda_local_num_heads(self.num_k_heads, self.tp_size), - num_k_heads=_get_kda_local_num_heads(self.num_k_heads, self.tp_size), - num_v_heads=_get_kda_local_num_heads(self.num_v_heads, self.tp_size), + num_q_heads=_get_kda_local_num_heads(self.num_k_heads, self.shard_tp_size), + num_k_heads=_get_kda_local_num_heads(self.num_k_heads, self.shard_tp_size), + num_v_heads=_get_kda_local_num_heads(self.num_v_heads, self.shard_tp_size), head_q_dim=self.head_k_dim, head_k_dim=self.head_k_dim, head_v_dim=self.head_v_dim, @@ -344,12 +443,12 @@ class KimiDeltaAttention(nn.Module): bias=bias, A_log=self.A_log, dt_bias=self.dt_bias, + lower_bound=self.lower_bound, ) def forward_qkvbfg(self, hidden_states: torch.Tensor): qkv, _ = self.qkv_proj(hidden_states) - # Compute beta, forget_gate, and g_proj_states beta = self.b_proj(hidden_states)[0] forget_gate = self.f_b_proj(self.f_a_proj(hidden_states)[0])[0] g_proj_states = self.g_b_proj(self.g_a_proj(hidden_states)[0])[0] @@ -362,7 +461,23 @@ class KimiDeltaAttention(nn.Module): ) def forward_qkvbfg_fused(self, hidden_states: torch.Tensor): - # Single fused projection for all: qkv + beta + f_a + g_a + if self.no_kda_lora: + # Full-rank f/g: everything comes out of one GEMM, no batched + # second-stage matmul. + fused_states, _ = self.fused_qkvbfg_proj(hidden_states) + split_states = torch.split(fused_states, self.split_sizes, dim=-1) + if self.fuse_no_lora_beta: + qkv, beta, forget_gate, g_proj_states = split_states + else: + qkv, forget_gate, g_proj_states = split_states + beta = self.b_proj(hidden_states)[0] + return ( + qkv, + beta, + forget_gate, + g_proj_states, + ) + fused_states = self.fused_qkvbfg_a_proj(hidden_states) qkv, beta, fg_a_states = torch.split( diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py index 7f0c2d6d7..82488eb75 100644 --- a/python/sglang/srt/parser/reasoning_parser.py +++ b/python/sglang/srt/parser/reasoning_parser.py @@ -672,6 +672,9 @@ class Glm45Detector(BaseReasoningFormatDetector): stream_reasoning: bool = True, force_reasoning: bool = False, force_nonempty_content: bool = False, + continue_final_message: bool = False, + previous_content: str = "", + reasoning_default: str = "enable_thinking", ): think_excluded_tokens = [ "", @@ -688,11 +691,57 @@ class Glm45Detector(BaseReasoningFormatDetector): stream_reasoning=stream_reasoning, tool_start_token="", thinks_internally=True, - reasoning_default="enable_thinking", + reasoning_default=reasoning_default, force_nonempty_content=force_nonempty_content, + continue_final_message=continue_final_message, + previous_content=previous_content, ) +class Ling3Detector(Glm45Detector): + """ + Detector for Ling3 models. + + Ling3 is a hybrid-thinking model whose chat template defaults to thinking + on (the template sets `thinking_option='on'` when `enable_thinking` is + omitted, which the generic template detector cannot infer). Tool calls also + terminate reasoning when the model omits . + + If non-streaming output only contains reasoning text and no tool call, Ling3 + moves that text into normal content as a client-experience fallback. Streaming + parsing still emits reasoning increments as they arrive because this parser + does not receive a final end-of-generation signal. + """ + + def __init__( + self, + stream_reasoning: bool = True, + force_reasoning: bool = False, + continue_final_message: bool = False, + previous_content: str = "", + force_nonempty_content: bool = True, + ): + super().__init__( + stream_reasoning=stream_reasoning, + force_reasoning=force_reasoning, + continue_final_message=continue_final_message, + previous_content=previous_content, + reasoning_default="enable_thinking", + ) + self._force_nonempty_content = force_nonempty_content + + def detect_and_parse(self, text: str) -> StreamingParseResult: + ret = super().detect_and_parse(text) + if ( + self._force_nonempty_content + and ret.reasoning_text + and not ret.normal_text + and self.tool_start_token not in text + ): + ret.normal_text, ret.reasoning_text = ret.reasoning_text, ret.normal_text + return ret + + class GptOssDetector(BaseReasoningFormatDetector): """ Detector for T4-style reasoning format (GPT-OSS), using the HarmonyParser. @@ -1886,6 +1935,7 @@ class ReasoningParser: "deepseek-v4": DeepSeekV4Detector, "dots": Qwen3Detector, "glm45": Glm45Detector, + "ling3": Ling3Detector, "hunyuan": HunyuanDetector, "gpt-oss": GptOssDetector, "kimi": KimiDetector, diff --git a/python/sglang/srt/speculative/dflash_info.py b/python/sglang/srt/speculative/dflash_info.py index 21baee79c..8390480af 100644 --- a/python/sglang/srt/speculative/dflash_info.py +++ b/python/sglang/srt/speculative/dflash_info.py @@ -131,6 +131,7 @@ class DFlashVerifyInput(SpecInput): paged_kernel_lens_sum: int, req_to_token: torch.Tensor, kv_start_idx: Optional[torch.Tensor] = None, + kv_indices_buf: Optional[torch.Tensor] = None, ): device = req_pool_indices.device bs = len(req_pool_indices) @@ -159,11 +160,18 @@ class DFlashVerifyInput(SpecInput): paged_kernel_lens = paged_kernel_lens + verify_lens cum_kv_seq_len[1:] = torch.cumsum(paged_kernel_lens, dim=0) - kv_indices = torch.empty( - paged_kernel_lens_sum + kv_indices_extra, - dtype=torch.int32, - device=device, - ) + if kv_indices_buf is not None: + # Sync-free fast-plan path: write straight into the attention + # backend's cuda-graph kv_indices buffer (the captured kernels read + # it), skipping both the fresh allocation and the wrapper plan()'s + # device-to-device refresh copy. + kv_indices = kv_indices_buf + else: + kv_indices = torch.empty( + paged_kernel_lens_sum + kv_indices_extra, + dtype=torch.int32, + device=device, + ) create_flashinfer_kv_indices_triton[(bs,)]( req_to_token, req_pool_indices, diff --git a/python/sglang/srt/speculative/dspark_components/dspark_draft.py b/python/sglang/srt/speculative/dspark_components/dspark_draft.py index f5d5b3335..e0c2bca89 100644 --- a/python/sglang/srt/speculative/dspark_components/dspark_draft.py +++ b/python/sglang/srt/speculative/dspark_components/dspark_draft.py @@ -205,6 +205,9 @@ class DraftBlockProposer: self._draft_block_spec_info = draft_block_spec_info self._draft_sampler = None self._dp_moe_sync = dp_moe_sync + # Persistent (bs, gamma) mask-token buffer: only column 0 (the bonus + # token) changes per step, so avoid a fresh torch.full every decode. + self._draft_block_ids_buf: Optional[torch.Tensor] = None def attach_draft_sampler(self, draft_sampler) -> None: self._draft_sampler = draft_sampler @@ -358,12 +361,17 @@ class DraftBlockProposer: positions_2d = verify_window.positions_2d verify_cache_loc_2d = verify_window.verify_cache_loc_2d - draft_block_ids = torch.full( - (bs, query_token_num), - int(self._mask_token_id), - dtype=torch.long, - device=device, - ) + buf = self._draft_block_ids_buf + if buf is None or buf.shape[0] < bs or buf.device != prefix_lens.device: + buf = torch.full( + (bs, query_token_num), + int(self._mask_token_id), + dtype=torch.long, + device=device, + ) + self._draft_block_ids_buf = buf + draft_block_ids = buf[:bs] + draft_block_ids[:, 0].copy_(draft_input.bonus_tokens.view(-1)) draft_positions = positions_2d[:, :query_token_num].reshape(-1) draft_cache_loc = verify_cache_loc_2d[:, :query_token_num].reshape(-1) diff --git a/python/sglang/srt/speculative/dspark_components/dspark_draft_sampler.py b/python/sglang/srt/speculative/dspark_components/dspark_draft_sampler.py index 1b7c3392f..d5853e5fb 100644 --- a/python/sglang/srt/speculative/dspark_components/dspark_draft_sampler.py +++ b/python/sglang/srt/speculative/dspark_components/dspark_draft_sampler.py @@ -9,6 +9,7 @@ from sglang.kernels.ops.speculative.dspark.dspark_draft_model import ( SampleStepTokens, ) from sglang.srt.environ import DsparkFoldedSampling, envs +from sglang.srt.models.dspark import VanillaMarkov from sglang.srt.speculative.dspark_components.dspark_draft import ( select_draft_hidden_without_anchor, ) @@ -55,6 +56,9 @@ class DsparkDraftSampler: self.sample_from_anchor = bool(model.sample_from_anchor) self.query_token_num = self.gamma if self.sample_from_anchor else self.gamma + 1 max_bs = int(max_bs) + # Resolved once: this sampler runs inside cuda-graph capture, so the + # branch below is baked into the captured graph anyway. + self._fused_greedy = envs.SGLANG_DSPARK_OPT_FUSED_GREEDY_MARKOV.get() if out is not None: assert out.shape == (max_bs * self.gamma,) and out.dtype == torch.int64 self.out = out @@ -119,18 +123,47 @@ class DsparkDraftSampler: base_logits = base_logits.view(bs, self.gamma, -1) anchor = input_ids.view(bs, self.query_token_num)[:, 0] - if self.folded_sampling: + # Fused greedy fast path: only valid for the greedy (non-sampling) fold. + # Gated/RNN subclasses return None (hidden-state-dependent bias); fall + # through to the block sampler below. + draft_tokens = None + if ( + not self.folded_sampling + and self._fused_greedy + and isinstance(self.markov_head, VanillaMarkov) + ): + draft_tokens = self.markov_head.sample_block_greedy_fused( + base_logits, first_prev_tokens=anchor + ) - def sampler(step_logits: torch.Tensor, step_idx: int) -> torch.Tensor: - del step_idx - # In-graph philox noise: each replay advances the generator - # and redraws. - noise = self.exp_noise[:bs].exponential_() - return SampleStepTokens.execute( - step_logits=step_logits, - temperatures=self.temperatures[:bs], - greedy_mask=self.greedy_mask[:bs], - exp_noise=noise, + if draft_tokens is None: + if self.folded_sampling: + + def sampler(step_logits: torch.Tensor, step_idx: int) -> torch.Tensor: + del step_idx + # In-graph philox noise: each replay advances the generator + # and redraws. + noise = self.exp_noise[:bs].exponential_() + return SampleStepTokens.execute( + step_logits=step_logits, + temperatures=self.temperatures[:bs], + greedy_mask=self.greedy_mask[:bs], + exp_noise=noise, + ) + + else: + sampler = greedy_step_sampler + + draft_tokens, corrected_logits = self.markov_head.sample_block( + base_logits, + first_prev_tokens=anchor, + hidden_states=hidden_states.view(bs, self.gamma, -1), + sampler=sampler, + collect_corrected=self.folded_sampling, + ) + if self.folded_sampling: + self.corrected_out[: bs * self.gamma].copy_( + corrected_logits.reshape(bs * self.gamma, -1) ) else: @@ -143,10 +176,6 @@ class DsparkDraftSampler: sampler=sampler, ) self.out[: draft_tokens.numel()].copy_(draft_tokens.reshape(-1)) - if self.folded_sampling: - self.corrected_out[: bs * self.gamma].copy_( - corrected_logits.reshape(bs * self.gamma, -1) - ) if self.confidence_out is not None: confidence = self.confidence_fn( draft_hidden=sample_hidden, diff --git a/python/sglang/srt/speculative/eagle_draft_cuda_graph_runner.py b/python/sglang/srt/speculative/eagle_draft_cuda_graph_runner.py index ab2f5ce27..8e89a86ce 100644 --- a/python/sglang/srt/speculative/eagle_draft_cuda_graph_runner.py +++ b/python/sglang/srt/speculative/eagle_draft_cuda_graph_runner.py @@ -277,6 +277,16 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner): f"Capture cuda graph failed: {e}\n{CUDA_GRAPH_CAPTURE_FAILED_MSG}" ) + # Metadata glue graph is intentionally not used for the EAGLE draft + # runner. FlashInferMLAMultiStepDraftBackend.init_forward_metadata_out_graph + # re-plans the per-step CUDA-graph wrappers that were already captured + # (decode_cuda_graph_metadata dict entries). Capturing that re-plan + # into a secondary glue graph would corrupt the wrapper's internal GPU + # state on replay. The main decode runner (DecodeCudaGraphRunner) is + # where the glue graph saves latency; draft metadata is cheaper and + # already amortised over speculative_num_steps. + self._metadata_glue = None + def _replay_graph(self, shape_key, forward_batch): return self.backend.replay(shape_key, forward_batch) @@ -655,7 +665,9 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner): buffers.seq_lens_cpu[:raw_bs].copy_(forward_batch.seq_lens_cpu) forward_batch.seq_lens_cpu = buffers.seq_lens_cpu[:bs] - # forward_batch.batch_size was overwritten to bs above when padding. + # Prepare per-step draft attention metadata (kv_indptr / kv_indices for + # each speculative step). The glue-graph optimisation is not applied + # here — see __init__ comment for why. self.draft_attn_backend.init_forward_metadata_out_graph(forward_batch) self.raw_bs = raw_bs self.bs = bs diff --git a/test/registered/attention/test_kda_kernels.py b/test/registered/attention/test_kda_kernels.py index 27cd47b38..055b0eb31 100644 --- a/test/registered/attention/test_kda_kernels.py +++ b/test/registered/attention/test_kda_kernels.py @@ -409,7 +409,18 @@ class TestKDAPackedDecode(unittest.TestCase): @staticmethod def _run_baseline( - mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices, H, HV, K, V + mixed_qkv, + a, + b, + A_log, + dt_bias, + ssm_states, + cache_indices, + H, + HV, + K, + V, + lower_bound=None, ): B = mixed_qkv.shape[0] q_flat, k_flat, v_flat = torch.split(mixed_qkv, [H * K, H * K, HV * V], dim=-1) @@ -436,11 +447,22 @@ class TestKDAPackedDecode(unittest.TestCase): scale=K**-0.5, use_qk_l2norm_in_kernel=True, is_kda=True, + lower_bound=lower_bound, ) @staticmethod def _run_packed( - mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices, HV, K, V + mixed_qkv, + a, + b, + A_log, + dt_bias, + ssm_states, + cache_indices, + HV, + K, + V, + lower_bound=None, ): B = mixed_qkv.shape[0] out = mixed_qkv.new_empty(B, 1, HV, V) @@ -455,10 +477,11 @@ class TestKDAPackedDecode(unittest.TestCase): out=out, ssm_state_indices=cache_indices, use_qk_l2norm_in_kernel=True, + lower_bound=lower_bound, ) return out.transpose(0, 1) - def _check(self, B, H, HV, K, V): + def _check(self, B, H, HV, K, V, lower_bound=None): device = get_device() dtype = torch.bfloat16 pool_size = B + 4 @@ -469,10 +492,31 @@ class TestKDAPackedDecode(unittest.TestCase): s_baseline = ssm_states.clone() o_packed = self._run_packed( - mixed_qkv, a, b, A_log, dt_bias, s_packed, cache_indices, HV, K, V + mixed_qkv, + a, + b, + A_log, + dt_bias, + s_packed, + cache_indices, + HV, + K, + V, + lower_bound=lower_bound, ) o_baseline = self._run_baseline( - mixed_qkv, a, b, A_log, dt_bias, s_baseline, cache_indices, H, HV, K, V + mixed_qkv, + a, + b, + A_log, + dt_bias, + s_baseline, + cache_indices, + H, + HV, + K, + V, + lower_bound=lower_bound, ) torch.testing.assert_close( @@ -501,6 +545,9 @@ class TestKDAPackedDecode(unittest.TestCase): # Common KDA config with HV > H (grouped query). self._check(B=8, H=8, HV=16, K=128, V=128) + def test_safe_gate_lower_bound(self): + self._check(B=8, H=16, HV=16, K=128, V=128, lower_bound=-5.0) + def test_pad_slot(self): """Entries with state_idx == -1 must produce zero output and skip state writeback.""" device = get_device() @@ -544,6 +591,7 @@ class TestKDAPackedDecode(unittest.TestCase): device = get_device() dtype = torch.bfloat16 B, H, HV, K, V = 4, 16, 16, 128, 128 + lower_bound = -5.0 pool_size = B + 4 mixed_qkv, a, b, A_log, dt_bias, ssm_states, cache_indices = self._make_inputs( B, H, HV, K, V, pool_size, dtype, device @@ -568,11 +616,23 @@ class TestKDAPackedDecode(unittest.TestCase): cache_indices=cache_indices, num_v_heads=HV, head_v_dim=V, + lower_bound=lower_bound, ) s_baseline = ssm_states.clone() o_baseline = self._run_baseline( - mixed_qkv, a, b, A_log, dt_bias, s_baseline, cache_indices, H, HV, K, V + mixed_qkv, + a, + b, + A_log, + dt_bias, + s_baseline, + cache_indices, + H, + HV, + K, + V, + lower_bound=lower_bound, ) # Dispatcher returns [1, B, HV, V], same layout as the baseline. diff --git a/test/registered/attention/test_kda_prefill_flashkda.py b/test/registered/attention/test_kda_prefill_flashkda.py index 5bf57e85d..215843063 100644 --- a/test/registered/attention/test_kda_prefill_flashkda.py +++ b/test/registered/attention/test_kda_prefill_flashkda.py @@ -80,7 +80,7 @@ def _chunk_kda_ref(d, lower_bound): """Triton chunk_kda reference. chunk_kda mutates g/v and the state in place, so feed clones; returns (output, updated_state_slots).""" st = d["pool"].clone() - out = chunk_kda( + out, _ = chunk_kda( q=d["q"].clone(), k=d["k"].clone(), v=d["v"].clone(), @@ -105,7 +105,7 @@ def test_flashkda_matches_triton_safe_gate(seq_lens): ref_out, ref_state = _chunk_kda_ref(d, LOWER_BOUND) st_fk = d["pool"].clone() - out = FlashKDAKernel().extend( + out, h = FlashKDAKernel().extend( d["q"].clone(), d["k"].clone(), d["v"].clone(), @@ -121,6 +121,7 @@ def test_flashkda_matches_triton_safe_gate(seq_lens): ) torch.cuda.synchronize() + assert h is None assert torch.isfinite(out).all(), "FlashKDA output has non-finite values" assert torch.isfinite(st_fk).all(), "FlashKDA final state has non-finite values" # bf16 cross-implementation noise (chunk=16 CUTLASS vs chunk=64 Triton); @@ -140,7 +141,7 @@ def test_flashkda_falls_back_without_lower_bound(): ref_out, _ = _chunk_kda_ref(d, None) st_fk = d["pool"].clone() - out = FlashKDAKernel().extend( + out, _ = FlashKDAKernel().extend( d["q"].clone(), d["k"].clone(), d["v"].clone(), @@ -170,7 +171,7 @@ def test_flashkda_spec_verify_falls_back(): ref_out, _ = _chunk_kda_ref(d, LOWER_BOUND) st_fk = d["pool"].clone() - out = FlashKDAKernel().extend( + out, _ = FlashKDAKernel().extend( d["q"].clone(), d["k"].clone(), d["v"].clone(), diff --git a/test/registered/kernels/test_fused_kda_conv_recurrent_verify.py b/test/registered/kernels/test_fused_kda_conv_recurrent_verify.py new file mode 100644 index 000000000..81e0b08b0 --- /dev/null +++ b/test/registered/kernels/test_fused_kda_conv_recurrent_verify.py @@ -0,0 +1,187 @@ +import sys + +import pytest +import torch + +from sglang.kernels.ops.attention.fla.fused_kda_conv_recurrent_verify import ( + fused_kda_conv_gating_verify, +) +from sglang.kernels.ops.attention.fla.fused_sigmoid_gating_recurrent import ( + fused_sigmoid_gating_delta_rule_update, +) +from sglang.kernels.ops.mamba.causal_conv1d_triton import ( + causal_conv1d_update, +) +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=60, stage="base-b", runner_config="1-gpu-large") + +_DEVICE = "cuda" + +_CASES = [ + (1, 4, 4, 4, 128, 128, 4, False, None, False, 1), + (1, 4, 4, 4, 128, 128, 4, True, None, False, 2), + (1, 4, 4, 4, 128, 128, 4, True, 2.0, False, 3), + (3, 4, 4, 4, 128, 128, 4, True, None, False, 4), + (3, 4, 4, 4, 128, 128, 4, True, None, True, 5), + (2, 3, 4, 4, 128, 128, 4, True, None, False, 6), + (2, 8, 2, 2, 128, 128, 4, True, 1.5, False, 7), + (1, 4, 8, 8, 64, 64, 4, True, None, False, 8), +] + + +def _make_inputs(B, T, H, HV, K, V, W, has_bias, neg_slot, seed): + torch.manual_seed(seed) + dim = 2 * H * K + HV * V + seq_len = B * T + lines = slots = 8 + + inputs = { + "mixed": torch.randn(seq_len, dim, device=_DEVICE, dtype=torch.bfloat16) * 0.5, + "w": torch.randn(dim, W, device=_DEVICE, dtype=torch.bfloat16) * 0.3, + "bias": ( + torch.randn(dim, device=_DEVICE, dtype=torch.bfloat16) * 0.1 + if has_bias + else None + ), + "a": torch.randn(seq_len, HV * K, device=_DEVICE, dtype=torch.bfloat16) * 0.5, + "b": torch.randn(seq_len, HV, device=_DEVICE, dtype=torch.bfloat16), + "A_log": torch.randn(HV, device=_DEVICE, dtype=torch.float32) * 0.5, + "dt_bias": torch.randn(HV * K, device=_DEVICE, dtype=torch.float32) * 0.5, + # Pool layouts mirroring MambaPool: conv [lines, state_len, dim] (then + # transposed), ssm [slots, HV, V, K] fp32, window [lines, T, W-1, dim], + # intermediate ssm cache [lines, T, HV, V, K] fp32. + "conv_pool": torch.randn( + lines, W - 1, dim, device=_DEVICE, dtype=torch.bfloat16 + ), + "ssm": torch.randn(slots, HV, V, K, device=_DEVICE, dtype=torch.float32) * 0.2, + "win_pool": torch.zeros( + lines, T, W - 1, dim, device=_DEVICE, dtype=torch.bfloat16 + ), + "inter_ssm": torch.zeros( + lines, T, HV, V, K, device=_DEVICE, dtype=torch.float32 + ), + } + idx_vals = list(range(2, 2 + B)) + if neg_slot and B >= 2: + idx_vals[1] = -1 + inputs["idx_vals"] = idx_vals + inputs["cache_indices"] = torch.tensor(idx_vals, device=_DEVICE, dtype=torch.int32) + inputs["inter_indices"] = torch.arange(B, device=_DEVICE, dtype=torch.int32) + return inputs + + +def _run_reference(inp, B, T, H, HV, K, V, lower_bound): + dim = 2 * H * K + HV * V + seq_len = B * T + conv = inp["conv_pool"].clone() + ssm = inp["ssm"].clone() + win = inp["win_pool"].clone() + ic = inp["inter_ssm"].clone() + + x3 = inp["mixed"].reshape(B, T, dim).transpose(1, 2) + out3 = causal_conv1d_update( + x3, + conv.transpose(-1, -2), + inp["w"], + inp["bias"], + activation="silu", + conv_state_indices=inp["cache_indices"], + intermediate_conv_window=win.transpose(-1, -2), + intermediate_state_indices=inp["inter_indices"], + ) + mixed_out = out3.transpose(1, 2).reshape(seq_len, dim) + q, k, v = mixed_out.split([H * K, H * K, HV * V], dim=-1) + q = q.unflatten(-1, (H, K)).unsqueeze(0) + k = k.unflatten(-1, (H, K)).unsqueeze(0) + v = v.unflatten(-1, (HV, V)).unsqueeze(0) + cu = torch.arange(0, B + 1, device=_DEVICE, dtype=torch.int32) * T + o = fused_sigmoid_gating_delta_rule_update( + A_log=inp["A_log"], + a=inp["a"], + dt_bias=inp["dt_bias"], + softplus_beta=1.0, + softplus_threshold=20.0, + q=q, + k=k, + v=v, + b=inp["b"], + initial_state_source=ssm, + initial_state_indices=inp["cache_indices"], + use_qk_l2norm_in_kernel=True, + cu_seqlens=cu, + is_kda=True, + disable_state_update=True, + intermediate_states_buffer=ic, + intermediate_state_indices=inp["inter_indices"], + cache_steps=T, + retrieve_parent_token=None, + lower_bound=lower_bound, + ) + return o, conv, win, ic + + +def _run_fused(inp, B, T, H, HV, K, V, lower_bound, num_warps): + conv = inp["conv_pool"].clone() + ssm = inp["ssm"].clone() + win = inp["win_pool"].clone() + ic = inp["inter_ssm"].clone() + + o = fused_kda_conv_gating_verify( + mixed_qkv=inp["mixed"], + conv_weight=inp["w"], + conv_bias=inp["bias"], + conv_state=conv.transpose(-1, -2), + conv_state_indices=inp["cache_indices"], + intermediate_conv_window=win.transpose(-1, -2), + intermediate_state_indices=inp["inter_indices"], + a=inp["a"], + b=inp["b"], + A_log=inp["A_log"], + dt_bias=inp["dt_bias"], + ssm_states=ssm, + cache_indices=inp["cache_indices"], + intermediate_states_buffer=ic, + scale=K**-0.5, + T=T, + num_q_heads=H, + num_v_heads=HV, + head_k_dim=K, + head_v_dim=V, + lower_bound=lower_bound, + num_warps=num_warps, + ) + return o, conv, win, ic + + +def _compare_case(case, num_warps): + B, T, H, HV, K, V, W, has_bias, lower_bound, neg_slot, seed = case + inp = _make_inputs(B, T, H, HV, K, V, W, has_bias, neg_slot, seed) + o_ref, conv_ref, win_ref, ic_ref = _run_reference( + inp, B, T, H, HV, K, V, lower_bound + ) + o_fus, conv_fus, win_fus, ic_fus = _run_fused( + inp, B, T, H, HV, K, V, lower_bound, num_warps + ) + + idx_vals = inp["idx_vals"] + valid_rows = [i for i, slot in enumerate(idx_vals) if slot >= 0] + touched_slots = [slot for slot in idx_vals if slot >= 0] + + o_ref_v = o_ref.reshape(B, T, HV, V)[valid_rows] + o_fus_v = o_fus.reshape(B, T, HV, V)[valid_rows] + assert torch.equal(o_ref_v, o_fus_v) + assert torch.equal(conv_ref[touched_slots], conv_fus[touched_slots]) + assert torch.equal(win_ref[valid_rows], win_fus[valid_rows]) + torch.testing.assert_close( + ic_ref[valid_rows], ic_fus[valid_rows], atol=4e-3, rtol=0 + ) + + +@pytest.mark.parametrize("case", _CASES) +def test_matches_unfused_reference(case): + _compare_case(case, num_warps=4) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__])) diff --git a/test/registered/unit/configs/test_model_config_shapes.py b/test/registered/unit/configs/test_model_config_shapes.py index ec505f378..8cdd4dc10 100644 --- a/test/registered/unit/configs/test_model_config_shapes.py +++ b/test/registered/unit/configs/test_model_config_shapes.py @@ -1,9 +1,12 @@ -"""Unit tests for ModelConfig shape normalization.""" - +import math import unittest from types import SimpleNamespace -from sglang.srt.configs.model_config import ModelConfig +from sglang.srt.configs.model_config import ( + AttentionArch, + ModelConfig, + _quant_config_to_dict, +) from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.test_utils import CustomTestCase @@ -66,6 +69,62 @@ class TestModelConfigShapes(CustomTestCase): self.assertEqual(model_config.swa_head_dim, 64) self.assertEqual(model_config.swa_v_head_dim, 48) + def test_ling_mla_nope_shapes(self): + text_config = _make_text_config( + architectures=["BailingMoeV3ForCausalLM"], + kv_lora_rank=512, + qk_nope_head_dim=128, + qk_rope_head_dim=64, + use_mla_nope=True, + v_head_dim=128, + ) + + model_config = self._derive_shapes(text_config) + + self.assertEqual(model_config.attention_arch, AttentionArch.MLA) + self.assertEqual(model_config.head_dim, 128) + self.assertEqual(model_config.qk_rope_head_dim, 0) + self.assertEqual(model_config.scaling, 1 / math.sqrt(128)) + + def test_ling_mla_rope_shapes(self): + text_config = _make_text_config( + architectures=["BailingMoeV3ForCausalLM"], + kv_lora_rank=512, + qk_nope_head_dim=128, + qk_rope_head_dim=64, + use_mla_nope=False, + v_head_dim=128, + ) + + model_config = self._derive_shapes(text_config) + + self.assertEqual(model_config.attention_arch, AttentionArch.MLA) + self.assertEqual(model_config.head_dim, 128) + self.assertEqual(model_config.qk_rope_head_dim, 64) + self.assertEqual(model_config.scaling, 1 / math.sqrt(192)) + + def test_sarvam_mla_shapes(self): + text_config = _make_text_config( + architectures=["SarvamMLAForCausalLM"], + kv_lora_rank=512, + qk_nope_head_dim=128, + qk_rope_head_dim=64, + rope_scaling=None, + v_head_dim=128, + ) + + model_config = self._derive_shapes(text_config) + + self.assertEqual(model_config.attention_arch, AttentionArch.MLA) + self.assertEqual(model_config.head_dim, 192) + self.assertEqual(model_config.qk_rope_head_dim, 64) + self.assertEqual(model_config.scaling, 1 / math.sqrt(192)) + + def test_quant_config_objects_are_normalized(self): + quant_config = SimpleNamespace(to_dict=lambda: {"quant_method": "test"}) + + self.assertEqual(_quant_config_to_dict(quant_config), {"quant_method": "test"}) + if __name__ == "__main__": unittest.main() diff --git a/test/registered/unit/entrypoints/openai/test_serving_chat.py b/test/registered/unit/entrypoints/openai/test_serving_chat.py index 033a14761..2500f0c9c 100644 --- a/test/registered/unit/entrypoints/openai/test_serving_chat.py +++ b/test/registered/unit/entrypoints/openai/test_serving_chat.py @@ -3236,6 +3236,29 @@ class ServingChatTestCase(unittest.TestCase): ) self.assertTrue(self.chat._get_reasoning_from_request(req_enabled)) + def test_fallback_ling3_default_on(self): + """Ling3 public checkpoints default `thinking_option='on'` in the chat + template when `enable_thinking` is omitted, and the template detector + cannot infer that indirect assignment. The parser fallback must mirror + the template default: omitted kwargs enable reasoning, only an explicit + `enable_thinking=False` disables it. Regression: the detector shipped + with `explicit_enable_thinking`, which left `reasoning_content` null on + default requests while the model was in fact thinking.""" + self._setup_fallback("ling3") + req = ChatCompletionRequest( + model="x", messages=[{"role": "user", "content": "hi"}] + ) + cases = [ + (None, True), # no chat_template_kwargs → thinking (template default) + ({}, True), # empty kwargs → thinking + ({"enable_thinking": True}, True), # explicit on + ({"enable_thinking": False}, False), # explicit off + ] + for kwargs, expected in cases: + with self.subTest(kwargs=kwargs): + req.chat_template_kwargs = kwargs + self.assertEqual(self.chat._get_reasoning_from_request(req), expected) + def test_fallback_no_detector_returns_false(self): self.chat.reasoning_parser = "qwen3" self.chat._reasoning_detector = None diff --git a/test/registered/unit/function_call/test_function_call_parser.py b/test/registered/unit/function_call/test_function_call_parser.py index 9c683d25e..8f4e00fb6 100644 --- a/test/registered/unit/function_call/test_function_call_parser.py +++ b/test/registered/unit/function_call/test_function_call_parser.py @@ -28,6 +28,7 @@ from sglang.srt.function_call.inkling_detector import InklingDetector from sglang.srt.function_call.json_array_parser import JsonArrayParser from sglang.srt.function_call.kimik2_detector import KimiK2Detector from sglang.srt.function_call.lfm2_detector import Lfm2Detector +from sglang.srt.function_call.ling3_detector import Ling3Detector from sglang.srt.function_call.llama32_detector import Llama32Detector from sglang.srt.function_call.mistral_detector import MistralDetector from sglang.srt.function_call.pythonic_detector import PythonicDetector @@ -2944,6 +2945,84 @@ class TestGlm4MoeDetector(unittest.TestCase): ) self.assertEqual(result.normal_text, "") + def test_streaming_tool_call(self): + chunks = [ + "get_weather\n", + "city\nBeijing\n", + "date\n2024-06-27\n", + "", + ] + tool_calls = [] + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, self.tools) + for tool_call_chunk in result.calls: + if ( + hasattr(tool_call_chunk, "tool_index") + and tool_call_chunk.tool_index is not None + ): + while len(tool_calls) <= tool_call_chunk.tool_index: + tool_calls.append({"name": "", "parameters": ""}) + tc = tool_calls[tool_call_chunk.tool_index] + if tool_call_chunk.name: + tc["name"] = tool_call_chunk.name + if tool_call_chunk.parameters: + tc["parameters"] += tool_call_chunk.parameters + self.assertEqual(len(tool_calls), 1) + self.assertEqual(tool_calls[0]["name"], "get_weather") + self.assertEqual( + tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}' + ) + + def test_streaming_tool_call_without_arguments(self): + chunks = [ + "get_weather\n", + "", + ] + tool_calls = [] + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, self.tools) + for tool_call_chunk in result.calls: + if ( + hasattr(tool_call_chunk, "tool_index") + and tool_call_chunk.tool_index is not None + ): + while len(tool_calls) <= tool_call_chunk.tool_index: + tool_calls.append({"name": "", "parameters": ""}) + tc = tool_calls[tool_call_chunk.tool_index] + if tool_call_chunk.name: + tc["name"] = tool_call_chunk.name + if tool_call_chunk.parameters: + tc["parameters"] += tool_call_chunk.parameters + + self.assertEqual(len(tool_calls), 1) + self.assertEqual(tool_calls[0]["name"], "get_weather") + self.assertEqual(tool_calls[0]["parameters"], "{}") + self.assertEqual(self.detector.streamed_args_for_tool[0], "{}") + + def test_streaming_tool_call_without_arguments_single_chunk(self): + """Test no-argument tool call when name and end token arrive together.""" + chunks = ["get_weather\n"] + tool_calls = [] + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, self.tools) + for tool_call_chunk in result.calls: + if ( + hasattr(tool_call_chunk, "tool_index") + and tool_call_chunk.tool_index is not None + ): + while len(tool_calls) <= tool_call_chunk.tool_index: + tool_calls.append({"name": "", "parameters": ""}) + tc = tool_calls[tool_call_chunk.tool_index] + if tool_call_chunk.name: + tc["name"] = tool_call_chunk.name + if tool_call_chunk.parameters: + tc["parameters"] += tool_call_chunk.parameters + + self.assertEqual(len(tool_calls), 1) + self.assertEqual(tool_calls[0]["name"], "get_weather") + self.assertEqual(tool_calls[0]["parameters"], "{}") + self.assertEqual(self.detector.streamed_args_for_tool[0], "{}") + def test_streaming_multiple_tool_calls(self): """Test streaming incremental parsing of multiple tool calls.""" chunks = [ @@ -3602,6 +3681,119 @@ class TestGlm47MoeDetector(unittest.TestCase): _glm47_native_structural_tag_available.cache_clear() +class TestLing3Detector(unittest.TestCase): + def setUp(self): + self.tools = [ + Tool( + type="function", + function=Function( + name="get_weather", + description="Get weather information", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string"}, + "date": {"type": "string"}, + }, + }, + ), + ), + Tool( + type="function", + function=Function( + name="get_date", + description="Get current date", + parameters={"type": "object", "properties": {}}, + ), + ), + ] + self.detector = Ling3Detector() + + def _collect_streaming_tool_calls(self, chunks): + tool_calls = [] + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, self.tools) + for tool_call_chunk in result.calls: + while len(tool_calls) <= tool_call_chunk.tool_index: + tool_calls.append({"name": "", "parameters": ""}) + tc = tool_calls[tool_call_chunk.tool_index] + if tool_call_chunk.name: + tc["name"] = tool_call_chunk.name + if tool_call_chunk.parameters: + tc["parameters"] += tool_call_chunk.parameters + return tool_calls + + def test_detect_and_parse_newline_and_compact_tool_call(self): + cases = { + "newline": ( + "get_weather\n" + "cityBeijing" + "date2024-06-27" + "", + '{"city": "Beijing", "date": "2024-06-27"}', + ), + "compact": ( + "get_weather" + "cityShanghai" + "date2024-06-28" + "", + '{"city": "Shanghai", "date": "2024-06-28"}', + ), + } + for layout, (text, expected) in cases.items(): + with self.subTest(layout=layout): + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + self.assertEqual(result.calls[0].parameters, expected) + + def test_detect_and_parse_empty_args(self): + result = self.detector.detect_and_parse( + "get_date", self.tools + ) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_date") + self.assertEqual(json.loads(result.calls[0].parameters), {}) + + def test_streaming_empty_args_emits_single_empty_object(self): + tool_calls = self._collect_streaming_tool_calls( + ["get_date", ""] + ) + self.assertEqual(len(tool_calls), 1) + self.assertEqual(tool_calls[0]["name"], "get_date") + self.assertEqual(tool_calls[0]["parameters"], "{}") + self.assertEqual(self.detector.streamed_args_for_tool[0], "{}") + + def test_streaming_newline_and_compact_tool_call(self): + cases = { + "newline": ( + [ + "get_weather\n", + "cityBeijing", + "date2024-06-27", + "", + ], + '{"city": "Beijing", "date": "2024-06-27"}', + ), + "compact": ( + [ + "get_weather", + "cityShanghai", + "date2024-06-28", + "", + ], + '{"city": "Shanghai", "date": "2024-06-28"}', + ), + } + for layout, (chunks, expected) in cases.items(): + with self.subTest(layout=layout): + self.setUp() + tool_calls = self._collect_streaming_tool_calls(chunks) + self.assertEqual(len(tool_calls), 1) + self.assertEqual(tool_calls[0]["name"], "get_weather") + self.assertEqual(tool_calls[0]["parameters"], expected) + + class TestJsonArrayParser(unittest.TestCase): def setUp(self): # Create sample tools for testing diff --git a/test/registered/unit/layers/moe/test_fused_shared_expert_scaling.py b/test/registered/unit/layers/moe/test_fused_shared_expert_scaling.py index 4c570b9d3..233dbee81 100644 --- a/test/registered/unit/layers/moe/test_fused_shared_expert_scaling.py +++ b/test/registered/unit/layers/moe/test_fused_shared_expert_scaling.py @@ -1,13 +1,16 @@ -"""Unit tests for fused shared-expert weight scaling on per-rank shared slots. +"""Unit tests for fused shared-expert weight scaling. -These tests pin the contract of ``remap_topk_for_per_rank_shared_slots`` for -the fused shared expert's topk weight on the two paths this fix covers: +These tests pin the fused shared expert's topk weight contract on three paths: - * aiter (HIP) path: routed_scaling_factor is folded into the routed weights and - the post-MoE multiply is skipped, so the shared weight must be 1.0 - for a net 1.0x contribution. - * post-MoE scaling path (default): the whole MoE output is multiplied by - routed_scaling_factor afterward, so the shared weight must be 1/rsf. + * aiter (HIP) per-rank-slot path: routed_scaling_factor is folded into the + routed weights and the post-MoE multiply is skipped, so the shared weight + must be 1.0 for a net 1.0x contribution. + * post-MoE scaling per-rank-slot path (default): the whole MoE output is + multiplied by routed_scaling_factor afterward, so the shared weight must + be 1/rsf. + * standard EP path (no per-rank slots): every rank computes the fused shared + expert and the outputs are all-reduced, so the model-supplied 1/ep_size + factor must be applied to the shared weight. """ from sglang.test.ci.ci_register import register_cpu_ci @@ -80,6 +83,49 @@ class TestFusedSharedExpertScaling(CustomTestCase): shared_weight = self._run_remap(use_aiter=False) self.assertAlmostEqual(shared_weight, 1.0 / self.ROUTED_SCALING_FACTOR) + def _run_post_process_standard_path(self, *, scaling_factor): + topk_ids = torch.tensor([[5, 40, 100, 256]], dtype=torch.int32) + topk_weights = torch.tensor([[1.0, 0.5, 0.25, 1.0]], dtype=torch.float32) + topk_config = TopKConfig( + top_k=4, + num_fused_shared_experts=1, + fused_shared_experts_scaling_factor=scaling_factor, + allow_routed_experts_capture=False, + ) + router_logits = torch.zeros((1, 256), dtype=torch.float32) + with ( + patch.object(topk_module, "_is_cuda", False), + patch.object(topk_module, "_is_hip", False), + patch.object(topk_module, "_use_aiter", False), + patch.object( + topk_module, "has_per_rank_fused_shared_slots", return_value=False + ), + ): + _out_ids, out_weights, _recorder_ids = topk_module._post_process_topk_ids( + topk_ids.clone(), + topk_weights.clone(), + topk_config, + router_logits, + layer_id=0, + ) + self.assertTrue(torch.equal(out_weights[0, :-1], topk_weights[0, :-1])) + return out_weights[0, -1].item() + + def test_standard_ep_path_applies_shared_scaling_factor(self): + # Regression: models pass 1/ep_size under standard EP (every rank + # computes the fused shared expert and outputs are all-reduced), but + # the standard CUDA post-process dropped the factor, so the shared + # contribution was summed ep_size times (corrupt EP4 output on + # BailingMoeV3, BF16 and FP8 alike). + shared_weight = self._run_post_process_standard_path(scaling_factor=0.25) + self.assertAlmostEqual(shared_weight, 0.25) + + def test_standard_path_without_factor_keeps_shared_weight(self): + # TP mode passes no factor; the shared weight must pass through + # unscaled (guards the predicate against degrading to always-scale). + shared_weight = self._run_post_process_standard_path(scaling_factor=None) + self.assertAlmostEqual(shared_weight, 1.0) + def test_shared_expert_ids_route_to_home_rank(self): # Sanity: the shared slot id is placed at this rank's interleaved # position (ep_rank * num_local_experts + num_local_routed). diff --git a/test/registered/unit/layers/quantization/test_compressed_tensors_wna16_moe_no_linear.py b/test/registered/unit/layers/quantization/test_compressed_tensors_wna16_moe_no_linear.py index f00ad7eff..1721ce2ef 100644 --- a/test/registered/unit/layers/quantization/test_compressed_tensors_wna16_moe_no_linear.py +++ b/test/registered/unit/layers/quantization/test_compressed_tensors_wna16_moe_no_linear.py @@ -1,32 +1,10 @@ -"""CPU regression test for WNA16 compressed-tensors MoE with no "Linear" group. - -CompressedTensorsWNA16MoE used to read ``target_scheme_map["Linear"]`` in its -constructor. That raised ``KeyError: 'Linear'`` for compressed-tensors MoE -checkpoints whose ``config_groups`` only target the expert projections through a -regex or per-layer FQN target and therefore have no group literally named -"Linear" (e.g. mixed-precision INT4/INT8 MoE quant configs). ``get_moe_scheme`` -already resolves the per-layer weight scheme by matching the layer against the -config_groups targets, so it now threads that ``weight_quant`` into the scheme -constructor instead of assuming a "Linear" group. - -These tests pin that contract: building a MoE compressed-tensors config with no -"Linear" group and calling ``get_moe_scheme`` must return the correct WNA16 MoE -scheme rather than raising ``KeyError``. This is pure config-parsing logic (no -weights are created and no kernels run), so it runs on CPU. - -The configs mirror real Laguna-style MoE quant configs: WNA16 int4/int8, group -strategy, group_size 128, symmetric, expert projections targeted by regex or by -per-layer FQN, with attention / router layers ignored. -""" - -from sglang.test.ci.ci_register import register_cpu_ci - -register_cpu_ci(est_time=5, suite="base-a-test-cpu") - import unittest +from unittest import mock import torch +from sglang.srt.layers.moe import MoeRunnerBackend +from sglang.srt.layers.quantization.compressed_tensors import compressed_tensors from sglang.srt.layers.quantization.compressed_tensors.compressed_tensors import ( CompressedTensorsConfig, ) @@ -34,18 +12,13 @@ from sglang.srt.layers.quantization.compressed_tensors.schemes import ( CompressedTensorsWNA16MoE, CompressedTensorsWNA16TritonMoE, ) +from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.test_utils import CustomTestCase -# WNA16 MoE Marlin (default) and Triton backends are both valid resolutions for -# this config; only the "no KeyError, correct WNA16 int-N scheme" contract matters. +register_cpu_ci(est_time=5, suite="base-a-test-cpu") + _WNA16_MOE_SCHEMES = (CompressedTensorsWNA16MoE, CompressedTensorsWNA16TritonMoE) - -# Layer whose experts we resolve a scheme for. get_moe_scheme() expands this into -# ".0.gate_proj" / ".0.up_proj" / ".0.down_proj" and matches each against targets. EXPERTS_LAYER = "model.layers.0.mlp.experts" - -# Per-layer FQN targets: the three expert projections of layer 0, named -# explicitly rather than via regex. Still no "Linear" group. PER_LAYER_EXPERT_TARGETS = [ f"{EXPERTS_LAYER}.0.gate_proj", f"{EXPERTS_LAYER}.0.up_proj", @@ -53,28 +26,22 @@ PER_LAYER_EXPERT_TARGETS = [ ] -def _make_wna16_moe_config(targets, num_bits): - """A WNA16 compressed-tensors MoE quant config with NO "Linear" group. - - Only the expert projections are quantized, targeted via ``targets`` (regex or - per-layer FQN). Attention / router / lm_head are ignored, exactly as a real - mixed-precision MoE checkpoint would express it. - """ +def _make_wna16_moe_config(targets, num_bits, **weight_overrides): + weights = { + "num_bits": num_bits, + "type": "int", + "symmetric": True, + "strategy": "group", + "group_size": 128, + } + weights.update(weight_overrides) return { "quant_method": "compressed-tensors", - # pack-quantized => WNA16 (weight-only, int, no input activations). "format": "pack-quantized", "config_groups": { "group_0": { "targets": targets, - "weights": { - "num_bits": num_bits, - "type": "int", - "symmetric": True, - "strategy": "group", - "group_size": 128, - }, - # Weight-only: no activation quantization. + "weights": weights, "input_activations": None, } }, @@ -83,18 +50,11 @@ def _make_wna16_moe_config(targets, num_bits): class TestWNA16MoENoLinearGroup(CustomTestCase): - """Regression: get_moe_scheme() must not assume a "Linear" config group.""" - def _assert_wna16_moe(self, config_dict, expected_bits): quant_config = CompressedTensorsConfig.from_config(config_dict) - - # Precondition that reproduces the original bug: the parsed scheme map - # has no "Linear" group, so the old target_scheme_map["Linear"] lookup - # would KeyError. self.assertNotIn("Linear", quant_config.target_scheme_map) layer = torch.nn.Module() - # Would raise KeyError: 'Linear' before the fix. scheme = quant_config.get_moe_scheme(layer, layer_name=EXPERTS_LAYER) self.assertIsInstance(scheme, _WNA16_MOE_SCHEMES) @@ -113,6 +73,122 @@ class TestWNA16MoENoLinearGroup(CustomTestCase): config = _make_wna16_moe_config(PER_LAYER_EXPERT_TARGETS, num_bits=4) self._assert_wna16_moe(config, expected_bits=4) + def test_blackwell_int4_auto_uses_triton(self): + for group_size in (32, 128): + with self.subTest(group_size=group_size): + quant_config = CompressedTensorsConfig.from_config( + _make_wna16_moe_config( + ["re:.*mlp.experts.*"], + num_bits=4, + group_size=group_size, + ) + ) + + with ( + mock.patch.object( + compressed_tensors, + "get_moe_runner_backend", + return_value=MoeRunnerBackend.AUTO, + ), + mock.patch.object( + compressed_tensors, "is_sm100_supported", return_value=True + ), + ): + scheme = quant_config.get_moe_scheme( + torch.nn.Module(), layer_name=EXPERTS_LAYER + ) + + self.assertIsInstance(scheme, CompressedTensorsWNA16TritonMoE) + + def test_blackwell_auto_rejects_unvalidated_triton_layouts(self): + cases = { + "asymmetric": {"symmetric": False}, + "channel": {"strategy": "channel", "group_size": None}, + "group64": {"group_size": 64}, + "actorder": {"actorder": "group"}, + } + for name, overrides in cases.items(): + with self.subTest(name=name): + quant_config = CompressedTensorsConfig.from_config( + _make_wna16_moe_config( + ["re:.*mlp.experts.*"], num_bits=4, **overrides + ) + ) + with ( + mock.patch.object( + compressed_tensors, + "get_moe_runner_backend", + return_value=MoeRunnerBackend.AUTO, + ), + mock.patch.object( + compressed_tensors, "is_sm100_supported", return_value=True + ), + ): + scheme = quant_config.get_moe_scheme( + torch.nn.Module(), layer_name=EXPERTS_LAYER + ) + + self.assertIsInstance(scheme, CompressedTensorsWNA16MoE) + self.assertNotIsInstance(scheme, CompressedTensorsWNA16TritonMoE) + + def test_explicit_triton_rejects_unvalidated_layout(self): + quant_config = CompressedTensorsConfig.from_config( + _make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=4, symmetric=False) + ) + + with ( + mock.patch.object( + compressed_tensors, + "get_moe_runner_backend", + return_value=MoeRunnerBackend.TRITON, + ), + self.assertRaisesRegex(ValueError, "only supports symmetric INT4"), + ): + quant_config.get_moe_scheme(torch.nn.Module(), layer_name=EXPERTS_LAYER) + + def test_blackwell_explicit_marlin_is_preserved(self): + quant_config = CompressedTensorsConfig.from_config( + _make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=4) + ) + + with ( + mock.patch.object( + compressed_tensors, + "get_moe_runner_backend", + return_value=MoeRunnerBackend.MARLIN, + ), + mock.patch.object( + compressed_tensors, "is_sm100_supported", return_value=True + ), + ): + scheme = quant_config.get_moe_scheme( + torch.nn.Module(), layer_name=EXPERTS_LAYER + ) + + self.assertIsInstance(scheme, CompressedTensorsWNA16MoE) + + def test_blackwell_int8_auto_keeps_marlin(self): + quant_config = CompressedTensorsConfig.from_config( + _make_wna16_moe_config(["re:.*mlp.experts.*"], num_bits=8) + ) + + with ( + mock.patch.object( + compressed_tensors, + "get_moe_runner_backend", + return_value=MoeRunnerBackend.AUTO, + ), + mock.patch.object( + compressed_tensors, "is_sm100_supported", return_value=True + ), + ): + scheme = quant_config.get_moe_scheme( + torch.nn.Module(), layer_name=EXPERTS_LAYER + ) + + self.assertIsInstance(scheme, CompressedTensorsWNA16MoE) + self.assertNotIsInstance(scheme, CompressedTensorsWNA16TritonMoE) + if __name__ == "__main__": unittest.main() diff --git a/test/registered/unit/managers/test_mamba_checkpoint_depth.py b/test/registered/unit/managers/test_mamba_checkpoint_depth.py index ec8c3720a..4735ea638 100644 --- a/test/registered/unit/managers/test_mamba_checkpoint_depth.py +++ b/test/registered/unit/managers/test_mamba_checkpoint_depth.py @@ -50,6 +50,9 @@ def _track_seqlen(*, tree_page: int, prefix_len: int, extend_len: int) -> int: req.mamba_branching_seqlen = None batch = ScheduleBatch(reqs=[req]) + batch.model_config = SimpleNamespace( + hf_text_config=SimpleNamespace(mamba_chunk_size=CHUNK) + ) batch.tree_cache = SimpleNamespace(page_size=tree_page) batch.req_to_token_pool = MagicMock() batch.req_to_token_pool.get_mamba_ping_pong_other_idx.return_value = 1 diff --git a/test/registered/unit/mem_cache/test_flashkda_strided_state_access.py b/test/registered/unit/mem_cache/test_flashkda_strided_state_access.py index 7860e8791..8d576b4f5 100644 --- a/test/registered/unit/mem_cache/test_flashkda_strided_state_access.py +++ b/test/registered/unit/mem_cache/test_flashkda_strided_state_access.py @@ -178,11 +178,12 @@ class TestFlashKDAStridedStateAccess(unittest.TestCase): conv_before = [cv.clone() for cv in conv_views] cache_indices = torch.tensor([5, 2], dtype=torch.int32) - out = self._run_extend(ssm_states, cache_indices) + out, intermediate_states = self._run_extend(ssm_states, cache_indices) # Routing: the fused path ran exactly once (a silent re-route to the # triton fallback would make every assertion below vacuous). self.assertEqual(self.fake.calls, 1) + self.assertIsNone(intermediate_states) self.assertEqual(tuple(out.shape), (1, 2 * _SEQ_LEN, _H, _V)) # Gather: the external kernel must receive a CONTIGUOUS copy whose rows diff --git a/test/registered/unit/models/test_shared_experts_fusion_gates.py b/test/registered/unit/models/test_shared_experts_fusion_gates.py index 65d06092a..d53b365e9 100644 --- a/test/registered/unit/models/test_shared_experts_fusion_gates.py +++ b/test/registered/unit/models/test_shared_experts_fusion_gates.py @@ -14,9 +14,13 @@ through `get_parallel().override(...)`; the ones that are pure config / quantization are exercised directly. """ +import importlib.util +import sys import unittest import unittest.mock -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace + +import pytest from sglang.srt.runtime_context import get_context, get_parallel from sglang.test.ci.ci_register import register_cpu_ci @@ -29,6 +33,25 @@ def _quant(name: str): return SimpleNamespace(get_name=lambda: name) +def _import_bailing_modules(): + if importlib.util.find_spec("vllm") is not None: + from sglang.srt.models import bailing_moe_nextn, bailing_moe_v3 + + return bailing_moe_v3, bailing_moe_nextn + + # CPU CI omits vLLM; these fusion gates never execute the imported AWQ kernel. + vllm = ModuleType("vllm") + vllm.__path__ = [] + custom_ops = ModuleType("vllm._custom_ops") + custom_ops.awq_dequantize = unittest.mock.Mock() + with unittest.mock.patch.dict( + sys.modules, {"vllm": vllm, "vllm._custom_ops": custom_ops} + ): + from sglang.srt.models import bailing_moe_nextn, bailing_moe_v3 + + return bailing_moe_v3, bailing_moe_nextn + + class _FusionGateCase(CustomTestCase): def _seed(self, **fields): override = get_context().override_server_args(**fields) @@ -228,6 +251,113 @@ class TestMiniMaxGates(_FusionGateCase): ) +class TestBailingMoeV3Gate(_FusionGateCase): + def _config(self): + return SimpleNamespace( + architectures=["BailingMoeV3ForCausalLM"], + num_shared_experts=1, + moe_intermediate_size=1024, + ) + + def _compressed_tensors(self, ignore): + return SimpleNamespace( + get_name=lambda: "compressed_tensors", + ignore=ignore, + packed_modules_mapping={}, + ) + + def _reason_on_cuda(self, quant_config): + bailing_moe_v3, _ = _import_bailing_modules() + + self._seed() + with ( + unittest.mock.patch.object(bailing_moe_v3, "_is_cuda", True), + unittest.mock.patch.object( + bailing_moe_v3.torch.cuda, + "get_device_capability", + return_value=(9, 0), + ), + ): + return self._reason( + bailing_moe_v3.BailingMoeV3ForCausalLM, + self._config(), + quant_config, + ) + + def test_compressed_tensors_mixed_expert_layout_cannot_fuse(self): + reason = self._reason_on_cuda( + self._compressed_tensors( + ["re:.*(mlp|shared_experts)\\.(gate|up|gate_up|down|eh)_proj.*"] + ) + ) + self.assertIn("different quant methods", reason) + + def test_compressed_tensors_uniform_expert_layout_can_fuse(self): + self.assertIsNone(self._reason_on_cuda(self._compressed_tensors([]))) + + def test_nextn_uses_its_rewritten_architecture(self): + bailing_moe_v3, bailing_moe_nextn = _import_bailing_modules() + + config = self._config() + config.architectures = ["BailingMoeForCausalLMNextN"] + config.model_type = "bailing_hybrid" + config.use_kda = True + self._seed() + with ( + unittest.mock.patch.object(bailing_moe_v3, "_is_cuda", True), + unittest.mock.patch.object( + bailing_moe_v3.torch.cuda, + "get_device_capability", + return_value=(9, 0), + ), + ): + reason = self._reason( + bailing_moe_nextn.BailingMoeForCausalLMNextN, + config, + self._compressed_tensors( + ["re:.*(mlp|shared_experts)\\.(gate|up|gate_up|down|eh)_proj.*"] + ), + ) + + self.assertIn("different quant methods", reason) + + def test_nextn_constructor_calls_v3_fusion_setup(self): + bailing_moe_v3, bailing_moe_nextn = _import_bailing_modules() + + config = SimpleNamespace( + architectures=["BailingMoeForCausalLMNextN"], + model_type="bailing_hybrid", + use_kda=True, + num_shared_experts=1, + vocab_size=32000, + hidden_size=4096, + ) + parallel = SimpleNamespace( + tp_size=1, + moe_ep_size=1, + enable_dp_lm_head=False, + ) + with ( + unittest.mock.patch.object( + bailing_moe_nextn, "get_parallel", return_value=parallel + ), + unittest.mock.patch.object( + bailing_moe_v3, "get_parallel", return_value=parallel + ), + unittest.mock.patch.object( + bailing_moe_v3, + "is_shared_experts_fusion_disabled", + return_value=False, + ), + unittest.mock.patch.object(bailing_moe_nextn, "BailingMoEModelNextN"), + unittest.mock.patch.object(bailing_moe_nextn, "ParallelLMHead"), + unittest.mock.patch.object(bailing_moe_nextn, "LogitsProcessor"), + ): + model = bailing_moe_nextn.BailingMoeForCausalLMNextN(config) + + self.assertEqual(model.num_fused_shared_experts, 1) + + class TestQwen3_5Gate(_FusionGateCase): def test_every_entry_class_answers(self): import sglang.srt.models.qwen3_5 as qwen3_5 @@ -569,4 +699,4 @@ class TestFamiliesWithoutAGate(_FusionGateCase): if __name__ == "__main__": - unittest.main() + sys.exit(pytest.main([__file__])) diff --git a/test/registered/unit/parser/test_reasoning_parser.py b/test/registered/unit/parser/test_reasoning_parser.py index 879391af1..e67f85c6b 100644 --- a/test/registered/unit/parser/test_reasoning_parser.py +++ b/test/registered/unit/parser/test_reasoning_parser.py @@ -14,6 +14,7 @@ from sglang.srt.parser.reasoning_parser import ( InklingDetector, KimiDetector, KimiK2Detector, + Ling3Detector, Nemotron3Detector, Qwen3Detector, ReasoningParser, @@ -466,6 +467,69 @@ class TestGlm45Detector(CustomTestCase): self.assertEqual(result.normal_text, "tool call") +class TestLing3Detector(CustomTestCase): + def setUp(self): + self.detector = Ling3Detector() + + def test_init(self): + self.assertEqual(self.detector.tool_start_token, "") + self.assertEqual(self.detector.reasoning_default, "enable_thinking") + self.assertTrue(self.detector.thinks_internally) + self.assertTrue(self.detector._force_nonempty_content) + self.assertFalse(self.detector._in_reasoning) + + def test_tool_interrupt(self): + text = "I need a toolget_weather" + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "I need a tool") + self.assertEqual(result.normal_text, "get_weather") + + def test_reasoning_only_swaps_to_normal_text(self): + text = "Final answer without a closing think tag" + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "") + self.assertEqual(result.normal_text, "Final answer without a closing think tag") + + def test_reasoning_only_with_end_token_swaps_to_normal_text(self): + text = "Final answer accidentally wrapped as reasoning" + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "") + self.assertEqual( + result.normal_text, "Final answer accidentally wrapped as reasoning" + ) + + def test_force_nonempty_content_false_disables_swap(self): + detector = Ling3Detector(force_nonempty_content=False) + text = "Reasoning only" + result = detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "Reasoning only") + self.assertEqual(result.normal_text, "") + + def test_does_not_swap_when_normal_text_exists(self): + text = "Reasoning hereThe answer is 42." + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "Reasoning here") + self.assertEqual(result.normal_text, "The answer is 42.") + + def test_empty_reasoning_with_normal_text(self): + text = "The answer is 42." + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "") + self.assertEqual(result.normal_text, "The answer is 42.") + + def test_plain_text_without_thinking(self): + text = "The answer is 42." + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "") + self.assertEqual(result.normal_text, text) + + def test_streaming_reasoning_only_currently_streams_reasoning(self): + self.detector.parse_streaming_increment("") + result = self.detector.parse_streaming_increment("The answer is 42.") + self.assertEqual(result.reasoning_text, "The answer is 42.") + self.assertEqual(result.normal_text, "") + + class TestHunyuanDetector(CustomTestCase): """Test cases for Hunyuan detector with tool interruption support.""" @@ -678,6 +742,9 @@ class TestReasoningParser(CustomTestCase): parser = ReasoningParser("glm45") self.assertIsInstance(parser.detector, Glm45Detector) + parser = ReasoningParser("ling3") + self.assertIsInstance(parser.detector, Ling3Detector) + parser = ReasoningParser("hunyuan") self.assertIsInstance(parser.detector, HunyuanDetector) diff --git a/test/registered/unit/test_model_overrides.py b/test/registered/unit/test_model_overrides.py index b02515bef..bb9fb4ff3 100644 --- a/test/registered/unit/test_model_overrides.py +++ b/test/registered/unit/test_model_overrides.py @@ -2117,6 +2117,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): disable_overlap_schedule=False, page_size=None, linear_attn_backend="triton", + linear_attn_prefill_backend=None, ) defaults.update(kw) return ResolvedView( @@ -2142,6 +2143,13 @@ class TestGoldenModelOverrides(_IsolatedPublish): "mamba_radix_cache_strategy": "extra_buffer", }, ) + self.assertEqual( + _mamba_radix_cache_resolution(_view("BailingMoeV3ForCausalLM")), + { + "uses_mamba_radix_cache": True, + "mamba_radix_cache_strategy": "extra_buffer", + }, + ) # auto + no extra-buffer support (Lfm2) -> no_buffer + overlap disable self.assertEqual( _mamba_radix_cache_resolution(_view("Lfm2ForCausalLM")), @@ -2204,6 +2212,15 @@ class TestGoldenModelOverrides(_IsolatedPublish): SimpleNamespace(linear_attn_backend="fla"), "Qwen3NextForCausalLM" ) ) + self.assertTrue( + supports_mamba_cache_extra_buffer( + SimpleNamespace( + linear_attn_backend="triton", + linear_attn_prefill_backend="flashinfer", + ), + "Qwen3_5MoeForConditionalGeneration", + ) + ) def test_qwen3_5_hybrid_coupled_declaration(self): from sglang.srt.arg_groups.overrides import _qwen3_5_hybrid_overrides