Skip to content

[Bugfix][EPLB] Start the async rearrangement interval after the last layer commits - #64485

Merged
Isotr0py merged 9 commits into
vllm-project:mainfrom
abmfy:bowen/eplb-async-cycle-counter
Nov 12, 2026
Merged

Isotr0py merged 9 commits into
vllm-project:mainfrom
abmfy:bowen/eplb-async-cycle-counter

Conversation

@abmfy

@abmfy abmfy commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Overview

Async EPLB resets the rearrangement counter only when a cycle starts. A cycle
longer than step_interval therefore logs a negative "steps until the next
rearrangement" and starts the next rearrangement in the same step its last
layer commits. This PR counts the interval from the last layer's commit and
logs in-flight progress instead. If a cycle overruns, a warning names the
slowest EP ranks; when the ranks wait for each other inside a transfer, it
names all of them.

Related: #57883 (stall that shows the negative count), #60685 / #60686 (load
window credited to the wrong experts), #57905.

Claims

Validation

GPU end-to-end (GB200, Qwen3.5-35B-A3B, 2 GPUs with EP)

Setup:

  • Async EPLB, num_redundant_experts=2, log_balancedness_interval=1.
  • Communicator: torch_gloo (CPU-staged), because NIXL is not installed in
    this venv. The cycle lengths below are for that path.
  • Each job runs both arms on the same node:
  • "Forced" means window_size=step_interval=30. A cycle takes 7.3-8.9 s
    (140-370 steps), so every cycle overruns the interval.
vllm serve Qwen/Qwen3.5-35B-A3B --tensor-parallel-size 2 \
  --max-model-len 8192 --enable-expert-parallel --enable-eplb \
  --eplb-config '{"window_size":30,"step_interval":30,"num_redundant_experts":2,"log_balancedness":true,"log_balancedness_interval":1,"use_async":true}'
# DP2: --data-parallel-size 2 --tensor-parallel-size 1
# MTP: --speculative-config '{"method":"mtp","num_speculative_tokens":1}'
# step_interval=600 row: "window_size":50,"step_interval":600

python tests/evals/gsm8k/gsm8k_eval.py --num-questions 1319 --num-shots 5 \
  --max-tokens 256 --temperature 0 --max-concurrency 64

Cadence (target model):

  • Negative counts and warnings are over the whole run. That includes the last
    cycle, which was still in flight when the run ended.
  • The other two columns are over complete cycles.
config arm negative counts steps from last commit to next cycle in-flight steps per cycle (median) warnings
forced, TP2 main 2874 0 (9/9 cycles) 296 -
PR 0 30 (9/9) 288 91, all at multiples of 30 (earlier warning revision)
forced, TP2 + MTP drafter main 1330 0 (9/9) 159 -
PR 0 30 (8/8) 149 40, all at multiples of 30
forced, DP2 main 2864 0 (9/9) 322 -
PR 0 30 (8/8) 320 94, all at multiples of 30
window_size=50, step_interval=600, TP2 main 0 ~310 (600 from cycle start) 291 -
PR 0 600 (3/3) 295 0
  • With the drafter, both models report the in-progress status. The drafter's
    single layer commits first. The counter resets once the target's last layer
    commits.

  • Sample PR lines (DP2):

    EPLB step: 286 for model <target>: avg_tokens=10080.00, max_tokens=10766, balancedness=0.9363, async rearrangement in progress: 30/40 layers committed
    WARNING Async EPLB rearrangement still in progress after 30 steps: 1/40 layers of <target> committed, slowest EP ranks [0, 1]: transferring layer 1
    WARNING Async EPLB rearrangement still in progress after 180 steps: 25/40 layers of <target> committed, slowest EP ranks [0]: published layer 24
    
  • Every cycle in this run overran, but no rank was actually slow. Of the 94
    DP2 warnings:

    • 81 list both ranks transferring the same layer. They wait for each other
      in that layer's coordinated-stop all-reduce or gloo exchange.
    • 10 list both ranks at published layer N, either waiting for the commit
      or just past it.
    • 3 name one rank, because of a short race: that rank had not yet returned
      from the commit of layer N, while the other was already waiting for it at
      layer N+1.

GSM8K accuracy, one value per round:

