Skip to content

feat(orchestration): give services on a shared node their own GPUs - #3707

Closed
wprazuch wants to merge 14 commits into
mainfrom
wprazuch/auto-gpu-packing
Closed

wprazuch wants to merge 14 commits into
mainfrom
wprazuch/auto-gpu-packing

Conversation

@wprazuch

@wprazuch wprazuch commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Follows up on review of #3654 (#3654 (comment)): automatic GPU placement, agreed to land separately. Stacked on it: base is wprazuch/service-node-placement.

In one sentence

Services that share a node now get their own GPUs automatically, unless you say otherwise.

The problem

Every service on a node sees every GPU. Put a 1-GPU judge and a 2-GPU policy on one 4-GPU node and both start on GPU 0. Today the only fix is writing CUDA_VISIBLE_DEVICES by hand in each service's env.

What changed

When two or more services that use GPUs share a node (same node_pool, or both in a one-node job), each vLLM service gets its own CUDA_VISIBLE_DEVICES. The count is TP x PP x instances on that node. Services are handed the lowest free GPUs in the order they are declared.

The value is exported inside the step's command, not passed as env K=V srun. On a 4-GPU GB200 node, Slurm reset an env-prefix value to 0,1,2,3 (with and without pyxis). An export inside the step survived, and CUDA saw only the planned GPUs:

[gym_judge_want_0]    CVD=0   cuda sees 1: ['0008:01:00.0']
[gym_policy_want_1,2] CVD=1,2 cuda sees 2: ['0009:01:00.0', '0018:01:00.0']
[gym_ray_want_3]      CVD=3   cuda sees 1: ['0019:01:00.0']
services:
  judge:  {type: vllm, model: /judge}                           # -> CUDA_VISIBLE_DEVICES=0
  policy: {type: vllm, model: /policy, tensor_parallel_size: 2} # -> CUDA_VISIBLE_DEVICES=1,2
  ray:    {type: ray}                                           # -> CUDA_VISIBLE_DEVICES=3 (the rest)
  shared: {type: vllm, model: /small, share_gpus: true}         # opt out: sees every GPU
  • A CUDA_VISIBLE_DEVICES you set yourself wins, and its GPUs count as taken. It now actually takes effect: it is exported inside the step too, even for a single service on a node (under Slurm it was silently ignored before).
  • Overlapping manual sets, or asking for more GPUs than the node has, fail at config validation. The error lists what each service needs and suggests node_pool, another node, or share_gpus.
  • share_gpus: true opts a service out.
  • A ray service on a packed node gets the GPUs nobody took, unless it sets its own. One vLLM service next to a GPU-using ray service is packed too, so Ray does not advertise the model's GPUs.

Automatic node choice

A pool with nodes: auto is sized by Gym. The policy goes on the pool's first node, beside the driver. The other services go biggest first into the first node with room, and are never split. A service bigger than one node gets whole nodes of its own: 6 GPUs on 4-GPU nodes takes 2 nodes. More than one node splits the pool into sub-pools <pool>-0, <pool>-1, ..., placed like any pool. A ray service spanning the pool spans all of them and gets each node's leftovers.

compute:
  cluster:
    node_pools:
      gpu: {partition: batch, nodes: auto, gpus_per_node: 4}
services:
  judge:  {type: vllm, model: /judge}                           # 1 GPU
  policy: {type: vllm, model: /policy, tensor_parallel_size: 2} # 2 GPUs
# -> one node: judge on GPU 0, policy on GPUs 1,2

Health checks reach a service on the node it runs on. That fix now comes from #3654. This PR only extends its coverage to auto-sized pools: a service nodes: auto moves to a later node is probed there.

What stays the same

A node with a single GPU service renders nothing new, and explicit node counts are unchanged. A ray service with num_gpus: 0 does not count as a GPU service. A manual value that is not an index list (e.g. runtime:CUDA_VISIBLE_DEVICES) is kept and treated as sharing.

Out of scope

  • Multi-node services that Ray places (one instance spanning nodes, or Ray Serve across nodes). A pool holding one is left as today.
  • An unpinned vLLM service in a multi-node job. It spans every node, so it is left as today.
  • Moving services between explicitly sized pools. nodes: auto packs only within its own pool.
  • With nodes: auto, a Ray-placed service bigger than a node can leave GPUs unused on its last node.

