perf(r3): collate routes through a container-aware pool, and refuse replay under VPP - #2233
Conversation
There was a problem hiding this comment.
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.
|
8d95e20 to
f7244ae
Compare
9a2be08 to
a479239
Compare
f1e8d5e to
f9bdc69
Compare
There was a problem hiding this comment.
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-- removesparallel_fill.pyand its tests; the fill is now a plain loop. A tenth of a second per step does not pay for two modules, a per-stepThreadPoolExecutor, and ~130 lines of tests.skyrl/utils/cpu_topology.pyis kept. It has a second consumer: the weight-sync apply and publish pools inweight_sync/delta/checkpoint.py, wherepool_workersreplacedmin(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 tovpp_size is None or vpp_size <= 1(matchingmegatron_worker.py, which only raises above 1), buttest_routing_replay_refuses_virtual_pipeline_parallelism[1]still asserted 1 is refused. Moved1to 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
- 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, onlytorch.from_numpydoes. - The int32 warning fires per batch, i.e. every step.
- The VPP guard is unrelated to route collation; fine to keep given its size, but it does make the PR two things.
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>
5837f33 to
dd171d4
Compare
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>
…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>
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/SkyRLrather 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.
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
The sender remains responsible for route compaction. Collation validates the accepted
uint8,int16, andint32dtypes 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
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/TokenMetadataTraceaccumulate per-token route rows, the gym generator sendsrouted_experts_prompt_startson each LLM call, andRemoteGenerateClientowns the HTTP generate path (includingrouted_experts_prompt_startin sampling params). Collation no longer rescans or re-compacts routes at batch time—it validates canonicaluint8/int16/int32arrays and packs them in parallel.Performance: New
cpu_topologysizes thread pools from process affinity and cgroup CPU limits;fill_batch_rowsfills disjoint trajectory slices in a bounded pool (with core reserve for Ray). Route tensors are allocated once and written directly, with torchreplay_padding_rowfor 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.