Repository navigation
feat(models): add the Laguna family (Poolside Laguna XS/S, hybrid sliding/full MoE with NVFP4 experts) #1347
Description
Summary
mlxcel cannot load Poolside's Laguna coding models (model_type: "laguna", architectures: ["LagunaForCausalLM"]): Laguna XS 2.1 / XS.2 (40 layers, 256 experts, about 33B total) and Laguna S 2.1 (48 layers, about 117B total). The family is a pre-norm decoder with QK-RMSNorm, hybrid full_attention (YaRN, partial rotary) / sliding_attention (plain RoPE, window 512) layers, per-layer query-head counts, a per-head softplus attention output gate, and a sigmoid-routed top-k MoE with a correction bias, a shared expert and a moe_routed_scaling_factor. The published NVFP4 checkpoints store experts in the compressed-tensors nvfp4-pack-quantized layout (per-expert weight_packed / weight_scale / weight_global_scale). This change adds src/models/laguna.rs, the detection arm, the NVFP4 sanitize path, the sqrtsoftplus router variant, and the Laguna tokenization contract (the chat template owns the BOS token).
Current behavior
src/models/detection.rs:150dispatches onmodel_type; there is no"laguna"arm, somlxcel generate -m models/Laguna-XS-2.1-NVFP4fails with an unsupported model type.- The nearest existing module is
src/models/mellum.rs(JetBrains Mellum 2):layer_typesfull/sliding hybrid, per-layer-typerope_parameters, YaRN viacompute_yarn_ropeatsrc/models/mellum.rs:190,RotatingKVCacheon sliding layers,SwitchGLUMoE. Mellum has no attention output gate, a single head count for every layer, a softmax router without correction bias or shared expert, and nopartial_rotary_factor. src/models/sanitize.rs:564(repack_nvfp4_weights_to_quantized) handles only the ModelOpt layout keyed by.weight_scale_2; the compressed-tensors layout (weight_packed/weight_scale/weight_global_scale) has no reader anywhere insrc/.src/server/model_provider.rs:1418suppressesadd_special_tokensonly when the rendered prompt starts with the literal<bos>or<s>:
let add_special = !plain_prompt.starts_with("<bos>") && !plain_prompt.starts_with("<s>");The Laguna chat template emits 〈|EOS|〉 (id 2, which is both BOS and EOS) as its first token, and the checkpoint's tokenizer.json post_processor (TemplateProcessing, single: [〈|EOS|〉, A]) prepends id 2 again when add_special_tokens is true, so every chat request would start with a doubled id 2.
Expected behavior
Config (top level of config.json; types and defaults):
| key | type | XS 2.1 | S 2.1 | notes |
|---|---|---|---|---|
vocab_size |
int | 100352 | 100352 | |
hidden_size |
int | 2048 | 3072 | |
intermediate_size |
int | 8192 | 12288 | dense MLP (layer 0) |
num_hidden_layers |
int | 40 | 48 | |
num_attention_heads |
int | 48 | 48 | default head count |
num_attention_heads_per_layer |
list[int] | 48 on full, 64 on sliding | 48 / 72 | default [num_attention_heads] * L |
num_key_value_heads |
int | 8 | 8 | every layer |
head_dim |
int | 128 | 128 | |
max_position_embeddings |
int | 262144 | 1048576 | |
rms_norm_eps |
float | 1e-6 | 1e-6 | default 1e-6 |
layer_types |
list[str] | full_attention every 4th layer starting at 0, else sliding_attention |
same | default all full_attention |
sliding_window |
int | 512 | 512 | |
rope_parameters |
dict keyed by layer type | see below | see below | |
partial_rotary_factor |
float or absent | 0.5 (XS.2 only, top level) | absent | fallback when a layer entry lacks it |
gating |
bool or str | "per-head" (true in XS.2) |
"per-head" |
true means per-head; "per-head": gate dim = n_heads; any other truthy string: gate dim = n_heads * head_dim |
swa_attention_sink_enabled |
bool | absent (false) | absent | when true, sliding layers carry self_attn.sink [n_heads] passed as attention sinks |
num_experts |
int | 256 | 256 | |
num_experts_per_tok |
int | 8 | 10 | |
moe_intermediate_size |
int | 512 | 1024 | |
shared_expert_intermediate_size |
int | 512 | 1024 | |
norm_topk_prob |
bool | true | true | default true |
decoder_sparse_step |
int | 1 | 1 | layer i is MoE iff (i+1) % step == 0 and i not in mlp_only_layers |
mlp_only_layers |
list[int] | [0] |
[0] |
overridden by mlp_layer_types ("dense" entries) when present |
mlp_layer_types |
list[str] | ["dense", "sparse", ...] |
same | |
moe_routed_scaling_factor |
float | 2.5 | 2.5 | default 1.0 |
moe_router_logit_softcapping |
float | 0.0 | 0.0 | default 0.0 (off) |
moe_router_score_func |
str | absent | absent | "sigmoid" (default) or "sqrtsoftplus" |
moe_router_use_sigmoid |
bool | absent | absent | legacy spelling: false means softmax scores |
moe_apply_router_weight_on_input |
bool | false | false | reject true at load |
qkv_bias, attention_bias |
bool | false | false | |
tie_word_embeddings |
bool | false | false | |
bos_token_id / eos_token_id / pad_token_id |
int / list / int | 2 / [2, 24] / 9 |
same | 24 is </assistant> (not a special token) |
quantization_config |
dict | compressed-tensors nvfp4 | same | see sanitize |
rope_parameters (XS 2.1):
"full_attention": {"rope_theta": 500000.0, "rope_type": "yarn", "factor": 32.0,
"original_max_position_embeddings": 8192, "beta_slow": 1.0, "beta_fast": 64.0,
"attention_factor": 1.3465735902799727, "partial_rotary_factor": 0.5},
"sliding_attention": {"rope_type": "default", "rope_theta": 10000.0, "partial_rotary_factor": 1.0}S 2.1 uses factor: 128, beta_fast: 32, attention_factor: 1.4852; XS.2 uses factor: 64, original_max_position_embeddings: 4096, and also carries original_max_position_embeddings at the top level of rope_parameters (a non-dict entry that applies to every layer type as a default). When a layer type has no entry, fall back to the top-level rope_theta (default 500000) with rope_type: default.
Per-layer forward (x: [B, L, H], n_h = num_attention_heads_per_layer[i], n_kv = 8, d = 128):
h_in = RMSNorm(x; input_layernorm, eps)
q = q_proj(h_in).reshape(B, L, n_h, d); k = k_proj(h_in).reshape(B, L, n_kv, d); v = v_proj(h_in).reshape(B, L, n_kv, d)
q = RMSNorm_d(q; q_norm.weight[128], eps); k = RMSNorm_d(k; k_norm.weight[128], eps) # per head, before RoPE
q, k -> [B, heads, L, d]
full layers: rotate the first r = int(d * partial_rotary_factor) = 64 dims with YaRN frequencies (rotate-half, not interleaved);
multiply q[..., :r] and k[..., :r] by mscale = attention_factor before rotation
(mscale = 0.1 * ln(factor) + 1 when mscale=1, mscale_all_dim=0; equals the config's attention_factor)
sliding layers: rotate all 128 dims, base 10000, no scaling
attn = SDPA(q, k, v, scale = d^-0.5, mask) # GQA: n_h / n_kv query heads per kv head
full layers: causal mask, KVCache
sliding layers: causal mask with window 512 (key allowed iff 0 <= q_pos - k_pos <= 512 during prefill), RotatingKVCache(max_size = 512)
if sink is present (sliding layers only): attention sinks per head, as in gpt_oss.rs
o = attn.transpose -> [B, L, n_h * d]
gate = softplus(g_proj(h_in).astype(f32)).astype(o.dtype) # [B, L, n_h] (per-head)
o = (o.reshape(B, L, n_h, d) * gate[..., None]).reshape(B, L, n_h * d)
x = x + o_proj(o)
m_in = RMSNorm(x; post_attention_layernorm)
x = x + MLP(m_in)
Dense MLP (layer 0): down_proj(silu(gate_proj(m_in)) * up_proj(m_in)), intermediate 8192.
MoE block (layers with mlp_layer_types[i] == "sparse"):
logits = (m_in @ gate.proj.weight.T).astype(f32) # [N, E], E = 256
if softcap > 0: logits = tanh(logits / softcap) * softcap
scores = sigmoid(logits) if score_func == "sigmoid"
= sqrt(softplus(logits)) if score_func == "sqrtsoftplus" # softplus(z) = ln(1 + e^z)
= softmax(logits, axis=-1) if moe_router_use_sigmoid == false (legacy)
sel = scores + e_score_correction_bias[E] # selection only
idx = argpartition(-sel, k-1)[..., :k] # k = num_experts_per_tok
w = take_along_axis(scores, idx) # weights come from scores WITHOUT the bias
if norm_topk_prob: w = w / sum(w, -1, keepdims)
y = sum_k w_k * SwiGLU_expert_{idx_k}(m_in) # expert: down(silu(gate(x)) * up(x)), intermediate 512
y = y * moe_routed_scaling_factor # 2.5
out = y + shared_expert(m_in) # SwiGLU MLP, intermediate 512, unquantized key names mlp.shared_expert.*
Final: logits = lm_head(RMSNorm(x; model.norm)) (lm_head.weight [100352, hidden], untied).
Caches: KVCache on full layers, RotatingKVCache::new(512) on sliding layers (mellum.rs pattern). Full layer 0 is the first entry, so the mask builder can key on layer_types.iter().position(|t| t == "full_attention").
Implementation plan
src/models/laguna.rs(new),pub struct ModelArgsvia serde with the table above.rope_parametersisserde_json::Valueso the per-layer-type dicts and the loose top-level scalars can both be read:layer_rope(layer_type)returns(base, rotated_dims, Option<YarnRope>): reusecrate::models::mellum::compute_yarn_rope(rotated_dims, &entry)(make itpub(crate)if needed) withrotated_dims = (head_dim as f32 * partial) as usize,partialfrom the entry, then top-levelpartial_rotary_factor, then 1.0. Apply theYarnRope.mscaletoq[..., :rotated_dims]andk[..., :rotated_dims]beforemlxcel_core::fast_ropewithdims = rotated_dimsandfreqs = yarn.freqs; the remaininghead_dim - rotated_dimsfeatures pass through untouched (MLXfast::ropewithdims < head_dimalready does this).Attention::from_weights(weights, prefix, args, layer_idx):UnifiedLinearforq_proj,k_proj,v_proj,o_proj,g_proj(bias-free),RMSNormforq_norm/k_norm([head_dim]). Storen_headsper layer fromnum_attention_heads_per_layer. Gate:softplusviamlxcel_core::softpluson an f32 cast, multiply per head (reshape [B, L, n_h, d]), per-element whengatingis a string other than"per-head". Optionalsink(self_attn.sink[n_h]) on sliding layers whenswa_attention_sink_enabled; wire it the waygpt_oss.rspasses sinks into SDPA.LagunaSparseMoeBlock:gate: UnifiedLinear(mlp.gate.proj.weight, bf16, never quantized),e_score_correction_bias: UniquePtr<MlxArray>(kept f32),switch_mlp: SwitchGLU(SwitchLinear::from_weights_with_modewith the checkpoint's mode),shared_expert: MLP,routed_scaling_factor,score_funcenum{Sigmoid, SqrtSoftplus, Softmax}. Router math as in Expected behavior; reusecrate::models::switch_layers::moe_weighted_sum. KeepMLXCEL_FUSED_MOE=0semantics: the fused decode kernel is affine-only (switch_layers.rs:802), so NVFP4 experts take theforwardpath automatically.- Sanitize (
Laguna::sanitize_weights), run before anyfrom_weights, in this order:- Drop
language_model.prefixes if present, drop every key containingrotary_emb.inv_freq, and every key ending in.self_attn.k_scale,.self_attn.v_scale,.input_global_scale. - Router:
model.layers.N.mlp.gate.weight->model.layers.N.mlp.gate.proj.weight;model.layers.N.mlp.experts.e_score_correction_bias->model.layers.N.mlp.gate.e_score_correction_bias. - compressed-tensors NVFP4 experts (present when
quantization_config.format == "nvfp4-pack-quantized"orconfig_groups.group_0.formatsays so; recordquantization = {group_size: 16, bits: 4, mode: "nvfp4"}for the rest of the loader). For each layer N and projection p in{gate_proj, up_proj, down_proj}and expert e in0..num_experts, the checkpoint has:model.layers.N.mlp.experts.{e}.{p}.weight_packed:U8 [out, in/2], two E2M1 codes per byte, low nibble first. Reinterpret the bytes as little-endianuint32[out, in/8](8 codes per word, code j of the word in bits4j..4j+4); this is exactly MLX'snvfp4weight layout, so no nibble shuffling..weight_scale:F8_E4M3 [out, in/16](the safetensors dtype isF8_E4M3; keep the raw bytes, do not go through the load-time f16 promotion, or re-encode withf32_to_f8_e4m3fromsrc/models/sanitize.rs:127which is lossless for values that came from E4M3)..weight_global_scale:F32 [1]per expert and projection; the dequantized weight iscode * E4M3(weight_scale) / weight_global_scale.
Stack over experts intomodel.layers.N.mlp.switch_mlp.{p}.weight(uint32 [E, out, in/8]),.scales(uint8 [E, out, in/16]), and a new per-expert sidecar.global_scale(f32 [E], value1 / weight_global_scale[e]).SwitchLineargains an optionalglobal_scale: Option<UniquePtr<MlxArray>>that multiplies the gathered output bytake(global_scale, indices)[..., None]aftergather_qmm(the same roleQuantizedWeight::apply_global_scaleplays for a dense linear). This keeps every code and block scale bit-exact. Folding1/globalinto the E4M3 block scales (re-encodingscale / globalto E4M3) is not acceptable here: the quotient is generally not representable in 3 mantissa bits and shifts expert outputs by up to 6 percent.- Shared expert:
model.layers.N.mlp.shared_expert.{p}.weight_packed/.weight_scale/.weight_global_scale->UnifiedLinearnvfp4 weight/scales plusQuantizedWeight::apply_global_scale(1 / global). - Unquantized tensors (attention projections,
g_proj,q_norm/k_norm, layer norms,model.layers.0.mlp.*,embed_tokens,lm_head,mlp.gate.proj) stay bf16 and load through the plainUnifiedLinear::from_weights.
- Pre-stacked MLX checkpoints (
poolside/Laguna-XS-2.1-NVFP4-mlx,mlx-community/Laguna-XS.2-4bit) already carrymodel.layers.N.mlp.switch_mlp.{gate_proj|up_proj|down_proj|gate_up_proj}.{weight,scales,biases}; whengate_up_projis present split it back intogate_proj(rows0..I) andup_proj(rowsI..2I) along axis 1 so oneSwitchGLUlayout serves both. Also accept per-expert bf16experts.{e}.{gate_proj,up_proj,down_proj}.weight(the bf16 originals) by stacking. - Pop
lm_head.weightwhentie_word_embeddings.
The sanitize must be idempotent (run twice, same map), which the existing sanitize tests assert for every family.
- Drop
- Detection:
src/models/detection.rsadd"laguna" => Ok(ModelType::Laguna); addModelType::Lagunatosrc/models/mod.rs(enum, the(description, category)table nearmod.rs:910, and the architecture list). Loader:src/loading/mod.rsarm callingLagunaModel::load.LanguageModelimpl follows mellum.rs (make_cachesreturns the mixedKVCache/RotatingKVCachevector through the model-owned sequence state pattern insrc/models/model_owned.rsif the trait'sVec<KVCache>cannot carry rotating caches directly; check how mellum.rs does it and copy that exactly). - Tokenization contract:
src/server/model_provider.rs:1418andsrc/commands/generate.rs:810: extend the BOS-prefix suppression to the tokenizer's own BOS string: readbos_tokenfromtokenizer_config.json(here〈|EOS|〉) and treat a prompt that starts with it like one that starts with<bos>. Implement once inMlxcelTokenizer(fn prompt_carries_bos(&self, prompt: &str) -> bool) and call it from both sites so the scheduler and the dispatch thread keep producing identical ids (issue perf(server): incremental detokenization and per-token streaming overhead reduction #633 invariant).- Stop tokens:
eos_token_id = [2, 24]; 24 is</assistant>, an ordinary (non-special) added token, so the stop check must run on ids, not onskip_special_tokensdecoding. - Reasoning: the template emits
<think>whenenable_thinkingis true and</think>otherwise;<think>...</think>is the reasoning span forstream_filter.rs(plain-text markers, no special ids). - Do not patch the pre-tokenizer regex. The checkpoint's
tokenizer.jsonpre-tokenizer (Splitwith[^\r\n\p{L}\p{N}]?\p{L}+|..., thenByteLevel) is what the provider's own Hub loading path and the TensorRT-LLM / vLLM deployments use; thetokenizerscrate loads it unchanged.
docs/supported-models.md: add Laguna (XS 2.1, XS.2, S 2.1; NVFP4 and MLX 4-bit).
Validation
(a) Unit tests, src/models/laguna_tests.rs:
layer_rope_full_uses_yarn_partial_and_sliding_uses_default: for the XS 2.1rope_parameters, full layers yieldrotated_dims == 64,mscalewithin 1e-6 of1.3465735902799727, sliding layers yieldrotated_dims == 128, base 10000, no mscale.router_sigmoid_selects_with_bias_but_weights_without_bias: hand-built logits where the bias flips the top-k choice; assert indices followscores + biasand weights are the bias-free scores renormalized and scaled by 2.5.router_sqrtsoftplus_matches_formula:score_func = "sqrtsoftplus"reproducessqrt(ln(1 + exp(z)))within 1e-6 and stays finite forz = -100.per_head_gate_applies_softplus_per_head: a synthetic 2-head layer whereg_projgives logits[0, 10]; output head 0 is scaled byln 2, head 1 by about 10.sanitize_compressed_tensors_nvfp4_stacks_experts_and_keeps_global_scale: build 2 experts withweight_packedU8 [4, 8],weight_scale[4, 1],weight_global_scale; assertswitch_mlp.gate_proj.weightisuint32 [2, 4, 2]with the expected little-endian words,.scalesbytes are verbatim,.global_scale == [1/g0, 1/g1],input_global_scaleandk_scale/v_scaleare gone, and running sanitize twice is a no-op.sanitize_splits_prestacked_gate_up: agate_up_proj.weight [E, 2I, in/8]input yieldsgate_proj/up_projhalves.detection_laguna:model_type: "laguna"maps toModelType::Laguna(add tosrc/models/detection_tests.rs).prompt_carries_bos_recognizes_tokenizer_bos_stringinsrc/tokenizertests: a prompt starting with〈|EOS|〉suppressesadd_special.- Causality:
src/models/laguna_tests.rs::prefill_is_causal_on_both_layer_typeswith a random tiny config and a 96-token prompt: logits at position t must not change when tokens after t change (masklessforwardcontract; a 6-token prompt cannot expose a bidirectional prefill).
(b) Real checkpoint: poolside/Laguna-XS-2.1-NVFP4 (21.5 GB, native nvfp4, modelsize:medium) downloaded to models/Laguna-XS-2.1-NVFP4, plus mlx-community/Laguna-XS.2-4bit (affine 4-bit) for the pre-stacked path.
./target/release/mlxcel generate -m models/Laguna-XS-2.1-NVFP4 -p "Write a Python retry wrapper with exponential backoff." -n 64
MLXCEL_FUSED_MOE=0 ./target/release/mlxcel generate -m models/Laguna-XS.2-4bit -p "Write a Python retry wrapper with exponential backoff." -n 64
./target/release/mlxcel-server -m models/Laguna-XS-2.1-NVFP4 # then one /v1/chat/completions with enable_thinking=falseAcceptance: greedy output is fluent Python and token-exact for the first 64 tokens against a reference run of the same checkpoint on the same machine with the RAW (untemplated) prompt, with MLXCEL_FUSED_MOE=0; the chat request renders exactly one id 2 at the start of the prompt and stops on </assistant> (id 24). Laguna S 2.1 NVFP4 (poolside/Laguna-S-2.1-NVFP4, about 100 GB, modelsize:large) loads and produces fluent output on a 128 GB or larger machine (finite logits, no NaN; token-exactness not required for S).
Acceptance criteria
-
mlxcel listshows Laguna andmlxcel generateruns Laguna XS 2.1 NVFP4 with the command above - The NVFP4 sanitize keeps every E2M1 code and E4M3 block scale byte-exact and applies the per-expert global scale as a post-matmul multiplier
- Router supports
sigmoidandsqrtsoftplusscoring with correction-bias selection, top-k renormalization andmoe_routed_scaling_factor - Sliding layers use
RotatingKVCache, full layers use YaRN withpartial_rotary_factorandattention_factor - Chat requests carry a single BOS (id 2) and stop on ids 2 and 24
- docs/supported-models.md updated
- detection table in src/models/detection.rs updated with a test
- cargo test --workspace --profile test-fast --features metal,accelerate passes
- cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass
Out of scope
- The DFlash speculative drafter (feat(speculative): add the Laguna DFlash drafter (sliding-window gated attention over captured target hidden states) #1351).
moe_apply_router_weight_on_input = true(no published checkpoint sets it; reject at load).- Laguna M.1 (
poolside/Laguna-M.1, not inspected here).
Dependencies
- Blocked by fix(server): make the chat-template tojson filter match Python json.dumps (kwargs and separators) #1309 (tool-call rendering; text generation without tools does not depend on it).
Activity
feat(models): add the Laguna family with compressed-tensors NVFP4 exp…
docs(models): record what Laguna was validated on, and the YaRN gap
fix(server): count prompt tokens with the generation add_special rule
fix(laguna): bound the router and expert-plane geometry at load
feat(models): add the Laguna family with compressed-tensors NVFP4 exp…
docs(report): record the Kimi K3 tokenizer and XTML renderer for #1743