Skip to content

fix: align Gemma3n gelu_topk bf16 rounding across eager, XLA and mlx-lm - #2801

Merged
inureyes merged 1 commit into
mainfrom
fix/issue-2795-gemma3n-gelu-topk-bf16
Oct 31, 2026
Merged

inureyes merged 1 commit into
mainfrom
fix/issue-2795-gemma3n-gelu-topk-bf16

Conversation

@inureyes

@inureyes inureyes commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Summary

Gemma3n sparse layers (0-9, sparsity 0.95) rounded the bf16 gelu_topk three different ways: the eager compiled kernel promoted the whole tape to f32, and the XLA tape left three constants unrounded with a two-rounding cube. Both sides now move to mlx-lm's bf16 carrier, per the issue's settled Decision.

What changed

  • The eager compiled_gelu_topk kernel (per-op and fused paths) mirrors mlx-lm's graph op for op: the f32 multiplier cast to the input dtype like std_multiplier.astype, the zero and nn.gelu_approx constants built in it like Python's weak scalars, the cube one power op. The shared gelu_tanh_approx helper stays as-is (its x*x*x cube serves the dense GeGLU paths).
  • The XLA sparse_gelu_stages tape rounds the multiplier, root and cubic constants to bf16 at emit time and computes the cube as one f32 product, exact before a single rounding (36 to 34 converts).
  • The pinned E2B probe's manual tape mirrors the same bf16 carrier and pins the compiled kernel against both the per-op tape and the FMA-site tape (MLX's CUDA compiler contracts the two mul+add sites into FMAs).
  • New ffi_gelu_topk_tests module pins the kernel bit for bit against mlx-lm's carrier, with an f32-carrier reference kept so the test can tell carriers apart.
  • docs/gelu-call-sites.md rows G15/G17 and the compiled_gelu_topk / gemma3n_mlp_forward rows describe the bf16 carrier and drop the known-gap note.

