Skip to content

perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key - #195

Merged
unadlib merged 21 commits into
mainfrom
perf/set-drafts
Oct 9, 2026
Merged

unadlib merged 21 commits into
mainfrom
perf/set-drafts

Conversation

@unadlib

@unadlib unadlib commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Part of #168.

Set drafts were one of the few places where Immer was faster: without auto-freeze or patches, adding or deleting an item was 5–12% slower, and the performance page listed it among the 9 cases that Immer won. The first change or iteration of a Set draft built a Map of all its items (new Map(original.entries()), 542 µs at 10,000 items), every change went through that Map, and finalization cleared the copy and added every item back from it (408 µs), even when no item had to be replaced. The copy made at the first change was discarded.

  1. Fix: a Set draft that left its key keeps its changes. When a recipe moved a Set draft to another place (another key, an array, a Map, another Set, a new object or a new Set) and its old place got another value or was removed, the state held that Set without its added, deleted and changed items; Mutative 1.3.0 does the same. finalizeNode skipped such a draft, and only a draft found at its key rebuilt its copy. A Set draft that left its key now finalizes its items at its own entry, before the new place takes its value. The change in 2 depends on it: without it, a draft added to a moved Set would stay in the next state as a revoked proxy.
  2. Perf: Set drafts change their copy directly. add, delete and clear change the copy, which holds the items in order, and has, size and the iterators read it. setMap maps only the original items that an iterator drafted, to their drafts, and the objects that the recipe added, drafts among them, to themselves. Finalization rebuilds the Set in order only once one of those items has a different final value. Patch paths under the items of an unchanged Set take their positions from the original Set, which no rebuild changes. has looks a value up before it inspects it, so a value that is an item, even a draft that its producer revoked, is found as on main.
  3. Perf: a Set is rebuilt by its own producer, once. The first rebuild happens when the Set's own producer finalizes it, never earlier: a nested producer that finalizes a draft the Set holds, or a place that holds the Set, leaves the Set as it is while the outer recipe goes on changing it. Drafts of the Set's own producer have their final values by then, so adding k of them rebuilds the Set once instead of k + 1 times, as on main. Only a draft of another producer, which may change after the Set's producer finished, rebuilds it again when its own producer finalizes it; for that, the first rebuild keeps the items in order on setMap, so drafts keep the size they have on main.
  4. Perf: no search before a rebuild that an item caused. A Set draft that was only read is not rebuilt. One that changed only through items that it drafted, without adding or deleting any, is rebuilt without first searching its drafted items for the one that changed; otherwise the search stops at the first item that changed, and each item is looked up once. The patch path of a changed item of an unchanged Set counts the item's position without copying the Set.
  5. Set subclasses. A draft of a Set subclass changes its copy, an instance of the subclass, with the subclass's own add, has, delete and clear, as Map drafts do. The copy holds the original items in their order, whatever the subclass's constructor or add does with them, and the draft, current(), patch paths and the rebuild iterate the items as a Set holds them, so patches replay onto the right items also when the subclass's constructor or values() orders them otherwise. As a Map subclass's set already must on main, a Set subclass's add must store the value that it receives: main passed the items of a changed Set through add only after the recipe, so an add that stores a copy could receive drafts there, while here it leaves the drafts in the next state. The migration guide notes both.
  6. Build. The size baseline is refreshed, and the size-limit caps rise from 8.4 to 8.6 kB for the production CommonJS bundle, from 7.4 to 7.5 kB for the ESM create import and from 8.3 to 8.4 kB for all ESM exports, which measure 8.52, 7.50 (7,495 bytes) and 8.37 kB. Against main, in Brotli bytes: the production builds grow by 133 (CommonJS, 8,201 to 8,334, of which 11 for fix 1), 138 (ESM) and 140 (UMD), the consumer bundles by 138–148, and the development builds by 183–200.
  7. Docs. The migration guide notes the fix and how drafts of Set subclasses behave, and the README, the website and the performance summary report the Set scenarios measured again.

Results

Mutative alone, the source of main at e6af4b0 and of this branch in alternating order, each scenario in processes of its own, three runs, Apple M1 Max, Node 24.16.0, µs per update:

Scenario Items Freeze Patches main This PR PR/main
set-add 100 off off 6.51 0.70 0.11
set-add 100 off on 8.38 2.59 0.31
set-delete 100 off off 6.40 0.69 0.11
set-add 1,000 off off 66.9 1.29 0.02
set-add 10,000 off off 987 33.2 0.03
set-add 10,000 on off 1,000 62.6 0.06
set-add 10,000 off on 1,396 452 0.32
set-update 100 off off 8.27 3.39 0.41
set-update 10,000 off off 1,053 542 0.52
set-update-10pct 100 off off 10.90 6.98 0.64