config main PR
forced, TP2, fa3b9d1 wheel 0.8711, 0.8908 0.8848, 0.8673
forced, TP2 0.8484 0.8666
step_interval=600, TP2 0.8817 0.8764
forced, TP2 + MTP drafter 0.8795, 0.8476, 0.8324, 0.8976 0.8469, 0.9045, 0.8506, 0.9030
forced, DP2 0.8832 0.8666
mean of all rounds 0.8703 (9 rounds, 0.8324-0.8976) 0.8741 (9 rounds, 0.8469-0.9045)
  • The PR is 0.4 points above main (Welch 95% CI [-1.8, +2.5]).
  • The same arm on the same config varies by up to 6.5 points between rounds
    (MTP main: 0.8324 to 0.8976), so a single-round difference is noise.
  • The change only moves when cycles start. It does not change how expert
    weights are moved or how tokens are routed.

Which PR revision each arm ran:

  • The fa3b9d1 row ran this PR's first commit (ce3f57a), from before the
    merge of main. Its warning names no ranks.
  • The other "forced, TP2" PR arm ran an earlier encoding of the rank warning.
  • The counter logic is the same in all of them.
  • The step_interval=600, MTP and DP2 rows ran 4a71a57. Later commits only
    merge main ([Feature] Initial support for fault tolerant ep using scale-down #46370 moved the worker loop into _transfer_cycle, and the two
    progress writes moved with it), reword a comment and add the second unit test.

Unit tests

tests/distributed/test_eplb_utils.py is run by the "EPLB Algorithm" CI step.

  • test_async_rearrangement_interval_starts_after_last_layer_commit uses a real
    single-rank parallel state (dist_init), the real EplbState.step() /
    rearrange() / _move_to_workspace(), and a cycle longer than
    step_interval. It plays the async worker, publishing one layer result every
    two steps.
  • test_async_rearrangement_overrun_warning_names_slowest_rank runs two gloo
    processes through the real EplbState.step() at the overrun step, with rank
    1's worker one stage behind rank 0. Rank 0 must log once and name rank 1
    ("transferring layer 1"), and rank 1 must not log. A rank that skips the
    all-reduce makes the other one time out after 30 s.
$ pytest tests/distributed/test_eplb_utils.py -q    # this PR
7 passed, 1 skipped    # the skip is a CUDA-only test, run on a Mac

$ pytest tests/distributed/test_eplb_utils.py -q -k async_rearrangement    # eplb_state.py from main
E       assert [0, 6] == [0, 10]
E       AssertionError: Expected 'warning' to have been called once. Called 0 times.
2 failed, 6 deselected

On main, the second rearrangement starts at step 6, the step that commits the
last layer. With this PR it starts at step 10 (= 6 + step_interval).

The warning test also fails if the all-reduce runs only on rank 0 (timeout),
without the all-reduce, with max instead of min, or if every rank logs.

CPU simulation of the window misattribution

Setup:

  • The real EplbState, with 4 layers and 440 steps per layer.
  • step_interval=1500, window_size=1000.
  • The fake worker copies transfer_run_periodically. The router splits each
    logical expert's demand evenly over its replicas.
main this PR
rearrange #2 starts at step 2136 (same step as last commit) 3636 (= 2136 + 1500)
misattributed window tokens, last layer, rearrange #2 / #3 32.3% / 16.3% 0% / 0%
balancedness of the computed placement under true demand, rearrange #2 / #3 / #4 (both arms: 0.9556 at #1) 0.93 / 0.89 / 0.90 0.9556 / 0.9556 / - (only three rearranges in the same run)

Other checks:

  • Every step_interval >= window_size case showed 0% misattribution with this
    PR. The sweep covered log_balancedness on and off, log intervals
    1 / 250 / 2000, 20 layers, and a last commit at counter I-1, I and I+1.
  • Sync mode output is byte-identical between the arms.
  • A two-rank gloo simulation used a target and a drafter, random per-rank
    delays and dummy steps:
    • The counters were identical across ranks at every step.
    • The rank warning named the delayed rank when it was held outside a
      collective, and listed both ranks when they were held inside one.

Details

Root cause. In EplbState.step(), expert_rearrangement_step is reset
just before rearrange() starts a cycle.

  • When the counter reaches step_interval while a cycle is still in flight,
    the bail-out returns without resetting it. The counter keeps growing, so the
    log goes negative. A production log showed -250 with
    step_interval=1500.
  • The step() that commits the last layer then sees
    counter >= step_interval with nothing in flight, and starts the next cycle
    at once.
  • That cycle's window is indexed by physical slot and scattered through the new
    map. So rows recorded before each layer's commit are credited to the wrong
    logical experts.

