Repository navigation
fix(models): settle the GELU formula for the families #2423 left open - #2785
Conversation
Apply one rule to every GELU call site #2423 listed as an open decision: the checkpoint config, or the Hugging Face config-class default when the key is omitted, decides the formula as ACT2FN evaluates it. Where the mlx-lm or mlx-vlm port departs from Hugging Face, follow Hugging Face and record the divergence. The per-row decisions and reasons are in docs/gelu-call-sites.md section 0b. - Gemma: the dense bf16 GeGLU kernel (Gemma v1, Gemma 4 dense MLP, Gemma 4 assistant drafter), the Gemma 4 tensor-parallel gate, Gemma3n gelu_topk and the RecurrentGemma MLP now use the tanh GELU in the input dtype, matching the quantized kernels. The Gemma3n XLA sparse GELU tape and host oracle moved with it. - Phi, Phixtral, GPT-2, StarCoder2 (eager), Moondream2/3 and the SigLIP-style towers of Phi-4-MM, DeepSeek-VL2, Youtu-VL, Jina-VLM, LFM2-VL, PaddleOCR-VL, Aya Vision, Granite Vision and Bunny switch from erf to the compiled f32 tanh GELU. The StarCoder2 XLA graph was already tanh and token-exact against the HF fp32 oracle, so eager moves to it. - Qwen2-VL, Molmo, LLaVA CLIP and Phi-3.5-V CLIP use quick_gelu, in eager and in the XLA emitters. - An omitted vision_config.hidden_act now resolves by tower model_type (SigLIP/SigLIP2 to tanh, CLIP to quick_gelu), and a declared quick_gelu parses as fast. - MiniCPM-o reads its vision activation from vision_config.hidden_act. - Nemotron-NAS parses hidden_act once at load: GELU names go through GeluActivation::from_hf_name instead of falling back to SiLU, and unknown names are a load error. - The Nemotron codec and VoiceChat TTS private GELU copies fold onto GeluActivation with tolerance tests. The fused single-kernel tanh GELU raised in the issue thread is deferred to a follow-up. Closes #2548
Implementation Review SummaryIntent
Findings AddressedNo CRITICAL or HIGH findings, so no code fixes. Checked and confirmed: each changed row matches the local checkpoint config or remote modeling code (StarCoder2 and Gemma v1 Implementer deviations: StarCoder2 eager moving to tanh is correct under rule 1 (the XLA goldens were frozen after a token-exact match against an HF oracle built with Remaining Items
Verification
Technical report added in |
Security and performance reviewReviewed at Untrusted config parsing
Hot-path activation per family
Kernel cache keysCompiled-graph modules are named from the op tape plus a constants hash, and the PTX disk cache keys on that module name. Erf to tanh changes the tape, so old entries are orphaned rather than reused. |
Row T17 claimed Moondream3 had no local checkpoint, but models/moondream3-preview-mlx-4bit exists and was smoke-tested after the tanh change. Rows G15 and G17 (and the summary row) labelled the Gemma3n compiled_gelu_topk path "tanh (input dtype)"; for bf16 inputs the eager kernel promotes the cutoff, shift and tanh to f32 and rounds once, while the XLA tape rounds each intermediate through bf16. The label now says so and records the eager/XLA gap as pre-existing, needing a same-input comparison on an IREE host. Validation: docs-only change; fmt and clippy checks run on the root crate, mlxcel-core and mlxcel-xla. Refs #2548
Five rule-breakers outside #2548's row list, all following the section-0b rule: the checkpoint config decides, an omitted key falls back to the Hugging Face config-class default, and mlxcel follows HF where mlx-lm or mlx-vlm departs. - GPT-BigCode now reads activation_function (absent or null means the GPTBigCodeConfig default gelu_pytorch_tanh), resolves it once at load onto MLP, and rejects non-GELU names in validate(); santacoder's declared tanh runs the compiled f32 kernel instead of the erf mlx-lm applies. - Idefics2 and Granite 4 Vision stop forcing use_fast_gelu (mlx-vlm's FastGELUMLP): the tower config decides, with Idefics2 resolving an omitted key to the Idefics2VisionConfig default via default_missing_hidden_act_to_siglip. - The shared VisionHiddenActivation maps every ACT2FN GELU name through GeluActivation::from_hf_name (gelu_new / gelu_fast now tanh) and rejects non-GELU names as load errors instead of silently running erf; Bunny's sparse-config fallback propagates the error rather than swallowing it. - A declared gelu_pytorch_tanh on the shared SigLIP MLP runs the compiled kernel like the omitted-key path (VisionMlpActivation::PytorchTanh removed, SiglipDefaultTanh folded in). - The dead C++ compiled_gelu is removed with its three FFI tests and both compile-audit entries; the XLA llava vision config accepts the same GELU name set. Docs rows T5, S6, S7 and the shared-config rule updated. Validated: cargo fmt, clippy --all-targets -D warnings (mlxcel, mlxcel-core, mlxcel-xla), the mlxcel-xla vision_config tests (10 passed), and a clean cargo check on the three packages. The GPU lib-test gate and VLM smoke runs were not run before this commit: the GB10 gpu-lock was held by another session; the orchestrator's contract gate covers them. No GPT-BigCode checkpoint exists on this host, so that smoke cannot run here. Closes #2794.
Summary
Settles the GELU formula for every call site #2423 left as an open decision, using one rule: the checkpoint config decides, and where the key is omitted, the Hugging Face config-class default for that
model_typedecides, evaluated asACT2FNdoes. Where the mlx-lm or mlx-vlm port departs from Hugging Face, mlxcel follows Hugging Face and the row records the divergence. The full per-row table with reasons is indocs/gelu-call-sites.mdsection 0b, and every affected inventory row carries a#2548note.Closes #2548
Changes
compiled_gelu_mlp_forward_fp16(Gemma v1 bf16, Gemma 4 dense bf16 MLP, Gemma 4 assistant drafter), the Gemma 4 tensor-parallel per-layer gate, Gemma3ncompiled_gelu_topkand the RecurrentGemma MLP. The Gemma3n XLAsparse_gelu_stagestape, thegemma3n_mathhost oracle and the two xla-diagnostics manual graphs moved to tanh with it.GeluActivation::TanhF32Compiled): Phi, Phixtral, GPT-2, StarCoder2 (eager), Moondream2/3 LM and vision, RecurrentGemma y-branch, and the SigLIP-style towers of Phi-4-MM, DeepSeek-VL2, Youtu-VL, Jina-VLM, LFM2-VL, PaddleOCR-VL, Aya Vision, Granite Vision and Bunny.quick_gelu(GeluActivation::Fast): Qwen2-VL and Molmo ViT MLPs in eager and in the XLA emitters (newnumeric_ops::quick_gelu), LLaVA 1.5 / NeXT CLIP and Phi-3.5-V CLIP.vision_config.hidden_actresolves by towermodel_type(SigLIP / SigLIP2 to tanh, CLIP toquick_gelu); a declaredquick_gelunow parses as fast in the eagerVisionConfigand in the XLA llava vision config.vision_config.hidden_act.hidden_actonce at load (NasActivation): GELU names go throughGeluActivation::from_hf_nameinstead of falling back to SiLU, and an unknown name is a load error.GeluActivation, each with a tolerance test against the copy it replaced.Kept as they were, with the reason recorded: GPT-NeoX, ModernBERT, Llama 4 vision (HF
gelu), Kokoro PLBert, Molmo2, Qwen3-VL-MoE, Qwen3-Omni, LLaVA-Interleave and Llama-Nemotron-VL-Embed (already matched their config).Decisions that need a maintainer look
starcoder2-3b-4bitdeclaresgelu_pytorch_tanh(also theStarcoder2Configdefault), and the XLA StarCoder2 goldens were frozen after that graph ran token-exact against the HF fp32 oracle. Under the decision rule eager is the side that moves, so eager switched to tanh andassets/starcoder2/*.mliris untouched. mlx-lm'snn.geludeparts from HF here.compiled_gelu_tanh_f32.compiled_gelu_topk, the bf16 stddev multiplied by the f32std_multiplierarray promotes the cutoff, shift and tanh to f32 with one final rounding; the XLAsparse_gelu_stagestape rounds each intermediate through bf16, as mlx-lm's weak Python scalars do.docs/gelu-call-sites.mdG15/G17 now say so. The new FFI test cannot separate the two (its reference computes mean and variance in f32). A same-input eager vs XLA comparison needs an IREE host (see below).Tests
--test-threads=1,MLX_ENABLE_TF32=0): 26 GELU tests pass, including two new ones.test_compiled_gelu_topk_is_the_tanh_gelu_of_the_shifted_inputchecks f32 within 2e-6 of tanh and more than 1e-4 from erf.test_compiled_gelu_mlp_forward_fp16_uses_the_tanh_gegludoes the same for the dense GeGLU.models::gemma3_backbone(6),audio::nemotron_codec(8),models::nemotron_nas(12),vision::config(3),vision::encoders::siglip(5),vision::encoders::minicpmo(3),models::nemotron_voicechat::tts(14),models::gemma3n::(4),models::recurrent_gemma(9),models::phi(61),models::gpt2(28),vision::encoders::molmo(1),loading::vlm(250). The Gemma3 backbone fold measures worst error 0 (f32), 4.15e-3 (bf16) and 9.77e-4 (f16) againstmlx.nn.gelu_approx.validation::tests pass, StarCoder2 byte-exact goldens included.tests/cuda_custom_kernel_disk_cache.rs: pass.cargo clippy --lib --tests --features cuda -D warningsis clean for the root crate. The same check is clean for mlxcel-core and mlxcel-xla.cargo fmt --checkis clean.cargo test --workspace --profile test-fast --features cuda --no-fail-fast -- --test-threads=1) on this branch: 14859 passed, 1 failed. The failure istests/step3p7_parity.rs::text_decode_delegates_sequence_local_native_batching, which fails identically on the base commit29c2f0c2, so it is not from this PR.--features xla-diagnosticswas not compiled: there is no IREE distribution on this host (IREE_DISTunset). The changed diagnostics graphs ingemma3n.rswere reviewed by hand.mlxcel_core::tanhis exported and the renamed export keys have no other consumers. Run them on an IREE host.Smoke (GB10, greedy, one
mlxcel generateand onemlxcel-serverrequest each)Vision prompts used
tests/fixtures/test_image.png, a flat orange square.Notes:
mainproduces the same Qwen2-VL and Aya output token for token, and the same Moondream2 "omentous" prefix (one later word differs). Ontests/fixtures/test_image_shapes.png, bothmainand this branch name the red square, blue circle and green triangle correctly for Qwen2-VL and Aya. The wording differs between the two, which shows the switched activation runs.models::recurrent_gemma), Phixtral, GPT-2 (models::gpt2), Phi-4-MM, and the Gemma 4 TP gate (needs multi-host).backbone_gelu_stays_within_rounding_of_mlx_nn.llama-3.3-nemotron-super-49b-v1-4bit) declaressilu, so its path is unchanged.models::nemotron_nascovers the parsing.