Skip to content

test: add FSDP2 distributed coverage for AsyncGRPOTrainer - #6144

Open
behroozazarkhalili wants to merge 25 commits into
mainfrom
test/async-grpo-fsdp2-coverage
Open

test: add FSDP2 distributed coverage for AsyncGRPOTrainer#6144
behroozazarkhalili wants to merge 25 commits into
mainfrom
test/async-grpo-fsdp2-coverage

Conversation

@behroozazarkhalili

@behroozazarkhalili behroozazarkhalili commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a 2-process FSDP2 functional test for the experimental AsyncGRPOTrainer, which previously had no distributed-test coverage (tests/experimental/test_async_grpo_trainer.py only ran single-process).

test_train_fsdp2 launches a companion worker via accelerate launch under a 2-process FSDP2 config and asserts the trainer trains end-to-end on FSDP2-sharded parameters: training steps run, the loss is finite, and parameters update. It uses an in-process stub rollout worker (no vLLM server / NCCL weight transfer), so the only distributed surface exercised is the FSDP2 parameter lifecycle. The test is guarded by @require_torch_multi_accelerator, so it skips automatically when fewer than 2 accelerators are available.

Why

AsyncGRPOTrainer runs under FSDP2 in practice but had no test confirming it trains under a real 2-process FSDP2 group. This closes that gap with a lightweight functional smoke that mirrors the existing _openreward_echo_env.py companion-script pattern.

Test plan

Verified on 2x H100:

pytest tests/experimental/test_async_grpo_trainer.py::TestAsyncGRPOTrainer::test_train_fsdp2
# 1 passed in 76.10s

Skips cleanly on single-GPU / CPU. ruff check and ruff format pass.


Note

Low Risk
Changes are limited to experimental test harness and Accelerate config; no production trainer or runtime behavior is modified.

Overview
Adds distributed FSDP2 coverage for experimental AsyncGRPOTrainer, which previously only had single-process training tests.

A new test_train_fsdp2 (marked slow, gated by @require_torch_multi_accelerator) spawns _async_grpo_fsdp2_worker.py via accelerate launch and a dedicated fsdp2_reshard.yaml (2 ranks, FSDP v2, fsdp_reshard_after_forward: true). The worker runs two training steps with an in-process stub rollout queue and a no-op weight transfer so the test exercises FSDP2 parameter/optimizer lifecycle without vLLM or NCCL weight sync. It emits a single ASYNC_GRPO_FSDP2_RESULT JSON line; pytest asserts finite loss, parameter updates, full step count, and launch shape (2 processes, FSDP2, DTensors).

The pytest side sets PYTHONPATH to the repo root, passes output dir via env, and on timeout kills the full /proc descendant tree so detached elastic ranks cannot hang the suite.

Reviewed by Cursor Bugbot for commit d03ecc2. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds a 2-process FSDP2 functional test for the experimental AsyncGRPOTrainer,
which previously had no distributed-test coverage. test_train_fsdp2 launches a
companion worker via accelerate launch under a 2-process FSDP2 config and asserts
the trainer trains end-to-end on FSDP2-sharded parameters (steps run, loss finite,
params update). It uses an in-process stub rollout worker (no vLLM server / NCCL
weight transfer), so the only distributed surface exercised is the FSDP2 parameter
lifecycle. Guarded by require_torch_multi_accelerator so it skips when fewer than
2 accelerators are available.
…-coverage

# Conflicts:
#	tests/experimental/test_async_grpo_trainer.py
@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@albertvillanova albertvillanova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @behroozazarkhalili — this is careful work and the companion-worker pattern is a clean match for _openreward_echo_env.py.

The blocker is that the test won't actually run anywhere in CI: the experimental lane runs on a single-GPU aws-g4dn-2xlarge, so @require_torch_multi_accelerator skips it (the green "Tests (experimental)" check here never executed it), and the only multi-GPU lane is the slow-tests multi_gpu job, which collects -m slow only — and this test isn't marked slow. Could you add @pytest.mark.slow so it lands in that lane (and confirm the runner really exposes 2 GPUs)? Otherwise it's a permanent skip that will quietly bitrot.

Separately, the #6077 narrative in the module docstring and the test comment is very long for what it ultimately concludes — trimming to a one-line note would keep the test readable. The duplicated stub is fine given the subprocess boundary.

…CI lane

test_train_fsdp2 requires @require_torch_multi_accelerator, but the
experimental lane is single-GPU and would skip it permanently. The only
multi-GPU lane is slow_tests, which collects -m slow. Mark the test slow
so it runs there. Also trim the #6077 narrative in the test comment and
the worker module docstring to a one-line note per review feedback.
@behroozazarkhalili

