[feature] Enable multi-node inference through Ray backend (#7938)
Signed-off-by: Maciej Bala <mbala@nvidia.com> Co-authored-by: Bartosz Stefaniak <bstefaniak@nvidia.com> Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
This commit is contained in:
20 files changed
+1721
-37
No files matched your search
@@ -78,7 +78,44 @@ explicit CLI or stage override, or the downstream vLLM engine default.
|
||||
Note: for the diffusion path, an omitted `distributed_executor_backend` selects
|
||||
`uni` on a single GPU (in-process worker, no MessageQueue / `/dev/shm` output
|
||||
segments) and `mp` when `num_gpus > 1`. Set `mp` explicitly to keep a worker
|
||||
subprocess on one GPU. `ray` / `external_launcher` are not fully supported yet.
|
||||
subprocess on one GPU. Set `ray` for Ray-managed single- or multi-node
|
||||
diffusion workers. `external_launcher` is not supported yet.
|
||||
|
||||
The diffusion `ray` backend runs one worker actor per GPU and supports workers
|
||||
spanning Ray nodes. Enable it in the deploy YAML:
|
||||
|
||||
```yaml
|
||||
distributed_executor_backend: ray
|
||||
```
|
||||
|
||||
The executor reuses Ray when already initialized; otherwise it calls
|
||||
`ray.init()`, which handles cluster discovery or local startup and respects
|
||||
`RAY_ADDRESS`. There is no diffusion-specific address setting. Ray must be
|
||||
installed on every node.
|
||||
|
||||
Ray diffusion workers receive the driver's `VLLM_*`, `OMNI_*`, `DIFFUSION_*`,
|
||||
`NCCL_*`, `TORCH_NCCL_*`, `UCX_*`, and Hugging Face environment settings,
|
||||
plus `PYTHONPATH`, `CUDA_LAUNCH_BLOCKING`, and `OMP_NUM_THREADS`.
|
||||
Explicit stage `runtime.env` entries are also forwarded and take precedence.
|
||||
Device visibility, rank/rendezvous settings, worker host addresses, and `RAY_*`
|
||||
variables are excluded so each worker retains its own assignment and identity.
|
||||
|
||||
For multiple nodes, start the cluster before launching Omni. For example,
|
||||
with head IP `10.0.0.1`:
|
||||
|
||||
```bash
|
||||
# On the head node:
|
||||
ray start --head --node-ip-address=10.0.0.1 --port=6379
|
||||
|
||||
# On each additional worker node:
|
||||
ray start --address=10.0.0.1:6379
|
||||
|
||||
# On the host launching Omni (MODEL and my_deploy.yaml are placeholders):
|
||||
RAY_ADDRESS=10.0.0.1:6379 vllm serve MODEL --omni --deploy-config my_deploy.yaml
|
||||
```
|
||||
|
||||
Omni creates actors on the resources of that cluster; it does not start Ray
|
||||
on additional machines.
|
||||
|
||||
### Stage-level runner selection
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ validation_paths:
|
||||
- tests/diffusion/test_diffusion_worker.py
|
||||
- tests/diffusion/test_multiproc_engine_concurrency.py
|
||||
- tests/diffusion/test_uniproc_executor.py
|
||||
- tests/diffusion/test_ray_executor.py
|
||||
- tests/diffusion/test_result_pump.py
|
||||
- tests/diffusion/test_async_output_worker.py
|
||||
- tests/diffusion/test_diffusion_ipc.py
|
||||
@@ -96,8 +97,10 @@ The executor then chooses how the worker runs:
|
||||
RPC.
|
||||
- **`mp`** (default when `num_gpus > 1`, or when set explicitly):
|
||||
`MultiprocDiffusionExecutor` starts one `WorkerProc` per device.
|
||||
- **`ray`** (explicit): `RayDiffusionExecutor` starts one GPU actor per worker
|
||||
across one or more Ray nodes.
|
||||
|
||||
Either way, each worker owns its device, distributed state,
|
||||
In all backends, each worker owns its device, distributed state,
|
||||
`DiffusionWorker`, `DiffusionModelRunner`, and pipeline instance.
|
||||
|
||||
```mermaid
|
||||
@@ -108,7 +111,7 @@ flowchart TB
|
||||
stageProc["StageDiffusionProc<br/>stage transport and lifecycle"]
|
||||
engine["DiffusionEngine<br/>admission and control loop"]
|
||||
scheduler["RequestScheduler or StepScheduler<br/>request state and policy"]
|
||||
executor["DiffusionExecutor<br/>uni or mp backend"]
|
||||
executor["DiffusionExecutor<br/>uni, mp, or ray backend"]
|
||||
streams["Per-request output streams"]
|
||||
|
||||
stageProc --> engine
|
||||
@@ -132,6 +135,8 @@ flowchart TB
|
||||
client -.->|"inline: direct call"| engine
|
||||
executor -->|"mp: broadcast / result queues"| workerProc
|
||||
executor -.->|"uni: in-process call"| worker
|
||||
executor -->|"ray: actor RPC"| rayActor["RayDiffusionWorkerWrapper"]
|
||||
rayActor --> worker
|
||||
streams -->|"process-backed"| stageProc
|
||||
stageProc -->|"ZMQ results"| client
|
||||
streams -.->|"inline"| client
|
||||
@@ -154,6 +159,7 @@ flowchart TB
|
||||
| `DiffusionExecutor` | Execution backend contract, worker RPC, health, shutdown | Admission and request-state transitions |
|
||||
| `UniProcDiffusionExecutor` | Single in-process worker for `num_gpus == 1`; no IPC or async output pump | Multi-GPU execution |
|
||||
| `MultiprocDiffusionExecutor` | Worker processes, shared-memory message queues, result dispatch, worker monitoring | In-process single-GPU path |
|
||||
| `RayDiffusionExecutor` | GPU actors, placement groups, remote RPC, actor monitoring | Local worker subprocesses and shared-memory IPC |
|
||||
| `WorkerProc` | One `mp` worker process's IPC loop and reply rules | Scheduling policy; unused by `uni` |
|
||||
| `DiffusionWorker` | Device/distributed setup, LoRA activation, profiling, sleep/wake, runner delegation | Request admission |
|
||||
| `DiffusionModelRunner` | Pipeline loading, request-local model state, cache/compile setup, request or step execution | Queueing and cross-stage routing |
|
||||
@@ -219,12 +225,12 @@ uses fused `execute_model_batch()` and requires the pipeline to declare
|
||||
request-batch support.
|
||||
|
||||
**Distributed layerwise offload with AllGather (DLO DP concurrency)** is a
|
||||
separate multiproc dispatch path. It activates when
|
||||
separate dispatch path supported by `mp` and `ray`. It activates when
|
||||
`data_parallel_size > 1`, `enable_distributed_layerwise_offload` is set, and
|
||||
`dlo_use_allgather` is true. The engine then sets `dp_concurrent = True` and
|
||||
raises `scheduler.max_num_running_reqs` to `dp_size`, overriding the
|
||||
`max_num_seqs` value from `initialize()`. The scheduler still emits a
|
||||
multi-request `DiffusionSchedulerOutput`; the multiproc executor routes that
|
||||
multi-request `DiffusionSchedulerOutput`; these executors route that
|
||||
wave through `execute_request()` instead of fused `execute_model_batch()`.
|
||||
|
||||
Optional admission coalescing can fill a `dp_size` wave: when
|
||||
@@ -233,7 +239,7 @@ the first schedule of a wave (under `dp_concurrent`, the stable window is
|
||||
`min(0.3s, wait/2)`). With the default `request_batch_max_wait_ms == 0`,
|
||||
there is no wait.
|
||||
|
||||
Before dispatch, `MultiprocDiffusionExecutor.execute_request()` rejects waves
|
||||
Before dispatch, both backends reject waves
|
||||
whose requests differ in sampling-parameter compatibility or `extra_args`
|
||||
(AllGather requires every DP rank to follow the same forward schedule). Each
|
||||
worker rank then picks one envelope from the wave:
|
||||
@@ -297,8 +303,9 @@ head-of-line blocking.
|
||||
| --- | --- | --- |
|
||||
| `uni` | Default for `num_gpus == 1`, or set explicitly | One in-process worker |
|
||||
| `mp` | Default for `num_gpus > 1`, or set explicitly | One worker process per device |
|
||||
| `ray` | Set explicitly | One GPU actor per worker across Ray nodes |
|
||||
|
||||
Ray and external-launcher diffusion backends are not implemented. A custom
|
||||
The external-launcher diffusion backend is not implemented. A custom
|
||||
`DiffusionExecutor` subclass or import path is also accepted.
|
||||
|
||||
### Uniproc backend
|
||||
@@ -337,11 +344,27 @@ result path and does not start those pumps.
|
||||
See [Async diffusion output](../../feature/async_diffusion_output.md) for the
|
||||
multiproc request-mode timeline.
|
||||
|
||||
### Ray backend
|
||||
|
||||
`RayDiffusionExecutor` reuses initialized Ray or calls `ray.init()` (respecting
|
||||
`RAY_ADDRESS`). It reuses the current placement group or reserves one GPU
|
||||
bundle per worker with `PACK` placement. Ray owns GPU placement; PyTorch
|
||||
owns distributed collectives through a shared TCP rendezvous. Global ranks
|
||||
are grouped by host, while each actor uses local device 0.
|
||||
|
||||
Request batches, step execution, and DLO DP waves use actor RPCs with
|
||||
rank-aware replies. Returned tensors move to CPU before Ray serialization;
|
||||
this backend does not use the multiproc async output pumps. Driver inference
|
||||
settings and explicit stage `runtime.env` are forwarded, excluding worker
|
||||
identity and device assignment. See [Stage configuration](../../../configuration/stage_configs.md)
|
||||
for cluster setup and environment details.
|
||||
|
||||
## Worker and runner boundary
|
||||
|
||||
On the `mp` path, `WorkerProc` receives messages and calls methods through
|
||||
`WorkerWrapperBase`. On the `uni` path, the executor owns that wrapper
|
||||
directly. In both cases, `DiffusionWorker` handles the parts tied to a device:
|
||||
directly. Ray actors also construct `WorkerWrapperBase`. In all cases,
|
||||
`DiffusionWorker` handles the parts tied to a device:
|
||||
distributed initialization, model-runner construction, LoRA activation,
|
||||
profiling, and memory sleep/wake. It delegates actual model work to
|
||||
`DiffusionModelRunner`.
|
||||
@@ -399,6 +422,10 @@ streams with an error, closes scheduler state, and shuts down the executor.
|
||||
async-output futures as failed, then clears them. Completed async outputs
|
||||
cached for later `wait_output_ready()` calls are dropped with the executor
|
||||
object.
|
||||
- **`ray`**: the executor gives actors up to five seconds to shut down, then
|
||||
kills them and removes placement groups it owns. Actor death is monitored
|
||||
even while idle; actor errors and RPC timeouts fail the group and notify the
|
||||
stage. The stage client allows subprocess cleanup before signal fallback.
|
||||
- **`uni`**: the executor shuts down the in-process worker, drops the final
|
||||
model reference, and empties the accelerator cache so a later engine can reuse
|
||||
the device.
|
||||
@@ -407,7 +434,7 @@ streams with an error, closes scheduler state, and shuts down the executor.
|
||||
|
||||
Do not continue using a worker group after a fatal collective timeout or
|
||||
unexpected worker exit. Distributed state may be incomplete. The
|
||||
multiprocess executor deliberately fails closed so the stage can restart.
|
||||
`mp` and `ray` executors deliberately fail closed so the stage can restart.
|
||||
The uniproc executor fails closed when the accelerator context itself is
|
||||
poisoned.
|
||||
|
||||
@@ -417,10 +444,11 @@ streams with an error, closes scheduler state, and shuts down the executor.
|
||||
`diffusion_model_runner_cls` configuration still wins.
|
||||
- Tests and custom engine integrations may inject a `BaseScheduler` subclass.
|
||||
`SchedulerInterface` remains only as a deprecated compatibility name.
|
||||
- `distributed_executor_backend` may be `"uni"`, `"mp"`, a custom `DiffusionExecutor`
|
||||
- `distributed_executor_backend` may be `"uni"`, `"mp"`, `"ray"`, a custom `DiffusionExecutor`
|
||||
subclass, or an import path. A backend must preserve scheduler output,
|
||||
request identity, health, and cleanup contracts. Use `"mp"` when you need
|
||||
process isolation or multi-GPU; `"uni"` is the single-GPU default.
|
||||
local process isolation or multi-GPU, or `"ray"` for workers across nodes;
|
||||
`"uni"` is the single-GPU default.
|
||||
- Worker extensions go through `WorkerWrapperBase`. They add worker methods;
|
||||
they do not become a second scheduler or request lifecycle.
|
||||
|
||||
@@ -466,7 +494,7 @@ Test the smallest affected slice, then cover its neighboring boundary:
|
||||
| --- | --- |
|
||||
| Admission or state transitions | `test_diffusion_scheduler.py`, including duplicate IDs, compatibility, abort, and missing output |
|
||||
| Engine loop or output delivery | `test_diffusion_engine.py`, `test_diffusion_engine_cleanup.py`, `test_diffusion_engine_rpc_routing.py` |
|
||||
| Executor or IPC | `test_multiproc_engine_concurrency.py`, `test_uniproc_executor.py`, `test_result_pump.py`, `test_diffusion_ipc.py`, `test_async_output_worker.py` |
|
||||
| Executor or IPC | `test_multiproc_engine_concurrency.py`, `test_uniproc_executor.py`, `test_ray_executor.py`, `test_result_pump.py`, `test_diffusion_ipc.py`, `test_async_output_worker.py` |
|
||||
| Worker or runner | `test_diffusion_worker.py`, `test_diffusion_model_runner.py` |
|
||||
| Stage boundary | `test_stage_diffusion_proc.py`, `test_inline_stage_diffusion_client.py` |
|
||||
| Warmup or streaming | `test_diffusion_engine_dummy_run.py`, `test_diffusion_streaming_output.py` |
|
||||
|
||||
@@ -1948,7 +1948,9 @@ def test_diffusion_config_field_classification_covers_current_fields():
|
||||
|
||||
assert classified_fields == {f.name for f in fields(omni_config_module._DiffusionConfigProjection)}
|
||||
assert {f.name for f in fields(OmniDiffusionConfig)} <= (
|
||||
classified_fields | omni_config_module._DIFFUSION_MOVED_SHARED_FIELDS
|
||||
classified_fields
|
||||
| omni_config_module._DIFFUSION_MOVED_SHARED_FIELDS
|
||||
| omni_config_module._DIFFUSION_INTERNAL_FIELDS
|
||||
)
|
||||
assert {
|
||||
"enable_prompt_embed_cache",
|
||||
|
||||
@@ -31,6 +31,7 @@ def _make_request(request_id: str) -> OmniDiffusionRequest:
|
||||
|
||||
def _make_engine() -> DiffusionEngine:
|
||||
engine = DiffusionEngine.__new__(DiffusionEngine)
|
||||
engine.od_config = SimpleNamespace(distributed_executor_backend=None)
|
||||
engine.scheduler = RequestScheduler()
|
||||
engine.scheduler.initialize(SimpleNamespace())
|
||||
engine.executor = SimpleNamespace(shutdown=Mock())
|
||||
|
||||
@@ -1290,6 +1290,44 @@ class TestStageDiffusionClientErrorPropagation:
|
||||
client = self._make_client()
|
||||
client.check_health()
|
||||
|
||||
def test_local_shutdown_uses_existing_signal_path(self):
|
||||
client = self._make_client()
|
||||
client._zmq_ctx = MagicMock()
|
||||
client._proc_manager.distributed_executor_backend = "mp"
|
||||
client._proc_manager.wait_for_shutdown = MagicMock()
|
||||
client._proc_manager.shutdown = MagicMock()
|
||||
client.shutdown()
|
||||
client._proc_manager.wait_for_shutdown.assert_not_called()
|
||||
client._proc_manager.shutdown.assert_called_once_with(timeout=10)
|
||||
|
||||
def test_shutdown_waits_for_clean_subprocess_exit(self):
|
||||
client = self._make_client()
|
||||
client._zmq_ctx = MagicMock()
|
||||
client._proc_manager.distributed_executor_backend = "ray"
|
||||
client._proc_manager.wait_for_shutdown = MagicMock(return_value=True)
|
||||
client._proc_manager.shutdown = MagicMock()
|
||||
|
||||
client.shutdown()
|
||||
|
||||
client._request_socket.send.assert_called_once()
|
||||
client._proc_manager.wait_for_shutdown.assert_called_once_with(timeout=10)
|
||||
client._proc_manager.shutdown.assert_not_called()
|
||||
client._request_socket.close.assert_called_once_with(linger=0)
|
||||
client._response_socket.close.assert_called_once_with(linger=0)
|
||||
client._zmq_ctx.term.assert_called_once_with()
|
||||
|
||||
def test_shutdown_uses_signals_only_after_clean_exit_timeout(self):
|
||||
client = self._make_client()
|
||||
client._zmq_ctx = MagicMock()
|
||||
client._proc_manager.distributed_executor_backend = "ray"
|
||||
client._proc_manager.wait_for_shutdown = MagicMock(return_value=False)
|
||||
client._proc_manager.shutdown = MagicMock()
|
||||
|
||||
client.shutdown()
|
||||
|
||||
client._proc_manager.wait_for_shutdown.assert_called_once_with(timeout=10)
|
||||
client._proc_manager.shutdown.assert_called_once_with(timeout=10)
|
||||
|
||||
def test_get_output_raises_engine_dead_when_dead(self):
|
||||
"""When ``_engine_dead`` is True and the output queue is empty,
|
||||
``get_diffusion_output_nowait`` must raise ``EngineDeadError``."""
|
||||
@@ -1783,3 +1821,29 @@ class TestDrainResponsesDeathSentinel:
|
||||
assert output.request_id == "req-fail"
|
||||
assert output.error == "gpu fault"
|
||||
assert output.finished is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["ray", "mp"])
|
||||
def test_engine_close_interrupts_only_ray_before_join(backend):
|
||||
engine = DiffusionEngine.__new__(DiffusionEngine)
|
||||
engine.od_config = SimpleNamespace(distributed_executor_backend=backend)
|
||||
engine._cv = threading.Condition()
|
||||
engine._closed = False
|
||||
engine._shutdown_complete = False
|
||||
engine.stop_event = threading.Event()
|
||||
engine._out_streams = {}
|
||||
interrupted = threading.Event()
|
||||
engine.executor = MagicMock()
|
||||
engine.executor.shutdown.side_effect = interrupted.set
|
||||
engine.scheduler = MagicMock(_native_prefetch_enabled=False)
|
||||
engine.worker_thread = MagicMock()
|
||||
engine.worker_thread.is_alive.side_effect = [True, False]
|
||||
|
||||
def join(timeout):
|
||||
assert engine._closed
|
||||
assert interrupted.is_set() == (backend == "ray")
|
||||
|
||||
engine.worker_thread.join.side_effect = join
|
||||
engine.close()
|
||||
assert engine._shutdown_complete
|
||||
engine.scheduler.close.assert_called_once()
|
||||
@@ -0,0 +1,586 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""CPU tests of Ray placement, rank assignment, RPC output, and cleanup."""
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.executor import ray_executor as ray_module
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_cross_node_workers_get_global_rank_and_explicit_rendezvous(monkeypatch):
|
||||
monkeypatch.setenv("NCCL_SOCKET_IFNAME", "eth-test")
|
||||
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "3,4,5")
|
||||
actors = []
|
||||
for ip in ["10.0.0.2", "10.0.0.1", "10.0.0.2"]:
|
||||
actor = Mock()
|
||||
actor.get_node_ip.remote.return_value = ip
|
||||
actor.get_open_port.remote.return_value = 23456
|
||||
actors.append(actor)
|
||||
factory = Mock()
|
||||
factory.remote.side_effect = actors
|
||||
fake_ray = Mock()
|
||||
fake_ray.remote.return_value = lambda cls: factory
|
||||
fake_ray.get.side_effect = lambda values, **kwargs: values
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
monkeypatch.setattr(ray_module, "get_ip", lambda: "10.0.0.1")
|
||||
monkeypatch.setattr(ray_module, "PlacementGroupSchedulingStrategy", Mock())
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor.od_config = SimpleNamespace(num_gpus=3, ray_worker_env={"CUSTOM_PLUGIN_SETTING": "enabled"})
|
||||
executor.workers = []
|
||||
executor._init_workers(object())
|
||||
assert fake_ray.remote.call_args.kwargs["max_concurrency"] == 1
|
||||
assert fake_ray.remote.call_args.kwargs["concurrency_groups"] == {"health": 1, "monitor": 1}
|
||||
worker_env = fake_ray.remote.call_args.kwargs["runtime_env"]["env_vars"]
|
||||
assert worker_env["NCCL_SOCKET_IFNAME"] == "eth-test"
|
||||
assert worker_env["CUSTOM_PLUGIN_SETTING"] == "enabled"
|
||||
assert "CUDA_VISIBLE_DEVICES" not in worker_env
|
||||
assert len(fake_ray.get.call_args_list) == 3
|
||||
assert all(call.kwargs["timeout"] == ray_module._WORKER_INIT_TIMEOUT_S for call in fake_ray.get.call_args_list)
|
||||
assert [worker.ip for worker in executor.workers] == ["10.0.0.1", "10.0.0.2", "10.0.0.2"]
|
||||
for rank, metadata in enumerate(executor.workers):
|
||||
metadata.worker.init_worker.remote.assert_called_once_with(executor.od_config, rank, "tcp://10.0.0.1:23456")
|
||||
|
||||
|
||||
def test_worker_env_forwards_stage_overrides_without_driver_identity(monkeypatch):
|
||||
from unittest.mock import patch
|
||||
|
||||
driver_env = {
|
||||
"HF_TOKEN": "test-token",
|
||||
"VLLM_PLUGINS": "test-plugin",
|
||||
"NCCL_DEBUG": "WARN",
|
||||
"DIFFUSION_ATTENTION_BACKEND": "FLASH_ATTN",
|
||||
"PYTHONPATH": "/test/modules",
|
||||
"UNRELATED_SECRET": "do-not-copy",
|
||||
"HOME": "/driver/home",
|
||||
}
|
||||
protected = {
|
||||
"CUDA_VISIBLE_DEVICES": "7",
|
||||
"VLLM_HOST_IP": "10.0.0.1",
|
||||
"VLLM_HOST_PORT": "1234",
|
||||
"VLLM_NIXL_SIDE_CHANNEL_HOST": "10.0.0.1",
|
||||
"LOCAL_RANK": "7",
|
||||
"RANK": "7",
|
||||
"WORLD_SIZE": "8",
|
||||
"MASTER_ADDR": "driver-host",
|
||||
"MASTER_PORT": "1234",
|
||||
"RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1",
|
||||
}
|
||||
config = SimpleNamespace(ray_worker_env={**protected, "NCCL_DEBUG": "INFO", "CUSTOM_PLUGIN_SETTING": "stage"})
|
||||
with patch.dict(ray_module.os.environ, {**driver_env, **protected}, clear=True):
|
||||
env = ray_module._worker_env(config)
|
||||
assert env == {key: value for key, value in driver_env.items() if key not in {"UNRELATED_SECRET", "HOME"}} | {
|
||||
"NCCL_DEBUG": "INFO",
|
||||
"CUSTOM_PLUGIN_SETTING": "stage",
|
||||
}
|
||||
assert ray_module.os.environ["NCCL_DEBUG"] == "WARN"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1])
|
||||
def test_collective_executes_all_ranks_but_only_output_rank_returns_tensors(rank):
|
||||
actor = ray_module.RayDiffusionWorkerWrapper(rank)
|
||||
value = torch.ones(2, requires_grad=True)
|
||||
actor.worker = Mock()
|
||||
actor.worker.execute_method.return_value = DiffusionOutput(output=value)
|
||||
result = actor.execute_rpc("execute_model", output_rank=0, exec_all_ranks=True)
|
||||
actor.worker.execute_method.assert_called_once()
|
||||
assert result["replied"] == (rank == 0)
|
||||
if rank == 0:
|
||||
assert not result["result"].output.requires_grad
|
||||
torch.testing.assert_close(result["result"].output, value)
|
||||
else:
|
||||
assert result["result"] is None
|
||||
|
||||
|
||||
def test_finalizer_kills_workers_without_waiting_and_releases_owned_group(monkeypatch):
|
||||
events = []
|
||||
fake_ray = Mock()
|
||||
fake_ray.get.side_effect = lambda futures, **kw: events.append("wait")
|
||||
fake_ray.kill.side_effect = lambda worker, **kw: events.append("kill")
|
||||
fake_ray.util.remove_placement_group.side_effect = lambda group: events.append("remove")
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
actor = Mock()
|
||||
actor.shutdown.remote.side_effect = lambda: events.append("shutdown")
|
||||
resources = ray_module._RayExecutorResources([ray_module._RayWorkerMetadata(actor)], object(), True)
|
||||
resources()
|
||||
assert events == ["kill", "remove"]
|
||||
fake_ray.kill.assert_called_once_with(actor, no_restart=True)
|
||||
actor.shutdown.remote.assert_not_called()
|
||||
fake_ray.get.assert_not_called()
|
||||
resources()
|
||||
assert events == ["kill", "remove"]
|
||||
assert resources.workers == []
|
||||
|
||||
|
||||
def test_wrapper_shutdown_releases_worker_once():
|
||||
actor = ray_module.RayDiffusionWorkerWrapper(0)
|
||||
worker = Mock()
|
||||
actor.worker = worker
|
||||
actor.shutdown()
|
||||
actor.shutdown()
|
||||
worker.shutdown.assert_called_once_with()
|
||||
assert not actor.check_alive()
|
||||
|
||||
|
||||
def _make_executor(fake_ray, actors, group=None, owns_group=True):
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor._closed = False
|
||||
executor._is_failed = False
|
||||
executor._state_lock = threading.Lock()
|
||||
executor._failure_callbacks = []
|
||||
executor.workers = [ray_module._RayWorkerMetadata(actor, rank=rank) for rank, actor in enumerate(actors)]
|
||||
executor._resources = ray_module._RayExecutorResources(executor.workers, group, owns_group)
|
||||
executor._finalizer = executor._resources
|
||||
return executor
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stuck_rank", [None, 1])
|
||||
def test_shutdown_drains_workers_before_killing_them(monkeypatch, stuck_rank):
|
||||
events = []
|
||||
actors = [Mock(), Mock()]
|
||||
refs = []
|
||||
for rank, actor in enumerate(actors):
|
||||
ref = f"shutdown-{rank}"
|
||||
refs.append(ref)
|
||||
actor.shutdown.remote.side_effect = lambda ref=ref: events.append(ref) or ref
|
||||
fake_ray = Mock()
|
||||
|
||||
def wait(pending, num_returns, timeout):
|
||||
events.append("wait")
|
||||
assert num_returns == len(pending)
|
||||
assert timeout == ray_module._WORKER_SHUTDOWN_TIMEOUT_S
|
||||
return [ref for ref in pending if ref != f"shutdown-{stuck_rank}"], [
|
||||
ref for ref in pending if ref == f"shutdown-{stuck_rank}"
|
||||
]
|
||||
|
||||
fake_ray.wait.side_effect = wait
|
||||
fake_ray.kill.side_effect = lambda worker, **kw: events.append("kill")
|
||||
fake_ray.util.remove_placement_group.side_effect = lambda group: events.append("remove")
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
executor = _make_executor(fake_ray, actors, group=object())
|
||||
callback = Mock()
|
||||
executor._failure_callbacks = [callback]
|
||||
|
||||
executor.shutdown()
|
||||
|
||||
assert events == [*refs, "wait", "kill", "kill", "remove"]
|
||||
assert [call.args[0] for call in fake_ray.get.call_args_list] == [
|
||||
ref for ref in refs if ref != f"shutdown-{stuck_rank}"
|
||||
]
|
||||
assert executor._closed
|
||||
assert not executor._is_failed
|
||||
callback.assert_not_called()
|
||||
executor.shutdown()
|
||||
assert fake_ray.kill.call_count == len(actors)
|
||||
|
||||
|
||||
def test_shutdown_kills_workers_when_drain_fails(monkeypatch):
|
||||
fake_ray = Mock()
|
||||
fake_ray.wait.side_effect = RuntimeError("Ray is shutting down")
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
actors = [Mock(), Mock()]
|
||||
executor = _make_executor(fake_ray, actors)
|
||||
executor.shutdown()
|
||||
assert [call.args[0] for call in fake_ray.kill.call_args_list] == actors
|
||||
|
||||
|
||||
def test_shutdown_preserves_borrowed_placement_group(monkeypatch):
|
||||
fake_ray = Mock()
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
ray_module._RayExecutorResources([], object(), False)()
|
||||
fake_ray.util.remove_placement_group.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_kind", ["GetTimeoutError", "RayActorError", "RayTaskError"])
|
||||
@pytest.mark.parametrize("output_rank", [None, 0])
|
||||
def test_rpc_failure_kills_peers_and_rejects_subsequent_work(monkeypatch, failure_kind, output_rank):
|
||||
fake_ray = Mock()
|
||||
fake_ray.exceptions = SimpleNamespace(
|
||||
**{name: type(name, (Exception,), {}) for name in ("GetTimeoutError", "RayActorError", "RayTaskError")}
|
||||
)
|
||||
failure = getattr(fake_ray.exceptions, failure_kind)("rank failed while peers are still running")
|
||||
fake_ray.get.side_effect = failure
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
actors = [Mock(), Mock()]
|
||||
group = object()
|
||||
executor = _make_executor(fake_ray, actors, group=group)
|
||||
callback = Mock()
|
||||
executor._failure_callbacks = [callback]
|
||||
|
||||
expected_error = TimeoutError if failure_kind == "GetTimeoutError" else ray_module.EngineDeadError
|
||||
with pytest.raises(expected_error) as raised:
|
||||
executor.collective_rpc("execute_model", unique_reply_rank=output_rank, exec_all_ranks=True)
|
||||
|
||||
assert raised.value.__cause__ is failure
|
||||
assert executor.is_dead
|
||||
assert executor._is_failed
|
||||
assert executor._closed
|
||||
callback.assert_called_once_with()
|
||||
assert [call.args[0] for call in fake_ray.kill.call_args_list] == actors
|
||||
fake_ray.util.remove_placement_group.assert_called_once_with(group)
|
||||
# Peers may be stuck in a collective, so failure skips the graceful drain.
|
||||
fake_ray.wait.assert_not_called()
|
||||
for actor in actors:
|
||||
actor.shutdown.remote.assert_not_called()
|
||||
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
executor.collective_rpc("execute_model", unique_reply_rank=output_rank, exec_all_ranks=True)
|
||||
for actor in actors:
|
||||
actor.execute_rpc.remote.assert_called_once()
|
||||
fake_ray.get.assert_called_once()
|
||||
executor.shutdown()
|
||||
assert fake_ray.kill.call_count == len(actors)
|
||||
callback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_actor_death_during_shutdown_does_not_report_failure():
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor._closed = True
|
||||
executor._is_failed = False
|
||||
executor._state_lock = threading.Lock()
|
||||
executor._finalizer = Mock()
|
||||
callback = Mock()
|
||||
executor._failure_callbacks = [callback]
|
||||
executor._fail(RuntimeError("actor terminated"))
|
||||
assert not executor._is_failed
|
||||
callback.assert_not_called()
|
||||
executor._finalizer.assert_not_called()
|
||||
|
||||
|
||||
class _InlineThread:
|
||||
"""Run the monitor synchronously so tests observe its effects."""
|
||||
|
||||
def __init__(self, target, **kwargs):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("closed_first", [False, True])
|
||||
def test_worker_monitor_fails_executor_when_idle_actor_dies(monkeypatch, closed_first):
|
||||
fake_ray = Mock()
|
||||
death = RuntimeError("actor died")
|
||||
actors = [Mock(), Mock()]
|
||||
sentinels = [actor.wait_for_exit.options.return_value.remote.return_value for actor in actors]
|
||||
|
||||
def wait(refs, num_returns):
|
||||
assert refs == sentinels
|
||||
assert num_returns == 1
|
||||
if closed_first:
|
||||
executor._closed = True
|
||||
return [sentinels[1]], [sentinels[0]]
|
||||
|
||||
fake_ray.wait.side_effect = wait
|
||||
fake_ray.get.side_effect = death
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
monkeypatch.setattr(ray_module.threading, "Thread", _InlineThread)
|
||||
group = object()
|
||||
executor = _make_executor(fake_ray, actors, group=group)
|
||||
callback = Mock()
|
||||
executor.register_failure_callback(callback)
|
||||
|
||||
executor._start_worker_monitor()
|
||||
|
||||
for actor in actors:
|
||||
actor.wait_for_exit.options.assert_called_once_with(concurrency_group="monitor")
|
||||
if closed_first:
|
||||
assert not executor._is_failed
|
||||
callback.assert_not_called()
|
||||
fake_ray.kill.assert_not_called()
|
||||
return
|
||||
fake_ray.get.assert_called_once_with(sentinels[1])
|
||||
assert executor._is_failed
|
||||
assert executor._closed
|
||||
callback.assert_called_once_with()
|
||||
assert [call.args[0] for call in fake_ray.kill.call_args_list] == actors
|
||||
fake_ray.util.remove_placement_group.assert_called_once_with(group)
|
||||
fake_ray.wait.assert_called_once()
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
executor.check_health()
|
||||
|
||||
|
||||
def test_worker_monitor_does_not_keep_executor_alive(monkeypatch):
|
||||
fake_ray = Mock()
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
started = []
|
||||
monkeypatch.setattr(
|
||||
ray_module.threading,
|
||||
"Thread",
|
||||
lambda target, **kwargs: started.append(target) or SimpleNamespace(start=lambda: None),
|
||||
)
|
||||
executor = _make_executor(fake_ray, [Mock()])
|
||||
executor._start_worker_monitor()
|
||||
(monitor,) = started
|
||||
assert all(executor is not cell.cell_contents for cell in monitor.__closure__ or ())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundles, required, error",
|
||||
[
|
||||
([{"GPU": 1}] * 4, 4, None),
|
||||
([{"GPU": 1}] * 8, 4, None),
|
||||
([{"CPU": 1}, {"GPU": 0.5}, {"GPU": 1}], 1, None),
|
||||
([{"GPU": 1}] * 2, 4, "requires 4 full GPUs.*only 2"),
|
||||
([{"GPU": 0.5}] * 4, 4, "requires 4 full GPUs.*only 0"),
|
||||
([{"CPU": 4}], 1, "requires 1 full GPUs.*only 0"),
|
||||
([{"GPU": 4}], 4, None),
|
||||
([{"GPU": 2}, {"GPU": 2}], 4, None),
|
||||
([{"GPU": 1.5}, {"GPU": 1.5}], 3, "requires 3 full GPUs.*only 2"),
|
||||
],
|
||||
)
|
||||
def test_reused_placement_group_requires_full_gpu_capacity(monkeypatch, bundles, required, error):
|
||||
group = SimpleNamespace(bundle_specs=bundles)
|
||||
fake_ray = Mock()
|
||||
fake_ray.util.get_current_placement_group.return_value = group
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor.od_config = SimpleNamespace(num_gpus=required)
|
||||
|
||||
if error is not None:
|
||||
with pytest.raises(ValueError, match=error):
|
||||
executor._get_or_create_placement_group()
|
||||
else:
|
||||
actual_group, owned = executor._get_or_create_placement_group()
|
||||
assert actual_group is group
|
||||
assert not owned
|
||||
|
||||
fake_ray.util.placement_group.assert_not_called()
|
||||
fake_ray.util.remove_placement_group.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_phase", ["creation", "discovery", "port", "initialization"])
|
||||
def test_worker_startup_failure_cleans_up_registered_actors(monkeypatch, failure_phase):
|
||||
actors = [Mock(), Mock()]
|
||||
for actor in actors:
|
||||
actor.get_node_ip.remote.return_value = "10.0.0.1"
|
||||
actor.get_open_port.remote.return_value = 23456
|
||||
factory = Mock()
|
||||
error = RuntimeError("startup failed")
|
||||
factory.remote.side_effect = [actors[0], error] if failure_phase == "creation" else actors
|
||||
fake_ray = Mock()
|
||||
fake_ray.is_initialized.return_value = True
|
||||
fake_ray.remote.return_value = lambda cls: factory
|
||||
call_count = 0
|
||||
|
||||
def get(values, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
assert kwargs["timeout"] == ray_module._WORKER_INIT_TIMEOUT_S
|
||||
if call_count == {"discovery": 1, "port": 2, "initialization": 3}.get(failure_phase):
|
||||
raise error
|
||||
return values
|
||||
|
||||
fake_ray.get.side_effect = get
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
monkeypatch.setattr(ray_module, "get_ip", lambda: "10.0.0.1")
|
||||
monkeypatch.setattr(ray_module, "PlacementGroupSchedulingStrategy", Mock())
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor.od_config = SimpleNamespace(num_gpus=2)
|
||||
group = object()
|
||||
executor._get_or_create_placement_group = Mock(return_value=(group, True))
|
||||
|
||||
with pytest.raises(RuntimeError, match="startup failed"):
|
||||
executor._init_executor()
|
||||
|
||||
expected = actors[:1] if failure_phase == "creation" else actors
|
||||
assert [call.args[0] for call in fake_ray.kill.call_args_list] == expected
|
||||
for actor in expected:
|
||||
actor.shutdown.remote.assert_not_called()
|
||||
actor.wait_for_exit.options.assert_not_called()
|
||||
fake_ray.util.remove_placement_group.assert_called_once_with(group)
|
||||
assert executor.workers == []
|
||||
assert executor._closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_type", [TimeoutError, RuntimeError, KeyboardInterrupt])
|
||||
@pytest.mark.parametrize("cleanup_fails", [False, True])
|
||||
def test_placement_group_wait_failure_cleans_up_and_preserves_error(monkeypatch, failure_type, cleanup_fails):
|
||||
fake_ray = Mock()
|
||||
fake_ray.exceptions.GetTimeoutError = TimeoutError
|
||||
fake_ray.util.get_current_placement_group.return_value = None
|
||||
group = fake_ray.util.placement_group.return_value
|
||||
original_error = failure_type("readiness failed")
|
||||
fake_ray.get.side_effect = original_error
|
||||
if cleanup_fails:
|
||||
fake_ray.util.remove_placement_group.side_effect = RuntimeError("cleanup failed")
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor.od_config = SimpleNamespace(num_gpus=2)
|
||||
|
||||
expected_type = ValueError if failure_type is TimeoutError else failure_type
|
||||
with pytest.raises(expected_type) as raised:
|
||||
executor._get_or_create_placement_group()
|
||||
|
||||
if failure_type is TimeoutError:
|
||||
assert raised.value.__cause__ is original_error
|
||||
assert "Cannot reserve 2 Ray GPU bundle(s)" in str(raised.value)
|
||||
else:
|
||||
assert raised.value is original_error
|
||||
fake_ray.util.remove_placement_group.assert_called_once_with(group)
|
||||
|
||||
|
||||
def test_health_probes_use_separate_concurrency_group(monkeypatch):
|
||||
fake_ray = Mock()
|
||||
fake_ray.get.return_value = [True, True]
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
actors = [Mock(), Mock()]
|
||||
executor = _make_executor(fake_ray, actors)
|
||||
|
||||
executor.check_health()
|
||||
|
||||
for actor in actors:
|
||||
actor.check_alive.options.assert_called_once_with(concurrency_group="health")
|
||||
actor.check_alive.options.return_value.remote.assert_called_once_with()
|
||||
actor.check_alive.remote.assert_not_called()
|
||||
fake_ray.get.assert_called_once_with(
|
||||
[actor.check_alive.options.return_value.remote.return_value for actor in actors],
|
||||
timeout=ray_module._HEALTH_CHECK_TIMEOUT_S,
|
||||
)
|
||||
assert not executor._is_failed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("probe_result", ["error", "not_alive"])
|
||||
def test_health_failure_releases_actors_and_notifies(monkeypatch, probe_result):
|
||||
fake_ray = Mock()
|
||||
if probe_result == "error":
|
||||
fake_ray.get.side_effect = RuntimeError("actor died")
|
||||
else:
|
||||
fake_ray.get.return_value = [True, False]
|
||||
monkeypatch.setattr(ray_module, "ray", fake_ray)
|
||||
actors = [Mock(), Mock()]
|
||||
group = object()
|
||||
executor = _make_executor(fake_ray, actors, group=group)
|
||||
callback = Mock()
|
||||
executor.register_failure_callback(callback)
|
||||
|
||||
with pytest.raises(ray_module.EngineDeadError):
|
||||
executor.check_health()
|
||||
|
||||
assert executor._is_failed
|
||||
assert executor._closed
|
||||
callback.assert_called_once_with()
|
||||
assert [call.args[0] for call in fake_ray.kill.call_args_list] == actors
|
||||
fake_ray.util.remove_placement_group.assert_called_once_with(group)
|
||||
|
||||
|
||||
def _make_request_executor(monkeypatch, prompts, component="text_encoder", allgather=True):
|
||||
executor = object.__new__(ray_module.RayDiffusionExecutor)
|
||||
executor._closed = False
|
||||
executor._is_failed = False
|
||||
executor._state_lock = threading.Lock()
|
||||
executor._failure_callbacks = []
|
||||
executor._finalizer = Mock()
|
||||
executor.od_config = SimpleNamespace(
|
||||
parallel_config=SimpleNamespace(data_parallel_size=len(prompts)),
|
||||
diffusion_offload_config={
|
||||
"mode": "layer",
|
||||
"components": [component],
|
||||
"layer_options": {component: {"weight_transfer": "allgather" if allgather else "rank-local"}},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(ray_module, "build_request_batch_sampling_params_key", lambda req: ())
|
||||
requests = []
|
||||
for index, prompt in enumerate(prompts):
|
||||
request_id = str(index)
|
||||
req = SimpleNamespace(request_id=request_id, prompt=prompt, sampling_params=SimpleNamespace(extra_args=None))
|
||||
requests.append(SimpleNamespace(request_id=request_id, req=req, diffusion_kv_metadata=None))
|
||||
return executor, SimpleNamespace(scheduled_new_reqs=requests, kv_prefetch_job=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["prompt_embeds", "negative_prompt_embeds"])
|
||||
def test_text_encoder_allgather_rejects_mismatched_embedding_presence(monkeypatch, field):
|
||||
executor, scheduled = _make_request_executor(monkeypatch, ["first", {"prompt": "second", field: object()}])
|
||||
executor.collective_rpc = Mock()
|
||||
with pytest.raises(ValueError, match="same positive/negative prompt embedding fields"):
|
||||
executor.execute_request(scheduled)
|
||||
executor.collective_rpc.assert_not_called()
|
||||
|
||||
|
||||
def test_text_encoder_allgather_accepts_matching_precomputed_embedding_paths(monkeypatch):
|
||||
executor, scheduled = _make_request_executor(
|
||||
monkeypatch, [{"prompt_embeds": object()}, {"prompt_embeds": object()}]
|
||||
)
|
||||
results = [DiffusionOutput(output="first"), DiffusionOutput(output="second")]
|
||||
executor.collective_rpc = Mock(return_value=[{"dp_rank": rank, "output": out} for rank, out in enumerate(results)])
|
||||
output = executor.execute_request(scheduled)
|
||||
assert [item.result for item in output.runner_outputs] == results
|
||||
executor.collective_rpc.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allgather", [False, True])
|
||||
def test_single_request_allgather_timeout_option(monkeypatch, allgather):
|
||||
executor, scheduled = _make_request_executor(monkeypatch, ["prompt"], allgather=allgather)
|
||||
executor.collective_rpc = Mock(return_value=DiffusionOutput(output="done"))
|
||||
executor.execute_request(scheduled)
|
||||
kwargs = executor.collective_rpc.call_args.kwargs
|
||||
if allgather:
|
||||
assert kwargs["timeout"] == ray_module._DLO_DP_WAVE_TIMEOUT_S
|
||||
else:
|
||||
assert "timeout" not in kwargs
|
||||
|
||||
|
||||
def test_single_request_allgather_timeout_fails_executor_and_cleans_up(monkeypatch):
|
||||
executor, scheduled = _make_request_executor(monkeypatch, ["prompt"])
|
||||
callback = Mock()
|
||||
executor.register_failure_callback(callback)
|
||||
executor.collective_rpc = Mock(side_effect=TimeoutError("AllGather timed out"))
|
||||
output = executor.execute_request(scheduled)
|
||||
assert executor._is_failed
|
||||
assert executor._closed
|
||||
executor._finalizer.assert_called_once()
|
||||
callback.assert_called_once()
|
||||
assert "AllGather timed out" in output.runner_outputs[0].result.error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapper", ["media", "output", "batch"])
|
||||
def test_move_to_cpu_handles_typed_video_media(wrapper):
|
||||
from vllm_omni.diffusion.media import (
|
||||
DiffusionMediaOutput,
|
||||
VideoMediaOutput,
|
||||
VideoTensorEncoding,
|
||||
VideoTensorLayout,
|
||||
VideoTensorSpec,
|
||||
VideoValueRange,
|
||||
)
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
tensor = torch.ones((1, 3, 2, 4, 4), requires_grad=True)
|
||||
media = DiffusionMediaOutput(
|
||||
video=VideoMediaOutput(
|
||||
tensor=tensor,
|
||||
spec=VideoTensorSpec(
|
||||
layout=VideoTensorLayout.BCTHW,
|
||||
encoding=VideoTensorEncoding.NORMALIZED_FLOAT,
|
||||
value_range=VideoValueRange.ZERO_TO_ONE,
|
||||
),
|
||||
),
|
||||
prepared_for_transport=True,
|
||||
)
|
||||
value = media
|
||||
if wrapper != "media":
|
||||
value = DiffusionOutput(media=media, to_cpu=False)
|
||||
if wrapper == "batch":
|
||||
value = BatchRunnerOutput.from_list([RunnerOutput(request_id="video", finished=True, result=value)])
|
||||
|
||||
moved = ray_module._move_to_cpu(value)
|
||||
if wrapper == "batch":
|
||||
moved = moved.runner_outputs[0].result
|
||||
if wrapper != "media":
|
||||
moved = moved.media
|
||||
|
||||
moved.validate()
|
||||
assert moved.video.tensor.device.type == "cpu"
|
||||
assert not moved.video.tensor.requires_grad
|
||||
assert moved.video.spec == media.video.spec
|
||||
assert moved.prepared_for_transport
|
||||
torch.testing.assert_close(moved.video.tensor, tensor.detach())
|
||||
assert media.video.tensor is tensor
|
||||
assert tensor.requires_grad
|
||||
@@ -191,3 +191,62 @@ async def test_proc_process_request_with_batching_async_output():
|
||||
time_gap = elapsed_time - base_time
|
||||
assert time_gap > time_gap_std - eps and time_gap < time_gap_std + eps
|
||||
base_time = elapsed_time
|
||||
|
||||
|
||||
class _FailingExecutor:
|
||||
def __init__(self, is_dead: bool = False) -> None:
|
||||
self.is_dead = is_dead
|
||||
self.callbacks = []
|
||||
|
||||
def register_failure_callback(self, callback) -> None:
|
||||
self.callbacks.append(callback)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failed_before_loop", [False, True])
|
||||
async def test_run_loop_exits_when_executor_fails_between_requests(failed_before_loop):
|
||||
"""An idle executor failure must reach the client without waiting for a request."""
|
||||
import threading
|
||||
|
||||
import zmq
|
||||
import zmq.asyncio
|
||||
|
||||
ctx = zmq.asyncio.Context()
|
||||
request_socket = ctx.socket(zmq.PUSH)
|
||||
response_socket = ctx.socket(zmq.PULL)
|
||||
request_port = request_socket.bind_to_random_port("tcp://127.0.0.1")
|
||||
response_port = response_socket.bind_to_random_port("tcp://127.0.0.1")
|
||||
executor = _FailingExecutor(is_dead=failed_before_loop)
|
||||
stage_proc = StageDiffusionProc.__new__(StageDiffusionProc)
|
||||
stage_proc._engine = type("_Engine", (), {"executor": executor})()
|
||||
stage_proc._active_tasks = None
|
||||
stage_proc._fatal_event = None
|
||||
try:
|
||||
loop_task = asyncio.create_task(
|
||||
stage_proc.run_loop(f"tcp://127.0.0.1:{request_port}", f"tcp://127.0.0.1:{response_port}")
|
||||
)
|
||||
if not failed_before_loop:
|
||||
for _ in range(500):
|
||||
if executor.callbacks:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert executor.callbacks, "run_loop did not register an executor failure callback"
|
||||
# Executor monitors report failures from their own threads.
|
||||
monitor = threading.Thread(target=executor.callbacks[0])
|
||||
monitor.start()
|
||||
monitor.join()
|
||||
|
||||
with pytest.raises(RuntimeError, match="permanent failure"):
|
||||
await asyncio.wait_for(loop_task, timeout=5)
|
||||
assert await asyncio.wait_for(response_socket.recv(), timeout=5) == StageDiffusionProc.DIFFUSION_PROC_DEAD
|
||||
assert len(executor.callbacks) == 1
|
||||
|
||||
# A late failure after run_loop has exited must not raise.
|
||||
closed_loop = asyncio.new_event_loop()
|
||||
closed_loop.close()
|
||||
stage_proc._watch_executor_failure(closed_loop)
|
||||
executor.callbacks[-1]()
|
||||
finally:
|
||||
request_socket.close(linger=0)
|
||||
response_socket.close(linger=0)
|
||||
ctx.term()
|
||||
@@ -770,3 +770,60 @@ class TestCustomPipelineWorkerExtension:
|
||||
# Verify load_model was called twice with different pipelines
|
||||
assert wrapper.worker.load_model.call_count == 2
|
||||
assert wrapper.worker.init_lora_manager.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("init_method", "rank", "local_rank", "expected_addr", "expected_port"),
|
||||
[
|
||||
(None, 1, 1, "localhost", "12345"),
|
||||
("tcp://10.0.0.2:23456", 7, 0, "unrelated-host", "9999"),
|
||||
],
|
||||
)
|
||||
def test_worker_rendezvous_preserves_local_device_rank(
|
||||
monkeypatch, init_method, rank, local_rank, expected_addr, expected_port
|
||||
):
|
||||
"""TCP rendezvous must not confuse a global rank with the actor's GPU index."""
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import Mock
|
||||
|
||||
from vllm_omni.diffusion.worker import diffusion_worker as worker_module
|
||||
|
||||
class ReachedRendezvousError(Exception):
|
||||
pass
|
||||
|
||||
worker = object.__new__(DiffusionWorker)
|
||||
worker.rank = rank
|
||||
worker.local_rank = local_rank
|
||||
worker.distributed_init_method = init_method
|
||||
worker.od_config = SimpleNamespace(num_gpus=8, master_port=12345, moe_backend="auto")
|
||||
config = SimpleNamespace(kernel_config=SimpleNamespace(ir_op_priority=None))
|
||||
platform = Mock()
|
||||
platform.get_torch_device.return_value = "cuda:0"
|
||||
rendezvous = Mock(side_effect=ReachedRendezvousError)
|
||||
monkeypatch.setenv("MASTER_ADDR", "unrelated-host")
|
||||
monkeypatch.setenv("MASTER_PORT", "9999")
|
||||
# Restore these variables after init_device publishes the worker identity.
|
||||
for key in ("LOCAL_RANK", "RANK", "WORLD_SIZE"):
|
||||
monkeypatch.setenv(key, "stale")
|
||||
monkeypatch.setattr(worker_module, "current_omni_platform", platform)
|
||||
monkeypatch.setattr(worker_module, "create_diffusion_vllm_config", Mock(return_value=config))
|
||||
monkeypatch.setattr(worker_module, "_resolve_ir_op_priority", Mock(return_value=[]))
|
||||
monkeypatch.setattr(worker_module, "set_forward_context", lambda **kwargs: nullcontext())
|
||||
monkeypatch.setattr(worker_module, "set_current_vllm_config", lambda config: nullcontext())
|
||||
monkeypatch.setattr(worker_module, "init_distributed_environment", rendezvous)
|
||||
|
||||
with pytest.raises(ReachedRendezvousError):
|
||||
worker.init_device()
|
||||
|
||||
rendezvous.assert_called_once_with(
|
||||
world_size=8,
|
||||
rank=rank,
|
||||
distributed_init_method=init_method or "env://",
|
||||
local_rank=local_rank,
|
||||
)
|
||||
platform.get_torch_device.assert_called_once_with(local_rank)
|
||||
assert os.environ["MASTER_ADDR"] == expected_addr
|
||||
assert os.environ["MASTER_PORT"] == expected_port
|
||||
assert os.environ["LOCAL_RANK"] == str(local_rank)
|
||||
assert os.environ["RANK"] == str(rank)
|
||||
@@ -71,6 +71,34 @@ def test_stage_runtime_env_accepts_typed_runtime_config(monkeypatch):
|
||||
assert env_key not in os.environ
|
||||
|
||||
|
||||
@pytest.mark.parametrize("typed", [False, True])
|
||||
def test_build_ray_diffusion_config_preserves_explicit_stage_env(monkeypatch, typed):
|
||||
from vllm_omni.engine import stage_init_utils as init_mod
|
||||
|
||||
env = {"CUSTOM_PLUGIN_SETTING": "stage", "OMP_NUM_THREADS": 2}
|
||||
runtime_cfg = OmniStageRuntimeConfig(env=env) if typed else {"env": env}
|
||||
metadata = types.SimpleNamespace(
|
||||
runtime_cfg=runtime_cfg, stage_id=1, cfg_kv_collect_func=None, default_sampling_params=None
|
||||
)
|
||||
config = types.SimpleNamespace(
|
||||
distributed_executor_backend="ray", parallel_config=types.SimpleNamespace(world_size=2)
|
||||
)
|
||||
monkeypatch.setattr(init_mod, "build_engine_args_dict", lambda *args: {})
|
||||
monkeypatch.setattr(init_mod, "OmniDiffusionConfig", lambda **kwargs: config)
|
||||
monkeypatch.setattr(
|
||||
init_mod,
|
||||
"current_omni_platform",
|
||||
types.SimpleNamespace(device_control_env_var=None, get_device_count=lambda: 0),
|
||||
)
|
||||
|
||||
result = init_mod.build_diffusion_config("model", {}, metadata)
|
||||
|
||||
assert result is config
|
||||
assert result.ray_worker_env == {"CUSTOM_PLUGIN_SETTING": "stage", "OMP_NUM_THREADS": "2"}
|
||||
assert result.num_gpus == 2
|
||||
assert env["OMP_NUM_THREADS"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_key,invalid_value",
|
||||
[("INVALID=ENV", "value"), ("INVALID\0ENV", "value"), ("INVALID_ENV", "value\0")],
|
||||
@@ -702,7 +730,11 @@ def test_launch_diffusion_stage_replica_preserves_configured_max_num_seqs(monkey
|
||||
assert kwargs["visible_devices"] == "1"
|
||||
return [42]
|
||||
|
||||
od_config = types.SimpleNamespace(max_num_seqs=4, parallel_config=types.SimpleNamespace(world_size=1))
|
||||
od_config = types.SimpleNamespace(
|
||||
max_num_seqs=4,
|
||||
distributed_executor_backend="mp",
|
||||
parallel_config=types.SimpleNamespace(world_size=1),
|
||||
)
|
||||
monkeypatch.setattr(startup_mod, "build_diffusion_config", lambda *args: od_config)
|
||||
monkeypatch.setattr(startup_mod, "acquire_device_locks", acquire)
|
||||
monkeypatch.setattr(
|
||||
@@ -796,6 +828,7 @@ def test_launch_diffusion_stage_replica_preserves_step_execution_max_num_seqs(mo
|
||||
od_config = types.SimpleNamespace(
|
||||
max_num_seqs=8,
|
||||
step_execution=True,
|
||||
distributed_executor_backend="mp",
|
||||
parallel_config=types.SimpleNamespace(world_size=1),
|
||||
)
|
||||
monkeypatch.setattr(startup_mod, "build_diffusion_config", lambda *args: od_config)
|
||||
|
||||
@@ -1219,7 +1219,11 @@ class TestSingleStageReplicaInitialization:
|
||||
runtime._init_visible_devices_baseline = "0"
|
||||
|
||||
mocker.patch.object(runtime_mod, "inject_kv_stage_info")
|
||||
od_config = SimpleNamespace(max_num_seqs=4, parallel_config=SimpleNamespace(world_size=1))
|
||||
od_config = SimpleNamespace(
|
||||
max_num_seqs=4,
|
||||
parallel_config=SimpleNamespace(world_size=1),
|
||||
distributed_executor_backend="mp",
|
||||
)
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.build_diffusion_config", return_value=od_config)
|
||||
mock_register = mocker.patch(
|
||||
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
|
||||
@@ -1352,7 +1356,11 @@ class TestSingleStageReplicaInitialization:
|
||||
runtime._init_visible_devices_baseline = "0"
|
||||
|
||||
mocker.patch.object(runtime_mod, "inject_kv_stage_info")
|
||||
od_config = SimpleNamespace(max_num_seqs=None, parallel_config=SimpleNamespace(world_size=1))
|
||||
od_config = SimpleNamespace(
|
||||
max_num_seqs=None,
|
||||
parallel_config=SimpleNamespace(world_size=1),
|
||||
distributed_executor_backend="mp",
|
||||
)
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.build_diffusion_config", return_value=od_config)
|
||||
mocker.patch(
|
||||
"vllm_omni.engine.stage_engine_startup.register_stage_with_omni_master",
|
||||
|
||||
@@ -1145,6 +1145,9 @@ _DIFFUSION_MOVED_SHARED_FIELDS = frozenset(
|
||||
"disable_autocast",
|
||||
}
|
||||
)
|
||||
# Runtime-populated OmniDiffusionConfig state (init=False) that is never
|
||||
# user-configurable and therefore not part of the projection.
|
||||
_DIFFUSION_INTERNAL_FIELDS = frozenset({"ray_worker_env"})
|
||||
|
||||
|
||||
_STAGE_DEPLOY_ENGINE_FIELDS: tuple[str, ...] = tuple(_STAGE_DEPLOY_FIELDS)
|
||||
|
||||
@@ -1005,6 +1005,9 @@ class OmniDiffusionConfig:
|
||||
# Worker extension class for custom functionality
|
||||
worker_extension_cls: str | None = None
|
||||
|
||||
# Internal transport of explicit stage runtime.env to remote Ray actors.
|
||||
ray_worker_env: dict[str, str] = field(default_factory=dict, init=False, repr=False)
|
||||
|
||||
# Custom pipeline arguments for custom pipelines
|
||||
custom_pipeline_args: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -669,8 +669,10 @@ class DiffusionEngine:
|
||||
self._poll_native_kv()
|
||||
worker_execution_completed = True
|
||||
except Exception as exc:
|
||||
# Shutdown can interrupt an in-flight executor call. Exit through
|
||||
# the loop tail so pending RPCs are failed instead of left waiting.
|
||||
if self._closed:
|
||||
return
|
||||
break
|
||||
worker_execution_completed = False
|
||||
logger.error(
|
||||
"Execution failed for diffusion requests %s", sched_output.scheduled_request_ids, exc_info=True
|
||||
@@ -1569,6 +1571,10 @@ class DiffusionEngine:
|
||||
for stream in pending_streams:
|
||||
self._put_queue_output(stream, closed_output)
|
||||
|
||||
# Interrupt remote inference before waiting for the thread blocked on Ray.
|
||||
if self.od_config.distributed_executor_backend == "ray":
|
||||
self.executor.shutdown()
|
||||
|
||||
worker_thread = self.worker_thread
|
||||
if worker_thread is not None:
|
||||
if worker_thread.is_alive():
|
||||
|
||||
@@ -49,7 +49,9 @@ class DiffusionExecutor(ABC):
|
||||
)
|
||||
executor_class = distributed_executor_backend
|
||||
elif distributed_executor_backend == "ray":
|
||||
raise NotImplementedError("ray backend is not yet supported.")
|
||||
from vllm_omni.diffusion.executor.ray_executor import RayDiffusionExecutor
|
||||
|
||||
executor_class = RayDiffusionExecutor
|
||||
elif distributed_executor_backend == "mp":
|
||||
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
|
||||
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Ray-based distributed executor for diffusion models.
|
||||
|
||||
Ray owns worker placement and process lifecycle. PyTorch distributed still
|
||||
owns the model-parallel data plane: the executor gives every actor a global
|
||||
rank and a common rendezvous address before the actor constructs its worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
from vllm.utils.network_utils import get_distributed_init_method, get_ip, get_open_port
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
from vllm_omni.diffusion.data import (
|
||||
DiffusionOutput,
|
||||
OmniDiffusionConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
|
||||
from vllm_omni.diffusion.media import DiffusionMediaOutput
|
||||
from vllm_omni.diffusion.offloader.config import (
|
||||
TEXT_ENCODER_COMPONENT,
|
||||
any_selected_component_uses_allgather,
|
||||
resolve_offload,
|
||||
)
|
||||
from vllm_omni.diffusion.sched.request_scheduler import build_request_batch_sampling_params_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.actor import ActorHandle
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
|
||||
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
|
||||
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_PLACEMENT_GROUP_WAIT_TIMEOUT_S = 1800
|
||||
_WORKER_INIT_TIMEOUT_S = 7200
|
||||
_HEALTH_CHECK_TIMEOUT_S = 10
|
||||
# Must stay below StageDiffusionClient's wait for the Ray subprocess to exit.
|
||||
_WORKER_SHUTDOWN_TIMEOUT_S = 5.0
|
||||
_DLO_DP_WAVE_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_DLO_DP_WAVE_TIMEOUT", 600.0))
|
||||
|
||||
try:
|
||||
import ray
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
except ImportError:
|
||||
ray = None # type: ignore[assignment]
|
||||
PlacementGroupSchedulingStrategy = None # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
def _worker_env(od_config: OmniDiffusionConfig) -> dict[str, str]:
|
||||
"""Copy inference settings, keeping worker identity local to each actor."""
|
||||
prefixes = (
|
||||
"VLLM_",
|
||||
"OMNI_",
|
||||
"DIFFUSION_",
|
||||
"NCCL_",
|
||||
"TORCH_NCCL_",
|
||||
"UCX_",
|
||||
"HF_",
|
||||
"HUGGINGFACE_",
|
||||
"HUGGING_FACE_HUB_",
|
||||
)
|
||||
names = {"PYTHONPATH", "CUDA_LAUNCH_BLOCKING", "OMP_NUM_THREADS"}
|
||||
env = {key: value for key, value in os.environ.items() if key.startswith(prefixes) or key in names}
|
||||
# Explicit stage settings may include plugin-specific variables outside
|
||||
# the standard prefixes, and take precedence over the driver's defaults.
|
||||
env.update(getattr(od_config, "ray_worker_env", {}))
|
||||
worker_specific = {
|
||||
"CUDA_VISIBLE_DEVICES",
|
||||
"HIP_VISIBLE_DEVICES",
|
||||
"ROCR_VISIBLE_DEVICES",
|
||||
"ASCEND_RT_VISIBLE_DEVICES",
|
||||
"MUSA_VISIBLE_DEVICES",
|
||||
"ONEAPI_DEVICE_SELECTOR",
|
||||
"VLLM_HOST_IP",
|
||||
"VLLM_HOST_PORT",
|
||||
"VLLM_NIXL_SIDE_CHANNEL_HOST",
|
||||
"VLLM_LOCAL_RANK",
|
||||
"MASTER_ADDR",
|
||||
"MASTER_PORT",
|
||||
"LOCAL_RANK",
|
||||
"LOCAL_WORLD_SIZE",
|
||||
"RANK",
|
||||
"WORLD_SIZE",
|
||||
}
|
||||
return {key: value for key, value in env.items() if key not in worker_specific and not key.startswith("RAY_")}
|
||||
|
||||
|
||||
def _uses_dlo_dp_concurrency(od_config: OmniDiffusionConfig) -> bool:
|
||||
parallel_config = getattr(od_config, "parallel_config", None)
|
||||
return bool(
|
||||
(getattr(parallel_config, "data_parallel_size", 1) or 1) > 1
|
||||
and any_selected_component_uses_allgather(od_config)
|
||||
)
|
||||
|
||||
|
||||
def _is_empty_dp_prompt(prompt: object) -> bool:
|
||||
"""Return whether a DP request has no usable text prompt."""
|
||||
if prompt is None:
|
||||
return True
|
||||
if isinstance(prompt, (str, list, tuple)):
|
||||
return not prompt
|
||||
if isinstance(prompt, dict):
|
||||
return (
|
||||
not prompt.get("prompt")
|
||||
and not prompt.get("prompt_token_ids")
|
||||
and not prompt.get("prompt_ids")
|
||||
and prompt.get("prompt_embeds") is None
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _text_encoder_input_signature(prompt: object) -> tuple[bool, bool]:
|
||||
"""Describe precomputed embeddings that change encoder forward counts."""
|
||||
if not isinstance(prompt, dict):
|
||||
return False, False
|
||||
return prompt.get("prompt_embeds") is not None, prompt.get("negative_prompt_embeds") is not None
|
||||
|
||||
|
||||
def _uses_text_encoder_allgather(config: object) -> bool:
|
||||
resolved = resolve_offload(config)
|
||||
return resolved.offloads(TEXT_ENCODER_COMPONENT) and resolved.uses_allgather(TEXT_ENCODER_COMPONENT)
|
||||
|
||||
|
||||
def _move_to_cpu(value: Any) -> Any:
|
||||
"""Recursively detach tensors before Ray serializes a worker response."""
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.detach().cpu()
|
||||
if isinstance(value, DiffusionMediaOutput):
|
||||
return value.to_cpu()
|
||||
if isinstance(value, DiffusionOutput):
|
||||
for output_field in fields(value):
|
||||
setattr(value, output_field.name, _move_to_cpu(getattr(value, output_field.name)))
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _move_to_cpu(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_move_to_cpu(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_move_to_cpu(item) for item in value)
|
||||
|
||||
# RunnerOutput and BatchRunnerOutput are kept out of the module imports to
|
||||
# avoid pulling the complete worker stack into the driver at import time.
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
if isinstance(value, RunnerOutput):
|
||||
value.result = _move_to_cpu(value.result)
|
||||
return value
|
||||
if isinstance(value, BatchRunnerOutput):
|
||||
value.runner_outputs = [_move_to_cpu(output) for output in value.runner_outputs]
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RayWorkerMetadata:
|
||||
worker: ActorHandle
|
||||
rank: int = -1
|
||||
ip: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RayExecutorResources:
|
||||
"""Resources owned by the executor's finalizer."""
|
||||
|
||||
workers: list[_RayWorkerMetadata]
|
||||
placement_group: PlacementGroup | None = None
|
||||
owns_placement_group: bool = False
|
||||
|
||||
def drain(self, timeout_s: float) -> None:
|
||||
"""Run worker shutdown on every actor, waiting at most ``timeout_s``.
|
||||
|
||||
Shutdown RPCs queue behind in-flight inference, so actors that do not
|
||||
finish in time are left for ``__call__`` to kill.
|
||||
"""
|
||||
if ray is None or not self.workers:
|
||||
return
|
||||
|
||||
pending: dict[Any, int] = {}
|
||||
for metadata in self.workers:
|
||||
try:
|
||||
pending[metadata.worker.shutdown.remote()] = metadata.rank
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to request shutdown of Ray diffusion worker rank %d: %s", metadata.rank, exc)
|
||||
if not pending:
|
||||
return
|
||||
|
||||
done, not_done = ray.wait(list(pending), num_returns=len(pending), timeout=timeout_s)
|
||||
for ref in done:
|
||||
try:
|
||||
ray.get(ref)
|
||||
except Exception as exc:
|
||||
logger.warning("Ray diffusion worker rank %d did not shut down cleanly: %s", pending[ref], exc)
|
||||
if not_done:
|
||||
logger.warning(
|
||||
"Ray diffusion worker rank(s) %s did not shut down within %.1fs; killing them",
|
||||
sorted(pending[ref] for ref in not_done),
|
||||
timeout_s,
|
||||
)
|
||||
|
||||
def __call__(self) -> None:
|
||||
if ray is None:
|
||||
return
|
||||
|
||||
# Actors that were drained have already released their resources. The
|
||||
# rest may be blocked in inference or a collective, so kill them all.
|
||||
for metadata in self.workers:
|
||||
try:
|
||||
ray.kill(metadata.worker, no_restart=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to kill Ray diffusion worker rank %d: %s", metadata.rank, exc)
|
||||
self.workers.clear()
|
||||
|
||||
if self.owns_placement_group and self.placement_group is not None:
|
||||
try:
|
||||
ray.util.remove_placement_group(self.placement_group)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to remove Ray diffusion placement group: %s", exc)
|
||||
self.placement_group = None
|
||||
|
||||
|
||||
class RayDiffusionWorkerWrapper:
|
||||
"""Ray actor that constructs one rank of the existing DiffusionWorker."""
|
||||
|
||||
def __init__(self, provisional_rank: int) -> None:
|
||||
self.rank = provisional_rank
|
||||
self.worker = None
|
||||
|
||||
def get_node_ip(self) -> str:
|
||||
return get_ip()
|
||||
|
||||
def get_open_port(self) -> int:
|
||||
return get_open_port()
|
||||
|
||||
def init_worker(self, od_config: OmniDiffusionConfig, rank: int, distributed_init_method: str) -> None:
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
from vllm_omni.plugins import load_omni_general_plugins
|
||||
|
||||
load_omni_general_plugins()
|
||||
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import WorkerWrapperBase
|
||||
|
||||
worker_cls_path = current_omni_platform.get_diffusion_worker_cls()
|
||||
self.rank = rank
|
||||
self.worker = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
rank=self.rank,
|
||||
distributed_init_method=distributed_init_method,
|
||||
od_config=od_config,
|
||||
worker_extension_cls=od_config.worker_extension_cls,
|
||||
custom_pipeline_args=getattr(od_config, "custom_pipeline_args", None),
|
||||
base_worker_class=resolve_obj_by_qualname(worker_cls_path),
|
||||
)
|
||||
|
||||
def _is_primary_in_replica(self) -> bool:
|
||||
from vllm.distributed.parallel_state import get_tensor_model_parallel_rank
|
||||
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
get_classifier_free_guidance_rank,
|
||||
get_pipeline_parallel_rank,
|
||||
get_sequence_parallel_rank,
|
||||
)
|
||||
|
||||
return (
|
||||
get_sequence_parallel_rank() == 0
|
||||
and get_classifier_free_guidance_rank() == 0
|
||||
and get_tensor_model_parallel_rank() == 0
|
||||
and get_pipeline_parallel_rank() == 0
|
||||
)
|
||||
|
||||
def execute_rpc(
|
||||
self,
|
||||
method: str,
|
||||
args: tuple = (),
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
output_rank: int | None = None,
|
||||
exec_all_ranks: bool = False,
|
||||
primary_replies_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if self.worker is None:
|
||||
raise RuntimeError("Ray diffusion worker is not initialized")
|
||||
|
||||
should_execute = exec_all_ranks or output_rank is None or output_rank == self.rank
|
||||
should_reply = output_rank is None or output_rank == self.rank
|
||||
if primary_replies_only:
|
||||
should_reply = self._is_primary_in_replica()
|
||||
|
||||
result = None
|
||||
if should_execute:
|
||||
result = self.worker.execute_method(method, *(args or ()), **(kwargs or {}))
|
||||
if should_reply:
|
||||
result = _move_to_cpu(result)
|
||||
else:
|
||||
result = None
|
||||
return {"rank": self.rank, "replied": should_reply, "result": result}
|
||||
|
||||
def check_alive(self) -> bool:
|
||||
return self.worker is not None
|
||||
|
||||
def wait_for_exit(self) -> None:
|
||||
"""Block until the actor dies; the executor monitor waits on this call."""
|
||||
threading.Event().wait()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
worker, self.worker = self.worker, None
|
||||
if worker is not None:
|
||||
worker.shutdown()
|
||||
|
||||
|
||||
class RayDiffusionExecutor(DiffusionExecutor):
|
||||
"""Execute one diffusion worker per Ray GPU actor."""
|
||||
|
||||
def _init_executor(self) -> None:
|
||||
if ray is None:
|
||||
raise ImportError(
|
||||
"Ray is required for distributed_executor_backend='ray'. "
|
||||
'Install the project dependencies or `pip install "ray[default]"`.'
|
||||
)
|
||||
|
||||
self._closed = False
|
||||
self._is_failed = False
|
||||
self._state_lock = threading.Lock()
|
||||
self._failure_callbacks: list[Callable[[], None]] = []
|
||||
self.workers: list[_RayWorkerMetadata] = []
|
||||
|
||||
if not ray.is_initialized():
|
||||
logger.info("Initializing Ray for the diffusion executor")
|
||||
ray.init()
|
||||
|
||||
placement_group, owns_placement_group = self._get_or_create_placement_group()
|
||||
self._resources = _RayExecutorResources(
|
||||
workers=self.workers,
|
||||
placement_group=placement_group,
|
||||
owns_placement_group=owns_placement_group,
|
||||
)
|
||||
self._finalizer = weakref.finalize(self, self._resources)
|
||||
|
||||
try:
|
||||
self._init_workers(placement_group)
|
||||
except Exception:
|
||||
self._shutdown(graceful=False)
|
||||
raise
|
||||
self._start_worker_monitor()
|
||||
|
||||
@property
|
||||
def is_dead(self) -> bool:
|
||||
return self._closed or self._is_failed
|
||||
|
||||
def _ensure_open(self) -> None:
|
||||
if self._closed:
|
||||
raise RuntimeError("RayDiffusionExecutor is closed")
|
||||
if self._is_failed:
|
||||
raise EngineDeadError()
|
||||
|
||||
def _get_or_create_placement_group(self) -> tuple[PlacementGroup, bool]:
|
||||
num_gpus = int(self.od_config.num_gpus or 1)
|
||||
current_group = ray.util.get_current_placement_group()
|
||||
if current_group is not None:
|
||||
# A worker needs one full GPU within a single bundle.
|
||||
gpu_capacity = sum(int(bundle.get("GPU", 0)) for bundle in current_group.bundle_specs)
|
||||
if gpu_capacity < num_gpus:
|
||||
raise ValueError(
|
||||
f"Ray diffusion requires {num_gpus} full GPUs, "
|
||||
f"but the placement group can accommodate only {gpu_capacity} one-GPU workers."
|
||||
)
|
||||
logger.info("Using the current Ray placement group for diffusion workers")
|
||||
return current_group, False
|
||||
|
||||
placement_group = ray.util.placement_group([{"GPU": 1} for _ in range(num_gpus)], strategy="PACK")
|
||||
logger.info("Waiting for a Ray placement group with %d GPU bundle(s)", num_gpus)
|
||||
try:
|
||||
ray.get(placement_group.ready(), timeout=_PLACEMENT_GROUP_WAIT_TIMEOUT_S)
|
||||
except BaseException as exc:
|
||||
# The executor finalizer is not registered until this method returns.
|
||||
try:
|
||||
ray.util.remove_placement_group(placement_group)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove placement group after startup failure", exc_info=True)
|
||||
if isinstance(exc, ray.exceptions.GetTimeoutError):
|
||||
raise ValueError(
|
||||
f"Cannot reserve {num_gpus} Ray GPU bundle(s) within "
|
||||
f"{_PLACEMENT_GROUP_WAIT_TIMEOUT_S}s; check `ray status`."
|
||||
) from exc
|
||||
raise
|
||||
return placement_group, True
|
||||
|
||||
def _init_workers(self, placement_group: PlacementGroup) -> None:
|
||||
num_gpus = int(self.od_config.num_gpus or 1)
|
||||
driver_ip = get_ip()
|
||||
actor_cls = ray.remote(
|
||||
num_cpus=0,
|
||||
num_gpus=1,
|
||||
runtime_env={"env_vars": _worker_env(self.od_config)},
|
||||
max_concurrency=1,
|
||||
concurrency_groups={"health": 1, "monitor": 1},
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||||
placement_group=placement_group,
|
||||
placement_group_capture_child_tasks=True,
|
||||
),
|
||||
)(RayDiffusionWorkerWrapper)
|
||||
|
||||
# Register actors immediately so partial startup failures can clean them up.
|
||||
for rank in range(num_gpus):
|
||||
actor = actor_cls.remote(provisional_rank=rank)
|
||||
self.workers.append(_RayWorkerMetadata(worker=actor, rank=rank))
|
||||
|
||||
worker_ips = ray.get(
|
||||
[item.worker.get_node_ip.remote() for item in self.workers],
|
||||
timeout=_WORKER_INIT_TIMEOUT_S,
|
||||
)
|
||||
for item, worker_ip in zip(self.workers, worker_ips):
|
||||
item.ip = worker_ip
|
||||
|
||||
ip_counts: dict[str, int] = defaultdict(int)
|
||||
for item in self.workers:
|
||||
ip_counts[item.ip] += 1
|
||||
|
||||
# Keep ranks from the same host contiguous. Prefer the driver host for
|
||||
# rank 0 when it owns a GPU; otherwise choose deterministically by IP.
|
||||
self.workers.sort(key=lambda item: (0 if item.ip == driver_ip else 1, ip_counts[item.ip], item.ip))
|
||||
for rank, item in enumerate(self.workers):
|
||||
item.rank = rank
|
||||
|
||||
unique_ips = {item.ip for item in self.workers}
|
||||
master_addr = "127.0.0.1" if len(unique_ips) == 1 else self.workers[0].ip
|
||||
master_port = ray.get(self.workers[0].worker.get_open_port.remote(), timeout=_WORKER_INIT_TIMEOUT_S)
|
||||
distributed_init_method = get_distributed_init_method(master_addr, master_port)
|
||||
|
||||
logger.info(
|
||||
"Initializing %d Ray diffusion worker(s) across %d node(s)",
|
||||
num_gpus,
|
||||
len(unique_ips),
|
||||
)
|
||||
futures = [
|
||||
item.worker.init_worker.remote(self.od_config, item.rank, distributed_init_method) for item in self.workers
|
||||
]
|
||||
ray.get(futures, timeout=_WORKER_INIT_TIMEOUT_S)
|
||||
logger.info("All %d Ray diffusion workers initialized", num_gpus)
|
||||
|
||||
def _start_worker_monitor(self) -> None:
|
||||
"""Fail the executor as soon as any actor dies, even while idle."""
|
||||
sentinels = {
|
||||
item.worker.wait_for_exit.options(concurrency_group="monitor").remote(): item.rank for item in self.workers
|
||||
}
|
||||
if not sentinels:
|
||||
return
|
||||
# Keep the thread from holding the executor alive; its finalizer kills
|
||||
# the actors, which releases the wait below.
|
||||
self_ref = weakref.ref(self)
|
||||
|
||||
def _monitor() -> None:
|
||||
try:
|
||||
done, _ = ray.wait(list(sentinels), num_returns=1)
|
||||
except Exception:
|
||||
return
|
||||
executor = self_ref()
|
||||
if executor is None or executor._closed:
|
||||
return
|
||||
ref = done[0]
|
||||
try:
|
||||
ray.get(ref)
|
||||
exc: BaseException = RuntimeError(f"Ray diffusion worker rank {sentinels[ref]} exited unexpectedly")
|
||||
except Exception as actor_exc:
|
||||
exc = actor_exc
|
||||
executor._fail(exc)
|
||||
|
||||
threading.Thread(target=_monitor, daemon=True, name="ray-diffusion-worker-monitor").start()
|
||||
|
||||
def _fail(self, exc: BaseException) -> None:
|
||||
"""Mark the executor dead, release every actor, then notify listeners."""
|
||||
with self._state_lock:
|
||||
if self._closed or self._is_failed:
|
||||
return
|
||||
self._is_failed = True
|
||||
logger.error("Ray diffusion executor failed: %s", exc)
|
||||
self._shutdown(graceful=False)
|
||||
for callback in self._failure_callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception:
|
||||
logger.exception("Diffusion executor failure callback raised")
|
||||
|
||||
def register_failure_callback(self, callback: Callable[[], None]) -> None:
|
||||
self._failure_callbacks.append(callback)
|
||||
|
||||
def collective_rpc(
|
||||
self,
|
||||
method: str,
|
||||
timeout: float | None = None,
|
||||
args: tuple = (),
|
||||
kwargs: dict | None = None,
|
||||
unique_reply_rank: int | None = None,
|
||||
exec_all_ranks: bool = False,
|
||||
) -> Any:
|
||||
self._ensure_open()
|
||||
|
||||
# ``None + exec_all_ranks`` is the multiprocessing executor's
|
||||
# rank-local-DP response mode: all ranks compute, but only the primary
|
||||
# rank of each DP replica returns an output.
|
||||
primary_replies_only = unique_reply_rank is None and exec_all_ranks
|
||||
execute_all_ranks = unique_reply_rank is None or exec_all_ranks
|
||||
try:
|
||||
futures = [
|
||||
item.worker.execute_rpc.remote(
|
||||
method=method,
|
||||
args=args,
|
||||
kwargs=kwargs or {},
|
||||
output_rank=unique_reply_rank,
|
||||
exec_all_ranks=execute_all_ranks,
|
||||
primary_replies_only=primary_replies_only,
|
||||
)
|
||||
for item in self.workers
|
||||
]
|
||||
envelopes = ray.get(futures, timeout=timeout)
|
||||
except ray.exceptions.GetTimeoutError as exc:
|
||||
self._fail(exc)
|
||||
raise TimeoutError(f"RPC call to {method} timed out") from exc
|
||||
except ray.exceptions.RayActorError as exc:
|
||||
self._fail(exc)
|
||||
raise EngineDeadError() from exc
|
||||
except ray.exceptions.RayTaskError as exc:
|
||||
# ray.get raises on the first failed rank without waiting for its
|
||||
# peers. They may still be blocked in a collective, so no worker
|
||||
# can safely accept another RPC from this executor.
|
||||
self._fail(exc)
|
||||
raise EngineDeadError() from exc
|
||||
|
||||
replies = sorted(
|
||||
(envelope for envelope in envelopes if envelope["replied"]),
|
||||
key=lambda envelope: envelope["rank"],
|
||||
)
|
||||
if unique_reply_rank is not None:
|
||||
for envelope in replies:
|
||||
if envelope["rank"] == unique_reply_rank:
|
||||
return envelope["result"]
|
||||
raise RuntimeError(f"Ray diffusion rank {unique_reply_rank} did not return a response for {method}")
|
||||
return [envelope["result"] for envelope in replies]
|
||||
|
||||
def execute_request(self, scheduler_output: DiffusionSchedulerOutput) -> BaseRunnerOutput:
|
||||
from vllm_omni.diffusion.sched.interface import validate_new_request_data_identity
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
self._ensure_open()
|
||||
new_reqs = scheduler_output.scheduled_new_reqs
|
||||
for new_req in new_reqs:
|
||||
validate_new_request_data_identity(new_req)
|
||||
|
||||
if len(new_reqs) > 1 and _uses_dlo_dp_concurrency(self.od_config):
|
||||
compatibility_keys = [build_request_batch_sampling_params_key(item.req) for item in new_reqs]
|
||||
if any(key != compatibility_keys[0] for key in compatibility_keys[1:]):
|
||||
raise ValueError(
|
||||
"Rank-local DP concurrency requires compatible shape, CFG, denoise schedule, "
|
||||
"output count, and LoRA settings for every request in a collective wave."
|
||||
)
|
||||
extra_args_signatures = {
|
||||
json.dumps(getattr(item.req.sampling_params, "extra_args", None), sort_keys=True, default=repr)
|
||||
for item in new_reqs
|
||||
}
|
||||
if len(extra_args_signatures) > 1:
|
||||
raise ValueError("DP multi-concurrency requires identical extra_args for every request")
|
||||
if _uses_text_encoder_allgather(self.od_config):
|
||||
encoder_signatures = {_text_encoder_input_signature(item.req.prompt) for item in new_reqs}
|
||||
if len(encoder_signatures) > 1:
|
||||
raise ValueError(
|
||||
"DLO text_encoder AllGather requires every concurrent request "
|
||||
"to provide the same positive/negative prompt embedding fields."
|
||||
)
|
||||
empty_prompt_ids = [item.request_id for item in new_reqs if _is_empty_dp_prompt(item.req.prompt)]
|
||||
if empty_prompt_ids:
|
||||
raise ValueError(
|
||||
"DP multi-concurrency requires a non-empty prompt for every request; "
|
||||
f"empty prompt request IDs: {empty_prompt_ids}."
|
||||
)
|
||||
|
||||
try:
|
||||
results = self.collective_rpc(
|
||||
"execute_model",
|
||||
timeout=_DLO_DP_WAVE_TIMEOUT_S,
|
||||
args=(new_reqs, self.od_config, scheduler_output.kv_prefetch_job),
|
||||
unique_reply_rank=None,
|
||||
exec_all_ranks=True,
|
||||
)
|
||||
tagged_results = sorted(results, key=lambda item: item["dp_rank"])
|
||||
outputs = []
|
||||
for index, new_req in enumerate(new_reqs):
|
||||
result = tagged_results[index]["output"]
|
||||
if not isinstance(result, DiffusionOutput):
|
||||
raise RuntimeError(f"Unexpected DP response type [{index}]: {type(result)!r}")
|
||||
outputs.append(
|
||||
RunnerOutput(
|
||||
request_id=new_req.request_id,
|
||||
step_index=None,
|
||||
finished=True,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
return BatchRunnerOutput.from_list(outputs)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, TimeoutError):
|
||||
self._fail(exc)
|
||||
return BatchRunnerOutput.from_list(
|
||||
[
|
||||
RunnerOutput(
|
||||
request_id=item.request_id,
|
||||
step_index=None,
|
||||
finished=True,
|
||||
result=DiffusionOutput.from_exception(exc),
|
||||
)
|
||||
for item in new_reqs
|
||||
]
|
||||
)
|
||||
|
||||
outputs = []
|
||||
for new_req in new_reqs:
|
||||
args: tuple = (new_req.req, self.od_config, scheduler_output.kv_prefetch_job)
|
||||
if new_req.diffusion_kv_metadata is not None:
|
||||
args += (new_req.diffusion_kv_metadata,)
|
||||
try:
|
||||
timeout_options: dict[str, Any] = {}
|
||||
if any_selected_component_uses_allgather(self.od_config):
|
||||
timeout_options["timeout"] = _DLO_DP_WAVE_TIMEOUT_S
|
||||
result = self.collective_rpc(
|
||||
"execute_model",
|
||||
args=args,
|
||||
unique_reply_rank=0,
|
||||
exec_all_ranks=True,
|
||||
**timeout_options,
|
||||
)
|
||||
if not isinstance(result, DiffusionOutput):
|
||||
raise RuntimeError(f"Unexpected response type: {type(result)!r}")
|
||||
except Exception as exc:
|
||||
if isinstance(exc, TimeoutError):
|
||||
self._fail(exc)
|
||||
result = DiffusionOutput.from_exception(exc)
|
||||
outputs.append(
|
||||
RunnerOutput(
|
||||
request_id=new_req.request_id,
|
||||
step_index=None,
|
||||
finished=True,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
return BatchRunnerOutput.from_list(outputs)
|
||||
|
||||
def execute_batch(self, scheduler_output: DiffusionSchedulerOutput) -> BaseRunnerOutput:
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput
|
||||
|
||||
self._ensure_open()
|
||||
if len(scheduler_output.scheduled_new_reqs) <= 1:
|
||||
return self.execute_request(scheduler_output)
|
||||
if _uses_dlo_dp_concurrency(self.od_config):
|
||||
return self.execute_request(scheduler_output)
|
||||
|
||||
result = self.collective_rpc(
|
||||
"execute_model_batch",
|
||||
args=(scheduler_output, self.od_config),
|
||||
unique_reply_rank=0,
|
||||
exec_all_ranks=True,
|
||||
)
|
||||
if not isinstance(result, BatchRunnerOutput):
|
||||
raise RuntimeError(f"Unexpected response type for execute_batch: {type(result)!r}")
|
||||
return result
|
||||
|
||||
def execute_step(self, scheduler_output: DiffusionSchedulerOutput) -> BaseRunnerOutput:
|
||||
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput
|
||||
|
||||
self._ensure_open()
|
||||
result = self.collective_rpc(
|
||||
"execute_stepwise",
|
||||
args=(scheduler_output,),
|
||||
unique_reply_rank=0,
|
||||
exec_all_ranks=True,
|
||||
)
|
||||
if not isinstance(result, BaseRunnerOutput):
|
||||
raise RuntimeError(f"Unexpected response type for execute_step: {type(result)!r}")
|
||||
return result
|
||||
|
||||
def check_health(self) -> None:
|
||||
self._ensure_open()
|
||||
try:
|
||||
alive = ray.get(
|
||||
[item.worker.check_alive.options(concurrency_group="health").remote() for item in self.workers],
|
||||
timeout=_HEALTH_CHECK_TIMEOUT_S,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._fail(exc)
|
||||
raise EngineDeadError() from exc
|
||||
dead_ranks = [item.rank for item, is_alive in zip(self.workers, alive) if not is_alive]
|
||||
if dead_ranks:
|
||||
exc = RuntimeError(f"Ray diffusion worker ranks are not healthy: {dead_ranks}")
|
||||
self._fail(exc)
|
||||
raise EngineDeadError() from exc
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._shutdown(graceful=True)
|
||||
|
||||
def _shutdown(self, graceful: bool) -> None:
|
||||
with self._state_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
if graceful:
|
||||
self._resources.drain(_WORKER_SHUTDOWN_TIMEOUT_S)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to drain Ray diffusion workers: %s", exc)
|
||||
finally:
|
||||
self._finalizer()
|
||||
@@ -524,14 +524,28 @@ class StageDiffusionClient(StageClientBase):
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._shutting_down = True
|
||||
shutdown_requested = False
|
||||
try:
|
||||
# A blocking send never returns once the subprocess is gone.
|
||||
self._request_socket.send(self._encoder.encode({"type": "shutdown"}), flags=zmq.NOBLOCK)
|
||||
shutdown_requested = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._proc_manager is not None and self._proc_manager.proc.is_alive():
|
||||
self._proc_manager.shutdown(timeout=10)
|
||||
if self._proc_manager.distributed_executor_backend == "ray":
|
||||
# Let the subprocess kill its remote actors before terminating it.
|
||||
stopped = shutdown_requested and self._proc_manager.wait_for_shutdown(timeout=10.0)
|
||||
if not stopped:
|
||||
logger.warning(
|
||||
"Stage-%d Ray diffusion subprocess did not stop within %.1fs; "
|
||||
"falling back to signal-based shutdown",
|
||||
self.stage_id,
|
||||
10.0,
|
||||
)
|
||||
self._proc_manager.shutdown(timeout=10.0)
|
||||
else:
|
||||
self._proc_manager.shutdown(timeout=10.0)
|
||||
|
||||
self._request_socket.close(linger=0)
|
||||
self._response_socket.close(linger=0)
|
||||
|
||||
@@ -130,6 +130,25 @@ class StageDiffusionProc:
|
||||
)
|
||||
self._fatal_event.set()
|
||||
|
||||
def _watch_executor_failure(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Tear down when the executor fails between requests.
|
||||
|
||||
Executor monitors fire failure callbacks from their own threads, so
|
||||
the signal is marshalled onto ``run_loop``'s event loop.
|
||||
"""
|
||||
executor: DiffusionExecutor | None = getattr(self._engine, "executor", None)
|
||||
if executor is None:
|
||||
return
|
||||
|
||||
def _on_executor_failure() -> None:
|
||||
# The loop is closed once run_loop has exited; nothing to signal.
|
||||
with contextlib.suppress(RuntimeError):
|
||||
loop.call_soon_threadsafe(self._signal_fatal_engine_failure, "diffusion executor failed")
|
||||
|
||||
executor.register_failure_callback(_on_executor_failure)
|
||||
if executor.is_dead:
|
||||
self._signal_fatal_engine_failure("diffusion executor failed before run_loop started")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ------------------------------------------------------------------
|
||||
@@ -357,6 +376,7 @@ class StageDiffusionProc:
|
||||
# "DiffusionExecutor is closed" on every subsequent request.
|
||||
fatal_event = asyncio.Event()
|
||||
self._fatal_event = fatal_event
|
||||
self._watch_executor_failure(asyncio.get_running_loop())
|
||||
|
||||
async def _dispatch_request(
|
||||
request_id: str,
|
||||
@@ -760,6 +780,7 @@ class StageDiffusionProcManager:
|
||||
)
|
||||
proc.start()
|
||||
self.proc = proc
|
||||
self.distributed_executor_backend = od_config.distributed_executor_backend
|
||||
self.addresses = addresses
|
||||
self.manager_stopped = False
|
||||
self.failed_proc_name: str | None = None
|
||||
@@ -797,6 +818,7 @@ class StageDiffusionProcManager:
|
||||
)
|
||||
proc.start()
|
||||
self.proc = proc
|
||||
self.distributed_executor_backend = od_config.distributed_executor_backend
|
||||
self.addresses = addresses
|
||||
self.manager_stopped = False
|
||||
self.failed_proc_name = None
|
||||
@@ -835,6 +857,12 @@ class StageDiffusionProcManager:
|
||||
self.manager_stopped = True
|
||||
shutdown([self.proc], timeout=timeout)
|
||||
|
||||
def wait_for_shutdown(self, timeout: float | None = None) -> bool:
|
||||
"""Wait for subprocess cleanup without sending a termination signal."""
|
||||
self.manager_stopped = True
|
||||
self.proc.join(timeout)
|
||||
return not self.proc.is_alive()
|
||||
|
||||
def sentinels(self) -> list[int]:
|
||||
return [self.proc.sentinel]
|
||||
|
||||
|
||||
@@ -251,7 +251,9 @@ class DiffusionWorker:
|
||||
rank: int,
|
||||
od_config: OmniDiffusionConfig,
|
||||
skip_load_model: bool = False,
|
||||
distributed_init_method: str | None = None,
|
||||
):
|
||||
self.distributed_init_method = distributed_init_method
|
||||
self.local_rank = local_rank
|
||||
self.rank = rank
|
||||
self.od_config = od_config
|
||||
@@ -320,15 +322,16 @@ class DiffusionWorker:
|
||||
world_size = self.od_config.num_gpus
|
||||
rank = self.rank
|
||||
|
||||
# Set environment variables for distributed initialization
|
||||
os.environ["MASTER_ADDR"] = "localhost"
|
||||
os.environ["MASTER_PORT"] = str(self.od_config.master_port)
|
||||
# Set environment variables for local distributed initialization.
|
||||
if self.distributed_init_method is None:
|
||||
os.environ["MASTER_ADDR"] = "localhost"
|
||||
os.environ["MASTER_PORT"] = str(self.od_config.master_port)
|
||||
os.environ["LOCAL_RANK"] = str(self.local_rank)
|
||||
os.environ["RANK"] = str(rank)
|
||||
os.environ["WORLD_SIZE"] = str(world_size)
|
||||
|
||||
# Setup device
|
||||
self.device = current_omni_platform.get_torch_device(rank)
|
||||
self.device = current_omni_platform.get_torch_device(self.local_rank)
|
||||
current_omni_platform.set_device(self.device)
|
||||
|
||||
# Create vllm_config for parallel configuration. Pass explicit device_config
|
||||
@@ -354,7 +357,12 @@ class DiffusionWorker:
|
||||
set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config),
|
||||
set_current_vllm_config(self.vllm_config),
|
||||
):
|
||||
init_distributed_environment(world_size=world_size, rank=rank)
|
||||
init_distributed_environment(
|
||||
world_size=world_size,
|
||||
rank=rank,
|
||||
distributed_init_method=self.distributed_init_method or "env://",
|
||||
local_rank=self.local_rank,
|
||||
)
|
||||
logger.info(f"Worker {self.rank}: Initialized device and distributed environment.")
|
||||
|
||||
parallel_config = self.od_config.parallel_config
|
||||
@@ -1682,16 +1690,22 @@ class WorkerWrapperBase:
|
||||
wake_event: mp.Event = None,
|
||||
worker_extension_cls: str | None = None,
|
||||
custom_pipeline_args: dict[str, Any] | None = None,
|
||||
rank: int | None = None,
|
||||
distributed_init_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize WorkerWrapperBase with support for worker extensions.
|
||||
|
||||
Args:
|
||||
gpu_id: GPU device ID
|
||||
gpu_id: Local GPU device ID
|
||||
od_config: OmniDiffusionConfig configuration
|
||||
worker_extension_cls: Optional qualified name of worker extension class
|
||||
custom_pipeline_args: Optional arguments passed to native pipelines.
|
||||
A ``pipeline_class`` entry triggers custom pipeline initialization.
|
||||
rank: Global distributed rank. Defaults to ``gpu_id`` for local
|
||||
multiprocessing.
|
||||
distributed_init_method: Explicit rendezvous URL, or None for the
|
||||
local launcher's environment-based rendezvous.
|
||||
"""
|
||||
self.gpu_id = gpu_id
|
||||
self.od_config = od_config
|
||||
@@ -1706,12 +1720,15 @@ class WorkerWrapperBase:
|
||||
# Create the actual worker instance. Only dynamic custom pipelines skip
|
||||
# initial loading; native pipelines may also use custom_pipeline_args
|
||||
# for model-specific component paths.
|
||||
worker = worker_class(
|
||||
local_rank=gpu_id,
|
||||
rank=gpu_id,
|
||||
od_config=od_config,
|
||||
skip_load_model=self.uses_custom_pipeline,
|
||||
)
|
||||
worker_init_kwargs: dict[str, Any] = {
|
||||
"local_rank": gpu_id,
|
||||
"rank": gpu_id if rank is None else rank,
|
||||
"od_config": od_config,
|
||||
"skip_load_model": self.uses_custom_pipeline,
|
||||
}
|
||||
if distributed_init_method is not None:
|
||||
worker_init_kwargs["distributed_init_method"] = distributed_init_method
|
||||
worker = worker_class(**worker_init_kwargs)
|
||||
try:
|
||||
# Re-initialize pipeline with custom pipeline if provided.
|
||||
if self.uses_custom_pipeline:
|
||||
|
||||
@@ -1620,11 +1620,17 @@ def launch_diffusion_stage_replica(
|
||||
world_size = max(1, int(world_size))
|
||||
except (TypeError, ValueError):
|
||||
world_size = 1
|
||||
lock_fds = acquire_device_locks(
|
||||
metadata.stage_id,
|
||||
{"tensor_parallel_size": world_size},
|
||||
stage_init_timeout,
|
||||
visible_devices=stage_visible_devices,
|
||||
# These locks coordinate local stage initialization; the orchestrator cannot
|
||||
# acquire them on other hosts where Ray workers may run.
|
||||
lock_fds = (
|
||||
[]
|
||||
if od_config.distributed_executor_backend == "ray"
|
||||
else acquire_device_locks(
|
||||
metadata.stage_id,
|
||||
{"tensor_parallel_size": world_size},
|
||||
stage_init_timeout,
|
||||
visible_devices=stage_visible_devices,
|
||||
)
|
||||
)
|
||||
proc_manager = None
|
||||
try:
|
||||
|
||||
@@ -1994,6 +1994,10 @@ def build_diffusion_config(
|
||||
if isinstance(value, int) and value > 0:
|
||||
od_config.additional_config.setdefault(f"diffusion_kv_profile_{dimension}", value)
|
||||
|
||||
if od_config.distributed_executor_backend == "ray":
|
||||
runtime_env = _to_dict(_get_attr_or_item(metadata.runtime_cfg, "env", {}) or {})
|
||||
od_config.ray_worker_env = {str(key): str(value) for key, value in runtime_env.items()}
|
||||
|
||||
num_devices_per_stage = od_config.parallel_config.world_size
|
||||
device_control_env = current_omni_platform.device_control_env_var
|
||||
visible_devices_str = os.environ.get(device_control_env) if device_control_env else None
|
||||
@@ -2003,7 +2007,10 @@ def build_diffusion_config(
|
||||
else:
|
||||
physical_devices = list(range(current_omni_platform.get_device_count()))
|
||||
|
||||
if len(physical_devices) < num_devices_per_stage:
|
||||
# Ray validates cluster-wide GPU availability through its placement
|
||||
# group. The stage driver only sees the GPUs on its own node, so a local
|
||||
# device-count check would reject every valid multi-node configuration.
|
||||
if od_config.distributed_executor_backend != "ray" and len(physical_devices) < num_devices_per_stage:
|
||||
raise ValueError(
|
||||
f"Stage {metadata.stage_id} requires {num_devices_per_stage} device(s) based on parallel_config, "
|
||||
f"but {len(physical_devices)} device(s) are available: {physical_devices}"
|
||||
|
||||
Reference in new issue
Block a user