Skip to content

Make benchmark history points comparable with each other - #5404

Draft
Timothee Guerin (timotheeguerin) wants to merge 10 commits into
mainfrom
benchmark-data-quality
Draft

Make benchmark history points comparable with each other#5404
Timothee Guerin (timotheeguerin) wants to merge 10 commits into
mainfrom
benchmark-data-quality

Conversation

@timotheeguerin

@timotheeguerin Timothee Guerin (timotheeguerin) commented Sep 4, 2026

Copy link
Copy Markdown
Member

The benchmark chart draws one line through every run, which says those numbers
can be compared. They often can't, and nothing in history.json said so.

Checking the current 453 entries:

  • 37 points were measured on a laptop, single-iteration, darwin-25.4.0.
  • Iterations went 1 → 5 → 15 → 25 over the life of the series.
  • Node moved v24.11.1 → v24.17.0 and back, with mean total of 412 / 814 / 920 / 920 / 666 ms across those groups — bigger than any regression the chart is meant to catch.
  • Five points sit ~9x above their neighbors (~6500 ms against a local median of ~730 ms). The existing noise gate only reacts to spread within a run, so a contended runner sails straight through.

The result files knew all of this — runner, iterations, variability.total.cv — and flattenRuntime() discarded it.

What this changes

history.json gains a version, and each entry gains the runner it was measured on plus a quality summary:

{
  "commit": "",
  "metrics": { "total": 6595,  },
  "runner": { "os": "linux-6.11.0", "nodeVersion": "v24.15.0", "arch": "x64" },
  "quality": {
    "iterations": 25,
    "cv": 0.014,            // null for runs that predate the field
    "flags": ["outlier"]    // also: "low-iterations", "foreign-runner"
  }
}

On the live data that flags 37 low-iterations, 37 foreign-runner and 5 outlier — exactly the anomalies above. The outlier test compares a point against the median of its 11 neighbors excluding itself, so a change that persists moves the window with it and is never flagged; only isolated spikes and dips are.

foreign-runner compares platform and arch only, not the kernel string, so routine image bumps don't light up the whole series.

Re-measuring the series

Flagging bad points is a stopgap; the fix is to measure them all the same way. That wasn't possible — backfill skipped commits that already had results, wrote to a hardcoded results/ regardless of --results-dir, and never forwarded the noise-gate flags, so a backfilled point was produced differently from a live one.

gh workflow run benchmark.yml \
  -f backfill_from=<oldest-sha> -f backfill_to=<sha> -f backfill_reset=true

backfill_reset re-measures everything in range and drops the stored results; backfill_to splits a long range into chunks that each fit inside a job, so the whole series can be rebuilt from Actions rather than someone's laptop. Subsequent chunks run without reset and append.

Rebuild cost

Regenerating the history spawned one git show per result file, which is now most of the work at 453 files. Batched git cat-file --batch reads plus streaming the files instead of holding them all:

before after
rebuild 66.7 s 2.8 s
heap 198 MB 31 MB

Byte-for-byte identical output, checked against git show across 120 real result files.

Backfill was broken

Testing this on Actions found the backfill path had not worked in some time. Four separate faults, each of which stopped a run dead:

  • It only ran from main. A CI checkout creates just the branch it was asked for, so git log main failed on every other branch — including any branch testing a change to the backfill itself.
  • It never built the emitters the specs use. The specs emit: with the Python, JS, TS and Java client emitters; the build filter still listed only the core and Azure libraries, so every compile died with Emitter "@azure-tools/typespec-python" not found.
  • It had no git identity, so its commit failed. That commit was silent, leaving the branch unborn and the push failing much later with a baffling src refspec ... does not match any.
  • It reported success having measured nothing. Every commit could fail and the job still went green — and the log file it told you to consult was never written, because the benchmark's output went to /dev/null.

Verified green on Actions against a throwaway benchmark-data-test branch (run), which produced:

"runner":  { "os": "linux-6.17.0-1022-azure", "nodeVersion": "v24.15.0", "arch": "x64" },
"quality": { "iterations": 25, "cv": 0.048, "flags": [] }

