Skip to content

feat(orchestration): express prefill/decode disaggregation - #3763

Merged
wprazuch merged 11 commits into
mainfrom
wprazuch/vllm-prefill-decode-v2
Sep 30, 2026
Merged

wprazuch merged 11 commits into
mainfrom
wprazuch/vllm-prefill-decode-v2

Conversation

@wprazuch

@wprazuch wprazuch commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Same change as #3657, rebased onto main on a new branch; #3657's branch stays untouched for now.

In one sentence

Express prefill/decode disaggregated serving, so a policy can be split across two tiers behind a router.

Builds on #3654 (per-service node placement, now on main): prefill and decode need node pools of their own.

Why

A large policy served for an agentic benchmark spends most of its time in prefill. Splitting prefill and decode onto separate node tiers, with KV cache transferred between them and a router in front, is how that workload is served in practice today. SubmitConfig had no way to say it.

The change

One new service type, vllm_pd. It nests its two tiers and owns the router and KV settings:

services:
  policy:
    type: vllm_pd
    container: vllm/vllm-openai:latest
    model: /models/checkpoint
    served_model_name: my-model
    port: 8000                     # the router; driver.policy_model names this service
    prefill:
      node_pool: prefill
      tensor_parallel_size: 4
      number_of_instances: 4
    decode:
      node_pool: decode
      tensor_parallel_size: 4
      number_of_instances: 6
    # optional: server_per_node (false), nixl_side_channel_port (5600; decode +1),
    # prefill_policy, decode_policy, kv_connector (NixlConnector),
    # kv_load_failure_policy (fail), request_timeout_secs, log_level
driver:
  policy_model: policy
  • prefill: and decode: are plain vLLM service settings. Each inherits container, model and served_model_name.
  • Every P/D setting lives on the vllm_pd service. server_per_node applies to both tiers. nixl_side_channel_port is prefill's; decode gets it + 1. Setting either on a tier is refused.
  • Each tier gets its KV role automatically.
  • Tier ports default to router port + 1 and + 2. Decode's data-parallel port defaults to prefill's + 1. All can be set.

Why vllm_pd: the service is a vLLM prefill/decode deployment, not a general router. A different kind of router can have its own type later.

No logic is duplicated. At validation, the service expands into three internal services: policy-prefill, policy-decode and policy (the router). The existing multi-node rank builder, node placement and health checks run on those unchanged. The resolved config written to the job directory keeps the nested form, so it loads back.

Bugs found by generating a real job script

Before running anything, we generated the job script for SWE-bench Pro's real layout (4 prefill nodes, 6 decode nodes) and read it. That found three bugs, all still guarded:

  • Both tiers used the same meeting point. Each tier's vLLM servers now coordinate on their own first node and their own port.
  • Decode's health check looked in the wrong place. Each tier and the router are now probed on the node they run on.
  • Nothing stopped the two tiers from picking the same ports. Validation refuses a data-parallel port clash and names both tiers. The NIXL ports are derived, so they cannot clash.

Found on a real cluster

A SWE-bench Pro prefill/decode run on a real cluster exposed four more problems. All are fixed:

  • One server per node. A tier with server_per_node: true runs an independent vllm serve on every node, and the router lists each one.
  • The router listens on 0.0.0.0. The driver and the health probe call it on localhost.
  • The router and driver share node 0. This comes from feat(orchestration): let a service claim its own nodesΒ #3654's placement rule.
  • driver.policy_host. An agent in a remote sandbox cannot reach the driver's localhost, so the injected policy URL can name a routable host.

Testing

  • Same job script as before. One commit renders SWE-bench Pro's layout with the earlier three-service form and pins the output as fixtures. There are two variants: data-parallel tiers and one server per node. The next commit switches to vllm_pd, and the new form renders both fixtures byte for byte. The collector's scrape config is also unchanged.
  • Unit tests. Tests cover the expansion, inheritance and port defaults. They also check that the resolved config loads back. Refusal tests cover: a port clash, a tier without a pool or with an unknown pool, a missing tier, a name that clashes with a tier's name, KV fields or server_per_node / nixl_side_channel_port set on a tier, and those fields set on a plain vLLM service.
  • Each new test was revert-checked. It fails when the piece it guards is removed.
  • Non-P/D rendering is unchanged.

Verified end to end

SWE-bench Pro ran the full 4 prefill + 6 decode shape on a real cluster with this change. That was before the config was reshaped, and the job script is byte-identical. All 731 tasks x 3 = 2193 rollouts finished. Mean reward was 0.533, against a reference of 0.526 from the same checkpoint served another way.

A deployment still needs its fabric environment (UCX_*, NCCL_*) supplied through each tier's env or pre_command. This PR does not presume a set.

A disaggregated deployment splits serving in two: a prefill tier computes the
prompt and hands its KV cache over, a decode tier generates from that cache,
and a router in front sends each phase to the tier that owns it. SubmitConfig
could not say any of this, so the one benchmark that needs it stayed on a
bespoke launcher script.

Modelled as two fields and one service type rather than a new composite:

- `kv_role` on a vllm service marks it a producer or consumer tier and renders
  the matching `--kv-transfer-config`, alongside the NIXL side-channel exports
  the connector needs on each node.
