Skip to content

fix(discovery): multicast discovery never forms a cluster on macOS - #2286

Draft
zhast wants to merge 10 commits into
exo-explore:mainfrom
zhast:fix/discovery-multicast-interfaces
Draft

fix(discovery): multicast discovery never forms a cluster on macOS#2286
zhast wants to merge 10 commits into
exo-explore:mainfrom
zhast:fix/discovery-multicast-interfaces

Conversation

@zhast

@zhast zhast commented Sep 1, 2026

Copy link
Copy Markdown

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 — AddrInUse silently drops interfaces

rust/networking/src/discovery.rs, in the netwatcher callback:

match sock.join_multicast_v6(&GROUP, *iface_idx) {
    Ok(()) => ifaces.lock().push(/* ... */),
    Err(e) if e.kind() != io::ErrorKind::AddrInUse => { warn!(/* ... */) }
    _ => {}          // AddrInUse lands here — interface never registered
}

Multicast membership is held per-socket, so on macOS the second and every
subsequent
join_multicast_v6 for the same group returns AddrInUse. The
existing comment ("skip AddrInUse - just means we've already joined the mv6")
shows the intent was to tolerate it, but because the guard excludes AddrInUse
the value falls through to _ => {} and the interface is never pushed onto
ifaces.

Since announce() iterates ifaces, only the first interface to join
successfully ever receives a Hello.

Defect 2 — the multicast egress interface is never set

announce() sends to SocketAddrV6::new(GROUP, port, 0, iface_idx), relying on
the scope id to select the outgoing interface. That is not sufficient for IPv6
multicast: IPV6_MULTICAST_IF must 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_peer silences discovery permanently

rust/networking/src/lib.rs. Discovery::next() is the only driver of
announce() — it announces on each 1s tick and returns as soon as a peer is
discovered. The task that consumes it awaits connect_peer inline:

let discovered = discovery.next().await;   // announces while polling
// ...
runtime.connect_peer(&discovered.zid.into(), &[locator]).await;   // blocks here

connect_peer can block for a long time — indefinitely, when a peer is
unreachable 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_next bring-up and the out-of-tree patches, are in PR-DRAFT.md and patches/ on this branch.

🤖 Generated with Claude Code

zhast and others added 8 commits August 29, 2026 12:49
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>
@zhast

zhast commented Sep 1, 2026

Copy link
Copy Markdown
Author

Follow-up, to close out the open question this PR left and to save anyone chasing a non-bug.

The glm5_next / GLM-5.3-Flash output-quality issue is not an exo bug, and not an mlx-vlm bug.

I verified mlx-vlm 0.6.17's glm5_next component-by-component against the official reference in transformers 5.16.1 (modeling_glm5_next.py), feeding both implementations identical real weights from the checkpoint:

Component Coverage vs reference
HyperConnection (sinkhorn; fused Metal kernel and pure-MLX path) all layers exact (1 bf16 ULP)
Linear attention / KDA (fused + unfused input projection) 34 of 45 layers corr 0.999974
DSA sparse attention + k-pool indexer 11 of 45 layers corr 0.999995
MoE router (noaux_tc, norm_topk_prob, routed_scaling_factor) 42 layers matches

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 kv_b_proj with an MLA-absorbed embed_q/unembed_out pair of its own construction — that absorption is correct (corr 0.99997 against the true kv_b_proj).

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 patches/ in this PR is only about loading the architecture, not about output quality.

Separately, the second finding I listed above — pipeline_auto_parallel replacing inner.layers after construction, so a model that cached layer indices in __init__ silently builds masks from the wrong cache — is a real exo bug and is now filed on its own as #2287 with a regression test.

🤖 Generated with Claude Code

@zhast

zhast commented Sep 1, 2026

Copy link
Copy Markdown
Author

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):

prompt: What is the capital of France? Answer in one word.
top-1 next token: 'The'  (logit +16.75)
greedy: 'The user asks: "What is the capital of France?'

That is coherent. The same weights under exo's 3-way pipeline return '\nfor\n-\n ... }} \nnal'. I also sampled the 2-bit routed experts against the true upstream FP8 weights across layers 4/10/20/33/44 — cosine is a consistent 0.922, which is evidently sufficient. So the checkpoint is fine and the defect is in the distributed path.

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 c.trim(2) / SSM-snapshot rollback and not in decode. KV-cache quantisation is off by default (KV_BITS = None), so that is not a factor either.

Still bisecting; I'll report the actual root cause rather than another guess. Apologies for the noise.

🤖 Generated with Claude Code

@zhast

zhast commented Sep 1, 2026

Copy link
Copy Markdown
Author

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 deepseek_v4/hyper_connection.py — filed as Blaizzy/mlx-vlm#2139.

That kernel reads mixes, scale and base as float32, type-punning them via *(const device float4*). HyperConnection.__init__ allocates all three as float32, so that holds for a freshly constructed module — but load_weights() replaces them with the checkpoint's dtype, which is bfloat16 in every GLM-5.3-Flash and DeepSeek-V4 release. The kernel then reinterprets bfloat16 bytes as float32.

Measured on GLM-5.3-Flash layer 0 against a float64 reference of the same sinkhorn: _hc_ops matches to 0.00000 relative error, the kernel is 1.98 off. Casting the three inputs makes them agree exactly (max_abs 0.999160 -> 0.000000).

Why it survived so much scrutiny, which may be useful to others:

  • Nothing raises. Sinkhorn renormalises the corrupted logits back onto the doubly-stochastic manifold — column sums come out exactly 1.0 — so the residual stream stays finite and well-scaled through all 45 layers, no NaN, no inf. Every "is anything blowing up" diagnostic looks clean.
  • nn.Module.training defaults to True, so a freshly constructed HyperConnection takes the _hc_ops path. My first kernel-vs-reference test never called .eval() and therefore never exercised the kernel — it compared ops against ops and reported an exact match. That false negative is what sent me down the quantisation path.
  • Only .eval() + Metal hits it, i.e. precisely the inference path and nothing else.

After the fix, on 3 nodes over JACCL/RDMA:

'391 divided by 17. 17 x 23 = 391 (17 x 20 = 340, 17 x 3 = 51, 340 + 51 = 391).
 So the answer is 23.'   -> 23

Throughput improves as well (4.09 tok/s with the fixed kernel vs 2.39 forcing _hc_ops).

So: the component numbers in my first comment stand, the "it's the 2-bit quant" conclusion was wrong, and patches/ in this PR remains purely about loading the architecture. #2287 (stale layer indices after pipeline sharding) is still a genuine exo bug and is unaffected by any of this.

🤖 Generated with Claude Code

zhast and others added 2 commits September 2, 2026 14:36
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>
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.

1 participant