Repository navigation
fix(lora): pin runtime LoRA term widths on quantized linear bases - #2438
Merged
Merged
Conversation
The runtime loader checks a pair on a quantized base only through the scales group count, so up to four input widths pass and the wrong ones abort the process at the first forward with an MLX shape mismatch. The linear constructors now claim terms through `runtime_lora::claim_linear`, which checks every term against the layer's real widths (groups times the reconciled group size for a packed weight, the weight's columns for a dense one), in `Linear::from_weights`, the quantized arm of `UnifiedLinear::from_weights_with_mode`, and per projection in `FusedQKVLinear`. A mismatch fails the load with the layer named, as `EmbeddingLoraTerms::new` does for embedding tables. The fusion loop refuses any base weight with a `.scales` plane, extending the embedding-table refusal to linear weights. A delta as wide as the packed plane used to pass the shape guard and be added to the packed words; a valid adapter already failed that guard, so only its message changes. Closes #2437
…n up front `claim_linear` now checks the staged terms, and the tied-head form of any embedding term for the prefix, before taking them out of staging. A caller that maps a constructor error to an absent layer (Nemotron-H builds its optional projections with `.ok()`) then still leaves the term staged, and the load fails as unclaimed instead of serving a model without the layer. The width work is skipped when nothing is staged. The fused-path refusal moves into an up-front pass next to the Conv1D verdict, so a refused adapter leaves the base weight map untouched, and it also refuses a pair that resolves to a quantization side plane (`.scales`, `.biases`, `.global_scale`), which used to be fused into the scales. Tests: the layout test covers a declared group size the reconciler overrides (mxfp4 stored at 32 under 64, the 4-bit gs16 fallback stored at 64), gates each block-float mode on native support and checks the error names the layer; new loader tests for the up-front refusal and the side-plane target.
This was referenced Oct 18, 2026
inureyes
added a commit
that referenced
this pull request
Oct 19, 2026
Five follow-ups left by #2438 and #2439, plus one blocker found while verifying the first. - `kv_arch` maps a Nemotron-H `layers_block_type` / `layer_types` array through `NEMOTRON_H_BLOCK_NAMES` and takes the layer count from its length, so `linear_attention` is no longer counted as attention. `estimate_kv_arch` now runs `sanitize_config_json` first: Nemotron-H configs carry `time_step_limit: [0.0, Infinity]`, and main reported the KV estimate as unavailable for them. - `optiq_vision_sidecar_path` moves into `mlxcel_core::weights`, and `collect_shard_paths` drops exactly the declared subdirectory sidecar before index validation, so OptiQ checkpoints no longer print the stale-index warning or take the glob fallback. - Text-only Muse Glimmer refuses prompts with image or video marker tokens at both the model worker and the VLM runtime sites. - `compute_lora_delta` casts both LoRA factors to f32 before the fusion matmul, as the runtime path does. An integer-typed adapter aborted the process before (exit 134). - Nemotron-H's four optional projections return `None` only when the weight is absent, so #2438's width error is the load error instead of a later "unclaimed" failure. Verified on GB10 (`MLXCEL_SDPA_DETERMINISTIC=1`, checkpoints under `~/models_archive/mlx`). `mlxcel inspect` on nvidia-nemotron-3-nano-30b-a3b-4bit and on its transformers 5 `layer_types` copy now reports 48.0 MiB over 6 attention layers instead of unavailable. lfm2.5-vl-3b-optiq-4bit and muse-glimmer-30b-optiq-4bit load without the warning and generate text identical to main. A text-only Muse view refuses an `<|image|>` prompt. llama-3.2-1b-instruct-bf16 with an int32 q_proj adapter aborts on main and here generates text identical to the same values stored as f32. Each of the six new tests fails with its fix reverted. Not in this PR: the downloader completeness check, the WebUI catalog and `weight_footprint_bytes` still treat the OptiQ sidecar as a bad shard (#2451). Closes #2445
inureyes
added a commit
that referenced
this pull request
Oct 19, 2026
Closes #2444 `nvidia/Qwen3-30B-A3B-NVFP4` (ModelOpt) failed to load on main with `1-bit weights are affine-only ... (prefix: model.layers.0.self_attn.q_proj)`. The fused QKV loader hardcoded `affine`, so the bias-free nvfp4 planes solved to a 1-bit affine layout, and the concatenated plane had no slot for each plane's `weight_scale_2`. ## Changes - `FusedQKVLinear::from_weights_separate` infers the mode from `q_proj` with `infer_quantization_mode`, as `UnifiedLinear::from_weights` does. - If any of q/k/v has a `{plane}.global_scale` sidecar, the layer loads as `QkvProjection::Split`. Each plane is built by `UnifiedLinear::from_weights_with_mode` at its reconciled layout, applies its scalar after the matmul (key name and meaning unchanged) and claims its own width-pinned runtime-LoRA terms (#2438). - The fused arm reconciles a block-float plane's group size from its shapes and checks the backend before storing it. Previously it stored the declared value, for example 64 for an mxfp4 plane packed at 32. - No per-layer struct gains a field (#2434). - Qwen3 and Qwen3-MoE `group_size()` fall back to `modelopt_nvfp4_group_size` (16), as PhiMoE does. This adds a `#[serde(default)] quantization_config` field to both `ModelArgs` and one line to each of three struct-literal test fixtures. - `finish_vlm_weights_common` and `load_gemma4_unified` (raw keys, before the sanitizer) run the native global-scale flag pass. - The filtered VLM loaders end in `finish_vlm_weights_common` too. There a subset variant skips a flagged plane with neither its weight nor its scale in the map. A loaded flagged plane without its scale still fails. - `split_fused_moe_key` handles `.native_global_scale` and `.global_scale`. The `[num_experts]` vector is copied unsplit to `gate_proj` and `up_proj` and renamed under `down_proj`, so an unflagged scale fails at its `switch_glu.gate_proj` prefix instead of being orphaned. - DeepSeek v1 `SwitchLinear::from_stacked_parts` refuses a plane that has only one of `.scales` / `.biases`, naming the prefix. Previously it built `Regular` and aborted in `gather_mm`. ## Validation (GB10, CUDA, every GPU run under gpu-lock) - `MLXCEL_SDPA_DETERMINISTIC=1 mlxcel generate -m ~/models_archive/hf-src/Qwen3-30B-A3B-NVFP4 -p "The capital of France is" -n 80 --temp 0 --show-reasoning` loads (main fails at load) and generates coherent text: "... Since the question is about the capital of France, the answer is Paris. ...". No mlx-lm teacher-forced trace: mlx-lm 0.32.2 refuses this checkpoint (55968 unexpected parameters, unstacked ModelOpt keys). - Greedy output is token-identical to main (`MLXCEL_SDPA_DETERMINISTIC=1`, same prompts) for `~/models_archive/mlx/laguna-xs-2.1-nvfp4` (with `--show-reasoning`), `olmoe-1b-7b-0924-nvfp4-gs` and `gemma-4-31b-it-nvfp4` (two prompts). This was re-checked on the final commit. - 10 new tests: - layers: nvfp4 split QKV with per-plane scales, its forward checked against `x @ dequantize(w).T * s`; mxfp4 QKV fused at group 32. - qwen3 / qwen3_moe: ModelOpt-only `group_size() == 16`. - Gemma 4 Unified: flagged and unflagged fused experts. - VLM: a shipped `.native_global_scale` is refused, a flagged scale is resolved, and a filtered load skips planes outside the filter. - DeepSeek: the half-quantized plane. - Each test fails with its fix reverted and passes with it restored. - These filters pass: `layers::`, `runtime_lora`, `one_bit` (mlxcel-core) and `qwen3 vlm deepseek:: switch_layers sanitize` (mlxcel). Clippy `--lib --tests -D warnings` is clean for both crates. fmt and the five contract checks pass. Not addressed here (pre-existing): the Kimi K3 VLM loader bypasses `finish_vlm_weights_common`, so a flagged export is refused cleanly at `SwitchLinear`. A dense `.global_scale` is not shape-validated, which is the same as `o_proj` and MLP on main.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #2437. A runtime LoRA pair on a quantized linear base is now checked against the layer's real input width when the layer claims it, and fusion refuses a quantized linear base by its
.scalesplane. Both reuse the mechanisms #2434 added for embedding tables.Problem
mlxcel-server --lora,mlxcel serve --lora):validate_pair_shapesinsrc/lora/runtime.rschecks a quantized base only through the scales group count, so any width within / groupsin {16, 32, 64, 128} passes. On qwen3-0.6b-4bit aq_projlora_aof width 512 loads and then aborts the process at warmup with[matmul] Last dimension of first input with shape (1,1,1024) must match ... (512,8)(SIGABRT, exit 134).mlxcel generate --adapter,--lora-fuse, the pipeline stage loader): the shape guard compares the delta with the packed plane. A delta of widthin * bits / 32matches it and is added to the packed words; the plane becomes f32 and the first forward aborts with[quantized_matmul] The weight matrix should be uint32 but received float32.Fix
src/lib/mlxcel-core/src/runtime_lora.rs:claim_linear(prefix, in, out)checks every staged term for the prefix againsta_t == [in, r]andb_t == [r, out], modeled onEmbeddingLoraTerms::new, and only then claims them. The tied-head form of an embedding term staged for the prefix is checked the same way. The check runs before anything leaves staging, so a caller that maps a constructor error to an absent layer (Nemotron-H builds four optional projections withUnifiedLinear::from_weights(..).ok()) still fails the load as unclaimed instead of serving a model without the layer.src/lib/mlxcel-core/src/layers.rs: every linear claim goes throughclaim_linear. For a packed weightin = scales_cols * group_size(the reconciled group size inUnifiedLinear::from_weights_with_mode, the group size the concatenated plane carries inFusedQKVLinear), andoutis the weight's rows.FusedQKVLinearchecks each projection against its own planes.Linear::from_weightschecks the dense columns too, which the loader already enforces exactly, so this changes nothing for dense bases and also covers a tied head built from the embedding prefix. The width work is skipped when nothing is staged. No struct gains a field.src/lora/loader.rs: the.scalesrefusal that feat(lora): support mlx-lm LoRAEmbedding adapters on embed_tokens #2434 added for the embedding table moves into an up-front pass next to the Conv1D verdict (reject_quantized_base_fusion) and covers linear weights too, so a refused adapter leaves the base weight map untouched. It also refuses a pair that resolves to a quantization side plane itself (q_proj.scales.lora_aresolves toq_proj.scales; its delta matched the scales plane and was added to it). The message names the layer and points to--lora.Behavior change on the fused path
Before refusing, I checked what a valid adapter does today on a quantized base through the fused path. It never worked: there is no dequantize step before fusion (
load_model_with_adapterfuses into the raw safetensors map), and the[out, in]delta fails the shape guard against the[out, in * bits / 32]plane. On qwen3-0.6b-4bit with themlx_lm.lora-trained q_proj adapter, main 8744c87 answersLoRA shape mismatch fusing adapter layer model.layers.26.self_attn.q_proj ...: base is [2048, 128], delta is [2048, 1024]. So the refusal breaks no working case. It replaces that message with one that says why and where to go, and it closes the packed-width corruption. Fusing into a dequantized base is not added here.Verification
GB10,
--features cuda, test-fast profile, every GPU run undergpu-lock,MLXCEL_SDPA_DETERMINISTIC=1.Real checkpoint
qwen3-0.6b-4bit(affine 4-bit, group 64; at~/models/mlx/qwen3-0.6b-4bitfor the first runs, re-run on the final commit from~/models_archive/mlx/qwen3-0.6b-4bitafter the host's model directory moved there). Adapters derived from anmlx_lm.loraadapter (rank 8, scale 20, q_proj of layers 26 and 27):valid_qproj(both pairs as trained),bad_w512(layer 27lora_acut to[512, 8], passes the runtime loader's group-count check),packed_w128(only layer 27,lora_a[128, 8], as wide as the packed plane). "Before" is the main 8744c87 binary, "after" this branch. Server request:/v1/completions, prompt "The capital of France is", 16 tokens, temperature 0.mlxcel-server --lorabad_w512model.layers.27.self_attn.q_proj("takes 1024 input features and produces 2048"), no abortmlxcel serve --lorabad_w512mlxcel generate --adapterpacked_w128model.layers.27.self_attn.q_proj.weight is a quantized linear weight, exit 1mlxcel generate --adaptervalid_qprojLoRA shape mismatch(base [2048, 128], delta [2048, 1024]), exit 1mlxcel generate --adapterbad_w512LoRA shape mismatch, exit 1mlxcel-server --lorapacked_w128mlxcel-server --loravalid_qprojmlxcel-server --loraAfter a load error the server stays up and answers 503, which is its existing behavior for any failed model load.
Real-checkpoint gate
real_checkpoint_embedding_adapter_parity(ignored test) on the final commit: Llama-3.2-1B-Instruct-bf16 with #2434's adapter gives runtime vs fused 0.125 and stage vs fused 0.0000, scale 0 byte-identical to base, the same numbers #2434 reported. qwen3-0.6b-4bit runtime path: scale 0 byte-identical to base.Tests:
-p mlxcel-core runtime_lora: 11 passed, 3 new.a_term_of_the_wrong_width_on_a_quantized_linear_fails_the_claimstages widths 64, 128 and 512 (all accepted by the loader) on a 4-bit group-64[8, 256]UnifiedLinearand expectsErrwith the refused term still staged, plus a wrong output width, then loads the right width and checks the forward value.every_quantized_linear_layout_pins_the_term_widthcovers affine at bits 2, 3, 4, 5, 6, 8 and group sizes 32, 64, 128, mxfp4, mxfp8 and nvfp4 (each block-float mode only where the backend runs it natively), and two layouts whose declared group size the reconciler overrides (mxfp4 stored at 32 under a declared 64, the 4-bit group-16 fallback stored at 64).fused_qkv_pins_each_projection_term_to_its_own_planechecks the fused q/k/v path per plane and the forward of the right width.lora::: 69 passed, 3 new:the_fusion_loop_refuses_a_packed_width_delta_on_a_quantized_linear(a real 4-bit packed plane, delta as wide as it),a_quantized_target_is_refused_before_any_dense_pair_is_written,a_pair_aimed_at_a_quantization_side_plane_is_refused.loading::adapter_: 9 passed, 1 ignored.llama::adapter_tests: 4 passed.partial_loading_adapter: 16 passed.claim_linearandreject_quantized_base_fusiondisabled, the 3 new core tests and the 3 new loader tests fail (along with the existing feat(lora): support mlx-lm LoRAEmbedding adapters on embed_tokens #2434 embedding refusal test, which shares that refusal); restored, they pass.cargo clippy --profile test-fast --features cuda --lib --tests -- -D warningsfor themlxcelandmlxcel-corepackages,cargo fmt --all -- --check, and the contract scripts (check_llama_compat_manifest,check_kernel_dtype_keys,check_kernel_port_dispatch,check_crate_versions,insert_apache_header --check) pass.Docs:
docs/server-features.mdstates that only the unfused path applies adapters on a quantized checkpoint, and that it pins each pair's width at load.Changes during review
.ok()constructors would have turned a wrong-width term into a silently missing projection. The check now runs before the claim.q_proj.scalesfused into the scales plane; the up-front refusal now covers quantization side planes. The refusal also moved ahead of the first write.Not addressed
lora_a/lora_btensors still reachmatmuluncast on the fused path, where MLX throws for non-floating types. This predates the PR, applies to dense bases as well, and is a separate fix.