Skip to content

Propagate CPU errors to events - #3742

Merged
zcbenz merged 4 commits into
ml-explore:mainfrom
zcbenz:cpu-error
Aug 17, 2026
Merged

zcbenz merged 4 commits into
ml-explore:mainfrom
zcbenz:cpu-error

Conversation

@zcbenz

@zcbenz zcbenz commented Jun 22, 2026

Copy link
Copy Markdown
Member

This PR implements exception handling for errors happened in eval_cpu. Similar to #3523, the cpu scheduler would poison all pending events in the stream whenever an error happened, and an exception would throw when the poisoned event is synchronized.

Most of this PR is doing refactoring:

  1. Move the error handling from metal::EventImpl to the public Event class.
  2. Add methods to Scheduler to make it capable of setting errors in events.
  3. Refactor platform event implementations to use the new Scheduler methods to signal/wait events.

Note that most of the errors happened in eval_cpu would be fatal and not recoverable, so this PR does not catch all errors, instead we have to catch the expected errors and pass to the scheduler explicitly, this PR handles the IO error in Load::eval_cpu as example.

@zcbenz zcbenz mentioned this pull request Jun 22, 2026
4 tasks
Comment thread mlx/scheduler.cpp Outdated
Comment thread mlx/event.h Outdated
@aleroot

aleroot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

This PR adds errors to Event, but array::is_available() can now silently discard them.

If a CPU load fails, the event may have both error != nullptr and is_signaled() == true by the time the caller reaches array::wait(). In that case is_available() takes this branch, detaches the event, marks the array available, and never calls Event::wait() / check_error().

A concrete fast-failure interleaving is: the event is inserted, set_error() poisons it, the stream later signals it, and all of that finishes before the main thread calls eval_impl(...).wait(). The final wait then sees a signaled event and swallows the error.

I think either array::is_available() must check/take the event error before detaching a signaled event, or Event::is_signaled() needs to surface poisoned events somehow.

For context, these issues would prevent me from reliably landing ml-explore/mlx-swift#427, which is why I opened my original MLX PR.

That Swift PR depends on CPU lazy-load read failures propagating deterministically to eval. If those errors can be dropped or swallowed, the progress API can work for the happy path but still cannot safely handle truncated or failed safetensors reads.

Comment thread mlx/scheduler.cpp Outdated
@zcbenz
zcbenz force-pushed the cpu-error branch 2 times, most recently from 84a9b7a to b71f0ec Compare June 23, 2026 03:55
@zcbenz

zcbenz commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

Thanks a lot for reviewing this!

I updated the PR with a different strategy: the error happened in eval_cpu is now persistent in scheduler per stream, until the eval ends. All signaled events in the stream would be poisoned by the error in stream, and all waited events would poison the stream if an error happened.

On the race condition of error() I made method private and added a thread-safe load_error() to replace it.

On array::is_available() swallowing the error, I made array::detach_event check error before detaching.

Comment thread mlx/transforms.cpp Outdated

@aleroot aleroot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this work, once released I will definitely make use of it in my apps.

@aleroot

aleroot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@zcbenz Sorry to disturb, but can this be merged so that we can unlock the mlx-swift development as well ? Thanks.

erwinzhang7 added a commit to erwinzhang7/mlx that referenced this pull request Aug 9, 2026
Killing one rank of a ring group leaves every surviving rank hung. Two
separate defects combine to produce it.

An orderly peer close is invisible. recv() reports it by returning 0 and
leaves errno untouched, so the errno != EAGAIN test reads a stale value.
On a non-blocking socket that value is almost always EAGAIN, because every
earlier call with no data available set it, so the failure is skipped, the
error count never rises, and the worker spins on a dead socket at 100% CPU
without logging anything. sendAll() and recvAll() in the nccl backend
already treat <= 0 as failure.

Reaching the error threshold does not release waiters either. The worker
returned, leaving every queued task's promise unsatisfied. Because the
SocketThread outlives its worker those promises are not destroyed, so no
broken_promise is delivered, the futures never become ready, and every
wait blocks forever.

Treat r == 0 as an error on both the send and recv paths, and reject the
pending promises rather than returning, so the failure reaches whoever is
waiting instead of the collective completing without this peer's
contribution. The internal waits become get() so the exception is
observed rather than discarded.

Until the error can be carried from the stream thread to the main thread
the exception terminates the process rather than surfacing to the caller.
That is still a diagnosable stop rather than a silent hang or a wrong
result, and ml-explore#3742 makes it a catchable error.
erwinzhang7 added a commit to erwinzhang7/mlx that referenced this pull request Aug 9, 2026
Killing one rank of a ring group leaves every surviving rank hung. Two
separate defects combine to produce it.

An orderly peer close is invisible. recv() reports it by returning 0 and
leaves errno untouched, so the errno != EAGAIN test reads a stale value.
On a non-blocking socket that value is almost always EAGAIN, because every
earlier call with no data available set it, so the failure is skipped, the
error count never rises, and the worker spins on a dead socket at 100% CPU
without logging anything. sendAll() and recvAll() in the nccl backend
already treat <= 0 as failure.

Reaching the error threshold does not release waiters either. The worker
returned, leaving every queued task's promise unsatisfied. Because the
SocketThread outlives its worker those promises are not destroyed, so no
broken_promise is delivered, the futures never become ready, and every
wait blocks forever.

Treat r == 0 as an error on both the send and recv paths, and reject the
pending promises rather than returning, so the failure reaches whoever is
waiting instead of the collective completing without this peer's
contribution. The internal waits become get() so the exception is
observed rather than discarded.

Until the error can be carried from the stream thread to the main thread
the exception terminates the process rather than surfacing to the caller.
That is still a diagnosable stop rather than a silent hang or a wrong
result, and ml-explore#3742 makes it a catchable error.
zcbenz pushed a commit to erwinzhang7/mlx that referenced this pull request Aug 9, 2026
Killing one rank of a ring group leaves every surviving rank hung. Two
separate defects combine to produce it.

An orderly peer close is invisible. recv() reports it by returning 0 and
leaves errno untouched, so the errno != EAGAIN test reads a stale value.
On a non-blocking socket that value is almost always EAGAIN, because every
earlier call with no data available set it, so the failure is skipped, the
error count never rises, and the worker spins on a dead socket at 100% CPU
without logging anything. sendAll() and recvAll() in the nccl backend
already treat <= 0 as failure.

Reaching the error threshold does not release waiters either. The worker
returned, leaving every queued task's promise unsatisfied. Because the
SocketThread outlives its worker those promises are not destroyed, so no
broken_promise is delivered, the futures never become ready, and every
wait blocks forever.

Treat r == 0 as an error on both the send and recv paths, and reject the
pending promises rather than returning, so the failure reaches whoever is
waiting instead of the collective completing without this peer's
contribution. The internal waits become get() so the exception is
observed rather than discarded.

Until the error can be carried from the stream thread to the main thread
the exception terminates the process rather than surfacing to the caller.
That is still a diagnosable stop rather than a silent hang or a wrong
result, and ml-explore#3742 makes it a catchable error.
@sashko-zakharchuk

Copy link
Copy Markdown
Contributor

Ran into a case this doesn't cover yet, in the ring backend after #4060.

When a peer disconnects, SocketThread fails the pending promises and the exception is
rethrown by future::get() inside the closures the collectives dispatch onto the stream
thread. Nothing catches on that thread, so the process dies with terminate called after throwing an instance of 'std::runtime_error' rather than surfacing the error. Deterministic
on a 2-rank localhost ring: hard-kill one rank while the other runs ring send/recv on one
thread and a CPU-stream all_sum on another; the survivor aborts mid-run, every run, CPU and
CUDA backends both (sm_120). Same with this branch merged into current main; it merges
cleanly.

Wiring the ring dispatch sites into this PR's mechanism closes it: the dispatched closures
get wrapped so a comm failure goes to scheduler::set_error instead of escaping the thread.
Two smaller pieces fell out of that: the segment futures need draining so no socket task
still points into array buffers after a failure, and a broken SocketThread fails fast so a
collective on a dead group raises rather than hanging. The survivor then gets
RuntimeError: [ring] connection to a peer was lost out of mx.eval and can keep running.

What I checked: a CPU-stream all_sum feeding a GPU matmul in one synchronous eval raises
out of mx.eval, so the error-carrying events in this PR do cross streams through the fence
path; a second collective on the dead group raises immediately; ring_test_distributed.py
passes 13/13 on both ranks under both DEVICE=cpu and DEVICE=gpu. The functional delta is
+71/-22 in ring.cpp (git diff -w; clang-format reindentation on top of that). Happy to open
it as a follow-up PR on top of this branch, or fold it in here, whichever you prefer.

One note: even with the fix, a process that exits while the ring is still up can hit
terminate called without an active exception at teardown. That is pre-existing on main for
clean runs as well and looks like #4110's territory, so I left it alone.

repro (2-rank localhost ring, kill one rank mid-run)
# repro.py, one process per rank:
#   MLX_RANK=0 MLX_HOSTFILE=hosts.json python repro.py
#   MLX_RANK=1 MLX_HOSTFILE=hosts.json python repro.py
# hosts.json: [["127.0.0.1:15500"], ["127.0.0.1:15501"]]
# kill -9 the rank-1 process mid-run; rank 0 dies with SIGABRT on main.
import time
import mlx.core as mx

group = mx.distributed.init(backend="ring")
cpu = mx.default_stream(mx.Device(mx.cpu))
a = mx.ones((256, 256))
expected = float(group.size()) * a.size
for it in range(2000):
    z = mx.distributed.all_sum(a, group=group, stream=cpu)
    y = (z @ mx.eye(256)).sum()  # gpu consumer, fence-linked to z
    try:
        mx.eval(y)
    except RuntimeError as e:
        print(f"it {it}: caught {e}")
        break
    assert float(y) == expected
    time.sleep(0.01)

@erwinzhang7

Copy link
Copy Markdown
Contributor

Confirmed this myself, since #4060 is what introduced the gap.

Reproduced on an M5 Max, macOS 26.6, 2-rank localhost ring on current main, using your repro. Rank 0 dies
with exit 134:

[ring] Socket 3 was closed by the peer
[ring] Too many send/recv errors. Failing pending tasks...
libc++abi: terminating due to uncaught exception of type std::runtime_error:
[ring] connection to a peer was lost

