Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03cd912b4 | ||
|
|
12a5f6fbf3 | ||
|
|
53afbf3a0c | ||
|
|
3f1357c26b | ||
|
|
ee118ab492 |
No files matched your search
+38
-28
@@ -1,9 +1,9 @@
|
||||
# Claude Skills for vLLM-Omni
|
||||
# Repository Skills for vLLM-Omni
|
||||
|
||||
This directory contains Claude Code skills maintained for the `vllm-omni`
|
||||
repository. These skills capture repeatable workflows for common contributor
|
||||
tasks such as model integration, pull request review, and release note
|
||||
generation.
|
||||
This directory contains repository-scale skills maintained for `vllm-omni`.
|
||||
They capture repeatable workflows for common contributor and maintainer tasks
|
||||
such as model integration, CI-aligned testing, performance optimization, and
|
||||
pull request review.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,31 +14,41 @@ include:
|
||||
- `references/`: focused reference material used by the skill
|
||||
- `scripts/`: small helper scripts used by the skill
|
||||
|
||||
## Using the Skills
|
||||
|
||||
Coding agents that discover repository skills can invoke the relevant skill by
|
||||
name. For other agents, point them to the skill's `SKILL.md` and ask them to
|
||||
read it before changing code. Combine a domain skill with `vllm-omni-test` when
|
||||
tests or CI coverage change, then use `precheck-pr` for the contributor's final
|
||||
self-check.
|
||||
|
||||
## Available Skills
|
||||
|
||||
- `add-diffusion-model`: guides integration of a new diffusion model into
|
||||
`vllm-omni`
|
||||
- `diffusion-perf-opt`: guides diffusion model performance optimization,
|
||||
including profiling traces, parallel strategies, stage timing analysis, and
|
||||
benchmark-driven tuning
|
||||
- `quantization`: guides quantization method selection, model integration,
|
||||
checkpoint loading, and quality/performance validation for vLLM-Omni
|
||||
- `add-omni-model`: covers addition of new omni-modality model support
|
||||
- `add-tts-model`: covers integration of new TTS models and related serving
|
||||
workflows
|
||||
- `generate-release-note`: helps prepare release notes for repository changes
|
||||
- `precheck-pr`: self-check a branch before creating a PR — validates PR title
|
||||
format, catches dead code, verifies accuracy/perf claims, and confirms merge
|
||||
readiness
|
||||
- `vllm-omni-test`: guides generation and execution of CI-aligned tests (L1–L4),
|
||||
pytest marker selection (`core_model` / `advanced_model` / `full_model`,
|
||||
`omni` / `tts` / `diffusion`), Buildkite wiring (`test-ready.yml`,
|
||||
`test-merge.yml`, `test-nightly.yml`, `test-weekly.yml`), and copy-paste
|
||||
local plus CI-like `pytest` commands; see `references/test-routing.md` for
|
||||
level-to-command mapping
|
||||
- `review-pr`: provides a structured workflow for reviewing pull requests
|
||||
- `vllm-omni-npu-model-runner-upgrade`: upgrades NPU model runners to align with the
|
||||
latest vllm-ascend NPUModelRunner
|
||||
- [`add-diffusion-model`](add-diffusion-model/SKILL.md): guides integration of
|
||||
a new diffusion model into `vllm-omni`
|
||||
- [`add-tts-model`](add-tts-model/SKILL.md): covers integration of new TTS
|
||||
models and related serving workflows
|
||||
- [`diffusion-perf-opt`](diffusion-perf-opt/SKILL.md): guides diffusion model
|
||||
performance optimization, including profiling traces, parallel strategies,
|
||||
stage timing analysis, and benchmark-driven tuning
|
||||
- [`precheck-pr`](precheck-pr/SKILL.md): self-checks a branch before creating a
|
||||
PR by validating title format, dead code, accuracy and performance claims,
|
||||
and merge readiness
|
||||
- [`quantization`](quantization/SKILL.md): guides quantization method selection,
|
||||
model integration, checkpoint loading, and quality/performance validation
|
||||
for vLLM-Omni
|
||||
- [`review-pr`](review-pr/SKILL.md): provides a frozen-snapshot, contract-aware
|
||||
workflow for maintainers and reviewers; contributors should use
|
||||
`precheck-pr` for self-review
|
||||
- [`vllm-omni-npu-model-runner-upgrade`](vllm-omni-npu-upgrade/SKILL.md):
|
||||
upgrades NPU model runners to align with the latest vllm-ascend
|
||||
`NPUModelRunner`
|
||||
- [`vllm-omni-test`](vllm-omni-test/SKILL.md): guides generation and execution
|
||||
of CI-aligned tests (L1–L4), pytest marker selection (`core_model` /
|
||||
`advanced_model` / `full_model`, `omni` / `tts` / `diffusion`), Buildkite
|
||||
wiring (`test-ready.yml`, `test-merge.yml`, `test-nightly.yml`,
|
||||
`test-weekly.yml`), and copy-paste local plus CI-like `pytest` commands; see
|
||||
`references/test-routing.md` for level-to-command mapping
|
||||
|
||||
## Maintenance Guidelines
|
||||
|
||||
|
||||
@@ -76,6 +76,47 @@ Please refer to the [test instructions](./ci/test_execution_guide.md) for detail
|
||||
!!! warning
|
||||
Currently, not all unit tests pass when run on CPU platforms. If you don't have access to a GPU platform to run unit tests locally, rely on the continuous integration system to run the tests for now.
|
||||
|
||||
### Using repository skills with coding agents
|
||||
|
||||
vLLM-Omni maintains [repository-scale skills](https://github.com/vllm-project/vllm-omni/tree/main/.claude/skills) for common coding, testing, performance, and review workflows. These skills capture repository-specific structure, validation requirements, and evidence standards that a general coding agent may not know.
|
||||
|
||||
If your coding agent discovers repository skills automatically, ask it to use the relevant skill by name. Otherwise, point it to the linked `SKILL.md` and ask it to read the file before changing code. Skills can be combined: use a domain skill for the implementation, `vllm-omni-test` for test and CI decisions, and `precheck-pr` before opening the pull request.
|
||||
|
||||
| Task | Repository skill |
|
||||
| --- | --- |
|
||||
| Add or extend a diffusion model | [`add-diffusion-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-diffusion-model/SKILL.md) |
|
||||
| Add or extend a text-to-speech model | [`add-tts-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-tts-model/SKILL.md) |
|
||||
| Add, select, or debug quantization | [`quantization`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/quantization/SKILL.md) |
|
||||
| Diagnose or optimize diffusion performance | [`diffusion-perf-opt`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/diffusion-perf-opt/SKILL.md) |
|
||||
| Upgrade an NPU model runner | [`vllm-omni-npu-model-runner-upgrade`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-npu-upgrade/SKILL.md) |
|
||||
| Add a regression test, choose L1-L4 coverage, or wire Buildkite | [`vllm-omni-test`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-test/SKILL.md) |
|
||||
| Self-check a branch before opening a pull request | [`precheck-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) |
|
||||
| Perform a maintainer or reviewer review of an existing pull request or local branch | [`review-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/review-pr/SKILL.md) |
|
||||
|
||||
Use the following workflow when collaborating with a coding agent:
|
||||
|
||||
1. **Ground the task.** Provide the issue, RFC, or design document that defines the expected behavior. Ask the agent to state its assumptions and identify the affected module or feature contracts before editing code.
|
||||
2. **Select the narrowest applicable skills.** Start with the domain skill that owns the change. Add `vllm-omni-test` whenever production behavior or tests change. For a bug fix, ask for the smallest reproducer and a regression test tied to the original symptom.
|
||||
3. **Require executable evidence.** Ask the agent to run the narrowest relevant checks and report the exact commands and results. It must identify tests it could not run because of unavailable hardware, model weights, credentials, or dependencies; an unrun check is not a pass.
|
||||
4. **Inspect the resulting diff.** Confirm that the implementation stays within the issue or RFC, does not overwrite unrelated work, and includes the required tests, documentation, and benchmark or accuracy evidence.
|
||||
5. **Run the pre-submit workflow.** Use `precheck-pr`, then run the applicable local tests and pre-commit hooks before opening the pull request.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
Use add-diffusion-model and vllm-omni-test to implement the linked issue.
|
||||
Read the relevant design documents first, keep the change within the issue,
|
||||
and report every test command, result, and hardware validation gap.
|
||||
```
|
||||
|
||||
```text
|
||||
Use vllm-omni-test to turn this bug reproducer into the smallest deterministic
|
||||
regression test. Prefer L1 CPU coverage; if the failure requires real weights
|
||||
or serving, explain the required L2/L3 environment and provide the exact command.
|
||||
```
|
||||
|
||||
Repository skills guide the agent, but they do not replace contributor judgment, the accepted issue or RFC, required test evidence, or maintainer review. Review all generated changes before submitting them.
|
||||
|
||||
## Issues
|
||||
|
||||
If you encounter a bug or have a feature request, please search existing issues first to see if it has already been reported. If not, please file a new issue, providing as much relevant information as possible.
|
||||
|
||||
@@ -315,11 +315,32 @@ def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -
|
||||
pytest.fail(f"[{scenario} health] no terminal post-fault health observed: {last_observation}")
|
||||
|
||||
|
||||
def _assert_server_alive_after_fault(host: str, port: int, *, scenario: str) -> None:
|
||||
"""#4285: a single-stage engine death must NOT take down the API server.
|
||||
|
||||
The process must stay up and keep answering /health (503 is acceptable —
|
||||
errored but listening). A connection-refused means the server process
|
||||
exited, which is the bug this fix closes.
|
||||
"""
|
||||
deadline = time.monotonic() + 15.0
|
||||
responded = False
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, _ = get_health_raw(host, port, timeout_sec=5)
|
||||
responded = True
|
||||
assert status in (200, 503), f"[{scenario} alive] unexpected /health status={status}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if _looks_like_server_unreachable(exc):
|
||||
pytest.fail(f"[{scenario} alive] server exited after single-stage OOM (#4285): {exc!r}")
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
assert responded, f"[{scenario} alive] /health never responded after fault"
|
||||
|
||||
|
||||
QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLOY_CONFIGS_DIR)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
|
||||
@@ -386,9 +407,11 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
|
||||
assert "code" in chat_error, f"chat error lacks code field: {chat_error!r}"
|
||||
assert "code" in speech_error, f"speech error lacks code field: {speech_error!r}"
|
||||
|
||||
# #4285: a single dead stage must not take the whole API server down.
|
||||
_assert_server_alive_after_fault(host, port, scenario="oom_contract")
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
|
||||
@@ -431,7 +454,6 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
|
||||
@@ -774,7 +796,11 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@pytest.mark.skip(
|
||||
reason="needs request-scoped OOM recovery: under transient pressure the stage "
|
||||
"EngineCore still dies and is evicted (not respawned), so the engine does not "
|
||||
"recover after the hog stops. Out of scope for the #4285 fault-isolation fix."
|
||||
)
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
|
||||
|
||||
@@ -83,6 +83,11 @@ class _FakePool:
|
||||
self.stage_client = SimpleNamespace(requires_multimodal_data=False, custom_process_input_func=None)
|
||||
self.aborted: list[list[str]] = []
|
||||
|
||||
def live_replica_ids(self) -> list[int]:
|
||||
# Single always-live replica: these tests cover CFG bundling, not
|
||||
# replica death, and the forward path fast-fails on an empty pool.
|
||||
return [0]
|
||||
|
||||
async def abort_requests(self, request_ids):
|
||||
self.aborted.append(list(request_ids))
|
||||
|
||||
|
||||
@@ -74,8 +74,6 @@ async def test_engine_dead_broadcasts_fatal_to_rpc_waiters(monkeypatch: pytest.M
|
||||
rpc_async_queue=rpc_queue,
|
||||
stage_pools=[],
|
||||
)
|
||||
orchestrator._fatal_error = "stage engine died"
|
||||
orchestrator._fatal_error_stage_id = 2
|
||||
|
||||
async def wait_for_requests() -> None:
|
||||
await asyncio.Event().wait()
|
||||
@@ -91,8 +89,7 @@ async def test_engine_dead_broadcasts_fatal_to_rpc_waiters(monkeypatch: pytest.M
|
||||
fatal = rpc_queue.get_nowait()
|
||||
assert isinstance(fatal, ErrorMessage)
|
||||
assert fatal.fatal is True
|
||||
assert fatal.error == "stage engine died"
|
||||
assert fatal.stage_id == 2
|
||||
assert "stage engine died" in fatal.error
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""Tests for error propagation paths within the Orchestrator.
|
||||
|
||||
Covers:
|
||||
- EngineDeadError from an LLM stage poll → fatal error broadcast + shutdown
|
||||
- EngineDeadError from an LLM stage poll → fatal error broadcast + replica eviction
|
||||
- Diffusion stage error output (OmniRequestOutput.from_error) → routed correctly
|
||||
"""
|
||||
|
||||
@@ -14,17 +14,30 @@ import queue
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import janus
|
||||
import pytest
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
from vllm_omni.engine.messages import EngineQueueMessage, ErrorMessage, ShutdownRequestMessage
|
||||
from vllm_omni.engine.messages import (
|
||||
AddCompanionRequestMessage,
|
||||
EngineQueueMessage,
|
||||
ErrorMessage,
|
||||
ShutdownRequestMessage,
|
||||
StageSubmissionMessage,
|
||||
)
|
||||
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
|
||||
from vllm_omni.engine.stage_pool import StageUnavailableError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
from .test_orchestrator import (
|
||||
FakeOutputProcessor,
|
||||
FakeStageClient,
|
||||
OrchestratorFixture,
|
||||
_build_harness,
|
||||
_build_request_output,
|
||||
_build_stage_pools,
|
||||
_engine_core_outputs,
|
||||
_enqueue_add_request,
|
||||
_wait_for,
|
||||
)
|
||||
@@ -50,6 +63,22 @@ async def _get_any_output_message(fixture: OrchestratorFixture, *, timeout: floa
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def _wait_for_error_message(
|
||||
fixture: OrchestratorFixture,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
max_messages: int = 50,
|
||||
) -> ErrorMessage:
|
||||
"""Drain output messages until the ErrorMessage arrives, skipping the
|
||||
non-error outputs (e.g. stage metrics, forwarded stage results) that a
|
||||
driven pipeline can interleave ahead of the failure."""
|
||||
for _ in range(max_messages):
|
||||
msg = await _get_any_output_message(fixture)
|
||||
if isinstance(msg, ErrorMessage) and (request_id is None or msg.request_id == request_id):
|
||||
return msg
|
||||
raise AssertionError(f"No ErrorMessage for req={request_id} within {max_messages} messages")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orchestrator_factory():
|
||||
fixtures: list[OrchestratorFixture] = []
|
||||
@@ -72,32 +101,14 @@ def orchestrator_factory():
|
||||
# ───────── EngineDeadError from LLM stage poll ─────────
|
||||
|
||||
|
||||
class FakeDeadLLMStageClient(FakeStageClient):
|
||||
"""LLM stage client that raises EngineDeadError once a request is in-flight.
|
||||
|
||||
Raising on the first poll races with ``add_request``: under suite load the
|
||||
orchestrator can die and drain before the test enqueues anything, so no
|
||||
fatal ErrorMessage is ever published. Keep returning empty outputs until
|
||||
``add_request_async`` has been called, then fail on the next poll.
|
||||
"""
|
||||
|
||||
async def get_output_async(self):
|
||||
if not self.add_request_calls:
|
||||
return SimpleNamespace(
|
||||
outputs=[],
|
||||
scheduler_stats=None,
|
||||
finished_requests=None,
|
||||
)
|
||||
raise EngineDeadError("Stage-0 engine core is dead")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_factory) -> None:
|
||||
"""When a stage raises EngineDeadError during poll, the orchestrator must:
|
||||
async def test_engine_dead_error_evicts_replica_and_keeps_running(orchestrator_factory) -> None:
|
||||
"""When a stage replica raises EngineDeadError during poll, the orchestrator must:
|
||||
1. Enqueue a fatal error message for each affected request
|
||||
2. Shut itself down (thread exits)
|
||||
2. Evict the dead replica and keep running so the API server stays up
|
||||
(per-replica fault isolation, #4285)
|
||||
"""
|
||||
stage0 = FakeDeadLLMStageClient(stage_type="llm", final_output=True)
|
||||
stage0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
|
||||
orchestrator_fixture = orchestrator_factory([stage0])
|
||||
request = SimpleNamespace(request_id="req-dead", prompt_token_ids=[1, 2])
|
||||
|
||||
@@ -110,6 +121,12 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
# Let the request dispatch before the replica dies; a replica dead on
|
||||
# its very first poll can be evicted before dispatch, which routes the
|
||||
# failure through the add-request guard instead (that ordering is
|
||||
# covered by test_add_request_to_dead_stage_fails_request_not_server).
|
||||
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
|
||||
stage0.die = True
|
||||
|
||||
# Collect the fatal error message.
|
||||
msg = await _get_any_output_message(orchestrator_fixture)
|
||||
@@ -118,13 +135,17 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
|
||||
assert msg.type == "error"
|
||||
assert msg.fatal is True
|
||||
assert msg.request_id == "req-dead"
|
||||
assert "Stage-0 engine core is dead" in msg.error
|
||||
assert "Stage-0 replica-0 is dead" in msg.error
|
||||
|
||||
# The orchestrator thread should exit after the fatal error.
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
assert not orchestrator_fixture.thread.is_alive()
|
||||
# The dead replica is evicted; the orchestrator keeps running.
|
||||
pool = orchestrator_fixture.orchestrator.stage_pools[0]
|
||||
deadline = time.monotonic() + 5.0
|
||||
while pool.live_replica_ids() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.02)
|
||||
assert pool.live_replica_ids() == []
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
|
||||
# Request state should be cleaned up.
|
||||
# Affected request state should be cleaned up.
|
||||
assert "req-dead" not in orchestrator_fixture.orchestrator.request_states
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
@@ -132,6 +153,596 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
class _DieOnDemandLLMStageClient(FakeStageClient):
|
||||
"""LLM stage replica that raises EngineDeadError on poll once ``die`` is set."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.die = False
|
||||
|
||||
async def get_output_async(self):
|
||||
if self.die:
|
||||
raise EngineDeadError(f"Stage-{self.stage_id} replica-{self.replica_id} is dead")
|
||||
return await super().get_output_async()
|
||||
|
||||
|
||||
class _DieOnDemandDiffusionStageClient(FakeStageClient):
|
||||
"""Diffusion stage replica that raises EngineDeadError on poll once ``die`` is set."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.die = False
|
||||
|
||||
def get_diffusion_output_nowait(self):
|
||||
if self.die:
|
||||
raise EngineDeadError(f"Stage-{self.stage_id} diffusion replica-{self.replica_id} is dead")
|
||||
return super().get_diffusion_output_nowait()
|
||||
|
||||
|
||||
class _UnavailableDiffusionStageClient(FakeStageClient):
|
||||
"""Diffusion replica that stays live (so the pool keeps stage_type) but whose
|
||||
dispatch fails with StageUnavailableError, as a downstream prewarm would when
|
||||
its stage has no capacity."""
|
||||
|
||||
async def add_request_async(self, *args, **kwargs) -> None:
|
||||
raise StageUnavailableError(f"stage {self.stage_id} has no live replica")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_dead_error_fails_only_dead_replica_requests(orchestrator_factory) -> None:
|
||||
"""Multi-replica stage: one replica dying must fail only the requests bound
|
||||
to it. Requests bound to a surviving replica keep running, and the stage
|
||||
(still having a live replica) is not torn down (per-replica fault isolation,
|
||||
#4285).
|
||||
"""
|
||||
r0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
|
||||
r1 = FakeStageClient(stage_type="llm", final_output=True)
|
||||
stage_pools = _build_stage_pools([[r0, r1]])
|
||||
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
|
||||
pool = orchestrator_fixture.orchestrator.stage_pools[0]
|
||||
|
||||
try:
|
||||
# req-0 → replica 0 (round-robin starts at 0)
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-0",
|
||||
prompt=SimpleNamespace(request_id="req-0", prompt_token_ids=[1, 2]),
|
||||
original_prompt={"prompt": "a"},
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
await _wait_for(lambda: len(r0.add_request_calls) == 1)
|
||||
|
||||
# req-1 → replica 1
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-1",
|
||||
prompt=SimpleNamespace(request_id="req-1", prompt_token_ids=[3, 4]),
|
||||
original_prompt={"prompt": "b"},
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
await _wait_for(lambda: len(r1.add_request_calls) == 1)
|
||||
assert pool.get_bound_replica_id("req-0") == 0
|
||||
assert pool.get_bound_replica_id("req-1") == 1
|
||||
|
||||
# Kill replica 0 on its next poll.
|
||||
r0.die = True
|
||||
|
||||
msg = await _get_any_output_message(orchestrator_fixture)
|
||||
assert isinstance(msg, ErrorMessage)
|
||||
assert msg.fatal is True
|
||||
assert msg.request_id == "req-0"
|
||||
|
||||
# Replica 0 evicted, replica 1 still serving; req-1 untouched.
|
||||
await _wait_for(lambda: pool.live_replica_ids() == [1])
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
assert "req-0" not in orchestrator_fixture.orchestrator.request_states
|
||||
assert "req-1" in orchestrator_fixture.orchestrator.request_states
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_to_dead_downstream_stage_fails_request_not_server(orchestrator_factory) -> None:
|
||||
"""A request mid-pipeline whose downstream stage has lost all replicas must
|
||||
fail that request, not tear down the orchestrator. Forwarding to a stage with
|
||||
no live replica is handled gracefully (per-replica fault isolation, #4285).
|
||||
"""
|
||||
stage0 = FakeStageClient(stage_type="llm", final_output=False, next_inputs=[{"prompt_token_ids": [7, 8]}])
|
||||
stage1 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
|
||||
proc0 = FakeOutputProcessor(request_outputs=[_build_request_output("req-x", token_ids=[3], finished=True)])
|
||||
proc1 = FakeOutputProcessor()
|
||||
stage_pools = _build_stage_pools([[stage0], [stage1]], output_processors=[proc0, proc1])
|
||||
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
|
||||
pool1 = orchestrator_fixture.orchestrator.stage_pools[1]
|
||||
|
||||
try:
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-x",
|
||||
prompt=SimpleNamespace(request_id="req-x", prompt_token_ids=[1, 2]),
|
||||
original_prompt={"prompt": "hello"},
|
||||
sampling_params_list=[_sampling_params(), _sampling_params()],
|
||||
final_stage_id=1,
|
||||
)
|
||||
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
|
||||
|
||||
# Kill stage-1's only replica before stage-0 output is forwarded.
|
||||
stage1.die = True
|
||||
await _wait_for(lambda: pool1.live_replica_ids() == [])
|
||||
|
||||
# Completing stage 0 triggers a forward to the now-empty stage 1.
|
||||
stage0.push_engine_core_outputs(_engine_core_outputs("s0-raw", 1.0))
|
||||
|
||||
# Completing stage 0 also emits its normal outputs; skip past them to
|
||||
# the downstream-forward failure.
|
||||
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-x")
|
||||
assert error_msg.fatal is True
|
||||
assert error_msg.stage_id == 1
|
||||
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
assert "req-x" not in orchestrator_fixture.orchestrator.request_states
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_request_to_dead_stage_fails_request_not_server(orchestrator_factory) -> None:
|
||||
"""A new request entering a stage that already lost all replicas fails that
|
||||
request instead of raising out of the request handler and tearing the server
|
||||
down. The guard runs before request state is registered, so nothing leaks.
|
||||
"""
|
||||
r0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
|
||||
stage_pools = _build_stage_pools([[r0]])
|
||||
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
|
||||
pool = orchestrator_fixture.orchestrator.stage_pools[0]
|
||||
|
||||
try:
|
||||
# Send a first request so the replica is polled and dies → evicted.
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-0",
|
||||
prompt=SimpleNamespace(request_id="req-0", prompt_token_ids=[1, 2]),
|
||||
original_prompt={"prompt": "a"},
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
r0.die = True
|
||||
await _wait_for(lambda: pool.live_replica_ids() == [])
|
||||
|
||||
# A new request now enters a stage with no live replica.
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-after",
|
||||
prompt=SimpleNamespace(request_id="req-after", prompt_token_ids=[3, 4]),
|
||||
original_prompt={"prompt": "b"},
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
|
||||
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-after")
|
||||
assert error_msg.fatal is True
|
||||
assert error_msg.stage_id == 0
|
||||
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
# Guard runs before registration, so the failed request never enters state.
|
||||
assert "req-after" not in orchestrator_fixture.orchestrator.request_states
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diffusion_replica_death_on_poll_keeps_server(orchestrator_factory) -> None:
|
||||
"""A diffusion stage replica raising EngineDeadError during poll must be
|
||||
evicted while the orchestrator keeps serving. The per-replica catch has to
|
||||
cover the diffusion polling path, not just the LLM one (#4285).
|
||||
"""
|
||||
stage0 = _DieOnDemandDiffusionStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
|
||||
stage_pools = _build_stage_pools([[stage0]])
|
||||
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
|
||||
pool = orchestrator_fixture.orchestrator.stage_pools[0]
|
||||
|
||||
try:
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-dit",
|
||||
prompt={"prompt": "a cat"},
|
||||
original_prompt={"prompt": "a cat"},
|
||||
sampling_params_list=[OmniDiffusionSamplingParams()],
|
||||
final_stage_id=0,
|
||||
)
|
||||
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
|
||||
|
||||
stage0.die = True
|
||||
|
||||
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-dit")
|
||||
assert error_msg.fatal is True
|
||||
assert error_msg.stage_id == 0
|
||||
|
||||
await _wait_for(lambda: pool.live_replica_ids() == [])
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
assert "req-dit" not in orchestrator_fixture.orchestrator.request_states
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_chunk_prewarm_failure_reports_failing_downstream_stage(orchestrator_factory) -> None:
|
||||
"""When an async-chunk prewarm dispatch to a downstream stage fails, the
|
||||
failure must be attributed to that stage, not stage 0 (the prewarm's own
|
||||
dispatch stage). Regression for the guard using stage_id=0.
|
||||
"""
|
||||
stage0 = FakeStageClient(stage_type="llm", final_output=False)
|
||||
stage1 = FakeStageClient(stage_type="diffusion", final_output=False, final_output_type="image")
|
||||
stage2 = _UnavailableDiffusionStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
|
||||
stage_pools = _build_stage_pools([[stage0], [stage1], [stage2]])
|
||||
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools, async_chunk=True)
|
||||
|
||||
try:
|
||||
await _enqueue_add_request(
|
||||
orchestrator_fixture,
|
||||
request_id="req-x",
|
||||
prompt=SimpleNamespace(request_id="req-x", prompt_token_ids=[1, 2, 3]),
|
||||
original_prompt={"prompt": "a cat"},
|
||||
sampling_params_list=[_sampling_params(), OmniDiffusionSamplingParams(), OmniDiffusionSamplingParams()],
|
||||
final_stage_id=2,
|
||||
)
|
||||
|
||||
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-x")
|
||||
assert error_msg.fatal is True
|
||||
assert error_msg.stage_id == 2
|
||||
assert "Stage-2" in error_msg.error
|
||||
|
||||
# Stage 1 prewarm ran before the stage-2 failure; server stays up.
|
||||
assert len(stage1.add_request_calls) == 1
|
||||
assert orchestrator_fixture.thread.is_alive()
|
||||
finally:
|
||||
if orchestrator_fixture.thread.is_alive():
|
||||
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
|
||||
orchestrator_fixture.thread.join(timeout=5)
|
||||
|
||||
|
||||
# ───────── Direct unit tests for the fault-isolation helpers ─────────
|
||||
|
||||
|
||||
def _build_bare_orchestrator(stage_pools) -> tuple[Orchestrator, tuple[janus.Queue, ...]]:
|
||||
"""Construct an Orchestrator on the current event loop without running it.
|
||||
|
||||
Lets tests await the fault-isolation helpers directly instead of driving
|
||||
them through the orchestration loop.
|
||||
"""
|
||||
queues = (janus.Queue(), janus.Queue(), janus.Queue())
|
||||
orchestrator = Orchestrator(
|
||||
request_async_queue=queues[0].async_q,
|
||||
output_async_queue=queues[1].async_q,
|
||||
rpc_async_queue=queues[2].async_q,
|
||||
stage_pools=stage_pools,
|
||||
)
|
||||
return orchestrator, queues
|
||||
|
||||
|
||||
def _register_request(orchestrator: Orchestrator, request_id: str, *, submitted_stage: int | None = 0) -> None:
|
||||
state = OrchestratorRequestState(request_id=request_id)
|
||||
if submitted_stage is not None:
|
||||
state.stage_submit_ts[submitted_stage] = time.time()
|
||||
orchestrator.request_states[request_id] = state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_dead_replica_fails_only_bound_requests() -> None:
|
||||
"""Direct helper test: with a surviving replica, only requests bound to the
|
||||
dead replica fail; the survivor's requests and bindings are untouched.
|
||||
"""
|
||||
r0 = FakeStageClient(stage_type="llm", final_output=True)
|
||||
r1 = FakeStageClient(stage_type="llm", final_output=True)
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0, r1]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-0")
|
||||
_register_request(orchestrator, "req-1")
|
||||
assert pool.select_replica_id("req-0") == 0
|
||||
assert pool.select_replica_id("req-1") == 1
|
||||
|
||||
await orchestrator._handle_dead_replica(0, 0, EngineDeadError("replica-0 dead"))
|
||||
|
||||
assert pool.live_replica_ids() == [1]
|
||||
assert r0.shutdown_calls == 1
|
||||
msg = queues[1].async_q.get_nowait()
|
||||
assert isinstance(msg, ErrorMessage)
|
||||
assert msg.fatal is True
|
||||
assert msg.request_id == "req-0"
|
||||
assert msg.stage_id == 0
|
||||
assert "replica-0 dead" in msg.error
|
||||
assert queues[1].async_q.empty()
|
||||
assert "req-0" not in orchestrator.request_states
|
||||
assert "req-1" in orchestrator.request_states
|
||||
assert pool.get_bound_replica_id("req-0") is None
|
||||
assert pool.get_bound_replica_id("req-1") == 1
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_dead_replica_last_replica_fails_all_stage_requests() -> None:
|
||||
"""Direct helper test: when the dead replica was the stage's last one, every
|
||||
request routed through the stage fails — bound or not — while requests that
|
||||
never entered the stage survive.
|
||||
"""
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-bound")
|
||||
_register_request(orchestrator, "req-unbound")
|
||||
_register_request(orchestrator, "req-elsewhere", submitted_stage=None)
|
||||
assert pool.select_replica_id("req-bound") == 0
|
||||
|
||||
await orchestrator._handle_dead_replica(0, 0, EngineDeadError("last replica dead"))
|
||||
|
||||
assert pool.live_replica_ids() == []
|
||||
failed = {queues[1].async_q.get_nowait().request_id for _ in range(2)}
|
||||
assert failed == {"req-bound", "req-unbound"}
|
||||
assert queues[1].async_q.empty()
|
||||
assert set(orchestrator.request_states) == {"req-elsewhere"}
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_request_dead_stage_cleans_registered_request() -> None:
|
||||
"""Direct helper test: a registered request failed against a dead stage gets
|
||||
a fatal ErrorMessage, its state and bindings are released, and its
|
||||
in-flight work on live replicas is aborted.
|
||||
"""
|
||||
r0 = FakeStageClient(stage_type="llm")
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-a")
|
||||
assert pool.select_replica_id("req-a") == 0
|
||||
|
||||
await orchestrator._fail_request_dead_stage("req-a", 0)
|
||||
|
||||
msg = queues[1].async_q.get_nowait()
|
||||
assert isinstance(msg, ErrorMessage)
|
||||
assert msg.error == "Stage-0 has no live replica"
|
||||
assert msg.fatal is True
|
||||
assert msg.request_id == "req-a"
|
||||
assert msg.stage_id == 0
|
||||
assert "req-a" not in orchestrator.request_states
|
||||
assert pool.get_bound_replica_id("req-a") is None
|
||||
# abort=True: the failed request's work on the still-live replica is
|
||||
# stopped, not left to run to completion.
|
||||
assert r0.abort_calls == [["req-a"]]
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_update_racing_eviction_fails_request_not_server() -> None:
|
||||
"""A streaming_update can find its request state still present while the
|
||||
stage has already lost all replicas (_handle_dead_replica awaits between
|
||||
eviction and cleanup). Submitting then raises from replica selection; the
|
||||
handler must fail that request instead of letting the raise escape
|
||||
_request_handler and shut the server down (#4285).
|
||||
"""
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-s")
|
||||
orchestrator.request_states["req-s"].sampling_params_list = [_sampling_params()]
|
||||
assert pool.select_replica_id("req-s") == 0
|
||||
pool.evict_replica(0)
|
||||
|
||||
msg = StageSubmissionMessage(
|
||||
type="streaming_update",
|
||||
request_id="req-s",
|
||||
prompt=SimpleNamespace(request_id="req-s", prompt_token_ids=[1]),
|
||||
original_prompt={"prompt": "hi"},
|
||||
output_prompt_text=None,
|
||||
sampling_params_list=[],
|
||||
final_stage_id=0,
|
||||
preprocess_ms=0.0,
|
||||
request_timestamp=time.time(),
|
||||
enqueue_ts=time.time(),
|
||||
)
|
||||
await orchestrator._handle_streaming_update(msg)
|
||||
|
||||
err = queues[1].async_q.get_nowait()
|
||||
assert isinstance(err, ErrorMessage)
|
||||
assert err.fatal is True
|
||||
assert err.request_id == "req-s"
|
||||
assert "req-s" not in orchestrator.request_states
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_update_unrelated_error_is_not_masked() -> None:
|
||||
"""The dispatch guard converts only StageUnavailableError/EngineDeadError;
|
||||
a genuine bug surfacing as a plain RuntimeError must propagate (fail
|
||||
fast), not be misreported as a dead-stage request failure.
|
||||
"""
|
||||
|
||||
class _BuggyClient(FakeStageClient):
|
||||
async def add_request_async(self, *args, **kwargs):
|
||||
raise RuntimeError("boom: unrelated bug")
|
||||
|
||||
orchestrator, queues = _build_bare_orchestrator(
|
||||
_build_stage_pools([[_BuggyClient(stage_type="llm", final_output=True)]])
|
||||
)
|
||||
try:
|
||||
_register_request(orchestrator, "req-b")
|
||||
orchestrator.request_states["req-b"].sampling_params_list = [_sampling_params()]
|
||||
|
||||
msg = StageSubmissionMessage(
|
||||
type="streaming_update",
|
||||
request_id="req-b",
|
||||
prompt=SimpleNamespace(request_id="req-b", prompt_token_ids=[1]),
|
||||
original_prompt={"prompt": "hi"},
|
||||
output_prompt_text=None,
|
||||
sampling_params_list=[],
|
||||
final_stage_id=0,
|
||||
preprocess_ms=0.0,
|
||||
request_timestamp=time.time(),
|
||||
enqueue_ts=time.time(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="unrelated bug"):
|
||||
await orchestrator._handle_streaming_update(msg)
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_companion_racing_eviction_fails_parent_not_server() -> None:
|
||||
"""A CFG companion submission hitting a fully dead stage must fail the
|
||||
parent request (whose cleanup also releases the companion), not raise out
|
||||
of _request_handler (#4285).
|
||||
"""
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-p")
|
||||
orchestrator.request_states["req-p"].sampling_params_list = [_sampling_params()]
|
||||
pool.evict_replica(0)
|
||||
|
||||
msg = AddCompanionRequestMessage(
|
||||
companion_id="req-p-cfg",
|
||||
parent_id="req-p",
|
||||
role="negative",
|
||||
prompt=SimpleNamespace(request_id="req-p-cfg", prompt_token_ids=[2]),
|
||||
companion_prompt_text=None,
|
||||
sampling_params_list=[_sampling_params()],
|
||||
)
|
||||
await orchestrator._handle_add_companion(msg)
|
||||
|
||||
err = queues[1].async_q.get_nowait()
|
||||
assert isinstance(err, ErrorMessage)
|
||||
assert err.fatal is True
|
||||
assert err.request_id == "req-p"
|
||||
assert "req-p" not in orchestrator.request_states
|
||||
assert "req-p-cfg" not in orchestrator.request_states
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_request_dead_stage_is_noop_safe_for_unregistered_request() -> None:
|
||||
"""Direct helper test: the stage-0 dispatch guard calls the helper before the
|
||||
request is registered; the cleanup must be a safe no-op and still emit the
|
||||
fatal ErrorMessage.
|
||||
"""
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
|
||||
try:
|
||||
await orchestrator._fail_request_dead_stage("req-unknown", 0)
|
||||
|
||||
msg = queues[1].async_q.get_nowait()
|
||||
assert isinstance(msg, ErrorMessage)
|
||||
assert msg.error == "Stage-0 has no live replica"
|
||||
assert msg.fatal is True
|
||||
assert msg.request_id == "req-unknown"
|
||||
assert orchestrator.request_states == {}
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_death_evicts_so_subsequent_requests_reroute_to_survivor() -> None:
|
||||
"""A replica dying mid-submit (EngineDeadError from dispatch, before the poll
|
||||
loop evicts it) is evicted by the guard so subsequent requests reroute to the
|
||||
survivor instead of round-robining back onto the dead slot until the poll
|
||||
catches up. The survivor and its request are untouched (#4285).
|
||||
"""
|
||||
r0 = FakeStageClient(stage_type="llm")
|
||||
r1 = FakeStageClient(stage_type="llm")
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0, r1]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-dead")
|
||||
_register_request(orchestrator, "req-live")
|
||||
assert pool.select_replica_id("req-dead") == 0
|
||||
assert pool.select_replica_id("req-live") == 1
|
||||
|
||||
async def _die() -> None:
|
||||
raise EngineDeadError("stage-0 replica-0 died on submit")
|
||||
|
||||
failed = await orchestrator._dispatch_or_fail_request(
|
||||
_die, req_id="req-dead", stage_id=0, operation="add_request"
|
||||
)
|
||||
assert failed is False
|
||||
|
||||
# The dead replica is evicted; only the request on it fails, the survivor
|
||||
# and its request are untouched.
|
||||
assert pool.live_replica_ids() == [1]
|
||||
assert r0.shutdown_calls == 1
|
||||
assert r1.shutdown_calls == 0
|
||||
msg = queues[1].async_q.get_nowait()
|
||||
assert isinstance(msg, ErrorMessage) and msg.fatal is True and msg.request_id == "req-dead"
|
||||
assert queues[1].async_q.empty()
|
||||
assert "req-dead" not in orchestrator.request_states
|
||||
assert "req-live" in orchestrator.request_states and pool.get_bound_replica_id("req-live") == 1
|
||||
|
||||
# The point of eviction: a burst of subsequent requests all route to the
|
||||
# survivor and never land back on the evicted replica.
|
||||
for rid in ("next-1", "next-2", "next-3"):
|
||||
assert pool.select_replica_id(rid) == 1
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_death_evicts_so_subsequent_requests_fast_fail() -> None:
|
||||
"""When the replica dying on submit was the stage's only one, the guard evicts
|
||||
it (leaving the stage empty). A subsequent request then fast-fails at routing —
|
||||
the ``_handle_add_request`` entry guard turns ``StageUnavailableError`` into a
|
||||
fatal 5xx immediately, rather than dispatching onto the dead replica (#4285).
|
||||
"""
|
||||
r0 = FakeStageClient(stage_type="llm")
|
||||
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0]]))
|
||||
try:
|
||||
pool = orchestrator.stage_pools[0]
|
||||
_register_request(orchestrator, "req-a")
|
||||
assert pool.select_replica_id("req-a") == 0
|
||||
|
||||
async def _die() -> None:
|
||||
raise EngineDeadError("stage-0 replica-0 died on submit")
|
||||
|
||||
failed = await orchestrator._dispatch_or_fail_request(_die, req_id="req-a", stage_id=0, operation="add_request")
|
||||
assert failed is False
|
||||
|
||||
assert pool.live_replica_ids() == []
|
||||
assert r0.shutdown_calls == 1
|
||||
msg = queues[1].async_q.get_nowait()
|
||||
assert isinstance(msg, ErrorMessage) and msg.request_id == "req-a"
|
||||
assert queues[1].async_q.empty()
|
||||
assert "req-a" not in orchestrator.request_states
|
||||
|
||||
# A subsequent request cannot be routed at all: the stage has no live
|
||||
# replica, so selection raises fast instead of dispatching onto the dead
|
||||
# slot. _handle_add_request converts this to a fatal 5xx at its entry guard.
|
||||
with pytest.raises(StageUnavailableError):
|
||||
pool.select_replica_id("req-next")
|
||||
finally:
|
||||
for q in queues:
|
||||
q.close()
|
||||
|
||||
|
||||
# ───────── Diffusion stage error output routing ─────────
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ class FakeAsyncOmniEngine:
|
||||
]
|
||||
self.supported_tasks = ("generate",)
|
||||
self.stage_clients = [SimpleNamespace(is_comprehension=False) for _ in range(self.num_stages)]
|
||||
# One replica per stage, sharing the stage_clients objects so a test that
|
||||
# marks stage_clients[i] dead is reflected in the pool's liveness.
|
||||
self.stage_pools = [_FakeStagePool([client], stage_id=i) for i, client in enumerate(self.stage_clients)]
|
||||
self.stage_vllm_configs = [None for _ in range(self.num_stages)]
|
||||
self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)]
|
||||
self.input_processor = None
|
||||
@@ -969,11 +972,17 @@ async def test_async_omni_propagates_non_400_client_error_status(
|
||||
assert str(exc_info.value) == error_text
|
||||
|
||||
|
||||
class _FakeStagePool:
|
||||
def __init__(self, clients: list, stage_id: int = 0):
|
||||
self.clients = clients
|
||||
self.stage_id = stage_id
|
||||
|
||||
|
||||
def test_async_omni_errored_property_alive():
|
||||
omni = object.__new__(AsyncOmni)
|
||||
omni.engine = SimpleNamespace(
|
||||
is_alive=lambda: True,
|
||||
stage_clients=[SimpleNamespace(is_comprehension=False)],
|
||||
stage_pools=[_FakeStagePool(clients=[SimpleNamespace()])],
|
||||
)
|
||||
|
||||
assert omni.errored is False
|
||||
@@ -983,21 +992,47 @@ def test_async_omni_errored_property_dead_engine():
|
||||
omni = object.__new__(AsyncOmni)
|
||||
omni.engine = SimpleNamespace(
|
||||
is_alive=lambda: False,
|
||||
stage_clients=[SimpleNamespace(is_comprehension=False)],
|
||||
stage_pools=[_FakeStagePool(clients=[SimpleNamespace()])],
|
||||
)
|
||||
|
||||
assert omni.errored is True
|
||||
|
||||
|
||||
def test_async_omni_errored_property_dead_stage():
|
||||
def test_async_omni_errored_false_when_stage_dead():
|
||||
# errored is process-fatal only: a dead stage must not trip the serving
|
||||
# precheck, or requests that never touch that stage would be rejected too.
|
||||
# Stage liveness surfaces via check_health / per-request dispatch failures.
|
||||
omni = object.__new__(AsyncOmni)
|
||||
dead_stage = SimpleNamespace(is_comprehension=False, _engine_dead=True)
|
||||
omni.engine = SimpleNamespace(
|
||||
is_alive=lambda: True,
|
||||
stage_clients=[dead_stage],
|
||||
stage_pools=[_FakeStagePool(clients=[SimpleNamespace(_engine_dead=True)])],
|
||||
)
|
||||
|
||||
assert omni.errored is True
|
||||
assert omni.errored is False
|
||||
|
||||
|
||||
def test_async_omni_errored_false_when_a_replica_survives():
|
||||
# Per-replica fault isolation (#4285): one replica dies, another lives →
|
||||
# the stage is still serving, so the engine is not errored.
|
||||
omni = object.__new__(AsyncOmni)
|
||||
omni.engine = SimpleNamespace(
|
||||
is_alive=lambda: True,
|
||||
stage_pools=[_FakeStagePool(clients=[SimpleNamespace(_engine_dead=True), SimpleNamespace()])],
|
||||
)
|
||||
|
||||
assert omni.errored is False
|
||||
|
||||
|
||||
def test_async_omni_errored_false_when_evicted_replica_leaves_stage_empty():
|
||||
# Even a fully evicted stage (all slots None/dead) is not process-fatal;
|
||||
# only orchestrator death makes errored True.
|
||||
omni = object.__new__(AsyncOmni)
|
||||
omni.engine = SimpleNamespace(
|
||||
is_alive=lambda: True,
|
||||
stage_pools=[_FakeStagePool(clients=[None, SimpleNamespace(_engine_dead=True)])],
|
||||
)
|
||||
|
||||
assert omni.errored is False
|
||||
|
||||
|
||||
def _enqueue_stage_error(
|
||||
@@ -1067,20 +1102,22 @@ async def test_async_omni_propagates_engine_generate_error(monkeypatch: pytest.M
|
||||
|
||||
def test_check_health_passes_when_all_healthy():
|
||||
base = _make_base()
|
||||
healthy_stage = MagicMock()
|
||||
healthy_stage.check_health = MagicMock()
|
||||
base.engine.is_alive.return_value = True
|
||||
base.engine.stage_clients = [healthy_stage]
|
||||
healthy_stage = SimpleNamespace(check_health=lambda: None)
|
||||
base.engine.stage_pools = [_FakeStagePool([healthy_stage])]
|
||||
base.check_health() # should not raise
|
||||
|
||||
|
||||
def test_check_health_raises_when_stage_dead():
|
||||
base = _make_base()
|
||||
dead_stage = MagicMock()
|
||||
dead_stage.check_health = MagicMock(side_effect=EngineDeadError("Stage-1 dead"))
|
||||
base.engine.is_alive.return_value = True
|
||||
base.engine.stage_clients = [dead_stage]
|
||||
with pytest.raises(EngineDeadError, match="Stage-1 dead"):
|
||||
|
||||
def _raise_dead() -> None:
|
||||
raise EngineDeadError("Stage-1 engine core is dead")
|
||||
|
||||
dead_stage = SimpleNamespace(check_health=_raise_dead)
|
||||
base.engine.stage_pools = [_FakeStagePool([dead_stage], stage_id=1)]
|
||||
with pytest.raises(EngineDeadError, match="Stage-1"):
|
||||
base.check_health()
|
||||
|
||||
|
||||
@@ -1098,22 +1135,25 @@ def test_check_health_raises_when_orchestrator_dead():
|
||||
def test_omni_base_errored_false_when_alive():
|
||||
base = _make_base()
|
||||
base.engine.is_alive.return_value = True
|
||||
base.engine.stage_clients = [SimpleNamespace()]
|
||||
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace()])]
|
||||
assert base.errored is False
|
||||
|
||||
|
||||
def test_omni_base_is_running_false_when_stage_engine_dead():
|
||||
def test_omni_base_is_running_true_when_stage_engine_dead():
|
||||
# is_running tracks the orchestrator only; a dead stage must not make
|
||||
# `errored and not is_running` true, which would let terminate_if_errored
|
||||
# kill the server on a partial failure.
|
||||
base = _make_base()
|
||||
base.engine.is_alive.return_value = True
|
||||
base.engine.stage_clients = [SimpleNamespace(_engine_dead=True)]
|
||||
assert base.is_running is False
|
||||
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace(_engine_dead=True)])]
|
||||
assert base.is_running is True
|
||||
|
||||
|
||||
def test_omni_base_is_running_false_when_stage_resources_engine_dead():
|
||||
def test_omni_base_is_running_true_when_stage_resources_engine_dead():
|
||||
base = _make_base()
|
||||
base.engine.is_alive.return_value = True
|
||||
base.engine.stage_clients = [SimpleNamespace(resources=SimpleNamespace(engine_dead=True))]
|
||||
assert base.is_running is False
|
||||
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace(resources=SimpleNamespace(engine_dead=True))])]
|
||||
assert base.is_running is True
|
||||
|
||||
|
||||
def test_omni_base_errored_true_when_orchestrator_dead():
|
||||
@@ -1123,20 +1163,22 @@ def test_omni_base_errored_true_when_orchestrator_dead():
|
||||
assert base.errored is True
|
||||
|
||||
|
||||
def test_omni_base_errored_true_when_stage_engine_dead():
|
||||
def test_omni_base_errored_false_when_stage_engine_dead():
|
||||
# Process-fatal only: the serving precheck reads errored before routing,
|
||||
# so a dead stage here would reject requests that never touch it.
|
||||
base = _make_base()
|
||||
base.engine.is_alive.return_value = True
|
||||
dead_stage = SimpleNamespace(_engine_dead=True)
|
||||
base.engine.stage_clients = [dead_stage]
|
||||
assert base.errored is True
|
||||
base.engine.stage_pools = [_FakeStagePool([dead_stage])]
|
||||
assert base.errored is False
|
||||
|
||||
|
||||
def test_omni_base_errored_true_when_stage_resources_engine_dead():
|
||||
def test_omni_base_errored_false_when_stage_resources_engine_dead():
|
||||
base = _make_base()
|
||||
base.engine.is_alive.return_value = True
|
||||
dead_stage = SimpleNamespace(resources=SimpleNamespace(engine_dead=True))
|
||||
base.engine.stage_clients = [dead_stage]
|
||||
assert base.errored is True
|
||||
base.engine.stage_pools = [_FakeStagePool([dead_stage])]
|
||||
assert base.errored is False
|
||||
|
||||
|
||||
# ───────── Omni (sync) EngineDeadError / EngineGenerateError ─────────
|
||||
@@ -1202,13 +1244,14 @@ def test_omni_errored_property_dead_engine(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_omni_errored_property_dead_stage(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Omni.errored returns True when a stage client is marked dead."""
|
||||
"""Omni.errored stays False when only a stage client dies (process-fatal
|
||||
semantics): the request-level failure and check_health carry the signal."""
|
||||
engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
|
||||
_patch_engine(monkeypatch, engine)
|
||||
|
||||
app = Omni("dummy-model")
|
||||
try:
|
||||
engine.stage_clients[0]._engine_dead = True
|
||||
assert app.errored is True
|
||||
assert app.errored is False
|
||||
finally:
|
||||
app.shutdown()
|
||||
@@ -83,11 +83,16 @@ class SyntheticAcousticTransformer(nn.Module):
|
||||
# time_embedding: (B, 1) -> (B, dim)
|
||||
self.time_embedding = nn.Linear(1, HIDDEN_DIM, bias=False)
|
||||
|
||||
# _predict_velocity: takes x_t (B, C), llm_output (B, D), t_emb (B, D) -> (B, C)
|
||||
# Identities: callers pass pre-projected inputs; the synthetic transformer folds
|
||||
# everything into _velocity_proj.
|
||||
self.llm_projection = nn.Identity()
|
||||
self.time_projection = nn.Identity()
|
||||
|
||||
# _predict_velocity: takes x_t (B, C), llm_proj (B, D), t_proj (B, D) -> (B, C)
|
||||
self._velocity_proj = nn.Linear(N_ACOUSTIC_CODEBOOK + HIDDEN_DIM + HIDDEN_DIM, N_ACOUSTIC_CODEBOOK, bias=False)
|
||||
|
||||
def _predict_velocity(self, x_t: torch.Tensor, llm_output: torch.Tensor, t_emb: torch.Tensor) -> torch.Tensor:
|
||||
combined = torch.cat([x_t, llm_output, t_emb], dim=-1)
|
||||
def _predict_velocity(self, x_t: torch.Tensor, llm_proj: torch.Tensor, t_proj: torch.Tensor) -> torch.Tensor:
|
||||
combined = torch.cat([x_t, llm_proj, t_proj], dim=-1)
|
||||
return self._velocity_proj(combined)
|
||||
|
||||
|
||||
@@ -129,14 +134,18 @@ class SyntheticModel(nn.Module):
|
||||
hidden_zero = torch.zeros_like(hidden_states)
|
||||
timesteps = torch.linspace(0, 1, 16, device=hidden_states.device, dtype=hidden_states.dtype)
|
||||
|
||||
# llm_projection is constant across steps so hoist it (mirrors decode_one_frame).
|
||||
llm_batched = torch.cat([hidden_states, hidden_zero], dim=0)
|
||||
llm_proj_batched = at.llm_projection(llm_batched)
|
||||
|
||||
for i in range(len(timesteps) - 1):
|
||||
t = timesteps[i]
|
||||
dt = timesteps[i + 1] - timesteps[i]
|
||||
t_emb = at.time_embedding(t.view(-1, 1).repeat(B, 1)).to(hidden_states.dtype)
|
||||
t_proj = at.time_projection(t_emb)
|
||||
x_batched = torch.cat([x, x], dim=0)
|
||||
llm_batched = torch.cat([hidden_states, hidden_zero], dim=0)
|
||||
t_emb_batched = t_emb.repeat(2, 1)
|
||||
v_all = at._predict_velocity(x_t=x_batched, llm_output=llm_batched, t_emb=t_emb_batched)
|
||||
t_proj_batched = t_proj.repeat(2, 1)
|
||||
v_all = at._predict_velocity(x_t=x_batched, llm_proj=llm_proj_batched, t_proj=t_proj_batched)
|
||||
v_t, uncond_v_t = v_all[:B], v_all[B:]
|
||||
cfg_alpha = 1.2
|
||||
v_t = cfg_alpha * v_t + (1 - cfg_alpha) * uncond_v_t
|
||||
|
||||
+289
-182
@@ -50,7 +50,7 @@ from vllm_omni.engine.messages import (
|
||||
)
|
||||
from vllm_omni.engine.orchestrator_monitor import create_orch_monitor, replica_key
|
||||
from vllm_omni.engine.serialization import serialize_additional_information
|
||||
from vllm_omni.engine.stage_pool import StagePool
|
||||
from vllm_omni.engine.stage_pool import StagePool, StageUnavailableError
|
||||
from vllm_omni.metrics.prometheus import OmniRequestCounter
|
||||
from vllm_omni.metrics.stat_logger import OmniPrometheusStatLogger
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
@@ -417,8 +417,6 @@ class Orchestrator:
|
||||
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._stages_shutdown = False
|
||||
self._fatal_error: str | None = None
|
||||
self._fatal_error_stage_id: int | None = None
|
||||
|
||||
# Distributed membership (optional, injected by DistStageRuntime)
|
||||
self._membership = membership_controller
|
||||
@@ -532,19 +530,17 @@ class Orchestrator:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except EngineDeadError as e:
|
||||
# EngineDeadError from _orchestration_loop means the diffusion
|
||||
# engine died. All pending requests were already notified and
|
||||
# _shutdown_event was already set by the loop's handler.
|
||||
# During teardown this is expected; the finally block handles
|
||||
# proper cleanup. Do not re-raise.
|
||||
logger.info("[Orchestrator] Engine dead during shutdown: %s", e)
|
||||
if self._fatal_error is None:
|
||||
self._fatal_error = str(e) or "Stage engine died"
|
||||
# The orchestration loop isolates per-replica EngineDeadError
|
||||
# (evict + continue), so this only fires if one escapes outside the
|
||||
# poll handler (e.g. from output post-processing). Treat it as a
|
||||
# teardown signal and let the finally block clean up; do not re-raise.
|
||||
logger.info("[Orchestrator] Engine dead, shutting down: %s", e)
|
||||
# Unblock any collective_rpc callers waiting on rpc_async_queue
|
||||
# instead of leaving them to hit their own timeout.
|
||||
await self.rpc_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=self._fatal_error or str(e),
|
||||
error=str(e) or "Stage engine died",
|
||||
fatal=True,
|
||||
stage_id=self._fatal_error_stage_id,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
@@ -562,9 +558,6 @@ class Orchestrator:
|
||||
if self.duplex_control_plane is not None:
|
||||
await self.duplex_control_plane.shutdown()
|
||||
|
||||
if self._fatal_error is not None:
|
||||
await self._drain_pending_requests_on_fatal()
|
||||
|
||||
if self._membership is not None:
|
||||
await self._membership.drain_tasks(timeout=10.0)
|
||||
self._membership.shutdown()
|
||||
@@ -651,6 +644,13 @@ class Orchestrator:
|
||||
final_stage_id = msg.final_stage_id
|
||||
final_output_stage_ids = set(msg.final_output_stage_ids or [final_stage_id])
|
||||
|
||||
if not self.stage_pools[stage_id].live_replica_ids():
|
||||
# Stage 0 lost all replicas between the HTTP-layer errored check and
|
||||
# dispatch. Runs before request state / running counter registration,
|
||||
# so the helper's cleanup is a no-op here.
|
||||
await self._fail_request_dead_stage(request_id, stage_id)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"[Orchestrator] _handle_add_request: stage=%s req=%s "
|
||||
"prompt_type=%s original_prompt_type=%s final_stage=%s "
|
||||
@@ -682,12 +682,18 @@ class Orchestrator:
|
||||
preprocess_ms = msg.preprocess_ms
|
||||
if preprocess_ms > 0:
|
||||
req_state.pipeline_timings["preprocess_ms"] = preprocess_ms
|
||||
await self.stage_pools[stage_id].submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
prompt,
|
||||
prompt_text=msg.output_prompt_text,
|
||||
)
|
||||
if not await self._dispatch_or_fail_request(
|
||||
lambda: self.stage_pools[stage_id].submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
prompt,
|
||||
prompt_text=msg.output_prompt_text,
|
||||
),
|
||||
req_id=request_id,
|
||||
stage_id=stage_id,
|
||||
operation="add_request",
|
||||
):
|
||||
return
|
||||
|
||||
if self.async_chunk and stage_id == 0 and final_stage_id > 0:
|
||||
await self._prewarm_async_chunk_stages(request_id, prompt, req_state)
|
||||
@@ -717,12 +723,18 @@ class Orchestrator:
|
||||
|
||||
req_state.streaming.enabled = True
|
||||
req_state.stage_submit_ts[stage_id] = _time.time()
|
||||
await self.stage_pools[stage_id].submit_update(
|
||||
request_id,
|
||||
req_state,
|
||||
request,
|
||||
prompt_text=msg.output_prompt_text,
|
||||
)
|
||||
if not await self._dispatch_or_fail_request(
|
||||
lambda: self.stage_pools[stage_id].submit_update(
|
||||
request_id,
|
||||
req_state,
|
||||
request,
|
||||
prompt_text=msg.output_prompt_text,
|
||||
),
|
||||
req_id=request_id,
|
||||
stage_id=stage_id,
|
||||
operation="streaming_update",
|
||||
):
|
||||
return
|
||||
|
||||
if self.async_chunk and stage_id == 0 and final_stage_id > 0:
|
||||
await self._prewarm_async_chunk_stages(request_id, request, req_state)
|
||||
@@ -757,20 +769,29 @@ class Orchestrator:
|
||||
)
|
||||
self.request_states[companion_id] = companion_state
|
||||
companion_state.stage_submit_ts[0] = _time.time()
|
||||
companion_replica_id = await self.stage_pools[0].submit_initial(
|
||||
companion_id,
|
||||
companion_state,
|
||||
companion_prompt,
|
||||
prompt_text=msg.companion_prompt_text,
|
||||
affinity_request_id=parent_id,
|
||||
)
|
||||
# A dead-stage failure is attributed to the parent request; its cleanup
|
||||
# also releases this companion.
|
||||
if not await self._dispatch_or_fail_request(
|
||||
lambda: self.stage_pools[0].submit_initial(
|
||||
companion_id,
|
||||
companion_state,
|
||||
companion_prompt,
|
||||
prompt_text=msg.companion_prompt_text,
|
||||
affinity_request_id=parent_id,
|
||||
),
|
||||
req_id=parent_id,
|
||||
stage_id=0,
|
||||
operation=f"companion {companion_id}",
|
||||
dispatch_req_id=companion_id,
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[Orchestrator] CFG companion submitted: %s (role=%s, parent=%s, stage-0 replica-%s)",
|
||||
companion_id,
|
||||
role,
|
||||
parent_id,
|
||||
companion_replica_id,
|
||||
self.stage_pools[0].get_bound_replica_id(companion_id),
|
||||
)
|
||||
|
||||
async def _handle_abort(self, msg: AbortRequestMessage) -> None:
|
||||
@@ -903,16 +924,17 @@ class Orchestrator:
|
||||
if self._shutdown_event.is_set():
|
||||
return
|
||||
|
||||
if pool.stage_type == "diffusion":
|
||||
output = pool.poll_diffusion_output(replica_id)
|
||||
if output is None:
|
||||
continue
|
||||
# Shared catch so a dead replica on either poll path is
|
||||
# evicted rather than tearing down every stage (#4285).
|
||||
try:
|
||||
if pool.stage_type == "diffusion":
|
||||
diffusion_output = pool.poll_diffusion_output(replica_id)
|
||||
if diffusion_output is None:
|
||||
continue
|
||||
|
||||
pool.record_output_timestamps([output])
|
||||
await self._handle_processed_outputs(stage_id, replica_id, [output])
|
||||
idle = False
|
||||
else:
|
||||
try:
|
||||
pool.record_output_timestamps([diffusion_output])
|
||||
processed = [diffusion_output]
|
||||
else:
|
||||
raw_outputs = await pool.poll_llm_raw_output(replica_id, timeout_s=0.001)
|
||||
if raw_outputs is None:
|
||||
continue
|
||||
@@ -944,7 +966,7 @@ class Orchestrator:
|
||||
iteration_stats = (
|
||||
IterationStats() if (self._stat_logger is not None and raw_outputs.outputs) else None
|
||||
)
|
||||
raw_output = await pool.process_llm_raw_outputs(
|
||||
processed = await pool.process_llm_raw_outputs(
|
||||
replica_id,
|
||||
raw_outputs,
|
||||
iteration_stats=iteration_stats,
|
||||
@@ -957,80 +979,23 @@ class Orchestrator:
|
||||
iteration_stats,
|
||||
engine_idx=self._stage_replica_to_engine_idx[(stage_id, replica_id)],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except EngineDeadError as e:
|
||||
logger.error(
|
||||
"[Orchestrator] Stage-%s replica-%s is dead: %s",
|
||||
stage_id,
|
||||
replica_id,
|
||||
e,
|
||||
)
|
||||
affected_request_ids = pool.mark_replica_unavailable(replica_id)
|
||||
closed_sessions = (
|
||||
self.duplex_control_plane.close_sessions_for_request_ids(
|
||||
affected_request_ids,
|
||||
abort=False,
|
||||
)
|
||||
if self.duplex_control_plane is not None
|
||||
else {}
|
||||
)
|
||||
for session_id, stale_request_ids in closed_sessions.items():
|
||||
affected_request_ids.extend(stale_request_ids)
|
||||
logger.warning(
|
||||
"[Orchestrator] closed duplex session %s after stage-%s replica-%s died; "
|
||||
"stale_request_ids=%s",
|
||||
session_id,
|
||||
stage_id,
|
||||
replica_id,
|
||||
stale_request_ids,
|
||||
)
|
||||
affected_request_ids = list(dict.fromkeys(affected_request_ids))
|
||||
if pool.available_replica_ids():
|
||||
for req_id in affected_request_ids:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=str(e),
|
||||
fatal=False,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
await self._cleanup_request_ids(
|
||||
affected_request_ids,
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except EngineDeadError as e:
|
||||
await self._handle_dead_replica(stage_id, replica_id, e)
|
||||
continue
|
||||
except Exception:
|
||||
if self._shutdown_event.is_set():
|
||||
return
|
||||
logger.exception(
|
||||
"[Orchestrator] Stage-%s replica-%s processing failed",
|
||||
stage_id,
|
||||
replica_id,
|
||||
)
|
||||
raise
|
||||
|
||||
self._fatal_error = str(e)
|
||||
self._fatal_error_stage_id = stage_id
|
||||
for req_id in affected_request_ids:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=str(e),
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
await self._cleanup_request_ids(
|
||||
affected_request_ids,
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
self._shutdown_event.set()
|
||||
raise
|
||||
except Exception:
|
||||
if self._shutdown_event.is_set():
|
||||
return
|
||||
logger.exception(
|
||||
"[Orchestrator] Stage-%s replica-%s processing failed",
|
||||
stage_id,
|
||||
replica_id,
|
||||
)
|
||||
raise
|
||||
|
||||
await self._handle_processed_outputs(stage_id, replica_id, raw_output)
|
||||
idle = False
|
||||
await self._handle_processed_outputs(stage_id, replica_id, processed)
|
||||
idle = False
|
||||
|
||||
self._orch_monitor.note_loop(idle=idle)
|
||||
if idle:
|
||||
@@ -1091,6 +1056,143 @@ class Orchestrator:
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
|
||||
async def _handle_dead_replica(self, stage_id: int, replica_id: int, error: EngineDeadError) -> None:
|
||||
"""Evict a dead stage replica and fail the requests stranded on it (#4285).
|
||||
|
||||
A dead replica must not tear down the whole server: evict it and keep the
|
||||
orchestrator loop alive so remaining live replicas / stages keep serving.
|
||||
If the stage still has a live replica, fail only the requests bound to the
|
||||
dead replica; requests bound to a surviving replica (or not yet bound)
|
||||
keep running and can be (re)dispatched. If the stage has no live replica
|
||||
left, every request routed through it must fail since none can be served.
|
||||
"""
|
||||
pool = self.stage_pools[stage_id]
|
||||
logger.error(
|
||||
"[Orchestrator] Stage-%s replica-%s is dead; evicting it and failing its in-flight requests: %s",
|
||||
stage_id,
|
||||
replica_id,
|
||||
error,
|
||||
)
|
||||
pool.evict_replica(replica_id)
|
||||
stage_has_live = bool(pool.live_replica_ids())
|
||||
failed_ids: list[str] = []
|
||||
for req_id, req_state in list(self.request_states.items()):
|
||||
if stage_id not in req_state.stage_submit_ts:
|
||||
continue
|
||||
bound = pool.get_bound_replica_id(req_id)
|
||||
if bound == replica_id or not stage_has_live:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=str(error),
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
failed_ids.append(req_id)
|
||||
# Use the shared cleanup path so the running counter, PD/CFG state, and
|
||||
# bindings across every stage pool are released (not just this pool).
|
||||
# abort=True stops the failed requests' in-flight work on surviving
|
||||
# stages (abort skips dead/evicted bindings), matching the
|
||||
# distributed-membership eviction path.
|
||||
for req_id in failed_ids:
|
||||
await self._cleanup_request_ids(
|
||||
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
|
||||
abort=True,
|
||||
)
|
||||
|
||||
async def _fail_request_dead_stage(self, req_id: str, stage_id: int) -> None:
|
||||
"""Fail one request whose target stage has no live replica (#4285).
|
||||
|
||||
Used at dispatch sites so a fully dead stage fails just that request
|
||||
instead of raising out of the request/orchestration task and tearing
|
||||
down the server. The cleanup releases the running counter, PD/CFG state,
|
||||
and bindings across every stage pool; it is a no-op for a request that
|
||||
was never registered.
|
||||
"""
|
||||
logger.error(
|
||||
"[Orchestrator] req=%s: stage-%d has no live replica; failing the request",
|
||||
req_id,
|
||||
stage_id,
|
||||
)
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=f"Stage-{stage_id} has no live replica",
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
await self._cleanup_request_ids(
|
||||
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
|
||||
abort=True,
|
||||
)
|
||||
|
||||
async def _dispatch_or_fail_request(
|
||||
self,
|
||||
dispatch: Callable[[], Awaitable[Any]],
|
||||
*,
|
||||
req_id: str,
|
||||
stage_id: int,
|
||||
operation: str,
|
||||
dispatch_req_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Run a dispatch action, converting stage unavailability into a
|
||||
single-request failure.
|
||||
|
||||
``dispatch`` is a thunk rather than a bare coroutine so that replica
|
||||
selection and other argument setup run inside this guard: a synchronous
|
||||
StageUnavailableError raised while building the dispatch is caught here,
|
||||
not left to propagate out of the caller.
|
||||
|
||||
Replica eviction can interleave with any dispatch, surfacing as
|
||||
StageUnavailableError (no live replica / evicted slot) or
|
||||
EngineDeadError (replica died mid-submit before the poll loop evicted
|
||||
it). Both mean "this request cannot be placed", not "the server is
|
||||
broken": fail the request and keep serving (#4285). Unrelated errors
|
||||
propagate. Returns False when the request was failed.
|
||||
|
||||
``req_id`` is the request the failure is attributed to; ``dispatch_req_id``
|
||||
is the id actually being dispatched when it differs (e.g. a CFG companion
|
||||
submitted under its parent's attribution) and is used to locate the
|
||||
replica that died.
|
||||
"""
|
||||
try:
|
||||
await dispatch()
|
||||
return True
|
||||
except StageUnavailableError as e:
|
||||
# No specific replica to evict: the stage already has no live
|
||||
# replica or the chosen slot was evicted. Fail just this request.
|
||||
logger.error(
|
||||
"[Orchestrator] %s dispatch for req=%s hit unavailable stage-%s: %s",
|
||||
operation,
|
||||
req_id,
|
||||
stage_id,
|
||||
e,
|
||||
)
|
||||
await self._fail_request_dead_stage(req_id, stage_id)
|
||||
return False
|
||||
except EngineDeadError as e:
|
||||
# A live replica died mid-submit before the poll loop evicted it.
|
||||
# Evict it now (and fail every request bound to it) so a burst of
|
||||
# new requests stops round-robining onto the dead slot until the
|
||||
# poll catches up. Capture the replica before failing this request,
|
||||
# since the failure releases its binding.
|
||||
pool = self.stage_pools[stage_id]
|
||||
dead_replica = pool.get_bound_replica_id(dispatch_req_id or req_id)
|
||||
logger.error(
|
||||
"[Orchestrator] %s dispatch for req=%s hit dead stage-%s replica-%s: %s",
|
||||
operation,
|
||||
req_id,
|
||||
stage_id,
|
||||
dead_replica,
|
||||
e,
|
||||
)
|
||||
await self._fail_request_dead_stage(req_id, stage_id)
|
||||
if dead_replica is not None and dead_replica in pool.live_replica_ids():
|
||||
await self._handle_dead_replica(stage_id, dead_replica, e)
|
||||
return False
|
||||
|
||||
# ---- Shared helpers ----
|
||||
|
||||
async def _cleanup_request_ids(
|
||||
@@ -1735,10 +1837,42 @@ class Orchestrator:
|
||||
src_replica_id: int | None = None,
|
||||
is_streaming_session: bool = False,
|
||||
is_final_update: bool = False,
|
||||
) -> None:
|
||||
"""Forward output to the next stage; a dead stage fails only this request."""
|
||||
await self._dispatch_or_fail_request(
|
||||
lambda: self._forward_to_next_stage_unguarded(
|
||||
req_id,
|
||||
src_stage_id,
|
||||
output,
|
||||
req_state,
|
||||
src_replica_id=src_replica_id,
|
||||
is_streaming_session=is_streaming_session,
|
||||
is_final_update=is_final_update,
|
||||
),
|
||||
req_id=req_id,
|
||||
stage_id=src_stage_id + 1,
|
||||
operation="inter-stage forward",
|
||||
)
|
||||
|
||||
async def _forward_to_next_stage_unguarded(
|
||||
self,
|
||||
req_id: str,
|
||||
src_stage_id: int,
|
||||
output: Any,
|
||||
req_state: OrchestratorRequestState,
|
||||
*,
|
||||
src_replica_id: int | None = None,
|
||||
is_streaming_session: bool = False,
|
||||
is_final_update: bool = False,
|
||||
) -> None:
|
||||
"""Forward output from the current logical stage to the next one."""
|
||||
next_logical = src_stage_id + 1
|
||||
next_pool = self.stage_pools[next_logical]
|
||||
if not next_pool.live_replica_ids():
|
||||
# The downstream stage lost all of its replicas before this in-flight
|
||||
# request could be forwarded.
|
||||
await self._fail_request_dead_stage(req_id, next_logical)
|
||||
return
|
||||
next_client = next_pool.stage_client
|
||||
params = req_state.sampling_params_list[next_logical]
|
||||
source_outputs = [output]
|
||||
@@ -2101,16 +2235,22 @@ class Orchestrator:
|
||||
_t_submit_start = _time.perf_counter()
|
||||
|
||||
if next_pool.stage_type == "diffusion":
|
||||
replica_id = await next_pool.submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
req_state.prompt,
|
||||
submit_kwargs={
|
||||
"kv_sender_info": self._build_kv_sender_info(
|
||||
list(getattr(next_pool.stage_client, "engine_input_source", None) or [next_stage_id - 1]),
|
||||
request_id=request_id,
|
||||
)
|
||||
},
|
||||
submit_kwargs = {
|
||||
"kv_sender_info": self._build_kv_sender_info(
|
||||
list(getattr(next_pool.stage_client, "engine_input_source", None) or [next_stage_id - 1]),
|
||||
request_id=request_id,
|
||||
)
|
||||
}
|
||||
submitted = await self._dispatch_or_fail_request(
|
||||
lambda: next_pool.submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
req_state.prompt,
|
||||
submit_kwargs=submit_kwargs,
|
||||
),
|
||||
req_id=request_id,
|
||||
stage_id=next_stage_id,
|
||||
operation="async-chunk prewarm",
|
||||
)
|
||||
else:
|
||||
import copy
|
||||
@@ -2140,16 +2280,30 @@ class Orchestrator:
|
||||
resumable=downstream_resumable,
|
||||
)
|
||||
request.external_req_id = request.request_id
|
||||
replica_id = await next_pool.submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
request,
|
||||
prompt_text=None,
|
||||
submitted = await self._dispatch_or_fail_request(
|
||||
lambda: next_pool.submit_initial(
|
||||
request_id,
|
||||
req_state,
|
||||
request,
|
||||
prompt_text=None,
|
||||
),
|
||||
req_id=request_id,
|
||||
stage_id=next_stage_id,
|
||||
operation="async-chunk prewarm",
|
||||
)
|
||||
|
||||
# Attribute the failure to the stage that failed, not stage 0; the
|
||||
# request is already cleaned up, so stop prewarming.
|
||||
if not submitted:
|
||||
return
|
||||
|
||||
# The guard swallows submit_initial's return value; the pool binding
|
||||
# it recorded carries the same replica.
|
||||
bound_replica_id = next_pool.get_bound_replica_id(request_id)
|
||||
self._record_duplex_stage_submission(
|
||||
next_stage_id,
|
||||
request_id,
|
||||
replica_id,
|
||||
bound_replica_id if bound_replica_id is not None else 0,
|
||||
req_state,
|
||||
)
|
||||
|
||||
@@ -2201,53 +2355,6 @@ class Orchestrator:
|
||||
|
||||
# ---- Shutdown / lifecycle ----
|
||||
|
||||
async def _drain_pending_requests_on_fatal(self) -> None:
|
||||
"""Drain the request queue and broadcast fatal errors for any
|
||||
pending add_request messages that were never processed.
|
||||
|
||||
Called from the ``run()`` finally block when a fatal error
|
||||
(e.g. ``EngineDeadError``) caused the orchestrator to shut down
|
||||
before the request handler could process all queued messages.
|
||||
Also broadcasts for any already-tracked requests still in
|
||||
``request_states`` that were not yet notified.
|
||||
"""
|
||||
assert self._fatal_error is not None
|
||||
|
||||
notified: set[str] = set()
|
||||
|
||||
# 1) Drain pending messages from the request queue.
|
||||
while True:
|
||||
try:
|
||||
msg = self.request_async_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if msg.type == "add_request":
|
||||
req_id = msg.request_id
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=self._fatal_error,
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=self._fatal_error_stage_id,
|
||||
)
|
||||
)
|
||||
notified.add(req_id)
|
||||
|
||||
# 2) Broadcast for any tracked requests not already notified
|
||||
# (e.g. request was registered but the EngineDeadError handler
|
||||
# missed it because it wasn't submitted to the dead stage yet).
|
||||
for req_id in list(self.request_states):
|
||||
if req_id not in notified:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=self._fatal_error,
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=self._fatal_error_stage_id,
|
||||
)
|
||||
)
|
||||
self.request_states.pop(req_id, None)
|
||||
|
||||
def _shutdown_stages(self) -> None:
|
||||
"""Shutdown all stage pools."""
|
||||
if self._stages_shutdown:
|
||||
|
||||
@@ -44,6 +44,19 @@ if TYPE_CHECKING:
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class StageUnavailableError(RuntimeError):
|
||||
"""Raised when dispatch cannot reach a live replica of a stage.
|
||||
|
||||
Deliberately a ``RuntimeError`` subclass (not ``EngineDeadError``): no
|
||||
engine died at the raise site — the stage's replica set is empty or the
|
||||
chosen slot was evicted, which is a routing/capacity condition and, in
|
||||
distributed mode, recoverable. Dispatch guards catch exactly this type so
|
||||
unrelated ``RuntimeError``s keep failing fast, and existing
|
||||
``EngineDeadError`` handlers (teardown-on-dead, poll-path eviction) are
|
||||
not silently enrolled.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ReplicaMetrics:
|
||||
"""Per-replica metrics accumulators owned by a stage pool."""
|
||||
@@ -329,7 +342,9 @@ class StagePool:
|
||||
|
||||
now = _time.monotonic()
|
||||
if now >= deadline:
|
||||
raise RuntimeError(f"no UP replica for stage {self.stage_id} after {self.DISPATCH_WAIT_TIMEOUT_S:.1f}s")
|
||||
raise StageUnavailableError(
|
||||
f"no UP replica for stage {self.stage_id} after {self.DISPATCH_WAIT_TIMEOUT_S:.1f}s"
|
||||
)
|
||||
await asyncio.sleep(min(self.DISPATCH_RETRY_INTERVAL_S, deadline - now))
|
||||
|
||||
def preselect_replica_id(
|
||||
@@ -555,7 +570,7 @@ class StagePool:
|
||||
# Prefer replicas that are both live (client up) and available.
|
||||
live = [r for r in self.live_replica_ids() if self.is_replica_available(r)]
|
||||
if not live:
|
||||
raise RuntimeError(f"stage {self.stage_id} has no live replicas")
|
||||
raise StageUnavailableError(f"stage {self.stage_id} has no live replicas")
|
||||
if len(live) == 1:
|
||||
chosen = live[0]
|
||||
else:
|
||||
@@ -570,13 +585,13 @@ class StagePool:
|
||||
def _llm_client(self, replica_id: int) -> StagePoolLLMClient:
|
||||
client = self.clients[replica_id]
|
||||
if client is None:
|
||||
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
raise StageUnavailableError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
return cast(StagePoolLLMClient, client)
|
||||
|
||||
def _diffusion_client(self, replica_id: int) -> StagePoolDiffusionClient:
|
||||
client = self.clients[replica_id]
|
||||
if client is None:
|
||||
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
raise StageUnavailableError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
return cast(StagePoolDiffusionClient, client)
|
||||
|
||||
# ---- Metrics ----
|
||||
@@ -961,7 +976,7 @@ class StagePool:
|
||||
)
|
||||
client = self.clients[replica_id]
|
||||
if client is None:
|
||||
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
raise StageUnavailableError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
try:
|
||||
self.output_processor.add_request(
|
||||
request=request,
|
||||
@@ -1010,7 +1025,7 @@ class StagePool:
|
||||
|
||||
client = self.clients[replica_id]
|
||||
if client is None:
|
||||
raise RuntimeError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
raise StageUnavailableError(f"stage {self.stage_id} replica {replica_id} is not attached")
|
||||
|
||||
if self.stage_type == "diffusion":
|
||||
if isinstance(request, list):
|
||||
@@ -1246,3 +1261,21 @@ class StagePool:
|
||||
replica_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def evict_replica(self, replica_id: int) -> None:
|
||||
"""Shut down a replica and remove it from the live set.
|
||||
|
||||
After eviction ``live_replica_ids`` / ``live_num_replicas`` no longer
|
||||
include this slot, so the orchestrator stops polling and dispatching to
|
||||
it (per-replica fault isolation). The slot is left as a ``None`` hole,
|
||||
consistent with ``num_replicas``.
|
||||
"""
|
||||
# replica_id should always come from live_replica_ids() (a valid, live
|
||||
# index). Fail loud on an out-of-range id rather than letting a negative
|
||||
# index wrap or IndexError on the next line; an ``assert`` would be
|
||||
# stripped under ``python -O``, silently leaving a dead replica in the
|
||||
# live set and breaking isolation.
|
||||
if not 0 <= replica_id < len(self.clients):
|
||||
raise ValueError(f"evict_replica: replica_id {replica_id} out of range (num_replicas={len(self.clients)})")
|
||||
self.shutdown_replica(replica_id)
|
||||
self.clients[replica_id] = None
|
||||
@@ -906,17 +906,27 @@ class AsyncOmni(EngineClient, OmniBase):
|
||||
await self.duplex_lifecycle_events.put(msg)
|
||||
continue
|
||||
|
||||
if isinstance(msg, ErrorMessage) and not msg.fatal:
|
||||
req_state = self.request_states.get(msg.request_id)
|
||||
if req_state is not None:
|
||||
await req_state.queue.put(msg)
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] dropping non-fatal error for unknown req %s",
|
||||
self._name,
|
||||
msg.request_id,
|
||||
)
|
||||
continue
|
||||
if isinstance(msg, ErrorMessage):
|
||||
# Route request-scoped errors to that request's queue and
|
||||
# keep the loop alive. A request whose stage replica died
|
||||
# and was evicted gets a fatal error delivered here; only
|
||||
# that request fails (its consumer raises), the server
|
||||
# stays up for other stages/requests (#4285). A fatal
|
||||
# error without a request_id is a genuine engine-wide
|
||||
# death and falls through to the except handler below.
|
||||
if msg.request_id is not None:
|
||||
req_state = self.request_states.get(msg.request_id)
|
||||
if req_state is not None:
|
||||
await req_state.queue.put(msg)
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] dropping error for unknown req %s",
|
||||
self._name,
|
||||
msg.request_id,
|
||||
)
|
||||
continue
|
||||
if not msg.fatal:
|
||||
continue
|
||||
|
||||
should_continue, _, stage_id, req_state = self._handle_output_message(msg)
|
||||
if should_continue:
|
||||
@@ -1317,10 +1327,11 @@ class AsyncOmni(EngineClient, OmniBase):
|
||||
|
||||
@property
|
||||
def errored(self) -> bool:
|
||||
"""Whether the engine is in a non-recoverable error state.
|
||||
"""Whether the engine is in a process-fatal error state.
|
||||
|
||||
Delegates to ``OmniBase.errored`` which checks the orchestrator
|
||||
thread and all stage clients. Redeclared here to satisfy the
|
||||
Delegates to ``OmniBase.errored``, which is true only when the
|
||||
orchestrator thread is dead; per-stage liveness is reported via
|
||||
``check_health`` instead. Redeclared here to satisfy the
|
||||
``EngineClient`` abstract-property requirement (Python's ABC
|
||||
mechanism does not resolve abstract methods from sibling MRO
|
||||
entries).
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import huggingface_hub
|
||||
import vllm.envs as envs
|
||||
@@ -31,6 +31,9 @@ from vllm_omni.model_executor.model_loader.weight_utils import download_weights_
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.utils.tracking_parser import TrackingNamespace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.engine.stage_pool import StagePool, StagePoolClient
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@@ -237,6 +240,42 @@ class OmniBase(PDDisaggregationMixin):
|
||||
"""Expose engine stage configs for PD disaggregation detection and validation."""
|
||||
return self.engine.stage_configs
|
||||
|
||||
@staticmethod
|
||||
def _replica_is_dead(client: StagePoolClient | None) -> bool:
|
||||
"""Whether a replica slot is evicted (``None``) or its engine is dead.
|
||||
|
||||
``check_health()`` is the source of truth: it raises ``EngineDeadError``
|
||||
when dead (inspecting the ``engine_dead`` flags internally), and the
|
||||
diffusion client additionally runs a synchronous ``proc.is_alive()``
|
||||
probe that catches a silent SIGKILL/segfault the monitor thread has not
|
||||
flagged yet — so ``/health`` does not report 200 for a stage whose
|
||||
subprocess is already dead. A client exposing no ``check_health`` cannot
|
||||
confirm liveness, so it is treated as dead (fail closed).
|
||||
"""
|
||||
if client is None:
|
||||
return True
|
||||
check_health = getattr(client, "check_health", None)
|
||||
if not callable(check_health):
|
||||
return True
|
||||
try:
|
||||
check_health()
|
||||
except EngineDeadError:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _live_replica_count(self, pool: StagePool) -> int:
|
||||
"""Number of replicas in ``pool`` that are neither evicted nor dead.
|
||||
|
||||
A dead replica is evicted from its pool (slot set to ``None`` in
|
||||
``StagePool.clients``); a replica that died but has not been evicted
|
||||
yet still carries an ``engine_dead`` flag. Both are excluded.
|
||||
"""
|
||||
return sum(1 for client in pool.clients if not self._replica_is_dead(client))
|
||||
|
||||
def _stage_has_no_live_replica(self, pool: StagePool) -> bool:
|
||||
"""True when a non-empty stage pool has lost all of its replicas."""
|
||||
return len(pool.clients) > 0 and self._live_replica_count(pool) == 0
|
||||
|
||||
def _consumed_metric_message_ids(self, request_id: str) -> set[int]:
|
||||
consumed_by_request = getattr(self, "_consumed_metric_messages", None)
|
||||
if consumed_by_request is None:
|
||||
@@ -244,38 +283,33 @@ class OmniBase(PDDisaggregationMixin):
|
||||
self._consumed_metric_messages = consumed_by_request
|
||||
return consumed_by_request.setdefault(request_id, set())
|
||||
|
||||
def _has_dead_stage(self) -> bool:
|
||||
for stage_client in self.engine.stage_clients:
|
||||
if getattr(stage_client, "_engine_dead", False):
|
||||
return True
|
||||
resources = getattr(stage_client, "resources", None)
|
||||
if resources is not None and getattr(resources, "engine_dead", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self.engine.is_alive() and not self._has_dead_stage()
|
||||
return self.engine.is_alive()
|
||||
|
||||
@property
|
||||
def errored(self) -> bool:
|
||||
"""Whether the engine is in a non-recoverable error state.
|
||||
"""Whether the engine is in a process-fatal error state.
|
||||
|
||||
True when the orchestrator thread is dead **or** any stage client
|
||||
has been marked dead (e.g. diffusion worker OOM / process death).
|
||||
|
||||
Checks both ``_engine_dead`` (StageDiffusionClient) and
|
||||
``resources.engine_dead`` (StageEngineCoreClient / AsyncMPClient)
|
||||
since the two client types store the flag differently.
|
||||
True only when the orchestrator thread is dead. Per-stage liveness is
|
||||
deliberately excluded: the OpenAI serving paths precheck ``errored``
|
||||
before request routing, so including it would reject requests that do
|
||||
not touch the dead stage (e.g. text-only chat when the talker stage is
|
||||
down). A fully dead stage instead fails only the requests routed
|
||||
through it (dispatch guards) and flips readiness to 503 via
|
||||
:meth:`check_health` (per-replica fault isolation, #4285).
|
||||
"""
|
||||
return not self.engine.is_alive() or self._has_dead_stage()
|
||||
return not self.engine.is_alive()
|
||||
|
||||
def check_health(self) -> None:
|
||||
if not self.engine.is_alive():
|
||||
raise EngineDeadError("Orchestrator process is not alive")
|
||||
for stage_client in self.engine.stage_clients:
|
||||
if hasattr(stage_client, "check_health"):
|
||||
stage_client.check_health()
|
||||
pools = getattr(self.engine, "stage_pools", None)
|
||||
if pools is None:
|
||||
return
|
||||
for pool in pools:
|
||||
if self._stage_has_no_live_replica(pool):
|
||||
raise EngineDeadError(f"Stage-{pool.stage_id} has no live replica")
|
||||
|
||||
def resolve_sampling_params_list(
|
||||
self,
|
||||
|
||||
@@ -758,7 +758,11 @@ class StepAudio2ThinkerForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
|
||||
audio_feature_lens = (audio_lens - 1) // 2 + 1
|
||||
|
||||
audio_feature_list = [audio_features[i, : audio_feature_lens[i]] for i in range(audio_features.size(0))]
|
||||
# Single host sync; slicing with per-item 0-d cuda tensors syncs once per item.
|
||||
# Safe: _process_audio_input runs in the eager multimodal-encoder prefill path,
|
||||
# never inside a decode CUDA-graph capture, so this .tolist() host sync is fine.
|
||||
audio_feature_lens_cpu = audio_feature_lens.tolist()
|
||||
audio_feature_list = [audio_features[i, : audio_feature_lens_cpu[i]] for i in range(audio_features.size(0))]
|
||||
|
||||
return audio_feature_list
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ class StepAudio2Token2WavCore(nn.Module):
|
||||
|
||||
def stream_chunk_for(
|
||||
self,
|
||||
audio_tokens: list[int],
|
||||
audio_tokens: list[int] | torch.Tensor,
|
||||
prompt_wav: str,
|
||||
last_chunk: bool,
|
||||
state: _StreamState,
|
||||
@@ -377,7 +377,14 @@ class StepAudio2Token2WavCore(nn.Module):
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
_, _, spk_emb, prompt_mels, _ = self.cache[prompt_wav]
|
||||
|
||||
token_tensor = torch.tensor([audio_tokens], dtype=torch.int32, device=self.device)
|
||||
if isinstance(audio_tokens, torch.Tensor):
|
||||
# Already on device; avoids the D2H->H2D round-trip of rebuilding from a list.
|
||||
# clone() so token_tensor never aliases the caller's input_ids: when the ids
|
||||
# already arrive int32-on-device the .to() is a no-op and would share storage,
|
||||
# so an in-place write inside inference_chunk could corrupt input_ids.
|
||||
token_tensor = audio_tokens.reshape(1, -1).to(device=self.device, dtype=torch.int32).clone()
|
||||
else:
|
||||
token_tensor = torch.tensor([audio_tokens], dtype=torch.int32, device=self.device)
|
||||
|
||||
with torch.amp.autocast(
|
||||
str(self.device.type),
|
||||
@@ -651,10 +658,11 @@ class StepAudio2Token2WavForConditionalGeneration(nn.Module, SupportsPP):
|
||||
state = self._stream_states[0]
|
||||
|
||||
# --- Extract audio tokens ---
|
||||
audio_tokens = input_ids.flatten().cpu().tolist()
|
||||
# Kept on device; stream_chunk_for takes the tensor directly.
|
||||
audio_tokens = input_ids.flatten()
|
||||
|
||||
# Empty chunk (e.g. EOF with no audio tokens)
|
||||
if not audio_tokens:
|
||||
if audio_tokens.numel() == 0:
|
||||
if state.setup_done:
|
||||
self.token2wav.reset_stream_for(state)
|
||||
else:
|
||||
|
||||
+19
-9
@@ -74,6 +74,12 @@ class CUDAGraphAcousticTransformerWrapper:
|
||||
|
||||
# Pre-create persistent buffers
|
||||
self.timesteps = torch.linspace(0, 1, self.n_steps + 1, device=device, dtype=dtype)
|
||||
# Precompute schedule constants (dt, projected time embeddings) so the captured
|
||||
# graph indexes a table instead of re-running them on every replay.
|
||||
self.dts = self.timesteps[1:] - self.timesteps[:-1]
|
||||
with torch.no_grad():
|
||||
t_emb_table = self.acoustic_transformer.time_embedding(self.timesteps.view(-1, 1)).to(dtype)
|
||||
self.t_proj_table = self.acoustic_transformer.time_projection(t_emb_table)
|
||||
self.fake_eos_one = torch.tensor(1.0, dtype=dtype, device=device)
|
||||
self.fake_eos_zero = torch.tensor(0.0, dtype=dtype, device=device)
|
||||
|
||||
@@ -142,21 +148,21 @@ class CUDAGraphAcousticTransformerWrapper:
|
||||
|
||||
x = noise
|
||||
|
||||
# Pre-compute zero hidden states for unconditional CFG branch
|
||||
hidden_states_zero = torch.zeros_like(hidden_states)
|
||||
# Hoist the step-invariant concat + llm_projection out of the loop; inside the
|
||||
# captured graph these would otherwise be baked in and replayed every step.
|
||||
llm_batched = torch.cat([hidden_states, torch.zeros_like(hidden_states)], dim=0) # (2B, D)
|
||||
llm_proj_batched = at.llm_projection(llm_batched) # (2B, D)
|
||||
|
||||
timesteps = self.timesteps
|
||||
for i in range(len(timesteps) - 1):
|
||||
t = timesteps[i]
|
||||
dt = timesteps[i + 1] - timesteps[i]
|
||||
dt = self.dts[i]
|
||||
|
||||
# Batch conditional + unconditional velocity in a single forward pass
|
||||
t_emb = at.time_embedding(t.view(-1, 1).repeat(B, 1)).to(hidden_states.dtype)
|
||||
# Batch cond + uncond in a single pass; reuse the cached projected-t_emb row.
|
||||
t_proj = self.t_proj_table[i].unsqueeze(0).expand(B, -1)
|
||||
x_batched = torch.cat([x, x], dim=0) # (2B, C)
|
||||
llm_batched = torch.cat([hidden_states, hidden_states_zero], dim=0) # (2B, D)
|
||||
t_emb_batched = t_emb.repeat(2, 1) # (2B, D)
|
||||
t_proj_batched = t_proj.repeat(2, 1) # (2B, D)
|
||||
|
||||
v_all = at._predict_velocity(x_t=x_batched, llm_output=llm_batched, t_emb=t_emb_batched)
|
||||
v_all = at._predict_velocity(x_t=x_batched, llm_proj=llm_proj_batched, t_proj=t_proj_batched)
|
||||
v_t, uncond_v_t = v_all[:B], v_all[B:]
|
||||
|
||||
# CFG combination (cfg_alpha is (B, 1), v_t is (B, C))
|
||||
@@ -239,6 +245,10 @@ class CUDAGraphAcousticTransformerWrapper:
|
||||
if not self.enabled or not self._warmed_up:
|
||||
return self.model.compute_mm_logits(hidden_states, cfg_alpha=cfg_alpha)
|
||||
|
||||
# Inner graph replay is illegal during an outer stream capture.
|
||||
if torch.cuda.is_current_stream_capturing():
|
||||
return self.model.compute_mm_logits(hidden_states, cfg_alpha=cfg_alpha)
|
||||
|
||||
padded_size = self._get_padded_size(actual_size)
|
||||
if padded_size is None or padded_size not in self.graphs:
|
||||
return self.model.compute_mm_logits(hidden_states, cfg_alpha=cfg_alpha)
|
||||
|
||||
@@ -45,10 +45,12 @@ def parse_batched_audio_input(input_ids: torch.Tensor, num_codebooks: int) -> tu
|
||||
"""
|
||||
all_audio_tokens: list[torch.Tensor] = []
|
||||
all_ctx_frames: list[int] = []
|
||||
# One D2H copy up-front avoids 2 syncs per request inside the loop.
|
||||
header_view = input_ids.cpu() if input_ids.is_cuda else input_ids
|
||||
offset = 0
|
||||
while offset < input_ids.numel():
|
||||
ctx_frames = int(input_ids[offset].item())
|
||||
context_length = int(input_ids[offset + 1].item())
|
||||
ctx_frames = int(header_view[offset])
|
||||
context_length = int(header_view[offset + 1])
|
||||
offset += 2
|
||||
|
||||
req_input_ids_length = (ctx_frames + context_length) * num_codebooks
|
||||
@@ -155,6 +157,9 @@ class VoxtralTTSForConditionalGeneration(
|
||||
architectures=["VoxtralTTSAudioTokenizer"],
|
||||
)
|
||||
self.model = self.audio_tokenizer
|
||||
# forward() returns a runtime-length per-request list; FULL CUDAGraphWrapper
|
||||
# would freeze that length at capture.
|
||||
self.supports_cudagraph_full = False
|
||||
else:
|
||||
raise ValueError("Invalid model stage")
|
||||
|
||||
|
||||
@@ -441,6 +441,8 @@ class FlowMatchingAudioTransformer(nn.Module):
|
||||
torch.linspace(0, 1, self._n_steps + 1),
|
||||
persistent=False,
|
||||
)
|
||||
# Lazy per-dtype cache of schedule constants: (timesteps, t_proj_table, dts) on device.
|
||||
self._timesteps_cache: dict[torch.dtype, tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
|
||||
|
||||
def load_weight(self, weight: tuple[str, torch.Tensor]) -> str:
|
||||
params_dict = dict(self.named_parameters())
|
||||
@@ -514,12 +516,25 @@ class FlowMatchingAudioTransformer(nn.Module):
|
||||
# Skip decoding if codebook 0 is [END_AUDIO] token.
|
||||
should_decode = semantic_code != self._end_audio_token_id
|
||||
|
||||
# acoustic_codes starts from x_0
|
||||
x_0 = torch.randn(B, self.model_args.n_acoustic_codebook).to(dtype=llm_hidden.dtype, device=llm_hidden.device)
|
||||
# acoustic_codes starts from x_0; generate directly on device to skip H2D.
|
||||
x_0 = torch.randn(B, self.model_args.n_acoustic_codebook, dtype=llm_hidden.dtype, device=llm_hidden.device)
|
||||
x_0 = self._noise_scale * x_0
|
||||
|
||||
timesteps = self._timesteps.to(dtype=llm_hidden.dtype)
|
||||
llm_hidden_zero = torch.zeros_like(llm_hidden)
|
||||
# Build the schedule constants once per dtype and reuse them every frame.
|
||||
cache = self._timesteps_cache.get(llm_hidden.dtype)
|
||||
if cache is None:
|
||||
timesteps_d = self._timesteps.to(dtype=llm_hidden.dtype)
|
||||
t_emb_table = self.time_embedding(timesteps_d.view(-1, 1)).to(llm_hidden.dtype)
|
||||
t_proj_table = self.time_projection(t_emb_table)
|
||||
dts = timesteps_d[1:] - timesteps_d[:-1]
|
||||
self._timesteps_cache[llm_hidden.dtype] = (timesteps_d, t_proj_table, dts)
|
||||
cache = self._timesteps_cache[llm_hidden.dtype]
|
||||
timesteps, t_proj_table, dts = cache
|
||||
|
||||
# Hoist the step-invariant concat + llm_projection out of the loop; inside the
|
||||
# captured graph these would otherwise replay every step.
|
||||
llm_batched = torch.cat([llm_hidden, torch.zeros_like(llm_hidden)], dim=0)
|
||||
llm_proj_batched = self.llm_projection(llm_batched)
|
||||
|
||||
# Reshape cfg_alpha for broadcasting: (B,) -> (B, 1)
|
||||
cfg_alpha = cfg_alpha.to(dtype=llm_hidden.dtype, device=llm_hidden.device)
|
||||
@@ -528,20 +543,19 @@ class FlowMatchingAudioTransformer(nn.Module):
|
||||
# Euler integration with batched conditional + unconditional velocity
|
||||
sampled = x_0
|
||||
for i in range(len(timesteps) - 1):
|
||||
t = timesteps[i]
|
||||
dt = timesteps[i + 1] - timesteps[i]
|
||||
dt = dts[i] # precomputed constant step size
|
||||
|
||||
t_emb = self.time_embedding(t.view(-1, 1).repeat(B, 1)).to(llm_hidden.dtype)
|
||||
# Reuse cached projected-t_emb row, expanded along batch dim.
|
||||
t_proj = t_proj_table[i].unsqueeze(0).expand(B, -1)
|
||||
|
||||
# Batch cond + uncond into a single forward pass (2B batch)
|
||||
x_batched = torch.cat([sampled, sampled], dim=0)
|
||||
llm_batched = torch.cat([llm_hidden, llm_hidden_zero], dim=0)
|
||||
t_emb_batched = torch.cat([t_emb, t_emb], dim=0)
|
||||
t_proj_batched = torch.cat([t_proj, t_proj], dim=0)
|
||||
|
||||
v_all = self._predict_velocity(
|
||||
x_t=x_batched,
|
||||
llm_output=llm_batched,
|
||||
t_emb=t_emb_batched,
|
||||
llm_proj=llm_proj_batched,
|
||||
t_proj=t_proj_batched,
|
||||
)
|
||||
v_t, uncond_v_t = v_all[:B], v_all[B:]
|
||||
v_t = cfg_alpha * v_t + (1 - cfg_alpha) * uncond_v_t
|
||||
@@ -558,18 +572,16 @@ class FlowMatchingAudioTransformer(nn.Module):
|
||||
def _predict_velocity(
|
||||
self,
|
||||
x_t: torch.Tensor, # BxC
|
||||
llm_output: torch.Tensor, # BxD
|
||||
t_emb: torch.Tensor, # BxD
|
||||
llm_proj: torch.Tensor, # BxD, already through llm_projection
|
||||
t_proj: torch.Tensor, # BxD, already through time_projection
|
||||
) -> torch.Tensor:
|
||||
x_t = x_t.to(llm_output.dtype)
|
||||
|
||||
t_emb = self.time_projection(t_emb)
|
||||
llm_output = self.llm_projection(llm_output)
|
||||
# llm_proj/t_proj arrive pre-projected (hoisted out of the per-step loop by callers).
|
||||
x_t = x_t.to(llm_proj.dtype)
|
||||
|
||||
acoustic_and_semantic_embeddings = [
|
||||
self.input_projection(x_t.unsqueeze(1)), # Bx1xD
|
||||
t_emb.unsqueeze(1),
|
||||
llm_output.unsqueeze(1),
|
||||
t_proj.unsqueeze(1),
|
||||
llm_proj.unsqueeze(1),
|
||||
]
|
||||
acoustic_transformer_inputs = torch.concatenate(acoustic_and_semantic_embeddings, dim=1)
|
||||
|
||||
@@ -954,6 +966,8 @@ class VoxtralTTSAudioGenerationForConditionalGeneration(nn.Module, SupportsMulti
|
||||
self.audio_tok_id = audio_encoder.audio_token
|
||||
self.eos_tok_id = self.tokenizer.instruct.tokenizer.eos_id
|
||||
self.vocab_size = config.text_config.vocab_size
|
||||
self._end_audio_token_id = AudioSpecialTokens.id(AudioSpecialTokens.end_audio)
|
||||
self._fake_eos_consts: dict[torch.device, tuple[torch.Tensor, torch.Tensor]] = {}
|
||||
|
||||
def get_language_model(self) -> torch.nn.Module:
|
||||
return self.language_model
|
||||
@@ -1052,11 +1066,15 @@ class VoxtralTTSAudioGenerationForConditionalGeneration(nn.Module, SupportsMulti
|
||||
llm_hidden=hidden_states,
|
||||
cfg_alpha=cfg_alpha,
|
||||
)
|
||||
fake_eos = torch.where(
|
||||
audio_codes[:, 0] == AudioSpecialTokens.id(AudioSpecialTokens.end_audio),
|
||||
torch.tensor(1.0, dtype=torch.bfloat16),
|
||||
torch.tensor(0.0, dtype=torch.bfloat16),
|
||||
)
|
||||
# Cache device-resident 1.0/0.0 scalars to avoid a per-call H2D transfer.
|
||||
consts = self._fake_eos_consts.get(audio_codes.device)
|
||||
if consts is None:
|
||||
consts = (
|
||||
torch.tensor(1.0, dtype=torch.bfloat16, device=audio_codes.device),
|
||||
torch.tensor(0.0, dtype=torch.bfloat16, device=audio_codes.device),
|
||||
)
|
||||
self._fake_eos_consts[audio_codes.device] = consts
|
||||
fake_eos = torch.where(audio_codes[:, 0] == self._end_audio_token_id, consts[0], consts[1])
|
||||
# BxC -> Bx1xC since this is per-step
|
||||
# Make it a list for vllm-omni processing
|
||||
audio_list = list(torch.split(audio_codes.unsqueeze(1), 1, dim=0))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import logging
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
@@ -9,6 +10,7 @@ import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
from vllm_omni.model_executor.models.voxtral_tts.voxtral_tts_audio_generation import (
|
||||
@@ -485,9 +487,10 @@ class Attention(nn.Module):
|
||||
)
|
||||
return slopes # shape [n_heads], values in (2^-8, 1]
|
||||
|
||||
# Keep alibi slopes in fp32 so `forward` can skip the per-call `.to(fp32)` cast.
|
||||
self.register_buffer(
|
||||
"alibi_slopes",
|
||||
get_alibi_slopes(self.n_local_heads),
|
||||
get_alibi_slopes(self.n_local_heads).to(torch.float32).contiguous(),
|
||||
persistent=False,
|
||||
)
|
||||
|
||||
@@ -552,8 +555,8 @@ class Attention(nn.Module):
|
||||
# rel_pos[i, j] = j - i
|
||||
rel_pos = positions.unsqueeze(0) - positions.unsqueeze(1) # (S, S)
|
||||
|
||||
# Alibi bias: slope * (j - i), shape (H, S, S)
|
||||
alibi_slopes = self.alibi_slopes.to(dtype=xq.dtype, device=xq.device)
|
||||
# Alibi bias: slope * (j - i), shape (H, S, S). Cast only to xq dtype (already on device).
|
||||
alibi_slopes = self.alibi_slopes.to(dtype=xq.dtype)
|
||||
attn_bias = alibi_slopes.view(H, 1, 1) * rel_pos.unsqueeze(0).to(xq.dtype)
|
||||
|
||||
# Causal mask: mask out j > i (future positions)
|
||||
@@ -584,7 +587,7 @@ class Attention(nn.Module):
|
||||
xv = xv.view(bsz, seqlen, self.n_local_kv_heads, self.args.head_dim)
|
||||
|
||||
if HAS_FLASH_ATTN:
|
||||
alibi_slopes = self.alibi_slopes.to(torch.float32)
|
||||
# alibi_slopes already fp32 — avoid per-call cast.
|
||||
output = flash_attn_func(
|
||||
xq,
|
||||
xk,
|
||||
@@ -594,7 +597,7 @@ class Attention(nn.Module):
|
||||
self.sliding_window,
|
||||
0 if self.args.causal else self.sliding_window,
|
||||
),
|
||||
alibi_slopes=alibi_slopes,
|
||||
alibi_slopes=self.alibi_slopes,
|
||||
)
|
||||
else:
|
||||
output = self._native_attention(xq, xk, xv)
|
||||
@@ -1052,14 +1055,15 @@ class VoxtralTTSAudioTokenizer(nn.Module):
|
||||
"""
|
||||
chunk_size = 375 # TODO(chenyos): Hardcode. Fix it
|
||||
|
||||
# Pre-process: find EOA and unshift tokens
|
||||
processed = []
|
||||
for codes in codes_list:
|
||||
eoa_mask = codes[:, 0] == 1
|
||||
eoa_indices = eoa_mask.nonzero(as_tuple=False)
|
||||
cutting_point = eoa_indices[0].item() if len(eoa_indices) > 0 else len(codes)
|
||||
audio_tokens = codes[:cutting_point] - 2
|
||||
processed.append(audio_tokens)
|
||||
# Compute per-request EOA cutting points in a single D2H instead of one sync per request.
|
||||
flags = []
|
||||
for c in codes_list:
|
||||
eoa_mask = c[:, 0] == 1
|
||||
flags.append(torch.stack([eoa_mask.any().to(torch.long), eoa_mask.long().argmax()]))
|
||||
resolved = torch.stack(flags).cpu().tolist() if flags else []
|
||||
cutting_points = [int(idx) if has else c.shape[0] for (has, idx), c in zip(resolved, codes_list)]
|
||||
|
||||
processed = [c[:cp] - 2 for c, cp in zip(codes_list, cutting_points)]
|
||||
|
||||
# Separate empty and non-empty
|
||||
results: list[torch.Tensor | None] = [None] * len(processed)
|
||||
@@ -1087,25 +1091,19 @@ class VoxtralTTSAudioTokenizer(nn.Module):
|
||||
chunk_map.append((orig_idx, req_chunk_indices))
|
||||
|
||||
# Pad chunks to max length and batch decode
|
||||
max_chunk_len = max(chunk_lengths)
|
||||
K = all_chunks[0].shape[1]
|
||||
padded = torch.zeros(
|
||||
len(all_chunks),
|
||||
max_chunk_len,
|
||||
K,
|
||||
dtype=all_chunks[0].dtype,
|
||||
device=all_chunks[0].device,
|
||||
)
|
||||
for i, chunk in enumerate(all_chunks):
|
||||
padded[i, : len(chunk)] = chunk
|
||||
padded = pad_sequence(all_chunks, batch_first=True, padding_value=0.0)
|
||||
|
||||
audio_codes = padded.to(device=current_omni_platform.device_type) # [B, T, K]
|
||||
audio_values = self.decode(audio_codes.transpose(1, 2), dtype=torch.bfloat16) # [B, 1, T_out]
|
||||
audio_values = audio_values.detach().cpu().float().squeeze(1) # [B, T_out]
|
||||
if torch.min(audio_values) < -1.0:
|
||||
logger.warning("Min value of decoded waveform signal is %s", torch.min(audio_values))
|
||||
if torch.max(audio_values) > 1.0:
|
||||
logger.warning("Max value of decoded waveform signal is %s", torch.max(audio_values))
|
||||
# Gate min/max reductions behind logger level.
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
amin = float(audio_values.min())
|
||||
amax = float(audio_values.max())
|
||||
if amin < -1.0:
|
||||
logger.warning("Min value of decoded waveform signal is %s", amin)
|
||||
if amax > 1.0:
|
||||
logger.warning("Max value of decoded waveform signal is %s", amax)
|
||||
|
||||
# Trim padding and reassemble per request
|
||||
for orig_idx, chunk_indices in chunk_map:
|
||||
|
||||
Reference in new issue
Block a user