Skip to content

fix: KV estimation misses n_layer / n_embd for GPT-2 and GPT-BigCode #927

Description

@inureyes

Problem / Background

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:

$ ./target/release/mlxcel inspect -m models/gpt2

=== Memory Estimate ===
  Model:           models/gpt2
  Context length:  8192 tokens (batch = 1)
  Quant hint:      default (from config.json)
  KV dtype:        fp16

  Weights:         522.7 MiB (548090880 bytes)  (safetensors header)
  KV cache:        0 bytes  (unavailable (config.json missing architecture fields))
  Activation:      0 bytes  (batch 1 x <=512 prefill tokens x (hidden+intermediate) + logits)
  Allocator ovhd:  104.5 MiB (109618175 bytes)  (factor 1.20x on weights+kv)
  -----
  Total estimate:  627.2 MiB (657709055 bytes)
  Available:       121.60 GiB (130567005798 bytes)

  FITS: 120.99 GiB (129909296743 bytes) of headroom
$ ./target/release/mlxcel inspect -m models/gpt_bigcode-santacoder

=== Memory Estimate ===
  Model:           models/gpt_bigcode-santacoder
  Context length:  8192 tokens (batch = 1)
  Quant hint:      default (from config.json)
  KV dtype:        fp16

  Weights:         2.10 GiB (2249773056 bytes)  (safetensors header)
  KV cache:        0 bytes  (unavailable (config.json missing architecture fields))
  Activation:      0 bytes  (batch 1 x <=512 prefill tokens x (hidden+intermediate) + logits)
  Allocator ovhd:  429.1 MiB (449954611 bytes)  (factor 1.20x on weights+kv)
  -----
  Total estimate:  2.51 GiB (2699727667 bytes)
  Available:       121.60 GiB (130567005798 bytes)

  FITS: 119.09 GiB (127867278131 bytes) of headroom

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:

Family Checkpoint Naming in config.json mlxcel inspect KV at ctx 8192 / fp16
gpt_neox (#836, PR #928) models/pythia-1b num_hidden_layers 16, hidden_size 2048, num_attention_heads 8 1.00 GiB (1073741824), standard attention (16 layers, full context)
helium (PR #930) models/helium-1-preview-2b-4bit num_hidden_layers 24, hidden_size 2560, num_attention_heads 20, num_key_value_heads 20, explicit head_dim 128 1.88 GiB (2013265920), standard attention (24 layers, full context)
bailing_moe (PR #946) models/ling-lite-1.5 num_hidden_layers 28, hidden_size 2048, num_attention_heads 16, num_key_value_heads 4 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:

Model Config kv heads head_dim Bytes per token Total at ctx 8192
models/gpt2 n_layer 12, n_head 12, n_embd 768 12 (MHA) 64 36,864 301,989,888 (288 MiB)
models/gpt_bigcode-santacoder n_layer 24, n_head 16, n_embd 2048, multi_query true 1 128 12,288 100,663,296 (96 MiB)

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.
  • CI runs no cargo test at all (only changes, 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 on main during 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 the FAMILY_ORDER entries fixed in feat(models): add Ant Group Ling / Bailing MoE (bailing_moe) text model support #946.
  • 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.

Activity

  1. added
    type:bugBug fixes, error corrections, or issue resolutions
    area:coremlxcel-core: MLX FFI, primitives, KV cache, layers
    on Jul 26, 2026
  2. inureyes commented on Jul 26, 2026

    @inureyes
    MemberAuthor

    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.

  3. added a commit that references this issue on Jul 30, 2026
    94782d8
  4. self-assigned this
    on Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:coremlxcel-core: MLX FFI, primitives, KV cache, layerspriority:lowLow prioritystatus:doneCompletedtype:bugBug fixes, error corrections, or issue resolutions

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions