Skip to content

fix: fail fast on endpoint response stalls - #462

Open
roborluo wants to merge 1 commit into
mlcommons:mainfrom
roborluo:fix-bofengl-no-progress-watchdog
Open

fix: fail fast on endpoint response stalls#462
roborluo wants to merge 1 commit into
mlcommons:mainfrom
roborluo:fix-bofengl-no-progress-watchdog

Conversation

@roborluo

@roborluo roborluo commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add an opt-in generic no-progress watchdog for in-flight endpoint requests
  • expose no_progress_timeout_s through YAML and direct CLI aliases
  • cancel the watchdog immediately when the active cohort drains, and document TensorRT-LLM disaggregated guidance

When this is useful

Use this for automated runs where a request can be accepted but the endpoint then becomes silent—for example, a TensorRT-LLM disaggregated executor or KV-transfer stall that never reaches the normal terminal-error path. It is also engine-agnostic: it catches the same client-visible silent failure through vLLM, a frontend, or transport. Without it, the benchmark can remain blocked until an outer wall-time limit.

The guard is disabled by default. Enable it only for deployments where that failure mode matters; it starts after work is issued, runs only while requests are in flight, and resets on an observed stream chunk or final result. It does not diagnose or restart the backend—it makes the benchmark fail with a clear error. For non-streaming endpoints, configure it above the full expected request latency. For TensorRT-LLM disaggregated serving, the documented starting value is 300 seconds, matching the executor hang_detection_timeout.

Validation

  • pytest tests/unit/config/test_schema.py tests/unit/commands/test_benchmark.py tests/unit/load_generator/test_async_session.py -q (334 passed)
  • python scripts/regenerate_templates.py --check
  • git diff --check
  • AGA disaggregated held-response canary: job 537769 failed intentionally after 10s with Endpoint made no response progress for 10.0s with 1 request(s) in flight. The 10-second deadline was intentional fault-injection coverage, not the deployment recommendation.

@roborluo
roborluo requested a review from a team August 22, 2026 17:29
@github-actions

Copy link
Copy Markdown

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

@github-actions github-actions Bot added the size/normal PR Review Policy: <=500 non-test lines & <=20 files label Aug 22, 2026
@codecov-commenter

codecov-commenter commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@ea33275). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #462   +/-   ##
=======================================
  Coverage        ?   81.12%           
=======================================
  Files           ?      150           
  Lines           ?    20308           
  Branches        ?        0           
=======================================
  Hits            ?    16474           
  Misses          ?     3834           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@roborluo
roborluo force-pushed the fix-bofengl-no-progress-watchdog branch from 02eeec7 to a5b80ac Compare August 24, 2026 17:18
@roborluo
roborluo requested a review from viraatc August 24, 2026 17:39

1. Publish `SessionEventType.STARTED`
2. Start receiver coroutine (`_receive_responses`)
2. Start receiver coroutine (`_receive_responses`). When the no-progress timeout is set, each active in-flight cohort arms a liveness watchdog; it exits as soon as the cohort drains. The watchdog fails the session only when an in-flight request has no streamed chunk or final result for the configured interval.

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.

Lets not use cohort here.

Comment on lines +655 to +669
help=(
"Fail a run when in-flight requests make no "
"response progress for this many seconds"
),
),
] = Field(
None,
gt=0,
description=(
"Fail a run when requests are in flight but no "
"response chunk or completion arrives for this many seconds. Disabled "
"by default; set above the longest expected interval between response "
"progress (full request latency for non-streaming endpoints) and, for "
"TensorRT-LLM disaggregated serving, match hang_detection_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.

Lets keep a single copy in the help/description so there isn't drift over time.

@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.

The opt-in liveness guard is useful, but this implementation adds avoidable work and state to the load generator's hot paths. The most serious issue is retaining every completed UUID in a second phase-lifetime container, which is an O(total requests) memory regression at the repository's 50k+ QPS target. The disabled path also performs work for every response, and low-concurrency workloads can create/cancel a watchdog task per request. Please keep the disabled path inert and use bounded active-request state plus a single event/deadline-driven watchdog.

)

if phase_issuer is not None and query_id in phase_issuer.uuid_to_index:
phase_issuer.completed_uuids.add(query_id)

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.

[P1] Avoid retaining every normally completed UUID here. completed_uuids lives for the entire phase alongside uuid_to_index, so this adds a second O(total requests) hash index. At the repository's 50k+ QPS target, long phases can accumulate millions of decoded UUID strings and multi-GB incremental memory pressure, eventually affecting cache locality or causing OOM. Keep tombstones only for exceptional/synthetic completions, or track active IDs and remove them on completion.

# This is a session-level liveness guard: any response proves
# the endpoint/transport is making progress. In particular,
# warmup responses may arrive after the performance phase starts.
self._record_response_activity()

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.

[P2] Please keep the default-disabled path inert. This unconditionally calls monotonic_ns() and writes _last_response_progress_ns for every chunk and final response even when no_progress_timeout_s is None; streaming frame rate can substantially exceed request QPS. Gate activity tracking on the feature being enabled and reuse the timestamp already taken while handling the response.

return
if self._progress_watchdog_task and not self._progress_watchdog_task.done():
self._progress_watchdog_task.cancel()
self._progress_watchdog_task = asyncio.create_task(self._watch_no_progress())

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.

[P2] This creates a new asyncio task and timer for every 0-to-1 in-flight transition, then cancels it on 1-to-0. Concurrency-one and sparse workloads therefore allocate/cancel a watchdog task per request, directly adding scheduler/timer-heap work to the guarded hot path. Prefer one phase/session-lifetime watchdog driven by an activity event and updated deadline.

self._strategy_task.cancel()
break
self._handle_response(resp)
await asyncio.sleep(timeout_s)

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.

[P2] Sleeping a full timeout on every iteration can detect silence almost 2x later than configured: if progress occurs shortly after arming, the first wake observes it and then sleeps another full interval. Compute the remaining duration from last_progress + timeout or use an event-driven deadline. That also avoids keeping stale timer work around longer than necessary.

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

Labels

size/normal PR Review Policy: <=500 non-test lines & <=20 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants