feat(metrics): opt-in MLPerf early-stopping percentile estimates (LoadGen parity)#417
Conversation
|
MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅ |
There was a problem hiding this comment.
Code Review
This pull request introduces an optional early-stopping percentile estimate feature for tail-latency metrics (TTFT, TPOT, latency), mirroring the MLPerf LoadGen binomial-confidence-backed approach. The implementation includes updates to the metrics aggregator, a new configuration schema, and a post-hoc analysis script for historical event logs. I have reviewed the changes and suggest addressing a potential resource leak in the analysis script by using a 'with' statement for file handling and improving maintainability in the '_betacf' function by replacing magic numbers with named constants.
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.
nvzhihanj
left a comment
There was a problem hiding this comment.
Review Council — Multi-AI Code Review
Reviewed by: Codex (gpt-5.5, xhigh) + Claude | Depth: thorough
Posting 14 inline findings (1 critical, 5 medium, 8 low). Verified against HEAD before posting; summary comment follows.
Review Council — Multi-AI Code ReviewReviewed by: Codex (gpt-5.5, xhigh) + Claude | Depth: thorough (diff ~1.3k lines) Found 15 issues across 10 files. All line numbers and claims verified against HEAD ( 🔴 Must Fix (critical/high)
🟡 Should Fix (medium)
🔵 Consider (low)
Fixes for all findings are being applied to this branch iteratively. 🤖 Generated with Claude Code |
- critical: restore @cyclopts.Parameter(name="*") on Settings — inserting EarlyStoppingConfig had stolen it, renaming every dotted CLI flag to --settings.* and double-binding --settings.enabled. New CLI-surface tests pin the flag namespace (tests/unit/commands/test_cli_flag_surface.py). - validate 0<p<1 and 0<c<1 in the ES math (p>=1 hung the doubling search; c>=1 returned garbage) + the post-hoc script rejects bad --percentiles/ --confidence up front. - block empirical now uses the report grid convention (floor(p*(n-1)), np.percentile method=lower) so it can never disagree with the grid. - report.txt/console render the ES blocks per metric (doc claimed it). - empty target series with ES on emit n=0/sufficient:false blocks instead of silently omitting; INTERRUPTED snapshots pinned ES-free by test. - ES estimate/empirical pass through _scrub_nonfinite like every other field. - es_from_events.py: count+warn malformed lines, tolerate non-dict/missing-key events, aggregator-parity duplicate-ISSUED handling, top-level imports, context-managed --compare read, single sort, dead dict-shape branch removed. - regenerate_templates.py: comment keys are dotted paths — warmup/early_stopping enabled comments coexist; previously collided comments (name, samples, eval_method, drain) reappear in templates. - docs: removed non-public companion-repo reference, corrected the offline no-op claim, documented the empirical convention and empty-series behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review Council — fixes applied (
|
| # | Finding | Resolution |
|---|---|---|
| 1 | 🔴 Settings lost name="*" decorator |
Fixed — decorator restored on Settings, EarlyStoppingConfig un-flattened. New tests/unit/commands/test_cli_flag_surface.py pins the flag namespace (--client.num-workers top-level, no --settings.* leak, warmup keeps bare --enabled, ES stays namespaced). tests/integration/commands/test_cli.py passes again (5/5). |
| 2 | find_min_passing hangs for p≥1 |
Fixed — domain validation (0<p<1, 0<c<1, 0≤d<p) raises ValueError; the script also rejects bad --percentiles/--confidence up front. The hang was reproduced live by the new test before the fix. |
| 3 | empirical convention diverges from grid |
Fixed — now floor(p·(n−1)) (matches np.percentile(..., method="lower")); test pins block-empirical == grid value. |
| 4 | ES not rendered in text report | Fixed — _display_metric renders an "Early-stopping estimates" section (insufficient percentiles say so explicitly). |
| 5 | script silent/crashy on corrupt JSONL | Fixed — malformed lines counted + WARN'd; non-dict JSON and missing sample_uuid/timestamp_ns skip-and-count instead of crashing. One review sub-claim did not survive verification: msgspec.DecodeError is not a ValueError subclass — the original two-exception tuple was correct and is kept (with a comment); the new junk-line test caught this. |
| 6 | aggregator test gaps | Fixed — INTERRUPTED pinned ES-free; empty-series blocks; all three target series (incl. float tpot_ns) asserted; empirical-vs-grid pin. |
| 7 | doc: offline "no-op" claim | Fixed — reworded to match reality (computed, not gating). |
| 8 | doc: companion-repo dangling ref | Fixed — removed; conclusion inlined. |
| 9 | duplicate-ISSUED divergence | Fixed — aggregator parity (row kept, issue ts refreshed); test mirrors the retry sequence. |
| 10 | lazy import / unclosed handle / double sort | Fixed — top-level imports (token_metrics is stdlib-only at module level), context-managed --compare read, single sort shared by es_blocks + _cross_check. |
| 11 | dead dict-shape branch | Fixed — removed. |
| 12 | empty series omits ES block | Fixed — emits n=0, sufficient:false blocks on COMPLETE; documented. |
| 13 | ES block bypasses _scrub_nonfinite |
Fixed — estimate/empirical scrubbed like every other numeric field. |
| 14 | templates lost warmup comment | Fixed at the root — regenerate_templates.py comment keys are now dotted paths (indentation-aware injector); warmup + ES comments coexist, and previously-collided comments (name, samples, eval_method, drain) reappear. --check mode passes. |
| 15 | script test gaps | Fixed — malformed-input, tool-call-warn, _cross_check match/mismatch, and main() (json output + arg validation) covered. |
Gate: 1348 unit tests pass (+15 new), integration CLI tests pass, templates --check clean, ruff + ruff-format clean on all touched files.
🤖 Generated with Claude Code
Design follow-up (
|
|
Pre-commit gate is now fully green on the branch ( 🤖 Generated with Claude Code |
|
Report/UX follow-up ( 🤖 Generated with Claude Code |
|
Default flipped to ON ( 🤖 Generated with Claude Code |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Review Council — round 2 (Codex + Claude, thorough)11 findings (2 duplicates across reviewers), all verified against HEAD and fixed in
Gate: 1330 unit tests (lean consolidated suite), e2e + binding integration tests, 13/13 pre-commit hooks. 🤖 Generated with Claude Code |
…re + tests) Port loadgen early_stopping.cc/results.cc SingleStream estimate: es_percentile_estimate(sorted, p, c=0.99, d=0) returns a conservative confidence-backed percentile (sorted[n-t]) plus empirical and the min-queries floor. Fast Numerical-Recipes betai (no scipy). Validated vs loadgen values (h_min(t=0,p99)=459). Integration into build_stat/report/schema follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…report/config
Default-off YAML flag settings.early_stopping.{enabled,percentile,confidence,tolerance} ->
aggregator subprocess CLI args -> MetricsRegistry -> SeriesSampler.build_stat (COMPLETE path)
computes the estimate for TTFT/TPOT/latency only -> SeriesStat.early_stopping (optional trailing
field) -> result_summary.json + report. No hot-path impact; LIVE snapshots unaffected. Adds the
integration test, design doc (docs/early_stopping.md), and regenerated templates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…; tolerance is now an algorithm constant - settings.early_stopping.percentile (float) -> percentiles (list, default [0.5, 0.9, 0.95, 0.99]) so enabling ES is just enabled:true — the scenario gate percentile is always covered without per-yaml tuning. result_summary.json early_stopping becomes a list of self-describing per-percentile blocks. - tolerance removed from config/CLI/report: loadgen hardcodes d=0.0 (results.cc:158) and d>0 weakens the guarantee to percentile p-d, so it is TOLERANCE=0.0 in metrics/early_stopping.py, kept only as a defaulted arg for parity tests. - aggregator flag --es-percentile -> --es-percentiles (comma-separated); templates regenerated; docs/early_stopping.md updated (incl. marginal-confidence caveat). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… event log Rebuilds ttft/tpot/latency from events.jsonl with the aggregator rules (tracking-window gating; TPOT = (complete-recv_first)/tokens(text_after_first_chunk) via TextModelOutput + token_metrics.encode_lengths) and reuses metrics/early_stopping.py for the math — ES results for historical runs that never enabled the feature. --compare cross-checks against a run with in-band blocks (validated: exact match on estimate/empirical/n/discarded for all three metrics, including the TPOT tokenization). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es + confidence become constants Review feedback: the unrolled percentiles list in the YAML and the confidence field added config surface for values users should not tune. LoadGen hardcodes confidence=0.99 and tolerance=0.0 (results.cc:157-158); the standard report set PERCENTILES=(0.5,0.9,0.95,0.99) always covers the scenario gate percentile. All three are now constants in metrics/early_stopping.py; the schema, launcher args, and aggregator CLI shrink to --early-stopping. Templates regenerated; docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- critical: restore @cyclopts.Parameter(name="*") on Settings — inserting EarlyStoppingConfig had stolen it, renaming every dotted CLI flag to --settings.* and double-binding --settings.enabled. New CLI-surface tests pin the flag namespace (tests/unit/commands/test_cli_flag_surface.py). - validate 0<p<1 and 0<c<1 in the ES math (p>=1 hung the doubling search; c>=1 returned garbage) + the post-hoc script rejects bad --percentiles/ --confidence up front. - block empirical now uses the report grid convention (floor(p*(n-1)), np.percentile method=lower) so it can never disagree with the grid. - report.txt/console render the ES blocks per metric (doc claimed it). - empty target series with ES on emit n=0/sufficient:false blocks instead of silently omitting; INTERRUPTED snapshots pinned ES-free by test. - ES estimate/empirical pass through _scrub_nonfinite like every other field. - es_from_events.py: count+warn malformed lines, tolerate non-dict/missing-key events, aggregator-parity duplicate-ISSUED handling, top-level imports, context-managed --compare read, single sort, dead dict-shape branch removed. - regenerate_templates.py: comment keys are dotted paths — warmup/early_stopping enabled comments coexist; previously collided comments (name, samples, eval_method, drain) reappear in templates. - docs: removed non-public companion-repo reference, corrected the offline no-op claim, documented the empirical convention and empty-series behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion flag, documented math - ES percentile targets are no longer a separate hardcoded set: they derive from each series own report percentile grid, filtered to p >= ES_MIN_PERCENTILE (0.5) — an ES estimate is a tail certification, so below-median grid entries (p25/p10/p5/p1) are skipped. One source of truth: whatever percentiles a series reports, ES covers (default grid -> p50/p75/p80/p90/p95/p97/p99/p99.9). EarlyStoppingSpec.percentiles=None means derive; explicit tuples remain for tests/offline analysis. DEFAULT_PERCENTILES promoted to public for the post-hoc script default. - ES_TARGET_METRICS frozenset removed: series opt in with register_series(..., tail_latency=True) at the metric definition site, so a new latency metric declares its nature where it is registered instead of updating a distant name set. - metrics/early_stopping.py helpers documented with citations (Numerical Recipes 3rd ed. section 6.4 betacf/betai via modified Lentz; loadgen early_stopping.cc odds/MinPassingQueriesFinder, results.cc SingleStream estimate) and every magic number explained (eps=3e-16, fpmin=1e-300, the 400-iteration CF cap, the symmetry-split threshold). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rence/AGENTS.md pre-commit run --all-files now passes all 13 hooks (incl. mypy on src+tests, prettier, license headers, regenerate-templates --check, uv lock check). Includes the pinned ruff-format hook normalization on touched files and prettier normalization of the markdown docs. Docs: --early-stopping added to docs/CLI_QUICK_REFERENCE.md common flags; README metrics feature row mentions the optional ES estimates; AGENTS.md gains metrics/early_stopping.py in the code tree and an aggregator bullet describing the tail_latency registration flag, grid-derived percentiles, COMPLETE-only computation, and the post-hoc script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ld mapping SERIES_TO_SUMMARY_FIELD promoted to a public constant in metrics/report.py (from_snapshot builds the summary fields from it) and imported by the script — the mapping can no longer drift. Script constants audited: _UNITS rekeyed by summary field and documented as script-local display formatting (latency in seconds for long-CoT runs), _TPOT_BATCH documented (bounds text buffering to ~64 MB while amortizing batch-encode overhead). _cross_check skips non-list early_stopping payloads (pre-release artifacts) instead of crashing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dead negative flag; script renamed + typed decode Review feedback: - result_summary.json now carries a compact early_stopping_percentile map per metric — keys mirror the percentiles grid (>= p50), values are the estimate or null when insufficient. The rich detail (empirical/n/min_queries/ discarded/confidence) is INFO-logged by the aggregator and reproducible via the post-hoc script; the text report renders the map (N/A = insufficient). - --no-early-stopping removed (cyclopts Parameter negative=()): the feature is default-off, the negative was dead surface. CLI-surface test pins absence. - scripts/es_from_events.py -> scripts/early_stopping_estimate_from_events.py. - The script decodes lines with the product typed EventRecord (msgspec Decoder + EventType.decode_hook — the JSONL writer mirror), so event names, payload shapes, and TPOT chunk semantics all come from core/record.py and core/types.py instead of hand-parsed wire format; extract_tpot_text is now a two-line isinstance + text_after_first_chunk call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…place The feature is non-invasive — computed once at run COMPLETE from data the aggregator already keeps, hot path untouched, output field additive — so it now defaults ON for every run; enabled: false / --no-early-stopping is the single opt-out (strict summary-schema consumers). The exact path sorts the raw array ONCE, in place (terminal path; arrival order is meaningless after COMPLETE), and shares it between the percentile grid, the histogram, and the ES estimates, so default-on adds no transient copy; a repeated-COMPLETE-snapshot test guards the in-place mutation. CLI-surface test flipped to pin the opt-out flag. Note: tests/integration/commands/test_benchmark_command.py template runs fail in this dev environment identically on the parent commit (toy char tokenizer vs chat-template warmup probe in the unpinned venv) — pre-existing, unrelated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e the ES test suite - result_summary.json key early_stopping_percentile -> early_stopping_percentiles (plural, consistent with the sibling percentiles grid it overlays); renamed across snapshot/registry/report/script/docs. - Test consolidation: the ES tests had accreted one-per-review-round. Rewritten as a lean set where every test pins one named failure mode (loadgen reference anchors, grid/key overlay, sufficiency floor, empirical/grid convention, domain validation hang, wiring lifecycle, tail_latency gating incl. float dtype, in-place-sort identity, script gating/robustness/CLI contract, product-mapping drift, generated CLI surface). Net: ~25 redundant tests removed, chunk-semantics coverage MOVED to tests/unit/test_core_types.py where TextModelOutput.text_after_first_chunk lives (it had no direct product coverage), CLI-surface tests merged into one subprocess invocation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…true above-estimate count
Review round 2 (Codex):
- _series_to_metric_dict returned {} for count==0, silently dropping the
all-null early_stopping_percentiles map the registry emits for enabled empty
series — result_summary.json made enabled-but-insufficient indistinguishable
from disabled. The empty-rollup dict now keeps the map, and _display_metric
tolerates missing rollups (.get -> N/A) per its own partial-dict philosophy.
- discarded reported the LoadGen order-statistic offset t, but the estimate IS
the t-th highest sample, so only t-1 samples are discarded above it (at the
floor, t==1, nothing is discarded). Now reports t-1; docstrings + reference
anchors updated. Estimates themselves are unchanged.
- test_cli_flag_surface.py removed as redundant with the integration CLI tests;
its one non-redundant pin (silent --enabled double-bind between warmup and
early_stopping) moved into tests/integration/commands/test_cli.py as an
in-process parse assertion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…escape, recv_first parity - es_targets_from_grid replaces es_percentiles_from_grid: excludes grid entries >= 100 (p1.0 is outside the binomial domain and crashed build_snapshot INSIDE publish_final before its try/except — losing the whole final snapshot, with ES default-on) and keys the map by str() of the ORIGINAL grid values so int grids ((99, 90, 50)) key as they appear in percentiles — the 1:1 overlay contract holds for custom grids. Crash-repro integration test added. - script: EventType.decode_hook raises NotImplementedError for a non-string event_type and msgspec does not wrap it — one such line crashed the tool; now counted as malformed (fixture line added). - script: duplicate RECV_FIRST now mirrors the aggregator (re-fires ttft and refreshes the TPOT window start) instead of first-only; doubled flush() removed. - _betacf 400-cap comment corrected: the cap IS reachable at n~1e7 boundary searches (a~b~n/2); measured truncation error <= 3e-8 vs scipy — returning the plateaued value is deliberate on the terminal-snapshot path. - e2e guard: test_cli.py offline run asserts early_stopping_percentiles lands in result_summary.json (the execute.py arg plumbing had no end-to-end test). - docs: layering diagram said enabled=False; output wording scoped to COMPLETE snapshots and recorded series. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e5f6d48 to
021c223
Compare
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
…script emits run-shaped summaries - es_targets_from_grid preserves the grid own (descending) order and accepts summary-JSON key strings, so the map overlays percentiles in naming AND ordering; explicit overrides sort descending via grid_percentile_key. - early_stopping_percentiles is placed directly after percentiles (before the histogram) in both snapshot_to_dict and the report dict — single source of position: report.place_early_stopping_percentiles, shared with the script. - early_stopping_estimate_from_events.py: --compare replaced by --summary; the targets derive from the run own percentile grid (exact keys, exact order) and --json now writes the SUMMARY AUGMENTED with the maps — byte-identical shape to what an ES-enabled run would produce — instead of a bare block dump. es_blocks wrapper removed; ordering/augmentation reuse product helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arekay-nv
left a comment
There was a problem hiding this comment.
Looks good - thanks for the quick turnaround!
…he series mapping lookup Plus clarifying comments where review questions showed the code did not explain itself: RECV_FIRST without a row is the expected untracked-sample path (not malformed input), and es_targets_from_grid deliberately keys by str() of the original grid entry rather than grid_percentile_key (float-style reconstruction would break int-grid key fidelity; the 6-fraction-decimal rounding equals the key helper 4 percent decimals). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arekay-nv
left a comment
There was a problem hiding this comment.
Review Council — Multi-AI Code Review (round 3)
Reviewed by Codex (gpt-5.5) + Grok (cursor-grok-4.5-high) + Claude, depth thorough. All line numbers and claims verified against HEAD; findings already raised/fixed in prior rounds were dropped. Full tiered summary in the follow-up comment.
Review Council — Multi-AI Code Review (round 3)Reviewed by Codex (gpt-5.5, xhigh) + Grok (cursor-grok-4.5-high) + Claude · depth thorough Raw: Grok 5, Codex 2, Claude 5. After verifying every line/claim against HEAD and dedup'ing against the 45 existing comments from prior rounds, 8 non-duplicative findings survived. The two most actionable are new and were not caught in earlier rounds. 🔴 Must Fix
🟡 Should Fix
🔵 Consider
Dropped after verification
All findings posted as inline comments on their exact lines. Event: COMMENT (no approve/request-changes). |
What
Adds an on-by-default MLPerf-LoadGen-style early-stopping percentile estimate to the tail-latency metrics (
ttft/tpot/latency) for scenarios that measure percentile latencies (Server/Poisson, fixed concurrency) — closing a parity gap with LoadGen (loadgen/early_stopping.cc; mlcommons/inference#2188, mlcommons/inference#1095; MLPerf Inference paper arXiv:1911.02549). LoadGen's fixed-sample requirement (~270k queries for Server p99) is impractical for slow modern workloads (LLM, and especially T2V at minutes per query); early stopping replaces it with a data-dependent binomial-confidence test so short runs surface an honest, conservative tail estimate instead of a silently under-reported empirical percentile.metrics/early_stopping.py— pure port of the LoadGen binomial-confidence math (continued-fraction regularized incomplete beta, no scipy), validated against LoadGen reference values (e.g.h_min(t=0, p99) = 459; SingleStream-style floors: p90 = 64, p99 = 662).settings.early_stopping.enabled: false/--no-early-stoppingis the single opt-out (for consumers that strictly validate the summary schema).CONFIDENCE = 0.99andTOLERANCE = 0.0are LoadGen-parity algorithm constants (results.cc:157-158), and the percentile targets derive from each series' own report percentile grid (filtered to ≥ p50 — an ES estimate is a tail certification), so the scenario gate percentile is always covered — nothing to tune per config, no way to accidentally weaken the statistical claim.result_summary.jsongains a compactearly_stopping_percentilemap per metric — keys mirror thepercentilesgrid (≥ p50), values are the conservative estimate ornullwhen the run is below that percentile's floor. The rich detail (empirical/n/min_queries/discarded) is INFO-logged by the aggregator and reproducible offline.docs/early_stopping.md— design, layering, and the multi-percentile marginal-confidence caveat.scripts/early_stopping_estimate_from_events.py— post-hoc tool that recomputes ES for any historical run from itsevents.jsonl. It decodes lines with the product's typedEventRecord(the JSONL writer's mirror decoder), so event names, payload shapes, TPOT chunk semantics, tokenizer counting, and the ES math all come from product modules.Estimate-only by design: no target-latency pass/fail gate and no dynamic mid-run halt (scope in the doc).
Validation
early_stopping_percentilemap reports estimates for p50–p97 (floors 11–130) andnullfor p99/p99.9 (floors 662/6636) — the short-run behavior the feature exists for.Notes for review
settings.early_stoppingvs nesting under a metrics section), the report block shape, and whetherscripts/es_from_events.pyshould graduate to a CLI subcommand.🤖 Generated with Claude Code