Skip to content

Commit 4235a9c

Browse files
nv-alichengclaude
andauthored
Integrate steady state into report (#514)
* feat(core): announce each phase's shape with a PHASE_START event `BenchmarkSession` publishes `session.phase_start` immediately before `START_PERFORMANCE_TRACKING`, carrying `PhaseData` (phase type, whether the phase drains, and the phase's turn/trajectory counts). Published for every phase, not just performance ones. `num_turns` is the super-pass size a steady-state consumer needs, and `events.jsonl` consumers get the phase shape recorded for free -- until now nothing on the wire said how large a dataset pass was. `_publish_session_event` grows the payload argument rather than gaining a second publish path, so PHASE_START keeps the publish-then-flush ordering that makes these control signals reliable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(metrics): promote the steady-state detector into the package `git mv` of the detector, its tests, and its operator reference into `src/inference_endpoint/metrics/`, `tests/unit/metrics/`, and `docs/`. It has to be importable before the metrics aggregator can feed it live. No algorithm change. The PEP-723 script header and the `importlib` loader shim in the tests go away; `--model`/`--tokenizer` resolution, the CLI, and the JSON blob are untouched. `python -m inference_endpoint.metrics.steady_state_diagnostics <run_dir>` replaces `uv run scripts/steady_state_diagnostics.py <run_dir>`. The `transformers` import moves to module scope, as the repo requires: the CLI already imports it through `full_run_osl`, so it costs nothing. mypy now sees the module, which caught one genuine type collision in `main()` between the NATL and window result shapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(metrics): narrow the steady-state result and add the missing metrics Splits the algorithm from the event-log parse. `compute_steady_state_metrics` is pure over a `SuperPassRollup` series -- warmup crop, plateau selection, verdict -- so a second producer can feed it without re-reading and re-tokenizing `events.jsonl`, and both reach the same verdict. The result carries only what a report needs: the window (now with its wall-clock bounds), TTFT/TPOT plus OSL and end-to-end latency, TPS with its confidence intervals, the CoV of the metrics *inside* the reported window, and the anomaly/trend flags. Flat at 4-8 KiB regardless of run length. Dropped from it: `trajectories`, `per_super_pass`, the rolling drift scan, and the per-window-size CoV table -- 85-90% of the bytes and a third of the compute, and none of it explains the verdict. `rolling_windows` and `per_super_pass_diagnostics` go with them. The standalone CLI composes the debug tables on top in `run()`: a trailing-window CoV table per `--window-sizes` plus one whole-run drift table (it was previously repeated identically per window size). The window CoV now appears in the headline, where it belongs -- it is the number that justifies the verdict, unlike the trailing table it joins. `DEFAULT_COV_BOUNDS` and `DEFAULT_WINDOW_SIZES` give the library and the CLI one grid, so a hand re-derived verdict is comparable to a live one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(metrics): collect the steady-state series inside the aggregator `SuperPassCollector` becomes the one implementation of super-pass bucketing. The metrics aggregator drives it live from the event router it already has, and `build_super_pass_series` now drives the same object by replaying an `events.jsonl`, so the two producers cannot drift: the duplicate bucketing loop in the parse is gone. `--steady-state-profile <name>` is the whole opt-in surface. The profile carries the CoV bounds and warmup driver the verdict is judged on, and only the parent knows the load pattern they follow from, so a hand re-run of the standalone detector is judged the same way. Omitted = no collection, and no boolean can contradict it. The bucket size arrives on PHASE_START and is latched while the series is still empty, so a phase announced after the performance one cannot re-size a series already built. Two ordering rules hold the TPOT path together, both pinned by tests that were confirmed to fail when the order is swapped: - `on_complete` runs *after* `set_field`, because `TpotTrigger` fires from inside `set_field` and must read the sample's super-pass before `on_complete` releases it. - `TpotTrigger` reads that super-pass at fire time, not inside the recorder closure: the token count arrives at the drain flush, long after the row is gone. Carrying the index in the closure is also what spares the collector a uuid map growing for the length of the run. ISSUED is gated on `table.is_tracking` as well as the row. `set_field` returns early for an ISSUED outside the tracking window *without* dropping the in-flight row, so a sample re-issued after STOP_PERFORMANCE_TRACKING still has one while the log parse skips it -- which would move `last_issue_ns` and give the same run two system TPS figures. The equivalence test carries exactly that sample. Buffering the parse's pending TPOT by batch position rather than by uuid falls out of the shared implementation, and with it the `KeyError` a sample completing twice inside one flush window used to raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(metrics): carry the steady-state verdict on the final snapshot `MetricsSnapshot` gains a trailing `steady_state` field, so no new artifact file and no second write of `final_snapshot.json`: the aggregator computes the verdict just before the terminal snapshot is built, and the one atomic write carries it. The pub/sub frame carries it for free, which is what a TUI would read. Computed only for a run its series actually describes -- terminal state COMPLETE, and every tokenization drained. An incomplete drain means TPOT and OSL counts are missing. The computation is wrapped, not the write: a detector failure yields `steady_state=None` and the snapshot is written exactly as before. The verdict is also the one snapshot field not built key by key, so `snapshot_to_dict` scrubs it recursively -- a single non-finite float inside it would make `json.dumps(..., allow_nan=False)` raise and cost the run the file its Report is built from. `Report.steady_state` reads it straight through and keeps it in `to_json` (unlike `accuracy`), so it reaches `result_summary.json`, `report.txt`, and the console. `display` prints the window, how long it held, steady TPS, and the two warnings; the bulky parts stay in the JSON. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(config): make steady-state detection an opt-in run setting `settings.steady_state.enabled` / `--steady-state`, off by default. The field carries an explicit cyclopts `name=`, not an `alias=`: an alias would also expose the field name, and a bare `--enabled` collides with every other flattened single-field option model. Both `--steady-state` and `--no-steady-state` parse, and neither adds an `--enabled`. `steady_state_profile` in `pipeline.py` is the whole gate: enabled, not accuracy-only, a tokenizer resolved, and a load pattern whose `Profile.supported` accepts it. Eligibility is read from the detector's own profile table, so the parent's gate and the profile the aggregator is judged on cannot drift. It has to run before the run starts, because collection happens as the run happens. `profile_for_load_pattern` now fails closed -- an unclassified load pattern resolves to no profile rather than inheriting `concurrency` -- and the `offline` profile is marked unsupported: its min-duration gate measures the issue span, which collapses to ~0 when every query is issued at t=0. The standalone CLI takes the super-pass size off the wire (`session.phase_start`) instead of a `run_meta.json` sidecar nothing writes, so a hand re-run against a report directory needs no arguments and cannot disagree with the size the run itself bucketed on. Verified end to end: a live `--steady-state` run and a zero-flag re-run over its own `events.jsonl` report the same verdict. Audit phases turn it off (`commands/audit.py`): TEST04 issues one repeated sample, which no steady-window verdict describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(metrics): refuse steady-state collection for agentic runs Agentic already resolves to an unsupported profile, so this is a second, explicit check on each side of the process boundary. The parent's gate refuses `LoadPatternType.AGENTIC_INFERENCE` by name, and the aggregator refuses any profile whose `Profile.supported` is False -- agentic and offline today. Neither side alone can now turn collection on for a workload it does not describe. An agentic run's steady-state metric is per-trajectory NATL, which this collector does not produce. Its behaviour must be identical to a run before steady state existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(metrics): store super-pass samples in array("d") The five per-sample series on `SuperPassRollup` were `list[float]`: a boxed float plus a pointer for every sample. `array("d")` packs them at 8 bytes, measured at 108 -> 34 bytes per sample across the five, so a 2M-sample run retains ~70 MB in the collector instead of ~220 MB. Nothing else moves: every reader needs only a Sequence -- append, len, truthiness, `sorted`, `sum` -- and `array("d")` satisfies all of them. Only the tests that compared a series against a list literal change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(metrics): drop the collector's duplicate uuid map `SuperPassCollector` kept a `_PendingRow` per in-flight sample, mirroring `SampleRow.issued_ns`/`recv_first_ns`: inside the aggregator the same sample lived in two uuid maps for as long as it was in flight. `SampleRow` gains an `sp_index` field instead, and the collector keeps only the series and the rollup arithmetic -- callers pass the super-pass index and the timestamps back in. The rollup arithmetic stays in one place, which is what makes the live collector and `build_super_pass_series` reach the same verdict; only the in-flight row storage differs. The parse keeps its `_PendingRow`, since it has no table to hang the index off. `TokenTrigger._make_recorder` now takes the row, so `TpotTrigger` can read `row.sp_index` at fire time -- the token count arrives at the drain flush, by which point `set_field` has dropped the row from the table. Two ordering rules move with it, both mutation-checked: the router reads the row before `set_field` (COMPLETE drops it; RECV_FIRST overwrites `recv_first_ns`, which is how a retry's second first-chunk is told apart). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(benchmark): keep the run deadline off the post-run drain `drain_and_build_report` waited on the metrics services while `RunWatchdog` was still armed -- `watchdog.cancel()` only ran in the outer `finally`. On a `--timeout` run the deadline could fire during that wait, calling `session.stop()` and setting `abort_event`, after which the teardown grace expired and the services were killed mid-finalize. That used to cost the run only the steady-state verdict, which was computed after the snapshot was written. It now costs `final_snapshot.json` itself: the verdict is computed before the single atomic write, so a kill in that window leaves the parent falling back to the last live pub/sub snapshot -- non-terminal -- and a clean run reports as incomplete. The watchdog is cancelled immediately before the drain. Everything past the session's end is cleanup, not the run; `watchdog.fired` is untouched, so a run that genuinely timed out still reports as timed out. The parent cannot cancel between the drain and the analysis -- both happen inside the subprocess and the wait is opaque from outside. `wait_for_exit(None)` becomes `metrics_drain_timeout_s + 60s`, so removing the deadline does not let a wedged aggregator hang the parent forever. The grace covers the publisher's 10s ZMQ linger, the analysis (13.7s measured at 2M samples), the snapshot write and process exit. An unlimited drain budget leaves the wait unbounded: bounding only the tail would buy nothing and could kill a legitimately slow drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(metrics): state the supported workloads as a table Replaces the prose sentence listing what steady-state detection silently skips with a table of load patterns and the reason each is or is not judged, and says plainly that detection is best-effort and cannot cost a run its result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(metrics): say why an explicit --steady-state was refused The gate returned None for five different reasons without saying which, so a user who asked for detection on the command line got nothing and no explanation. It now warns with the reason. Opting out stays silent: nothing was promised, so there is nothing to explain. Moves the post-drain service-exit grace onto `Timeouts` beside the other deadlines rather than leaving it a module constant in pipeline.py. It carries `show=False`, so it is co-located without becoming an operator knob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(audit): lean on the steady-state default instead of restating it Detection is off by default, so an audit phase needs a default-constructed SteadyStateConfig rather than an explicit enabled=False. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(metrics): point steady-state policy at the rules repo What counts as an official result is set by endpoints_policies §4.4, not here. Links it and says the rules win on disagreement, so this document can stay a description of the tool rather than a second copy of policy that drifts from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(metrics): drop window sizes, report CoV over what was measured The detector grows the maximal admissible plateau, so the fixed (4, 6, 8) window grid never reached a verdict -- it only produced trailing-CoV debug tables in the standalone CLI. Removes `--window-sizes`, `DEFAULT_WINDOW_SIZES` and the per-size scan. What those tables were for still matters: a run that found no window had `cov: {}`, so the one case worth debugging carried no CoV at all. The verdict now measures CoV over the longest *trend-steady* span instead, and `cov_basis` says which span that was. Splitting the gate into its trend and scatter halves makes that distinguishable, so a run rejected for noise now says so: no admissible steady plateau: the longest trend-steady span is 12 super-passes, but its CoV exceeds 0.08 rather than reporting only that no plateau was admissible. That reason reaches the console and result_summary.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(metrics): answer the CoV question without searching for it `largest_trend_steady_span` scanned every candidate window to find the longest trend-steady span, and each probe rebuilt the per-super-pass percentile trajectory from scratch -- re-sorting raw samples that do not depend on the window -- then ran a pairwise trend test over it. Measured on a drifting series at 100 samples per super-pass: n= 80 0.54s n=160 5.42s n=240 22.19s ~n^3.4. `n` is passes over the dataset, so 600k samples over a 500-row dataset is n=1200, which extrapolates past an hour -- on the `found: false` path, which is the run an operator most wants a verdict for, and in front of the snapshot write. The search was re-deriving something already at hand: whether the run drifts is a whole-span question, and one trend test answers it. Same rule as `_window_gate`, effect-size floor included, so "trend-steady" still means what it means for admissibility. Trend-steady throughout -> report CoV over that span; drifting -> no CoV to claim, and the reason names the metric. n=240 22.19s -> 0.008s What this gives up: a flat-but-noisy sub-span inside an otherwise drifting run is no longer singled out. The anomaly/plateau detail and `drifting_up` already name what went wrong there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(metrics): give the steady-state analysis a deadline of its own The docstring claimed the analysis was bounded by the metrics drain budget. It was not: that budget covers `flush_remaining`, which has already returned by the time the verdict is computed. Nothing else bounded it, and it runs in front of the final snapshot write -- so an overrun meant the parent's grace expired, `wait_for_exit` killed the process before `publish_final`, and the run lost `final_snapshot.json` entirely rather than just its verdict. The Report then fell back to a non-terminal tick and the run failed. "No failure here may cost the run its snapshot" held for exceptions but not for wall-clock. It does now: the analysis runs off the event loop with a 30s ceiling, comfortably inside the parent's 60s grace, and a run that blows it publishes without a verdict. Off-loop because a synchronous call cannot be interrupted; abandoning the thread is fine because the process exits moments later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(benchmark): keep a ceiling on the post-run wait by default Cancelling the run watchdog before the drain removed the only thing that could interrupt a wedged service, and the replacement bound existed only when the operator had set `metrics_drain_timeout_s`. That is `None` by default -- the generated templates all ship `metrics_drain_timeout_s: null` -- so on a default config the parent waited on `wait_for_exit(None)` forever with nothing left to abort it. A `--timeout` run used to fail at its deadline; it would now hang the CLI indefinitely. An unlimited drain has no budget to extend, so fall back to the run deadline: it is the operator's own number for how long anything should take, and it restores exactly the ceiling the watchdog used to provide without letting a late firing relabel a run whose work already finished. Unbounded only when neither knob is set, which is the operator declining to bound either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(metrics): stop the verdict advertising a flag its reader may not have The "all plateaus too short" reason ended with "pass --no-min-duration to override". That reason now rides the snapshot into result_summary.json, report.txt and the console, where the reader ran `benchmark online --steady-state` and has no such flag -- it exists only on the standalone CLI, whose own --help documents it. Also corrects three stale descriptions the window-sizes removal left behind: the module docstring still described a per-window-size CoV table, the operator doc claimed `offline` uses a completion-based window (it uses the same issue-time span, which is precisely why it is unsupported), and a test comment still named `build_steady_state`. Guards the aggregator's profile lookup while here: an unknown name raised a bare KeyError that killed the subprocess at startup, so the parent reported a launch timeout naming nothing. It now degrades to collection-off with a warning, like every other ineligibility. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(metrics): survive a scrubbed verdict, and size super-passes from the pass Two defects that hide each other. `snapshot_to_dict` scrubs every non-finite float in the verdict to `None` before the snapshot is written, and `dict.get(key, default)` returns that stored `None` rather than the default. Formatting it with `:.2f` raised out of `Report.display()` -- on the success path of every run that produced a verdict. `cov()` divides by a mean that can be zero, so it was reachable. Values are coerced at the display boundary now. The super-pass size was taken from `dataset.num_samples()`, but the sample order cycles over `RuntimeSettings.n_samples_from_dataset`. The ruleset path sets that from `ds_subset_size` without truncating the dataloader, so a submission-ref run with a subset sized super-passes to a pass that never happens -- bucket boundaries silently stopped meaning "one pass over the dataset", which is the premise of the whole rollup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(core): move PhaseType to core/types, and decode PhaseData `PhaseType` lived beside `BenchmarkSession`, so the two consumers that need it on the wire could not import it: `importing load_generator.session costs 13.9s against 18ms for core.types`, and the metrics aggregator subprocess imports the detector. The string was therefore written out three times -- `PERFORMANCE_PHASE` in the detector, `_PERFORMANCE_PHASE` in full-run OSL, and the enum itself. Moving it to `core/types.py` deletes all three copies and lets `PhaseData.phase_type` be a `PhaseType` rather than a bare `str`, which is where it belonged: the field exists to carry this enum over the wire. Also replaces the hand-indexed `PhaseData` read in `superpass_size_from_events` with `msgspec.convert`. The struct is `array_like` -- every member of the `EventRecord.data` tagged union must be -- so it lands in the log as `["PhaseData", "performance", true, 1000, 0]` and indexing it by position would quietly read the wrong field the day one is added. `convert` checks the tag and the arity too. The agentic `num_turns` becomes `len(conv.samples)`: turns issued in one pass over the dataset, matching what the single-turn branch reports, rather than `max_turns_per_conv` -- a per-conversation ceiling that is not a count of anything issued. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(metrics): make the steady-state verdict a typed Struct tree The verdict's seven types were TypedDicts because this file used to be a standalone script: no msgspec dependency, output written with json.dump. Neither holds now -- it lives in the package, rides `MetricsSnapshot`, and lands in result_summary.json -- so the shape was declared in one place and then re-guessed with `.get()` defaults at every consumer. They are msgspec Structs now, `MetricsSnapshot.steady_state` is `SteadyState | None`, and `Report.from_snapshot` converts rather than carrying `dict[str, Any]` through to the renderer. A verdict that does not match the schema is dropped, not coerced: it is a diagnostic, and a run that produced one still deserves its report, so the absence is made visible instead of printing a plausible wrong number. That replaces the `_num` coercion added a few commits ago -- the crash it guarded is now unrepresentable, since `end_ns` cannot be None. mypy checks field types through this (verified: `return ss.reason` from an `-> int` is now an error). The `type: ignore[call-arg]` on each class line is the repo's existing convention for msgspec class kwargs -- eight other modules carry it, and msgspec 0.20 ships no mypy plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(metrics): pin the resize guard, and note what archived runs need The test named for the "a later phase cannot re-size a started series" invariant announced an accuracy phase, which the router drops on phase_type before the collector sees it -- so the guard it was named for had no coverage at all. Splits it: one case for the router's check, one for a *second performance* announcement, which clears that check and leaves `announce_phase`'s own empty-series guard as the only thing stopping a mid-run re-bucket. Removing that guard now fails the second case and only the second case. Also documents the back-compat edge the PHASE_START switch introduced: run directories written before the event carry no announced super-pass size, so the zero-argument re-run needs `--superpass-size` for those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(metrics): rewrite the steady-state prose for readability Review feedback was that several docstrings and comments were harder to follow than the code they described. Rewrites them to lead with what the function does before the rationale, and drops the narrative phrasing. Prose only: every file here is byte-identical after stripping docstrings and comments, apart from two argparse `help=` strings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(metrics): drop the new timeouts, and name the super-pass index in full Review asked not to add another user-tunable timeout, and questioned whether the analysis needed a deadline at all. Both hold up on measurement, so this removes the whole chain: - `Timeouts.service_exit_grace_s` is gone. It only existed because cancelling the watchdog removed the post-run ceiling, which needed a bound on `wait_for_exit`, which needed a grace on top of the drain budget. `Timeouts` is now unchanged by this branch. - The watchdog is no longer cancelled before the metrics drain, so `execute.py` matches main again. - `wait_for_exit` goes back to an unbounded wait. It SIGKILLs on expiry, and the aggregator writes final_snapshot.json as the last thing it does, so a parent-side deadline trades a wedged-service hang for a lost snapshot. The abort path keeps `interrupted_teardown_grace_s`. - The steady-state analysis carries no deadline. It is pure CPU over rollups the collector already built: 2.9s at 100 super-passes (438,800 samples, the longest shape we run) and 19s at 245. A deadline guards a cost that does not occur. Failure stays contained either way: `_steady_state_verdict` catches, so a failure publishes the snapshot with every other stat and no verdict, and it runs in `asyncio.to_thread` so a SIGTERM mid-analysis still reaches its handler. Also renames `sp_index` to `superpass_index` throughout, per review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e71b928 commit 4235a9c