- `type: router` names the two tiers it fronts and renders `vllm-router
  --vllm-pd-disaggregation`. It is a model service, so `driver.policy_model`
  points at the router and clients never address a tier directly.

A tier is then just an ordinary multi-node data-parallel service pinned to a
node pool, which the executor already knows how to render. That is what keeps
this small: 4 prefill nodes and 6 decode nodes are two pools, and the existing
head/headless rank split builds each tier unchanged.

Three things node placement alone did not cover, each found by rendering a
4P/6D config and reading it against the working launcher:

- Both tiers coordinated their data-parallel ranks on the allocation's head
  node. Each tier now uses its own pool's head, and its own RPC port, so the
  two rank-0s do not collide.
- The decode tier's health probe went to localhost, where nothing answers: the
  probe runs on the first node and decode's API rank is on node 4. A pinned
  service is now probed where it actually runs.
- Ports that are bound per host -- the NIXL side channel and the DP RPC port --
  are refused when the two tiers share them, rather than failing at startup.

Non-P/D rendering is untouched: single-node, multi-node data-parallel and
ray-spanning configs all render byte-identically to the parent commit.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A tier pinned to a multi-node pool ran as one data-parallel engine across
the pool, and the router addressed only its head. The launcher prefill/decode
deployments are validated against runs one independent vllm serve on every
node instead, with the router listing each node.

server_per_node selects that shape: each node in the pool runs the plain
single-node command, the router gets one --prefill or --decode per node, and
the service is sized as a single-node deployment.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
(cherry picked from commit 9d679b966d79a418f6b4530a36315ffe3cc295b1)
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The router bound --host $(hostname), the node's interface address, while
driver.policy_model and the health probe both call http://localhost:<port>.
Nothing answered there, so the router never looked healthy and the driver
could not have reached it. It is validated to sit on the driver's node, so
bind all interfaces.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
(cherry picked from commit ef8ce00a3f0e76964f3c83eda8ee1d1977957dda)
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
driver.policy_model always injected http://localhost:<port>/v1. That suits
clients on the driver's node, but a sandboxed agent (SWE-bench Pro's opencode
runs in a remote OpenSandbox) resolves localhost to the sandbox and never
reaches the policy. Setting policy_base_url by hand is refused while
policy_model is set, and unsetting policy_model also drops --model-type.

driver.policy_host names the host instead; the default keeps localhost.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
(cherry picked from commit 314f1f4d60a041e8064bfb11121953f9fe66dfac)
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
…ray head

An unpinned router runs beside the driver, so it is probed on the driver's
node. `_probe_host` reuses `_pool_head` for the host expansion.

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

@prokotg prokotg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good but I think a change in API might simplify things. Let's discuss

Comment thread nemo_gym/orchestration/api.py Resolved
Comment thread nemo_gym/orchestration/api.py Resolved
@yaoyu-33 yaoyu-33 added area:orchestration Job submission, deployment, services, and lifecycle management complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work waiting-on-customer Waiting on the original author to respond labels Sep 29, 2026
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
Renders the 4 prefill + 6 decode layout, data-parallel and one server
per node, and compares it byte for byte with a fixture, so the config
shape can be reworked against a fixed output.

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

A prefill/decode deployment is now a single `type: vllm_pd` service that
nests its `prefill` and `decode` tiers and owns the router and KV settings
(kv_connector, kv_load_failure_policy). Each tier's KV role is set
automatically; kv_role, kv_connector and kv_load_failure_policy leave
VllmServiceConfig.

Tiers inherit container, model and served_model_name, and take the next
ports after the router's and prefill's unless set. SubmitConfig expands
the service into <name>-prefill, <name>-decode and <name> (the router)
through deployed_services, so the existing rank builder, node placement
and health checks run unchanged. The resolved config keeps the nested
form, so it loads back.

The pinned SWE-bench Pro job scripts render byte for byte the same.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
Main now suffixes service and driver logs with $SLURM_JOB_ID and adds an
(empty here) resume prologue, so the two P/D golden scripts pick up those
lines. Nothing else in them changes.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
prefill and decode are now plain vLLM service settings. server_per_node
and nixl_side_channel_port are set once on the vllm_pd service (decode's
NIXL port is prefill's + 1), and setting either on a tier is refused.

The per-tier values are derived into private tier objects when the
config loads, so the saved config never carries them. The golden P/D
job scripts are unchanged. An empty KV config renders no
--kv-transfer-config flag.

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

@prokotg prokotg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@wprazuch
wprazuch added this pull request to the merge queue Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

πŸ”„ Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36722816677

Merged via the queue into main with commit 17add3c Sep 30, 2026
43 checks passed
@wprazuch
wprazuch deleted the wprazuch/vllm-prefill-decode-v2 branch September 30, 2026 13:50
@prokotg prokotg linked an issue Sep 30, 2026 that may be closed by this pull request

This branch was previously deployed

1 inactive deployment
public β€” 8e2fabf2 Deployed Sep 30, 2026 by copy-pr-bot[bot] via release / finalize / notify #4034
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:orchestration Job submission, deployment, services, and lifecycle management complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work waiting-on-customer Waiting on the original author to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[orchestration] disaggregated vllm

3 participants