fix(discovery): multicast discovery never forms a cluster on macOS - #2286
fix(discovery): multicast discovery never forms a cluster on macOS#2286zhast wants to merge 10 commits into
Conversation
Two defects meant multicast discovery only ever announced on a single interface, so nodes never found each other on macOS. 1. join_multicast_v6 returns AddrInUse for every interface after the first successful join, because the membership is held per-socket. The previous match treated that as a failure and fell through to the catch-all arm without pushing the interface onto the announce list. On a four-Mac cluster this left exactly one entry -- awdl0, the AirDrop interface -- so announcements never reached wifi or Thunderbolt. 2. A scope id in the destination address does not select the egress interface for IPv6 multicast. Without IPV6_MULTICAST_IF set per send, every datagram leaves via the default multicast interface regardless of which address it was addressed to. Verified on 4x Mac Studio (macOS 26.6.2) joined by wifi and Thunderbolt: announcements went from 1 interface (awdl0) to 14, including en1 and all three Thunderbolt links.
Discovery::next() is the only driver of announce(); the task that awaits it also awaits runtime.connect_peer() inline. connect_peer can block for a long time (or indefinitely) when a peer is unreachable or its zenoh handshake stalls, so the task never returns to next() and the node stops announcing entirely - permanently, not just for the duration of the stalled connect. Observed on a 4-node macOS cluster: each node emitted exactly 2 'announcing Hello' lines and then went silent, with the process alive and its API still serving. tcpdump confirmed zero packets on the discovery port across en1/en3/en4/en5/awdl0/lo0 while mDNS was captured normally on the same interface. A Python sender/listener pair on the same multicast group, port and interfaces exchanged packets every time, ruling out the network, the group and the interface set. Spawning the connect off-task lets the loop return to next() immediately. After this change a node sustains announcements indefinitely (2 -> 422 and climbing over the same interval).
…n thunderbolt probe timeout Three independent defects found while bringing up a 4x Mac Studio cluster on macOS 26. 1. announce() awaited send_to unbounded. A single interface whose transmit queue never drains (a tunnel with no reader, e.g. utun*) blocks the task forever, and because Discovery::next() is the only driver of announce() AND of recv_from, that silences discovery for the whole node permanently. Measured: the loop froze after 2 ticks; with a 50ms per interface cap it sustains indefinitely. Announcements are periodic and idempotent, so skipping a congested interface for one tick is the right trade. 2. There is no way to form a cluster when multicast discovery does not work, because --bootstrap-peers raises 'temporarily removed'. Add an EXO_ZENOH_CONNECT escape hatch taking comma separated host:port pairs, which is what let this cluster federate at all. 3. system_profiler SPThunderboltDataType is wrapped in fail_after(30) but takes ~106s on a Mac Studio with six Thunderbolt ports. It timed out on every cycle (52 consecutive failures observed), so no MacThunderboltIdentifiers/Connections were ever published, the master found no RDMA-connected cycles, and every MlxJaccl placement was rejected as 'no RDMA-connected cycles available'. Widening the timeout to 240s took nodeThunderbolt from 1 to 4 and produced a full 6/6 RDMA mesh.
…adlock Adds the two findings from the GLM-5.3-Flash bring-up: placement sizing shards from system RAM while macOS caps the GPU working set (the resulting mid-collective abort is indistinguishable from a deadlock), and the glm5_next pipeline hang with the full isolation matrix showing model, slicing, cache, RDMA and the pipeline wrappers each working in isolation.
Reproduced the same hang with GLM-4.7-Flash-4bit (glm4_moe_lite, 16 GiB), a model the released app serves correctly. Identical signature: rank 0 frozen after last.layer with an idle MLX stream, peer parked in first.recv. Tensor sharding on the same cluster is fine, so the fault is specific to the Pipeline path on main rather than to any model.
…ne code Tensor + MlxJaccl x2 stalls during load with one rank never starting (0/47 layers, frozen cpu) while its peer reaches 46/47. Combined with the pipeline deadlock this means both distributed backends fail on main for this cluster, whereas the released app drives the same machines correctly. All results re-verified after restoring auto_parallel.py to pristine.
Captures the venv-level changes needed to load and pipeline-shard GLM-5.3-Flash over MlxJaccl on EXO v1.0.71 built from source, as reviewable diffs against the pristine upstream wheels. - mlx_lm glm5_next bridge (new file): exposes the architecture through mlx-lm's registry, re-nests the forget-gate quantization tensors, and builds caches from mlx-lm's cache classes so EXO's isinstance-based SSM detection works. Without the last part EXO calls .trim() on recurrent state and no runner reaches ready. - mlx_vlm glm5_next: ssm_idx/fa_idx resolved against the current layer list (pipeline sharding replaces it after __init__, so cached indices address the wrong per-shard cache), plus a DSA-indexer _pool staleness guard for KV rewinds. - mlx_lm load_model: nesting-aware class_predicate. Loading, sharding, RDMA and generation work end to end; output quality on this 2-bit checkpoint is still wrong and is documented as open in the README, with the control result that exonerates the pipeline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… own Records that the release built from source is the working path (the shipped .app cannot take a new architecture - its Python is in a PyInstaller archive), points at patches/ for the out-of-tree changes, and calls out the two that are arguably exo's rather than mlx's: isinstance-based SSM cache detection tied to one package's class, and pipeline layer-slicing that leaves model-cached layer indices stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up, to close out the open question this PR left and to save anyone chasing a non-bug. The I verified mlx-vlm 0.6.17's
I also checked the checkpoint's own transformations against the real upstream weights, pulling individual tensors out of the 640 GB FP8 original with HTTP range requests (~42 MB total rather than the whole repo). The community quantiser had replaced upstream's What is actually wrong is the quantisation itself: the 2-bit routed experts measure cosine 0.922 against the true upstream weights (~38% orthogonal noise per expert matrix, compounded over 42 MoE layers). The 8-bit tensors in the same checkpoint measure 0.99997. That is enough to destroy coherence while leaving activation statistics perfectly healthy — every layer's mean/std/absmax looks normal end-to-end, with no NaN or inf, which is what made it awkward to diagnose. So: nothing for exo to fix here, and Separately, the second finding I listed above — 🤖 Generated with Claude Code |
|
Retracting my previous comment — it was wrong. I claimed the GLM-5.3-Flash output problem was the community 2-bit quantisation and not exo. That conclusion does not hold, and I'd rather correct it here than leave it standing. I have since run the identical checkpoint single-process (no pipeline sharding, no distributed group, same mlx-vlm code path): That is coherent. The same weights under exo's 3-way pipeline return Where I went wrong: the component-level comparisons in my previous comment are all still valid (hyper-connection exact, linear attention corr 0.999974, DSA corr 0.999995, MLA absorption corr 0.99997 — those numbers stand). But I also checked that the pipeline was internally consistent — each rank's received tensor matched the previous rank's sent tensor, activations stayed finite and well-scaled across all 45 layers — and wrongly read that as correct. Consistent is not correct, and "everything I tested is fine" is not "everything is fine." Useful narrowing for anyone hitting this: exo's very first generated token already differs from the single-process argmax, so the divergence is in the sharded forward pass itself, not in the post-prefill Still bisecting; I'll report the actual root cause rather than another guess. Apologies for the noise. 🤖 Generated with Claude Code |
|
Root cause found, and it is neither exo nor the quantisation. Filing here to close out the two incorrect calls I made above. It is the fused sinkhorn Metal kernel in mlx-vlm's That kernel reads Measured on GLM-5.3-Flash layer 0 against a float64 reference of the same sinkhorn: Why it survived so much scrutiny, which may be useful to others:
After the fix, on 3 nodes over JACCL/RDMA: Throughput improves as well (4.09 tok/s with the fixed kernel vs 2.39 forcing So: the component numbers in my first comment stand, the "it's the 2-bit quant" conclusion was wrong, and 🤖 Generated with Claude Code |
Checkpoints converted from the upstream-HF layout (orcarouter's GLM-5.3-Flash-Uncensored 6-bit) keep model.language_model.layers.N.* names, per-expert mlp.experts.N.* tensors and a raw kv_b_proj, and key their 212 per-module quantisation overrides as model.layers.N.*. mlx-vlm's inherited DeepSeek-V32 sanitize already stacks the experts and absorbs kv_b_proj into embed_q/unembed_out, but only for model.layers.* keys; the bridge now canonicalises the prefix first, and the class_predicate also tries the language_model.-stripped module path when resolving overrides. Verified on real shards (layers 0 and 3, embeddings, lm_head): every expected parameter present with the right shape, 8-bit overrides applied to the right modules, both layer types finite, embedding table semantically intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MLX promotes on the scales dtype: quantized_matmul and gather_qmm with bfloat16 activations and float16 scales return float32. A checkpoint that stores scales/biases as float16 therefore runs its entire residual stream in float32, and MLA prefill then requests a float32 SDPA kernel at head_dim 256 that exceeds Metal's 32 KB threadgroup limit, so every prompt of nine tokens or more fails with 'Unable to load kernel'. Shorter prompts merely run ~2.6x slower with a doubled KV cache, which is why a smoke test misses it. Casting float16 to bfloat16 in sanitize is free (both two bytes) and loses less accuracy than the checkpoint's own quantisation already does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(discovery): multicast discovery never forms a cluster on macOS
Summary
On macOS, nodes never discover each other and every node reports a topology of
one. Three independent defects combine to cause it: two restrict which
interfaces are announced on, and a third stops announcing altogether after the
first discovery. This PR fixes all three.
Verified on 4× Mac Studio (M3 Ultra, macOS 26.6.2) connected by both wifi and a
full Thunderbolt 5 mesh.
Defect 1 —
AddrInUsesilently drops interfacesrust/networking/src/discovery.rs, in thenetwatchercallback:Multicast membership is held per-socket, so on macOS the second and every
subsequent
join_multicast_v6for the same group returnsAddrInUse. Theexisting comment ("skip AddrInUse - just means we've already joined the mv6")
shows the intent was to tolerate it, but because the guard excludes
AddrInUsethe value falls through to
_ => {}and the interface is never pushed ontoifaces.Since
announce()iteratesifaces, only the first interface to joinsuccessfully ever receives a Hello.
Defect 2 — the multicast egress interface is never set
announce()sends toSocketAddrV6::new(GROUP, port, 0, iface_idx), relying onthe scope id to select the outgoing interface. That is not sufficient for IPv6
multicast:
IPV6_MULTICAST_IFmust be set on the socket before each send.Without it every datagram leaves via the default multicast interface no matter
which scoped address it was sent to. The socket only ever calls
set_multicast_loop_v6.Defect 3 — a blocking
connect_peersilences discovery permanentlyrust/networking/src/lib.rs.Discovery::next()is the only driver ofannounce()— it announces on each 1s tick and returns as soon as a peer isdiscovered. The task that consumes it awaits
connect_peerinline:connect_peercan block for a long time — indefinitely, when a peer isunreachable or its handshake stalls. While it is blocked the task never returns
to
next(), so the node stops announcing entirely, and never resumes.This is the defect that actually prevents the cluster forming. Fixing defects 1
Full findings, including the GLM-5.3-Flash /
glm5_nextbring-up and the out-of-tree patches, are inPR-DRAFT.mdandpatches/on this branch.🤖 Generated with Claude Code