Testing

  • New unit tests in tests/unit_tests/test_slurm_script.py cover: disjoint sets, a lone service renders nothing, manual wins and reserves, overlap and over-subscription errors, opt-out, Ray leftovers, per-pool packing, multi-node data parallel, the in-step export, and nodes: auto (1 GPU + 2 GPUs packs to one node and renders exactly like nodes: 1, 6 + 4 GPUs on 4-GPU nodes, the policy on node 0, explicit counts unchanged, Ray leftovers, manual values).
  • All orchestration tests pass (399). ruff check and ruff format --check are clean.
  • Mutation checks: breaking disjointness, the two-service trigger, first-fit sharing, policy-first order, whole-node sizing, or the manual-overlap check each fails at least one test.
  • Cluster: the export shape was run on oci-hsg (output above).
  • End to end on oci-hsg (4-GPU GB200 nodes, gym eval submit, AIME25 with a vLLM policy and a separate vLLM judge on one node):
    • nodes: 1: policy got CUDA_VISIBLE_DEVICES=0,1, judge got 2. nvidia-smi showed the policy's two workers on GPUs 0 and 1, the judge on GPU 2, and GPU 3 empty. 256/256 rollouts, 0 failures. The policy served 256 requests and the judge 70. No OOM.
    • nodes: auto, same services: resolved to 1 node and rendered the same script, with the same GPU split. 256/256 rollouts, the judge served 64 requests.
    • nodes: auto with 6 GPUs of need: resolved to 2 nodes. Policy (0,1) and judge (2,3) on node 0, the third model alone on node 1. It passed its health check at ${gym_nodes[1]} and answered a request there.
  • A WMT24++ COMET config on this branch renders byte-identical to the base branch, and ran clean (8/8 rollouts, COMET and spBLEU in the aggregate metrics).

A submit config could already declare several services, but every one of them
rendered as a bare `srun --overlap`, so they all landed on the same node and
competed for its GPUs. A scorer or judge that cannot share a GPU with the
policy had nowhere to go.

A service may now name a `node_pool`. Pools take contiguous slices of the
allocation in declaration order, and a pinned service is launched with
`srun --relative=<offset>`, which starts the step at that slice.

Sizing follows placement: a service pinned to a one-node pool inside a
ten-node job is a single-node deployment, so it is no longer built as a
multi-node Ray one, and its GPU footprint is checked against its own pool
instead of the allocation total.

This also fixes multiple pools being silently useless. The directives were
emitted per pool, so each --nodes/--partition overwrote the last and only the
final pool survived, while the executor sized itself on the sum. There is now
one directive block for the allocation, and pools that disagree on a
whole-allocation directive are refused instead of resolved by ordering.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
`_build_ray_command` returned the fixed string "ray start --head". That starts
a cluster nobody can join, on whichever node srun picked, and returns
immediately -- ray daemonises, so the srun step exited and Slurm tore the
service down again.

A ray service now takes a mode. A head starts a cluster on a chosen port; a
worker joins one at `address`. Either may advertise custom Ray resources
(`{"extra_gpu": 4}`), which is how a benchmark asks for a node whose GPUs it
places actors on itself rather than letting Ray count them. --block keeps the
step alive for as long as the node should be up.

With node_pool from the previous commit, this is enough to put such a worker
on a node of its own.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A worker could only join a fixed host:port, but the head's host is not known
until Slurm places the job. A worker may now name a ray head service in the
same config (`head: ray_head`); the job script exports that head's address as
GYM_RAY_ADDRESS_<SERVICE>, resolved from its pool's first node, and the worker
joins through it. The driver can read the same variable to connect.

Also adds `extra_args` to ray services for flags the schema does not model,
such as fixed ports that avoid another Ray instance on the same node.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
--relative is only where Slurm starts looking. On a real two-node job a ray
head with --relative=0 landed on node 1, next to the worker, because node 0's
resources were held by the policy step; it then tried to reach node 0's IP and
never came up.

