Skip to content

fix(vllm): apply the TCPStore port offset on the branch vLLM actually takes - #3350

Closed
terrykong wants to merge 12 commits into
terryk/bump-vllm-0.25.1from
terryk/rl-1104-tcpstore-port
Closed

fix(vllm): apply the TCPStore port offset on the branch vLLM actually takes#3350
terrykong wants to merge 12 commits into
terryk/bump-vllm-0.25.1from
terryk/rl-1104-tcpstore-port

Conversation

@terrykong

Copy link
Copy Markdown
Collaborator

What does this PR do ?

Fixes RL-1104: on vLLM 0.25.1, any engine whose model-parallel size spans more than one node dies at startup with EADDRINUSE on the engine's VLLM_PORT. This targets terryk/bump-vllm-0.25.1 so the fix can be reviewed against the bump.

Why the patch on this branch is currently inert

ce28ac042 added the right offset but put it in a branch vLLM never takes:

if local_dp_rank is None:      # <- NeMo-RL's fix went in here
    return get_open_port()
# Offset past the DP master port reserved range, one window per rank.
window = 32
start_port = master_port + 100 + local_dp_rank * window
try:
    return _get_open_port(start_port=start_port, max_attempts=window)
except RuntimeError:
    return get_open_port()     # <- what actually runs

ParallelConfig.__post_init__ takes its "offline SPMD" path whenever vLLM's own DP coordinator is not in use — always, here — and assigns data_parallel_rank_local = envs.VLLM_DP_RANK_LOCAL and data_parallel_master_port = envs.VLLM_DP_MASTER_PORT. Both default to 0. So a plain non-DP engine arrives with local_dp_rank=0, not None:

  1. the None branch is dead — the patch never executes;
  2. the DP branch searches from 0 + 100 + 0*32 = port 100, fails all 32 attempts on the privileged range, and
  3. falls through to get_open_port() → straight back to VLLM_PORT.

That is the port the broadcast MessageQueue binds and holds a few lines later, so rank 0's TCPStore gets EADDRINUSE.

Instrumented on hardware:

ENTRY pid=2797290 local_dp_rank=0 master_port=0 VLLM_PORT=7000

The Port 100 is already in use, trying port 101 ... lines present in every vLLM worker log are the fingerprint of step 2.

The fix