Copy link
Copy Markdown
Collaborator Author

Thanks @albertvillanova, both points addressed in 2fc1fe06.

1. @pytest.mark.slow added. You are right that it was a guaranteed permanent skip: make slow_tests is pytest -m "slow" tests/, so without the marker the test is deselected from both slow lanes, and the only other lane that collects it (tests-experimental) runs single-GPU where @require_torch_multi_accelerator skips it. With the marker it is now collectable by the run_all_tests_multi_gpu slow lane.

2. Docstring/comment trimmed. The long #6077 narrative is now a one-line note in both the test comment and the worker module docstring.

On "confirm the runner really exposes 2 GPUs", being precise about what I could and could not verify from outside your infra:

  • What I confirmed from slow-tests.yml: the run_all_tests_multi_gpu job sets CUDA_VISIBLE_DEVICES: "0,1" (vs "0" for the single-GPU job), so it is configured for 2 GPUs.
  • What I could not confirm: torch.cuda.device_count() == 2 at runtime on that runner. I looked at the latest slow run (28670435029) and the multi_gpu and single_gpu jobs returned an identical 35 passed / 71 skipped. That identity is not evidence of one GPU: it happens because there is currently no test that is both @pytest.mark.slow and @require_torch_multi_accelerator, so nothing in that run could differentiate the two lanes. This PR's test becomes the first such probe.

The nice property is that it is self-verifying and cannot go falsely red: @require_torch_multi_accelerator degrades to a visible skip, not a failure, when fewer than 2 accelerators are present. So the next slow-tests report will show either test_train_fsdp2 PASSED (confirming the multi_gpu runner really has 2 GPUs) or SKIPPED (flagging that the lane is effectively single-GPU and needs a real 2-GPU box), with no risk of a spurious failure either way.

If you would prefer a louder signal than a silent skip, I can add an explicit assert torch.cuda.device_count() >= 2 in the worker so a misprovisioned runner fails loudly instead of quietly skipping. The tradeoff is that it would hard-fail on a 1-GPU runner rather than skip, so I left it as the standard skip guard and am happy to switch on your call.

cc @qgallouedec

…po_trainer.py (keep new async-GRPO suite + FSDP2 smoke test)
Comment thread tests/experimental/_async_grpo_fsdp2_worker.py
Comment thread tests/experimental/_async_grpo_fsdp2_worker.py
… API

Two defects in the companion worker, both surfaced by review on this PR and both introduced by
merging main into a branch whose stub predated the current API:

- `RolloutSample` now requires `group_id`, which the stub never passed, so the first sample raised
  `TypeError` and training never started. Every completion of one prompt shares a group id.
- Without an explicit `weight_transfer`, `AsyncGRPOTrainer` builds the default `WeightTransferClient`,
  which needs a live vLLM server. The smoke is meant to exercise only the FSDP2 parameter lifecycle,
  so pass a no-op implementation of the protocol.
…c_grpo_trainer.py import block

#6715 added `from collections import defaultdict` in the same stdlib import block this
branch added `subprocess` and `pathlib.Path` to. Both sides are needed, so the resolution
keeps all four in isort order. No other hunk conflicted.
…ains

#6715 widened `RolloutWorkerProtocol` with a required `metrics_queue`, and
`AsyncGRPOTrainer.log()` drains it unconditionally on the main process. The FSDP2
worker's stub carried only `rollout_buffer`, so the smoke would have raised
AttributeError at the first log step. Line copied from the sibling stub in
test_async_grpo_trainer.py so the two stay identical.
`token_budget` was unset, so `get_train_dataloader` defaulted it to the vLLM
server's max_model_len and rank 0 called `wait_for_server_ready()` first. This
smoke starts no vLLM server, so it died on the 5s timeout before reaching a single
FSDP step. The sibling stubbed `test_train` already sets `token_budget=256` for
exactly this reason; line and comment copied from it.

The params_changed loop broke at the first changed parameter, but `_materialize`
calls the collective `DTensor.full_tensor()`. With `fsdp_cpu_ram_efficient_loading`
the pre-wrap snapshot holds real weights on rank 0 and placeholders elsewhere, so
in the no-update regression this smoke exists to catch, the ranks break at
different indices and rank 0 blocks forever on an unpartnered collective: the test
hung instead of reporting the failure. Every rank now walks the full parameter list
and decides afterwards. The list comprehension is deliberate, since `any()` over a
generator short-circuits exactly like the `break` did.
…ock again

#5911 added `unittest.mock` to the same stdlib import block, and its `json` / `os`
additions were already here. Union of both sides in isort order; no other hunk
conflicted.
Comment thread tests/experimental/_async_grpo_fsdp2_worker.py
`TokenBudgetBatcher` emits a micro-batch only when the next sample fits in no
row, and a bounded producer of short samples never triggers that. Running TRL's
own batcher on this worker's inputs gives:

    10 samples x 41 tok, budget 256, 2 ranks -> rows [205, 205], 0 micro-batches
    24 samples,          budget 0,   2 ranks -> 4 micro-batches, no empty row

At 0 micro-batches the rollout consumer blocks forever on an empty queue.

`token_budget=256` came from the sibling single-process `test_train`, where a
single row of 256 overflows at the 7th sample. Two ranks double the capacity, so
the overflow that made the sibling work never happens here.

`token_budget=0` is the only value that both selects the count-based
`FixedCountBatcher` (`> 0` is false) and skips `wait_for_server_ready()`
(`is None` is false). The run therefore needs no vLLM server and no packing
arithmetic.

`samples_per_weight_sync=24` covers `max_steps=2` with no weight-sync refill:

    micro-batch    = per_device_train_batch_size 3 x 2 ranks = 6
    samples needed = 6 x grad accum 1 x max_steps 2          = 12
    previous value = 10

The `subprocess.run` timeout bounds a failure mode this fix does not remove.
`RolloutQueueDataset.__iter__` loops on `queue.Empty` and calls only
`check_health`, a no-op in this stub, so a future starvation would hang the
pytest process rather than fail it. The timeout makes it a readable failure.
Comment thread tests/experimental/test_async_grpo_trainer.py
`AsyncGRPOTrainer` loads every model with
`attn_implementation="kernels-community/flash-attn3"` (`async_grpo_trainer.py:811`),
and Flash Attention rejects a head_size that is not a multiple of 8.
`trl-internal-testing/tiny-Qwen2ForCausalLM-2.5` reports hidden_size 8 over 4
attention heads, so head_size is 2 and the forward pass raises. The three sibling
tests in this file already carry the #6837 xfail. `test_train_fsdp2` drives the
same model through the same trainer without it.

The comment above the test also claimed `@pytest.mark.slow` routes it to the
multi-GPU `slow_tests` lane. It does not. `slow_tests` is `pytest -m "slow"
tests/`, a recursive collection, and `norecursedirs` excludes
`tests/experimental`:

    pytest -m slow tests/    --collect-only -> 0 matches for test_train_fsdp2
    pytest tests/experimental --collect-only -> 1 match

`test_experimental` passes an explicit path, so it does collect the test, but its
runner is single-GPU and `@require_torch_multi_accelerator` skips it there. The
comment now says that instead.
@behroozazarkhalili

behroozazarkhalili commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@albertvillanova a correction to what I told you on 2026-07-03, since you adjusted your review on the strength of it. It changes the conclusion rather than a detail, so the short version first: test_train_fsdp2 has never executed in any lane since it was added, and it cannot execute today for three independent reasons.

I wrote: "With the marker it is now collectable by the run_all_tests_multi_gpu slow lane." That is wrong on all three counts.

The slow lane never collects it. run_all_tests_multi_gpu runs make slow_tests, which is pytest -m "slow" tests/, a recursive collection from tests/, and pyproject.toml sets norecursedirs = ["tests/experimental", "tests/invariant"], so everything under tests/experimental drops out before the marker is consulted:

pytest -m slow tests/     --collect-only -> 0 matches for test_train_fsdp2
pytest tests/experimental --collect-only -> 1 match

