From d7f2891f7a7a4cdba53febeda6dc1411a9c0f0aa Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 21:02:17 +0800 Subject: [PATCH 1/7] perf: read the type of a draft from its state when applying patches --- src/apply.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/apply.ts b/src/apply.ts index 8a84ac58..554d9ae6 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -7,10 +7,22 @@ import type { ApplyOptions, ApplyResult, } from './interface'; -import { deepClone, get, getType, isDraft, unescapePath } from './utils'; +import { + deepClone, + getProxyDraft, + getType, + isDraft, + unescapePath, +} from './utils'; import { create } from './create'; import { die, ErrorCode } from './error'; +// `getType` finds the type of a draft through the traps of its proxy. +function getPatchTargetType(value: any) { + const proxyDraft = getProxyDraft(value); + return proxyDraft ? proxyDraft.type : getType(value); +} + function normalizePatchKey(key: any) { // Let JavaScript perform ToPropertyKey once, including a Symbol result. // Primitive keys cannot disguise a reserved name and need no conversion here. @@ -102,7 +114,7 @@ export function apply< const path = unescapePath(_path); let base: any = draft; for (let index = 0; index < path.length - 1; index += 1) { - const parentType = getType(base); + const parentType = getPatchTargetType(base); let key = path[index]; // Map keys and Set positions retain their native identity/semantics. if (parentType <= DraftType.Array) { @@ -115,14 +127,17 @@ export function apply< die(ErrorCode.ReservedPatchAttribute); } } - // use `index` in Set draft - base = get(parentType === DraftType.Set ? Array.from(base) : base, key); + base = + parentType === DraftType.Map + ? base.get(key) + : // use `index` in Set draft + (parentType === DraftType.Set ? Array.from(base) : base)[key]; if (typeof base !== 'object') { die(ErrorCode.CannotApplyPatch, path); } } - const type = getType(base); + const type = getPatchTargetType(base); // ensure the original patch is not modified. const value = deepClone(patch.value); let key = path[path.length - 1]; From 25184079ba540f470996fde3f2f6da1e5838758a Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 21:02:20 +0800 Subject: [PATCH 2/7] build: refresh the size baseline for applying patches through the types of drafts --- scripts/build-size-baseline.json | 76 ++++++++++++++++---------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index e4396c46..91e6c68b 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,7 +1,7 @@ { "reference": { - "revision": "b8f009e2b1a82b57fdd0bb919bac3f6f6ae6de88", - "description": "Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, a Set subclass draft keeps its items in their order, and a Set draft that left its key keeps its changes", + "revision": "d7f2891f7a7a4cdba53febeda6dc1411a9c0f0aa", + "description": "apply() reads the type of a draft from its state instead of through its proxy", "node": "v24.16.0", "commands": [ "pnpm install --frozen-lockfile", @@ -15,39 +15,39 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 27109, - "gzip": 9083, - "brotli": 8334 + "raw": 27175, + "gzip": 9104, + "brotli": 8347 }, "artifact/mutative.umd.production.min.js": { - "raw": 27266, - "gzip": 9150, - "brotli": 8389 + "raw": 27332, + "gzip": 9172, + "brotli": 8408 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 27084, - "gzip": 9110, - "brotli": 8358 + "raw": 27150, + "gzip": 9130, + "brotli": 8375 }, "artifact/mutative.cjs.development.js": { - "raw": 72711, - "gzip": 17489, - "brotli": 15219 + "raw": 72901, + "gzip": 17528, + "brotli": 15245 }, "artifact/mutative.umd.development.js": { - "raw": 74975, - "gzip": 17688, - "brotli": 15367 + "raw": 75169, + "gzip": 17728, + "brotli": 15422 }, "artifact/mutative.esm.js": { - "raw": 73563, - "gzip": 17603, - "brotli": 15302 + "raw": 73753, + "gzip": 17642, + "brotli": 15321 }, "artifact/mutative.esm.mjs": { - "raw": 73564, - "gzip": 17604, - "brotli": 15296 + "raw": 73754, + "gzip": 17643, + "brotli": 15312 }, "consumer/import/create": { "raw": 24439, @@ -55,29 +55,29 @@ "brotli": 7838 }, "consumer/import/patches": { - "raw": 26332, - "gzip": 9153, - "brotli": 8393 + "raw": 26396, + "gzip": 9169, + "brotli": 8418 }, "consumer/import/all": { - "raw": 27117, - "gzip": 9407, - "brotli": 8621 + "raw": 27181, + "gzip": 9415, + "brotli": 8636 }, "consumer/require/create": { - "raw": 26619, - "gzip": 9403, - "brotli": 8627 + "raw": 26683, + "gzip": 9426, + "brotli": 8643 }, "consumer/require/patches": { - "raw": 26687, - "gzip": 9442, - "brotli": 8660 + "raw": 26751, + "gzip": 9464, + "brotli": 8678 }, "consumer/require/all": { - "raw": 26584, - "gzip": 9382, - "brotli": 8603 + "raw": 26648, + "gzip": 9405, + "brotli": 8630 } } } From 98c0b2088349699ca55214ee9a3a70d3be195917 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 21:17:06 +0800 Subject: [PATCH 3/7] docs: refresh the patch application figures with the scenarios measured again --- README.md | 14 ++--- perf-testing/reports/SUMMARY.md | 54 ++++++++++++++----- website/app/layout.tsx | 2 +- website/components/home/benchmarks.tsx | 6 +-- website/components/home/hero.tsx | 2 +- .../extra-topics/comparison-with-immer.mdx | 2 +- .../docs/getting-started/performance.mdx | 14 ++--- website/content/docs/intro.mdx | 2 +- 8 files changed, 63 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 914142e0..be4e2852 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![NPM Downloads](https://img.shields.io/npm/dm/mutative)](https://npmtrends.com/mutative) ![license](https://img.shields.io/npm/l/mutative) -**Mutative** - A JavaScript library for efficient immutable updates. In the [benchmark](./perf-testing/reports/SUMMARY.md), it was about 3.6x faster than Immer with the same settings and 6.6x faster with each library's defaults across 93 workloads. +**Mutative** - A JavaScript library for efficient immutable updates. In the [benchmark](./perf-testing/reports/SUMMARY.md), it was about 3.7x faster than Immer with the same settings and 6.8x faster with each library's defaults across 93 workloads. In that benchmark the gap widened on large arrays, where Mutative moved elements up to 1,213x faster than Immer. When copying dominates, such as updating objects with thousands of keys, inserting at the front of a large array or adding an item to a large Set, Mutative is even faster than hand-written reducers. @@ -35,7 +35,7 @@ This is why Mutative was created. The [benchmark suite](./perf-testing/README.md) times 93 workloads: Immer's own performance tests, array methods, reads, Map and Set values, object records, class instances, a deep path, patch application, returned values, and searches. It compares Mutative with Immer 11.1.18 and with reducers written by hand, after checking every result against those reducers. The [performance summary](./perf-testing/reports/SUMMARY.md) has the complete results, the method, and their limits. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 551 of 566 measured cases, 3.7x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.8x on geometric mean. Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0, each library in a process of its own; lower is better. Mutative, the first Immer column, and the hand-written reducers run without auto-freeze; the second Immer column shows Immer's default. @@ -53,25 +53,25 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | -| Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | +| Apply patches to 10% of rows | 10,000 | 726 | 1,165 | 2,097 | — | | Search `current()` after `shift()` | 10,000 | 257 | 11,230 | 11,913 | 64.7 | | Return draft.filter() | 10,000 | 2,647 | 4,306 | 4,735 | 78.8 | | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 | | Return it with rawReturn() | 10,000 | 0.26 | 5,699 | 0.79 | 0.02 | -Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 6 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. +Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 3 of the 566 matched cases: with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. The patch application workloads were measured again after `apply()` began to read the type of each draft from its state; applying patches that replace nested values became 1.6-2x faster. Run `pnpm benchmark:immer` to measure the suite; the [benchmark guide](./perf-testing/README.md) describes its options. ### Large arrays -At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 190 of 200 cases at these sizes, 4.9x on geometric mean, and faster in every case that moves elements: +At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 192 of 200 cases at these sizes, 5.0x on geometric mean, and faster in every case that moves elements: | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | ----------- | ------- | ------------------------: | -------------------------: | ----------------: | -----------------: | -| off | off | 8.8x | 10.9x | 211x | 442x | +| off | off | 9.2x | 11.3x | 211x | 442x | | off | on | 4.6x | 5.2x | 7.1x | 7.5x | -| on | off | 3.9x | 4.2x | 32x | 40x | +| on | off | 4.0x | 4.4x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](./perf-testing/reports/SUMMARY.md) breaks these results down by scenario. diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index 931e575a..3753d5b8 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -4,6 +4,8 @@ Measurements from 2026-10-06 and 2026-10-07 UTC of the source of `main` at [`2ab The Set scenarios were measured again on 2026-10-09 UTC, after [PR #195](https://github.com/unadlib/mutative/pull/195) made Set drafts change their copy directly instead of mapping all their items: `set-add`, `set-delete`, `set-update` and `set-update-10pct` at 100 rows and `set-add` and `set-update` at 1,000 and 10,000 rows, every library in processes of its own, three runs each (312 trials from 96 processes), and the memory of `set-add` at every size. The results below use those cells in place of the earlier ones, except where [shared and isolated processes](#shared-and-isolated-processes) compares the cells of October 6 and 7; [Set drafts](#set-drafts) compares the change with `main`. +The patch application scenarios were measured again on 2026-10-09 UTC as well, after [PR #196](https://github.com/unadlib/mutative/pull/196) made `apply()` read the type of each draft from its state instead of asking its proxy: `apply-update-10pct`, `apply-reverse` and `apply-array-ops` at 100 rows and `apply-update-10pct` and `apply-reverse` at 1,000 and 10,000 rows, every library in processes of its own, three runs each (126 trials from 63 processes), and the memory of `apply-update-10pct` at 100 rows. The results below use those cells too, with the same exception; [Patch application](#patch-application) compares the change with `main`. + This batch remeasures every scenario and replaces the cross-library comparison of 2026-10-03 and 2026-10-04, which the [history](#history) summarizes. Unlike earlier batches, every timing below comes from processes that each run one scenario with one library. With all libraries in one process, as earlier batches measured every scenario but the wide-object ones, each library ran slower by a different amount, which favored the candidate against Immer and hid much of the hand-written reducer's lead; [shared and isolated processes](#shared-and-isolated-processes) compares the two. The harness is unchanged since the previous batch except for the three `search-*` scenarios of PR #180, which now also run at 1,000 and 10,000 rows and with Immer's array-method plugin. ## Scope and method @@ -26,9 +28,9 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | Comparator | 100 rows | 1,000 and 10,000 rows | All | | --- | ---: | ---: | ---: | -| Immer 11.1.18 | 358 / 6 / 2 of 366, 3.06 | 190 / 6 / 4 of 200, 4.92 | 548 / 12 / 6 of 566, 3.62 | -| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.33 | 50 / 0 / 2 of 52, 7.16 | 142 / 0 / 3 of 145, 6.62 | -| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.36 | 163 / 37 / 0 of 200, 4.89 | 495 / 70 / 1 of 566, 3.84 | +| Immer 11.1.18 | 359 / 6 / 1 of 366, 3.08 | 192 / 6 / 2 of 200, 5.01 | 551 / 12 / 3 of 566, 3.66 | +| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.42 | 50 / 0 / 2 of 52, 7.43 | 142 / 0 / 3 of 145, 6.76 | +| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.38 | 163 / 37 / 0 of 200, 4.98 | 495 / 70 / 1 of 566, 3.88 | | Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | Against Immer by workload group, faster / within 5% / slower and geometric mean: @@ -46,11 +48,11 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | | Push and insert | 8 / 0 / 0, 2.01 | 4 / 0 / 0, 2.41 | 4 / 0 / 0, 3.34 | -| Patch application | 5 / 0 / 1, 2.74 | 3 / 0 / 1, 1.08 | 3 / 0 / 1, 1.06 | +| Patch application | 6 / 0 / 0, 4.13 | 4 / 0 / 0, 1.65 | 4 / 0 / 0, 1.59 | | Returned values | 12 / 0 / 0, 3.83 | 12 / 0 / 0, 5.60 | 12 / 0 / 0, 8.85 | | Searches | 12 / 0 / 0, 5.14 | 12 / 0 / 0, 8.33 | 12 / 0 / 0, 9.47 | -The 6 cells slower than Immer are `apply-update-10pct` with freeze off at every size (0.82–0.86), `class-wide-update` with freeze on at 1,000 rows (411 µs and 415 µs against 239 µs and 238 µs), and `concat` with freeze and patches on at 100 rows (22.5 µs against 20.8 µs, a cell whose three candidate processes measured 19.2–22.8 µs); see the limits below. With each library's defaults, the candidate is slower only in `return-replace`, at every size: without auto-freeze it searches the returned state for drafts, which Immer's auto-freeze skips because the state is frozen (27.0 µs, 256 µs and 2.59 ms against 0.78–0.79 µs). The one cell slower than Mutative 1.3.0 is the same `concat` cell, against 18.3 µs. +The 3 cells slower than Immer are `class-wide-update` with freeze on at 1,000 rows (411 µs and 415 µs against 239 µs and 238 µs) and `concat` with freeze and patches on at 100 rows (22.5 µs against 20.8 µs, a cell whose three candidate processes measured 19.2–22.8 µs); see the limits below. With each library's defaults, the candidate is slower only in `return-replace`, at every size: without auto-freeze it searches the returned state for drafts, which Immer's auto-freeze skips because the state is frozen (27.0 µs, 256 µs and 2.59 ms against 0.78–0.79 µs). The one cell slower than Mutative 1.3.0 is the same `concat` cell, against 18.3 µs. ### Large arrays @@ -58,13 +60,13 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | Rows | Freeze | Patches | All scenarios | Geometric mean | Moves, geometric mean | Moves, range | | ---: | --- | --- | ---: | ---: | ---: | ---: | -| 1,000 | off | off | 24 / 1 / 1 | 8.84 | 211 | 72.0–492 | +| 1,000 | off | off | 25 / 1 / 0 | 9.16 | 211 | 72.0–492 | | 1,000 | off | on | 23 / 1 / 0 | 4.56 | 7.1 | 6.1–7.5 | -| 1,000 | on | off | 25 / 0 / 1 | 3.88 | 32.4 | 18.5–46.9 | +| 1,000 | on | off | 25 / 0 / 1 | 3.99 | 32.4 | 18.5–46.9 | | 1,000 | on | on | 23 / 0 / 1 | 2.79 | 6.6 | 6.1–6.9 | -| 10,000 | off | off | 24 / 1 / 1 | 10.92 | 442 | 176–1,213 | +| 10,000 | off | off | 25 / 1 / 0 | 11.31 | 442 | 176–1,213 | | 10,000 | off | on | 23 / 1 / 0 | 5.23 | 7.5 | 5.0–9.7 | -| 10,000 | on | off | 25 / 1 / 0 | 4.24 | 39.7 | 22.2–53.0 | +| 10,000 | on | off | 25 / 1 / 0 | 4.36 | 39.7 | 22.2–53.0 | | 10,000 | on | on | 23 / 1 / 0 | 3.02 | 7.1 | 5.1–9.0 | Immer time over candidate time for the moves at 10,000 rows, by freeze and patch mode: @@ -188,6 +190,27 @@ A snapshot search was faster than the draft search at every size: 1.7 times at 1 Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.28 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,878 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 2.9–51.1 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. +### Patch application + +[PR #196](https://github.com/unadlib/mutative/pull/196) changed how `apply()` finds the type of each value on a patch path. It asked every draft on the path for its type twice, once for the step itself and once to read the next value, and `getType` tests `instanceof Map` and `instanceof Set`, which reach the `getPrototypeOf` trap of the draft's proxy; in a profile of `apply-update-10pct` at 1,000 rows, `getType` took 47% of the time. `apply()` now reads the type of a draft from its state, once per step. Mutative alone in processes of its own, the source of `main` at `4ca6345` and that of the PR in alternating order, three runs each, µs per patch application: + +| Scenario | Rows | Freeze | `main` | PR #196 | PR/`main` | +| --- | ---: | --- | ---: | ---: | ---: | +| apply-update-10pct | 100 | off | 14.2 | 7.02 | 0.49 | +| apply-update-10pct | 100 | on | 18.4 | 11.3 | 0.61 | +| apply-update-10pct | 1,000 | off | 141 | 70.6 | 0.50 | +| apply-update-10pct | 1,000 | on | 176 | 105 | 0.60 | +| apply-update-10pct | 10,000 | off | 1,445 | 730 | 0.51 | +| apply-update-10pct | 10,000 | on | 1,770 | 1,075 | 0.61 | +| apply-reverse | 100 | off | 119 | 91.6 | 0.77 | +| apply-reverse | 100 | on | 137 | 110 | 0.80 | +| apply-reverse | 10,000 | off | 12,635 | 9,992 | 0.79 | +| apply-reverse | 10,000 | on | 14,503 | 11,739 | 0.81 | +| apply-array-ops | 100 | off | 3.80 | 2.97 | 0.78 | +| apply-array-ops | 100 | on | 6.29 | 5.48 | 0.87 | + +Over all 14 cells, three patch application scenarios at 100 rows and two at 1,000 and 10,000 rows in both freeze modes, the PR took 0.68 of the time of `main` on geometric mean and was faster in each. Replacing nested values took half the time without auto-freeze and 0.6 of it with auto-freeze, so `apply-update-10pct`, where the candidate was 16–22% slower than Immer without auto-freeze, is now faster than Immer at every size, 1.6–2.0 times. Structural patches spend more of their time outside the path walk and gained less. + ### Selected times Microseconds per complete scenario, medians of three runs. @@ -205,7 +228,7 @@ Microseconds per complete scenario, medians of three runs. | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | | set-update | 100 | off | off | 3.39 | 8.98 | 42.0 | 2.14 | 12.37 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | -| apply-array-ops | 100 | off | off | 4.06 | 116 | 101 | — | 24.83 | +| apply-array-ops | 100 | off | off | 3.00 | 115 | 100 | — | 33.46 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | | search-current | 100 | off | off | 12.3 | 43.6 | 238 | 0.14 | 19.36 | | array-splice-insert-nested | 1,000 | off | off | 4.90 | 394 | 352 | 2.03 | 71.98 | @@ -219,7 +242,7 @@ Microseconds per complete scenario, medians of three runs. | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | | set-update | 10,000 | off | off | 544 | 1,057 | 6,119 | 411 | 11.24 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | -| apply-reverse | 10,000 | off | off | 12,590 | 13,801 | 13,864 | — | 1.10 | +| apply-reverse | 10,000 | off | off | 9,918 | 13,844 | 13,880 | — | 1.40 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | | return-replace | 10,000 | on | off | 0.53 | 2,630 | 0.79 | — | 1.48 | | search-current-shifted | 10,000 | off | off | 257 | 15,874 | 11,230 | 64.7 | 43.74 | @@ -307,7 +330,6 @@ In 10 cells every process was slower than every process of the previous batch: ` - Returned values: without auto-freeze, the candidate searches every object of a returned value for drafts, as Immer does: `return-replace` took 2.59 ms at 10,000 rows, against 5.63 ms for Immer without auto-freeze. Immer's default auto-freeze skips frozen values (0.79 µs), and so do production builds of the candidate with auto-freeze (0.53 µs). Wrapping the value in `rawReturn()` skips the search (0.26 µs). - A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 544 µs at 10,000 items, against 411 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 448 µs with patches at 10,000 items, against 34.1 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. - With auto-freeze, Immer updated a class instance with 1,000 fields faster: 239 µs against 411 µs. Without freezing the candidate is faster, 98 µs against 206 µs, so the difference lies in freezing the candidate's copy, which it makes property by property with descriptors where Immer 11 uses `Object.assign`. -- Applying patches that replace nested values was 16–22% slower than Immer; structural patches are faster, as `apply-array-ops` shows (25 times). - Even in processes of their own, some cells varied between processes of the same build by up to about 1.5 times, for example `remove-reuse` with freeze on (127–191 µs) and Immer's `map-read` at 10,000 rows; `class-wide-update` without freezing at 1,000 rows took either about 95 µs or about 200 µs in both Mutative builds. Medians of three processes absorb one outlier, not two; compare such cells through geometric means rather than one by one. - Isolated processes measure each library without the other libraries and scenarios, not inside a real application, whose own code also changes V8's state; wide-object timings in particular depend on it, by up to about 70 times. - Freeze-on inputs are already frozen, so freezing cold external data is excluded. Allocation is sampled, and small retained deltas are noisy. All measurements come from one machine and Node.js; browser engines may differ. @@ -347,6 +369,13 @@ PR #195 production: 74adeacf19aaa8df15d94751824c3d8f12dd821f854eb8a9f137ee9daf main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c27e7c5 ``` +The patch application scenarios of 2026-10-09 measured the production artifact of PR #196, which its head builds unchanged, and compared it with `main` at `4ca6345`: + +```text +PR #196 production: c3c7d10325d60c9a217a153527ed742e6830bd92514359b320f0f9e14564de99 +main production: 74adeacf19aaa8df15d94751824c3d8f12dd821f854eb8a9f137ee9daff41d05 +``` + ## Reproduce Use Node 24.16.0 and the frozen lockfile. Run the following sequentially from the repository root; timings will vary. The [benchmark guide](../README.md) explains workload semantics and options. @@ -400,6 +429,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. +- 2026-10-09, PR #196: `apply()` reads the type of each draft on a patch path from its state instead of asking its proxy twice per step. Against `main` at `4ca6345`, Mutative alone, the PR took 0.68 of the time over 14 patch application cells, and `apply-update-10pct` 0.49–0.61 of it. The patch application scenarios, measured again with every library in processes of its own, replace the earlier cells: 551 / 12 / 3 of 566 against Immer (geometric mean 3.66), 142 / 0 / 3 of 145 with each library's defaults (6.76), and 495 / 70 / 1 against 1.3.0 (3.88). The production CJS artifact grew from 8,334 to 8,347 Brotli bytes. - 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, drafts of Set subclasses keep their items in order, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.28 of the time over 32 Set cells; `set-add` at 10,000 items took 33.2 µs instead of 987 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.62), 142 / 0 / 3 of 145 with each library's defaults (6.62), 495 / 70 / 1 against 1.3.0 (3.84), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,334 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 53a40f86..46077efc 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = { template: '%s | Mutative', }, description: - "Efficient immutable updates, about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults.", + "Efficient immutable updates, about 3.7x faster than Immer with matched settings and 6.8x faster with each library's defaults.", }), }; diff --git a/website/components/home/benchmarks.tsx b/website/components/home/benchmarks.tsx index 44ad229c..2376a955 100644 --- a/website/components/home/benchmarks.tsx +++ b/website/components/home/benchmarks.tsx @@ -5,12 +5,12 @@ import { SectionHeading } from './section-heading'; // The results of the performance page in the docs. const stats = [ { - value: '3.6x', + value: '3.7x', label: 'Faster than Immer with matched settings', - detail: 'Faster in 548 of 566 measured cases', + detail: 'Faster in 551 of 566 measured cases', }, { - value: '6.6x', + value: '6.8x', label: "Faster than Immer with each library's defaults", detail: 'Faster in 142 of 145 measured cases', }, diff --git a/website/components/home/hero.tsx b/website/components/home/hero.tsx index 993ddf97..95731013 100644 --- a/website/components/home/hero.tsx +++ b/website/components/home/hero.tsx @@ -42,7 +42,7 @@ export function Hero() { > - About 3.6x faster than Immer + About 3.7x faster than Immer across 93 workloads diff --git a/website/content/docs/extra-topics/comparison-with-immer.mdx b/website/content/docs/extra-topics/comparison-with-immer.mdx index 7865617e..b87fe2a5 100644 --- a/website/content/docs/extra-topics/comparison-with-immer.mdx +++ b/website/content/docs/extra-topics/comparison-with-immer.mdx @@ -26,7 +26,7 @@ The [Immer array methods audit](https://github.com/unadlib/mutative/blob/main/te > Mutative passed all of Immer's test cases. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer 11.1.18 in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. Immer was faster when applying patches that replace nested values, with auto-freeze when updating class instances with 1,000 fields, and in one concatenation with auto-freeze and patches. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer 11.1.18 in 551 of 566 measured cases, 3.7x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.8x on geometric mean. Immer was faster with auto-freeze when updating class instances with 1,000 fields, and in one concatenation with auto-freeze and patches. With patches on and auto-freeze off, Mutative was faster in 136 of 138 cases and never slower, and faster in every array case: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. The comparisons run Immer without its `enableArrayMethods()` plugin. In Immer 11.1.18 the plugin returns raw base objects from `shift`, `pop` and `splice`, so editing them changes the previous state, and its patches can fail to replay; the audit above reproduces these failures. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index 19c83970..b104a53b 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -2,7 +2,7 @@ title: Performance --- -Mutative is about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults, across 93 benchmarked workloads. A hand-written reducer that copies only what it changes is faster still in most of them. +Mutative is about 3.7x faster than Immer with matched settings and 6.8x faster with each library's defaults, across 93 benchmarked workloads. A hand-written reducer that copies only what it changes is faster still in most of them. ## Benchmark suite @@ -12,7 +12,7 @@ The [benchmark suite](https://github.com/unadlib/mutative/tree/main/perf-testing > Mutative passed all of Immer's test cases. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 551 of 566 measured cases, 3.7x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.8x on geometric mean. Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0, each library in a process of its own; lower is better. Mutative, the first Immer column, and the hand-written reducers run without auto-freeze; the second Immer column shows Immer's default. @@ -30,23 +30,23 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | -| Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | +| Apply patches to 10% of rows | 10,000 | 726 | 1,165 | 2,097 | — | | Search `current()` after `shift()` | 10,000 | 257 | 11,230 | 11,913 | 64.7 | | Return draft.filter() | 10,000 | 2,647 | 4,306 | 4,735 | 78.8 | | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 | | Return it with rawReturn() | 10,000 | 0.26 | 5,699 | 0.79 | 0.02 | -Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 6 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. +Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 3 of the 566 matched cases: with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. The patch application workloads were measured again after `apply()` began to read the type of each draft from its state; applying patches that replace nested values became 1.6-2x faster. ## Large arrays -At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 190 of 200 cases at these sizes, 4.9x on geometric mean, and faster in every case that moves elements: +At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 192 of 200 cases at these sizes, 5.0x on geometric mean, and faster in every case that moves elements: | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | --- | --- | ---: | ---: | ---: | ---: | -| off | off | 8.8x | 10.9x | 211x | 442x | +| off | off | 9.2x | 11.3x | 211x | 442x | | off | on | 4.6x | 5.2x | 7.1x | 7.5x | -| on | off | 3.9x | 4.2x | 32x | 40x | +| on | off | 4.0x | 4.4x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](https://github.com/unadlib/mutative/blob/main/perf-testing/reports/SUMMARY.md) breaks these results down by scenario. diff --git a/website/content/docs/intro.mdx b/website/content/docs/intro.mdx index 4f2df7b6..e51f7c18 100644 --- a/website/content/docs/intro.mdx +++ b/website/content/docs/intro.mdx @@ -2,7 +2,7 @@ title: Introduction --- -**Mutative** - A JavaScript library for efficient immutable updates, about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults, across [93 benchmarked workloads](/docs/getting-started/performance). +**Mutative** - A JavaScript library for efficient immutable updates, about 3.7x faster than Immer with matched settings and 6.8x faster with each library's defaults, across [93 benchmarked workloads](/docs/getting-started/performance). ## What is Mutative? From 199359c38e8521e687f6942f877ee7c9b621c571 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 22:58:21 +0800 Subject: [PATCH 4/7] fix: keep apply() working on objects of the application that reject reading symbol keys --- src/apply.ts | 22 ++++++++++++++++------ test/apply.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/apply.ts b/src/apply.ts index 554d9ae6..eb2671b4 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -17,10 +17,17 @@ import { import { create } from './create'; import { die, ErrorCode } from './error'; -// `getType` finds the type of a draft through the traps of its proxy. +// A draft knows its type, which `getType` would find only through the traps of +// its proxy. Any other value is typed as before, also a proxy of the +// application that rejects reading the symbol of drafts. function getPatchTargetType(value: any) { - const proxyDraft = getProxyDraft(value); - return proxyDraft ? proxyDraft.type : getType(value); + try { + const proxyDraft = getProxyDraft(value); + if (proxyDraft) return proxyDraft.type; + } catch { + // Not a draft. + } + return getType(value); } function normalizePatchKey(key: any) { @@ -108,13 +115,16 @@ export function apply< if (i > -1) { patches = patches.slice(i + 1); } + const mutable = (applyOptions as ApplyMutableOptions)?.mutable; + // The objects that a mutable application changes in place are not drafts. + const getTargetType = mutable ? getType : getPatchTargetType; const mutate = (draft: Draft | T) => { patches.forEach((patch) => { const { path: _path, op } = patch; const path = unescapePath(_path); let base: any = draft; for (let index = 0; index < path.length - 1; index += 1) { - const parentType = getPatchTargetType(base); + const parentType = getTargetType(base); let key = path[index]; // Map keys and Set positions retain their native identity/semantics. if (parentType <= DraftType.Array) { @@ -137,7 +147,7 @@ export function apply< } } - const type = getPatchTargetType(base); + const type = getTargetType(base); // ensure the original patch is not modified. const value = deepClone(patch.value); let key = path[path.length - 1]; @@ -192,7 +202,7 @@ export function apply< } }); }; - if ((applyOptions as ApplyMutableOptions)?.mutable) { + if (mutable) { if (__DEV__) { if ( Object.keys(applyOptions!).filter((key) => key !== 'mutable').length diff --git a/test/apply.test.ts b/test/apply.test.ts index 4bc24568..304ca9d0 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -2248,3 +2248,38 @@ test('Set patches remove object elements by identity', () => { const replayed = apply(new Set([1]), numbers[1]); expect([...apply(replayed, numbers[2])]).toStrictEqual([1]); }); + +describe('objects of the application on a patch path', () => { + // A proxy of the application that rejects reading symbol keys. + const rejectSymbols = (value: T) => + new Proxy(value, { + get(target, key, receiver) { + if (typeof key === 'symbol') throw new Error('Unsupported symbol'); + return Reflect.get(target, key, receiver); + }, + }); + + test('are changed in place by a mutable application', () => { + const root = rejectSymbols({ value: 1 }); + apply(root, [{ op: 'replace', path: ['value'], value: 2 }], { + mutable: true, + }); + expect(root.value).toBe(2); + const item = rejectSymbols({ value: 1 }); + apply({ item }, [{ op: 'replace', path: ['item', 'value'], value: 2 }], { + mutable: true, + }); + expect(item.value).toBe(2); + }); + + test('are changed in place under a mutable mark', () => { + const item = rejectSymbols({ value: 1 }); + const state = apply( + { item }, + [{ op: 'replace', path: ['item', 'value'], value: 2 }], + { mark: (value, { mutable }) => (value === item ? mutable : undefined) } + ); + expect(state.item).toBe(item); + expect(item.value).toBe(2); + }); +}); From f162e2f4bb96fdf314e95811a35a29b4e29cd957 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 22:58:35 +0800 Subject: [PATCH 5/7] build: raise the size limit of all ESM exports and refresh the baseline for apply() --- package.json | 2 +- scripts/build-size-baseline.json | 76 ++++++++++++++++---------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 7ea3213a..d7d09a11 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "name": "ESM, all exports", "path": "dist/mutative.esm.mjs", "import": "*", - "limit": "8.4 KB" + "limit": "8.5 KB" } ], "repository": { diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index 91e6c68b..6464f15a 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,7 +1,7 @@ { "reference": { - "revision": "d7f2891f7a7a4cdba53febeda6dc1411a9c0f0aa", - "description": "apply() reads the type of a draft from its state instead of through its proxy", + "revision": "199359c38e8521e687f6942f877ee7c9b621c571", + "description": "apply() reads the type of a draft from its state, and types the objects that a mutable application changes as before", "node": "v24.16.0", "commands": [ "pnpm install --frozen-lockfile", @@ -15,39 +15,39 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 27175, - "gzip": 9104, - "brotli": 8347 + "raw": 27208, + "gzip": 9129, + "brotli": 8367 }, "artifact/mutative.umd.production.min.js": { - "raw": 27332, - "gzip": 9172, - "brotli": 8408 + "raw": 27365, + "gzip": 9196, + "brotli": 8434 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 27150, - "gzip": 9130, - "brotli": 8375 + "raw": 27183, + "gzip": 9153, + "brotli": 8393 }, "artifact/mutative.cjs.development.js": { - "raw": 72901, - "gzip": 17528, - "brotli": 15245 + "raw": 73016, + "gzip": 17569, + "brotli": 15298 }, "artifact/mutative.umd.development.js": { - "raw": 75169, - "gzip": 17728, - "brotli": 15422 + "raw": 75289, + "gzip": 17769, + "brotli": 15448 }, "artifact/mutative.esm.js": { - "raw": 73753, - "gzip": 17642, - "brotli": 15321 + "raw": 73868, + "gzip": 17684, + "brotli": 15364 }, "artifact/mutative.esm.mjs": { - "raw": 73754, - "gzip": 17643, - "brotli": 15312 + "raw": 73869, + "gzip": 17686, + "brotli": 15358 }, "consumer/import/create": { "raw": 24439, @@ -55,29 +55,29 @@ "brotli": 7838 }, "consumer/import/patches": { - "raw": 26396, - "gzip": 9169, - "brotli": 8418 + "raw": 26437, + "gzip": 9212, + "brotli": 8443 }, "consumer/import/all": { - "raw": 27181, - "gzip": 9415, - "brotli": 8636 + "raw": 27222, + "gzip": 9467, + "brotli": 8682 }, "consumer/require/create": { - "raw": 26683, - "gzip": 9426, - "brotli": 8643 + "raw": 26722, + "gzip": 9458, + "brotli": 8667 }, "consumer/require/patches": { - "raw": 26751, - "gzip": 9464, - "brotli": 8678 + "raw": 26790, + "gzip": 9496, + "brotli": 8704 }, "consumer/require/all": { - "raw": 26648, - "gzip": 9405, - "brotli": 8630 + "raw": 26687, + "gzip": 9437, + "brotli": 8646 } } } From 65b7ddf109cc57d7f9ffe1a673299fb7110d2b88 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 22:58:53 +0800 Subject: [PATCH 6/7] docs: remove apply-update-10pct from the cells where Immer was faster at 1,000 and 10,000 rows --- perf-testing/reports/SUMMARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index 3753d5b8..deb6d5ef 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -79,7 +79,7 @@ Immer time over candidate time for the moves at 10,000 rows, by freeze and patch | array-reverse-nested | 551 | 8.8 | 40.5 | 8.1 | | array-reverse-primitive | 302 | 5.0 | 53.0 | 5.1 | -The gap widens with size. With freeze and patches off, the moves were 211 times faster at 1,000 rows and 442 times at 10,000 on geometric mean: the candidate moves elements natively on its copy, while Immer moves each element through its draft proxy. Removing the first of 10,000 rows took 7.10 µs against 8,605 µs. With patches, both libraries emit one patch per moved index, which bounds the gain to 5–10 times. At these sizes Immer was faster in `apply-update-10pct` and in `class-wide-update` with freeze on at 1,000 rows, and within 5% in Map updates without freezing and `class-wide-update` with freeze on at 10,000 rows; the limits below discuss each. +The gap widens with size. With freeze and patches off, the moves were 211 times faster at 1,000 rows and 442 times at 10,000 on geometric mean: the candidate moves elements natively on its copy, while Immer moves each element through its draft proxy. Removing the first of 10,000 rows took 7.10 µs against 8,605 µs. With patches, both libraries emit one patch per moved index, which bounds the gain to 5–10 times. At these sizes Immer was faster only in `class-wide-update` with freeze on at 1,000 rows, and within 5% in Map updates without freezing and `class-wide-update` with freeze on at 10,000 rows; the limits below discuss each. ### Freeze off, patches on From 8419730d6c673bae2072efdf063c87f739da2fea Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 23:10:01 +0800 Subject: [PATCH 7/7] docs: refresh the patch application figures for the final artifact --- README.md | 2 +- perf-testing/reports/SUMMARY.md | 40 +++++++++---------- .../docs/getting-started/performance.mdx | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index be4e2852..57644deb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | -| Apply patches to 10% of rows | 10,000 | 726 | 1,165 | 2,097 | — | +| Apply patches to 10% of rows | 10,000 | 736 | 1,169 | 2,102 | — | | Search `current()` after `shift()` | 10,000 | 257 | 11,230 | 11,913 | 64.7 | | Return draft.filter() | 10,000 | 2,647 | 4,306 | 4,735 | 78.8 | | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 | diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index deb6d5ef..c98929c2 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -28,8 +28,8 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | Comparator | 100 rows | 1,000 and 10,000 rows | All | | --- | ---: | ---: | ---: | -| Immer 11.1.18 | 359 / 6 / 1 of 366, 3.08 | 192 / 6 / 2 of 200, 5.01 | 551 / 12 / 3 of 566, 3.66 | -| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.42 | 50 / 0 / 2 of 52, 7.43 | 142 / 0 / 3 of 145, 6.76 | +| Immer 11.1.18 | 359 / 6 / 1 of 366, 3.08 | 192 / 6 / 2 of 200, 5.00 | 551 / 12 / 3 of 566, 3.66 | +| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.42 | 50 / 0 / 2 of 52, 7.42 | 142 / 0 / 3 of 145, 6.76 | | Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.38 | 163 / 37 / 0 of 200, 4.98 | 495 / 70 / 1 of 566, 3.88 | | Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | @@ -48,7 +48,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | | Push and insert | 8 / 0 / 0, 2.01 | 4 / 0 / 0, 2.41 | 4 / 0 / 0, 3.34 | -| Patch application | 6 / 0 / 0, 4.13 | 4 / 0 / 0, 1.65 | 4 / 0 / 0, 1.59 | +| Patch application | 6 / 0 / 0, 4.08 | 4 / 0 / 0, 1.65 | 4 / 0 / 0, 1.58 | | Returned values | 12 / 0 / 0, 3.83 | 12 / 0 / 0, 5.60 | 12 / 0 / 0, 8.85 | | Searches | 12 / 0 / 0, 5.14 | 12 / 0 / 0, 8.33 | 12 / 0 / 0, 9.47 | @@ -64,9 +64,9 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | 1,000 | off | on | 23 / 1 / 0 | 4.56 | 7.1 | 6.1–7.5 | | 1,000 | on | off | 25 / 0 / 1 | 3.99 | 32.4 | 18.5–46.9 | | 1,000 | on | on | 23 / 0 / 1 | 2.79 | 6.6 | 6.1–6.9 | -| 10,000 | off | off | 25 / 1 / 0 | 11.31 | 442 | 176–1,213 | +| 10,000 | off | off | 25 / 1 / 0 | 11.30 | 442 | 176–1,213 | | 10,000 | off | on | 23 / 1 / 0 | 5.23 | 7.5 | 5.0–9.7 | -| 10,000 | on | off | 25 / 1 / 0 | 4.36 | 39.7 | 22.2–53.0 | +| 10,000 | on | off | 25 / 1 / 0 | 4.35 | 39.7 | 22.2–53.0 | | 10,000 | on | on | 23 / 1 / 0 | 3.02 | 7.1 | 5.1–9.0 | Immer time over candidate time for the moves at 10,000 rows, by freeze and patch mode: @@ -192,22 +192,22 @@ Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 ro ### Patch application -[PR #196](https://github.com/unadlib/mutative/pull/196) changed how `apply()` finds the type of each value on a patch path. It asked every draft on the path for its type twice, once for the step itself and once to read the next value, and `getType` tests `instanceof Map` and `instanceof Set`, which reach the `getPrototypeOf` trap of the draft's proxy; in a profile of `apply-update-10pct` at 1,000 rows, `getType` took 47% of the time. `apply()` now reads the type of a draft from its state, once per step. Mutative alone in processes of its own, the source of `main` at `4ca6345` and that of the PR in alternating order, three runs each, µs per patch application: +[PR #196](https://github.com/unadlib/mutative/pull/196) changed how `apply()` finds the type of each value on a patch path. It asked every draft on the path for its type twice, once for the step itself and once to read the next value, and `getType` tests `instanceof Map` and `instanceof Set`, which reach the `getPrototypeOf` trap of the draft's proxy; in a profile of `apply-update-10pct` at 1,000 rows, `getType` took 47% of the time. `apply()` now reads the type of a draft from its state, once per step. Other values are typed with `getType` as before, and a mutable application, which changes the objects of the application in place, reads nothing else from them. Mutative alone in processes of its own, the source of `main` at `4ca6345` and that of the PR in alternating order, three runs each, µs per patch application: | Scenario | Rows | Freeze | `main` | PR #196 | PR/`main` | | --- | ---: | --- | ---: | ---: | ---: | -| apply-update-10pct | 100 | off | 14.2 | 7.02 | 0.49 | -| apply-update-10pct | 100 | on | 18.4 | 11.3 | 0.61 | -| apply-update-10pct | 1,000 | off | 141 | 70.6 | 0.50 | +| 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,445 | 730 | 0.51 | -| apply-update-10pct | 10,000 | on | 1,770 | 1,075 | 0.61 | -| apply-reverse | 100 | off | 119 | 91.6 | 0.77 | +| 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 | 100 | on | 137 | 110 | 0.80 | -| apply-reverse | 10,000 | off | 12,635 | 9,992 | 0.79 | -| apply-reverse | 10,000 | on | 14,503 | 11,739 | 0.81 | -| apply-array-ops | 100 | off | 3.80 | 2.97 | 0.78 | -| apply-array-ops | 100 | on | 6.29 | 5.48 | 0.87 | +| apply-reverse | 10,000 | off | 12,647 | 9,934 | 0.79 | +| apply-reverse | 10,000 | on | 14,540 | 11,694 | 0.80 | +| apply-array-ops | 100 | off | 3.83 | 2.98 | 0.78 | +| apply-array-ops | 100 | on | 6.32 | 5.42 | 0.86 | Over all 14 cells, three patch application scenarios at 100 rows and two at 1,000 and 10,000 rows in both freeze modes, the PR took 0.68 of the time of `main` on geometric mean and was faster in each. Replacing nested values took half the time without auto-freeze and 0.6 of it with auto-freeze, so `apply-update-10pct`, where the candidate was 16–22% slower than Immer without auto-freeze, is now faster than Immer at every size, 1.6–2.0 times. Structural patches spend more of their time outside the path walk and gained less. @@ -228,7 +228,7 @@ Microseconds per complete scenario, medians of three runs. | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | | set-update | 100 | off | off | 3.39 | 8.98 | 42.0 | 2.14 | 12.37 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | -| apply-array-ops | 100 | off | off | 3.00 | 115 | 100 | — | 33.46 | +| apply-array-ops | 100 | off | off | 2.99 | 115 | 100 | — | 33.57 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | | search-current | 100 | off | off | 12.3 | 43.6 | 238 | 0.14 | 19.36 | | array-splice-insert-nested | 1,000 | off | off | 4.90 | 394 | 352 | 2.03 | 71.98 | @@ -242,7 +242,7 @@ Microseconds per complete scenario, medians of three runs. | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | | set-update | 10,000 | off | off | 544 | 1,057 | 6,119 | 411 | 11.24 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | -| apply-reverse | 10,000 | off | off | 9,918 | 13,844 | 13,880 | — | 1.40 | +| apply-reverse | 10,000 | off | off | 9,918 | 13,772 | 13,889 | — | 1.40 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | | return-replace | 10,000 | on | off | 0.53 | 2,630 | 0.79 | — | 1.48 | | search-current-shifted | 10,000 | off | off | 257 | 15,874 | 11,230 | 64.7 | 43.74 | @@ -372,7 +372,7 @@ main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c The patch application scenarios of 2026-10-09 measured the production artifact of PR #196, which its head builds unchanged, and compared it with `main` at `4ca6345`: ```text -PR #196 production: c3c7d10325d60c9a217a153527ed742e6830bd92514359b320f0f9e14564de99 +PR #196 production: bd8ff3a63a6007bb800f8782985e6156c57aac1bbd0c8f7251708e863c2c19c9 main production: 74adeacf19aaa8df15d94751824c3d8f12dd821f854eb8a9f137ee9daff41d05 ``` @@ -429,7 +429,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. -- 2026-10-09, PR #196: `apply()` reads the type of each draft on a patch path from its state instead of asking its proxy twice per step. Against `main` at `4ca6345`, Mutative alone, the PR took 0.68 of the time over 14 patch application cells, and `apply-update-10pct` 0.49–0.61 of it. The patch application scenarios, measured again with every library in processes of its own, replace the earlier cells: 551 / 12 / 3 of 566 against Immer (geometric mean 3.66), 142 / 0 / 3 of 145 with each library's defaults (6.76), and 495 / 70 / 1 against 1.3.0 (3.88). The production CJS artifact grew from 8,334 to 8,347 Brotli bytes. +- 2026-10-09, PR #196: `apply()` reads the type of each draft on a patch path from its state instead of asking its proxy twice per step. Against `main` at `4ca6345`, Mutative alone, the PR took 0.68 of the time over 14 patch application cells, and `apply-update-10pct` 0.49–0.61 of it. The patch application scenarios, measured again with every library in processes of its own, replace the earlier cells: 551 / 12 / 3 of 566 against Immer (geometric mean 3.66), 142 / 0 / 3 of 145 with each library's defaults (6.76), and 495 / 70 / 1 against 1.3.0 (3.88). The production CJS artifact grew from 8,334 to 8,367 Brotli bytes. - 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, drafts of Set subclasses keep their items in order, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.28 of the time over 32 Set cells; `set-add` at 10,000 items took 33.2 µs instead of 987 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.62), 142 / 0 / 3 of 145 with each library's defaults (6.62), 495 / 70 / 1 against 1.3.0 (3.84), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,334 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index b104a53b..781d5c7d 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -30,7 +30,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | -| Apply patches to 10% of rows | 10,000 | 726 | 1,165 | 2,097 | — | +| Apply patches to 10% of rows | 10,000 | 736 | 1,169 | 2,102 | — | | Search `current()` after `shift()` | 10,000 | 257 | 11,230 | 11,913 | 64.7 | | Return draft.filter() | 10,000 | 2,647 | 4,306 | 4,735 | 78.8 | | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 |