Run the VLLM_PORT-anchored search before the local_dp_rank test, so it applies on whichever branch vLLM would otherwise take, and fall through to vLLM's own logic when VLLM_PORT is unset or the window is full. Ports stay inside the engine's reserved 100-port band and therefore below the OS ephemeral floor — dropping VLLM_PORT instead would reintroduce the TOCTOU contention that band exists to prevent (#2380, #3103).

Confirmation

Reproduced and fixed on 2 nodes x 1 GPU, generation TP=2 — the cheapest shape that puts one engine across two nodes (previously the smallest repro in the repo was 64 GPUs):

vLLM 0.25.1 state 2-node hardware (H100) CPU-only repro
pristine EADDRINUSE port 7000 in RayWorkerProc.initialize_worker() EADDRINUSE 7000
ce28ac042 (this branch today) EADDRINUSE, unchanged EADDRINUSE 7000
this PR distributed_init_method=tcp://<rank0>:7032, engine starts and generates TCPStore 7032, no collision

The hardware signature matches the DeepSeek-V3 64-GPU CI failure exactly.

Blast radius

17 recipes across performance*.txt and release*.txt have tp*pp > gpus_per_node and should be assumed broken at engine startup on 0.25 without this. Zero nightly tests do, which is why the bump's nightly parity comparison is unaffected.

Changelog

  • Move the RayExecutorV2._select_tcpstore_port offset ahead of the local_dp_rank test so it runs on the branch vLLM actually takes.
  • Add tests/unit/models/generation/test_vllm_tcpstore_port.py: a GPU-free, single-node regression test that pins the port arithmetic. It fails against ce28ac042 and passes against this PR, and guards the anchor snippet against upstream drift.

Usage

N/A — no user-facing API change.

Before your PR is "Ready for review"

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you run the unit tests and functional tests locally? (new unit test run locally: 6 passed in 6s; 2-node functional reproduction run by hand — see table above)
  • Did you add or update any necessary documentation? (patch docstring explains the dead-branch trap)

Additional Information

  • Still open (tracked in RL-1104): wiring the 2-node reproduction in as a permanent functional test. The unit test above covers the port arithmetic at zero cost, but there is still no CI recipe with tp*pp > gpus_per_node, so the node-spanning engine-startup path itself remains uncovered. The 2-node driver script used here is attached to RL-1104.
  • Attempt 2 on this branch's earlier history (1d634dfb3, "use ephemeral vLLM ports for engines that span nodes") was also a no-op: its guard was model_parallel_size > len(bundle_indices), and bundle_indices is the engine's local_bundle_indices, so len(...) == model_parallel_size and the condition is always false. That explains the pre-patch H100 CI datapoint independently.

🤖 Generated with Claude Code

terrykong added 12 commits July 25, 2026 00:46
vLLM 0.25's RayExecutorV2 picks the torch.distributed TCPStore port with a
bind-probe (Step 3) but only binds it much later, in the rank-0 worker's
init_process_group. In between, Step 4 builds the broadcast MessageQueue;
when the engine spans nodes that queue needs a real TCP socket, so it calls
get_open_port() and binds and holds the result (shm_broadcast.py:
remote_subscribe_port = get_open_port(), then remote_socket.bind(...)).
Both searches start at VLLM_PORT, so the queue takes the very port the probe
just released and startup dies with EADDRINUSE (DeepSeek-V3 generation TP=32,
observed on port 7000). Engines that fit on one node bind an ipc:// socket
instead and never allocate a TCP port here, which is why only node-spanning
engines are affected.

Fix: patch _select_tcpstore_port to start its search at VLLM_PORT + 32, past
the queue's scan range. Both ports stay inside the engine's 100-port window
and therefore below the OS ephemeral floor (as low as 9000 on some nodes).
vLLM applies the same disjoint-window idea to co-located DP engines a few
lines below, seeding them from master_port + 100 + rank * 32.

Deliberately NOT fixed by leaving VLLM_PORT unset for these engines: that
sends vLLM to _get_open_port()'s s.bind(("", 0)) fallback, i.e. kernel-assigned
ephemeral ports, which is exactly the TOCTOU contention the reserved port
layout exists to prevent (#2380, #3103). The port assignment in
configure_worker is therefore unchanged from main.

The patch verifies its own result by reading the file back, so a patch that
silently fails to land is visible in worker logs instead of degrading to the
collision at runtime.

Signed-off-by: Terry Kong <terryk@nvidia.com>
…reep

process_weights_after_loading runs after every refit, and rebinding each
linear layer's weight/weight_scale_inv .data to the fresh tensors returned
by process_fp8_weight_block_strategy slowly fragments device memory across
sleep/wake cycles until CuMemAllocator wake_up fails with 'CUDA Error: out
of memory at csrc/cumem_allocator.cpp' (~75-87 steps into both nightly
fp8-rollouts variants; vllm 0.20 copied the scale in place). Copy into the
existing storages when the processed layout is stable and only rebind on
the first call when shapes change.

Signed-off-by: Terry Kong <terryk@nvidia.com>
…ke-up

On vLLM 0.25 the colocated fp8 recipes (fp8-rollouts.v3 incl. tq_simple,
moonlight fp8-e2e) die when the CuMemAllocator fails to re-map its sleep
pool at wake-up ("CUDA Error: out of memory at csrc/cumem_allocator.cpp"),
while 0.20 passes. Measured on moonlight at identical
gpu_memory_utilization=0.5: 0.20 sizes the KV cache to 19.83 GiB (sleep
pool ~36 GiB) while 0.25 sizes it to 34.29 GiB (sleep pool 49.96 GiB) —
the pool now exceeds what can be re-mapped next to the colocated Megatron
policy's residual footprint.

Utilization tuning cannot express the old footprint (0.5 still OOM'ed on
fp8-rollouts, and cudagraph capture measured only 0.45 GiB, ruling out the
FULL_AND_PIECEWISE default as the consumer), so pin
vllm_kwargs.kv_cache_memory_bytes to the 0.20-proven 20 GiB on both
recipes; it takes precedence over gpu_memory_utilization.

Signed-off-by: Terry Kong <terryk@nvidia.com>
All observed metrics are healthy on-policy values; the old bounds were tuned
tightly around vLLM 0.20 behavior:
- eval_async score ceiling 0.14 -> 0.2
- ppo_automodel critic grad-norm ceiling 350 -> 1500

(The w4a8 fakequant token_mult_prob_error bound this branch previously
relaxed to 1.08 was superseded by main relaxing it to 1.15.)

Signed-off-by: Terry Kong <terryk@nvidia.com>
vLLM 0.25 turned FusedMoE into a factory returning a MoERunner whose expert
weights live on a nested RoutedExperts submodule:
- expert parameter names gain a '.routed_experts.' segment in
  named_parameters(); parse_hf_expert_weight now targets the qualified name
  (both the vLLM-side layout advertisement and the policy-side sender lookup
  key by the same function, so the mapping stays consistent)
- the weight_loader owner is now the RoutedExperts module: the unquantized
  backend check reads owner.quant_method.unquantized_backend (the
  base_quant_method attribute no longer exists and the old getattr default
  made the guard reject valid Triton setups), and tp_rank/tp_size/
  num_logical_experts/enable_eplb moved onto owner.moe_config (the old
  getattr defaults silently produced rank-0/size-1 sharding for TP>1)

Unit tests updated for the qualified names; the storage-parity test now
instantiates RoutedExperts (which owns _load_w13/_load_w2 in 0.25) instead
of the removed FusedMoE class.

Signed-off-by: Terry Kong <terryk@nvidia.com>
- FusedMoeWeightScaleSupported moved out of fused_moe.layer in vLLM 0.25;
  import it from the fused_moe package re-export (both mxfp8 MoE helpers
  raised ImportError otherwise)
- accept both FlashInferCutedslMxfp8LinearKernel and
  FlashInferCutlassMxfp8LinearKernel in the mxfp8 linear refit guard: 0.25
  prefers the Cutedsl kernel and both process weight scales with the same
  swizzle_mxfp8_scale layout this refit replicates

Signed-off-by: Terry Kong <terryk@nvidia.com>
- vLLM 0.25's ModelOptNvFp4Config.__init__ installs LinearMethodCls as an
  instance attribute keyed off the quant algo, shadowing the NeMo subclass
  class attribute: the W4A16 config silently instantiated the native W4A4
  linear method, whose process_weights_after_loading reads an input_scale a
  W4A16 checkpoint never loads. from_config now rebinds the instance
  attribute to the NeMo Marlin weight-only method.
- W4A16_NVFP4 is a natively understood algo in 0.25, so the from_config
  normalization to NVFP4 is gone (validate-only); the base FusedMoE __init__
  keys weight-only mode off the algo (activation_key=None), replacing the
  0.20-era duplicated __init__ in NemoModelOptW4A16FusedMoE.
- vLLM 0.25's prepare_nvfp4_moe_layer_for_marlin pads rank-local
  intermediate tiles itself and asserts on unpadded checkpoint shapes, so
  the NeMo-side Marlin pre-padding (_pad_nvfp4_moe_for_marlin) would
  double-pad and trip that assertion; it is removed. Only the E4M3 sign-bit
  canonicalization of ModelOpt scale exports remains NeMo's concern.

Fake-vllm test harness updated to model the 0.25 instance-attribute and
use_a16 behavior so the shadowing bug is covered by a regression test.

Signed-off-by: Terry Kong <terryk@nvidia.com>
- the real-quant test scripts assert on the engine log's quantization= tag,
  which on vLLM 0.25 prints the registered NeMo config name
  (e.g. nemo_modelopt_w4a16_nvfp4) instead of modelopt; accept both (the
  w4a16-real GB200 run trained and produced healthy metrics but failed only
  this grep)
- log the full traceback when an IPC weight batch load fails: the refit
  manifest only records the exception message, which for bare assertions
  (e.g. 'AssertionError: ') leaves nothing to diagnose in CI logs

Signed-off-by: Terry Kong <terryk@nvidia.com>
Gated models (e.g. Qwen3-MoE) route batched 3-D expert tensors through
vLLM 0.25's RoutedExperts.load_weights fused branch, whose orientation
heuristic compares the last dim against the unpacked hidden size and
mis-transposes packed NVFP4 weights (K/2 uint8) and block scales (K/16),
tripping the layerwise-reload numel assert in RoutedExperts._load_w13.
Emit per-expert 2-D shards instead, which take the same proven
weight_loader path as the initial disk load. Non-gated models
(NemotronH) load through the model's own expert loop, which uses the
heuristic-free 3-D full_load path, and keep the batched layout.

Signed-off-by: Terry Kong <terryk@nvidia.com>
vLLM 0.25.1 resolves the Qwen3.5 hang from vllm-project/vllm#36237 for the
AutoModel EP=16 recipes. Both were validated passing post-rebase on the
H100 nightly, so move them out of disabled.txt.

The Megatron geo3k variant stays disabled: it still hangs in sample_tokens,
and so does the already-enabled grpo-qwen3.5-35ba3b-2n8g-megatron-ep16tp2cp2
run on plain main, so that is the pre-existing Qwen3.5 + Megatron + EP hang
rather than anything the bump regressed. The 397B recipe also stays disabled
(separate upstream bug, plus the model is missing from CI's offline cache).

Re-enabling costs 2 x 64 GPU-hours, taking the nightly suite from 3409 to
3537, so raise the guardrail from 3420 to 3550 to keep the same small
headroom the previous cap had.

Signed-off-by: Terry Kong <terryk@nvidia.com>
The recipe was disabled for vllm-project/vllm#37856, but the observed CI
failure is unrelated to that bug and to vLLM entirely: the run dies at
model load with

  OSError: We couldn't connect to 'https://huggingface.co' to load the
  files, and couldn't find them in the cached files.

CI runs offline, so a model only resolves if it is already in the shared
HF_HOME. Qwen/Qwen3.5-397B-A17B was never seeded there (the 35B and 9B
Qwen3.5 models are), so the test could not have passed on any vLLM
version. The model has since been seeded into the CI cache.

Put it in release.txt rather than nightly.txt: one run costs 1024
GPU-hours (32 nodes x 8 GPUs x 4h), which would grow the nightly suite by
29% on its own.

Signed-off-by: Terry Kong <terryk@nvidia.com>
… takes

The TCPStore port patch added in "fix: keep the vllm 0.25 TCPStore port out of
the MessageQueue scan range" never ran. It was placed inside RayExecutorV2
._select_tcpstore_port's `local_dp_rank is None` branch, and that branch is dead
for every engine NeMo-RL builds.

ParallelConfig.__post_init__ takes its "offline SPMD" path whenever vLLM's own
data-parallel coordinator is not in use -- which is always, here -- and assigns
data_parallel_rank_local = envs.VLLM_DP_RANK_LOCAL and data_parallel_master_port
= envs.VLLM_DP_MASTER_PORT. Both default to 0. So a plain non-DP engine reaches
_select_tcpstore_port with local_dp_rank=0, not None, and takes the DP branch:

    start_port = master_port + 100 + local_dp_rank * 32   # = 100

which searches ports 100..131, fails all 32 attempts on the privileged range
(the "Port 100 is already in use, trying port 101" lines in every worker log),
and falls through to `except RuntimeError: return get_open_port()` -- straight
back to VLLM_PORT. That is exactly the port the broadcast MessageQueue binds and
holds a few lines later, so the rank-0 worker dies with EADDRINUSE on VLLM_PORT
(7000) for any engine that spans nodes.

Fix: run the VLLM_PORT-anchored search before the local_dp_rank test, so it
applies whichever branch vLLM would otherwise take, and fall through to vLLM's
own logic when VLLM_PORT is unset or the window is full. Ports stay inside the
engine's reserved 100-port band and therefore below the OS ephemeral floor; the
deliberately-rejected alternative of dropping VLLM_PORT would reintroduce the
TOCTOU contention that band exists to prevent (#2380, #3103).

Confirmed on 2 nodes x 1 GPU with generation TP=2 (the cheapest shape that puts
one engine across two nodes):

  * pristine vLLM 0.25.1  -> EADDRINUSE, port 7000, in RayWorkerProc
                             .initialize_worker(), matching the DeepSeek-V3
                             64-GPU CI signature exactly
  * previous patch        -> EADDRINUSE, unchanged (dead branch)
  * this patch            -> distributed_init_method=tcp://<rank0>:7032,
                             engine starts and generates

Adds tests/unit/models/generation/test_vllm_tcpstore_port.py, which pins the
port arithmetic with no GPU and no second node -- the failure class previously
had zero nightly coverage because no nightly test has a node-spanning engine.
The test fails against the previous patch and passes against this one, and also
guards the anchor snippet against upstream drift.

Refs RL-1104.

Signed-off-by: Terry Kong <terryk@nvidia.com>
@terrykong
terrykong requested review from a team as code owners July 26, 2026 08:57
@copy-pr-bot

copy-pr-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@terrykong

Copy link
Copy Markdown
Collaborator Author

Closing as superseded — the fix is already on terryk/bump-vllm-0.25.1 and so is part of #3280.

Its content was applied to the branch directly rather than by merging this PR, so GitHub never auto-closed it (unlike #3355, which did). The branch carries the start_port=envs.VLLM_PORT + 32 offset plus its regression suite, now rebased onto main.

Two follow-ups landed on top of what was reviewed here, both worth knowing about:

  • The pristine_source fixture in test_vllm_tcpstore_port.py was copying an already-patched vLLM. _apply_vllm_patches rewrites site-packages in place, so once any earlier test in the vLLM lane builds a generation worker, the "unpatched" fixture is patched. It now reverses the patch, with the snippets read from patches.py via ast so they cannot drift.
  • test_unpatched_vllm_hands_the_tcpstore_the_messagequeue_port asserted the unpatched port equals VLLM_PORT, which only holds where privileged ports cannot be bound. CI runs as root, where unpatched vLLM instead returns master_port + 100 + local_dp_rank * 32 = 100. Both outcomes are broken, so the test now asserts the invariant the patch restores — a port inside the engine's reserved band that the MessageQueue will not also take — rather than either environment's number.

Tracking issue RL-1104 stays as the record.

@terrykong terrykong closed this Jul 27, 2026
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.

1 participant