One caveat for planning the real re-measure: a commit costs ~30 min, since each one rebuilds the compiler and five emitters before measuring. That is roughly a dozen commits per 6-hour job, so re-measuring the full 453 means a good number of chunked backfill_to runs rather than one sitting.

Follow-ups

The dashboard doesn't read the new flags yet — that belongs on top of #5402. Also still open: metrics averages dissimilar specs, stats.complexity never reaches the history, and the 11 MB payload is ~76% repeated metric names.

A chart line implies every point was measured the same way, but this series
spans single-iteration laptop runs, several Node versions and two runner
images -- shifts that move the numbers further than most real regressions do.
The result files knew all of this; the history step threw it away.

history.json now carries a schema version, the runner behind each point, and a
quality summary flagging the points that are not comparable with their
neighbors: too few iterations, a foreign platform, or an isolated spike. On
the current data that marks 37 laptop runs and the five ~9x spikes, and leaves
genuine step changes alone.

Rebuilding the history also stopped spawning a git process per result file,
which was most of its cost at 453 files: 67s and 198MB, now 3s and 31MB.
Points measured with different iteration counts and noise gates cannot be
compared, so making the series uniform means measuring all of it again. That
was impossible: backfill skipped commits that already had results, wrote to a
hardcoded results/ directory regardless of --results-dir, and did not forward
the noise-gate flags, so a backfilled point was produced differently from a
live one.

    gh workflow run benchmark.yml \
      -f backfill_from=<oldest-sha> -f backfill_to=<sha> -f backfill_reset=true

--reset re-measures every commit in range and drops the stored results, and
backfill_to splits a long range into chunks that each fit in a job, so the
whole series can be rebuilt from Actions without a laptop in the loop.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

No changes needing a change description found.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📦 Package size report

1 package changed size, +9.95 KB (+0.1%) packed overall.

Package Packed (base → head) Δ Packed Unpacked (base → head) Δ Unpacked
@azure-tools/typespec-client-generator-core 229.57 KB → 237.05 KB +7.47 KB (+3.3%) 🔴 1.23 MB → 1.27 MB +41.49 KB (+3.3%) 🔴
12 package(s) with no notable change
Package Packed (base → head) Δ Packed Unpacked (base → head) Δ Unpacked
@azure-tools/typespec-java 13.51 MB → 13.51 MB +1.88 KB (+0.0%) 15.03 MB → 15.04 MB +5.42 KB (+0.0%)
@azure-tools/typespec-azure-resource-manager 182.42 KB → 182.94 KB +538 B (+0.3%) 1.10 MB → 1.10 MB +3.51 KB (+0.3%)
@azure-tools/typespec-azure-rulesets 5.28 KB → 5.35 KB +68 B (+1.3%) 33.16 KB → 33.04 KB -118 B (-0.3%)
@azure-tools/azure-http-specs 146.76 KB → 146.77 KB +2 B (+0.0%) 1.16 MB → 1.16 MB
@azure-tools/typespec-azure-core 130.84 KB → 130.84 KB +1 B (+0.0%) 709.84 KB → 709.83 KB -1 B (-0.0%)
@azure-tools/typespec-azure-portal-core 42.47 KB → 42.47 KB +1 B (+0.0%) 193.15 KB → 193.15 KB +4 B (+0.0%)
@azure-tools/typespec-ts 540.59 KB → 540.59 KB -1 B (-0.0%) 2.60 MB → 2.60 MB +4 B (+0.0%)
@azure-tools/typespec-autorest 81.23 KB → 81.23 KB 396.61 KB → 396.61 KB
@azure-tools/typespec-autorest-canonical 7.42 KB → 7.42 KB 26.00 KB → 26.00 KB
@azure-tools/typespec-go 261.39 KB → 261.39 KB 1.34 MB → 1.34 MB
@azure-tools/typespec-metadata 15.91 KB → 15.91 KB 62.26 KB → 62.26 KB
@azure-tools/typespec-python 42.22 KB → 42.22 KB 164.91 KB → 164.91 KB

