Repository navigation
0.2.17
Highlights
- CPU transcendentals were calling libm per element on every architecture.
Op::SwiGlu— the FFN activation of every modern transformer — had its loop inlined byte-identically in both execution paths with no NEON or AVX2 arm at all, so it ran scalar on Apple Silicon as much as on an Atom. New plain-Rust polynomial primitives inrlx_cpu::vmath(exp,log,ln_1p,softplus,tanh,sin,cos,tan,atan) auto-vectorize to baseline SSE2 and NEON, so they need no#[target_feature]and no CPUID gate: SwiGLU 1.63–1.78× on an M4 (real hardware) and 2.69–3.16× on x86, row softmax ~2.0×/~2.5×,exp~3× on x86,atan3.93×/6.95×. - One binary now runs on an Atom and uses AVX2 where it exists.
bias_geluandlayer_norm_rowwere gated oncfg(target_feature = "avx2"), so a portable build silently lost their AVX2 kernels and the only way to get them was a-C target-cpu=nativeartifact thatSIGILLs on any pre-Gracemont Atom. Both now dispatch on CPUID. just check-isa— a new ISA portability gate. It attributes every above-baseline instruction to its enclosing symbol and fails on any that is not runtime-gated, judged against the baseline of the target the binary was built for (sdotis baseline onaarch64-apple-darwinand a finding onaarch64-unknown-linux-gnu), and it can build and run the suite on an emulated Goldmont Atom or Cortex-A53 underqemu-user.- x86-64 Linux could not build without OpenBLAS.
build.rsprobed only whentarget_arch != "x86_64", turning a missing optional dependency into a hard link error on the one target most likely to lack it. It now probes every native Linux target and falls back to the portable gemm. - MLX rejected typed low-precision params.
run_typedwidened typed inputs to the graph's dtype butset_param_typeddid not, so a BF16 param against a graph rewritten to F32 was refused outright while the identical bytes through the untyped path converted fine. rlx-xdnatargeted anaieccCLI that no longer exists (#2). mlir-aie replaced the driver with a declarative one that requests outputs by graph edge name; every bare flag rlx passed had been removed. The invocation is now probed, so both driver generations work.
Fixed
-
A checkpointed scan's backward indexed its upstream by the wrong axis. With
partial checkpointing a scan emits one row per checkpoint, sotrajectory
andupstreamcarryk_totalrows whilexscarrieslength— but
process_tnarrowedupstreamat the time step. Withlength = 4and 2
checkpoints it asked for row 3 of a 2-row tensor. It now maps the time step to
its checkpoint index and omits the upstream term at non-checkpoint steps, whose
cotangent arrives throughdcarry. Second-derivative CPU-parity tests
(higher_order_decompose_parity) cover it.Previously silent:
narrow_inferred the output shape without checking
start, so the graph was built with an out-of-range window and no complaint.
The bounds check that surfaced this is a genuine improvement, not the bug. -
CompiledGraph's narrow-boundary conversion fired onF64. The f32 entry
points convert anF16/BF16boundary rather than reinterpreting it, but the
predicate matched any non-F32dtype. SPD / Riemannian-geometry graphs
declare theirsF64, which already round-trips through the plain f32 path, so
they were converted and returned garbage — everyspd_host_paritytest failed
with max|Δ| in the single digits. Scoped toF16 | BF16.
Added
-
rms_norm_mul_siluandl2_norm_lastdimcan emit the intermediate they
recompute (silu(z)and the per-row denominator, both optional). This lets
those fusions absorb a producer whose value a later thunk still reads —
a backward graph re-reads the recomputed forward values — where before they had
to decline. Sound because there is still exactly one writer, unlike the two
earlier attempts at keeping the producer alongside the fusion, which put 24 of
54 and then 1 of 54 Qwen3.5 gradients wrong.No measured speedup on the graph that motivated it: instrumenting the
pattern shows 0 blocks fused of 34 mul anchors in that backward, so the
decline was never the cost. The earlier attribution of ~2× to it was wrong.
Measured per-step cost is 10.2 s against a ~6.2 s f32 roofline, i.e. the step
is compute-bound and there is no large dispatch overhead to recover. The verbose
fusion log now reports anchors alongside fusions, which is how this was caught. -
MLX could not run a low-precision training graph at all. Two independent
bugs, both of which only bite once a graph is differentiated:grad_with_losssized a zero cotangentConstantat a hardcoded 4 bytes per
element while taking the shape — dtype included — from a graph node. On an
autocast graph that node is bf16, so the constant declared 2 B/elem and held
4. The consequence landed far away:cpu_low_precision::promote_to_f32
widened those f32 bytes a second time as if they were bf16, and MLX refused
the leaf. Both sites (zero_missing_wrtand the Min/Max VJP) now size by
Shape::size_bytes. Pinned byrlx-autodiff's
constant_payload_matches_dtype— a length invariant, which is what catches
this: zero is zero in every float format, so no value check would have.- MLX's
Op::RmsNormlowering returnedops::rms_norm(x, …), i.e.x's
shape, ignoring the node's declared output shape.fuse_rms_norm_reshape
folds a followingreshapeinto the norm and so changes its RANK, and
dropping that made a followinggather(axis=0)index the batch axis; a
matmul then produced rank 3 and the failure surfaced as "[reshape] Cannot
reshape array of size 984 into shape (1,3,8)". Pinned by rlx-mlx's
gather_after_fused_rmsnorm_reshape— whose parent node must be computed,
since with a graphInputthere the fusion declines and the test passes
vacuously.
Together these make bf16 training work on MLX (CPU, Metal and MLX now all
reproduce the f32 run), and they are what letrlx-kevre-enable MLX in its
backend-parity test. -
Metal returned NaN or zero for most parameter gradients of a Qwen3.5 trunk
while computing the forward bit-identically to CPU (32 of 54 on a 4-token
synthetic trunk). Nothing in a training run said so — the loss looked healthy,
it just sat onln(n_options), the chance floor — and it cost a 1529-step
rlx-kevreproduction. Two independent causes, both invisible to per-op tests
because every op was correct in isolation:- Thunk-level fusions dropped a producer that a later thunk still read.
fuse_gdn_gated_normandfuse_l2_normNopout the thunks that produced
the intermediates they collapse, guarding only against graph outputs. Their
liveness scan stops at the fused window — deliberately, because a full scan
hits an opaque thunk and conservatively rejects, which would disable the
fusion on the decode graphs it exists for — so a reader after the window
was invisible. A forward graph has none; a BACKWARD graph re-reads the
recomputed forward values, which then read an unwritten slot (zeros for
silu(gate), and 0/0 NaN out of the L2-normalize chain). They now also
consult aprotected_offsetsset built from IR use counts, which is
complete whatever the thunk scan can analyze. - The conv weight gradient was history-dependent at
N == 1. That case
took a per-group im2col+GEMM path whose answer depended on what was already
in the arena: a one-shot run agreed with finite differences, but after other
compiles had dirtied memory the same shapes came back 30–50% off.N == 1
now uses the same two-pass kernels asN > 1, which write every element they
read back; the scratch was already sized for them.
Pinned by
rlx-qwen35'strunk_backward_device_parity(whole-trunk, with the
forward as a control, ~2 s),rlx-metal'sthunk_fusion_second_reader(the
fusion hazard in isolation) andconv_backward_weight_parity(with a
finite-difference oracle, and a multi-shape sweep in one process — the history
is what made the old path fail, and a single-shape test passed right through
it).The criterion is use count ≤ readers inside the window, not "use count > 1":
two readers that are both inside the fused window — one combined matmul feeding
two narrows, which is what the decode-MLP fusions match — are legitimate, and a
coarser test declines them (metal_fused_decode_mlp_paritycatches that).
Declining when a reader IS outside costs ~2× on a Metal training step, where
these chains are hot, and nothing on a forward/decode graph, where the values
have a single reader and the fusion still fires. Fusing anyway while keeping
the still-read producer was tried and is wrong: the fused output can share an
arena slot with the intermediate being kept (24 of 54 gradients went bad). - Thunk-level fusions dropped a producer that a later thunk still read.
-
AutoMixedBf16produced NaN on CPU and Metal. One root cause, six
symptoms: a low-precision tensor lives in the arena either PACKED at 2 B/elem
or WIDENED to 4 B, and kernels on both backends read one layout out of the
other. Every op kind, and the full policy, now match the f32 run on CPU, Metal
and MLX. The NaN surfaced at an innocent downstream op whose own input was
intact, which is what made it hard to place.ArenaWidthPolicy::NativeHalfWidened(new, now CPU's policy): params and
integer/bool tensors stay native, f16/bf16 activations widen to 4 B —
what a thunk executor that computes in f32 and reads every activation slot
as&[f32]actually needs.Nativepacked them, contradicting the comments
that claimed otherwise.ArenaWidthPolicy::NativeBf16Widened(new, now Metal's policy): widens bf16
activations only. Metal has genuinehalfkernels, butHalfFlaghas no
BF16 lane, so every Metal kernel reads a bf16 tensor as f32.- rlx-cpu
Op::MatMulpicked the packed dequant GEMM fromParam-ness and
the declared dtype instead of the real slot width; a packed operand or
destination now goes through a newThunk::SgemmHalf, and a node with no
buffer is no longer mistaken for a packed one (that was the bf16 backward's
"range end index 16 out of range for slice of length 0"). - rlx-metal
Op::Castto/from BF16 decoded 4 B f32 words as packed bf16
pairs — every odd element right, every even one mantissa junk. It now rounds
in place via a newround_bf16_f32MSL kernel or plain-copies. - rlx-metal refuses an MPSGraph plan for a graph with bf16 activations
(graph_has_bf16_activation): it would be handedMPSDataTypeBFloat16for
an f32-wide buffer. - rlx-metal's arena derived element counts by dividing the slot size by the
dtype width, double-counting any widened slot; it now takes them from the
shape, and exposesArena::bytes_per_elemso a kernel can ask.
-
rlx-metal
encode_castwrote zeros for a same-dtype cast: it bound
copy_f32as if it took two pre-offset buffers when it takes
(arena, src_byte_off, dst_byte_off, len).
Changed
- MLX reports which boundary tensor was mis-sized.
Array::from_f32_slice
checks the shape/length agreement itself, andbuild_leaf_fornames the node,
so a mismatch reads "shape [1,41,2,1] wants 82 elements, got 164 (F32) — while
building leaf NodeId(903) Constant …" instead of a bare "nelems doesn't match
shape product" from the C++ shim, which said nothing about which tensor, shape
or dtype was involved.
Added
- tvOS, watchOS and visionOS join the Apple platform set.
just check-apple
now cross-compiles all four Apple OSes, device and simulator (8 targets), and
just test-apple-simexecutes the backend smoke + parity tests on all four
simulators rather than only cross-compiling them.
ios/build-xcframework.shemits oneRlxNode.xcframeworkwith 8 slices, and
ios/Demobuilds four apps from one source tree. Backend surface: CPU + Metal +
CoreML/ANE + MLX on iOS/tvOS/visionOS; watchOS is CPU/Accelerate only — it
has no public Metal API and no CoreML runtime model-compile.
--all-archsaddsarm64_32-apple-watchos(Apple Watch Series 4–8, which
cannot load the arm64 slice at all) plus the x86_64 tvOS/watchOS simulators via
-Zbuild-std. - MLX builds for every Metal-capable Apple platform, device and simulator,
via two tracked patches to the MLX submodule
(crates/backends/rlx-mlx-sys/patches/). Submodule bumped to v0.32.2. rlx-runtime/model-iofeature (indefault) gates the.rlxp/ MLX /
DDUF readers. A distributed node receives its stage over the wire and never
opens a model file, so dropping them keeps the Apple/embedded archive a compute
artifact rather than a file-format library.- Per-architecture budget axis in
cargo rlx check. Reports what a graph
costs on a backend — arena bytes, liveness reuse, width-policy overhead,
per-run host-boundary traffic, parameter residency, dispatch count and bytes
moved — as notes, never errors. Rendered in the CLI and in--json.
Supporting:arena_width_policy(FusionTarget)as one source of truth for how
each architecture stores an activation, andplan_memory_with_policyso the
width policy can be priced with every other planner option held fixed. ios/build-xcframework.shemits MLX's kernel library to
ios/build/metallib/<target>/mlx.metallibwhen built with--apple. A static
archive cannot carry it — MLX looks it up next to the executable at the first
GPU kernel — so the xcframework alone shipped an MLX that linked, reported
itself available, and failed at first use. Copy it into the.appbeside the
executable.- First low-precision coverage in
rlx-corpus. The corpus was entirely F32
plus three U8 packed-weight cases; alow_precisionfamily now carries
bf16/f16 elementwise and MLP graphs plus a mixedbf16 → Cast → f32case.
Fixed
- An over-limit
ElementwiseRegionemitted a dangling operand. The fusion
pass replaces every non-tail region member with aNodeId(u32::MAX)sentinel
on the promise that the tail will emit the region and rewire them; the limit
check ran at the tail, so bailing out left consumers pointing at a node that
does not exist. Caught only by a debug assertion — a release build handed the
dangling operand to the backend. The check now runs in the analysis phase,
before anything is rewritten. RLX_KEEP_ELEMENTWISE_REGIONS=1could overrun the CPU region interpreter.
It removes the unfuse pass for CPU, MLX, Metal and wgpu at once without
touching their fusion limits, and CPU asks forFusionLimits::UNBOUNDED
precisely because it normally unfuses — so an arbitrarily long chain reached
an interpreter whose scratch is a fixed[0f32; 32]. Limit resolution now
lives in one sharedresolve_fusion_limits; it had been two copies of the same
line, and a clamp added to one never ran on the path the runtime takes.- An
F16/BF16boundary was reinterpreted rather than converted.
run(&[(name, &[f32])])handed f32 bytes to the backend to be read as pairs of
halves —[1,2,3,4] + 0returned[2.0038757, 4.007843, 0.0, 0.0]on CPU, with
no error. Root cause:cpu_low_precision::promote_to_f32promoted the ops to
F32 while keeping everyInput/Paramat its declared dtype with noCast
between them, so a BF16 input got a 2 B/elem slot the promoted f32 thunk read
4 B/elem from. A boundary now keeps low precision only while every consumer
does too — conditional becauseKvAppendaliases its cache input's buffer and
strides from the node's dtype. - The tvOS simulator SDK ships MPS without MPSGraph, so the Metal backend
failed to link there. Gated behindrlx_mps_graph_host; nothing in the
MPSGraph module binds a framework symbol at link time, so only the#[link]
directive had to go. objc0.2 does not know tvOS or visionOS exist. It selects its dispatch
backend withcfg(any(target_os = "macos", target_os = "ios"))and falls
through to the GNUstep runtime otherwise, so every app link failed on
_objc_msg_lookup.rlx-metalnow supplies both lookup symbols from Apple's
runtime.- MLX stamped every metallib as macOS. Upstream hardcodes
xcrun -sdk macosx metal, so an iOS/tvOS/visionOS cross-build produced a macOS
metallib that Metal refuses to load — verified by the platform byte at offset
0x0B(macOS81, iOS82, tvOS83, visionOS8b). Fixed by patch 0001. - MLX's CPU backend called
system()/popen(), both unavailable on tvOS and
visionOS, failing the build at the capability probe itself. Patch 0002. tolerance_forinrlx-corpusderived an f32 bound for every graph, asking
a BF16 case to be more accurate than the format can represent. Now scaled from
the format's own unit roundoff.rlx-onnx-conformancerequired ORT on platforms it has no binaries for
(tvOS/watchOS/visionOS), failing the build script rather than linking.is_available(Device::Mlx)answered from acfg, not from the device. It
returnedtruefor any target linking the native MLX stack, while callers
treat it as a probe —fastest_device()picks the best available backend and
then runs on it. On an iPad with MLX's kernel library missing from the app
bundle, selection returned MLX and execution then failed with
Failed to load the default metallib. library not found. MLX loads that
library lazily at the first GPU kernel, so it now probes with a one-element add
pluseval(the add alone is lazy), cached andcatch_unwind-guarded.
Verified both ways on hardware: without the metallib MLX reports unavailable
and selection falls to Metal; with it, MLX is selected and matches CPU.
Changed
- iOS and tvOS deployment floors raised to 17.0. That is where Metal gained
nativebfloat, which MLX's kernels use — measured against the Metal compiler,
bfloatis an unknown type at 16.0 on both. visionOS stays at 1.0 (already past
that line); watchOS is 26.0, because rustup's prebuiltstdfor
aarch64-apple-watchosis itself compiled at 26.0. - The Apple xcframework builds with a size-tuned
applecargo profile (LTO,
one codegen unit,panic = "abort"— safe because everyrlx-ffientry point
isextern "C"and already aborts on unwind).opt-levelstays at 3
deliberately: this is numeric kernel code on a thermally-limited device.
Validated
-
All four Apple backends executed on hardware — iPad Pro 11-inch (3rd gen),
iOS 26.5.2 — each matching the CPU reference atmax|Δ| = 1.49e-8:backend vs CPU Metal 1.49e-8 CoreML / ANE 1.49e-8 MLX 1.49e-8 wgpu 1.49e-8 This is the first execution of Metal, MLX and wgpu on an Apple platform other
than the macOS host. A headlesssimctl spawnexposes no Metal device, so on
every simulator these three reportunavailableand skip — meaning the parity
assertions for them had never actually run.skipped: []on device is the line
that says they finally did.
Performance
- Apple release artifact: 11.46 MB → 6.36 MB for a linked watchOS app
(−44.5%), measured.__eh_frame709 KB → 38.6 KB,__gcc_except_tab
329 KB → 6.0 KB,__unwind_info124 KB → 14.4 KB. Numerics unchanged:
ANE-vs-CPU max|Δ| is 1.49e-8 under both the stock release and theapple
profile, andjust test-apple-sim applere-runs the simulator suite under the
shipped settings as a standing precision gate.
Added
- Portable polynomial transcendentals in
rlx_cpu::vmath—exp,log,
ln_1p,softplus,tanh,sin,cos,tan,atan, plus
vv{sin,cos,tan,atan}f_fastand thevvlogf_fast/vvlogf_hotpair that
expandtanhalready had. Written in plain Rust rather than intrinsics on
purpose: with no libm call and no branches they auto-vectorize to baseline
SSE2 and to NEON, so they need no#[target_feature]and no CPUID gate and
land on every CPU of the architecture — including the pre-AVX Atoms, plus
wasm and riscv. Accuracy is pinned by guard tests with bounds set from
measurement on both architectures:exp2.53e-7 rel,log1.19e-7 rel over
2^±30 including denormals,sin/cos1.19e-7 abs to |x| = 1e6,tan
2.15e-7 rel (finite and accurate at the poles),atan1.19e-7 abs. kernels::swiglu_rows,kernels::softmax_rows_poly,kernels::layer_norm_rows.- ISA portability gate —
just check-isaruns
crates/backends/rlx-cpu/tools/isa_portability.py.scanattributes every
above-baseline instruction to its enclosing symbol and fails on any that is
not runtime-gated, judged against the baseline of the target the binary was
built for (sdotis baseline onaarch64-apple-darwin, a finding on
aarch64-unknown-linux-gnu); it also catches the inverse, a
-C target-cpu=nativeartifact with above-baseline code smeared across
ordinary symbols where no runtime dispatch can help.atomandarmbuild
and run the suite underqemu-useron an emulated Goldmont Atom and
Cortex-A53.
Changed
bias_geluandlayer_norm_rowdispatch on CPUID at runtime instead of
cfg(target_feature = "avx2"). One artifact now takes the AVX2 kernel on a
capable host and the portable loop on a pre-AVX one. Previously a portable
build silently lost those AVX2 kernels, and the only way to get them was a
-C target-cpu=nativebinary thatSIGILLs on any pre-Gracemont Atom.
x86_dispatch_arms_agreepins the two arms together (measured divergence
1.19e-7 and 4.77e-7).- Nine
Activationarms — Sigmoid, Softplus, Elu, Mish, LogSigmoid, Sin, Cos,
Tan, Atan — andErfnow use the polynomial arms, matching Tanh/Exp (via
vmath::*_hot) and Gelu/Silu (viakernels), which already did.
Activation::Log, log-softmax and the cross-entropylndeliberately stay
on accurate libm: they feed losses, and that is worth more than throughput. rlx-cpu/build.rsprobes for OpenBLAS on every native Linux target, searches
/usr/lib64(Fedora/RHEL/SUSE), and asks the C compiler whether
-lopenblasresolves before giving up.
Deprecated
rlx_ir::target::has_avx2_likely— it reads as a capability check and is
really a build-flag check, which is howbias_geluended up existing only in
non-portable artifacts. Dispatch onis_x86_feature_detected!instead.
Fixed
- x86-64 Linux could not build without OpenBLAS.
build.rsprobed only
whentarget_arch != "x86_64", so x86-64 fell through to an unconditional
-lopenblasand a hardunable to find library -lopenblason the one target
most likely to lack it — a container, a minimal distro, a cross build — while
every other target degraded to the portable gemm. - A Q4_K accuracy test compared a kernel against itself.
exact_q4k_decode_gemv_beats_the_q8k_arm_on_accuracytook its reference from
gguf_matmul_bt_cached, which undercfg(not(rlx_cpu_blas))forwards to the
arm being measured, so the error was identically 0 and the assertion could
never hold. Now an independent f64 oracle, which is stricter in every build. exec_dispatch's AVX2 elementwise path could not inline its intrinsics.
The runtime check and the_mm256_*calls sat in the same function, which
carried no#[target_feature], so each intrinsic stayed an out-of-lined
callplus avzeroupper— four per 8 lanes.- Redundant
unsafeblock inhsum_i32_avx2(the crate's only warning).
Performance
Measured; Apple figures are real hardware, x86 figures are from a
Rosetta-backed container and so are indicative rather than final.
- SwiGLU 1.63–1.78× (M4) / 2.69–3.16× (x86). Both
Thunk::FusedSwiGLU
paths had the loop inlined byte-identically with a libmexpfper element on
every architecture — no NEON arm, no AVX2 arm — so the FFN activation of
every modern transformer was scalar on Apple Silicon too. Now one shared
kernels::swiglu_rows. - Row softmax ~2.0× (M4) / ~2.5× (x86), of which 1.42–1.51× / 1.19–1.28×
comes from giving themaxandsumreductions four independent
accumulators so they vectorize at all. exp~3× on x86 (746–797 vs 247 Melem/s): the pre-AVX2 fallback was a
libm call per element, which is both slow and unvectorizable.atan3.93× (M4) / 6.95× (x86);sin1.25× / 1.63× (lower because its
argument reduction must run in f64, halving the lane count).- LayerNorm +13.2% at h = 32. Every transformer-block path called
layer_norm_rowinsidefor r in 0..m, running the CPUID check once per
row;layer_norm_rowschecks once per op. 2.4% at h = 128, nil by h = 1024.
Known issues
kernels::avx2_exp8builds its clamp from_mm256_max_ps/_mm256_min_ps,
which return the non-NaN operand, so the AVX2 arm turns a NaN input into
~1e-38 wherevmath::exp_polynow propagates it. Fixing it costs a compare
and a blend per call in the AVX2 kernel; recorded rather than copied.