Sitelet https://github.com/mlx-node/mlx-node/pull/76
Skip to content

feat(convert): quantize gemma4_unified (12B) — recognize + keep vision_embedder bf16 - #76

Merged
Brooooooklyn merged 5 commits into
mainfrom
feat/gemma4-unified-convert-q4
Jun 25, 2026
Merged

Brooooooklyn merged 5 commits into
mainfrom
feat/gemma4-unified-convert-q4

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

What

Enable mlx convert to quantize gemma4_unified (Gemma 4 12B — encoder-free text+vision+audio) checkpoints. The runtime loader already supports gemma4_unified (PR #74); only the converter didn't recognize it, and the default quant predicate would have corrupted one vision weight.

Motivating model: google/gemma-4-12B-it-qat-q4_0-unquantized (dense 12B, bf16, QAT-trained for llama.cpp q4_0). The best quality+speed target is 4-bit affine group_size 32 — near-bf16 quality at 4-bit memory/speed.

mlx convert <ckpt> -o <out> --quantize --q-mode affine --q-bits 4 --q-group-size 32

Changes

  1. Recognize gemma4_unified — Gemma4Recipe::model_types / recipe_for / CONVERTIBLE_MODEL_TYPES route it to the existing shared Gemma4Recipe (it is dense — no MoE split, sym8-supported, no MTP — so it behaves identically to gemma4 on every recipe_for-keyed path).
  2. Keep vision_embedder.* bf16 — should_quantize() excludes any key containing vision_embedder. The encoder-free unified vision patch projection is installed dense-bf16-only by the loader (apply_unified_vision_embedder_weights, no .scales branch), so quantizing patch_dense.weight would corrupt the vision path. embed_vision/embed_audio projections still quantize (they have affine loader branches). vision_embedder is unified-only → zero collateral on other families.
  3. Sanitize keeps vision_embedder.* bare — sibling of embed_vision.*, not mis-prefixed under language_model.model..
  4. CLI passes gemma4_unified through unchanged — not collapsed to 'gemma4'. The driver's model_type is the CLI value, and the gemma-QAT (wNa8o8) prequantized importer gate is exact Some("gemma4"); collapsing would (a) dead-code the native recipe_for("gemma4_unified") arm and (b) misroute a gemma-QAT unified checkpoint into the E2B-only importer.

End-to-end validation

Converted the real 23GB checkpoint → 8.4GB {bits:4, group_size:32, mode:affine}:

  • auto-detected gemma4_unified (passthrough confirmed)
  • vision_embedder.* → bf16, no .scales ✓ · text / embed_vision / embed_audio → quantized (U32 + scales) ✓
  • model loads and generates coherent text (e.g. "The capital of France is Paris.")

Tests

  • Rust: should_quantize excludes vision_embedder (with positive controls that embed_vision + text layers still quantize); gemma4_unified resolves to a recipe + is in CONVERTIBLE_MODEL_TYPES; sanitize keeps vision_embedder.* bare; extended recipe_registry_reproduces_inline_flags (sym8 allowlist).
  • TS: convert-cmd.test.ts drives run() and asserts the modelType handed to native is gemma4_unified (and gemma4/gemma4_text still map to gemma4).
  • Gates: convert:: 106 pass, fmt + clippy clean, CLI test 4/4, typecheck clean.

Review

Three /codex:adversarial-review cycles → final verdict approve, no material findings. The CLI-collapse misroute (cycle #1) was fixed; a flagged sanitize-test SIGABRT (cycle #2) was confirmed to be a review-harness/metallib environment artifact (the pre-existing gemma4_recipe_sanitize_transforms test aborts identically; CI runs one process per binary).

🤖 Generated with Claude Code


Note

Medium Risk
Changes conversion routing and quantization rules for a new model type (wrong passthrough could misroute QAT checkpoints); CI runner change is low risk but affects all macOS jobs.

Overview
Adds first-class gemma4_unified support in mlx convert so encoder-free Gemma 4 unified checkpoints can be quantized without breaking vision or misrouting QAT imports.

The native converter registers gemma4_unified on the shared Gemma4Recipe, excludes vision_embedder.* from quantization (bf16-only loader path), and keeps those weights at bare keys during sanitize. The CLI auto-detects gemma4_unified (including architecture-only configs) and passes the string through instead of collapsing to gemma4, avoiding the E2B prequantized importer gate.

CI moves macOS jobs to macos-26 and runs ignored cargo model e2e tests with --test-threads=1 to avoid Metal GPU watchdog timeouts on shared runners. Docs and TS/Rust tests cover detection, quant exclusions, and sanitize routing.

Reviewed by Cursor Bugbot for commit fb4e8e0. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 2 commits June 25, 2026 00:09
Wire the Gemma 4 12B "unified" encoder-free checkpoint (model_type
`gemma4_unified`, arch Gemma4UnifiedForConditionalGeneration) into the
`mlx convert` pipeline so `--q-mode affine --q-bits 4 --q-group-size 32`
produces a correct quantized model. The runtime loader already supports
it (PR #74); only the converter did not recognize it and the default
quant predicate would corrupt one vision weight.

- Route `gemma4_unified` to the existing `Gemma4Recipe`: extend
  `model_types()`, `recipe_for`, and `CONVERTIBLE_MODEL_TYPES`; treat it
  identically to `gemma4` in the registry-consistency sym8 allowlist.
- Extend the TS auto-detect so `model_type == "gemma4_unified"` maps to
  `gemma4`.
- Exclude `vision_embedder.*` from `should_quantize`: the loader installs
  it as dense bf16 only (no `.scales` branch), so quantizing
  `patch_dense.weight` would corrupt the unified vision path. `embed_vision`
  / `embed_audio` keep quantizing (they have affine loader branches).
- Keep `vision_embedder.*` at its bare key in `Gemma4Recipe::sanitize`
  (sibling of `embed_vision.*`) instead of mis-prefixing under
  `language_model.model.`.
- Add focused unit tests for the quant-skip, recipe recognition, and bare
  key placement; document `gemma4_unified` in docs/cli.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CLI auto-detect collapsed model_type 'gemma4_unified' to 'gemma4'
before calling native convertModel. That is wrong: native recipe_for
already resolves 'gemma4_unified' to the shared Gemma4Recipe, so passing
the raw string yields identical behavior for every recipe-keyed path
(sym8_supported, sanitizer, embed_quantizable, mtp_policy, etc.), while
collapsing it (a) made the native recipe_for("gemma4_unified") arm dead
code and (b) misrouted a unified checkpoint carrying gemma-QAT metadata
into the E2B-only prequantized importer, whose gate keys on the exact
string "gemma4" and then hard-errors in validate_e2b_qat_schedule.

Pass 'gemma4_unified' through unchanged; keep 'gemma4'/'gemma4_text'
collapsing to 'gemma4'. Add a regression test that drives run() against a
synthetic config.json and asserts the modelType handed to the mocked
native convertModel: 'gemma4_unified' stays 'gemma4_unified', while
'gemma4' and 'gemma4_text' still resolve to 'gemma4'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 10934f80-f595-4ff9-a6c9-645308437902

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gemma4-unified-convert-q4

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce4623db9b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/cli/src/commands/convert.ts Outdated
@Brooooooklyn Brooooooklyn added the model-e2e Run the heavy Model E2E workflow (per-family real-checkpoint tests) on this PR label Jun 25, 2026
Brooooooklyn and others added 3 commits June 25, 2026 09:14
The runtime loader treats an architecture-only unified config (no
`model_type`, only `architectures:
['Gemma4UnifiedForConditionalGeneration']`) as unified
(model-loader.ts maps it to gemma4; persistence.rs parse_config sets
is_unified on EITHER model_type == "gemma4_unified" OR that
architecture). The CLI converter auto-detect inspected only
`config.model_type`, so such a config left modelType undefined, skipped
Gemma4Recipe::sanitize on the native side, and produced unloadable
output.

Extend the gemma4_unified auto-detect arm to also match the
architecture-only shape, mapping to the same 'gemma4_unified'
pass-through string (not 'gemma4', which would re-introduce the
E2B-importer misroute the exact-"gemma4" gate guards against). Adds a
regression test covering the architecture-only config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `e2e <family>` cargo legs run `cargo test … -- --ignored` with no
thread cap, so libtest's default per-test thread pool (= nproc) launches
several `#[ignore]` tests at once. Each loads 2-4 full models (lfm2's
`lfm2_paged_vs_flat_parity` has 6 such tests, ~8 LFM2-1.2B live at peak),
all driving the single shared Metal GPU. Under that oversubscription on
the shared macos-latest runner a command buffer occasionally waits past
the GPU watchdog -> kIOGPUCommandBufferCallbackErrorTimeout thrown at
device.cpp:456 -> uncaught -> SIGABRT, flaking the leg (observed on
`lfm2_paged_vs_flat_greedy_token_parity`; a no-change rerun went green,
confirming a resource flake, not a regression).

Serialize the cargo e2e tests with `--test-threads=1`. A single shared
GPU means the tests never actually ran in parallel — parallelism only
piled on GPU memory/queue pressure — so this costs ~no wall time. The
parity/oracle comparisons and `temperature=0` greedy determinism are
unchanged, so a real regression still fails. The gemma4 TS leg is
single-instance Vitest and unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`runs-on: macos-latest` resolves to a HETEROGENEOUS pool (macOS 26 and
macOS 15 both present). The Build job compiles the Metal shaders with
`xcrun -sdk macosx metal` and no `-std=metal` pin, so it inherits the
builder's SDK default Metal Shading Language version. When Build lands on
macOS 26 it emits an MSL 4.0 `mlx.metallib` / `paged_attn.metallib`; that
artifact is then downloaded by the consumer jobs (yarn test, and the
`yarn mlx convert` step of the e2e legs, which use the prebuilt addon).
A consumer that lands on macOS 15 cannot load it:

  Failed to load the default metallib. This library is using language
  version 4.0 which is not supported on this device.

-> C++ exception across the Rust FFI boundary -> "fatal runtime error:
Rust cannot catch foreign exceptions, aborting" -> convert never writes
its output -> the lfm2_session tests panic "model path does not exist".
Pure runner-pool roulette (which OS the builder vs each consumer draws),
so it surfaces intermittently.

Pin every macOS job to `macos-26` so the builder and all consumers share
one Metal version (MSL 4.0 loads on every macos-26 runner). The single
ubuntu publish job is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn merged commit 4be27cf into main Jun 25, 2026
11 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/gemma4-unified-convert-q4 branch June 25, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model-e2e Run the heavy Model E2E workflow (per-family real-checkpoint tests) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant