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 18 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>
| - `--runtime.n-samples-to-issue --num-samples` - Explicit sample count override | ||
| - `--min-duration-ms --duration` - Min perf-phase duration: ms default, or with suffix (600s, 10m); sample count = QPS x duration | ||
| - `--runtime.n-samples-to-issue --num-samples` - Explicit sample count | ||
| - `--duration` and `--num-samples` are mutually exclusive; omit both to issue the dataset once (the default) |
There was a problem hiding this comment.
Do you mean if both are set, both will be omitted?
What is the current loadgen behavior? I am thinking whether taking the max of the 2 will make more sense or we should error out
There was a problem hiding this comment.
Can we drop duration - this is overloading with the timeout as the duration of a benchmark can be the bounded max-runtime. We can rely on num-samples to specify exactly how many samples the user wants to run.
This will bound the lower end - and we can use the timeout on the upper end.
I think this would make it easier to understand rather than having to recall the details every time we specify duration, timeout and num-samples.
There was a problem hiding this comment.
I think he is trying to say if we set neither, it will just sweep once for the dataset we sepcified ? But yeah, a little unclear at initial read.
Also +1 to @arekay-nv . Having --min-duration-ms --duration representing the same it confusing
There was a problem hiding this comment.
Do you mean if both are set, both will be omitted?
this originally was is both are set, raise validation error - nothing is run.
if neither is set, we would run 1 dataset epoch.
if any is set, that value would be used
Can we drop duration
+1, dropped: duration and min_duration_ms so no longer applies.
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.
| - `--runtime.n-samples-to-issue --num-samples` - Explicit sample count override | ||
| - `--min-duration-ms --duration` - Min perf-phase duration: ms default, or with suffix (600s, 10m); sample count = QPS x duration | ||
| - `--runtime.n-samples-to-issue --num-samples` - Explicit sample count | ||
| - `--duration` and `--num-samples` are mutually exclusive; omit both to issue the dataset once (the default) |
There was a problem hiding this comment.
Can we drop duration - this is overloading with the timeout as the duration of a benchmark can be the bounded max-runtime. We can rely on num-samples to specify exactly how many samples the user wants to run.
This will bound the lower end - and we can use the timeout on the upper end.
I think this would make it easier to understand rather than having to recall the details every time we specify duration, timeout and num-samples.
| @@ -0,0 +1,264 @@ | |||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |||
There was a problem hiding this comment.
wonder why you seperate part of the config and left some in the schema.py? What is your critira to do the splitting?
There was a problem hiding this comment.
The intent was to shrink schema.py (~1700 lines and growing) and refocus it on what it's actually for: the top-level aggregates — BenchmarkConfig and EndpointConfig — plus the cross-field validation that has to see every domain at once. Everything else moved out along one rule: one module per config domain, every name (model, enum, helper) beside its owner.
▏ ┌─────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
▏ │ file │ contains │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ schema.py │ BenchmarkConfig/EndpointConfig (root aggregates), cross-field validation, root-level TestType/TestMode, and the re-export hub — existing config.schema imports unchanged │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ settings.py │ the settings: block: Settings, RuntimeConfig, LoadPattern(+LoadPatternType), WarmupConfig, ProfilingConfig(+ProfilerEngine), EarlyStoppingConfig │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ timeouts.py │ Timeouts — every global wait/deadline (settings.timeouts) │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ model_params.py │ ModelParams(+StreamingMode), OSLDistribution(+OSLDistributionType), SubmissionReference │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ datasets.py │ Dataset(+DatasetType), AccuracyConfig(+EvalMethod/ScorerMethod), AgenticInferenceConfig, the generation-override merge helpers │
▏ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
▏ │ audit.py │ AuditTestId, OutputCachingTestConfig (the audit: block) │
▏ └─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
There was a problem hiding this comment.
Addressed the granularity concern directly — the split is now three modules, not six. Criterion: keep together what changes together. settings.py owns the settings: subtree (including the frozen Timeouts at settings.timeouts); workload.py owns the request payload + data definition (model_params:, datasets:, accuracy config, and the generation-override merge helpers whose only consumer is Dataset); schema.py keeps the root aggregates, the cross-field validation that must see every domain at once, the small root-level audit: block, and the re-export hub — so no import site outside config/ changed. Also fixed while consolidating: the regenerate-templates pre-commit hook now matches every config/*.py instead of an enumerated filename list, so a renamed module can never silently skip template regeneration.
| @@ -0,0 +1,195 @@ | |||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |||
| # SPDX-License-Identifier: Apache-2.0 | |||
There was a problem hiding this comment.
can we add a normal completion before timeout? And another timeout during metric darining?
There was a problem hiding this comment.
Both cases are covered in tests/integration/commands/test_run_timeout.py: test_generous_run_timeout_completes_normally (normal completion with the watchdog armed) and test_run_timeout_during_metrics_drain_interrupts (watchdog firing during the metrics drain), plus test_metrics_drain_timeout_fails_run for the drain-deadline-expiry path.
…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.
The three endpoint-client worker waits (init, graceful shutdown, force kill) are client internals, not global run deadlines — restore them on HTTPClientConfig under their original names and drop them from Timeouts. CLI_QUICK_REFERENCE gains a run-lifetime timeline visualizing where every time knob acts.
test_metrics_preflight_tap.py references a local scratchpad path and test_protocol.py targets transport features that do not exist on main; neither belongs to this PR.
One object owns the whole-run deadline: timer handle, fired flag, and the late-bound session, replacing the nonlocal flag + mutable-holder closure threaded through _run_benchmark_async. Behavior unchanged.
Fix references left behind by the consolidation: renamed drain knob in session.py docstring, argv-vs-schema 0-sentinel wording in the aggregator snapshot/help text, config module pointers after the schema split, the from-config flag surface in CLI_QUICK_REFERENCE, regenerate-templates trigger lists, the config DESIGN nested-model table, and the compliance plan's duration-floor note. schema.py re-exports HTTPClientConfig again.
Reviewer discussion on #409 settled on keeping this MR about the timeout-knob consolidation; the schema.py monolith split (whatever its final shape) moves to a dedicated follow-up MR. schema.py stays one module — the declared YAML/CLI surface, distinct from the resolved run plan in runtime_settings.py — now containing the settings: subtree (incl. the frozen Timeouts at settings.timeouts), the workload definition (model_params:/datasets:), the root aggregates, and the audit: block, in that order with section banners. Kept from the split work, split-independent: - the regenerate-templates pre-commit hook matches every config/*.py instead of an enumerated filename list, so a renamed module cannot silently skip template regeneration. - enums.py stays dissolved: each enum lives beside its owner. - a stale 3-tuple _load_datasets mock left by the rebase onto main (#437 changed the return arity to 2) stays fixed.
06ae759 to
c8881d1
Compare
Reviewer discussion on #409 settled on keeping this MR about the timeout-knob consolidation; the schema.py monolith split (whatever its final shape) moves to a dedicated follow-up MR. schema.py stays one module — the declared YAML/CLI surface, distinct from the resolved run plan in runtime_settings.py — now containing the settings: subtree (incl. the frozen Timeouts at settings.timeouts), the workload definition (model_params:/datasets:), the root aggregates, and the audit: block, in that order with section banners. Kept from the split work, split-independent: - the regenerate-templates pre-commit hook matches every config/*.py instead of an enumerated filename list, so a renamed module cannot silently skip template regeneration. - enums.py stays dissolved: each enum lives beside its owner. - a stale 3-tuple _load_datasets mock left by the rebase onto main (#437 changed the return arity to 2) stays fixed.
caeb147 to
6ce9311
Compare
339499b to
0d711fd
Compare
Local review-council findings (codex + claude, run against the PR diff): - A deadline expiring during service launch or endpoint connect used to SIGTERM the aggregator and then let the pending readiness awaits run out their own timeouts (30s+ past the deadline), surfacing as a 'service crashed during startup' RuntimeError instead of the run timeout. The watchdog now cancels the orchestration task while no session exists — pending awaits unwind immediately, MetricsPipeline __aexit__ kills the services — and _run_benchmark_async translates any exception unwinding after a fire into the run-timeout ExecutionError (KeyboardInterrupt/SystemExit excluded). New integration test pins the prompt-abort behavior. - A timed-out run whose aggregator finalized COMPLETE before SIGTERM landed kept state: complete with complete: false in its artifacts — the documented drain-timeout signature — misattributing a watchdog abort to a slow drain. The split-brain guard now also sets state: interrupted. - --timeout <= 0 in from-config re-validates the frozen Timeouts model after the config try/except, escaping as a raw pydantic traceback (exit 1); now mapped to InputValidationError (exit 2) like every other bad input, with a unit test. - _RunWatchdog docstring described the fire ordering aspirationally (session ENDED flushing tokenizer-drain samples into the aggregator before SIGTERM); rewritten to match the actual first-wins semantics. - CLI_QUICK_REFERENCE showed finalization inside the watchdog brace; it runs after watchdog.cancel() — the diagram and --timeout row now state that scoring/artifact writes are not deadline-bounded. - Stale pointers to schema modules abandoned with the deferred split (config/timeouts.py, config/settings.py, config/schema/settings.py) in the aggregator --help strings, DESIGN.md, DEVELOPMENT.md, and AGENTS.md now point at config/schema.py. - test_run_timeout_produces_interrupted_report ran with a 2 s budget that a slow host could burn during startup, silently exercising the pre-session path instead of the mid-run one; budget raised to 6 s.
0d711fd to
3e8d31c
Compare
…ero budget The aggregator argv kept a second sentinel (0 = wait indefinitely, converted from the schema's None at the argv boundary), which made an explicit zero drain budget unexpressible and forced every reader to hold two conventions. Now None/omitted flag = unlimited everywhere and 0 is an honest zero-second budget (give up immediately): - --drain-timeout defaults to None (absent = unlimited) and is passed through verbatim; the benchmark omits the flag when metrics_drain_timeout_s is None. - metrics_drain_timeout_s relaxes gt=0 to ge=0 so an explicit 0 is accepted; flush_remaining already treats 0 as an immediate deadline. - snapshot.py/AGENTS.md drop the argv-conversion caveat.
…atchdog.py execute.py had grown to ~1390 lines; the two event-loop timers — PerfPhaseTimeout (runtime.max_duration_ms perf-phase cap) and RunWatchdog (settings.timeouts.run_timeout_s whole-run deadline) — are a self-contained unit with their own semantics, so they get their own module. Public names now that they cross a module boundary; execute.py remains the only consumer. MetricsPipeline is imported under TYPE_CHECKING to keep the pipeline<->watchdog edge type-only.
30acee9 to
0929d71
Compare
…me launcher.terminate to terminate_module bind_session now stops an already-fired session itself, collapsing the 15-line fired branch at the session.run call site to a one-line hook selection — the remaining watchdog.fired checks each guard a distinct data-preservation step (teardown-race swallow so finalize still writes INTERRUPTED artifacts, timeout attribution on unwind, post-finalize raise) and stay. ServiceLauncher.terminate(module) read like a general kill-everything API; terminate_module says what it selects on and pairs with terminate_all.
…tings.runtime Review discussion questioned min_duration living in the timeouts block (it is a lower bound on the workload, not a give-up deadline) — the first cut of this branch resolved that by deleting the knob outright, which lost a real feature: --duration (min_duration_ms) was forwarded into RuntimeSettings on main and sized the run as target_qps × duration samples. The intended replacement (a ruleset setting it via apply_user_config/UserConfig) is not wired yet, so the knob was simply unreachable. Reinstated where the reviewer said bounds belong: beside max_duration_ms on settings.runtime, suffix parsing (600s/10m) and the max >= min cross-check restored, under the branch's conventions — int | None with None = no duration target (issue the dataset once, the default; main defaulted to 600000 with 0 as the sentinel). from_config forwards it again; unit test covers suffix parse + qps × duration derivation + the dataset-once default.
…tate The phase numbering came from the 2025Q4 project plan, which is closed; the comments read as if ruleset apply_user_config wiring is scheduled when it has no active tracking issue. State the actual contract: the ruleset arg is accepted but unapplied, rulesets enforce schema-side constraints only.
… drop the synthetic 10.0 QPS default Duration sizing multiplies a rate by a time — outside poisson there is no rate. Previously offline/max_throughput fell back to a hardcoded effective_qps of 10.0 (a self-described temporary compat default), so --duration on an offline run silently sized it as 10 QPS × duration, unrelated to what the server sustains. - Settings validator rejects runtime.min_duration_ms unless the load pattern is poisson with an explicit target_qps. - effective_qps is gone: RuntimeSettings.metric_target is now Metric | None (None when no target_qps), reported_metrics [] — the ruleset path already produced exactly these shapes. - total_samples_to_issue guards the programmatic path with a clear error instead of a nonsense count.
What does this PR do?
Unifies every global time knob into one place — the frozen
Timeoutsmodel in the newsrc/inference_endpoint/config/timeouts.py, mounted atsettings.timeouts— gives the previously dead--timeoutflag real whole-run-watchdog semantics, deletes the--durationknob, and splits the ~1700-lineconfig/schema.pyinto focused domain modules.The headline bug this fixes
BenchmarkConfig.timeout(--timeout, top-leveltimeout:YAML key) was consumed nowhere — a silent no-op. It is nowsettings.timeouts.run_timeout_s: a real whole-run watchdog whose deadline is captured atrun_benchmarkentry (setup counts against it) and stays armed through every phase and the metrics drain. When it fires: the session stops (ENDED still flows, buffered tokenizer-drain samples are recorded), the metrics aggregator is SIGTERMed (its handler writes an INTERRUPTEDfinal_snapshot.json), scoring is skipped, andrun_benchmarkraisesExecutionErrorafter artifacts are written — a fired watchdog can never yieldcomplete: trueand always exits non-zero. Locked bytests/integration/commands/test_run_timeout.py.The one block
Deleted outright (hard cutover,
extra=forbidmakes stale YAML keys error loudly): top-leveltimeout:, thesettings.drainblock (fields absorbed intotimeoutswith consistent*_drain_timeout_snames),settings.service_ready_timeout_s(moved),runtime.min_duration_ms/--duration, and every0 = unlimitedsentinel (gt=0; unlimited isnull).docs/CLI_QUICK_REFERENCE.md gains a run-lifetime timeline showing where every knob acts, a YAML-path <-> CLI-flag table, and composition rules. All CLI aliases unchanged.
Sample count: explicit or dataset-once (breaking)
--duration/min_duration_msis deleted (review consensus in the duration thread): the sample count is explicitruntime.n_samples_to_issue(--num-samples) or, when omitted, one pass over the dataset.target_qps x 10 minworth of samples; it now runs the dataset once. Example YAMLs that relied on derivation carry explicit counts. The MLPerf ruleset path (RuntimeSettings/UserConfig) keeps its internal duration fields, so submission validity rules are unaffected.Metrics drain gets teeth
An expired
metrics_drain_timeout_s(aggregator finalizes COMPLETE withn_pending_tasks > 0) previously exited 0 withcomplete: falseburied in the summary. It now fails the run: artifacts are written first, thenExecutionError. Partial ISL/OSL/TPOT stats can never look like a clean run. (The audit path already refused to certify these.)schema.py split
config/schema.pynow owns only the root aggregates (BenchmarkConfig/EndpointConfig), cross-field validation, the root-levelTestType/TestMode, and the re-export hub (import sites unchanged). Domains moved tosettings.py,timeouts.py,model_params.py,datasets.py,audit.py— each module owns its models AND enums (no shared enums file; every enum had exactly one consumer). Dead code deleted:SystemDefaults,TEMPLATE_TYPE_MAP.Design invariants
run_timeout_snever derives per-stage deadlines; it is the only total-wall-time bound.max_duration_msandperformance_drain_timeout_snever run concurrently: the cap bounds issuing and skips the drain; the drain bounds post-issuing waiting after a natural end.settings.client(unchanged from main).turn_timeout_sstays dataset-scoped.Robustness fixes found during review hardening
publish_finalis serialized with anasyncio.Lock: a watchdog SIGTERM racing the ENDED-driven finalize can no longer abandon a half-writtenfinal_snapshot.json.finalize_benchmarkforcescomplete: falsewhen the aggregator finalized COMPLETE just before the SIGTERM landed — timed-out artifacts are never split-brained.Follow-up filed: #449 (promote warmup to a first-class phase type with per-phase config).
Type of change
Testing
tests/unit: 1865 passed; integration command suites green--helpverified aliases unchangedChecklist
🤖 Generated with Claude Code