Skip to content

perf: read the type of a draft from its state when applying patches - #196

Merged
unadlib merged 7 commits into
mainfrom
perf/apply-patch-paths
Oct 9, 2026
Merged

unadlib merged 7 commits into
mainfrom
perf/apply-patch-paths

Conversation

@unadlib

@unadlib unadlib commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Part of #168.

Applying patches that replace nested values was one of the few cases where Immer was faster: without auto-freeze, apply-update-10pct took 1.16–1.22 times as long as Immer at 100, 1,000 and 10,000 rows, 3 of the 6 cells that Immer won. A CPU profile of that scenario at 1,000 rows put 47% of the time in getType and 6% more in the getPrototypeOf trap: apply() asked every draft on a patch path for its type twice, once for the step itself and once in get() to read the next value, and getType tests instanceof Map and instanceof Set, each of which reaches the getPrototypeOf trap of the draft's proxy. Immer reads its draft state once per step.

  1. Perf: apply() reads the type of a draft from its state. For a draft, the type comes from its ProxyDraft, one read through the proxy, and the type found for a step also reads the next value, instead of get() finding it again.
  2. The objects of the application are typed as before. Values that are not drafts get their type from getType. A mutable application, which changes the objects of the application in place, uses getType alone and reads no symbol from them; in other applications, a value that rejects reading the symbol of drafts, such as a proxy of the application, is typed with getType too.
  3. Build. The size baseline is refreshed, and the size-limit cap of all ESM exports rises from 8.4 to 8.5 kB. Against main, in Brotli bytes: the production builds grow by 33 (CommonJS, 8,334 to 8,367), 35 (ESM) and 45 (UMD), the consumer bundles by 40–61, except create alone, which does not include apply() and is unchanged, and the development builds by 62–81. size-limit measures 8.56, 7.50 and 8.41 kB against caps of 8.6, 7.5 and 8.5 kB.
  4. Docs. The README, the website and the performance summary report the patch application scenarios measured again, and the summary compares the change with main in a section of its own.

Results

Mutative alone, the source of main at 4ca6345 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 patch application:

Scenario Rows Freeze main This PR PR/main
apply-update-10pct 100 off 14.2 6.98 0.49
apply-update-10pct 100 on 18.5 11.3 0.61
apply-update-10pct 1,000 off 142 70.2 0.49
apply-update-10pct 1,000 on 176 105 0.60
apply-update-10pct 10,000 off 1,440 738 0.51
apply-update-10pct 10,000 on 1,766 1,079 0.61
apply-reverse 100 off 119 91.1 0.77
apply-reverse 10,000 off 12,647 9,934 0.79
apply-array-ops 100 off 3.83 2.98 0.78
apply-array-ops 100 on 6.32 5.42 0.86

All 14 patch application cells were faster, 0.68 of the time of main on geometric mean. With every library in processes of its own, apply-update-10pct is now faster than Immer in every cell, 1.6–2.0 times (10,000 rows without auto-freeze: 736 µs against 1,169 µs), and the published comparison becomes 551 / 12 / 3 of 566 against Immer (geometric mean 3.66, was 548 / 12 / 6 and 3.62) and 142 / 0 / 3 of 145 with each library's defaults (6.76, was 6.62); Immer is faster in 3 cases instead of 6. The README, the website and its home page now say about 3.7x and 6.8x instead of 3.6x and 6.6x.

A mutable application of the same patches to plain objects takes 0.91–0.93 of the time of main at 100, 1,000 and 10,000 rows (Mutative alone, medians of three alternating processes per build).

Correctness

  • The change only decides how the type of a value on a patch path is found, so a differential fuzzer compared apply() of this branch with that of main: 20,000 runs in production builds and 10,000 in development builds, on random states with objects, arrays, Maps, Sets and class instances, with patch lists that random recipes generated, with array and string paths, and with hand-written ones that are invalid or hostile (reserved keys and keys that convert to them, missing paths, replace on a Set, unsupported operations, root replacement), in every mode: default, auto-freeze, mark, mutable, a draft as the state and strict mode. It compared the results, the errors and the untouched base: 0 differences. The two Set fuzzers, which replay patches through apply(), ran 20,000 and 8,000 recipes: 0 differences.
  • A proxy of the application that rejects reading symbol keys is changed in place by a mutable application, as the state and below it, and under a mark that keeps it mutable, as on main; two new tests cover these cases.
  • 4,938 tests pass with 100% coverage, and the benchmark suite validates the 18 patch application combinations against its reference results and patch replay at 100 and 1,000 rows.

Not covered

  • In an immutable application, a value that a mark keeps mutable or that cannot be drafted is still asked once per step for the symbol of drafts, as Immer's applyPatches asks every value on a path for its own; a value that rejects the read is typed as before.
  • The ESM create import measures 7,495 of its 7,500 bytes, so the next change to it is likely to raise that cap.

@unadlib unadlib mentioned this pull request Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage after merging perf/apply-patch-paths 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/apply-patch-paths 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%

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