Hazel performance analysis · ^report(6) · 2026-10-12

Catching performance regressions in CI

The second question from a meeting with Cyrus: ^report(1)–^report(5) found and fixed performance problems by hand, one profile at a time. What stops them coming back? This report covers what Hazel's CI checks today and why its two benchmark workflows never worked. It explains why a time can't fail a build on GitHub's runners and a count can, and describes the first count-based check (#2678). It also weighs building on #2161, an earlier benchmarking PR, against starting fresh.

Short answers.

  1. Nothing in CI catches a performance regression today. No check is required to merge into dev at all; the tests run on every push, and a red run is a signal, not a gate.
  2. The two benchmark workflows on dev, /perf and /bench-compare, never worked there. They came from #2161 without the scripts they call. /perf also ran a pull request's own code for anyone who commented. #2678 turns them off.
  3. A time can't fail a build on GitHub's shared runners; a count can. The full test job's time varied by about 10% across the last five dev runs, and the quick job once ran 40% faster than usual. The same program does the same amount of work on every machine.
  4. The first count-based check is in #2678: the evaluator's steps, held to budgets. A cold run takes 206,834 steps. A re-run after an edit takes 378, and must stay under 5% of a run. With reuse switched off, the re-run takes 206,834 steps and the test fails.
  5. #2161 or fresh? We started fresh for the check, because #2161 measures time. Its phase-by-phase timings would suit a later report that informs but does not fail the build (below).

Update, later on 2026-10-12: a count is not a time. A count of evaluation steps has no noise, but it can disagree with the time, in either direction. In the woods, 26% fewer steps made the view only 6% faster. Turning the view's call memo off made a woods change about 25% faster, while a step count would have called it a regression. So a budget that fails is a reason to time the change, not a verdict on it. The numbers.

What CI runs today

WorkflowWhenWhat it doesTime
ci.yml → checks.ymlEvery push, to any branchFast checks: formatting, a release build, the quick tests. Full tests: the whole suite with coverage.19–28 min
fork-ci.ymlPull requests from forksThe same checks, without coverage
deploy.ymlEvery pushA release build, published to hazel.org/build4 min
extended-tests.ymlSundays, or by handThe property tests at 100 times their usual size2–3 h
perf.ymlA /perf commentMeant to benchmark a PR against its basenever worked on dev
bench-compare.ymlA /bench-compare commentMeant to compare two commits' stored benchmarksnever succeeded
update_deps.ymlDailyUpdates dependencies in a PR10–14 min

dev's branch protection lists no required checks. So even a failing test is information, not a block.

The two benchmark workflows

A CI audit noted the missing scripts on #2161 in August. #2678 turns both workflows off: they now start only by hand, and then their job is skipped. A comment in each file says why, and what reviving it would need.

Why not times

A regression worth catching is often 10–20%. That is the size of the noise on GitHub's shared runners. For the last five dev runs, with nearly the same tests (4,699–4,704):

Each run was a different commit, so some of that spread is code. But a single timing per build cannot tell a 10% regression from a slow machine. Timing the base and the PR on the same runner, interleaved and repeated, narrows it. That costs a second build and a long job, and still gives a judgment, not a pass or fail. Times belong in a report.

Counts that can fail a build

The same program, on the same code, does the same work on every machine. A count of that work can be held to a budget with no false alarms. These counts exist or are close:

Update, later on 2026-10-12: no false alarms from noise, that is. A count can still disagree with the time, in either direction (below).

CountWhat a rise meansWhere
Evaluator steps actually takenmore evaluation, or reuse that stopped workingdev, with #2678
Statics analyses per expressionan expression analyzed again, as module and livelit definitions were, twice each, until #2651#2635: Test_StaticsPasses, with per-file budgets
Work per edit: pieces reparsed, items re-analyzedan edit recomputing more than it touched#2635: Test_EditLocality, Test_BenchStatics
Bytes: an elaboration's size; the worker's messageslarger values crossing between the threads (^report(5))#2635: Test_ElabSize; messages measured by hand so far

What counts miss: a step that got slower, and the page's own costs (virtual DOM, layout), which need a browser. Those stay with timings.

Budgets as a ratchet. An exact count fails on improvements too. A count on #2635 fails now with "expected 2, received 1", although 1 is better. A budget with room above the count catches a rise. A floor a quarter below the budget catches an improvement, and the failure says to lower the budget, in the same change. So each budget stays close to the truth, and the history of the budgets is a history of the improvements.

The first check: #2678

Evaluator.evaluate_counting_work is evaluate, also returning the steps its trampoline actually took. A result taken from the cache counts as the one step that fetches it.

The evaluator already had a step count, but it is the wrong one for this. To keep probe samples' step numbers in line, it counts a reused subtree's steps again, so it is the same whether anything was reused or not.

A new test group, PerfBudgets, runs in the quick suite in under a second:

CaseSteps nowBudget
A cold run: recursion, list traversals and case matches (a smaller bench/eval-bench.hz)206,834227,000
A re-run after an edit to a literal nothing else reads; also under 5% of a cold run, whatever the budgets378420

It catches what it is for. With reuse switched off in IncrEval.reuse_check, the re-run takes 206,834 steps, and the test fails: "over its budget of 420. A performance regression". This is the failure ^report(4) and ^report(5) kept finding by hand, a change re-evaluating everything, invisible to tests that only check the answer. The counts are the same on every run.

#2161: build on it, or start fresh?

#2161, "Performance benchmarking", by David Moon, is a draft opened on 2026-03-05. Its last commit is from 2026-03-13, and it conflicts with dev. It has 48 commits: +2,376 and −323 lines in 30 files.

Its harness, bench/hazel_bench.re, times the editor's syntax and statics phases: zipping, parsing (MakeTerm), layout (Measured), type checking, elaboration. It runs them on let-chains of 100 and 500 bindings, in four scenarios: cold, warm, after a cursor move, and after an edit. Each is the median of 10 runs. It posted result tables on its own PR in March and August. The PR also replaces structural memoization with memoization by pointer, in MakeTerm, Measured, highlighting and elsewhere. The two workflows on dev came from it.

Build on #2161Start fresh: counts in the test suite (#2678)
MeasuresTime per phase of the editor's syntax and staticsWork done: evaluator steps now; statics analyses and bytes next
Can it fail a build?No. Its threshold (10% and 0.5 ms) is the size of the runners' noise, and its comparison never failsYes, with no false alarms: a count over budget fails the test
Covers where ^report(1)–^report(5) found the costsPartly: statics, yes; evaluation, reuse, the worker and the page, noPartly: evaluation and reuse now; statics once #2635's counts reach dev; the page, no
Explains a regressionYes: time by phase shows where it wentPartly: a count shows that work grew, and which test, not where the time goes
Sees a step getting slowerYes, given enough repetitionsNo
ProgramsSynthetic let-chainsSmall programs in the test; slides can be added
Cost per runA separate job building base and head, about 11 minutes, on requestUnder a second, inside the tests every push already runs
SafetyRan a PR's code for any commenterOnly the normal CI
StateA conflicting draft that mixes the harness with unrelated memoization changes; it would need splitting firstOne small commit on today's dev
OwnershipDavid's PR: reviving it is his callOurs

The decision: counts for the check that fails a build, so we started fresh. #2161's timings by phase are the right shape for the other half: a report on a PR that shows where time went, run on one runner for base and head, repeated, and read by a person. If David wants that, the harness could be split out of #2161 for it.

Update, later on 2026-10-12: a count is not a time

Later the same day, work on the woods' picture view (^report(5)) measured steps and time side by side. Times are of the view's own evaluation on the page: a timer around it, on one build, comparing variants in turn. Changes in the whole redraw were too noisy to see.

ChangeStepsThe view's time
The points strings made more directly (a function call fewer per number, no check per point)99,621 → 74,055 (−26%)780 → 735, 726 ms (−6%)
The view's call memo turned off, the woods, moving the figure to a new sizemore: every reuse lostabout 700 → 555 ms (−25%)
The memo turned off, the woods, back to a size drawn beforemoreabout 230 → 600 ms
The memo turned off, Kids' Choice's face, an eye-size changemoreabout 7–23 → 38–51 ms

Why they disagree. Steps are not equal: a variable lookup is cheap, a pattern match or a cast through the recursive Html type is not. And some work is no step at all. The call memo builds a key for each call it might reuse, putting the call's inputs in a comparable form and hashing them. In the woods those keys copied the whole picture three times on every change, about a quarter of the view's time, and none of it counted as a step. Statics, the page's drawing, garbage collection and memory are no steps either. Nor is copying. Giving a value a type rebuilt the whole value in one step: the woods' picture, 15,500 nodes, at every typed parameter it crossed. Removing that copying made the view 20–25% faster without changing its step count much (^report(4)'s update, #2679).

So a step budget can be wrong both ways.

What it means for #2678: the budgets stay, since they are deterministic and catch the failure they are for (reuse that stops working). But a budget that fails is a prompt to measure, not a verdict, which the failure message already says: find it, or raise the budget with the reason. Decisions are made on time, with a timer around the part that changed and the variants compared in turn on one build. Two counts would make the budgets harder to fool: the memo's own work (the nodes its keys put in form), and memory allocated, if js_of_ocaml can report it.

What next

How this was put together