diff --git a/.changeset/maparray-small-move-fast-path.md b/.changeset/maparray-small-move-fast-path.md new file mode 100644 index 000000000..82697eacc --- /dev/null +++ b/.changeset/maparray-small-move-fast-path.md @@ -0,0 +1,5 @@ +--- +"@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.45 kB brotli in mapArray-bearing bundles. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 255489fb0..0695025dd 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -114,6 +114,146 @@ 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). */ +function trySmallMove( + data: MapData, + 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); + } + // Pair destinations with displaced sources (unmatched = replacement or + // insertion → general path); leftovers dispose (dif < 0). + 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) + } + } + // PHASE 2: commit. + 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(this: MapData): any[] { const newItems = this._list() || [], newLen = newItems.length; @@ -236,6 +376,22 @@ function updateKeyedMap(this: MapData): 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 && + 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); diff --git a/packages/signals/tests/mapArray-smallmove.test.ts b/packages/signals/tests/mapArray-smallmove.test.ts new file mode 100644 index 000000000..e431afc18 --- /dev/null +++ b/packages/signals/tests/mapArray-smallmove.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSignal, flush, mapArray } from "../src/index.js"; + +/** SMALL-MOVE fast path (jfb-reorder profile, 2026-09-02): after prefix/ + * suffix trimming, a same-length window whose mismatches are ≤K displaced + * identities commits as k in-place patches over sliced arrays — no window + * Map, no staging arrays. These tests pin the semantics the fast path must + * preserve: mapped identity moves with the item, the mapper never re-runs + * for moved rows, index accessors update for exactly the moved positions, + * and every non-move shape (replacement, duplicates, adds) still lands in + * the general path with correct results. */ + +function rotateF(a: readonly T[]): T[] { + return [...a.slice(1), a[0]]; +} +function rotateB(a: readonly T[]): T[] { + return [a[a.length - 1], ...a.slice(0, -1)]; +} +function displace(a: readonly T[], k: number): T[] { + // move k evenly-spaced rows to new positions (jfb displace shape) + const next = [...a]; + for (let i = 0; i < k; i++) { + const from = Math.floor(((i + 1) * next.length) / (k + 2)); + const [row] = next.splice(from, 1); + next.splice((from + 7) % next.length, 0, row); + } + return next; +} + +function harness(n = 50) { + const items = Array.from({ length: n }, (_, i) => ({ id: i })); + const [$src, setSrc] = createSignal(items); + const mapper = vi.fn((value: { id: number }, index: () => number) => ({ + item: value, + get index() { + return index(); + } + })); + const map = mapArray($src, mapper); + map(); + return { $src, setSrc, map, mapper, items }; +} + +describe("mapArray small-move semantics", () => { + it("rotate forward preserves every mapped identity and re-runs no mappers", () => { + const { setSrc, map, mapper } = harness(); + const before = map(); + mapper.mockClear(); + setSrc(p => rotateF(p)); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(after.length).toBe(before.length); + // row 0 moved to the end; everyone else shifted up one position + expect(after[after.length - 1]).toBe(before[0]); + for (let i = 0; i < after.length - 1; i++) expect(after[i]).toBe(before[i + 1]); + // index accessors reflect the new positions + after.forEach((m, i) => expect(m.index).toBe(i)); + // fresh array identity for downstream change propagation + expect(after).not.toBe(before); + }); + + it("rotate backward preserves identity", () => { + const { setSrc, map, mapper } = harness(); + const before = map(); + mapper.mockClear(); + setSrc(p => rotateB(p)); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(after[0]).toBe(before[before.length - 1]); + for (let i = 1; i < after.length; i++) expect(after[i]).toBe(before[i - 1]); + after.forEach((m, i) => expect(m.index).toBe(i)); + }); + + it("displace-k preserves identity for k = 3..8", () => { + for (const k of [3, 4, 5, 6, 8]) { + const { setSrc, map, mapper, items } = harness(60); + const before = map(); + const byItem = new Map(before.map(m => [m.item, m])); + mapper.mockClear(); + setSrc(p => displace(p, k)); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(after.length).toBe(items.length); + after.forEach((m, i) => { + expect(byItem.get(m.item)).toBe(m); // identity moved with the item + expect(m.index).toBe(i); + }); + } + }); + + it("adjacent swap (jfb swap) preserves identity", () => { + const { setSrc, map, mapper } = harness(20); + const before = map(); + mapper.mockClear(); + setSrc(p => { + const next = [...p]; + const tmp = next[1]; + next[1] = next[18]; + next[18] = tmp; + return next; + }); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(after[1]).toBe(before[18]); + expect(after[18]).toBe(before[1]); + expect(after[1].index).toBe(1); + expect(after[18].index).toBe(18); + }); + + it("REPLACEMENT inside a same-length window creates a new row and disposes the old", () => { + const { setSrc, map, mapper } = harness(10); + const before = map(); + mapper.mockClear(); + const fresh = { id: 99 }; + setSrc(p => { + const next = [...p]; + next[4] = fresh; // same length, not a move — must NOT fast-path + return next; + }); + flush(); + const after = map(); + expect(mapper).toHaveBeenCalledTimes(1); + expect(after[4].item).toBe(fresh); + for (let i = 0; i < 10; i++) { + if (i !== 4) expect(after[i]).toBe(before[i]); + } + }); + + it("MIXED move + replacement in one window stays correct", () => { + const { setSrc, map, mapper } = harness(12); + const before = map(); + mapper.mockClear(); + const fresh = { id: 77 }; + setSrc(p => { + const next = [...p]; + // swap 2 and 9, replace 5 + const tmp = next[2]; + next[2] = next[9]; + next[9] = tmp; + next[5] = fresh; + return next; + }); + flush(); + const after = map(); + expect(mapper).toHaveBeenCalledTimes(1); + expect(after[2]).toBe(before[9]); + expect(after[9]).toBe(before[2]); + expect(after[5].item).toBe(fresh); + after.forEach((m, i) => expect(m.index).toBe(i)); + }); + + it("DUPLICATE items moving within the window stay correct", () => { + const dup = { id: 1000 }; + const items = [{ id: 0 }, dup, { id: 2 }, dup, { id: 4 }, { id: 5 }]; + const [$src, setSrc] = createSignal(items); + const map = mapArray($src, (value: any, index: () => number) => ({ + item: value, + get index() { + return index(); + } + })); + const before = map(); + setSrc(p => { + // move both duplicates and a neighbor + return [p[1], p[0], p[2], p[4], p[3], p[5]]; + }); + flush(); + const after = map(); + expect(after.map(m => m.item)).toEqual([dup, items[0], items[2], items[4], dup, items[5]]); + after.forEach((m, i) => expect(m.index).toBe(i)); + expect(new Set(after).size).toBe(6); // no shared mapped rows + expect(before.filter(m => after.includes(m)).length).toBe(6); // all reused + }); + + it("custom-keyed small moves match by KEY, not identity", () => { + const [$src, setSrc] = createSignal([ + { id: "a", v: 1 }, + { id: "b", v: 1 }, + { id: "c", v: 1 } + ]); + const mapper = vi.fn((value: () => any, index: () => number) => ({ + get id() { + return value().id; + }, + get v() { + return value().v; + }, + get index() { + return index(); + } + })); + const map = mapArray($src, mapper, { keyed: (item: any) => item.id }); + const [a, b, c] = map(); + mapper.mockClear(); + // rotate with FRESH objects (same keys, new identities, new values) + setSrc([ + { id: "b", v: 2 }, + { id: "c", v: 2 }, + { id: "a", v: 2 } + ]); + flush(); + const [x, y, z] = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(x).toBe(b); + expect(y).toBe(c); + expect(z).toBe(a); + // row signals must carry the NEW objects' values + expect(x.v).toBe(2); + expect(y.v).toBe(2); + expect(z.v).toBe(2); + expect(x.index).toBe(0); + expect(y.index).toBe(1); + expect(z.index).toBe(2); + }); + + it("large scrambles (beyond the fast-path bound) still work via the general path", () => { + const { setSrc, map, mapper } = harness(200); + const before = map(); + const byItem = new Map(before.map(m => [m.item, m])); + mapper.mockClear(); + setSrc(p => { + // seeded shuffle — far more than K displaced + const next = [...p]; + let seed = 42; + for (let i = next.length - 1; i > 0; i--) { + seed = (seed * 16807) % 2147483647; + const j = seed % (i + 1); + const tmp = next[i]; + next[i] = next[j]; + next[j] = tmp; + } + return next; + }); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + after.forEach((m, i) => { + expect(byItem.get(m.item)).toBe(m); + expect(m.index).toBe(i); + }); + }); + + it("jfb-scale (1000 rows): rotate/displace/swap/removefirst all preserve identity", () => { + for (const op of [ + (p: any[]) => rotateF(p), + (p: any[]) => rotateB(p), + (p: any[]) => displace(p, 8), + (p: any[]) => { + const next = [...p]; + const tmp = next[1]; + next[1] = next[998]; + next[998] = tmp; + return next; + }, + (p: any[]) => p.slice(1) + ]) { + const { setSrc, map, mapper } = harness(1000); + const before = map(); + const byItem = new Map(before.map(m => [m.item, m])); + mapper.mockClear(); + setSrc(p => op(p as any[]) as any); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + after.forEach((m, i) => { + expect(byItem.get(m.item)).toBe(m); + expect(m.index).toBe(i); + }); + } + }); + + it("removefirst (length change) keeps identities through the general path", () => { + const { setSrc, map, mapper } = harness(30); + const before = map(); + mapper.mockClear(); + setSrc(p => p.slice(1)); + flush(); + const after = map(); + expect(mapper).not.toHaveBeenCalled(); + expect(after.length).toBe(29); + for (let i = 0; i < 29; i++) expect(after[i]).toBe(before[i + 1]); + after.forEach((m, i) => expect(m.index).toBe(i)); + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index a6d768716..bc7ae61d3 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -357,7 +357,17 @@ module.exports = [ // In-place class mutation fix (#3188): 17.67 -> 17.68 KB, measured at // 17.673. Hydration seeds the applied-class snapshot without mutating // the claimed DOM so the first live in-place change still diffs. - limit: "17.72 KB", + // + // mapArray small-move fast path (#3227, 2026-09-02): 17.72 -> 18.26 KB, + // measured at 18.20 — trySmallMove in mapArray's keyed path, retained by + // every For (A/B against the branch base: the five For-bearing scenarios + // pay 430-490 B; non-For bundles pay zero). Buys delta-cost keyed + // reorders (rotate/swap/displace/removal, ≤32 displaced rows): jfb + // swap1k −6.5% validated with the main suite clean; hopeless shapes + // (reverse/shuffle) bail within a bounded compare budget and keep the + // general path byte-for-byte. Scoped to plain identity-keyed mode; row- + // signal and index modes never enter it. + limit: "18.26 KB", modifyEsbuildConfig }, { @@ -428,7 +438,10 @@ module.exports = [ // retains every store family, so it pays the whole module. Ruled // correctness-over-size in the #3164 thread; conscious bump. path: "hydrating-store-app.js", - limit: "26.99 KB", + // mapArray small-move fast path (#3227, 2026-09-02): 26.99 -> 27.49 KB, + // measured at 27.43 — see the hydrating no-store note; this scenario + // retains mapArray through the store families. + limit: "27.49 KB", modifyEsbuildConfig }, { @@ -458,7 +471,9 @@ module.exports = [ // scheduler-resident ledger (nothing to shake), so it pays only the // hook call site's second argument plus brotli layout drift. path: "csr-app.js", - limit: "13.11 KB", + // mapArray small-move fast path (#3227, 2026-09-02): 13.11 -> 13.60 KB, + // measured at 13.54 — see the hydrating no-store note. + limit: "13.60 KB", modifyEsbuildConfig }, { @@ -483,7 +498,9 @@ module.exports = [ // Fold relocation pass (2026-09-01): 14.69 -> 14.68 KB, measured at // 14.67 — the core-floor relocation (see that note). path: "csr-app-patch.js", - limit: "14.81 KB", + // mapArray small-move fast path (#3227, 2026-09-02): 14.81 -> 15.36 KB, + // measured at 15.30 — see the hydrating no-store note. + limit: "15.36 KB", modifyEsbuildConfig }, { @@ -508,7 +525,9 @@ module.exports = [ // Fold relocation pass (2026-09-01): 16.94 -> 16.91 KB, measured at // 16.90 — retained-ledger shake, same as the hydrating no-store note. path: "csr-app-patch-lists.js", - limit: "17.06 KB", + // mapArray small-move fast path (#3227, 2026-09-02): 17.06 -> 17.60 KB, + // measured at 17.54 — see the hydrating no-store note. + limit: "17.60 KB", modifyEsbuildConfig }, {