Skip to content

feat(rollout): add TP/EP/PP parallel strategies on SGLang rollout side#191

Draft
NancyFyong wants to merge 13 commits into
Tencent-Hunyuan:mainfrom
NancyFyong:feat/rollout-tp-ep-pp
Draft

feat(rollout): add TP/EP/PP parallel strategies on SGLang rollout side#191
NancyFyong wants to merge 13 commits into
Tencent-Hunyuan:mainfrom
NancyFyong:feat/rollout-tp-ep-pp

Conversation

@NancyFyong

@NancyFyong NancyFyong commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Add tensor-parallel (TP) distribution on the SGLang rollout engine in colocate mode, with EP sharding inside SGLang and PP rank layout reserved for future work. Larger rollout models (e.g. Qwen3.5-MoE 35B) need >1 GPU per engine; before this change UniRL could only do dp_size = world_size, one GPU per engine.

The train side stays flat (FSDP). Each SGLang engine now spans tp_size GPUs: tp_rank==0 owns the server, tp_rank>0 workers are no-op shells that participate in training but hold no SGLang process. EP is forwarded to ServerArgs; PP>1 populates RankInfo but weight sync is fail-closed (NotImplementedError).

Key changes:

  1. Rank layout (handle.py): _build_rank_infos lays out (dp, pp, tp) with TP rank fastest. Handle.__init__ injects tp_rank/tp_size/tp_device_ids/pp_rank/pp_size/ep_size into the engine constructor. sp_size>1 combined with tp/pp>1 is rejected; tp=pp=ep=1 reproduces the flat layout.

  2. SGLang engine (sglang/{config,engine}.py): tp_rank>0 constructs as a no-op shell (no server, no ports). All rollout verbs early-return on tp_rank>0. tp_rank==0 with tp_size>1 passes base_gpu_id via runtime_overrides instead of overriding CUDA_VISIBLE_DEVICES globally.

  3. Weight sync — all four handlers guard on tp_rank:

    • NCCLWeightSync: rank_offset = i*tp + 1, world_size = num_engines*tp + 1; PP>1 raises.
    • TensorWeightSync: ships [payload]*tp_size on tp_rank==0; tp_rank>0 drains the FSDP all-gather in lockstep.
    • IPCWeightSync: receiver started once per stage; tp_rank>0 shells drop at the collect filter.
    • LocalLoraWeightSync/RemoteLoraWeightSync: extract on all ranks, push only from tp_rank==0/rank-0.
  4. TP>1 colocate crash fix (sglang/backends/{native,http}.py): SGLang Engine spawn temporarily exposes all TP GPUs via CUDA_VISIBLE_DEVICES then restores Ray's CVD. LoRA tensor serialization switched from ForkingPickler to plain pickle to avoid fd handle races across TP subprocesses.

Related Issue

N/A

Test Plan

  • SKIP=no-commit-to-branch pre-commit run --all-files --show-diff-on-failure
  • pytest scripts/tests/rollout/ -k on_cpu — CPU unit tests (rank layout, shell construction, weight sync tp_rank guards)
  • python scripts/check_recipe_targets.py_target_ paths resolve
  • GPU E2E (manual, tiny-random/qwen3.5-moe):
    • 2 GPUs, tp=2: boot + generate + sleep/wake — EXIT=0
    • 2 GPUs, tp=2 weight-sync round-trip — tokens returned post-sync
    • 8 GPUs, tp=2 ep=2 (4 engines × 2 GPU): full GRPO loop — reward/loss logged, EXIT=0
  • Not run; reason: PP>1 weight sync is fail-closed; TP=4/EP=4 blocked by tiny model's num_heads=2 (cannot divide tp_size=4)

Compatibility / Risk

  • Config additions (backward-compatible): SGLangEngineConfig gains pp_size/ep_size/dp_size/enable_expert_parallel, all default to 1/None. tp_size=1 path is asserted identical to pre-change layout.
  • Weight sync: NCCLWeightSync.connect now takes tp_size; pp_size>1 raises. All handlers' tp_size=1 path is unchanged.
  • Risk area: tp_rank>0 workers must construct without booting SGLang or reserving ports. A regression would spawn duplicate multi-GPU servers and OOM. Covered by test_tp_engine_shell_on_cpu.py and E2E boot test.

Reviewer Notes

  • AI assistance: implementation and tests drafted with CodeBuddy assistance; every changed line reviewed by the submitter before push.
  • Duplicate-work check: no overlap found. PR feat(veomni-ep): expert parallelism for the VeOmni FSDP2 training backend #158 (feat/veomni-backend-ep-pr) is training-backend EP (VeOmni FSDP2), distinct from this PR's rollout-side (SGLang) EP.
  • Limitations: PP>1 per-stage weight sync routing is fail-closed; PP is exercised at rank-layout / dispatch-filter level only. SGLang E2E tests force-exit pytest on success (SGLang's in-process shutdown fires SIGQUIT at siblings); long-term fix is to run the engine inside a Ray actor.
  • Suggested review order: handle.py (rank layout + kwargs injection) → weight_sync/*.sync (tp_rank guards) → sglang/engine.py (tp_rank>0 no-op shell)

Checklist

  • I reviewed the changed code and removed unrelated/generated artifacts.
  • I updated tests, docs, and configs where needed, or explained why not.

Add rollout-end TP/EP/PP parallelism in colocate mode (sleep/wake
alternation between train and rollout on the same GPU), mirroring
the approach used in verl and slime.

Core changes:
- group/handle.py: extend rank layout from (dp, sp) to (dp, pp, tp)
  with EP support. Inject per-rank tp_rank/tp_size/tp_device_ids and
  pp_rank/pp_size/ep_rank/ep_size into SGLangRolloutEngine init kwargs.
  Add tp_size/pp_size/ep_size/tp_zero_workers properties.
- group/placement.py: propagate tp/pp/ep sizes through HandleRef markers.
- rollout/engine/sglang/config.py: add pp_size/ep_size/dp_size/
  enable_expert_parallel to SGLangEngineConfig; thread server_args
  runtime_overrides for base_gpu_id/gpu_id_step.
- rollout/engine/sglang/engine.py: tp_rank>0 workers become no-op shells
  (no SGLang boot, no port, no weight_sync). tp_rank==0 with tp_size>1
  overrides CUDA_VISIBLE_DEVICES to the full TP group and sets
  base_gpu_id=0, gpu_id_step=1. All rollout verbs guarded by _is_tp_zero.
- weight_sync/full/nccl.py: rank_offset = i*tp + 1; PP>1 raises
  NotImplementedError (fail-closed, future work).
- weight_sync/full/tensor.py: tp_rank==0 ships [payload]*tp_size;
  tp_rank>0 drives all-gather but does not push.
- trainer/async_ar.py, trainer/diffusion.py: driver targets
  tp_zero_workers; pass tp_size/pp_size through.

Tests (scripts/tests/, pytest):
- 60 unit tests covering rank layout math, server_intent, engine shell
  behavior, weight sync (NCCL + Tensor), EP/PP (incl. PP fail-closed).
- 2 GPU-gated E2E recipes (skipped without GPUs).
- E2E recipes verified end-to-end on tiny-random/qwen3.5-moe:
  * examples/ar/qwen3_moe_tiny_sglang_tp2.yaml  (2 GPUs, tp=2)
  * examples/ar/qwen3_moe_tiny_sglang_ep8.yaml  (8 GPUs, tp=2 ep=2,
    4 engines x 2 GPU)

Design doc: docs/rollout_tp_ep_pp_design.md

Note: PP>1 weight sync per-stage rank_offset mapping is fail-closed
(NotImplementedError) and left as future work.
Reorganize the rollout TP/EP/PP tests into a rollout/ subdirectory with
_on_cpu / _gpu name suffixes (mirroring verl-omni/tests), so the pure-Python
and GPU-gated tiers are distinguishable at a glance and share one conftest.

- Extract the duplicated transport fakes (Ray handle, FSDP backend, NCCL sync
  builder) into conftest so each module imports them instead of redefining.
- Centralize the SGLang E2E force-exit into conftest.sglang_e2e_teardown with
  a docstring explaining the root cause (in-process SGLang SIGQUIT) and the
  long-term fix (Ray-actor isolation); replace the per-test _RESULT globals
  with a local passed flag.
- Delete the placeholder assert 0 == 0 shell test (its contract is covered by
  the E2E _is_tp_zero assertions) and drop unused imports / dead locals.

Verified: 57 passed, 6 deselected (GPU-gated) in 8.66s.
Satisfy the Lint CI (pre-commit run --all-files): ruff import-sort/format,
remove unused imports (torch/Any) and the unused ep local, fix a mis-indented
dict in handle.py, and trim trailing whitespace in the design doc.
- LocalLoraWeightSync/RemoteLoraWeightSync: extract on all ranks, push only
  from tp_rank==0 / rank-0; SGLang fans the adapter to its TP workers.
- IPCWeightSync: per-stage receiver thread + sender pump covered by CPU tests.
- _is_sglang_rollout_role: switch from string name match to the
  _accepts_rollout_tp_kwargs class attribute so per-rank TP kwargs injection
  survives rename/subclassing and never fires on sibling (weight sync /
  reward / algorithm) roles.
- SGLangRolloutEngine: document the DP_SCATTER collect filter contract and
  record ep_rank/ep_size for completeness (EP is sharded inside SGLang).
- Add CPU unit tests for NCCL/Tensor/IPC/LoRA tp_rank guards and the
  rank-zero broadcast vs non-rank-zero drain behavior.
Three fixes for SGLang rollout engine TP>1 in colocate (same-actor) mode:

1. engine.py: stop overriding CUDA_VISIBLE_DEVICES for TP>1; pass
   base_gpu_id via runtime_overrides so SGLang locates its GPUs without
   breaking the train side's single-GPU view.

2. backends/{native,http}.py boot(): temporarily set
   CUDA_VISIBLE_DEVICES to all TP GPUs before spawning the SGLang
   server subprocess, then restore the parent's Ray-assigned view so
   FSDP training is unaffected.

3. backends/{native,http}.py set_lora(): use plain pickle.dumps instead
   of ForkingPickler (MultiprocessingSerializer.serialize) to avoid
   resource_sharer fd handles that race across TP>1 scheduler
   subprocesses. SafeUnpickler (SGLang's deserializer) handles plain
   pickle CPU tensors correctly.

Verified: Qwen3.5-35B-A3B TP=8 EP=8 LoRA GRPO on 8×H200, baseline eval
generates 960 requests at ~20k token/s with CUDA graph enabled.
@github-actions github-actions Bot added the wip Draft / work in progress label Jul 8, 2026
@NancyFyong NancyFyong force-pushed the feat/rollout-tp-ep-pp branch from 890e64c to 2a365dc Compare July 8, 2026 04:10
NancyFyong and others added 6 commits July 8, 2026 04:13
- Replace CUDA_VISIBLE_DEVICES assertions with _tp_size/_backend checks
  (TP>1 fix moved CVD handling into backend boot, not engine __init__)
- Fix test_tp4_ep4_e2e fixture from tp8_gate to tp4_gate (4 GPUs needed)
- Remove unused devices parameter from _boot_and_generate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wip Draft / work in progress

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants