Skip to content

perf: find a moved array element's original index from the offset of its move - #200

Merged
unadlib merged 5 commits into
mainfrom
perf/moved-element-offset
Oct 11, 2026
Merged

unadlib merged 5 commits into
mainfrom
perf/moved-element-offset

Conversation

@unadlib

@unadlib unadlib commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Part of #168.

After a native move, an array draft needs the original index of each moved element that a recipe reads. Since #183, the first eight lookups searched the original array from its end with lastIndexOf, and later ones built a map of all original indices. V8 runs lastIndexOf on frozen, sealed and non-extensible arrays about 15 times as slowly as on other arrays, about 23 ns per element against 1.6 ns, while indexOf takes 0.21 ns per element on every kind of array. So with several reads of moved rows of a frozen array, the eight searches cost more than the map they deferred: eight edits after shift() of 10,000 frozen rows took 2,050 µs, against 632 µs before #183.

  1. Lookup from the offset of the move. shift(), unshift() and splice() shift every element they move by the same offset. A lookup now checks first the original index that the offset of the last move gives. When that index holds the element, it searches forward from there with indexOf for a later copy of a repeated element, which finds the same last index as lastIndexOf and the map. After reverse(), after moves that the last offset does not explain, or when the check fails, the lookup searches from the end as before. The offset is only a hint: a wrong one costs one comparison and cannot change the result.
  2. Tests. The tests of edits after each move also run on frozen arrays with auto-freeze, including a repeated element that is drafted under its last original index. A new test checks that an element moved by shift(), unshift() or splice() is found without a backward search. The three new tests fail on main.
  3. Size. The production CJS artifact grows from 8,367 to 8,439 Brotli bytes (+72), UMD +55, production ESM +70, the esbuild consumers +52–60, and the development builds +46–75. Following the cap rule (new size plus 1%, rounded up to 0.1 kB), the size limits rise from 8.6 to 8.7 kB for all CJS exports and from 7.5 to 7.7 kB for the ESM create; ESM with all exports stays under 8.5 kB at 8.48 kB.
  4. Measurements. In the performance summary, a new section compares the change with main. The shift-and-update cells were measured again with every library and layered over the batch of perf: rerun the full benchmark on main and refresh the published results #199, so the published figures follow. The default-settings figure becomes 6.7x instead of 6.6x, the matched-settings figure stays at 3.6x, and two cells of the large-array table change; README and website follow.

Measurements

The suite, with Mutative alone in processes of its own, main at 9f93f16 and this branch in alternating order, three runs each, µs per update:

Scenario Rows Freeze Patches main This PR PR/main
shift-and-update 100 off off 1.56 1.48 0.95
shift-and-update 100 on off 4.82 3.67 0.76
shift-and-update 1,000 off off 2.92 2.24 0.77
shift-and-update 1,000 on off 32.4 20.7 0.64
shift-and-update 10,000 off off 15.9 8.88 0.56
shift-and-update 10,000 on off 301 185 0.62
shift-and-update 10,000 on on 1,614 1,523 0.94
  • The 12 shift-and-update cells. 0.82 of the time of main on geometric mean. With patches, emitting the patches of the moved rows takes most of the time.
  • Everything else. Over all 250 cells of the scenarios that move array elements, the geometric mean was 0.988. No cell other than shift-and-update moved outside the spread of its processes, search-current-shifted included. Seven freeze-on cells of six upstream scenarios differed by 7–21% in those three rounds. Ten more alternating rounds of the six scenarios measured 0.93–1.03 with overlapping ranges.

Recipes that edit several moved rows, measured outside the suite with both production builds, three alternating rounds, µs per update:

Recipe Rows Frozen main This PR
shift(), then 8 edits near the start 1,000 yes 212 27.9
shift(), then 8 edits near the start 10,000 yes 2,050 208
shift(), then 32 edits across the array 10,000 yes 2,328 676
shift(), then 8 edits near the start 10,000 no 139 30.3
shift(), then 32 edits across the array 10,000 no 565 471
unshift(), then 1 edit in the middle 10,000 yes 311 191
reverse(), then 1 edit in the middle 10,000 yes 312 310

A prototype of the same lookup ran four recipes in three engines: one edit after shift(), eight edits after shift(), one edit after splice() and one after reverse(), on 10,000 frozen and unfrozen rows. These are single runs in one page per recipe and build.

Engine This PR / main Why
Chromium 148 0.11–1.00 Same V8 slow path as Node.js
Firefox 150 0.63–1.05 lastIndexOf has no slow path for frozen arrays
WebKit 26.4 0.97–1.07 Frozen arrays are slow to scan in either direction

Correctness

  • Same index as before. When the checked index holds the element, the true last index is at or after it. The forward search finds every later copy, so the result equals that of lastIndexOf and of the map in every case.
  • Differential fuzz. 40,000 random recipes of native moves, reads and edits matched main exactly with the committed build: results, forward and inverse patches, and both replays. The recipes cover repeated objects, frozen and unfrozen bases, and up to three moves.
  • Test suite. The full suite passes, 4,957 tests, and coverage stays at 100%.
  • Other checks. The CI correctness checks of the benchmark harness pass, along with pnpm test:benchmarks, pnpm test:package, pnpm type-check, lint, format and the website build.

Not covered

  • reverse() and several moves. After reverse(), or after several moves of the same element, the lookup still searches from the end. Editing the middle of 10,000 frozen rows after reverse() takes 310 µs, against 188 µs after shift(); the summary lists this under its limits.
  • Safari. Large frozen arrays stay expensive in WebKit for every build: Object.freeze of a 10,000-element array alone takes about 2.4 ms there.
  • Default settings. Without auto-freeze, which the auto-freeze guide recommends for production, the frozen slow path appears only with frozen inputs from other code. The single-edit gains for unfrozen arrays apply to everyone.

Commits

  1. perf: find a moved element's original index from the offset of its move
  2. test: cover moved elements of frozen arrays and lookups from the offset of their move
  3. build: refresh the size baseline and limits for the offset lookup of moved elements
  4. perf: record measurements of the offset lookup of moved elements
  5. docs: refresh the README and website figures that the faster moved element lookup changes

@github-actions

Copy link
Copy Markdown

Coverage after merging perf/moved-element-offset 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 4206e03 into main Oct 11, 2026
10 checks passed
@unadlib
unadlib deleted the perf/moved-element-offset branch October 11, 2026 00:00
@unadlib unadlib mentioned this pull request Oct 11, 2026
96 of 99 tasks
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