Repository navigation
[Benchmark][Omni-DuplexEval] Send the drain padding and bound the session writes - #9802
Conversation
…sion writes Follow-up to vllm-project#9260, answering the review findings. - The drain padding called `append_audio`, which exists only on the duplex client in `vllm_omni/clients/duplex.py`; the runner's `RealtimeDuplexClient` has no such method, so the AttributeError was swallowed by the broad except and zero padding units were ever sent. Add `RealtimeDuplexClient.append_silence` — the same `input_audio_buffer.append` event shape `stream_av_units` sends, `_media_clock_ms` advanced — and call it from the padding loop, catching only `(ConnectionClosed, OSError)`. - A completed response whose close stalls now costs the handshake only: `close_timeout` stays out of `result.error` (metadata only), so the sample is published instead of retried and lost. - The outer close wait leaves room for the inner one (`timeout_s + _FORCE_CLOSE_GRACE_S`); at the same value the outer timer always fired first. - `TimeoutError`s raised by inner `wait_for`s before the per-request deadline are logged and retried as inner leaks, not as the benchmark's per-request timeout. - The judge format follow-up carries the parse error itself, and the first trace line records it. - The final commit write and the input stream are bounded (`_FINAL_COMMIT_TIMEOUT_S`, clip duration + `_INPUT_SLACK_S`); a stalled write is recorded (`commit_timeout` / `input_timeout`) and the sample still runs to completion. The 66-sample run quoted in vllm-project#9260 predates this fix: its padding was a no-op, so its 0/66 drain_timeout does not speak to how a real server reacts to appended silence. A real-client run is follow-up work. Signed-off-by: y_null <y_null@qq.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
amy-why-3459
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. I went through the runner/patch/client changes and the new tests. The append_silence fix, narrowing the padding's except, and the outer-close grace all look right to me. A few code-level points:
1. A completed reply with an input_timeout/commit_timeout is now failed and never retried. generate_sample puts input_timeout/commit_timeout into result.error regardless of response_done, so output.success is False for such a sample. But the new early return in _duplex_retryable_stall (if metadata.get("response_done") is True: return None) skips the retry for any completed response. test_stalled_input_is_bounded_and_the_sample_still_lands produces exactly this state (response_done=True, input_timeout set, in result.error), and that sample would end up as a non-retried failure. That doesn't match the description ("Both are retried once as transport stalls"); test_a_stalled_input_or_commit_write_is_retried_once only covers response_done=False. If only close/ack are meant to be advisory, the early return should probably apply only when no input/commit/drain timeout is recorded. Otherwise, please document that a stalled input on a completed reply is a final failure, and add a test for that case.
2. The wire-order claim for a stalled commit isn't covered. The _commit_clip_then_pad docstring says the stalled commit "stays queued ahead of the padding on the same socket". That only holds if RealtimeDuplexClient.send serializes writes FIFO (a lock, or the transport's write queue). The new test actually shows the opposite in the fake: the padding's append_silence calls land while the commit never does. If the server receives padding units before input_audio_buffer.commit, the residual unit the commit is shaped to flush changes, which is the thing the docstring says padding must not do. Could you either not start the padding when commit_timeout is set, or confirm (and test against the real client) that sends are strictly ordered?
3. Cancelling stream_av_units mid-write. asyncio.wait_for(client.stream_av_units(...)) cancels the coroutine on timeout, possibly in the middle of a websocket send. Depending on the websockets version, a cancelled send can leave a partially written frame and the connection unusable, so the follow-up commit and padding may fail with ConnectionClosed instead of flushing the residual unit. It may also leave _media_clock_ms and frame_sink (image_items) in a half-advanced state. It would help to say what state the client is in after this cancel, or to stop at a unit boundary instead.
4. Inner-timeout classification. inner_timeout = elapsed + 0.001 < deadline will raise TypeError if _omni_request_timeout_s() can return None (no per-request timeout) and an inner wait_for leaks a TimeoutError. If None is a valid value, please guard it. The 1 ms margin is also tight relative to event-loop timer resolution and scheduling jitter under load; a relative margin would be safer for the log label.
5. Minor. input_committed = commit_timeout is None records False even if the shielded commit lands a moment later. That's fine as a conservative flag, but a comment noting it would help readers of the metadata. Also, appending str(exc) from parse_judge_json to the judge follow-up is useful, but if that message can embed the raw reply, it duplicates the reply that's already echoed in the follow-up. It may be worth capping its length.
|
This PR appears to belong to: docs/design/module/benchmarking.md. Module owners: @alex-jw-brooks @Bounty-hunter @NickCao Routing: @alex-jw-brooks via module of the changed files, CODEOWNERS; @Bounty-hunter via module of the changed files, CODEOWNERS; @NickCao via CODEOWNERS @y-null, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer. Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment. |
…ng the drain The real MiniCPM-o server answers every padded silence unit with a `response.listen`. The drain's quiet window counted those listens as completion activity, so every padded step reset `stable_since` and left each drain to end at its timeout: measured 37/39 drains timed out with padding enabled, one of them logging 151 listens, and the run slowed from ~27 to ~180 seconds per sample. In 'none' mode the client sends nothing but silence after the commit, so the drain settle test now counts completion activity without `response.listen`; the replies' created/done/delta events still count, and the server-turn path is untouched. A regression test replays the server's per-unit listens against the drain and asserts the sample settles without a drain timeout; it fails on the previous tip. tests/benchmarks: 876 passed. Measured end to end against the MiniCPM-o server over 66 Omni-DuplexEval samples: 66/66 published with `response_done`, 0 drain timeouts, and the full run back under an hour. The pre-fix baseline also published every sample's sentences, but timed out 37/39 drains. Signed-off-by: y_null <y_null@qq.com>
|
Real-client numbers for this PR, and one more fix they forced. What the first real run exposedWith Fixed in 3a58649: in End-to-end, same machine, same commands
Scoring is unaffected by the drain change: the pre-fix 39 samples and the post-fix first 39 score 0.846 vs 0.769 mean all-success, within one standard error of a single run (server sampling is temperature 0.8). Artifacts and per-sample metas are retained under |
amy-why-3459
left a comment
There was a problem hiding this comment.
Incremental look at 3a58649f only (the one new commit on top of 42989003, which I reviewed earlier). I read the diff; I did not run it.
Excluding response.listen from the drain's quiet-window count makes sense given the measurement (every padded unit acknowledged with a listen), and the new test reproduces that shape with listens paced faster than the settle window. A few points:
- Stale docstring.
_pad_silence_until_stoppedstill says non-speech units "are expected to produce noresponse.listen/delta events and not to reset the completion activity window". After this commit that's no longer the assumption the drain relies on; it might be worth rewording it to say the server does acknowledge padded units with listens and that_DRAIN_ACTIVITYis what keeps them from resetting the window, so the next reader doesn't chase the wrong lead. - A drain whose only post-commit events are listens. Previously a model that chose to keep listening (no reply to the committed clip) still produced "activity" after the commit; with
_DRAIN_ACTIVITYthat drain now sees zero activity. Could you double-check thatresponses_finished(including thesettled_silence_is_terminalpath) treats "no non-listen activity since the commit" the way you intend, i.e. it settles rather than waiting for the drain timeout, and maybe add a small case for it next to the new test? - Scope of the exclusion.
_DRAIN_ACTIVITYis applied to the whole post-commit window. If the server ever emits a reply-bearingresponse.listen(e.g. one that carries state the reply depends on) during the drain, it will no longer count. Not an issue with today's server as far as I can tell, just flagging that the comment ties this to'none'mode while the helper doesn't check the mode itself. - Test strength (minor).
test_padding_listens_do_not_reset_the_drain_quiet_windowassertsdrain_timeout is None, which is the key property. Asserting that at least oneresponse.listenwas actually recorded after the commit would make sure the test keeps exercising the exclusion and can't pass just because the fake's listen task never got scheduled before the settle.
|
Nightly-aligned validation on the followup tip, run through the same entry as the nightly job (
Published 66, The 66/66 run with real padding, this C8 replay, and the earlier serial run agree: the padding now streams, the drain settles, and the run is faster than the no-op-padding nightly. |
…ests ruff format drops the redundant line breaks and stray blank lines the two new test files carried; no behavior change. The code hooks of `pre-commit run --all-files` (fix end of files, ruff check, ruff format) pass on the branch. Signed-off-by: y_null <y_null@qq.com>
amy-why-3459
left a comment
There was a problem hiding this comment.
Incremental review of 882c7f3: this commit is ruff format only on tests/benchmarks/duplex/test_omni_duplex_eval_drain_padding.py and tests/benchmarks/patch/test_duplex_eval_retry.py (joined line breaks, removed trailing blank line). No behavior change and nothing new to flag beyond my earlier comments.
|
Thanks @amy-why-3459 — all four points from the 3a58649 review are addressed in 7c98035, none left open.
|
- Reword the padding docstring: the server acknowledges every padded unit with a response.listen, and _DRAIN_ACTIVITY is what keeps those acknowledgements from resetting the quiet window. - Note next to _DRAIN_ACTIVITY that no runtime mode check is needed: the helper is only reachable through the padding path (server_turns false). - Assert that padding listens actually landed in the quiet-window test so it cannot pass vacuously. - Add a test for a committed clip whose only post-commit event is a model-listen: the drain settles on it instead of hitting drain_timeout. Signed-off-by: y_null <y_null@qq.com>
7c98035 to
4c9506f
Compare
amy-why-3459
left a comment
There was a problem hiding this comment.
Incremental look at 7c98035a only (the one new commit on top of 882c7f38, which I reviewed earlier). I read the diff; I did not run it.
The reworded _pad_silence_until_stopped docstring and the _DRAIN_ACTIVITY comment now match how the drain actually behaves, and the new test_drain_settles_when_the_committed_clip_gets_only_a_listen covers the listen-only case I asked about. Two small test notes:
assert sent_listenschecks that listens were sent, not that they arrived after the commit. The comment says the exclusion is exercised "only if padding listens actually landed after the commit", butsent_listensis appended to on every send, so iflisten_everycan start beforeinput_audio_buffer.commitis handled, a listen sent before the commit would satisfy the assertion. If the task is only started on commit, this is fine as is. Otherwise, recording the index or time of the commit in the handler and asserting at least one listen after it would make the assertion match the comment.- The listen-only test can't tell "settled because of the listen" apart from "settled on an empty quiet window".
drain_timeout is Nonewould also hold ifresponses_finishedsettled without ever looking at the listen. If the point is that_residual_unit_decidedfires on the model-listen decision, a variant where the fake sends no listen at all, plus an assertion on what that variant does, would pin down that the listen is what drives the decision. That's optional, but it would keep the test meaningful if the settle logic changes later.
Nothing else to flag in this commit.
Omni ReviewBot routing recordAssigned Strict on claude-code (mimo-v2.6-pro) under experiment |
Omni ReviewBot review statusReview for commit |
amy-why-3459
left a comment
There was a problem hiding this comment.
Review of 4c9506fcd8bb6448cf1802e5e48e859e697cd27a (static inspection; I did not run tests).
-
Fix the completed-response guard in
vllm_omni/benchmarks/patch/patch.py:316. The earlier input-timeout finding remains unresolved:test_stalled_input_is_bounded_and_the_sample_still_landsproducesresponse_done=Truewithinput_timeoutincluded inresult.error._duplex_retryable_stallthen returns early, so this recoverable transport failure becomes a final failed sample without the promised retry. A completed reply does not establish that the complete input reached the server. Restrict the exemption to advisory close/ack failures, preserving the existing checks for deterministic errors, and add retry coverage forresponse_done=Truecombined with input/commit timeouts. -
Propagate or explicitly record unexpected padding-task exceptions. Narrowing the catch in
vllm_omni/benchmarks/duplex/omni_duplex_eval_runner.py:612does not yet make coding errors surface:generate_samplestill catches and discards everyExceptionwhen awaitingsilence_taskat lines 854–858. AnAttributeErroror other programming error therefore still silently stops padding and can appear as a drain timeout. Keep expected transport shutdown best-effort, but expose unexpected failures and cover this throughgenerate_sample, not only by awaiting the padding helper directly.
The client/runner responsibility split is reasonable; the completion guard above is the main correctness issue. The diff-scoped redundancy pass found no additional actionable removal.
Please provide complete repository unit-test evidence for the updated head after these fixes, covering the CPU shards and applicable backend-specific UT jobs, with commands, environment/dependency versions, result counts, and CI or complete-log links. Explain skipped/deselected tests and unavailable jobs; compare base/head in the same environment for any pre-existing failures. The benchmark-directory results and the reported 66-sample runs on earlier revisions do not establish full-suite coverage on this head. Maintainers own admission to the project's Buildkite jobs. Merge readiness remains unestablished until the required evidence is available or maintainers explicitly disposition the gaps.
… surface padding errors Review follow-ups on 4c9506f: - A completed reply does not establish that the whole input reached the server. _duplex_retryable_stall no longer exempts every finished sample: an input or commit stall under response_done=True is retried once like any other transport stall, and only the advisory close of a completed reply stays exempt (server_errors and reported failures still get exactly one attempt). Covered by a parametrized retry test. - generate_sample no longer swallows every exception from the padding task: an already-dead transport (ConnectionClosed/OSError) stays best effort, while a coding error in the padding path now propagates and lands in the sample's error instead of masquerading as a drain timeout. Covered through generate_sample with a broken append_silence. - The three fullduplex e2e tests predate the drain padding and counted the padded zeroed units as clip audio (and one expected the reply's media timestamp before the padding advances the clock). Scope the clip assertions to the wire before the commit and split the realtime / as-fast-as-possible timestamp expectation. Signed-off-by: y_null <y_null@qq.com>
|
Both fixes from this review are in 0819c49, together with one find of my own that the requested test evidence surfaced.
Unit-test evidence on 0819c49 (venv: Python 3.12.13, pytest 9.1.1, pytest-asyncio 1.4.0, ruff 0.14.10):
Gap, stated plainly: I cannot run the repository's full CPU shard locally — |
amy-why-3459
left a comment
There was a problem hiding this comment.
Incremental review of 4c9506fcd8..0819c4904d (static inspection of the new commit; I did not run tests).
Both findings from the previous review look addressed:
_duplex_retryable_stall(vllm_omni/benchmarks/patch/patch.py:316-324) now keeps theserver_errors/ reported-error checks first, and only exempts aclose_timeoutunderresponse_done=True. Since_duplex_timeout_reasonchecksinput_timeout/commit_timeoutbeforeclose_timeout, a completed reply that also has an input or commit stall is retried even when the close also stalled.test_an_input_or_commit_stall_under_a_completed_reply_is_retriedcovers this, because_session_resultdefaults toresponse_done=True.- The
silence_taskcleanup ingenerate_sample(omni_duplex_eval_runner.py:850-863) now swallows onlyCancelledErrorandConnectionClosed/OSError, andtest_a_padding_coding_error_surfaces_through_the_sampleexercises the full path throughasync_request_openai_realtime_duplex. Because the session runs insideasync with client, the transport still gets torn down when the error propagates and skips the playback ack and close.
Remaining points (non-blocking):
- The new e2e expectation
sentence_time = 0.8 if pace == "realtime" else 1.8(tests/e2e/features/fullduplex/test_omni_duplex_eval_cli.py:381-386) relies on the first padded unit reaching the wire before the reply delta is processed in the as-fast-as-possible pace. That ordering comes from event-loop scheduling between the padding task and the reader rather than anything the test controls, so it could flake on a slower or loaded runner. If the fake server can make the order deterministic (for example, sending the delta only after it receives the first post-commit append), the assertion would stop depending on timing. Otherwise, asserting on the realtime value and accepting either value for the fast pace would be enough. - A retry now throws away a completed reply's artifacts when that reply had an input or commit stall. If the second attempt then fails (for example, with a server error), the sample ends up recorded as failed even though the first attempt produced a full reply. That might be acceptable for this benchmark, but it would help to state the behavior in the docstring or
retry_countmetadata, or to add a test that pins it down, so it's an explicit choice. - Minor: if the drain body raises something other than
TimeoutErrorand the padding task then fails with a coding error, the padding exception replaces the original one, which survives only as__context__. That's an unlikely combination and doesn't need a change. I'm mentioning it only in case the traceback inoutput.errorturns out to be confusing.
The test evidence is noted. I have not independently verified CI on 0819c4904d. The Buildkite CPU and backend shards are still with maintainers, as you said.
Follow-up to #9260, answering @amy-why-3459's review findings.
1. The drain padding never sent a single unit (blocking).
_pad_silence_until_stoppedcalledclient.append_audio(...), which exists only on the duplex client invllm_omni/clients/duplex.py; the runner instantiatesRealtimeDuplexClient, which has no such method, so theAttributeErrorwas swallowed by the broadexcept Exceptionand the padding task ended before sending anything.RealtimeDuplexClient.append_silencenow sends the sameinput_audio_buffer.appendevent shapestream_av_unitssends —duration_ms/audio_end_msincluded,_media_clock_msadvanced — so the server's step loop can keep stepping on appended silence as it did on the clip. The padding loop catches only(ConnectionClosed, OSError).Tests: the fake client surface is now guarded by a mirror check against the real class (
test_the_fake_client_mirrors_the_real_client_surface), andtest_padding_works_against_the_real_clientruns the real client with onlysendstubbed, asserting the append events and the advancing media clock.2. A completed sample whose close stalls is published, not retried and lost.
close_timeoutno longer ridesresult.erroronce the reply is done: it stays in metadata,output.errorstays empty, and the sample goes throughfinalize_duplex_evalunchanged._duplex_retryable_stallreturns nothing forresponse_done=True, so the close cost is the handshake, not a full retry that could lose the sample. (test_completed_sample_with_a_stalled_close_is_not_retried,test_completed_sample_with_a_stalled_close_is_published.)3. The outer close wait leaves the inner one room.
_close_duplex_sessionnow waitstimeout_s + _FORCE_CLOSE_GRACE_Soutside; at the same value the outer timer always fired first and force-aborted closes that would still have receivedsession.closed.4. Inner
wait_forleaks are no longer logged as the per-request timeout.The retry branch compares elapsed time against the per-request deadline: a
TimeoutErrorraised well before the deadline (e.g. the configure handshake) is logged as an innerwait_forleak, the deadline itself keeps its own message. (test_timeout_logging_distinguishes_inner_and_outer_timeouts.)5. The judge follow-up says what failed to parse.
Any
ValueErrorfromparse_judge_json— a prose reply or a malformed value in a present field — now carries the parse error into the follow-up, and the first trace line records it, so the trace shows which reply failed and why. (test_parse_error_reaches_the_retry_and_the_trace.)Also bounded — the same unbounded session write, earlier in the epilogue:
_FINAL_COMMIT_TIMEOUT_S; on a stall thecommit_timeoutis recorded and the padding still starts;_INPUT_SLACK_S; on a stallinput_timeoutis recorded and the sample still lands with its transcript (input_committed=False).Both are retried once as transport stalls by the existing retry logic. (
test_stalled_commit_is_bounded_and_padding_still_starts,test_stalled_input_is_bounded_and_the_sample_still_lands,test_a_stalled_input_or_commit_write_is_retried_once.)Test evidence:
tests/benchmarksfull directory: 875 passed locally (plain pytest run). Two fake servers were updated because the padding is real now: in the lifecycle test'snoneturn-detection mode the committed clip's reply drains on the first padding unit, so its transcript lands at 0.01 + 1.0s (the padding advances the media clock); in the multiturn test the streamed clip's reply fires once — appended silence no longer re-triggers it.Follow-up (needs GPU, out of scope here): the 66-sample run quoted in #9260 predates this fix — its padding was a no-op, so its 0/66
drain_timeoutdoes not speak to how the real server reacts to appended silence. A real-client 66-sample run and the full-repo UT will follow separately.