Repository navigation
ci(webui): the Activity performance gate fails on runner noise #1925
Description
Problem / Background
The WebUI installed artifact job (.github/workflows/ci.yml:503) fails at random in the step "Run Activity performance gate with the hidden acceptance deferred" on changes that cannot affect decode or the Activity polling path. The job runs on the single self-hosted GB10 runner lablup-dgxspark21, one job at a time, so every rerun costs 10 to 40 minutes. Context: measured on 2026-09-19 while landing the WebUI PRs of epic #1910 (this issue is not a child of it), which still has several WebUI PRs queued.
Every failure below was the per-mode verdict investigate. Per-sample logs are in each run's webui-activity-performance-diagnostics-Linux artifact (activity-performance.log, one JSON line per request).
| Change under test | Run (attempt) | Mode | Median degradation % | Baseline CV % | Paired range % |
|---|---|---|---|---|---|
| PR #1920, catalog display-name strings only | 35423171315 (1) | two-visible | -0.35 | 5.63 | -4.99 to 1.25 |
| PR #1920 | 35425297407 (1) | two-visible | +0.20 | 5.45 | -6.13 to 2.45 |
| PR #1920 | 35425297407 (2) | two-visible | +2.74 | 3.38 | -2.14 to 4.90 |
| PR #1920 | 35425297407 (3) | one-visible | +3.38 | 4.22 | -0.60 to 6.26 |
main after #1922 merged |
35425050365 (1), attempt 2 passed | two-visible | -6.76 | 7.45 | -13.64 to 7.52 |
| PR #1919 | 35421166203 (1), next run 35422803924 passed | two-visible | +3.19 | 4.03 | -3.30 to 10.58 |
A faster result (-6.76) and a flat one (+0.20) both fail on baseline CV alone, and flat changes cross the 2 percent median threshold. Five pairs cannot resolve a 2 percent threshold against a per-pair spread of 6 to 21 points.
Current Behavior
webui/scripts/activity-performance.mjs:126sends one warmup request;:128-139runs, per mode, 5 alternating off/on pairs (:130, order flipped each pair at:132), computes the paired degradation(off - on) / off * 100(:133), the population CV of the 5offrates (:135-136) and the median of 5 (:137, helper at:121), and setsstatus: degradation > 2 || cv > 5 ? 'investigate' : 'within-target'(:138). Each request (:111) decodes 256 tokens at about 140 to 172 tok/s, roughly 1.6 s.- The baseline CV mostly measures slow drift across the whole block, which the alternating pairs are designed to cancel: in run 35425050365 attempt 1 the
offrate fell from 171.4 to 141.7 tok/s over the two-visible block, and the mode failed on CV 7.45 although the Activity arm measured 6.76 percent faster. - The same rule is enforced two more times:
scripts/webui/verify_activity_performance.py:258-260fails on any non-within-targetsummary,:269-270requirespaired_runs == 5,:275-276re-appliesdegradation > 2 or cv > 5, and:39-44hard-codes the sample counts (5 per mode).scripts/webui/summarize_activity_evidence.py:64-68repeats the status,paired_runs == 5,<= 2and<= 5checks, and:77-82emits that schema. webui/scripts/activity-performance-config.mjs:16-27(performanceCompletion) aggregates onlyinvestigate; under--perf-mode visible-only-headedthe top-level status isincompleteregardless, so the per-mode status is what fails the job.- The CI step (
.github/workflows/ci.yml:683-731) has a 45 minute timeout and--activity-timeout 1800; it currently finishes in about 2 minutes (run 35427681676: 06:58:02 to 06:59:58).make verify-webui-activity-performance(Makefile:880-890) drives the same harness infullmode.
Proposed Solution
Keep the intent (fail when visible Activity pages slow decode by more than the 2 percent budget) and make the verdict statistically meaningful. The properties below are required; the statistic, pair counts and retry budget are the implementer's call, sized from the measured noise in the artifacts above.
- Interval, not a bare median. Judge the paired degradations with a confidence interval (bootstrap or t-interval of the per-pair differences, the implementer's choice). Decision criterion for the rule: with the per-pair noise observed on this runner, show (by resampling the artifact data, with the calculation in the PR body) a false-failure rate of at most 1 percent on a true 0 percent change and at least 95 percent detection of the injected slowdown used in the acceptance test. Recorded tradeoff so it is not re-derived: failing only when the interval lies wholly above 2 meets both targets with a few tens of pairs at this noise, while passing only when it lies wholly below 2 needs far more pairs to keep false failures down.
- Precision gate replaces the CV verdict. Baseline CV stops being a failure condition. When the interval is too wide to decide, the harness adds pairs up to a cap; only when the cap is exhausted does the mode end as a distinct status such as
inconclusive, which fails the job with a message that names measurement noise, not a slowdown. Keepbaseline_cv_percentandpaired_range_percentin the summary as diagnostics. - Warmup. Discard leading pairs only if the artifact data shows a first-pair bias; state the finding either way.
- One rule, checked consistently. The Python validators must not keep an independent copy of the thresholds. Either they re-derive the verdict from the raw
samplesarray with a port of the JS function, or they validate structure and internal consistency only (awithin-targetsummary whose interval violates the rule is rejected). Whichever is chosen, JS and Python are tested against one shared fixture file of sample vectors with expected verdicts. - Rejected: raising the 2 percent or 5 percent thresholds. That hides noise and real regressions alike.
Scope
In scope: webui/scripts/activity-performance.mjs (sampling loop and verdict), webui/scripts/activity-performance-config.mjs (pure verdict function and performanceCompletion handling of the new status), scripts/webui/verify_activity_performance.py and scripts/webui/summarize_activity_evidence.py (variable paired_runs, sample counts derived from it, new summary fields), their tests (webui/scripts/activity-stream.spec.mjs:41-72, scripts/webui/verify_activity_performance_tests.py, scripts/webui/summarize_activity_evidence_tests.py), .github/workflows/ci.yml only if the timeout needs adjusting, and the Activity row of docs/webui-integration-matrix.md:17 where it describes the budget.
Out of scope: the deferred native hidden acceptance (--perf-mode full on a browser that propagates document.hidden), runner provisioning or a second GB10 runner, making the job non-required, and any product code in webui/src or src/server.
Implementation Notes
- Reuse: keep
performanceMode,assertGeometry, theclients()visibility checks, and the fail-closed GPU-process check at.github/workflows/ci.yml:692-697. Seed the shared fixture from the per-sample logs of the six runs above, copied into the repository now, because artifacts expire. - Constraints: the step must stay well inside the 45 minute timeout; target under 15 minutes worst case including re-measurement.
fullmode (withhidden) uses the same verdict. Keepn_predict,seed,temperature: 0andcache_prompt: falseunless the PR shows why a change is needed. - Edge cases: a negative degradation (Activity arm faster) never fails by itself; a failed or non-finite request still throws as today (
:112-115); sample counts must equal2 * paired_runsper mode plus warmup, and the validators reject any mismatch. - Error handling: each failing mode reports its status, point estimate, interval, pair count and the rule that fired, so a reader can tell
investigate(slowdown) frominconclusive(noise) in the job log without downloading the artifact.
Acceptance Criteria
- The verdict is a pure function covered by unit tests, and the JS and Python sides are tested against the same fixture of recorded sample vectors, including all six runs above, none of which returns
investigate. - Baseline CV above 5 percent alone no longer fails a mode; an undecidable interval triggers re-measurement and, at the cap, fails with a status distinct from a slowdown.
- The PR body gives the resampling estimate of false-failure rate and detection power for the chosen rule and pair counts.
- With an injected slowdown whose true effect is measured beforehand at 4 to 8 percent (for example a per-poll busy loop in the Activity page; if a browser-side loop does not reach decode, inject in the
ui_runtimehandler atsrc/server/router_server.rs:708, which serves the Activity page's/ui-api/v1/runtimepoll in the router mode the harness launches), the gate fails withinvestigateonlablup-dgxspark21. The injection is reverted, never merged, and its run ID is in the PR body. - The job passes on at least five consecutive runs on
lablup-dgxspark21of a tree whose only change is the gate, with run IDs in the PR body. - The gate still runs inside the real
WebUI installed artifactjob on every WebUI or Rust change.
Verification
pnpm --dir webui run unit
make verify-webui-helper-tests
python3 scripts/webui/verify_activity_performance_tests.py
python3 scripts/webui/summarize_activity_evidence_tests.py
# On GB10: rerun the Activity job and record run IDs
gh run rerun <run-id> --job <job-id>A pass is all unit tests green, five consecutive green WebUI installed artifact runs on the unchanged tree, and one red run with investigate under the injected slowdown.
Activity
fix(webui): print the checkpoint's real name as its display name (#1920)
feat(webui): split Settings into scoped tabs with typed live controls (…
feat(webui): rebuild Chat as a conversation view (#1931)
cestercian commentedon Sep 19, 2026
Taking this — will harden the Activity performance CI gate against runner noise.
inureyes commentedon Sep 23, 2026
One correction to the Reuse note, from two failures on 2026-09-21: the fail-closed GPU-process check is necessary but not sufficient, so keeping it as is will not be enough on its own.
Both failures scored with a paired range crossing zero, PR #1944 at
[-1.147, 4.771](median 2.798) and PR #1946 at[-1.139, 7.548](median 3.871,baseline_cv_percent3.233), and neither PR touched a TypeScript, JavaScript or WebUI file. What moved the score was a concurrent compile on the same host, which holds no GPU compute process and so passes that check unchanged. The #1946 re-run passed specifically because it was held until every other check had left pending, not because the GPU was free.Worth knowing for the fix: the six GB10 jobs in
ci.ymlall land onlablup-dgxspark21and run sequentially, so this workflow's own serialization cannot prevent it. The contention arrives from another runner label on the same machine or from host-local work, which means the gate has to measure the host directly rather than infer quiet from its own workflow. A%cputhreshold will not do it either, sincepsreports a lifetime average and reads a fresh compile as idle.The CI-step half of this (cleanup of the leaked server, orphan discrimination, and a host-quiet precondition) is filed separately as #1949 so it does not collide with the verdict work here. The line reference above has also moved: that check now sits at
ci.yml:748-751.fix(ci): reap leaked Activity gate servers and wait for a quiet host (#…
inureyes commentedon Oct 12, 2026
The same failure recurred while validating the YAML-parser-only PR #2279, at unchanged head bfc38fd in run 38265665905.
A third failed-job-only attempt is running after the other jobs completed. No thresholds or product code were changed. This evidence supplements the existing runner-noise investigation; it does not establish a decode regression from the parser replacement.
inureyes commentedon Oct 12, 2026
Attempt 3 of run 38265665905 passed on the same bfc38fd commit after the other checks had completed. No code, workload, or acceptance threshold changed between the two failures and this successful attempt.