Change.

  • After _move_to_workspace, once no model is still rebalancing, set the
    counter back to 0. The _all_ranks_result_ready all-reduce gates the commit,
    so it happens in the same step on every rank and the counter stays
    consistent.
  • The main thread tracks num_committed_layers for the progress log.
  • rearrange() sets worker_progress to -1. The async worker then writes
    2 * layer while transferring a layer, and 2 * layer + 1 once its result
    is published.
  • When the warning fires, each rank writes its value into its own slot of a
    zero int64 tensor of EP size. A sum all-reduce over the EP CPU group then
    gathers them. This costs one small CPU all-reduce, only when the warning
    fires. While a cycle is in flight, async mode already does a CPU all-reduce
    on that group every step (_all_ranks_result_ready).

Trade-off. In async mode, the period goes from max(step_interval, D) to
step_interval + D, where D is the cycle length. Here D was 7.3-8.9 s with
the gloo communicator, or 140-370 steps under GSM8K load. With the default
interval of 3000 steps, that makes the period 5-12% longer: about 10% without
the drafter, and about 5% with MTP. The step_interval docstring now says when
the interval starts.

Not done: clearing the window on commit.

Limitations.

  • The following also apply to sync mode on main:
    • With step_interval < window_size, some window rows still come from before
      the commit.
    • A rank that runs dummy steps keeps some old rows, because dummy steps do
      not advance the window ring.
    • [Bugfix][EPLB] Record the load window by logical expert #60686's logical-expert window credits those rows to the right experts in
      both cases.
  • If the ranks are stalled inside a transfer collective, the warning lists all
    of them. It still names the layer.

Not a duplicate.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

AI assistance (Claude Code) was used for the investigation, the code and this description.

🤖 Generated with Claude Code

…layer commits

In async mode the rearrangement counter was only reset when a cycle
started. A cycle longer than step_interval drove "steps until the next
rearrangement" negative and started the next rearrangement in the same
step the last layer committed, so its physical-slot load window still
held rows recorded under the old placement.

Reset the counter again once the last layer of the last in-flight model
commits, log committed-layer progress instead of a negative count while
a cycle is in flight, and warn on rank 0 every step_interval steps a
cycle is still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bowen Wang <bowen@inferact.ai>
@mergify mergify Bot added the bug Something isn't working label Oct 29, 2026
@abmfy
abmfy marked this pull request as ready for review October 29, 2026 08:51

@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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Comment thread vllm/distributed/eplb/eplb_state.py Outdated
Comment on lines +713 to +721
logger.warning(
"Async EPLB rearrangement still in progress after %d steps: %s",
self.expert_rearrangement_step,
", ".join(
f"{ms.model_name} {ms.num_committed_layers}/"
f"{ms.model.num_moe_layers} layers committed"
for ms in self.model_states.values()
),
)

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.

since it only fires on rank 0, it can't tell us which rank is stuck. can we find a way to show which rank is behind? but maybe takes some extra communication cost, we can use debug flags open it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point, done in 4a71a57.

  • The async worker now records how far it has got in the current rearrangement: which layer, and whether that layer's result has been published.
  • When the warning fires, every EP rank writes its progress into its own slot of an int64 tensor of EP size, and a sum all-reduce over the EP CPU group gathers them.
  • Rank 0 then logs the ranks that are furthest behind and where they are:
Async EPLB rearrangement still in progress after 30 steps: 1/40 layers of <model> committed, slowest EP ranks [0, 1]: transferring layer 1

I didn't put it behind a flag:

  • The all-reduce only runs when the warning fires, which is every step_interval steps while a cycle is overrunning.
  • While a cycle is in flight, async mode already does a CPU all-reduce on the EP group every step (_all_ranks_result_ready).
  • In a GB200 run with step_interval=600, the warning never fired.

What it can and cannot tell you:

  • A rank stuck outside a collective is named in every warning while it stays stuck. For example, it may still be computing the new placement, or still finishing its own transfer after the others have published. In a two-rank gloo simulation, the warning named the delayed rank.
  • When the ranks wait for each other inside a layer's transfer, they all report the same layer, so the warning lists all of them. It still names the layer. These waits are:
    • the coordinated-stop all-reduce at the start of each layer;
    • the communicator's exchange: the gloo/XCCL send/recv waits, the NIXL post-read barrier, or the symm_mem barriers.
  • On GB200 (DP2, gloo communicator) I ran with step_interval=30, so every cycle overran but no rank was actually slow.
    • 91 of 94 warnings listed both ranks.
    • The other 3 named one rank because of a short race: that rank had not yet returned from the commit of layer N, while the other was already waiting for it at layer N+1.

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.

makes sense, LGTM! thanks @abmfy

@mergify

mergify Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @abmfy.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 30, 2026
abmfy and others added 2 commits October 30, 2026 09:33
Carry the rearrangement status through the deferred balancedness log
added in vllm-project#62009.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bowen Wang <bowen@inferact.ai>
The in-progress warning only reported committed layers, so a stall did
not show which rank's async worker was behind. The worker now records
its progress through each layer (transferring, published).
Every EP rank reaches the warning in the same step, so one small CPU
all-reduce there gathers each rank's progress, and rank 0 names the
ranks that are furthest behind and where they are. This only runs when
a cycle overruns step_interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bowen Wang <bowen@inferact.ai>
@mergify mergify Bot removed the needs-rebase label Oct 30, 2026
@mergify

mergify Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @abmfy.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 30, 2026
Move the worker progress marks into _transfer_cycle, which vllm-project#46370
split out of transfer_run_periodically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bowen Wang <bowen@inferact.ai>
@mergify mergify Bot removed the needs-rebase label Nov 2, 2026

@ilmarkov ilmarkov 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.

Thanks for the logging improvement! Layer progress makes much more sense rather than negative steps. Just small nits.

Comment thread vllm/distributed/eplb/eplb_state.py Outdated
ep_rank=ep_group.rank(),
)
if not self._async_rearrangement_in_progress():
# Count the interval from when the new placement is fully

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.

Could you improve the wording a bit? "Start the next interval after the final commit, allowing the load window to fill under the new placement"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in e9fb25b, with your wording.

model_state.rebalanced for model_state in self.model_states.values()
)

def _warn_async_rearrangement_in_progress(self) -> None:

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.

Could you add a two-rank Gloo test for an overrun? It would verify that both ranks enter the new collective, only rank 0 logs, and the warning identifies the rank with the lowest worker progress. The PR mentions a manual simulation of this, but the added automated test covers only restart timing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in e9fb25b: test_async_rearrangement_overrun_warning_names_slowest_rank.

  • Two gloo processes run the real EplbState.step() at the step where the cycle overruns step_interval.
  • Rank 1's worker is one stage behind rank 0: rank 0 has published layer 1, rank 1 is still transferring it. So rank 0 can only name rank 1 through the all-reduce.
  • It checks that rank 0 logs once and names rank 1 and transferring layer 1, and that rank 1 logs nothing.
  • If one rank skips the all-reduce, the other times out after 30 s and the test fails instead of hanging.
  • It builds a gloo GroupCoordinator without a device communicator, like test_cpu_all_reduce.py, so it also runs on the single-GPU "EPLB Algorithm" step.

It fails with main's eplb_state.py, and also when the all-reduce runs only on rank 0, without the all-reduce, with max instead of min, or when every rank logs.

abmfy and others added 2 commits November 11, 2026 17:07
Add a two-rank gloo test: while an async cycle overruns step_interval,
both ranks enter the progress all-reduce, only rank 0 logs, and the
warning names the rank that is behind. Reword the comment on the
interval reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bowen Wang <bowen@inferact.ai>
@abmfy

abmfy commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #101816 for commit 4ec204dd3d08.

ℹ️ This PR is 12 commits behind upstream main. Merged upstream main into this branch as 4ec204dd3d08 before starting CI. Pull the branch before pushing more changes.

@abmfy

abmfy commented Nov 12, 2026

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #102056 for commit 4020eeecacc7.

ℹ️ This PR is 1 commit behind upstream main. Merged upstream main into this branch as 4020eeecacc7 before starting CI. Pull the branch before pushing more changes.

@Isotr0py
Isotr0py merged commit fa211f2 into vllm-project:main Nov 12, 2026
127 checks passed
latioswang added a commit to latioswang/vllm that referenced this pull request Nov 12, 2026
Port vllm-project#64485 onto the async runner: the rearrangement interval restarts
when the cycle's last layer commits, and the overrun warning reads the
running layer and the ranks still busy from the runner instead of a
worker-written progress field.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Shuojie Wang <latioswang@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants