Skip to content

DRAFT: feat(config): consolidate all durations and deadlines into one Timeouts model - #409

Draft
viraatc wants to merge 5 commits into
mainfrom
timeouts-consolidation
Draft

DRAFT: feat(config): consolidate all durations and deadlines into one Timeouts model#409
viraatc wants to merge 5 commits into
mainfrom
timeouts-consolidation

Conversation

@viraatc

@viraatc viraatc commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Consolidates every global time knob into one frozen pydantic modelTimeouts in the new src/inference_endpoint/config/timeouts.py, mounted at settings.timeouts — gives the previously dead --timeout flag real semantics, splits the 1400-line config/schema.py into focused modules, and makes the sample-count knobs mutually exclusive.

The headline bug this fixes

BenchmarkConfig.timeout (--timeout, top-level timeout: YAML key) was consumed nowhere — a silent no-op. Several example YAMLs set it believing it bounded the run. It is now settings.timeouts.run_timeout_s: a real whole-run watchdog. When it fires, it SIGTERMs the metrics aggregator (whose handler writes an INTERRUPTED final_snapshot.json), stops the session, and run_benchmark raises ExecutionError (non-zero exit) after finalization — a fired watchdog can never yield a COMPLETE report and always leaves result_summary.json with complete: false. Locked by tests/integration/commands/test_run_timeout.py.

The one model

settings:
  timeouts:
    # workload durations (benchmark definition — reaching them is a normal end)
    min_duration_ms: null          # --duration (suffixes: 600s, 10m); sample count = QPS × duration
    max_duration_ms: null          # whole-perf-phase-only cap; never bounds warmup/accuracy
    # give-up deadlines (failure handling — None = wait indefinitely)
    run_timeout_s: null            # --timeout, whole-run watchdog (covers drains too), fires → INTERRUPTED
    service_ready_timeout_s: 30.0  # --service-ready-timeout
    warmup_drain_timeout_s: 240.0  # --warmup-drain-timeout
    performance_drain_timeout_s: null  # ⚠️ default changed: was 240 — now unbounded by request
    accuracy_drain_timeout_s: null # deliberately unbounded: every sample must complete
    metrics_drain_timeout_s: null  # None = unlimited (was 0 = unlimited)
  metrics:
    tokenizer_workers: 2           # --metrics-tokenizer-workers (not a timeout → own block)

All CLI flag aliases are unchanged. Only YAML key locations and auto-generated dotted flags moved.

Sample count: explicit XOR duration-derived (new)

runtime.n_samples_to_issue (--num-samples) and timeouts.min_duration_ms (--duration) are now mutually exclusive (Settings validator). Previously an explicit count silently won and the configured duration was dead weight — several examples carried both. Omitting both issues the dataset once, which also deletes the old min_duration_ms: 0 = "all samples" and max_duration_ms: 0 = "no limit" sentinels (0 is now rejected loudly).

⚠️ Reviewer callouts (deliberate behavior changes):

  • Bare-config default changed: neither knob set previously derived qps × 600s worth of samples; it now runs the dataset once.
  • performance_drain_timeout_s default is now None (unbounded). A default run has no time backstop on a wedged endpoint — set run_timeout_s for unattended runs.
  • metrics_drain_timeout_s: 0 / min_duration_ms: 0 / max_duration_ms: 0 now error loudly.
  • Example run_timeout_s values were dropped, not carried over (old top-level timeout: was inert; enforcing stale values would hard-kill valid runs). Also fixed compare_with_vllm.py, which capped the same budget twice via max_duration_ms and --timeout.

schema.py split

config/schema.py now owns only BenchmarkConfig/EndpointConfig and re-exports the full schema surface (import sites unchanged). Models moved to config/enums.py, audit.py, model_params.py, datasets.py, settings.py (+ existing timeouts.py). Dead code deleted: SystemDefaults (DEFAULT_TIMEOUT had zero consumers), TEMPLATE_TYPE_MAP.

Design invariants (multi-model design review: Codex/Opus/Grok/Gemini)

  • run_timeout_s never derives per-stage deadlines (no remaining-budget propagation).
  • min/max_duration_ms bound the whole performance phase (single phase; not per-dataset); max_duration_ms remains the sole perf ceiling.
  • turn_timeout_s stays dataset-scoped; worker lifecycle timeouts stay on settings.client.*.
  • Hot path untouched: everything resolves to plain values at setup.
  • No back-compat shims: old keys hard-error; templates/examples/docs/tests migrated in this PR.

Multi-AI review hardening

A Codex review + adversary-council pass found and fixed: watchdog now covers the unbounded metrics drain; watchdog SIGTERMs only the aggregator (event logger kept its buffered events.jsonl); timed-out runs skip accuracy scoring (artifacts still salvaged); teardown race can no longer skip result_summary.json; run_audit maps a watchdog fire to ExecutionError instead of the Ctrl-C exit-130 path.

Type of change

  • Bug fix
  • New feature
  • Refactor/cleanup

Testing

  • Tests added/updated — watchdog locking test, audit run-timeout test, sample-count mutual-exclusion + dataset-once tests; all suites migrated
  • All tests pass locally — tests/unit: 1522 passed, 5 skipped; integration command suites green (incl. full test_cli.py e2e)
  • Manual testing completed — every example YAML load-validated; CLI --help verified aliases unchanged

Checklist

  • Code follows project style
  • Pre-commit hooks pass
  • Documentation updated — AGENTS.md, CLI docs, LOCAL_TESTING, config DESIGN docs, example READMEs

🤖 Generated with Claude Code

…ts model

One frozen pydantic model (config/timeouts.py, mounted at settings.timeouts)
now owns every global time knob: min/max duration, per-phase drain deadlines,
service-ready deadline, metrics drain budget, and the whole-run watchdog.

- BenchmarkConfig.timeout was a silent no-op consumed nowhere; it is now
  settings.timeouts.run_timeout_s (--timeout alias preserved): a real
  whole-run watchdog that SIGTERMs the metrics aggregator (INTERRUPTED
  final snapshot), stops the session, and exits non-zero. A fired watchdog
  can never produce a COMPLETE report (locked by integration test).
- DrainConfig deleted; drain fields moved to settings.timeouts with CLI
  aliases unchanged. metrics_drain_timeout_s normalized to None=unlimited
  (was 0=unlimited); the aggregator argv boundary still speaks 0.
- metrics_tokenizer_workers is not a timeout: moved to settings.metrics
  (new MetricsConfig block).
- min/max_duration_ms (+ suffix parsing + cross-validator) moved from
  RuntimeConfig to Timeouts; RuntimeSettings resolves them to plain values
  at setup, so nothing in the hot path reads pydantic.
- ServiceLauncher.terminate_all() added (graceful SIGTERM counterpart to
  kill_all) for the watchdog path.
- Templates regenerated; examples/docs/tests migrated (YAML keys moved,
  no back-compat shims per repo convention). Example run_timeout_s values
  were dropped rather than carried over: the old top-level timeout was
  inert, and enforcing stale values would abort valid runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@viraatc
viraatc requested a review from a team July 13, 2026 20:20
@github-actions

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@github-actions
github-actions Bot requested review from arekay-nv and nvzhihanj July 13, 2026 20:21

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

viraatc and others added 3 commits July 13, 2026 13:31
…on semantics

- performance_drain_timeout_s default 240.0 -> None (wait indefinitely),
  matching the accuracy drain default.
- min/max_duration_ms help/description now state they bound the whole
  performance phase (single phase; not per-dataset) and never bound
  warmup/accuracy. Templates regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schema.py (1415 lines) now holds only BenchmarkConfig/EndpointConfig and
re-exports the full schema surface; models move to sibling modules:
enums.py, audit.py, model_params.py, datasets.py, settings.py (timeouts.py
already existed). Import sites are unchanged — config.schema remains the
single import point.

Dead code deleted: SystemDefaults (DEFAULT_TIMEOUT had no consumers;
DEFAULT_METRIC inlined into rulesets/mlcommons/rules.py) and the unused
TEMPLATE_TYPE_MAP. regenerate-templates pre-commit hook now watches the
new modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
# (launched once from top-level model_params), so a per-dataset override would
# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected
# as generation_config_override keys — they are per-run/identity, not per-dataset.
_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"})
@viraatc viraatc changed the title feat(config): consolidate all durations and deadlines into one Timeouts model DRAFT: feat(config): consolidate all durations and deadlines into one Timeouts model Jul 13, 2026
@viraatc
viraatc marked this pull request as draft July 13, 2026 21:30
…entinels

min_duration_ms and n_samples_to_issue are now mutually exclusive
(Settings validator): the sample count is either an explicit count or
derived from QPS x duration — previously an explicit count silently won
and the configured duration was dead weight.

- min_duration_ms: int | None = None. Omitting both knobs issues the
  dataset once. BEHAVIOR CHANGE: a bare config (neither set) previously
  derived qps x 600s worth of samples; it now runs the dataset once.
  The 0 = 'all dataset samples' sentinel is gone (0 now rejected).
- max_duration_ms: int | None = None, replacing the 0 = 'no limit'
  sentinel; the argv-boundary 0->None conversion in RuntimeSettings is
  deleted (internal RuntimeSettings stays permissive for programmatic
  callers passing 0).
- Examples/docs/templates migrated: dropped min_duration where an
  explicit count is set (it was silently ignored), dropped --duration 0
  usage, fixed compare_with_vllm.py double-cap (it set max_duration_ms
  from the same value it passed as --timeout, capping one budget via two
  mechanisms), corrected CLI_QUICK_REFERENCE/LOCAL_TESTING stale
  defaults and key locations.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@roborluo roborluo Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

scheduler_random_seed: 42 # For Poisson/distribution sampling
dataloader_random_seed: 42 # For dataset shuffling
timeouts:
min_duration_ms: 600000 # 10 minutes (or set runtime.n_samples_to_issue instead; omit both = dataset once)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Trying to think why min_duration is a timeout? Sounds like it's a lower bound instead of a upper bound

Comment thread docs/LOCAL_TESTING.md
- Sample priority: `--num-samples` > dataset size (when `--duration 0`) > calculated (target_qps × duration)
- Default duration: 600000ms (10 minutes)
- By default (no `--num-samples`, no `--duration`) a run stops after issuing the dataset once
- `--num-samples` (explicit count) and `--duration` (count = target_qps × duration) are mutually exclusive

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does duration work when running without target_qps (fixed concurrency or offline)?

Comment on lines +26 to +27
min_duration_ms: 6000 # 6 seconds
max_duration_ms: 60000 # 1 minute

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's a bit weird to put runtime lower/upper bound in the timeouts?


timeouts:
min_duration_ms: 600000 # 10 minutes (mutually exclusive with runtime.n_samples_to_issue)
warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we consider removing this? It seems that we are trying to prevent a hang in warmup (or excessive OSL), but a hang in warmup might signal something wrong or simply means that the warmup is not set correctly. (Or what does the drain mean here)

cc: @arekay-nv

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes. We should drop it. One option is to move to per-phase timeouts which limit each phase and have a global timeout like the one in the current PR.
The global timeout enforces the overall duration.
We are already doing something similar for drain - there is a per-phase drain config
Suggestion - Remove warmup timeout here, and create an issue to promote warmup to a phase type .

@arekay-nv arekay-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the schema breakdown make sense and is a lot cleaner.
Regarding the timeouts - two suggestions, and feedback is welcome:

  1. Remove the duration field - makes it simpler especially since we are mostly going to be doing concurrency based runs.
  2. 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 as warmup always goes before performance, reporting comes after accuracy etc.

- `--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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines -329 to +330
- Default duration: 600000ms (10 minutes)
- `--num-samples` and `--duration` are mutually exclusive: explicit count, or calculated (target_qps × duration)
- Default (neither set): issue the dataset once

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Dropping duration makes it simpler. And by default num-samples is none which means a single pass of the dataset.


timeouts:
min_duration_ms: 600000 # 10 minutes (mutually exclusive with runtime.n_samples_to_issue)
warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes. We should drop it. One option is to move to per-phase timeouts which limit each phase and have a global timeout like the one in the current PR.
The global timeout enforces the overall duration.
We are already doing something similar for drain - there is a per-phase drain config
Suggestion - Remove warmup timeout here, and create an issue to promote warmup to a phase type .

@arekay-nv
arekay-nv requested a review from roborluo August 6, 2026 19:17
- `--model-params.osl-distribution.min --min-output-tokens` - Min output tokens (default: 1)
- `--model-params.streaming --streaming` - Streaming mode: auto/on/off (default: auto)
- `--runtime.min-duration-ms --duration` - Min duration: ms default, or with suffix (600s, 10m) (default: 600000)
- `--runtime.n-samples-to-issue --num-samples` - Explicit sample count override

@roborluo roborluo Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do you think we should also add another line to explain max_duration_ms? and run_timeout_s Wonder what is the expectd behavior when max_duration_ms is 0 ?

does not expose a CLI override for `report_dir`. Set it in the YAML only if you need to control
the output location; otherwise a default report directory is used.
- `--timeout` - Global timeout in seconds
- `--timeout` - Whole-run watchdog in seconds (off by default). If it fires, the run is aborted and the report is marked INTERRUPTED (exits non-zero).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is again, confusing to me. We have timeout already in settings.timeout. And under that we have another timeout?

If we really need this, I would suggest also update Desgin.md as well and comeup with a different name?

Comment thread docs/config/DESIGN.md
| `min_duration_ms` | `int` | runtime config |
| `max_duration_ms` | `int` | runtime config |
| `min_duration_ms` | `int` | `settings.timeouts` |
| `max_duration_ms` | `int` | `settings.timeouts` |

@roborluo roborluo Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we can talk in a different topic. The yaml file and CMD flag are hard to correlate to each other. We are using yaml so it is fine., but we need to structure it if we want to convince more people to use it.

num_requests: Number of requests to send
max_output_tokens: Maximum output tokens per request
workers: Number of parallel http-client workers
timeout: Timeout in seconds

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If timeout is not used at all. Suggest to delete it everywhere

timeouts:
min_duration_ms: 600000 # 10 minutes (mutually exclusive with runtime.n_samples_to_issue)
warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely)
performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely)

@roborluo roborluo Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wonder where is this coming from? never see these flag in any logs or readme files.

Also do we want to simply the timeout story a bit? To many flags make it hard for us to use. Especially when they coexists or conflicts. For example, now you have min_duration_ms which is doing the same thing as performance_drain_timeout_s

if proc.poll() is None:
proc.kill()

def terminate(self, module_suffix: str) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it possible to use def terminate_metrics_aggregator(self) -> None: intead? I feel like the scope of the function is too big. We should not desgin our existing code based on some future requirement which might not be true.

run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines.
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)
performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would be good to include these in read me instruction and explain the use case and priority of each one

@@ -0,0 +1,264 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wonder why you seperate part of the config and left some in the schema.py? What is your critira to do the splitting?

loop, max_duration_ms, _on_perf_phase_timeout
)
run_watchdog = (
loop.call_later(run_timeout_s, _on_run_timeout)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this seems only start after service readiness. And server start up is bounded by service_ready_timeout_s.

Consider to start this earlier.

@@ -0,0 +1,195 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we add a normal completion before timeout? And another timeout during metric darining?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants