Skip to content

fix(vllm): per-engine TP + strict external config check (harden #68) - #85

Merged
aoshen02 merged 7 commits into
feature/multi_nodesfrom
aoshen/vllm-harden-p1
May 30, 2026
Merged

fix(vllm): per-engine TP + strict external config check (harden #68)#85
aoshen02 merged 7 commits into
feature/multi_nodesfrom
aoshen/vllm-harden-p1

Conversation

@aoshen02

Copy link
Copy Markdown
Collaborator

Hardening on top of #68 (feature/multi_nodes). Two fixes of one root cause: TP / parallel sizing read the global rollout_num_gpus_per_engine instead of the per-engine value, in the two engine-launch paths. No new features.

P2 — managed launch: TP was not actually per-engine

validate_args unconditionally set a global args.vllm_tp_size = global rollout_num_gpus_per_engine // pp, and _resolve_vllm_parallel_sizes preferred it — so the per-engine tp = gpus_per_engine // pp branch was dead code in real runs. A heterogeneous per-group engine (e.g. a group with num_gpus_per_engine=2, tp=2) therefore launched with the global TP, while the trainer sized the NCCL weight-transfer rendezvous from the per-group engine_gpu_counts. They disagreed → the rendezvous hung 300 s (3/4 clients joined / DistStoreError).

  • TP is now derived per engine in _resolve_vllm_parallel_sizes (no global shadow), matching upstream slime's sglang_engine (tp = _gpus_per_engine // pp).
  • The global vllm_tp_size computation in validate_args is removed.
  • dp>1 raises NotImplementedError (DP/EP wiring is a follow-up PR); pp divisibility is validated per engine.

P1 — external engine: weak, wrong-baseline, not node-rank aware

_wait_external_config_ready compared the engine's reported TP against the same global flag and only warned, and it ran on headless workers (node_rank>0) that own no HTTP. Replaced with _sanity_check_external_server_args:

  • compares tp/pp/dp/nnodes against the per-engine expectation (self._server_args) and raises on mismatch (fail fast at init instead of a 300 s rendezvous hang later);
  • gated to node_rank == 0;
  • skips fields /server_info does not report (vLLM parallel_config may omit nnodes), so it stays strict without false-failing.

Note on #66

#66 (aoshen/vllm-mirror-sglang-arch) carries the identical global-TP shadow (_get_vllm_tp_size + its validate_args). This is a fix both branches need, not a port from #66.

Tests

  • New unit tests: per-engine TP, heterogeneous per-group TP via compute_vllm_engine_topology, dp>1 guard, and the strict external check (match / mismatch-raises / unreported-field-skipped / missing-parallel-config).
  • Existing [Feature]vLLM multi-node rollout engine topology #68 topology unit tests are unchanged and still pass (they set pp consistently, so gpus//pp equals their old vllm_tp_size).
  • Verified the new logic in a fresh r3 container (py_compile + inline drivers for all cases). E2E confirmation on test_qwen2.5_0.5B_vllm_config_distributed (heterogeneous tp=2/tp=1) to follow.

AI assistance (Claude Code) was used for this change.

…ig check

Two fixes of one root cause — TP/parallel sizing read the *global*
rollout_num_gpus_per_engine instead of the per-engine value — across the two
engine launch paths.

P2 (managed launch): validate_args unconditionally set a global args.vllm_tp_size
(= global rollout_num_gpus_per_engine // pp) and _resolve_vllm_parallel_sizes
preferred it, so the per-engine `tp = gpus_per_engine // pp` branch was dead in
real runs. A heterogeneous per-group engine (e.g. num_gpus_per_engine=2, tp=2)
thus launched with the global TP while the trainer sized the NCCL weight-transfer
rendezvous from the per-group engine_gpu_counts — they disagreed and the
rendezvous hung 300s ("3/4 clients joined"). TP is now derived per engine in
_resolve_vllm_parallel_sizes (no global shadow), matching upstream slime's
sglang_engine; the global vllm_tp_size computation is removed. dp>1 raises
NotImplementedError (DP/EP wiring is a follow-up); pp divisibility validated per
engine.

P1 (external engine): _wait_external_config_ready compared the engine's reported
TP against the same global flag and only warned, and ran on headless workers
(node_rank>0) that own no HTTP. Replaced with _sanity_check_external_server_args:
checks tp/pp/dp/nnodes against the per-engine expectation and raises on mismatch
(fail fast instead of a later rendezvous hang), gated to node_rank 0, skipping
fields /server_info does not report (vLLM may omit nnodes).

Note: #66 (aoshen/vllm-mirror-sglang-arch) carries the identical global-tp
shadow; this is a fix both branches need, not a port.

Tests: unit tests for per-engine/heterogeneous TP, the dp>1 guard, and the strict
external check (match / mismatch-raises / unreported-skipped / missing-config).
Existing topology unit tests are unchanged and still pass.

AI assistance (Claude Code) was used for this change.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request refactors how parallel sizes (specifically tensor parallel size) are resolved for vLLM engines, moving from a global precomputation to a per-engine resolution to support heterogeneous per-group engines. It also introduces strict verification of external engine configurations against expected parameters during initialization, raising an error on mismatch instead of just logging a warning. Additionally, only the head node (node_rank 0) now performs external engine initialization checks. Unit tests have been added to cover these changes.

Feedback on the PR highlights two potential issues in _sanity_check_external_server_args:

  1. A potential AttributeError if vllm_config is explicitly null in the JSON response, which can be avoided by using or {} instead of .get(..., {}).
  2. Raw requests.RequestException errors being raised directly on network or endpoint failures, which should be wrapped in a try-except block to provide a more descriptive error message.

response = requests.get(f"{self._http_base()}/server_info", params={"config_format": "json"}, timeout=30)
response.raise_for_status()
body = response.json()
parallel_cfg = body.get("vllm_config", {}).get("parallel_config", {})

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.

high

If vllm_config is explicitly null (None) in the JSON response, body.get(\"vllm_config\", {}) will return None. Chaining .get(\"parallel_config\", {}) on None will then raise an AttributeError: 'NoneType' object has no attribute 'get', crashing the initialization instead of raising the intended RuntimeError on line 738. Using or {} handles this safely.

        vllm_cfg = body.get(\"vllm_config\") or {}
        parallel_cfg = vllm_cfg.get(\"parallel_config\") or {}

Comment on lines +733 to +734
response = requests.get(f"{self._http_base()}/server_info", params={"config_format": "json"}, timeout=30)
response.raise_for_status()

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.

medium

If the external server does not support /server_info or if there is a transient network issue, requests.get or response.raise_for_status() will raise a raw requests.RequestException. Wrapping this call in a try-except block and raising a descriptive RuntimeError will significantly improve usability and debuggability.

        try:
            response = requests.get(f\"{self._http_base()}/server_info\", params={\"config_format\": \"json\"}, timeout=30)
            response.raise_for_status()
        except requests.RequestException as e:
            raise RuntimeError(
                f\"Failed to fetch server configuration from external vLLM server at {self._http_base()}/server_info: {e}\"
            ) from e

aoshen02 and others added 6 commits May 30, 2026 14:42
…llback

Remove two silent/ambiguous defaults in the vLLM launch path:

- launch_server_process no longer forces --gpu-memory-utilization=0.55. In colocate,
  training and rollout do not occupy the GPU simultaneously (sleep/offload cycles), so
  vLLM's own default is appropriate; a user value via --vllm-gpu-memory-utilization is
  still auto-forwarded by _forward_vllm_cli_args. (This reverts the unset default to
  vLLM's; memory-tight large-model colocate setups can set the flag explicitly.)
- VLLMEngine.init: drop the `else self.args.vllm_router_ip/port` fallback. rollout always
  calls engine.init(router_ip=self.router_ip, router_port=self.router_port) and
  _start_router always returns a real address, so the fallback was dead/redundant.
- Update the arguments.py note that referenced the removed 0.55 default.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- _sanity_check_external_server_args now uses the _response_json helper (consistent
  error handling — annotates HTTP errors with response text) instead of a manual
  raise_for_status + .json().
- Remove unused VllmEngineTopology.master_host/master_port fields: never set or read
  (append_vllm_distributed_launch_flags takes the master addr via its own param).
- Remove three test-only back-compat aliases (_append_vllm_distributed_launch_flags,
  _redact_cmd_for_log, _serialize_for_cli); tests now call the public names directly.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orkers)

Mirror SGLang's _make_request choke point: route the node_rank guard through the
single shared POST helper so every control-plane method that POSTs is guarded by
construction, instead of scattered (and incomplete) per-method `if node_rank != 0`
checks.

Before, 7 control-plane HTTP methods were unguarded (release/resume_memory_occupation,
init_weight_transfer_engine, start/finish_weight_update, init_weights_update_group,
update_weights_from_distributed): on a headless worker (node_rank>0, no HTTP server)
they would hit a non-existent endpoint instead of no-op'ing.

- `_post_json` (the central POST path, = SGLang's `_make_request`) now short-circuits
  to None on node_rank>0; `_response_json(None)` returns None so the no-op propagates
  to callers with no per-call special-casing and no return-type change (mocked tests
  unaffected).
- `/sleep` and `/wake_up` bypass `_post_json` (query params), so they keep an explicit
  guard — same shape as SGLang's explicit guards on its non-_make_request methods.

Tests: headless worker no-ops all control-plane methods with zero HTTP;
`_post_json` short-circuits; `_response_json(None) -> None`.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng-style)

Add `_make_request` (guard + POST + JSON parse), mirroring SGLang's
HttpServerEngineAdapter._make_request, and route the control-plane POST callers
through it: _post_vllm_update_weights_http, init_weight_transfer_engine,
init_weights_update_group, start_weight_update, finish_weight_update. Each becomes a
one-liner (no more `response = self._post_json(...); return _response_json(response)`).

The node_rank guard stays centralized in `_post_json` (which `_make_request` wraps),
so behavior is unchanged and tests that mock `_post_json` are unaffected (no
return-type change, no test ripple).

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses normally.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…point)

Per review feedback, `_make_request -> _post_json -> _response_json` was one layer
too many. SGLang's `_make_request` is self-contained (guard + POST inline), so inline
`_post_json` into `_make_request` (node_rank guard + POST + parse via the shared
`_response_json`, which the query-param endpoints /sleep, /wake_up also reuse) and drop
`_post_json` entirely.

`_response_json` reverts to strict (the None-tolerance is unneeded now that nothing
passes it None). Tests that mocked `_post_json` now mock `_make_request` (which returns
parsed JSON), with no other behavior change.

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses; `_post_json` is fully removed.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tested

test_response_json_empty_body_returns_ok built _MockResponse(text="") and expected
{"ok": True}, but _MockResponse had no `content` attribute while _response_json checks
`response.content` — so the test raised AttributeError instead of exercising the
empty-body branch (the /sleep, /wake_up empty-200 handling, cf. #80).

Give _MockResponse a `content` (JSON-body bytes when json_data is set, else the text
bytes, i.e. b"" when empty) so the empty-body handling is actually verified. Other
_MockResponse usages set json_data and thus get non-empty content — unaffected.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aoshen02
aoshen02 merged commit 4fb9021 into feature/multi_nodes May 30, 2026
1 of 2 checks passed
aoshen02 added a commit that referenced this pull request May 31, 2026
…e-TP contract (#91)

PR #85 removed the global vllm_tp_size from validate_args (TP is derived per-engine
in vllm_engine._resolve_vllm_parallel_sizes) and moved the pp-divisibility check out
of validate_args. But three test_arguments.py cases still asserted the old contract
and now fail on feature/multi_nodes:
  - test_validate_args_pp1            (expected ns.vllm_tp_size == 4)
  - test_validate_args_pp2_dp2_derives_tp (expected ns.vllm_tp_size == 2)
  - test_validate_args_pp_indivisible_asserts (expected validate_args to raise)
Rewrite them to the new contract: validate_args records pp/dp but sets no global TP,
and no longer raises on pp-indivisibility (that check moved per-engine, covered in
test_vllm_engine.py).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aoshen02 added a commit that referenced this pull request May 31, 2026
* [Feature][1/N] Add vLLM multi-node rollout engine topology

- VllmEngineTopology / compute_server_args for cross-node vLLM engines
- Merge origin/main: add processed_logprobs default and weight-transfer comments
- Fix _response_json for vLLM sleep/wake empty HTTP 200 body
- Update unit tests for multi-node SKIPPED_DESTS and vllm_router_ip API

* fix(vllm): per-engine TP + strict external config check (harden #68) (#85)

* fix(vllm): derive rollout-engine TP per-engine + strict external config check

Two fixes of one root cause — TP/parallel sizing read the *global*
rollout_num_gpus_per_engine instead of the per-engine value — across the two
engine launch paths.

P2 (managed launch): validate_args unconditionally set a global args.vllm_tp_size
(= global rollout_num_gpus_per_engine // pp) and _resolve_vllm_parallel_sizes
preferred it, so the per-engine `tp = gpus_per_engine // pp` branch was dead in
real runs. A heterogeneous per-group engine (e.g. num_gpus_per_engine=2, tp=2)
thus launched with the global TP while the trainer sized the NCCL weight-transfer
rendezvous from the per-group engine_gpu_counts — they disagreed and the
rendezvous hung 300s ("3/4 clients joined"). TP is now derived per engine in
_resolve_vllm_parallel_sizes (no global shadow), matching upstream slime's
sglang_engine; the global vllm_tp_size computation is removed. dp>1 raises
NotImplementedError (DP/EP wiring is a follow-up); pp divisibility validated per
engine.

P1 (external engine): _wait_external_config_ready compared the engine's reported
TP against the same global flag and only warned, and ran on headless workers
(node_rank>0) that own no HTTP. Replaced with _sanity_check_external_server_args:
checks tp/pp/dp/nnodes against the per-engine expectation and raises on mismatch
(fail fast instead of a later rendezvous hang), gated to node_rank 0, skipping
fields /server_info does not report (vLLM may omit nnodes).

Note: #66 (aoshen/vllm-mirror-sglang-arch) carries the identical global-tp
shadow; this is a fix both branches need, not a port.

Tests: unit tests for per-engine/heterogeneous TP, the dp>1 guard, and the strict
external check (match / mismatch-raises / unreported-skipped / missing-config).
Existing topology unit tests are unchanged and still pass.

AI assistance (Claude Code) was used for this change.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* cleanup(vllm): drop forced 0.55 gpu-mem default + redundant router fallback

Remove two silent/ambiguous defaults in the vLLM launch path:

- launch_server_process no longer forces --gpu-memory-utilization=0.55. In colocate,
  training and rollout do not occupy the GPU simultaneously (sleep/offload cycles), so
  vLLM's own default is appropriate; a user value via --vllm-gpu-memory-utilization is
  still auto-forwarded by _forward_vllm_cli_args. (This reverts the unset default to
  vLLM's; memory-tight large-model colocate setups can set the flag explicitly.)
- VLLMEngine.init: drop the `else self.args.vllm_router_ip/port` fallback. rollout always
  calls engine.init(router_ip=self.router_ip, router_port=self.router_port) and
  _start_router always returns a real address, so the fallback was dead/redundant.
- Update the arguments.py note that referenced the removed 0.55 default.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* cleanup(vllm): use _response_json helper + drop dead fields/aliases

- _sanity_check_external_server_args now uses the _response_json helper (consistent
  error handling — annotates HTTP errors with response text) instead of a manual
  raise_for_status + .json().
- Remove unused VllmEngineTopology.master_host/master_port fields: never set or read
  (append_vllm_distributed_launch_flags takes the master addr via its own param).
- Remove three test-only back-compat aliases (_append_vllm_distributed_launch_flags,
  _redact_cmd_for_log, _serialize_for_cli); tests now call the public names directly.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vllm): central node_rank guard for control-plane HTTP (headless workers)

Mirror SGLang's _make_request choke point: route the node_rank guard through the
single shared POST helper so every control-plane method that POSTs is guarded by
construction, instead of scattered (and incomplete) per-method `if node_rank != 0`
checks.

Before, 7 control-plane HTTP methods were unguarded (release/resume_memory_occupation,
init_weight_transfer_engine, start/finish_weight_update, init_weights_update_group,
update_weights_from_distributed): on a headless worker (node_rank>0, no HTTP server)
they would hit a non-existent endpoint instead of no-op'ing.

- `_post_json` (the central POST path, = SGLang's `_make_request`) now short-circuits
  to None on node_rank>0; `_response_json(None)` returns None so the no-op propagates
  to callers with no per-call special-casing and no return-type change (mocked tests
  unaffected).
- `/sleep` and `/wake_up` bypass `_post_json` (query params), so they keep an explicit
  guard — same shape as SGLang's explicit guards on its non-_make_request methods.

Tests: headless worker no-ops all control-plane methods with zero HTTP;
`_post_json` short-circuits; `_response_json(None) -> None`.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): route control-plane POSTs through _make_request (SGLang-style)

Add `_make_request` (guard + POST + JSON parse), mirroring SGLang's
HttpServerEngineAdapter._make_request, and route the control-plane POST callers
through it: _post_vllm_update_weights_http, init_weight_transfer_engine,
init_weights_update_group, start_weight_update, finish_weight_update. Each becomes a
one-liner (no more `response = self._post_json(...); return _response_json(response)`).

The node_rank guard stays centralized in `_post_json` (which `_make_request` wraps),
so behavior is unchanged and tests that mock `_post_json` are unaffected (no
return-type change, no test ripple).

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses normally.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): collapse _post_json into _make_request (single choke point)

Per review feedback, `_make_request -> _post_json -> _response_json` was one layer
too many. SGLang's `_make_request` is self-contained (guard + POST inline), so inline
`_post_json` into `_make_request` (node_rank guard + POST + parse via the shared
`_response_json`, which the query-param endpoints /sleep, /wake_up also reuse) and drop
`_post_json` entirely.

`_response_json` reverts to strict (the None-tolerance is unneeded now that nothing
passes it None). Tests that mocked `_post_json` now mock `_make_request` (which returns
parsed JSON), with no other behavior change.

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses; `_post_json` is fully removed.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(vllm): model _MockResponse.content so the empty-200-body path is tested

test_response_json_empty_body_returns_ok built _MockResponse(text="") and expected
{"ok": True}, but _MockResponse had no `content` attribute while _response_json checks
`response.content` — so the test raised AttributeError instead of exercising the
empty-body branch (the /sleep, /wake_up empty-200 handling, cf. #80).

Give _MockResponse a `content` (JSON-body bytes when json_data is set, else the text
bytes, i.e. b"" when empty) so the empty-body handling is actually verified. Other
_MockResponse usages set json_data and thus get non-empty content — unaffected.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): reorder vllm_engine.py functions by lifecycle (no logic change)

Pure top-level reordering for readability — group module functions by stage:
shared helpers -> topology -> launch config (compute_server_args) -> command/env
build (build_vllm_cmd_and_env) -> process spawn (launch_server_process) -> misc ->
the VLLMEngine actor.

No behavior change (module-level functions resolve names at call time). Verified the
module imports cleanly and the topology / control-plane (headless no-op) drivers still
pass; a reorder script asserted no non-blank line was added or removed (diff is a
balanced 71/71 move).

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rollout): always use the full Rust router for PD; remove the unusable MiniLB path (#88)

_start_router defaulted PD to MiniLB (mini_lb=True unless SLIME_VLLM_ROUTER_USE_RUST=1).
MiniLB (vllm_router/mini_lb.py) is a debug-only load balancer that REQUIRES static
prefill/decode URLs at construction; slime never provides those (it launches the router
first and engines register dynamically via POST /workers), so MiniLB can never work here.

Remove the MiniLB activation and the dead SLIME_VLLM_ROUTER_USE_RUST gate. PD now uses the
full Rust router (accepts dynamic registration like the non-PD path). MiniLB stays off via
RouterArgs' own default (mini_lb=False) — no explicit setter needed. Also drop the stale
MiniLB mention from the router-startup-failure error message.

Note: routing-layer fix only; PD end-to-end still needs the engine-side KV transport
(--kv-transfer-config) to initialize, a separate open blocker.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(vllm): update validate_args unit tests to the post-#85 per-engine-TP contract (#91)

PR #85 removed the global vllm_tp_size from validate_args (TP is derived per-engine
in vllm_engine._resolve_vllm_parallel_sizes) and moved the pp-divisibility check out
of validate_args. But three test_arguments.py cases still asserted the old contract
and now fail on feature/multi_nodes:
  - test_validate_args_pp1            (expected ns.vllm_tp_size == 4)
  - test_validate_args_pp2_dp2_derives_tp (expected ns.vllm_tp_size == 2)
  - test_validate_args_pp_indivisible_asserts (expected validate_args to raise)
Rewrite them to the new contract: validate_args records pp/dp but sets no global TP,
and no longer raises on pp-indivisibility (that check moved per-engine, covered in
test_vllm_engine.py).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vllm): wait for engine health with no time limit (SGLang-style), gated by liveness (#90)

_wait_server_healthy hardcoded a 300s deadline that a large engine exceeds while still
loading/compiling/capturing CUDA graphs (a 30B FP8 dp=2 colocate engine timed out at 300s
though it was healthy seconds later). Match slime's SGLang backend: loop until /health 200
or — for a managed subprocess — until it dies (fail fast via process.is_alive()), with no
overall deadline. Drop the timeout_s parameter entirely (cleaner); keep the per-probe
timeout=3 so a single stuck socket can't wedge the loop. External mode (process is None)
has no liveness signal and loops until reachable, by design (caller-managed engine).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
momo609 pushed a commit that referenced this pull request Jun 8, 2026
* [Feature][1/N] Add vLLM multi-node rollout engine topology

- VllmEngineTopology / compute_server_args for cross-node vLLM engines
- Merge origin/main: add processed_logprobs default and weight-transfer comments
- Fix _response_json for vLLM sleep/wake empty HTTP 200 body
- Update unit tests for multi-node SKIPPED_DESTS and vllm_router_ip API

* fix(vllm): per-engine TP + strict external config check (harden #68) (#85)

* fix(vllm): derive rollout-engine TP per-engine + strict external config check

Two fixes of one root cause — TP/parallel sizing read the *global*
rollout_num_gpus_per_engine instead of the per-engine value — across the two
engine launch paths.

P2 (managed launch): validate_args unconditionally set a global args.vllm_tp_size
(= global rollout_num_gpus_per_engine // pp) and _resolve_vllm_parallel_sizes
preferred it, so the per-engine `tp = gpus_per_engine // pp` branch was dead in
real runs. A heterogeneous per-group engine (e.g. num_gpus_per_engine=2, tp=2)
thus launched with the global TP while the trainer sized the NCCL weight-transfer
rendezvous from the per-group engine_gpu_counts — they disagreed and the
rendezvous hung 300s ("3/4 clients joined"). TP is now derived per engine in
_resolve_vllm_parallel_sizes (no global shadow), matching upstream slime's
sglang_engine; the global vllm_tp_size computation is removed. dp>1 raises
NotImplementedError (DP/EP wiring is a follow-up); pp divisibility validated per
engine.

P1 (external engine): _wait_external_config_ready compared the engine's reported
TP against the same global flag and only warned, and ran on headless workers
(node_rank>0) that own no HTTP. Replaced with _sanity_check_external_server_args:
checks tp/pp/dp/nnodes against the per-engine expectation and raises on mismatch
(fail fast instead of a later rendezvous hang), gated to node_rank 0, skipping
fields /server_info does not report (vLLM may omit nnodes).

Note: #66 (aoshen/vllm-mirror-sglang-arch) carries the identical global-tp
shadow; this is a fix both branches need, not a port.

Tests: unit tests for per-engine/heterogeneous TP, the dp>1 guard, and the strict
external check (match / mismatch-raises / unreported-skipped / missing-config).
Existing topology unit tests are unchanged and still pass.

AI assistance (Claude Code) was used for this change.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* cleanup(vllm): drop forced 0.55 gpu-mem default + redundant router fallback

Remove two silent/ambiguous defaults in the vLLM launch path:

- launch_server_process no longer forces --gpu-memory-utilization=0.55. In colocate,
  training and rollout do not occupy the GPU simultaneously (sleep/offload cycles), so
  vLLM's own default is appropriate; a user value via --vllm-gpu-memory-utilization is
  still auto-forwarded by _forward_vllm_cli_args. (This reverts the unset default to
  vLLM's; memory-tight large-model colocate setups can set the flag explicitly.)
- VLLMEngine.init: drop the `else self.args.vllm_router_ip/port` fallback. rollout always
  calls engine.init(router_ip=self.router_ip, router_port=self.router_port) and
  _start_router always returns a real address, so the fallback was dead/redundant.
- Update the arguments.py note that referenced the removed 0.55 default.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* cleanup(vllm): use _response_json helper + drop dead fields/aliases

- _sanity_check_external_server_args now uses the _response_json helper (consistent
  error handling — annotates HTTP errors with response text) instead of a manual
  raise_for_status + .json().
- Remove unused VllmEngineTopology.master_host/master_port fields: never set or read
  (append_vllm_distributed_launch_flags takes the master addr via its own param).
- Remove three test-only back-compat aliases (_append_vllm_distributed_launch_flags,
  _redact_cmd_for_log, _serialize_for_cli); tests now call the public names directly.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vllm): central node_rank guard for control-plane HTTP (headless workers)

Mirror SGLang's _make_request choke point: route the node_rank guard through the
single shared POST helper so every control-plane method that POSTs is guarded by
construction, instead of scattered (and incomplete) per-method `if node_rank != 0`
checks.

Before, 7 control-plane HTTP methods were unguarded (release/resume_memory_occupation,
init_weight_transfer_engine, start/finish_weight_update, init_weights_update_group,
update_weights_from_distributed): on a headless worker (node_rank>0, no HTTP server)
they would hit a non-existent endpoint instead of no-op'ing.

- `_post_json` (the central POST path, = SGLang's `_make_request`) now short-circuits
  to None on node_rank>0; `_response_json(None)` returns None so the no-op propagates
  to callers with no per-call special-casing and no return-type change (mocked tests
  unaffected).
- `/sleep` and `/wake_up` bypass `_post_json` (query params), so they keep an explicit
  guard — same shape as SGLang's explicit guards on its non-_make_request methods.

Tests: headless worker no-ops all control-plane methods with zero HTTP;
`_post_json` short-circuits; `_response_json(None) -> None`.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): route control-plane POSTs through _make_request (SGLang-style)

Add `_make_request` (guard + POST + JSON parse), mirroring SGLang's
HttpServerEngineAdapter._make_request, and route the control-plane POST callers
through it: _post_vllm_update_weights_http, init_weight_transfer_engine,
init_weights_update_group, start_weight_update, finish_weight_update. Each becomes a
one-liner (no more `response = self._post_json(...); return _response_json(response)`).

The node_rank guard stays centralized in `_post_json` (which `_make_request` wraps),
so behavior is unchanged and tests that mock `_post_json` are unaffected (no
return-type change, no test ripple).

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses normally.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): collapse _post_json into _make_request (single choke point)

Per review feedback, `_make_request -> _post_json -> _response_json` was one layer
too many. SGLang's `_make_request` is self-contained (guard + POST inline), so inline
`_post_json` into `_make_request` (node_rank guard + POST + parse via the shared
`_response_json`, which the query-param endpoints /sleep, /wake_up also reuse) and drop
`_post_json` entirely.

`_response_json` reverts to strict (the None-tolerance is unneeded now that nothing
passes it None). Tests that mocked `_post_json` now mock `_make_request` (which returns
parsed JSON), with no other behavior change.

Verified: headless workers (node_rank>0) no-op every control-plane method with zero
HTTP; node-0 posts + parses; `_post_json` is fully removed.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(vllm): model _MockResponse.content so the empty-200-body path is tested

test_response_json_empty_body_returns_ok built _MockResponse(text="") and expected
{"ok": True}, but _MockResponse had no `content` attribute while _response_json checks
`response.content` — so the test raised AttributeError instead of exercising the
empty-body branch (the /sleep, /wake_up empty-200 handling, cf. #80).

Give _MockResponse a `content` (JSON-body bytes when json_data is set, else the text
bytes, i.e. b"" when empty) so the empty-body handling is actually verified. Other
_MockResponse usages set json_data and thus get non-empty content — unaffected.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vllm): reorder vllm_engine.py functions by lifecycle (no logic change)

Pure top-level reordering for readability — group module functions by stage:
shared helpers -> topology -> launch config (compute_server_args) -> command/env
build (build_vllm_cmd_and_env) -> process spawn (launch_server_process) -> misc ->
the VLLMEngine actor.

No behavior change (module-level functions resolve names at call time). Verified the
module imports cleanly and the topology / control-plane (headless no-op) drivers still
pass; a reorder script asserted no non-blank line was added or removed (diff is a
balanced 71/71 move).

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rollout): always use the full Rust router for PD; remove the unusable MiniLB path (#88)

_start_router defaulted PD to MiniLB (mini_lb=True unless SLIME_VLLM_ROUTER_USE_RUST=1).
MiniLB (vllm_router/mini_lb.py) is a debug-only load balancer that REQUIRES static
prefill/decode URLs at construction; slime never provides those (it launches the router
first and engines register dynamically via POST /workers), so MiniLB can never work here.

Remove the MiniLB activation and the dead SLIME_VLLM_ROUTER_USE_RUST gate. PD now uses the
full Rust router (accepts dynamic registration like the non-PD path). MiniLB stays off via
RouterArgs' own default (mini_lb=False) — no explicit setter needed. Also drop the stale
MiniLB mention from the router-startup-failure error message.

Note: routing-layer fix only; PD end-to-end still needs the engine-side KV transport
(--kv-transfer-config) to initialize, a separate open blocker.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(vllm): update validate_args unit tests to the post-#85 per-engine-TP contract (#91)

PR #85 removed the global vllm_tp_size from validate_args (TP is derived per-engine
in vllm_engine._resolve_vllm_parallel_sizes) and moved the pp-divisibility check out
of validate_args. But three test_arguments.py cases still asserted the old contract
and now fail on feature/multi_nodes:
  - test_validate_args_pp1            (expected ns.vllm_tp_size == 4)
  - test_validate_args_pp2_dp2_derives_tp (expected ns.vllm_tp_size == 2)
  - test_validate_args_pp_indivisible_asserts (expected validate_args to raise)
Rewrite them to the new contract: validate_args records pp/dp but sets no global TP,
and no longer raises on pp-indivisibility (that check moved per-engine, covered in
test_vllm_engine.py).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vllm): wait for engine health with no time limit (SGLang-style), gated by liveness (#90)

_wait_server_healthy hardcoded a 300s deadline that a large engine exceeds while still
loading/compiling/capturing CUDA graphs (a 30B FP8 dp=2 colocate engine timed out at 300s
though it was healthy seconds later). Match slime's SGLang backend: loop until /health 200
or — for a managed subprocess — until it dies (fail fast via process.is_alive()), with no
overall deadline. Drop the timeout_s parameter entirely (cleaner); keep the per-probe
timeout=3 so a single stuck socket can't wedge the loop. External mode (process is None)
has no liveness signal and loops until reachable, by design (caller-managed engine).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: aoshen02 <aoshen@inferact.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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