All 32 Set cells were faster, 0.28 of the time of main on geometric mean. With every library in processes of its own, the change is faster than Immer in every Set cell, 2.9–51.1 times (set-add at 10,000 items: 34.1 µs against 929 µs), and faster than the hand-written reducer in set-add at 1,000 and 10,000 items. set-add at 10,000 items allocates 325 KiB per update, against 2,878 KiB for Mutative 1.3.0 (Immer 1,607 KiB). With these cells in place of the earlier ones, the published comparison becomes 548 / 12 / 6 of 566 against Immer (geometric mean 3.62, was 536 / 21 / 9 and 3.37) and 142 / 0 / 3 of 145 with each library's defaults (6.62, was 5.98); Immer is faster in 6 cases instead of 9.

Iterating every item of a Set of objects and changing one, which the suite does not measure, takes 0.96–0.99 of the time of main at 100 items and 1.01–1.05 at 1,000 and 10,000 items (2,945 µs against 2,827 µs at 10,000): the rebuild looks each item up in the map of drafted items, where main walked its map of all items. Reading every item takes 0.91–1.00 of the time. With patches, changing 100 items of an unchanged Set of 1,000 objects takes 106 µs, against 291 µs on main (Mutative alone, medians in one process), as the patch path of each item counts its position without copying the Set.

Adding 50 changed drafts to a Set of numbers rebuilt the Set once per draft on main; it is now rebuilt once, whether the drafts were created before or after the Set draft: 111 µs instead of 1,901 µs at 1,000 numbers and 684 µs instead of 22,846 µs at 10,000 (Mutative alone, medians in one process). A draft takes 331.8 bytes, as on main.

Correctness

  • Two differential fuzzers compared this branch with main plus fix 1 only. The first ran 40,000 random Set recipes in production builds and 10,000 in development builds: has, size, add, delete and clear with primitives, items, drafts from other paths and objects holding drafts, every iteration form, partial iteration, changes during iteration, nested Sets, Maps and arrays, current(), union(), Sets moved, shared and detached, auto-freeze, patches with array and string paths, strict mode and marks. It compared the reads, errors, results with their sharing of base objects, freezing, patches and their replay, and the untouched base: 0 differences. The second ran 16,000 recipes in production and 8,000 in development builds with nested producers, among them outer recipes that go on changing a Set after a nested producer finalized a draft that the Set holds or a place that holds the Set, Set subclasses, returned values, rawReturn(), strict mode with unsafe(), marks, frozen bases and apply() on drafts, warnings included: 0 differences.
  • Against main itself, the fuzzers differ in 252 of the 40,000 recipes of the first and 1,471 of the 16,000 of the second, the same counts as between main and main plus fix 1: the recipes where main loses the changes of a moved Set draft.
  • Set subclasses whose constructor or values() orders the items otherwise replay their patches onto the right items in both directions, with auto-freeze and with array and string paths, as on main; a subclass whose add stores a copy of what it receives fails as described in item 5.
  • 4,936 tests pass with 100% coverage. Of the 19 new tests, 11 fail on main: the 7 for moved Set drafts, the 2 that check that adding, deleting and reading items neither index nor rebuild the Set, the one that checks that added drafts rebuild a Set once, and the one for a subclass's add and has during the recipe. The other 8 guard the order of changed items, drafts of an outer producer that a Set holds, changes that an outer recipe makes after a nested producer finalized a draft that a Set holds or a place that holds the Set, the positions of patch paths after an early rebuild and in Set subclasses that order their items otherwise, an item that is a revoked draft, and a nested producer that copies a draft of a Set subclass.

Not covered

  • With patches, a Set that added or deleted items still compares every original item with its copy and every item of the copy with the original to emit its own patches: set-add with patches at 10,000 items takes 452 µs, against 33.2 µs without (Immer 1,347 µs). The membership changes recorded in assignedMap could produce those patches without the full comparison.
  • A changed item still rebuilds the Set in order: set-update at 10,000 items takes 544 µs, against 411 µs for the hand-written reducer. The patch path of each changed item of an unchanged Set still counts its position in the original Set.
  • Iterating every item of a large Set of objects and changing one stays up to 5% slower than on main (see Results). When no item was added or deleted, walking the map of drafted items alongside the original Set instead of looking each item up would remove that, for about 44 more bytes.
  • A Set subclass whose add stores something other than the value it receives cannot receive drafts (item 5). Restoring the behavior of main for subclasses, with the methods of a Set during the recipe and the subclass's own methods only when the Set is rebuilt, would take about 125 more bytes.
  • The ESM create import measures 7,495 of its 7,500 bytes, so the next change to it is likely to raise that cap.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Coverage after merging perf/set-drafts 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%

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Coverage after merging perf/set-drafts 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%

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage after merging perf/set-drafts 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%

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage after merging perf/set-drafts 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%

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage after merging perf/set-drafts 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 4ca6345 into main Oct 9, 2026
9 checks passed
@unadlib
unadlib deleted the perf/set-drafts branch October 9, 2026 12:08
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