Skip to content

perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections - #198

Merged
unadlib merged 7 commits into
mainfrom
perf/micro-benchmark-scenarios
Oct 9, 2026
Merged

unadlib merged 7 commits into
mainfrom
perf/micro-benchmark-scenarios

Conversation

@unadlib

@unadlib unadlib commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Part of #168.

#183 and #184 improved four paths that only micro-benchmarks measured, so neither the benchmark suite nor the CI budgets would notice if one of them regressed. This PR adds a scenario for each, budgets the new scenarios in CI, and adds their measurements to the published results, so that the rerun for the release covers them.

  1. set-read looks up the middle number and a missing number in a Set of --array-size numbers and reads its size, without changing the Set. It covers the lazy item map of Set drafts from perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183: Mutative 1.3.0 mapped every item as soon as the recipe read the Set, and copied the Set when a lookup missed.
  2. map-forEach sums a Map of numbers with forEach(). It covers the direct entry reads of Map drafts from fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184: before them, every entry called get() through the draft's proxy.
  3. shift-and-update removes the first row with shift() and updates the middle row, which drafts a moved row. It covers the search of the original array for the first moved elements from perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183: before it, the first lookup built a map of all original indices.
  4. map-set-beside inserts a row into a Map of rows and a number into a Set of numbers, which copies both, and then updates a value beside them in nine more producers. With auto-freeze it covers the freezing of Map and Set instances from fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184: before it, the instances were never frozen, so every later producer walked both copies again. The input is pre-frozen like any other, so the copies are the only collections that the library freezes itself.
  5. CI budgets. shift-and-update joins core. The Map and Set scenarios move from collections into maps and sets, two jobs instead of one: with the three new scenarios, the collections job would have taken an estimated 10–11 minutes instead of 8. The five jobs take 4–8 minutes each.
  6. Published results. The four scenarios ran with every library in processes of their own, three runs each, at 100, 1,000 and 10,000 rows, in both freeze and patch modes, and shift-and-update also with Immer's array-method plugin. Their cells join the published results, layered over the batch of October 6 and 7 as the Set and patch application cells were for perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195 and perf: read the type of a draft from its state when applying patches #196. The README, the website and perf-testing/reports/SUMMARY.md now cover 97 workloads, and the summary has a section for the new scenarios.

The published figures change

Comparator Before (93 workloads) After (97 workloads)
Immer 11.1.18 551 / 12 / 3 of 566, 3.66 595 / 16 / 3 of 614, 3.59
Immer with its defaults 142 / 0 / 3 of 145, 6.76 153 / 1 / 3 of 157, 6.66
Mutative 1.3.0 495 / 70 / 1 of 566, 3.88 543 / 70 / 1 of 614, 4.28
Hand-written reducer 18 / 2 / 118 of 138, 0.12 18 / 3 / 129 of 150, 0.12

Cells faster / within 5% / slower, and the geometric mean of comparator time over Mutative time. The headline figures become 3.6x (was 3.7x) and 6.7x (was 6.8x): in set-read, Mutative and Immer both read the original Set (0.6–0.7 µs at every size, 4 of 12 cells within 5%), and map-forEach is 1.1–1.3 times as fast as Immer. None of the new cells is slower than Immer.

The new scenarios

Microseconds per scenario at 1,000 rows, patches off, medians of three isolated runs on an Apple M1 Max with Node.js 24.16.0:

Scenario Freeze Mutative Mutative 1.3.0 Immer Hand-written
set-read off 0.62 39.8 0.66 0.04
map-forEach off 26.4 87.4 29.9 5.46
shift-and-update off 2.96 801 675 0.32
shift-and-update on 32.3 831 748 —
map-set-beside off 44.7 114 167 42.5
map-set-beside on 66.4 309 201 —

At 10,000 rows, set-read took 0.62 µs against 558 µs for Mutative 1.3.0, and shift-and-update 15.9 µs against 8,768 µs for Immer. With Immer's array-method plugin, Mutative was faster in every shift-and-update cell, 1.1–24 times.

Each scenario catches the regression it covers

Mutative alone, 1,000 rows, three rounds in alternating order, the commit that made each improvement against the commit before it:

Scenario Commits Before After Before/after
set-read 715a41f → 538b7ae 39.7 µs 0.62 µs 62–65 in every mode
map-forEach ce20399 → 0bf2740 91.9 µs 25.9 µs 3.5 in every mode
shift-and-update, freeze and patches off 13460fb → bad9902 32.7 µs 2.94 µs 11.1 (1.53 with freeze on)
map-set-beside, freeze on 2168780 → 00d4829 311 µs 135 µs 2.3 (2.0 with patches)

A budget fails a cell at 1.30 times.

Calibration on GitHub runners

Before budgeting the new scenarios, --self-control compared identical builds on 30 GitHub runners, 10 for each of core, maps and sets, as for #194. All 30 runs pass, and all 1,160 decisions:

  • Largest gate ratios: 1.058 for timing (mutation-density-100pct), 1.084 for allocation (array-reverse-nested with auto-freeze, 4.3 KiB, below the 8 KiB floor) and 1.003 for retained heap.
  • The 160 decisions on the new scenarios reached at most 1.040.
  • Over all 252 ways to split each run's ten processes between the two builds, the new cells reached at most 1.256: map-forEach with patches, whose processes in one run differed by up to 1.29 times. The fastest-process statistic of perf: budget the scenarios added in #177 and #180, and compare the fastest processes of each build #194 absorbs this. The medians of paired ratios that the gate used before reached 1.303 on shift-and-update.
  • The jobs took 6.0–6.8 minutes for core, 6.6–7.8 for maps and 4.3–5.2 for sets.

Found while measuring

With auto-freeze, shift-and-update took 301 µs at 10,000 rows, against 15.9 µs without, and 189 µs for array-shift-nested, which only removes the row. The moved row's original index is searched with lastIndexOf on the original array, which is frozen then. V8 runs lastIndexOf on a frozen array about 15 times as slowly: 116 µs against 7.9 µs to find the middle element of 10,000. indexOf takes 1.1 µs on both. Searching forward with indexOf, then again from after each hit until there is none, would find the same last index. This PR does not change the runtime; the summary lists the cost under its limits.

Checks

  • node perf-testing/run-benchmarks.mjs --list lists 97 scenarios.
  • The correctness checks that CI runs pass: 1,240 combinations with --patches both and bounded fixtures, and 18 for apply-*. Each measured process also validated its scenario at 100, 1,000 and 10,000 rows.
  • pnpm test:benchmarks: 35 tests pass.
  • pnpm lint, pnpm format --check, oxfmt --check of perf-testing.
  • The website builds, and its link check passes.
  • No file under src/ changes, so the production artifacts are unchanged (bd8ff3a6…, the same as perf: read the type of a draft from its state when applying patches #196's).

Commits

  1. perf: add a scenario that reads a Set without changing it
  2. perf: add a scenario that iterates a Map of numbers with forEach()
  3. perf: add a scenario that updates a row that shift() moved
  4. perf: add a scenario of producers beside a Map and a Set that they leave unchanged
  5. ci: run the Map and the Set budgets in jobs of their own
  6. perf: budget the scenarios for Set reads, Map iteration, moved rows and unchanged collections
  7. perf: record the measurements of the Set read, Map iteration, moved row and unchanged collection scenarios

@unadlib unadlib mentioned this pull request Oct 9, 2026
96 of 99 tasks
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage after merging perf/micro-benchmark-scenarios into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts100%100%100%100%
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib
unadlib merged commit ce0c25e into main Oct 9, 2026
10 checks passed
@unadlib
unadlib deleted the perf/micro-benchmark-scenarios branch October 9, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant