Skip to content

feat(zmq): wake a paused lockstep engine group from the connector - #2078

Merged
slin1237 merged 2 commits into
mainfrom
zmq/dp-multi-engine
Aug 8, 2026
Merged

feat(zmq): wake a paused lockstep engine group from the connector#2078
slin1237 merged 2 commits into
mainfrom
zmq/dp-multi-engine

Conversation

@slin1237

@slin1237 slin1237 commented Aug 8, 2026

Copy link
Copy Markdown
Member

Why

vLLM's MoE data-parallel ranks step in lockstep: they all-reduce every step, drain a wave together, and then park. A request handed to one rank leaves the others asleep, so somebody has to tell them to start the next wave.

Upstream that somebody is a separate DP coordinator process. The engines only need it because their Python frontend has no other way to reach every rank at once — and the engine's poller dispatches the coordinator socket and the client socket through the same request-type switch, so START_DP_WAVE is accepted on the socket we already own. We hold a socket to each rank, so the connector plays the wake role itself. No coordinator process, no extra sockets.

What

  • Protocol seam: EngineBatch carries an optional WaveEvent (Complete/Start), and EngineProtocol gains encode_start_wave. vLLM surfaces the wave_complete/start_wave control messages it was previously dropping on the floor and encodes START_DP_WAVE; TokenSpeed reports neither and returns Ok(None), so nothing wakes.
  • Connector: keeps the same state the coordinator would — the current wave and whether the group is stepping — and broadcasts a start to every rank but the one already holding the request.

Two decisions worth reviewing

When the wake arms. Only when the handshake reports data_parallel_size > 1. That is precisely the lockstep case: dense DP ranks call reconfigure_for_independent_dp_rank() and answer the handshake with a size of one, so they never park as a group and never get woken. The connector branches on what the engine reports about itself, not on the model.

Order of add and wake. Add first, wake after. Waking first admits a window where the group re-drains and parks again before the add lands, stranding the request in a sleeping engine. Adding first keeps the group parked for the whole window. If the wake then fails, the request is taken back (deregistered + aborted) rather than left behind.

Tests

Three new connector tests over N mock ranks: a first submit wakes the paused group excluding the holder, a drained wave re-arms the wake, and independent ranks are never woken.

vLLM's MoE data-parallel ranks step in lockstep: they all-reduce every
step, drain a wave together, and then park. A request handed to one rank
leaves the others asleep, so somebody has to tell them to start the next
wave. Upstream that somebody is a separate DP coordinator process; the
engines only need it because their Python frontend has no other way to
reach every rank at once. We already hold a socket to each rank, so the
connector plays the wake role itself over the sockets it has.

Widen the protocol seam with a wave notification on the decoded batch and
an `encode_start_wave` hook. vLLM surfaces `wave_complete`/`start_wave`
control messages (previously dropped on the floor) and encodes
`START_DP_WAVE`; TokenSpeed reports neither and returns `Ok(None)`, so
nothing wakes.

The connector keeps the same wave state the coordinator would: the
current wave, and whether the group is stepping. It arms only when the
handshake reports a data-parallel size above one, which is exactly the
lockstep case — dense DP ranks reconfigure themselves to a size of one
and run independently. Submit adds the request first and wakes after, so
the group stays parked for the whole window; if the wake fails the
request is taken back rather than left stranded in a sleeping engine.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c49141a6-ed10-4cdc-9fdc-af8c455e4aed

📥 Commits

Reviewing files that changed from the base of the PR and between 6a33c8f and b8f2b25.

📒 Files selected for processing (3)
  • crates/engine_zmq_client/src/connector.rs
  • crates/engine_zmq_client/src/protocol/mod.rs
  • crates/mock_worker/src/zmq.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine_zmq_client/src/connector.rs
  • crates/engine_zmq_client/src/protocol/mod.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added lockstep wave coordination for supported multi-engine deployments.
    • Added wave start and completion event handling for vLLM-based engines.
    • Added wake-up retries and safe request cancellation when coordination fails.
    • Preserved independent-rank behavior for engines without wave scheduling support.
  • Tests

    • Added coverage for wave wake-ups, re-arming, failure handling, and independent operation.

Walkthrough

Adds lockstep wave coordination for multi-rank ZMQ clients. Protocols encode start-wave commands and decode wave lifecycle events. The connector tracks group state, wakes peer ranks, handles failures, and re-arms drained waves. Tests cover lockstep and independent-rank behavior.

