feat(config): consolidate all time knobs into settings.timeouts; make --timeout a real run watchdog - #409
feat(config): consolidate all time knobs into settings.timeouts; make --timeout a real run watchdog#409viraatc wants to merge 45 commits into
Conversation
|
MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅ |
There was a problem hiding this comment.
Code Review
This pull request refactors the configuration schema by centralizing all global durations, deadlines, and timeouts into a new frozen Pydantic model Timeouts (accessible via settings.timeouts). This separates workload durations from failure-handling deadlines. Additionally, a whole-run watchdog (run_timeout_s) has been introduced to gracefully abort stuck runs, signaling managed subprocesses via SIGTERM to write an interrupted final snapshot before exiting non-zero. All configuration templates, examples, and tests have been updated to align with this new schema, and new integration tests have been added to verify the watchdog behavior. No review comments were provided, so there is no feedback to address.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Findings from the review council (Codex review + adversary council), all verified against the code before fixing: - Watchdog now stays armed through the unbounded metrics drain: it was cancelled right after session.run, so run_timeout_s could not bound a stuck aggregator drain (wait_for_exit(None)). Cancelled after services exit instead. - Watchdog SIGTERMs only the metrics aggregator (ServiceLauncher.terminate with module suffix, replacing terminate_all): SIGTERMing the event logger dropped its buffered events.jsonl tail; the logger flushes on the ENDED event, which session.stop() still delivers. - A timed-out run skips accuracy scoring in finalize: phases that never started KeyError in scorer init and partial phases would yield misleading subset scores. Artifacts are still salvaged. - Teardown race no longer skips finalization: if session.run raises after the watchdog fired, fall through with an empty SessionResult so result_summary.json (INTERRUPTED, complete=false) is always written; run_benchmark raises the timeout ExecutionError after finalize. - run_audit maps a watchdog fire to ExecutionError naming the timeout instead of the Ctrl-C KeyboardInterrupt path (exit 130). - MetricsConfig gets cyclopts.Parameter(name='*') matching sibling settings blocks (flat --tokenizer-workers + --metrics-tokenizer-workers). - Stale drain-key name fixed in session.py docstring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arekay-nv
left a comment
There was a problem hiding this comment.
I think the schema breakdown make sense and is a lot cleaner.
Regarding the timeouts - two suggestions, and feedback is welcome:
- Remove the duration field - makes it simpler especially since we are mostly going to be doing concurrency based runs.
- Modularize the phases with an explicit type of phases and dependencies, but move the per-phase timeouts/drains etc there.
So a global timeout for everything - and a per-phase config for controlling how a phase behaves. We can have some explicit dependencies such aswarmupalways goes beforeperformance,reportingcomes afteraccuracyetc.
…s; make --timeout a real run watchdog Reworked from PR #409 review feedback, rebuilt on latest main: - New frozen Timeouts model at settings.timeouts holds every give-up deadline: run_timeout_s (--timeout, whole-run watchdog), service-ready, per-phase drains (absorbs DrainConfig), metrics drain (0-sentinel killed; None = unlimited), and the worker lifecycle waits (moved off settings.client; carriers renamed *_s, excluded from dumps and CLI). - --timeout was consumed nowhere; it now aborts the run: session.stop() then SIGTERM the aggregator (INTERRUPTED final snapshot, first-wins), ExecutionError after finalization - a fired watchdog can never yield a COMPLETE result_summary.json. Deadline is captured before setup; the timer stays armed through the metrics drain. Timed-out runs skip accuracy scoring; audit phases map a fired watchdog to ExecutionError. - publish_final serialized with an asyncio.Lock: a SIGTERM racing the ENDED-driven finalize can no longer abandon a half-written snapshot. - runtime.min_duration_ms/--duration deleted: sample count is explicit (--num-samples) or the dataset issued once. max_duration_ms stays in runtime as the perf-phase workload cap (int|None, gt 0); reaching it is a normal end. MLPerf ruleset path (RuntimeSettings/UserConfig) keeps its internal duration fields. - ServiceLauncher.terminate(module): exact-match SIGTERM; MetricsPipeline.terminate_metrics_aggregator() is the narrow public face. - config/schema.py split into enums/audit/model_params/datasets/settings/ timeouts modules; schema.py keeps the root aggregate + re-export hub. SystemDefaults and TEMPLATE_TYPE_MAP deleted. - Examples, templates, and docs migrated; docs gain a YAML<->CLI time-knob table. Stale inert timeout: values dropped, warmup drain removed from examples. Breaking: bare configs (no --num-samples) now run the dataset once instead of deriving QPS x 10min samples; old YAML keys hard-error via extra=forbid.
…GINT restore Two holes found by post-simplification review: 1. Grace expiry SIGKILLs a wedged aggregator, so the report is built from the subscriber's last LIVE pub/sub snapshot - and the split-brain guard only rewrote state=="complete", letting an aborted run persist result_summary.json with state:"live". The guard now rewrites any aborted non-interrupted state; the wedge integration test asserts the summary lands interrupted/complete:false and the unit guard test is parametrized over both snapshot states. 2. signal.signal(SIGINT, None) raises TypeError, so the sentinel-based restore was broken for exactly the case it existed for (a C-installed previous handler, getsignal()->None). Restoring SIG_DFL instead would destroy the host's handler, so the governor now probes getsignal() first and refuses to install over an unrepresentable C handler (stays passive, same stance as off-main-thread); the restore path only ever sees Python-representable handlers. The _SIGINT_NOT_INSTALLED sentinel, its object-typed local, and the type:ignore are gone (both run_benchmark and run_audit).
…edence, shard reaping Council findings (two parallel reviewers, addressed locally): Correctness: - finalize_benchmark writes the split-brain-rewritten report back onto BenchmarkResult, so the audit runner's post-phase state check sees the honest interrupted state instead of the aggregator's stale COMPLETE (a drain-window ^C during the final audit phase could previously certify a PASS). - run_benchmark refreshes user_interrupted from the governor after the loop returns: a ^C landing between the coroutine's flag snapshot and task completion can no longer produce COMPLETE artifacts on an exit-130 run. - A drain/report failure on an interrupted run no longer re-raises as ExecutionError (exit 4): the user's abort outranks a drain error its own grace escalation may have caused - exit stays 130. - The finalization KeyboardInterrupt handler now covers every post-measurement write (scoring, summary log, profiling.json, accuracy_results), rewriting and RE-persisting the summary as interrupted if it was already written COMPLETE. - Tokenizer shard workers arm PR_SET_PDEATHSIG=SIGKILL in their initializer: a SIGKILLed aggregator (watchdog / teardown-grace escalation) can no longer orphan the non-daemon ProcessPool shards that are outside every launcher PID list. Behavioral test reads PR_GET_PDEATHSIG back from a real pool worker. - SIGINT install/restore is one shared context manager (watchdog.sigint_policy) used by run_benchmark and run_audit: probes getsignal() first (an unrepresentable C handler stays untouched, governor passive), restores after the caller's finally so a repeat ^C during tmpfs salvage still hits the governor's no-op. Quality: _on_global_timeout renamed _on_perf_phase_timeout (it only caps the perf phase), Timeouts docstring no longer promises None for service_ready_timeout_s, sigint test helpers deduplicate the /proc scan and name timeout_s, grace-expiry unit test asserts exactly one fire, timeouts validation tests pin the failing field (extra=forbid would mask typos), dict annotations typed.
…able-code false positive)
|
|
||
| def test_restores_on_exception(self): | ||
| gov = SigintGovernor() | ||
| prev = signal.getsignal(signal.SIGINT) |
Cut the whole-finalize KeyboardInterrupt re-persist wrapper (guarded a millisecond window between artifact writes; the exit code is the documented run-outcome verdict) and the tokenizer-shard pdeathsig guard (pre-existing risk - main's terminate_all already SIGKILLs - and CF workers self-reap on queue EOF; follow-up material). The scoring-window invalidation, split-brain write-back, user_interrupted refresh, and exit-130 precedence stay: each enforces the documented contract in 1-3 lines.
…s a config knob Size pass: PerfPhaseTimeout returns to its original execute.py location (verbatim, as _PerfPhaseTimeout) and SigintGovernor/sigint_policy/ RunWatchdog live beside it - the separate watchdog.py module double- counted the moved code in the diff. Multi-line rationale comments compressed to their load-bearing core. The teardown grace is now settings.timeouts.teardown_grace_s (default 30; None = never abandon; 0 = abandon immediately) consumed by both the ^C governor and the run watchdog; the wedged-drain integration test shrinks it via YAML instead of a python -c constant override. The five per-drain CLI alias flags are dropped - the auto-generated dotted flags remain and the docs table now shows those spellings; --timeout stays. Non-test churn: 1694 -> 1514.
| ) from e | ||
| raise | ||
| finally: | ||
| watchdog.cancel() |
There was a problem hiding this comment.
[review-council: Claude+Codex+Grok] medium — accuracy scoring / finalize is not covered by run_timeout_s; a hung external scorer hangs an otherwise-clean run. RunWatchdog is cancelled here (watchdog.cancel()), then run_benchmark calls finalize_benchmark → score_accuracy → scorer.score() with no live deadline. For SKIP_ENDPOINT_PHASE scorers that delegates whole agent execution + grading to an external service (SWE-bench swebench_service_url), and VBench/LCB spawn uv run subprocesses — any can block indefinitely on a clean run, and only a manual ^C escapes. Acknowledged in CLI_QUICK_REFERENCE.md:166 ("not deadline-bounded") but contradicted by this field's own docstring (schema.py:838 "through every phase and drain") and CLI_QUICK_REFERENCE.md:188 ("the only total-wall-time bound"). Either bound scoring with a dedicated scorer timeout, or reconcile the three statements.
| Priority: | ||
| 1. If `n_samples_to_issue` is set, return it (explicit override) | ||
| 2. If min_duration_ms=0, return all dataset samples (new CLI default) | ||
| 2. If no duration target is set, return all dataset samples |
There was a problem hiding this comment.
nit: min_duration to make clear where it should be (duration now applies to
Every bare 'duration' the MR added is now explicit: the sizing input is min_duration_ms (poisson-only, target_qps x min_duration_ms, sizing not a timer), the runtime cap is max_duration_ms, and the give-up deadline is run_timeout_s. total_samples_to_issue's priority docstring names the actual rule per step; offline/concurrency runs are called out as purely count-driven (min_duration_ms with them is a config error).
The grace applies exactly to runs being marked INTERRUPTED (^C or the run watchdog); the name now says so. Config field, constructor params, docs table, and templates renamed together.
…_duration_ms docstring The interrupt machinery (SigintGovernor, sigint_policy, RunWatchdog) returns to commands/benchmark/watchdog.py - the module reviewers reviewed; the perf-phase cap (_PerfPhaseTimeout) stays at its original execute.py location. RuntimeSettings.min_duration_ms now documents the full contract: sizing input (target_qps x min_duration_ms), never a runtime timer, n_samples_to_issue wins, populated from settings.runtime.min_duration_ms or directly by rulesets. Reverts the gratuitous 'duration floor' -> 'min_duration_ms floor' renames in the compliance docs (the original text was already precise).
… public boundary run_benchmark_async gives callers without a governor (audit phases get one from run_audit; embedded/test callers) a passive SigintGovernor - never installed as a signal handler, interrupted stays False - so _run_benchmark_async requires it and the five scattered 'sigint is not None' guards collapse into plain attribute access. The adjacent report-persist guards in finalize merge into one block. Also restores the compliance-plan min_duration blockquote verbatim from main (the branch's condensed rewrite had dropped the 'merely derives a count' explanation; the file is now untouched by this MR).
…run_timeout_s is a hard bound arekay's review round: - HIGH: a ^C during service launch / endpoint connect (task bound, session not yet) previously raised raw KeyboardInterrupt out of the loop, skipping the pipeline __aexit__ and orphaning the aggregator/event-logger children. The governor now cancels the run task - exactly like the watchdog's pre-session fire - so the unwind kills the children; _run_benchmark_async maps the cancellation back to KeyboardInterrupt for exit 130. Unit test pins the cancel. - MED: interrupted_teardown_grace_s: null no longer softens run_timeout_s - the watchdog's SIGKILL escalation always arms (null grace applies to the ^C path only, as documented). - nits: redundant cyclopts help= strings that duplicated the field description are dropped (cyclopts falls back to the description); templates regenerated.
| | `--num-prompts`, `-n` | Number of prompts | 100 | | ||
| | `--endpoint` | Server URL | `http://localhost:8000` | | ||
| | `--max-output-tokens` | Max output tokens | 2000 | | ||
| | `--timeout` | Whole-run watchdog (seconds) passed to inference-endpoint; firing aborts the run | 900 | |
There was a problem hiding this comment.
Wondering if 900 is too short and whether it's worth changing to something like an hour or two
| name: "edge-agentic-full-run" | ||
| version: "1.0" | ||
| type: "online" | ||
| timeout: 21600 # 6 h: ~2.5 h perf + ~3 h accuracy, with headroom. |
There was a problem hiding this comment.
Dumb question, where is this 21600 timeout set, if not in the config?
| 3. **Optional CLI overrides.** `--timeout` and `--mode` applied via `config.with_updates(...)` which re-runs validators. | ||
| 3. **Optional CLI overrides.** `--timeout` maps into `settings.timeouts.run_timeout_s` via `with_updates(...)` (re-runs validators); `--mode` selects the `TestMode` passed to the runner and never touches the config object. | ||
|
|
||
| > CLI `--timeout` overrides YAML `settings.timeouts.run_timeout_s`. |
There was a problem hiding this comment.
I saw we are removing the CLI timeout in lots of places, why are we not adding the run_timeout_s in the config?
| interrupted_teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). | ||
| service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. | ||
| warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) | ||
| performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) |
There was a problem hiding this comment.
Cna you help add a bit more comment on how performance_drain interacts with metrics_drain? (Would be helpful to understand whether it's fast-failing like metrics drain) and how it's timed
My guess is both performance drain and metrics drain happen after the last response is returned. Trying to understand how they are controlled differently
| gt=0, | ||
| description="Accuracy drain timeout in seconds (None = wait indefinitely)", | ||
| ge=0, | ||
| description="Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", |
There was a problem hiding this comment.
Similar here, need to expand the readme a bit more
nvzhihanj
left a comment
There was a problem hiding this comment.
Review Council — Multi-AI Code Review (re-review)
Re-reviewed against the stated design principle: correct answers or no answers — never a plausible-looking wrong number. Posting the critical + high findings only.
| await self._publisher.publish_final(registry, n_pending_tasks=n_pending) | ||
| await self._publisher.publish_final( | ||
| registry, | ||
| n_pending_tasks=n_pending, |
There was a problem hiding this comment.
[Claude] critical: Root cause for the drain-abandonment findings below. n_pending_tasks counts pending tokenizations only — it has no notion of a query the load generator gave up waiting for.
A sample abandoned by a skipped or expired response drain never produces a QueryResult, so session.py publishes no COMPLETE and no ERROR. aggregator.py:390-393 increments tracked_samples_failed only on an ERROR event. The sample was already counted at ISSUED. Net result: it lands in tracked_samples_issued and in neither tracked_samples_completed nor tracked_samples_failed.
So issued == completed + failed silently breaks and nothing asserts it. A run that dropped 20% of its samples looks like a run where 20% simply vanished — no error count, no warning, complete: true, exit 0.
Highest-leverage fix: reconcile in Report.from_snapshot (report.py:353-374) — when n_samples_issued != n_samples_completed + n_samples_failed, set complete=False (or add n_samples_dropped and give the run_benchmark ladder an arm for it). One check closes every drain-abandonment path at once, including future ones.
| @@ -898,59 +886,87 @@ def _on_global_timeout() -> None: | |||
| # perf cap. | |||
| session.stop_current_phase() | |||
There was a problem hiding this comment.
[Claude] critical: max_duration_ms reaching its cap skips the response drain entirely — performance_drain_timeout_s is never consulted.
stop_current_phase() sets _current_phase_stopped = True, which is the third clause of _drain_inflight's early return (session.py:554-559):
if (phase_issuer.inflight <= 0 or self._stop_requested or self._current_phase_stopped):
returnEvery in-flight response at cap time is abandoned. Because stop_current_phase deliberately does not set _stop_requested, no INTERRUPTED marker is published — the snapshot is a clean COMPLETE, exit 0.
The in-flight set at a cap boundary is systematically the longest requests, and the triggers split: ISL (ISSUED_NS) and TTFT (RECV_FIRST_NS) are recorded for them, latency/OSL/TPOT (COMPLETE_NS) are not. So p99 latency, p99 TPOT and OSL come out optimistically wrong while ISL/TTFT look normal.
This is the highest-volume instance: max_duration_ms is set in ~12 shipped examples. docs/CLI_QUICK_REFERENCE.md:186-187 currently blesses it — "in-flight requests are abandoned (no drain), the report is valid."
Fix: the cap should end issuing and then still drain, as the docs' own two-stage diagram implies. Drop self._current_phase_stopped from the session.py:554-559 guard (keep _stop_requested, the genuine abort); the re-check-after-clear at session.py:561-566 already covers the "cap fired while already inside the wait" case.
There was a problem hiding this comment.
Is this the intended behavior? This is the confusion I had around the different timeouts
| @@ -460,7 +470,11 @@ async def process(self, records: list[EventRecord]) -> None: | |||
| MetricCounterKey.LEGACY_LOADGEN_WINDOW_DURATION_NS.value, | |||
| table.total_loadgen_window_ns, | |||
There was a problem hiding this comment.
[Claude] critical: TPS/QPS numerator and denominator can truncate independently — the exact failure mode this PR exists to eliminate.
A perf sample abandoned by a skipped/expired drain keeps its row in _in_flight. If an accuracy phase follows, the recv task is still alive, so its COMPLETE arrives minutes or hours later; metrics_table._update_tracked_block then pushes block.last_complete_ns and _loadgen_window_end_ns deep into the accuracy phase. This line re-publishes the window from those extended values at ENDED, and report.py:327-334 divides by it:
qps = (n_completed - 1) / window_s
tps = osl.get("total", 0) / window_sAccuracy-phase samples contribute nothing to osl.total (their ISSUED arrives with is_tracking == False), so the denominator grows while the numerator gains only the straggler's tokens. One stuck request returning 1700 s into an 1800 s accuracy phase turns a 600 s window into 2300 s — TPS understated ~3.8x, complete: true, exit 0.
Fix: freeze the tracked block at STOP_PERFORMANCE_TRACKING — either evict still-in-flight rows (recording them as dropped) or mark the block closed so _update_tracked_block refuses to extend past the stop event.
| PhaseType.PERFORMANCE, | ||
| strategy=perf_strategy, | ||
| drain_timeout=drain_cfg.performance_timeout_s, | ||
| drain_timeout=timeouts.performance_drain_timeout_s, |
There was a problem hiding this comment.
[Claude] critical: When performance_drain_timeout_s expires, the abandoned samples are dropped from the measurement and the run still reports complete: true, exit 0.
_drain_inflight logs one ERROR and returns (session.py:572-578); nothing downstream observes the residual phase_issuer.inflight, so _run_phase proceeds to stop_performance_tracking() and builds a normal PhaseResult. Per the root-cause comment on aggregator.py, those samples are counted in n_samples_issued but in neither completed nor failed.
A user who sets this knob to escape a hang therefore buys a silently-low QPS and optimistically-low latency/TPOT/OSL tails. Under "correct answers or no answers" this is the wrong side: it should be no answer.
Also: the expiry message doesn't say which knob fired — the same line serves warmup, performance and accuracy (execute.py:615/626/681), printing only the value. PhaseConfig already carries phase.name.
Fix: have _drain_inflight return the residual in-flight count; if it is non-zero for the PERFORMANCE phase, mark the report incomplete and give the run_benchmark ladder an arm that raises. Name the phase and the knob in the log line.
| "the tokenizer failed mid-drain — see the aggregator log; " | ||
| "report is partial (complete: false in result_summary.json)" | ||
| ) | ||
| if bench.report is None: |
There was a problem hiding this comment.
[Claude] critical: The outcome ladder has no arm for state: "live" / "draining", so this path exits 0 with a complete: false report. Confirmed by tracing to main.py — nothing raises, run_benchmark returns normally, exit code 0.
drain_and_build_report falls back to subscriber.latest when final_snapshot.json is missing (pipeline.py:354-363), and the tick task only ever emits LIVE or DRAINING. Reachable without any watchdog or ^C: the aggregator OOM-killed or crashed after STARTED, or the atomic write failing on a full disk. The split-brain guard at execute.py:1187 doesn't help — it is gated on run_timed_out or user_interrupted, both False here.
The artifact is populated from a mid-run tick: HDR-approximate percentiles, counters frozen at the last tick, and a tps/qps over a tracked_duration_ns that was never finalized. CI and && chains see success.
Fix: replace the enumerate-the-states approach with one rule, placed before the report is None arm:
if bench.report is not None and not bench.report.complete:
raise ExecutionError(f"Report is not complete (state={bench.report.state})")That subsumes the existing 1325-1342 arm and makes the invariant literally exit 0 iff complete: true.
| # process group. The default KeyboardInterrupt would kill this | ||
| # child mid-run and lose every buffered (unflushed) event record; | ||
| # the parent's ENDED event is the authoritative shutdown signal. | ||
| loop.add_signal_handler( |
There was a problem hiding this comment.
[Claude] high: This handler covers SIGINT only — there is no SIGTERM handler, and every abort path in this PR kills the event logger with SIGTERM (pipeline._kill_services → launcher.terminate_all).
So close() never runs. The writer is constructed with flush_interval=100 on top of Python's ~8 KB text buffer, so up to ~99 records plus the buffer tail are lost. _salvage_tmpfs then copies the truncated file to report_dir/events.jsonl with no marker, no line count, no truncated flag.
A truncated events.jsonl is indistinguishable from a complete one — and it is the one artifact that survives a failed run and is most likely to be re-analysed later (scripts/early_stopping_estimate_from_events.py, SWEBenchScorer, agentic turn accounting). Each recomputes over it and gets a plausible wrong answer.
The irony: terminate_module exists precisely because the event logger "would lose buffered records on SIGTERM" (launcher.py:154-156), and abandon_drain SIGTERMs it anyway.
Fix: add a SIGTERM handler that runs service.close() before setting shutdown_event; and have _salvage_tmpfs check the last record is SessionEventType.ENDED, writing an events.jsonl.truncated marker when it isn't.
| # A wedged aggregator ignores the SIGTERM; always escalate so | ||
| # run_timeout_s stays a hard bound — a null ^C-grace must not soften | ||
| # it (cancelled when the drain finishes on its own). | ||
| grace = self._interrupted_teardown_grace_s |
There was a problem hiding this comment.
[Claude+Quality] high: null is silently reinterpreted as 30 s here, so one field means two different things depending on which abort fired.
interrupted_teardown_grace_s is documented as "(None = never abandon; 0 = abandon immediately)" (schema.py:841) and docs/CLI_QUICK_REFERENCE.md:172 repeats "null = never abandon". On the run_timeout_s path that is false. A user who chose null deliberately — the correct-or-nothing choice, "never truncate my drain" — gets the opposite of what both the field help and the reference table promise, with no log line saying so.
The intent (a null ^C-grace must not soften a hard run bound) is reasonable; the expression isn't. The 30.0 literal also duplicates the schema default and will drift the moment someone changes it.
Fix: give the watchdog its own named field (run_timeout_escalation_grace_s) with its own default, or source the fallback from a module constant shared with Timeouts — and state the exception in the field description and the docs table either way.
| """Runs on the loop: stop the session and bound the teardown.""" | ||
| assert self._session is not None and self._loop is not None | ||
| self._session.stop() | ||
| if ( |
There was a problem hiding this comment.
[Claude] high: With interrupted_teardown_grace_s: null the process becomes un-interruptible by Ctrl-C.
The escalation timer is only armed when the grace is non-None, and __call__ makes every subsequent SIGINT a logged no-op (watchdog.py:141-144). So with null — a documented, supported value — a wedged metrics drain parks forever in wait_for_exit(None) and no number of ^C presses escapes; only kill -9 from another terminal will.
docs/CLI_QUICK_REFERENCE.md:205-208 asserts the opposite: "Repeat ^C: logged no-op — the stop is already in flight and the grace bounds the teardown." That is false in exactly this case. test_sigint_grace_expiry_abandons_wedged_drain covers the scenario only with grace=3.0.
Fix: when interrupted_teardown_grace_s is None, either let the second ^C fall through to raise KeyboardInterrupt, or drop None from this field's domain (make it a plain float, ge=0) — "never abandon" has no safe realization while it is also the thing that makes ^C work.
| self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop | ||
| ) -> None: | ||
| """Bind the run coroutine's task — the live-run gate for the graceful path.""" | ||
| self._task = task |
There was a problem hiding this comment.
[Codex] high: bind_task replaces the task and loop but never clears _session.
When run_audit reuses one governor across phases, _session still points at the previous, already-completed BenchmarkSession. A SIGINT during a later phase's service launch or endpoint connect therefore takes the _stop_gracefully path and stops the stale session instead of cancelling the current task — so the new phase keeps running despite Ctrl-C.
Fix: clear _session and _on_grace_expiry when binding each new phase (or take them together in one bind call so the two can't drift out of sync).
| int, | ||
| cyclopts.Parameter( | ||
| alias="--duration", help="Min duration (ms, or with suffix: 600s, 10m)" | ||
| min_duration_ms: int | None = Field( |
There was a problem hiding this comment.
[Claude] high: The default changed from 600000 to None — the dangerous class of break, because a YAML that simply omits the key keeps parsing under extra=forbid and silently runs a different workload.
On origin/main: Field(600000, ge=0). Combined with dropping the synthetic target_qps = 10.0, a poisson config that relied on the default previously issued ceil(target_qps × 600 s × 1.1) samples and now issues one dataset pass. For a 1000-sample dataset at 100 QPS that is 66 000 → 1 000 — a 66x shorter measurement window, reported with a perfectly valid-looking TPS. No error, no warning, no log line saying the sizing rule changed.
config/templates/submission_template.yaml is the proof: it had to drop min_duration_ms: 600000 # 10 minutes because the new _min_duration_requires_qps validator rejects everything but poisson and that template is max_throughput. Two things worth calling out explicitly: (1) max_throughput + min_duration_ms was a shipped combination that now hard-errors with no replacement knob; (2) the reference MLPerf submission config silently lost its 10-minute floor.
Fix: WARN at config-resolve time when min_duration_ms is unset and target_qps is set, naming the old default and the new sizing; and give the submission template an explicit n_samples_to_issue rather than a silent deletion.
Review Council — Multi-AI Code ReviewReviewed by: Codex + Claude + Code-Quality | Depth: thorough Re-reviewed against the stated design principle: correct answers or no answers — a benchmark must hang or fail loudly rather than ship a plausible-looking wrong number. Verdict: the central fix is right, but the guarantee is enforced in only one of the four places it needs to be.
But the same guarantee is not applied to the response drains. 11 issues posted, most sharing one root cause. 🔴 Must Fix (critical)
🟡 Should Fix (high)
The one fix that closes most of #1–#6Reconcile in What's genuinely right — worth keeping in the PR description
Filtered by depth — found but not posted inlineTuning UX (your stated second goal — "easier for the user to tune"): the "Time Knobs" reference table documents Migration: Code quality:
|
| # Local character-level tokenizer: lets the metrics aggregator tokenize | ||
| # ISL/OSL without a HuggingFace Hub download (same trick as | ||
| # test_benchmark_command.py). | ||
| _CHAR_TOKENIZER_DIR = Path(__file__).resolve().parents[2] / "assets/tokenizers/char" |
There was a problem hiding this comment.
suggestion - convert to fixture.
| ) -> BenchmarkConfig: | ||
| settings_kwargs: dict[str, Any] = { | ||
| "load_pattern": load_pattern | ||
| or LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), |
There was a problem hiding this comment.
nit : Can we move this to the default value for the function argument intead?
| return BenchmarkConfig( | ||
| type=test_type, | ||
| endpoint_config=EndpointConfig(endpoints=[endpoint_url]), | ||
| model_params=ModelParams(name=model_name, streaming=StreamingMode.OFF), |
There was a problem hiding this comment.
Do we have coverage for streaming responses as well.
arekay-nv
left a comment
There was a problem hiding this comment.
Review Council — Multi-AI Code Review
Reviewed by: Codex + Claude (max-effort / thorough). event: COMMENT — no approve/reject.
This PR is already well-reviewed across three prior rounds, so after de-duplicating against existing comments I'm only posting what's new. Everything the council re-derived that was already raised — the cross-phase SigintGovernor stale-_session ^C bug (watchdog.py:102), the 30.0 grace literal duplicating the schema default (watchdog.py:258), the event-logger missing-SIGTERM record-loss gap (event_logger/__main__.py:200), the audit SIGINT-policy path (audit.py:174), and the fired_before_run run-outcome-state sprawl (issue #459) — was left alone as already-covered.
New findings (3)
| # | File:line | Severity | Category | Summary |
|---|---|---|---|---|
| 1 | docs/CLI_QUICK_REFERENCE.md:167-172 |
high | documentation | The six timeouts CLI flags are documented as --settings.timeouts.*; cyclopts strips settings (Settings is name="*", Timeouts is not), so the real flags are --timeouts.* / --timeout. Verified live (cyclopts prints Did you mean --timeouts...). The DESIGN.md:65 reply repeats the same wrong form. |
| 2 | docs/async_utils/services/metrics_aggregator/DESIGN.md:118 |
medium | documentation | --drain-timeout row still says 0 (unlimited); the PR flipped the default to None and inverted the sentinel (0 = give-up-immediately). Contradicts the updated AGENTS.md. |
| 3 | src/inference_endpoint/commands/audit.py:157 |
low | api-contract | run_timeout_s is setup-inclusive on the main run but audit phases run setup_benchmark before the deadline is captured, so audit-phase setup isn't counted and each phase gets a fresh full budget after setup. |
Verified correct (no action): the None/0 convention across Timeouts; publish_final atomic write + _final_lock serialization; launcher.terminate → terminate_module rename propagation; INTERRUPTED-before-ENDED ordering; template regeneration; no stale old-knob names in committed YAML/code.
⚠️ Commit hygiene: 45 commits incl. ~8fix*fixups on this branch — expected for a long-lived consolidation; consider squashing before merge.
| | `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | | ||
| | `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | | ||
| | `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | | ||
| | `settings.timeouts.service_ready_timeout_s` | `--settings.timeouts.service-ready-timeout-s` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | |
There was a problem hiding this comment.
[review-council: Codex+Claude] high — These six timeouts rows document the CLI flag as --settings.timeouts.<field>, but cyclopts rejects that form. Settings is @cyclopts.Parameter(name="*") (transparent), so the settings segment is stripped, while Timeouts is not transparent — the real flags are --timeouts.<field> (and run_timeout_s is --timeout).
Verified live:
$ inference-endpoint benchmark online ... --settings.timeouts.warmup-drain-timeout-s 10
Error: Unknown option: "--settings.timeouts.warmup-drain-timeout-s". Did you mean "--timeouts.warmup-drain-timeout-s"?
The runtime/client rows in this same table already drop the prefix correctly (--runtime.min-duration-ms, --client.worker-initialization-timeout), so rows 167–172 are internally inconsistent and every copy-paste of a timeouts flag fails.
Heads-up: the reply on docs/config/DESIGN.md:65 states the flag as --settings.timeouts.run-timeout-s — the same broken form — so the mental model is worth reconciling too.
Fix: change the six --settings.timeouts.* cells to --timeouts.*.
| @@ -117,13 +117,14 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ | |||
| | `--publish-interval` | 0.25 | Live snapshot cadence (seconds) | | |||
| | `--drain-timeout` | `0` (unlimited) | End-of-run tokenize budget (`0` = unlimited) | | |||
There was a problem hiding this comment.
[review-council: Codex+Claude] medium — Stale after this PR's None=unlimited / 0=zero-budget convention. This row says --drain-timeout | 0 (unlimited) | ... (0 = unlimited) and line 123 repeats service-side defaults (0 and 4). But metrics_aggregator/__main__.py now sets --drain-timeout default=None (help: “Omit to wait indefinitely (the default); 0 gives up immediately.”) and forwards it verbatim (the old None if arg == 0 translation was removed). So the default is now None, and 0 means give-up-immediately, the exact opposite of this row — and it contradicts the updated AGENTS.md (“None or omitted flag = unlimited, 0 = give up immediately”).
Fix: default → None (unlimited), drop the 0 = unlimited gloss, and change the (0 and 4) phrasing to (None and 4).
| audit_cfg, dataset_size, config.settings.load_pattern.type | ||
| ) | ||
| bench = run_benchmark_async(ctx) | ||
| bench = run_benchmark_async(ctx, sigint=sigint) |
There was a problem hiding this comment.
[review-council: Codex] low — run_timeout_s is documented as setup-inclusive (“synchronous setup — tokenizer probe, dataset load — counts against the budget”), and the main run honors it: run_benchmark captures the deadline at entry (execute.py:1282) before setup_benchmark. Audit phases don’t — setup_benchmark(...) runs at line 147 and then run_benchmark_async(ctx, sigint=sigint) is called here with no deadline, so run_benchmark_async recomputes a fresh deadline at entry after setup. Net: an audit phase’s dataset/tokenizer load isn’t counted against the budget (unlike the main run), and a phase whose setup alone exceeds run_timeout_s still launches with a fresh full budget. Minor — per-phase fresh budget is intended and audit datasets are small — but the setup-inclusive contract silently doesn’t hold on the audit path.
Fix: capture the deadline before setup_benchmark and pass it into run_benchmark_async, mirroring run_benchmark.
arekay-nv
left a comment
There was a problem hiding this comment.
Thanks for this - overall looks great - feel free to merge and create issues for followup as this is likely to cause more merge conflicts.
What does this PR do?
Consolidates every global time knob into
settings.timeoutsand makes--timeouta real whole-run watchdog.Changes
--timeout/ top-leveltimeout:was consumed nowhere (silent no-op) -> nowsettings.timeouts.run_timeout_s, a whole-run watchdog armed from setup through the metrics drain; firing writes INTERRUPTED artifacts, skips scoring, exits non-zero.settings.drainblock, flatsettings.service_ready_timeout_s, and every0 = unlimitedsentinel deleted; hard cutover (extra=forbid), no back-compat shims; templates/examples/docs/tests migrated. One convention everywhere:null= unlimited; drain budgets additionally accept an honest0(zero budget, give up immediately);run_timeout_srejects0.min_duration_ms/max_duration_msare workload durations onsettings.runtime, not timeouts. The--durationalias is deleted —--runtime.min-duration-msis the one spelling (poisson only, requires an explicittarget_qps; explicit--num-sampleswins; both unset = one dataset pass). The MLPerf ruleset path keeps its internal duration fields.metrics_drain_timeout_snow fails the run instead of exiting 0 with partial ISL/OSL buried in the summary; artifacts written first.SigintGovernor, shared by compliance-audit runs) stops the session gracefully — the aggregator drains, artifacts landstate: interrupted/complete: false,events.jsonlflushed, exit 130. Teardown is bounded by a fixed 30 s grace: if the metrics drain has not finished, the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed — a wedged drain can never hang the abort, and no second keystroke is needed. Repeat ^C is a logged no-op, so runners that forward the terminal's group SIGINT (uv rundelivers a single ^C twice) need no special handling. Escalation is timeout-driven, not keystroke-driven (design cue: aiperf).interrupted/complete: falsebefore it is persisted — an interrupted run is an invalid run; its artifacts only expose the partial metrics).result_summary.json+ the exit code are the run-level truth;metrics/final_snapshot.jsonrecords what the aggregator itself observed and can legitimately readstate: completewhen the abort lands after the session's terminal ENDED (post-ENDED drain window). The aggregator ignores SIGINT (the parent's ENDED path is authoritative); INTERRUPTED is entered via the session's marker event or SIGTERM (run watchdog / teardown grace).result_summary.jsonis nevercomplete: true(split-brain rewrite guard keyed onreport.state, covering the drain-timeout subcase too).--durationdeleted, above).uv run, drain-window artifact divergence — exit 130, honest artifacts, no orphans), governor unit suite (graceful + grace arming/expiry/disarm, repeat no-op, no-live-run raise), drain-timeout failure, finalization-window regression, config validation + removed-key tripwires.Exit codes (interrupt/timeout contract)
state: complete,complete: trueExecutionError: watchdog (run_timeout_s) fired,metrics_drain_timeout_sexpired, or session aborted (e.g. transport closure)state: interruptedorcomplete: falseevents.jsonl; grace expiry: whatever was already written + best-effortinterruptedsnapshotInvariant: an aborted run never exits 0 and never ships a
complete: truesummary — an interrupted run is an invalid run; its artifacts exist only to expose the partial metrics. One documented, tested edge:final_snapshot.jsonmay readstate: completefor a post-ENDED-drain-window abort (the summary stays authoritative).Follow-ups: #449 (promote warmup to a first-class phase type), #459 (consolidate run-outcome state flags into one abort/outcome model), profiling lifecycle hardening (explicit line-profiler shutdown instead of atexit; non-blocking profile POSTs) as a separate PR after this merges. schema.py module split also deferred to a follow-up MR.
Type of change
Testing
uv runkeystroke forwarding)Checklist