[large-scale-serving] Integrate OmniCoordinator into stage engine pipeline (#3569)

Signed-off-by: chickeyton <ngton2014@gmail.com>
Signed-off-by: herotai214 <herotai214@gmail.com>
Co-authored-by: herotai214 <herotai214@gmail.com>
This commit is contained in:
chickeytonandherotai214 authored and GitHub committed 2026-05-19 12:08:20 +08:00
1 parent ca9fd0b71c
commit e277feacaf
33 files changed
+3033 -782

No files matched your search

@@ -136,6 +136,7 @@ def _make_engine_with_loop(
calls and a real ``RequestScheduler``.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=rpc_delay)
sched = RequestScheduler()
@@ -371,6 +372,7 @@ def test_collective_rpc_before_loop_starts_calls_executor_directly():
on the caller's thread without enqueueing.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
@@ -391,6 +393,7 @@ def test_collective_rpc_before_loop_starts_serializes_concurrent_callers():
serialize them so they cannot race on the shared executor MQ pair.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
@@ -6,18 +6,18 @@ from time import time
import pytest
from vllm_omni.distributed.omni_coordinator import (
InstanceInfo,
LeastQueueLengthBalancer,
RandomBalancer,
ReplicaInfo,
ReplicaStatus,
RoundRobinBalancer,
StageStatus,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_load_balancer_select_returns_valid_index():
"""Verify RandomBalancer.select() returns a valid index for instances."""
"""Verify RandomBalancer.select() returns a valid index for replicas."""
# Task structure mirrors async_omni; RandomBalancer ignores task contents.
task: dict = {
"request_id": "test",
@@ -26,30 +26,30 @@ def test_load_balancer_select_returns_valid_index():
}
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=1,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
@@ -58,38 +58,38 @@ def test_load_balancer_select_returns_valid_index():
balancer = RandomBalancer()
index = balancer.select(task, instances)
index = balancer.select(task, replicas)
assert isinstance(index, int)
assert 0 <= index < len(instances)
assert 0 <= index < len(replicas)
def test_round_robin_balancer_cycles_instances():
def test_round_robin_balancer_cycles_replicas():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=1,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
@@ -97,35 +97,35 @@ def test_round_robin_balancer_cycles_instances():
]
balancer = RoundRobinBalancer()
results = [balancer.select({}, instances) for _ in range(5)]
results = [balancer.select({}, replicas) for _ in range(5)]
# Default start_index=0 => 0,1,2,0,1
assert results == [0, 1, 2, 0, 1]
def test_round_robin_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
def test_round_robin_balancer_empty_replicas_raises():
with pytest.raises(ValueError, match="replicas must not be empty"):
RoundRobinBalancer().select({}, [])
def test_round_robin_balancer_after_large_index_and_shorter_list():
"""Large start_index % len(instances) then counter wraps with shorter list."""
"""Large start_index % len(replicas) then counter wraps with shorter list."""
now = time()
two = [
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
@@ -138,30 +138,30 @@ def test_round_robin_balancer_after_large_index_and_shorter_list():
def test_least_queue_length_balancer_picks_min_queue():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=5,
last_heartbeat=now,
registered_at=now,
@@ -169,41 +169,41 @@ def test_least_queue_length_balancer_picks_min_queue():
]
balancer = LeastQueueLengthBalancer()
index = balancer.select({}, instances)
index = balancer.select({}, replicas)
assert index == 1
def test_least_queue_length_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
def test_least_queue_length_balancer_empty_replicas_raises():
with pytest.raises(ValueError, match="replicas must not be empty"):
LeastQueueLengthBalancer().select({}, [])
def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
@@ -214,21 +214,21 @@ def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
"vllm_omni.distributed.omni_coordinator.load_balancer.random.choice",
return_value=2,
)
assert balancer.select({}, instances) == 2
assert balancer.select({}, replicas) == 2
def test_least_queue_length_balancer_negative_queue_raises():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=-1,
last_heartbeat=now,
registered_at=now,
),
]
with pytest.raises(ValueError, match="queue_length must be non-negative"):
LeastQueueLengthBalancer().select({}, instances)
LeastQueueLengthBalancer().select({}, replicas)
@@ -8,8 +8,8 @@ import pytest
import zmq
from vllm_omni.distributed.omni_coordinator import (
InstanceList,
OmniCoordClientForHub,
ReplicaList,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -32,8 +32,8 @@ def _wait_for_condition(cond, timeout: float = 2.0, interval: float = 0.01) -> b
return False
def test_hub_client_caches_instance_list_from_pub():
"""Verify OmniCoordClientForHub receives instance list updates from OmniCoordinator and caches for get_instance_list()."""
def test_hub_client_caches_replica_list_from_pub():
"""Verify OmniCoordClientForHub receives replica list updates from OmniCoordinator and caches for get_replica_list()."""
ctx, pub, endpoint = _bind_pub()
client = OmniCoordClientForHub(endpoint)
@@ -41,7 +41,7 @@ def test_hub_client_caches_instance_list_from_pub():
time.sleep(0.2)
now = time.time()
instances_payload = [
replicas_payload = [
{
"input_addr": "tcp://stage:10001",
"output_addr": "tcp://stage:10001-out",
@@ -71,37 +71,37 @@ def test_hub_client_caches_instance_list_from_pub():
},
]
payload = {"instances": instances_payload, "timestamp": now}
payload = {"replicas": replicas_payload, "timestamp": now}
pub.send(json.dumps(payload).encode("utf-8"))
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 3)
assert _wait_for_condition(lambda: len(client.get_replica_list().replicas) == 3)
inst_list = client.get_instance_list()
assert isinstance(inst_list, InstanceList)
assert len(inst_list.instances) == 3
rep_list = client.get_replica_list()
assert isinstance(rep_list, ReplicaList)
assert len(rep_list.replicas) == 3
for src, inst in zip(instances_payload, inst_list.instances, strict=True):
assert inst.input_addr == src["input_addr"]
assert inst.output_addr == src["output_addr"]
assert inst.stage_id == src["stage_id"]
assert inst.status.value == src["status"]
for src, rep in zip(replicas_payload, rep_list.replicas, strict=True):
assert rep.input_addr == src["input_addr"]
assert rep.output_addr == src["output_addr"]
assert rep.stage_id == src["stage_id"]
assert rep.status.value == src["status"]
stage0 = client.get_instances_for_stage(0)
stage1 = client.get_instances_for_stage(1)
stage0 = client.get_replicas_for_stage(0)
stage1 = client.get_replicas_for_stage(1)
assert all(inst.stage_id == 0 for inst in stage0.instances)
assert all(inst.stage_id == 1 for inst in stage1.instances)
assert all(rep.stage_id == 0 for rep in stage0.replicas)
assert all(rep.stage_id == 1 for rep in stage1.replicas)
# Send an updated list with fewer instances and verify cache refresh.
# Send an updated list with fewer replicas and verify cache refresh.
updated_payload = {
"instances": instances_payload[:2],
"replicas": replicas_payload[:2],
"timestamp": now + 1.0,
}
pub.send(json.dumps(updated_payload).encode("utf-8"))
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 2)
updated_list = client.get_instance_list()
assert len(updated_list.instances) == 2
assert _wait_for_condition(lambda: len(client.get_replica_list().replicas) == 2)
updated_list = client.get_replica_list()
assert len(updated_list.replicas) == 2
client.close()
pub.close(0)
@@ -9,7 +9,7 @@ import zmq
from vllm_omni.distributed.omni_coordinator import (
OmniCoordClientForStage,
StageStatus,
ReplicaStatus,
)
from vllm_omni.distributed.omni_coordinator import (
omni_coord_client_for_stage as stage_client_module,
@@ -47,7 +47,7 @@ def test_stage_client_auto_register_on_init():
event = _recv_event(router)
assert event["event_type"] == "update"
assert event["status"] == StageStatus.UP.value
assert event["status"] == ReplicaStatus.UP.value
assert event["stage_id"] == stage_id
assert event["input_addr"] == input_addr
assert event["output_addr"] == output_addr
@@ -70,13 +70,13 @@ def test_stage_client_update_info_sends_correct_event():
# Discard initial registration event.
_recv_event(router)
client.update_info(status=StageStatus.ERROR)
client.update_info(status=ReplicaStatus.ERROR)
client.update_info(queue_length=10)
first = _recv_event(router)
second = _recv_event(router)
assert first["status"] == StageStatus.ERROR.value
assert first["status"] == ReplicaStatus.ERROR.value
assert first["stage_id"] == stage_id
assert first["input_addr"] == input_addr
assert first["output_addr"] == output_addr
@@ -107,7 +107,7 @@ def test_stage_client_close_sends_down_status():
client.close()
event = _recv_event(router)
assert event["status"] == StageStatus.DOWN.value
assert event["status"] == ReplicaStatus.DOWN.value
assert event["stage_id"] == stage_id
assert event["input_addr"] == input_addr
assert event["output_addr"] == output_addr
@@ -274,6 +274,7 @@ def test_heartbeat_loop_retries_after_transient_send_failure():
client._closed = False
client._heartbeat_interval = 0.0
client._stop_event = _FakeStopEvent()
client._on_heartbeat = None
calls = {"count": 0}
@@ -11,14 +11,14 @@ from vllm.v1.utils import get_engine_client_zmq_addr
from vllm_omni.distributed.omni_coordinator import (
OmniCoordClientForStage,
OmniCoordinator,
StageStatus,
ReplicaStatus,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
"""Receive InstanceList JSON from SUB socket. Returns None on timeout."""
def _recv_replica_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
"""Receive ReplicaList JSON from SUB socket. Returns None on timeout."""
sub.setsockopt(zmq.RCVTIMEO, timeout_ms)
try:
data = sub.recv()
@@ -27,16 +27,16 @@ def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
return None
def _wait_for_instance_list(
def _wait_for_replica_list(
sub: zmq.Socket,
expected_count: int,
timeout: float = 3.0,
) -> dict | None:
"""Wait until received InstanceList with expected_count active instances."""
"""Wait until received ReplicaList with expected_count active replicas."""
start = time.time()
while time.time() - start < timeout:
msg = _recv_instance_list(sub, timeout_ms=500)
if msg is not None and len(msg.get("instances", [])) == expected_count:
msg = _recv_replica_list(sub, timeout_ms=500)
if msg is not None and len(msg.get("replicas", [])) == expected_count:
return msg
return None
@@ -45,7 +45,7 @@ def _drain_sub_messages(sub: zmq.Socket, max_seconds: float = 0.4) -> None:
"""Drain queued SUB messages for a short window."""
deadline = time.time() + max_seconds
while time.time() < deadline:
_recv_instance_list(sub, timeout_ms=50)
_recv_replica_list(sub, timeout_ms=50)
def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
@@ -81,7 +81,7 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
)
# Wait for initial registration broadcast and clear any queued messages.
msg = _wait_for_instance_list(sub, expected_count=1)
msg = _wait_for_replica_list(sub, expected_count=1)
assert msg is not None
_drain_sub_messages(sub)
@@ -96,7 +96,7 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
deadline = time.time() + window_s
recv_count = 0
while time.time() < deadline:
if _recv_instance_list(sub, timeout_ms=100) is not None:
if _recv_replica_list(sub, timeout_ms=100) is not None:
recv_count += 1
assert recv_count < update_count // 2, (
@@ -110,8 +110,8 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
def test_omni_coordinator_registration_broadcast():
"""Verify that after multiple OmniCoordClientForStage instances register,
OmniCoordinator publishes an InstanceList containing all registered instances.
"""Verify that after multiple OmniCoordClientForStage replicas register,
OmniCoordinator publishes a ReplicaList containing all registered replicas.
"""
router_addr = get_engine_client_zmq_addr(
local_only=False,
@@ -144,12 +144,12 @@ def test_omni_coordinator_registration_broadcast():
OmniCoordClientForStage(router_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1),
]
msg = _wait_for_instance_list(sub, expected_count=3)
assert msg is not None, "Expected InstanceList with 3 instances"
assert len(msg["instances"]) == 3
msg = _wait_for_replica_list(sub, expected_count=3)
assert msg is not None, "Expected ReplicaList with 3 replicas"
assert len(msg["replicas"]) == 3
assert isinstance(msg["timestamp"], (int, float))
input_addrs = {inst["input_addr"] for inst in msg["instances"]}
input_addrs = {rep["input_addr"] for rep in msg["replicas"]}
assert "tcp://stage:10001" in input_addrs
assert "tcp://stage:10002" in input_addrs
assert "tcp://stage:10003" in input_addrs
@@ -162,7 +162,7 @@ def test_omni_coordinator_registration_broadcast():
def test_omni_coordinator_heartbeat_timeout_handling():
"""Verify that when a stage instance stops sending heartbeats,
"""Verify that when a stage replica stops sending heartbeats,
OmniCoordinator marks it as unhealthy and excludes it from the active list.
"""
router_addr = get_engine_client_zmq_addr(
@@ -201,23 +201,23 @@ def test_omni_coordinator_heartbeat_timeout_handling():
"output_addr": "tcp://stage:c-out",
"stage_id": 0,
"event_type": "update",
"status": StageStatus.UP.value,
"status": ReplicaStatus.UP.value,
"queue_length": 0,
}
dealer_c.send(json.dumps(reg_event).encode("utf-8"))
msg = _wait_for_instance_list(sub, expected_count=3)
assert msg is not None, "Expected initial 3 instances"
assert len(msg["instances"]) == 3
msg = _wait_for_replica_list(sub, expected_count=3)
assert msg is not None, "Expected initial 3 replicas"
assert len(msg["replicas"]) == 3
# Wait for heartbeat timeout (timeout=5s, check interval ~2.5s).
time.sleep(8.0)
# Receive the update (C should be ERROR and excluded from active list).
msg_after_timeout = _wait_for_instance_list(sub, expected_count=2, timeout=5.0)
assert msg_after_timeout is not None, "Expected InstanceList with 2 instances after timeout"
instances = msg_after_timeout.get("instances", [])
input_addrs = {inst["input_addr"] for inst in instances}
msg_after_timeout = _wait_for_replica_list(sub, expected_count=2, timeout=5.0)
assert msg_after_timeout is not None, "Expected ReplicaList with 2 replicas after timeout"
replicas = msg_after_timeout.get("replicas", [])
input_addrs = {rep["input_addr"] for rep in replicas}
assert "tcp://stage:a" in input_addrs
assert "tcp://stage:b" in input_addrs
@@ -232,8 +232,8 @@ def test_omni_coordinator_heartbeat_timeout_handling():
sub_ctx.term()
def test_omni_coordinator_instance_shutdown_handling():
"""Verify that when a stage instance sends status='down',
def test_omni_coordinator_replica_shutdown_handling():
"""Verify that when a stage replica sends status='down',
OmniCoordinator removes it from the active list and broadcasts an updated list.
"""
router_addr = get_engine_client_zmq_addr(
@@ -261,18 +261,18 @@ def test_omni_coordinator_instance_shutdown_handling():
client = OmniCoordClientForStage(router_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0)
msg = _wait_for_instance_list(sub, expected_count=1)
msg = _wait_for_replica_list(sub, expected_count=1)
assert msg is not None
assert len(msg["instances"]) == 1
assert msg["instances"][0]["input_addr"] == "tcp://stage:shutdown"
assert len(msg["replicas"]) == 1
assert msg["replicas"][0]["input_addr"] == "tcp://stage:shutdown"
# Send down status (simulating graceful shutdown).
client.update_info(status=StageStatus.DOWN)
client.update_info(status=ReplicaStatus.DOWN)
# Receive updated list (should have 0 active instances).
msg = _wait_for_instance_list(sub, expected_count=0)
# Receive updated list (should have 0 active replicas).
msg = _wait_for_replica_list(sub, expected_count=0)
assert msg is not None
assert len(msg["instances"]) == 0
assert len(msg["replicas"]) == 0
client.close()
coordinator.close()
@@ -171,18 +171,6 @@ def test_collect_initialized_clients_for_cleanup_deduplicates_clients():
assert cleanup_clients == [shared, extra]
def test_initialize_stages_rejects_non_diffusion_replicas_in_single_stage_mode():
engine = object.__new__(AsyncOmniEngine)
engine.single_stage_mode = True
engine.stage_configs = [types.SimpleNamespace(stage_id=0, runtime={"num_replicas": 2})]
with pytest.raises(
ValueError,
match="single_stage_mode only supports num_replicas > 1 for diffusion stages",
):
engine._validate_single_stage_mode_replica_constraints()
def test_initialize_diffusion_replica_restores_device_visibility_after_local_init(monkeypatch):
import vllm_omni.engine.async_omni_engine as engine_mod
from vllm_omni.platforms import current_omni_platform
@@ -427,6 +415,7 @@ def test_initialize_stages_cleans_up_successful_replicas_after_partial_multi_rep
engine.single_stage_mode = False
engine._single_stage_id_filter = None
engine._omni_master_server = None
engine._coordinator_runtime = None
engine.stage_configs = [types.SimpleNamespace()]
cfg0 = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
@@ -471,6 +460,7 @@ def test_initialize_stages_cleans_up_late_successful_replicas_after_early_multi_
engine.single_stage_mode = False
engine._single_stage_id_filter = None
engine._omni_master_server = None
engine._coordinator_runtime = None
engine.stage_configs = [types.SimpleNamespace()]
cfg0 = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
+1 -1
View File
@@ -1052,7 +1052,7 @@ async def test_stage_pool_abort_requests_logs_when_binding_is_missing(caplog) ->
target_logger.setLevel(prev_level)
assert not stage0.abort_calls
assert "abort: no binding for req=missing-req in stage-0" in caplog.text
assert "abort: no live binding for req=missing-req in stage-0" in caplog.text
@pytest.mark.asyncio
+54 -46
View File
@@ -203,6 +203,32 @@ class TestOmniMasterServerAllocation:
server = OmniMasterServer(master_address="127.0.0.1", master_port=15004, stage_ids=[3])
assert server.get_allocation(3) is server._stage_routes[(3, 0)]
def test_next_free_replica_id_skips_head_local_slot_until_filled(self):
# Head pre-allocates slot (0, 0) for its own register_stage_with_omni_master
# call. A same-host headless that registers with auto-assign BEFORE the
# head's own registration must NOT be handed slot 0 — it should land on
# slot 1 instead. Without the head_local_replicas reservation,
# _next_free_replica_id would see (0, 0) absent from _stage_configs and
# return 0, colliding with the head's own bound sockets.
server = OmniMasterServer(
master_address="127.0.0.1",
master_port=15010,
stage_ids=[0],
head_local_replicas={0: [0]},
)
assert server._next_free_replica_id(0) == 1
def test_next_free_replica_id_uses_remote_slot_when_unowned(self):
# When the head pre-allocates a remote-only slot (the head's _initialize_*
# path waits on get_stage_config), auto-assign SHOULD fill it so the
# head's wait unblocks. This is the original behavior, preserved.
server = OmniMasterServer(
master_address="127.0.0.1",
master_port=15011,
stage_ids=[0],
)
assert server._next_free_replica_id(0) == 0
# ---------------------------------------------------------------------------
# OmniMasterServer registration flow
@@ -233,36 +259,6 @@ class TestOmniMasterServerRegistration:
ctx.term()
server.stop()
def test_server_handles_unknown_stage_id_gracefully(self):
import msgspec
import zmq
from vllm.utils.network_utils import get_open_port
master_port = get_open_port()
server = OmniMasterServer(master_address="127.0.0.1", master_port=master_port, stage_ids=[0])
server.start()
ctx = zmq.Context()
bad_sock = None
good_sock = None
try:
bad_sock = ctx.socket(zmq.DEALER)
bad_sock.connect(f"tcp://127.0.0.1:{master_port}")
bad_sock.send(msgspec.msgpack.encode({"stage_id": 99}))
assert not bad_sock.poll(timeout=500)
good_sock = ctx.socket(zmq.DEALER)
good_sock.connect(f"tcp://127.0.0.1:{master_port}")
good_sock.send(msgspec.msgpack.encode({"stage_id": 0}))
assert good_sock.poll(timeout=2_000)
good_sock.recv()
finally:
for sock in (bad_sock, good_sock):
if sock is not None:
sock.close(linger=0)
ctx.term()
server.stop()
def test_registration_stores_stage_config(self):
import msgspec
import zmq
@@ -474,6 +470,8 @@ class TestSingleStageInitialization:
engine._omni_master_address = "127.0.0.1"
engine._omni_master_port = 26000
engine._omni_master_server = None
engine._omni_heartbeat_timeout = 30.0
engine._coordinator_runtime = None
engine.async_chunk = False
engine.diffusion_batch_size = 2
return engine
@@ -508,10 +506,16 @@ class TestSingleStageInitialization:
def test_start_omni_master_server_uses_configured_stage_ids(self, mocker: MockerFixture):
import vllm_omni.engine.async_omni_engine as engine_mod
from vllm_omni.distributed import omni_coordinator as omni_coord_mod
engine = self._build_engine([], single_stage_mode=True, stage_id_filter=7)
mock_oms = mocker.Mock(spec=OmniMasterServer)
mocker.patch.object(engine_mod, "OmniMasterServer", return_value=mock_oms)
mocker.patch.object(
omni_coord_mod,
"OmniCoordinatorRuntime",
return_value=mocker.Mock(router_address="tcp://127.0.0.1:9999"),
)
stage_plans = [
_make_llm_plan(0, configured_stage_id=7, launch_mode="local"),
@@ -520,12 +524,16 @@ class TestSingleStageInitialization:
engine._start_omni_master_server(stage_plans)
engine_mod.OmniMasterServer.assert_called_once_with(
master_address="127.0.0.1",
master_port=26000,
stage_ids=[7, 11],
stage_replica_counts={7: 1, 11: 1},
)
call_kwargs = engine_mod.OmniMasterServer.call_args.kwargs
assert call_kwargs["master_address"] == "127.0.0.1"
assert call_kwargs["master_port"] == 26000
assert call_kwargs["stage_ids"] == [7, 11]
assert call_kwargs["stage_replica_counts"] == {7: 1, 11: 1}
# head_local_replicas reserves slots that the head will register
# itself (launch_mode == "local"). Stage 11 is remote, so it must
# NOT appear in the head-owned set — that slot is for the headless
# to fill via auto-assign.
assert call_kwargs["head_local_replicas"] == {7: [0]}
mock_oms.start.assert_called_once()
def test_start_omni_master_server_duplicate_stage_ids_raise(self):
@@ -545,7 +553,9 @@ class TestSingleStageInitialization:
with pytest.raises(ValueError, match="requires both"):
engine._start_omni_master_server([_make_llm_plan(0, configured_stage_id=7, launch_mode="local")])
def test_build_logical_stage_init_plans_clears_runtime_cfg_in_single_stage_mode(self, mocker: MockerFixture):
def test_build_logical_stage_init_plans_preserves_runtime_cfg_for_local_llm_in_single_stage_mode(
self, mocker: MockerFixture
):
import vllm_omni.engine.async_omni_engine as engine_mod
engine = self._build_engine([_make_stage_cfg(7)], single_stage_mode=True, stage_id_filter=7)
@@ -574,7 +584,7 @@ class TestSingleStageInitialization:
finally:
monkeypatch.undo()
assert stage_plans[0].replicas[0].metadata.runtime_cfg is None
assert stage_plans[0].replicas[0].metadata.runtime_cfg == {"devices": "0"}
def test_validate_single_stage_mode_allows_diffusion_replicas(self):
stage_cfg = _make_stage_cfg(0, stage_type="diffusion")
@@ -583,14 +593,6 @@ class TestSingleStageInitialization:
engine._validate_single_stage_mode_replica_constraints()
def test_validate_single_stage_mode_rejects_llm_replicas(self):
stage_cfg = _make_stage_cfg(0, stage_type="llm")
stage_cfg.runtime.num_replicas = 2
engine = self._build_engine([stage_cfg], single_stage_mode=True, stage_id_filter=0)
with pytest.raises(ValueError, match="only supports num_replicas > 1 for diffusion"):
engine._validate_single_stage_mode_replica_constraints()
def test_build_logical_stage_init_plans_preserves_diffusion_runtime_cfg_in_single_stage_mode(
self, mocker: MockerFixture
):
@@ -809,6 +811,7 @@ class TestSingleStageReplicaInitialization:
engine.model = "fake-model"
engine.single_stage_mode = True
engine._omni_master_server = mocker.Mock(spec=OmniMasterServer)
engine._coordinator_runtime = None
engine.stage_configs = []
fake_vllm_config = SimpleNamespace(parallel_config=SimpleNamespace())
@@ -896,6 +899,7 @@ class TestSingleStageReplicaInitialization:
engine._omni_master_server = mocker.Mock(spec=OmniMasterServer)
engine._omni_master_server.address = "127.0.0.1"
engine._omni_master_server.port = 25000
engine._coordinator_runtime = None
plan = _make_diffusion_plan(0, configured_stage_id=5, launch_mode="local").replicas[0]
sentinel_client = SimpleNamespace()
@@ -950,6 +954,9 @@ class TestSingleStageReplicaInitialization:
handshake_address="tcp://hs",
request_address="tcp://req",
response_address="tcp://resp",
omni_coordinator_address=None,
omni_stage_id=5,
omni_replica_id=0,
)
mock_handshake.assert_called_once_with(proc, "tcp://hs", 60)
mock_from_addresses.assert_called_once_with(
@@ -972,6 +979,7 @@ class TestSingleStageReplicaInitialization:
engine._omni_master_server = mocker.Mock(spec=OmniMasterServer)
engine._omni_master_server.address = "127.0.0.1"
engine._omni_master_server.port = 25000
engine._coordinator_runtime = None
plan = _make_diffusion_plan(0, configured_stage_id=5, launch_mode="local").replicas[0]
proc = mocker.Mock()
+294 -152
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import argparse
from types import SimpleNamespace
from typing import Any
import pytest
from pytest_mock import MockerFixture
@@ -31,143 +33,118 @@ def test_serve_parser_accepts_no_async_chunk() -> None:
assert args.async_chunk is False
def _make_headless_args() -> argparse.Namespace:
return argparse.Namespace(
# ---------------------------------------------------------------------------
# run_headless validation
# ---------------------------------------------------------------------------
def _make_headless_args(**overrides: Any) -> argparse.Namespace:
"""Build an argparse.Namespace shaped like the headless CLI passes in.
Defaults pass every validation gate so individual tests can mutate just
the field they're exercising.
"""
defaults = dict(
model="fake-model",
stage_id=3,
stage_id=0,
replica_id=0,
omni_master_address="127.0.0.1",
omni_master_port=26000,
omni_replica_address=None,
omni_dp_size_local=1,
api_server_count=0,
worker_backend="multi_process",
stage_configs_path=None,
deploy_config=None,
log_stats=False,
disable_log_stats=False,
stage_init_timeout=600,
tokenizer=None,
)
defaults.update(overrides)
return argparse.Namespace(**defaults)
def test_run_headless_registers_stage_once_and_launches_all_local_engines(mocker: MockerFixture) -> None:
args = _make_headless_args()
stage_cfg = mocker.Mock(stage_id=3)
stage_cfgs = [stage_cfg]
parallel_config = mocker.Mock(
data_parallel_size_local=2,
data_parallel_rank=4,
data_parallel_rank_local=1,
node_rank_within_dp=0,
)
vllm_config = mocker.Mock(parallel_config=parallel_config)
vllm_config.needs_dp_coordinator = False
executor_class = mocker.Mock()
engine_manager = mocker.Mock()
def test_run_headless_requires_stage_id() -> None:
args = _make_headless_args(stage_id=None)
with pytest.raises(ValueError, match="--stage-id is required"):
run_headless(args)
def test_run_headless_requires_master_address() -> None:
args = _make_headless_args(omni_master_address=None)
with pytest.raises(ValueError, match="--omni-master-address and --omni-master-port"):
run_headless(args)
def test_run_headless_requires_master_port() -> None:
args = _make_headless_args(omni_master_port=None)
with pytest.raises(ValueError, match="--omni-master-address and --omni-master-port"):
run_headless(args)
def test_run_headless_rejects_multi_api_server_count() -> None:
args = _make_headless_args(api_server_count=2)
with pytest.raises(ValueError, match="api_server_count can't be set"):
run_headless(args)
def test_run_headless_rejects_non_multiprocess_worker_backend() -> None:
args = _make_headless_args(worker_backend="ray")
with pytest.raises(ValueError, match="worker_backend=multi_process"):
run_headless(args)
def test_run_headless_raises_when_stage_id_not_in_configs(mocker: MockerFixture) -> None:
"""Headless looks up its assigned stage_id in the loaded deploy YAML and
fails fast when the launcher's --stage-id doesn't match any entry."""
other_stage = SimpleNamespace(stage_id=99)
mocker.patch(
"vllm_omni.entrypoints.utils.load_and_resolve_stage_configs",
return_value=("/fake/stages.yaml", stage_cfgs),
return_value=("/fake/stages.yaml", [other_stage]),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=mocker.Mock())
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mock_build_vllm_config = mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config",
return_value=(vllm_config, executor_class),
)
mock_register = mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
return_value="tcp://127.0.0.1:26001",
)
mock_manager_cls = mocker.patch("vllm.v1.engine.utils.CoreEngineProcManager", return_value=engine_manager)
mocker.patch("signal.signal")
run_headless(args)
mock_build_vllm_config.assert_called_once_with(
stage_cfg,
"fake-model",
stage_connector_spec={},
engine_args_dict={},
headless=True,
)
mock_register.assert_called_once_with(
omni_master_address="127.0.0.1",
omni_master_port=26000,
omni_stage_id=3,
omni_stage_config=stage_cfg,
coordinator=None,
replica_id=0,
)
mock_manager_cls.assert_called_once()
manager_kwargs = mock_manager_cls.call_args.kwargs
assert manager_kwargs["local_engine_count"] == 2
assert manager_kwargs["start_index"] == 4
assert manager_kwargs["local_start_index"] == 0
assert manager_kwargs["local_client"] is False
assert manager_kwargs["handshake_address"] == "tcp://127.0.0.1:26001"
assert manager_kwargs["log_stats"] is False
engine_manager.monitor_engine_liveness.assert_called_once_with()
engine_manager.shutdown.assert_called_once_with()
args = _make_headless_args(stage_id=0)
with pytest.raises(ValueError, match="No stage config found for stage_id=0"):
run_headless(args)
def test_run_headless_honors_explicit_log_stats_flag(mocker: MockerFixture) -> None:
args = _make_headless_args()
args.log_stats = True
stage_cfg = mocker.Mock(stage_id=3)
stage_cfgs = [stage_cfg]
parallel_config = mocker.Mock(
data_parallel_size_local=2,
data_parallel_rank=4,
data_parallel_rank_local=1,
node_rank_within_dp=0,
)
vllm_config = mocker.Mock(parallel_config=parallel_config)
executor_class = mocker.Mock()
engine_manager = mocker.Mock()
mocker.patch(
"vllm_omni.entrypoints.utils.load_and_resolve_stage_configs",
return_value=("/fake/stages.yaml", stage_cfgs),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=mocker.Mock())
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config",
return_value=(vllm_config, executor_class),
)
mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
return_value="tcp://127.0.0.1:26001",
)
mock_manager_cls = mocker.patch("vllm.v1.engine.utils.CoreEngineProcManager", return_value=engine_manager)
mocker.patch("signal.signal")
run_headless(args)
manager_kwargs = mock_manager_cls.call_args.kwargs
assert manager_kwargs["log_stats"] is True
# ---------------------------------------------------------------------------
# run_headless happy paths
# ---------------------------------------------------------------------------
def test_run_headless_registers_llm_replica_id(mocker: MockerFixture) -> None:
args = _make_headless_args()
args.replica_id = 2
stage_cfg = mocker.Mock(stage_id=3)
parallel_config = mocker.Mock(
def _make_stage_cfg(stage_id: int, stage_type: str) -> SimpleNamespace:
"""Build a stage config that satisfies every attribute run_headless reads.
Notably ``engine_args`` is a real dict (not a Mock) so
``get_stage_devices_per_replica`` can call ``.get("tensor_parallel_size")``
and feed the result through ``int()`` without TypeError.
"""
return SimpleNamespace(
stage_id=stage_id,
stage_type=stage_type,
# No "devices" key -> split_devices_for_replicas skipped, each replica
# inherits the launcher's CUDA_VISIBLE_DEVICES.
runtime=None,
engine_args={},
)
def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: MockerFixture) -> None:
"""LLM headless: each loop iteration registers with auto-assigned
replica_id (master picks a free slot) and spawns one
``OmniCoreEngineProcManager`` per local replica."""
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(0, stage_type="llm")
parallel_config = SimpleNamespace(
data_parallel_size_local=1,
data_parallel_rank=0,
data_parallel_rank_local=0,
node_rank_within_dp=0,
)
vllm_config = mocker.Mock(parallel_config=parallel_config)
vllm_config.needs_dp_coordinator = False
vllm_config = SimpleNamespace(parallel_config=parallel_config, needs_dp_coordinator=False)
engine_manager = mocker.Mock()
mocker.patch(
@@ -175,82 +152,247 @@ def test_run_headless_registers_llm_replica_id(mocker: MockerFixture) -> None:
return_value=("/fake/stages.yaml", [stage_cfg]),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=mocker.Mock())
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None)
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config",
return_value=(vllm_config, mocker.Mock()),
return_value=(vllm_config, object),
)
mock_register = mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
return_value="tcp://127.0.0.1:26001",
return_value=StageRegistrationResponse(
handshake_address="tcp://127.0.0.1:26001",
input_address="tcp://127.0.0.1:26002",
output_address="tcp://127.0.0.1:26003",
replica_id=0,
coordinator_router_address="tcp://127.0.0.1:26100",
),
)
mock_manager_cls = mocker.patch(
"vllm_omni.engine.omni_core_engine_proc_manager.OmniCoreEngineProcManager",
return_value=engine_manager,
)
mocker.patch("vllm.v1.engine.utils.CoreEngineProcManager", return_value=engine_manager)
mocker.patch("signal.signal")
run_headless(args)
run_headless(_make_headless_args(stage_id=0))
assert mock_register.call_args.kwargs["replica_id"] == 2
# The launcher must request auto-assignment (replica_id=None) and the
# full response so it can wire the master-allocated coordinator into the
# spawned subprocess. ``replica_binds_sockets=False`` is required for LLM
# because the head binds all three sockets (handshake, input, output).
assert mock_register.call_count == 1
kwargs = mock_register.call_args.kwargs
assert kwargs["omni_master_address"] == "127.0.0.1"
assert kwargs["omni_master_port"] == 26000
assert kwargs["omni_stage_id"] == 0
assert kwargs["omni_stage_config"] is stage_cfg
assert kwargs["replica_id"] is None
assert kwargs["return_full_response"] is True
assert kwargs["replica_binds_sockets"] is False
assert mock_manager_cls.call_count == 1
mgr_kwargs = mock_manager_cls.call_args.kwargs
assert mgr_kwargs["local_engine_count"] == 1
assert mgr_kwargs["local_client"] is False
assert mgr_kwargs["handshake_address"] == "tcp://127.0.0.1:26001"
assert mgr_kwargs["omni_stage_id"] == 0
assert mgr_kwargs["omni_coordinator_address"] == "tcp://127.0.0.1:26100"
assert mgr_kwargs["omni_replica_base_id"] == 0
engine_manager.monitor_engine_liveness.assert_called_once_with()
engine_manager.shutdown.assert_called_once_with()
def test_run_headless_launches_diffusion_stage_via_omni_master(mocker: MockerFixture) -> None:
args = _make_headless_args()
args.replica_id = 1
stage_cfg = mocker.Mock(stage_id=3, stage_type="diffusion")
stage_cfg.engine_args = mocker.Mock()
stage_cfg.engine_input_source = []
stage_cfgs = [stage_cfg]
metadata = mocker.Mock(stage_id=3, stage_type="diffusion")
def test_run_headless_llm_launches_one_manager_per_omni_dp_size_local(mocker: MockerFixture) -> None:
"""``--omni-dp-size-local=N`` must spawn N managers, each with its own
master-assigned replica_id, and join all of them before returning."""
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(0, stage_type="llm")
parallel_config = SimpleNamespace(
data_parallel_size_local=1,
data_parallel_rank=0,
data_parallel_rank_local=0,
node_rank_within_dp=0,
)
vllm_config = SimpleNamespace(parallel_config=parallel_config, needs_dp_coordinator=False)
manager_a = mocker.Mock()
manager_b = mocker.Mock()
mocker.patch(
"vllm_omni.entrypoints.utils.load_and_resolve_stage_configs",
return_value=("/fake/stages.yaml", [stage_cfg]),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None)
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config",
return_value=(vllm_config, object),
)
mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
side_effect=[
StageRegistrationResponse(
handshake_address=f"tcp://127.0.0.1:2700{idx}",
input_address=f"tcp://127.0.0.1:2710{idx}",
output_address=f"tcp://127.0.0.1:2720{idx}",
replica_id=idx,
coordinator_router_address=None,
)
for idx in (0, 1)
],
)
mock_manager_cls = mocker.patch(
"vllm_omni.engine.omni_core_engine_proc_manager.OmniCoreEngineProcManager",
side_effect=[manager_a, manager_b],
)
mocker.patch("signal.signal")
run_headless(_make_headless_args(stage_id=0, omni_dp_size_local=2))
assert mock_manager_cls.call_count == 2
assigned_ids = [call.kwargs["omni_replica_base_id"] for call in mock_manager_cls.call_args_list]
assert assigned_ids == [0, 1]
# Multi-replica path joins the monitor threads instead of calling
# ``monitor_engine_liveness`` synchronously on the main thread, but every
# manager must still be shut down in the finally block.
manager_a.shutdown.assert_called_once_with()
manager_b.shutdown.assert_called_once_with()
def test_run_headless_diffusion_registers_and_spawns_proc(mocker: MockerFixture) -> None:
"""Diffusion headless: registers as auto-assign, spawns a single
``StageDiffusionProc`` per local replica, and waits for it via
``multiprocessing.connection.wait``."""
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(1, stage_type="diffusion")
od_config = mocker.Mock()
proc = mocker.Mock()
proc.exitcode = 0
proc = mocker.Mock(sentinel=object(), exitcode=0)
proc.is_alive.return_value = False
mocker.patch(
"vllm_omni.entrypoints.utils.load_and_resolve_stage_configs",
return_value=("/fake/stages.yaml", stage_cfgs),
return_value=("/fake/stages.yaml", [stage_cfg]),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=mocker.Mock())
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None)
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch("vllm_omni.engine.stage_init_utils.extract_stage_metadata", return_value=metadata)
mock_inject_stage_info = mocker.patch("vllm_omni.engine.stage_init_utils.inject_kv_stage_info")
mocker.patch(
"vllm_omni.engine.stage_init_utils.extract_stage_metadata",
return_value=SimpleNamespace(stage_id=1, stage_type="diffusion"),
)
mock_inject = mocker.patch("vllm_omni.engine.stage_init_utils.inject_kv_stage_info")
mocker.patch("vllm_omni.engine.stage_init_utils.build_diffusion_config", return_value=od_config)
mock_register = mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
return_value=("tcp://127.0.0.1:26001", "tcp://127.0.0.1:26002", "tcp://127.0.0.1:26003"),
return_value=StageRegistrationResponse(
handshake_address="tcp://127.0.0.1:26001",
input_address="tcp://127.0.0.1:26002",
output_address="tcp://127.0.0.1:26003",
replica_id=0,
coordinator_router_address="tcp://127.0.0.1:26100",
),
)
mock_spawn = mocker.patch(
"vllm_omni.diffusion.stage_diffusion_proc.spawn_diffusion_proc",
return_value=(proc, "tcp://127.0.0.1:26001", "tcp://127.0.0.1:26002", "tcp://127.0.0.1:26003"),
return_value=(proc, None, None, None),
)
mock_handshake = mocker.patch("vllm_omni.diffusion.stage_diffusion_proc.complete_diffusion_handshake")
# Replace the blocking wait with one that returns the only proc's sentinel
# immediately so the test does not hang.
mocker.patch(
"multiprocessing.connection.wait",
side_effect=lambda sentinels: [sentinels[0]],
)
mocker.patch("vllm_omni.engine.stage_init_utils.terminate_alive_proc")
mocker.patch("signal.signal")
run_headless(args)
mock_inject_stage_info.assert_called_once_with(stage_cfg, 3)
mock_register.assert_called_once_with(
omni_master_address="127.0.0.1",
omni_master_port=26000,
omni_stage_id=3,
omni_stage_config=stage_cfg,
return_addresses=True,
replica_id=1,
)
mock_spawn.assert_called_once_with(
"fake-model",
od_config,
handshake_address="tcp://127.0.0.1:26001",
request_address="tcp://127.0.0.1:26002",
response_address="tcp://127.0.0.1:26003",
)
run_headless(_make_headless_args(stage_id=1))
mock_inject.assert_called_once_with(stage_cfg, 1)
reg_kwargs = mock_register.call_args.kwargs
assert reg_kwargs["omni_master_address"] == "127.0.0.1"
assert reg_kwargs["omni_master_port"] == 26000
assert reg_kwargs["omni_stage_id"] == 1
assert reg_kwargs["omni_stage_config"] is stage_cfg
assert reg_kwargs["replica_id"] is None
assert reg_kwargs["return_full_response"] is True
spawn_kwargs = mock_spawn.call_args.kwargs
assert spawn_kwargs["handshake_address"] == "tcp://127.0.0.1:26001"
assert spawn_kwargs["request_address"] == "tcp://127.0.0.1:26002"
assert spawn_kwargs["response_address"] == "tcp://127.0.0.1:26003"
assert spawn_kwargs["omni_coordinator_address"] == "tcp://127.0.0.1:26100"
assert spawn_kwargs["omni_stage_id"] == 1
assert spawn_kwargs["omni_replica_id"] == 0
mock_handshake.assert_called_once_with(proc, "tcp://127.0.0.1:26001", 600)
proc.join.assert_called_once_with()
def test_run_headless_diffusion_raises_on_nonzero_proc_exit(mocker: MockerFixture) -> None:
"""A diffusion replica that exits with a non-zero code must surface as a
RuntimeError from ``run_headless`` (the head needs the signal to roll
back its own stage init)."""
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(1, stage_type="diffusion")
proc = mocker.Mock(sentinel=object(), exitcode=137, name="proc-stage1-rep0")
proc.is_alive.return_value = False
mocker.patch(
"vllm_omni.entrypoints.utils.load_and_resolve_stage_configs",
return_value=("/fake/stages.yaml", [stage_cfg]),
)
mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment")
mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None)
mocker.patch(
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch(
"vllm_omni.engine.stage_init_utils.extract_stage_metadata",
return_value=SimpleNamespace(stage_id=1, stage_type="diffusion"),
)
mocker.patch("vllm_omni.engine.stage_init_utils.inject_kv_stage_info")
mocker.patch("vllm_omni.engine.stage_init_utils.build_diffusion_config", return_value=mocker.Mock())
mocker.patch(
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
return_value=StageRegistrationResponse(
handshake_address="tcp://127.0.0.1:26001",
input_address="tcp://127.0.0.1:26002",
output_address="tcp://127.0.0.1:26003",
replica_id=0,
coordinator_router_address=None,
),
)
mocker.patch(
"vllm_omni.diffusion.stage_diffusion_proc.spawn_diffusion_proc",
return_value=(proc, None, None, None),
)
mocker.patch("vllm_omni.diffusion.stage_diffusion_proc.complete_diffusion_handshake")
mocker.patch(
"multiprocessing.connection.wait",
side_effect=lambda sentinels: [sentinels[0]],
)
mocker.patch("vllm_omni.engine.stage_init_utils.terminate_alive_proc")
mocker.patch("signal.signal")
with pytest.raises(RuntimeError, match=r"exited with code 137"):
run_headless(_make_headless_args(stage_id=1))
+23 -4
View File
@@ -17,6 +17,7 @@ from threading import Thread
from typing import TYPE_CHECKING, Any
import zmq
import zmq.asyncio
from vllm.logger import init_logger
from vllm.v1.engine.exceptions import EngineDeadError
@@ -124,6 +125,11 @@ class StageDiffusionClient(StageClientBase):
self.engine_input_source = getattr(metadata, "engine_input_source", [])
self._proc = proc
self._owns_process = proc is not None
# Expose the ZMQ addresses on the instance so callers (e.g.
# ``StagePool._client_input_addr``) can identify the diffusion
# replica by its bound address.
self.request_address = request_address
self.response_address = response_address
self._zmq_ctx = zmq.Context()
self._request_socket = self._zmq_ctx.socket(zmq.PUSH)
@@ -131,6 +137,9 @@ class StageDiffusionClient(StageClientBase):
self._response_socket = self._zmq_ctx.socket(zmq.PULL)
self._response_socket.connect(response_address)
self._response_poller = zmq.asyncio.Poller()
self._response_poller.register(self._response_socket, zmq.POLLIN)
self._encoder = OmniMsgpackEncoder()
self._decoder = OmniMsgpackDecoder()
@@ -468,17 +477,27 @@ class StageDiffusionClient(StageClientBase):
try:
while True:
self._drain_responses()
if rpc_id in self._rpc_results:
return self._rpc_results.pop(rpc_id)
result = self._rpc_results.pop(rpc_id, None)
if result is not None:
return result
if self._engine_dead or (self._owns_process and self._proc is not None and not self._proc.is_alive()):
self._engine_dead = True
raise EngineDeadError(
f"StageDiffusionProc died while waiting for "
f"collective_rpc '{method}' (exit code {self._proc.exitcode})"
)
if deadline and time.monotonic() > deadline:
if deadline is not None and time.monotonic() > deadline:
raise TimeoutError(f"collective_rpc_async '{method}' timed out after {timeout}s")
await asyncio.sleep(0.01)
# Block (async) until data arrives on the ZMQ response
# socket or until the timeout expires, then loop back to
# drain and check.
if deadline is not None:
poll_timeout_ms = max(int((deadline - time.monotonic()) * 1000), 0)
else:
poll_timeout_ms = 100
# no exception raised on timeout (capped at 100ms so the
# engine-dead check still fires regularly).
await self._response_poller.poll(timeout=min(poll_timeout_ms, 100))
finally:
self._pending_rpcs.discard(rpc_id)
+158 -2
View File
@@ -7,6 +7,7 @@ communicating with StageDiffusionClient via ZMQ (PUSH/PULL).
from __future__ import annotations
import asyncio
import contextlib
import signal
import time
from concurrent.futures import ThreadPoolExecutor
@@ -30,6 +31,7 @@ from vllm_omni.distributed.omni_connectors.utils.serialization import (
OmniMsgpackDecoder,
OmniMsgpackEncoder,
)
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -54,6 +56,56 @@ class StageDiffusionProc:
self._engine: DiffusionEngine | None = None
self._executor: ThreadPoolExecutor | None = None
self._closed = False
# Set by ``run_loop`` to the live dispatch task dict so
# :attr:`queue_length` can report in-flight requests for the
# OmniCoordinator heartbeat hook.
self._active_tasks: dict[str, asyncio.Task] | None = None
# Set when a request-handler detects that the engine's multiproc
# executor has died (e.g. a worker process crashed and the executor's
# monitor thread closed it). Once set, ``run_loop`` breaks out so the
# outer ``except``/``finally`` can send ``DIFFUSION_PROC_DEAD`` and
# ``ReplicaStatus.DOWN``, then the subprocess exits non-zero. Without
# this, the run_loop would swallow per-request errors and keep
# serving 500s indefinitely while heartbeats still report UP.
self._fatal_event: asyncio.Event | None = None
@property
def queue_length(self) -> int:
"""Number of in-flight diffusion requests.
Returns 0 before :meth:`run_loop` starts and after it exits.
"""
tasks = self._active_tasks
return 0 if tasks is None else len(tasks)
def _is_executor_dead(self) -> bool:
"""True iff the multiproc executor has been closed or marked failed.
Detects the "workers died but the diffusion proc is still pulling
requests" case: ``MultiprocDiffusionExecutor`` sets ``_closed = True``
and ``is_failed = True`` from its worker-monitor thread the moment any
worker process exits; every subsequent ``execute_request`` /
``collective_rpc`` then raises ``RuntimeError("DiffusionExecutor is
closed.")`` inside the engine. Callers in ``run_loop`` use this to
decide whether a per-request failure is recoverable or fatal.
"""
if self._engine is None:
return False
executor = getattr(self._engine, "executor", None)
if executor is None:
return False
return bool(getattr(executor, "_closed", False) or getattr(executor, "is_failed", False))
def _signal_fatal_engine_failure(self, reason: str) -> None:
"""Idempotently signal ``run_loop`` to tear down on a fatal engine error."""
if self._fatal_event is None or self._fatal_event.is_set():
return
logger.error(
"[StageDiffusionProc] fatal engine failure detected (%s); "
"signaling run_loop to send DIFFUSION_PROC_DEAD and exit.",
reason,
)
self._fatal_event.set()
# ------------------------------------------------------------------
# Initialization
@@ -309,6 +361,14 @@ class StageDiffusionProc:
decoder = OmniMsgpackDecoder()
tasks: dict[str, asyncio.Task] = {}
# Expose the live task dict so :attr:`queue_length` (used by the
# OmniCoordinator heartbeat hook) can read the in-flight count.
self._active_tasks = tasks
# Wakes the main recv loop when a request-handler detects a fatal
# engine failure so we tear down promptly instead of swallowing
# "DiffusionExecutor is closed" on every subsequent request.
fatal_event = asyncio.Event()
self._fatal_event = fatal_event
async def _dispatch_request(
request_id: str,
@@ -342,12 +402,40 @@ class StageDiffusionProc:
}
)
)
# Per-request errors are usually recoverable, but a closed
# executor means every future request will get the same
# "DiffusionExecutor is closed" error. Signal the main loop
# to send DIFFUSION_PROC_DEAD and exit so the head's hub
# demotes this replica instead of waiting on the heartbeat
# timeout (~30 s by default).
if self._is_executor_dead():
self._signal_fatal_engine_failure(f"add_request {request_id}: {e!s}")
finally:
tasks.pop(request_id, None)
try:
while True:
raw = await request_socket.recv()
# Await recv and fatal_event concurrently so the loop wakes
# up immediately when a per-request handler signals a fatal
# engine failure — even if no fresh ZMQ frame arrives.
recv_task: asyncio.Task = asyncio.ensure_future(request_socket.recv())
fatal_task: asyncio.Task = asyncio.ensure_future(fatal_event.wait())
try:
done, pending = await asyncio.wait(
[recv_task, fatal_task],
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for waiter in (recv_task, fatal_task):
if not waiter.done():
waiter.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await waiter
if fatal_event.is_set():
raise RuntimeError(
"StageDiffusionProc executor reported permanent failure; tearing down the diffusion subprocess."
)
raw = recv_task.result()
msg = decoder.decode(raw)
msg_type = msg.get("type")
@@ -397,6 +485,11 @@ class StageDiffusionProc:
}
)
)
# Same rationale as the single-request path: a
# closed executor turns every subsequent batch
# into a 500, so escalate now.
if self._is_executor_dead():
self._signal_fatal_engine_failure(f"add_batch_request {rid}: {e!s}")
finally:
tasks.pop(rid, None)
@@ -446,6 +539,13 @@ class StageDiffusionProc:
}
)
)
# Collective RPCs run through the same multiproc
# executor — a closed executor means every future
# RPC fails the same way, so tear down promptly.
if self._is_executor_dead():
self._signal_fatal_engine_failure(
f"collective_rpc {msg['method']} (rpc_id={rpc_id}): {e!s}"
)
elif msg_type == "shutdown":
break
@@ -466,6 +566,8 @@ class StageDiffusionProc:
if tasks:
await asyncio.gather(*tasks.values(), return_exceptions=True)
self._active_tasks = None
self._fatal_event = None
request_socket.close()
response_socket.close()
ctx.term()
@@ -504,8 +606,22 @@ class StageDiffusionProc:
handshake_address: str,
request_address: str,
response_address: str,
*,
omni_coordinator_address: str | None = None,
omni_stage_id: int | None = None,
omni_replica_id: int = 0,
) -> None:
"""Entry point for the diffusion subprocess."""
"""Entry point for the diffusion subprocess.
Omni-specific kwargs (mirroring :meth:`StageEngineCoreProc.run_stage_core`):
- ``omni_coordinator_address``: ROUTER address of the head-side
OmniCoordinator. When set, a :class:`OmniCoordClientForStage`
reports the diffusion replica's status + queue length.
- ``omni_stage_id``: logical stage id; required when
``omni_coordinator_address`` is set.
- ``omni_replica_id``: cluster-unique replica id within the
stage (logging / metrics only).
"""
shutdown_requested = False
def signal_handler(signum: int, frame: Any) -> None:
@@ -518,6 +634,7 @@ class StageDiffusionProc:
signal.signal(signal.SIGINT, signal_handler)
proc = cls(model, od_config)
coord_client: OmniCoordClientForStage | None = None
try:
proc.initialize()
@@ -529,6 +646,32 @@ class StageDiffusionProc:
handshake_socket.close()
handshake_ctx.term()
# Wire OmniCoordClientForStage *after* READY so that the head
# has bound its head-side request/response sockets — the
# address pair we report is the same pair this proc binds to
# (request/response addresses passed in).
if omni_coordinator_address is not None:
if omni_stage_id is None:
raise ValueError("omni_stage_id must be provided when omni_coordinator_address is set")
coord_client = OmniCoordClientForStage(
coord_zmq_addr=omni_coordinator_address,
input_addr=request_address,
output_addr=response_address,
stage_id=int(omni_stage_id),
)
def _refresh_queue_length() -> None:
coord_client._queue_length = proc.queue_length # type: ignore[union-attr]
coord_client._on_heartbeat = _refresh_queue_length
logger.info(
"StageDiffusionProc registered with OmniCoordinator (stage_id=%d replica_id=%d coord=%s)",
omni_stage_id,
omni_replica_id,
omni_coordinator_address,
)
# Run async event loop
asyncio.run(proc.run_loop(request_address, response_address))
@@ -539,6 +682,9 @@ class StageDiffusionProc:
logger.exception("StageDiffusionProc encountered a fatal error.")
raise
finally:
if coord_client is not None:
with contextlib.suppress(RuntimeError):
coord_client.close()
proc.close()
@@ -551,10 +697,17 @@ def spawn_diffusion_proc(
handshake_address: str | None = None,
request_address: str | None = None,
response_address: str | None = None,
*,
omni_coordinator_address: str | None = None,
omni_stage_id: int | None = None,
omni_replica_id: int = 0,
) -> tuple[BaseProcess, str, str, str]:
"""Spawn a StageDiffusionProc subprocess.
Returns ``(proc, handshake_address, request_address, response_address)``.
Pass ``omni_coordinator_address`` / ``omni_stage_id`` / ``omni_replica_id``
to have the subprocess publish heartbeats to an OmniCoordinator.
"""
handshake_address = handshake_address or get_open_zmq_ipc_path()
request_address = request_address or get_open_zmq_ipc_path()
@@ -570,6 +723,9 @@ def spawn_diffusion_proc(
"handshake_address": handshake_address,
"request_address": request_address,
"response_address": response_address,
"omni_coordinator_address": omni_coordinator_address,
"omni_stage_id": omni_stage_id,
"omni_replica_id": omni_replica_id,
},
)
proc.start()
@@ -21,6 +21,7 @@ from .utils.kv_utils import (
build_rank_aware_send_keys,
get_kv_target_ranks,
get_local_tp_rank,
get_omni_replica_id,
get_tp_world_size,
kv_zmq_port,
merge_received_rank_shards,
@@ -415,7 +416,13 @@ class OmniKVTransferManager:
stage_int = int(self.config.from_stage) if self.config.from_stage is not None else 0
except (TypeError, ValueError):
stage_int = 0
zmq_port = kv_zmq_port(base_port, stage_int, self._tp_topo.local_rank)
replica_id = get_omni_replica_id()
zmq_port = kv_zmq_port(
base_port,
stage_int,
self._tp_topo.local_rank,
replica_id=replica_id,
)
if self.config.need_send_cache:
c_extra["role"] = "sender"
@@ -26,9 +26,17 @@ KV_TRANSFER_PORT_OFFSET = 100
# Port stride between TP ranks so each worker binds a unique ZMQ port
# when TP > 1. Must be larger than the maximum number of pipeline stages.
# Formula: zmq_port = base + KV_TRANSFER_PORT_OFFSET + rank * STRIDE + stage
# Formula:
# zmq_port = base + KV_TRANSFER_PORT_OFFSET
# + replica * KV_REPLICA_PORT_STRIDE
# + rank * KV_RANK_PORT_STRIDE
# + stage
KV_RANK_PORT_STRIDE = 16
# Port stride between Omni replicas of the same stage. This reserves a
# comfortably sized block per replica for TP-rank and stage offsets.
KV_REPLICA_PORT_STRIDE = 1024
def initialize_connectors_from_config(
config_path: str | Path | None = None,
@@ -16,7 +16,7 @@ from vllm.distributed.parallel_state import (
)
from vllm.logger import init_logger
from .initialization import KV_RANK_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET
from .initialization import KV_RANK_PORT_STRIDE, KV_REPLICA_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET
logger = init_logger(__name__)
@@ -94,20 +94,42 @@ def get_tp_world_size() -> int:
return 1
def get_omni_replica_id() -> int:
"""Return the Omni replica id for this worker process."""
try:
replica_id = int(os.environ.get("VLLM_OMNI_REPLICA_ID", "0"))
except (ValueError, TypeError):
return 0
return max(replica_id, 0)
# ------------------------------------------------------------------ #
# ZMQ port computation
# ------------------------------------------------------------------ #
def kv_zmq_port(base_port: int, from_stage: int, local_rank: int = 0) -> int:
def kv_zmq_port(
base_port: int,
from_stage: int,
local_rank: int = 0,
replica_id: int | None = None,
) -> int:
"""Compute the ZMQ port for a KV-transfer connector.
Each TP rank gets its own port so that TP > 1 deployments do not
cause ``EADDRINUSE`` when multiple sender workers bind on the same
host. The formula is backward-compatible: rank 0 produces the same
port as the previous ``base + OFFSET + stage`` formula.
Each Omni replica and TP rank gets its own port so multi-replica or
TP > 1 deployments do not cause ``EADDRINUSE`` when multiple sender
workers bind on the same host. The formula is backward-compatible:
replica 0 / rank 0 produces the previous ``base + OFFSET + stage`` port.
"""
return base_port + KV_TRANSFER_PORT_OFFSET + local_rank * KV_RANK_PORT_STRIDE + from_stage
replica = get_omni_replica_id() if replica_id is None else max(int(replica_id), 0)
return (
base_port
+ KV_TRANSFER_PORT_OFFSET
+ replica * KV_REPLICA_PORT_STRIDE
+ local_rank * KV_RANK_PORT_STRIDE
+ from_stage
)
# ------------------------------------------------------------------ #
@@ -8,18 +8,21 @@ from .load_balancer import (
RandomBalancer,
RoundRobinBalancer,
Task,
build_load_balancer_factory,
)
from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus
from .messages import ReplicaEvent, ReplicaInfo, ReplicaList, ReplicaStatus
from .omni_coord_client_for_hub import OmniCoordClientForHub
from .omni_coord_client_for_stage import OmniCoordClientForStage
from .omni_coordinator import OmniCoordinator
from .runtime import OmniCoordinatorRuntime
__all__ = [
"OmniCoordinator",
"StageStatus",
"InstanceEvent",
"InstanceInfo",
"InstanceList",
"OmniCoordinatorRuntime",
"ReplicaStatus",
"ReplicaEvent",
"ReplicaInfo",
"ReplicaList",
"OmniCoordClientForStage",
"OmniCoordClientForHub",
"Task",
@@ -28,4 +31,5 @@ __all__ = [
"RandomBalancer",
"RoundRobinBalancer",
"LeastQueueLengthBalancer",
"build_load_balancer_factory",
]
@@ -6,17 +6,18 @@ from __future__ import annotations
import random
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable
from enum import Enum
from typing import Any, TypedDict
from .messages import InstanceInfo
from .messages import ReplicaInfo
class Task(TypedDict, total=False):
"""Task structure passed from async_omni (stage.submit(task)).
"""Task structure passed to ``StagePool.pick`` / ``LoadBalancer.select``.
Mirrors the dict built in AsyncOmni with request_id, engine_inputs,
sampling_params. Future load-balancing policies may use these fields.
Mirrors the dict built around a stage submission with request_id and any
payload-related fields a future load-balancing policy might inspect.
"""
request_id: str
@@ -28,10 +29,7 @@ class LoadBalancingPolicy(str, Enum):
"""Enumeration for load balancing policies.
These policies are used by :class:`LoadBalancer` implementations to route
tasks to a subset of available instances.
TODO(NumberWan): Map enum values to balancer classes when OmniCoordinator
integration lands. Tracked in https://github.com/vllm-project/vllm-omni/pull/2448
tasks to a subset of available replicas.
"""
RANDOM = "random"
@@ -42,74 +40,60 @@ class LoadBalancingPolicy(str, Enum):
class LoadBalancer(ABC):
"""Abstract base class for load balancers.
Subclasses implement :meth:`select` to choose an instance for a given task.
Subclasses implement :meth:`select` to choose a replica for a given task.
"""
@abstractmethod
def select(self, task: Task, instances: list[InstanceInfo]) -> int:
"""Route a task to one of the available instances.
def select(self, task: Task, replicas: list[ReplicaInfo]) -> int:
"""Route a task to one of the available replicas.
Args:
task: The task to route. Not used by the random policy but reserved
for future strategies that may inspect task metadata.
instances: List of available instances to choose from.
replicas: List of available replicas to choose from.
Returns:
Index of the selected instance in ``instances``.
Index of the selected replica in ``replicas``.
Raises:
ValueError: If ``instances`` is empty.
ValueError: If ``replicas`` is empty.
"""
raise NotImplementedError
class RandomBalancer(LoadBalancer):
"""Load balancer that selects an instance uniformly at random.
"""Load balancer that selects a replica uniformly at random."""
It intentionally ignores the task payload and chooses a random index from
the provided instance list. More sophisticated policies (e.g. round-robin,
least-queue-length) can be implemented as additional subclasses of
:class:`LoadBalancer`.
"""
def select(self, task: Task, replicas: list[ReplicaInfo]) -> int: # noqa: ARG002
if not replicas:
raise ValueError("replicas must not be empty")
def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
if not instances:
raise ValueError("instances must not be empty")
return random.randrange(len(instances))
return random.randrange(len(replicas))
class RoundRobinBalancer(LoadBalancer):
"""Load balancer that selects instances in a round-robin fashion.
"""Load balancer that selects replicas in a round-robin fashion.
This implementation keeps a running index modulo ``len(instances)``. It
therefore depends on the **order and stable meaning** of the ``instances``
This implementation keeps a running index modulo ``len(replicas)``. It
therefore depends on the **order and stable meaning** of the ``replicas``
list between calls. If the list length or ordering changes, the sequence
of picks may skip or repeat entries relative to a fixed set of backends.
When instance membership changes dynamically, callers should reset routing
state—for example by constructing a new ``RoundRobinBalancer`` or resetting
``_next_index``—similar to rebuilding ``itertools.cycle`` after mutating
the instance list (as in vLLM's disaggregated proxy examples).
Concurrency: ``select`` is synchronous and is expected to run on the
coordinator asyncio event loop thread without ``await`` inside this
method, so a single invocation is not interleaved with another on that
thread. A :class:`threading.Lock` still serializes updates to
``_next_index`` for callers that might invoke ``select`` from multiple
threads or alongside threaded infrastructure (e.g. ZMQ receive threads).
Concurrency: a ``threading.Lock`` serializes updates to ``_next_index``
for callers that invoke ``select`` from multiple threads or alongside
threaded infrastructure (e.g. ZMQ receive threads).
"""
def __init__(self, start_index: int = 0) -> None:
self._next_index = start_index
self._lock = threading.Lock()
def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
if not instances:
raise ValueError("instances must not be empty")
def select(self, task: Task, replicas: list[ReplicaInfo]) -> int: # noqa: ARG002
if not replicas:
raise ValueError("replicas must not be empty")
n = len(instances)
n = len(replicas)
with self._lock:
idx = self._next_index % n
self._next_index = (self._next_index + 1) % n
@@ -117,27 +101,43 @@ class RoundRobinBalancer(LoadBalancer):
class LeastQueueLengthBalancer(LoadBalancer):
"""Select the instance with the smallest ``queue_length``.
"""Select the replica with the smallest ``queue_length``.
If multiple instances share the same minimum queue length, one of them is
If multiple replicas share the same minimum queue length, one of them is
chosen uniformly at random.
Raises:
ValueError: If any instance has a negative ``queue_length``.
ValueError: If any replica has a negative ``queue_length``.
"""
def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
if not instances:
raise ValueError("instances must not be empty")
def select(self, task: Task, replicas: list[ReplicaInfo]) -> int: # noqa: ARG002
if not replicas:
raise ValueError("replicas must not be empty")
queue_lengths = [inst.queue_length for inst in instances]
queue_lengths = [rep.queue_length for rep in replicas]
if any(q < 0 for q in queue_lengths):
raise ValueError("queue_length must be non-negative for all instances")
raise ValueError("queue_length must be non-negative for all replicas")
min_q = min(queue_lengths)
candidates = [i for i, q in enumerate(queue_lengths) if q == min_q]
return random.choice(candidates)
def build_load_balancer_factory(policy: str) -> Callable[[], LoadBalancer]:
"""Translate ``--omni-lb-policy`` (string) into a per-pool LB factory."""
try:
normalized = LoadBalancingPolicy(policy)
except ValueError as exc:
valid = ", ".join(p.value for p in LoadBalancingPolicy)
raise ValueError(f"unknown --omni-lb-policy {policy!r} (valid: {valid})") from exc
if normalized is LoadBalancingPolicy.RANDOM:
return RandomBalancer
if normalized is LoadBalancingPolicy.ROUND_ROBIN:
return RoundRobinBalancer
if normalized is LoadBalancingPolicy.LEAST_QUEUE_LENGTH:
return LeastQueueLengthBalancer
raise ValueError(f"unhandled load balancing policy {normalized!r}")
__all__ = [
"Task",
"LoadBalancingPolicy",
@@ -145,4 +145,5 @@ __all__ = [
"RandomBalancer",
"RoundRobinBalancer",
"LeastQueueLengthBalancer",
"build_load_balancer_factory",
]
@@ -7,55 +7,55 @@ from dataclasses import dataclass
from enum import Enum
class StageStatus(str, Enum):
"""Enumeration for stage instance status."""
class ReplicaStatus(str, Enum):
"""Enumeration for stage replica status."""
UP = "up" # Instance is ready and available
DOWN = "down" # Instance is shutdown gracefully
ERROR = "error" # Instance encountered an error or timeout
UP = "up" # Replica is ready and available
DOWN = "down" # Replica is shutdown gracefully
ERROR = "error" # Replica encountered an error or timeout
@dataclass
class InstanceEvent:
class ReplicaEvent:
"""Wire payload from OmniCoordClientForStage to OmniCoordinator.
Schema for Stage → Coordinator events over ZMQ:
input_addr, output_addr, stage_id, status, queue_length, event_type.
"""
input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port")
output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port")
input_addr: str # Stage replica input ZMQ address (e.g., "tcp://host:port")
output_addr: str # Stage replica output ZMQ address (e.g., "tcp://host:port")
stage_id: int # Stage ID
event_type: str # "update" | "heartbeat"
status: StageStatus # Current status
status: ReplicaStatus # Current status
queue_length: int # Current queue length
@dataclass
class InstanceInfo:
"""Metadata for a single stage instance.
class ReplicaInfo:
"""Metadata for a single stage replica.
This type is stored in OmniCoordinator's internal registry and is also
published to hubs via :class:`InstanceList`.
published to hubs via :class:`ReplicaList`.
"""
input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port")
output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port")
stage_id: int # Stage ID of this instance
status: StageStatus # Current status of the instance
queue_length: int # Current queue length of this instance
input_addr: str # Stage replica input ZMQ address (e.g., "tcp://host:port")
output_addr: str # Stage replica output ZMQ address (e.g., "tcp://host:port")
stage_id: int # Stage ID of this replica
status: ReplicaStatus # Current status of the replica
queue_length: int # Current queue length of this replica
last_heartbeat: float # Timestamp of the last heartbeat received (seconds)
registered_at: float # Timestamp when the instance was registered (seconds)
registered_at: float # Timestamp when the replica was registered (seconds)
@dataclass
class InstanceList:
"""Container for instance list updates.
class ReplicaList:
"""Container for replica list updates.
OmniCoordinator publishes an :class:`InstanceList` whenever its view of
active instances changes. OmniCoordClientForHub caches the latest value
OmniCoordinator publishes a :class:`ReplicaList` whenever its view of
active replicas changes. OmniCoordClientForHub caches the latest value
and exposes it to AsyncOmni and the load balancer.
"""
instances: list[InstanceInfo]
replicas: list[ReplicaInfo]
timestamp: float # Time when the list was last updated (seconds)
@@ -9,16 +9,16 @@ from typing import Any
import zmq
from .messages import InstanceInfo, InstanceList, StageStatus
from .messages import ReplicaInfo, ReplicaList, ReplicaStatus
logger = logging.getLogger(__name__)
class OmniCoordClientForHub:
"""Client for AsyncOmni side to receive instance list updates.
"""Client for AsyncOmni side to receive replica list updates.
This client maintains a SUB socket connected to OmniCoordinator's PUB
endpoint and caches the latest :class:`InstanceList` in memory for use by
endpoint and caches the latest :class:`ReplicaList` in memory for use by
the load balancer and routing logic.
"""
@@ -28,7 +28,7 @@ class OmniCoordClientForHub:
self._ctx = zmq.Context()
self._lock = threading.Lock()
self._instance_list: InstanceList | None = None
self._replica_list: ReplicaList | None = None
self._closed = False
self._stop_event = threading.Event()
self._init_done = threading.Event()
@@ -41,29 +41,29 @@ class OmniCoordClientForHub:
if self._init_error:
raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}") from self._init_error[0]
def _decode_instance_list(self, payload: dict[str, Any]) -> InstanceList:
"""Convert a JSON-decoded dict into an :class:`InstanceList`."""
instances_payload = payload.get("instances", [])
instances: list[InstanceInfo] = []
for inst in instances_payload:
instances.append(
InstanceInfo(
input_addr=inst["input_addr"],
output_addr=inst["output_addr"],
stage_id=int(inst["stage_id"]),
status=StageStatus(inst["status"]),
queue_length=int(inst["queue_length"]),
last_heartbeat=float(inst["last_heartbeat"]),
registered_at=float(inst["registered_at"]),
def _decode_replica_list(self, payload: dict[str, Any]) -> ReplicaList:
"""Convert a JSON-decoded dict into a :class:`ReplicaList`."""
replicas_payload = payload.get("replicas", [])
replicas: list[ReplicaInfo] = []
for rep in replicas_payload:
replicas.append(
ReplicaInfo(
input_addr=rep["input_addr"],
output_addr=rep["output_addr"],
stage_id=int(rep["stage_id"]),
status=ReplicaStatus(rep["status"]),
queue_length=int(rep["queue_length"]),
last_heartbeat=float(rep["last_heartbeat"]),
registered_at=float(rep["registered_at"]),
)
)
timestamp = float(payload.get("timestamp", time()))
return InstanceList(instances=instances, timestamp=timestamp)
return ReplicaList(replicas=replicas, timestamp=timestamp)
def _recv_loop(self) -> None:
"""Background loop that receives and caches instance lists."""
sub = None
"""Background loop that receives and caches replica lists."""
sub: zmq.Socket | None = None
try:
sub = self._ctx.socket(zmq.SUB)
sub.setsockopt(zmq.SUBSCRIBE, b"")
@@ -115,9 +115,9 @@ class OmniCoordClientForHub:
try:
payload = json.loads(data.decode("utf-8"))
inst_list = self._decode_instance_list(payload)
rep_list = self._decode_replica_list(payload)
with self._lock:
self._instance_list = inst_list
self._replica_list = rep_list
except (
json.JSONDecodeError,
KeyError,
@@ -125,7 +125,7 @@ class OmniCoordClientForHub:
TypeError,
AttributeError,
) as e:
logger.warning("Invalid instance list message, skipping: %s", e)
logger.warning("Invalid replica list message, skipping: %s", e)
finally:
try:
if sub is not None:
@@ -137,22 +137,22 @@ class OmniCoordClientForHub:
except zmq.ZMQError:
pass
def get_instance_list(self) -> InstanceList:
"""Return the latest cached :class:`InstanceList`.
def get_replica_list(self) -> ReplicaList:
"""Return the latest cached :class:`ReplicaList`.
If no update has been received yet, returns an empty list with
``timestamp=0.0``.
"""
with self._lock:
if self._instance_list is None:
return InstanceList(instances=[], timestamp=0.0)
return self._instance_list
if self._replica_list is None:
return ReplicaList(replicas=[], timestamp=0.0)
return self._replica_list
def get_instances_for_stage(self, stage_id: int) -> InstanceList:
"""Return instances filtered by ``stage_id``."""
base = self.get_instance_list()
filtered = [inst for inst in base.instances if inst.stage_id == stage_id]
return InstanceList(instances=filtered, timestamp=base.timestamp)
def get_replicas_for_stage(self, stage_id: int) -> ReplicaList:
"""Return replicas filtered by ``stage_id``."""
base = self.get_replica_list()
filtered = [rep for rep in base.replicas if rep.stage_id == stage_id]
return ReplicaList(replicas=filtered, timestamp=base.timestamp)
def close(self) -> None:
"""Close the SUB socket and stop the background thread."""
@@ -1,24 +1,26 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import contextlib
import json
import logging
import threading
import time
from collections.abc import Callable
from dataclasses import asdict
import zmq
from .messages import InstanceEvent, StageStatus
from .messages import ReplicaEvent, ReplicaStatus
logger = logging.getLogger(__name__)
class OmniCoordClientForStage:
"""Client used by stage instances to send events to OmniCoordinator.
"""Client used by stage replicas to send events to OmniCoordinator.
This client maintains a DEALER socket connected to OmniCoordinator's
ROUTER endpoint and sends JSON-encoded events describing instance status.
ROUTER endpoint and sends JSON-encoded events describing replica status.
"""
def __init__(
@@ -42,13 +44,18 @@ class OmniCoordClientForStage:
self._socket.close()
raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}: {e}") from e
self._status = StageStatus.UP
self._status = ReplicaStatus.UP
self._queue_length = 0
self._closed = False
self._closing = False
self._heartbeat_interval = 5.0
self._stop_event = threading.Event()
self._send_lock = threading.RLock()
# Optional hook invoked from the heartbeat thread before each
# heartbeat send. Stages set this to refresh ``queue_length`` (or any
# other field) just-in-time. Exceptions raised by the hook are
# suppressed and logged.
self._on_heartbeat: Callable[[], None] | None = None
self._send_event("update")
@@ -100,11 +107,13 @@ class OmniCoordClientForStage:
return False
def _send_event(self, event_type: str) -> None:
"""Send an InstanceEvent to OmniCoordinator.
"""Send a ReplicaEvent to OmniCoordinator.
Wire format: input_addr, output_addr, stage_id, status, queue_length, event_type.
For "update": includes status and queue_length from instance state.
For "heartbeat": status and queue_length are null.
For "update": includes status and queue_length from replica state.
For "heartbeat": includes the latest queue_length (refreshed by the
optional ``_on_heartbeat`` hook) so the coordinator can propagate
live load to load balancers between explicit ``update`` events.
On send failure (ZMQError / RuntimeError), attempts to reconnect up
to 3 times (5s sleep each) and retries the send once after a
@@ -114,7 +123,7 @@ class OmniCoordClientForStage:
if self._closed:
raise RuntimeError("Client already closed")
event = InstanceEvent(
event = ReplicaEvent(
input_addr=self._input_addr,
output_addr=self._output_addr,
stage_id=self._stage_id,
@@ -147,10 +156,10 @@ class OmniCoordClientForStage:
def update_info(
self,
status: StageStatus | None = None,
status: ReplicaStatus | None = None,
queue_length: int | None = None,
) -> None:
"""Update instance information and notify OmniCoordinator.
"""Update replica information and notify OmniCoordinator.
At least one of ``status`` or ``queue_length`` must be provided.
"""
@@ -174,6 +183,15 @@ class OmniCoordClientForStage:
if self._closed:
break
# Invoke the optional pre-heartbeat hook so callers (e.g. the
# engine subprocess) can refresh ``queue_length`` from live state
# before the heartbeat is sent. Exceptions are swallowed so a
# buggy hook never breaks the heartbeat loop.
hook = self._on_heartbeat
if hook is not None:
with contextlib.suppress(Exception):
hook()
try:
self._send_event("heartbeat")
except (RuntimeError, zmq.ZMQError) as e:
@@ -199,7 +217,7 @@ class OmniCoordClientForStage:
self._closing = True
# Mark status as DOWN and send one last update.
self._status = StageStatus.DOWN
self._status = ReplicaStatus.DOWN
try:
self._send_event("update")
except (RuntimeError, zmq.ZMQError):
@@ -11,22 +11,22 @@ from typing import Any
import zmq
from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus
from .messages import ReplicaEvent, ReplicaInfo, ReplicaList, ReplicaStatus
logger = logging.getLogger(__name__)
class OmniCoordinator:
"""Coordinator for stage instances and hub clients.
"""Coordinator for stage replicas and hub clients.
This service receives instance events from :class:`OmniCoordClientForStage`
via a ZMQ ROUTER socket and publishes active instance lists to
This service receives replica events from :class:`OmniCoordClientForStage`
via a ZMQ ROUTER socket and publishes active replica lists to
:class:`OmniCoordClientForHub` via a PUB socket.
The coordinator maintains an in-memory registry of all known instances,
The coordinator maintains an in-memory registry of all known replicas,
including their status, queue length, and heartbeat timestamps. A
background thread periodically checks for heartbeat timeouts and marks
unhealthy instances as ``StageStatus.ERROR``.
unhealthy replicas as ``ReplicaStatus.ERROR``.
"""
def __init__(
@@ -40,7 +40,7 @@ class OmniCoordinator:
Args:
router_zmq_addr: ZMQ address to bind the ROUTER socket.
pub_zmq_addr: ZMQ address to bind the PUB socket.
heartbeat_timeout: Seconds before an instance is considered
heartbeat_timeout: Seconds before a replica is considered
unhealthy if no heartbeat / update is received.
"""
self._router_zmq_addr = router_zmq_addr
@@ -55,7 +55,7 @@ class OmniCoordinator:
self._pub = self._ctx.socket(zmq.PUB)
self._pub.bind(self._pub_zmq_addr)
self._instances: dict[str, InstanceInfo] = {}
self._replicas: dict[str, ReplicaInfo] = {}
self._lock = threading.Lock()
self._pub_lock = threading.Lock()
@@ -75,43 +75,43 @@ class OmniCoordinator:
self._periodic_thread = threading.Thread(target=self._periodic_loop, daemon=True)
self._periodic_thread.start()
def get_active_instances(self) -> InstanceList:
"""Return an :class:`InstanceList` of active (UP) instances only."""
def get_active_replicas(self) -> ReplicaList:
"""Return a :class:`ReplicaList` of active (UP) replicas only."""
with self._lock:
active = [inst for inst in self._instances.values() if inst.status == StageStatus.UP]
return InstanceList(instances=active, timestamp=time())
active = [rep for rep in self._replicas.values() if rep.status == ReplicaStatus.UP]
return ReplicaList(replicas=active, timestamp=time())
def add_new_instance(self, event: InstanceEvent) -> None:
"""Add a new instance based on an incoming event."""
def add_new_replica(self, event: ReplicaEvent) -> None:
"""Add a new replica based on an incoming event."""
with self._lock:
self._add_new_instance_locked(event)
self._add_new_replica_locked(event)
self._schedule_broadcast()
def update_instance_info(self, event: InstanceEvent) -> None:
"""Update an existing instance based on an incoming event."""
def update_replica_info(self, event: ReplicaEvent) -> None:
"""Update an existing replica based on an incoming event."""
with self._lock:
self._update_instance_info_locked(event)
self._update_replica_info_locked(event)
self._schedule_broadcast()
def remove_instance(self, event: InstanceEvent) -> None:
"""Mark an instance as removed / down based on an incoming event.
def remove_replica(self, event: ReplicaEvent) -> None:
"""Mark a replica as removed / down based on an incoming event.
This marks the instance's status as DOWN or ERROR (depending on the
This marks the replica's status as DOWN or ERROR (depending on the
event) but keeps it in the internal registry. It is removed from the
*active* instance list published to hubs.
*active* replica list published to hubs.
"""
with self._lock:
self._remove_instance_locked(event)
self._remove_replica_locked(event)
self._schedule_broadcast()
def publish_instance_list_update(self) -> bool:
"""Publish the current active instance list to all subscribers.
def publish_replica_list_update(self) -> bool:
"""Publish the current active replica list to all subscribers.
Returns:
True if the PUB send succeeded, False if it was dropped (e.g.
socket not ready when using ``zmq.NOBLOCK``).
"""
active_list = self.get_active_instances()
active_list = self.get_active_replicas()
payload = asdict(active_list)
data = json.dumps(payload).encode("utf-8")
@@ -133,12 +133,12 @@ class OmniCoordinator:
with self._pending_lock:
self._pending_broadcast = True
def _mark_instance_error_locked(self, info: InstanceInfo) -> None:
"""Mark instance as ERROR (e.g. after heartbeat timeout)."""
info.status = StageStatus.ERROR
def _mark_replica_error_locked(self, info: ReplicaInfo) -> None:
"""Mark replica as ERROR (e.g. after heartbeat timeout)."""
info.status = ReplicaStatus.ERROR
def _check_heartbeat_timeouts(self) -> None:
"""Mark instances as ERROR if their heartbeat has timed out."""
"""Mark replicas as ERROR if their heartbeat has timed out."""
now = time()
timed_out = False
gc_ttl = 600.0 # 10 minutes
@@ -146,17 +146,17 @@ class OmniCoordinator:
with self._lock:
to_delete: list[str] = []
for input_addr, info in self._instances.items():
if info.status == StageStatus.UP and now - info.last_heartbeat > self._heartbeat_timeout:
self._mark_instance_error_locked(info)
for input_addr, info in self._replicas.items():
if info.status == ReplicaStatus.UP and now - info.last_heartbeat > self._heartbeat_timeout:
self._mark_replica_error_locked(info)
timed_out = True
elif info.status in (StageStatus.DOWN, StageStatus.ERROR) and now - info.last_heartbeat > gc_ttl:
elif info.status in (ReplicaStatus.DOWN, ReplicaStatus.ERROR) and now - info.last_heartbeat > gc_ttl:
to_delete.append(input_addr)
for input_addr in to_delete:
del self._instances[input_addr]
del self._replicas[input_addr]
if timed_out:
# Instance liveness changed; request broadcast.
# Replica liveness changed; request broadcast.
self._schedule_broadcast()
def close(self) -> None:
@@ -187,22 +187,22 @@ class OmniCoordinator:
except zmq.ZMQError:
pass
def _parse_instance_event(self, data: dict[str, Any]) -> InstanceEvent | None:
"""Parse wire payload dict into InstanceEvent. Returns None if invalid."""
def _parse_replica_event(self, data: dict[str, Any]) -> ReplicaEvent | None:
"""Parse wire payload dict into ReplicaEvent. Returns None if invalid."""
try:
return InstanceEvent(
return ReplicaEvent(
input_addr=str(data["input_addr"]),
output_addr=str(data["output_addr"]),
stage_id=int(data["stage_id"]),
event_type=str(data["event_type"]),
status=StageStatus(data.get("status")),
status=ReplicaStatus(data.get("status")),
queue_length=data.get("queue_length"),
)
except (KeyError, ValueError, TypeError):
return None
def _recv_loop(self) -> None:
"""Background loop that receives and processes instance events."""
"""Background loop that receives and processes replica events."""
while self._running:
try:
frames = self._router.recv_multipart()
@@ -219,12 +219,12 @@ class OmniCoordinator:
payload = frames[-1]
try:
data = json.loads(payload.decode("utf-8"))
event = self._parse_instance_event(data)
event = self._parse_replica_event(data)
except json.JSONDecodeError as e:
logger.warning("Invalid JSON in instance event, dropping: %s", e)
logger.warning("Invalid JSON in replica event, dropping: %s", e)
continue
if event is None:
logger.warning("Malformed instance event, dropping")
logger.warning("Malformed replica event, dropping")
continue
self._handle_event(event)
@@ -234,7 +234,10 @@ class OmniCoordinator:
Heartbeat timeouts are checked on their original cadence, while all
broadcast requests are coalesced and flushed at most once per
``_publish_min_interval``.
``_publish_min_interval``. The heartbeat-check tick also schedules a
keepalive broadcast so late-joining hubs (which miss any PUB sends
that happened before their SUB connected) catch up within at most
``heartbeat_interval`` seconds.
"""
heartbeat_interval = max(1.0, min(self._heartbeat_timeout / 2.0, 5.0))
loop_interval = self._publish_min_interval
@@ -245,6 +248,13 @@ class OmniCoordinator:
if now - last_heartbeat_check >= heartbeat_interval:
self._check_heartbeat_timeouts()
# Keepalive broadcast: ZMQ PUB doesn't queue for late
# subscribers, so an OmniCoordClientForHub that connects
# after the initial UP events miss them entirely and would
# never see the current replica list otherwise. Scheduling a
# broadcast on every heartbeat tick caps that staleness at
# ``heartbeat_interval`` without flooding the wire.
self._schedule_broadcast()
last_heartbeat_check = now
with self._pending_lock:
@@ -256,43 +266,51 @@ class OmniCoordinator:
continue
# Publish outside lock. Clear pending only on success.
if self.publish_instance_list_update():
if self.publish_replica_list_update():
with self._pending_lock:
self._pending_broadcast = False
if self._stop_event.wait(timeout=loop_interval):
break
def _handle_event(self, event: InstanceEvent) -> None:
def _handle_event(self, event: ReplicaEvent) -> None:
"""Dispatch an incoming event to the appropriate handler."""
try:
input_addr = event.input_addr
# Heartbeat: only update last_heartbeat; if previously ERROR,
# promote back to UP and broadcast once.
# Heartbeat: refresh last_heartbeat and queue_length. The stage
# client refreshes queue_length just-in-time via its
# ``_on_heartbeat`` hook, so heartbeats are the only periodic
# source of live load for LeastQueueLengthBalancer; failing to
# propagate it here would let the policy route on stale data.
# If previously ERROR, promote back to UP and broadcast once.
if event.event_type == "heartbeat":
promote = False
queue_changed = False
with self._lock:
info = self._instances.get(input_addr)
info = self._replicas.get(input_addr)
if info is not None:
info.last_heartbeat = time()
if info.status == StageStatus.ERROR:
info.status = StageStatus.UP
if event.queue_length is not None and info.queue_length != event.queue_length:
info.queue_length = event.queue_length
queue_changed = True
if info.status == ReplicaStatus.ERROR:
info.status = ReplicaStatus.UP
promote = True
if promote:
if promote or queue_changed:
self._schedule_broadcast()
return
# Check-and-act under single lock to avoid TOCTOU race (duplicate
# registration when concurrent events arrive for the same instance).
# registration when concurrent events arrive for the same replica).
with self._lock:
if input_addr not in self._instances:
self._add_new_instance_locked(event)
if input_addr not in self._replicas:
self._add_new_replica_locked(event)
else:
if event.status == StageStatus.DOWN:
self._remove_instance_locked(event)
if event.status == ReplicaStatus.DOWN:
self._remove_replica_locked(event)
else:
self._update_instance_info_locked(event)
self._update_replica_info_locked(event)
# Any non-heartbeat state change that affects the active list
# is coalesced and flushed via the periodic loop.
@@ -300,7 +318,7 @@ class OmniCoordinator:
except (KeyError, ValueError, TypeError) as e:
logger.warning("Dropping malformed event: %s", e)
def _add_new_instance_locked(self, event: InstanceEvent) -> None:
def _add_new_replica_locked(self, event: ReplicaEvent) -> None:
input_addr = event.input_addr
if not input_addr:
raise KeyError("input_addr required")
@@ -309,7 +327,7 @@ class OmniCoordinator:
raise KeyError("stage_id required and must be non-negative")
now = time()
info = InstanceInfo(
info = ReplicaInfo(
input_addr=input_addr,
output_addr=event.output_addr,
stage_id=stage_id,
@@ -318,11 +336,11 @@ class OmniCoordinator:
last_heartbeat=now,
registered_at=now,
)
self._instances[input_addr] = info
self._replicas[input_addr] = info
def _update_instance_info_locked(self, event: InstanceEvent) -> None:
def _update_replica_info_locked(self, event: ReplicaEvent) -> None:
input_addr = event.input_addr
info = self._instances[input_addr]
info = self._replicas[input_addr]
if event.status is not None:
info.status = event.status
@@ -330,10 +348,10 @@ class OmniCoordinator:
if event.queue_length is not None:
info.queue_length = event.queue_length
def _remove_instance_locked(self, event: InstanceEvent) -> None:
def _remove_replica_locked(self, event: ReplicaEvent) -> None:
input_addr = event.input_addr
info = self._instances.get(input_addr)
info = self._replicas.get(input_addr)
if info is None:
return
info.status = StageStatus.DOWN
info.status = ReplicaStatus.DOWN
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Lifecycle wrapper around :class:`OmniCoordinator`.
``OmniCoordinatorRuntime`` is the single-purpose owner of the head-side
coordinator process artifacts: it picks two free TCP ports, constructs an
:class:`OmniCoordinator` bound to them, exposes the resulting addresses, and
provides a single ``close()`` method to tear everything down.
The ROUTER address is later handed to :class:`OmniMasterServer` so it can be
published to registering replicas; the PUB address is handed to the
``Orchestrator``, which constructs its :class:`OmniCoordClientForHub` against
it.
"""
from __future__ import annotations
import logging
from vllm.utils.network_utils import get_open_ports_list
from .omni_coordinator import OmniCoordinator
logger = logging.getLogger(__name__)
class OmniCoordinatorRuntime:
"""Own one :class:`OmniCoordinator` and the two ports it binds.
Constructor binds; :meth:`close` tears down. The class deliberately does
not expose the coordinator instance — callers should consume the
coordinator only via its wire protocol through
:class:`OmniCoordClientForStage` and :class:`OmniCoordClientForHub`.
"""
def __init__(
self,
*,
host: str,
heartbeat_timeout: float,
) -> None:
if not host:
raise ValueError("host must be a non-empty string")
if heartbeat_timeout <= 0:
raise ValueError("heartbeat_timeout must be positive")
router_port, pub_port = get_open_ports_list(count=2)
self.router_address: str = f"tcp://{host}:{router_port}"
self.pub_address: str = f"tcp://{host}:{pub_port}"
self._closed = False
self._coordinator = OmniCoordinator(
router_zmq_addr=self.router_address,
pub_zmq_addr=self.pub_address,
heartbeat_timeout=heartbeat_timeout,
)
logger.info(
"[OmniCoordinatorRuntime] Started (router=%s pub=%s heartbeat_timeout=%.1fs)",
self.router_address,
self.pub_address,
heartbeat_timeout,
)
def close(self) -> None:
"""Tear down the underlying coordinator. Idempotent."""
if self._closed:
return
self._closed = True
try:
self._coordinator.close()
except Exception:
logger.exception("[OmniCoordinatorRuntime] coordinator close failed")
+4
View File
@@ -155,6 +155,10 @@ class OmniEngineArgs(EngineArgs):
omni_master_address: str | None = None
omni_master_port: int | None = None
# OmniCoordinator integration knobs (process-local).
omni_dp_size_local: int = 1
omni_lb_policy: str = "random"
omni_heartbeat_timeout: float = 30.0
stage_configs_path: str | None = None
output_modalities: list[str] | None = None
log_stats: bool = False
+280 -53
View File
@@ -18,7 +18,7 @@ import threading
import time
import uuid
import weakref
from collections.abc import Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import ExitStack
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -45,6 +45,10 @@ from vllm_omni.diffusion.stage_diffusion_proc import (
from vllm_omni.distributed.omni_connectors.utils.initialization import (
resolve_omni_kv_config_for_stage,
)
from vllm_omni.distributed.omni_coordinator import (
LoadBalancer,
build_load_balancer_factory,
)
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.messages import (
AbortRequestMessage,
@@ -53,6 +57,7 @@ from vllm_omni.engine.messages import (
CollectiveRPCResultMessage,
EngineQueueMessage,
ErrorMessage,
RegisterRemoteReplicaMessage,
ShutdownRequestMessage,
StageSubmissionMessage,
)
@@ -77,6 +82,7 @@ from vllm_omni.engine.stage_engine_startup import (
from vllm_omni.engine.stage_init_utils import (
LogicalStageInitPlan,
ReplicaInitPlan,
StageRemoteFactoryContext,
_inject_inferred_kv_tp_topology,
acquire_device_locks,
acquire_diffusion_device_locks,
@@ -85,6 +91,7 @@ from vllm_omni.engine.stage_init_utils import (
build_llm_stage_output_processor,
build_stage0_input_processor,
build_vllm_config,
capture_stage_factory_contexts,
compute_replica_layout,
extract_stage_metadata,
get_stage_connector_spec,
@@ -148,6 +155,7 @@ _PARENT_ARGS_KEEP: frozenset[str] = frozenset(
# trigger the ``create_model_config`` guard).
_PARENT_ARGS_STRIP: frozenset[str] = frozenset({"stage_configs_path"})
# Fields always populated by callers (via ``from_cli_args`` / ``asdict``) so
# their presence as an override is never a surprise — suppress the
# "override ignored" warning for these.
@@ -229,10 +237,9 @@ def _weak_shutdown_async_omni_engine(
pass
for q in (request_queue, output_queue, rpc_output_queue):
if q is None:
continue
try:
q.close()
if q is not None:
q.close()
except Exception:
pass
@@ -301,6 +308,23 @@ class AsyncOmniEngine:
self._omni_master_port: int | None = kwargs.get("omni_master_port")
self._omni_master_server: OmniMasterServer | None = None
# New omni-coordinator flags. Consumed only in single_stage_mode.
# ``omni_dp_size_local`` is process-local: each invocation (head and
# every headless) launches that many replicas for its own stage.
self._omni_dp_size_local: int = int(kwargs.get("omni_dp_size_local") or 1)
if self._omni_dp_size_local < 1:
raise ValueError(f"--omni-dp-size-local must be >= 1, got {self._omni_dp_size_local}")
self._omni_lb_policy: str = str(kwargs.get("omni_lb_policy") or "random")
self._omni_heartbeat_timeout: float = float(kwargs.get("omni_heartbeat_timeout") or 30.0)
if self._omni_heartbeat_timeout <= 0:
raise ValueError(f"--omni-heartbeat-timeout must be > 0, got {self._omni_heartbeat_timeout}")
# Coordinator runtime (head-distributed only).
self._coordinator_runtime: Any | None = None
# Per-stage construction context, captured after _initialize_stages
# and used by ``_build_remote_replica`` (the RemoteReplicaFactory
# passed to Orchestrator) when a headless replica registers.
self._stage_remote_factory_contexts: dict[int, StageRemoteFactoryContext] = {}
if single_stage_mode:
logger.info(
"[AsyncOmniEngine] Single-stage mode enabled (stage_id_filter=%s, master=%s:%s)",
@@ -321,9 +345,14 @@ class AsyncOmniEngine:
self.supported_tasks: tuple[str, ...] = ("generate",)
self.default_sampling_params_list: list[OmniSamplingParams] = []
self.stage_metadata: list[StageRuntimeInfo] = []
self.request_queue: janus.Queue[EngineQueueMessage] | None = None
self.output_queue: janus.Queue[EngineQueueMessage] | None = None
self.rpc_output_queue: janus.Queue[EngineQueueMessage] | None = None
# Janus queues are constructed eagerly here (not deferred to the
# orchestrator thread) so the master server's ROUTER thread always
# sees a non-None ``self.request_queue`` when on_register fires.
# ``async_q`` lazily binds to whatever event loop first awaits on
# it (the orchestrator loop), so cross-thread use stays correct.
self.request_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
self.output_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
self.rpc_output_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
self._shutdown_called = False
self._weak_finalizer: weakref.finalize | None = None
self._rpc_lock = threading.Lock()
@@ -443,30 +472,37 @@ class AsyncOmniEngine:
)
def _validate_single_stage_mode_replica_constraints(self) -> None:
"""Reject unsupported replica fan-out in single-stage mode."""
"""Apply --omni-dp-size-local to the local stage's runtime.num_replicas.
In the previous revision this method rejected LLM stages with
``num_replicas > 1``. The whole point of ``--omni-dp-size-local`` is
to lift that restriction for the *local* stage, so the rejection is
gone. We now use this hook to write ``--omni-dp-size-local`` onto
the self-stage's runtime config so downstream code
(``compute_replica_layout`` → ``_build_logical_stage_init_plans``)
sees a consistent view.
"""
if not self.single_stage_mode:
return
target_stage_id = self._single_stage_id_filter
if target_stage_id is None:
return
unsupported: list[tuple[int, int]] = []
for idx, stage_cfg in enumerate(self.stage_configs):
runtime_cfg = getattr(stage_cfg, "runtime", {})
num_replicas = int(
runtime_cfg.get("num_replicas", 1)
if hasattr(runtime_cfg, "get")
else getattr(runtime_cfg, "num_replicas", 1)
)
if num_replicas <= 1:
continue
if getattr(stage_cfg, "stage_type", "llm") == "diffusion":
continue
stage_id = int(getattr(stage_cfg, "stage_id", idx))
unsupported.append((stage_id, num_replicas))
if unsupported:
raise ValueError(
"single_stage_mode only supports num_replicas > 1 for diffusion stages; "
f"found non-diffusion stages {unsupported}"
)
runtime_cfg = getattr(stage_cfg, "runtime", None)
if runtime_cfg is None:
continue
if stage_id == target_stage_id:
# Self stage: take --omni-dp-size-local from this process.
try:
runtime_cfg.num_replicas = self._omni_dp_size_local
except Exception:
if hasattr(runtime_cfg, "__setitem__"):
runtime_cfg["num_replicas"] = self._omni_dp_size_local
# Other stages keep their config-declared num_replicas; in
# head-distributed mode they will be launched as ``launch_mode
# == "remote"`` with the configured count.
def _build_logical_stage_init_plans(
self,
@@ -540,9 +576,18 @@ class AsyncOmniEngine:
replica_metadata = extract_stage_metadata(replica_cfg)
replica_metadata.replica_id = replica_id
if self.single_stage_mode:
if replica_metadata.stage_type != "diffusion":
replica_metadata.runtime_cfg = None
# In single_stage_mode the head only owns its self stage's
# replicas; the remote-stage metadata exists only so the
# orchestrator can route requests through StagePool. Wiping
# ``runtime_cfg`` there makes sense (we don't manage those
# devices). For the *self stage* we MUST keep the
# per-replica runtime so ``setup_stage_devices`` can apply
# the device split from ``compute_replica_layout`` —
# otherwise every replica inherits the parent's full
# CUDA_VISIBLE_DEVICES and stacks on cuda:0 (OOM with any
# model whose footprint exceeds ~1/(2N) of the card).
if launch_mode == "remote" and replica_metadata.stage_type != "diffusion":
replica_metadata.runtime_cfg = None
replicas.append(
ReplicaInitPlan(
@@ -578,6 +623,11 @@ class AsyncOmniEngine:
all_stage_ids: list[int] = []
stage_replica_counts: dict[int, int] = {}
# Slots that the head itself will register (launch_mode == "local")
# — auto-assign on the master must not hand these out to remote
# headless registrations even within the race window before the
# head's own register_stage_with_omni_master call completes.
head_local_replicas: dict[int, list[int]] = {}
seen_stage_ids: set[int] = set()
for plan in stage_plans:
stage_id = plan.configured_stage_id
@@ -588,12 +638,27 @@ class AsyncOmniEngine:
seen_stage_ids.add(stage_id)
all_stage_ids.append(stage_id)
stage_replica_counts[stage_id] = len(plan.replicas)
local_rids = [rep.replica_id for rep in plan.replicas if rep.launch_mode == "local"]
if local_rids:
head_local_replicas[stage_id] = local_rids
# Start the OmniCoordinator runtime first so its router address is
# available to publish in every registration reply.
from vllm_omni.distributed.omni_coordinator import OmniCoordinatorRuntime
self._coordinator_runtime = OmniCoordinatorRuntime(
host=self._omni_master_address,
heartbeat_timeout=self._omni_heartbeat_timeout,
)
self._omni_master_server = OmniMasterServer(
master_address=self._omni_master_address,
master_port=self._omni_master_port,
stage_ids=all_stage_ids,
stage_replica_counts=stage_replica_counts,
coordinator_router_address=self._coordinator_runtime.router_address,
on_register=self._dispatch_master_register,
head_local_replicas=head_local_replicas,
)
self._omni_master_server.start()
logger.info(
@@ -601,6 +666,147 @@ class AsyncOmniEngine:
all_stage_ids,
)
# ------------------------------------------------------------------
# Remote replica factory (head-side client construction)
# ------------------------------------------------------------------
async def _build_remote_replica(self, stage_id: int, replica_id: int) -> Any:
"""Construct a head-side stage client for a newly-registered remote replica.
Used by :class:`Orchestrator` as its ``remote_replica_factory``.
The orchestrator awaits this from its own asyncio loop, so the
client is created in the same loop that owns ZMQ sockets — no
cross-thread setup is required.
Raises if the stage is not known or if its construction context
was not captured (e.g. the stage was empty at bring-up time).
"""
ctx = self._stage_remote_factory_contexts.get(stage_id)
if ctx is None:
raise RuntimeError(
f"no factory context captured for stage {stage_id}; "
f"known stages: {sorted(self._stage_remote_factory_contexts.keys())}"
)
if self._omni_master_server is None:
raise RuntimeError("OmniMasterServer is not running; cannot build remote replica")
alloc = self._omni_master_server.get_allocation(stage_id, replica_id=replica_id)
# Build a per-replica copy of the base metadata so ``replica_id``
# is correct (StageMetadata is a plain dataclass-like).
metadata = copy.copy(ctx.base_metadata)
try:
metadata.replica_id = replica_id
except Exception:
# Best-effort: if the metadata object is frozen / unusual, the
# downstream client will fall back to ``replica_id = 0``.
pass
if ctx.stage_type == "diffusion":
client = StageDiffusionClient.from_addresses(
metadata,
request_address=alloc.input_bind_address,
response_address=alloc.output_bind_address,
batch_size=ctx.diffusion_batch_size,
)
logger.info(
"[AsyncOmniEngine] Built remote diffusion client for stage=%d replica=%d (req=%s resp=%s)",
stage_id,
replica_id,
alloc.input_bind_address,
alloc.output_bind_address,
)
return client
# LLM path
if ctx.vllm_config is None or ctx.executor_class is None:
raise RuntimeError(f"stage {stage_id} factory context is missing vllm_config / executor_class")
# The headless's StageEngineCoreProc subprocess calls
# vllm.v1.engine.core.startup_handshake at boot and blocks until the
# head's handshake ROUTER answers — without this step it hits the
# built-in 5-minute timeout and exits. The bootstrap (pre-allocated)
# path runs `connect_remote_engine_cores` to perform that
# handshake; dynamic attach must do the same, otherwise every
# replica that comes in via `on_register` (auto-assigned or
# explicit-id-beyond-pre-alloc) deadlocks. Run the blocking
# handshake in a thread so the orchestrator loop stays responsive,
# then build the async client on this loop where `make_async_mp_client`
# expects to be invoked.
master_server = self._omni_master_server
ctx_vllm_config = ctx.vllm_config
ctx_executor_class = ctx.executor_class
def _run_handshake() -> Any:
with connect_remote_engine_cores(
vllm_config=ctx_vllm_config,
omni_master_server=master_server,
stage_id=stage_id,
replica_id=replica_id,
) as remote_resources:
_engine_manager, _coordinator, addresses, _ = remote_resources
return _engine_manager, _coordinator, addresses
engine_manager, coordinator, addresses = await asyncio.to_thread(_run_handshake)
client_addresses: dict[str, str] = {
"input_address": addresses.inputs[0],
"output_address": addresses.outputs[0],
}
if addresses.frontend_stats_publish_address is not None:
client_addresses["stats_update_address"] = addresses.frontend_stats_publish_address
client = StageEngineCoreClientBase.make_async_mp_client(
vllm_config=ctx_vllm_config,
executor_class=ctx_executor_class,
metadata=metadata,
client_addresses=client_addresses,
proc=None,
engine_manager=engine_manager,
coordinator=coordinator,
)
logger.info(
"[AsyncOmniEngine] Built remote LLM client for stage=%d replica=%d (input=%s)",
stage_id,
replica_id,
client_addresses["input_address"],
)
return client
# ------------------------------------------------------------------
# OmniCoordinator on_register proxy
# ------------------------------------------------------------------
def _dispatch_master_register(self, stage_id: int, replica_id: int, alloc: Any) -> None:
"""Forward a master-server registration to the orchestrator queue.
Called on :class:`OmniMasterServer`'s ROUTER thread (not the
orchestrator loop). Must return promptly. ``self.request_queue``
is initialized in :meth:`_initialize_janus_queues` (running inside
the orchestrator thread) before the master server starts, so it
is non-None here; ``janus.Queue.sync_q`` is thread-safe by
construction.
"""
if self.request_queue is None:
logger.warning(
"[AsyncOmniEngine] request_queue not initialized; dropping register_remote_replica stage=%d replica=%d",
stage_id,
replica_id,
)
return
msg = RegisterRemoteReplicaMessage(
stage_id=int(stage_id),
replica_id=int(replica_id),
)
try:
self.request_queue.sync_q.put_nowait(msg)
except Exception:
logger.exception(
"[AsyncOmniEngine] Failed to enqueue register_remote_replica for stage=%d replica=%d",
stage_id,
replica_id,
)
def _initialize_llm_replica(
self,
plan: ReplicaInitPlan,
@@ -686,6 +892,11 @@ class AsyncOmniEngine:
stage_init_timeout,
)
if self.single_stage_mode and self._omni_master_server is not None:
coord_router_addr: str | None = (
self._coordinator_runtime.router_address
if self._coordinator_runtime is not None
else None
)
engine_manager, coordinator, addresses = launch_stack.enter_context(
launch_omni_core_engines(
vllm_config=vllm_config,
@@ -695,6 +906,7 @@ class AsyncOmniEngine:
stage_id=plan.metadata.stage_id,
stage_config=stage_cfg,
replica_id=plan.replica_id,
omni_coordinator_address=coord_router_addr,
)
)
else:
@@ -830,12 +1042,20 @@ class AsyncOmniEngine:
"[AsyncOmniEngine] Stage %s diffusion registration completed",
plan.metadata.stage_id,
)
coord_router_addr: str | None = (
self._coordinator_runtime.router_address
if self._coordinator_runtime is not None
else None
)
proc, _, _, _ = spawn_diffusion_proc(
self.model,
od_config,
handshake_address=handshake_address,
request_address=request_address,
response_address=response_address,
omni_coordinator_address=coord_router_addr,
omni_stage_id=plan.metadata.stage_id,
omni_replica_id=plan.replica_id,
)
complete_diffusion_handshake(proc, handshake_address, stage_init_timeout)
logger.info(
@@ -1049,7 +1269,13 @@ class AsyncOmniEngine:
replicas_per_stage,
replica_devices_map,
)
# Capture per-stage context now (before _start_omni_master_server)
# so the on_register proxy can build head-side clients for
# registrations that arrive immediately after the server starts.
if self.single_stage_mode:
self._stage_remote_factory_contexts = capture_stage_factory_contexts(
stage_plans, diffusion_batch_size=self.diffusion_batch_size
)
self._start_omni_master_server(stage_plans)
stage_pools: list[StagePool] = []
@@ -1085,6 +1311,14 @@ class AsyncOmniEngine:
self._omni_master_server.stop()
except Exception:
logger.exception("[AsyncOmniEngine] Failed to stop OmniMasterServer during stage-init cleanup")
if self._coordinator_runtime is not None:
try:
self._coordinator_runtime.close()
except Exception:
logger.exception(
"[AsyncOmniEngine] Failed to close OmniCoordinatorRuntime during stage-init cleanup"
)
self._coordinator_runtime = None
raise
self.stage_pools = stage_pools
@@ -1104,13 +1338,6 @@ class AsyncOmniEngine:
supported_tasks.add("speech")
self.supported_tasks = tuple(supported_tasks) if supported_tasks else ("generate",)
def _initialize_janus_queues(self) -> None:
"""Initialize janus queues inside orchestrator thread loop context."""
self.request_queue = janus.Queue()
self.output_queue = janus.Queue()
self.rpc_output_queue = janus.Queue()
logger.debug("[AsyncOmniEngine] janus queues initialized in orchestrator thread loop")
def _bootstrap_orchestrator(
self,
stage_init_timeout: int,
@@ -1122,10 +1349,15 @@ class AsyncOmniEngine:
asyncio.set_event_loop(loop)
async def _run_orchestrator() -> None:
self._initialize_janus_queues()
self._initialize_stages(stage_init_timeout)
pd_config = self._detect_pd_config()
coordinator_pub_address: str | None = None
load_balancer_factory: Callable[[], LoadBalancer] | None = None
remote_replica_factory: Callable[[int, int], Awaitable[Any]] | None = None
if self._coordinator_runtime is not None:
coordinator_pub_address = self._coordinator_runtime.pub_address
load_balancer_factory = build_load_balancer_factory(self._omni_lb_policy)
remote_replica_factory = self._build_remote_replica
orchestrator = Orchestrator(
request_async_queue=self.request_queue.async_q,
output_async_queue=self.output_queue.async_q,
@@ -1133,6 +1365,9 @@ class AsyncOmniEngine:
stage_pools=self.stage_pools,
async_chunk=self.async_chunk,
pd_config=pd_config,
coordinator_pub_address=coordinator_pub_address,
load_balancer_factory=load_balancer_factory,
remote_replica_factory=remote_replica_factory,
)
if not startup_future.done():
startup_future.set_result(asyncio.get_running_loop())
@@ -1948,8 +2183,6 @@ class AsyncOmniEngine:
reasoning_ended=reasoning_ended,
resumable=resumable,
)
if self.request_queue is None:
raise RuntimeError("request_queue is not initialized")
self.request_queue.sync_q.put_nowait(msg)
# CFG companion expansion: create and enqueue companion requests
@@ -2017,8 +2250,6 @@ class AsyncOmniEngine:
resumable=resumable,
message_type="streaming_update",
)
if self.request_queue is None:
raise RuntimeError("request_queue is not initialized")
self.request_queue.sync_q.put_nowait(msg)
async def add_streaming_update_async(
@@ -2045,8 +2276,6 @@ class AsyncOmniEngine:
def try_get_output(self, timeout: float = 0.001) -> EngineQueueMessage | None:
"""Read one output message from the Orchestrator output queue."""
if self.output_queue is None:
return None
try:
return self.output_queue.sync_q.get(timeout=timeout)
except queue.Empty:
@@ -2056,8 +2285,6 @@ class AsyncOmniEngine:
async def try_get_output_async(self) -> EngineQueueMessage | None:
"""Async read from the Orchestrator output queue."""
if self.output_queue is None:
return None
try:
return self.output_queue.sync_q.get_nowait()
except queue.Empty:
@@ -2092,11 +2319,6 @@ class AsyncOmniEngine:
This uses a dedicated RPC output queue so control-plane messages do not
race with the normal request output polling loop.
"""
if self.request_queue is None:
raise RuntimeError("request_queue is not initialized")
if self.rpc_output_queue is None:
raise RuntimeError("rpc_output_queue is not initialized")
rpc_id = uuid.uuid4().hex
msg = CollectiveRPCRequestMessage(
rpc_id=rpc_id,
@@ -2184,8 +2406,6 @@ class AsyncOmniEngine:
logger.warning("[AsyncOmniEngine] Orchestrator thread did not exit in time")
for q in (self.request_queue, self.output_queue, self.rpc_output_queue):
if q is None:
continue
try:
q.close()
except Exception:
@@ -2198,6 +2418,13 @@ class AsyncOmniEngine:
logger.exception("[AsyncOmniEngine] Failed to stop OmniMasterServer during shutdown")
self._omni_master_server = None
if self._coordinator_runtime is not None:
try:
self._coordinator_runtime.close()
except Exception:
logger.exception("[AsyncOmniEngine] Failed to close OmniCoordinatorRuntime during shutdown")
self._coordinator_runtime = None
def _try_shutdown(self, *args, **kwargs) -> None:
try:
self.shutdown()
+12
View File
@@ -56,6 +56,18 @@ class ShutdownRequestMessage(EngineQueueMessage, kw_only=True):
type: Literal["shutdown"] = "shutdown"
class RegisterRemoteReplicaMessage(EngineQueueMessage, kw_only=True):
type: Literal["register_remote_replica"] = "register_remote_replica"
stage_id: int
replica_id: int
class UnregisterRemoteReplicaMessage(EngineQueueMessage, kw_only=True):
type: Literal["unregister_remote_replica"] = "unregister_remote_replica"
stage_id: int
input_addr: str
class ErrorMessage(EngineQueueMessage, kw_only=True):
type: Literal["error"] = "error"
error: str
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Process manager for omni stage engine subprocesses.
This is a drop-in replacement for vLLM's :class:`CoreEngineProcManager` that
spawns :meth:`StageEngineCoreProc.run_stage_core` instead of the upstream
``EngineCoreProc.run_engine_core``, and forwards omni-specific kwargs
(coordinator address, stage id, per-rank replica id).
Each spawned subprocess corresponds to exactly one omni *replica*: it has its
own ZMQ allocation from :class:`OmniMasterServer` and (when an
``omni_coordinator_address`` is provided) its own
:class:`OmniCoordClientForStage` reporting heartbeat / status.
Liveness monitoring and shutdown are inherited from
:class:`CoreEngineProcManager` unchanged.
"""
from __future__ import annotations
import contextlib
import threading
import weakref
from multiprocessing.process import BaseProcess
from multiprocessing.queues import Queue
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.platforms import current_platform
from vllm.utils import numa_utils
from vllm.utils.system_utils import get_mp_context
from vllm.v1.engine.utils import CoreEngineProcManager
from vllm.v1.executor import Executor
from vllm.v1.utils import shutdown
from vllm_omni.engine.stage_engine_core_proc import StageEngineCoreProc
logger = init_logger(__name__)
try:
# ``set_device_control_env_var`` lives next to CoreEngineProcManager and
# is only required for non-CUDA DP, so we tolerate its absence on
# older / future vLLM revisions.
from vllm.v1.engine.utils import set_device_control_env_var # type: ignore
except ImportError: # pragma: no cover - depends on vLLM build
set_device_control_env_var = None # type: ignore[assignment]
class OmniCoreEngineProcManager(CoreEngineProcManager):
"""Spawn :class:`StageEngineCoreProc` subprocesses with omni kwargs.
The body mirrors :class:`CoreEngineProcManager.__init__` because the
upstream class hardcodes ``target=EngineCoreProc.run_engine_core`` and
does not expose an extensibility hook. The differences from upstream are:
* ``target`` is :meth:`StageEngineCoreProc.run_stage_core`.
* Per-rank ``omni_replica_id`` is computed as
``base_replica_id + rank_idx`` and added to each subprocess's kwargs.
* ``omni_coordinator_address`` (if provided) and ``omni_stage_id`` are
added to every subprocess's kwargs.
"""
def __init__(
self,
local_engine_count: int,
start_index: int,
local_start_index: int,
vllm_config: VllmConfig,
local_client: bool,
handshake_address: str,
executor_class: type[Executor],
log_stats: bool,
*,
omni_stage_id: int,
omni_coordinator_address: str | None = None,
omni_replica_base_id: int = 0,
client_handshake_address: str | None = None,
tensor_queue: Queue | None = None,
) -> None:
# NOTE: we intentionally do not call ``super().__init__`` — the
# parent's body hardcodes the wrong target. We re-implement it here
# while reusing the parent's instance methods (shutdown, monitor).
if local_engine_count <= 0:
raise ValueError(f"local_engine_count must be > 0, got {local_engine_count}")
context = get_mp_context()
common_kwargs: dict[str, object] = {
"vllm_config": vllm_config,
"local_client": local_client,
"handshake_address": handshake_address,
"executor_class": executor_class,
"log_stats": log_stats,
"tensor_queue": tensor_queue,
"omni_stage_id": int(omni_stage_id),
"omni_coordinator_address": omni_coordinator_address,
}
if client_handshake_address:
common_kwargs["client_handshake_address"] = client_handshake_address
# Intra-replica vLLM DP mesh (i.e. ``data_parallel_size`` ranks sharing
# one engine, one DPCoordinator, one set of weights). Distinct from
# the omni-level notion of multiple independent replicas of a stage —
# those each spawn their own OmniCoreEngineProcManager and never join
# a vLLM DP group across replicas.
has_intra_replica_dp = vllm_config.parallel_config.data_parallel_size > 1
self.processes: list[BaseProcess] = []
local_dp_ranks: list[int] = []
for index in range(local_engine_count):
local_index = local_start_index + index
global_index = start_index + index
# Each spawned subprocess is one omni replica. The replica id
# is contiguous within this manager; the master server may have
# pre-allocated a contiguous block starting at ``omni_replica_base_id``.
omni_replica_id = omni_replica_base_id + index
local_dp_ranks.append(local_index)
self.processes.append(
context.Process(
target=StageEngineCoreProc.run_stage_core,
name=(
f"StageEngineCoreProc_stage{omni_stage_id}"
f"_replica{omni_replica_id}" + (f"_DP{global_index}" if has_intra_replica_dp else "")
),
kwargs=common_kwargs
| {
"dp_rank": global_index,
"local_dp_rank": local_index,
"omni_replica_id": omni_replica_id,
},
)
)
self._finalizer = weakref.finalize(self, shutdown, self.processes)
self.manager_stopped = threading.Event()
self.failed_proc_name: str | None = None
try:
for proc, local_dp_rank in zip(self.processes, local_dp_ranks):
device_control_context: contextlib.AbstractContextManager[None] = contextlib.nullcontext()
if (
has_intra_replica_dp
and set_device_control_env_var is not None
and (not current_platform.is_cuda_alike() or vllm_config.parallel_config.use_ray)
):
device_control_context = set_device_control_env_var(vllm_config, local_dp_rank)
with (
device_control_context,
numa_utils.configure_subprocess(
vllm_config,
local_rank=0,
dp_local_rank=local_dp_rank,
process_kind="EngineCore",
),
):
proc.start()
finally:
if self.finished_procs():
self.shutdown()
+252 -8
View File
@@ -4,12 +4,20 @@ Orchestrator for vLLM-Omni multi-stage runtime.
Runs inside a background thread with its own asyncio event loop.
Owns logical request progression across stage pools and handles
stage-to-stage transfer logic.
In distributed mode (``coordinator_pub_address`` provided), it also
owns the single :class:`OmniCoordClientForHub`, runs a
:meth:`_watch_replica_list` task that converts replica disappearances
into ``unregister_remote_replica`` control messages, and handles the
``register_remote_replica`` / ``unregister_remote_replica`` flow that
attaches / detaches head-side stage clients for headless replicas.
"""
from __future__ import annotations
import asyncio
import time as _time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -22,6 +30,12 @@ from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreOutputs
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.distributed.omni_coordinator import (
LoadBalancer,
OmniCoordClientForHub,
RandomBalancer,
ReplicaStatus,
)
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.engine.messages import (
@@ -32,13 +46,26 @@ from vllm_omni.engine.messages import (
EngineQueueMessage,
ErrorMessage,
OutputMessage,
RegisterRemoteReplicaMessage,
ShutdownRequestMessage,
StageMetricsMessage,
StageSubmissionMessage,
UnregisterRemoteReplicaMessage,
)
from vllm_omni.engine.serialization import serialize_additional_information
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.outputs import OmniRequestOutput
# Factory signature for building a head-side stage client for a
# *dynamically attached* (auto-assigned) remote replica.
#
# Receives ``(stage_id, replica_id)`` and returns an awaitable yielding the
# constructed client (any type — it must satisfy the shape expected by the
# matching :class:`StagePool`, i.e. expose ``client_addresses["input_address"]``
# or ``request_address``, plus the usual ``add_request_async`` /
# ``get_output_async`` / ``shutdown`` surface).
RemoteReplicaFactory = Callable[[int, int], Awaitable[Any]]
logger = init_logger(__name__)
@@ -124,6 +151,10 @@ class StreamingInputState:
class Orchestrator:
"""Runs inside a background thread's asyncio event loop."""
# Cadence at which the replica-list watcher polls for disappearances.
_WATCH_REPLICA_INTERVAL_S: float = 0.5
_WATCH_REPLICA_IDLE_INTERVAL_S: float = 1.0
def __init__(
self,
request_async_queue: janus.AsyncQueue[EngineQueueMessage],
@@ -133,6 +164,9 @@ class Orchestrator:
*,
async_chunk: bool = False,
pd_config: dict[str, Any] | None = None,
coordinator_pub_address: str | None = None,
load_balancer_factory: Callable[[], LoadBalancer] | None = None,
remote_replica_factory: RemoteReplicaFactory | None = None,
) -> None:
self.request_async_queue = request_async_queue
self.output_async_queue = output_async_queue
@@ -159,6 +193,28 @@ class Orchestrator:
self._fatal_error: str | None = None
self._fatal_error_stage_id: int | None = None
# Background tasks for fire-and-forget message handlers (currently
# only ``register_remote_replica`` and ``unregister_remote_replica``).
# Held as a set so each task's reference survives the loop and the
# task can self-deregister on completion.
self._membership_tasks: set[asyncio.Task[None]] = set()
# Distributed-mode wiring. The hub is constructed on the
# orchestrator's asyncio loop because it spawns a SUB background
# thread; building it from another thread would race the
# ``_init_done`` event.
self._hub: OmniCoordClientForHub | None = (
OmniCoordClientForHub(coordinator_pub_address) if coordinator_pub_address is not None else None
)
self._remote_replica_factory = remote_replica_factory
# Inject hub + per-pool LB into each StagePool so they can run
# distributed dispatch via ``StagePool.pick``.
if self._hub is not None:
factory = load_balancer_factory or RandomBalancer
for pool in self.stage_pools:
pool.attach_hub(self._hub)
pool.attach_load_balancer(factory())
async def run(self) -> None:
"""Main entry point for the Orchestrator event loop."""
logger.info("[Orchestrator] Starting event loop")
@@ -168,9 +224,16 @@ class Orchestrator:
self._orchestration_output_handler(),
name="orchestrator-stage-output-handler",
)
# The replica watcher only runs in distributed mode. It's still
# created in both cases so ``run()`` has a uniform task graph;
# ``_watch_replica_list`` is a no-op poll when ``self._hub`` is None.
watch_task = asyncio.create_task(
self._watch_replica_list(),
name="orchestrator-replica-watcher",
)
try:
await asyncio.gather(request_task, output_task)
await asyncio.gather(request_task, output_task, watch_task)
except asyncio.CancelledError:
raise
except EngineDeadError as e:
@@ -185,11 +248,11 @@ class Orchestrator:
raise
finally:
self._shutdown_event.set()
for task in (request_task, output_task):
for task in (request_task, output_task, watch_task):
if not task.done():
task.cancel()
try:
await asyncio.gather(request_task, output_task, return_exceptions=True)
await asyncio.gather(request_task, output_task, watch_task, return_exceptions=True)
except Exception:
pass
@@ -199,8 +262,32 @@ class Orchestrator:
if self._fatal_error is not None:
await self._drain_pending_requests_on_fatal()
# Wait briefly for any in-flight membership handlers (register /
# unregister remote replica) to finish so they don't leave the
# head-side pool in a half-attached state. Cancel anything that
# hasn't completed in time; the generic pending-task sweep below
# will collect the cancellations.
if self._membership_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*self._membership_tasks, return_exceptions=True),
timeout=10.0,
)
except (asyncio.TimeoutError, Exception):
for t in self._membership_tasks:
if not t.done():
t.cancel()
self._shutdown_stages()
# Close the hub last so any in-flight dispatch still has access.
if self._hub is not None:
try:
self._hub.close()
except RuntimeError:
pass
self._hub = None
loop = asyncio.get_running_loop()
pending = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task() and not t.done()]
for task in pending:
@@ -208,6 +295,27 @@ class Orchestrator:
if pending:
await asyncio.gather(*pending, return_exceptions=True)
# ---- Background task helpers ----
def _spawn_membership_task(self, coro: Awaitable[None], *, label: str) -> None:
"""Run a fire-and-forget membership-change coroutine.
Holds a strong reference until completion (asyncio would otherwise
garbage-collect a bare task), and logs any uncaught exception.
"""
task = asyncio.create_task(coro, name=f"orchestrator-{label}")
self._membership_tasks.add(task)
def _on_done(t: asyncio.Task[None]) -> None:
self._membership_tasks.discard(t)
if t.cancelled():
return
exc = t.exception()
if exc is not None:
logger.error("[Orchestrator] %s task crashed", label, exc_info=exc)
task.add_done_callback(_on_done)
# ---- Request handling ----
async def _request_handler(self) -> None:
@@ -226,7 +334,20 @@ class Orchestrator:
await self._handle_abort(msg)
elif msg_type == "collective_rpc":
await self._handle_collective_rpc(msg)
elif msg_type == "shutdown":
elif isinstance(msg, RegisterRemoteReplicaMessage):
# Dynamic-attach involves a ~5s blocking handshake (run in a
# thread by ``_build_remote_replica``); ``await`` here would
# block the queue and stall the next ``add_request`` until
# the attach finishes. Dispatch as a background task so the
# main message loop keeps draining.
self._spawn_membership_task(self._handle_register_remote_replica(msg), label="register_remote_replica")
elif isinstance(msg, UnregisterRemoteReplicaMessage):
# Symmetric with register: keep the main queue flowing.
self._spawn_membership_task(
self._handle_unregister_remote_replica(msg),
label="unregister_remote_replica",
)
elif isinstance(msg, ShutdownRequestMessage):
logger.info("[Orchestrator] Received shutdown signal")
self._shutdown_event.set()
# Pre-mark stage clients as shutting down to prevent
@@ -415,7 +536,7 @@ class Orchestrator:
results: list[Any] = []
stage_ids: list[int] = []
for pool in target_pools:
for replica_id in range(pool.num_replicas):
for replica_id in pool.live_replica_ids():
stage_result = await pool.collective_rpc(
replica_id=replica_id,
method=method,
@@ -451,7 +572,7 @@ class Orchestrator:
idle = True
for stage_id in range(self.num_stages):
pool = self.stage_pools[stage_id]
for replica_id in range(pool.num_replicas):
for replica_id in pool.live_replica_ids():
if self._shutdown_event.is_set():
return
@@ -1105,14 +1226,137 @@ class Orchestrator:
)
self.request_states.pop(req_id, None)
# ---- Distributed-mode replica attach / detach ----
async def _watch_replica_list(self) -> None:
"""Convert hub replica disappearances into unregister control messages."""
last_up: set[tuple[int, str]] = set()
while not self._shutdown_event.is_set():
if self._hub is None:
# No coordinator wired up; sleep coarsely and re-check shutdown.
try:
await asyncio.sleep(self._WATCH_REPLICA_IDLE_INTERVAL_S)
except asyncio.CancelledError:
raise
continue
try:
snap = self._hub.get_replica_list()
current = {(rep.stage_id, rep.input_addr) for rep in snap.replicas if rep.status == ReplicaStatus.UP}
for stage_id, addr in last_up - current:
await self.request_async_queue.put(
UnregisterRemoteReplicaMessage(
stage_id=stage_id,
input_addr=addr,
)
)
last_up = current
except asyncio.CancelledError:
raise
except Exception:
logger.exception("[Orchestrator] _watch_replica_list iteration failed")
try:
await asyncio.sleep(self._WATCH_REPLICA_INTERVAL_S)
except asyncio.CancelledError:
raise
async def _handle_register_remote_replica(self, msg: RegisterRemoteReplicaMessage) -> None:
"""Bind a head-side client for a newly registered remote replica."""
stage_id = int(msg.stage_id)
replica_id = int(msg.replica_id)
if not (0 <= stage_id < self.num_stages):
logger.warning(
"[Orchestrator] register_remote_replica: stage_id %d out of range (num_stages=%d)",
stage_id,
self.num_stages,
)
return
if self._remote_replica_factory is None:
logger.warning(
"[Orchestrator] register_remote_replica received for stage=%d replica=%d but no factory installed",
stage_id,
replica_id,
)
return
try:
await self._attach_remote_replica(stage_id, replica_id)
except Exception:
logger.exception(
"[Orchestrator] failed to attach remote replica stage=%d replica=%d",
stage_id,
replica_id,
)
async def _handle_unregister_remote_replica(self, msg: UnregisterRemoteReplicaMessage) -> None:
"""Tear down the head-side client for a vanished remote replica."""
stage_id = int(msg.stage_id)
input_addr = str(msg.input_addr)
if not (0 <= stage_id < self.num_stages):
return
pool = self.stage_pools[stage_id]
affected = pool.invalidate_addr(input_addr)
self._detach_remote_replica(stage_id, input_addr)
if affected:
await self._cleanup_request_ids(affected, abort=True)
for req_id in affected:
await self.output_async_queue.put(
ErrorMessage(
error="stage replica disappeared",
request_id=req_id,
stage_id=stage_id,
)
)
async def _attach_remote_replica(self, stage_id: int, replica_id: int) -> None:
"""Build a head-side stage client via the injected factory and register it."""
factory = self._remote_replica_factory
if factory is None:
return
pool = self.stage_pools[stage_id]
client = await factory(stage_id, replica_id)
input_addr = StagePool._client_input_addr(client)
if input_addr is None:
raise RuntimeError(
f"remote replica factory for stage {stage_id} produced a client without a discoverable input address"
)
pool.add_client(input_addr, client)
logger.info(
"[Orchestrator] attached remote replica stage=%d replica=%d addr=%s",
stage_id,
replica_id,
input_addr,
)
def _detach_remote_replica(self, stage_id: int, input_addr: str) -> None:
"""Shut down + remove the head-side client at ``input_addr``."""
pool = self.stage_pools[stage_id]
client = pool.remove_client(input_addr)
if client is None:
return
try:
client.shutdown()
except Exception:
logger.exception(
"[Orchestrator] failed to shutdown client for stage=%d addr=%s",
stage_id,
input_addr,
)
logger.info(
"[Orchestrator] detached remote replica stage=%d addr=%s",
stage_id,
input_addr,
)
def _shutdown_stages(self) -> None:
"""Shutdown all stage pools."""
if self._stages_shutdown:
return
self._stages_shutdown = True
total = sum(pool.num_replicas for pool in self.stage_pools)
total = sum(pool.live_num_replicas for pool in self.stage_pools)
logger.info("[Orchestrator] Shutting down all %d client(s)", total)
for pool in self.stage_pools:
for replica_id in range(pool.num_replicas):
for replica_id in pool.live_replica_ids():
pool.shutdown_replica(replica_id)
+13 -2
View File
@@ -23,6 +23,7 @@ from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.distributed.omni_connectors.utils.initialization import (
KV_TRANSFER_PORT_OFFSET,
)
from vllm_omni.distributed.omni_connectors.utils.kv_utils import kv_zmq_port
from vllm_omni.engine.stage_client import StageClientBase
from vllm_omni.engine.stage_init_utils import StageMetadata
@@ -344,7 +345,12 @@ class StageEngineCoreClientBase(StageClientBase):
try:
# Orchestrator always reports rank-0's port; receiver
# workers add their own local_rank * KV_RANK_PORT_STRIDE.
sender_port = int(base_port) + KV_TRANSFER_PORT_OFFSET + int(from_stage)
sender_port = kv_zmq_port(
int(base_port),
int(from_stage),
local_rank=0,
replica_id=self.replica_id,
)
except (TypeError, ValueError):
logger.warning(
"[StageEngineCoreClient] stage-%s [rep-%s] could not resolve sender_zmq_port "
@@ -386,7 +392,12 @@ class StageEngineCoreClientBase(StageClientBase):
# rank-0 base port; receiver workers adjust per KV_RANK_PORT_STRIDE.
return {
"host": self._kv_sender_host,
"zmq_port": base_port + kv_transfer_port_offset + int(self.stage_id),
"zmq_port": kv_zmq_port(
base_port - KV_TRANSFER_PORT_OFFSET + kv_transfer_port_offset,
int(self.stage_id),
local_rank=0,
replica_id=self.replica_id,
),
}
def set_engine_outputs(self, engine_outputs: EngineCoreOutput) -> None:
+68 -13
View File
@@ -7,6 +7,8 @@ busy loop in a subprocess, communicating with StageEngineCoreClient via ZMQ.
from __future__ import annotations
import contextlib
import os
import signal
from multiprocessing.process import BaseProcess
from typing import TYPE_CHECKING, Any
@@ -33,6 +35,8 @@ from vllm.v1.engine.utils import (
)
from vllm.v1.utils import shutdown
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
if TYPE_CHECKING:
from vllm.config import VllmConfig
from vllm.v1.executor import Executor
@@ -53,28 +57,41 @@ class StageEngineCoreProc(EngineCoreProc):
*args: Any,
dp_rank: int = 0,
local_dp_rank: int = 0,
omni_coordinator_address: str | None = None,
omni_stage_id: int | None = None,
omni_replica_id: int = 0,
**kwargs: Any,
) -> None:
"""Launch StageEngineCoreProc busy loop in background process."""
"""Launch StageEngineCoreProc busy loop in background process.
Omni-specific kwargs:
- ``omni_coordinator_address``: ROUTER address of the head-side
:class:`OmniCoordinator`. When provided, this subprocess
instantiates an :class:`OmniCoordClientForStage` after the
HELLO/INIT/READY handshake completes and reports its status +
queue length via heartbeats. The hook is wired so each
heartbeat refreshes ``queue_length`` from the live scheduler.
- ``omni_stage_id``: logical stage id this replica belongs to.
Required when ``omni_coordinator_address`` is provided.
- ``omni_replica_id``: cluster-unique replica id within the
stage (assigned by :class:`OmniMasterServer`). Used for
logging / metrics only.
"""
signal_callback: SignalCallback | None = None
maybe_register_config_serialize_by_value()
engine_core: StageEngineCoreProc | None = None
coord_client: OmniCoordClientForStage | None = None
try:
vllm_config: VllmConfig = kwargs["vllm_config"]
parallel_config = vllm_config.parallel_config
# NOTE: previous revisions hardcoded data_parallel_size=1 here
# (TODO referencing issue #984). The hardcoding has been removed
# so the DP fields propagate through from the caller exactly
# like upstream vLLM.
set_process_title(f"StageEngineCoreProc_DP{dp_rank}")
stage_label = f"stage{omni_stage_id}" if omni_stage_id is not None else "noid"
set_process_title(f"StageEngineCoreProc_{stage_label}_replica{omni_replica_id}_DP{dp_rank}")
decorate_logs()
# the current vllm-omni does not support data parallelism,
# so we set the data parallel size to 1.
# [TODO] support data parallelism in the future.
# https://github.com/vllm-project/vllm-omni/issues/984
parallel_config.data_parallel_size = 1
parallel_config.data_parallel_size_local = 1
parallel_config.data_parallel_rank = 0
parallel_config.data_parallel_index = dp_rank
os.environ["VLLM_OMNI_REPLICA_ID"] = str(max(int(omni_replica_id), 0))
engine_core = StageEngineCoreProc(
*args,
@@ -82,6 +99,41 @@ class StageEngineCoreProc(EngineCoreProc):
**kwargs,
)
# Each subprocess corresponds to exactly one omni replica with
# its own OmniMasterServer allocation, so the heartbeat client
# runs unconditionally — there is no dp_rank-based gating.
if omni_coordinator_address is not None:
if omni_stage_id is None:
raise ValueError("omni_stage_id must be provided when omni_coordinator_address is set")
addresses: EngineZmqAddresses = engine_core.addresses
if not addresses.inputs or not addresses.outputs:
raise RuntimeError(
"EngineCore handshake did not populate input/output addresses; "
"cannot start OmniCoordClientForStage"
)
coord_client = OmniCoordClientForStage(
coord_zmq_addr=omni_coordinator_address,
input_addr=addresses.inputs[0],
output_addr=addresses.outputs[0],
stage_id=int(omni_stage_id),
)
def _refresh_queue_length() -> None:
"""Pre-heartbeat hook: refresh queue_length from scheduler."""
scheduler = getattr(engine_core, "scheduler", None)
if scheduler is None:
return
try:
coord_client._queue_length = int( # type: ignore[union-attr]
scheduler.get_num_unfinished_requests()
)
except Exception:
# Live scheduler stats are best-effort — heartbeats
# must not fail because of a stats lookup error.
pass
coord_client._on_heartbeat = _refresh_queue_length
def wakeup_engine() -> None:
engine_core.input_queue.put_nowait((EngineCoreRequestType.WAKEUP, None))
@@ -111,6 +163,9 @@ class StageEngineCoreProc(EngineCoreProc):
signal.signal(signal.SIGINT, signal.SIG_DFL)
if signal_callback is not None:
signal_callback.stop()
if coord_client is not None:
with contextlib.suppress(RuntimeError):
coord_client.close()
if engine_core is not None:
engine_core.shutdown()
+390 -59
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import contextlib
import dataclasses
import socket
import threading
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from typing import Any
@@ -31,6 +32,15 @@ logger = init_logger(__name__)
StageRoute = tuple[int, int]
# Sentinel that signals "auto-assign me a replica_id" on the wire. Negative
# values are not valid replica ids, so any sub-zero value works equivalently.
AUTO_ASSIGN_REPLICA_ID = -1
# Callback signature for OmniMasterServer.on_register. Fires only for
# auto-assigned replicas (new, headless-launched). The arguments are
# (stage_id, replica_id, allocation).
OnRegisterCallback = Callable[[int, int, "StageAllocation"], None]
# Poll period (ms) used by the registration/handshake loop.
_POLL_PERIOD_MS = 5_000
# Default timeout (s) for a stage to send READY.
@@ -110,6 +120,10 @@ class OmniMasterServer:
master_port: int,
stage_ids: list[int],
stage_replica_counts: dict[int, int] | None = None,
*,
coordinator_router_address: str | None = None,
on_register: OnRegisterCallback | None = None,
head_local_replicas: dict[int, list[int]] | None = None,
) -> None:
self._address = master_address
self._port = master_port
@@ -117,25 +131,41 @@ class OmniMasterServer:
self._stage_configs: dict[StageRoute, Any] = {}
self._stage_coordinator_addresses: dict[StageRoute, StageCoordinatorAddresses] = {}
self._stage_config_events: dict[StageRoute, threading.Event] = {}
# Coordinator ROUTER address echoed back in every registration reply
# so OmniCoordClientForStage knows where to connect from inside the
# engine subprocess.
self._coordinator_router_address = coordinator_router_address
# Fires only for *newly assigned* (auto-assigned) replicas, not for
# head-side pre-allocated slots that already have head-side clients.
self._on_register = on_register
# Per-stage allocation lock + auto-assign cursor, so concurrent
# registrations from multiple headless processes for the same stage
# don't race on the routing table.
self._alloc_lock = threading.Lock()
self._stage_ids_known: set[int] = set(int(sid) for sid in stage_ids)
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
stage_replica_counts = dict(stage_replica_counts or {})
# Slots the *head* itself will fill via ``launch_omni_core_engines``
# / its own ``register_stage_with_omni_master`` call. Auto-assigning
# headless registrations must skip these even when they appear
# ``_stage_configs``-unfilled — otherwise a fast headless on the same
# host can race the head's own registration and steal slot 0.
self._head_local_slots: set[StageRoute] = set()
for sid, rids in (head_local_replicas or {}).items():
for rid in rids:
self._head_local_slots.add((int(sid), int(rid)))
for sid in stage_ids:
replica_count = max(1, int(stage_replica_counts.get(sid, 1)))
replica_count = int(stage_replica_counts.get(sid, 1))
# Allow 0 explicitly so non-self stages (head distributed mode)
# can declare "no local replicas; remote ones will register
# dynamically".
if replica_count < 0:
raise ValueError(f"stage_replica_counts[{sid}] must be >= 0, got {replica_count}")
for replica_id in range(replica_count):
route = (sid, replica_id)
self._stage_config_events[route] = threading.Event()
self._stage_coordinator_addresses[route] = StageCoordinatorAddresses()
hs_port, inp_port, out_port = get_open_ports_list(count=3)
self._stage_routes[route] = StageAllocation(
handshake_bind_address=f"tcp://{master_address}:{hs_port}",
handshake_connect_address=f"tcp://{master_address}:{hs_port}",
input_bind_address=f"tcp://{master_address}:{inp_port}",
input_connect_address=f"tcp://{master_address}:{inp_port}",
output_bind_address=f"tcp://{master_address}:{out_port}",
output_connect_address=f"tcp://{master_address}:{out_port}",
)
self._allocate_route_locked(sid, replica_id)
logger.info(
"[OmniMasterServer] Pre-allocated addresses for stages %s (master=%s:%d)",
@@ -157,10 +187,84 @@ class OmniMasterServer:
"""Return the registration port exposed to stage launchers."""
return self._port
@property
def coordinator_router_address(self) -> str | None:
"""Return the OmniCoordinator ROUTER address echoed to replicas."""
return self._coordinator_router_address
def get_allocation(self, stage_id: int, replica_id: int = 0) -> StageAllocation:
"""Return the full address allocation for *stage_id*."""
return self._stage_routes[(stage_id, replica_id)]
# ------------------------------------------------------------------
# Allocation
# ------------------------------------------------------------------
def _allocate_route_locked(self, stage_id: int, replica_id: int) -> StageAllocation:
"""Allocate handshake/input/output ports for ``(stage_id, replica_id)``.
Idempotent: if the route already exists, returns the existing
allocation unchanged. Caller is responsible for holding
``self._alloc_lock`` when needed.
"""
route = (stage_id, replica_id)
existing = self._stage_routes.get(route)
if existing is not None:
return existing
self._stage_config_events[route] = threading.Event()
self._stage_coordinator_addresses[route] = StageCoordinatorAddresses()
hs_port, inp_port, out_port = get_open_ports_list(count=3)
alloc = StageAllocation(
handshake_bind_address=f"tcp://{self._address}:{hs_port}",
handshake_connect_address=f"tcp://{self._address}:{hs_port}",
input_bind_address=f"tcp://{self._address}:{inp_port}",
input_connect_address=f"tcp://{self._address}:{inp_port}",
output_bind_address=f"tcp://{self._address}:{out_port}",
output_connect_address=f"tcp://{self._address}:{out_port}",
)
self._stage_routes[route] = alloc
return alloc
def _next_free_replica_id(self, stage_id: int) -> int:
"""Return the next replica id to assign for an auto-assign registration.
Strategy: prefer filling a pre-allocated-but-unfilled slot (one that
``__init__`` reserved in ``_stage_routes`` but no registration has
completed yet) so the head's bootstrap path — which waits on
``_stage_config_events[(stage_id, replica_id)]`` for specific
pre-allocated ids — unblocks. Only when every pre-allocated slot for
this stage has been filled do we allocate a fresh id.
Slots in ``_head_local_slots`` are reserved for the head's own
``launch_omni_core_engines`` registration. Auto-assign must skip
them even when ``_stage_configs`` shows them unfilled — otherwise a
same-host headless that registers before the head's own
``register_stage_with_omni_master`` call would steal slot 0.
Without this, a headless contributor using ``--omni-dp-size-local > 1``
(auto-assign mode) would skip past pre-allocated slot 0 and pick ids
beyond ``num_replicas``, deadlocking the head's
``connect_remote_engine_cores`` wait.
"""
# Pre-allocated slots that haven't received a registration yet are
# tracked by absence from ``_stage_configs``. Head-owned slots are
# not auto-assignable.
for sid, rid in sorted(self._stage_routes):
if sid != stage_id:
continue
if (sid, rid) in self._head_local_slots:
continue
if (sid, rid) not in self._stage_configs:
return rid
# Every pre-allocated slot is filled (or head-owned); allocate a
# fresh id past the existing routes.
used = {rid for (sid, rid) in self._stage_routes if sid == stage_id}
rid = 0
while rid in used:
rid += 1
return rid
def register_stage_config(
self,
stage_id: int,
@@ -280,12 +384,21 @@ class OmniMasterServer:
poller = zmq.Poller()
poller.register(reg_socket, zmq.POLLIN)
# The server runs until ``stop()`` is called so that headless replicas
# spawned after the head finished its initial bring-up can still
# register dynamically. ``pending`` is kept around purely for
# debug-level logging of which pre-allocated slots have not yet
# registered; once empty it does not terminate the loop.
pending: set[StageRoute] = set(self._stage_routes.keys())
while pending and not self._stop_event.is_set():
while not self._stop_event.is_set():
events: list[tuple[zmq.Socket, int]] = poller.poll(_POLL_PERIOD_MS) # type: ignore[assignment]
if not events:
logger.debug("[OmniMasterServer] Still waiting for registration from stages: %s", pending)
if pending:
logger.debug(
"[OmniMasterServer] Still waiting for registration from pre-allocated slots: %s",
pending,
)
continue
for sock, _ in events:
@@ -296,12 +409,12 @@ class OmniMasterServer:
# Cleanup
reg_socket.close(linger=0)
logger.info("[OmniMasterServer] All stages registered; server thread exiting.")
logger.info("[OmniMasterServer] Server thread exiting.")
def _handle_registration(self, reg_socket: zmq.Socket) -> StageRoute | None: # type: ignore[type-arg]
"""Receive a stage registration and reply with the handshake address.
Returns the registered stage_id on success, or None on failure.
Returns ``(stage_id, replica_id)`` on success or ``None`` on failure.
"""
frames = reg_socket.recv_multipart()
if len(frames) < 2:
@@ -318,45 +431,186 @@ class OmniMasterServer:
logger.warning("[OmniMasterServer] Failed to decode registration message: %s", exc)
return None
stage_id: int | None = msg.get("stage_id")
replica_id = int(msg.get("replica_id", 0) or 0)
key = (stage_id, replica_id)
if key not in self._stage_routes:
stage_id_raw = msg.get("stage_id")
if not isinstance(stage_id_raw, int) or stage_id_raw < 0:
logger.warning(
"[OmniMasterServer] Received registration for unknown stage_id=%s replica_id=%s",
stage_id,
replica_id,
"[OmniMasterServer] Registration missing or invalid stage_id: %r",
stage_id_raw,
)
return None
stage_id: int = stage_id_raw
self.register_stage_config(
stage_id,
msg.get("stage_config"),
coordinator_addresses=StageCoordinatorAddresses(
coordinator_input=msg.get("coordinator_input"),
coordinator_output=msg.get("coordinator_output"),
frontend_stats_publish_address=msg.get("frontend_stats_publish_address"),
),
replica_id=replica_id,
)
incoming_replica_id = int(msg.get("replica_id", 0) or 0)
was_auto_assigned = incoming_replica_id < 0
# Distinguish two registration shapes:
# - Pre-allocated slots (concrete replica_id >= 0): the head built
# this slot during _initialize_stages. Just confirm it; do NOT
# fire ``on_register`` (the head already has a head-side client).
# - Auto-assigned slots (replica_id == AUTO_ASSIGN_REPLICA_ID):
# a *new* replica from a headless launcher. Allocate, then
# fire ``on_register`` so the orchestrator attaches.
with self._alloc_lock:
if was_auto_assigned:
replica_id = self._next_free_replica_id(stage_id)
# When auto-assign picks a slot the head pre-allocated (and
# is therefore waiting on in ``connect_remote_engine_cores``),
# the head's bootstrap path builds the head-side client. We
# must NOT also fire ``on_register`` for it; otherwise the
# orchestrator would build a duplicate client and overwrite
# the bootstrap-built one in the pool, leaking it.
preexisting_slot = (stage_id, replica_id) in self._stage_routes
alloc = self._allocate_route_locked(stage_id, replica_id)
if preexisting_slot:
was_auto_assigned = False
else:
replica_id = incoming_replica_id
if (stage_id, replica_id) not in self._stage_routes:
# Tolerate explicit replica_ids that haven't been
# pre-allocated (e.g. headless that wants a specific id).
alloc = self._allocate_route_locked(stage_id, replica_id)
was_auto_assigned = True
else:
alloc = self._stage_routes[(stage_id, replica_id)]
# Cross-host override: when the registering replica advertised
# its own bind address + ports, rewrite the StageAllocation so
# each socket is rooted on the host that actually binds it
# (the master's pre-allocated ports are unreachable from a
# remote replica's host).
#
# Diffusion and LLM stages have different binder ownership:
#
# Diffusion remote replica (StageDiffusionProc):
# - handshake: replica binds -> rewrite to replica IP
# - input : replica binds -> rewrite to replica IP
# - output : replica binds -> rewrite to replica IP
#
# LLM remote replica (CoreClient on head):
# - handshake: head binds (``connect_remote_engine_cores``)
# -> keep on master IP, worker TCP-connects
# - input : head binds (``CoreClient`` ROUTER)
# -> keep on master IP, worker TCP-connects
# - output : head binds (``CoreClient`` PULL — default
# bind=True for PULL in ``make_zmq_socket``)
# -> keep on master IP, worker TCP-connects
#
# The registrant indicates which case via the boolean
# ``replica_binds_sockets`` payload flag. It defaults to
# True (the diffusion / single-host case) so older callers
# still get the previous full-rewrite semantics. For LLM
# remote replicas, the master keeps every address on its
# own host and the remote worker establishes 3 outbound
# TCP connections to the master.
new_bind_address = msg.get("replica_bind_address")
if new_bind_address:
replica_binds_sockets = bool(msg.get("replica_binds_sockets", True))
if replica_binds_sockets:
hs_port = int(msg["replica_handshake_port"])
inp_port = int(msg["replica_input_port"])
out_port = int(msg["replica_output_port"])
hs_bind_addr = f"tcp://{new_bind_address}:{hs_port}"
inp_bind_addr = f"tcp://{new_bind_address}:{inp_port}"
out_bind_addr = f"tcp://{new_bind_address}:{out_port}"
alloc = StageAllocation(
handshake_bind_address=hs_bind_addr,
handshake_connect_address=hs_bind_addr,
input_bind_address=inp_bind_addr,
input_connect_address=inp_bind_addr,
output_bind_address=out_bind_addr,
output_connect_address=out_bind_addr,
)
self._stage_routes[(stage_id, replica_id)] = alloc
logger.info(
"[OmniMasterServer] Stage %d replica %d cross-host bind (sockets bound on %s; replica_ip=%s)",
stage_id,
replica_id,
"replica" if replica_binds_sockets else "master",
new_bind_address,
)
# Mark the slot as filled *inside* the lock. Without this,
# concurrent auto-assign registrations from a second headless
# could call ``_next_free_replica_id`` between the lock
# releasing above and the ``register_stage_config`` call
# below, observe the slot as unfilled, and hand the same
# pre-allocated handshake/input/output addresses to two
# different replicas — which then collide on
# ``zmq_socket_ctx(handshake_address, ROUTER, bind=True)``.
self.register_stage_config(
stage_id,
msg.get("stage_config"),
coordinator_addresses=StageCoordinatorAddresses(
coordinator_input=msg.get("coordinator_input"),
coordinator_output=msg.get("coordinator_output"),
frontend_stats_publish_address=msg.get("frontend_stats_publish_address"),
),
replica_id=replica_id,
)
# Fire on_register only for genuinely new (auto-assigned or newly
# allocated) replicas, on the ROUTER thread. Callback is expected to
# be cheap and non-blocking (e.g. enqueue onto an asyncio queue).
if was_auto_assigned and self._on_register is not None:
try:
self._on_register(stage_id, replica_id, alloc)
except Exception:
logger.exception(
"[OmniMasterServer] on_register callback failed for stage=%d replica=%d",
stage_id,
replica_id,
)
alloc = self._stage_routes[key]
response = msgspec.msgpack.encode(
{
"handshake_address": alloc.handshake_connect_address,
"input_address": alloc.input_bind_address,
"output_address": alloc.output_bind_address,
"replica_id": replica_id,
"coordinator_router_address": self._coordinator_router_address,
}
)
# ROUTER-DEALER: reply is [identity, payload] (no empty delimiter).
reg_socket.send_multipart([identity, response])
logger.info(
"[OmniMasterServer] Stage %d replica %d registered; assigned handshake=%s",
"[OmniMasterServer] Stage %d replica %d registered (auto=%s); handshake=%s",
stage_id,
replica_id,
was_auto_assigned,
alloc.handshake_connect_address,
)
return key
return (stage_id, replica_id)
@dataclass(frozen=True)
class StageRegistrationResponse:
"""Reply payload returned by :class:`OmniMasterServer` after a successful registration."""
handshake_address: str
input_address: str
output_address: str
replica_id: int
coordinator_router_address: str | None
def _detect_local_bind_address(master_address: str, master_port: int) -> str:
"""Return the local IP the kernel would use to reach the master.
Uses a connected UDP socket as a routing-table probe: ``connect()`` on
SOCK_DGRAM sends no packets but forces a route lookup, after which
``getsockname()[0]`` exposes the source IP that an outbound packet to
``(master_address, master_port)`` would carry. For a co-located master
this returns the loopback or eth0 IP (same effect as the legacy
``self._address`` behaviour); for a remote master it returns the
NIC IP that's actually reachable from the master — which is exactly
the address the headless's per-stage ZMQ sockets must bind on.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((master_address, master_port))
return s.getsockname()[0]
finally:
s.close()
def register_stage_with_omni_master(
@@ -367,23 +621,37 @@ def register_stage_with_omni_master(
omni_stage_config: Any = None,
coordinator: DPCoordinator | None = None,
return_addresses: bool = False,
replica_id: int = 0,
) -> str | tuple[str, str, str]:
replica_id: int | None = 0,
return_full_response: bool = False,
replica_bind_address: str | None = None,
replica_binds_sockets: bool = True,
) -> str | tuple[str, str, str] | StageRegistrationResponse:
"""Register a stage with the omni master server.
Returns the per-stage handshake address by default. When
``return_addresses`` is true, also returns the stage input/output
addresses allocated by the master.
addresses allocated by the master. When ``return_full_response`` is
true, returns the full :class:`StageRegistrationResponse` including the
assigned ``replica_id`` and the OmniCoordinator ROUTER address (if
published by the master).
Pass ``replica_id=None`` to request auto-assignment of a free replica
id by the master (used by headless launchers).
"""
if replica_id is None:
wire_replica_id = AUTO_ASSIGN_REPLICA_ID
else:
wire_replica_id = int(replica_id)
reg_ctx = zmq.Context()
try:
reg_sock: zmq.Socket = reg_ctx.socket(zmq.DEALER) # type: ignore[attr-defined]
try:
reg_sock.connect(f"tcp://{omni_master_address}:{omni_master_port}")
payload = {
payload: dict[str, Any] = {
"stage_id": omni_stage_id,
"replica_id": replica_id,
"replica_id": wire_replica_id,
"stage_config": _serialize_stage_config(omni_stage_config),
}
if coordinator is not None:
@@ -392,6 +660,31 @@ def register_stage_with_omni_master(
payload["coordinator_output"] = coordinator_output
payload["frontend_stats_publish_address"] = coordinator.get_stats_publish_address()
# Always advertise THIS host's local bind address + 3 locally
# free ports so the master can root the per-stage socket
# allocation on the replica's own interface. For a co-located
# replica the detected IP matches the master's address and
# the override is a no-op semantically; for a cross-host
# replica it's what makes the headless's ROUTER bind succeed
# (otherwise the master would hand back ``tcp://<master_ip>:port``
# and ``zmq.bind`` would EADDRNOTAVAIL on the remote host).
if replica_bind_address is None:
replica_bind_address = _detect_local_bind_address(omni_master_address, omni_master_port)
hs_port, inp_port, out_port = get_open_ports_list(count=3)
payload["replica_bind_address"] = replica_bind_address
payload["replica_handshake_port"] = hs_port
payload["replica_input_port"] = inp_port
payload["replica_output_port"] = out_port
# ``False`` only for LLM headless replicas: the head's
# ``connect_remote_engine_cores`` is the binder for the
# handshake ROUTER, and ``CoreClient.__init__`` binds the
# input ROUTER and the output PULL (``make_zmq_socket``
# defaults bind=True for PULL). The master must keep all
# three addresses on the master's host so the head can
# ``bind`` them; the remote LLM worker TCP-connects across
# hosts on all three.
payload["replica_binds_sockets"] = bool(replica_binds_sockets)
reg_sock.send(msgspec.msgpack.encode(payload))
timeout_ms = _DEFAULT_STARTUP_TIMEOUT_S * 1_000
if not reg_sock.poll(timeout=timeout_ms):
@@ -402,13 +695,16 @@ def register_stage_with_omni_master(
f"for stage {omni_stage_id}."
)
response_bytes = reg_sock.recv()
response = msgspec.msgpack.decode(response_bytes)
handshake_address: str = response["handshake_address"]
input_address: str = response["input_address"]
output_address: str = response["output_address"]
response_msg = msgspec.msgpack.decode(response_bytes)
handshake_address: str = response_msg["handshake_address"]
input_address: str = response_msg["input_address"]
output_address: str = response_msg["output_address"]
assigned_replica_id: int = int(response_msg.get("replica_id", wire_replica_id))
coord_router_addr: str | None = response_msg.get("coordinator_router_address")
logger.info(
"Stage %d registered; handshake_address=%s",
"Stage %d replica %d registered; handshake_address=%s",
omni_stage_id,
assigned_replica_id,
handshake_address,
)
finally:
@@ -416,6 +712,14 @@ def register_stage_with_omni_master(
finally:
reg_ctx.term()
if return_full_response:
return StageRegistrationResponse(
handshake_address=handshake_address,
input_address=input_address,
output_address=output_address,
replica_id=assigned_replica_id,
coordinator_router_address=coord_router_addr,
)
if return_addresses:
return handshake_address, input_address, output_address
return handshake_address
@@ -543,8 +847,16 @@ def launch_omni_core_engines(
stage_id: int,
stage_config: Any = None,
replica_id: int = 0,
*,
omni_coordinator_address: str | None = None,
) -> Iterator[tuple[CoreEngineProcManager, DPCoordinator | None, EngineZmqAddresses]]:
"""Launch local engine cores using the omni registration flow."""
"""Launch local engine cores using the omni registration flow.
When ``omni_coordinator_address`` is provided, the spawned engine
subprocesses use :class:`OmniCoreEngineProcManager` and each
instantiates an :class:`OmniCoordClientForStage` after the handshake
completes so the head's :class:`OmniCoordinator` knows about them.
"""
addresses = omni_master_server.get_zmq_addresses(stage_id, replica_id=replica_id)
parallel_config = vllm_config.parallel_config
# Determine the number of local engines and their ranks.
@@ -608,16 +920,35 @@ def launch_omni_core_engines(
handshake_bind_address = omni_master_server.get_allocation(stage_id, replica_id=replica_id).handshake_bind_address
with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket:
local_engine_manager = CoreEngineProcManager(
local_engine_count=local_engine_count,
start_index=start_index,
local_start_index=local_start_index,
vllm_config=vllm_config,
local_client=True,
handshake_address=handshake_address,
executor_class=executor_class,
log_stats=log_stats,
)
if omni_coordinator_address is not None:
# Use the omni subclass so each spawned subprocess instantiates
# an OmniCoordClientForStage and heartbeats to the coordinator.
from vllm_omni.engine.omni_core_engine_proc_manager import OmniCoreEngineProcManager
local_engine_manager: CoreEngineProcManager = OmniCoreEngineProcManager(
local_engine_count=local_engine_count,
start_index=start_index,
local_start_index=local_start_index,
vllm_config=vllm_config,
local_client=True,
handshake_address=handshake_address,
executor_class=executor_class,
log_stats=log_stats,
omni_stage_id=stage_id,
omni_coordinator_address=omni_coordinator_address,
omni_replica_base_id=replica_id,
)
else:
local_engine_manager = CoreEngineProcManager(
local_engine_count=local_engine_count,
start_index=start_index,
local_start_index=local_start_index,
vllm_config=vllm_config,
local_client=True,
handshake_address=handshake_address,
executor_class=executor_class,
log_stats=log_stats,
)
yield local_engine_manager, coordinator, addresses
+115 -19
View File
@@ -61,6 +61,63 @@ class LogicalStageInitPlan:
replicas: list[ReplicaInitPlan]
@dataclass
class StageRemoteFactoryContext:
"""Per-stage context cached by AsyncOmniEngine for dynamic replica attach.
Populated once during ``_bootstrap_orchestrator`` from the per-stage
init plans. ``_build_remote_replica`` consumes it to construct the
right head-side stage client when a headless replica registers.
"""
stage_id: int
stage_type: str
stage_cfg: Any
base_metadata: Any
# LLM-only fields:
vllm_config: Any | None = None
executor_class: type | None = None
# Diffusion-only fields:
diffusion_batch_size: int = 1
def capture_stage_factory_contexts(
stage_plans: Sequence[LogicalStageInitPlan],
diffusion_batch_size: int,
) -> dict[int, StageRemoteFactoryContext]:
"""Snapshot per-stage construction context for dynamic replica attach.
Called once after ``_initialize_stages`` finishes. The captured
context holds everything ``_build_remote_replica`` needs to build a
fresh head-side client when a new headless replica registers
(vllm_config / executor_class for LLM, batch_size for diffusion,
plus the base stage metadata).
Per-replica fields like ``replica_id`` are filled in at build time,
not at capture time.
"""
contexts: dict[int, StageRemoteFactoryContext] = {}
for plan in stage_plans:
if not plan.replicas:
# Stage was declared but has zero replicas locally; we still
# want to be able to attach incoming headless ones, so use
# the stage_cfg-derived context if any replica plan exists.
continue
template = plan.replicas[0]
stage_id = int(plan.configured_stage_id)
stage_type = template.metadata.stage_type or "llm"
contexts[stage_id] = StageRemoteFactoryContext(
stage_id=stage_id,
stage_type=stage_type,
stage_cfg=template.stage_cfg,
base_metadata=template.metadata,
vllm_config=template.stage_vllm_config,
executor_class=template.executor_class,
diffusion_batch_size=diffusion_batch_size,
)
return contexts
def _resolve_model_to_local_path(model: str) -> str:
"""Resolve an HF Hub model ID to a local cache path."""
if os.path.isdir(model):
@@ -420,32 +477,59 @@ def split_devices_for_replicas(
"""Split a devices string into per-replica subsets.
When ``num_replicas`` is 1, returns ``[devices_str]`` unchanged.
Otherwise, the total number of device IDs must equal
``num_replicas * tp_size``; each replica gets ``tp_size`` consecutive
device IDs.
Otherwise, two YAML shapes are accepted:
Example::
1. **Legacy / pool mode** — ``len(devices) == num_replicas * tp_size``:
the string enumerates the full per-stage pool. Each replica gets
``tp_size`` consecutive entries. The values are logical indices
into the launcher's ``CUDA_VISIBLE_DEVICES``.
split_devices_for_replicas("1,2,3,4", num_replicas=2, tp_size=2, stage_id=1)
# → ["1,2", "3,4"]
``split_devices_for_replicas("1,2,3,4", 2, 2, 1) → ["1,2", "3,4"]``
2. **Template mode** — ``len(devices) == tp_size``: the YAML declares
a single per-replica template (the same shape one replica would
use), and is **dp-independent**. Each replica r gets the offsets
``[r*tp_size + a for a in template]`` of the launcher's
``CUDA_VISIBLE_DEVICES``. The template's entries must lie in
``[0, tp_size)``.
``split_devices_for_replicas("0,1", 2, 2, 1) → ["0,1", "2,3"]``
``split_devices_for_replicas("0,1", 4, 2, 1) → ["0,1", "2,3", "4,5", "6,7"]``
This lets the same ``devices: "0,1"`` YAML work for any
``--omni-dp-size-local``: the launcher's CVD scales, the YAML
does not.
Any other length raises ``ValueError`` (the two modes are
length-disjoint for ``num_replicas > 1``).
"""
if num_replicas <= 1 or devices_str is None:
return [devices_str] if devices_str is not None else [devices_str]
device_list = [d.strip() for d in devices_str.split(",") if d.strip()]
required = num_replicas * tp_size
if len(device_list) != required:
raise ValueError(
f"Stage {stage_id}: num_replicas={num_replicas}, "
f"tensor_parallel_size={tp_size} requires "
f"{required} devices, got {len(device_list)}: {devices_str}"
)
result: list[str] = []
for r in range(num_replicas):
chunk = device_list[r * tp_size : (r + 1) * tp_size]
result.append(",".join(chunk))
return result
if len(device_list) == num_replicas * tp_size:
return [",".join(device_list[r * tp_size : (r + 1) * tp_size]) for r in range(num_replicas)]
if len(device_list) == tp_size:
try:
offsets = [int(a) for a in device_list]
except ValueError as e:
raise ValueError(f"Stage {stage_id}: template-mode devices must be ints, got {devices_str!r}") from e
bad = [a for a in offsets if not (0 <= a < tp_size)]
if bad:
raise ValueError(
f"Stage {stage_id}: template-mode device offset(s) {bad} "
f"out of range [0, {tp_size}); devices={devices_str!r}"
)
return [",".join(str(r * tp_size + a) for a in offsets) for r in range(num_replicas)]
raise ValueError(
f"Stage {stage_id}: devices={devices_str!r} has {len(device_list)} id(s); "
f"need either {tp_size} (template, dp-independent) or "
f"{num_replicas * tp_size} (pool / legacy). "
f"num_replicas={num_replicas}, tensor_parallel_size={tp_size}."
)
def get_stage_tp_size(stage_cfg: Any) -> int:
@@ -479,9 +563,19 @@ def get_stage_devices_per_replica(stage_cfg: Any) -> int:
def compute_replica_layout(
stage_configs: Sequence[Any],
*,
allow_zero: bool = False,
) -> tuple[list[int], dict[int, list[str]]]:
"""Compute per-stage replica counts and device assignments.
Args:
stage_configs: per-stage config objects with a ``runtime`` sub-config
exposing ``num_replicas`` and ``devices``.
allow_zero: when True, ``num_replicas == 0`` is honored (used by
single-stage / head-distributed mode for non-self stages that
will be filled dynamically by remote registrations); when False
(default), the count is clamped to at least 1.
Returns:
replicas_per_stage: num_replicas per logical stage.
replica_devices_map: stage_idx -> per-replica device strings
@@ -495,7 +589,9 @@ def compute_replica_layout(
if hasattr(runtime_cfg, "get")
else getattr(runtime_cfg, "num_replicas", 1)
)
replicas_per_stage.append(max(1, num_replicas))
if num_replicas < 0:
raise ValueError(f"num_replicas must be >= 0, got {num_replicas}")
replicas_per_stage.append(num_replicas if allow_zero else max(1, num_replicas))
replica_devices_map: dict[int, list[str]] = {}
for stage_id, stage_cfg in enumerate(stage_configs):
+355 -32
View File
@@ -10,6 +10,13 @@ from typing import TYPE_CHECKING, Any, cast
from vllm.logger import init_logger
from vllm.v1.engine import EngineCoreOutputs
from vllm_omni.distributed.omni_coordinator import (
LoadBalancer,
OmniCoordClientForHub,
ReplicaInfo,
ReplicaStatus,
)
from vllm_omni.distributed.omni_coordinator.load_balancer import Task
from vllm_omni.engine.stage_client import (
StagePoolClient,
StagePoolDiffusionClient,
@@ -35,7 +42,33 @@ class _ReplicaMetrics:
class StagePool:
"""Replicas of one logical stage with RR + affinity selection."""
"""Replicas of one logical stage + per-stage routing (LB + affinity).
The pool owns the head-side stage clients for one logical stage. It also
absorbs the per-stage dispatch responsibility (load balancing, affinity
tracking, bounded-wait pick) that used to live in a separate
``StageDispatcher`` class — see the design doc for the rationale.
In distributed mode (when an :class:`OmniCoordClientForHub` and a
:class:`LoadBalancer` are injected via :meth:`attach_hub` /
:meth:`attach_load_balancer`), :meth:`pick` consults the hub's cached
replica list and routes via the load balancer, sticking subsequent calls
for the same ``request_id`` to the same replica.
In non-distributed mode (no hub attached), :meth:`pick` falls back to the
legacy ``select_replica_id`` round-robin path so the multi-stage
in-process invocation is unchanged.
Dynamic replica membership: when a remote replica is added or removed
(driven by :class:`Orchestrator` via :meth:`add_client` /
:meth:`remove_client`), the pool keeps stable integer ``replica_id``s by
storing clients in a list whose entries can be ``None`` after a removal.
Iteration callers should use :meth:`live_replica_ids` rather than
``range(pool.num_replicas)`` to skip the gaps.
"""
DISPATCH_WAIT_TIMEOUT_S: float = 10.0
DISPATCH_RETRY_INTERVAL_S: float = 0.1
def __init__(
self,
@@ -50,33 +83,69 @@ class StagePool:
else:
normalized_clients = [clients]
if not normalized_clients:
raise ValueError(f"StagePool for stage {stage_id} has no replicas")
# Allow empty pools when running in distributed head mode for a
# non-self stage; clients will arrive via add_client(...).
self.stage_id = stage_id
self.clients: list[StagePoolClient] = normalized_clients
# Slots can become None after a dynamic remove_client (distributed mode);
# iterate via live_replica_ids() to skip holes.
self.clients: list[StagePoolClient | None] = list(normalized_clients)
self._output_processor = output_processor
self._stage_vllm_config = stage_vllm_config
self._next_replica_id = 0
self._request_bindings: dict[str, int] = {}
self._replica_metrics: list[_ReplicaMetrics] = [_ReplicaMetrics() for _ in self.clients]
# Distributed-mode state. Populated by add_client / remove_client.
self._addr_to_replica_id: dict[str, int] = {}
for replica_id, client in enumerate(self.clients):
if client is not None:
addr = self._client_input_addr(client)
if addr is not None:
self._addr_to_replica_id[addr] = replica_id
# Distributed-mode dispatch hooks (injected by Orchestrator on bring-up).
self._hub: OmniCoordClientForHub | None = None
self._lb: LoadBalancer | None = None
# ``request_id`` → ``input_addr`` affinity (distributed mode only).
# Kept separate from the legacy ``_request_bindings`` so the two
# binding shapes do not collide.
self._affinity: dict[str, str] = {}
# ---- Stage-level properties ----
@property
def num_replicas(self) -> int:
"""Total slot count, including ``None`` holes from removed replicas.
Use :meth:`live_replica_ids` to iterate only live entries.
"""
return len(self.clients)
@property
def live_num_replicas(self) -> int:
"""Number of currently live (non-None) replicas in this pool."""
return sum(1 for c in self.clients if c is not None)
def live_replica_ids(self) -> list[int]:
"""Return the indices of currently live replicas in this pool."""
return [i for i, c in enumerate(self.clients) if c is not None]
@property
def stage_type(self) -> str | None:
return self.stage_client.stage_type
client = self.stage_client
return None if client is None else client.stage_type
@property
def final_output(self) -> bool:
return self.clients[0].final_output
client = self.stage_client
return False if client is None else bool(client.final_output)
@property
def stage_client(self) -> StagePoolClient:
return self.clients[0]
def stage_client(self) -> StagePoolClient | None:
for client in self.clients:
if client is not None:
return client
return None
@property
def llm_stage_client(self) -> StagePoolLLMClient:
@@ -90,11 +159,209 @@ class StagePool:
def output_processor(self) -> Any:
return self._output_processor
# ---- Route binding lifecycle ----
@property
def is_distributed(self) -> bool:
"""True iff a hub has been attached (i.e. running in head-distributed mode)."""
return self._hub is not None
# ---- Distributed-mode dispatch hooks ----
def attach_hub(self, hub: OmniCoordClientForHub | None) -> None:
"""Inject the shared :class:`OmniCoordClientForHub`.
Called once by :class:`Orchestrator` after the hub is constructed.
``hub=None`` keeps the pool in legacy mode (no behavior change).
"""
self._hub = hub
def attach_load_balancer(self, lb: LoadBalancer | None) -> None:
"""Inject the per-pool :class:`LoadBalancer` for distributed-mode pick."""
self._lb = lb
# ---- Dynamic membership (distributed mode) ----
@staticmethod
def _client_input_addr(client: Any) -> str | None:
"""Return the input ZMQ address advertised by ``client`` if any.
LLM clients expose ``client_addresses["input_address"]``; diffusion
clients expose ``request_address``. Both are stable strings used by
:class:`OmniCoordinator` to key replicas.
"""
request_address = getattr(client, "request_address", None)
if isinstance(request_address, str) and request_address:
return request_address
addrs = getattr(client, "client_addresses", None)
if isinstance(addrs, dict):
addr = addrs.get("input_address")
if isinstance(addr, str) and addr:
return addr
return None
def add_client(self, input_addr: str, client: Any) -> int:
"""Register a head-side client for ``input_addr``.
Returns the assigned ``replica_id`` (index into :attr:`clients`).
If the address is already known, replaces the existing client and
returns its existing id (this should not happen in practice — the
master server assigns unique slots — but the contract is idempotent
to keep the dispatch layer robust).
"""
if not input_addr:
raise ValueError("input_addr must be a non-empty string")
existing = self._addr_to_replica_id.get(input_addr)
if existing is not None:
self.clients[existing] = client
return existing
replica_id = len(self.clients)
self.clients.append(client)
self._addr_to_replica_id[input_addr] = replica_id
self._replica_metrics.append(_ReplicaMetrics())
return replica_id
def remove_client(self, input_addr: str) -> Any | None:
"""Remove the client at ``input_addr``. Returns the removed client or ``None``.
Slot is marked ``None`` to preserve indices for outstanding bindings.
"""
replica_id = self._addr_to_replica_id.pop(input_addr, None)
if replica_id is None:
return None
client = self.clients[replica_id]
self.clients[replica_id] = None
return client
def get_client_by_addr(self, input_addr: str) -> Any | None:
"""Return the live client for ``input_addr`` if present."""
replica_id = self._addr_to_replica_id.get(input_addr)
if replica_id is None:
return None
return self.clients[replica_id]
def get_replica_id_by_addr(self, input_addr: str) -> int | None:
"""Return the stable replica_id for ``input_addr`` if registered."""
return self._addr_to_replica_id.get(input_addr)
# ---- Per-request distributed dispatch ----
async def pick(
self,
request_id: str,
task: Task | None = None,
*,
affinity_request_id: str | None = None,
) -> int:
"""Return a replica id for ``request_id``.
In distributed mode: consults the hub for UP replicas, runs the load
balancer, and records affinity so future picks for the same
``request_id`` return the same replica. Bounded wait up to
``DISPATCH_WAIT_TIMEOUT_S`` when no UP replica is currently usable.
In non-distributed (legacy) mode: delegates to
:meth:`select_replica_id`.
"""
if self._hub is None or self._lb is None:
return self.select_replica_id(request_id, affinity_request_id=affinity_request_id)
# 1. Sticky: previously bound and still serviceable?
bound_addr = self._affinity.get(request_id)
if bound_addr is not None:
replica_id = self._serviceable_replica_id_for_addr(bound_addr)
if replica_id is not None:
return replica_id
# Bound replica is gone or DOWN — fall through to re-select.
self._affinity.pop(request_id, None)
# 2. Inherited affinity (CFG companion sharing a parent request_id).
if affinity_request_id is not None:
parent_addr = self._affinity.get(affinity_request_id)
if parent_addr is not None:
replica_id = self._serviceable_replica_id_for_addr(parent_addr)
if replica_id is not None:
self._affinity[request_id] = parent_addr
return replica_id
# 3. Fresh pick: poll hub + LB with bounded wait.
task = task or Task(request_id=request_id)
deadline = _time.monotonic() + self.DISPATCH_WAIT_TIMEOUT_S
while True:
candidates = self._collect_serviceable_replicas()
if candidates:
# LB chose an index *into our candidates list*.
lb_idx = self._lb.select(task, [rep for rep, _ in candidates])
replica_info, replica_id = candidates[lb_idx]
self._affinity[request_id] = replica_info.input_addr
return replica_id
now = _time.monotonic()
if now >= deadline:
raise RuntimeError(f"no UP replica for stage {self.stage_id} after {self.DISPATCH_WAIT_TIMEOUT_S:.1f}s")
await asyncio.sleep(min(self.DISPATCH_RETRY_INTERVAL_S, deadline - now))
def _collect_serviceable_replicas(self) -> list[tuple[ReplicaInfo, int]]:
"""Return list of ``(ReplicaInfo, replica_id)`` for UP, attached replicas."""
if self._hub is None:
return []
snap = self._hub.get_replicas_for_stage(self.stage_id)
out: list[tuple[ReplicaInfo, int]] = []
for rep in snap.replicas:
if rep.status != ReplicaStatus.UP:
continue
replica_id = self._addr_to_replica_id.get(rep.input_addr)
if replica_id is None:
continue # Hub knows about it but head-side client not attached yet.
if self.clients[replica_id] is None:
continue
out.append((rep, replica_id))
return out
def _serviceable_replica_id_for_addr(self, input_addr: str) -> int | None:
"""Return ``replica_id`` for ``input_addr`` iff currently UP + attached."""
if self._hub is None:
return None
replica_id = self._addr_to_replica_id.get(input_addr)
if replica_id is None or self.clients[replica_id] is None:
return None
snap = self._hub.get_replicas_for_stage(self.stage_id)
for rep in snap.replicas:
if rep.input_addr == input_addr and rep.status == ReplicaStatus.UP:
return replica_id
return None
def bind(self, request_id: str, input_addr: str) -> None:
"""Explicitly record affinity (distributed mode)."""
self._affinity[request_id] = input_addr
def release(self, request_id: str) -> None:
"""Drop affinity (distributed mode) and legacy binding for ``request_id``."""
self._affinity.pop(request_id, None)
self.release_binding(request_id)
def invalidate_addr(self, input_addr: str) -> list[str]:
"""Drop affinity rows pointing at ``input_addr``; return affected request ids."""
affected: list[str] = [rid for rid, addr in self._affinity.items() if addr == input_addr]
for rid in affected:
self._affinity.pop(rid, None)
return affected
# ---- Legacy (non-distributed) route binding ----
def get_bound_replica_id(self, request_id: str) -> int | None:
"""Return the currently bound replica id for *request_id* if present."""
return self._request_bindings.get(request_id)
"""Return the currently bound replica id for *request_id* if present.
In distributed mode the binding may have been recorded via
:meth:`pick`; we honor it transparently here.
"""
legacy = self._request_bindings.get(request_id)
if legacy is not None:
return legacy
addr = self._affinity.get(request_id)
if addr is None:
return None
return self._addr_to_replica_id.get(addr)
def get_bound_client(self, request_id: str) -> StagePoolClient | None:
"""Return the currently bound client for *request_id* if present."""
@@ -113,6 +380,7 @@ class StagePool:
def release_binding(self, request_id: str) -> None:
"""Drop the route binding for *request_id* in this stage."""
self._request_bindings.pop(request_id, None)
self._affinity.pop(request_id, None)
def release_bindings(self, request_ids: list[str]) -> None:
"""Drop route bindings for the given request ids in this stage."""
@@ -125,27 +393,43 @@ class StagePool:
*,
affinity_request_id: str | None = None,
) -> int:
"""Pick a replica id for *request_id* and cache the choice."""
"""Pick a replica id for *request_id* and cache the choice (legacy path)."""
cached = self.get_bound_replica_id(request_id)
if cached is not None:
if cached is not None and self.clients[cached] is not None:
return cached
chosen = self.get_bound_replica_id(affinity_request_id) if affinity_request_id is not None else None
chosen: int | None = None
if affinity_request_id is not None:
parent = self.get_bound_replica_id(affinity_request_id)
if parent is not None and self.clients[parent] is not None:
chosen = parent
if chosen is None:
if self.num_replicas == 1:
chosen = 0
live = self.live_replica_ids()
if not live:
raise RuntimeError(f"stage {self.stage_id} has no live replicas")
if len(live) == 1:
chosen = live[0]
else:
chosen = self._next_replica_id
self._next_replica_id = (self._next_replica_id + 1) % self.num_replicas
# Round-robin over live replicas only.
start = self._next_replica_id % len(live)
chosen = live[start]
self._next_replica_id = (self._next_replica_id + 1) % len(live)
self._request_bindings[request_id] = chosen
return chosen
def _llm_client(self, replica_id: int) -> StagePoolLLMClient:
return cast(StagePoolLLMClient, self.clients[replica_id])
client = self.clients[replica_id]
if client is None:
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
return cast(StagePoolLLMClient, client)
def _diffusion_client(self, replica_id: int) -> StagePoolDiffusionClient:
return cast(StagePoolDiffusionClient, self.clients[replica_id])
client = self.clients[replica_id]
if client is None:
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
return cast(StagePoolDiffusionClient, client)
# ---- Metrics ----
@@ -206,7 +490,7 @@ class StagePool:
params = params_override if params_override is not None else req_state.sampling_params_list[self.stage_id]
submit_kwargs = dict(submit_kwargs or {})
if self.stage_type == "diffusion":
replica_id = self.select_replica_id(
replica_id = await self._pick_or_select(
request_id,
affinity_request_id=affinity_request_id,
)
@@ -217,10 +501,13 @@ class StagePool:
await client.add_request_async(request_id, request, params, **submit_kwargs)
return replica_id
replica_id = self.select_replica_id(
replica_id = await self._pick_or_select(
request_id,
affinity_request_id=affinity_request_id,
)
client = self.clients[replica_id]
if client is None:
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
try:
self.output_processor.add_request(
request=request,
@@ -262,8 +549,12 @@ class StagePool:
"""Submit a streaming update to an already admitted request."""
params = req_state.sampling_params_list[self.stage_id]
replica_id = self.get_bound_replica_id(request_id)
if replica_id is None:
replica_id = self.select_replica_id(request_id)
if replica_id is None or self.clients[replica_id] is None:
replica_id = await self._pick_or_select(request_id)
client = self.clients[replica_id]
if client is None:
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
if self.stage_type == "diffusion":
await self._diffusion_client(replica_id).add_request_async(request_id, request, params)
@@ -281,6 +572,17 @@ class StagePool:
await self._llm_client(replica_id).add_request_async(request)
return replica_id
async def _pick_or_select(
self,
request_id: str,
*,
affinity_request_id: str | None = None,
) -> int:
"""Bridge to ``pick`` in distributed mode or ``select_replica_id`` legacy."""
if self.is_distributed:
return await self.pick(request_id, affinity_request_id=affinity_request_id)
return self.select_replica_id(request_id, affinity_request_id=affinity_request_id)
# ---- Stage-local polling ----
async def _poll_stage_raw(self, client: StagePoolLLMClient) -> EngineCoreOutputs | None:
@@ -296,7 +598,10 @@ class StagePool:
raw_outputs: EngineCoreOutputs,
) -> list[Any]:
"""Run the shared LLM output processor on one raw poll result."""
client = self._llm_client(replica_id)
raw_client = self.clients[replica_id]
if raw_client is None:
return []
client = cast(StagePoolLLMClient, raw_client)
processor = self.output_processor
processed = processor.process_outputs(
raw_outputs.outputs,
@@ -319,7 +624,10 @@ class StagePool:
timeout_s: float = 0.001,
) -> EngineCoreOutputs | None:
"""Poll raw EngineCore outputs from one LLM replica once."""
client = self._llm_client(replica_id)
raw_client = self.clients[replica_id]
if raw_client is None:
return None
client = cast(StagePoolLLMClient, raw_client)
try:
return await asyncio.wait_for(
self._poll_stage_raw(client),
@@ -339,7 +647,10 @@ class StagePool:
def poll_diffusion_output(self, replica_id: int) -> Any | None:
"""Drain one ready diffusion output from the given replica if present."""
return self._diffusion_client(replica_id).get_diffusion_output_nowait()
raw_client = self.clients[replica_id]
if raw_client is None:
return None
return cast(StagePoolDiffusionClient, raw_client).get_diffusion_output_nowait()
# ---- Stage-local control plane ----
@@ -347,7 +658,7 @@ class StagePool:
"""Abort the given requests in this stage pool.
Request-bound abort routing stays inside the pool because route affinity
(`request_id -> replica_id`) is pool-owned.
(``request_id -> replica_id``) is pool-owned.
"""
if not request_ids:
return
@@ -355,13 +666,16 @@ class StagePool:
request_ids_by_replica: dict[int, list[str]] = {}
for request_id in request_ids:
replica_id = self.get_bound_replica_id(request_id)
if replica_id is None:
logger.debug("[StagePool] abort: no binding for req=%s in stage-%s", request_id, self.stage_id)
if replica_id is None or self.clients[replica_id] is None:
logger.debug("[StagePool] abort: no live binding for req=%s in stage-%s", request_id, self.stage_id)
continue
request_ids_by_replica.setdefault(replica_id, []).append(request_id)
for replica_id, replica_request_ids in request_ids_by_replica.items():
await self.clients[replica_id].abort_requests_async(replica_request_ids)
client = self.clients[replica_id]
if client is None:
continue
await client.abort_requests_async(replica_request_ids)
# Clean up OutputProcessor state (e.g. mm_accumulated tensors) that
# would otherwise leak — aborted requests never produce a final
@@ -377,10 +691,15 @@ class StagePool:
timeout: float | None = None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
) -> Any:
) -> dict[str, Any] | Any:
"""Dispatch a stage-scoped control-plane RPC to one physical route."""
kwargs = dict(kwargs or {})
client = self.clients[replica_id]
if client is None:
return {
"supported": False,
"error": f"stage {self.stage_id} replica {replica_id} is not attached",
}
try:
return await client.collective_rpc_async(
method=method,
@@ -402,7 +721,11 @@ class StagePool:
def shutdown_replica(self, replica_id: int) -> None:
"""Shutdown one backend handle in this stage pool."""
if replica_id >= len(self.clients):
return
client = self.clients[replica_id]
if client is None:
return
try:
client.shutdown()
logger.info(
+364 -67
View File
@@ -9,6 +9,8 @@ import argparse
import json
import os
import signal
import threading
from multiprocessing import connection
from types import FrameType
from typing import Any
@@ -103,6 +105,62 @@ class OmniServeCommand(CLISubcommand):
if args.stage_id is not None and (args.omni_master_address is None or args.omni_master_port is None):
raise ValueError("--stage-id requires both --omni-master-address and --omni-master-port to be set")
# --omni-replica-address is only consulted in run_headless(); reject it
# on the head so a misconfigured launch fails loudly instead of being
# silently ignored.
if getattr(args, "omni_replica_address", None) is not None and not args.headless:
raise ValueError("--omni-replica-address requires --headless to be set")
# --omni-dp-size-local is process-local. A value other than 1 only
# makes sense when this process owns a stage (head or headless).
omni_dp_size_local = getattr(args, "omni_dp_size_local", None)
if omni_dp_size_local is not None:
if omni_dp_size_local < 1:
raise ValueError(f"--omni-dp-size-local must be >= 1, got {omni_dp_size_local}")
if omni_dp_size_local != 1 and args.stage_id is None:
raise ValueError("--omni-dp-size-local != 1 requires --stage-id to be set")
# vLLM CLI args that omni does not honor: parallelism comes from the
# per-stage YAML (parallel_config:, enable_expert_parallel:) and the
# process-local replica count from --omni-dp-size-local. Passing the
# vLLM equivalents on the command line would silently disagree with
# those sources of truth, so reject them at parse time.
if getattr(args, "omni", False):
explicit_cli_keys: set[str] = getattr(args, "_cli_explicit_keys", set()) or set()
prohibited_with_omni: dict[str, str] = {
"data_parallel_size": "--data-parallel-size",
"data_parallel_size_local": "--data-parallel-size-local",
"data_parallel_address": "--data-parallel-address",
"data_parallel_rpc_port": "--data-parallel-rpc-port",
"data_parallel_start_rank": "--data-parallel-start-rank",
"data_parallel_backend": "--data-parallel-backend",
"api_server_count": "--api-server-count",
"enable_expert_parallel": "--enable-expert-parallel",
}
offenders = sorted(flag for dest, flag in prohibited_with_omni.items() if dest in explicit_cli_keys)
if offenders:
raise ValueError(
"The following CLI args are not supported under --omni: "
f"{', '.join(offenders)}. Configure parallelism through the "
"per-stage YAML (`--deploy-config` / `--stage-configs-path`) "
"and replica count via `--omni-dp-size-local`."
)
# --omni-lb-policy is validated against the LoadBalancingPolicy enum.
omni_lb_policy = getattr(args, "omni_lb_policy", None)
if omni_lb_policy is not None:
from vllm_omni.distributed.omni_coordinator import LoadBalancingPolicy
try:
LoadBalancingPolicy(omni_lb_policy)
except ValueError as exc:
valid = ", ".join(p.value for p in LoadBalancingPolicy)
raise ValueError(f"--omni-lb-policy={omni_lb_policy!r} is not one of: {valid}") from exc
omni_heartbeat_timeout = getattr(args, "omni_heartbeat_timeout", None)
if omni_heartbeat_timeout is not None and omni_heartbeat_timeout <= 0:
raise ValueError(f"--omni-heartbeat-timeout must be > 0, got {omni_heartbeat_timeout}")
# Skip validation for diffusion models as they have different requirements
from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model
@@ -190,8 +248,12 @@ class OmniServeCommand(CLISubcommand):
omni_config_group.add_argument(
"--replica-id",
type=int,
default=0,
help="Replica id to register when launching a single headless stage.",
default=None,
help=(
"Deprecated and ignored — replica ids are auto-assigned by the "
"master server. Specifying this flag prints a warning and has "
"no effect."
),
)
omni_config_group.add_argument(
"--stage-init-timeout",
@@ -253,6 +315,50 @@ class OmniServeCommand(CLISubcommand):
type=int,
help="Port of the Omni orchestrator (master).",
)
omni_config_group.add_argument(
"--omni-replica-address",
"-ora",
type=str,
default=None,
help=(
"Local bind address (this host's IP) that the headless stage "
"advertises to the Omni master for its handshake/input/output "
"ZMQ sockets. If unset, auto-detected via a UDP-connect "
"routing probe against --omni-master-address. Override only "
"when the auto-detected IP is wrong (e.g. multi-NIC host "
"where the master is reachable on the wrong interface)."
),
)
omni_config_group.add_argument(
"--omni-dp-size-local",
type=int,
default=1,
help=(
"Number of stage replicas this runtime launches locally for its "
"own --stage-id. Process-local: head and every headless invocation "
"read their own copy; values may differ across invocations. "
"Requires --stage-id to be set when not equal to 1."
),
)
omni_config_group.add_argument(
"--omni-lb-policy",
type=str,
default="random",
choices=["random", "round-robin", "least-queue-length"],
help=(
"Per-stage load-balancing policy used by the head's StagePool to "
"route requests across UP replicas. Only consulted on the head runtime."
),
)
omni_config_group.add_argument(
"--omni-heartbeat-timeout",
type=float,
default=30.0,
help=(
"Seconds before an unreporting replica is marked ERROR in the "
"OmniCoordinator. Only consulted on the head runtime."
),
)
# Diffusion model specific arguments
omni_config_group.add_argument(
@@ -564,9 +670,15 @@ def _create_default_diffusion_stage_cfg(args: argparse.Namespace) -> list[dict[s
def run_headless(args: argparse.Namespace) -> None:
"""Run a single stage in headless mode."""
"""Run a single stage in headless mode.
Honors ``--omni-dp-size-local``: launches that many replicas locally for
``--stage-id``. Each replica registers with the head's OmniMasterServer
(auto-assigned replica id when ``--omni-dp-size-local > 1`` so multiple
headless invocations can coexist) and reports heartbeats to the head's
OmniCoordinator.
"""
from vllm.v1.engine.coordinator import DPCoordinator
from vllm.v1.engine.utils import CoreEngineProcManager
from vllm.v1.executor.multiproc_executor import MultiprocExecutor
from vllm.version import __version__ as VLLM_VERSION
@@ -575,6 +687,7 @@ def run_headless(args: argparse.Namespace) -> None:
spawn_diffusion_proc,
)
from vllm_omni.distributed.omni_connectors.utils.initialization import resolve_omni_kv_config_for_stage
from vllm_omni.engine.omni_core_engine_proc_manager import OmniCoreEngineProcManager
from vllm_omni.engine.stage_engine_startup import register_stage_with_omni_master
from vllm_omni.engine.stage_init_utils import (
build_diffusion_config,
@@ -582,23 +695,40 @@ def run_headless(args: argparse.Namespace) -> None:
build_vllm_config,
extract_stage_metadata,
get_stage_connector_spec,
get_stage_devices_per_replica,
inject_kv_stage_info,
load_omni_transfer_config_for_model,
prepare_engine_environment,
setup_stage_devices,
split_devices_for_replicas,
terminate_alive_proc,
)
from vllm_omni.entrypoints.utils import inject_omni_kv_config, load_and_resolve_stage_configs
from vllm_omni.platforms import current_omni_platform
model = args.model
stage_id: int | None = args.stage_id
replica_id: int = args.replica_id
omni_master_address: str | None = args.omni_master_address
omni_master_port: int | None = args.omni_master_port
omni_replica_address: str | None = getattr(args, "omni_replica_address", None)
omni_dp_size_local: int = max(1, int(getattr(args, "omni_dp_size_local", 1) or 1))
if stage_id is None:
raise ValueError("--stage-id is required in headless mode")
if replica_id < 0:
raise ValueError("--replica-id must be >= 0 in headless mode")
# ``--replica-id`` is deprecated and ignored — replica ids are
# auto-assigned by ``OmniMasterServer`` so headless processes carry
# no knowledge of their per-replica id at launch time. Warn (don't
# error) when the operator still supplies it so existing launchers
# keep working with a single log line.
explicit_cli_keys: set[str] = getattr(args, "_cli_explicit_keys", set()) or set()
if "replica_id" in explicit_cli_keys:
logger.warning(
"[Headless] --replica-id is deprecated and ignored "
"(supplied value: %s). Replica ids are auto-assigned by the "
"master server.",
args.replica_id,
)
if omni_master_address is None or omni_master_port is None:
raise ValueError("--omni-master-address and --omni-master-port are required in headless mode")
api_server_count = args.api_server_count or 0
@@ -609,6 +739,11 @@ def run_headless(args: argparse.Namespace) -> None:
args_dict = vars(args).copy()
args_dict.pop("_cli_explicit_keys", None)
# Forward ``--deploy-config`` so the headless reads the same YAML the
# head was launched with — otherwise ``load_and_resolve_stage_configs``
# falls back to ``vllm_omni/deploy/<model>.yaml`` and the headless's
# view of ``stage.runtime.devices`` diverges from the head's, breaking
# the per-replica device split.
config_path, stage_configs = load_and_resolve_stage_configs(
model,
args_dict.get("stage_configs_path"),
@@ -631,6 +766,39 @@ def run_headless(args: argparse.Namespace) -> None:
omni_transfer_config = load_omni_transfer_config_for_model(model, config_path)
omni_conn_cfg, omni_from, omni_to = resolve_omni_kv_config_for_stage(omni_transfer_config, stage_id)
# When ``--omni-dp-size-local > 1``, slice the YAML's ``devices:`` field
# into per-replica subsets so each subprocess we spawn below sees a
# narrowed ``CUDA_VISIBLE_DEVICES`` and doesn't stack on cuda:0. Mirrors
# the head-side per-replica device application at
# ``async_omni_engine.py`` (setup_stage_devices around each launch).
runtime_cfg = getattr(stage_cfg, "runtime", None)
devices_str: str | None = None
if runtime_cfg is not None:
devices_str = (
runtime_cfg.get("devices") if hasattr(runtime_cfg, "get") else getattr(runtime_cfg, "devices", None)
)
devices_per_replica = get_stage_devices_per_replica(stage_cfg)
if devices_str:
# Always remap YAML's logical devices through setup_stage_devices,
# even for omni_dp_size_local==1. The launcher's CUDA_VISIBLE_DEVICES
# is dropped from the engine-subprocess env between vllm-serve and
# OmniCoreEngineProcManager.Process, so the worker would otherwise
# default cuda:0 to physical GPU 0 and collide with a co-located
# head on the same host (see hyi3_multi_host_1 reproducer).
per_replica_devices: list[str | None] = split_devices_for_replicas(
devices_str, omni_dp_size_local, devices_per_replica, stage_id
)
logger.info(
"[Headless] Stage %d: %d local replicas, devices_per_replica=%d, per-replica devices: %s",
stage_id,
omni_dp_size_local,
devices_per_replica,
per_replica_devices,
)
else:
per_replica_devices = [None] * omni_dp_size_local
device_control_env = current_omni_platform.device_control_env_var
if stage_cfg.stage_type == "diffusion":
metadata = extract_stage_metadata(stage_cfg)
if omni_conn_cfg:
@@ -639,39 +807,107 @@ def run_headless(args: argparse.Namespace) -> None:
od_config = build_diffusion_config(model, stage_cfg, metadata)
logger.info(
"[Headless] Launching diffusion stage %d replica %d via OmniMasterServer at %s:%d",
"[Headless] Launching %d diffusion replica(s) for stage %d via OmniMasterServer at %s:%d",
omni_dp_size_local,
stage_id,
replica_id,
omni_master_address,
omni_master_port,
)
proc = None
procs: list[Any] = []
try:
handshake_address, request_address, response_address = register_stage_with_omni_master(
omni_master_address=omni_master_address,
omni_master_port=omni_master_port,
omni_stage_id=stage_id,
omni_stage_config=stage_cfg,
return_addresses=True,
replica_id=replica_id,
for _rep_idx in range(omni_dp_size_local):
# Always auto-assign: headless processes carry no knowledge
# of their per-replica id and the master server is the sole
# authority on the per-stage id namespace.
response = register_stage_with_omni_master(
omni_master_address=omni_master_address,
omni_master_port=omni_master_port,
omni_stage_id=stage_id,
omni_stage_config=stage_cfg,
replica_id=None,
return_full_response=True,
replica_bind_address=omni_replica_address,
)
# Apply this replica's CUDA_VISIBLE_DEVICES (only when
# ``--omni-dp-size-local > 1`` and the YAML's stage devices
# field is set). The spawned subprocess inherits the env at
# spawn time; we restore the parent env afterwards so the
# next replica's setup sees the same baseline.
previous_visible_devices = os.environ.get(device_control_env)
try:
if per_replica_devices[_rep_idx] is not None:
setup_stage_devices(stage_id, {"devices": per_replica_devices[_rep_idx]})
# Each StageDiffusionProc starts its own
# torch.distributed group bound to
# ``od_config.master_port``. Without an explicit
# per-replica override all spawned subprocesses
# share the value ``OmniDiffusionConfig.__post_init__``
# picked once (and the second binder hits EADDRINUSE
# on ``init_process_group``). We can't use
# kernel-ephemeral allocation either, because the
# master server's pre-allocated ZMQ ports (returned
# by ``register_stage_with_omni_master``) also live
# in the ephemeral range and are not actually bound
# until the headless ``_perform_diffusion_handshake``
# runs — so picking an ephemeral port here can steal
# a port the master server already promised to a
# sibling headless. Use ``settle_port`` from a base
# above the Linux default ephemeral range
# (32768-60999) so torch.distributed master ports
# never overlap with ZMQ allocations.
if omni_dp_size_local > 1:
od_config.master_port = od_config.settle_port(
61000 + _rep_idx * 100,
port_inc=37,
)
proc, _, _, _ = spawn_diffusion_proc(
model,
od_config,
handshake_address=response.handshake_address,
request_address=response.input_address,
response_address=response.output_address,
omni_coordinator_address=response.coordinator_router_address,
omni_stage_id=stage_id,
omni_replica_id=response.replica_id,
)
finally:
if previous_visible_devices is None:
current_omni_platform.unset_device_control_env_var()
else:
current_omni_platform.set_device_control_env_var(previous_visible_devices)
complete_diffusion_handshake(proc, response.handshake_address, args.stage_init_timeout)
procs.append(proc)
logger.info(
"[Headless] Diffusion replica id=%d for stage %d is up (coord=%s)",
response.replica_id,
stage_id,
response.coordinator_router_address,
)
# Block on the sentinel set so any replica crash is detected
# immediately (the previous per-proc join loop only noticed
# crashes in registration order). Any exit triggers fleet
# shutdown via the finally block; non-zero exits propagate.
sentinel_to_proc = {p.sentinel: p for p in procs}
died = connection.wait(list(sentinel_to_proc.keys()))
first = sentinel_to_proc[died[0]]
logger.info(
"[Headless] Diffusion replica %s exited (code=%s); shutting down stage %d.",
first.name,
first.exitcode,
stage_id,
)
proc, _, _, _ = spawn_diffusion_proc(
model,
od_config,
handshake_address=handshake_address,
request_address=request_address,
response_address=response_address,
)
complete_diffusion_handshake(proc, handshake_address, args.stage_init_timeout)
proc.join()
if proc.exitcode not in (None, 0):
raise RuntimeError(f"Diffusion stage {stage_id} replica {replica_id} exited with code {proc.exitcode}")
if first.exitcode not in (None, 0):
raise RuntimeError(
f"Diffusion stage {stage_id} replica {first.name!r} exited with code {first.exitcode}"
)
return
finally:
logger.info("[Headless] Shutting down stage %d replica %d.", stage_id, replica_id)
if proc is not None and proc.is_alive():
terminate_alive_proc(proc)
logger.info("[Headless] Shutting down %d diffusion replica(s) for stage %d.", len(procs), stage_id)
for p in procs:
if p.is_alive():
terminate_alive_proc(p)
stage_connector_spec = get_stage_connector_spec(
omni_transfer_config=omni_transfer_config,
@@ -679,8 +915,10 @@ def run_headless(args: argparse.Namespace) -> None:
async_chunk=False,
)
# Device assignment is managed externally (e.g. CUDA_VISIBLE_DEVICES);
# runtime_cfg is intentionally ignored in headless mode.
# ``runtime_cfg`` is mostly inherited from the parent's
# CUDA_VISIBLE_DEVICES; when ``--omni-dp-size-local > 1`` we additionally
# bracket each replica's spawn below with setup_stage_devices so they
# don't all stack on cuda:0 (see ``per_replica_devices`` above).
engine_args_dict = build_engine_args_dict(
stage_cfg,
model,
@@ -746,57 +984,116 @@ def run_headless(args: argparse.Namespace) -> None:
enable_wave_coordination=vllm_config.model_config.is_moe,
)
logger.info(
"[Headless] Started DP Coordinator process for stage %d replica %d (PID: %d)",
"[Headless] Started DP Coordinator process for stage %d (PID: %d)",
stage_id,
replica_id,
coordinator.proc.pid,
)
logger.info(
"[Headless] Launching %d engine core(s) for stage %d replica %d via OmniMasterServer at %s:%d",
"[Headless] Launching %d omni replica(s) (vLLM dp_size_local=%d each) for stage %d "
"via OmniMasterServer at %s:%d",
omni_dp_size_local,
local_engine_count,
stage_id,
replica_id,
omni_master_address,
omni_master_port,
)
# Headless mode launches all local engine cores for a single stage.
# The OmniMasterServer allocates one handshake endpoint per stage, so we
# register the stage once here and let every local engine core reuse the
# returned handshake address directly.
handshake_address = register_stage_with_omni_master(
omni_master_address=omni_master_address,
omni_master_port=omni_master_port,
omni_stage_id=stage_id,
omni_stage_config=stage_cfg,
coordinator=coordinator,
replica_id=replica_id,
)
engine_manager = None
# One OmniMasterServer registration per omni replica; each registration
# yields its own (handshake, input, output) allocation and the head's
# OmniCoordinator ROUTER address. We then spawn one
# OmniCoreEngineProcManager per replica so its subprocess gets the
# right replica id wired into its OmniCoordClientForStage.
log_stats = bool(args.log_stats)
if args.disable_log_stats:
log_stats = False
engine_managers: list[Any] = []
monitor_threads: list[threading.Thread] = []
def _monitor_target(mgr: Any) -> None:
try:
mgr.monitor_engine_liveness()
except Exception:
logger.exception("[Headless] monitor_engine_liveness raised")
try:
engine_manager = CoreEngineProcManager(
local_engine_count=local_engine_count,
start_index=dp_rank,
local_start_index=0,
vllm_config=vllm_config,
local_client=False,
handshake_address=handshake_address,
executor_class=executor_class,
log_stats=log_stats,
)
# vllm>=0.19 renamed CoreEngineProcManager.join_first() to
# monitor_engine_liveness() (see upstream PR #35862).
engine_manager.monitor_engine_liveness()
for _rep_idx in range(omni_dp_size_local):
# Always auto-assign: see the diffusion branch comment above
# for the rationale (headless owns no replica-id namespace).
response = register_stage_with_omni_master(
omni_master_address=omni_master_address,
omni_master_port=omni_master_port,
omni_stage_id=stage_id,
omni_stage_config=stage_cfg,
coordinator=coordinator,
replica_id=None,
return_full_response=True,
replica_bind_address=omni_replica_address,
# LLM headless: the head binds *all* three sockets —
# handshake ROUTER (``connect_remote_engine_cores``),
# input ROUTER and output PULL (``CoreClient`` —
# ``make_zmq_socket`` defaults bind=True for PULL).
# The remote LLM worker is purely a connector: it
# opens 3 outbound TCP connections to the master's
# host. So the master must keep every address on
# its own host; rewriting any of them to this
# replica's NIC makes the head's ``bind`` go
# EADDRNOTAVAIL on a cross-host launch.
replica_binds_sockets=False,
)
# Per-replica CUDA_VISIBLE_DEVICES, same pattern as the diffusion
# branch above. OmniCoreEngineProcManager.__init__ spawns its
# subprocesses via context.Process inside the constructor, so we
# must set the env *before* instantiation and restore after.
previous_visible_devices = os.environ.get(device_control_env)
try:
if per_replica_devices[_rep_idx] is not None:
setup_stage_devices(stage_id, {"devices": per_replica_devices[_rep_idx]})
mgr = OmniCoreEngineProcManager(
local_engine_count=local_engine_count,
start_index=dp_rank,
local_start_index=0,
vllm_config=vllm_config,
local_client=False,
handshake_address=response.handshake_address,
executor_class=executor_class,
log_stats=log_stats,
omni_stage_id=stage_id,
omni_coordinator_address=response.coordinator_router_address,
omni_replica_base_id=response.replica_id,
)
finally:
if previous_visible_devices is None:
current_omni_platform.unset_device_control_env_var()
else:
current_omni_platform.set_device_control_env_var(previous_visible_devices)
engine_managers.append(mgr)
logger.info(
"[Headless] Stage %d replica id=%d up (coord=%s)",
stage_id,
response.replica_id,
response.coordinator_router_address,
)
# Run all managers' liveness monitors in parallel. Each blocks
# until its own subprocesses exit (or fail).
if len(engine_managers) == 1:
engine_managers[0].monitor_engine_liveness()
else:
for mgr in engine_managers:
t = threading.Thread(target=_monitor_target, args=(mgr,), name=f"omni-replica-monitor-{id(mgr):x}")
t.start()
monitor_threads.append(t)
for t in monitor_threads:
t.join()
finally:
logger.info("[Headless] Shutting down stage %d.", stage_id)
if engine_manager is not None:
engine_manager.shutdown()
logger.info("[Headless] Shutting down stage %d (%d managers).", stage_id, len(engine_managers))
for mgr in engine_managers:
try:
mgr.shutdown()
except Exception:
logger.exception("[Headless] engine manager shutdown failed")
if coordinator is not None:
coordinator.shutdown()
+18
View File
@@ -95,6 +95,24 @@ def _map_device_list(stage_id: int, device_list: list[str], visible_device_list:
if not all(device.isdigit() for device in device_list):
raise ValueError("Logical devices must be non-negative integers")
# Idempotency: if every requested id already names an entry in the visible
# pool (by value, not by index), treat the YAML field as physical and skip
# the remap. This avoids a double-mapping crash when a parent harness has
# already narrowed CUDA_VISIBLE_DEVICES to specific physical ids before
# spawning the subprocess (the test runtime's OmniServerStageCli does this
# — it sets each stage subprocess's env via ``_set_stage_device_env``).
# Without this guard, a YAML ``devices: "1"`` against a narrowed visible
# list ``["1"]`` would try ``visible[1]`` (out of range) and raise.
visible_set = set(visible_device_list)
if all(device in visible_set for device in device_list):
logger.info(
"Stage %s requested devices %s are already in the visible set %s; using as-is (no remap).",
stage_id,
device_list,
visible_device_list,
)
return list(device_list)
logical_ids = [int(device) for device in device_list]
mapped_devices = [visible_device_list[idx] for idx in logical_ids if idx < num_visible]
mapping_pairs = [