Changes

Lockstep wave coordination

Layer / File(s) Summary
Wave protocol contracts and encoding
crates/engine_zmq_client/src/protocol/mod.rs, crates/engine_zmq_client/src/protocol/vllm/mod.rs, crates/engine_zmq_client/src/protocol/tokenspeed/mod.rs
Adds WaveEvent, optional batch wave notifications, and encode_start_wave. vLLM encodes and decodes data-parallel wave messages. TokenSpeed reports no wave support.
Connector wave orchestration
crates/engine_zmq_client/src/connector.rs
Detects lockstep groups, tracks wave state, wakes peer ranks after submissions, processes wave events, and rolls back failed wake attempts.
Wave coordination test support and validation
crates/engine_zmq_client/src/mock_engine.rs, crates/engine_zmq_client/src/connector.rs, crates/mock_worker/src/zmq.rs
Adds structured mock-engine start-wave reception, test connection helpers, mock-worker handling, and coverage for wake-ups, re-arming, failure handling, and independent ranks.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RankClient
  participant ZmqConnector
  participant PeerRank
  participant vLLMProtocol
  RankClient->>ZmqConnector: Submit request
  ZmqConnector->>vLLMProtocol: Encode start-wave command
  vLLMProtocol->>PeerRank: Send wave and excluded rank
  PeerRank-->>ZmqConnector: Return wave start or completion event
  ZmqConnector-->>RankClient: Advance or re-arm wave state
Loading

Possibly related PRs

  • smg-project/smg#2015: Changes the connector’s ZMQ IPC namespace ownership.
  • smg-project/smg#2032: Adds protocol-generic EngineProtocol and EngineBatch infrastructure extended by this change.
  • smg-project/smg#2036: Adds protocol, connector, and mock-engine structures extended for wave coordination.

Suggested labels: protocols, tests

Suggested reviewers: catherinesue, key4ng

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: the ZMQ connector wakes paused lockstep engine groups.
Description check ✅ Passed The description directly explains the lockstep wake-up behavior, protocol changes, connector logic, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch zmq/dp-multi-engine

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

👋 The PR description doesn't fully follow
PULL_REQUEST_TEMPLATE.md:

  • Missing header: ## Description
  • Missing header: ### Problem
  • Missing header: ### Solution
  • Missing header: ## Changes
  • Missing header: ## Test Plan

Please update the PR description so reviewers have the context they need.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/engine_zmq_client/src/connector.rs (3)

957-971: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🟡 Nit — independent_ranks_are_never_woken does not prove the negative.

The test reads one message from rank 0 and asserts it is the Add. A wake sent after that Add would still pass. The name promises that no wake occurs at all.

Assert the absence directly. A short tokio::time::timeout around a second recv proves no further message arrives. Asserting client.inner.wave.is_none() also pins the mode.

Two paths from the PR objectives are also untested: a failed wake that deregisters and aborts the request, and the WaveEvent::Start handling in observe_wave.

As per coding guidelines: "Run the pr-test-analyzer agent to verify that tests adequately cover new or changed functionality."

💚 Proposed strengthening of the assertion
         // Rank 0's first message is its own Add, not a wake for rank 1's.
         match engines[0].recv().await.unwrap() {
             EngineInbound::Add(request) => assert_eq!(request.request_id, "req-2"),
             other => panic!("independent rank expected only its Add, got {other:?}"),
         }
+        // No wake follows: independent ranks carry no wave state at all.
+        assert!(client.inner.wave.is_none());
+        let extra = tokio::time::timeout(Duration::from_millis(200), engines[0].recv()).await;
+        assert!(extra.is_err(), "independent rank received an unexpected message");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine_zmq_client/src/connector.rs` around lines 957 - 971, Strengthen
`independent_ranks_are_never_woken` by asserting `client.inner.wave.is_none()`
and wrapping a second `engines[0].recv()` in a short `tokio::time::timeout`,
expecting it to time out after the initial `Add`. Add focused tests covering
failed wake deregistration/request abortion and `WaveEvent::Start` handling in
`observe_wave`, then run the PR test analyzer to verify coverage.

Source: Coding guidelines


174-191: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

🟡 Nit — broadcast_start_wave stops at the first failed peer send.

The loop returns on the first send_to_engine error. Ranks already sent a wake stay awake, and the remaining ranks stay parked. submit then aborts the request, so the woken ranks step with no work until they drain and report WaveComplete.

Consider sending to every peer and returning an error only after the full pass. This keeps the wake attempt symmetric and records which rank failed.

♻️ Proposed change to complete the broadcast before failing
         for engine in &self.engines {
             if engine.engine_id.engine_index() == Some(exclude_index) {
                 continue;
             }
-            self.send_to_engine(
-                &engine.engine_id,
-                frame.clone(),
-                payload.clone(),
-                Vec::new(),
-            )
-            .await?;
+            if let Err(error) = self
+                .send_to_engine(
+                    &engine.engine_id,
+                    frame.clone(),
+                    payload.clone(),
+                    Vec::new(),
+                )
+                .await
+            {
+                warn!(%error, ?engine.engine_id, wave, "failed to wake a rank");
+                first_error.get_or_insert(error);
+            }
         }
-        Ok(())
+        match first_error {
+            Some(error) => Err(error),
+            None => Ok(()),
+        }

Declare let mut first_error = None; before the loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine_zmq_client/src/connector.rs` around lines 174 - 191, Update
broadcast_start_wave to continue sending to every eligible engine instead of
returning immediately when send_to_engine fails. Track the first send error
while completing the loop, then return that error after all peers have been
attempted; preserve the existing exclusion and successful no-error behavior.

281-283: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🟡 Nit — A mixed data_parallel_size report resolves silently.

any enables wave coordination when at least one rank reports a size above 1. If ranks disagree, the client picks a mode without reporting the disagreement. any is the safe direction, because enabling coordination on an independent group only adds harmless wakes.

Log a warning when the reported sizes are not uniform, so a misconfigured handshake is visible.

