Repository navigation
feat(distributed): make zero-config --pp-peer bring-up work (beacons and stage push-assignment) #2658
Description
Depends on #2655
Problem / Background
Zero-config pipeline bring-up over LAN discovery cannot work today. mlxcel-server --pp-auto N --cluster-discovery=mdns always waits the 30 s DEFAULT_DISCOVERY_TIMEOUT (src/distributed/cluster_init.rs:52) and then fails with "cluster discovery timed out ... fewer peer mlxcel-server processes running than expected" (cluster_init.rs:596-604), because no process sends a beacon. --pp-peer cannot join without a hand-copied TOML and a hand-guessed --node-id. The decision is to make the mdns flow work, not to remove it; the removal previously planned in #2655 item c is withdrawn and its verified facts moved here.
This depends on #2655 because both change src/server/startup.rs, src/distributed/cluster_init.rs, and the cluster handshake, which #2655 bumps to protocol version 2 with a typed identity rejection. Build on the merged #2655 handshake; do not fork it.
Current Behavior
- Coordinator:
run_zero_config_bring_up(src/server/startup.rs:2749-2812) resolves the secret, callsdiscover_peers(:2760; receiver atcluster_init.rs:499-610), thenplan_cluster(:2775) andwrite_plan_toml(:2781), and pointsstartup.distributed_configat the written file (:2791). Nothing is sent to any peer. - Peer: the
--pp-peerbranch (startup.rs:2798-2808) onlyensure!s--distributed-config("Future work will remove this once the coordinator push-assigns stages (follow-up)") and--node-id. The id must equal thestage-{i}thatplan_clusterderives from the lexicographic order of peer addresses (cluster_init.rs:200-245), which the operator has to guess. broadcast_beacon_loop(cluster_init.rs:619-666) is the peer-side sender. Its only caller iscluster_init_tests.rs:409; it has had no production caller since it was added in 535987f. It sends to the hard-coded limited broadcast255.255.255.255(:642). TheDiscoveryBeacondoc (cluster_init.rs:435-442) already describes the intended flow: a peer broadcasts "until the coordinator confirms receipt". Beacons are HMAC-signed with timestamp, nonce, version, and source-IP checks (sign_beaconatdiscovery_beacon.rs:121,BeaconVerifierat:182, from fix(distributed): authenticate cluster transports and correlate stage replies #2650).--pp-peerhelp (src/bin/mlx_server.rs:1438-1446) promises registration "against a known coordinator address" or by broadcast, then a stage service "using the stage assignment the coordinator returns". None of it exists, and in static mode it is the coordinator that knows the peer addresses (--cluster-peers), not the reverse.src/main.rs:2597-2633carries the same flags formlxcel serve.- The pipeline transport already runs the mutual HMAC handshake over a raw
TcpStream:client_handshakeandserver_handshake(src/distributed/cluster_handshake.rs:184,:241), with the client's advertised identity bound to its source IP (bind_advertised_identity,:91-107).
Facts found while verifying that the design below accounts for:
- A remote stage computes its own layer range from its local checkpoint (
resolve_in_process_stage_assignments_for_modelinsideresolve_remote_pipeline_topology,startup.rs:2560-2590). The TOML carries no layer range, and the partition is a pure function of the model config (src/distributed/pipeline/local_runtime.rs:109-140). --pp-auto Nneeds N peer processes, because the coordinator node has no stage (cluster_init.rs:206,:218-224).--pp-auto 2takes two--pp-peerprocesses, not one.discover_peersbinds its UDP socket to the coordinator control IP (startup.rs:2763, bound atcluster_init.rs:522). A UDP socket bound to a unicast address does not receive datagrams sent to255.255.255.255on Linux or macOS, and the default control IP is the--hostdefault127.0.0.1(mlx_server.rs:432,startup.rs:2672-2677), so a sender alone would still not be heard.cluster_init_tests.rs:406-407already notes that limited broadcast is unreliable and uses a direct unicast sender instead.- With
--host 0.0.0.0the coordinator node is recorded as0.0.0.0:19000, and a stage'sPeerMatcherthen accepts commands from any authenticated member on port 19000 (src/distributed/peer_identity.rs:79-91). - The generated TOML records the coordinator's absolute
secret_filepath (startup.rs:2699,:2713), which need not exist on a peer host. - A peer has no flag that names its own listen address.
Proposed Solution
A new module src/distributed/stage_assignment.rs (tests in stage_assignment_tests.rs), re-exported from src/distributed/mod.rs:99-103, owns the assignment protocol. startup.rs calls it from both branches of run_zero_config_bring_up; everything after that function is unchanged, so there is one stage startup path.
1. Assignment channel and wire format
The coordinator dials the peer's stage address and runs client_handshake advertising its control address; the peer runs server_handshake advertising its listener address. Then both sides exchange frames: u32 big-endian length followed by JSON, at most 64 KiB, every read bounded by ASSIGNMENT_IO_TIMEOUT = 10 s. The exchange has two phases, so a bring-up that fails on one peer leaves no peer committed:
- coordinator to peer:
AssignmentFrame::Offer(StageAssignmentOffer) - peer to coordinator:
AssignmentFrame::Accepted { node_id }orAssignmentFrame::Rejected { reason: AssignmentRejectReason, detail: String } - only after every peer answered
Accepted, coordinator to each peer:AssignmentFrame::Commit - peer to coordinator:
AssignmentFrame::Committed { node_id }
pub const ASSIGNMENT_PROTOCOL_VERSION: u16 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelIdentity {
pub config_sha256: String, // hex SHA-256 of the config.json bytes
pub weight_files: Vec<(String, u64)>, // every *.safetensors file name and byte size, sorted by name
pub num_layers: usize, // resolve_in_process_pipeline_num_layers
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageAssignmentOffer {
pub protocol: u16, // ASSIGNMENT_PROTOCOL_VERSION
pub mlxcel_version: String, // env!("CARGO_PKG_VERSION")
pub cluster_name: String,
pub node_id: String, // "stage-{i}" from plan_cluster
pub stage_index: u32,
pub num_stages: u32,
pub layer_range: std::ops::Range<usize>, // coordinator's partition, re-derived and compared by the peer
pub coordinator_control_addr: SocketAddr,
pub model: ModelIdentity,
pub cluster_toml: String, // render_deterministic_toml output with [cluster] secret_file = None
}
#[non_exhaustive]
pub enum AssignmentRejectReason {
ProtocolMismatch, VersionMismatch, ClusterNameMismatch, ModelMismatch,
LayerRangeMismatch, AddressMismatch, InvalidClusterConfig,
}ModelIdentity::from_model_dir(&Path) -> Result<ModelIdentity> uses sha2 (already in Cargo.toml:198) and reads no weight bytes. The layer range is not an instruction (each stage derives its own, see above); it is a second identity check, so a checkpoint whose config differs in a way that moves the partition is refused even if the hash check were weakened later.
2. Peer side (--pp-peer, both discovery modes)
await_stage_assignment(config: PeerJoinConfig) -> Result<CommittedAssignment>, where PeerJoinConfig { listen_addr, discovery, discovery_port, discovery_target: IpAddr, cluster_name, node_id_hint: Option<String>, model_dir, auth: Arc<ClusterAuth>, assignment_timeout, commit_timeout } and CommittedAssignment { node_id, stage_index, cluster: ClusterConfig, coordinator_identity: String }:
- Startup resolves the secret from
--cluster-secret-file,MLXCEL_CLUSTER_SECRET_FILE,MLXCEL_CLUSTER_SECRET(ClusterSecretSources::from_env(cli, None)) and fails closed before any socket is bound. The peer computes itsModelIdentityonce. - Bind a
tokio::net::TcpListeneron the peer listen address, taken from--cluster-control-addr(the flag gains a peer meaning, see 5). Static mode requires it with a non-zero port, since it must equal the coordinator's--cluster-peersentry. Mdns defaults it to0.0.0.0:0and uses the OS-assigned port. - Mdns only: spawn
broadcast_beacon_loopwithpeer_addrset to the listener's local address (BeaconVerifiersubstitutes the source IP for an unspecified IP),node_idset to--node-idwhen given, interval 1 s, and the target from--cluster-discovery-target(see 4). - Accept connections until
PEER_ASSIGNMENT_TIMEOUT = 600 s, then fail with "no stage assignment received within 600s" naming the listen address, discovery mode, and cluster name. Run at most 8 handshakes concurrently (excess sockets are closed at accept and logged with the same rate limit ashandshake_warn_allowedintcp_transport.rs). A failed handshake is logged and does not end the wait. - Verify the offer in this order and answer
Rejectedwith the first failing reason: protocol version;mlxcel_versionequals ours;cluster_nameequals ours (--cluster-name, defaultmlxcel-cluster);modelequals ours;layer_rangeequals the entry forstage_indexfrom our ownresolve_in_process_stage_assignments_for_model(model_dir, num_layers, Some(num_stages), None);cluster_tomlparses withClusterConfig::from_tomland holdsnode_idas apipeline_stagenode withstage == stage_indexwhose port equals our listener port;coordinator_control_addragrees with the handshake-bound identity (same port, and same IP unless the offered IP is unspecified). ProtocolMismatch,VersionMismatch,ModelMismatch, andLayerRangeMismatchend the process with the typed errorAssignmentRefused { reason, detail }, because a retry against the same coordinator cannot succeed. Every other rejection, and a connection that drops, returns to step 4.- After
Accepted, wait up toASSIGNMENT_COMMIT_TIMEOUT = 60 sforCommit. On timeout or EOF discard the offer and return to step 4 (beacons keep running). OnCommit, replyCommitted, flush, stop the beacon loop (await its task for at most 1 s), drop the listener, and accept nothing more: exactly one committed assignment per run. - Startup then rewrites the received config: the coordinator node's address becomes the handshake-bound identity when the offered IP was unspecified; our own node's address becomes the listener's local address (the address the stage service can bind);
secret_filestaysNone. It writes the result withrender_deterministic_tomlto--cluster-config-out(default.mlxcel/cluster-<node_id>.toml) and setsstartup.distributed_configandstartup.node_id = Some(node_id), mirroring the coordinator branch atstartup.rs:2791-2794. A user--node-idthat differs from the assigned id is overridden with an info log.--distributed-configgiven together with--pp-peerkeeps today's behavior as a manual override: skip the assignment wait entirely.
3. Coordinator side (--pp-auto N)
Between plan_cluster (startup.rs:2775) and write_plan_toml, call push_stage_assignments(plan: &ClusterInitPlan, coordinator_control_addr: SocketAddr, model_dir: &Path, auth: &ClusterAuth, timeout: Duration) -> Result<()>. It computes ModelIdentity and the partition once, builds one offer per pipeline_stage node, and drives every peer concurrently: TCP connect retried every 500 ms (a static peer may start after the coordinator), handshake, Offer, wait for Accepted; then Commit to all and wait for Committed. The whole push is bounded by ASSIGNMENT_ACK_TIMEOUT = 60 s. Any failure returns AssignmentPushFailed { failures: Vec<PeerAssignmentFailure { node_id, addr, reason }> }, whose message names every failing peer with its reason (timeout plus the last connect error, handshake rejection, or the peer's Rejected reason and detail). No Commit is sent when any peer failed the offer phase. The existing path then continues unchanged.
Two coordinator fixes ride along. Bind the mdns receiver to Ipv4Addr::UNSPECIFIED (Ipv6Addr::UNSPECIFIED for a v6 control address) instead of the control IP, so broadcast datagrams arrive. After discovery, fail when the control address is loopback and any resolved peer is not, with a message naming --cluster-control-addr and --host, because stages reply to the control address.
4. Broadcast target
Add --cluster-discovery-target <IP> (both CLIs, peer side, default 255.255.255.255) and pass it to broadcast_beacon_loop as a new target: IpAddr parameter that replaces the hard-coded Ipv4Addr::BROADCAST. It accepts a directed subnet broadcast (192.168.1.255) or a unicast coordinator IP, including 127.0.0.1. It is needed for three reasons: limited broadcast leaves only through the interface of the default route, so a host with a separate cluster NIC or a Thunderbolt bridge needs a directed broadcast; loopback carries no broadcast, so the loopback integration and end-to-end tests cannot exercise the real sender without it; and a unicast target still works on networks that drop broadcast. Rejected: a coordinator-side setting, since the coordinator only listens.
5. CLI help and docs
Rewrite the help for --pp-auto, --pp-peer, --cluster-discovery, --cluster-control-addr, and the new --cluster-discovery-target in src/bin/mlx_server.rs:1427-1493 and src/main.rs:2597-2633: --pp-auto N pushes assignments to N peer processes; --pp-peer listens on --cluster-control-addr, announces itself by beacon in mdns mode, and waits for the assignment, with --distributed-config and --node-id optional; --cluster-discovery drops the "future zeroconf" wording and states HMAC-signed UDP beacons on --cluster-discovery-port; --cluster-control-addr means the control address on the coordinator and the stage listen address on a peer. In docs/distributed.md, replace :138-140 and :224-225 with a "Zero-config bring-up" subsection under "Server / multi-host path" that walks through two hosts for both static and mdns (secret distribution, coordinator plus two peers, --host 0.0.0.0 or --cluster-control-addr, UDP 49555 and the TCP stage ports in the firewall), and extend the Discovery beacons bullet (:195-200) to cover the assignment channel.
Scope
In scope: src/distributed/stage_assignment.rs and its tests (new); src/distributed/cluster_init.rs (broadcast_beacon_loop target parameter); src/distributed/mod.rs (re-exports); src/server/startup.rs (both run_zero_config_bring_up branches, receiver bind, loopback check, new cluster_discovery_target field at :403-416); src/server/cli_input.rs:291-303, src/bin/mlx_server.rs, src/main.rs (flag and help); docs/distributed.md; tests listed below.
Out of scope: DNS-SD or real mDNS (the mode keeps its name); TLS or per-frame MAC; NAT traversal; handshake protocol changes (owned by #2655); waiting for peer model load before the first request (pre-existing 30 s stage_timeout); remote parity of llama, gemma3, gemma4 (#2654).
Implementation Notes
- Reuse:
client_handshakeandserver_handshakeas merged by hardening(distributed): Bound pre-auth slots, type handshake rejections, extend cluster auth #2655;sign_beacon,BeaconVerifier,discover_peers,broadcast_beacon_loop;plan_cluster,render_deterministic_toml,ClusterConfig::from_toml;resolve_in_process_pipeline_num_layersandresolve_in_process_stage_assignments_for_model;ClusterSecretSources. The peer feeds the written TOML and node id intoresolve_distributed_startup(startup.rs:2814) like a manual run; do not add a second stage startup path. - Constraints: Linux and macOS (native Windows is already refused by
ServerDistributedFlags::ensure_supported). The stage service rebinds the listener's port after the assignment listener is dropped; tokio setsSO_REUSEADDRon Unix. Rejected alternative: handing the bound listener toRemoteStageServiceHandle::spawn(src/distributed/pipeline/remote_service.rs:792), which binds on its own thread and runtime and would need a non-Clonefield inRemoteStageServiceConfig. - Edge cases: a static peer starting after the coordinator is covered by the connect retry; an mdns peer must beacon within the coordinator's 30 s discovery window, otherwise the coordinator fails and the peer keeps waiting until its own timeout; peers beyond N that were not selected time out with the step 4 message; a second coordinator after commit finds the listener closed; a coordinator that dies between
AcceptedandCommitleaves the peer waiting again; repeated beacons from one peer collapse indiscover_peers(PeerKey); after commit the coordinator's first stage connect may race the peer's rebind, whichTransport::connectalready tolerates with a warning and a lazy reconnect. - Error handling: the peer exits non-zero on
AssignmentRefusedand on its timeout; the coordinator exits non-zero onAssignmentPushFailed, before writing the TOML. Neither side logs the secret or the offer'scluster_tomlabove debug.
Acceptance Criteria
-
--pp-peerno longer requires--distributed-configor--node-id; with static discovery it requires--cluster-control-addrwith a concrete port. - The coordinator pushes and commits assignments to every planned peer over the authenticated handshake and fails, naming each peer that did not commit, within
ASSIGNMENT_ACK_TIMEOUT. - A coordinator holding a different secret cannot assign a stage or a model; the peer keeps waiting and still accepts the real coordinator afterwards.
- A peer refuses a model identity, layer range, or mlxcel version mismatch with
AssignmentRefused, and commits exactly one assignment per run. - Every wait is bounded: handshake 5 s, frame I/O 10 s, commit 60 s, coordinator push 60 s, peer assignment 600 s.
-
--cluster-discovery-targetexists in both CLIs, and help text anddocs/distributed.mddescribe the working flow with a two-host walkthrough for static and mdns. - Integrated into the real CLI:
mlxcel-server --pp-auto 2 --cluster-discovery=mdnsplus twomlxcel-server --pp-peer --cluster-discovery=mdnsprocesses serve a/v1/chat/completionsrequest end to end. The work is not complete until this passes through the real binaries. - Unit tests in
src/distributed/stage_assignment_tests.rs:assignment_frames_round_trip_and_reject_oversize(each frame variant encodes and decodes equal; a 64 KiB + 1 frame and a truncated frame are errors);model_identity_changes_with_config_or_weight_files(tempdir model dirs);peer_commits_exactly_one_assignment(a second push after commit fails withAssignmentPushFailed);peer_refuses_model_mismatch_with_typed_error(peer returnsAssignmentRefused { reason: ModelMismatch, .. }, coordinator error namesstage-0);peer_refuses_version_mismatch(raw client afterclient_handshakesends an offer withmlxcel_version = "0.0.0");peer_discards_offer_without_commit(raw coordinator sendsOfferand drops; a later real push commits);peer_times_out_without_assignment(200 ms timeout);peer_ignores_coordinator_with_wrong_secret;coordinator_names_peers_that_did_not_commit(one listening peer and one dead address, 300 ms timeout; the error lists only the dead node id and address, and the live peer receives noCommit);peer_substitutes_bound_identity_for_unspecified_coordinator. -
cluster_init_tests::broadcast_beacon_loop_resigns_each_tick_and_resolves_against_coordinatorsends with target127.0.0.1and drops the direct sender, so the loop itself must reachdiscover_peers. -
src/server/startup_tests.rs:pp_peer_static_requires_listen_addr_not_distributed_config(error names--cluster-control-addr; on current code it names--distributed-config) andpp_auto_rejects_loopback_control_with_remote_peers. - Integration tests in
tests/zero_config_cluster_init.rs, no weights (a tempdir withconfig.jsonholdingnum_hidden_layers = 4and an emptymodel.safetensors):zero_config_static_bring_up_without_tomlandzero_config_mdns_bring_up_without_tomlrun a coordinator (discover_peers,plan_cluster,push_stage_assignments) and twoawait_stage_assignmentpeers on loopback, the mdns one throughbroadcast_beacon_loopwith target127.0.0.1; both assert node idsstage-0/stage-1, a parseable TOML with a loopback coordinator address and nosecret_file. These fail on current code. - Ignored real-model tests in the same file,
zero_config_cli_static_matches_single_nodeandzero_config_cli_mdns_matches_single_node, spawn threemlxcel-serverprocesses (common::repo_binary_path("mlxcel-server")) on loopback withqwen3-0.6b-4bitand assert that greedy output equals a single-nodemlxcel-serveron the same prompt. Use qwen3 because llama, gemma3, and gemma4 remote parity is broken (fix(distributed): remote pipeline parity fails for llama, gemma3, gemma4 on GB10 #2654); the existing llama-based ignored test in this file is affected by that too.
Verification
Run once at the end, on the final head. On macOS drop --features cuda.
cargo test --profile test-fast --features cuda -p mlxcel --lib -- --test-threads=1 distributed::
cargo test --profile test-fast --features cuda -p mlxcel --lib -- --test-threads=1 server::startup
cargo test --profile test-fast --features cuda -p mlxcel --test zero_config_cluster_init -- --test-threads=1
cargo test --profile test-fast --features cuda -p mlxcel --test cli_help_consistency
cargo clippy --profile test-fast --features cuda -p mlxcel --lib --tests -- -D warnings
cargo fmt --checkReal model on the GB10 host. Build first outside the lock (a cargo build inside gpu-lock run hands the lock to the sccache daemon; sccache --stop-server frees it), then:
cargo test --profile test-fast --features cuda -p mlxcel --test zero_config_cluster_init --no-run
gpu-lock run --tag zero-config-e2e -- env MLXCEL_SDPA_DETERMINISTIC=1 \
cargo test --profile test-fast --features cuda -p mlxcel --test zero_config_cluster_init -- \
--ignored --test-threads=1 zero_config_cli_Manual CLI check under the same lock, with MLXCEL_SDPA_DETERMINISTIC=1 and one shared --cluster-secret-file on every process: start two peers with mlxcel-server -m models/qwen3-0.6b-4bit --pp-peer --cluster-discovery=mdns --cluster-discovery-target 127.0.0.1 --cluster-control-addr 127.0.0.1:0 --cluster-config-out <distinct path>, then the coordinator with mlxcel-server -m models/qwen3-0.6b-4bit --pp-auto 2 --cluster-discovery=mdns --cluster-control-addr 127.0.0.1:19000 --port 18080. A pass is: both peers log their committed stage-0/stage-1, /v1/chat/completions on port 18080 returns 200 with temperature: 0, and its text equals a single-node mlxcel-server -m models/qwen3-0.6b-4bit --port 18090 answer to the same request. Repeat with --cluster-discovery=static, --cluster-peers on the coordinator, and fixed peer ports. Record the commands and outputs in the PR body.
Activity
fix(distributed): cap pre-auth slots, type rejections, auth DI roles (#…
fix(distributed): wait the full commit timeout for Commit on a peer
fix(security): bind assignment node ids and coordinator to the handshake
fix(distributed): harden zero-config bring-up edge cases
feat(distributed): push stage assignments to --pp-peer processes (#2680)