You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(models): add Ant Group Ling / Bailing MoE (bailing_moe) text model support #838
Add support for the Ant Group Ling / Bailing MoE text model family (bailing_moe) to the mlxcel Rust inference runtime, porting the architecture from mlx-lm. Bailing MoE is a DeepSeek-style mixture-of-experts model with routed experts, an always-on shared expert, and optional grouped top-k routing, so much of it maps onto mlxcel's existing DeepSeek-V2/V3 MoE machinery. The routing details differ from DeepSeek in several places where a blind reuse is silently wrong; those places are called out below.
This body was revised after a pre-implementation pass that read the real checkpoint at models/ling-lite-1.5 and the upstream reference line by line. Three config fields the original text named do not exist in the checkpoint, one mapping instruction was a divergence from upstream rather than a port of it, and the shared-expert shape was described incorrectly.
inclusionAI/Ling-lite-1.5, present locally at models/ling-lite-1.5. No mlx-community conversion is required: the upstream weights are raw bf16 SafeTensors and load directly.
Roughly 33.6 GB across 4 shards, entirely bf16. Validation needs a machine with headroom for that plus KV cache.
Real weight keys per layer: model.layers.N.attention.query_key_value.weight (fused QKV, [3072, 2048] = (16 + 2*4) * 128), model.layers.N.attention.dense.weight, model.layers.N.mlp.gate.weight (router, [64, 2048]), model.layers.N.mlp.experts.{0..63}.{gate_proj,up_proj,down_proj}.weight (per-expert, not pre-stacked), model.layers.N.mlp.shared_experts.{gate_proj,up_proj,down_proj}.weight, plus input_layernorm and post_attention_layernorm. Top level: model.word_embeddings.weight, model.norm.weight, lm_head.weight. Note word_embeddings, not embed_tokens.
use_bias and use_qkv_bias are both false, so no projection carries a bias tensor, and no expert_bias tensor exists in the checkpoint.
Config fields the checkpoint does not contain
Three fields the original issue text named are absent from models/ling-lite-1.5/config.json: n_group, moe_router_enable_expert_bias, and moe_router_enable_routed_scaling. They must be optional with serde defaults matching upstream, or the config will not parse at all. The full set of upstream-optional routing fields:
Field
Upstream default
moe_router_enable_expert_bias
false
moe_router_enable_routed_scaling
true
routed_scaling_factor
1.0
score_function
"softmax"
n_group
1
topk_group
4
moe_shared_expert_intermediate_size
None
moe_router_enable_shared_expert
true
score_function defaults to "softmax". DeepSeek-V3's router is sigmoid. Since this issue directs the implementer to reuse the DeepSeek-V2/V3 MoE machinery, this is exactly the place where reuse goes wrong: taking the DeepSeek gate as-is changes the routing distribution on every token while producing perfectly finite output. The score function must be read from config and both "softmax" and "sigmoid" supported.
Also absent from the checkpoint and therefore default-bearing: use_qk_norm (false), partial_rotary_factor (1.0), rotary_dim (None). With those defaults the rope dim is the full head_dim of 128 and there are no QK norm weights to load.
What the validation checkpoint actually exercises
This matters for how much a green real-model run is worth:
n_group defaults to 1, so the grouped top-k branch never runs.
moe_router_enable_expert_bias defaults to false, so the bias add never runs and there is no expert_bias tensor to load.
first_k_dense_replace is 0, so all 28 layers are MoE and there is no dense prefix to exercise for this checkpoint.
Ling-lite-1.5 therefore exercises only the plain softmax top-k path with a shared expert. The grouped-routing branch, the expert-bias branch, the sigmoid score function, and the dense-prefix path all still have to be implemented, and each needs unit-test coverage against a synthetic config, because no checkpoint available here reaches them. A token-exact real-model comparison will not validate those branches. Do not read a passing real-model run as full coverage.
Router: selection uses biased scores, weights come from unbiased scores
group_expert_select computes scores = softmax(gates.astype(float32)) (or sigmoid when score_function == "sigmoid"), saves orig_scores = scores, then adds the correction bias to scores only. The grouped-group masking, when n_group > 1, also applies to that biased copy. Top-k indices come from argpartition on the biased scores, and the returned weights are then gathered from orig_scores via take_along_axis. Applying the bias to the weights as well as to the selection is a silent correctness bug: output stays finite and plausible while every routed contribution is misweighted. This is the same selection-only-bias pattern DeepSeek-V3 and ERNIE-4.5 already implement in this tree; follow those.
Two further details from the same function:
norm_topk_prob is conditional and has an epsilon. Upstream applies it only when top_k > 1, and the denominator is scores.sum(-1, keepdims=True) + 1e-20, not a bare sum.
The score function runs in float32 and the result is cast back to the input dtype only at the very end, after normalization and scaling.
moe_router_enable_routed_scaling is dead code upstream
BailingMoeGate stores self.enable_routed_scaling = args.moe_router_enable_routed_scaling and never reads it again. __call__ passes routed_scaling_factor unconditionally, and group_expert_select ends with an unconditional scores = scores * routed_scaling_factor. The flag has no effect upstream.
The original instruction in this issue to "map moe_router_enable_routed_scaling onto routed scaling" would therefore be a deliberate divergence from the reference rather than a port of it. It is unobservable on Ling-lite-1.5, where routed_scaling_factor defaults to 1.0, but it is observable on any checkpoint that sets a non-unit factor together with the flag false.
Pick one behavior explicitly: either mirror upstream (always scale, flag parsed and ignored) or honor the flag (scale only when true). State which one was chosen and why in the PR body, and pin it with a unit test so a later reader cannot mistake it for an oversight.
Shared experts are one wide MLP, not N separate experts
shared_dim = moe_shared_expert_intermediate_size or moe_intermediate_size, and the shared MLP is constructed with intermediate_size = shared_dim * num_shared_experts. For Ling-lite-1.5 that is a single MLP of width 1408 * 2 = 2816, which the real tensors confirm: mlp.shared_experts.gate_proj.weight is [2816, 2048] and mlp.shared_experts.down_proj.weight is [2048, 2816]. There is no per-shared-expert axis anywhere in the checkpoint.
It is built only when num_shared_experts > 0 and moe_router_enable_shared_expert, it is added to the routed mixture at a fixed weight of 1.0 (out = out + self.shared_experts(x)), and it is never packed into the switch tensors. Do not stack it into SwitchLinear and do not give it a routing weight.
Vendor config keys upstream partly ignores
config.json sets norm_head: false and norm_softmax: false.
norm_head is live upstream, contrary to what a quick read suggests. mlx-lm's ModelArgs declares it, Model stores it, and sanitize L2-normalizes lm_head.weight along axis 0 in float32 with a +1e-7 epsilon before casting back to the original dtype. The vendored modeling_bailing_moe.py does the same normalization at inference time, once, then clears the flag. Implement it in sanitize to match, and unit-test it: it is a few lines, and a checkpoint that sets it true would otherwise produce wrong logits with no error.
norm_softmax is dead everywhere. mlx-lm declares it in ModelArgs and never reads it, and it does not appear anywhere in the vendored modeling_bailing_moe.py; configuration_bailing_moe.py does not even name it, so it survives only via **kwargs. A checkpoint setting it true is asking for behavior no released code defines. Reject that config at load rather than parsing it and silently ignoring it.
Weight remap
Upstream sanitize does two things per layer at or after first_k_dense_replace:
Stacks mlp.experts.{e}.{gate_proj,down_proj,up_proj}.{weight,scales,biases} over e in 0..num_experts into mlp.switch_mlp.{m}.{k}. The scales and biases variants exist only for quantized checkpoints; the raw bf16 checkpoint has weight only.
Renames the router mlp.gate.weight to mlp.gate.gate_proj.weight, and mlp.gate.bias to mlp.gate.gate_proj.bias when present.
Watch the naming collision in step 2: mlp.gate.* is the router, while gate_proj is the SwiGLU gate of an expert MLP, and after the rename the router itself lives at mlp.gate.gate_proj.weight. A remap rule that matches on gate_proj without anchoring the prefix will swallow the router weight.
Upstream also carries a quant_predicate that quantizes mlp.gate.gate_proj at group_size 64, bits 8 while leaving the rest at the default, and a cast_predicate that excludes expert_bias from dtype casting. Both matter only for quantized conversions, but note them if one is validated later.
Architecture notes
DeepSeek-style MoE block:
num_experts routed experts (64 here) plus a single shared MLP sized moe_intermediate_size * num_shared_experts, gated on moe_router_enable_shared_expert.
Optional n_group grouped top-k routing (experts partitioned into n_group groups, topk_group groups kept, the rest zeroed before top-k). Inactive by default and inactive on this checkpoint.
Optional router expert bias (moe_router_enable_expert_bias), the same role as DeepSeek's e_score_correction_bias, applied to selection only.
Routed scaling by routed_scaling_factor, unconditional upstream (see above).
norm_topk_prob, applied only when top_k > 1, with a 1e-20 epsilon in the denominator.
first_k_dense_replace layers before the MoE layers use a plain BailingMoeMLP at intermediate_size. Zero on this checkpoint.
Standard RMSNorm + RoPE attention, no exotic attention variant, but the QKV projection is fused into one query_key_value tensor split at [q_size, q_size + kv_size], and the output projection is named dense.
Optional QK RMSNorm per head (use_qk_norm), applied after the head reshape and before RoPE. Off on this checkpoint.
Implementation plan
Much of this maps onto mlxcel's existing DeepSeek-V2/V3 MoE path (src/models/deepseek_v2.rs, src/models/deepseek_v3.rs), which already implements grouped top-k routing, shared experts, the e_score_correction_bias router bias, and routed scaling. Reuse that machinery where the semantics actually match, and do not assume they match by default:
Experts: reuse src/models/switch_layers.rs (SwitchLinear / fused experts) as the DeepSeek path already does.
Routing: reuse the grouped top-k router and shared-expert combination, but parameterize the score function (softmax default, not DeepSeek's sigmoid), keep the bias selection-only, keep the top_k > 1 guard and the 1e-20 epsilon on norm_topk_prob, and decide the moe_router_enable_routed_scaling question explicitly.
Shared expert: one MLP at shared_dim * num_shared_experts, added at weight 1.0, outside the switch tensors.
Attention: RMSNorm + RoPE with a fused query_key_value split and a dense output projection, reusing existing shared layers.
Main new work is config field mapping (including the optional-with-defaults set above), the Bailing weight-key naming in sanitize (expert stacking plus the router rename), and the norm_head lm_head normalization.
New model file under src/models/ (for example src/models/bailing_moe.rs), following docs/adding-models.md.
Validation
A token-exact comparison against the mlx-lm reference requires --no-chat-template on both sides. The reference harness feeds the raw prompt, while this checkpoint does ship a chat template (embedded in tokenizer_config.json as a <role>HUMAN</role> style template; there is no separate chat_template.json). Without the flag the two sides tokenize different strings and diverge on the first token for reasons unrelated to the port.
Captured reference for the raw prompt What is the capital of France?: generation stops at EOS after 11 token ids.
The trailing 126081 is <|endoftext|>, which config.json sets as both eos_token_id and pad_token_id.
Then run a second time without --no-chat-template. That run is the real-usage sanity check; it is expected to produce different text, and it should be coherent and terminate.
Defensive requirements
Every port already merged in this series (#833, #834, #836, #837) produced findings of the same class, so these are requirements rather than suggestions. The underlying hazard: a config-derived value that passes Rust-side validation but violates an undocumented precondition of an MLX C++ entry point terminates the process at the first inference rather than failing at load, because those bridge functions return UniquePtr rather than Result and a C++ throw crossing the cxx bridge is an uncatchable std::terminate. Rejecting at load is the only containment.
Gather and slice bounds must be taken from real tensor shapes, not from config fields. MLX take wraps negative indices but does not range-check positive ones, so an out-of-range expert index or slice offset silently reads adjacent memory and the result reaches the logits as plausible numbers.
Magnitude validation on the architecture scalars (hidden_size, num_hidden_layers, num_attention_heads, num_key_value_heads, num_experts, num_experts_per_tok, num_shared_experts, intermediate_size, moe_intermediate_size, moe_shared_expert_intermediate_size, vocab_size, max_position_embeddings, n_group, topk_group, first_k_dense_replace) before any Vec::with_capacity, truncating as i32 cast, or use as a divisor. Zero checks go before divisibility checks: 0.is_multiple_of(0) is true in Rust, so a divisibility check on its own lets a zero divisor through. src/models/gpt_neox.rs has the established shape of this (MAX_* ceilings plus a validate() returning Result<(), String>).
Routing-specific bounds beyond magnitude: num_experts_per_tok at least 1 and no greater than num_experts; when n_group > 1, num_experts divisible by n_group, and topk_group between 1 and n_group. Upstream computes k = n_group - topk_group and calls argpartition(kth=k - 1), which is out of range as soon as topk_group >= n_group.
RoPE dims validated even, positive, and no larger than the last axis before reaching fast_rope. partial_rotary_factor is a float, so a hostile value can be NaN or infinite; the saturating as i32 cast turns those into 0 and i32::MAX respectively, and both must be rejected with a message naming the actual field. rope_theta must be finite and positive, since RoPE takes its logarithm.
rms_norm_eps validated finite and positive.
Reject norm_softmax: true rather than ignoring it, per the section above.
Delivered and closed. Implemented by PR #946, merged as a3c823611; mlxcel arch now reports the family. Test coverage: 42 unit tests in src/models/bailing_moe_tests.rs, mutation-checked by reverting five behaviors and observing exactly five paired failures. Real-checkpoint validation: models/ling-lite-1.5 reproduces the captured reference ids 198 198 198 678 7706 300 11406 341 13997 13 and stops at EOS 126081, run with --no-chat-template on both sides so the reference and mlxcel see the same raw prompt. Note the gate does NOT cover grouped routing or the expert-bias branch, because n_group defaults to 1 and moe_router_enable_expert_bias defaults false, so those two rest entirely on the unit tests. The unticked boxes below are the original pre-implementation plan and were not maintained during the work; treat the merged PR and its review thread as the record of what shipped.
Config struct + serde parsing for the Bailing MoE config, with n_group, topk_group, moe_router_enable_expert_bias, moe_router_enable_routed_scaling, routed_scaling_factor, score_function, moe_shared_expert_intermediate_size, moe_router_enable_shared_expert, use_qk_norm, partial_rotary_factor, and rotary_dim all optional and defaulted to the upstream values in the table above. A parse test over the verbatim models/ling-lite-1.5/config.json proves none of them is required.
score_function read from config and both "softmax" and "sigmoid" implemented, with a unit test that the default is softmax and that an unknown value is rejected rather than silently falling back.
from_weights constructor wiring the MoE block (routed experts, single wide shared MLP added at weight 1.0, grouped top-k router) and RMSNorm/RoPE attention with the fused query_key_value split.
Router implemented with selection on the biased scores and weights gathered from the unbiased scores, norm_topk_prob applied only when top_k > 1 with the 1e-20 epsilon, and the float32 score computation cast back at the end. Unit tests pin each of these three separately.
moe_router_enable_routed_scaling behavior chosen explicitly (mirror upstream or honor the flag), stated in the PR body, and pinned by a unit test.
Grouped routing (n_group > 1) and the expert-bias path implemented and unit-tested against synthetic configs, since the validation checkpoint reaches neither.
first_k_dense_replace dense-prefix path implemented and unit-tested against a synthetic config, since the validation checkpoint sets it to 0.
sanitize stacking mlp.experts.{e}.{gate_proj,down_proj,up_proj}.{weight,scales,biases} into the fused SwitchLinear layout and renaming mlp.gate.weight / mlp.gate.bias to mlp.gate.gate_proj.*, with a test that the router weight is not captured by the expert gate_proj rule.
norm_head implemented in sanitize (axis-0 L2 normalization of lm_head.weight in float32 with +1e-7, cast back) and unit-tested.
norm_softmax: true rejected at load with a message explaining that no released implementation defines it.
Config validation covering the magnitude, routing, RoPE, and norm-eps requirements in the Defensive requirements section, with a rejection test per rule.
Arm added to src/models/detection.rs for the bailing_moe architecture / model_type.
Registration in src/model_metadata.rs via for_each_model_registration!.
TP / distributed arch-string entry if applicable.
Unit tests in the model's _tests.rs (config parse, sanitize expert remap and router rename, router selection, forward shape) in addition to the targeted tests above.
docs/supported-models.md updated to list Bailing MoE / Ling.
Real-checkpoint validation: mlxcel arch reports the architecture, and ./target/release/mlxcel generate on models/ling-lite-1.5 reproduces the captured reference token ids exactly with --no-chat-template.
Second real-checkpoint run without --no-chat-template produces coherent output and terminates.
Correction: the real-checkpoint validation step above originally read mlxcel list, which lists downloaded checkpoints in the local model store. The architecture registry is mlxcel arch, and that is what confirms the binary knows the family.
Done only when the model loads and generates from the real checkpoint (per docs/adding-models.md).
Effort
MEDIUM. The expert machinery and the general MoE shape already exist on the DeepSeek-V2/V3 path, but the reuse is not free: the score function default differs from DeepSeek, the shared expert is one wide MLP rather than N experts, several config fields are absent from real checkpoints and must be defaulted, and two of the routing branches cannot be validated against any available checkpoint and need synthetic-config tests instead. Add the usual registration, detection, validation, test, and doc touchpoints.
Summary
Add support for the Ant Group Ling / Bailing MoE text model family (
bailing_moe) to the mlxcel Rust inference runtime, porting the architecture from mlx-lm. Bailing MoE is a DeepSeek-style mixture-of-experts model with routed experts, an always-on shared expert, and optional grouped top-k routing, so much of it maps onto mlxcel's existing DeepSeek-V2/V3 MoE machinery. The routing details differ from DeepSeek in several places where a blind reuse is silently wrong; those places are called out below.This body was revised after a pre-implementation pass that read the real checkpoint at
models/ling-lite-1.5and the upstream reference line by line. Three config fields the original text named do not exist in the checkpoint, one mapping instruction was a divergence from upstream rather than a port of it, and the shared-expert shape was described incorrectly.Upstream reference
Validation checkpoint
inclusionAI/Ling-lite-1.5, present locally atmodels/ling-lite-1.5. No mlx-community conversion is required: the upstream weights are raw bf16 SafeTensors and load directly.hidden_size2048,intermediate_size1408,moe_intermediate_size1408, 64 routed experts,num_experts_per_tok6,num_shared_experts2, 16 query heads / 4 KV heads (head_dim128),vocab_size126464,rope_theta600000,rms_norm_eps1e-6,tie_word_embeddingsfalse,rope_scalingnull.model.layers.N.attention.query_key_value.weight(fused QKV,[3072, 2048]=(16 + 2*4) * 128),model.layers.N.attention.dense.weight,model.layers.N.mlp.gate.weight(router,[64, 2048]),model.layers.N.mlp.experts.{0..63}.{gate_proj,up_proj,down_proj}.weight(per-expert, not pre-stacked),model.layers.N.mlp.shared_experts.{gate_proj,up_proj,down_proj}.weight, plusinput_layernormandpost_attention_layernorm. Top level:model.word_embeddings.weight,model.norm.weight,lm_head.weight. Noteword_embeddings, notembed_tokens.use_biasanduse_qkv_biasare both false, so no projection carries a bias tensor, and noexpert_biastensor exists in the checkpoint.Config fields the checkpoint does not contain
Three fields the original issue text named are absent from
models/ling-lite-1.5/config.json:n_group,moe_router_enable_expert_bias, andmoe_router_enable_routed_scaling. They must be optional with serde defaults matching upstream, or the config will not parse at all. The full set of upstream-optional routing fields:moe_router_enable_expert_biasfalsemoe_router_enable_routed_scalingtruerouted_scaling_factor1.0score_function"softmax"n_group1topk_group4moe_shared_expert_intermediate_sizeNonemoe_router_enable_shared_experttruescore_functiondefaults to"softmax". DeepSeek-V3's router is sigmoid. Since this issue directs the implementer to reuse the DeepSeek-V2/V3 MoE machinery, this is exactly the place where reuse goes wrong: taking the DeepSeek gate as-is changes the routing distribution on every token while producing perfectly finite output. The score function must be read from config and both"softmax"and"sigmoid"supported.Also absent from the checkpoint and therefore default-bearing:
use_qk_norm(false),partial_rotary_factor(1.0),rotary_dim(None). With those defaults the rope dim is the fullhead_dimof 128 and there are no QK norm weights to load.What the validation checkpoint actually exercises
This matters for how much a green real-model run is worth:
n_groupdefaults to 1, so the grouped top-k branch never runs.moe_router_enable_expert_biasdefaults to false, so the bias add never runs and there is noexpert_biastensor to load.first_k_dense_replaceis 0, so all 28 layers are MoE and there is no dense prefix to exercise for this checkpoint.Ling-lite-1.5 therefore exercises only the plain softmax top-k path with a shared expert. The grouped-routing branch, the expert-bias branch, the sigmoid score function, and the dense-prefix path all still have to be implemented, and each needs unit-test coverage against a synthetic config, because no checkpoint available here reaches them. A token-exact real-model comparison will not validate those branches. Do not read a passing real-model run as full coverage.
Router: selection uses biased scores, weights come from unbiased scores
group_expert_selectcomputesscores = softmax(gates.astype(float32))(orsigmoidwhenscore_function == "sigmoid"), savesorig_scores = scores, then adds the correction bias toscoresonly. The grouped-group masking, whenn_group > 1, also applies to that biased copy. Top-k indices come fromargpartitionon the biased scores, and the returned weights are then gathered fromorig_scoresviatake_along_axis. Applying the bias to the weights as well as to the selection is a silent correctness bug: output stays finite and plausible while every routed contribution is misweighted. This is the same selection-only-bias pattern DeepSeek-V3 and ERNIE-4.5 already implement in this tree; follow those.Two further details from the same function:
norm_topk_probis conditional and has an epsilon. Upstream applies it only whentop_k > 1, and the denominator isscores.sum(-1, keepdims=True) + 1e-20, not a bare sum.moe_router_enable_routed_scalingis dead code upstreamBailingMoeGatestoresself.enable_routed_scaling = args.moe_router_enable_routed_scalingand never reads it again.__call__passesrouted_scaling_factorunconditionally, andgroup_expert_selectends with an unconditionalscores = scores * routed_scaling_factor. The flag has no effect upstream.The original instruction in this issue to "map
moe_router_enable_routed_scalingonto routed scaling" would therefore be a deliberate divergence from the reference rather than a port of it. It is unobservable on Ling-lite-1.5, whererouted_scaling_factordefaults to 1.0, but it is observable on any checkpoint that sets a non-unit factor together with the flag false.Pick one behavior explicitly: either mirror upstream (always scale, flag parsed and ignored) or honor the flag (scale only when true). State which one was chosen and why in the PR body, and pin it with a unit test so a later reader cannot mistake it for an oversight.
Shared experts are one wide MLP, not N separate experts
shared_dim = moe_shared_expert_intermediate_size or moe_intermediate_size, and the shared MLP is constructed withintermediate_size = shared_dim * num_shared_experts. For Ling-lite-1.5 that is a single MLP of width1408 * 2 = 2816, which the real tensors confirm:mlp.shared_experts.gate_proj.weightis[2816, 2048]andmlp.shared_experts.down_proj.weightis[2048, 2816]. There is no per-shared-expert axis anywhere in the checkpoint.It is built only when
num_shared_experts > 0 and moe_router_enable_shared_expert, it is added to the routed mixture at a fixed weight of 1.0 (out = out + self.shared_experts(x)), and it is never packed into the switch tensors. Do not stack it intoSwitchLinearand do not give it a routing weight.Vendor config keys upstream partly ignores
config.jsonsetsnorm_head: falseandnorm_softmax: false.norm_headis live upstream, contrary to what a quick read suggests. mlx-lm'sModelArgsdeclares it,Modelstores it, andsanitizeL2-normalizeslm_head.weightalong axis 0 in float32 with a+1e-7epsilon before casting back to the original dtype. The vendoredmodeling_bailing_moe.pydoes the same normalization at inference time, once, then clears the flag. Implement it insanitizeto match, and unit-test it: it is a few lines, and a checkpoint that sets it true would otherwise produce wrong logits with no error.norm_softmaxis dead everywhere. mlx-lm declares it inModelArgsand never reads it, and it does not appear anywhere in the vendoredmodeling_bailing_moe.py;configuration_bailing_moe.pydoes not even name it, so it survives only via**kwargs. A checkpoint setting it true is asking for behavior no released code defines. Reject that config at load rather than parsing it and silently ignoring it.Weight remap
Upstream
sanitizedoes two things per layer at or afterfirst_k_dense_replace:mlp.experts.{e}.{gate_proj,down_proj,up_proj}.{weight,scales,biases}overe in 0..num_expertsintomlp.switch_mlp.{m}.{k}. Thescalesandbiasesvariants exist only for quantized checkpoints; the raw bf16 checkpoint hasweightonly.mlp.gate.weighttomlp.gate.gate_proj.weight, andmlp.gate.biastomlp.gate.gate_proj.biaswhen present.Watch the naming collision in step 2:
mlp.gate.*is the router, whilegate_projis the SwiGLU gate of an expert MLP, and after the rename the router itself lives atmlp.gate.gate_proj.weight. A remap rule that matches ongate_projwithout anchoring the prefix will swallow the router weight.Upstream also carries a
quant_predicatethat quantizesmlp.gate.gate_projatgroup_size 64, bits 8while leaving the rest at the default, and acast_predicatethat excludesexpert_biasfrom dtype casting. Both matter only for quantized conversions, but note them if one is validated later.Architecture notes
num_expertsrouted experts (64 here) plus a single shared MLP sizedmoe_intermediate_size * num_shared_experts, gated onmoe_router_enable_shared_expert.n_groupgrouped top-k routing (experts partitioned inton_groupgroups,topk_groupgroups kept, the rest zeroed before top-k). Inactive by default and inactive on this checkpoint.moe_router_enable_expert_bias), the same role as DeepSeek'se_score_correction_bias, applied to selection only.routed_scaling_factor, unconditional upstream (see above).norm_topk_prob, applied only whentop_k > 1, with a1e-20epsilon in the denominator.first_k_dense_replacelayers before the MoE layers use a plainBailingMoeMLPatintermediate_size. Zero on this checkpoint.query_key_valuetensor split at[q_size, q_size + kv_size], and the output projection is nameddense.use_qk_norm), applied after the head reshape and before RoPE. Off on this checkpoint.Implementation plan
Much of this maps onto mlxcel's existing DeepSeek-V2/V3 MoE path (
src/models/deepseek_v2.rs,src/models/deepseek_v3.rs), which already implements grouped top-k routing, shared experts, thee_score_correction_biasrouter bias, and routed scaling. Reuse that machinery where the semantics actually match, and do not assume they match by default:src/models/switch_layers.rs(SwitchLinear/ fused experts) as the DeepSeek path already does.softmaxdefault, not DeepSeek's sigmoid), keep the bias selection-only, keep thetop_k > 1guard and the1e-20epsilon onnorm_topk_prob, and decide themoe_router_enable_routed_scalingquestion explicitly.shared_dim * num_shared_experts, added at weight 1.0, outside the switch tensors.query_key_valuesplit and adenseoutput projection, reusing existing shared layers.sanitize(expert stacking plus the router rename), and thenorm_headlm_head normalization.src/models/(for examplesrc/models/bailing_moe.rs), followingdocs/adding-models.md.Validation
A token-exact comparison against the mlx-lm reference requires
--no-chat-templateon both sides. The reference harness feeds the raw prompt, while this checkpoint does ship a chat template (embedded intokenizer_config.jsonas a<role>HUMAN</role>style template; there is no separatechat_template.json). Without the flag the two sides tokenize different strings and diverge on the first token for reasons unrelated to the port.Captured reference for the raw prompt
What is the capital of France?: generation stops at EOS after 11 token ids.\n\n\nThe capital of France is Paris.198 198 198 678 7706 300 11406 341 13997 13 126081The trailing
126081is<|endoftext|>, whichconfig.jsonsets as botheos_token_idandpad_token_id.Then run a second time without
--no-chat-template. That run is the real-usage sanity check; it is expected to produce different text, and it should be coherent and terminate.Defensive requirements
Every port already merged in this series (#833, #834, #836, #837) produced findings of the same class, so these are requirements rather than suggestions. The underlying hazard: a config-derived value that passes Rust-side validation but violates an undocumented precondition of an MLX C++ entry point terminates the process at the first inference rather than failing at load, because those bridge functions return
UniquePtrrather thanResultand a C++ throw crossing the cxx bridge is an uncatchablestd::terminate. Rejecting at load is the only containment.takewraps negative indices but does not range-check positive ones, so an out-of-range expert index or slice offset silently reads adjacent memory and the result reaches the logits as plausible numbers.hidden_size,num_hidden_layers,num_attention_heads,num_key_value_heads,num_experts,num_experts_per_tok,num_shared_experts,intermediate_size,moe_intermediate_size,moe_shared_expert_intermediate_size,vocab_size,max_position_embeddings,n_group,topk_group,first_k_dense_replace) before anyVec::with_capacity, truncatingas i32cast, or use as a divisor. Zero checks go before divisibility checks:0.is_multiple_of(0)is true in Rust, so a divisibility check on its own lets a zero divisor through.src/models/gpt_neox.rshas the established shape of this (MAX_*ceilings plus avalidate()returningResult<(), String>).num_experts_per_tokat least 1 and no greater thannum_experts; whenn_group > 1,num_expertsdivisible byn_group, andtopk_groupbetween 1 andn_group. Upstream computesk = n_group - topk_groupand callsargpartition(kth=k - 1), which is out of range as soon astopk_group >= n_group.fast_rope.partial_rotary_factoris a float, so a hostile value can be NaN or infinite; the saturatingas i32cast turns those into 0 andi32::MAXrespectively, and both must be rejected with a message naming the actual field.rope_thetamust be finite and positive, since RoPE takes its logarithm.rms_norm_epsvalidated finite and positive.norm_softmax: truerather than ignoring it, per the section above.quantizationblock is tracked separately as fix(core): an unvalidated config.json quantization block reaches MLX and terminates the process #929. This port only needs to avoid adding new exposure, which the per-modelvalidate_quantizationpattern already present insrc/models/gpt_neox.rsachieves at negligible cost.Touchpoints & acceptance criteria
Delivered and closed. Implemented by PR #946, merged as
a3c823611;mlxcel archnow reports the family. Test coverage: 42 unit tests insrc/models/bailing_moe_tests.rs, mutation-checked by reverting five behaviors and observing exactly five paired failures. Real-checkpoint validation:models/ling-lite-1.5reproduces the captured reference ids198 198 198 678 7706 300 11406 341 13997 13and stops at EOS126081, run with--no-chat-templateon both sides so the reference and mlxcel see the same raw prompt. Note the gate does NOT cover grouped routing or the expert-bias branch, becausen_groupdefaults to 1 andmoe_router_enable_expert_biasdefaults false, so those two rest entirely on the unit tests. The unticked boxes below are the original pre-implementation plan and were not maintained during the work; treat the merged PR and its review thread as the record of what shipped.n_group,topk_group,moe_router_enable_expert_bias,moe_router_enable_routed_scaling,routed_scaling_factor,score_function,moe_shared_expert_intermediate_size,moe_router_enable_shared_expert,use_qk_norm,partial_rotary_factor, androtary_dimall optional and defaulted to the upstream values in the table above. A parse test over the verbatimmodels/ling-lite-1.5/config.jsonproves none of them is required.score_functionread from config and both"softmax"and"sigmoid"implemented, with a unit test that the default is softmax and that an unknown value is rejected rather than silently falling back.from_weightsconstructor wiring the MoE block (routed experts, single wide shared MLP added at weight 1.0, grouped top-k router) and RMSNorm/RoPE attention with the fusedquery_key_valuesplit.norm_topk_probapplied only whentop_k > 1with the1e-20epsilon, and the float32 score computation cast back at the end. Unit tests pin each of these three separately.moe_router_enable_routed_scalingbehavior chosen explicitly (mirror upstream or honor the flag), stated in the PR body, and pinned by a unit test.n_group > 1) and the expert-bias path implemented and unit-tested against synthetic configs, since the validation checkpoint reaches neither.first_k_dense_replacedense-prefix path implemented and unit-tested against a synthetic config, since the validation checkpoint sets it to 0.sanitizestackingmlp.experts.{e}.{gate_proj,down_proj,up_proj}.{weight,scales,biases}into the fusedSwitchLinearlayout and renamingmlp.gate.weight/mlp.gate.biastomlp.gate.gate_proj.*, with a test that the router weight is not captured by the expertgate_projrule.norm_headimplemented insanitize(axis-0 L2 normalization oflm_head.weightin float32 with+1e-7, cast back) and unit-tested.norm_softmax: truerejected at load with a message explaining that no released implementation defines it.src/models/detection.rsfor thebailing_moearchitecture /model_type.src/model_metadata.rsviafor_each_model_registration!._tests.rs(config parse, sanitize expert remap and router rename, router selection, forward shape) in addition to the targeted tests above.docs/supported-models.mdupdated to list Bailing MoE / Ling.mlxcel archreports the architecture, and./target/release/mlxcel generateonmodels/ling-lite-1.5reproduces the captured reference token ids exactly with--no-chat-template.--no-chat-templateproduces coherent output and terminates.Correction: the real-checkpoint validation step above originally read
mlxcel list, which lists downloaded checkpoints in the local model store. The architecture registry ismlxcel arch, and that is what confirms the binary knows the family.Done only when the model loads and generates from the real checkpoint (per
docs/adding-models.md).Effort
MEDIUM. The expert machinery and the general MoE shape already exist on the DeepSeek-V2/V3 path, but the reuse is not free: the score function default differs from DeepSeek, the shared expert is one wide MLP rather than N experts, several config fields are absent from real checkpoints and must be defaulted, and two of the routing branches cannot be validated against any available checkpoint and need synthetic-config tests instead. Add the usual registration, detection, validation, test, and doc touchpoints.