fix: Port contention issues between Ray/vLLM/Gym and sandbox workers - #3103
Conversation
6aa6039 to
a42fc85
Compare
|
/ok to test a42fc85 |
a42fc85 to
dc8705e
Compare
|
/ok to test dc8705e |
dc8705e to
7a4ac77
Compare
|
/ok to test 7a4ac77 |
terrykong
left a comment
There was a problem hiding this comment.
Reviewed with a coordinated agent team (RL expert, test agent, bug finder, devil's advocate). Nice, thorough fix — the new layout is internally consistent across ray.sub, virtual_cluster.py, all example configs, and the frozen reference configs, and we verified the key premises (sandbox NGINX_PORT/SANDBOX_WORKER_BASE_PORT are consumed by NeMo-Skills' start-with-nginx.sh; the 1301-1312 odd/even head-offset math checks out; the shrunken master/generation/gym ranges can't realistically be exhausted; head+worker[0] sharing 2000-2999 is safe since worker ports are bind-probed).
Local verification: tests/unit/distributed/test_virtual_cluster.py passes (31 passed) including the updated vLLM port parametrizations, test_config_v2.py::test_reference_configs_up_to_date passes (7 passed), and all pre-commit hooks pass.
Findings are inline (2 doc syncs, 1 naming convention, plus optional nits). One soft ask: it would be helpful to add a line to the PR description noting what job validated the fix end-to-end (e.g. N-node run on the affected GB200 cluster with no EADDRINUSE) so future readers know the layout was battle-tested.
Generated by Claude Code
7a4ac77 to
7801856
Compare
7801856 to
1d66c6a
Compare
|
/ok to test 1d66c6a |
…workers (cherry picked from commit 5816e9131753720b50c3892b5cf4f74c5c74c5c2) Signed-off-by: Saurabh Mishra <sauramishra@nvidia.com> [NemoRL] Move Ray management ports as well below the ephemeral port range (cherry picked from commit d993e7b5be8a6d196ce6d57b118ebcdd30cef613) Signed-off-by: Saurabh Mishra <sauramishra@nvidia.com> chore: propagate below-ephemeral port ranges to remaining configs and tests The port-contention fix moved the default port bands below the 9000 ephemeral floor (generation 3000-4999, gym 5000-5999, vLLM 7000+, master 1400-1999). Several example configs still hardcoded the old ranges, which would override the new defaults with colliding values. This propagates the new layout to those configs, mirrors it into the frozen reference configs (tests/unit/reference_configs), refreshes stale port-range comments, and updates the vLLM port-assignment test to the new DEFAULT_VLLM_PORT_RANGE_LOW. Signed-off-by: Saurabh Mishra <sauramishra@nvidia.com> fix: move SGLang engine ports below the ephemeral floor SGLang allocated its engine ports (server / nccl / dist_init) starting at 15000, above the 9000 ephemeral floor, and the free-port helpers defaulted to 10000 — both prone to the same TOCTOU collisions the port layout is meant to avoid. SGLang and vLLM are mutually exclusive backends, so SGLang now shares the below-floor engine rendezvous band (7000-8999). Refreshes the stale port-range comment and notes SGLang in the central port-layout maps (ray.sub, virtual_cluster.py). The router HTTP port already allocated from 3000-4000 (generation band) and is unchanged. Signed-off-by: Saurabh Mishra <sauramishra@nvidia.com>
1d66c6a to
f673c74
Compare
|
/ok to test f673c74 |
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>
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 search from 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).
The offset must run *before* the local_dp_rank test, not inside it.
_select_tcpstore_port's disjoint-window branch reads as though it only serves
co-located DP engines, but ParallelConfig.__post_init__ takes its offline-SPMD
path for every engine NeMo-RL builds and assigns data_parallel_rank_local =
VLLM_DP_RANK_LOCAL (0 by default) and data_parallel_master_port =
VLLM_DP_MASTER_PORT (0 by default). A plain non-DP engine therefore arrives
with local_dp_rank=0, not None: the None branch is dead code, and the DP branch
searches from 0 + 100 + 0*32 = 100, fails all 32 attempts on the privileged
range, and falls through to get_open_port() -- straight back to VLLM_PORT, the
exact port the MessageQueue holds. An earlier revision of this commit put the
offset inside the None branch and was silently inert on hardware. See RL-1104.
Deliberately NOT fixed by leaving VLLM_PORT unset: 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.
tests/unit/models/generation/test_vllm_tcpstore_port.py pins the arithmetic
using the values vLLM actually passes (local_dp_rank=0, master_port=0) and the
real patch applied to a copy of the installed executor, so a fix on a dead
branch fails the suite instead of passing review. It also pins the anchor
snippet so an upstream rename cannot silently turn the patch into a no-op.
The patch additionally re-reads the file and warns if the marker is absent.
Signed-off-by: Terry Kong <terryk@nvidia.com>
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 search from 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).
The offset must run *before* the local_dp_rank test, not inside it.
_select_tcpstore_port's disjoint-window branch reads as though it only serves
co-located DP engines, but ParallelConfig.__post_init__ takes its offline-SPMD
path for every engine NeMo-RL builds and assigns data_parallel_rank_local =
VLLM_DP_RANK_LOCAL (0 by default) and data_parallel_master_port =
VLLM_DP_MASTER_PORT (0 by default). A plain non-DP engine therefore arrives
with local_dp_rank=0, not None: the None branch is dead code, and the DP branch
searches from 0 + 100 + 0*32 = 100, fails all 32 attempts on the privileged
range, and falls through to get_open_port() -- straight back to VLLM_PORT, the
exact port the MessageQueue holds. An earlier revision of this commit put the
offset inside the None branch and was silently inert on hardware. See RL-1104.
Deliberately NOT fixed by leaving VLLM_PORT unset: 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.
tests/unit/models/generation/test_vllm_tcpstore_port.py pins the arithmetic
using the values vLLM actually passes (local_dp_rank=0, master_port=0) and the
real patch applied to a copy of the installed executor, so a fix on a dead
branch fails the suite instead of passing review. It also pins the anchor
snippet so an upstream rename cannot silently turn the patch into a no-op.
The patch additionally re-reads the file and warns if the marker is absent.
Signed-off-by: Terry Kong <terryk@nvidia.com>
vLLM 0.25's MessageQueue picks the port for its remote (TCP) socket with
get_open_port(), which binds a probe socket, releases it, and returns the
number; ZMQ binds it for real several statements later. That window is a
TOCTOU race, and on a non-driver node it is lost reliably rather than
occasionally.
Every RayWorkerProc on a non-driver node takes n_local_reader=0
(ray_executor_v2.py::_init_message_queues), so every one of them needs a
real TCP port and they all scan from the same VLLM_PORT -- 7000 for a
node-spanning engine. _init_message_queues runs immediately after
init_device(), whose process-group setup is a collective barrier, so all
workers on the node reach the probe within microseconds of each other,
all see the same port free, and all but one die with:
zmq.error.ZMQError: Address already in use (addr='tcp://10.65.1.9:7000')
Workers on the driver node take n_local_reader=1 and use an ipc:// socket,
which is why only engines spanning >= 2 nodes are affected -- and why no
nightly test catches it, since none runs an engine whose
tensor_parallel_size * pipeline_parallel_size exceeds cluster.gpus_per_node.
Fix the race at the bind rather than the probe: retry, advancing past the
port that was lost. This terminates because a port a peer already holds
with ZMQ is visible to the next probe. Ports stay anchored at VLLM_PORT
rather than falling back to kernel-ephemeral ones, which is the contention
the reserved sub-ephemeral band exists to prevent (#2380, #3103). Patching
the bind rather than handing each worker a private start port also covers
every other MessageQueue with a remote reader, including the executor's own
rpc_broadcast_mq.
The code is byte-identical on vLLM main; this is an upstream bug, so the
same change is worth proposing there.
Verified against the real vLLM 0.25.1 MessageQueue with concurrent
processes: unpatched, 6/8 and 30/32 workers die (3/3 runs each); patched,
8/8 and 32/32 bind distinct ports inside the reserved band.
Signed-off-by: Terry Kong <terryk@nvidia.com>
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 search from 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).
The offset must run *before* the local_dp_rank test, not inside it.
_select_tcpstore_port's disjoint-window branch reads as though it only serves
co-located DP engines, but ParallelConfig.__post_init__ takes its offline-SPMD
path for every engine NeMo-RL builds and assigns data_parallel_rank_local =
VLLM_DP_RANK_LOCAL (0 by default) and data_parallel_master_port =
VLLM_DP_MASTER_PORT (0 by default). A plain non-DP engine therefore arrives
with local_dp_rank=0, not None: the None branch is dead code, and the DP branch
searches from 0 + 100 + 0*32 = 100, fails all 32 attempts on the privileged
range, and falls through to get_open_port() -- straight back to VLLM_PORT, the
exact port the MessageQueue holds. An earlier revision of this commit put the
offset inside the None branch and was silently inert on hardware. See RL-1104.
Deliberately NOT fixed by leaving VLLM_PORT unset: 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.
tests/unit/models/generation/test_vllm_tcpstore_port.py pins the arithmetic
using the values vLLM actually passes (local_dp_rank=0, master_port=0) and the
real patch applied to a copy of the installed executor, so a fix on a dead
branch fails the suite instead of passing review. It also pins the anchor
snippet so an upstream rename cannot silently turn the patch into a no-op.
The patch additionally re-reads the file and warns if the marker is absent.
Signed-off-by: Terry Kong <terryk@nvidia.com>
vLLM 0.25's MessageQueue picks the port for its remote (TCP) socket with
get_open_port(), which binds a probe socket, releases it, and returns the
number; ZMQ binds it for real several statements later. That window is a
TOCTOU race, and on a non-driver node it is lost reliably rather than
occasionally.
Every RayWorkerProc on a non-driver node takes n_local_reader=0
(ray_executor_v2.py::_init_message_queues), so every one of them needs a
real TCP port and they all scan from the same VLLM_PORT -- 7000 for a
node-spanning engine. _init_message_queues runs immediately after
init_device(), whose process-group setup is a collective barrier, so all
workers on the node reach the probe within microseconds of each other,
all see the same port free, and all but one die with:
zmq.error.ZMQError: Address already in use (addr='tcp://10.65.1.9:7000')
Workers on the driver node take n_local_reader=1 and use an ipc:// socket,
which is why only engines spanning >= 2 nodes are affected -- and why no
nightly test catches it, since none runs an engine whose
tensor_parallel_size * pipeline_parallel_size exceeds cluster.gpus_per_node.
Fix the race at the bind rather than the probe: retry, advancing past the
port that was lost. This terminates because a port a peer already holds
with ZMQ is visible to the next probe. Ports stay anchored at VLLM_PORT
rather than falling back to kernel-ephemeral ones, which is the contention
the reserved sub-ephemeral band exists to prevent (#2380, #3103). Patching
the bind rather than handing each worker a private start port also covers
every other MessageQueue with a remote reader, including the executor's own
rpc_broadcast_mq.
The code is byte-identical on vLLM main; this is an upstream bug, so the
same change is worth proposing there.
Verified against the real vLLM 0.25.1 MessageQueue with concurrent
processes: unpatched, 6/8 and 30/32 workers die (3/3 runs each); patched,
8/8 and 32/32 bind distinct ports inside the reserved band.
Signed-off-by: Terry Kong <terryk@nvidia.com>
What does this PR do ?
Eliminates port contention between all NeMo RL services (Ray, vLLM, SGLang, Gym, Sandbox) and the kernel's ephemeral port range by pinning every service port below the observed ephemeral floor.
Fix
Pin all service ports below 9000. Final layout (documented in
ray.subandnemo_rl/distributed/virtual_cluster.py):MIN_WORKER_PORT/MAX_WORKER_PORTinray.sub), and--min-worker-port/--max-worker-portadded toray start --headso the head node is constrained to the same range as workers.DEFAULT_MASTER_PORT_RANGE_*invirtual_cluster.py).port_range_low/highin the example configs andDEFAULT_GENERATION_PORT_RANGE_*invirtual_cluster.py).DEFAULT_GYM_PORT_RANGE_*, referenced fromnemo_gym.py).SANDBOX_WORKER_BASE_PORT=6001andNGINX_PORT=6000into the sandbox srun (the container'sstart-with-nginx.shalready supports these env vars — no image change needed).DEFAULT_VLLM_PORT_RANGE_LOWinvirtual_cluster.py, consumed byvllm_worker.py).tests/unit/reference_configs/*copies in sync, and refreshed stale port-range comments.Issues
None
Usage
None