Repository navigation
Description
Description
mx.random.normal(shape=..., key=mx.random.key(seed)) produces different float32 outputs on Apple M1 Max vs Apple M3 Ultra and Apple M5, given identical MLX version, identical Python, and identical inputs. mx.random.uniform and mx.random.split are unaffected — both produce bit-identical output across all three machines.
This breaks bit-exact reproducibility for any code that relies on mx.random.normal with a directly-constructed key, across Apple Silicon hardware generations.
Reproducer
import mlx.core as mx
import numpy as np
import hashlib
key = mx.random.key(0)
a = mx.random.normal(shape=(8,), key=key)
mx.eval(a)
print(hashlib.sha256(np.asarray(a, dtype=np.float32).tobytes()).hexdigest())Observed output (MLX 0.31.1, Python 3.14.3-4, macOS 26)
| Chip | sha256 of normal(shape=(8,), key=mx.random.key(0)) |
|---|---|
| Apple M5 | a662607828c52cafea1f79d98f09bd81855784116acd23364a6130eb037d6a5e |
| Apple M3 Ultra | a662607828c52cafea1f79d98f09bd81855784116acd23364a6130eb037d6a5e |
| Apple M1 Max | 8a8c5a6737feea9eeee3c7056f4699250e6b8440db8930fd10495ada9117c05a |
Same pattern for shape=(8,) key=mx.random.key(42) and shape=(16, 4) key=mx.random.key(0) — M1 Max always differs from {M5, M3 Ultra}.
Adjacent observations — narrowing the cause
The following calls produce bit-identical output across all three machines:
| Probe | M5 | M3 Ultra | M1 Max | All match ? |
|---|---|---|---|---|
mx.random.key(0) materialised |
✓ | ✓ | ✓ | yes |
mx.random.split(mx.random.key(7)) (both halves) |
✓ | ✓ | ✓ | yes |
mx.random.uniform(low=-1, high=1, shape=(8,), key=mx.random.key(0)) |
✓ | ✓ | ✓ | yes |
mx.random.normal(shape=(4,), key=<split-child-of-key(7)>) |
✓ | ✓ | ✓ | yes |
mx.random.normal(shape=(8,), key=mx.random.key(0)) |
✓ | ✓ | ❌ | no |
mx.random.normal(shape=(8,), key=mx.random.key(42)) |
✓ | ✓ | ❌ | no |
mx.random.normal(shape=(16, 4), key=mx.random.key(0)) |
✓ | ✓ | ❌ | no |
So the divergence is specifically in mx.random.normal on Apple M1 Max when the key is the raw output of mx.random.key(seed). The key tensor itself is identical; mx.random.split returns identical halves; mx.random.uniform produces identical output with the same raw key. Only mx.random.normal with the raw key diverges, only on M1 Max.
It is plausible (but not confirmed) that mx.random.normal with a split-derived key matches across all three, but mx.random.normal with a directly-constructed key takes a different kernel path on M1 Max.
Environment
All three machines:
- MLX
0.31.1 - Python 3.14.3 (M5) / 3.14.4 (M3 Ultra, M1 Max)
- macOS 26.x (Tahoe), arm64
- Same
uvlockfile, single MLX wheel
Why this matters (downstream)
We hit this in a research project (dream-of-kiki) that runs an R1 bit-exact reproducibility suite. After shipping new ops that call mx.random.normal with seeded keys, the hashes diverge between M1 Max and M5/M3 Ultra even at the same commit, breaking cross-machine equivalence that previously held for ops that didn't touch normal. Full milestone: docs/milestones/r1-cross-machine-m5-vs-m1-2026-05-20.md.
We've put a golden_hashes.json per-hardware-family workaround in place locally, but it would be much cleaner if mx.random.normal produced the same bytes across Apple Silicon generations.
Full repro script
A self-contained script that emits a JSON dump of 8 different mx.random.* probes (including the matching ones above as controls): [/tmp/mlx_rng_repro.py — paste here on request].
Question
- Is this an intentional kernel divergence (different SIMD path / Philox internal variant per hardware family) or a regression worth fixing?
- If intentional, is there a recommended way to opt into a portable code path for
mx.random.normal?
Activity
milestone: R1 probe 3-machines + MLX upstream
feat(r1): split golden_hashes per chip family
docs: qualify R1 within-machine vs cross-machine
electron-rare commentedon May 20, 2026
Forensic update — likely root cause is
erfinvFMA chainTracing the call path in
mlx/random.cpp(v0.31.2, commit68cf2fd):The probe in the original issue isolates the divergence to step (2)
erfinv:uniform(low=-1+eps, high=1, key=mx.random.key(0))matches across M5, M3 Ultra, M1 Max (verified bymx.random.uniform(low=-1.0, high=1.0, ...)probe).multiply(sqrt(2), ...)is a pure element-wise op and would not be hardware-dependent for finite inputs.That leaves step (2) — the
erfinvMetal kernel inmlx/backend/metal/kernels/erf.h:42. Its body is a 10-step Horner-form polynomial entirely built frommetal::fmacalls:metal::fma(a, b, c) = a*b + cwith a single IEEE-754 rounding (intermediate result is held in extended precision). If the Metal compiler lowersmetal::fmato a hardware FMA on M5 / M3 Ultra but to a separate multiply-then-add path with two roundings on M1 Max (or vice versa), the entire polynomial cascade diverges at float32 precision.The 10-step chain compounds the per-step ulp differences: each step's
r1andr3reduction stages can differ in low bits, and by the time we exit the loop we have float32 outputs that bit-differ in the last few mantissa bits across hardware.Why some normal calls match and others don't
In the original probe,
mx.random.normal(shape=(4,), key=split_derived_key)matched all three machines whilemx.random.normal(shape=(8,), key=raw_mx.random.key(0))diverged on M1 Max. Hypothesis: the Metal kernel launch grid for the polynomial differs by shape (threadgroup size, vectorization) and only some launch shapes route through the FMA-divergent path on M1 Max. A confirming test would be:…run on each machine and diff. If only specific shapes diverge, the launch-grid/SIMD-width hypothesis is confirmed.
C++ unit test demonstrating the divergence
A minimal unit test that could live in
tests/random_tests.cpp:The first 32 bytes shown are M5 / M3 Ultra; M1 Max produces different bytes.
Possible mitigations (sorted by intrusiveness)
metal::fmacalls inerfinvwith explicit multiply-then-add (p = p * t; p = p + coeff). Costs ~2× in throughput on the polynomial — buterfinvis rarely the bottleneck. Trade perf for cross-hardware reproducibility.erfinv. Route theerfinvop to the CPU backend when a deterministic-across-hardware mode is requested. Requires an opt-in flag (mx.set_deterministic(True)or similar) — non-trivial API surface.mx.random.normalbit-exactness is per-chip-family-conditional, and users requiring cross-hardware bit-exactness should pre-compute on one machine and ship the outputs. Zero code change.I don't have strong opinions about which path the project should take — the research project that surfaced this hit it accidentally and has migrated to per-chip-family golden hashes locally (see hypneum-lab/dream-of-kiki commit
deeb1e8). Documentation alone would have been sufficient for us; the FMA forensic is shared in case it's useful to the maintainers.Happy to test patches on M5 / M3 Ultra / M1 Max if a candidate fix lands — full per-machine test infrastructure is already in place at
hypneum-lab/dream-of-kiki/tests/reproducibility/.electron-rare commentedon May 20, 2026
Empirical update — added 4th host (M3 Pro,
applegpu_g15s) confirms theg13 vs g15+frontierFollowing up the original issue with a 4-host triangulation at fixed MLX 0.31.2 (rules out version regression) and isolated
applegpuarchitecture strings reported bymx.metal.device_info():applegpuarchdevice_info()reportsapplegpu_g13s{'device_name': 'Apple M1 Max', 'architecture': 'applegpu_g13s', ...}applegpu_g15s{'device_name': 'Apple M3 Pro', 'architecture': 'applegpu_g15s', ...}applegpu_g15d{'device_name': 'Apple M3 Ultra', 'architecture': 'applegpu_g15d', ...}applegpu_g17g{'device_name': 'Apple M5', 'architecture': 'applegpu_g17g', ...}Cross-arch sha256 table (MLX 0.31.2 everywhere)
Tested across 3 sample sizes × 5 seeds ×
normalanduniform. Hashes are sha256 hex overf"{v:.8e}"per-value, truncated to 24 chars:mx.random.normal(8 tests × 4 hosts)normal(seed=0, n=1000)7530424b...b628fb71...b628fb71...b628fb71...normal(seed=0, n=10000)be71f90a...ef71df1d...ef71df1d...ef71df1d...normal(seed=0, n=100000)b488ea90...750379c9...750379c9...750379c9...normal(seed=1, n=10000)8bd6ad78...8e047aef...8e047aef...8e047aef...normal(seed=42, n=10000)372f5de0...17767b0d...17767b0d...17767b0d...normal(seed=100, n=10000)5cb3349a...c54bcfb3...c54bcfb3...c54bcfb3...normal(seed=1234, n=10000)1c0f6499...285a378d...285a378d...285a378d...mx.random.uniformcontrol (2 tests × 4 hosts) — bit-exact everywhereuniform(seed=0, n=10000)af192db2...af192db2...af192db2...af192db2...uniform(seed=0, n=100000)0db1b61b...0db1b61b...0db1b61b...0db1b61b...Frontière empirique nette
8 normal tests × 4 hosts = 32 hashes. Distribution :
applegpuarch strings (g15s ≠ g15d ≠ g17g).The divergence is therefore localized to uniform → erfinv transformation (cf. previous comment on
mlx/backend/metal/kernels/erf.h:42's 10-step Horner polynomial), and the frontier is g13 (M1 family, Apple7) vs g15+ (Apple8 onwards). The Metal compiler appears to emit identical kernel bytecode forerfinvfrom g15 onward, but a different FMA chain layout for g13.Mechanistic hypothesis (consistent with #2205)
This matches angeloskath's earlier explanation in #2205 ("the grid needs to be a different size for the M1 vs the M3 basically this means that you can't run 1024 threads per thread group on the M1"). The M1's tighter register file (Rosenzweig's M1 GPU reverse-engineering documents ~256 half-word registers per thread vs. larger budget on g15+) forces the Metal compiler to choose a different SIMD-width / FMA-fusion strategy for
erfinv's polynomial body — same source code, different bytecode emitted.Reproducer (5 lines, runs in any MLX install)
Expected output on Apple M1 family:
applegpu_g13s: be71f90acef4a52e36ca89adExpected output on Apple M3+ :
applegpu_g15s/g15d/g17g: ef71df1dfcccab1cb156c6fcWhat this changes for the documentation request
The original ask remains: document this in
mx.random.normal's docstring. The added evidence shows:erfinv, not the wholenormalpipeline —uniformandsplitare unaffected.A minimal docstring note like "Note:
mx.random.normalis not bit-exact across Apple Silicon GPU architectures (g13 vs g15+) — use a fixed-architecture worker for reproducibility-critical workflows" would close the gap.🔬 4-host triangulation by @electron-rare (M1 Max, M3 Pro, M3 Ultra, M5) at MLX 0.31.2. Generated as part of the nerve-wml gap-analysis remediation.
electron-rare commentedon Oct 31, 2026
Re-test at MLX 0.32.4: M3 Pro, M3 Ultra and M5 still agree; M1 Max not re-run
Re-ran the probes at 0.32.4 on M3 Pro (
g15s), M3 Ultra (g15d) and M5 (g17g); all 19 are bit-identical across the three.normal(shape=(8,), key=mx.random.key(0))givesa662607828c52cafea1f79d9, the same value reported for M5 / M3 Ultra in the issue body at 0.31.1. I did not re-run the M1 Max (g13s), so this says nothing new about the g13 divergence.Hashes: first 24 hex chars of sha256 over the raw float32 bytes, as in the issue body. My May 20 comment used
mx.random.seed()and hashed formatted strings, so its values are not comparable.normal((8,), key(0))a662607828c52cafea1f79d9normal((8,), key(42))7ef9a0d55131a5b50cb4d5b5normal((16, 4), key(0))b2b3b19fe3512c1b88f78032normal((4,), split(key(7))[0])a42df6b2e9a815bd93b05c59normal((1000,), key(0))46b0d4be6635990603a76631normal((10000,), key(0))03ef4378f4c0325a232d5eddnormal((100000,), key(0))72e6ab35a28a4b2f8290d4e8normal((10000,), key(1) / key(42) / key(100) / key(1234))39364402dbec903e7bc5e5f3/5fe34c2a1665002b0b378b97/b96f53140ef1f69ec89b467f/141599632fb7a7161ce5ec15uniform(-1, 1, (8,), key(0))(control)f8646c5b74ac4c237e50710auniform((10000,), key(0))(control)386c7b9e80903e37662a3836uniform((100000,), key(0))(control)fe91dc3993634df1097d92ederfinv(linspace(-0.999, 0.999, 10001))(new)a983396ba86f1a158e22aca3erf(linspace(-0.999, 0.999, 10001))(new)d3c14ad20901a5fbe6495f71The last two rows apply
erfinvanderfto a fixed input, without the RNG; they have not been run on g13.What I checked
random.cppat v0.32.4:normal(l.179) still drawsuniform(nextafter(-1, 0), 1)(l.194-196), applieserfinv(l.202), multiplies bysqrt(2)(l.203).kernels/erf.hat v0.32.4:erfinv(l.42-69) ismetal::log(l.44) then ametal::fmapolynomial; kernels are built with-fno-fast-math(kernels/CMakeLists.txtl.18). I have not inspected per-architecture compiler output, so I cannot say whetherlogor the FMA chain differs on g13.Question for the maintainers. Would either of these be acceptable?
(a) A portable
erfinvpath forrandom.normal, accepting a throughput cost and a likely one-time change ofnormaloutputs.(b) A docstring note that
mx.random.normalis not guaranteed bit-exact across GPU architectures (observed at 0.31.x: g13 vs g15+).I can open a PR for (b). Probe script below.
Environment: MLX 0.32.4 via
pip --require-hashes; macOS 27.0.1 / 27.2; Python 3.12.15 (M5), 3.13.9 (M3 Pro), 3.13.16 (M3 Ultra).probe.py
docs: note random.normal cross-GPU bit-exactness