Skip to content

Commit 2feebbb

Browse files
committed
fix: add endpoint liveness watchdog
Keep the guard disabled by default; recommend a 300-second or longer deadline when enabled. Preserve standard artifacts after a liveness failure and cover session-wide progress semantics.
1 parent 8259f8c commit 2feebbb

14 files changed

Lines changed: 863 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ Dataset Manager ──> Load Generator ──> Endpoint Client ──> External
9090
- **Online** (`poisson`): Fixed QPS with Poisson arrival distribution for latency profiling
9191
- **Concurrency**: Fixed concurrent request count
9292

93+
### Endpoint liveness
94+
95+
Set an endpoint response idle timeout to fail the run when in-flight work stops responding. Use `>=300` seconds; raise it for long requests:
96+
97+
```yaml
98+
settings:
99+
timeouts:
100+
endpoint_response_idle_timeout_s: 300
101+
```
102+
103+
Or pass `--endpoint-response-idle-timeout 300`. The guard is client-side; see [the configuration guide](docs/config/DESIGN.md#endpoint-response-idle-timeout) for tuning.
104+
93105
### Performance Design
94106

95107
The hot path is optimized for minimal overhead:

‎docs/CLI_DESIGN.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Both paths produce the **same subclass with the same defaults**. A YAML file wit
6161

6262
3. **Datasets injected after construction.** `--dataset` strings are parsed by a `BeforeValidator` on the `datasets` field, then merged via `config.with_updates(datasets=...)`.
6363

64+
Fields get their dotted path automatically; those needing a shorter operational spelling also declare a `cyclopts.Parameter(alias=...)`. For example `settings.timeouts.endpoint_response_idle_timeout_s` accepts either `--timeouts.endpoint-response-idle-timeout-s` or the alias `--endpoint-response-idle-timeout`; it defaults to off and, when enabled, we recommend `>=300` seconds.
65+
6466
### YAML path
6567

6668
1. **`from_yaml_file(path)`** loads YAML, resolves `${VAR}` env vars on parsed values, then passes the dict to a Pydantic `TypeAdapter` with `Discriminator`.

‎docs/CLI_QUICK_REFERENCE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ settings:
300300
timeouts:
301301
run_timeout_s: null # Whole-run watchdog; null = off
302302
performance_drain_timeout_s: null # null = unlimited
303+
endpoint_response_idle_timeout_s: 300 # Endpoint liveness deadline; use >=300 s, null = off
303304
load_pattern:
304305
type: "max_throughput"
305306
target_qps: 10.0

‎docs/config/DESIGN.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Key nested models:
4848
| Model | Purpose |
4949
| ---------------- | --------------------------------------------------- |
5050
| `LoadPattern` | Pattern type + parameters (target QPS, concurrency) |
51-
| `RuntimeConfig` | Duration, sample count, RNG seeds |
51+
| `RuntimeConfig` | Workload sizing, issue-duration cap, and RNG seeds |
5252
| `ClientSettings` | Worker count and HTTP client settings |
5353
| `EndpointConfig` | Endpoint URLs, API key |
5454
| `Dataset` | Dataset path, type (performance / accuracy) |
@@ -71,6 +71,16 @@ Immutable snapshot of all parameters needed to execute a run.
7171

7272
Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance.
7373

74+
### Endpoint response idle timeout
75+
76+
`settings.timeouts.endpoint_response_idle_timeout_s` is a client-side liveness deadline, `None` by default. When enabled, use at least 300 seconds in normal deployments. Once a phase has an in-flight request, `BenchmarkSession` fails the run if no streamed chunk or final response arrives within the interval. The clock starts at local issuance — when the request is handed to the client transport. There is no server-acceptance acknowledgment in the client contract, so worker scheduling, connection acquisition, and local transport queueing all consume the deadline before the server even sees the request. It does not replace an engine's own KV-transfer deadline.
77+
78+
**What counts as progress.** Only messages that reach the main process reset the clock. Under the default `client.stream_all_chunks: false`, workers forward a request's first streamed chunk and its final result — intermediate chunks never cross the IPC boundary. The largest gap a healthy streaming request can therefore produce is its full first-chunk-to-completion span, not a per-token gap. With `stream_all_chunks: true` every chunk resets the clock, at the cost of per-chunk IPC traffic.
79+
80+
**Choosing a value.** Use `>=300` seconds and set it above the deployment's longest expected gap between main-process-visible events. Under the default `stream_all_chunks: false` — and for any non-streaming endpoint — that is the full latency of the longest single request, including pre-accept overhead; use `600` seconds or more for reasoning or long-output workloads. For TensorRT-LLM disaggregated serving, match the executor `hang_detection_timeout` (default 300 s), not `cache_transceiver_config.kv_transfer_timeout_ms`; raise the value if the normal p99 gap between visible events is longer.
81+
82+
**On failure**, the run still drains the metrics pipeline and writes the standard artifacts (`performance/result_summary.json`, `report.txt`) with the report marked `INTERRUPTED`, then exits non-zero.
83+
7484
### `BenchmarkSuiteRuleset` (abstract base)
7585

7686
Extension point for competition-specific constraints.

‎docs/load_generator/DESIGN.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ each producing an independent report.
3535
BenchmarkSession.run(phases)
3636
|
3737
+-- STARTED
38+
+-- [endpoint response idle watchdog] armed only if endpoint_response_idle_timeout_s is set (default null)
3839
+-- [warmup] strategy.execute() → drain_after=False (keep in-flight saturated)
3940
+-- [perf phase 1] START_PERFORMANCE_TRACKING → strategy.execute() → drain → STOP_PERFORMANCE_TRACKING
4041
+-- [warmup] strategy.execute() → drain_after=False (keep in-flight saturated)
@@ -173,7 +174,7 @@ class BenchmarkSession:
173174
**`run(phases)`** lifecycle:
174175

175176
1. Publish `SessionEventType.STARTED`
176-
2. Start receiver coroutine (`_receive_responses`)
177+
2. Start receiver coroutine (`_receive_responses`). When the endpoint response idle timeout is set, a single session-liveness timer is armed while work is in flight and fails the session if every in-flight request is silent for the interval. Configure `>=300` seconds; see `docs/config/DESIGN.md` for tuning.
177178
3. For each phase:
178179
a. Create `SampleOrder` and `LoadStrategy` from phase settings
179180
b. Set `self._current_dataset` to phase dataset
@@ -296,13 +297,17 @@ async def _receive_responses(self):
296297
self._strategy_task.cancel()
297298
break
298299
self._handle_response(resp)
300+
if self._endpoint_response_idle_timeout_ns is not None:
301+
self._last_response_progress_ns = time.monotonic_ns()
299302
```
300303

301304
Uses `recv()` exclusively — no `poll()` spin. The ZMQ fd is registered with
302305
the event loop, so `recv()` wakes exactly when a response is available with
303306
zero CPU overhead. Each `recv()` call yields to the event loop, ensuring
304307
strategy coroutines (call_at callbacks, semaphore waiters) are never starved.
305308

309+
The endpoint response idle guard rides this same path to catch a client-visible worker or transport stall. It is not a per-request engine timeout: any chunk or final response counts as progress, and only a fully silent endpoint fails the run. A request may remain stuck while other in-flight requests respond; once those requests drain, the remaining stuck request causes the session to fail after the interval. The receiver only stamps a timestamp, and only when the deadline is set — streaming frame rate is QPS x output length, so per-chunk work is paid per token. The armed `call_later` timer re-derives its own deadline from that stamp when it fires, and re-arms for the remainder if progress raced it.
310+
306311
For `ConcurrencyStrategy`, `_handle_response` calls `strategy.on_query_complete()`
307312
which releases the semaphore. Since `recv()` returns as soon as the fd is readable
308313
and `eager_task_factory` executes the woken semaphore waiter synchronously, there

‎src/inference_endpoint/commands/benchmark/execute.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
from inference_endpoint.load_generator.conversation_manager import ConversationManager
102102
from inference_endpoint.load_generator.session import (
103103
BenchmarkSession,
104+
EndpointResponseIdleTimeoutError,
104105
PhaseConfig,
105106
PhaseType,
106107
SessionResult,
@@ -170,6 +171,9 @@ class BenchmarkResult:
170171
profiling: dict[str, Any] | None = None
171172
run_timed_out: bool = False
172173
user_interrupted: bool = False
174+
# Endpoint response idle timeout message when the endpoint stalled; the run still drains
175+
# and finalizes its artifacts, then run_benchmark fails with this message.
176+
stall_error: str | None = None
173177

174178

175179
@dataclass
@@ -871,6 +875,7 @@ async def _run_benchmark_async(
871875
loop=loop,
872876
on_sample_complete=on_sample_complete,
873877
session_id=session_id,
878+
endpoint_response_idle_timeout_s=ctx.config.settings.timeouts.endpoint_response_idle_timeout_s,
874879
)
875880
watchdog.bind_session(session)
876881
sigint.bind_session(session, abort_event)
@@ -883,6 +888,7 @@ async def _run_benchmark_async(
883888
)
884889
_perf_cap_done = False
885890
session_completed_normally = False
891+
stall_error: str | None = None
886892

887893
def _on_perf_phase_timeout() -> None:
888894
if not _perf_cap_done:
@@ -929,6 +935,23 @@ def _on_phase_start(phase: PhaseConfig) -> None:
929935
start_time_ns=0,
930936
end_time_ns=0,
931937
)
938+
elif isinstance(e, EndpointResponseIdleTimeoutError):
939+
# Endpoint stall is fatal but the drained data is still
940+
# worth keeping: fall through to the drain below so the
941+
# interrupted report and standard artifacts are written,
942+
# then run_benchmark fails with this message.
943+
logger.error(
944+
"Endpoint stalled — finalizing partial results "
945+
"before failing the run: %s",
946+
e,
947+
)
948+
stall_error = str(e)
949+
result = SessionResult(
950+
session_id=session_id,
951+
phase_results=[],
952+
start_time_ns=0,
953+
end_time_ns=0,
954+
)
932955
else:
933956
raise ExecutionError(f"Benchmark execution failed: {e}") from e
934957
finally:
@@ -1010,6 +1033,7 @@ def _on_phase_start(phase: PhaseConfig) -> None:
10101033
profiling=profiler.payload(),
10111034
run_timed_out=watchdog.fired,
10121035
user_interrupted=sigint.interrupted,
1036+
stall_error=stall_error,
10131037
)
10141038

10151039

@@ -1157,6 +1181,7 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None:
11571181
aborted = (
11581182
bench.run_timed_out
11591183
or bench.user_interrupted
1184+
or bench.stall_error is not None
11601185
or (report is not None and report.state == "interrupted")
11611186
)
11621187
if report is not None and aborted and report.state != "interrupted":
@@ -1253,6 +1278,10 @@ def run_benchmark(
12531278
f"Run timeout ({run_timeout_s}s) reached; run aborted and "
12541279
"report marked INTERRUPTED"
12551280
)
1281+
if bench.stall_error is not None:
1282+
raise ExecutionError(
1283+
f"{bench.stall_error}; run aborted and report marked INTERRUPTED"
1284+
)
12561285
if bench.report is None:
12571286
raise ExecutionError("Benchmark produced no usable metrics report")
12581287
if not (report := bench.report).complete:

‎src/inference_endpoint/config/schema.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,21 @@ class Timeouts(WithUpdatesMixin, BaseModel):
801801
gt=0,
802802
description="Whole-run watchdog seconds (None = off).",
803803
)
804+
endpoint_response_idle_timeout_s: Annotated[
805+
float | None,
806+
cyclopts.Parameter(
807+
alias="--endpoint-response-idle-timeout",
808+
),
809+
] = Field(
810+
None,
811+
gt=0,
812+
description=(
813+
"Endpoint liveness deadline: fail the run when work is in flight but "
814+
"no response chunk or completion arrives for this many seconds "
815+
"(None = off). When enabled, recommend >=300 seconds and size it above the longest "
816+
"single-request latency. See docs/config/DESIGN.md."
817+
),
818+
)
804819
interrupted_teardown_grace_s: float = Field(
805820
30.0,
806821
gt=0,

‎src/inference_endpoint/config/templates/concurrency_template_full.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ settings:
8686
worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system
8787
timeouts: # All global waits and deadlines (see config/schema.py)
8888
run_timeout_s: null # Whole-run watchdog seconds (None = off).
89+
endpoint_response_idle_timeout_s: null # Endpoint liveness deadline: fail the run when work is in flight but no response chunk or completion arrives for this many seconds (None = off). When enabled, recommend >=300 seconds and size it above the longest single-request latency. See docs/config/DESIGN.md.
8990
interrupted_teardown_grace_s: 30.0 # Abort drain grace before remaining services are killed.
9091
service_ready_timeout_s: 30.0 # Service startup wait in seconds.
9192
warmup_drain_timeout_s: 240.0 # Warmup drain seconds (None = unlimited).

‎src/inference_endpoint/config/templates/offline_template_full.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ settings:
8686
worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system
8787
timeouts: # All global waits and deadlines (see config/schema.py)
8888
run_timeout_s: null # Whole-run watchdog seconds (None = off).
89+
endpoint_response_idle_timeout_s: null # Endpoint liveness deadline: fail the run when work is in flight but no response chunk or completion arrives for this many seconds (None = off). When enabled, recommend >=300 seconds and size it above the longest single-request latency. See docs/config/DESIGN.md.
8990
interrupted_teardown_grace_s: 30.0 # Abort drain grace before remaining services are killed.
9091
service_ready_timeout_s: 30.0 # Service startup wait in seconds.
9192
warmup_drain_timeout_s: 240.0 # Warmup drain seconds (None = unlimited).

‎src/inference_endpoint/config/templates/online_template_full.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ settings:
8787
worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system
8888
timeouts: # All global waits and deadlines (see config/schema.py)
8989
run_timeout_s: null # Whole-run watchdog seconds (None = off).
90+
endpoint_response_idle_timeout_s: null # Endpoint liveness deadline: fail the run when work is in flight but no response chunk or completion arrives for this many seconds (None = off). When enabled, recommend >=300 seconds and size it above the longest single-request latency. See docs/config/DESIGN.md.
9091
interrupted_teardown_grace_s: 30.0 # Abort drain grace before remaining services are killed.
9192
service_ready_timeout_s: 30.0 # Service startup wait in seconds.
9293
warmup_drain_timeout_s: 240.0 # Warmup drain seconds (None = unlimited).

0 commit comments

Comments
 (0)