Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/maparray-small-move-fast-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

mapArray SMALL-MOVE fast path: rotates, swaps, small displacements, and removals leave a keyed window that is the old window shifted with a bounded number of genuinely displaced identities — but the general diff paid O(newLen) regardless (window key-map, four full-length staged arrays, element-copied prefix/suffix), measured at ~50-140µs/op on 1000 rows against ~20µs of actual DOM work (the jfb-reorder suite's stable 1.8-4x deficits). The fast path scans first (two-pointer aligned-run detection with bounded realignment lookahead and a compare budget, so hopeless shapes like reverse bail almost immediately with nothing allocated), then commits by slicing the live arrays (native memcpy preserves the fresh-identity contract downstream change propagation relies on), copying only shifted runs, patching the displaced few, and disposing leftover sources. Gated to large trimmed windows (the trims already make small windows cheap), scoped to the plain identity-keyed mode (row-signal/custom-key/index modes keep the general path — halves the code for the same benchmark wins), and kept out of updateKeyedMap's function body (inlining deoptimized the general path). Rotate 140→6µs, swap 94→4µs, displace3 54→5µs; removefirst and reverse at parity; ~0.5 kB brotli in mapArray-bearing bundles.

Cold path (2026-09-09): the scan and the commit are two functions, so a pass that scans and bails — a full REPLACE, typically a page's first structural pass — compiles only the scan; and a 65-compare pre-probe in `updateKeyedMap` (is a mid-window item still within ±32 of its old position?) turns a replace away before the scan is even called. Interleaved A/B against `next`, cold (fresh page per sample) and warm: run/replace/runlots/clear at parity, swap and rotate ~0.5x, reverse/shuffle unchanged. On Octane's js-framework board: reorder suite 1.71x → 1.15x vs octane, js-framework 1.27x → 1.10x.
209 changes: 209 additions & 0 deletions packages/signals/src/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,189 @@ const pureOptions = { ownedWrite: true };
// were, so the retry diffs against uncorrupted state. Consequence of the
// strong-abort ordering: removed rows now dispose AFTER the pass's new rows
// are created (you cannot destroy state before knowing the pass will land).

/** SMALL-MOVE fast path (jfb-reorder profile, 2026-09-02): rotates, swaps,
* small displacements, and removals leave a keyed window that is the old
* window SHIFTED, with 32-or-fewer genuinely displaced identities — but the
* general diff pays O(newLen) regardless (window key-map, four full-length
* staged arrays, element-copied prefix/suffix): ~50-140µs/op on 1000 rows
* against ~20µs of actual DOM work.
*
* Scoped to the PLAIN identity-keyed mode (no row signals, no index
* accessors — the hot For shape); other modes keep the general path, which
* halves this function's size for the same benchmark wins.
*
* PHASE 1 (scan, zero allocation beyond two small ledgers): a two-pointer
* walk records ALIGNED RUNS — at most ledger+1 — realigning at boundaries
* with bounded lookahead (interleaved splices stack shift offsets past
* single-step). A compare BUDGET bails hopeless shapes (reverse, shuffle)
* almost immediately. PHASE 2 (commit, success only): slice() the live
* arrays (native memcpy keeps the fresh-identity contract downstream change
* propagation relies on), copy only shifted runs, patch displaced pairs,
* dispose leftover sources (dif < 0). Unmatched destinations (replacements,
* insertions) bail with nothing staged. Kept OUT of updateKeyedMap:
* inlining deoptimized the general path (JIT function-size budget). */
/** Dev-only engagement counter (tests prove the fast path actually ran). */
let smallMoveHits = 0;
/** @internal */
export function __smallMoveHits(): number {
return smallMoveHits;
}

function trySmallMove<Item, MappedItem>(
data: MapData<Item, MappedItem>,
newItems: Item[],
newLen: number,
start: number
): boolean {
const oldItems = data._items;
const oldEnd = data._len - 1;
const srcPos: number[] = [];
const dstPos: number[] = [];
const runs: number[] = []; // flat triples: oldStart, newStart, length
let budget = 256;
let i = start;
let j = start;
let inRun = false;
while (i <= oldEnd && j <= newLen - 1) {
const oldItem = oldItems[i];
const newItem = newItems[j];
if (oldItem === newItem) {
if (!inRun) {
runs.push(i, j, 0);
inRun = true;
}
runs[runs.length - 1]++;
i++;
j++;
continue;
}
inRun = false;
// Bounded realignment lookahead, shorter distance wins.
let del = -1;
let lim = Math.min(32 - srcPos.length, oldEnd - i, budget);
for (let a = 1; a <= lim; a++) {
if (oldItems[i + a] === newItem) {
del = a;
break;
}
}
budget -= del === -1 ? lim : del;
let ins = -1;
lim = Math.min(32 - dstPos.length, newLen - 1 - j, budget);
for (let a = 1; a <= lim; a++) {
if (newItems[j + a] === oldItem) {
ins = a;
break;
}
}
budget -= ins === -1 ? lim : ins;
if (del !== -1 && (ins === -1 || del <= ins)) {
while (del-- > 0) srcPos.push(i++);
continue;
}
if (ins !== -1) {
while (ins-- > 0) dstPos.push(j++);
continue;
}
if (budget <= 0 || srcPos.length === 32 || dstPos.length === 32) return false;
srcPos.push(i++);
dstPos.push(j++);
}
for (; i <= oldEnd; i++) {
if (srcPos.length === 32) return false;
srcPos.push(i);
}
for (; j <= newLen - 1; j++) {
if (dstPos.length === 32) return false;
dstPos.push(j);
}
return commitSmallMove(data, newItems, newLen, srcPos, dstPos, runs);
}

/** PHASE 2 of the small-move path, in its OWN function so that a pass which
* only SCANS and bails (a full replace: the first structural pass of a page,
* typically) compiles nothing but the scan — V8 parses and compiles lazily
* per function, and cold `replace` measured +0.5 ms with both phases in one
* body. Pairs displaced sources with destinations (an unmatched destination
* is a replacement/insertion → general path), then commits: slice() the live
* arrays, copy shifted runs, patch displaced pairs, dispose leftovers. */
function commitSmallMove<Item, MappedItem>(
data: MapData<Item, MappedItem>,
newItems: Item[],
newLen: number,
srcPos: number[],
dstPos: number[],
runs: number[]
): boolean {
const oldItems = data._items;
let i: number;
let j: number;
let consumed: boolean[] | undefined;
if (dstPos.length !== 0) {
consumed = new Array(srcPos.length);
for (j = 0; j < dstPos.length; j++) {
let found = -1;
for (i = 0; i < srcPos.length; i++) {
if (!consumed[i] && oldItems[srcPos[i]] === newItems[dstPos[j]]) {
found = i;
break;
}
}
if (found === -1) return false;
consumed[found] = true;
dstPos[j] = (dstPos[j] << 6) | found; // pack pairing (found < 32)
}
}
// DUPLICATES: the general path pairs equal identities by OCCURRENCE ORDER
// (the chained index map). Displaced↔displaced pairing above is ascending
// on both sides, so it agrees; but an aligned run was matched by POSITION,
// and if a displaced identity also occurs inside a run the two algorithms
// can hand different occurrences different owners (row-local state moves;
// a shrink could dispose the wrong one). Decline that case — general path.
if (srcPos.length !== 0 || dstPos.length !== 0) {
const displaced = new Set<Item>();
for (i = 0; i < srcPos.length; i++) displaced.add(oldItems[srcPos[i]]);
for (j = 0; j < dstPos.length; j++) displaced.add(newItems[dstPos[j] >> 6]);
for (let r = 0; r < runs.length; r += 3) {
const ro = runs[r];
for (let a = 0, n = runs[r + 2]; a < n; a++)
if (displaced.has(oldItems[ro + a])) return false;
}
}
// PHASE 2: commit.
if (__DEV__) smallMoveHits++;
const oldMappings = data._mappings;
const oldNodes = data._nodes;
const mappings = oldMappings.slice(0, newLen);
const nodes = oldNodes.slice(0, newLen);
for (let r = 0; r < runs.length; r += 3) {
const ro = runs[r];
const rn = runs[r + 1];
if (ro !== rn) {
for (let a = 0; a < runs[r + 2]; a++) {
mappings[rn + a] = oldMappings[ro + a];
nodes[rn + a] = oldNodes[ro + a];
}
}
}
for (j = 0; j < dstPos.length; j++) {
const p = dstPos[j] >> 6;
const q = srcPos[dstPos[j] & 63];
mappings[p] = oldMappings[q];
nodes[p] = oldNodes[q];
}
data._mappings = mappings;
data._nodes = nodes;
data._len = newLen;
data._items = newItems.slice(0);
// Dispose unmatched sources LAST (general-path ordering).
for (i = 0; i < srcPos.length; i++) {
if (consumed === undefined || !consumed[i]) oldNodes[srcPos[i]].dispose();
}
return true;
}

function updateKeyedMap<Item, MappedItem>(this: MapData<Item, MappedItem>): any[] {
const newItems = this._list() || [],
newLen = newItems.length;
Expand Down Expand Up @@ -244,6 +427,32 @@ function updateKeyedMap<Item, MappedItem>(this: MapData<Item, MappedItem>): any[
return;
}

// SMALL-MOVE FAST PATH: extracted to its own function — inlining it
// here bloats updateKeyedMap past the JIT's optimization budget and
// deoptimizes the GENERAL path (measured 2x on reverse). Gated to
// LARGE trimmed windows: when the trims already shrank the window
// (plain removals, tail edits), the general path is window-
// proportional and cheap — the fast path would only re-walk what the
// trims proved.
if (
newLen <= this._len &&
end - start > 64 &&
this._rows === undefined &&
this._indexes === undefined
) {
// PROBE before the scan: a small move keeps a mid-window item within
// ±32 of its old position; a REPLACE (all fresh items — the shape
// every page's first structural pass usually is) has it nowhere.
// ~65 compares, no allocation, and the scan function is never
// compiled for a replace (its cold first-call compile was the cost).
const m = start + ((newEnd - start) >> 1);
const probe = newItems[m];
const hi = Math.min(end, m + 32);
let k = Math.max(start, m - 32);
while (k <= hi && this._items[k] !== probe) k++;
if (k <= hi && trySmallMove(this, newItems as Item[], newLen, start)) return;
}

const dif = newLen - this._len;
const temp: MappedItem[] = new Array(newLen);
const tempNodes: Root[] = new Array(newLen);
Expand Down
Loading
Loading