From 3859062c352ce149339fd111f1cd203534f8c0b1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 08:50:43 -0700 Subject: [PATCH 1/3] =?UTF-8?q?perf(signals):=20mapArray=20small-move=20fa?= =?UTF-8?q?st=20path=20=E2=80=94=20keyed=20reorders=20at=20delta=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-op decomposition of the octane board's stable jfb-reorder deficits (rotate 1.9x, displace3-8 2.1-2.7x, removefirst 3-4x vs octane) profiled to updateKeyedMap: the keyed diff pays O(newLen) regardless of delta — window key-map, four full-length staged arrays, element-copied prefix and suffix — ~50-140µs/op on 1000 rows against ~20µs of actual DOM work. The fast path (trySmallMove, own function — inlining it deoptimized the GENERAL path via the JIT function-size budget, same lesson as the uibench reconcile regression): - PHASE 1, scan only: two-pointer walk records aligned RUNS, realigning at boundaries with bounded lookahead (interleaved splices stack shift offsets past single-step). A compare BUDGET makes hopeless shapes (reverse/shuffle) bail almost immediately with zero allocation. - PHASE 2, commit on success: slice() the live arrays (native memcpy keeps the fresh-identity contract downstream change propagation relies on), copy only shifted runs, patch displaced pairs (O(k^2), k<=32), dispose leftover sources (dif<0). Unmatched destinations (replacements, insertions) bail with nothing staged. - Gated to LARGE trimmed windows: the trims already make plain removals window-cheap; the fast path would only re-walk what they proved. Same-load A/B on 1000 rows: rotatef 140->13µs, rotateb 142->14µs, swap 94->8µs, displace3 54->9µs; removefirst 6.5µs and reverse at parity. Row-signal/custom-key modes adopt through the same compare() rule with per-run setSignal parity to the general path's step-3. New suite pins the semantics: identity preservation across rotate/ displace/swap at jfb scale (1000 rows), replacement/mixed/duplicate windows land the general path correctly, custom-key moves match by key and adopt fresh objects, scrambles beyond the bound and length changes stay correct. signals 1497 / web 726 / solid 576 green. Co-authored-by: Cursor --- .changeset/maparray-small-move-fast-path.md | 5 + packages/signals/src/map.ts | 196 ++++++++++++ .../signals/tests/mapArray-smallmove.test.ts | 288 ++++++++++++++++++ 3 files changed, 489 insertions(+) create mode 100644 .changeset/maparray-small-move-fast-path.md create mode 100644 packages/signals/tests/mapArray-smallmove.test.ts diff --git a/.changeset/maparray-small-move-fast-path.md b/.changeset/maparray-small-move-fast-path.md new file mode 100644 index 000000000..e3b498618 --- /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) and kept out of updateKeyedMap's function body (inlining deoptimized the general path). Rotate 140→13µs, swap 94→8µs, displace3 54→9µs; removefirst and reverse at parity. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 255489fb0..8e98a5b9b 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -114,6 +114,188 @@ 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 window that is the old window + * SHIFTED, with 32-or-fewer genuinely displaced identities. The general + * path pays O(newLen) regardless — a window key-map plus four full-length + * staged arrays and element-copied prefix/suffix — measured ~50µs/op on + * 1000 rows against ~20µs of actual DOM work. + * + * PHASE 1 (scan, zero allocation beyond two small ledgers): a two-pointer + * walk records ALIGNED RUNS [oldStart, newStart, length] — at most + * ledger+1 of them — realigning at boundaries with bounded lookahead + * (interleaved splices stack shift offsets, so a boundary's distance can + * exceed one). A compare BUDGET caps total scan work so hopeless shapes + * (reverse, shuffle) bail 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 the runs whose offset moved, patch the displaced few, + * dispose leftover sources (dif < 0). Insertions or any unmatched + * destination (replacements) return false with nothing staged. + * Kept OUT of updateKeyedMap on purpose: 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 oldRows = data._rows; + const keyFn = data._key; + 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 runOld = -1; + let runNew = -1; + let runLen = 0; + while (i <= oldEnd && j <= newLen - 1) { + const oldItem = oldItems[i]; + const newItem = newItems[j]; + if (oldItem === newItem || (oldRows !== undefined && compare(keyFn, oldItem, newItem))) { + if (runLen === 0) { + runOld = i; + runNew = j; + } + runLen++; + i++; + j++; + continue; + } + if (runLen !== 0) { + runs.push(runOld, runNew, runLen); + runLen = 0; + } + // Bounded realignment lookahead, shorter distance wins. + let del = -1; + let ins = -1; + const delLimit = Math.min(32 - srcPos.length, oldEnd - i, budget); + for (let a = 1; a <= delLimit; a++) { + const cand = oldItems[i + a]; + if (cand === newItem || (oldRows !== undefined && compare(keyFn, cand, newItem))) { + del = a; + break; + } + } + const insLimit = Math.min(32 - dstPos.length, newLen - 1 - j, budget); + for (let a = 1; a <= insLimit; a++) { + const cand = newItems[j + a]; + if (oldItem === cand || (oldRows !== undefined && compare(keyFn, oldItem, cand))) { + ins = a; + break; + } + } + budget -= (del === -1 ? delLimit : del) + (ins === -1 ? insLimit : ins); + if (budget <= 0 && del === -1 && ins === -1) return false; + if (del !== -1 && (ins === -1 || del <= ins)) { + for (let a = 0; a < del; a++) srcPos.push(i + a); + i += del; + continue; + } + if (ins !== -1) { + for (let a = 0; a < ins; a++) dstPos.push(j + a); + j += ins; + continue; + } + if (srcPos.length === 32 || dstPos.length === 32) return false; + srcPos.push(i); + dstPos.push(j); + i++; + j++; + } + if (runLen !== 0) runs.push(runOld, runNew, runLen); + 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 every destination with a displaced source (unmatched destination = + // replacement/insertion → general path). Leftover sources dispose. + let consumed: boolean[] | undefined; + if (dstPos.length !== 0) { + consumed = new Array(srcPos.length); + for (j = 0; j < dstPos.length; j++) { + const newItem = newItems[dstPos[j]]; + let found = -1; + for (i = 0; i < srcPos.length; i++) { + if ( + !consumed[i] && + (oldItems[srcPos[i]] === newItem || + (oldRows !== undefined && compare(keyFn, oldItems[srcPos[i]], newItem))) + ) { + 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 oldIndexes = data._indexes; + const mappings = oldMappings.slice(0, newLen); + const nodes = oldNodes.slice(0, newLen); + const nextRows = oldRows ? oldRows.slice(0, newLen) : undefined; + const nextIndexes = oldIndexes ? oldIndexes.slice(0, newLen) : undefined; + for (let r = 0; r < runs.length; r += 3) { + const ro = runs[r]; + const rn = runs[r + 1]; + const rl = runs[r + 2]; + if (ro !== rn) { + for (let a = 0; a < rl; a++) { + mappings[rn + a] = oldMappings[ro + a]; + nodes[rn + a] = oldNodes[ro + a]; + if (nextRows) nextRows[rn + a] = oldRows![ro + a]; + if (nextIndexes) { + nextIndexes[rn + a] = oldIndexes![ro + a]; + setSignal(nextIndexes[rn + a], rn + a); + } + } + } + if (nextRows) { + // Row-signal modes adopt the new objects across the run even in + // place (key-matched fresh objects; equality-gated). + for (let a = 0; a < rl; a++) setSignal(nextRows[rn + a], newItems[rn + 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]; + if (nextRows) { + nextRows[p] = oldRows![q]; + setSignal(nextRows[p], newItems[p]); + } + if (nextIndexes) { + nextIndexes[p] = oldIndexes![q]; + setSignal(nextIndexes[p], p); + } + } + data._mappings = mappings; + data._nodes = nodes; + nextRows && (data._rows = nextRows); + nextIndexes && (data._indexes = nextIndexes); + 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 +418,20 @@ 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 && + 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)); + }); +}); From 9981d7a34249961af989f8534567bbfb94ab6491 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 09:20:29 -0700 Subject: [PATCH 2/3] =?UTF-8?q?perf(signals):=20lean=20the=20small-move=20?= =?UTF-8?q?fast=20path=20=E2=80=94=20identity-keyed=20mode=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Size golf: source-level dedup (shared eq/scan/place helpers) bought almost nothing — brotli already compresses repetition — so the real cut is UNIQUE LOGIC: the fast path now serves only the plain identity-keyed mode (no row signals, no index accessors — the hot For shape); other modes keep the general path. That deletes the compare/keyFn plumbing and half of phase 2: added cost drops 0.62 -> ~0.45 kB brotli in mapArray- bearing bundles, zero elsewhere. The pure-identity hot loop also got FASTER: rotate 10.5 -> 5.8µs, swap 6.2 -> 4.0µs, displace3 7.0 -> 4.6µs, removefirst 3.4µs, reverse at pristine parity. Also removes a latent double-push in the del branch (leftover half-edit; engaged only at deep lookahead distances). All 32 map suites green; full signals 1497 green. Co-authored-by: Cursor --- .changeset/maparray-small-move-fast-path.md | 2 +- packages/signals/src/map.ts | 130 +++++++------------- 2 files changed, 46 insertions(+), 86 deletions(-) diff --git a/.changeset/maparray-small-move-fast-path.md b/.changeset/maparray-small-move-fast-path.md index e3b498618..82697eacc 100644 --- a/.changeset/maparray-small-move-fast-path.md +++ b/.changeset/maparray-small-move-fast-path.md @@ -2,4 +2,4 @@ "@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) and kept out of updateKeyedMap's function body (inlining deoptimized the general path). Rotate 140→13µs, swap 94→8µs, displace3 54→9µs; removefirst and reverse at parity. +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 8e98a5b9b..0695025dd 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -116,25 +116,26 @@ const pureOptions = { ownedWrite: true }; // 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 window that is the old window - * SHIFTED, with 32-or-fewer genuinely displaced identities. The general - * path pays O(newLen) regardless — a window key-map plus four full-length - * staged arrays and element-copied prefix/suffix — measured ~50µs/op on - * 1000 rows against ~20µs of actual DOM work. + * 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 [oldStart, newStart, length] — at most - * ledger+1 of them — realigning at boundaries with bounded lookahead - * (interleaved splices stack shift offsets, so a boundary's distance can - * exceed one). A compare BUDGET caps total scan work so hopeless shapes - * (reverse, shuffle) bail 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 the runs whose offset moved, patch the displaced few, - * dispose leftover sources (dif < 0). Insertions or any unmatched - * destination (replacements) return false with nothing staged. - * Kept OUT of updateKeyedMap on purpose: inlining deoptimized the general - * path (JIT function-size budget). */ + * 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[], @@ -142,8 +143,6 @@ function trySmallMove( start: number ): boolean { const oldItems = data._items; - const oldRows = data._rows; - const keyFn = data._key; const oldEnd = data._len - 1; const srcPos: number[] = []; const dstPos: number[] = []; @@ -151,64 +150,52 @@ function trySmallMove( let budget = 256; let i = start; let j = start; - let runOld = -1; - let runNew = -1; - let runLen = 0; + let inRun = false; while (i <= oldEnd && j <= newLen - 1) { const oldItem = oldItems[i]; const newItem = newItems[j]; - if (oldItem === newItem || (oldRows !== undefined && compare(keyFn, oldItem, newItem))) { - if (runLen === 0) { - runOld = i; - runNew = j; + if (oldItem === newItem) { + if (!inRun) { + runs.push(i, j, 0); + inRun = true; } - runLen++; + runs[runs.length - 1]++; i++; j++; continue; } - if (runLen !== 0) { - runs.push(runOld, runNew, runLen); - runLen = 0; - } + inRun = false; // Bounded realignment lookahead, shorter distance wins. let del = -1; - let ins = -1; - const delLimit = Math.min(32 - srcPos.length, oldEnd - i, budget); - for (let a = 1; a <= delLimit; a++) { - const cand = oldItems[i + a]; - if (cand === newItem || (oldRows !== undefined && compare(keyFn, cand, newItem))) { + 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; } } - const insLimit = Math.min(32 - dstPos.length, newLen - 1 - j, budget); - for (let a = 1; a <= insLimit; a++) { - const cand = newItems[j + a]; - if (oldItem === cand || (oldRows !== undefined && compare(keyFn, oldItem, cand))) { + 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 -= (del === -1 ? delLimit : del) + (ins === -1 ? insLimit : ins); - if (budget <= 0 && del === -1 && ins === -1) return false; + budget -= ins === -1 ? lim : ins; if (del !== -1 && (ins === -1 || del <= ins)) { - for (let a = 0; a < del; a++) srcPos.push(i + a); - i += del; + while (del-- > 0) srcPos.push(i++); continue; } if (ins !== -1) { - for (let a = 0; a < ins; a++) dstPos.push(j + a); - j += ins; + while (ins-- > 0) dstPos.push(j++); continue; } - if (srcPos.length === 32 || dstPos.length === 32) return false; - srcPos.push(i); - dstPos.push(j); - i++; - j++; + if (budget <= 0 || srcPos.length === 32 || dstPos.length === 32) return false; + srcPos.push(i++); + dstPos.push(j++); } - if (runLen !== 0) runs.push(runOld, runNew, runLen); for (; i <= oldEnd; i++) { if (srcPos.length === 32) return false; srcPos.push(i); @@ -217,20 +204,15 @@ function trySmallMove( if (dstPos.length === 32) return false; dstPos.push(j); } - // Pair every destination with a displaced source (unmatched destination = - // replacement/insertion → general path). Leftover sources dispose. + // 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++) { - const newItem = newItems[dstPos[j]]; let found = -1; for (i = 0; i < srcPos.length; i++) { - if ( - !consumed[i] && - (oldItems[srcPos[i]] === newItem || - (oldRows !== undefined && compare(keyFn, oldItems[srcPos[i]], newItem))) - ) { + if (!consumed[i] && oldItems[srcPos[i]] === newItems[dstPos[j]]) { found = i; break; } @@ -243,50 +225,26 @@ function trySmallMove( // PHASE 2: commit. const oldMappings = data._mappings; const oldNodes = data._nodes; - const oldIndexes = data._indexes; const mappings = oldMappings.slice(0, newLen); const nodes = oldNodes.slice(0, newLen); - const nextRows = oldRows ? oldRows.slice(0, newLen) : undefined; - const nextIndexes = oldIndexes ? oldIndexes.slice(0, newLen) : undefined; for (let r = 0; r < runs.length; r += 3) { const ro = runs[r]; const rn = runs[r + 1]; - const rl = runs[r + 2]; if (ro !== rn) { - for (let a = 0; a < rl; a++) { + for (let a = 0; a < runs[r + 2]; a++) { mappings[rn + a] = oldMappings[ro + a]; nodes[rn + a] = oldNodes[ro + a]; - if (nextRows) nextRows[rn + a] = oldRows![ro + a]; - if (nextIndexes) { - nextIndexes[rn + a] = oldIndexes![ro + a]; - setSignal(nextIndexes[rn + a], rn + a); - } } } - if (nextRows) { - // Row-signal modes adopt the new objects across the run even in - // place (key-matched fresh objects; equality-gated). - for (let a = 0; a < rl; a++) setSignal(nextRows[rn + a], newItems[rn + 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]; - if (nextRows) { - nextRows[p] = oldRows![q]; - setSignal(nextRows[p], newItems[p]); - } - if (nextIndexes) { - nextIndexes[p] = oldIndexes![q]; - setSignal(nextIndexes[p], p); - } } data._mappings = mappings; data._nodes = nodes; - nextRows && (data._rows = nextRows); - nextIndexes && (data._indexes = nextIndexes); data._len = newLen; data._items = newItems.slice(0); // Dispose unmatched sources LAST (general-path ordering). @@ -428,6 +386,8 @@ function updateKeyedMap(this: MapData): any[ if ( newLen <= this._len && end - start > 64 && + this._rows === undefined && + this._indexes === undefined && trySmallMove(this, newItems as Item[], newLen, start) ) return; From 21152c5d1033f437eae055ca4ee01f5b22b35b43 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 11:42:12 -0700 Subject: [PATCH 3/3] test(size): ratchet For-bearing budgets for the small-move fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trySmallMove rides mapArray's keyed path, so every For-bearing bundle pays 430-490 B brotli (A/B-verified against the branch base; non-For bundles pay zero). Buys delta-cost keyed reorders — jfb swap1k −6.5% with the main suite clean. Co-authored-by: Cursor --- scripts/size/.size-limit.js | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) 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 }, {