[Model] Laguna-XS.2 Model Support (#24204)
This commit is contained in:
@@ -15,6 +15,7 @@ from sglang.srt.configs.kimi_k25 import KimiK25Config
|
||||
from sglang.srt.configs.kimi_linear import KimiLinearConfig
|
||||
from sglang.srt.configs.kimi_vl import KimiVLConfig
|
||||
from sglang.srt.configs.kimi_vl_moonvit import MoonViTConfig
|
||||
from sglang.srt.configs.laguna import LagunaConfig
|
||||
from sglang.srt.configs.lfm2 import Lfm2Config
|
||||
from sglang.srt.configs.lfm2_moe import Lfm2MoeConfig
|
||||
from sglang.srt.configs.lfm2_vl import Lfm2VlConfig
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"Olmo3Config",
|
||||
"KimiLinearConfig",
|
||||
"KimiK25Config",
|
||||
"LagunaConfig",
|
||||
"Qwen3NextConfig",
|
||||
"Qwen3_5Config",
|
||||
"Qwen3_5MoeConfig",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# coding=utf-8
|
||||
# Copyright 2023-2026 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
|
||||
"""Laguna (poolside/Laguna-XS.2) model configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from transformers.configuration_utils import PretrainedConfig
|
||||
from transformers.utils import logging
|
||||
|
||||
logger = logging.get_logger(__name__)
|
||||
|
||||
|
||||
def _first_not_none(*candidates: Any) -> Any:
|
||||
"""First non-None candidate. Unlike `a or b`, preserves falsy values."""
|
||||
return next((c for c in candidates if c is not None), None)
|
||||
|
||||
|
||||
def _to_sglang_rope_scaling(rope_params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""HF per-layer rope dict → SGLang `get_rope` `rope_scaling`. None means plain RoPE."""
|
||||
if not rope_params:
|
||||
return None
|
||||
rope_type = rope_params.get("rope_type") or rope_params.get("type")
|
||||
if rope_type in (None, "default"):
|
||||
return None
|
||||
|
||||
out: Dict[str, Any] = {"rope_type": rope_type}
|
||||
pass_through = (
|
||||
"factor",
|
||||
"original_max_position_embeddings",
|
||||
"beta_fast",
|
||||
"beta_slow",
|
||||
"extrapolation_factor",
|
||||
"truncate",
|
||||
"low_freq_factor",
|
||||
"high_freq_factor",
|
||||
"mscale",
|
||||
"mscale_all_dim",
|
||||
"short_factor",
|
||||
"long_factor",
|
||||
"short_mscale",
|
||||
"long_mscale",
|
||||
)
|
||||
for key in pass_through:
|
||||
if key in rope_params:
|
||||
out[key] = rope_params[key]
|
||||
if "attention_factor" in rope_params:
|
||||
# HF spells it attention_factor; SGLang's factory reads attn_factor.
|
||||
out["attn_factor"] = rope_params["attention_factor"]
|
||||
return out
|
||||
|
||||
|
||||
class LagunaConfig(PretrainedConfig):
|
||||
model_type = "laguna"
|
||||
keys_to_ignore_at_inference = ["past_key_values"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vocab_size: int = 100352,
|
||||
hidden_size: int = 2048,
|
||||
intermediate_size: int = 8192,
|
||||
num_hidden_layers: int = 40,
|
||||
num_attention_heads: int = 48,
|
||||
num_key_value_heads: int = 8,
|
||||
head_dim: int = 128,
|
||||
hidden_act: str = "silu",
|
||||
max_position_embeddings: int = 131072,
|
||||
initializer_range: float = 0.02,
|
||||
rms_norm_eps: float = 1e-6,
|
||||
use_cache: bool = True,
|
||||
tie_word_embeddings: bool = False,
|
||||
attention_bias: bool = False,
|
||||
attention_dropout: float = 0.0,
|
||||
sliding_window: int = 512,
|
||||
layer_types: Optional[List[str]] = None,
|
||||
mlp_layer_types: Optional[List[str]] = None,
|
||||
num_attention_heads_per_layer: Optional[List[int]] = None,
|
||||
num_experts: int = 256,
|
||||
num_experts_per_tok: int = 8,
|
||||
moe_intermediate_size: int = 512,
|
||||
shared_expert_intermediate_size: int = 512,
|
||||
moe_routed_scaling_factor: float = 1.0,
|
||||
moe_router_logit_softcapping: float = 0.0,
|
||||
moe_apply_router_weight_on_input: bool = False,
|
||||
# Per-layer-type rope dict; nested under "full_attention" / "sliding_attention".
|
||||
rope_parameters: Optional[Dict[str, Any]] = None,
|
||||
partial_rotary_factor: Optional[float] = None,
|
||||
rope_theta: Optional[float] = None,
|
||||
rope_scaling: Optional[Dict[str, Any]] = None,
|
||||
bos_token_id: Optional[int] = 2,
|
||||
eos_token_id: Optional[Any] = None,
|
||||
pad_token_id: Optional[int] = 9,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
tie_word_embeddings=tie_word_embeddings,
|
||||
bos_token_id=bos_token_id,
|
||||
eos_token_id=eos_token_id,
|
||||
pad_token_id=pad_token_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.vocab_size = vocab_size
|
||||
self.hidden_size = hidden_size
|
||||
self.intermediate_size = intermediate_size
|
||||
self.num_hidden_layers = num_hidden_layers
|
||||
self.num_key_value_heads = num_key_value_heads
|
||||
self.head_dim = head_dim
|
||||
self.hidden_act = hidden_act
|
||||
self.max_position_embeddings = max_position_embeddings
|
||||
self.initializer_range = initializer_range
|
||||
self.rms_norm_eps = rms_norm_eps
|
||||
self.use_cache = use_cache
|
||||
self.attention_bias = attention_bias
|
||||
self.attention_dropout = attention_dropout
|
||||
self.sliding_window = sliding_window
|
||||
|
||||
self.num_experts = num_experts
|
||||
self.num_experts_per_tok = num_experts_per_tok
|
||||
self.moe_intermediate_size = moe_intermediate_size
|
||||
self.shared_expert_intermediate_size = shared_expert_intermediate_size
|
||||
self.moe_routed_scaling_factor = moe_routed_scaling_factor
|
||||
self.moe_router_logit_softcapping = moe_router_logit_softcapping
|
||||
self.moe_apply_router_weight_on_input = moe_apply_router_weight_on_input
|
||||
|
||||
# Synthesise per-layer schedules when the caller omits them so the model
|
||||
# file can index by layer_id without per-call guards.
|
||||
self.layer_types = (
|
||||
list(layer_types)
|
||||
if layer_types
|
||||
else [
|
||||
"full_attention" if i % 4 == 0 else "sliding_attention"
|
||||
for i in range(num_hidden_layers)
|
||||
]
|
||||
)
|
||||
self.mlp_layer_types = (
|
||||
list(mlp_layer_types)
|
||||
if mlp_layer_types
|
||||
else (["dense"] + ["sparse"] * (num_hidden_layers - 1))
|
||||
)
|
||||
self.num_attention_heads_per_layer = (
|
||||
list(num_attention_heads_per_layer)
|
||||
if (num_attention_heads_per_layer)
|
||||
else [num_attention_heads] * num_hidden_layers
|
||||
)
|
||||
|
||||
# SGLang's hybrid-SWA core reads `swa_*` KV/head_dim from hf_text_config.
|
||||
# Per-layer Q-head count is read directly from num_attention_heads_per_layer.
|
||||
# Pure-SWA models would have no full_attention layer, but the synthesized
|
||||
# default above always plants one at index 0; let .index() raise if a
|
||||
# caller passes an all-sliding layer_types — silent fallback would wire
|
||||
# the SWA head count into a "full" attribute and corrupt downstream sizes.
|
||||
full_idx = self.layer_types.index("full_attention")
|
||||
self.num_attention_heads = self.num_attention_heads_per_layer[full_idx]
|
||||
self.swa_num_key_value_heads = num_key_value_heads
|
||||
self.swa_head_dim = head_dim
|
||||
self.swa_v_head_dim = head_dim
|
||||
|
||||
# Released checkpoint nests rope_parameters under layer-type keys.
|
||||
rp = rope_parameters if isinstance(rope_parameters, dict) else {}
|
||||
full_rp = rp.get("full_attention") or {}
|
||||
swa_rp = rp.get("sliding_attention") or {}
|
||||
|
||||
# transformers v5 aliases `rope_scaling` ↔ `rope_parameters` on
|
||||
# PretrainedConfig — writing one clobbers the other. Keep the nested
|
||||
# form on those two slots (so HF's reference modeling code can index
|
||||
# rope_parameters[layer_type] when invoked via trust_remote_code) and
|
||||
# publish our SGLang-shaped flat rope dicts under different names.
|
||||
self.rope_parameters = rope_parameters
|
||||
|
||||
self.rope_theta = _first_not_none(
|
||||
full_rp.get("rope_theta"), rope_theta, 10000.0
|
||||
)
|
||||
self.partial_rotary_factor = _first_not_none(
|
||||
full_rp.get("partial_rotary_factor"), partial_rotary_factor, 1.0
|
||||
)
|
||||
self.full_rope_scaling = _first_not_none(
|
||||
_to_sglang_rope_scaling(full_rp), rope_scaling
|
||||
)
|
||||
|
||||
self.swa_rope_theta = _first_not_none(swa_rp.get("rope_theta"), self.rope_theta)
|
||||
self.swa_partial_rotary_factor = _first_not_none(
|
||||
swa_rp.get("partial_rotary_factor"), self.partial_rotary_factor
|
||||
)
|
||||
self.swa_rope_scaling = _to_sglang_rope_scaling(swa_rp)
|
||||
|
||||
# DeepSeek-style aliases consumed by cross-cutting infra outside this
|
||||
# model file: `lora/mem_pool.py` and `lora/utils.py` read
|
||||
# `n_routed_experts` / `n_shared_experts` / `first_k_dense_replace`,
|
||||
# `elastic_ep/expert_backup_*` reads `n_routed_experts`. The
|
||||
# hardcoded `n_shared_experts=1` and `norm_topk_prob=True` reflect
|
||||
# Laguna's fixed architecture (one shared expert, sigmoid-renormalized
|
||||
# top-k routing).
|
||||
self.n_routed_experts = num_experts
|
||||
self.n_shared_experts = 1
|
||||
self.routed_scaling_factor = moe_routed_scaling_factor
|
||||
self.norm_topk_prob = True
|
||||
self.first_k_dense_replace = (
|
||||
self.mlp_layer_types.index("sparse")
|
||||
if "sparse" in self.mlp_layer_types
|
||||
else num_hidden_layers
|
||||
)
|
||||
@@ -1660,6 +1660,7 @@ def is_hybrid_swa_model(model_architectures: List[str]):
|
||||
"Step3p5MTP",
|
||||
"Gemma4ForCausalLM",
|
||||
"Gemma4ForConditionalGeneration",
|
||||
"LagunaForCausalLM",
|
||||
}
|
||||
return any(arch in hybrid_swa_archs for arch in model_architectures)
|
||||
|
||||
@@ -1721,6 +1722,14 @@ def get_hybrid_layer_ids(
|
||||
full_attention_layer_ids = [
|
||||
i for i, x in enumerate(layer_types) if x == "full_attention"
|
||||
]
|
||||
elif "LagunaForCausalLM" in model_architectures:
|
||||
layer_types = getattr(hf_text_config, "layer_types", [])
|
||||
swa_attention_layer_ids = [
|
||||
i for i, x in enumerate(layer_types) if x == "sliding_attention"
|
||||
]
|
||||
full_attention_layer_ids = [
|
||||
i for i, x in enumerate(layer_types) if x == "full_attention"
|
||||
]
|
||||
else:
|
||||
swa_attention_layer_ids = None
|
||||
full_attention_layer_ids = None
|
||||
|
||||
@@ -30,6 +30,7 @@ from sglang.srt.function_call.llama32_detector import Llama32Detector
|
||||
from sglang.srt.function_call.mimo_detector import MiMoDetector
|
||||
from sglang.srt.function_call.minimax_m2 import MinimaxM2Detector
|
||||
from sglang.srt.function_call.mistral_detector import MistralDetector
|
||||
from sglang.srt.function_call.poolside_v1_detector import PoolsideV1Detector
|
||||
from sglang.srt.function_call.pythonic_detector import PythonicDetector
|
||||
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
|
||||
from sglang.srt.function_call.qwen25_detector import Qwen25Detector
|
||||
@@ -66,6 +67,7 @@ class FunctionCallParser:
|
||||
"llama3": Llama32Detector,
|
||||
"mimo": MiMoDetector,
|
||||
"mistral": MistralDetector,
|
||||
"poolside_v1": PoolsideV1Detector,
|
||||
"pythonic": PythonicDetector,
|
||||
"qwen": Qwen25Detector,
|
||||
"qwen25": Qwen25Detector,
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from enum import Enum, auto
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import Tool
|
||||
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
||||
from sglang.srt.function_call.core_types import (
|
||||
StreamingParseResult,
|
||||
StructureInfo,
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
|
||||
|
||||
class _ParseState(Enum):
|
||||
"""5 FSM states for the streaming parser.
|
||||
|
||||
Entry guard: READING_VALUE is reachable only from READING_KEY, so the
|
||||
"stray <arg_value> before <tool_call>" bug class is structurally
|
||||
impossible.
|
||||
|
||||
Exit guard: both READING_KEY and READING_VALUE recover on `</tool_call>`
|
||||
by closing the active call (orphan key dropped if any). READING_VALUE
|
||||
additionally recovers on `<arg_key>` by replacing the orphan pending key
|
||||
with the new one. Both guards match the (regex-tightened) non-streaming
|
||||
path. Without them, malformed inputs would leave the FSM stuck in
|
||||
READING_VALUE and mis-attribute subsequent values to stale state.
|
||||
"""
|
||||
|
||||
OUTSIDE = auto()
|
||||
READING_NAME = auto()
|
||||
READING_KEY = auto()
|
||||
READING_VALUE = auto()
|
||||
DRAINING = auto()
|
||||
|
||||
|
||||
class PoolsideV1Detector(BaseFormatDetector):
|
||||
"""
|
||||
Detector for poolside Laguna-XS.2 (poolside_v1 series) tool-call wire format.
|
||||
|
||||
Wire format:
|
||||
<tool_call>{name}\\n
|
||||
<arg_key>{key}</arg_key>\\n
|
||||
<arg_value>{val}</arg_value>\\n
|
||||
...
|
||||
</tool_call>
|
||||
|
||||
String values are emitted as raw text; non-strings are JSON-encoded by
|
||||
the chat template. The parser does schema-based type coercion to round-trip
|
||||
them: schema type `string` keeps the raw value; other types attempt
|
||||
`json.loads` and fall back to `ast.literal_eval`, then to the raw string.
|
||||
"""
|
||||
|
||||
# Wire-format tag tokens — constants, not per-instance.
|
||||
tool_call_start_token = "<tool_call>"
|
||||
tool_call_end_token = "</tool_call>"
|
||||
arg_key_start = "<arg_key>"
|
||||
arg_key_end = "</arg_key>"
|
||||
arg_value_start = "<arg_value>"
|
||||
arg_value_end = "</arg_value>"
|
||||
|
||||
tool_call_regex = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
|
||||
# Key uses [^<]*? to prevent the non-greedy `.*?` from backtracking
|
||||
# across an `</arg_key>` boundary on malformed inputs like
|
||||
# `<arg_key>K1</arg_key><arg_key>K2</arg_key><arg_value>V</arg_value>`
|
||||
# — without the `[^<]` constraint, the regex matches the entire orphan
|
||||
# span as a single key (`K1</arg_key><arg_key>K2`). Param names never
|
||||
# contain `<` in practice, so this is safe. The value side keeps `.*?`
|
||||
# because legitimate values can contain `<` (HTML, paths, etc.); the
|
||||
# `</arg_value>` boundary is anchored enough.
|
||||
arg_pair_regex = re.compile(
|
||||
r"<arg_key>([^<]*?)</arg_key>\s*<arg_value>(.*?)</arg_value>",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
_partial_tag_prefixes = (
|
||||
tool_call_start_token,
|
||||
tool_call_end_token,
|
||||
arg_key_start,
|
||||
arg_key_end,
|
||||
arg_value_start,
|
||||
arg_value_end,
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.parsed_pos: int = 0
|
||||
self._state: _ParseState = _ParseState.OUTSIDE
|
||||
self.current_func_name: Optional[str] = None
|
||||
self.current_pending_key: Optional[str] = None
|
||||
self.json_started: bool = False
|
||||
|
||||
# ---------- Helpers ----------
|
||||
|
||||
def _reset_call_state(self) -> None:
|
||||
"""Reset per-call FSM scratch fields. Called when entering a new
|
||||
<tool_call> and on </tool_call> close."""
|
||||
self.current_func_name = None
|
||||
self.current_pending_key = None
|
||||
self.json_started = False
|
||||
|
||||
def _consume_arg_key(self, slice_: str) -> bool:
|
||||
"""Consume `<arg_key>K</arg_key>`, set `current_pending_key` to K.
|
||||
Returns True if consumed, False if `</arg_key>` hasn't arrived yet
|
||||
(caller should break to wait for more bytes). Shared by READING_KEY
|
||||
(well-formed: transitions to READING_VALUE) and READING_VALUE
|
||||
(orphan-key-replace: stays in READING_VALUE)."""
|
||||
end = slice_.find(self.arg_key_end)
|
||||
if end == -1:
|
||||
return False
|
||||
self.current_pending_key = slice_[len(self.arg_key_start) : end].strip()
|
||||
self.parsed_pos += end + len(self.arg_key_end)
|
||||
return True
|
||||
|
||||
def _close_current_call(self, calls: List[ToolCallItem]) -> None:
|
||||
"""Emit the closing `}` (or `{}` for zero-arg) for the active call,
|
||||
advance past `</tool_call>`, return to OUTSIDE, and reset per-call
|
||||
state. Called from both READING_KEY (the well-formed close path) and
|
||||
READING_VALUE (malformed close: `<arg_key>...</arg_key></tool_call>`
|
||||
with no value — orphan key is discarded, matching the regex
|
||||
non-streaming path which drops unmatched <arg_key>...</arg_key>
|
||||
pairs)."""
|
||||
fragment = "}" if self.json_started else "{}"
|
||||
calls.append(
|
||||
ToolCallItem(
|
||||
tool_index=self.current_tool_id,
|
||||
parameters=fragment,
|
||||
)
|
||||
)
|
||||
self.streamed_args_for_tool[self.current_tool_id] += fragment
|
||||
self.parsed_pos += len(self.tool_call_end_token)
|
||||
self._state = _ParseState.OUTSIDE
|
||||
self._reset_call_state()
|
||||
|
||||
def has_tool_call(self, text: str) -> bool:
|
||||
return self.tool_call_start_token in text
|
||||
|
||||
@staticmethod
|
||||
def _get_param_schema(
|
||||
func_name: Optional[str], tools: Optional[List[Tool]]
|
||||
) -> dict:
|
||||
if not tools or not func_name:
|
||||
return {}
|
||||
for tool in tools:
|
||||
try:
|
||||
if (
|
||||
tool.type == "function"
|
||||
and tool.function.name == func_name
|
||||
and isinstance(tool.function.parameters, dict)
|
||||
):
|
||||
return tool.function.parameters.get("properties", {})
|
||||
except AttributeError:
|
||||
continue
|
||||
return {}
|
||||
|
||||
_STRING_TYPES = frozenset({"string", "str", "text", "enum"})
|
||||
|
||||
@staticmethod
|
||||
def _convert_param_value(raw: str, schema: dict, key: str) -> Any:
|
||||
"""Coerce a raw arg_value string per schema; fall back to raw on failure.
|
||||
|
||||
Decoder selection by schema type:
|
||||
- string-like types → identity (raw text)
|
||||
- no schema entry → json.loads only (conservative; don't
|
||||
ast-eval untyped values)
|
||||
- everything else (int,
|
||||
number, bool, object, …) → json.loads, then ast.literal_eval
|
||||
|
||||
Each decoder result is round-tripped through `json.dumps` before being
|
||||
returned; non-JSON-serializable values (sets / complex / bytes from
|
||||
`ast.literal_eval`) are rejected to the next decoder, ultimately
|
||||
falling through to the raw-string fallback rather than crashing the
|
||||
streaming JSON emission downstream.
|
||||
"""
|
||||
spec = schema.get(key) if isinstance(schema, dict) else None
|
||||
param_type = str(spec.get("type", "")).lower() if isinstance(spec, dict) else ""
|
||||
if param_type in PoolsideV1Detector._STRING_TYPES:
|
||||
return raw
|
||||
|
||||
decoders = (json.loads,) if not param_type else (json.loads, ast.literal_eval)
|
||||
for decoder in decoders:
|
||||
try:
|
||||
result = decoder(raw)
|
||||
# ast.literal_eval can return non-JSON-serializable values
|
||||
# (sets, complex numbers); reject so json.dumps downstream
|
||||
# doesn't choke.
|
||||
json.dumps(result)
|
||||
return result
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
continue
|
||||
return raw
|
||||
|
||||
def _find_name_boundary(self, text: str) -> int:
|
||||
"""Earliest of `\\n`, `<arg_key>`, `</tool_call>`. -1 if none."""
|
||||
hits = (
|
||||
text.find("\n"),
|
||||
text.find(self.arg_key_start),
|
||||
text.find(self.tool_call_end_token),
|
||||
)
|
||||
positive = [h for h in hits if h != -1]
|
||||
return min(positive) if positive else -1
|
||||
|
||||
def _is_partial_tag(self, slice_: str) -> bool:
|
||||
"""True if slice_ is a strict prefix of any known tag — i.e. more
|
||||
bytes might complete it into a real tag."""
|
||||
return any(
|
||||
tag.startswith(slice_) and tag != slice_
|
||||
for tag in self._partial_tag_prefixes
|
||||
)
|
||||
|
||||
# ---------- Non-streaming ----------
|
||||
|
||||
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
|
||||
if self.tool_call_start_token not in text:
|
||||
return StreamingParseResult(normal_text=text)
|
||||
|
||||
tool_indices = self._get_tool_indices(tools)
|
||||
first_idx = text.find(self.tool_call_start_token)
|
||||
normal_text = text[:first_idx] if first_idx > 0 else ""
|
||||
|
||||
calls: List[ToolCallItem] = []
|
||||
for body in self.tool_call_regex.findall(text):
|
||||
# _find_name_boundary searches for `\n` / `<arg_key>` /
|
||||
# `</tool_call>`, but the regex already stripped `</tool_call>`,
|
||||
# so a no-arg call without a trailing newline
|
||||
# (`<tool_call>now</tool_call>`) gives boundary == -1. Treat
|
||||
# that case as "name == entire body".
|
||||
boundary = self._find_name_boundary(body)
|
||||
name = (body if boundary == -1 else body[:boundary]).strip()
|
||||
if not name or name not in tool_indices:
|
||||
continue
|
||||
|
||||
schema = self._get_param_schema(name, tools)
|
||||
args: dict = {}
|
||||
for raw_key, raw_val in self.arg_pair_regex.findall(body):
|
||||
key = raw_key.strip()
|
||||
# Strip at most one wrapping `\n` on each side (template adds
|
||||
# them around the value); preserve newlines that are part of
|
||||
# the value itself.
|
||||
val = raw_val.removeprefix("\n").removesuffix("\n")
|
||||
args[key] = self._convert_param_value(val, schema, key)
|
||||
|
||||
calls.append(
|
||||
ToolCallItem(
|
||||
tool_index=tool_indices[name],
|
||||
name=name,
|
||||
parameters=json.dumps(args, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
|
||||
return StreamingParseResult(normal_text=normal_text, calls=calls)
|
||||
|
||||
# ---------- Streaming ----------
|
||||
|
||||
def parse_streaming_increment(
|
||||
self, new_text: str, tools: List[Tool]
|
||||
) -> StreamingParseResult:
|
||||
self._buffer += new_text
|
||||
if not self._buffer:
|
||||
return StreamingParseResult()
|
||||
|
||||
tool_indices = self._get_tool_indices(tools)
|
||||
calls: List[ToolCallItem] = []
|
||||
normal_text_chunks: List[str] = []
|
||||
|
||||
# No try/except: the FSM's invariants make the prior masked-IndexError
|
||||
# class unreachable, and TypeError from json.dumps is prevented at the
|
||||
# source (_convert_param_value round-trips its decoder output). If a
|
||||
# real bug surfaces, let it surface.
|
||||
while True:
|
||||
slice_ = self._buffer[self.parsed_pos :]
|
||||
if not slice_:
|
||||
break
|
||||
state = self._state
|
||||
|
||||
if state is _ParseState.OUTSIDE:
|
||||
if slice_.startswith(self.tool_call_start_token):
|
||||
self.parsed_pos += len(self.tool_call_start_token)
|
||||
self._state = _ParseState.READING_NAME
|
||||
self._reset_call_state()
|
||||
continue
|
||||
if slice_.startswith("<"):
|
||||
if self._is_partial_tag(slice_):
|
||||
break # could be a partial <tool_call>
|
||||
normal_text_chunks.append("<")
|
||||
self.parsed_pos += 1
|
||||
continue
|
||||
next_lt = slice_.find("<")
|
||||
segment = slice_ if next_lt == -1 else slice_[:next_lt]
|
||||
normal_text_chunks.append(segment)
|
||||
self.parsed_pos += len(segment)
|
||||
continue
|
||||
|
||||
if state is _ParseState.READING_NAME:
|
||||
boundary = self._find_name_boundary(slice_)
|
||||
if boundary == -1:
|
||||
break # name still incoming
|
||||
name = slice_[:boundary].strip()
|
||||
# Consume the name and a single delimiting newline (if
|
||||
# present). The other boundary types (<arg_key>,
|
||||
# </tool_call>) are left for the next state. boundary may
|
||||
# be 0 for a malformed `<tool_call><arg_key>...` (no
|
||||
# name); the state transition below is the loop-progress
|
||||
# guarantee.
|
||||
consume = boundary
|
||||
if boundary < len(slice_) and slice_[boundary : boundary + 1] == "\n":
|
||||
consume += 1
|
||||
self.parsed_pos += consume
|
||||
|
||||
if name and name in tool_indices:
|
||||
self.current_tool_id += 1
|
||||
while len(self.streamed_args_for_tool) <= self.current_tool_id:
|
||||
self.streamed_args_for_tool.append("")
|
||||
self.current_func_name = name
|
||||
# Per-response sequential index — OpenAI clients group
|
||||
# chunks by tool_index, so the name event and later
|
||||
# parameter fragments must share this value.
|
||||
calls.append(
|
||||
ToolCallItem(
|
||||
tool_index=self.current_tool_id,
|
||||
name=name,
|
||||
parameters="",
|
||||
)
|
||||
)
|
||||
self._state = _ParseState.READING_KEY
|
||||
else:
|
||||
# Unknown / empty name — drain to </tool_call> with no
|
||||
# client-visible emission.
|
||||
self._state = _ParseState.DRAINING
|
||||
continue
|
||||
|
||||
if state is _ParseState.READING_KEY:
|
||||
if slice_.startswith(self.tool_call_end_token):
|
||||
self._close_current_call(calls)
|
||||
continue
|
||||
if slice_.startswith(self.arg_key_start):
|
||||
if not self._consume_arg_key(slice_):
|
||||
break # incomplete <arg_key>
|
||||
self._state = _ParseState.READING_VALUE
|
||||
continue
|
||||
if slice_.startswith("<"):
|
||||
if self._is_partial_tag(slice_):
|
||||
break
|
||||
# Bare '<' that's not any known tag — discard silently
|
||||
# (inside a tool call, this is not normal_text).
|
||||
self.parsed_pos += 1
|
||||
continue
|
||||
# Inter-tag whitespace / newline — discard.
|
||||
next_lt = slice_.find("<")
|
||||
self.parsed_pos += len(slice_) if next_lt == -1 else next_lt
|
||||
continue
|
||||
|
||||
if state is _ParseState.READING_VALUE:
|
||||
# Recover from a malformed `<arg_key>K</arg_key></tool_call>`
|
||||
# (no <arg_value>) by closing the call here. Without this
|
||||
# branch the FSM would stay stuck in READING_VALUE and
|
||||
# mis-attribute the next call's <arg_value> to the orphan
|
||||
# `current_pending_key`, silently swallowing the next call's
|
||||
# name. Matches the regex non-streaming path, which drops
|
||||
# unmatched <arg_key>...</arg_key> pairs.
|
||||
if slice_.startswith(self.tool_call_end_token):
|
||||
self._close_current_call(calls)
|
||||
continue
|
||||
# Recover from a malformed `<arg_key>K1</arg_key><arg_key>K2`
|
||||
# (no value for K1, model went straight to a new key) by
|
||||
# replacing the orphan pending_key with the new one. Stays
|
||||
# in READING_VALUE so the next <arg_value> binds to K2.
|
||||
# Without this branch the FSM treats the second <arg_key>
|
||||
# as bare-`<` garbage and the next <arg_value> binds to
|
||||
# the stale K1 — wrong-argument corruption.
|
||||
if slice_.startswith(self.arg_key_start):
|
||||
if not self._consume_arg_key(slice_):
|
||||
break # incomplete <arg_key>
|
||||
continue # stay in READING_VALUE: orphan replaced
|
||||
if slice_.startswith(self.arg_value_start):
|
||||
end = slice_.find(self.arg_value_end)
|
||||
if end == -1:
|
||||
break # incomplete <arg_value> — no partial emission
|
||||
raw = (
|
||||
slice_[len(self.arg_value_start) : end]
|
||||
.removeprefix("\n")
|
||||
.removesuffix("\n")
|
||||
)
|
||||
# READING_VALUE is reachable only via READING_KEY
|
||||
# consuming an <arg_key>...</arg_key>, so
|
||||
# current_pending_key is set by construction.
|
||||
schema = self._get_param_schema(self.current_func_name, tools)
|
||||
converted = self._convert_param_value(
|
||||
raw, schema, self.current_pending_key
|
||||
)
|
||||
kv = (
|
||||
f"{json.dumps(self.current_pending_key)}: "
|
||||
f"{json.dumps(converted, ensure_ascii=False)}"
|
||||
)
|
||||
fragment = "{" + kv if not self.json_started else ", " + kv
|
||||
self.json_started = True
|
||||
calls.append(
|
||||
ToolCallItem(
|
||||
tool_index=self.current_tool_id,
|
||||
parameters=fragment,
|
||||
)
|
||||
)
|
||||
self.streamed_args_for_tool[self.current_tool_id] += fragment
|
||||
self.current_pending_key = None
|
||||
self.parsed_pos += end + len(self.arg_value_end)
|
||||
self._state = _ParseState.READING_KEY
|
||||
continue
|
||||
if slice_.startswith("<"):
|
||||
if self._is_partial_tag(slice_):
|
||||
break
|
||||
self.parsed_pos += 1
|
||||
continue
|
||||
next_lt = slice_.find("<")
|
||||
self.parsed_pos += len(slice_) if next_lt == -1 else next_lt
|
||||
continue
|
||||
|
||||
if state is _ParseState.DRAINING:
|
||||
end_idx = slice_.find(self.tool_call_end_token)
|
||||
if end_idx != -1:
|
||||
self.parsed_pos += end_idx + len(self.tool_call_end_token)
|
||||
self._state = _ParseState.OUTSIDE
|
||||
continue
|
||||
# Hold back trailing bytes that could be a prefix of
|
||||
# </tool_call>; the next chunk extends the tail.
|
||||
holdback = self._ends_with_partial_token(
|
||||
slice_, self.tool_call_end_token
|
||||
)
|
||||
self.parsed_pos += len(slice_) - holdback
|
||||
break
|
||||
|
||||
if self.parsed_pos > 0:
|
||||
self._buffer = self._buffer[self.parsed_pos :]
|
||||
self.parsed_pos = 0
|
||||
|
||||
return StreamingParseResult(
|
||||
calls=calls,
|
||||
normal_text="".join(normal_text_chunks),
|
||||
)
|
||||
|
||||
# ---------- Constrained generation ----------
|
||||
|
||||
def supports_structural_tag(self) -> bool:
|
||||
return False
|
||||
|
||||
def structure_info(self) -> _GetInfoFunc:
|
||||
return lambda name: StructureInfo(
|
||||
begin=f"<tool_call>{name}\n",
|
||||
end="</tool_call>",
|
||||
trigger="<tool_call>",
|
||||
)
|
||||
@@ -0,0 +1,787 @@
|
||||
# Copyright 2023-2026 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
|
||||
"""Inference-only Laguna (poolside/Laguna-XS.2) model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch import nn
|
||||
|
||||
from sglang.srt.configs.laguna import LagunaConfig
|
||||
from sglang.srt.distributed import (
|
||||
get_pp_group,
|
||||
get_tensor_model_parallel_world_size,
|
||||
tensor_model_parallel_all_reduce,
|
||||
)
|
||||
from sglang.srt.layers.activation import SiluAndMul
|
||||
from sglang.srt.layers.communicator import (
|
||||
LayerCommunicator,
|
||||
LayerScatterModes,
|
||||
)
|
||||
from sglang.srt.layers.dp_attention import (
|
||||
get_attention_tp_rank,
|
||||
get_attention_tp_size,
|
||||
is_dp_attention_enabled,
|
||||
)
|
||||
from sglang.srt.layers.layernorm import RMSNorm
|
||||
from sglang.srt.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
MergedColumnParallelLinear,
|
||||
QKVParallelLinear,
|
||||
RowParallelLinear,
|
||||
)
|
||||
from sglang.srt.layers.logits_processor import LogitsProcessor
|
||||
from sglang.srt.layers.moe import should_skip_post_experts_all_reduce
|
||||
from sglang.srt.layers.moe.ep_moe.layer import get_moe_impl_class
|
||||
from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE
|
||||
from sglang.srt.layers.moe.topk import TopK
|
||||
from sglang.srt.layers.quantization.base_config import QuantizationConfig
|
||||
from sglang.srt.layers.radix_attention import RadixAttention
|
||||
from sglang.srt.layers.rotary_embedding import get_rope
|
||||
from sglang.srt.layers.utils import PPMissingLayer, get_layer_id
|
||||
from sglang.srt.layers.vocab_parallel_embedding import (
|
||||
ParallelLMHead,
|
||||
VocabParallelEmbedding,
|
||||
)
|
||||
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors
|
||||
from sglang.srt.model_loader.weight_utils import default_weight_loader
|
||||
from sglang.srt.models.utils import apply_qk_norm
|
||||
from sglang.srt.server_args import get_global_server_args
|
||||
from sglang.srt.utils import LazyValue, add_prefix, make_layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LagunaMLP(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
intermediate_size: int,
|
||||
hidden_act: str,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
reduce_results: bool = True,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if hidden_act != "silu":
|
||||
raise ValueError(
|
||||
f"Unsupported activation: {hidden_act}. Only silu is supported."
|
||||
)
|
||||
self.gate_up_proj = MergedColumnParallelLinear(
|
||||
hidden_size,
|
||||
[intermediate_size] * 2,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=add_prefix("gate_up_proj", prefix),
|
||||
)
|
||||
self.down_proj = RowParallelLinear(
|
||||
intermediate_size,
|
||||
hidden_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
reduce_results=reduce_results,
|
||||
prefix=add_prefix("down_proj", prefix),
|
||||
)
|
||||
self.act_fn = SiluAndMul()
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
forward_batch: Optional[ForwardBatch] = None,
|
||||
should_allreduce_fusion: bool = False,
|
||||
use_reduce_scatter: bool = False,
|
||||
) -> torch.Tensor:
|
||||
gate_up, _ = self.gate_up_proj(x)
|
||||
x = self.act_fn(gate_up)
|
||||
# Skip the in-block reduce when LayerCommunicator will fuse it or when
|
||||
# the next layer expects reduce-scatter — otherwise we'd double-reduce.
|
||||
x, _ = self.down_proj(
|
||||
x,
|
||||
skip_all_reduce=should_allreduce_fusion or use_reduce_scatter,
|
||||
)
|
||||
return x
|
||||
|
||||
|
||||
class LagunaMoEGate(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: LagunaConfig,
|
||||
prefix: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(
|
||||
torch.empty(config.num_experts, config.hidden_size, dtype=torch.float32)
|
||||
)
|
||||
# Released checkpoint stores this under `mlp.experts.e_score_correction_bias`
|
||||
# (load_weights remaps it) but every value is 0.0; zero-init keeps us
|
||||
# correct if a future checkpoint omits the tensor entirely.
|
||||
self.e_score_correction_bias = nn.Parameter(
|
||||
torch.zeros(config.num_experts, dtype=torch.float32),
|
||||
requires_grad=False,
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return F.linear(hidden_states.to(torch.float32), self.weight, None)
|
||||
|
||||
|
||||
class LagunaMoE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: LagunaConfig,
|
||||
layer_id: int,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
prefix: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
self.tp_size = get_tensor_model_parallel_world_size()
|
||||
self.routed_scaling_factor = config.moe_routed_scaling_factor
|
||||
self.router_logit_softcapping = getattr(
|
||||
config, "moe_router_logit_softcapping", 0.0
|
||||
)
|
||||
|
||||
if self.tp_size > config.num_experts:
|
||||
raise ValueError(
|
||||
f"TP size {self.tp_size} > num_experts {config.num_experts}."
|
||||
)
|
||||
|
||||
self.gate = LagunaMoEGate(config, prefix=add_prefix("gate", prefix))
|
||||
|
||||
self.experts = get_moe_impl_class(quant_config)(
|
||||
num_experts=config.num_experts
|
||||
+ get_global_server_args().ep_num_redundant_experts,
|
||||
top_k=config.num_experts_per_tok,
|
||||
layer_id=layer_id,
|
||||
hidden_size=config.hidden_size,
|
||||
intermediate_size=config.moe_intermediate_size,
|
||||
quant_config=quant_config,
|
||||
reduce_results=False,
|
||||
apply_router_weight_on_input=bool(config.moe_apply_router_weight_on_input),
|
||||
prefix=add_prefix("experts", prefix),
|
||||
)
|
||||
|
||||
self.topk = TopK(
|
||||
top_k=config.num_experts_per_tok,
|
||||
layer_id=layer_id,
|
||||
renormalize=True,
|
||||
use_grouped_topk=False,
|
||||
scoring_func="sigmoid",
|
||||
correction_bias=self.gate.e_score_correction_bias,
|
||||
)
|
||||
|
||||
# HF safetensors key is singular `shared_expert.…`; mirror so the
|
||||
# default loader picks it up without remapping.
|
||||
self.shared_expert = LagunaMLP(
|
||||
hidden_size=config.hidden_size,
|
||||
intermediate_size=config.shared_expert_intermediate_size,
|
||||
hidden_act=config.hidden_act,
|
||||
quant_config=quant_config,
|
||||
reduce_results=False,
|
||||
prefix=add_prefix("shared_expert", prefix),
|
||||
)
|
||||
|
||||
def get_moe_weights(self):
|
||||
return [x.data for x in self.experts.parameters()]
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
forward_batch: Optional[ForwardBatch] = None,
|
||||
should_allreduce_fusion: bool = False,
|
||||
use_reduce_scatter: bool = False,
|
||||
) -> torch.Tensor:
|
||||
if hidden_states.shape[0] == 0:
|
||||
return hidden_states
|
||||
|
||||
shared_out = self.shared_expert(hidden_states)
|
||||
|
||||
router_logits = self.gate(hidden_states)
|
||||
if self.router_logit_softcapping > 0.0:
|
||||
cap = self.router_logit_softcapping
|
||||
router_logits = torch.tanh(router_logits / cap) * cap
|
||||
topk_output = self.topk(hidden_states, router_logits)
|
||||
routed_out = self.experts(hidden_states, topk_output)
|
||||
|
||||
# Non-grouped TopK doesn't honor apply_routed_scaling_factor_on_output,
|
||||
# so scale routed manually before adding the unscaled shared expert.
|
||||
if self.routed_scaling_factor != 1.0:
|
||||
routed_out = routed_out * self.routed_scaling_factor
|
||||
final = routed_out + shared_out
|
||||
|
||||
if self.tp_size > 1 and not should_skip_post_experts_all_reduce(
|
||||
is_tp_path=True,
|
||||
use_reduce_scatter=use_reduce_scatter,
|
||||
should_allreduce_fusion=should_allreduce_fusion,
|
||||
):
|
||||
final = tensor_model_parallel_all_reduce(final)
|
||||
return final
|
||||
|
||||
|
||||
class LagunaAttention(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
num_heads: int,
|
||||
num_kv_heads: int,
|
||||
head_dim: int,
|
||||
layer_id: int,
|
||||
rms_norm_eps: float,
|
||||
rope_theta: float,
|
||||
rope_scaling: Optional[Dict[str, Any]],
|
||||
partial_rotary_factor: float,
|
||||
max_position_embeddings: int,
|
||||
attention_bias: bool,
|
||||
sliding_window_size: int,
|
||||
layer_type: str,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = hidden_size
|
||||
self.head_dim = head_dim
|
||||
self.layer_id = layer_id
|
||||
|
||||
attn_tp_rank = get_attention_tp_rank()
|
||||
attn_tp_size = get_attention_tp_size()
|
||||
|
||||
self.total_num_heads = num_heads
|
||||
assert self.total_num_heads % attn_tp_size == 0
|
||||
self.num_heads = self.total_num_heads // attn_tp_size
|
||||
self.total_num_kv_heads = num_kv_heads
|
||||
if self.total_num_kv_heads >= attn_tp_size:
|
||||
assert self.total_num_kv_heads % attn_tp_size == 0
|
||||
else:
|
||||
assert attn_tp_size % self.total_num_kv_heads == 0
|
||||
self.num_kv_heads = max(1, self.total_num_kv_heads // attn_tp_size)
|
||||
self.q_size = self.num_heads * self.head_dim
|
||||
self.kv_size = self.num_kv_heads * self.head_dim
|
||||
self.scaling = self.head_dim**-0.5
|
||||
|
||||
self.qkv_proj = QKVParallelLinear(
|
||||
hidden_size,
|
||||
self.head_dim,
|
||||
self.total_num_heads,
|
||||
self.total_num_kv_heads,
|
||||
bias=attention_bias,
|
||||
quant_config=quant_config,
|
||||
tp_rank=attn_tp_rank,
|
||||
tp_size=attn_tp_size,
|
||||
prefix=add_prefix("qkv_proj", prefix),
|
||||
)
|
||||
self.o_proj = RowParallelLinear(
|
||||
self.total_num_heads * self.head_dim,
|
||||
hidden_size,
|
||||
bias=attention_bias,
|
||||
quant_config=quant_config,
|
||||
tp_rank=attn_tp_rank,
|
||||
tp_size=attn_tp_size,
|
||||
reduce_results=False,
|
||||
prefix=add_prefix("o_proj", prefix),
|
||||
)
|
||||
|
||||
# Per-head softplus gate (`gating=True` in HF). Shard like Q so the
|
||||
# local output dim matches `num_heads`.
|
||||
self.g_proj = ColumnParallelLinear(
|
||||
hidden_size,
|
||||
self.total_num_heads,
|
||||
bias=False,
|
||||
gather_output=False,
|
||||
quant_config=None,
|
||||
tp_rank=attn_tp_rank,
|
||||
tp_size=attn_tp_size,
|
||||
prefix=add_prefix("g_proj", prefix),
|
||||
)
|
||||
|
||||
self.q_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
|
||||
self.k_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
|
||||
|
||||
self.rotary_emb = get_rope(
|
||||
self.head_dim,
|
||||
rotary_dim=self.head_dim,
|
||||
max_position=max_position_embeddings,
|
||||
base=int(rope_theta),
|
||||
rope_scaling=rope_scaling,
|
||||
partial_rotary_factor=partial_rotary_factor,
|
||||
)
|
||||
|
||||
assert layer_type in {"sliding_attention", "full_attention"}
|
||||
use_sliding = layer_type == "sliding_attention"
|
||||
self.attn = RadixAttention(
|
||||
self.num_heads,
|
||||
self.head_dim,
|
||||
self.scaling,
|
||||
num_kv_heads=self.num_kv_heads,
|
||||
layer_id=layer_id,
|
||||
quant_config=quant_config,
|
||||
prefix=add_prefix("attn", prefix),
|
||||
sliding_window_size=sliding_window_size if use_sliding else -1,
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
positions: torch.Tensor,
|
||||
hidden_states: torch.Tensor,
|
||||
forward_batch: ForwardBatch,
|
||||
) -> torch.Tensor:
|
||||
if hidden_states.shape[0] == 0:
|
||||
return hidden_states
|
||||
|
||||
qkv, _ = self.qkv_proj(hidden_states)
|
||||
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
|
||||
|
||||
q, k = apply_qk_norm(
|
||||
q=q,
|
||||
k=k,
|
||||
q_norm=self.q_norm,
|
||||
k_norm=self.k_norm,
|
||||
head_dim=self.head_dim,
|
||||
)
|
||||
q, k = self.rotary_emb(positions, q, k)
|
||||
|
||||
attn_output = self.attn(q, k, v, forward_batch)
|
||||
|
||||
gate, _ = self.g_proj(hidden_states)
|
||||
gate = F.softplus(gate.float()).to(attn_output.dtype)
|
||||
attn_output = attn_output.view(-1, self.num_heads, self.head_dim)
|
||||
attn_output = attn_output * gate.view(-1, self.num_heads, 1)
|
||||
attn_output = attn_output.reshape(-1, self.num_heads * self.head_dim)
|
||||
|
||||
output, _ = self.o_proj(attn_output)
|
||||
return output
|
||||
|
||||
|
||||
class LagunaDecoderLayer(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: LagunaConfig,
|
||||
layer_id: int,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.layer_id = layer_id
|
||||
self.hidden_size = config.hidden_size
|
||||
|
||||
layer_types = config.layer_types
|
||||
layer_type = layer_types[layer_id]
|
||||
is_swa = layer_type == "sliding_attention"
|
||||
|
||||
layer_num_heads = config.num_attention_heads_per_layer[layer_id]
|
||||
|
||||
if is_swa:
|
||||
rope_theta = config.swa_rope_theta
|
||||
rope_scaling = config.swa_rope_scaling
|
||||
partial_rotary_factor = config.swa_partial_rotary_factor
|
||||
else:
|
||||
rope_theta = config.rope_theta
|
||||
rope_scaling = config.full_rope_scaling
|
||||
partial_rotary_factor = config.partial_rotary_factor
|
||||
|
||||
self.self_attn = LagunaAttention(
|
||||
hidden_size=self.hidden_size,
|
||||
num_heads=layer_num_heads,
|
||||
num_kv_heads=config.num_key_value_heads,
|
||||
head_dim=config.head_dim,
|
||||
layer_id=layer_id,
|
||||
rms_norm_eps=config.rms_norm_eps,
|
||||
rope_theta=rope_theta,
|
||||
rope_scaling=rope_scaling,
|
||||
partial_rotary_factor=partial_rotary_factor,
|
||||
max_position_embeddings=config.max_position_embeddings,
|
||||
attention_bias=config.attention_bias,
|
||||
# SGLang's window is exclusive; HF's `sliding_window` is inclusive.
|
||||
sliding_window_size=config.sliding_window - 1,
|
||||
layer_type=layer_type,
|
||||
quant_config=quant_config,
|
||||
prefix=add_prefix("self_attn", prefix),
|
||||
)
|
||||
|
||||
mlp_types = config.mlp_layer_types
|
||||
self.is_layer_sparse = mlp_types[layer_id] == "sparse"
|
||||
is_previous_layer_sparse = layer_id > 0 and mlp_types[layer_id - 1] == "sparse"
|
||||
is_next_layer_sparse = (
|
||||
layer_id + 1 < config.num_hidden_layers
|
||||
and mlp_types[layer_id + 1] == "sparse"
|
||||
)
|
||||
|
||||
if self.is_layer_sparse:
|
||||
self.mlp = LagunaMoE(
|
||||
config=config,
|
||||
layer_id=layer_id,
|
||||
quant_config=quant_config,
|
||||
prefix=add_prefix("mlp", prefix),
|
||||
)
|
||||
else:
|
||||
self.mlp = LagunaMLP(
|
||||
hidden_size=self.hidden_size,
|
||||
intermediate_size=config.intermediate_size,
|
||||
hidden_act=config.hidden_act,
|
||||
quant_config=quant_config,
|
||||
reduce_results=True,
|
||||
prefix=add_prefix("mlp", prefix),
|
||||
)
|
||||
|
||||
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.post_attention_layernorm = RMSNorm(
|
||||
config.hidden_size, eps=config.rms_norm_eps
|
||||
)
|
||||
|
||||
self.layer_scatter_modes = LayerScatterModes.init_new(
|
||||
layer_id=layer_id,
|
||||
num_layers=config.num_hidden_layers,
|
||||
is_layer_sparse=self.is_layer_sparse,
|
||||
is_previous_layer_sparse=is_previous_layer_sparse,
|
||||
is_next_layer_sparse=is_next_layer_sparse,
|
||||
)
|
||||
self.layer_communicator = LayerCommunicator(
|
||||
layer_scatter_modes=self.layer_scatter_modes,
|
||||
input_layernorm=self.input_layernorm,
|
||||
post_attention_layernorm=self.post_attention_layernorm,
|
||||
allow_reduce_scatter=True,
|
||||
is_last_layer=(layer_id == config.num_hidden_layers - 1),
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
positions: torch.Tensor,
|
||||
hidden_states: torch.Tensor,
|
||||
forward_batch: ForwardBatch,
|
||||
residual: Optional[torch.Tensor],
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
hidden_states, residual = self.layer_communicator.prepare_attn(
|
||||
hidden_states, residual, forward_batch
|
||||
)
|
||||
if hidden_states.shape[0] != 0:
|
||||
hidden_states = self.self_attn(
|
||||
positions=positions,
|
||||
hidden_states=hidden_states,
|
||||
forward_batch=forward_batch,
|
||||
)
|
||||
hidden_states, residual = self.layer_communicator.prepare_mlp(
|
||||
hidden_states, residual, forward_batch
|
||||
)
|
||||
|
||||
should_allreduce_fusion = (
|
||||
self.layer_communicator.should_fuse_mlp_allreduce_with_next_layer(
|
||||
forward_batch
|
||||
)
|
||||
)
|
||||
use_reduce_scatter = self.layer_communicator.should_use_reduce_scatter(
|
||||
forward_batch
|
||||
)
|
||||
|
||||
hidden_states = self.mlp(
|
||||
hidden_states,
|
||||
forward_batch=forward_batch,
|
||||
should_allreduce_fusion=should_allreduce_fusion,
|
||||
use_reduce_scatter=use_reduce_scatter,
|
||||
)
|
||||
|
||||
if should_allreduce_fusion:
|
||||
hidden_states._sglang_needs_allreduce_fusion = True
|
||||
else:
|
||||
hidden_states, residual = self.layer_communicator.postprocess_layer(
|
||||
hidden_states, residual, forward_batch
|
||||
)
|
||||
return hidden_states, residual
|
||||
|
||||
|
||||
class LagunaModel(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: LagunaConfig,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
prefix: str = "",
|
||||
decoder_layer_type: type = LagunaDecoderLayer,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.padding_idx = getattr(config, "pad_token_id", None)
|
||||
self.vocab_size = config.vocab_size
|
||||
self.pp_group = get_pp_group()
|
||||
|
||||
if self.pp_group.is_first_rank:
|
||||
self.embed_tokens = VocabParallelEmbedding(
|
||||
config.vocab_size,
|
||||
config.hidden_size,
|
||||
use_attn_tp_group=is_dp_attention_enabled(),
|
||||
prefix=add_prefix("embed_tokens", prefix),
|
||||
)
|
||||
else:
|
||||
self.embed_tokens = PPMissingLayer()
|
||||
|
||||
decoder_layer_type = decoder_layer_type or LagunaDecoderLayer
|
||||
self.layers, self.start_layer, self.end_layer = make_layers(
|
||||
config.num_hidden_layers,
|
||||
lambda idx, prefix: decoder_layer_type(
|
||||
layer_id=idx,
|
||||
config=config,
|
||||
quant_config=quant_config,
|
||||
prefix=prefix,
|
||||
),
|
||||
pp_rank=self.pp_group.rank_in_group,
|
||||
pp_size=self.pp_group.world_size,
|
||||
prefix=add_prefix("layers", prefix),
|
||||
)
|
||||
if self.pp_group.is_last_rank:
|
||||
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
else:
|
||||
self.norm = PPMissingLayer(return_tuple=True)
|
||||
|
||||
def get_input_embeddings(self) -> nn.Embedding:
|
||||
return self.embed_tokens
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
forward_batch: ForwardBatch,
|
||||
input_embeds: torch.Tensor = None,
|
||||
pp_proxy_tensors: Optional[PPProxyTensors] = None,
|
||||
) -> Union[torch.Tensor, PPProxyTensors]:
|
||||
if self.pp_group.is_first_rank:
|
||||
if input_embeds is None:
|
||||
hidden_states = self.embed_tokens(input_ids)
|
||||
else:
|
||||
hidden_states = input_embeds
|
||||
residual = None
|
||||
else:
|
||||
assert pp_proxy_tensors is not None
|
||||
hidden_states = pp_proxy_tensors["hidden_states"]
|
||||
residual = pp_proxy_tensors["residual"]
|
||||
|
||||
for i in range(self.start_layer, self.end_layer):
|
||||
layer = self.layers[i]
|
||||
hidden_states, residual = layer(
|
||||
positions, hidden_states, forward_batch, residual
|
||||
)
|
||||
|
||||
if not self.pp_group.is_last_rank:
|
||||
return PPProxyTensors(
|
||||
{"hidden_states": hidden_states, "residual": residual}
|
||||
)
|
||||
|
||||
if hidden_states.shape[0] != 0:
|
||||
if residual is None:
|
||||
hidden_states = self.norm(hidden_states)
|
||||
else:
|
||||
hidden_states, _ = self.norm(hidden_states, residual)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class LagunaForCausalLM(nn.Module):
|
||||
fall_back_to_pt_during_load = False
|
||||
packed_modules_mapping = {
|
||||
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
|
||||
"gate_up_proj": ["gate_proj", "up_proj"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: LagunaConfig,
|
||||
quant_config: Optional[QuantizationConfig] = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.pp_group = get_pp_group()
|
||||
self.config = config
|
||||
self.model = LagunaModel(
|
||||
config, quant_config=quant_config, prefix=add_prefix("model", prefix)
|
||||
)
|
||||
if self.pp_group.is_last_rank:
|
||||
self.lm_head = ParallelLMHead(
|
||||
config.vocab_size,
|
||||
config.hidden_size,
|
||||
quant_config=quant_config,
|
||||
prefix=add_prefix("lm_head", prefix),
|
||||
use_attn_tp_group=get_global_server_args().enable_dp_lm_head,
|
||||
)
|
||||
else:
|
||||
self.lm_head = PPMissingLayer()
|
||||
self.logits_processor = LogitsProcessor(config)
|
||||
|
||||
# Only walk this rank's local layers — out-of-range entries can be PPMissingLayer.
|
||||
self._routed_experts_weights_of_layer = LazyValue(
|
||||
lambda: {
|
||||
layer_id: self.model.layers[layer_id].mlp.get_moe_weights()
|
||||
for layer_id in range(self.start_layer, self.end_layer)
|
||||
if isinstance(self.model.layers[layer_id].mlp, LagunaMoE)
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def routed_experts_weights_of_layer(self):
|
||||
return self._routed_experts_weights_of_layer.value
|
||||
|
||||
@property
|
||||
def start_layer(self):
|
||||
return self.model.start_layer
|
||||
|
||||
@property
|
||||
def end_layer(self):
|
||||
return self.model.end_layer
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
forward_batch: ForwardBatch,
|
||||
input_embeds: torch.Tensor = None,
|
||||
pp_proxy_tensors: Optional[PPProxyTensors] = None,
|
||||
) -> torch.Tensor:
|
||||
hidden_states = self.model(
|
||||
input_ids,
|
||||
positions,
|
||||
forward_batch,
|
||||
input_embeds,
|
||||
pp_proxy_tensors=pp_proxy_tensors,
|
||||
)
|
||||
if self.pp_group.is_last_rank:
|
||||
return self.logits_processor(
|
||||
input_ids, hidden_states, self.lm_head, forward_batch
|
||||
)
|
||||
return hidden_states
|
||||
|
||||
def get_input_embeddings(self) -> nn.Embedding:
|
||||
return self.model.embed_tokens
|
||||
|
||||
def load_weights(self, weights: Iterable[Tuple[str, torch.Tensor]]):
|
||||
stacked_params_mapping = [
|
||||
("qkv_proj", "q_proj", "q"),
|
||||
("qkv_proj", "k_proj", "k"),
|
||||
("qkv_proj", "v_proj", "v"),
|
||||
("gate_up_proj", "gate_proj", 0),
|
||||
("gate_up_proj", "up_proj", 1),
|
||||
]
|
||||
|
||||
expert_params_mapping = FusedMoE.make_expert_params_mapping(
|
||||
ckpt_gate_proj_name="gate_proj",
|
||||
ckpt_down_proj_name="down_proj",
|
||||
ckpt_up_proj_name="up_proj",
|
||||
num_experts=self.config.num_experts,
|
||||
)
|
||||
|
||||
params_dict = dict(self.named_parameters())
|
||||
|
||||
# (layer, expert, shard) tuples that hit the per-expert loader,
|
||||
# cross-checked against `expected` below to fail on dropped weights.
|
||||
loaded_expert_shards: set[Tuple[int, int, str]] = set()
|
||||
moe_layer_ids = [
|
||||
i
|
||||
for i, mt in enumerate(self.config.mlp_layer_types)
|
||||
if mt == "sparse" and self.start_layer <= i < self.end_layer
|
||||
]
|
||||
|
||||
for name, loaded_weight in weights:
|
||||
layer_id = get_layer_id(name)
|
||||
if layer_id is not None and (
|
||||
layer_id < self.start_layer or layer_id >= self.end_layer
|
||||
):
|
||||
continue
|
||||
|
||||
if "rotary_emb.inv_freq" in name:
|
||||
continue
|
||||
|
||||
if self.config.tie_word_embeddings and "lm_head.weight" in name:
|
||||
continue
|
||||
|
||||
# HF stores the router correction bias under the experts namespace;
|
||||
# our parameter lives on the gate. Remap before dispatch.
|
||||
if name.endswith("mlp.experts.e_score_correction_bias"):
|
||||
name = name.replace(
|
||||
"mlp.experts.e_score_correction_bias",
|
||||
"mlp.gate.e_score_correction_bias",
|
||||
)
|
||||
|
||||
# Stacked dense (QKV / gate_up). The `mlp.experts.` guard stops
|
||||
# `up_proj` substring from false-matching `experts.{i}.up_proj.weight`.
|
||||
matched_stacked = False
|
||||
for param_name, weight_name, shard_id in stacked_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
if "mlp.experts." in name:
|
||||
continue
|
||||
name_mapped = name.replace(weight_name, param_name)
|
||||
if name_mapped.endswith(".bias") and name_mapped not in params_dict:
|
||||
continue
|
||||
if name_mapped not in params_dict:
|
||||
continue
|
||||
param = params_dict[name_mapped]
|
||||
param.weight_loader(param, loaded_weight, shard_id)
|
||||
matched_stacked = True
|
||||
break
|
||||
if matched_stacked:
|
||||
continue
|
||||
|
||||
matched_expert = False
|
||||
for param_name, weight_name, expert_id, shard_id in expert_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
name_mapped = name.replace(weight_name, param_name)
|
||||
if name_mapped not in params_dict:
|
||||
continue
|
||||
param = params_dict[name_mapped]
|
||||
param.weight_loader(
|
||||
param,
|
||||
loaded_weight,
|
||||
name,
|
||||
shard_id=shard_id,
|
||||
expert_id=expert_id,
|
||||
)
|
||||
if layer_id is not None:
|
||||
loaded_expert_shards.add((layer_id, expert_id, shard_id))
|
||||
matched_expert = True
|
||||
break
|
||||
if matched_expert:
|
||||
continue
|
||||
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
if name not in params_dict:
|
||||
logger.warning("Parameter %s not found in params_dict", name)
|
||||
continue
|
||||
param = params_dict[name]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
weight_loader(param, loaded_weight)
|
||||
|
||||
# If any routed-expert tensor was silently dropped (e.g. a future
|
||||
# checkpoint renaming `gate_proj`, or a ckpt-vs-mapping shape mismatch),
|
||||
# fail loud here instead of generating garbage.
|
||||
expected = {
|
||||
(layer_id, expert_id, shard_id)
|
||||
for layer_id in moe_layer_ids
|
||||
for expert_id in range(self.config.num_experts)
|
||||
for shard_id in ("w1", "w2", "w3")
|
||||
}
|
||||
missing = expected - loaded_expert_shards
|
||||
if missing:
|
||||
sample = sorted(missing)[:5]
|
||||
raise RuntimeError(
|
||||
f"{len(missing)} routed-expert tensors were not loaded "
|
||||
f"(sample: {sample}). Expected {len(expected)} (layers={moe_layer_ids}, "
|
||||
f"num_experts={self.config.num_experts}, shards=3)."
|
||||
)
|
||||
|
||||
def get_embed_and_head(self):
|
||||
return self.model.embed_tokens.weight, self.lm_head.weight
|
||||
|
||||
def set_embed_and_head(self, embed, head):
|
||||
del self.model.embed_tokens.weight
|
||||
del self.lm_head.weight
|
||||
self.model.embed_tokens.weight = embed
|
||||
self.lm_head.weight = head
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
|
||||
EntryClass = LagunaForCausalLM
|
||||
@@ -587,6 +587,15 @@ class _MimoDetector(Qwen3Detector):
|
||||
self.reasoning_default = "explicit_enable_thinking"
|
||||
|
||||
|
||||
class _PoolsideV1Detector(Qwen3Detector):
|
||||
"""Poolside v1 (Laguna-XS.2) reuses Qwen3 <think> tokens but the HF chat template
|
||||
defaults `enable_thinking=False`; reasoning is opt-in via `enable_thinking=True`."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.reasoning_default = "explicit_enable_thinking"
|
||||
|
||||
|
||||
class ReasoningParser:
|
||||
"""
|
||||
Parser that handles both streaming and non-streaming scenarios for extracting
|
||||
@@ -608,6 +617,7 @@ class ReasoningParser:
|
||||
"kimi": KimiDetector,
|
||||
"kimi_k2": KimiK2Detector,
|
||||
"mimo": _MimoDetector,
|
||||
"poolside_v1": _PoolsideV1Detector,
|
||||
"qwen3": Qwen3Detector,
|
||||
"qwen3-thinking": Qwen3Detector,
|
||||
"minimax": Qwen3Detector,
|
||||
|
||||
@@ -37,6 +37,7 @@ from sglang.srt.configs import (
|
||||
KimiK25Config,
|
||||
KimiLinearConfig,
|
||||
KimiVLConfig,
|
||||
LagunaConfig,
|
||||
LongcatFlashConfig,
|
||||
MultiModalityConfig,
|
||||
NemotronH_Nano_Omni_Reasoning_V3_Config,
|
||||
@@ -79,6 +80,7 @@ _CONFIG_REGISTRY: Dict[str, Type[PretrainedConfig]] = {
|
||||
MultiModalityConfig,
|
||||
KimiVLConfig,
|
||||
InternVLChatConfig,
|
||||
LagunaConfig,
|
||||
Step3VLConfig,
|
||||
LongcatFlashConfig,
|
||||
Olmo3Config,
|
||||
|
||||
Reference in New Issue
Block a user