38 files changed

Lines changed: 2818 additions & 950 deletions

‎AGENTS.md‎

Lines changed: 21 additions & 3 deletions
Large diffs are not rendered by default.

‎docs/steady_state_diagnostics.md‎

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# `steady_state_diagnostics`
2+
3+
Use this CLI to inspect steady-state and drift for a benchmark run's
4+
`events.jsonl`. The implementation is
5+
`src/inference_endpoint/metrics/steady_state_diagnostics.py`. The full methodology
6+
lives in [`steady-state-detection.md`](steady-state-detection.md); this page is the
7+
operator's quick reference.
8+
9+
**The rules live elsewhere.** MLCommons
10+
[§4.4 Reporting Basis (Steady-State Window)][rules] defines the official result:
11+
when a steady-state window supersedes the whole-run `total`, how coverage
12+
`status` is classified, and which load patterns are in scope. This document
13+
describes the tool and its defaults. Where the two disagree, the rules win.
14+
15+
[rules]: https://github.com/mlcommons/endpoints_policies/blob/main/endpoints_rules.md#44-reporting-basis-steady-state-window
16+
17+
## Two ways to get a verdict
18+
19+
**During a run** (opt-in): `--steady-state` /
20+
`settings.steady_state.enabled: true`. The metrics aggregator collects the same
21+
super-pass series while the run is active. It reuses the token counts from the
22+
TPOT trigger and computes the verdict after token drain finishes.
23+
24+
The verdict lands on `Report.steady_state`, then reaches `result_summary.json`,
25+
`report.txt`, and the console summary.
26+
27+
Live detection is off by default. It can judge these load patterns:
28+
29+
| Load pattern | Steady state | Why |
30+
| ----------------- | ------------- | ----------------------------------------------------------------------------------- |
31+
| `concurrency` | supported | |
32+
| `poisson` | supported | |
33+
| `offline` | not supported | every query is issued at t=0, so the min-duration gate has no issue span to measure |
34+
| `agentic` | not supported | its steady-state metric is per-trajectory NATL, not what this collector produces |
35+
| anything unmapped | not supported | fails closed rather than inheriting a profile nobody validated |
36+
37+
A live verdict also requires:
38+
39+
- a performance phase, not accuracy-only mode;
40+
- streaming, because TPOT is the gated metric and only exists when streaming;
41+
- a resolvable tokenizer.
42+
43+
If any requirement is missing, collection stays off.
44+
45+
Detection is best-effort. It runs inside the aggregator's finalize path. If it
46+
fails, the performance report is still written.
47+
48+
**After a run** (this CLI): replays `events.jsonl` through the same collector and
49+
the same `compute_steady_state_metrics`. The CLI is the debug and audit path. It
50+
is also the only path that prints the drift tables.
51+
52+
## What it does
53+
54+
1. Buckets performance-tracked samples into **super-passes** by issue order
55+
(`--superpass-size` samples each, default `--dataset-size`).
56+
2. Reconstructs per-sample **TTFT** (`recv_first − issued`) and **TPOT**
57+
(`(complete − recv_first) / tokens(output-after-first-chunk)`). TPOT needs the
58+
`--tokenizer`, so the tokenizer is required.
59+
3. Finds the **first steady plateau**. The window grows from the start while the
60+
gated metric, **TPOT** p50 and p90, stays trend-steady
61+
(Mann–Kendall + Hamed–Rao) and within a CoV bound.
62+
4. Treats **TTFT** as a diagnostic and drift warning, not as a hard gate. At high
63+
concurrency, TTFT tail variance comes from prefill time, tracking dataset ISL
64+
skew, and queueing. It is not decode unsteadiness. See §5.5.
65+
5. Splits staircase jumps into separate plateaus. The reported steady state is the
66+
first plateau that clears the min-duration gate. Earlier plateaus that are too
67+
brief to certify are skipped. Later plateaus are usually degradation.
68+
6. Selects by estimator precision, following MSER. It does not select by
69+
throughput.
70+
7. Summarizes the selected window with TTFT/TPOT histograms and percentiles, plus
71+
**per-user and system TPS** with batch-means confidence intervals.
72+
8. Flags a level shift toward the end of the run as an `anomaly` when a
73+
multi-plateau run has a Pettitt change-point.
74+
75+
## Requirements
76+
77+
- An installed `inference-endpoint` environment.
78+
- Network access to fetch the model's tokenizer (or a local tokenizer dir).
79+
80+
## Run
81+
82+
**Zero-config**: point it at a run directory. It auto-detects everything from the
83+
sidecar `config.yaml`: model to tokenizer, and load pattern to profile.
84+
85+
```bash
86+
uv run python -m inference_endpoint.metrics.steady_state_diagnostics <run_dir>/
87+
```
88+
89+
> **Runs without the `session.phase_start` event** do not announce the super-pass
90+
> size, so auto-detection cannot find one. Pass `--superpass-size <N>` as one full
91+
> dataset pass. Everything else still resolves from `config.yaml`.
92+
93+
**One flag**: for a bare `events.jsonl` with no sidecar, `--model` drives the
94+
built-in model-to-tokenizer registry and the workload profile:
95+
96+
```bash
97+
uv run python -m inference_endpoint.metrics.steady_state_diagnostics events.jsonl \
98+
--model kimi-k3
99+
```
100+
101+
The run config and **workload profile** auto-resolve the tokenizer, super-pass
102+
size, CoV bounds, and metric. Profiles are `concurrency`, `poisson`, `offline`,
103+
and `agentic`.
104+
105+
These optional overrides are still available:
106+
107+
```
108+
--model <name> # else auto-detected from config
109+
--tokenizer <hf-id-or-dir># else from the model registry / config
110+
--dataset-size <N> # else from the performance phase_start event
111+
--profile {concurrency,poisson,offline,agentic} # else from load_pattern
112+
--superpass-size N --warmup auto --warmup-band 0.05
113+
--warmup-driver tpot_p50 --cov-bounds 0.03,0.05,0.08 --trend-gate mk_hamed_rao
114+
--tokenize-batch-size N --trust-remote-code --json out.json
115+
--no-min-duration # downgrade the min-duration gate from reject to warning
116+
```
117+
118+
**Model registry** (extend as needed):
119+
120+
- `kimi-k3`, `kimi-k2` → Moonshot (trust-remote-code)
121+
- `gpt-oss` → `openai/gpt-oss-120b`
122+
- `deepseek-r1`/`dsr1` → `deepseek-ai/DeepSeek-R1`
123+
124+
**Profiles:**
125+
126+
- `concurrency` and `poisson` use the standard issue-time window and **TPOT
127+
gate**. TTFT is diagnostic, not a gate.
128+
- `offline` uses the same gate, window, and throughput span. It is not supported
129+
because every query is issued at t=0, so the span collapses.
130+
- `agentic` computes **NATL**, a per-trajectory throughput metric, over
131+
trajectory super-passes. It prints a prominent **NOT-YET-SUPPORTED** banner.
132+
Agentic steady-state is experimental and must not be used for submissions.
133+
134+
## Interpreting the output
135+
136+
### Headline — `STEADY STATE`
137+
138+
```
139+
=== STEADY STATE (headline) ===
140+
window: super-passes 0..3 (post-warmup), 23519 samples
141+
TPS per-user: 302.3 tok/s/user CI [302.1, 302.5]
142+
TPS system: 40960.9 tok/s CI [39399.3, 40606.8]
143+
TTFT p50 86.26ms p90 156.10ms p99 248.51ms mean 97.41ms
144+
TPOT p50 3.29ms p90 3.44ms p99 3.56ms mean 3.31ms
145+
```
146+
147+
- **window** — the steady plateau, as **post-warmup** super-pass indices `lo..hi`, plus
148+
the pooled sample count it was measured over.
149+
- **TPS per-user** = `1 / mean(TPOT)` — output tokens/s for a single stream
150+
(interactivity). **TPS system** = total output tokens ÷ window wall-clock (aggregate
151+
throughput). Each `CI` is a 95% batch-means interval (super-passes as batches), so it
152+
reflects per-super-pass variability, not a naïve iid interval.
153+
- **TTFT / TPOT** — percentiles and mean over the pooled raw samples of the window.
154+
155+
If no window qualifies:
156+
157+
```
158+
not found: no admissible steady plateau
159+
```
160+
161+
means no contiguous run of super-passes was steady enough. The run is drifting or
162+
too short. The per-window diagnostics below show which metric failed CoV or trend.
163+
164+
A second "not found" form comes from the **min-duration gate**. See
165+
docs/steady-state-detection.md §5.5. In this case, each admissible plateau is real
166+
but too brief in wall time to certify.
167+
168+
```
169+
not found: all 6 admissible plateau(s) too short: longest 15s < 600s required (floor-dominated); pass --no-min-duration to override
170+
```
171+
172+
A window can clear the ≥4-super-pass floor and still last only seconds at high
173+
throughput. For example, a `c16k`-scale run's 4 super-passes are about the
174+
concurrency. That is too short to reveal a minutes-scale hiccup.
175+
176+
The required duration is:
177+
178+
```
179+
max(precision, relaxation, floor)
180+
```
181+
182+
The terms are:
183+
184+
- `floor`: 600s. This binds for clean, fast runs.
185+
- `relaxation`: 5·p90-latency. This binds for long-tail workloads.
186+
- `precision`: k·τ. This binds for noisy metrics.
187+
188+
By default, the min-duration gate is **part of window selection**. The first
189+
plateau that clears `min_duration` is reported. Earlier too-short plateaus are
190+
skipped, and the headline shows
191+
`note: skipped N earlier plateau(s) below min-duration`.
192+
193+
The run reports `not found` only when no plateau qualifies. `--no-min-duration`
194+
turns the gate into a warning and reports the first plateau with
195+
`WARNING: Window too short`.
196+
197+
The full breakdown is in `--json`: `steady_state.short_window`,
198+
`window.plateau_index`, and `skipped_short`.
199+
200+
### `ANOMALY` line
201+
202+
```
203+
ANOMALY: level shift at super-pass 6, TPOT +100.0% toward end of run (likely degradation)
204+
```
205+
206+
A second, materially different plateau was detected after the first and confirmed
207+
by a Pettitt change-point. The headline steady result remains the **first**
208+
plateau. This line says the run degraded later, for example from KV-cache
209+
eviction or an unhealthy worker. `delta_pct` is signed (+ = TPOT rose = worse).
210+
211+
### `WARNING` line
212+
213+
```
214+
WARNING: ttft_p90 drifting UP over the rest of the run -- the window is a local
215+
plateau; global steady state is questionable
216+
```
217+
218+
The reported window is locally steady, but a **watched metric (TPOT or TTFT)**
219+
keeps climbing over the super-passes **after** it. The trend test covers the
220+
whole tail, not just the selected window.
221+
222+
TTFT is a soft warning, not a hard gate. A slow TTFT climb can show queue
223+
saturation building across a long high-concurrency run. The warning surfaces
224+
that drift without fragmenting the selected window.
225+
226+
Treat the steady number as a best-effort local plateau, not a clean whole-run
227+
steady state. `drifting_up` in `--json` lists the affected metrics. It is
228+
distinct from `anomaly`, which is a discrete step.
229+
230+
### Diagnostics (below the headline)
231+
232+
Per `--window-size`, the CLI prints a **CoV steadiness** table and a
233+
**whole-run trend** summary. The CoV table covers each gated and diagnostic
234+
metric against each CoV bound over the trailing window. The trend summary covers
235+
each metric across the algorithms.
236+
237+
The full rolling drift scan, including every window position, is only in
238+
`--json`.
239+
240+
### `--json`
241+
242+
Full structured result: `steady_state` (window, `ttft`/`tpot` summaries + histograms,
243+
`tps`, `anomaly` with every plateau), plus `trajectories`, `cov`, and `drift` (the
244+
rolling scan) for deeper analysis.
245+
246+
## Caveats
247+
248+
- **TPOT parity.** Token counts use plain tokenization of the output. The live
249+
aggregator uses the chat-template path for reasoning and tool-call outputs, so
250+
absolute TPOT ms can differ for reasoning models. CoV and the trend tests are
251+
scale-invariant, so the steady/drift **verdicts** are unaffected. Only the
252+
absolute TPOT magnitude can differ.
253+
- **Window sizes < 4** are useless for drift because the trend test needs ≥ 4
254+
points. They still contribute to the CoV table.
255+
- Window indices are **post-warmup relative**. Add the resolved warmup, shown as
256+
`warmup N [auto|fixed]` in the header, to get absolute super-pass numbers.
257+
- **Auto warmup** (default) crops leading super-passes that are still ramping
258+
toward the steady TPOT level. The band is around the back-half median. On
259+
long-ramp runs, such as high-concurrency and long-output runs, this can be
260+
large. A DeepSeek-R1 c28k run can crop about 24 super-passes. This moves the
261+
window off the ramp shoulder and onto the true plateau. Use a fixed
262+
`--warmup N` to override.

0 commit comments

Comments
 (0)