The slow workflow would also never fire for this change. slow-tests.yml triggers on push to main with paths filtered to trl/**.py and examples/**.py, and a file under tests/ matches neither, so merging this PR alone would not start that lane even if collection succeeded.

The lane that does collect it cannot run it. test_experimental is pytest -n auto -s -v tests/experimental, an explicit path, so norecursedirs does not apply, and what that lane reports is the answer to the question I twice said I could not settle from outside your infra:

[gw16] SKIPPED tests/experimental/test_async_grpo_trainer.py::TestAsyncGRPOTrainer::test_train_fsdp2

That is run 32436517199, from today, and the same line appears in all six completed Tests (experimental) runs on this branch, back to the first two on 2026-06-24. Since require_torch_multi_accelerator skips rather than fails, a SKIP means the runner exposes fewer than two accelerators. That is as far as I can take it, because the guard reports no count and I cannot see your runner.

#6837 would stop the test even on a two-GPU lane. AsyncGRPOTrainer loads every model with attn_implementation="kernels-community/flash-attn3", and trl-internal-testing/tiny-Qwen2ForCausalLM-2.5 has hidden_size 8 over 4 attention heads, so head_size is 2 and Flash Attention rejects the forward pass. I marked the test xfail in 2433b1e to match the three siblings you already have marked, and corrected the in-source comment that repeated my wrong lane claim.

Because that skip is silent, the same trap stays open for whatever is added next to it. Tests (experimental) reports 568 passed, 9 skipped, 8 xfailed and shows green with this test among the 9, so any future test carrying both @pytest.mark.slow and @require_torch_multi_accelerator under tests/experimental reaches only a lane that cannot run it, skips, and leaves the check green.

Which way to fix that is a repo-layout call. I would rather hand you the measurement than a recommendation I have not tested. Adding tests/experimental to the slow_tests target would admit every other slow-marked experimental test at once, at a runtime and pass-state I have not checked, and moving this test to a directory the multi-GPU lane already collects would separate it from the async-GRPO suite it belongs to. I am glad to do either, or to drop the multi-GPU test from this PR and keep the single-process coverage, if you would rather settle the lane question on its own.

… smoke

`test_train_fsdp2` was marked xfail because the trainer loads the model with
Flash Attention, which rejects a `head_size` that is not a multiple of 8, and
the worker used `tiny-Qwen2ForCausalLM-2.5` at `head_size=2`.

#6853 and #6854 fixed the same class of failure by swapping the model rather
than skipping the test, and removed every xfail carrying this reason. This does
the same for the one test they did not cover: the worker now loads
`small-Qwen2ForCausalLM-2.5` (`head_size=32`), and the xfail is gone.

Measured from the two configs rather than assumed:

    tiny-Qwen2ForCausalLM-2.5    hidden=8    heads=4  head_size=2   rejected
    small-Qwen2ForCausalLM-2.5   hidden=128  heads=4  head_size=32  accepted

Also merges main, which brings in those two PRs and drops the six inherited
xfails this branch was still carrying.
Comment thread tests/experimental/_async_grpo_fsdp2_worker.py Outdated
The worker set `output_dir` to a relative path while the launcher runs it with
`cwd` at the repo root, so every run left trainer artifacts in the working tree
with nothing to clean them up. The five sibling tests in this file already pass
`self.tmp_dir`; the worker is a subprocess, so it receives that path through the
environment dict the launcher already builds for `PYTHONPATH`.

No default for the variable: this worker is launched only by `test_train_fsdp2`,
so a missing value should fail loudly rather than fall back to the old behavior.
…very configured step, and make stub rewards distinct

Three defects from an adversarial review of the FSDP2 test, each reproduced before the change.

The timeout used subprocess.run, which kills only the accelerate launcher. torch elastic starts each rank with start_new_session=True, so on a hang the ranks survived the timeout and kept the GPUs. The launcher now runs in its own process group and the whole group is killed on timeout.

The parent accepted steps >= 1 although the worker configures max_steps=2 to exercise the optimizer loop more than once, so an early stop after step 1 passed every assertion. The worker reports its configured step count and the parent requires the measured count to match it.

The stub derived rewards from hash() of the completion text. Under a randomized PYTHONHASHSEED the three completions of a group can collide modulo 100, the reward standard deviation is then zero and the advantages are NaN, a failure unrelated to FSDP2 (reproduced with PYTHONHASHSEED=5062). Rewards are now a fixed linspace, distinct by construction.

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2a112af. Configure here.

Comment thread tests/experimental/test_async_grpo_trainer.py
…uncher's process group

The previous timeout path put the launcher in its own process group and killed that group. torch elastic starts every rank with start_new_session=True, so the ranks were never in that group: a hung run killed only the launcher, the ranks kept the GPUs and held the stdout pipe open, and the read after the timeout never returned. The descendants are now collected from /proc while the launcher is still their parent, and the launcher and every descendant get SIGKILL before the pipes are drained. Reproduced with a launcher whose child detached into its own session: the child is found, the drain returns at once, and nothing survives.
The worker only reported step count, a finite loss and changed parameters, all of which a replicated
single-process run also produces, so the test could not tell an FSDP2 launch from a plain one. The
result now carries the process count, the distributed type, the FSDP version and the number of DTensor
parameters, and the test pins them to two ranks, FSDP, version 2 and at least one sharded parameter.
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.

3 participants