The except RuntimeError never runs. So the detection from #4060 is doing its job and the
infinite hang is gone, but the exception escapes the stream thread and the process aborts
instead of the caller catching it. #4060 claimed pending operations fail "with an exception the
caller can observe", and that isn't happening on this path.

Your read of the mechanism matches what I'm seeing: the f.get() calls live inside the
encoder.dispatch closures in ring.cpp, so there is no handler on that thread.

@zcbenz
zcbenz force-pushed the cpu-error branch 2 times, most recently from 3f37869 to 1add2cf Compare August 15, 2026 05:18
@zcbenz

zcbenz commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

Thanks for testing this PR. I have rebased it on #4174 and change the code to simply catch and transfer all exceptions in CPU streams.

@erwinzhang7

erwinzhang7 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Tested the rebase against the peer-loss case. It closes it, with no change to ring.cpp.

Built 1add2cfe4, M5 Max, macOS 26.6, two rank localhost ring, one rank hard killed mid-run
while the survivor runs a CPU-stream all_sum in a loop inside try/except RuntimeError:

build outcome
v0.32.0 (released) hangs, no exception, killed at 40 s, 3/3
current main 9ab977b56 aborts, exit 134, except never runs, 4/4
this PR 1add2cfe4 except RuntimeError runs, exit 0, 8/8
[ring] Too many send/recv errors. Failing pending tasks...
rank 0 CAUGHT RuntimeError: [ring] connection to a peer was lost
RESULT: exception surfaced to the caller

The currently released version hangs rather than aborts, since #4060 has not shipped in a
tag yet. So on 0.32.0 a peer loss is an infinite wait, on main it is an abort, and with this it
is a catchable error. The last is the one #4060 claimed and did not deliver.

Why no ring change is needed

Scheduler::enqueue wraps every task now:

st.enqueue([&st, task = std::move(task)]() mutable {
  try {
    task();
  } catch (const std::exception& error) {
    if (!st.error.valid()) { ... }
  }
});

and cpu::CommandEncoder::dispatch goes through scheduler::enqueue on both of its branches,
which is where ring's four encoder.dispatch sites end up. The f.get() rethrow that had no
handler on the stream thread now has one, so it becomes a stream error and surfaces at eval.

One thing I looked for and did not find

In dispatch, every tenth call wraps the task so that notify_task_completion runs after it:

auto task_wrap = [s = stream_, task = std::move(task)]() mutable {
  task();
  scheduler::notify_task_completion(s);
};

The new catch sits outside that lambda, in enqueue, so a throw from task() skips
notify_task_completion and I expected an unbalanced counter to turn the abort into a hang
whenever the failing dispatch landed on that boundary. Eight consecutive runs all exited
cleanly, so if it is reachable it is rarer than this repro reaches, and it may not be reachable
at all. It's clearly not urgent, but may potentially show up later as a flake rather than a failure.

Not touching @sashko-zakharchuk's ring work: the two pieces beyond the abort, draining segment
futures so no socket task still points into array buffers and failing fast on a dead group, are
separate problems that a catch does not solve.

@zcbenz

zcbenz commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

In dispatch, every tenth call wraps the task so that notify_task_completion runs after it:

Thanks for noticing that, I think it is a valid concern that totally could happen. I added a fix for that.

