Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions docs/cli/bench/serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,33 @@ Print per-stage benchmark metrics for --omni serving when stage metrics are retu
Specify to save benchmark results to a json file

- `--save-detailed`
"When saving the results, whether to include per request "
"information such as response, error, ttfs, tpots, etc."
Include per-request information, such as responses, errors, and latency metrics, when saving results with `--save-result`.

For `daily-omni`, `openai-chat-omni`, `/v1/videos`, and the image backends
(`openai-image-edits-omni`, `/v1/images/generations`, `/v1/images/edits`), this
also requests server-side stage snapshots by setting
`extra_body.return_stage_metrics=true` unless explicitly overridden. This is
not a local-JSON-only option: snapshots can increase response/SSE payload size
and measurement overhead. Stage 0 timings can also supply token intervals
when client-side intervals are unavailable. Keep the same options in
performance comparisons. Image backends already request these metrics
without `--save-detailed`; unsupported backends are unchanged.

Benchmark results keep two distinct, request-aligned fields:

- `request_stage_metrics`: the existing compact workload snapshots containing
token counts, finish reasons, and audio frames/duration when available.
This field does not depend on `--save-detailed`, omits per-token timing
arrays, uses `null` for missing snapshots, and is omitted when no compact
snapshots are available.
- `stage_metrics`: full returned stage snapshots, including any per-token
timing arrays, only with `--save-detailed`. Missing snapshots use `{}`.
This field supplements rather than replaces `request_stage_metrics`.

Both arrays follow the formal input request order, retain entries for failed
requests, and exclude warmup requests. Saving snapshots does not create
metrics that the server did not return. An explicit
`extra_body.return_stage_metrics=false` is preserved.

- `--result-dir`
"Specify directory to save benchmark json results."
Expand Down
117 changes: 116 additions & 1 deletion tests/benchmarks/patch/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class MockResponse:

def __init__(self, status, chunks, delay_between_chunks=0):
self.status = status
self.headers: dict[str, str] = {}
self.reason = "OK" if status == 200 else "Error"
self._chunks = chunks
self._delay = delay_between_chunks
Expand All @@ -138,6 +139,111 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
pass


def test_request_stage_metrics_preserve_request_alignment():
first = MixRequestFuncOutput(
stage_metrics={
"0": {
"stage_id": 0,
"stage_gen_time_ms": 12.5,
}
}
)
missing = MixRequestFuncOutput(stage_metrics=None)
multiple = MixRequestFuncOutput(
stage_metrics={
"0": {"stage_id": 0},
"1": {"stage_id": 1},
}
)

assert patch._request_stage_metrics([first, missing, multiple]) == [
{
"0": {
"stage_id": 0,
"stage_gen_time_ms": 12.5,
}
},
{},
{
"0": {"stage_id": 0},
"1": {"stage_id": 1},
},
]


@pytest.mark.parametrize(
"backend",
[
"daily-omni",
"openai-chat-omni",
"openai-image-edits-omni",
"/v1/images/generations",
"/v1/images/edits",
"/v1/videos",
],
)
def test_save_detailed_requests_stage_metrics(backend):
args = SimpleNamespace(
print_stage=False,
save_detailed=True,
backend=backend,
extra_body={},
)

assert patch.should_request_stage_metrics(args) is True


def test_save_detailed_does_not_request_stage_metrics_for_unsupported_backend():
args = SimpleNamespace(
print_stage=False,
save_detailed=True,
backend="openai",
percentile_metrics=(),
extra_body={},
)

assert patch.should_request_stage_metrics(args) is False


def test_save_detailed_preserves_explicit_stage_metrics_opt_out():
extra_body = {"return_stage_metrics": False, "temperature": 0.0}
args = SimpleNamespace(
print_stage=False,
save_detailed=True,
backend="openai-chat-omni",
extra_body=extra_body,
)

actual = patch.maybe_enable_stage_metrics(extra_body, enabled=patch.should_request_stage_metrics(args))

assert actual == {"return_stage_metrics": False, "temperature": 0.0}
assert actual is not extra_body
assert extra_body == {"return_stage_metrics": False, "temperature": 0.0}


def test_detailed_stage_metrics_are_opt_in():
outputs = [
MixRequestFuncOutput(stage_metrics={"0": {"stage_id": 0}}),
MixRequestFuncOutput(stage_metrics=None),
]

result: dict[str, object] = {}
patch.set_save_detailed(False)
patch._add_detailed_stage_metrics(result, outputs)
assert "stage_metrics" not in result

patch.set_save_detailed(True)
try:
patch._add_detailed_stage_metrics(result, outputs)
finally:
patch.set_save_detailed(False)

assert result["stage_metrics"] == [
{"0": {"stage_id": 0}},
{},
]


@pytest.mark.asyncio
async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch):
class FakeRealtimeClient:
Expand Down Expand Up @@ -2091,7 +2197,8 @@ def fake_get_samples_old(args, tokenizer, **kwargs):


@pytest.mark.asyncio
async def test_benchmark_preserves_stage_metrics_request_order_and_missing_snapshots(monkeypatch):
@pytest.mark.parametrize("save_detailed", [False, True])
async def test_benchmark_preserves_stage_metrics_request_order_and_missing_snapshots(monkeypatch, save_detailed):
"""Persist compact formal-request snapshots in input order, excluding warmups and retaining gaps."""
second_finished = asyncio.Event()
completion_order = []
Expand Down Expand Up @@ -2128,6 +2235,7 @@ async def request_func(request_func_input, session, pbar=None):
)

monkeypatch.setitem(patch.ASYNC_REQUEST_FUNCS, "test-stage-metrics", request_func)
monkeypatch.setattr(patch, "_SAVE_DETAILED", save_detailed)
result = await patch.benchmark(
task_type=patch.TaskType.GENERATION,
endpoint_type="test-stage-metrics",
Expand Down Expand Up @@ -2158,6 +2266,13 @@ async def request_func(request_func_input, session, pbar=None):
)
assert completion_order.index(1) < completion_order.index(0)
assert result["request_stage_metrics"] == expected
if save_detailed:
assert result["stage_metrics"] == snapshots
serialized = json.loads(json.dumps(result))
assert serialized["request_stage_metrics"] == expected
assert serialized["stage_metrics"] == snapshots
else:
assert "stage_metrics" not in result


@pytest.mark.parametrize("snapshot", [None, {}, "not-a-dict"])
Expand Down
20 changes: 19 additions & 1 deletion tests/benchmarks/test_serve_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]


def test_it2i_dataset_uses_upstream_warmups(monkeypatch, tmp_path):
@pytest.mark.parametrize("save_detailed", [False, True])
def test_it2i_dataset_uses_upstream_warmups(monkeypatch, tmp_path, save_detailed):
"""Exercise real dataset/benchmark code; replace only the HTTP backend."""
from vllm_omni.benchmarks import serve
from vllm_omni.benchmarks.patch import patch
Expand Down Expand Up @@ -58,13 +59,21 @@ def test_it2i_dataset_uses_upstream_warmups(monkeypatch, tmp_path):
"--extra-body",
'{"seed":42,"guidance_scale":2.5,"bot_task":"think_recaption"}',
"--save-result",
*(["--save-detailed"] if save_detailed else []),
"--omni-request-timeout-s",
"42",
"--videomme-save-eval-items",
"--result-dir",
str(tmp_path),
"--result-filename",
"result.json",
]
)
requests = []
snapshot = {"0": {"num_tokens_out": 1, "finish_reason": "stop", "vllm_itls_ms": [5.0]}}
monkeypatch.setattr(patch, "_SAVE_DETAILED", not save_detailed)
monkeypatch.setattr(patch, "_REQUEST_TIMEOUT_OVERRIDE_S", None)
monkeypatch.delenv("VIDEOMME_SAVE_EVAL_ITEMS", raising=False)

async def request_func(request_func_input, session, pbar=None):
requests.append(request_func_input)
Expand All @@ -77,10 +86,14 @@ async def request_func(request_func_input, session, pbar=None):
image_count=1,
start_time=time.perf_counter(),
latency=99.0 if len(requests) <= 2 else 0.1,
stage_metrics=snapshot,
)

monkeypatch.setitem(patch.ASYNC_REQUEST_FUNCS, "/v1/images/edits", request_func)
result = serve.main(args)
assert patch._SAVE_DETAILED is save_detailed
assert patch._omni_request_timeout_s() == 42
assert os.environ["VIDEOMME_SAVE_EVAL_ITEMS"] == "1"
assert len(requests) == 10
for request in requests:
assert request.prompt == sample["prompt"]
Expand All @@ -90,6 +103,11 @@ async def request_func(request_func_input, session, pbar=None):
saved = json.loads((tmp_path / "result.json").read_text())
assert saved["completed"] == 8
assert saved["mean_e2el_ms"] == pytest.approx(100.0) # Excludes both 99-second warmups.
assert saved["request_stage_metrics"] == [{"0": {"num_tokens_out": 1, "finish_reason": "stop"}}] * 8
if save_detailed:
assert saved["stage_metrics"] == [snapshot] * 8
else:
assert "stage_metrics" not in saved


@pytest.mark.parametrize(
Expand Down
32 changes: 32 additions & 0 deletions vllm_omni/benchmarks/patch/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@
"openai-image-edits-omni",
}
)
_STAGE_METRICS_BACKENDS = frozenset(
{
"daily-omni",
"openai-chat-omni",
"/v1/videos",
*_IMAGE_STAGE_METRICS_BACKENDS,
}
)
_PRINT_STAGE = False
_SAVE_DETAILED = False


def maybe_enable_stage_metrics(extra_body: dict[str, Any] | None, *, enabled: bool) -> dict[str, Any] | None:
Expand All @@ -135,6 +144,8 @@ def should_request_stage_metrics(args: Any) -> bool:
return True