Packed = gzipped .tgz published to npm. Unpacked = total extracted size. 🆕 added, 🗑️ removed. Packages from the core/ submodule are not included.
🔴 grew · 🟢 shrank — only changes of at least 512 B and 0.5% are marked.

@azure-sdk-automation

Copy link
Copy Markdown
Contributor

You can try these changes here

🛝 Playground 🌐 Website

A CI checkout only creates the branch it was asked for, so `git log main`
fails on every branch except main -- including whichever branch a change to
the backfill is being tested on. Fall back to origin's copy, fetching it when
the checkout left it out.
Backfill pointed at a log file it never wrote -- the benchmark's output went
to /dev/null -- and a run where every commit failed still exited 0, so CI
reported green having measured nothing. Keep the output, print the tail of it
next to the failure, and fail the run when nothing was measured.
The specs emit with the Python, JS, TS and Java client emitters, but backfill
only ever built the core and Azure libraries, so every compile failed with
"Emitter not found" and no commit could be measured. Build those emitters and
link them where the specs resolve from.
A CI checkout has no git identity, so backfill's commit failed, and because
that commit was silent the branch was simply never created -- surfacing much
later as "src refspec does not match any" from the push. Configure the same
identity store-results uses, and let a failed commit say so.
Filtering the build to the packages the specs use left their own workspace
dependencies unbuilt, so the compiler failed to resolve tmlanguage-generator
and every commit in the range died before it could be measured.

Install and build output was also discarded, which reported this as a bare
"build failed" that could not be diagnosed after the runner was gone.
Every commit gets its own CI job, and CI hands out whichever runner is free.
Across 100 commits of main the same work varied by 63% depending on the machine:
spread between machines was 13.7% against 0.9% within one, so hardware outweighed
code changes 16 to 1. That noise lands directly between neighboring points, which
is why the chart jumps at commits that changed nothing relevant.

Machine speed scales TypeSpec workloads more or less uniformly, so it can be
divided out. Each run now also compiles a frozen reference workload on the same
machine, in the same job, and records how long it took. Dividing by it drops
between-machine spread below 1%, taking the smallest detectable regression from
roughly 41% to 3.5%.

The reference deliberately does not use the compiler being benchmarked. If it
moved with the repo, a real compiler regression would slow the reference by the
same amount and cancel itself out. It is a fixed spec built with a pinned
release from npm, materialized from constants, so it is identical for every
commit ever measured, including commits that predate it.

Calibration costs ~15s and never fails a run: if the pinned compiler cannot be
installed the run continues and the point is flagged uncalibrated. Raw numbers
are never rewritten; history stores the calibration next to them and exposes a
factor to multiply by, so the correction stays visible and reversible.

Also records the CPU model, which is what actually differs between runners and
which nothing captured before.
Points carried only the time they were measured, which is fine while one commit
is measured per push but wrong the moment history is rebuilt: a backfill covers
months of commits in an afternoon, so all 100 points collapsed onto the two days
the backfill ran and the chart's x-axis lost any meaning.

Worse, entries were also ordered by measurement time. Re-measuring a single old
commit would have moved it to the end of the series, silently reordering history
around it.

Record the commit's committer date and use it for both placement and ordering,
falling back to measurement time for points recorded before it was captured.
The first reference imported only @typespec/compiler and compiled synthetic
models. Measured across two CI machines it slowed 16% while the real specs
slowed 34%, so dividing by it removed only half the machine effect and left a
15% residual at the boundary -- still far more than the regressions this is
meant to expose.

Hardware sensitivity depends on the kind of work. loader is a third of the real
measurement and turned out to be the most sensitive phase of all (+41% between
those two machines), and a spec with no libraries to load barely exercises it.
A reference is only useful if it does the same mix of work.

Use a frozen copy of the azure-full spec against a pinned library stack and the
same linter ruleset. Measured cold, the way both are actually measured, its
phase mix now tracks the real one closely: loader 34.9% against 33.6%, where
before there was essentially no library loading at all.

The reference stays frozen and independent of the repo, so it still cannot
cancel out a real regression. Backfill carries the calibration directory
alongside dist and specs, so every commit is measured against the same
yardstick.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant