Repository navigation
feat(models): add the Laguna family with compressed-tensors NVFP4 experts - #1739
Conversation
…erts Poolside's Laguna XS / S (`model_type: "laguna"`) is a pre-norm decoder with QK-RMSNorm, a hybrid of YaRN partial-rotary full-attention layers and window-512 sliding layers, per-layer query-head counts, a per-head softplus attention output gate, and a sigmoid or sqrt-softplus routed top-k MoE with a correction bias, a shared expert and a routed scaling factor. The published NVFP4 checkpoints store every expert and the shared expert in the compressed-tensors `nvfp4-pack-quantized` layout, which nothing in the tree could read. The loader stacks the per-expert `weight_packed` bytes and reinterprets them as MLX native NVFP4 words on device, keeps the E4M3 block scales byte for byte, and carries `1 / weight_global_scale` as a new per-expert `SwitchLinear` `global_scale` sidecar applied after `gather_qmm` (the dense shared expert uses the existing `QuantizedWeight` sidecar). Folding the global scale into the 3-mantissa-bit block scales would have shifted expert outputs. Pre-stacked MLX conversions (fused `gate_up_proj` split back) and per-expert bf16 exports load through the same sanitize, which is idempotent. Each attention layer builds its own prefill mask from the key count its cache returns, so the sliding layers stay aligned with both the plain rotating cache and the buffered variant speculative rollback will arm. The tokenizer gains `prompt_carries_bos`, used by every tokenize site, so a chat prompt that starts with the tokenizer's own BOS spelling (`〈|EOS|〉` here) is not given a second BOS by the post-processor. Refs #1347
de928db to
e9bc766
Compare
The family entry now names both checkpoints the port was validated against and says Laguna S 2.1 was not on the host, so the next reader can tell which paths carry real-checkpoint evidence and which carry unit tests only. It also records the one place mlxcel deliberately differs from mlx-lm's Laguna port: the YaRN block's `attention_factor` is used when the config carries one, which is what `transformers`' `_compute_yarn_parameters` does and therefore what the checkpoint's own `modeling_laguna.py` inherits, while mlx-lm drops the key and always derives the factor from `factor`. `Laguna-XS.2-4bit` declares `attention_factor: 1.0` where the derived value is 1.3466, so the two runtimes rotate that checkpoint differently and a reference comparison against mlx-lm has to strip the key first. XS 2.1 declares the derived value and agrees either way. Refs #1347
`/v1/messages/count_tokens` and the `input_tokens` inspection routes passed `add_special = true` unconditionally, so for any template that emits its own BOS they reported one token more than the request actually evaluates. Both now follow `MlxcelTokenizer::prompt_carries_bos`, the rule the generation path already uses. Laguna surfaced it; the whole Llama 3 lineage was affected the same way. The rest is the review pass: `laguna_tests` covers the legacy `moe_router_use_sigmoid` spelling and the unknown-`moe_router_score_func` error, and its NVFP4 fixture scales now include a full-mantissa value and an E4M3 subnormal instead of powers of two only, which a byte-exact round-trip assertion would otherwise never exercise. A tokenizer test pins the Llama 3 double-BOS collapse, `tests/tensor_parallel_real_models.rs` tokenizes through the same rule as production, and the `// Used by:` lists on `create_causal_mask_full_window`, `softplus`, `compute_yarn_rope`, `moe_weighted_sum` and `SwitchGLU::from_weights_with_mode` name Laguna. Validated on an M5 Max (128 GB, Metal): the narrow lib suites for `models::laguna`, `models::detection`, `models::sanitize`, `tokenizer` and `models::switch_layers` are 293 passed / 0 failed, and a live server on `models/laguna-xs-2.1-nvfp4` answers `input_tokens: 51` from both count routes, equal to the 51 `prompt_tokens` the same chat request evaluates. Refs #1347
config.json is untrusted input on the `mlxcel generate -m <org>/<repo>` path, and three Laguna values reached MLX unchecked. `num_experts_per_tok` is a serde default, so a config that declares sparse layers through `mlp_layer_types` but omits the key yields 0 and `router_select` computes `argpartition(kth = num_experts)`, one past the end of the score row. MLX throws on that, and an MLX exception crossing the cxx bridge is an uncatchable abort on the first routed token rather than a load error. A value above the router width instead sliced a negative start out of the partition order and silently routed fewer than k experts. The router width comes from `mlp.gate.proj` while the sanitizer stacks exactly `num_experts` planes, so an under-declared `num_experts` lets the router emit an index past the stacked plane, and neither `gather_qmm` nor the `take` behind the per-expert `.global_scale` range-checks a positive index. `afmoe::validate_stacked_experts` already guards the same hazard. - `ModelArgs::validate` bounds `num_hidden_layers`, `num_experts`, `num_experts_per_tok`, the two non-finite router scalars, and `head_dim` (below 2 it panicked inside `Ord::clamp` while resolving the partial-rotary width), and runs before the sanitizer walks the same config. - `validate_router_geometry` cross-checks the router width against the stacked expert planes and the correction bias width at load; `router_select` clamps the partition pivot into the score row the router actually emitted, regardless of who built the block. - `laguna_sanitize` rejects an expert plane past the declared count, which also stops the leftovers from being transcoded as dense linears nothing ever reads, and requires the per-expert packed weight and block scales to agree on shape and dtype before `mlx::core::stack` throws on them. Validation: `cargo test --profile test-fast --features metal,accelerate --lib models::` (1647 passed, 0 failed), clippy and fmt clean. Refs #1347
Security and performance review
HIGH: HIGH: the router can index past the stacked expert planes. The router width comes from MEDIUM: an uncatchable abort and a panic on the load path. Per-expert Left alone: Validation: |
Bilingual technical report for the Laguna family port, dated today because the PR is still open and this repository tracks its reports. Covers the compressed-tensors NVFP4 transcode and why the per-expert `1 / weight_global_scale` stays out of the E4M3 block scales (3 mantissa bits) and out of the fused affine decode kernel, the four load-path hazards the security pass closed, the shared `prompt_carries_bos` rule and the off-by-one it removes from both token-count routes, and the two places the port deliberately diverges from mlx-lm. Also records what is not established: no external token-exact reference exists because the pinned mlx-lm carries no `laguna.py`, Laguna S 2.1 was not downloaded, and the fused/unfused divergence on the affine checkpoint reproduces on a Qwen3 control.
Summary
Adds Poolside's Laguna family (
model_type: "laguna", XS 2.1 / XS.2 / S 2.1): a pre-norm decoder with QK-RMSNorm, YaRN partial-rotary full-attention layers interleaved with window-512 sliding layers, per-layer query-head counts, a per-head softplus attention output gate, and a sigmoid or sqrt-softplus routed top-k MoE with a correction bias, a shared expert andmoe_routed_scaling_factor. The published NVFP4 checkpoints keep every expert and the shared expert in the compressed-tensorsnvfp4-pack-quantizedlayout, which nothing insrc/could read before.What changed
src/models/laguna{,_layers,_sanitize}.rs: config, per-layer RoPE resolution (per-layer-type entry overlaid on loose top-level scalars, explicitattention_factorpreferred over the derived one,partial_rotary_factorfallback chain), the model shell and the sanitize. The transcode stacks the per-expertweight_packedbytes and reinterprets them as MLX native NVFP4uint32words on device, keeps the E4M3 block scales byte for byte, and carries1 / weight_global_scaleas a per-expert.global_scalesidecar. Pre-stacked MLX conversions (fusedgate_up_projsplit back), per-expert bf16 exports and tied heads load through the same idempotent sanitize.src/models/switch_layers.rs:SwitchLinear::Quantizedgainsglobal_scale: Option<[num_experts] f32>, applied to the selected experts' rows aftergather_qmmon both layouts; planes carrying it stay off the fused affine decode kernel. Folding1 / globalinto the 3-mantissa-bit E4M3 block scales was rejected because the quotient is generally not representable.MlxcelTokenizer::prompt_carries_bos: every tokenize site now suppressesadd_special_tokenswhen the rendered prompt already opens with a BOS. Laguna's template emits〈|EOS|〉(id 2) first and the checkpoint'sTemplateProcessingpost-processor would otherwise prepend id 2 again. The two token-count routes follow the same rule, which also removes an off-by-one the whole Llama 3 lineage had.ModelArgs::validate,validate_router_geometry, and per-expert plane checks in the sanitize) because several of these values reach MLX as raw arguments. Aconfig.jsonthat declares sparse layers but omitsnum_experts_per_tokused to handargpartitionan out-of-range pivot, which MLX throws on and the cxx bridge does not catch, aborting the process after the whole checkpoint was resident; an under-declarednum_expertslet the router index past the stacked planes, which nothing downstream range-checks.LoadedModel, memory estimate and TP tables carry the family;docs/supported-models.mddocuments it, andmlxcel archlistsLaguna XS / S (Poolside code, hybrid MoE).Validation
Host: M5 Max, 128 GB, Metal. Everything below was re-run on the final commit.
cargo test --profile test-fast --features metal,accelerate --lib models::: 1647 passed, 0 failed, 65 ignored; themodels::laguna_testssubset is 21 tests. Narrow clippy andcargo fmt --all -- --checkare clean, and CI is green. An earlier run on the GB10 (sm_121) CUDA host passed the same suites under--features cuda.models/laguna-xs-2.1-nvfp4:MLXCEL_FUSED_MOE=0 mlxcel generate -m models/laguna-xs-2.1-nvfp4 -p "Write a Python retry wrapper with exponential backoff." -n 64 --no-chat-template --temp 0transcodes 234 compressed-tensors NVFP4 planes, loads in 3.2 s at 16.52 GB resident, and writes fluent Python at 53 to 79 tok/s. The default fused run is byte-identical, as expected: planes carrying.global_scalenever enter the affine fused decode kernel.models/laguna-xs.2-4bit(pre-stacked affine path): same command, fluent Python at 121 to 128 tok/s unfused and 108 tok/s fused. The two greedy continuations diverge from about token 10, which is the pre-existing fused affine decode kernel rather than this port:models/qwen3-30b-a3b-4bitdiverges at the same point under the same A/B on this host.models/laguna-xs-2.1-nvfp4:/apply-templaterenders a prompt opening with〈|EOS|〉, and/tokenizereturns 51 ids withadd_special=false([2, 97, ...]) against 52 withadd_special=true([2, 2, ...]). A/v1/chat/completionsrequest withenable_thinking=falsereportsprompt_tokens = 51and endsfinish_reason: "stop"after 672 of 700 allowed tokens with no</assistant>in the text, so id 24 fired the stop. Both count routes answerinput_tokens: 51.Not verified
laguna.py, so there is nothing on either host to diff greedy ids against. The fused/unfused A/B above is the only comparison that was run.cargo test --workspace --profile test-fast --features metal,acceleratewas not run whole on this shared host; CI runs the workspace gate.loading/config_backed.rs) reachesLagunaModel::from_weightswithout the sanitize, so an NVFP4 checkpoint fails there with a missing-weight error. Laguna registersadapter: Noneand every otherConfigBackedfamily is wired the same way, so this is left as is.Closes #1347