Skip to content

fix: stop the fully-async rollout dropping completed groups - #2238

Open
keepkeen wants to merge 2 commits into
THUDM:mainfrom
keepkeen:fix/fully-async-drops-surplus-groups
Open

fix: stop the fully-async rollout dropping completed groups#2238
keepkeen wants to merge 2 commits into
THUDM:mainfrom
keepkeen:fix/fully-async-drops-surplus-groups

Conversation

@keepkeen

Copy link
Copy Markdown

What

The fully-async rollout throws away almost every completed group, and its output queue can freeze the worker's event loop. The two defects are coupled, so this fixes both.

Bug 1: completed groups are dropped

_generate_rollout_async drains the entire output queue on every poll, then returns only a slice:

for gid, group in worker.get_completed_groups():   # drains everything
    collected[gid] = group
...
out = sorted(collected.values(), key=_key)[:target]  # surplus silently dropped

The dropped groups are fully generated and reward-scored, and their prompts were already consumed from data_buffer (never requeued) — burned out of the epoch, GPU work wasted.

This is not a rare edge: the worker is sized at sglang_server_concurrency * num_engines in-flight groups. With the shipped examples/fully_async/run-qwen2.5-0.5B-fully_async.sh that is 512 × 3 = 1536 in flight against --rollout-batch-size 8. The module's own documentation states the opposite contract three times: "The worker is shared across all subsequent generate_rollout calls so its queue stays warm", "each generate_rollout call drains until it has rollout_batch_size groups and returns them", and queue_left= is logged at the end as if leftovers persisted.

Reproduction (real _generate_rollout_async): 10 groups ready, target=4 → 4 returned, queue left 0, groups 4–9 gone.

Bug 2: blocking put on the event-loop thread

The task done-callback runs on the worker's event-loop thread and calls blocking Queue.put on a bounded queue (maxsize=1000). Once the queue fills — sized 1536-in-flight vs cap 1000, this happens on the first rollout of the example config — the callback blocks the loop itself, freezing every in-flight generate_and_rm_group coroutine until the training side happens to drain.

Why one PR

Fixing bug 1 alone makes bug 2 strictly worse: keeping the surplus queued makes a standing backlog the normal state, turning the blocking put from an occasional stall into a guaranteed freeze.

Fix

  • get_completed_groups(limit=None): the rollout pulls only target - len(collected) per poll; the surplus stays queued FIFO for the next rollout (the documented warm-queue behaviour). The now-dead [:target] slice is removed.
  • The queue becomes unbounded, so the done-callback can never block the loop. Real backpressure moves to _loop's top-up gate: no new prompts are pulled while a full pool (concurrency) of completed groups is already waiting to be consumed. Memory stays bounded at ~2 × concurrency groups, same order as today's in-flight pool.
  • _loop's sleep becomes self.poll_interval (default 1.0, unchanged) so the CPU test can drive iterations quickly.

Test

New CPU test tests/test_fully_async_rollout.py, registered in the cpu-unittest job (sglang_router / transformers stubbed the same way as tests/test_agent/test_agent_rollout_cpu.py):

  1. _generate_rollout_async returns exactly target groups FIFO and leaves the surplus queued — fails on main with assert 0 == 6.
  2. get_completed_groups limit semantics.
  3. The done-callback completes past the old 1000-item cap instead of blocking.
  4. With instantly-completing generations and 60 groups of fuel, the queue plateaus at ≤ 2×concurrency instead of absorbing the dataset — fails on main with queue grew to 9 with concurrency=3.

Two coupled defects in the fully-async worker's output queue:

1. `_generate_rollout_async` drained the entire queue on every poll but
   returned only `[:rollout_batch_size]`. Everything past the slice was
   discarded — fully generated, reward-scored groups whose prompts were
   already consumed from the data buffer. With the shipped example config the
   worker keeps `sglang_server_concurrency * num_engines` (1536) groups in
   flight against a target of 8, so nearly every completed group was thrown
   away. The module's own contract says the opposite: "each generate_rollout
   call drains until it has rollout_batch_size groups", "the queue stays
   warm", and `queue_left=` is logged as if leftovers persisted.

2. The task done-callback called blocking `Queue.put` on a bounded queue
   (maxsize=1000) from the event-loop thread. When the queue filled, the
   callback blocked the loop itself, freezing every in-flight generation.

They have to be fixed together: keeping the surplus queued (1) makes a
standing backlog normal, which turns the blocking put (2) from an occasional
stall into a guaranteed freeze.

- `get_completed_groups` takes a `limit`; the rollout pulls only what it
  still needs, and the surplus stays queued for the next call (FIFO).
- The queue is unbounded so the callback can never block the loop; real
  backpressure moves to `_loop`, which stops pulling new prompts while a
  full pool of completed groups is already waiting.
- The loop's sleep becomes `self.poll_interval` so the CPU test can drive
  iterations quickly.

Adds tests/test_fully_async_rollout.py (CPU CI): surplus retention + FIFO,
`limit` semantics, callback completing past the old 1000-item cap, and the
queue plateauing near `concurrency` instead of absorbing the whole dataset.
@shinytang6

Copy link
Copy Markdown

@keepkeen thx for the fix, we met the similar problem, please help take a look @zhuzilin

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.

2 participants