diff --git a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/fused_moe_weight.py b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/fused_moe_weight.py index fd3de5c9b4..ef970735d1 100644 --- a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/fused_moe_weight.py +++ b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/fused_moe_weight.py @@ -140,6 +140,7 @@ def experts( alpha: Optional[float] = None, limit: Optional[float] = None, clamp_up_add_one: bool = True, + shared_expert_output: Optional[torch.Tensor] = None, ) -> torch.Tensor: # Captures MoE topk expert ids for routed-experts metadata when enabled. moe_capture_callback = get_moe_capture_callback(infer_state, self.layer_num_) @@ -162,6 +163,7 @@ def experts( alpha=alpha, limit=limit, clamp_up_add_one=clamp_up_add_one, + **({"shared_expert_output": shared_expert_output} if shared_expert_output is not None else {}), ) def low_latency_dispatch( @@ -306,9 +308,10 @@ def _create_weight(self): self.per_expert_scale = None # Create e_score_correction_bias if self.e_score_correction_bias_name: + # Preserve FP32 correction scores at expert-selection boundaries. self.e_score_correction_bias = torch.empty( (self.n_routed_experts,), - dtype=self.data_type_, + dtype=torch.float32, device=f"cuda:{self.device_id_}", ) self.e_score_correction_bias.load_ok = False diff --git a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/deepgemm_impl.py b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/deepgemm_impl.py index 74e457426b..c22a5c595a 100644 --- a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/deepgemm_impl.py +++ b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/deepgemm_impl.py @@ -6,7 +6,7 @@ from lightllm.utils.envs_utils import ( get_deepep_num_max_dispatch_tokens_per_rank_prefill, get_deepep_num_max_dispatch_tokens_per_rank_decode, - get_env_use_ue8m0_scales, + get_env_use_ue8m0_activation_scales, ) from lightllm.common.basemodel.triton_kernel.fused_moe.grouped_fused_moe_ep import ( fused_experts, @@ -80,6 +80,7 @@ def _fused_experts( alpha: Optional[float] = None, limit: Optional[float] = None, clamp_up_add_one: bool = True, + shared_expert_output: Optional[torch.Tensor] = None, ): output = fused_experts( hidden_states=input_tensor, @@ -94,6 +95,7 @@ def _fused_experts( alpha=alpha, limit=limit, clamp_up_add_one=clamp_up_add_one, + shared_expert_output=shared_expert_output, ) return output @@ -130,7 +132,7 @@ def low_latency_dispatch( num_max_dispatch_tokens_per_rank=num_max_dispatch_tokens_per_rank, num_experts=self.total_expert_num_contain_redundancy, use_fp8=use_fp8_w8a8, - round_scale=get_env_use_ue8m0_scales(), + round_scale=get_env_use_ue8m0_activation_scales(), async_finish=False, return_recv_hook=True, ) diff --git a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/triton_impl.py b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/triton_impl.py index f748aea467..222e209374 100644 --- a/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/triton_impl.py +++ b/lightllm/common/basemodel/layer_weights/meta_weights/fused_moe/impl/triton_impl.py @@ -111,6 +111,7 @@ def __call__( alpha: Optional[float] = None, limit: Optional[float] = None, clamp_up_add_one: bool = True, + shared_expert_output: Optional[torch.Tensor] = None, ): topk_weights, topk_ids, origin_topk_ids = self._select_experts( input_tensor=input_tensor, @@ -140,5 +141,6 @@ def __call__( alpha=alpha, limit=limit, clamp_up_add_one=clamp_up_add_one, + **({"shared_expert_output": shared_expert_output} if shared_expert_output is not None else {}), ) return output diff --git a/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe.py b/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe.py index 90eeb5a4ca..19df68e041 100644 --- a/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe.py +++ b/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe.py @@ -25,6 +25,7 @@ from lightllm.utils.log_utils import init_logger from lightllm.utils.vllm_utils import vllm_ops from lightllm.utils.device_utils import triton_support_tensor_descriptor +from lightllm.utils.envs_utils import get_env_use_ue8m0_scales, get_env_use_ue8m0_activation_scales from .moe_silu_and_mul import silu_and_mul_fwd from .moe_sum_reduce import moe_sum_reduce from lightllm.common.basemodel.triton_kernel.quantization.fp8act_quant_kernel import per_token_group_quant_fp8 @@ -878,8 +879,12 @@ def grouped_matmul( # input 使用 per group quant 量化 _m, _k = token_inputs.shape assert _k % block_size_k == 0 + # Triton consumes FP32 activation scales even when weight scales are packed UE8M0. token_inputs, token_input_scale = per_token_group_quant_fp8( - token_inputs, block_size_k, dtype=expert_weights.dtype + token_inputs, + block_size_k, + dtype=expert_weights.dtype, + use_ue8m0_scales=get_env_use_ue8m0_activation_scales(get_env_use_ue8m0_scales() or use_packed_ue8m0), ) if reused_mblock_infos is None: diff --git a/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe_ep.py b/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe_ep.py index 61cd81556a..fda0ab09f9 100644 --- a/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe_ep.py +++ b/lightllm/common/basemodel/triton_kernel/fused_moe/grouped_fused_moe_ep.py @@ -22,7 +22,7 @@ from lightllm.utils.envs_utils import ( get_deepep_num_max_dispatch_tokens_per_rank_prefill, get_deepep_num_max_dispatch_tokens_per_rank_decode, - get_env_use_ue8m0_scales, + get_env_use_ue8m0_activation_scales, ) from lightllm.common.triton_utils.autotuner import Autotuner, AutotuneKernelType from lightllm.utils.device_utils import is_sm100_gpu @@ -158,7 +158,7 @@ def mega_moe_impl( qinput_tensor = per_token_cast_to_fp8( hidden_states, - use_ue8m0=True, + use_ue8m0=get_env_use_ue8m0_activation_scales(default=True, require_ue8m0=True), gran_k=quant_method.block_size, use_packed_ue8m0=True, ) @@ -192,7 +192,7 @@ def quantize_fused_experts_input( return per_token_cast_to_fp8( hidden_states, - use_ue8m0=True, + use_ue8m0=get_env_use_ue8m0_activation_scales(default=True, require_ue8m0=True), gran_k=quant_method.block_size, use_packed_ue8m0=True, ) @@ -204,7 +204,7 @@ def quantize_fused_experts_input( hidden_states, block_size_k, dtype=w13.weight.dtype, - use_ue8m0_scales=get_env_use_ue8m0_scales(), + use_ue8m0_scales=get_env_use_ue8m0_activation_scales(), ) @@ -221,10 +221,15 @@ def fused_experts( alpha: Optional[float] = None, limit: Optional[float] = None, clamp_up_add_one: bool = True, + shared_expert_output: Optional[torch.Tensor] = None, ): assert (limit is None and alpha is None) or (limit is not None and alpha is not None) + if shared_expert_output is not None and not is_prefill: + raise ValueError("Shared combine bias requires normal prefill combine") check_ep_expert_dtype(quant_method) if use_sm100_mega_moe(quant_method): + if shared_expert_output is not None: + raise NotImplementedError("Shared combine bias requires DeepEP normal combine") if limit is not None: raise NotImplementedError("FP4 Mega MoE does not support clamped SwiGLU") return mega_moe_impl(hidden_states, w13, w2, topk_weights, topk_idx, quant_method) @@ -249,6 +254,7 @@ def fused_experts( alpha=alpha, limit=limit, clamp_up_add_one=clamp_up_add_one, + shared_expert_output=shared_expert_output, ) @@ -271,7 +277,11 @@ def fused_experts_impl( limit: Optional[float] = None, clamp_up_add_one: bool = True, block_size_k: int = 128, + shared_expert_output: Optional[torch.Tensor] = None, ): + # Shared output joins normal combine before its final BF16 cast. + if shared_expert_output is not None and not is_prefill: + raise ValueError("Shared combine bias requires normal prefill combine") # Check constraints. assert hidden_states.shape[1] == w1.shape[2], "Hidden size mismatch" assert topk_weights.shape == topk_idx.shape, "topk shape mismatch" @@ -288,7 +298,7 @@ def fused_experts_impl( combined_x = None if is_prefill: qinput_tensor, input_scale = per_token_group_quant_fp8( - hidden_states, block_size_k, dtype=w1.dtype, use_ue8m0_scales=get_env_use_ue8m0_scales() + hidden_states, block_size_k, dtype=w1.dtype, use_ue8m0_scales=get_env_use_ue8m0_activation_scales() ) allocate_on_comm_stream = previous_event is not None # Expanded dispatch directly produces expert-contiguous, alignment-padded inputs: @@ -372,6 +382,7 @@ def fused_experts_impl( topk_weights=None, previous_event=previous_event, allocate_on_comm_stream=allocate_on_comm_stream, + **({"bias": shared_expert_output} if shared_expert_output is not None else {}), ) else: # low latency dispatch @@ -383,7 +394,7 @@ def fused_experts_impl( num_max_dispatch_tokens_per_rank, num_experts, use_fp8=use_fp8_w8a8, - round_scale=get_env_use_ue8m0_scales(), + round_scale=get_env_use_ue8m0_activation_scales(), async_finish=False, return_recv_hook=False, ) @@ -608,7 +619,7 @@ def workspace_quant_alloc(shape, dtype, device): column_major_scales=True, scale_tma_aligned=True, alloc_func=workspace_quant_alloc, - use_ue8m0_scales=get_env_use_ue8m0_scales(), + use_ue8m0_scales=get_env_use_ue8m0_activation_scales(), ) workspace_manager.free(silu_out) del silu_out diff --git a/lightllm/common/basemodel/triton_kernel/fused_moe/moe_silu_and_mul_mix_quant_ep.py b/lightllm/common/basemodel/triton_kernel/fused_moe/moe_silu_and_mul_mix_quant_ep.py index 8c94f088c2..ef058407dd 100644 --- a/lightllm/common/basemodel/triton_kernel/fused_moe/moe_silu_and_mul_mix_quant_ep.py +++ b/lightllm/common/basemodel/triton_kernel/fused_moe/moe_silu_and_mul_mix_quant_ep.py @@ -4,7 +4,7 @@ import triton.language as tl from lightllm.utils.config_utils import ffn_use_tanh_approximate_gelu -from lightllm.utils.envs_utils import get_env_use_ue8m0_scales +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales @triton.jit @@ -161,7 +161,7 @@ def silu_and_mul_masked_post_quant_fwd( alpha=alpha, limit=limit, CLAMP_UP_ADD_ONE=clamp_up_add_one, - USE_UE8M0_SCALE=get_env_use_ue8m0_scales(), + USE_UE8M0_SCALE=get_env_use_ue8m0_activation_scales(), num_warps=num_warps, ) return diff --git a/lightllm/common/basemodel/triton_kernel/mhc/post.py b/lightllm/common/basemodel/triton_kernel/mhc/post.py index b4050e0765..b420bb2edf 100644 --- a/lightllm/common/basemodel/triton_kernel/mhc/post.py +++ b/lightllm/common/basemodel/triton_kernel/mhc/post.py @@ -13,8 +13,8 @@ def _hc_post_4stream_kernel( layer_output, # 输入 [T, H],激活 dtype。 residual, # 输入 [T, 4 * H],激活 dtype;当前子层 pre-mixing 前的残差。 - residual_mix, # 输入 [T, 4, 4],fp32;最后两维为输入、输出 stream。 - post_mix, # 输入 [T, 4],fp32。 + residual_mix, # 输入 [T, 4, 4],fp32 或激活 dtype;最后两维为输入、输出 stream。 + post_mix, # 输入 [T, 4],fp32 或激活 dtype。 output, # 输出 [T, 4 * H],与 layer_output 相同 dtype。 hidden: tl.constexpr, layer_stride_m: tl.constexpr, @@ -23,6 +23,7 @@ def _hc_post_4stream_kernel( post_stride_m: tl.constexpr, out_stride_m: tl.constexpr, BLOCK_H: tl.constexpr, + GLM5_NUMERICS: tl.constexpr, ): """将子层输出和原始残差混合为四条新 stream:[T, H] + [T, 4, H] -> [T, 4, H]。 @@ -50,10 +51,10 @@ def _hc_post_4stream_kernel( post_base = post_mix + token * post_stride_m mix_base = residual_mix + token * mix_stride_m - accumulator_0 = layer_value * tl.load(post_base) - accumulator_1 = layer_value * tl.load(post_base + 1) - accumulator_2 = layer_value * tl.load(post_base + 2) - accumulator_3 = layer_value * tl.load(post_base + 3) + accumulator_0 = tl.full((BLOCK_H,), 0.0, tl.float32) if GLM5_NUMERICS else layer_value * tl.load(post_base) + accumulator_1 = tl.full((BLOCK_H,), 0.0, tl.float32) if GLM5_NUMERICS else layer_value * tl.load(post_base + 1) + accumulator_2 = tl.full((BLOCK_H,), 0.0, tl.float32) if GLM5_NUMERICS else layer_value * tl.load(post_base + 2) + accumulator_3 = tl.full((BLOCK_H,), 0.0, tl.float32) if GLM5_NUMERICS else layer_value * tl.load(post_base + 3) accumulator_0 += residual_0 * tl.load(mix_base) accumulator_0 += residual_1 * tl.load(mix_base + 4) @@ -72,6 +73,18 @@ def _hc_post_4stream_kernel( accumulator_3 += residual_2 * tl.load(mix_base + 11) accumulator_3 += residual_3 * tl.load(mix_base + 15) + if GLM5_NUMERICS: + # With TORCH_COMPILE_DISABLE=1, Megatron materializes both the + # residual bmm and the post product in the activation dtype. + accumulator_0 = accumulator_0.to(residual.dtype.element_ty).to(tl.float32) + accumulator_1 = accumulator_1.to(residual.dtype.element_ty).to(tl.float32) + accumulator_2 = accumulator_2.to(residual.dtype.element_ty).to(tl.float32) + accumulator_3 = accumulator_3.to(residual.dtype.element_ty).to(tl.float32) + accumulator_0 += (layer_value * tl.load(post_base)).to(layer_output.dtype.element_ty).to(tl.float32) + accumulator_1 += (layer_value * tl.load(post_base + 1)).to(layer_output.dtype.element_ty).to(tl.float32) + accumulator_2 += (layer_value * tl.load(post_base + 2)).to(layer_output.dtype.element_ty).to(tl.float32) + accumulator_3 += (layer_value * tl.load(post_base + 3)).to(layer_output.dtype.element_ty).to(tl.float32) + output_base = output + token * out_stride_m + hidden_offsets tl.store(output_base, accumulator_0, mask=hidden_mask) tl.store(output_base + hidden, accumulator_1, mask=hidden_mask) @@ -85,6 +98,8 @@ def hc_post( residual_mix: torch.Tensor, post_mix: torch.Tensor, streams: int, + *, + glm5_numerics: bool = False, ) -> torch.Tensor: """用一次 Triton launch 将子层输出混回残差 streams。 @@ -93,9 +108,10 @@ def hc_post( Args: layer_output: [T, H],连续的 attention / FFN 子层输出。 residual: [T, S * H],连续的、该子层 pre-mixing 前保存的原始残差。 - residual_mix: [T, S, S],fp32;来自 hc_pre_norm。 - post_mix: [T, S],fp32;来自同一次 pre 调用。 + residual_mix: [T, S, S],fp32 或激活 dtype;来自 hc_pre_norm。 + post_mix: [T, S],与 residual_mix 相同 dtype;来自同一次 pre 调用。 streams: 残差 stream 数 S;当前 Triton 入口要求 S = 4。 + glm5_numerics: 匹配 GLM 训练默认 native post,在加子层输出前将残差混合结果和 post 乘积分别舍入到激活 dtype。 Returns: 新残差 [T, S * H],dtype 与 layer_output 相同;输入张量保持不变。 @@ -127,6 +143,8 @@ def hc_post( post_stride_m=post_mix.stride(0), out_stride_m=output.stride(0), BLOCK_H=block_h, + GLM5_NUMERICS=glm5_numerics, + enable_fp_fusion=not glm5_numerics, num_warps=8, ) return output diff --git a/lightllm/common/basemodel/triton_kernel/mhc/pre_norm.py b/lightllm/common/basemodel/triton_kernel/mhc/pre_norm.py index 2710040673..3191ce0716 100644 --- a/lightllm/common/basemodel/triton_kernel/mhc/pre_norm.py +++ b/lightllm/common/basemodel/triton_kernel/mhc/pre_norm.py @@ -14,6 +14,63 @@ LIGHTLLM_DISABLE_DEEPGEMM_MHC = os.getenv("LIGHTLLM_DISABLE_DEEPGEMM_MHC", "False").upper() in ["ON", "TRUE", "1"] +@triton.jit +def _hc_input_inv_rms_kernel( + x, + inv_rms, + FLATTENED_HIDDEN: tl.constexpr, + RMS_EPS: tl.constexpr, + BLOCK_K: tl.constexpr, +): + token = tl.program_id(0) + offsets = tl.arange(0, BLOCK_K) + values = tl.load(x + token * FLATTENED_HIDDEN + offsets, offsets < FLATTENED_HIDDEN, 0).to(tl.float32) + variance = tl.sum(values * values, axis=0) / FLATTENED_HIDDEN + tl.store(inv_rms + token, tl.rsqrt(variance + RMS_EPS)) + + +@triton.jit +def _hc_normalized_projection_kernel( + x, + fn, + inv_rms, + gemm_partial, + tokens, + FLATTENED_HIDDEN: tl.constexpr, + MIX_SIZE: tl.constexpr, + SPLIT_K_BLOCKS: tl.constexpr, + BLOCK_T: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_K: tl.constexpr, +): + token_offsets = tl.program_id(0) * BLOCK_T + tl.arange(0, BLOCK_T) + split = tl.program_id(1) + mix_offsets = tl.arange(0, BLOCK_M) + k_offsets = split * SPLIT_K_BLOCKS * BLOCK_K + tl.arange(0, BLOCK_K) + rms = tl.load(inv_rms + token_offsets, token_offsets < tokens, 0) + accumulator = tl.zeros((BLOCK_T, BLOCK_M), dtype=tl.float32) + for _ in range(SPLIT_K_BLOCKS): + values = tl.load( + x + token_offsets[:, None] * FLATTENED_HIDDEN + k_offsets[None, :], + (token_offsets[:, None] < tokens) & (k_offsets[None, :] < FLATTENED_HIDDEN), + 0, + ).to(tl.float32) + weights = tl.load( + fn + mix_offsets[None, :] * FLATTENED_HIDDEN + k_offsets[:, None], + (mix_offsets[None, :] < MIX_SIZE) & (k_offsets[:, None] < FLATTENED_HIDDEN), + 0, + ) + # Normalize in FP32 before the IEEE projection, independent of TF32 settings. + normalized = values * rms[:, None] + accumulator = tl.dot(normalized, weights, accumulator, input_precision="ieee") + k_offsets += BLOCK_K + tl.store( + gemm_partial + (split * tokens + token_offsets[:, None]) * MIX_SIZE + mix_offsets[None, :], + accumulator, + (token_offsets[:, None] < tokens) & (mix_offsets[None, :] < MIX_SIZE), + ) + + @triton.jit def _hc_prepare_prenorm_kernel( gemm_partial, # 输入 [P, T, M],fp32;投影的 split-K 部分和。 @@ -21,8 +78,8 @@ def _hc_prepare_prenorm_kernel( scale, # 输入 [3],fp32;pre、post、residual 三组缩放系数。 base, # 输入 [M],fp32;M = 2 * S + S * S。 pre, # 输出 [T, S],fp32。 - post, # 输出 [T, S],fp32。 - residual_mix, # 输出 [T, S, S],fp32;最后两维为输入、输出 stream。 + post, # 输出 [T, S],fp32 或激活 dtype。 + residual_mix, # 输出 [T, S, S],fp32 或激活 dtype;最后两维为输入、输出 stream。 gemm_stride_s, gemm_stride_m: tl.constexpr, sqrsum_stride_s, @@ -36,6 +93,7 @@ def _hc_prepare_prenorm_kernel( POST_MULTIPLIER: tl.constexpr, SINKHORN_ITERS: tl.constexpr, N_SPLITS: tl.constexpr, + PROJECTION_IS_NORMALIZED: tl.constexpr, ): """归并分片并准备混合权重:[P, T, M] / [P, T] -> [T, S] / [T, S, S]。 @@ -55,11 +113,13 @@ def _hc_prepare_prenorm_kernel( pre_raw += tl.load(partial_base + stream_offsets) post_raw += tl.load(partial_base + STREAMS + stream_offsets) matrix_raw += tl.load(partial_base + 2 * STREAMS + matrix_offsets) - sqrsum += tl.load(sqrsum_partial + split * sqrsum_stride_s + token * sqrsum_stride_m) - inv_rms = tl.rsqrt(sqrsum / FLATTENED_HIDDEN + RMS_EPS) - pre_raw *= inv_rms - post_raw *= inv_rms - matrix_raw *= inv_rms + if not PROJECTION_IS_NORMALIZED: + sqrsum += tl.load(sqrsum_partial + split * sqrsum_stride_s + token * sqrsum_stride_m) + if not PROJECTION_IS_NORMALIZED: + inv_rms = tl.rsqrt(sqrsum / FLATTENED_HIDDEN + RMS_EPS) + pre_raw *= inv_rms + post_raw *= inv_rms + matrix_raw *= inv_rms pre_values = tl.sigmoid(pre_raw * tl.load(scale) + tl.load(base + stream_offsets)) + HC_EPS post_values = POST_MULTIPLIER * tl.sigmoid(post_raw * tl.load(scale + 1) + tl.load(base + STREAMS + stream_offsets)) @@ -141,6 +201,38 @@ def _compute_prenorm_splits(tokens: int, flattened_hidden: int, device: torch.de return max(1, min(sms // max(grid_size, 1), k_blocks // 4)) +def _hc_normalized_projection(x: torch.Tensor, fn: torch.Tensor, rms_eps: float) -> torch.Tensor: + """FP32 normalize-before-project, without materializing a [T, S * H] FP32 copy.""" + tokens, flattened_hidden = x.shape + mix_size = fn.shape[0] + n_splits = _compute_prenorm_splits(tokens, flattened_hidden, x.device) + inv_rms = torch.empty((tokens,), dtype=torch.float32, device=x.device) + gemm_partial = torch.empty((n_splits, tokens, mix_size), dtype=torch.float32, device=x.device) + _hc_input_inv_rms_kernel[(tokens,)]( + x, + inv_rms, + FLATTENED_HIDDEN=flattened_hidden, + RMS_EPS=rms_eps, + BLOCK_K=triton.next_power_of_2(flattened_hidden), + num_warps=8, + ) + _hc_normalized_projection_kernel[(triton.cdiv(tokens, 16), n_splits)]( + x, + fn, + inv_rms, + gemm_partial, + tokens, + FLATTENED_HIDDEN=flattened_hidden, + MIX_SIZE=mix_size, + SPLIT_K_BLOCKS=triton.cdiv(triton.cdiv(flattened_hidden, 64), n_splits), + BLOCK_T=16, + BLOCK_M=triton.next_power_of_2(mix_size), + BLOCK_K=64, + num_warps=4, + ) + return gemm_partial + + def hc_pre_norm( x: torch.Tensor, fn: torch.Tensor, @@ -153,6 +245,8 @@ def hc_pre_norm( hc_eps: float, sinkhorn_iters: int, post_multiplier: float = 2.0, + *, + glm5_numerics: bool = False, ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """将 mHC pre-mixing 与紧随其后的子层 RMSNorm 融合。 @@ -170,15 +264,18 @@ def hc_pre_norm( hc_eps: pre sigmoid 后的偏移量及 Sinkhorn 的稳定项。 sinkhorn_iters: Sinkhorn 迭代次数。 post_multiplier: post sigmoid 的乘数,默认 2.0。 + glm5_numerics: FP32 先归一化后投影;pre 系数保持 fp32,post/residual 舍入到激活 dtype。 Returns: - (layer_input, residual_mix, post_mix):分别为 bf16 [T, H]、fp32 [T, S, S]、fp32 [T, S]。 + (layer_input, residual_mix, post_mix):形状为 [T, H]、[T, S, S]、[T, S]。 + layer_input 为 bf16;两个混合系数默认 fp32,glm5_numerics=True 时为激活 dtype。 layer_input 已完成子层 RMSNorm;residual_mix[t, i, j] 对应输入 i 到输出 j。 x 保持不变,留给对应 hc_post 使用。 默认用 DeepGEMM 融合投影与平方和计算,Triton 完成 Sinkhorn、stream 合并和 RMSNorm。 合并结果先舍入到 bf16,再做 RMSNorm。 LIGHTLLM_DISABLE_DEEPGEMM_MHC=1 禁用 DeepGEMM,改用 PyTorch 计算投影与平方和。 + glm5_numerics=True 则使用独立的 IEEE FP32 Triton 投影,不依赖 DeepGEMM 或全局 TF32 设置。 """ assert x.ndim == 2 and x.shape[-1] % streams == 0 @@ -187,8 +284,13 @@ def hc_pre_norm( assert x.is_contiguous() and fn.is_contiguous() and norm_weight.is_contiguous() tokens, flattened_hidden = x.shape hidden = flattened_hidden // streams + assert fn.shape == ((2 + streams) * streams, flattened_hidden) - if LIGHTLLM_DISABLE_DEEPGEMM_MHC: + if glm5_numerics: + gemm_partial = _hc_normalized_projection(x, fn, rms_eps) + sqrsum_partial = None + n_splits = gemm_partial.shape[0] + elif LIGHTLLM_DISABLE_DEEPGEMM_MHC: # PyTorch produces one complete partition: [1, T, M] and [1, T]. x_fp32 = x.float() gemm_partial = (x_fp32 @ fn.T).unsqueeze(0) @@ -204,8 +306,9 @@ def hc_pre_norm( tf32_hc_prenorm_gemm(x, fn, gemm_partial, sqrsum_partial, n_splits) pre = torch.empty((tokens, streams), dtype=torch.float32, device=x.device) - post = torch.empty_like(pre) - residual_mix = torch.empty((tokens, streams, streams), dtype=torch.float32, device=x.device) + mix_dtype = x.dtype if glm5_numerics else torch.float32 + post = torch.empty((tokens, streams), dtype=mix_dtype, device=x.device) + residual_mix = torch.empty((tokens, streams, streams), dtype=mix_dtype, device=x.device) _hc_prepare_prenorm_kernel[(tokens,)]( gemm_partial, sqrsum_partial, @@ -216,8 +319,8 @@ def hc_pre_norm( residual_mix, gemm_partial.stride(0), gemm_partial.stride(1), - sqrsum_partial.stride(0), - sqrsum_partial.stride(1), + sqrsum_partial.stride(0) if sqrsum_partial is not None else 0, + sqrsum_partial.stride(1) if sqrsum_partial is not None else 0, pre.stride(0), residual_mix.stride(0), FLATTENED_HIDDEN=flattened_hidden, @@ -227,6 +330,7 @@ def hc_pre_norm( POST_MULTIPLIER=post_multiplier, SINKHORN_ITERS=sinkhorn_iters, N_SPLITS=n_splits, + PROJECTION_IS_NORMALIZED=glm5_numerics, num_warps=1, ) diff --git a/lightllm/common/quantization/deepgemm.py b/lightllm/common/quantization/deepgemm.py index c953afd27a..5aca76bd5d 100644 --- a/lightllm/common/quantization/deepgemm.py +++ b/lightllm/common/quantization/deepgemm.py @@ -6,6 +6,7 @@ from lightllm.common.quantization.registry import QUANTMETHODS from lightllm.common.basemodel.triton_kernel.quantization.fp8act_quant_kernel import per_token_group_quant_fp8 from lightllm.utils.device_utils import is_sm100_gpu +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales from lightllm.utils.log_utils import init_logger logger = init_logger(__name__) @@ -109,7 +110,9 @@ def apply( column_major_scales=True, scale_tma_aligned=True, alloc_func=alloc_func, - use_ue8m0_scales=self.use_ue8m0_scales, + use_ue8m0_scales=get_env_use_ue8m0_activation_scales( + self.use_ue8m0_scales, require_ue8m0=self.use_packed_ue8m0 + ), use_packed_ue8m0=self.use_packed_ue8m0, ) diff --git a/lightllm/common/quantization/w8a8.py b/lightllm/common/quantization/w8a8.py index 472db0d543..92075b8282 100644 --- a/lightllm/common/quantization/w8a8.py +++ b/lightllm/common/quantization/w8a8.py @@ -13,6 +13,7 @@ from lightllm.common.basemodel.triton_kernel.quantization.fp8w8a8_block_gemm_kernel import w8a8_block_fp8_matmul from lightllm.utils.vllm_utils import HAS_VLLM, vllm_ops, cutlass_scaled_mm from lightllm.utils.sgl_utils import HAS_SGL_KERNEL, sgl_ops +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales _HAS_SGL_FP8 = HAS_SGL_KERNEL and sgl_ops is not None and hasattr(sgl_ops, "fp8_scaled_mm") @@ -447,7 +448,11 @@ def _dynamic_quant_input( alloc_func = torch.empty if not use_custom_tensor_mananger else self.cache_manager.empty if input_scale is None: qinput_tensor, input_scale = per_token_group_quant_fp8( - input_tensor, self.block_size, dtype=qweight.dtype, alloc_func=alloc_func + input_tensor, + self.block_size, + dtype=qweight.dtype, + alloc_func=alloc_func, + use_ue8m0_scales=get_env_use_ue8m0_activation_scales(default=False), ) if out is None: out = alloc_func((m, n), dtype=input_tensor.dtype, device=input_tensor.device) diff --git a/lightllm/models/glm5_next/indexer.py b/lightllm/models/glm5_next/indexer.py index cf13ab83de..30a9883881 100644 --- a/lightllm/models/glm5_next/indexer.py +++ b/lightllm/models/glm5_next/indexer.py @@ -15,7 +15,8 @@ def __init__(self, layer_idx, network_config, tp_world_size): self.topk = network_config["index_topk"] self.heads = network_config["index_n_heads"] self.dim = network_config["index_head_dim"] - self.eps = network_config["rms_norm_eps"] + # The indexer LayerNorm has a separate epsilon from the model RMSNorm. + self.eps = 1e-6 def select_topk_indices(self, logits, lengths, indices): """Select row-relative indices, with valid entries before -1 padding.""" diff --git a/lightllm/models/glm5_next/layer_infer/transformer_layer_infer.py b/lightllm/models/glm5_next/layer_infer/transformer_layer_infer.py index 620554bc06..e748701967 100644 --- a/lightllm/models/glm5_next/layer_infer/transformer_layer_infer.py +++ b/lightllm/models/glm5_next/layer_infer/transformer_layer_infer.py @@ -17,7 +17,7 @@ ) from lightllm.common.triton_utils.autotuner import Autotuner from lightllm.models.glm5_next.indexer import Glm5NextNsaInfer -from lightllm.utils.envs_utils import get_env_start_args +from lightllm.utils.envs_utils import enable_env_vars, get_env_start_args from lightllm.utils.tensor_utils import tensor_to_no_ref_tensor @@ -37,6 +37,7 @@ def __init__(self, layer_num, network_config): self.hc_sinkhorn_iters = network_config.get("hc_sinkhorn_iters", 20) self.swiglu_limit = network_config["swiglu_limit"] self.enable_ep_moe = get_env_start_args().enable_ep_moe + self.ep_shared_combine_bias = self.enable_ep_moe and enable_env_vars("LIGHTLLM_EP_SHARED_COMBINE_BIAS") self.is_moe = ( network_config["n_routed_experts"] is not None and layer_num >= network_config["first_k_dense_replace"] @@ -101,10 +102,12 @@ def _moe_ffn(self, input, infer_state, layer_weight) -> torch.Tensor: hidden_states = input.view(-1, self.embed_dim_) num_tokens, hidden_dim = hidden_states.shape - # if fused_shared_experts is not enabled, compute shared_output + shared_output = None if self.n_shared_experts is not None and layer_weight.num_fused_shared_experts == 0: shared_output = self._ffn_tp(hidden_states, infer_state, layer_weight) + combine_shared = self.ep_shared_combine_bias and infer_state.is_prefill and shared_output is not None + moe_gate_dtype = layer_weight.moe_gate.data_type_ router_logits = layer_weight.moe_gate.mm(hidden_states.to(moe_gate_dtype)) output = layer_weight.experts.experts( @@ -120,9 +123,10 @@ def _moe_ffn(self, input, infer_state, layer_weight) -> torch.Tensor: alpha=1.0, limit=self.swiglu_limit, clamp_up_add_one=False, + **({"shared_expert_output": shared_output} if combine_shared else {}), ) - if self.n_shared_experts is not None and layer_weight.num_fused_shared_experts == 0: + if shared_output is not None and not combine_shared: output.add_(shared_output) return output.view(num_tokens, hidden_dim) @@ -428,6 +432,7 @@ def _hc_pre(self, streams, layer_weight, prefix, norm_weight): norm_eps=self.eps_, hc_eps=self.hc_eps, sinkhorn_iters=self.hc_sinkhorn_iters, + glm5_numerics=True, ) def _forward_mhc(self, input_embeddings, infer_state, layer_weight, *, prefill): @@ -438,11 +443,11 @@ def _forward_mhc(self, input_embeddings, infer_state, layer_weight, *, prefill): layer_output = self.context_attention_forward(layer_input, infer_state, layer_weight) else: layer_output = self.token_attention_forward(layer_input, infer_state, layer_weight) - streams = hc_post(layer_output, streams, residual_mix, post_mix, self.mhc_streams) + streams = hc_post(layer_output, streams, residual_mix, post_mix, self.mhc_streams, glm5_numerics=True) layer_input, residual_mix, post_mix = self._hc_pre(streams, layer_weight, "ffn", layer_weight.ffn_norm_weight_) layer_output = self._ffn(layer_input, infer_state, layer_weight) - streams = hc_post(layer_output, streams, residual_mix, post_mix, self.mhc_streams) + streams = hc_post(layer_output, streams, residual_mix, post_mix, self.mhc_streams, glm5_numerics=True) # Only prefill autotuning truncates the model. Decode autotuning still # executes every layer and must keep all residual streams until the end. is_autotune_last_layer = ( diff --git a/lightllm/models/glm5_next/triton_kernel/index_quant.py b/lightllm/models/glm5_next/triton_kernel/index_quant.py index 67485d9b52..25c09fd908 100644 --- a/lightllm/models/glm5_next/triton_kernel/index_quant.py +++ b/lightllm/models/glm5_next/triton_kernel/index_quant.py @@ -2,6 +2,7 @@ import triton import triton.language as tl from lightllm.models.deepseek3_2.triton_kernel.hadamard_transform import _butterfly_stage +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales @triton.jit @@ -13,6 +14,7 @@ def _hadamard_transform_quant_fp8_kernel( scale: tl.constexpr, BLOCK_R: tl.constexpr, BLOCK_N: tl.constexpr, + USE_UE8M0_SCALES: tl.constexpr, ): pid = tl.program_id(0) rows = pid * BLOCK_R + tl.arange(0, BLOCK_R) @@ -32,7 +34,9 @@ def _hadamard_transform_quant_fp8_kernel( # Match the unfused path's bf16 Hadamard output before FP8 quantization. x = (x * scale).to(tl.bfloat16).to(tl.float32) absmax = tl.maximum(tl.max(tl.abs(x), axis=1), 1e-4) - quant_scale = tl.exp2(tl.ceil(tl.log2(absmax * (1.0 / 448.0)))) + quant_scale = absmax * (1.0 / 448.0) + if USE_UE8M0_SCALES: + quant_scale = tl.exp2(tl.ceil(tl.log2(quant_scale))) y = tl.minimum(tl.maximum(x / quant_scale[:, None], -448.0), 448.0) tl.store(Y + offsets, y, mask=row_mask[:, None]) @@ -40,7 +44,7 @@ def _hadamard_transform_quant_fp8_kernel( def hadamard_transform_quant_fp8(x: torch.Tensor, scale: float = 1.0) -> tuple[torch.Tensor, torch.Tensor]: - """Fuse Hadamard-128 with the following ue8m0 FP8 quantization.""" + """Fuse Hadamard-128 and FP8 quantization with FP32 scale storage.""" assert x.is_cuda, "hadamard_transform_quant_fp8 only supports CUDA tensors" assert x.dtype == torch.bfloat16, "Hadamard transform expects bfloat16 input" @@ -64,6 +68,7 @@ def hadamard_transform_quant_fp8(x: torch.Tensor, scale: float = 1.0) -> tuple[t scale, BLOCK_R=block_r, BLOCK_N=128, + USE_UE8M0_SCALES=get_env_use_ue8m0_activation_scales(default=True), num_warps=2, ) return output.view(original_shape), output_scale diff --git a/lightllm/models/glm5_next/triton_kernel/kpool.py b/lightllm/models/glm5_next/triton_kernel/kpool.py index 0e126aea6a..c40ab42abd 100644 --- a/lightllm/models/glm5_next/triton_kernel/kpool.py +++ b/lightllm/models/glm5_next/triton_kernel/kpool.py @@ -4,6 +4,7 @@ from lightllm.models.deepseek3_2.triton_kernel.hadamard_transform import _butterfly_stage from lightllm.utils.device_utils import get_device_sm_count +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales @triton.jit @@ -45,6 +46,7 @@ def _compress_pools( SINGLE_QUERY: tl.constexpr, TAIL_SIZE: tl.constexpr, HAS_MTP_INDEX: tl.constexpr, + USE_UE8M0_SCALES: tl.constexpr, ): if SINGLE_QUERY: batch = tl.program_id(0) @@ -88,7 +90,9 @@ def _compress_pools( for step in tl.static_range(7): key = _butterfly_stage(key, 64 >> step, 1 << step, 1, 128) key = (key * (128 ** -0.5)).to(tl.bfloat16).to(tl.float32) - scale = tl.exp2(tl.ceil(tl.log2(tl.maximum(tl.max(tl.abs(key), 1), 1e-4) / 448.0))) + scale = tl.maximum(tl.max(tl.abs(key), 1), 1e-4) / 448.0 + if USE_UE8M0_SCALES: + scale = tl.exp2(tl.ceil(tl.log2(scale))) key = tl.minimum(tl.maximum(key / scale[:, None], -448.0), 448.0).to(tl.float8e4nv) loc = tl.load(Ragged + start + length - 1).to(tl.int64) dest = Packed + loc * PACKED_STRIDE @@ -156,6 +160,7 @@ def compress_pools( single_query, tail.shape[1], mtp_index is not None, + USE_UE8M0_SCALES=get_env_use_ue8m0_activation_scales(default=True), num_warps=4, ) # Complete all boundary pools before replacing the previous chunk's tail. diff --git a/lightllm/server/api_openai.py b/lightllm/server/api_openai.py index fa61a311f4..fee264748a 100644 --- a/lightllm/server/api_openai.py +++ b/lightllm/server/api_openai.py @@ -181,7 +181,7 @@ def _get_history_tool_calls_cnt(request: ChatCompletionRequest) -> int: def _is_force_thinking_mode(request: ChatCompletionRequest) -> bool: - """Whether this request uses forced thinking / reasoning (parser + template).""" + """Resolve the request's thinking switch for rendering the chat template.""" from .build_prompt import tokenizer_supports_force_thinking reasoning_parser = get_env_start_args().reasoning_parser @@ -201,11 +201,10 @@ def _is_force_thinking_mode(request: ChatCompletionRequest) -> bool: return True # default -def _get_guided_reasoning_end(request: ChatCompletionRequest, tokenizer) -> List[int]: - """Use the same thinking mode as the chat template for grammar activation.""" - if not _is_force_thinking_mode(request): +def _get_guided_reasoning_end(tokenizer, parser_name: Optional[str], initial_reasoning_state: bool) -> List[int]: + """Delay answer constraints while generation starts inside reasoning.""" + if not parser_name or not initial_reasoning_state: return [] - parser_name = get_env_start_args().reasoning_parser detector = ReasoningParser.DetectorMap[parser_name.lower()]() token_ids = tokenizer.encode(detector.grammar_start_token, add_special_tokens=False) return token_ids @@ -217,13 +216,14 @@ def _process_reasoning_stream( reasoning_parser_dict: Dict[int, ReasoningParser], metadata: Dict[str, Any], request: ChatCompletionRequest, -) -> tuple[Optional[str], str]: + force_reasoning: bool, +) -> tuple[str, str]: """Process reasoning content and update its token usage.""" if index not in reasoning_parser_dict: reasoning_parser_dict[index] = ReasoningParser( get_env_start_args().reasoning_parser, request.stream_reasoning, - _is_force_thinking_mode(request), + force_reasoning, ) parser = reasoning_parser_dict[index] token_id = metadata.get("id") @@ -353,6 +353,10 @@ async def chat_completions_impl(request: ChatCompletionRequest, raw_request: Req multimodal_params = _build_multimodal_params(request) tools = _select_chat_template_tools(request) prompt = await build_prompt(request, tools) + reasoning_parser = get_env_start_args().reasoning_parser + initial_reasoning_state = ReasoningParser.get_initial_reasoning_state( + reasoning_parser, prompt, default=_is_force_thinking_mode(request) + ) sampling_params_dict = { "do_sample": request.do_sample, "presence_penalty": request.presence_penalty, @@ -385,9 +389,9 @@ async def chat_completions_impl(request: ChatCompletionRequest, raw_request: Req sampling_params_dict["max_new_tokens"] = request.max_tokens if request.stop is not None: sampling_params_dict["stop_sequences"] = request.stop - if get_env_start_args().reasoning_parser: + if reasoning_parser: # The prompt may already open reasoning; generated tokens may omit the opening marker. - sampling_params_dict["_initial_reasoning_state"] = int(_is_force_thinking_mode(request)) + sampling_params_dict["_initial_reasoning_state"] = int(initial_reasoning_state) # Chat 接口提取 reasoning,按请求设置 stop 范围;通用 generate 接口不区分阶段。 sampling_params_dict["stop_str_match_mode"] = ( StopStrMatchMode.ALL_TEXT if is_stop_matching_in_reasoning_enabled() else StopStrMatchMode.CONTENT_ONLY @@ -405,7 +409,7 @@ async def chat_completions_impl(request: ChatCompletionRequest, raw_request: Req if sampling_params_dict.get("guided_json") or sampling_params_dict.get("guided_grammar"): sampling_params_dict["guided_reasoning_end"] = _get_guided_reasoning_end( - request, g_objs.httpserver_manager.tokenizer + g_objs.httpserver_manager.tokenizer, reasoning_parser, initial_reasoning_state ) sampling_params = SamplingParams() @@ -418,8 +422,6 @@ async def chat_completions_impl(request: ChatCompletionRequest, raw_request: Req # Non-streaming case if not request.stream: - reasoning_parser = get_env_start_args().reasoning_parser - request_enable_reasoning = _is_force_thinking_mode(request) if reasoning_parser else False final_output_dict = collections.defaultdict(list) count_output_tokens_dict = collections.defaultdict(lambda: 0) finish_reason_dict = {} @@ -440,7 +442,7 @@ async def chat_completions_impl(request: ChatCompletionRequest, raw_request: Req parser = ReasoningParser( reasoning_parser, stream_reasoning=False, - force_reasoning=request_enable_reasoning, + force_reasoning=initial_reasoning_state, ) reasoning_parser_dict[sub_req_id] = parser token_id = metadata.get("id") @@ -590,10 +592,21 @@ async def stream_results() -> AsyncGenerator[bytes, None]: yield f"data: {_serialize_sse_chunk(first_chunk, _first_choice_nulls, _first_resp_nulls)}\n\n" # Handle reasoning content - if get_env_start_args().reasoning_parser: + if reasoning_parser: reasoning_text, delta = _process_reasoning_stream( - choice_index, delta, reasoning_parser_dict, metadata, request + choice_index, + delta, + reasoning_parser_dict, + metadata, + request, + force_reasoning=initial_reasoning_state, ) + if current_finish_reason is not None: + # Flush before tool parsing so buffered argument text follows + # the same path as every other chunk, including on truncation. + flush_reasoning, flush_text = reasoning_parser_dict[choice_index].flush() + reasoning_text += flush_reasoning + delta += flush_text if reasoning_text: if request.separate_reasoning: choice_data = ChatCompletionStreamResponseChoice( @@ -609,7 +622,7 @@ async def stream_results() -> AsyncGenerator[bytes, None]: ) yield f"data: {_serialize_sse_chunk(chunk, _choice_nulls)}\n\n" else: - delta = reasoning_text + (delta or "") + delta = reasoning_text + delta if request.tool_choice != "none" and request.tools: # parse_increment => returns (normal_text, calls) @@ -773,46 +786,6 @@ async def stream_results() -> AsyncGenerator[bytes, None]: # Emit a per-choice final chunk with finish_reason (for tool_calls path # or when no delta was emitted alongside finish_reason). if current_finish_reason is not None: - # Flush any buffered reasoning content that was never released - # (e.g., max_completion_tokens hit before was seen). - if get_env_start_args().reasoning_parser: - parser = reasoning_parser_dict.get(choice_index) - if parser is not None: - flush_reasoning, flush_text = parser.flush() - if flush_reasoning: - if request.separate_reasoning: - flush_choice = ChatCompletionStreamResponseChoice( - index=choice_index, - delta=DeltaMessage(reasoning=flush_reasoning), - finish_reason=None, - ) - else: - # vLLM compat: emit buffered thinking as content - flush_choice = ChatCompletionStreamResponseChoice( - index=choice_index, - delta=DeltaMessage(content=flush_reasoning), - finish_reason=None, - ) - flush_chunk = ChatCompletionStreamResponse( - id=chat_completion_id, - created=created_time, - model=request.model, - choices=[flush_choice], - ) - yield f"data: {_serialize_sse_chunk(flush_chunk, _choice_nulls)}\n\n" - if flush_text: - flush_choice = ChatCompletionStreamResponseChoice( - index=choice_index, - delta=DeltaMessage(content=flush_text), - finish_reason=None, - ) - flush_chunk = ChatCompletionStreamResponse( - id=chat_completion_id, - created=created_time, - model=request.model, - choices=[flush_choice], - ) - yield f"data: {_serialize_sse_chunk(flush_chunk, _choice_nulls)}\n\n" if has_emitted_tool_calls[sub_req_id] and current_finish_reason == "stop": current_finish_reason = "tool_calls" final_choice = ChatCompletionStreamResponseChoice( @@ -828,7 +801,6 @@ async def stream_results() -> AsyncGenerator[bytes, None]: ) yield f"data: {_serialize_sse_chunk(final_chunk, _final_choice_nulls)}\n\n" - reasoning_parser = get_env_start_args().reasoning_parser completion_tokens_details = None if reasoning_parser: reasoning_tokens = sum(parser.reasoning_tokens for parser in reasoning_parser_dict.values()) diff --git a/lightllm/server/function_call_parser.py b/lightllm/server/function_call_parser.py index e5d7564081..e2b6d2ab84 100644 --- a/lightllm/server/function_call_parser.py +++ b/lightllm/server/function_call_parser.py @@ -13,6 +13,7 @@ import ast import json +import math import os import orjson import logging @@ -20,7 +21,7 @@ from abc import ABC, abstractmethod from json import JSONDecodeError, JSONDecoder from json.decoder import WHITESPACE -from typing import Any, Dict, List, Optional, Tuple, Type +from typing import Any, Dict, Iterator, List, Optional, Tuple, Type import partial_json_parser from partial_json_parser.core.exceptions import MalformedJSON @@ -28,6 +29,7 @@ from pydantic import BaseModel, Field from .api_models import Tool +from .parser_utils import GlmToolCallScanner logger = logging.getLogger(__name__) ENABLE_TOOL_NAME_CHECK = os.getenv("LIGHTLLM_ENABLE_TOOL_NAME_CHECK", "False").upper() in ["ON", "TRUE", "1"] @@ -1187,64 +1189,152 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami class Glm47Detector(BaseFormatDetector): - """ - Detector for GLM-4.7/GLM-4.7-Flash model function call format. - - The GLM-4.7 format uses an XML-style envelope with arg_key/arg_value pairs - instead of JSON arguments. - - Format Structure: - ``` - function_name - param1 - value1 - param2 - value2 - - ``` - - Example: - ``` - tool_brave_web_search_post - query - test search - count - 5 - - ``` - - Key Components: - - Tool Call Tags: `` and `` wrap each individual call - - Function Name: Appears on the first line after `` - - Arguments: Pairs of `name` and `value` + """Parse GLM's XML-style tool calls. - Reference: https://github.com/vllm-project/vllm/blob/main/vllm/tool_parsers/glm4_moe_tool_parser.py + Format: namekeyvalue + Arguments stay literal until the call is complete, then convert according + to the function's parameter schema. """ + _TYPE_ALIASES = { + "str": "string", + "text": "string", + "varchar": "string", + "char": "string", + "enum": "string", + "int": "integer", + "int32": "integer", + "int64": "integer", + "uint": "integer", + "uint32": "integer", + "uint64": "integer", + "long": "integer", + "short": "integer", + "unsigned": "integer", + "float": "number", + "float32": "number", + "float64": "number", + "double": "number", + "bool": "boolean", + "dict": "object", + "arr": "array", + "list": "array", + "sequence": "array", + } + _TYPE_PRIORITY = ("null", "integer", "number", "boolean", "object", "array", "string") + def __init__(self): super().__init__() self.bot_token = "" self.eot_token = "" self.tool_call_separator = "\n" - # Regex patterns for parsing GLM-4.7 tool calls - # Match complete tool call blocks - self.func_call_regex = re.compile(r".*?", re.DOTALL) - # Extract function name and arguments from a tool call block - # Function name can be followed by newline OR directly by - # Pattern: function_name(\n|)... - self.func_detail_regex = re.compile( - r"([^<\n]+?)(?:\n|(?=)|(?=))(.*?)", re.DOTALL - ) + # A function name ends at a newline, the first argument, or the closing tag. self.func_name_regex = re.compile(r"([^<\n]+?)(?:\n|(?=)|(?=))") - self._streaming_tool_name: Optional[str] = None - # Extract arg_key/arg_value pairs self.func_arg_regex = re.compile(r"(.*?)\s*(.*?)", re.DOTALL) + self._tool_scanner = GlmToolCallScanner() + self._tool_scan_offset = len(self.bot_token) def has_tool_call(self, text: str) -> bool: """Check if the text contains a GLM-4.7 format tool call.""" return self.bot_token in text + @classmethod + def _schema_types(cls, schema: Any) -> set[str]: + if not isinstance(schema, dict): + return {"string"} + types = set() + declared = schema.get("type") + if isinstance(declared, str): + types.add(declared) + elif isinstance(declared, list): + types.update(item for item in declared if isinstance(item, str)) + enum_values = schema.get("enum", []) + if not isinstance(enum_values, list): + enum_values = [] + for value in enum_values: + if value is None: + types.add("null") + elif isinstance(value, bool): + types.add("boolean") + elif isinstance(value, int): + types.add("integer") + elif isinstance(value, float): + types.add("number") + elif isinstance(value, str): + types.add("string") + elif isinstance(value, list): + types.add("array") + elif isinstance(value, dict): + types.add("object") + for field in ("anyOf", "oneOf", "allOf"): + choices = schema.get(field) + if isinstance(choices, list): + for choice in choices: + types.update(cls._schema_types(choice)) + return types or {"string"} + + @classmethod + def _is_finite_json(cls, value: Any) -> bool: + if isinstance(value, float): + return math.isfinite(value) + if isinstance(value, dict): + return all(cls._is_finite_json(item) for item in value.values()) + if isinstance(value, list): + return all(cls._is_finite_json(item) for item in value) + return True + + @staticmethod + def _parse_typed_value(value: str, value_type: str) -> Any: + match value_type: + case "string": + return value + case "null": + if value.lower() == "null": + return None + case "integer": + return int(value) + case "number": + number = float(value) + return int(number) if number.is_integer() else number + case "boolean": + normalized = value.strip().lower() + if normalized in ("true", "1"): + return True + if normalized in ("false", "0"): + return False + case "object" | "array": + return json.loads(value) + raise ValueError(f"Cannot parse value as {value_type}") + + @classmethod + def _convert_param_value(cls, value: str, schema: Any) -> Any: + """Match vLLM's schema-based coercion without changing literal strings. + + Union types use null/integer/number/boolean/object/array/string priority. + Unspecified fields remain strings. Non-finite numbers remain raw text so + the resulting arguments are always valid JSON. + """ + schema_types = set() + for type_name in cls._schema_types(schema): + type_name = type_name.strip().lower() + schema_types.add(cls._TYPE_ALIASES.get(type_name, type_name)) + + for value_type in cls._TYPE_PRIORITY: + if value_type not in schema_types: + continue + try: + parsed = cls._parse_typed_value(value, value_type) + except (ValueError, TypeError): + continue + if cls._is_finite_json(parsed): + return parsed + try: + parsed = json.loads(value) + return parsed if cls._is_finite_json(parsed) else value + except ValueError: + return value + def _parse_xml_arguments(self, arg_text: str, tool: Tool) -> dict: """ Parse XML-style arguments into a dictionary. @@ -1260,23 +1350,25 @@ def _parse_xml_arguments(self, arg_text: str, tool: Tool) -> dict: args = {} properties = (tool.function.parameters or {}).get("properties", {}) + if not isinstance(properties, dict): + properties = {} matches = self.func_arg_regex.findall(arg_text) for key, value in matches: key = key.strip() - # XML string values are literal: file content and edit targets may - # contain meaningful whitespace or text that happens to be JSON. - if properties.get(key, {}).get("type") == "string": - args[key] = value - continue - # Try to parse value as JSON for complex types (arrays, objects, numbers, booleans) - try: - parsed_value = json.loads(value) - args[key] = parsed_value - except (json.JSONDecodeError, ValueError): - # Keep as string if not valid JSON - args[key] = value + args[key] = self._convert_param_value(value, properties.get(key)) return args + def _iter_tool_call_spans(self, text: str) -> Iterator[Tuple[int, int]]: + start = text.find(self.bot_token) + while start != -1: + body_start = start + len(self.bot_token) + relative_end = GlmToolCallScanner().find_end(text[body_start:]) + if relative_end is None: + return + end = body_start + relative_end + yield start, end + start = text.find(self.bot_token, end) + def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult: """ One-time parsing: Detects and parses tool calls in the provided text. @@ -1285,28 +1377,26 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult :param tools: List of available tools. :return: StreamingParseResult with normal_text and parsed calls. """ - idx = text.find(self.bot_token) - normal_text = text[:idx].strip() if idx != -1 else text - if self.bot_token not in text: - return StreamingParseResult(normal_text=normal_text, calls=[]) + return StreamingParseResult(normal_text=text, calls=[]) tool_indices = self._get_tool_indices(tools) calls = [] + normal_parts = [] + cursor = 0 - # Find all ... blocks - match_result_list = self.func_call_regex.findall(text) - - for match_result in match_result_list: + for start, end in self._iter_tool_call_spans(text): + normal_parts.append(text[cursor:start]) + cursor = end + tool_text = text[start:end] try: - # Extract function name and arguments - func_detail = self.func_detail_regex.search(match_result) - if not func_detail: - logger.warning(f"Failed to parse GLM-4.7 tool call: {match_result}") + header = self.func_name_regex.match(tool_text) + if header is None: + logger.warning(f"Failed to parse GLM-4.7 tool call: {tool_text}") continue - func_name = func_detail.group(1).strip() - arg_text = func_detail.group(2) if func_detail.group(2) else "" + func_name = header.group(1).strip() + arg_text = tool_text[header.end() : -len(self.eot_token)] # Validate function name if func_name not in tool_indices: @@ -1324,10 +1414,32 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult ) ) except Exception as e: - logger.warning(f"Failed to parse GLM-4.7 tool call: {match_result}, error: {str(e)}") + logger.warning(f"Failed to parse GLM-4.7 tool call: {tool_text}, error: {str(e)}") continue - return StreamingParseResult(normal_text=normal_text, calls=calls) + # Keep ordinary text between and after calls. An unfinished envelope + # stays buffered in streaming mode and must not leak in non-streaming. + content_end = text.find(self.bot_token, cursor) + if content_end == -1: + content_end = len(text) + normal_parts.append(text[cursor:content_end]) + return StreamingParseResult(normal_text="".join(normal_parts).strip(), calls=calls) + + def _stream_incomplete_call(self, tools: List[Tool]) -> Optional[ToolCallItem]: + """Announce the name once, then emit empty argument deltas while buffering.""" + if self.current_tool_name_sent: + return ToolCallItem(tool_index=self.current_tool_id, parameters="") + + header = self.func_name_regex.match(self._buffer) + if header is None: + return None + name = header.group(1).strip() + if name not in self._get_tool_indices(tools): + return None + + self.current_tool_id += 1 + self.current_tool_name_sent = True + return ToolCallItem(tool_index=self.current_tool_id, name=name, parameters="") def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult: """Buffer XML arguments while emitting tool deltas to keep the stream alive.""" @@ -1345,35 +1457,29 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami normal_text += self._buffer[:start] self._buffer = self._buffer[start:] - end = self._buffer.find(self.eot_token) - if end == -1: - # Match the buffered Qwen3-Coder flow: announce the name once, - # then send empty argument deltas until the XML call is complete. - # Long Write/Bash arguments must not leave the HTTP stream idle. - name = None - if self._streaming_tool_name is None: - match = self.func_name_regex.match(self._buffer) - if match and match.group(1).strip() in self._get_tool_indices(tools): - name = match.group(1).strip() - self._streaming_tool_name = name - self.current_tool_id += 1 - if self._streaming_tool_name is not None: - calls.append(ToolCallItem(tool_index=self.current_tool_id, name=name, parameters="")) + relative_end = self._tool_scanner.find_end(self._buffer[self._tool_scan_offset :]) + if relative_end is None: + self._tool_scan_offset = len(self._buffer) + pending_call = self._stream_incomplete_call(tools) + if pending_call is not None: + calls.append(pending_call) break - end += len(self.eot_token) + end = self._tool_scan_offset + relative_end # Waiting for the closing tag avoids re-emitting partial JSON or # rewriting already sent arguments when another XML key arrives. result = self.detect_and_parse(self._buffer[:end], tools) for call in result.calls: - if self._streaming_tool_name is None: - self.current_tool_id += 1 - else: + if self.current_tool_name_sent: call.name = None + else: + self.current_tool_id += 1 call.tool_index = self.current_tool_id calls.append(call) - self._streaming_tool_name = None + self.current_tool_name_sent = False self._buffer = self._buffer[end:] + self._tool_scanner = GlmToolCallScanner() + self._tool_scan_offset = len(self.bot_token) return StreamingParseResult(normal_text=normal_text, calls=calls) diff --git a/lightllm/server/parser_utils.py b/lightllm/server/parser_utils.py new file mode 100644 index 0000000000..8299357554 --- /dev/null +++ b/lightllm/server/parser_utils.py @@ -0,0 +1,62 @@ +# Copyright 2025 ModelTC Team +# Copyright 2023-2025 SGLang Team +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from typing import Optional, Sequence, Tuple + + +def prefix_hold(text: str, markers: Sequence[str]) -> Tuple[str, str]: + """Split off the longest suffix that could become a complete marker.""" + max_length = min(len(text), max((len(marker) - 1 for marker in markers), default=0)) + for length in range(max_length, 0, -1): + suffix = text[-length:] + if any(len(suffix) < len(marker) and marker.startswith(suffix) for marker in markers): + return text[:-length], suffix + return text, "" + + +class GlmToolCallScanner: + """Find the outer GLM tool-call end while treating argument values as literal text.""" + + _MARKERS = ("", "", "") + _MARKER_PATTERN = re.compile(r"|") + + def __init__(self): + self._in_arg_value = False + self._pending_marker = "" + + def find_end(self, new_text: str) -> Optional[int]: + """Return the closing tag's end offset in this chunk, or None if incomplete. + + Callers may forward every character immediately. The retained marker + prefix is only for recognizing a delimiter split across chunks. + """ + pending_length = len(self._pending_marker) + text = self._pending_marker + new_text + self._pending_marker = "" + offset = 0 + + for match in self._MARKER_PATTERN.finditer(text): + offset = match.end() + marker = match.group() + if self._in_arg_value: + if marker == "": + self._in_arg_value = False + elif marker == "": + self._in_arg_value = True + elif marker == "": + return offset - pending_length + + _, self._pending_marker = prefix_hold(text[offset:], self._MARKERS) + return None diff --git a/lightllm/server/reasoning_parser.py b/lightllm/server/reasoning_parser.py index 1e7c2f1315..b0036ae288 100644 --- a/lightllm/server/reasoning_parser.py +++ b/lightllm/server/reasoning_parser.py @@ -16,6 +16,7 @@ from typing import Iterator, List, Tuple, Dict, Optional, Type from lightllm.utils.config_utils import get_token_id +from lightllm.server.parser_utils import GlmToolCallScanner, prefix_hold @dataclass @@ -36,28 +37,6 @@ class Token: end: int -def prefix_hold(text: str, tokens: List[str]) -> Tuple[str, str]: - """ - Holds back the longest suffix of `text` that could be a prefix of any token. - Returns (emit_now, keep_for_later). - """ - if not text: - return "", "" - max_hold = 0 - for tok in tokens: - if not tok: - continue - # Check for prefixes of tok in the suffix of text - L = min(len(tok) - 1, len(text)) - for k in range(L, 0, -1): - if tok.startswith(text[-k:]): - max_hold = max(max_hold, k) - break - if max_hold == 0: - return text, "" - return text[:-max_hold], text[-max_hold:] - - def iter_tokens(text: str, start_pos: int = 0) -> Iterator[Token]: """Iterate over structural tokens in left-to-right order.""" TOKENS = { @@ -600,6 +579,11 @@ def __init__( self._buffer = "" self.stripped_think_start = False + @classmethod + def initial_reasoning_state(cls, prompt: str) -> Optional[bool]: + """Infer the generation phase from the prompt, or defer to the request.""" + return None + @property def grammar_start_token(self) -> str: """Generated delimiter after which the final answer's grammar applies.""" @@ -756,6 +740,83 @@ def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False) ) +class Glm45Detector(BaseReasoningFormatDetector): + """Route GLM tool envelopes to content, even before an explicit ````.""" + + tool_start_token = "" + _MARKERS = ("", "", tool_start_token) + _MARKER_PATTERN = re.compile("|".join(re.escape(marker) for marker in _MARKERS)) + + def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False): + super().__init__("", "", stream_reasoning=stream_reasoning, force_reasoning=force_reasoning) + self._tool_scanner: Optional[GlmToolCallScanner] = None + + @classmethod + def initial_reasoning_state(cls, prompt: str) -> Optional[bool]: + # GLM-5.3 always opens thinking, even if enable_thinking=False was + # requested. Inspect only the final assistant turn, never past turns. + _, assistant_marker, assistant_prompt = prompt.rpartition("<|assistant|>") + if not assistant_marker: + return None + if any(marker in assistant_prompt for marker in ("<|system|>", "<|user|>", "<|observation|>")): + return None + think_start = assistant_prompt.rfind("") + think_end = assistant_prompt.rfind("") + if think_start == think_end == -1: + return None + return think_start > think_end + + def detect_and_parse(self, text: str) -> StreamingParseResult: + detector = type(self)(stream_reasoning=self.stream_reasoning, force_reasoning=self._in_reasoning) + result = detector.parse_streaming_increment(text) + remaining = detector.flush() + return StreamingParseResult( + reasoning_text=(result.reasoning_text + remaining.reasoning_text).strip(), + normal_text=result.normal_text + remaining.normal_text, + ) + + def parse_streaming_increment(self, new_text: str) -> StreamingParseResult: + self._buffer += new_text + reasoning_parts = [] + normal_parts = [] + + while self._buffer: + if self._tool_scanner is not None: + # Tool arguments are literal, including any reasoning markers. + end = self._tool_scanner.find_end(self._buffer) + if end is None: + end = len(self._buffer) + else: + self._tool_scanner = None + normal_parts.append(self._buffer[:end]) + self._buffer = self._buffer[end:] + continue + + match = self._MARKER_PATTERN.search(self._buffer) + output_parts = reasoning_parts if self._in_reasoning else normal_parts + if match is None: + # A delimiter may share a chunk with reasoning or span chunks. + text, self._buffer = prefix_hold(self._buffer, self._MARKERS) + output_parts.append(text) + break + + output_parts.append(self._buffer[: match.start()]) + marker = match.group() + self._buffer = self._buffer[match.end() :] + if marker == self.tool_start_token: + self._in_reasoning = False + self._tool_scanner = GlmToolCallScanner() + # Preserve the opener and literal thinking tags in tool arguments. + normal_parts.append(marker) + else: + self._in_reasoning = marker == self.think_start_token + + return StreamingParseResult( + reasoning_text="".join(reasoning_parts), + normal_text="".join(normal_parts), + ) + + class KimiDetector(BaseReasoningFormatDetector): """ Detector for Kimi Thinking model. @@ -919,7 +980,7 @@ class ReasoningParser: DetectorMap: Dict[str, Type[BaseReasoningFormatDetector]] = { "deepseek-r1": DeepSeekR1Detector, "deepseek-v3": Qwen3Detector, - "glm45": Qwen3Detector, + "glm45": Glm45Detector, "gpt-oss": GptOssDetector, "kimi": KimiDetector, "kimi_k2": DeepSeekR1Detector, @@ -933,6 +994,17 @@ class ReasoningParser: "gemma4": Gemma4Detector, } + @classmethod + def get_initial_reasoning_state(cls, model_type: Optional[str], prompt: str, default: bool) -> bool: + """Resolve the starting phase once for parsing, stops and answer constraints.""" + if not model_type: + return False + detector_class = cls.DetectorMap.get(model_type.lower()) + if detector_class is None: + raise ValueError(f"Unsupported model type: {model_type}") + state = detector_class.initial_reasoning_state(prompt) + return default if state is None else state + def __init__( self, model_type: Optional[str] = None, @@ -975,17 +1047,17 @@ def update_reasoning_token_count(self, token_id: int) -> None: else: self.reasoning_tokens += 1 - def parse_non_stream(self, full_text: str) -> Tuple[Optional[str], Optional[str]]: + def parse_non_stream(self, full_text: str) -> Tuple[str, str]: """Non-streaming call: one-time parsing""" ret = self.detector.detect_and_parse(full_text) return ret.reasoning_text, ret.normal_text - def parse_stream_chunk(self, chunk_text: str) -> Tuple[Optional[str], Optional[str]]: + def parse_stream_chunk(self, chunk_text: str) -> Tuple[str, str]: """Streaming call: incremental parsing""" ret = self.detector.parse_streaming_increment(chunk_text) return ret.reasoning_text, ret.normal_text - def flush(self) -> Tuple[Optional[str], Optional[str]]: + def flush(self) -> Tuple[str, str]: """Flush remaining buffered content when generation ends prematurely.""" ret = self.detector.flush() return ret.reasoning_text, ret.normal_text diff --git a/lightllm/utils/envs_utils.py b/lightllm/utils/envs_utils.py index 748f1ad2f7..7ad9d68cce 100644 --- a/lightllm/utils/envs_utils.py +++ b/lightllm/utils/envs_utils.py @@ -77,6 +77,25 @@ def get_env_use_ue8m0_scales() -> bool: return os.getenv("LIGHTLLM_USE_UE8M0_SCALES", "0").upper() in ["ON", "TRUE", "1"] +def get_env_use_ue8m0_activation_scales(default=None, *, require_ue8m0=False) -> bool: + """Select block-FP8 activation scales without changing checkpoint weights. + + LIGHTLLM_ACTIVATION_SCALE_FORMAT: auto keeps the caller's existing policy; + fp32 uses continuous scales; ue8m0 rounds scales upward to powers of two. + GLM5 indexer Q/K use this policy too; their payload and cache remain FP8. + """ + scale_format = os.getenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", "auto").strip().lower() + if scale_format == "auto": + use_ue8m0 = get_env_use_ue8m0_scales() if default is None else default + elif scale_format in ("fp32", "ue8m0"): + use_ue8m0 = scale_format == "ue8m0" + else: + raise ValueError("LIGHTLLM_ACTIVATION_SCALE_FORMAT must be auto, fp32, or ue8m0") + if require_ue8m0 and not use_ue8m0: + raise ValueError("This packed DeepGEMM backend requires UE8M0 activation scales; fp32 is supported on Hopper") + return use_ue8m0 + + @lru_cache(maxsize=None) def is_stop_matching_in_reasoning_enabled() -> bool: """是否允许用户配置的 stop 在 reasoning 阶段参与匹配。""" diff --git a/unit_tests/common/basemodel/triton_kernel/mhc/test_mhc.py b/unit_tests/common/basemodel/triton_kernel/mhc/test_mhc.py index 03ae1f3c33..2fea058ea6 100644 --- a/unit_tests/common/basemodel/triton_kernel/mhc/test_mhc.py +++ b/unit_tests/common/basemodel/triton_kernel/mhc/test_mhc.py @@ -1,7 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 +import math import sys -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest import torch @@ -16,7 +17,19 @@ pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") -def _hc_pre_reference(x, fn, scale, base, streams, rms_eps, hc_eps, sinkhorn_iters, post_multiplier=2.0): +def _hc_pre_reference( + x, + fn, + scale, + base, + streams, + rms_eps, + hc_eps, + sinkhorn_iters, + post_multiplier=2.0, + *, + normalize_before_projection=False, +): """Unfused FP32 reference; returns the merged input before sublayer RMSNorm.""" assert x.ndim == 2 and x.shape[-1] % streams == 0 tokens, flattened_hidden = x.shape @@ -25,7 +38,10 @@ def _hc_pre_reference(x, fn, scale, base, streams, rms_eps, hc_eps, sinkhorn_ite x_fp32 = x.float() inv_rms = torch.rsqrt(x_fp32.square().mean(dim=-1, keepdim=True) + rms_eps) - mixes = F.linear(x_fp32, fn) * inv_rms + if normalize_before_projection: + mixes = F.linear(x_fp32 * inv_rms, fn) + else: + mixes = F.linear(x_fp32, fn) * inv_rms pre_raw = mixes[:, :streams] post_raw = mixes[:, streams : 2 * streams] @@ -44,12 +60,17 @@ def _hc_pre_reference(x, fn, scale, base, streams, rms_eps, hc_eps, sinkhorn_ite return layer_input, residual_mix, post -def _hc_post_reference(layer_output, residual, residual_mix, post_mix, streams): +def _hc_post_reference(layer_output, residual, residual_mix, post_mix, streams, *, round_residual=False): """Unfused FP32 reference; residual_mix maps input streams to output streams.""" tokens, hidden = layer_output.shape residual_3d = residual.view(tokens, streams, hidden) mixed_residual = (residual_mix.unsqueeze(-1) * residual_3d.float().unsqueeze(2)).sum(dim=1) - out = post_mix.unsqueeze(-1) * layer_output.float().unsqueeze(1) + mixed_residual + if round_residual: + mixed_residual = mixed_residual.to(layer_output.dtype).float() + expanded = post_mix.unsqueeze(-1) * layer_output.float().unsqueeze(1) + if round_residual: + expanded = expanded.to(layer_output.dtype).float() + out = expanded + mixed_residual return out.to(layer_output.dtype).reshape(tokens, streams * hidden) @@ -98,3 +119,148 @@ def test_mhc_reports_missing_deepgemm(monkeypatch, missing): with pytest.raises(ImportError, match="deep_gemm"): hc_pre_norm(x, fn, scale, base, norm, 4, 1e-5, 1e-5, 1e-6, 20) + + +@pytest.mark.parametrize("tokens,hidden,prefix", [(1, 4096, "attn"), (19, 780, "ffn"), (3073, 4096, "attn")]) +@pytest.mark.parametrize("seed", [1525, 2026]) +def test_glm_mhc_caller_matches_normalize_first_reference(tokens, hidden, prefix, seed, monkeypatch): + from lightllm.models.glm5_next.layer_infer.transformer_layer_infer import Glm5NextTransformerLayerInfer + + torch.manual_seed(seed) + monkeypatch.setattr(pre_norm, "LIGHTLLM_DISABLE_DEEPGEMM_MHC", False) + monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", False) + streams = 4 + x = torch.randn(tokens, streams * hidden, device="cuda", dtype=torch.bfloat16) + fn = torch.randn(24, streams * hidden, device="cuda") * 0.005 + scale = torch.randn(3, device="cuda") + base = torch.randn(24, device="cuda") + norm = torch.randn(hidden, device="cuda", dtype=torch.bfloat16) + merged, expected_mix, expected_post = _hc_pre_reference( + x, fn, scale, base, streams, 1e-5, 1e-6, 20, normalize_before_projection=True + ) + merged = merged.float() + expected_input = (merged * torch.rsqrt(merged.square().mean(-1, keepdim=True) + 1e-5) * norm.float()).to(x.dtype) + expected_mix, expected_post = expected_mix.to(x.dtype), expected_post.to(x.dtype) + + layer = SimpleNamespace(mhc_streams=streams, eps_=1e-5, hc_eps=1e-6, hc_sinkhorn_iters=20) + weights = SimpleNamespace( + **{ + f"hc_{prefix}_{name}": SimpleNamespace(weight=value) + for name, value in (("fn", fn), ("scale", scale), ("base", base)) + } + ) + monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", True) + actual_input, actual_mix, actual_post = Glm5NextTransformerLayerInfer._hc_pre( + layer, x, weights, prefix, SimpleNamespace(weight=norm) + ) + assert torch.backends.cuda.matmul.allow_tf32 + assert actual_mix.dtype == actual_post.dtype == x.dtype + # FP32 reduction differences can cross the merged BF16 midpoint; RMSNorm + # rounds to BF16 again. Check FP32 projection separately at strict tolerance. + torch.testing.assert_close(actual_input, expected_input, atol=0.004, rtol=2 * torch.finfo(x.dtype).eps) + torch.testing.assert_close(actual_mix, expected_mix, atol=1e-5, rtol=0.008) + torch.testing.assert_close(actual_post, expected_post, atol=1e-5, rtol=0.008) + layer_out = torch.randn(tokens, hidden, device="cuda", dtype=x.dtype) + torch.testing.assert_close( + hc_post(layer_out, x, actual_mix, actual_post, streams, glm5_numerics=True), + _hc_post_reference(layer_out, x, actual_mix, actual_post, streams, round_residual=True), + atol=0.004, + rtol=0.008, + ) + + +@pytest.mark.parametrize("tokens,hidden", [(1, 4096), (19, 780), (3073, 4096)]) +def test_glm_mhc_projection_retains_fp32_inputs(tokens, hidden, monkeypatch): + monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", False) + x = torch.randn(tokens, 4 * hidden, device="cuda", dtype=torch.bfloat16) + fn = torch.randn(24, 4 * hidden, device="cuda") * 0.005 + x_fp32 = x.float() + expected = F.linear(x_fp32 * torch.rsqrt(x_fp32.square().mean(-1, keepdim=True) + 1e-5), fn) + + monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", True) + partials = pre_norm._hc_normalized_projection(x, fn, 1e-5) + assert torch.backends.cuda.matmul.allow_tf32 + torch.testing.assert_close(partials.sum(0), expected, atol=2e-5, rtol=2e-5) + + +def test_glm_mhc_normalizes_before_cancelling_large_products(monkeypatch): + monkeypatch.setattr(pre_norm, "LIGHTLLM_DISABLE_DEEPGEMM_MHC", False) + monkeypatch.setitem(sys.modules, "deep_gemm", None) + monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", False) + # Project-first overflows; normalize-first has finite cancelling products. + x = torch.full((1, 4 * 17), 2.0 ** 32, device="cuda", dtype=torch.bfloat16) + fn = torch.zeros(24, x.shape[-1], device="cuda") + fn[:, 0], fn[:, 1] = 2.0 ** 110, -(2.0 ** 110) + x_fp32 = x.float() + inv_rms = torch.rsqrt(x_fp32.square().mean(-1, keepdim=True) + 1e-5) + expected = F.linear(x_fp32 * inv_rms, fn) + old_order = F.linear(x_fp32, fn) * inv_rms + assert torch.isfinite(expected).all() + assert not torch.isfinite(old_order).all() + torch.testing.assert_close(pre_norm._hc_normalized_projection(x, fn, 1e-5).sum(0), expected, atol=0, rtol=0) + + +def test_glm_mhc_rounds_post_coefficients_before_mixing(): + x = torch.full((1, 4 * 17), 1 / 256, device="cuda", dtype=torch.bfloat16) + fn = torch.zeros(24, x.shape[-1], device="cuda") + scale = torch.zeros(3, device="cuda") + base = torch.zeros(24, device="cuda") + base[4:8] = math.log(1.003 / 0.997) + norm = torch.ones(17, device="cuda", dtype=x.dtype) + _, mix, post = hc_pre_norm(x, fn, scale, base, norm, 4, 1e-5, 1e-5, 1e-6, 20, glm5_numerics=True) + assert mix.dtype == post.dtype == x.dtype + torch.testing.assert_close(post, torch.ones_like(post), atol=0, rtol=0) + torch.testing.assert_close(mix, torch.full_like(mix, 0.25), atol=0, rtol=0) + layer_out = torch.ones(1, 17, device="cuda", dtype=x.dtype) + actual = hc_post(layer_out, x, mix, post, 4, glm5_numerics=True) + torch.testing.assert_close(actual, torch.ones_like(actual), atol=0, rtol=0) + _, unrounded_mix, unrounded_post = _hc_pre_reference( + x, fn, scale, base, 4, 1e-5, 1e-6, 20, normalize_before_projection=True + ) + old_result = _hc_post_reference(layer_out, x, unrounded_mix, unrounded_post, 4) + assert (old_result != actual).all() + + +def test_glm_mhc_post_rounds_residual_like_training(): + x = torch.zeros(1, 4, 17, device="cuda", dtype=torch.bfloat16) + x[:, 0], x[:, 1] = 1, 1 / 256 + mix = torch.full((1, 4, 4), 0.25, device="cuda", dtype=x.dtype) + post = torch.ones(1, 4, device="cuda", dtype=x.dtype) + layer_out = torch.full((1, 17), 3 / 512, device="cuda", dtype=x.dtype) + # The native training bmm rounds 0.2509765625 to 0.25 before expansion. + mixed = torch.bmm(mix.transpose(1, 2), x) + expected = torch.addcmul(mixed.float(), layer_out[:, None].float(), post[:, :, None].float()).to(x.dtype) + actual = hc_post(layer_out, x.flatten(1), mix, post, 4, glm5_numerics=True) + torch.testing.assert_close(actual, expected.flatten(1), atol=0, rtol=0) + assert (hc_post(layer_out, x.flatten(1), mix, post, 4) != actual).all() + + +def test_glm_mhc_keeps_pre_coefficients_in_fp32(): + x = torch.zeros(1, 4, 17, device="cuda", dtype=torch.bfloat16) + x[:, 0], x[:, 1] = 1, -1 + x = x.flatten(1) + fn = torch.zeros(24, x.shape[-1], device="cuda") + scale = torch.zeros(3, device="cuda") + base = torch.zeros(24, device="cuda") + # Both coefficients round to BF16 0.5, which would erase this input entirely. + pre_values = torch.tensor([0.5005, 0.5], device="cuda") - 1e-6 + base[:2] = torch.logit(pre_values) + norm = torch.ones(17, device="cuda", dtype=x.dtype) + actual, _, _ = hc_pre_norm(x, fn, scale, base, norm, 4, 1e-5, 1e-5, 1e-6, 20, glm5_numerics=True) + merged, _, _ = _hc_pre_reference(x, fn, scale, base, 4, 1e-5, 1e-6, 20, normalize_before_projection=True) + merged = merged.float() + expected = (merged * torch.rsqrt(merged.square().mean(-1, keepdim=True) + 1e-5)).to(x.dtype) + assert (expected > 0.1).all() + torch.testing.assert_close(actual, expected, atol=0.001, rtol=0.008) + + +def test_glm_mhc_rounds_post_product_before_residual_add(): + x = torch.full((1, 4 * 17), -1.015625, device="cuda", dtype=torch.bfloat16) + layer = torch.full((1, 17), 1.0078125, device="cuda", dtype=x.dtype) + mix = torch.eye(4, device="cuda", dtype=x.dtype).unsqueeze(0) + post = torch.full((1, 4), 1.0078125, device="cuda", dtype=x.dtype) + expected = _hc_post_reference(layer, x, mix, post, 4, round_residual=True) + actual = hc_post(layer, x, mix, post, 4, glm5_numerics=True) + torch.testing.assert_close(actual, expected, atol=0, rtol=0) + assert torch.count_nonzero(actual) == 0 + assert torch.count_nonzero(hc_post(layer, x, mix, post, 4)) > 0 diff --git a/unit_tests/common/fused_moe/test_deepgemm_activation.py b/unit_tests/common/fused_moe/test_deepgemm_activation.py index e9ba2ab26e..2df337f6b2 100644 --- a/unit_tests/common/fused_moe/test_deepgemm_activation.py +++ b/unit_tests/common/fused_moe/test_deepgemm_activation.py @@ -14,7 +14,12 @@ @pytest.mark.parametrize("is_prefill", [False, True]) @pytest.mark.parametrize("use_ue8m0_scales", [False, True]) @pytest.mark.parametrize("alpha,limit,add_one", [(None, None, True), (1.0, 10.0, False), (1.702, 7.0, True)]) -def test_grouped_gemm_activation_matches_reference(monkeypatch, is_prefill, use_ue8m0_scales, alpha, limit, add_one): +@pytest.mark.parametrize("activation_format", ["auto", "fp32", "ue8m0"]) +def test_grouped_gemm_activation_matches_reference( + monkeypatch, is_prefill, use_ue8m0_scales, alpha, limit, add_one, activation_format +): + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", activation_format) + activation_ue8m0 = use_ue8m0_scales if activation_format == "auto" else activation_format == "ue8m0" monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "0") monkeypatch.delenv("LIGHTLLM_USE_UE8M0_SCALES") config = {"quant_method": "fp8", "weight_block_size": [128, 128]} @@ -31,6 +36,24 @@ def test_grouped_gemm_activation_matches_reference(monkeypatch, is_prefill, use_ w2_scale = torch.ones(2, 1, 1, device="cuda") counts = torch.tensor([3, 2], device="cuda", dtype=torch.int32) activation_args = dict(alpha=alpha, limit=limit, clamp_up_add_one=add_one) + captured_scales = [] + quantize = ep.per_token_group_quant_fp8 + masked_quantize = ep.silu_and_mul_masked_post_quant_fwd + + def capture_contiguous(*args, **kwargs): + assert kwargs["use_ue8m0_scales"] == activation_ue8m0 + result = quantize(*args, **kwargs) + count = (3, 2)[len(captured_scales)] + captured_scales.append(result[1][:count].clone()) + return result + + def capture_masked(*args, **kwargs): + result = masked_quantize(*args, **kwargs) + captured_scales.append(torch.cat([args[2][0, :3], args[2][1, :2]]).clone()) + return result + + monkeypatch.setattr(ep, "per_token_group_quant_fp8", capture_contiguous) + monkeypatch.setattr(ep, "silu_and_mul_masked_post_quant_fwd", capture_masked) recv = torch.zeros(2, padded, dim, device="cuda", dtype=torch.float8_e4m3fn) recv[0, :3] = x[:3] recv[1, :2] = x[3:] @@ -77,7 +100,8 @@ def test_grouped_gemm_activation_matches_reference(monkeypatch, is_prefill, use_ gate = (gate * torch.sigmoid(gate * (alpha or 1.0))).bfloat16().float() activation = (gate * up).bfloat16().float() scales = activation.abs().amax(-1, keepdim=True).clamp(min=1e-10) / 448 - if use_ue8m0_scales: + if activation_ue8m0: scales = torch.exp2(torch.ceil(torch.log2(scales))) + torch.testing.assert_close(torch.cat(captured_scales), scales, atol=1e-6, rtol=1e-5) expected = ((activation / scales).to(torch.float8_e4m3fn).float() * scales).bfloat16() torch.testing.assert_close(actual, expected, atol=0.05, rtol=0.02) diff --git a/unit_tests/common/quantization/test_activation_scale_override.py b/unit_tests/common/quantization/test_activation_scale_override.py new file mode 100644 index 0000000000..21fc9e522a --- /dev/null +++ b/unit_tests/common/quantization/test_activation_scale_override.py @@ -0,0 +1,149 @@ +from types import SimpleNamespace + +import pytest +import torch + +from lightllm.common.quantization import Quantcfg, deepgemm +from lightllm.common.quantization.quantize_method import WeightPack +from lightllm.utils.envs_utils import get_env_use_ue8m0_activation_scales, get_env_use_ue8m0_scales + + +@pytest.mark.parametrize("scale_format", [None, "auto", "fp32", "ue8m0", " FP32 "]) +@pytest.mark.parametrize("legacy", [False, True]) +@pytest.mark.parametrize("default", [None, False, True]) +def test_activation_override_preserves_weight_policy(monkeypatch, scale_format, legacy, default): + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", str(int(legacy))) + if scale_format is None: + monkeypatch.delenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", raising=False) + else: + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) + expected = legacy if default is None else default + if scale_format is not None and scale_format.strip().lower() != "auto": + expected = scale_format.strip().lower() == "ue8m0" + assert get_env_use_ue8m0_activation_scales(default) == expected + assert get_env_use_ue8m0_scales() == legacy + + +def test_invalid_or_unsupported_activation_format_fails_explicitly(monkeypatch): + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", "typo") + with pytest.raises(ValueError, match="LIGHTLLM_ACTIVATION_SCALE_FORMAT"): + get_env_use_ue8m0_activation_scales() + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", "fp32") + with pytest.raises(ValueError, match="requires UE8M0"): + get_env_use_ue8m0_activation_scales(True, require_ue8m0=True) + + +@pytest.mark.skipif(not torch.cuda.is_available() or not deepgemm.HAS_DEEPGEMM, reason="CUDA + DeepGEMM required") +@pytest.mark.parametrize("weight_ue8m0", [False, True]) +def test_dense_activation_override_reuses_exact_weight_pack(monkeypatch, weight_ue8m0): + if torch.cuda.get_device_capability()[0] != 9: + pytest.skip("Continuous FP32 activation scales target Hopper") + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "0") + config = { + "n_layer": 1, + "quantization_config": { + "quant_method": "fp8", + "weight_block_size": [128, 128], + "scale_fmt": "ue8m0" if weight_ue8m0 else "float32", + }, + } + method = Quantcfg(config).get_quant_method(0, "q_proj") + pack, _ = method.create_weight([128], 512, torch.bfloat16, 0) + torch.manual_seed(54) + method.load_weight(torch.randn(128, 512, device="cuda", dtype=torch.bfloat16), pack) + before_weight, before_scale = pack.weight.view(torch.uint8).clone(), pack.weight_scale.clone() + x = torch.randn(17, 512, device="cuda", dtype=torch.bfloat16) + quantize = deepgemm.per_token_group_quant_fp8 + calls = [] + + def capture(values, group_size, **kwargs): + result = quantize(values, group_size, **kwargs) + expected = values.float().reshape(17, 4, 128).abs().amax(-1).clamp_min(1e-10) / 448 + if kwargs["use_ue8m0_scales"]: + expected = expected.log2().ceil().exp2() + assert group_size == 128 and result[1].dtype == torch.float32 + torch.testing.assert_close(result[1], expected, rtol=1e-6, atol=0) + calls.append(kwargs["use_ue8m0_scales"]) + return result + + monkeypatch.setattr(deepgemm, "per_token_group_quant_fp8", capture) + for scale_format in ("auto", "fp32", "ue8m0"): + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) + result = method.apply(x, pack, use_custom_tensor_mananger=False) + assert torch.isfinite(result).all() + assert torch.equal(pack.weight.view(torch.uint8), before_weight) + assert torch.equal(pack.weight_scale, before_scale) + assert method.use_ue8m0_scales == weight_ue8m0 + assert calls == [weight_ue8m0, False, True] + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") +@pytest.mark.parametrize("scale_format", ["auto", "fp32", "ue8m0"]) +def test_fallback_block_quantization_honors_override(monkeypatch, scale_format): + from lightllm.common.quantization.w8a8 import FP8w8a8B128QuantizationMethod + + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "1") + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) + method = object.__new__(FP8w8a8B128QuantizationMethod) + method.block_size = 128 + x = torch.randn(3, 512, device="cuda", dtype=torch.bfloat16) + pack = WeightPack( + weight=torch.ones(128, 512, device="cuda", dtype=torch.float8_e4m3fn), + weight_scale=torch.full((1, 4), 0.125, device="cuda"), + ) + result = method._dynamic_quant_input(x, pack, use_custom_tensor_mananger=False) + expected = x.float().reshape(3, 4, 128).abs().amax(-1).clamp_min(1e-10) / 448 + if scale_format == "ue8m0": + expected = expected.log2().ceil().exp2() + torch.testing.assert_close(result[2], expected, rtol=1e-6, atol=0) + assert torch.all(pack.weight_scale == 0.125) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") +@pytest.mark.parametrize("scale_format", ["auto", "fp32", "ue8m0"]) +@pytest.mark.parametrize("legacy", [False, True]) +@pytest.mark.parametrize("overlap", [False, True]) +def test_deepep_dispatch_uses_activation_override(monkeypatch, scale_format, legacy, overlap): + from lightllm.common.basemodel.triton_kernel.fused_moe import grouped_fused_moe_ep as ep + from lightllm.common.basemodel.layer_weights.meta_weights.fused_moe.impl.deepgemm_impl import FuseMoeDeepGEMM + + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", str(int(legacy))) + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) + expected = legacy if scale_format == "auto" else scale_format == "ue8m0" + hidden = torch.zeros(3, 128, device="cuda", dtype=torch.bfloat16) + ids = torch.zeros(3, 1, device="cuda", dtype=torch.int64) + weights = torch.ones(3, 1, device="cuda") + calls = [] + + def dispatch(*args, **kwargs): + calls.append(kwargs["round_scale"]) + return object(), object(), object(), None, None + + buffer = SimpleNamespace( + group_size=1, + low_latency_dispatch=dispatch, + low_latency_combine=lambda *args, **kwargs: (hidden, None, None), + ) + monkeypatch.setattr(ep.dist_group_manager, "ep_low_latency_buffer", buffer, raising=False) + if overlap: + impl = object.__new__(FuseMoeDeepGEMM) + impl.quant_method = SimpleNamespace(method_name="fp8w8a8-b128-deepgemm") + impl.total_expert_num_contain_redundancy = 1 + impl._select_experts = lambda **kwargs: (weights, ids, ids) + impl.low_latency_dispatch(hidden, hidden, None, False, 1, True, 1, 1, "sigmoid") + else: + monkeypatch.setattr(ep, "masked_group_gemm", lambda *args, **kwargs: hidden) + ep.fused_experts_impl( + hidden, + torch.zeros(1, 256, 128, device="cuda", dtype=torch.float8_e4m3fn), + torch.zeros(1, 128, 128, device="cuda", dtype=torch.float8_e4m3fn), + weights, + ids, + buffer=buffer, + is_prefill=False, + num_experts=1, + use_fp8_w8a8=True, + use_fp8_all2all=True, + block_size_k=128, + ) + assert calls == [expected] diff --git a/unit_tests/common/quantization/test_ue8m0_fp8_quantization.py b/unit_tests/common/quantization/test_ue8m0_fp8_quantization.py index cf87ac3ffb..0bdfbcd63f 100644 --- a/unit_tests/common/quantization/test_ue8m0_fp8_quantization.py +++ b/unit_tests/common/quantization/test_ue8m0_fp8_quantization.py @@ -266,13 +266,66 @@ def test_weight_scale_loading_preserves_fused_views( assert len(packed_weight_layout) == (2 * (experts or 1) if packed else 0) +@pytest.mark.parametrize("use_ue8m0", [False, True]) +@pytest.mark.parametrize("shared_expert", [False, True]) +@pytest.mark.parametrize("activation_format", ["auto", "fp32", "ue8m0"]) +def test_tp_moe_activation_scale_rule(monkeypatch, use_ue8m0, shared_expert, activation_format): + from lightllm.common.basemodel.triton_kernel.fused_moe import grouped_fused_moe as moe + from lightllm.common.basemodel.triton_kernel.fused_moe.append_shared_expert_topk import append_fused_shared_experts + + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "0") + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", activation_format) + activation_ue8m0 = use_ue8m0 if activation_format == "auto" else activation_format == "ue8m0" + Quantcfg( + { + "n_layer": 1, + "quantization_config": { + "quant_method": "fp8", + "weight_block_size": [128, 128], + "scale_fmt": "ue8m0" if use_ue8m0 else "float32", + }, + } + ) + torch.manual_seed(20261023) + x = torch.randn((17, 512), device="cuda", dtype=torch.bfloat16) + w1, s1 = weight.weight_quant(torch.randn((3, 512, 512), device="cuda", dtype=torch.bfloat16) * 0.02) + w2, s2 = weight.weight_quant(torch.randn((3, 512, 256), device="cuda", dtype=torch.bfloat16) * 0.02) + ids = torch.tensor([0, 1], device="cuda", dtype=torch.int32).repeat(17, 1) + scores = torch.tensor([0.3, 0.7], device="cuda").repeat(17, 1) + if shared_expert: + scores, ids = append_fused_shared_experts(scores, ids, shared_expert_start_id=2, num_fused_shared_experts=1) + calls = [] + quantize = moe.per_token_group_quant_fp8 + + def check_activation(values, group_size, **kwargs): + q, scales = quantize(values, group_size, **kwargs) + assert group_size == 128 + assert scales.dtype == torch.float32 + expected = values.float().reshape(values.shape[0], -1, 128).abs().amax(-1).clamp_min(1e-10) / 448.0 + if activation_ue8m0: + expected = torch.exp2(torch.ceil(torch.log2(expected.double()))).float() + torch.testing.assert_close(scales, expected, rtol=1e-6, atol=0) + calls.append(values.shape[1]) + return q, scales + + monkeypatch.setattr(moe, "per_token_group_quant_fp8", check_activation) + out = moe.fused_experts_impl(x, w1, w2, scores, ids, use_fp8_w8a8=True, w1_scale=s1, w2_scale=s2) + assert calls == [512, 256] # W13 and W2, including the optional shared-expert slot. + assert torch.isfinite(out).all() + + @pytest.mark.skipif(not deepgemm.HAS_DEEPGEMM, reason="requires DeepGEMM") +@pytest.mark.parametrize("packed_env", [False, True]) @pytest.mark.parametrize("need_trans", [False, True]) @pytest.mark.parametrize("k", [512, 640]) -def test_tp_moe_consumes_packed_weight_scales(monkeypatch, packed_weight_layout, need_trans, k): +@pytest.mark.parametrize("activation_format", ["auto", "fp32"]) +def test_tp_moe_consumes_packed_weight_scales( + monkeypatch, packed_weight_layout, need_trans, k, packed_env, activation_format +): from lightllm.common.basemodel.triton_kernel.fused_moe.grouped_fused_moe import grouped_matmul monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "1") + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", activation_format) monkeypatch.setattr(deepgemm, "is_sm100_gpu", lambda: True) method = Quantcfg({"n_layer": 1}, quant_type="fp8w8a8-b128-deepgemm").get_quant_method(0, "q_proj") full, _ = method.create_moe_weight([256], k, torch.bfloat16, 0, 2) @@ -287,6 +340,7 @@ def test_tp_moe_consumes_packed_weight_scales(monkeypatch, packed_weight_layout, indices[1, :8] = torch.arange(9, 17, device="cuda") outputs = [] for sf in (scales, full.weight_scale): + monkeypatch.setenv("LIGHTLLM_USE_UE8M0_SCALES", "1" if sf.dtype != torch.int32 or packed_env else "0") out = torch.empty((17, 256), device="cuda", dtype=torch.bfloat16) grouped_matmul( 17, @@ -442,11 +496,15 @@ def test_redundancy_expert_update_packs_scales(monkeypatch, packed_weight_layout @pytest.mark.parametrize("is_prefill", [False, True]) @pytest.mark.parametrize("sm100", [False, True]) @pytest.mark.parametrize("scale_source", ["none", "env", "config"]) -def test_ep_moe_rounds_scales_and_preserves_logical_block_size(monkeypatch, is_prefill, sm100, scale_source): +@pytest.mark.parametrize("activation_format", ["auto", "fp32", "ue8m0"]) +def test_ep_moe_rounds_scales_and_preserves_logical_block_size( + monkeypatch, is_prefill, sm100, scale_source, activation_format +): from lightllm.common.basemodel.triton_kernel.fused_moe import grouped_fused_moe_ep as ep from lightllm.utils.envs_utils import get_env_use_ue8m0_scales monkeypatch.delenv("LIGHTLLM_USE_UE8M0_SCALES", raising=False) + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", activation_format) assert not get_env_use_ue8m0_scales() config = {"n_layer": 1} if scale_source == "env": @@ -454,6 +512,7 @@ def test_ep_moe_rounds_scales_and_preserves_logical_block_size(monkeypatch, is_p elif scale_source == "config": config["quantization_config"] = {"quant_method": "fp8", "weight_block_size": [128, 128], "scale_fmt": "ue8m0"} use_ue8m0_scales = scale_source != "none" + activation_ue8m0 = use_ue8m0_scales if activation_format == "auto" else activation_format == "ue8m0" monkeypatch.setattr(deepgemm, "is_sm100_gpu", lambda: sm100) method = Quantcfg(config, quant_type="fp8w8a8-b128-deepgemm").get_quant_method(0, "q_proj") assert get_env_use_ue8m0_scales() == use_ue8m0_scales @@ -464,7 +523,7 @@ def test_ep_moe_rounds_scales_and_preserves_logical_block_size(monkeypatch, is_p assert q.shape == x.shape and sf.shape == (3, 4) assert sf.dtype == torch.float32 expected_scales = x.float().reshape(3, 4, 128).abs().amax(-1).clamp_min(1e-10) / 448 - if use_ue8m0_scales: + if activation_ue8m0: expected_scales = torch.exp2(torch.ceil(torch.log2(expected_scales))) expected_q = (x.float().reshape(3, 4, 128) / expected_scales.unsqueeze(-1)).to(q.dtype).reshape_as(q) torch.testing.assert_close(q.float(), expected_q.float(), atol=0, rtol=0) diff --git a/unit_tests/models/glm5_next/test_indexer_scale.py b/unit_tests/models/glm5_next/test_indexer_scale.py new file mode 100644 index 0000000000..a6bcd30cd5 --- /dev/null +++ b/unit_tests/models/glm5_next/test_indexer_scale.py @@ -0,0 +1,30 @@ +import pytest +import torch + +from lightllm.models.glm5_next.triton_kernel.index_quant import hadamard_transform_quant_fp8 + + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") + + +@pytest.mark.parametrize("scale_format", ["auto", "fp32", "ue8m0"]) +@pytest.mark.parametrize("rows", [0, 1, 33]) +def test_indexer_scale_matches_hadamard_reference(monkeypatch, scale_format, rows): + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) + # FP32 matrix products are exact for these power-of-two input fractions. + x = (torch.randint(-64, 64, (rows, 128), device="cuda").float() / 64).bfloat16() + actual, scales = hadamard_transform_quant_fp8(x, 128 ** -0.5) + assert actual.dtype == torch.float8_e4m3fn and scales.dtype == torch.float32 + assert actual.shape == x.shape and scales.shape == (rows, 1) + if not rows: + return + matrix = torch.ones((1, 1), device="cuda") + for _ in range(7): + matrix = torch.cat((torch.cat((matrix, matrix), 1), torch.cat((matrix, -matrix), 1)), 0) + transformed = ((x.float() @ matrix) * 128 ** -0.5).bfloat16().float() + expected_scale = transformed.abs().amax(-1, keepdim=True).clamp_min(1e-4) / 448 + if scale_format != "fp32": + expected_scale = expected_scale.log2().ceil().exp2() + torch.testing.assert_close(scales, expected_scale, rtol=1e-6, atol=0) + expected = (transformed / expected_scale).clamp(-448, 448).to(torch.float8_e4m3fn) + assert torch.equal(actual.view(torch.uint8), expected.view(torch.uint8)) diff --git a/unit_tests/models/glm5_next/test_kernels.py b/unit_tests/models/glm5_next/test_kernels.py index 1a5e32567b..c87afd3f2a 100644 --- a/unit_tests/models/glm5_next/test_kernels.py +++ b/unit_tests/models/glm5_next/test_kernels.py @@ -182,7 +182,9 @@ def set_inputs(counts, phase): @pytest.mark.parametrize("cuda_graph", [False, True]) -def test_kpool_chunk_boundaries_and_fragmented_token_kv(cuda_graph): +@pytest.mark.parametrize("scale_format", ["auto", "fp32", "ue8m0"]) +def test_kpool_chunk_boundaries_and_fragmented_token_kv(monkeypatch, cuda_graph, scale_format): + monkeypatch.setenv("LIGHTLLM_ACTIVATION_SCALE_FORMAT", scale_format) # Unaligned chunks, reordered requests, and pools spanning old/new tails. seqs = [9, 7] ragged = torch.randperm(40, device="cuda", dtype=torch.int32)[: sum(seqs)] diff --git a/unit_tests/models/glm5_next/test_pd_cache.py b/unit_tests/models/glm5_next/test_pd_cache.py index 57665cbbe3..39ee6c039c 100644 --- a/unit_tests/models/glm5_next/test_pd_cache.py +++ b/unit_tests/models/glm5_next/test_pd_cache.py @@ -73,7 +73,7 @@ def create(tp): return create -@pytest.mark.parametrize("prefill_tp,decode_tp", [(1, 1), (4, 4), (1, 4), (4, 1)]) +@pytest.mark.parametrize("prefill_tp,decode_tp", [(1, 1), (4, 4), (1, 4), (4, 1), (8, 1)]) @pytest.mark.parametrize("remainder", range(4)) def test_pd_kv_and_runtime_state_roundtrip(make_mems, prefill_tp, decode_tp, remainder): source, dest = make_mems(prefill_tp), make_mems(decode_tp) diff --git a/unit_tests/models/glm5_next/test_precision_alignment.py b/unit_tests/models/glm5_next/test_precision_alignment.py new file mode 100644 index 0000000000..309e56bf05 --- /dev/null +++ b/unit_tests/models/glm5_next/test_precision_alignment.py @@ -0,0 +1,198 @@ +import dataclasses +import json +from types import SimpleNamespace + +import pytest +import torch +import torch.nn.functional as F + +from lightllm.common.basemodel.layer_weights.meta_weights.fused_moe.impl.deepgemm_impl import FuseMoeDeepGEMM +from lightllm.common.basemodel.layer_weights.meta_weights.fused_moe.impl.triton_impl import FuseMoeTriton +from lightllm.common.quantization import Quantcfg +from lightllm.models.glm5_next.indexer import Glm5NextNsaInfer +from lightllm.models.glm5_next.layer_weights.transformer_layer_weight import Glm5NextTransformerLayerWeight +from lightllm.server.core.objs.start_args_type import StartArgs +from lightllm.utils.envs_utils import get_env_start_args, get_redundancy_expert_ids, get_redundancy_expert_num + + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") + + +@pytest.fixture(autouse=True) +def runtime(monkeypatch): + monkeypatch.setenv("LIGHTLLM_START_ARGS", json.dumps(dataclasses.asdict(StartArgs()))) + for name, value in { + "GLOBAL_RANK": 0, + "GLOBAL_WORLD_SIZE": 1, + "DP_WORLD_SIZE": 1, + "CURRENT_RANK_IN_DP": 0, + "CURRENT_RANK_IN_NODE": 0, + "CURRENT_DEVICE_ID": 0, + }.items(): + monkeypatch.setenv("LIGHTLLM_" + name, str(value)) + for getter in (get_env_start_args, get_redundancy_expert_ids, get_redundancy_expert_num): + getter.cache_clear() + yield + for getter in (get_env_start_args, get_redundancy_expert_ids, get_redundancy_expert_num): + getter.cache_clear() + + +@pytest.fixture +def config(): + return { + "hidden_size": 128, + "n_embed": 128, + "intermediate_size": 128, + "moe_intermediate_size": 128, + "num_hidden_layers": 4, + "n_layer": 4, + "num_attention_heads": 4, + "num_key_value_heads": 4, + "rms_norm_eps": 1e-5, + "q_lora_rank": 128, + "kv_lora_rank": 512, + "qk_nope_head_dim": 128, + "qk_rope_head_dim": 0, + "v_head_dim": 128, + "index_n_heads": 32, + "index_head_dim": 128, + "index_topk": 2048, + "index_kpool": 4, + "index_kpool_always_select_tail": True, + "linear_attn_config": {"num_heads": 4, "head_dim": 128, "short_conv_kernel_size": 4}, + "layer_types": ["linear_attention"] * 3 + ["deepseek_sparse_attention"], + "mhc": False, + "n_routed_experts": 4, + "n_shared_experts": 1, + "first_k_dense_replace": 4, + "num_experts_per_tok": 2, + "norm_topk_prob": True, + "scoring_func": "sigmoid", + "n_group": 1, + "topk_group": 1, + } + + +def test_indexer_layernorm_epsilon_preserves_pooled_topk_boundary(config, monkeypatch): + pytest.importorskip("deep_gemm") + if torch.cuda.get_device_capability()[0] < 9: + pytest.skip("FP8 indexer requires Hopper or newer") + monkeypatch.setattr("lightllm.models.glm5_next.indexer.HAS_VLLM", False) + dim, heads, topk = config["index_head_dim"], config["index_n_heads"], config["index_topk"] + weight = Glm5NextTransformerLayerWeight(3, torch.bfloat16, config, Quantcfg(config)) + indexer = Glm5NextNsaInfer(3, config, 1) + columns = torch.arange(dim) + a = torch.where(columns % 2 == 0, -1.0, 1.0) + b = torch.where((columns // 2) % 2 == 0, -1.0, 1.0) + query = 2 * a + b + prefix = "model.language_model.layers.3.self_attn.indexer" + eye = torch.eye(dim, dtype=torch.bfloat16) + weight.load_hf_weights( + { + f"{prefix}.wk.weight": eye, + f"{prefix}.k_norm.weight": torch.ones(dim, dtype=torch.bfloat16), + f"{prefix}.k_norm.bias": torch.zeros(dim, dtype=torch.bfloat16), + f"{prefix}.wq_b.weight": eye.repeat(heads, 1), + f"{prefix}.weights_proj.weight": ((a + b) / dim).repeat(heads, 1), + f"{prefix}.index_kpool_compress_gate": torch.zeros(dim, dim, dtype=torch.bfloat16), + f"{prefix}.index_kpool_compress_ape": torch.zeros(4, dim), + } + ) + + # 513 pools compete for 512 slots: epsilon changes the final selected pool. + hidden = torch.cat( + ( + (query * 2 ** -4).repeat(topk - 4, 1), + (a * 2 ** -10).repeat(4, 1), + (b * 2 ** -4).repeat(4, 1), + ) + ).to(device="cuda", dtype=torch.bfloat16) + tokens = hidden.shape[0] + q_lora = query.to(device="cuda", dtype=torch.bfloat16).repeat(tokens, 1) + projected_k = weight.wk_proj_.mm(hidden) + actual_k = weight.k_norm_(projected_k, eps=indexer.eps) + expected_k = F.layer_norm(projected_k, (dim,), weight.k_norm_.weight, weight.k_norm_.bias, eps=1e-6) + torch.testing.assert_close(actual_k, expected_k, atol=0, rtol=0) + wrong_k = F.layer_norm(projected_k, (dim,), weight.k_norm_.weight, weight.k_norm_.bias, eps=config["rms_norm_eps"]) + assert not torch.equal(expected_k, wrong_k) + + storage = torch.zeros(tokens + 1, 1, 584, dtype=torch.bfloat16, device="cuda") + tail = torch.zeros(2, 4, 2 * dim, dtype=torch.bfloat16, device="cuda") + locations = torch.arange(tokens, dtype=torch.int32, device="cuda") + infer = SimpleNamespace( + is_prefill=True, + max_kv_seq_len=tokens, + max_q_seq_len=tokens, + b_req_idx=torch.zeros(1, dtype=torch.int32, device="cuda"), + b_seq_len=torch.tensor([tokens], dtype=torch.int32, device="cuda"), + b1_cu_q_seq_len=torch.tensor([0, tokens], dtype=torch.int32, device="cuda"), + req_manager=SimpleNamespace( + req_to_token_indexs=locations[None], + get_indexer_tail_buffer=lambda _: tail, + ), + mem_manager=SimpleNamespace(get_indexer_k_buffer=lambda _: storage.view(torch.uint8)[:, :, -132:]), + ) + state = SimpleNamespace(lengths=locations + 1, ks=torch.zeros_like(locations), ragged_mem_index=locations) + _, actual = indexer._get_indices(hidden, q_lora, infer, state, weight) + torch.testing.assert_close(actual[-1, :topk].sort().values, locations[:topk], atol=0, rtol=0) + assert (actual[-1, topk:] == -1).all() + + # Negative control: the previous epsilon selects the other boundary pool. + indexer.eps = config["rms_norm_eps"] + _, wrong = indexer._get_indices(hidden, q_lora, infer, state, weight) + wrong_selection = torch.cat((locations[: topk - 4], locations[topk:])) + torch.testing.assert_close(wrong[-1, :topk].sort().values, wrong_selection, atol=0, rtol=0) + + +@pytest.mark.parametrize("enable_ep_moe", [False, True], ids=["tp", "ep"]) +def test_checkpoint_correction_bias_preserves_fp32_routing(config, monkeypatch, enable_ep_moe): + args = StartArgs(tp=2, enable_ep_moe=enable_ep_moe, enable_fused_shared_experts=False) + monkeypatch.setenv("LIGHTLLM_START_ARGS", json.dumps(dataclasses.asdict(args))) + # Rank 1 must retain every expert's global correction bias. + for name, value in {"GLOBAL_RANK": 1, "GLOBAL_WORLD_SIZE": 2, "DP_WORLD_SIZE": 2, "CURRENT_RANK_IN_DP": 1}.items(): + monkeypatch.setenv("LIGHTLLM_" + name, str(value)) + get_env_start_args.cache_clear() + config.update(n_routed_experts=288, num_experts_per_tok=8, first_k_dense_replace=1) + weight = Glm5NextTransformerLayerWeight(3, torch.bfloat16, config, Quantcfg(config)) + experts = weight.experts + expected_impl = FuseMoeDeepGEMM if enable_ep_moe else FuseMoeTriton + assert isinstance(experts.fuse_moe_impl, expected_impl) + source_bias = torch.zeros(config["n_routed_experts"], dtype=torch.float32) + source_bias[7:9] = torch.tensor([0.1002, 0.1001], dtype=torch.float32) + weight.load_hf_weights({"model.language_model.layers.3.mlp.gate.e_score_correction_bias": source_bias}) + assert experts.e_score_correction_bias.dtype == torch.float32 + assert experts.e_score_correction_bias.load_ok + torch.testing.assert_close(experts.e_score_correction_bias.cpu(), source_bias, atol=0, rtol=0) + assert experts.w13.weight.dtype == experts.w2.weight.dtype == torch.bfloat16 + + hidden = torch.zeros(1, config["hidden_size"], dtype=torch.bfloat16, device="cuda") + logits = torch.full((1, config["n_routed_experts"]), -10.0, dtype=torch.float32, device="cuda") + logits[:, :7] = 4.0 + logits[0, 7:9] = torch.tensor([0.0, 4e-5], dtype=torch.float32, device="cuda") + + def route(bias): + return experts.fuse_moe_impl._select_experts( + input_tensor=hidden, + router_logits=logits, + correction_bias=bias, + top_k=8, + renormalize=True, + use_grouped_topk=True, + topk_group=1, + num_expert_group=1, + scoring_func="sigmoid", + ) + + weights, ids, origin_ids = route(experts.e_score_correction_bias) + expected_ids = torch.arange(8, dtype=ids.dtype, device="cuda")[None] + torch.testing.assert_close(ids.sort(dim=-1).values, expected_ids, atol=0, rtol=0) + torch.testing.assert_close(origin_ids, ids, atol=0, rtol=0) + # Correction bias affects selection; weights use the original sigmoid scores. + expected_weights = logits.sigmoid().gather(1, ids.long()) + expected_weights /= expected_weights.sum(-1, keepdim=True) + torch.testing.assert_close(weights, expected_weights, atol=1e-7, rtol=1e-6) + + # Negative control: BF16 rounding makes expert 8 displace expert 7. + _, rounded_ids, _ = route(experts.e_score_correction_bias.to(torch.bfloat16)) + rounded_expected = torch.tensor([list(range(7)) + [8]], dtype=rounded_ids.dtype, device="cuda") + torch.testing.assert_close(rounded_ids.sort(dim=-1).values, rounded_expected, atol=0, rtol=0) diff --git a/unit_tests/models/glm5_next/test_shared_combine.py b/unit_tests/models/glm5_next/test_shared_combine.py new file mode 100644 index 0000000000..67c11f657e --- /dev/null +++ b/unit_tests/models/glm5_next/test_shared_combine.py @@ -0,0 +1,148 @@ +from types import SimpleNamespace + +import pytest +import torch + +from lightllm.models.glm5_next.layer_infer.transformer_layer_infer import Glm5NextTransformerLayerInfer +from lightllm.common.basemodel.layer_weights.meta_weights.fused_moe import fused_moe_weight as weight_module +from lightllm.common.basemodel.layer_weights.meta_weights.fused_moe.impl import deepgemm_impl +from lightllm.common.basemodel.triton_kernel.fused_moe import grouped_fused_moe_ep as ep + + +@pytest.mark.parametrize("prefill", [False, True]) +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("shared_mode", ["absent", "separate", "native_fused"]) +def test_shared_output_is_added_once(prefill, enabled, shared_mode): + hidden = torch.zeros(2, 128, dtype=torch.bfloat16) + shared = torch.full_like(hidden, 0.25) + calls = [] + + def experts(input_tensor, **kwargs): + calls.append(kwargs) + routed = torch.ones_like(input_tensor) + return routed if "shared_expert_output" not in kwargs else routed + kwargs["shared_expert_output"] + + layer = SimpleNamespace( + embed_dim_=128, + n_shared_experts=None if shared_mode == "absent" else 1, + ep_shared_combine_bias=enabled, + swiglu_limit=7.0, + num_experts_per_tok=2, + norm_topk_prob=True, + n_group=1, + topk_group=1, + _ffn_tp=lambda *args: shared, + ) + weights = SimpleNamespace( + num_fused_shared_experts=int(shared_mode == "native_fused"), + moe_gate=SimpleNamespace(data_type_=torch.float32, mm=lambda x: torch.zeros(2, 4)), + experts=SimpleNamespace(experts=experts), + ) + result = Glm5NextTransformerLayerInfer._moe_ffn(layer, hidden, SimpleNamespace(is_prefill=prefill), weights) + expected = torch.ones_like(hidden) + (shared if shared_mode == "separate" else 0) + torch.testing.assert_close(result, expected, rtol=0, atol=0) + if enabled and prefill and shared_mode == "separate": + assert calls[0]["shared_expert_output"] is shared + else: + assert "shared_expert_output" not in calls[0] + + +@pytest.mark.parametrize("with_shared", [False, True]) +def test_shared_bias_reaches_normal_combine_backend(monkeypatch, with_shared): + hidden = torch.zeros(2, 128, dtype=torch.bfloat16) + shared = torch.ones_like(hidden) if with_shared else None + ids = torch.zeros(2, 1, dtype=torch.int32) + impl = object.__new__(deepgemm_impl.FuseMoeDeepGEMM) + impl._select_experts = lambda **kwargs: (torch.ones(2, 1), ids, ids) + impl.total_expert_num_contain_redundancy = 8 + impl.quant_method = SimpleNamespace(block_size=128) + pack = SimpleNamespace(weight=hidden, weight_scale=None) + calls = [] + + def capture(**kwargs): + calls.append(kwargs) + return hidden + + monkeypatch.setattr(weight_module, "get_moe_capture_callback", lambda *args: None) + monkeypatch.setattr(ep, "check_ep_expert_dtype", lambda method: None) + monkeypatch.setattr(ep, "use_sm100_mega_moe", lambda method: False) + monkeypatch.setattr(ep, "fused_experts_impl", capture) + monkeypatch.setattr(ep.dist_group_manager, "ep_buffer", object(), raising=False) + weight = SimpleNamespace( + layer_num_=3, + fuse_moe_impl=impl, + w13=pack, + w2=pack, + e_score_correction_bias=None, + scoring_func="sigmoid", + per_expert_scale=None, + ) + result = weight_module.FusedMoeWeight.experts( + weight, + hidden, + hidden, + top_k=1, + renormalize=True, + use_grouped_topk=False, + topk_group=1, + num_expert_group=1, + is_prefill=True, + shared_expert_output=shared, + ) + assert result is hidden and calls[0]["shared_expert_output"] is shared + + +def test_unsupported_shared_bias_fails_before_dispatch(monkeypatch): + x = torch.zeros(1, 128, dtype=torch.bfloat16) + args = dict( + hidden_states=x, + w13=None, + w2=None, + topk_weights=None, + topk_idx=None, + num_experts=8, + quant_method=None, + shared_expert_output=x, + ) + with pytest.raises(ValueError, match="normal prefill combine"): + ep.fused_experts(**args, is_prefill=False) + monkeypatch.setattr(ep, "check_ep_expert_dtype", lambda method: None) + monkeypatch.setattr(ep, "use_sm100_mega_moe", lambda method: True) + with pytest.raises(NotImplementedError, match="DeepEP normal combine"): + ep.fused_experts(**args, is_prefill=True) + + +@pytest.mark.parametrize("with_shared", [False, True]) +def test_normal_combine_preserves_default_call_contract(monkeypatch, with_shared): + x = torch.zeros(2, 128, dtype=torch.bfloat16) + bias = torch.ones_like(x) if with_shared else None + calls = [] + handle = SimpleNamespace(num_recv_tokens_per_expert_list=[0], recv_src_metadata=torch.empty(0, 3)) + + def combine(*args, **kwargs): + calls.append(kwargs) + return x, None, None + + buffer = SimpleNamespace(dispatch=lambda *args, **kwargs: ((x, x), None, None, handle, None), combine=combine) + monkeypatch.setattr(ep, "per_token_group_quant_fp8", lambda *args, **kwargs: (x, x)) + monkeypatch.setattr(ep, "get_deepep_num_max_dispatch_tokens_per_rank_prefill", lambda: 256) + monkeypatch.setattr(ep.Autotuner, "is_kernel_autotune_warmup", lambda *args: False) + ids = torch.zeros(2, 1, dtype=torch.int64) + result = ep.fused_experts_impl( + x, + torch.empty(1, 256, 128), + torch.empty(1, 128, 128), + torch.ones(2, 1), + ids, + num_experts=1, + buffer=buffer, + is_prefill=True, + use_fp8_w8a8=True, + use_fp8_all2all=True, + shared_expert_output=bias, + ) + assert result is x + if with_shared: + assert calls[0]["bias"] is bias + else: + assert "bias" not in calls[0] diff --git a/unit_tests/server/router/model_infer/structured_output/test_reasoning.py b/unit_tests/server/router/model_infer/structured_output/test_reasoning.py index 8c265847db..e4f08421ad 100644 --- a/unit_tests/server/router/model_infer/structured_output/test_reasoning.py +++ b/unit_tests/server/router/model_infer/structured_output/test_reasoning.py @@ -49,7 +49,8 @@ def encode(text, add_special_tokens): chat_template_kwargs=template_kwargs, reasoning_effort=effort, ) - result = api_openai._get_guided_reasoning_end(request, SimpleNamespace(encode=encode)) + thinking = api_openai._is_force_thinking_mode(request) + result = api_openai._get_guided_reasoning_end(SimpleNamespace(encode=encode), parser, thinking) assert encoded == ([marker] if marker else []) assert result == ([7, 8] if marker else []) @@ -134,8 +135,11 @@ def test_fixed_reasoning_does_not_require_a_template_thinking_switch(monkeypatch monkeypatch.setattr(build_prompt, "tokenizer_supports_force_thinking", lambda: False) tokenizer = SimpleNamespace(encode=lambda text, **kwargs: list(text.encode())) request = ChatCompletionRequest(model="test", messages=[]) - assert api_openai._is_force_thinking_mode(request) == (marker is not None) - assert api_openai._get_guided_reasoning_end(request, tokenizer) == (list(marker.encode()) if marker else []) + thinking = api_openai._is_force_thinking_mode(request) + assert thinking == (marker is not None) + assert api_openai._get_guided_reasoning_end(tokenizer, parser, thinking) == ( + list(marker.encode()) if marker else [] + ) @pytest.mark.parametrize("stream", [False, True]) diff --git a/unit_tests/server/test_glm47_tool_parser.py b/unit_tests/server/test_glm47_tool_parser.py index 8f626d6343..eb57175ac6 100644 --- a/unit_tests/server/test_glm47_tool_parser.py +++ b/unit_tests/server/test_glm47_tool_parser.py @@ -84,6 +84,9 @@ def test_text_around_tool_calls_survives_chunk_boundaries(chunk_size): content, calls = collect_stream(text, chunk_size) assert content == "Before between after" assert list(calls) == [0, 1] + normal, nonstream = FunctionCallParser(TOOLS, "glm47").parse_non_stream(text) + assert normal == content + assert [call.parameters for call in nonstream] == [call["arguments"] for call in calls.values()] def test_buffered_arguments_keep_the_stream_alive_without_repeating_the_name(): @@ -95,7 +98,7 @@ def test_buffered_arguments_keep_the_stream_alive_without_repeating_the_name(): assert calls[0].name == "Write" assert calls[0].parameters == "" - value = ' line 1\n{"literal": true}\n' + "x" * 100_000 + value = ' line 1\n{"literal": true}\nRead\n' + "x" * 100_000 for chunk in (value[:13], value[13:27], value[27:]): normal, calls = parser.parse_stream_chunk(chunk) assert normal == "" @@ -125,3 +128,79 @@ def test_undefined_buffered_tool_does_not_consume_a_call_index(): assert len(calls) == 1 assert calls[0].name == "Read" assert calls[0].tool_index == 0 + + +@pytest.mark.parametrize("chunk_size", [1, 7, 4096]) +def test_tool_delimiters_inside_values_do_not_close_or_create_calls(chunk_size): + value = "Read\nliteralliteral" + text = ( + f"Writecontent{value}" + "Readfile_path/tmp/a" + ) + normal, calls = collect_stream(text, chunk_size) + assert normal == "" + assert list(calls) == [0, 1] + assert [call["name"] for call in calls.values()] == ["Write", "Read"] + assert [json.loads(call["arguments"]) for call in calls.values()] == [{"content": value}, {"file_path": "/tmp/a"}] + normal, nonstream = FunctionCallParser(TOOLS, "glm47").parse_non_stream(text) + assert normal == "" + assert [call.parameters for call in nonstream] == [call["arguments"] for call in calls.values()] + + +@pytest.mark.parametrize( + "schema,value,expected", + [ + ({"type": ["string"]}, "123", "123"), + ({"anyOf": [{"type": "string"}]}, "false", "false"), + ({"oneOf": [{"type": "string"}]}, "null", "null"), + ({"allOf": [{"type": "string"}]}, "123", "123"), + ({"enum": ["123", "false"]}, "123", "123"), + ({"type": ["string", "null"]}, "null", None), + ({"anyOf": [{"type": "integer"}, {"type": "string"}]}, "0012", 12), + ({"type": "boolean"}, "False", False), + ({"type": "boolean"}, "1", True), + ({"type": "boolean"}, " 0 ", False), + ({"type": "integer"}, "0012", 12), + ({"type": "number"}, ".5", 0.5), + ({"type": "number"}, "1.0", 1), + ({"type": "array"}, '[1, "中", false]', [1, "中", False]), + ({"type": "object"}, '{"nested": {"ok": true}}', {"nested": {"ok": True}}), + ({}, "123", "123"), + (None, "false", "false"), + ({"type": "number"}, "NaN", "NaN"), + ({"type": "number"}, "Infinity", "Infinity"), + ({"type": "number"}, "1e999", "1e999"), + ({"type": "array"}, "[1e999]", "[1e999]"), + ({"type": "object"}, '{"x": NaN}', '{"x": NaN}'), + ], +) +def test_schema_coercion_preserves_declared_types_and_finite_json(schema, value, expected): + tools = [ + Tool.model_validate( + { + "type": "function", + "function": { + "name": "Probe", + "parameters": { + "type": "object", + "properties": {"value": schema} if schema is not None else {}, + }, + }, + } + ) + ] + text = f"Probevalue{value}" + parser = FunctionCallParser(tools, "glm47") + normal, calls = parser.parse_non_stream(text) + assert normal == "" + assert len(calls) == 1 + parsed = json.loads(calls[0].parameters)["value"] + assert parsed == expected + assert type(parsed) is type(expected) + json.dumps(parsed, allow_nan=False) + parameters = "" + for char in text: + normal, deltas = parser.parse_stream_chunk(char) + assert normal == "" + parameters += "".join(delta.parameters for delta in deltas) + assert parameters == calls[0].parameters diff --git a/unit_tests/server/test_glm_reasoning_tool_calls.py b/unit_tests/server/test_glm_reasoning_tool_calls.py new file mode 100644 index 0000000000..92bc3e8496 --- /dev/null +++ b/unit_tests/server/test_glm_reasoning_tool_calls.py @@ -0,0 +1,328 @@ +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from lightllm.server import api_http, api_openai, build_prompt, reasoning_parser +from lightllm.server.api_models import ChatCompletionRequest, Tool +from lightllm.server.core.objs import FinishStatus +from lightllm.server.core.objs.start_args_type import StartArgs +from lightllm.server.function_call_parser import FunctionCallParser +from lightllm.server.httpserver.stop_seq_match import ReasoningStateMachine +from lightllm.server.reasoning_parser import ReasoningParser + + +TOOLS = [ + Tool.model_validate( + { + "type": "function", + "function": { + "name": "Write", + "parameters": {"type": "object", "properties": {"content": {"type": "string"}}}, + }, + } + ) +] +VALUE = ' 中文 "quoted" \\path\nliteralRead\n ' +TOOL_CALL = f"Writecontent{VALUE}" + + +@pytest.fixture(autouse=True) +def mock_token_ids(monkeypatch): + monkeypatch.setattr( + reasoning_parser, "get_token_id", {"": 98, "": 99, "": 77}.__getitem__ + ) + + +@pytest.fixture +def complete_chat(monkeypatch): + args = StartArgs(reasoning_parser="glm45", tool_call_parser="glm47") + monkeypatch.setattr(api_openai, "get_env_start_args", lambda: args) + monkeypatch.setattr(build_prompt, "tokenizer_supports_force_thinking", lambda: True) + + def complete( + chunks, + finish_status=FinishStatus.FINISHED_STOP, + prompt="<|assistant|>", + parser_name="glm45", + compiler=None, + **request_kwargs, + ): + args.reasoning_parser = parser_name + monkeypatch.setattr(api_openai, "build_prompt", AsyncMock(return_value=prompt)) + captured_params = [] + + async def generate(_prompt, sampling_params, *_args, **_kwargs): + captured_params.append(sampling_params) + for index, (text, token_id) in enumerate(chunks): + finish = FinishStatus(finish_status) if index == len(chunks) - 1 else FinishStatus() + yield 0, text, {"id": token_id, "prompt_tokens": 1}, finish + + tokenizer = SimpleNamespace(encode=lambda text, **kwargs: {"": [99]}[text]) + manager = SimpleNamespace(tokenizer=tokenizer, generate=generate, output_grammar_compiler=compiler) + monkeypatch.setattr(api_http, "g_objs", SimpleNamespace(args=args, httpserver_manager=manager)) + request = ChatCompletionRequest(model="glm", messages=[], tools=TOOLS, **request_kwargs) + + async def run(): + response = await api_openai.chat_completions_impl(request, None) + result = SimpleNamespace(reasoning="", content="", calls=[], finish_reason=None, sampling_params=None) + if not request.stream: + choice = response.choices[0] + result.reasoning = choice.message.reasoning or "" + result.content = choice.message.content or "" + result.calls = [ + {"id": call.id, "name": call.function.name, "arguments": call.function.arguments} + for call in choice.message.tool_calls or [] + ] + result.finish_reason = choice.finish_reason + result.sampling_params = captured_params[0] + return result + + calls = {} + done = False + async for chunk in response.body_iterator: + chunk = chunk.decode() if isinstance(chunk, bytes) else chunk + if chunk.strip() == "data: [DONE]": + done = True + continue + for choice in json.loads(chunk.removeprefix("data: "))["choices"]: + delta = choice["delta"] + result.reasoning += delta.get("reasoning") or "" + result.content += delta.get("content") or "" + for call in delta.get("tool_calls") or []: + index = call["index"] + name = call["function"].get("name") + if name is not None: + assert index not in calls # Send each name and ID once. + assert call["id"] + calls[index] = {"id": call["id"], "name": name, "arguments": ""} + assert index in calls + if call.get("id") is not None: + assert call["id"] == calls[index]["id"] + calls[index]["arguments"] += call["function"].get("arguments") or "" + result.finish_reason = choice.get("finish_reason") or result.finish_reason + assert done + assert list(calls) == list(range(len(calls))) + result.calls = list(calls.values()) + result.sampling_params = captured_params[0] + return result + + return asyncio.run(run()) + + return complete + + +@pytest.mark.parametrize( + "prefix,force_reasoning", + [ + ("Need a file.", False), + ("Need a file.", True), + ("Need a file.", False), + ("Need a file.", True), + ], +) +@pytest.mark.parametrize("chunk_size", [1, 7, 4096]) +def test_glm_reasoning_and_tools_agree_across_stream_boundaries(prefix, force_reasoning, chunk_size): + text = prefix + TOOL_CALL + TOOL_CALL + parser = ReasoningParser("glm45", force_reasoning=force_reasoning) + reasoning, content = parser.parse_non_stream(text) + assert reasoning == "Need a file." + assert content == TOOL_CALL + TOOL_CALL + + parser = ReasoningParser("glm45", force_reasoning=force_reasoning) + tool_parser = FunctionCallParser(TOOLS, "glm47") + reasoning_parts, content_parts, calls = [], [], {} + for start in range(0, len(text), chunk_size): + reasoning, content = parser.parse_stream_chunk(text[start : start + chunk_size]) + reasoning_parts.append(reasoning) + content, deltas = tool_parser.parse_stream_chunk(content) + content_parts.append(content) + for delta in deltas: + if delta.name is not None: + assert delta.tool_index not in calls + calls[delta.tool_index] = {"name": delta.name, "arguments": ""} + calls[delta.tool_index]["arguments"] += delta.parameters + + assert parser.flush() == ("", "") + assert "".join(reasoning_parts) == "Need a file." + assert "".join(content_parts) == "" + assert list(calls) == [0, 1] + assert all(call["name"] == "Write" for call in calls.values()) + assert all(json.loads(call["arguments"]) == {"content": VALUE} for call in calls.values()) + + +@pytest.mark.parametrize("suffix", [""]) +def test_truncated_or_similar_markers_remain_reasoning(suffix): + parser = ReasoningParser("glm45", force_reasoning=True) + reasoning, content = parser.parse_stream_chunk("Keep thinking." + suffix) + tail_reasoning, tail_content = parser.flush() + assert reasoning + tail_reasoning == "Keep thinking." + suffix + assert content + tail_content == "" + + +def test_non_thinking_glm_preserves_tool_payload(): + parser = ReasoningParser("glm45", force_reasoning=False) + assert parser.parse_non_stream(TOOL_CALL) == ("", TOOL_CALL) + assert parser.parse_stream_chunk(TOOL_CALL) == ("", TOOL_CALL) + + +def test_other_reasoning_parsers_keep_their_existing_boundary(): + parser = ReasoningParser("qwen3", force_reasoning=True) + reasoning, content = parser.parse_non_stream("Still thinking." + TOOL_CALL.replace(VALUE, "literal")) + assert reasoning.startswith("Still thinking.") + assert content == "" + + +def test_glm_tool_parsing_keeps_existing_reasoning_token_accounting(): + parser = ReasoningParser("glm45", force_reasoning=True) + for token_id in [1, 2, 77, 3, 99, 4]: + parser.update_reasoning_token_count(token_id) + assert parser.reasoning_tokens == 4 + + +def test_glm_tools_inside_reasoning_do_not_enable_string_stops(): + tokenizer = SimpleNamespace(encode=lambda text, **kwargs: {"": [98], "": [99]}[text]) + state = ReasoningStateMachine("glm45", tokenizer, 1, []) + assert not any(state.handle(token_id) for token_id in [1, 77, 2, 3]) + assert state.in_reasoning + assert state.handle(99) is False + assert not state.in_reasoning + assert state.handle(4) is True + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("separate_reasoning", [False, True]) +@pytest.mark.parametrize("reasoning_end", ["missing", "before_tool", "after_tool"]) +@pytest.mark.parametrize("chunk_size", [1, 4096]) +def test_chat_completion_exposes_glm_tool_calls(complete_chat, stream, separate_reasoning, reasoning_end, chunk_size): + segments = [("Need a file.", 1)] + if reasoning_end == "before_tool": + segments.append(("", 99)) + segments.extend([("", 77), (TOOL_CALL[len("") :], 2)] * 2) + if reasoning_end == "after_tool": + segments.append(("", 99)) + chunks = [ + (text[start : start + chunk_size], token_id if start + chunk_size >= len(text) else 1) + for text, token_id in segments + for start in range(0, len(text), chunk_size) + ] + result = complete_chat( + chunks, + stream=stream, + separate_reasoning=separate_reasoning, + # GLM-5.3's template opens thinking even with this toggle disabled. + chat_template_kwargs={"enable_thinking": False}, + ) + assert result.reasoning == ("Need a file." if separate_reasoning else "") + assert result.content == ("" if separate_reasoning else "Need a file.") + assert [call["name"] for call in result.calls] == ["Write", "Write"] + assert len({call["id"] for call in result.calls}) == 2 + assert [json.loads(call["arguments"]) for call in result.calls] == [{"content": VALUE}] * 2 + assert result.finish_reason == "tool_calls" + + +@pytest.mark.parametrize( + "prompt,state", + [ + ("<|assistant|>", True), + ("<|assistant|>reasoning directive", True), + ("<|assistant|>", False), + ("<|assistant|>", False), + ("<|assistant|>firstsecond", True), + ("<|assistant|>old<|user|>question<|assistant|>", True), + ("<|assistant|>old<|user|>question<|assistant|>", None), + ("<|assistant|>old<|observation|>tool result", None), + ("<|user|>quoted", None), + ], +) +def test_glm_initial_reasoning_state_comes_from_current_prompt_turn(prompt, state): + assert ReasoningParser.DetectorMap["glm45"].initial_reasoning_state(prompt) is state + + +def test_glm_prompt_override_does_not_change_other_models(monkeypatch): + monkeypatch.setattr(api_openai, "get_env_start_args", lambda: StartArgs(reasoning_parser="qwen3")) + monkeypatch.setattr(build_prompt, "tokenizer_supports_force_thinking", lambda: True) + request = ChatCompletionRequest(model="qwen", messages=[], chat_template_kwargs={"enable_thinking": False}) + default = api_openai._is_force_thinking_mode(request) + assert ReasoningParser.get_initial_reasoning_state("qwen3", "<|assistant|>", default) is False + + +@pytest.mark.parametrize("default", [False, True]) +@pytest.mark.parametrize("prompt", ["custom prompt", "<|assistant|>", "<|assistant|>old<|user|>question"]) +def test_glm_unrecognized_prompt_preserves_request_mode(prompt, default): + assert ReasoningParser.get_initial_reasoning_state("glm45", prompt, default) is default + + +@pytest.mark.parametrize("thinking", [False, True]) +def test_glm_grammar_activation_follows_rendered_prompt(monkeypatch, thinking): + monkeypatch.setattr(api_openai, "get_env_start_args", lambda: StartArgs(reasoning_parser="glm45")) + monkeypatch.setattr(build_prompt, "tokenizer_supports_force_thinking", lambda: True) + request = ChatCompletionRequest(model="glm", messages=[], chat_template_kwargs={"enable_thinking": not thinking}) + prompt = "<|assistant|>" + ("" if thinking else "") + tokenizer = SimpleNamespace(encode=lambda text, **kwargs: {"": [99]}[text]) + default = api_openai._is_force_thinking_mode(request) + initial_reasoning_state = ReasoningParser.get_initial_reasoning_state("glm45", prompt, default) + assert default is not thinking + assert initial_reasoning_state is thinking + assert api_openai._get_guided_reasoning_end(tokenizer, "glm45", initial_reasoning_state) == ( + [99] if thinking else [] + ) + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("thinking", [False, True]) +def test_glm_prompt_phase_is_shared_by_output_stop_and_grammar(complete_chat, compiler, stream, thinking): + chunks = [("Need a file.", 1), ("", 99)] if thinking else [] + chunks.append(('{"ok": true}', 2)) + result = complete_chat( + chunks, + prompt="<|assistant|>" + ("" if thinking else ""), + compiler=compiler, + stream=stream, + separate_reasoning=True, + tool_choice="none", + stop=["STOP"], + response_format={"type": "json_object"}, + chat_template_kwargs={"enable_thinking": not thinking}, + ) + assert result.reasoning == ("Need a file." if thinking else "") + assert result.content == '{"ok": true}' + assert result.sampling_params._initial_reasoning_state == int(thinking) + assert result.sampling_params.constraint_out_params.guided_reasoning_end.to_list() == ([99] if thinking else []) + + +@pytest.mark.parametrize("parser_name", ["glm45", "qwen3"]) +@pytest.mark.parametrize("thinking", [False, True]) +@pytest.mark.parametrize("separate_reasoning", [False, True]) +def test_stream_flush_preserves_truncated_text_in_each_phase(complete_chat, parser_name, thinking, separate_reasoning): + result = complete_chat( + [("Body", 1), ("<", 2)], + finish_status=FinishStatus.FINISHED_LENGTH, + parser_name=parser_name, + prompt="<|assistant|>" + ("" if thinking else ""), + stream=True, + separate_reasoning=separate_reasoning, + tool_choice="none", + chat_template_kwargs={"enable_thinking": thinking}, + ) + assert result.reasoning == ("Body<" if thinking and separate_reasoning else "") + assert result.content == ("" if thinking and separate_reasoning else "Body<") + assert result.finish_reason == "length" + + +@pytest.mark.parametrize("suffix", ["<", "Writecontentbody", 2), (suffix, 3)], + finish_status=FinishStatus.FINISHED_LENGTH, + stream=True, + separate_reasoning=separate_reasoning, + ) + assert result.reasoning == ("Need a file." if separate_reasoning else "") + assert result.content == ("" if separate_reasoning else "Need a file.") + assert [call["name"] for call in result.calls] == ["Write"] + assert result.calls[0]["arguments"] == "" + assert result.finish_reason == "length"