As per coding guidelines: "Do not silently fall back to None or a default when configuration validation should fail loudly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine_zmq_client/src/connector.rs` around lines 281 - 283, Update the
lockstep determination near engines iteration to detect whether all engines
report the same data_parallel_size, while preserving the existing any-based
behavior for enabling coordination. When the reported sizes are mixed, log a
warning through the connector’s existing logging mechanism with enough context
to identify the disagreement.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine_zmq_client/src/connector.rs`:
- Around line 237-247: Update the WaveEvent::Start branch to resolve and retain
the updated state.current value before broadcasting, then pass that resolved
wave to broadcast_start_wave instead of the original wave argument. Keep running
state management and failure handling unchanged, ensuring peers start the same
wave recorded by the connector when state.current is already higher.

---

Nitpick comments:
In `@crates/engine_zmq_client/src/connector.rs`:
- Around line 957-971: Strengthen `independent_ranks_are_never_woken` by
asserting `client.inner.wave.is_none()` and wrapping a second
`engines[0].recv()` in a short `tokio::time::timeout`, expecting it to time out
after the initial `Add`. Add focused tests covering failed wake
deregistration/request abortion and `WaveEvent::Start` handling in
`observe_wave`, then run the PR test analyzer to verify coverage.
- Around line 174-191: Update broadcast_start_wave to continue sending to every
eligible engine instead of returning immediately when send_to_engine fails.
Track the first send error while completing the loop, then return that error
after all peers have been attempted; preserve the existing exclusion and
successful no-error behavior.
- Around line 281-283: Update the lockstep determination near engines iteration
to detect whether all engines report the same data_parallel_size, while
preserving the existing any-based behavior for enabling coordination. When the
reported sizes are mixed, log a warning through the connector’s existing logging
mechanism with enough context to identify the disagreement.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d17655d9-e042-4e72-9de1-d614ae5ec656

📥 Commits

Reviewing files that changed from the base of the PR and between 73da68c and 6a33c8f.

📒 Files selected for processing (5)
  • crates/engine_zmq_client/src/connector.rs
  • crates/engine_zmq_client/src/mock_engine.rs
  • crates/engine_zmq_client/src/protocol/mod.rs
  • crates/engine_zmq_client/src/protocol/tokenspeed/mod.rs
  • crates/engine_zmq_client/src/protocol/vllm/mod.rs

Comment on lines +237 to +247
WaveEvent::Start(wave) => {
{
let mut state = state.lock();
state.current = state.current.max(wave);
state.running = true;
}
if let Err(error) = self.broadcast_start_wave(wave, engine_index).await {
warn!(%error, wave, "failed to start the requested wave");
state.lock().running = false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find tests exercising the StartWave path and any staleness assertions.
rg -n -C 6 'DpControlMessage::StartWave|WaveEvent::Start' --type=rust

Repository: smg-project/smg

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate connector.rs =="
fd -a 'connector\.rs$' . || true

echo
echo "== target file outline/section =="
if [ -f crates/engine_zmq_client/src/connector.rs ]; then
  wc -l crates/engine_zmq_client/src/connector.rs
  sed -n '180,270p' crates/engine_zmq_client/src/connector.rs | nl -ba -v180
fi

echo
echo "== nearby state/update symbols =="
rg -n "struct WaveState|enum WaveEvent|observe_wave|broadcast_start_wave|submit|wake_group|DpControlMessage::StartWave|WaveEvent::Start|state\.current" crates/engine_zmq_client/src/connector.rs

Repository: smg-project/smg

Length of output: 362


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file section =="
sed -n '180,270p' crates/engine_zmq_client/src/connector.rs

echo
echo "== relevant symbol usages =="
rg -n "struct WaveState|enum WaveEvent|observe_wave|broadcast_start_wave|submit\(|wake_group|DpControlMessage::StartWave|WaveEvent::Start|state\.current" crates/engine_zmq_client/src/connector.rs

echo
echo "== behavioral invariant probe =="
python3 - <<'PY'
class State:
    def __init__(self):
        self.current = 0
        self.running = False

def handle_start(state, wave):
    state.current = max(state.current, wave)
    state.running = True
    broadcast = wave
    return broadcast

state = State()
state.current = 5
state.running = False
broadcast = handle_start(state, 3)
print(f"state.current={state.current} running={state.running} broadcast={broadcast}")
print(f"state and broadcast disagree: {state.current != broadcast}")
PY

Repository: smg-project/smg

Length of output: 5567


🔴 Important — Broadcast the resolved start wave in WaveEvent::Start.

Complete(wave) advances state.current only for wave >= state.current, but Start(wave) always sets state.current = state.current.max(wave) and running = true while sending broadcast_start_wave(wave). If state.current is already higher, the connector marks the lockstep group running and tells peers to start an old wave, so later submit calls skip wake_group. Broadcast the resolved state.current value instead.

🐛 Proposed fix
             WaveEvent::Start(wave) => {
-                {
+                let wave = {
                     let mut state = state.lock();
                     state.current = state.current.max(wave);
                     state.running = true;
-                }
+                    state.current
+                };
                 if let Err(error) = self.broadcast_start_wave(wave, engine_index).await {
                     warn!(%error, wave, "failed to start the requested wave");
                     state.lock().running = false;
                 }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WaveEvent::Start(wave) => {
{
let mut state = state.lock();
state.current = state.current.max(wave);
state.running = true;
}
if let Err(error) = self.broadcast_start_wave(wave, engine_index).await {
warn!(%error, wave, "failed to start the requested wave");
state.lock().running = false;
}
}
WaveEvent::Start(wave) => {
let wave = {
let mut state = state.lock();
state.current = state.current.max(wave);
state.running = true;
state.current
};
if let Err(error) = self.broadcast_start_wave(wave, engine_index).await {
warn!(%error, wave, "failed to start the requested wave");
state.lock().running = false;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine_zmq_client/src/connector.rs` around lines 237 - 247, Update the
WaveEvent::Start branch to resolve and retain the updated state.current value
before broadcasting, then pass that resolved wave to broadcast_start_wave
instead of the original wave argument. Keep running state management and failure
handling unchanged, ensuring peers start the same wave recorded by the connector
when state.current is already higher.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean implementation — wave bookkeeping, lockstep detection, and the add-then-wake ordering are all correct. Concurrency around WaveState is sound (short critical sections, lock released before async work, proper error rollback). Both protocol implementations updated consistently. Good test coverage of the three key scenarios. LGTM.

The mock worker matches `EngineInbound` exhaustively, so adding the
start-wave variant broke its build. It simulates a single independent
engine, never a lockstep group, so it never parks and has no wave to
start: log and ignore.

Also name vLLM as the source of the word "wave" where the enum is
defined, with the upstream identifiers to grep for.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
@slin1237
slin1237 merged commit c3a8287 into main Aug 8, 2026
31 checks passed
@slin1237
slin1237 deleted the zmq/dp-multi-engine branch August 8, 2026 22:06
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