@zcbenz
zcbenz merged commit 06f154b into ml-explore:main Aug 17, 2026
28 checks passed
@zcbenz
zcbenz deleted the cpu-error branch August 17, 2026 10:39
davidtai added a commit to Layr-Labs/mlx that referenced this pull request Aug 25, 2026
* Return tuple in meshgrid (ml-explore#4229)

* Add endpoint parameter to linspace (ml-explore#4184)

Co-authored-by: Cheng <git@zcbenz.com>

* Fix vmap of partition/argpartition dropping the kth argument (ml-explore#4116)

* Fix nan_to_num replacing inf with 0 for float16 and bfloat16 (ml-explore#4222)

Co-authored-by: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com>
Co-authored-by: Cheng <git@zcbenz.com>

* Fix einsum not broadcasting batch dimensions in batched tensordot (ml-explore#4125)

Co-authored-by: Cheng <git@zcbenz.com>

* Dequantize in float32 (ml-explore#4241)

* chore: Reject complex in erf and erfinv (ml-explore#4243)

* Fix cpu compilation failure of abs with uint (ml-explore#4240)

Co-authored-by: Cheng <git@zcbenz.com>

* Fix quantize matrix multiplication floor issue (ml-explore#4251)

* Only use MPI backend for world size > 1 (ml-explore#4210)

* chore: Reject complex in expm1, sigmoid and arctan2 (ml-explore#4257)

* Decompose small kernel-depth 3D convs into 2D convs (ml-explore#3785)

Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com>
Co-authored-by: Cheng <git@zcbenz.com>

* Fix Metal sort of a view with a negative stride (ml-explore#4252)

* Mirror the depth axis in the decomposed 3D conv when flipped (ml-explore#4277)

* Fix Metal row reductions on negative-stride views (ml-explore#4267)

Co-authored-by: Fu Xiaonan <214359569+FU-max-boop@users.noreply.github.com>

* [CUDA] Fix custom kernel cache collision for same name, different source (ml-explore#4273)

Co-authored-by: Cheng <git@zcbenz.com>

* Fix ops rejecting integers larger than INT32_MAX (ml-explore#4255)

Co-authored-by: Feli <feli@hnu.edu.cn>
Co-authored-by: Cheng <git@zcbenz.com>

* Fix var/std for complex numbers (ml-explore#4260)

* Fix int32 overflow in conv padded input and pad shapes (ml-explore#4258)

Co-authored-by: Cheng <git@zcbenz.com>

* chore: Reject complex in remainder (ml-explore#4270)

* chore: Compare the macOS SDK version as a version when gating JACCL (ml-explore#4286)

* Clamp ring socket transfers so a payload of 2 GiB or more can be sent (ml-explore#4281)

Co-authored-by: Cheng <git@zcbenz.com>

* chore: Use normalize_axis_index in split/unstack/partition/topk (ml-explore#4288)

* Remove grouped output in CI (ml-explore#4195)

* [CUDA] Fix finding cuda 13 headers in JIT compilation (ml-explore#3995)

* Refactor wheel building script (ml-explore#3818)

* Make mx.compile cache erasing thread safe (ml-explore#4248)

Co-authored-by: yentur <mr.yentur@gmail.com>

* Add builds for free-threaded python (ml-explore#3812)

* Fix int32 overflow in concatenate/repeat/kron (ml-explore#4303)

* python: Widen list elements that do not fit in int32 to int64 (ml-explore#4305)

* Propagate CPU errors to events (ml-explore#3742)

Co-authored-by: Alessio Pollero <alessio.pollero@gmail.com>

* Fix mx.arange dtype inference overflow regression (ml-explore#4324)

* Add workflow to update pull request limit bypass list (ml-explore#4320)

* Support head dimension 72 in Metal full attention (ml-explore#4330)

* Patch bump to 0.32.2 (ml-explore#4333)

* Preserve subnormal float values when casting to bool (ml-explore#4224)

* python: Support assigning through a bare Ellipsis index (ml-explore#4314)

* Fix divmod truncating the quotient for floats (ml-explore#4108)

Co-authored-by: Cheng <git@zcbenz.com>

* Add force_fused option to scaled_dot_product_attention (ml-explore#4185)

* chore: Reject negative eps in the normalization layers (ml-explore#4312)

* Bound GGUF metadata string/array values against the file mapping (ml-explore#4212)

Co-authored-by: x14ngch3n <x14ngch3n@users.noreply.github.com>
Co-authored-by: Cheng <git@zcbenz.com>

* Read each K/V byte once in gqa-8 decode attention (ml-explore#4077)

* Fix fft vmap and jvp for transforms over a subset of axes (ml-explore#4138)

* Fix median dropping NaN (ml-explore#4146)

* Fix the CPU scan over a size one axis with a padded stride (ml-explore#4139)

Co-authored-by: Cheng <git@zcbenz.com>

* chore: Validate the optimizer betas at construction (ml-explore#4310)

Co-authored-by: Cheng <git@zcbenz.com>

* `RMSNormVJP` backward writes a full `{n_rows, D}` `gw_temp` intermediate (ml-explore#4293)

* [Bug]: add default none value to axis parameter of the take_along_axis (ml-explore#4357)

Co-authored-by: Anastasiia Filippova <a_filippova@apple.com>

* Add a fused full-attention path for head_dim 256 on NAX devices (ml-explore#3842)

Co-authored-by: Cheng <git@zcbenz.com>

* Update nanobind to 2.15.0 (ml-explore#4337)

* Skip unnecessary simdgroup computations for quantised MOE matmuls on NAX (ml-explore#4352)

* Add AI usage policy (ml-explore#4331)

Co-authored-by: Jake Bowhay <60778417+j-bowhay@users.noreply.github.com>

* Raise cpu stream errors from synchronize (ml-explore#4338)

Co-authored-by: Cheng <git@zcbenz.com>

* chore: Validate eps in Adam at construction (ml-explore#4361)

Co-authored-by: Anastasiia Filippova <a_filippova@apple.com>

* Bound winograd conv2d working set by tiling the batch (ml-explore#4102)

Co-authored-by: Cheng <git@zcbenz.com>

* Use a 32-row block in qmm_t_nax when one block covers all of M (ml-explore#4171)

* chore: Deduplicate fftshift and ifftshift (ml-explore#4318)

* Fix Log and Equal is_equivalent ignoring primitive state (ml-explore#4266)

Co-authored-by: Cheng <git@zcbenz.com>

* Stabilize reduced-precision InstanceNorm (ml-explore#4230)

* chore: Normalize negative axes in sort and argsort (ml-explore#4332)

* Clean up main thread compile cache before python interpreter shuts down (ml-explore#4373)

* chore: Check malformed jaccl hostfile that miss rdma in pairs (ml-explore#4284)

Co-authored-by: Cheng <git@zcbenz.com>

* Round mxfp8 block scales up to avoid saturation (ml-explore#4353)

Co-authored-by: Daniel Hiltgen <daniel.hiltgen@ollama.com>
Co-authored-by: Cheng <git@zcbenz.com>

* Add support for the __array_namespace_info__  (ml-explore#4334)

* Stop a failed CUDA graph commit from poisoning the encoder (ml-explore#4356)

Co-authored-by: Cheng <git@zcbenz.com>

* Fix quantized kernels in JIT build (ml-explore#4372)

Co-authored-by: Cheng <git@zcbenz.com>

* Avoid zero work in stride-2 ConvTranspose3d (ml-explore#4343)

* [CUDA] Ce fused kernel (ml-explore#3947)

* Fix cpu exclusive scan for complex numbers (ml-explore#4272)

Co-authored-by: Cheng <git@zcbenz.com>

* Support Relocatable CUDA DLLs on Windows (ml-explore#4382)

* Use cast_to for fused AsType in compiled Metal kernels (ml-explore#4351)

Co-authored-by: katlun-lgtm <katlun@windyviews.com>
Co-authored-by: Cheng <zcbenz@gmail.com>

* python: Declare DLPackCompatible protocol members as methods (ml-explore#4384)

* Fix quantizing sliced arrays (ml-explore#4381)

* Fix einsum dropping a trailing empty subscript (ml-explore#4299)

Co-authored-by: Cheng <git@zcbenz.com>

* Add script to run python tests (ml-explore#4393)

* Hold GIL in AttachedData destructor (ml-explore#4391)

* Bound Metal buffer COUNT, not just bytes, in MetalAllocator

The Metal allocator throws `[metal::malloc] Resource limit (N) exceeded`
when num_resources_ (the live+cached Metal buffer COUNT) reaches
resource_limit_ (the iogpu.rsrc_limit sysctl, default ~499000). Freed
buffers are recycled into a size-keyed cache whose only trim is by BYTES
(release_cached_buffers takes a bytes-to-free target, max_pool_size_ ~=
physical RAM). Under churn with many distinct buffer shapes (varied prompt
lengths, growing KV caches, multiple co-resident models) the cache fills
with entries never reused at that exact size, so the COUNT climbs to the
limit while byte usage stays modest and the byte trim never fires — the
process crashes mid-inference on a machine with most of its RAM free.

malloc() now also reclaims by count: when num_resources_ crosses a 90%
high-water mark of resource_limit_, it clears the (pure-reuse) buffer
cache so the count drops back to the live working set. Clearing the cache
only costs re-allocation, never correctness, so the count limit becomes
unreachable by any request mix or batching method while the existing byte
limits keep total memory bounded.

Adds get_num_resources()/get_resource_limit() to the public memory API
(metal + no_gpu + cuda backends) so the count and its ceiling are
observable from callers. Adds an MLX_RESOURCE_LIMIT env override that can
only LOWER the ceiling (clamped to the OS limit, strictly validated) to
exercise the trim deterministically and as an operator safety valve.

* perf(mlx): opt-in Gemma 4 expert-QMM tile kernel with parallel descriptor builder (#4)

* perf(mlx): add opt-in Gemma 4 expert-QMM tile kernel with parallel descriptor builder

Adds a distinctly-named expert QMM implementation for the Gemma 4
26B-A4B MoE production shapes, gated by MLX_GATHER_QMM_EXPERT_SLICES:

- qmm_t_expert_impl: BM32 expert tile body (BM16 fallback rows) taking a
  private/by-value row count; the shared qmm_t_impl constant-address ABI
  and all ordinary gathered/batched/dense QMM routes are unchanged.
- build_gemma4_sorted_expert_tiles_bm32: one 128-thread threadgroup
  replaces the reference design's single-GPU-thread serial builder;
  parallel expert-range binary search, Hillis-Steele scan, and strided
  upper-bound descriptor emission.
- Selector runs after the NAX-first route and requires affine BF16
  transposed inputs, 4-bit gs=64 weights, 128 experts, assignment counts
  of exactly 4096/8192/16384, and the exact gate/up or down rank-3
  shapes; every miss keeps the legacy route. NAX engagement is
  non-engagement, never bypassed.
- device.{h,cpp}: one-shot request resolution, nonthrowing dual-symbol
  AOT probe/prewarm, relaxed-atomic diagnostics (requested, aotAvailable,
  naxAvailable, hits, per-class fallbacks).
- gpu_tests: exact-shape arithmetic parity, fallback, and counter
  invariant probes.

Retention standing (2026-08-09 production matrix): opt-in experiment.
Standalone profile dropped (prefill -10.2% vs bracket); paired
weighted-unsort+R1 profile retained-final (prefill +1.8%, TTFT -7.5%,
decode +3.3%, arrival E2E +12.0%). NOTE: this source post-dates the
benchmarked binaries/metallib (post-measurement kernel-body edit);
rebuild and re-verify before any performance claim.

* fix(mlx): fail-safe sortedness check in gemma expert tile builder; counter/atomic hygiene

Review-wave fixes for the R1 expert-QMM path:

- N1 (sortedness trust): build_gemma4_sorted_expert_tiles_bm32 now
  verifies each thread's post-binary-search segment boundary against the
  generalized invariant indices[start - 1] < lid <= indices[start]
  (edge threads check their single neighbor), votes per simdgroup via
  simd_or, folds the votes through threadgroup memory, and on any
  violation retracts count[0] to 0 (tile kernel then early-returns) and
  records the violation in count[1]; the buffer ABI is unchanged
  (count index 1 was previously unused). try_gemma4_expert_qmm allocates
  the second count element, drains the encoder after the builder, and
  re-routes a retracted call to the order-agnostic legacy path instead of
  dispatching the tile kernel (zero count is unambiguous: the selector's
  assignment gate guarantees M is 4096/8192/16384).
- N2 (route-condition duplication): the sorted-RHS gate literal that
  appeared (negated) in the diagnostics record and in the dispatch
  decision is now the shared static constexpr predicate
  takes_sorted_rhs_route, so future tuning of the 16/4 thresholds cannot
  desynchronize counter vs route.
- N3 (per-call bias normalization): gather_qmm_rhs no longer spends
  ensure_row_contiguous on biases before classification reads the raw
  tensor's fields; normalization runs only inside the winning-route
  branch (hit semantics unchanged; the legacy block keeps its own
  normalization point and ordering).
- N4 (armed_ data race): Gemma4ExpertQMMCounters::armed_ is now
  std::atomic<bool> with relaxed loads/stores in armed(), snapshot(),
  snapshot_and_disarm() (read-then-write order preserved) and
  clear_and_arm(); the class remains non-copyable, now enforced.

* fix(mlx): make the R1 sortedness fail-safe sound; proper retract attribution

F1: the per-expert boundary vote was a partial detector -- an inversion
inside a segment used by no other expert's boundary could escape, so
"re-route on any violation" overclaimed. build_gemma4_sorted_expert_tiles_bm32
now also runs a strided adjacent-pair scan: thread lid checks
indices[i-1] <= indices[i] for i = lid+1; i < M; i += 128, covering every
adjacent pair in [1, M) exactly once (1..128 iterations at the reachable
M in {4096,8192,16384}). Adjacent-pair monotonicity is transitive, so a
clean scan is a sound and complete sortedness oracle; it folds into the
same simd_or/threadgroup vote and the same retract (count[0]=0, count[1]=1).
The boundary checks stay as cheap, precise diagnostics.

F2: retracts were write-only in count[1] and surfaced as
fallback_metallib_unavailable -- misattribution in the only observable
surface. A dedicated fallback_sortedness_retracted counter now rides the
GemmA4 route counters and the C diagnostics ABI
(sizeof 80 -> 88, new uint64 at offset 80; existing offsets unchanged).
try_gemma4_expert_qmm returns the route class: count[0]==0 with count[1]==1
records fallback_sortedness_retracted, any other unusable build keeps
fallback_metallib_unavailable, then re-routes to the legacy path as before.

F4: new doctest drives the full armed() -> clear_and_arm() ->
snapshot_and_disarm() cycle and the attempts == hits + fallbacks invariant
including the new class; the route-table and counter-invariant tests now
cover fallback_sortedness_retracted.

Verified: cmake tests 262/262 + 3550 assertions pass; metal -Wall -Wextra
-fno-fast-math compile of kernels/quantized.metal is warning-free.

* perf(metal): E=256 expert-tile route + trust + gpu::eval UAF fix — darkbloom-base mirror (#7)

* perf(metal): instantiate E=256 expert-tile route for Qwen 3.5/3.6 MoE prefill (mirror of Cmlx/mlx 58fab46)

* fix(metal): use-after-free in gpu::eval for primitives that synchronize mid-eval (mirror)

* perf(metal): trust mode skips retract readback (mirror)

* fix(compile): preserve all-cache binding cleanup

---------

Co-authored-by: JasonHonKL <148705846+JasonHonKL@users.noreply.github.com>
Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Cheng <git@zcbenz.com>
Co-authored-by: Adityaj0 <93090622+Adityaj0@users.noreply.github.com>
Co-authored-by: anchor <codeanqiang@gmail.com>
Co-authored-by: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com>
Co-authored-by: Rohan Gautam <rohan1gautam@gmail.com>
Co-authored-by: Ayaan Gazali <ayaangazali.work@gmail.com>
Co-authored-by: Erwin Zhang <59893706+erwinzhang7@users.noreply.github.com>
Co-authored-by: katlun-lgtm <katlun@gmail.com>
Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com>
Co-authored-by: robertomeroni <150194833+robertomeroni@users.noreply.github.com>
Co-authored-by: Fu Xiaonan <ht3fudatou@163.com>
Co-authored-by: Fu Xiaonan <214359569+FU-max-boop@users.noreply.github.com>
Co-authored-by: Hao Xu <hxu44@apple.com>
Co-authored-by: Feli <89400571+FeliGame@users.noreply.github.com>
Co-authored-by: Feli <feli@hnu.edu.cn>
Co-authored-by: Eyüp Can Akman <eyupcanakman@gmail.com>
Co-authored-by: Cheng <zcbenz@gmail.com>
Co-authored-by: yentur <mr.yentur@gmail.com>
Co-authored-by: Alessio Pollero <alessio.pollero@gmail.com>
Co-authored-by: Zhiqi Zhang <zhiqizhangg@gmail.com>
Co-authored-by: Daniel Hiltgen <dhiltgen@users.noreply.github.com>
Co-authored-by: Tanish Jain <recklurker@gmail.com>
Co-authored-by: hojin12312 <hojin12312@gmail.com>
Co-authored-by: Xiang Chen <46052474+x14ngch3n@users.noreply.github.com>
Co-authored-by: x14ngch3n <x14ngch3n@users.noreply.github.com>
Co-authored-by: Duhyeon, Kim <49020301+dudududukim@users.noreply.github.com>
Co-authored-by: rohith <kapellirohith@gmail.com>
Co-authored-by: Ishaan Samantray <devteam.aegis@gmail.com>
Co-authored-by: Aaishwarya Mishra <aaishwarymishra@gmail.com>
Co-authored-by: Anastasiia Filippova <a_filippova@apple.com>
Co-authored-by: Yanzhao Wang <19340816+wyanzhao@users.noreply.github.com>
Co-authored-by: XXXXRT666 <157766680+XXXXRT666@users.noreply.github.com>
Co-authored-by: Jake Bowhay <60778417+j-bowhay@users.noreply.github.com>
Co-authored-by: vraj patel <87225460+vraj00222@users.noreply.github.com>
Co-authored-by: Gusanidas <33495733+Gusanidas@users.noreply.github.com>
Co-authored-by: Dwijen Patel <dwijen@gmail.com>
Co-authored-by: Vladimir Iglovikov <ternaus@users.noreply.github.com>
Co-authored-by: Brian C. <94733710+deBrian07@users.noreply.github.com>
Co-authored-by: Daniel Hiltgen <daniel.hiltgen@ollama.com>
Co-authored-by: YH Yan <strayberry0w0@gmail.com>
Co-authored-by: katlun-lgtm <katlun@windyviews.com>
Co-authored-by: anupsv <6407789+anupsv@users.noreply.github.com>
Co-authored-by: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com>
Co-authored-by: David Tai <davidtai@Davids-MBP.lan>
inureyes added a commit to lablup/mlxcel that referenced this pull request Sep 10, 2026
Review of the pin bump found three things the new pin changes underneath this tree.

- ml-explore/mlx#4208 moved Cholesky onto cuSOLVER and `gpu::init()` now creates its handle cache on every CUDA start. MLX links it PRIVATE, so cargo never saw it and every `--features cuda` link would fail on `cusolverDnCreate`; `link_cuda()` now names `cusolver`.
- ml-explore/mlx#3742 made `array::is_available()` detach the event through `Event::check_error()`, which throws and clears a failed launch's error. The rejection sampler's deferred drain called it on slots other requests stashed, inside `fused_sample`, which is not a `Result` bridge, so a failed command buffer would terminate the process and hide the error from the request that owns it. The drain now reads status, signal and error pointer directly and drops a failed slot unread.
- The Metal `compiled.cpp` overlay still emitted `elem_to_loc_1<uint>` for 1-D inputs, half of ml-explore/mlx#3720 that an earlier sync missed; it now matches upstream, so the overlay's only delta is the mixed-dtype cast. The CUDA mixed-type `FloorDivide` overload floors like upstream's float branch (ml-explore/mlx#4108), and three stale sync notes are corrected.

Workspace gate 10985 passed, 0 failed; clippy and fmt clean on Metal. The CUDA link is not verifiable on this host.

Refs #1769
inureyes added a commit to lablup/mlxcel that referenced this pull request Sep 10, 2026
drain_pending_verification's Failed path (stashed_launch_state reading status, the event signal and its error pointer instead of array::is_available()) had no regression test, only the reasoning in its comment. Reverting it to call is_available() on the fixture aborts the whole test process with an uncaught std::runtime_error, exactly the ml-explore/mlx#3742 failure mode this PR works around, so a silent reintroduction would not fail by name without this test.

Adds a test-only cxx bridge hook (sampling_dispatch_stash_failed_launch_for_test, plus two accessors) that builds a valid, signalled event carrying an error through MLX's public Event/Error API, stashes it into pending-verification slot 0, then asserts the drain neither throws nor consumes the error and drops the slot. Confirmed by temporarily reverting drain_pending_verification to is_available() and watching the new test crash the process; restored afterward.

Validation:
- cargo test --profile test-fast --features metal,accelerate -p mlxcel-core --lib sampling_rejection_tests:: (28 passed)
- cargo fmt --check and cargo clippy -p mlxcel-core --lib -- -D warnings (clean, narrow scope per CLAUDE.md)

Refs #1769
inureyes added a commit to lablup/mlxcel that referenced this pull request Sep 10, 2026
)

## Why

The fp8 round-trip bound failed on every Metal host, M1 Ultra byte-identically to M5 Max, because the pinned MLX `9a795735` predates ml-explore/mlx#4353: Metal and CPU encoded the mxfp8 E8M0 block scale as `round(log2(amax / 448))`, so about half the blocks saturated their maxima, losing up to `1 - 2^-1/2`. CUDA rounds up, which is why #1742 passed on GB10. The test was right, and `requantize_block_fp8_weights`, the only E8M0 quantize caller, was clipping vendor FP8 checkpoints on Metal. Widening the bound, as the issue proposed, was rejected.

## What changed

- MLX pin `9a795735` to upstream main `81ba1c6a` (99 commits). The seven overlays whose targets upstream touched are three-way merged and keep their deltas; the other 21 are unchanged upstream. `metal/compiled.cpp` also drops a leftover `elem_to_loc_1<uint>` that undid part of ml-explore/mlx#3720, so its only delta is the mixed-dtype cast.
- Adaptations to what the new pin changes under the bridge:
  - `gather_qmm` gained `global_scale` ahead of `sorted_indices` (ml-explore/mlx#4458), so all 13 calls pass `std::nullopt`.
  - CUDA now needs cuSOLVER (ml-explore/mlx#4208). `link_cuda()` names it, `docs/installation.md` lists it, and CI's link job now runs on pin and CUDA link-list changes, which is how this slipped past CI.
  - `array::is_available()` now throws and clears a failed launch's error (ml-explore/mlx#3742). The rejection sampler's drain now reads status, signal and error pointer instead, so a GPU fault no longer terminates the process from inside `fused_sample` or hides the error from its owner. A regression test aborts the process with the old drain and passes with the new one.
- The round-trip check moves into its own test, `fp8_block_requantize_round_trip_stays_within_half_an_e4m3_step`, with the same seed and shape. It states the derivation and asserts `group_max <= 448 * scale` per block.

## Validation (M1 Ultra, macOS 27.0)

- fp8: the old pin fails the new test at block 0 (the maximum 4.8046875 scales to 615 and saturates). The new pin saturates 0 of 650 blocks, with a worst error of 0.0489 of the group max against 0.2928 before.
- Turbo launchers pass (max RMS 1.7263e-4 and 1.5259e-4).
- Teacher-forced logit traces, old pin vs new, on five checkpoints at widths 1, 8 and 256: 0 disagreements on decided positions. Over 4,096 positions on qwen3-30b-a3b, 1 of 1,720 decided positions differs, at the reference's rank 2, with perplexity -0.20%.
- Branches the short runs missed:
  - head-dim-512 decode past 1,024 keys: identical text.
  - GQA-8 decode past 8,192 keys: identical text, decode 55.6 to 60.8 tok/s.
  - head-dim-72 vision towers: image prefill 239 to 279 tok/s. Descriptions diverge into equally faithful text; decided answers are unchanged.
- Short-context throughput is within 0.6% on three checkpoints.
- Workspace gate: 10986 passed, 0 failed, 359 ignored. Clippy, fmt, both pin parsers and the cross-repo reference check are clean.
- CUDA was compiled and linked in CI but not run: `OpenXLA feature link` linked a `--features cuda,xla-iree` release binary on GB10 at the new pin, which covers the overlays and the cuSOLVER link. The green `CUDA sm_70 compile` check is a skip, because CUDA 13.0 cannot target sm_70.

The per-checkpoint numbers, method and derivations are in `TECHNICAL_REPORTS/1772-mlx-pin-mxfp8-round-up-20260911.en.md`.

## Not validated, or reported and not fixed

- No CUDA execution. No M5 Max run: the generation-17 NAX paths changed upstream in this range are unmeasured.
- `array_evaluated_bytes`, the server's lookahead read, is another non-`Result` bridge function that now throws on a failed launch. It needs routing through the scheduler's step-failure path.
- The mixed-dtype cast in `metal/compiled.cpp` would cast a comparison's inputs to `bool`. This is latent, since no compiled function contains a comparison.

Closes #1769
aleroot added a commit to aleroot/mlx-swift that referenced this pull request Sep 15, 2026
ml-explore/mlx-c#130 changed `mlx_io_vtable` so that the callbacks report
whether they succeeded:

    int    (*seek)(void*, int64_t off, int whence);
    size_t (*read)(void*, char* data, size_t n);
    size_t (*read_at_offset)(void*, char* data, size_t n, size_t off);
    size_t (*write)(void*, const char* data, size_t n);

and `CReader`/`CWriter` now turn a negative seek or a short read/write into a
thrown `std::runtime_error`. Bump the mlx-c submodule to that commit (plus the
checked-in copies of `io_types.h` and the CMake `GIT_TAG`) and adapt the Swift
side:

- `FileIOState.seek` returns `0`/`-1` instead of silently ignoring a bad
  `whence` or a negative resulting offset.
- `FileIOState.read` returns the number of bytes actually read, so a file that
  is truncated after its header was parsed now fails instead of leaving the
  destination buffer uninitialized. The private implementation is renamed
  `readBytes` so it cannot be confused with the two public overloads.
- The in-memory reader reports an out-of-bounds read as `0` bytes rather than
  doing nothing, and its `seek` reports failure for an unknown `whence` or a
  negative offset.
- The file reader's `write` reports `0` bytes, so using it as a writer errors
  out instead of silently discarding the data.

This is the error-propagation path the load progress work was waiting on
(ml-explore/mlx#3742 is already in the vendored mlx v0.32.2), so the truncated
file test asserts a failure rather than skipping, and a new test truncates the
file *after* the header is parsed to cover the lazy read path:

    [mlx_io_reader] unable to read 65536 bytes (read 65504 instead) in file ...

Also fix the `withLoadProgressHandler` documentation links -- the
`-(_,()throws->R)` disambiguation does not resolve and `verify-docs.sh` builds
with `--warnings-as-errors` -- document why the reported progress is
approximate (loading is lazy, so it may stop short of the file size and it
counts bytes read rather than bytes covered), and add `LoadProgress` to the
MLX topics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants