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
fix: KV estimation misses n_layer / n_embd for GPT-2 and GPT-BigCode #927
src/execution/kv_arch.rs classifies a model's KV geometry by reading field names out of config.json. The lookups accept the modern HuggingFace spellings plus several alternates, but never the OpenAI-era names that GPT-2 and GPT-BigCode configs actually use.
Layer count (kv_arch.rs:244): classify() reads get_u64(text, &["num_hidden_layers", "n_layers", "num_layers"])?. Both families write n_layer (singular, no plural alias). The lookup returns None, the ? short-circuits classify(), and the entire estimate becomes None.
Hidden size (kv_arch.rs:163): attn_dims() derives head_dim from get_u64(text, &["hidden_size", "d_model", "dim", "model_dim"])? when no explicit head_dim is present. Both families write n_embd, so even with the layer count repaired the head dim would still be underivable.
Head count is already correct: the num_heads lookup at kv_arch.rs:144 does alias n_head, so only the two fields above are missing.
estimate_total_memory maps the resulting None to KvSource::Unavailable and reports zero bytes (memory_estimate.rs:386-390). The same root cause hits a sibling parser: activation_dims_from_path (memory_estimate.rs:510) uses the same hidden-size alias list without n_embd, so the activation reserve collapses to zero as well.
Observed behavior
Run against real checkpoints with target/release/mlxcel. First reported at 051526fe2 and re-run on main at a3c823611, after #928, #930 and #946 merged; the figures are unchanged:
Both families are reported as having no KV cache and no activation reserve at all, which understates the real footprint of a long-context run.
Which families are affected
Only GPT-2 and GPT-BigCode. The three text families that merged after this issue was filed were each checked against a real checkpoint on main at a3c823611 and all three resolve normally, because all three use the modern field naming:
448.0 MiB (469762048), standard attention (28 layers, full context)
Bailing MoE was checked specifically because MoE geometry could have diverged: it does not. The sparse block changes the FFN only, the attention stack is ordinary GQA, and num_key_value_heads: 4 is picked up by the existing alias list (28 layers x 4 kv heads x 128 head_dim x 2 x 2 bytes = 57,344 bytes/token, x 8192 = 469,762,048). Activation reserves are non-zero for all three as well (21072128, 19756800 and 7330816 bytes respectively). None of them needs a change here, and none should regress.
Scope of impact today
This is a reporting defect, not a correctness or stability defect. Filed at low priority deliberately:
Paged decode never reaches these models.LanguageModel::supports_paged_decode_backend() defaults to false (src/lib/mlxcel-core/src/generate.rs:601) and neither src/models/gpt2.rs nor src/models/gpt_bigcode.rs overrides it, so the scheduler's paged_available gate (src/server/batch/scheduler.rs:199) is never satisfied. The block-budget consumer of this estimator is unreachable for GPT-2 and GPT-BigCode.
The failure is graceful, not silent-wrong.paged_block_bytes (memory_estimate.rs:810) returns None when the per-layer per-token rate is zero, and resolve_paged_block_budget propagates that None, which leaves the pool unbounded rather than installing a bogus cap. Zero is a fail-open value here, not a fail-wrong one.
The live user-visible surfaces are mlxcel inspect (the two runs above) and the KV cache mode advisor: advise_kv_cache_modes_from_config (kv_cache_advisor.rs:285) resolves its architecture through the same classifier and returns an empty advice vector when the classifier gives up, so no KV cache mode suggestions are printed for either family.
Expected behavior
Both families should classify as KvArchKind::Standard and produce a real figure. With fp16 KV at ctx 8192, batch 1:
The activation reserve should likewise be non-zero for both, since n_embd is present and n_inner / the 4 x hidden fallback covers the intermediate size.
The trap: adding the aliases alone over-estimates GPT-BigCode by 16x
Read this before implementing. The obvious fix, appending n_layer and n_embd to the two alias lists, is wrong for GPT-BigCode.
Every other family in this codebase expresses grouped or multi-query attention through a numeric field, and the num_kv_heads lookup at kv_arch.rs:150 reflects exactly that: num_key_value_heads, num_kv_heads, n_kv_heads, n_head_kv, multi_query_group_num. When none of them is present, it falls back to num_kv_heads = num_heads, that is, full MHA.
GPT-BigCode carries none of those fields. It signals multi-query attention with a boolean, multi_query: true, which means exactly one kv head shared across all query heads. models/gpt_bigcode-santacoder/config.json has "n_head": 16 and "multi_query": true, and the model implementation agrees: GptBigCodeArgs::num_kv_heads() (src/models/gpt_bigcode.rs:280) returns if self.multi_query { 1 } else { self.n_head }, with src/models/gpt_bigcode_tests.rs:672 asserting the cache holds one kv head, not n_head.
So an alias-only fix would take the num_kv_heads = num_heads fallback and estimate 16 kv heads where the runtime caches 1:
kv heads used
Total at ctx 8192, fp16
Today
(no estimate)
0 bytes
Alias-only "fix"
16
1,610,612,736 (1.50 GiB)
Correct
1
100,663,296 (96 MiB)
A 16x over-estimate that looks plausible is worse than an obvious zero: it would reserve 1.5 GiB of phantom budget and, once these families ever become pool-backed, shrink the admission budget accordingly. kv_arch.rs needs an explicit branch for the boolean multi_query, not just two more strings in the alias lists.
Acceptance criteria
kv_arch.rs layer-count lookup (classify, around line 244) accepts n_layer in addition to the existing num_hidden_layers / n_layers / num_layers.
kv_arch.rs hidden-size lookup (attn_dims, around line 163) accepts n_embd in addition to the existing hidden_size / d_model / dim / model_dim.
The num_kv_heads resolution honors a boolean multi_query: true as exactly one shared kv head, taking precedence over the num_kv_heads = num_heads MHA fallback. multi_query: false and an absent multi_query both keep the existing MHA behavior.
estimate_kv_arch for models/gpt_bigcode-santacoder at ctx 8192 / fp16 / batch 1 returns 100,663,296 bytes (96 MiB) and KvArchKind::Standard, not the 1.50 GiB an alias-only change would produce.
estimate_kv_arch for models/gpt2 at ctx 8192 / fp16 / batch 1 returns 301,989,888 bytes (288 MiB) and KvArchKind::Standard.
mlxcel inspect -m models/gpt2 and mlxcel inspect -m models/gpt_bigcode-santacoder both report a non-zero KV cache line sourced from the config, and no longer print "unavailable (config.json missing architecture fields)". Both families are covered, not only the one that triggered this report.
The activation reserve is non-zero for both families: activation_dims_from_path (memory_estimate.rs:510) resolves the hidden size from n_embd.
kv_cache_params_from_path (memory_estimate.rs:710 onward, alias lists from line 722), which duplicates both alias lists and the kv-head fallback, is brought in line so it does not disagree with the classifier.
advise_kv_cache_modes returns a non-empty advice vector for both families.
Unit tests cover: GPT-2 style config (n_layer / n_embd / n_head, no multi_query) classifying as standard MHA; GPT-BigCode style config with multi_query: true yielding one kv head; and the same config with multi_query: false degenerating to n_head kv heads.
No regression in the existing kv_arch test suite; families that already resolve their layer count and hidden size keep byte-identical estimates. Verified concretely against the three families checked above: models/pythia-1b stays at 1073741824 bytes, models/helium-1-preview-2b-4bit at 2013265920, and models/ling-lite-1.5 at 469762048.
Technical considerations
The multi_query branch belongs in attn_dims next to the existing num_kv_heads lookup, ordered so an explicit numeric field still wins if a config somehow carries both. Guard against the degenerate multi_query: true plus n_head: 0 shape the model-side validation already rejects.
read_head_dim in kv_cache_advisor.rs (around line 347) carries the same hidden-size alias list minus n_embd. It currently returns None for both families, and because turbo_ok uses is_none_or, the non-power-of-two Turbo guard defaults to permissive. GPT-2 (64) and GPT-BigCode (128) are both powers of two so nothing misbehaves today, but the list should stay consistent with attn_dims as the doc comment on that function already promises.
Consider factoring the repeated alias lists into shared constants. They are currently spelled out four times across kv_arch.rs, memory_estimate.rs, and kv_cache_advisor.rs, which is why this gap reproduced in every one of them.
References
Surfaced while reviewing #926 (GPT-BigCode, issue #834); the same gap is already present in the merged #924 (GPT-2, issue #833).
Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the machine build facts below, and the warning that CI runs no cargo test. The fix here is estimator arithmetic whose only gate is a local test run plus mlxcel inspect against the real checkpoints, so a passing PR check proves nothing about it.
Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
Every cargo invocation needs DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails with xcrun: error: unable to find utility "metal". xcode-select still points at CommandLineTools.
Only the RELEASE profile is warm. A debug cargo check triggers a COLD MLX C++ build that takes many minutes. Use DEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.
Narrow test selectors only: DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A bare cargo test, cargo test --lib, or cargo clippy --workspace --all-targets runs long enough to stall an agent with a stream-idle watchdog, and a cold cargo build --release can exceed it on the MLX C++ link step alone.
cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.
tracing::warn! is a NO-OP in the mlxcel CLI binary; only src/server/startup.rs installs a subscriber. Use eprintln! for any CLI-facing diagnostic.
A test that trips an MLX C++ throw aborts the whole test binary with SIGABRT rather than failing cleanly. That is expected, and the abort is stronger evidence than a clean assertion failure. One SIGABRT on the first run after a fresh link that does not reproduce on reruns is a cold Metal-initialization race, observed independently by two reviewers.
Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv, pip install mlx-lm, and drive mlx_lm.generate.stream_generate with make_sampler(temp=0.0), printing both text and token ids. Then compare against mlxcel generate. Pass --no-chat-template to mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.
Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note mlxcel_core::utils::gelu_approx is erf-based despite its name, while MLX Python's nn.gelu_approx really is the tanh form.
A recurring defect class worth checking for
A value from config.json passes every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returning UniquePtr<MlxArray> rather than Result, so the C++ throw is an uncatchable std::terminate, not something catch_unwind contains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.
Specifics established by reading the pinned MLX checkout rather than assuming:
Gathers do NOT range-check positive indices. take wraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.
slice CLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.
fast_rope requires dims even, positive, and no larger than the last axis.
rms_norm and layer_norm never inspect eps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.
matmul throws on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.
quantized_matmul divides by bits, so "bits": 0 is a division by zero. On AArch64 the divide returns 0 and std::invalid_argument fires; on x86-64 the hardware raises SIGFPE and kills the process before any exception exists.
Reconstruct a quantized input width the way MLX does, as scales.shape(-1) * group_size, and check .biases shape equals .scales shape. A self-consistency check cannot catch a checkpoint honestly packed for the wrong hidden_size, because such a checkpoint IS self-consistent.
Put zero checks BEFORE divisibility checks, because 0.is_multiple_of(0) is true.
Avoid unbounded probe loops over 0..n_layer in the load path: a huge n_layer hangs with a flat allocation footprint, so no OOM kill rescues it.
src/models/gpt_neox.rs and src/models/helium.rs carry the current ModelArgs::validate / validate_weights shape to copy from.
Conventions that bite
The // Used by: comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.
mlxcel arch is the architecture registry. mlxcel list lists downloaded models and will not tell you whether a family is supported.
Additional consequence found during the security review of #926, beyond the mlxcel inspect reporting gap already described above.
The paged KV pool is left unbounded on the server path. The chain is classify returning None, then paged_block_bytes returning None, and src/server/model_worker.rs:729 logging "KV geometry is unavailable; leaving the paged pool unbounded". So --kv-cache-budget auto has no admission ceiling for these families, and a StarCoder-class deployment taking a long-context burst gets no back-pressure. This is still fail-open rather than fail-wrong, so the priority does not change, but it is a second user-visible effect rather than a reporting-only one.
The over-estimation factor scales with head count, so it is model-specific. The 16x figure above is for gpt_bigcode-santacoder (n_head 16). For a StarCoder checkpoint with n_head 48 the naive alias-only fix over-reserves by 48x. The general statement is that an alias-only fix over-reserves by exactly n_head, because attn_dims has no multi_query key and falls back to num_kv_heads = num_heads while the runtime caches exactly one.
This reinforces the acceptance criteria already listed: the field aliases, the multi_query branch, and the duplicated alias lists in src/execution/memory_estimate.rs and src/execution/kv_cache_advisor.rs all have to land together, or the result is worse than the current zero.
Problem / Background
src/execution/kv_arch.rsclassifies a model's KV geometry by reading field names out ofconfig.json. The lookups accept the modern HuggingFace spellings plus several alternates, but never the OpenAI-era names that GPT-2 and GPT-BigCode configs actually use.kv_arch.rs:244):classify()readsget_u64(text, &["num_hidden_layers", "n_layers", "num_layers"])?. Both families writen_layer(singular, no plural alias). The lookup returnsNone, the?short-circuitsclassify(), and the entire estimate becomesNone.kv_arch.rs:163):attn_dims()deriveshead_dimfromget_u64(text, &["hidden_size", "d_model", "dim", "model_dim"])?when no explicithead_dimis present. Both families writen_embd, so even with the layer count repaired the head dim would still be underivable.num_headslookup atkv_arch.rs:144does aliasn_head, so only the two fields above are missing.estimate_total_memorymaps the resultingNonetoKvSource::Unavailableand reports zero bytes (memory_estimate.rs:386-390). The same root cause hits a sibling parser:activation_dims_from_path(memory_estimate.rs:510) uses the same hidden-size alias list withoutn_embd, so the activation reserve collapses to zero as well.Observed behavior
Run against real checkpoints with
target/release/mlxcel. First reported at051526fe2and re-run onmainata3c823611, after #928, #930 and #946 merged; the figures are unchanged:Both families are reported as having no KV cache and no activation reserve at all, which understates the real footprint of a long-context run.
Which families are affected
Only GPT-2 and GPT-BigCode. The three text families that merged after this issue was filed were each checked against a real checkpoint on
mainata3c823611and all three resolve normally, because all three use the modern field naming:config.jsonmlxcel inspectKV at ctx 8192 / fp16gpt_neox(#836, PR #928)models/pythia-1bnum_hidden_layers16,hidden_size2048,num_attention_heads8helium(PR #930)models/helium-1-preview-2b-4bitnum_hidden_layers24,hidden_size2560,num_attention_heads20,num_key_value_heads20, explicithead_dim128bailing_moe(PR #946)models/ling-lite-1.5num_hidden_layers28,hidden_size2048,num_attention_heads16,num_key_value_heads4Bailing MoE was checked specifically because MoE geometry could have diverged: it does not. The sparse block changes the FFN only, the attention stack is ordinary GQA, and
num_key_value_heads: 4is picked up by the existing alias list (28 layers x 4 kv heads x 128 head_dim x 2 x 2 bytes = 57,344 bytes/token, x 8192 = 469,762,048). Activation reserves are non-zero for all three as well (21072128, 19756800 and 7330816 bytes respectively). None of them needs a change here, and none should regress.Scope of impact today
This is a reporting defect, not a correctness or stability defect. Filed at low priority deliberately:
LanguageModel::supports_paged_decode_backend()defaults tofalse(src/lib/mlxcel-core/src/generate.rs:601) and neithersrc/models/gpt2.rsnorsrc/models/gpt_bigcode.rsoverrides it, so the scheduler'spaged_availablegate (src/server/batch/scheduler.rs:199) is never satisfied. The block-budget consumer of this estimator is unreachable for GPT-2 and GPT-BigCode.paged_block_bytes(memory_estimate.rs:810) returnsNonewhen the per-layer per-token rate is zero, andresolve_paged_block_budgetpropagates thatNone, which leaves the pool unbounded rather than installing a bogus cap. Zero is a fail-open value here, not a fail-wrong one.mlxcel inspect(the two runs above) and the KV cache mode advisor:advise_kv_cache_modes_from_config(kv_cache_advisor.rs:285) resolves its architecture through the same classifier and returns an empty advice vector when the classifier gives up, so no KV cache mode suggestions are printed for either family.Expected behavior
Both families should classify as
KvArchKind::Standardand produce a real figure. With fp16 KV at ctx 8192, batch 1:models/gpt2n_layer12,n_head12,n_embd768models/gpt_bigcode-santacodern_layer24,n_head16,n_embd2048,multi_querytrueThe activation reserve should likewise be non-zero for both, since
n_embdis present andn_inner/ the4 x hiddenfallback covers the intermediate size.The trap: adding the aliases alone over-estimates GPT-BigCode by 16x
Read this before implementing. The obvious fix, appending
n_layerandn_embdto the two alias lists, is wrong for GPT-BigCode.Every other family in this codebase expresses grouped or multi-query attention through a numeric field, and the
num_kv_headslookup atkv_arch.rs:150reflects exactly that:num_key_value_heads,num_kv_heads,n_kv_heads,n_head_kv,multi_query_group_num. When none of them is present, it falls back tonum_kv_heads = num_heads, that is, full MHA.GPT-BigCode carries none of those fields. It signals multi-query attention with a boolean,
multi_query: true, which means exactly one kv head shared across all query heads.models/gpt_bigcode-santacoder/config.jsonhas"n_head": 16and"multi_query": true, and the model implementation agrees:GptBigCodeArgs::num_kv_heads()(src/models/gpt_bigcode.rs:280) returnsif self.multi_query { 1 } else { self.n_head }, withsrc/models/gpt_bigcode_tests.rs:672asserting the cache holds one kv head, notn_head.So an alias-only fix would take the
num_kv_heads = num_headsfallback and estimate 16 kv heads where the runtime caches 1:A 16x over-estimate that looks plausible is worse than an obvious zero: it would reserve 1.5 GiB of phantom budget and, once these families ever become pool-backed, shrink the admission budget accordingly.
kv_arch.rsneeds an explicit branch for the booleanmulti_query, not just two more strings in the alias lists.Acceptance criteria
kv_arch.rslayer-count lookup (classify, around line 244) acceptsn_layerin addition to the existingnum_hidden_layers/n_layers/num_layers.kv_arch.rshidden-size lookup (attn_dims, around line 163) acceptsn_embdin addition to the existinghidden_size/d_model/dim/model_dim.num_kv_headsresolution honors a booleanmulti_query: trueas exactly one shared kv head, taking precedence over thenum_kv_heads = num_headsMHA fallback.multi_query: falseand an absentmulti_queryboth keep the existing MHA behavior.estimate_kv_archformodels/gpt_bigcode-santacoderat ctx 8192 / fp16 / batch 1 returns 100,663,296 bytes (96 MiB) andKvArchKind::Standard, not the 1.50 GiB an alias-only change would produce.estimate_kv_archformodels/gpt2at ctx 8192 / fp16 / batch 1 returns 301,989,888 bytes (288 MiB) andKvArchKind::Standard.mlxcel inspect -m models/gpt2andmlxcel inspect -m models/gpt_bigcode-santacoderboth report a non-zero KV cache line sourced from the config, and no longer print "unavailable (config.json missing architecture fields)". Both families are covered, not only the one that triggered this report.activation_dims_from_path(memory_estimate.rs:510) resolves the hidden size fromn_embd.kv_cache_params_from_path(memory_estimate.rs:710onward, alias lists from line 722), which duplicates both alias lists and the kv-head fallback, is brought in line so it does not disagree with the classifier.advise_kv_cache_modesreturns a non-empty advice vector for both families.n_layer/n_embd/n_head, nomulti_query) classifying as standard MHA; GPT-BigCode style config withmulti_query: trueyielding one kv head; and the same config withmulti_query: falsedegenerating ton_headkv heads.kv_archtest suite; families that already resolve their layer count and hidden size keep byte-identical estimates. Verified concretely against the three families checked above:models/pythia-1bstays at 1073741824 bytes,models/helium-1-preview-2b-4bitat 2013265920, andmodels/ling-lite-1.5at 469762048.Technical considerations
multi_querybranch belongs inattn_dimsnext to the existingnum_kv_headslookup, ordered so an explicit numeric field still wins if a config somehow carries both. Guard against the degeneratemulti_query: trueplusn_head: 0shape the model-side validation already rejects.read_head_diminkv_cache_advisor.rs(around line 347) carries the same hidden-size alias list minusn_embd. It currently returnsNonefor both families, and becauseturbo_okusesis_none_or, the non-power-of-two Turbo guard defaults to permissive. GPT-2 (64) and GPT-BigCode (128) are both powers of two so nothing misbehaves today, but the list should stay consistent withattn_dimsas the doc comment on that function already promises.kv_arch.rs,memory_estimate.rs, andkv_cache_advisor.rs, which is why this gap reproduced in every one of them.References
Surfaced while reviewing #926 (GPT-BigCode, issue #834); the same gap is already present in the merged #924 (GPT-2, issue #833).
Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the machine build facts below, and the warning that CI runs no
cargo test. The fix here is estimator arithmetic whose only gate is a local test run plusmlxcel inspectagainst the real checkpoints, so a passing PR check proves nothing about it.Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails withxcrun: error: unable to find utility "metal".xcode-selectstill points at CommandLineTools.cargo checktriggers a COLD MLX C++ build that takes many minutes. UseDEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A barecargo test,cargo test --lib, orcargo clippy --workspace --all-targetsruns long enough to stall an agent with a stream-idle watchdog, and a coldcargo build --releasecan exceed it on the MLX C++ link step alone.cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.cargo testat all (onlychanges,cargo-deny,cargo-fmt, and the cross-repo-ref guard). Local runs plus a real checkpoint are the only gate. Two deterministically failing tests were found sitting onmainduring this series for exactly that reason: see fix(mllama): ragged cross-attention test fails on main, and no CI job runs cargo test #939, and theFAMILY_ORDERentries fixed in feat(models): add Ant Group Ling / Bailing MoE (bailing_moe) text model support #946.tracing::warn!is a NO-OP in themlxcelCLI binary; onlysrc/server/startup.rsinstalls a subscriber. Useeprintln!for any CLI-facing diagnostic.Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv,
pip install mlx-lm, and drivemlx_lm.generate.stream_generatewithmake_sampler(temp=0.0), printing both text and token ids. Then compare againstmlxcel generate. Pass--no-chat-templateto mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note
mlxcel_core::utils::gelu_approxis erf-based despite its name, while MLX Python'snn.gelu_approxreally is the tanh form.A recurring defect class worth checking for
A value from
config.jsonpasses every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returningUniquePtr<MlxArray>rather thanResult, so the C++ throw is an uncatchablestd::terminate, not somethingcatch_unwindcontains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.Specifics established by reading the pinned MLX checkout rather than assuming:
takewraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.sliceCLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.fast_roperequiresdimseven, positive, and no larger than the last axis.rms_normandlayer_normnever inspecteps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.matmulthrows on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.quantized_matmuldivides bybits, so"bits": 0is a division by zero. On AArch64 the divide returns 0 andstd::invalid_argumentfires; on x86-64 the hardware raisesSIGFPEand kills the process before any exception exists.scales.shape(-1) * group_size, and check.biasesshape equals.scalesshape. A self-consistency check cannot catch a checkpoint honestly packed for the wronghidden_size, because such a checkpoint IS self-consistent.0.is_multiple_of(0)is true.0..n_layerin the load path: a hugen_layerhangs with a flat allocation footprint, so no OOM kill rescues it.src/models/gpt_neox.rsandsrc/models/helium.rscarry the currentModelArgs::validate/validate_weightsshape to copy from.Conventions that bite
// Used by:comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.mlxcel archis the architecture registry.mlxcel listlists downloaded models and will not tell you whether a family is supported.