backend = getattr(args, "backend", None)
if getattr(args, "save_detailed", False) and backend in _STAGE_METRICS_BACKENDS:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] This hunk makes --save-detailed also set `extra_body.return_stage_metrics=tru…

Evidence and suggested fix

This hunk makes --save-detailed also set extra_body.return_stage_metrics=true for daily-omni, openai-chat-omni, /v1/videos, and the image backends (patch.py:138-139 via serve.py:68-71). That is a request-protocol change, not JSON-only: unchanged serving_chat._filter_stage_metrics_detail (serving_chat.py:470-471, applied at serving_chat.py:2096) then sends full snapshots on every chat/daily-omni SSE chunk (daily-omni reuses chat completions at patch.py:2577), and unchanged _apply_stage0_token_timings (patch.py:2181-2184) can fill Stage-0 ITL when client intervals are empty. /v1/videos is not SSE but still gates response metrics on the same flag (serving_video.py:447, unchanged). Image backends were already always-on (patch.py:140-141); openai-chat-omni already requested when tpot/itl percentiles are selected (patch.py:147). docs/cli/bench/serve.md:68-70 still describes --save-detailed as saving per-request ttfs/errors only (argparse help is not defined in this repo). Update that --save-detailed docs to say those Omni backends also ask the server for stage snapshots.

Evidence: Trigger: vllm-omni bench serve --save-detailed with backend daily-omni, openai-chat-omni (no tpot/itl percentiles), or /v1/videos. Adverse effect / unmet requirement: the flag now injects extra_body.return_stage_metrics, so the server request/response (and Stage-0 ITL fill) is no longer the compact workload, while docs still describe JSON-only per-request save. patch.py:138-139 if getattr(args, "save_detailed", False) and backend in _STAGE_METRICS_BACKENDS: / return True. patch.py:128 body.setdefault(RETURN_STAGE_METRICS_FIELD, True). serve.py:68-71 set_save_detailed(getattr(args, "save_detailed", False)) then args.extra_body = maybe_enable_stage_metrics(..., enabled=should_request_stage_metrics(args)). Unchanged by this diff, present in the PR-time tree: serving_chat.py:470-471 if cls._truthy_extra_body_flag(request, "return_stage_metrics"): / return metrics; serving_chat.py:2096 metrics=self._filter_stage_metrics_detail(omni_res.metrics, request),; patch.py:2181-2184 if exact_itls: / if sum(measured_itls) > 0: / output.itl = measured_itls. docs/cli/bench/serve.md:68-70 --save-detailed / "When saving the results, whether to include per request " / "information such as response, error, ttfs, tpots, etc.". Image backends already always-on unchanged: patch.py:140-141 if backend in _IMAGE_STAGE_METRICS_BACKENDS: / return True. openai-chat-omni tpot/itl already requested unchanged: patch.py:147 if backend == "openai-chat-omni" and selected_metrics.intersection({"tpot", "itl"}):.

Suggestion: if getattr(args, "save_detailed", False) and backend in _STAGE_METRICS_BACKENDS:
return True

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 0adccecd272b442d9552c14dbe226169727e4270: the CLI documentation now identifies the request-protocol change, affected backends, possible response/SSE overhead and Stage-0 timing fallback. Explicit return_stage_metrics=false is preserved and tested. The claim is artifact correctness, not unchanged measurement overhead.

AI assistance: TraeCode assisted with this verification and reply.

return True
if backend in _IMAGE_STAGE_METRICS_BACKENDS:
return True

Expand All @@ -160,6 +171,12 @@ def set_print_stage(enabled: bool) -> None:
_PRINT_STAGE = bool(enabled)


def set_save_detailed(enabled: bool) -> None:
"""Set whether this benchmark run retains per-request detail."""
global _SAVE_DETAILED
_SAVE_DETAILED = bool(enabled)


def _audio_continuity_threshold_s() -> float:
"""Return the per-request underrun budget (s).

Expand Down Expand Up @@ -969,6 +986,20 @@ class MixRequestFuncOutput(RequestFuncOutput):
duplex_session_metrics: dict[str, object] | None = None


def _request_stage_metrics(outputs: Iterable[object]) -> list[dict[str, dict]]:
"""Return one stage snapshot per request while preserving alignment."""
return [dict(getattr(output, "stage_metrics", None) or {}) for output in outputs]


def _add_detailed_stage_metrics(
result: dict[str, Any],
outputs: Iterable[object],
) -> None:
"""Attach request-scoped stage snapshots only to detailed results."""
if _SAVE_DETAILED:
result["stage_metrics"] = _request_stage_metrics(outputs)


_IMAGE_EDITS_EXTRA_BODY_FORM_FIELDS = (
"negative_prompt",
"num_inference_steps",
Expand Down Expand Up @@ -3692,6 +3723,7 @@ def measured_ttft(output: RequestFuncOutput) -> float | None:
if any(request_stage_metrics):
result["request_stage_metrics"] = request_stage_metrics

_add_detailed_stage_metrics(result, outputs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] This diff inserts _add_detailed_stage_metrics(result, outputs) immediately af…

Evidence and suggested fix

This diff inserts _add_detailed_stage_metrics(result, outputs) immediately after the result dict is built, writing a full per-request result["stage_metrics"] list (missing snapshots as {}) when _SAVE_DETAILED is set. Current main already serializes compact, request-aligned result["request_stage_metrics"] at that same builder via _compact_request_stage_metrics (token count, finish_reason, audio frames/duration; missing snapshots as None; vllm_itls_ms stripped), GitHub reports mergeable=false / dirty, and test_benchmark_preserves_stage_metrics_request_order_and_missing_snapshots asserts that compact contract. Rebase: keep the compact request_stage_metrics block unconditionally and attach the full snapshots only under --save-detailed as a second key — do not replace request_stage_metrics with stage_metrics, and do not land two arrays with different missing sentinels without an explicit name/schema split.

Evidence: PR head patch.py:730 result["stage_metrics"] = _request_stage_metrics(outputs) (missing → {}). Live main patch.py:3602-3606 request_stage_metrics = [_compact_request_stage_metrics(...) for output in outputs] then result["request_stage_metrics"] = request_stage_metrics. Live main tests/benchmarks/patch/test_patch.py:2101 assert result["request_stage_metrics"] == expected with expected None gaps and no itls.

Suggestion: request_stage_metrics = [
_compact_request_stage_metrics(getattr(output, "stage_metrics", None)) for output in outputs
]
if any(request_stage_metrics):
result["request_stage_metrics"] = request_stage_metrics
_add_detailed_stage_metrics(result, outputs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 0adccecd272b442d9552c14dbe226169727e4270: compact request_stage_metrics remains independent of detailed mode and the full map is an additional opt-in stage_metrics key. The documentation explicitly separates null and {} missing values. Tests cover both schemas through real benchmark construction and CLI-to-saved-JSON execution, including out-of-order completion, failed requests and warmups.

AI assistance: TraeCode assisted with this verification and reply.

# Plain-vLLM backends (e.g. the vLLM-text perf config) return upstream
# RequestFuncOutput objects without the Mix duplex fields; read them
# tolerantly or the whole benchmark result is discarded ("fallback to
Expand Down
2 changes: 2 additions & 0 deletions vllm_omni/benchmarks/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
maybe_enable_stage_metrics,
set_print_stage,
set_request_timeout_s,
set_save_detailed,
should_request_stage_metrics,
)

Expand Down Expand Up @@ -70,6 +71,7 @@ def main(args: argparse.Namespace) -> dict[str, Any]:
set_request_timeout_s(request_timeout_s)
_use_endpoint_backend_when_implicit(args)
set_print_stage(getattr(args, "print_stage", False))
set_save_detailed(getattr(args, "save_detailed", False))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P0] PR 6464 is mergeable=false / mergeable_state=dirty at head 1ed39c8

Evidence and suggested fix

PR 6464 is mergeable=false / mergeable_state=dirty at head 1ed39c8. This head’s serve.py:68 adds set_save_detailed(...) beside set_print_stage and has no Video-MME/timeout calls, while current main’s serve.main independently runs VIDEOMME_SAVE_EVAL_ITEMS and set_request_timeout_s(omni_request_timeout_s) at the same setup site. Rebase onto main and keep both wirings; resolving the serve.py conflict by taking this head would drop --omni-request-timeout-s.

Evidence: GitHub pulls/6464 (live, not in the diff): mergeable=false, mergeable_state=dirty, head=1ed39c8b9d935e003e5d30101b82db45790f8c64. PR-time vllm_omni/benchmarks/serve.py:23 set_save_detailed, and serve.py:68 set_save_detailed(getattr(args, "save_detailed", False)) — serve.py:59-68 has seed/daily-omni env flags then set_print_stage/set_save_detailed only, no videomme or timeout. Current main vllm_omni/benchmarks/serve.py (not in this PR diff; live https://raw.githubusercontent.com/vllm-project/vllm-omni/main/vllm_omni/benchmarks/serve.py): imports set_request_timeout_s (not set_save_detailed) and in main() runs if getattr(args, "videomme_save_eval_items", False): / os.environ["VIDEOMME_SAVE_EVAL_ITEMS"] = "1" then request_timeout_s = getattr(args, "omni_request_timeout_s", None) / set_request_timeout_s(request_timeout_s) before set_print_stage. Trigger: merge/rebase of this dirty PR. Adverse effect: taking this head’s serve.py as conflict resolution drops --omni-request-timeout-s and Video-MME eval-item wiring from main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 0adccecd272b442d9552c14dbe226169727e4270 by merging the inspected upstream and preserving set_request_timeout_s, Video-MME evaluation setup, endpoint initialization and detailed-metrics wiring together. The tested commit merges cleanly with the inspected origin/main@131a7731a. This is local conflict verification, not a claim that GitHub CI or the merge gate has passed.

AI assistance: TraeCode assisted with this verification and reply.

args.extra_body = maybe_enable_stage_metrics(
getattr(args, "extra_body", None),
enabled=should_request_stage_metrics(args),
Expand Down
Loading