Test plan

  • cargo test -p mlxcel-core --profile test-fast --features cuda -- --test-threads=1 gelu_topk: 3 passed. The bit-exact test reports FMA-site tape 0 mismatches, per-op 99/849, f32 carrier 212/883.
  • mlxcel-xla sparse_gelu (34 converts), host-oracle and bf16_scalar tests pass; cargo fmt --check clean, clippy clean on the touched packages.
  • GPU reference probe (CUDA GB10 under gpu-lock, MLX_ENABLE_TF32=0, mlx-lm's compiled gelu_topk as reference, widths 2048/8192): old f32 carrier 370/1247 mismatches, xxx cube 8/821, per-op nn.gelu_approx(z) 0/816, bf16 carrier with fused FMA sites 0/0. On CUDA the implementation targets the FMA-contracted compiled kernel; the XLA tape reproduces the per-op tape (MLX CPU semantics).
  • Pinned E2B IREE probe on spark-101 (the issue's "spark-101 has no IREE install" is stale: the source+build tree at ~/.cache/mlxcel/iree-cuda-3.12.0rc20260721 works; IREE_CUDA_HOME + GEMMA3N_MODEL_DIR=models/gemma3n-e2b-4bit, --test-threads=1): bit_mismatches=0 on every layer0_sparse_* stage and fma_sites_bit_mismatches=0/24576 on layer0_sparse_activation_manual_vs_compiled (per-op 333/24576, the CUDA FMA residual). The probe overall still aborts at pre-existing, unrelated checks (below).
  • Greedy smoke on gemma3n-e2b-4bit and gemma3n-e4b-bf16, through mlxcel generate and mlxcel-server /v1/chat/completions, temperature 0, 64 tokens, two prompts: token sequences identical to mlx-lm greedy on the same rendered prompt (prompt_tokens 14/16 match). The only text difference is a trailing space delta the mlxcel SentencePiece detokenizer drops before a newline; display only.

Pre-existing findings (not from this change)

  • probe_real_projection's weight-carrier check fails on CUDA for every 4-bit weight: the Rust diagnostic pins MLX CPU dequant semantics (bf16(bf16(q*scale)+bias), 0 mismatches vs mx.dequantize on CPU) while the probe runs MLX on GPU; proven device-dependent via an oracle-venv GPU-vs-CPU check. This aborts pinned_e2b_real_projection_qmv_probe before the gelu stages; the stage results above were taken with that one assertion temporarily relaxed locally, and the edit is reverted.
  • AltUp prediction/correction coefficient stages differ by f32 noise (~5.6e-6 / 1.5e-5) that cascades 1 bf16 ulp into the predictions; unreachable on main since the probe aborts earlier. Both warrant follow-up issues.

Closes #2795

Gemma3n sparse layers (0-9, activation_sparsity 0.95) rounded the bf16 gelu_topk three different ways: the eager compiled kernel promoted the whole tape to f32 (strong f32 multiplier and zero), which disagrees with mlx-lm on about 4% of bf16 elements, and the XLA tape left the multiplier, root and cubic constants unrounded with a two-rounding cube.

The eager kernel now mirrors mlx-lm's gelu_topk graph op for op (#2795): the f32 multiplier is cast to the input dtype like mlx-lm's std_multiplier.astype, the zero and the nn.gelu_approx constants are built in the input dtype like Python's weak scalars, and the cube is one power op. The shared gelu_tanh_approx helper stays as-is because its x*x*x cube serves the dense GeGLU paths. The XLA emitter rounds the multiplier, root and cubic constants to bf16 at emit time (host-side round via bf16_scalar) and computes the cube as one f32 product, exact before a single rounding (36 to 34 converts).

The pinned E2B probe's manual tape mirrors the same bf16 carrier and additionally pins the compiled production kernel against both the per-op tape and the FMA-site tape, since MLX's CUDA compiled kernels contract the two mul+add sites into single-rounded FMAs. A new ffi_gelu_topk_tests module pins the kernel bit for bit against the mlx-lm carrier and keeps an f32-carrier reference so the test can tell the carriers apart.

Validated on the GB10 CUDA host: the mlx-lm compiled reference probe reports the bf16 carrier with fused sites at 0 mismatches of 8192 and 32768 (widths 2048 and 8192, MLX_ENABLE_TF32=0) while the old f32 carrier misses 370/1247; the pinned E2B IREE probe reports bit_mismatches=0 on every layer0_sparse_* stage and fma_sites_bit_mismatches=0 on layer0_sparse_activation_manual_vs_compiled; greedy smoke on gemma3n-e2b-4bit and gemma3n-e4b-bf16 through mlxcel generate and mlxcel-server reproduces mlx-lm's token sequences exactly.

Refs #2795
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:low Low priority area:models Model architectures, weights, loading, metadata area:core mlxcel-core: MLX FFI, primitives, KV cache, layers labels Oct 31, 2026
@inureyes
inureyes marked this pull request as ready for review October 31, 2026 17:14
@inureyes
inureyes merged commit 0a0b055 into main Oct 31, 2026
31 checks passed
inureyes added a commit that referenced this pull request Nov 1, 2026
…e stage could run on a real checkpoint (issue #2808, both failures recorded in the PR #2801 body). The weight-carrier check pinned MLX CPU dequant semantics while the probe executes MLX on GPU, so every 4-bit weight failed with about 51% 1-ulp mismatches and the probe never reached the stages; the AltUp chain, once reachable, drifted by f32 reduction noise that a strict bit comparison cannot tell from a formula error.

The weight carrier is now built with the rounding the default device applies: dequantize_affine_bf16_single_round matches MLX's GPU mx.dequantize (q * scale + bias in f32, one round to BF16; verified bit-exact for the pinned E2B checkpoint, 4194304/4194304, and synthetic wide-magnitude weights), while the CPU path keeps dequantize_affine_bf16_sequential. The strict comparison itself is unchanged and still rejects any wrong scale, bias, group layout, or nibble order, which move carriers far beyond one ulp.

The AltUp coefficient chain now compares under the #932 per-op rule with the measured noise recorded: coefficients at max_abs 1e-4 (measured 1.514e-5 prediction / 5.603e-6 correction), and the bf16 stages that consume them at max_abs 3.2e-3 plus two bf16 ulps at the operand's binade, the measured shapes of the compiled TF32 reduction noise (absolute 1.5e-4 on near-zero values, three ulps mid-binade, one ulp at [2, 8)). The layer0_sparse_* stages and manual_vs_compiled keep their bit-exact comparison and reported 0 mismatches in the final probe run.

Closes #2808
inureyes added a commit that referenced this pull request Nov 1, 2026
…e stage could run on a real checkpoint (issue #2808, both failures recorded in the PR #2801 body). The weight-carrier check pinned MLX CPU dequant semantics while the probe executes MLX on GPU, so every 4-bit weight failed with about 51% 1-ulp mismatches and the probe never reached the stages; the AltUp chain, once reachable, drifted by f32 reduction noise that a strict bit comparison cannot tell from a formula error. (#2810)

The weight carrier is now built with the rounding the default device applies: dequantize_affine_bf16_single_round matches MLX's GPU mx.dequantize (q * scale + bias in f32, one round to BF16; verified bit-exact for the pinned E2B checkpoint, 4194304/4194304, and synthetic wide-magnitude weights), while the CPU path keeps dequantize_affine_bf16_sequential. The strict comparison itself is unchanged and still rejects any wrong scale, bias, group layout, or nibble order, which move carriers far beyond one ulp.

The AltUp coefficient chain now compares under the #932 per-op rule with the measured noise recorded: coefficients at max_abs 1e-4 (measured 1.514e-5 prediction / 5.603e-6 correction), and the bf16 stages that consume them at max_abs 3.2e-3 plus two bf16 ulps at the operand's binade, the measured shapes of the compiled TF32 reduction noise (absolute 1.5e-4 on near-zero values, three ulps mid-binade, one ulp at [2, 8)). The layer0_sparse_* stages and manual_vs_compiled keep their bit-exact comparison and reported 0 mismatches in the final probe run.

Closes #2808
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:models Model architectures, weights, loading, metadata priority:low Low priority type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Align Gemma3n gelu_topk bf16 rounding across eager, XLA, mlx-lm

1 participant