feat(gym): route rollouts through an externally managed vLLM router - #3663
Open
aoshen02 wants to merge 2 commits into
Open
feat(gym): route rollouts through an externally managed vLLM router#3663aoshen02 wants to merge 2 commits into
aoshen02 wants to merge 2 commits into
Conversation
When env.nemo_gym.router_url is set, register each vLLM replica with the router (POST /workers blocks until the router's own health probe of the replica succeeds, so a 200 means registered and routable) and point Gym's policy_base_url at the router instead of the per-replica URL list. Unset router_url keeps the current direct-to-replica behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: aoshen02 <aoshen@inferact.ai>
GET /health lets an external router health-probe a replica while registering it as a dynamic backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: aoshen02 <aoshen@inferact.ai>
aoshen02
force-pushed
the
feat/external-vllm-router
branch
from
August 16, 2026 07:56
b62731b to
f4a0408
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
env.nemo_gym.router_urlis set, register each vLLM replica with an externally managed vLLM Router (POST /workersblocks until the router's own health probe succeeds, so a 200 means registered and routable) and point Gym'spolicy_base_urlat the router. Unset keeps today's direct-to-replica behavior.GET /healthto the async worker's HTTP server, which the router uses to health-probe replicas at registration.Complements #3518 (managed-router lifecycle owned by NeMo RL) with the external-router mode: the router can be started and owned outside the trainer, and NeMo RL only registers its replicas.
Workload
Deterministic replay throughout: recorded coding-agent trajectories replayed turn by turn, so every arm sees a byte-identical request sequence. Context accumulates naturally through the multi-turn loop — turn t re-sends everything from turns 1..t-1, which is what makes session→replica affinity worth anything at all. NVIDIA-Nemotron-3-Super-120B-A12B-BF16, TP2 vLLM engines, greedy decoding with
ignore_eos.Two datasets, drawn from the same trace pool and differing only in how the tail is weighted:
p30_alllongtaillongtailkeeps the same longest session (identicalreplay_turns) and thins the middle: 540 short sessions (8-20 turns) carrying 44% of the work, plus 60 long ones (27-95 turns) carrying 56%. It is the harder workload for a router, not the bigger one — total work is lower, concentration is 3x higher.Experiment results
Zero preemptions and zero HTTP 500s in every run below.
16 nodes / 28 engines,
p30_all,max_concurrent_rollouts: 112Code under test: this PR's commits on NeMo-RL upstream
daf46ff3(2026-08-06), with NVIDIA-NeMo/Gym#2570's commits on Gym upstream473f446f(2026-07-24). 16 GB200 nodes = 2 training + 14 generation, 2 engines/node.max_num_batched_tokens8480,gpu_memory_utilization0.9.8 nodes / 12 engines,
p30_all,max_concurrent_rollouts: 0(uncapped)8 GB200 nodes = 2 training + 6 generation, 2 engines/node; all other engine settings unchanged.
Workload counters are identical across the three (1,570,738,525 prompt tokens, 0 preemptions).
One asymmetry worth stating plainly: the two routed arms ran on Gym base
473f446f, whosevllm_modelpicks a replica per session with a per-uvicorn-process counter. Thenonearm ran on that base plus Gym's stable assignment,sha256(session_id) % len(clients), which is what current Gymmaindoes. Runningnoneon the counter instead does not reproduce this row. Withrouter_urlset the routed arms hold a single client (policy_base_urlis the router alone), so that picker is inert for them and the comparison stands.8 nodes / 12 engines,
longtail,max_concurrent_rollouts: 0(uncapped)Same topology and engine settings as the row above; the dataset is the only change. All three arms on the same eight nodes, chained through
--dependencyso no two share hardware in time.Identical request counts across all four (10,320 each).
Run-to-run noise floor, from the repeat: 1.8% on wall clock, 7.8% on time-to-90%.
cache_aware's 11.7% is six times the wall-clock floor, so it is a result rather than ahypothesis. The repeat also settles a question we could not answer before: 09:31's final
session finished in 4s against
none's 67s, which looked like placement luck, but therepeat lands at 09:41 — the tail is short because everything before it finished sooner,
not because of where one session landed.
cache_aware's advantage reproduces: 11.7% overnonehere, 12.0% onp30_all— across a 3x change in tail concentration.Its mechanism does not reproduce, and the earlier explanation was dataset-specific. On
p30_all,cache_awarewas the lowest hit rate of the three (91.6%) and we attributed the win to first-turn placement. Onlongtailit is the highest (90.5% vs 88.2 / 88.5%) and also the most evenly loaded (CV 8.6% vs 16.4 / 13.1%). With context per session growing faster, returning a session to the replica that already holds its prefix starts paying in hit rate directly. Same 12%, two different sources.Wall clock is the wrong headline metric on this workload, and the completion curve says why. Time to reach N sessions done, relative to
none:consistent_hashis behindnonefor the first 599 sessions and passes it only on the last one: the final session took 67s undernoneand 4s under both routed arms. That is not a policy effect, it is where the single 25x-outlier session happened to land once the fleet had drained.cache_aware's lead, by contrast, is established by session 100 and holds through 90%, and the repeat run reproduces the wall clock to within 1.8% — so unlikeconsistent_hash's, it is not a last-session effect.Read both columns. Time to 90% separates the policies on mechanism (218s vs 263s vs 263s, and it does not hinge on one session's placement), but the repeat shows it is the noisier of the two here — 7.8% run-to-run against wall clock's 1.8%. We had assumed the opposite from first principles and were wrong; noise character is a property of the configuration, not of the metric, so it has to be measured per campaign rather than argued.
What the tail costs, and what routing cannot do about it
Measured on the
longtailruns, from the per-engine/metricstimeline sampled every 30s:The last 10% of sessions take ~60% of the wall clock, and this is identical across all three policies —
cache_awarehas the highest tail share (61.8%) precisely because it shortened everything else.The reason is not that tail sessions are slow but that there is nothing left to parallelise. Concurrency measured on the same runs falls from ~530 in-flight requests at peak to 43 at t=451s and 2 at t=785s, while a single session is capped at ~0.45 turns/s by serial decoding. Saturating these 12 engines needs 150-220 concurrent requests; the tail runs one to two orders of magnitude below that. No routing policy changes this — it is a question of what the idle capacity is reconfigured to do, which is #3520's direction, not this PR's.
Caveats
cache_awareonly;noneandconsistent_hashare assumed to sit in the same range rather than measured.p30_all12-enginecache_awarerow is likewise a single run.longtailjobs exit non-zero: the harness interrupts after the rollout, and the 120B training step starts before the interrupt lands and OOMs. Ordering is rollout complete → metrics snapshot written → OOM, so measurement is unaffected. The publishedp30_allruns exit the same way.Full report, acceptance scripts, and trimmed evidence runs: https://github.com/aoshen02/RL/blob/feat/gym-router-url/experiments/routing/README.md
Testing
python -m py_compileon touched files; no unit tests added yet — happy to add router-registration unit tests if maintainers want this direction.AI assistance was used for this work; the submitting human has reviewed every line.
🤖 Generated with Claude Code