The job script now exports each pool's hosts (GYM_POOL_<NAME>_NODES) from the
allocation's node list, and a pinned service passes them to --nodelist. The
ray head address reuses the same host list.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
With services pinned to pools, the driver's srun had no node constraint, and on
a real two-node job Slurm ran it on the aux node. It reaches the policy on
localhost and joins a Ray head through that node's raylet, so both failed. The
driver now goes to the first node of the policy's pool.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The driver reaches the policy on localhost, but its srun only asked for one
node, so in a multi-node job Slurm could start it anywhere. A multi-node
policy serves its API from node 0, a pinned one from its pool's first node;
the driver now goes there in any multi-node job, not only when services are
pinned. An unpinned service that fits on one node follows it, for the same
reason.

Also brace the --nodelist expansion. Unbraced, $gym_nodes[0] expands to the
first host followed by a literal [0].

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A ray service now names the node pools it spans instead of starting a head
and workers separately. The head starts beside the driver, every other node
of those pools joins it as a worker, and all of them use the service's one
container. `resources` is keyed by pool, so only the pools that need a custom
resource advertise it.

Removes `mode`, `address` and `head`. Workers wait for the head, and flags
Ray accepts only on a head are dropped from their command.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
When two or more vLLM services share a node, each now gets its own
CUDA_VISIBLE_DEVICES (TP x PP x instances per node, in declaration order).
A ray service there gets the leftover GPUs. A node with one GPU service
renders nothing new.

A manual CUDA_VISIBLE_DEVICES index list wins and reserves its GPUs.
Overlaps and over-subscription fail validation. `share_gpus: true` opts
a service out.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 28, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

…vLLM beside Ray

Slurm resets CUDA_VISIBLE_DEVICES to the step's GPUs, so a value passed as
`env K=V srun` never reaches the task. Checked on a 4-GPU GB200 node: the
env-prefix value came back as 0,1,2,3, while `export` inside the step's
command left CUDA seeing only the planned GPUs. Planned and manual values on
a packed node are now exported in the step.

A lone vLLM service beside a GPU-using ray service is now packed too, so
Ray no longer advertises the model's GPUs. A ray service with num_gpus: 0,
or a single GPU service on a node, still renders nothing new.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A pool with `nodes: auto` is sized by Gym. The policy goes on the pool's
first node, beside the driver. The other vLLM services go biggest first
into the first node with room, never split. A service bigger than one node
gets ceil(need / gpus_per_node) whole nodes of its own. More than one node
splits the pool into sub-pools `<pool>-<i>`, placed with the existing
node-pool --nodelist mechanism; a ray service spanning the pool spans them
all and gets each node's leftover GPUs. Explicit node counts are unchanged.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A service's own CUDA_VISIBLE_DEVICES was passed as `env K=V srun`, which
Slurm resets to the step's GPUs, so it was silently ignored. It is now
exported inside the step's command on every node, including a node with a
single service. A runtime:VAR value is relayed as GYM_CUDA_VISIBLE_DEVICES
so the step still exports the batch shell's $VAR.

Drop runtime:CUDA_VISIBLE_DEVICES from the multi-instance example: it
re-exported the job's full GPU set and did nothing.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The batch script runs on node 0 and checked every service on localhost,
so a service pinned to a later pool, or placed beside a policy on one,
never passed its health check. `nodes: auto` makes that placement
routine. A service off node 0 is now checked at ${gym_nodes[i]}; node 0
keeps localhost, so single-node and node-0 renders are unchanged.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The health probe runs from the batch script on node 0, but a pinned service
answers on its pool's first node, and a ray head and the collector follow the
driver's node. Probed on localhost, they waited out their timeout on a later
node. Probe them where _service_nodelist places them.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
…-packing

Keep the base branch's health-probe fix (_probe_host, _health_check_host,
render_health_check host) and drop this branch's duplicate helper and its
two overlapping tests. Sub-pools from `nodes: auto` are ordinary pinned
pools by render time, so the base helper already probes them on their
node; the auto test stays as coverage for that.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
Base automatically changed from wprazuch/service-node-placement to main September 28, 2026 12:38
@wprazuch wprazuch closed this Oct 2, 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