Skip to content

investigate: gelu_approx computes erf GELU where upstream models use the tanh approximation #2423

Closed

Description

@inureyes

Problem / Background

mlxcel_core::gelu_approx (src/lib/mlxcel-core/src/utils.rs:1049, C++ gelu_approx at src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp:3287 and compiled_gelu_approx at :1891) computes the exact erf GELU, x * 0.5 * (1 + erf(x / sqrt(2))). In MLX, nn.gelu_approx is the tanh approximation 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x**3))), nn.gelu_fast_approx is x * sigmoid(1.702 * x), and nn.GELU(approx="precise") and approx="tanh" both select the tanh form, while only approx="none" (and nn.gelu) is erf. Source: https://github.com/ml-explore/mlx/blob/main/python/mlx/nn/layers/activations.py (gelu, gelu_approx, gelu_fast_approx, class GELU).

The erf choice is deliberate. The comment in gelu_approx() says:

The tanh approximation ... produced NaN for negative bf16 inputs in the SigLIP vision encoder MLP because power(x, 3.0f) uses exp(3log(x)) which is undefined for x<0. Even with xx*x the MLX lazy graph fuses operations that can overflow bf16 intermediates within the large SigLIP computation graph (4096 patches). The erf-based GELU ... matches Python nn.GELU(approx="precise") output within floating-point tolerance.

The last sentence is wrong: approx="precise" is the tanh form. Meanwhile the tree already runs tanh GELU in production: the shared C++ helper gelu_tanh_approx (src/lib/mlxcel-core/cpp/mlx_cxx_internal.h:91, uses x*x*x, no power) backs every fused GeGLU path (Gemma 4 and others), and at least five private Rust copies exist (src/models/bert.rs:303, src/models/kokoro/ops.rs:223, src/vision/encoders/molmo2.rs:90, src/vision/encoders/siglip.rs:92, src/vision/encoders/kimi_vl.rs:158).

Sites known to diverge

Not every call site is wrong: src/models/gpt_bigcode.rs:561-569 ports upstream nn.gelu (erf), so erf is correct there and only the function name misleads. There are 83 non-test gelu_approx references under src/, mostly in src/models/ and src/vision/encoders/. The new ports #2393 (InternVL HF layout) and #2398 (MiniCPM-V 4.7) already require the tanh form for their own paths.

Acceptance Criteria

Verification

cargo test --workspace --profile test-fast --features metal,accelerate
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --check

Activity

  1. added
    type:bugBug fixes, error corrections, or issue resolutions
    area:modelsModel architectures, weights, loading, metadata
    area:coremlxcel-core: MLX FFI, primitives, KV cache, layers
    and removed on Oct 16, 2026
  2. inureyes commented on Oct 22, 2026

    @inureyes
    MemberAuthor

    Investigation results, implemented in #2552 (uses Refs; this issue stays open).

    Full inventory, about 110 call sites each mapped to its upstream formula with pinned links (mlx-lm c39139d70, mlx-vlm v0.7.8, mlx-audio v0.5.9) and the Hugging Face activation where it differs: docs/gelu-call-sites.md. Measurements: docs/benchmark_results/gelu-erf-vs-tanh-2026-10-23.md.

    bf16 stability. tanh GELU with x*x*x, in input dtype and in f32, on gemma-3-4b-it-4bit's bf16 SigLIP tower at 4096 patches (27 x [1, 4096, 4304], max |input| 161 to 177), MiniCPM-V 4.6 bf16 and PaliGemma2 f16: zero NaN and zero Inf. The original NaN came from power(x, 3.0f) (exp(3 log x)), not from tanh.

    Gate. For every measured family the reverted (erf) arm does not fail: swapping erf and tanh moves logits by at most the bf16 quantum and changes no decided token, inside mlxcel and inside the float32 mlx-vlm reference alike. The decided-position gate cannot distinguish the two, so each family was decided by the activation the checkpoint declares, cross-checked against mlx-vlm.

    Family Checkpoint / HF mlx-vlm Decision
    MiniCPM-V 4.6 encoder MLP gelu_pytorch_tanh tanh tanh (f32), from hidden_act
    MiniCPM-V 4.6 window merger gelu_pytorch_tanh (hard-coded) tanh tanh (f32)
    MiniCPM-V 4.6 downsample merger nn.GELU() (erf) tanh keep erf
    InternViT hidden_act = gelu (erf) tanh keep erf
    Gemma 3 VLM / PaliGemma SigLIP key absent, SigLIP default tanh tanh tanh (f32), loader resolves the missing key (XLA Gemma 3 emitter too)
    Gemma 4 vision GeGLU (+ DiffusionGemma, EmbeddingGemma2) gelu_pytorch_tanh tanh tanh (f32), from hidden_activation
    Gemma3n sparse layers gelu_pytorch_tanh tanh measured, kept erf for now: kernel, fused path and XLA oracle move together

    Every changed family was smoke-tested through mlxcel generate --image and one mlxcel-server request (DiffusionGemma CLI only; its server rejects image input).

    Open. About 20 remaining mismatched families (Gemma3n sparse, Gemma 4 bf16 dense MLP and drafter, Gemma 4 TP gate, RecurrentGemma, MiniCPM-o, Aya/Granite SigLIP, CLIP quick_gelu defaults, Phi, GPT-2, Moondream, LFM2-VL, DeepSeek-VL2 and others) are in #2548 with one acceptance item each. Separately, # prompt_tokens disagree with mlx-vlm for InternVL (262 vs 268), MiniCPM-V 4.6 (78 vs 76) and Gemma 3 (273 vs 274), which invalidates those cross-runtime comparisons: #2547.

  3. inureyes commented on Oct 22, 2026

    @inureyes
    MemberAuthor

    PR #2552 merged as 58db639 with Refs #2423; this issue stays open for the families it did not change.

    Landed: criteria 1 (inventory in docs/gelu-call-sites.md), 2 (bf16 re-test: no NaN or Inf with x*x*x tanh in input dtype or f32 on gemma-3-4b SigLIP at 4096 patches, MiniCPM-V 4.6 bf16 and PaliGemma2; the original NaN came from power(x, 3)), 5 (comments), and the selector and folds of criterion 4.

    Switched to tanh (computed in f32 through compiled_gelu_tanh_f32), because the checkpoint and the mlx-vlm port both declare it: MiniCPM-V 4.6 encoder MLP and window merger, Gemma 3 VLM SigLIP and PaliGemma SigLIP (missing hidden_act resolves to the SigLIP default), Gemma 4 vision GeGLU. Each was smoke-tested through mlxcel generate --image and mlxcel-server (DiffusionGemma CLI only).

    The teacher-forced trace gate could not distinguish erf from tanh in any measured family: the reverted arm never fails (0 decided flips; logits move by at most the bf16 step), so these decisions rest on the declared activation, not on the gate.

    Measured image-prefill cost against erf on GB10 (interleaved, null arm): Gemma 3 +2.8% pooled (same sign in both blocks), Gemma 4 +2.0%, MiniCPM-V 4.6 and PaliGemma within the null arm. The eager f32 form had cost Gemma 3 +17%; a single-kernel fusion for the remainder is tracked in #2548.

    Open: every other family in #2548, and the mlxcel vs mlx-vlm prompt-length mismatch in #2547 that invalidates cross-runtime traces for InternVL, MiniCPM-V 4.6 and Gemma 3. Record: docs/benchmark_results/gelu-erf-vs-tanh-2026-10-23.md.

  4. inureyes commented on Nov 3, 2026

    @inureyes
    MemberAuthor

    Every remaining acceptance criterion here was moved to #2548, which closed on 2026-10-31 with PR #2785 and follow-up PR #2804 (status:done). Criteria 1, 2, 5 and the selector half of 4 landed in PR #2552; the per-family measurement (3), the per-family decisions and private-copy folds (4), and the real CLI/server smoke tests (5) were completed in #2548.

    No implementation work remains, so this issue closes as done rather than shipping another PR.

  5. added and removed on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:coremlxcel-core: MLX FFI, primitives, KV cache, layersarea:modelsModel architectures, weights, loading, metadatapriority:mediumMedium 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