Add Laguna (Poolside) architecture support - #1602
Conversation
…> mlp.gate.e_score_correction_bias
The poolside/Laguna-S-2.1 checkpoint stores the aux-loss-free routing
correction bias at model.layers.{l}.mlp.experts.e_score_correction_bias
(vLLM-trained checkpoint convention), but our Router module holds it at
mlp.gate.e_score_correction_bias. Without this remap the key was left
unconsumed and silently dropped by strict=False loading, leaving the
bias at its zero-init default forever and disabling the aux-loss-free
load-balancing correction (arXiv:2408.15664) at inference. Mirrors the
reference HF implementation's _checkpoint_conversion_mapping.
scripts/laguna_quant_predicate.py (6c4fe85) was verified only ad-hoc and had no persisted coverage. Add tests/test_laguna_quant.py exercising build_laguna_quant_predicate for expert_bits in (4, 6) across switch_mlp, attention, router, and shared-expert paths, plus the invalid-bits ValueError.
Adds the compatibility report's 4bit-Att-2bit-Ex recipe: routed experts account for ~116B of Laguna S-2.1's ~117.6B stored parameters, so this is the variant that actually fits a 118B checkpoint on a 64 GB Mac. mlx's affine quantization kernel already supports 2-bit groups; this just extends the predicate's accepted expert_bits and documents the recipe.
|
Some independent measured datapoints in case they're useful here — I ported Windowed KV — confirms the sliding/full
Prefill is ~2× on the sliding layers too. Prefill last-token logits are bitwise-identical to full-KV; generation ≤512 ctx is token-for-token identical; >512 differs only by the standard Expert dynamic-k is a dead end on this router — worth not chasing. Profiling the sigmoid router over a real generation (39 MoE layers), cumulative routed mass is top-1 22% / top-4 64% / top-6 83% / top-7 92% — spread, not peaky. So trimming top_k barely moves decode: top-6 = 1.09× for KL 2.4e-2 vs top-8, top-5 = 1.09× for KL 6.7e-2. The always-on shared expert + sigmoid routing don't garden like the peakier softmax routers in e.g. gpt-oss. 6-bit body / 8-bit router is a strong quant target. Using a quant predicate like the Minor: the tokenizer threw a Mistral-style regex warning on load — likely benign, but worth an edge-case tokenization check. Bench harnesses and exact configs available if useful. |
|
Adding clean S-2.1 data after a reboot, in case it helps decide the Laguna support direction here. Local setup:
Plain vs prompt-lookup decode on a repeated-code synthetic prompt:
Takeaways from this S-2.1 q4 run:
So my practical serving recipe for S-2.1 is: native Laguna arch + mixed sliding/full KV cache + q4 body/q8 routing, plain decode as baseline, prefix-cache for repeated prefixes, and PLD/DFlash as gated workload-specific accelerators rather than the architecture default. |
…rage The predicate's own comment overclaimed that the router's weight matmul was quantized at attention_bits; in reality mlx_lm.utils.quantize_model's wrapper filters out any module lacking to_quantized (Router.weight is a raw array, not nn.Linear) before ever calling a custom predicate, so the router -- and every RMSNorm -- stays at full precision regardless of what this predicate returns for their paths. Corrected the docstring and inline comment, and added a test that exercises the real quantize_model entry point (not just the predicate function in isolation) to lock in that behavior. Practical upshot: a "protect the router, shrink the body" recipe needs no custom predicate at all -- plain mlx_lm.convert(..., q_bits=6) already leaves the router at full precision while quantizing attention and routed experts uniformly.
Thanks for taking the time to run a full battery on real hardware and share exact numbers, this is genuinely useful validation, especially the windowed-KV scaling curve and the router mass-distribution profiling. Replying to the two points that needed a closer look: 6-bit body / 8-bit router: dug into this, and there's actually no custom predicate needed for it at all. Router.weight in this PR (mlx_lm/models/laguna.py) is a raw array, not an nn.Linear, so it has no to_quantized. mlx_lm.utils.quantize_model's wrapper checks hasattr(module, "to_quantized") before ever calling a custom quant_predicate, so the router, and every RMSNorm in the model, is always left at full precision, regardless of what a predicate returns for those paths. Concretely: plain mlx_lm.convert(..., quantize=True, q_bits=6), with no custom predicate at all, already gives you "router protected / body at 6-bit" — and the router ends up at full float32/bf16 rather than 8-bit, which is probably why your KL numbers came out so clean (0.008, 100% greedy agreement). Pushed a small follow-up commit that corrects laguna_quant_predicate.py's docstring/comment (it previously implied the router went through attention_bitly happens)and adds a test that exercises the real quantize_moderather thanonly unit-testing the predicate function in isolation, that gap is exactly how the original comment went unnoticed. Appreciate dynamic-top-k data point too! Good to have that one closed off rather than rediscovered later. |
Why
poolside/Laguna-S-2.1(118B total / ~8B active MoE, 256 experts top-10 routedplus one shared expert, OpenMDW-1.1) — and its siblings
Laguna-XS-2.1(33B-A3B) and
Laguna-M.1(225B-A23B) — have nomlx-lmarchitecture moduletoday.
model_type: "laguna"in theirconfig.jsonmaps only to remotecustom_codeclasses (configuration_laguna.LagunaConfig/modeling_laguna.LagunaForCausalLM) hosted in the HF repo, whichmlx-lmcannot run natively. This PR adds
mlx_lm/models/laguna.py, so Lagunacheckpoints load and convert through the same path as every other
architecture — no
trust_remote_coderequired, which matters more thanusual now that
transformersgatescustom_codeexecution behindtrust_remote_code=True(CVE-2026-5843, merged 2026-06-11).Evidence of real demand: poolside has already uploaded an mlx-quantized
checkpoint (
poolside/Laguna-S-2.1-NVFP4-mlx) whose weight-key layout(
mlp.switch_mlp.*,mlp.gate.*) already matches this port's targetnaming — i.e. this is the naming convention the checkpoint publisher expects
mlx-lm support to use.
What
Laguna combines several features
mlx-lmalready supports individually,just not previously combined in one model:
(same pattern as
glm4_moe.py'sMoEGate), 256 routed experts (top-10)plus one shared expert, via
SwitchGLU.type (YaRN for full-attention layers, default for sliding — same
make_cache/alternating-mask pattern asgpt_oss.py).num_attention_heads_per_layer).attn_out *= softplus(g_proj(x)),per-head or per-element) and optional learnable attention sinks on
sliding layers (
gpt_oss.pyprecedent for the sinks mechanism).Also included:
tests/test_models.py::test_laguna(exercises everynon-standard feature above through the existing shape/dtype/cache/batch>1
harness), and
scripts/laguna_quant_predicate.py— a custom per-modulequantization predicate demonstrating how to produce the mixed-precision
variants some Laguna publishers may want (attention/router/embeddings at
one bit width, routed experts at a lower one — 8-bit/{4,6}-bit-experts, or
4-bit/2-bit-experts for a footprint that actually fits a 64 GB Mac, since
routed experts hold ~116B of Laguna S-2.1's ~117.6B stored parameters),
for recipes the standard
mlx_lm.convert -q --q-bits Nflag can't expressdirectly. The three uniform variants (4-bit/6-bit/8-bit everywhere) need no
custom predicate.
How tested
tests/test_models.py::test_laguna(new, in this PR): a tiny random-initconfig that forces every layer through a genuinely distinct code path —
dense MLP vs. MoE, full vs. sliding attention, YaRN vs. default RoPE,
different per-layer head counts (8 vs. 12) — verified through the existing
model_test_runnershape/dtype/cache/batch>1/deepcopy checks.transformersimplementation.A local harness (not included in this PR — it depends on
torch/transformers, whichmlx-lmdoes not otherwise require) built a matched3-layer config in both the reference and this port from an identical
random seed, ran this port's real
Model.sanitize()on the reference'sactual per-expert weight layout (not a hand-constructed shortcut around
it), loaded with
strict=True, and compared logits:max abs diff 5.1e-4, mean abs diff 1.3e-4 (float32, tolerance 1e-3).
The router's auxiliary-loss-free correction bias was set to a nonzero
random tensor before comparison — with it left at its zero default the
first pass, an earlier review round found
sanitize()was silentlydropping this bias's real on-disk key (
mlp.experts.e_score_correction_bias→
mlp.gate.e_score_correction_bias) rather than remapping it, which azero-valued bias could not have exposed; that gap is now closed and
covered by a dedicated unit test, and the harness's discriminating power
was confirmed by deliberately breaking the bias-add sign and observing the
diff jump to 0.025 (fails, as it should).
poolside/Laguna-S-2.1checkpoint's safetensors headers (HTTP rangerequest — no 219 GiB download), confirming the per-layer head-count
override (48 vs. 72 heads) and per-expert unfused weight layout this port
assumes are correct.
Known limitations / explicitly out of scope
module, even though they share the same
config.jsonmodel_type: "laguna"string.poolside/Laguna-S-2.1-DFlash(and its siblings) use astructurally different architecture (
architectures: ["DFlashLagunaForCausalLM"]— dense, no MoE, all-sliding-window, with EAGLE-style auxiliary hidden
states and block-causal masking for multi-token draft generation), and
mlx-lmdispatches purely onmodel_type(mlx_lm/utils.py), notarchitectures. Converting a DFlash checkpoint with this module would notproduce a working draft model. A separate architecture module would be
needed for DFlash support; out of scope here (a dedicated
dflash_lagunaspeculator is already proposed separately in Add dflash_laguna EAGLE-3 speculator for Laguna #1531).self.sink(singular) whenswa_attention_sink_enabled=True; this portuses
self.sinksandsanitize()does not remap the name. None of thethree published Laguna checkpoints (S-2.1, XS-2.1, M.1) set this flag, so
it's inert today; a checkpoint that does would need the name reconciled
first.
attention_biasis read from config by this port, but the referencehardcodes no bias on Q/K/V/O regardless of config — inert for all three
published checkpoints (all set it
false), but worth aligning if a futurevariant sets it
true.exercise incremental/cached decode (
RotatingKVCachestepping) or routerlogit soft-capping (no published checkpoint sets a nonzero value there
either).
transformerswarning about an "incorrect regex pattern" and suggests
fix_mistral_regex=True. This is a false positive oftransformers's owndetection heuristic (it fires for any
config.jsonmissingtransformers_version, which Laguna's does, regardless ofmodel_type)and not a real tokenization bug: round-tripping code samples with
contractions, mixed case, and multi-newline runs through Laguna's real
tokenizer produces byte-identical output with and without the flag, and
the flag's own fix targets a different pipeline stage than the one
Laguna's pre-tokenizer actually differs on. Safe to ignore.