Skip to content

fix(xtest): fail benchmark cells whose build hangs instead of skipping - #685

Draft
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4372-bench-hang-not-budget
Draft

dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4372-bench-hang-not-budget

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Found while reviewing #669. This is a pre-existing bug on main, so this PR is based on main and is independent of the stage 2/3 stacks.

The bug

Every invocation's timeout is clipped to the cell's deadline. At CI defaults that is nearly every invocation: 1500 s across seven cells per SDK gives each cell about 214 s, against a 600 s timeout_s.

  • A hang looks like the budget running out. A build that never finishes times out at the deadline. one_round reclassified any MeasurementError that ended at or after the deadline as BudgetExhausted. That included a timeout, a crash that happened to land there, and even a full 600 s timeout that overran it.
  • A skip doesn't fail the run. test_benchmarks.py records BudgetExhausted as a skip, and skips fail the gate only when every cell skips (nothing_measured).
  • Net effect: a candidate that hung on every call read budget ran out during warm-up after 0 of 5 rounds. The other cells passed, and the run was green.
  • An intermittent hang after min_rounds ended the loop as stopped_because: "budget", which the step summary does not show.

What it does

  • Timeouts get their own type. measure() raises MeasurementTimeout(MeasurementError) for a timeout, and only that can become BudgetExhausted. A non-zero exit or a failure to start is a MeasurementError whenever it happens.

  • When a timeout counts as budget. Both of these must hold:

    • the deadline set it, so the invocation got less than timeout_s;
    • the cell has no evidence the invocation should have finished. Either nothing has completed yet, or it got less than _HANG_FACTOR (10) times the longest invocation completed in this cell (any arm, warm-up included).

    Any other timeout fails the cell, in any round, naming the arm, the time it had and that longest invocation.

  • The reference runs first in the cell's first warm-up round, so a candidate's first run always has evidence to be judged against. Warm-up samples are dropped, and the shuffle still runs in that round, so a seed draws the same measured interleaving as before.

  • If the reference times out before anything has completed, it counts as budget. No candidate has run yet. A reference that hangs everywhere still fails the run as NOTHING MEASURED.

  • Skip messages keep their cause. The warm-up and "only N rounds" BudgetExhausted messages append the reason one_round gave and chain it as __cause__.

  • README: a new design section, "A timeout fails the cell unless the budget caused it", a "failed cell" note in section 1, and invariant 11.

Behaviour changes

  • A full-length (600 s) timeout now always fails the cell, even if the deadline also passed. So does a crash past the deadline.
  • Genuine budget exhaustion still skips. The stopping rule and the statistics are unchanged.

Open questions for review

  • A cell left with little time can still go red. Example: the cell starts with 20 s left, the reference's first run takes 1 s, and the candidate's cold first run legitimately needs 25 s. That fails rather than skips. Adding a minimum (e.g. given time ≥ 10× longest and ≥ N s) would close it. An absolute floor was left out deliberately, because it would compare against a fixed number rather than this run's evidence.
  • The "first warm-up round" check is implicit. It is detected as "nothing has completed yet" (not longest_s), not as an explicit flag, so that one_round's signature stays the same and the change does not collide with chore(xtest): measure benchmark cells over a tuple of arms #669.
  • --bench-warmup 0 has no warm-up round to reorder. Whichever arm is drawn first is judged with no evidence. This is documented.

Validation

  • uv run pytest test_bench_*.py: 238 passed. ruff, ruff format and pyright are clean.
  • TestTimeouts has 10 new tests. All 10 fail against main's runner.py. Each uses CI-like numbers (214 s window, 600 s timeout) and covers:
    • an always-hanging candidate, under both shuffle draws;
    • reference-first in round 0, with the RNG sequence preserved;
    • cases that still skip: seconds left, slow rounds, and a reference timeout with no evidence;
    • the cause chain;
    • an intermittent hang after min_rounds;
    • a full-length timeout;
    • a crash at the deadline.
  • test_bench_measure.py asserts that a timeout is a MeasurementTimeout, and that a non-zero exit and a failure to start are not.

Interaction with #669

  • runner.py conflicts in four places in one_round's invocation block. Keep this PR's structure, use chore(xtest): measure benchmark cells over a tuple of arms #669's who (f"{arm.name} ({arm.label})") in the messages, and keep the inner BudgetExhausted messages without the cell_id prefix.
  • test_bench_runner.py conflicts on one import line.
  • After the rebase, TestTimeouts' run_cell calls take a list of arms, and the expected message text changes to who.

Every invocation's timeout is clipped to the cell's deadline. At CI
defaults that is nearly all of them: 1500 s across seven cells per SDK
gives each about 214 s, against a 600 s timeout_s. So a build that never
finishes times out at the deadline, and one_round reclassified any
MeasurementError that ended at or after the deadline as BudgetExhausted.
That covered a timeout, a crash that happened to land there, and even a
full 600 s timeout that overran it. test_benchmarks.py records
BudgetExhausted as a skip, and skips fail the gate only when every cell
skips. A candidate that hung on every call was therefore reported as
"budget ran out during warm-up after 0 of 5 rounds", the other cells
passed, and the run was green. An intermittent hang after min_rounds
ended the loop as stopped_because "budget", which the step summary does
not show. The measured loop also dropped the exception, so even the
"only N rounds" skip did not say why.

measure() now raises MeasurementTimeout, a MeasurementError subclass,
when the launcher reports a timeout, and only that can become
BudgetExhausted. It does so when the deadline set the timeout and the
cell has no evidence the invocation should have finished: either nothing
has completed yet, or it was given less than ten times the longest
invocation the cell has completed, any arm, warm-up included. Any other
timeout fails the cell, naming the arm, the time it had and that longest
invocation. Ten is generous on purpose. The measured loop starts a round
only if its median length fits, so an arm the budget really cuts off
typically had about a round's time, far short of ten times the cell's
slowest run. A cell that earlier cells left with only seconds still
skips.

The first invocation of a cell has no evidence, so the first warm-up
round now runs the reference first, and every candidate's first run can
be judged against it. Warm-up samples are discarded and the shuffle
still runs in that round, so a seed draws the same measured interleaving
as before. If the reference times out before anything has completed, it
counts as budget: no candidate has run, and an invocation that cannot
finish within the cell's share could not have been measured anyway. With
--bench-warmup 0 there is no warm-up round to reorder, and whichever arm
is drawn first is judged the same way.

When BudgetExhausted propagates, the warm-up and "only N rounds"
messages now append the reason one_round gave and chain it as
__cause__, so the skip in the report says what happened. Non-zero exits
and failures to start are MeasurementErrors whenever they happen, as
they already were before the deadline. Genuine budget exhaustion still
skips, and the stopping rule and the statistics are unchanged.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@coderabbitai

coderabbitai Bot commented Oct 29, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant