Skip to content

perf(r3): collate routes through a container-aware pool, and refuse replay under VPP - #2233

Merged
erictang000 merged 7 commits into
mainfrom
dyurk/r3-pooled-route-collation
Sep 21, 2026
Merged

erictang000 merged 7 commits into
mainfrom
dyurk/r3-pooled-route-collation

Conversation

@erictang000

Copy link
Copy Markdown
Collaborator

Note

Standalone-reviewable reopen of #2078 — original work by @dyurk-lila.
Commits are pushed unchanged, so author and committer metadata are preserved.
The branch lives on NovaSky-AI/SkyRL rather than a fork so CI runs.

Stack position 3/11 — based on #2232 (branch dyurk/r3-incremental-traces).
Because the base is the previous branch in the stack, the Files changed tab above shows only this PR's own changes, not the cumulative stack. Merge bottom-up.

Supersedes #2078.


Note on the diff: This PR is part of a routed-expert-replay / sampler-support series and builds on the PRs below. GitHub cannot show the intermediate branches here, so the diff is cumulative on top of main — the changes new to this PR sit on top of:

Reviewing in PR order (lowest number first) shows each incremental change cleanly.

Problem

Routed-expert replay arrives as one variable-length NumPy array per trajectory. Collating those arrays into the left-padded global training batch is dominated by allocating and first-touching a large route buffer. The previous serial path also rebuilt padding intermediates and rescanned route IDs even though the inference boundary had already selected a canonical integer dtype.

Separately, replay is unsafe with Megatron virtual pipeline parallelism. Each forward appends a microbatch to every local replay FIFO, while an interleaved model chunk consumes only its own entry during backward. The queues can therefore desynchronize without an immediate crash.

Implementation

  • Add affinity- and cgroup-aware CPU discovery for both cgroup v2 and v1.
  • Size controller-side pools from the usable CPU allocation, reserving capacity for colocated services.
  • Fill disjoint trajectory regions through a bounded thread pool; worker exceptions are propagated to the caller.
  • Allocate the final route tensor once, write canonical route arrays directly, and fill only left-padding and uncaptured suffix rows with valid dummy routes.
  • Reuse the CPU-topology helper for checkpoint delta workers, where the operation is already a barrier and does not need a core reserve.
  • Reject routed-expert replay with virtual pipeline sizes greater than one after Megatron configuration resolution, so provider defaults cannot bypass validation.

The sender remains responsible for route compaction. Collation validates the accepted uint8, int16, and int32 dtypes and promotes a mixed batch to the widest input dtype without another value scan.

Performance

The included packed-route collation benchmark compares identical outputs across serial and pooled implementations. On a 32-core host with OMP_NUM_THREADS=1, a representative 40-layer global batch improved from 29.61 s to 3.35 s (median of five runs).

The result depends on the controller running with one OpenMP thread, as it does under its one-CPU Ray allocation. With unrestricted library threads, the pool can oversubscribe the container and lose performance; the worker cap and cgroup-aware sizing make that dependency explicit.

Testing

  • CPU-topology tests cover affinity, cgroup v1/v2 quotas, unlimited and malformed limits, core reservation, and pool caps.
  • Parallel-fill tests cover serial and parallel execution, disjoint ownership, worker limits, empty inputs, and exception propagation.
  • Preprocessing tests cover route padding, dtype preservation and promotion, non-contiguous arrays, mismatched shapes, and invalid route lengths.
  • Configuration tests cover both explicit and provider-resolved virtual pipeline settings.

Note

Medium Risk
Changes MoE rollout→train route bookkeeping and batch collation (correctness-sensitive) plus Megatron replay config guards; parallel CPU pools affect performance but not model math.

Overview
Routed-expert (R3) replay now stitches multi-turn rollouts incrementally: RoutedExpertTrace / TokenMetadataTrace accumulate per-token route rows, the gym generator sends routed_experts_prompt_starts on each LLM call, and RemoteGenerateClient owns the HTTP generate path (including routed_experts_prompt_start in sampling params). Collation no longer rescans or re-compacts routes at batch time—it validates canonical uint8/int16/int32 arrays and packs them in parallel.

Performance: New cpu_topology sizes thread pools from process affinity and cgroup CPU limits; fill_batch_rows fills disjoint trajectory slices in a bounded pool (with core reserve for Ray). Route tensors are allocated once and written directly, with torch replay_padding_row for left/right padding; the NumPy padding helper is removed. Delta checkpoint apply/publish pools use the same helper with zero reserve.

Safety: MoE routing replay is rejected when virtual pipeline parallelism is > 1 (config validation and Megatron provider resolution), because interleaved chunks can desync RouterReplay’s backward FIFO.

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

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a new CPU topology utility to dynamically determine usable CPU cores based on process affinity and cgroup quotas, which is then used to size thread pools for weight synchronization and parallel batch filling. It also refactors routed expert index collation to perform parallel batch filling and adds validation to prevent the incompatible combination of Megatron routing replay and virtual pipeline parallelism. A review comment suggests catching OSError in addition to AttributeError when calling os.sched_getaffinity to prevent crashes in restricted container environments.

Comment thread skyrl/utils/cpu_topology.py Outdated
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR should not merge until the configuration guard permits virtual_pipeline_model_parallel_size=1, matching the resolved-provider check and the stated interleaving constraint.

Findings

  1. P1 VPP Size One Rejected
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Canonical NumPy route arrays] --> B[Validate dtype, shape, and route length]
  B --> C[Select widest batch dtype]
  C --> D[Allocate final Torch route tensor once]
  D --> E[Container-aware bounded thread pool]
  E --> F1[Fill trajectory 1 and padding]
  E --> F2[Fill trajectory 2 and padding]
  E --> FN[Fill trajectory N and padding]
  F1 --> G[Global training batch]
  F2 --> G
  FN --> G
Loading

Reviews (1) · Last reviewed commit: "style: tighten route collation comments ..."

Comment thread skyrl/train/utils/utils.py Outdated
@erictang000
erictang000 added this pull request to stack #2242 September 18, 2026 22:20
@erictang000
erictang000 force-pushed the dyurk/r3-pooled-route-collation branch from 8d95e20 to f7244ae Compare September 18, 2026 22:35
@erictang000
erictang000 force-pushed the dyurk/r3-pooled-route-collation branch 2 times, most recently from 9a2be08 to a479239 Compare September 18, 2026 23:29
@erictang000 erictang000 self-assigned this Sep 18, 2026
Base automatically changed from dyurk/r3-incremental-traces to main September 19, 2026 00:08
@avigyabb
avigyabb force-pushed the dyurk/r3-pooled-route-collation branch from f1e8d5e to f9bdc69 Compare September 19, 2026 00:08

@erictang000 erictang000 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Benchmarked the collation path: the win is the single-write change, not the pool

(Updated: the original version of this review reported speedup ratios, which oversold the thread pool. Absolute per-step times below, and the pool has now been removed.)

This runs once per training step (already timed as convert_to_training_input), so absolute cost per call is the figure that matters. Benchmarked on a 192-core host; fresh buffer per rep so first-touch faults are re-incurred as in production; median of 5-7 reps; torch.equal(old, new) asserted before timing.

Absolute cost of one collation (ms)

buffer pre-PR after single-write change (serial) + pooled(8) + pooled(32, original cap)
0.40 GB 398.7 70.8 48.0 107.6
1.61 GB 1596.4 206.5 86.6 147.4
4.09 GB 2568.3 244.3 150.9 204.8
16.1 GB - 721.3 528.6 607.8
32.2 GB - 1196.0 1126.0 1135.0

Saving per step, by change

change 0.40 GB 1.61 GB 4.09 GB 16.1 GB 32.2 GB
drop per-sample compact_routed_expert_indices 18 ms 76 ms 467 ms - -
replace full-buffer prefill with torch.empty + exact-region writes 310 ms 1314 ms 1857 ms - -
thread pool, best case 25 ms 124 ms 92 ms 193 ms 70 ms

Replacing make_replay_padding_indices_np's prefill-then-overwrite with single-write region fills saves 0.3-1.9 seconds per step. That is the PR, and it is worth landing on its own.

The pool saves 25-200 ms, and the margin shrinks as buffers grow -- at 32 GB it is 70 ms out of 1196 ms, because by then the fill is memory-bandwidth bound rather than page-fault bound. So the large-buffer case that motivates the pool is where it helps least.

The pool was also mis-sized

workers 0.40 GB 1.61 GB 4.09 GB 16.1 GB 32.2 GB
1 1.00x 1.00x 1.00x 1.00x 1.00x
4 1.38x 1.94x 1.60x 1.38x 1.20x
8 1.52x 2.43x 1.61x 1.36x 1.06x
16 1.06x 1.86x 1.42x 1.30x 1.06x
32 (original cap) 0.68x 1.43x 1.19x 1.19x 1.05x
64 0.27x 0.62x 0.60x - -

At the original cap of 32 the pool was slower than a serial fill below ~1 GB (107.6 ms vs 70.8 ms) -- thread startup exceeded the fill.

What I pushed

  • perf(r3): drop the batch-fill thread pool -- removes parallel_fill.py and its tests; the fill is now a plain loop. A tenth of a second per step does not pay for two modules, a per-step ThreadPoolExecutor, and ~130 lines of tests.
  • skyrl/utils/cpu_topology.py is kept. It has a second consumer: the weight-sync apply and publish pools in weight_sync/delta/checkpoint.py, where pool_workers replaced min(32, os.cpu_count()) and correctly respects the cgroup quota. On this host that is a real difference -- cpu.max = "9140000 100000" gives a 91-CPU quota against 192 visible cores.
  • test: allow virtual_pipeline_model_parallel_size=1 under routing replay -- this test has been failing since f9bdc69. That commit relaxed the assert to vpp_size is None or vpp_size <= 1 (matching megatron_worker.py, which only raises above 1), but test_routing_replay_refuses_virtual_pipeline_parallelism[1] still asserted 1 is refused. Moved 1 to the allows-parametrize.

One unrelated failure remains, test_megatron_validation_rejects_mxfp8_with_fp8_param, which also fails on main -- not from this PR.

Note for whoever propagates this up the stack

fill_batch_rows has call sites in later PRs (#2235 adds a second, #2238 a third). Those need the same one-line conversion to a plain loop when this merges up. I stopped at this branch rather than rebasing the descendants, since the stack is being propagated with merge commits.

Remaining, non-blocking

  1. The read-only fallback sample_indices.copy(order="C") is a full extra copy per sample. It should not fire today (generator output is built in-process and writable), but it is silent if provenance changes. Writing through a numpy view of the destination avoids it -- numpy does not require a writable source, only torch.from_numpy does.
  2. The int32 warning fires per batch, i.e. every step.
  3. The VPP guard is unrelated to route collation; fine to keep given its size, but it does make the PR two things.

lila-sync-bot and others added 4 commits September 21, 2026 11:30
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Benchmarked the collation path on a 192-core host across 0.4-32 GB buffers.
Throughput peaks at 4-8 workers and degrades past that as the fill saturates
memory bandwidth and threads contend:

  workers   0.40 GB  1.61 GB  4.09 GB  16.1 GB  32.2 GB
        1     1.00x    1.00x    1.00x    1.00x    1.00x
        4     1.38x    1.94x    1.60x    1.38x    1.20x
        8     1.52x    2.43x    1.61x    1.36x    1.06x
       16     1.06x    1.86x    1.42x    1.30x    1.06x
       32     0.68x    1.43x    1.19x    1.19x    1.05x
       64     0.27x    0.62x    0.60x        -        -

At the previous cap of 32 the pool was 1.4-2.2x slower than at 8 for every
size measured, and slower than a serial fill below ~1 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@erictang000
erictang000 force-pushed the dyurk/r3-pooled-route-collation branch from 5837f33 to dd171d4 Compare September 21, 2026 18:30
erictang000 and others added 2 commits September 21, 2026 11:35
Benchmarking the collation path attributed almost all of this PR's speedup to
the single-write change, not to threading. Absolute cost of one collation
(once per training step, timed as convert_to_training_input):

  buffer    pre-PR   single-write (serial)   pooled(8)
  0.40 GB   398.7ms                 70.8ms      48.0ms
  1.61 GB  1596.4ms                206.5ms      86.6ms
  4.09 GB  2568.3ms                244.3ms     150.9ms
  16.1 GB        -                 721.3ms     528.6ms
  32.2 GB        -                1196.0ms    1126.0ms

Replacing the full-buffer prefill with torch.empty plus exact-region writes
saves 0.3-1.9s per step. The pool saves a further 25-200ms, and that margin
shrinks as buffers grow because the fill becomes memory-bandwidth bound: at
32 GB it is worth 70ms out of 1196ms.

A tenth of a second per step does not pay for two modules, a per-step
ThreadPoolExecutor, and their tests, so the pool goes and the fill is a plain
loop. skyrl/utils/cpu_topology.py stays: the weight-sync apply and publish
pools in weight_sync/delta/checkpoint.py use pool_workers, where it replaced
min(32, os.cpu_count()) and correctly respects the cgroup quota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f9bdc69 relaxed the config assert from "any VPP size is refused" to
"vpp_size is None or vpp_size <= 1", matching megatron_worker.py, which only
raises for sizes above one. The test still asserted that 1 is refused, so it
has been failing since that commit.

Move 1 from the refuses-parametrize to the allows-parametrize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ounts

The batch-fill pool was the reason this PR grew a CPU-topology helper, and
that pool is gone, so the helper goes too.

Its only other consumers were the weight-sync apply and publish pools in
weight_sync/delta/checkpoint.py, which are pre-existing pools this PR had
re-plumbed through pool_workers. Those two sites go back to their original
os.cpu_count() form, so delta/checkpoint.py is now identical to main and
drops out of this PR entirely.

Sizing those pools by cgroup quota rather than os.cpu_count() is a real
improvement -- this host reports 192 cores against a 91-CPU quota -- but it
is a separate concern from routed-expert collation and belongs in its own
change if it is wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@erictang000
erictang000 merged commit 13432cd into main Sep 21, 2026
7 checks passed
avigyabb pushed a commit that referenced this pull request Sep 21, 2026
…body splice (#2234)

> [!NOTE]
> **Standalone-reviewable reopen of #2079** — original work by
@dyurk-lila.
> Commits are pushed unchanged, so author and committer metadata are
preserved.
> The branch lives on `NovaSky-AI/SkyRL` rather than a fork so CI runs.

**Stack position 4/11** — based on #2233 (branch
`dyurk/r3-pooled-route-collation`).
Because the base is the previous branch in the stack, the *Files
changed* tab above shows **only this PR's own changes**, not the
cumulative stack. Merge bottom-up.

Supersedes #2079.

---

> **Note on the diff:** This PR is part of a routed-expert-replay /
sampler-support series and builds on the PRs below. GitHub cannot show
the intermediate branches here, so the diff is cumulative on top of main
— the changes *new to this PR* sit on top of:
>
> - #1911 — refactor extract RemoteInferenceGenerator
> - #1912 — refactor assemble routed-expert traces incrementally
> - #2078 — perf(r3) collate routes through a container-aware pool
>
> Reviewing in PR order (lowest number first) shows each incremental
change cleanly.

## Problem

The generate endpoint returns large NumPy side channels inside a JSON
response. Parsing the entire body normally first materializes each
base64 blob as a Python string, adding a large allocation and copy
before decoding. The existing optimization recognized only the
routed-expert field through one hard-coded byte prefix; adding another
packed field would silently leave that second blob on the expensive
path.

## Wire contract

Packed arrays use one typed envelope:

```json
{"data": "<base64>", "shape": [12, 40, 8], "dtype": "int16"}
```

`data` is deliberately the first key. The client can then replace
registered blobs with `memoryview` objects before handing the remaining
JSON to `orjson`. Shape, dimensionality, dtype, and decoded byte count
are still validated by the field-specific decoder.

## Implementation

- Introduce shared `pack_ndarray` and `unpack_ndarray` helpers with
explicit allowed-dtype and dimensionality contracts.
- Keep routed-expert packing as a thin field-specific validator over the
generic codec.
- Register packed response fields centrally so future side channels do
not add another parser.
- Scan the response once, splice every registered blob, and restore
blobs to envelopes in document order.
- Preserve absent and `null` fields, sidecar metadata, multiple choices,
and all ordinary JSON content.
- Fail if the serialized envelope layout drifts instead of silently
materializing a large base64 string.
- Use packed parsing only for generate requests that can return side
channels; ordinary generation retains direct `orjson` parsing.

Transport errors and undecodable gateway responses retain the existing
retry behavior. A valid JSON response with a malformed packed contract
is treated as a deterministic protocol error and is not retried.

## Performance

For a representative 121 MiB response containing 90 MiB of routes,
parsing and decoding improved from **268 ms to 83 ms**. Registering a
second packed field did not add another full-body scan. The optimization
is therefore tied to the response as a whole rather than to one
particular side channel.

## Testing

- Codec tests cover contiguous and non-contiguous arrays, dtype and
shape validation, truncated buffers, sidecar fields, and canonical
routed-expert dtypes.
- Body-splice tests cover one and multiple fields, multiple choices,
absent and `null` values, JSON lookalikes, reordered envelopes,
unregistered fields, and unterminated data.
- Remote-client integration tests cover successful two-field decoding,
transient-response retries, deterministic layout failures, text error
bodies, and bypassing the scanner for ordinary generation.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes the generate response parsing contract and multi-turn
routed-expert bookkeeping used for training replay; mistakes could
corrupt routes or silently mis-parse payloads, though layout drift is
explicitly detected.
> 
> **Overview**
> **Generate wire format** moves from routed-expert-only packing to
shared **`pack_ndarray` / `unpack_ndarray`** envelopes (`data` first for
scanning) and a central **`PackedField`** registry (including
**`rollout_sample_support`**). **`load_packed_body`** splices registered
base64 blobs into **`memoryview`** before **`orjson`** parses the rest,
so multiple large side channels avoid giant Python strings; layout drift
fails fast instead of silently falling back.
> 
> **Remote inference** pulls raw **`/skyrl/v1/generate`** HTTP into
**`RemoteGenerateClient`**, enables packed parsing only when returning
routed experts, and forwards **`routed_experts_prompt_starts`** as
**`routed_experts_prompt_start`** in sampling params.
> 
> **Multi-turn R3** adds **`TokenMetadataTrace`** and
**`RoutedExpertTrace`** so the gym generator records incremental expert
rows per turn and finalizes against token count and loss mask; batch
preprocessing collates routes in parallel via **`fill_batch_rows`** and
**`cpu_topology.pool_workers`** (also used for delta weight-sync thread
counts).
> 
> **Megatron routing replay** now rejects **virtual pipeline
parallelism** (config validation and worker setup) because it desyncs
replay FIFOs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7dd3ff3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: lila-sync-bot <lila-sync-bot@users.noreply.github.com>
Co-authored-by: dyurk-lila <dyurk@lila.ai>

This branch was successfully deployed

1 active deployment
Preview 2f1c7999 Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants