From 33700ef53831f188cf297caca6d060c337a8d03d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 08:50:43 -0700 Subject: [PATCH 1/4] =?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 f9fd04f2c..60285e28c 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -118,6 +118,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; @@ -244,6 +426,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 27eb8aa3a9daff8ffacdfb119bba2bd7c09dda5f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 09:20:29 -0700 Subject: [PATCH 2/4] =?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 60285e28c..10292787d 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -120,25 +120,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[], @@ -146,8 +147,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[] = []; @@ -155,64 +154,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); @@ -221,20 +208,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; } @@ -247,50 +229,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). @@ -436,6 +394,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 71d2b4f3646513267daa2e4da5a422d462ff706b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 9 Sep 2026 08:56:15 -0700 Subject: [PATCH 3/4] =?UTF-8?q?perf(signals):=20small-move=20fast=20path?= =?UTF-8?q?=20=E2=80=94=20cold=20replace=20at=20parity=20(scan/commit=20sp?= =?UTF-8?q?lit=20+=20pre-probe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first structural pass of a page is usually a full replace, and it was paying ~0.5 ms cold on the fast path: V8 lazily compiled the whole trySmallMove body on that first call only to have the scan bail. Two changes, measured by interleaved A/B against next (fresh page per sample for cold, one page for warm; 70 rounds on cold replace): - The commit phase is its own function (`commitSmallMove`): a pass that scans and bails compiles the scan alone. - A pre-probe in updateKeyedMap before the scan: a small move keeps a mid-window item within ±32 of its old position; a replace has it nowhere. ~65 compares, no allocation, and the scan is never compiled for a replace. Cold: run 0.99x, replace 0.95x, runlots 1.00x, clear 1.00x. Warm: creation and clear at parity, swap 0.50x, rotatef/rotateb 0.48x/0.53x, reverse/ shuffle/removefirst unchanged. Octane js-framework board: reorder 1.71x → 1.15x vs octane, js-framework 1.27x → 1.10x. Size: +466–563 B brotli on the five For-bearing scenarios (ratcheted with notes); non-For bundles unchanged. Co-authored-by: Cursor --- .changeset/maparray-small-move-fast-path.md | 4 +- packages/signals/src/map.ts | 41 ++++++++++++++++++--- scripts/size/.size-limit.js | 35 ++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/.changeset/maparray-small-move-fast-path.md b/.changeset/maparray-small-move-fast-path.md index 82697eacc..4bfe44514 100644 --- a/.changeset/maparray-small-move-fast-path.md +++ b/.changeset/maparray-small-move-fast-path.md @@ -2,4 +2,6 @@ "@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. +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. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 10292787d..88f6f2d5f 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -208,8 +208,27 @@ function trySmallMove( if (dstPos.length === 32) return false; dstPos.push(j); } - // Pair destinations with displaced sources (unmatched = replacement or - // insertion → general path); leftovers dispose (dif < 0). + 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( + data: MapData, + 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); @@ -395,10 +414,20 @@ function updateKeyedMap(this: MapData): any[ newLen <= this._len && end - start > 64 && this._rows === undefined && - this._indexes === undefined && - trySmallMove(this, newItems as Item[], newLen, start) - ) - return; + 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); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 934789f05..abd252d6f 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -505,6 +505,13 @@ module.exports = [ // 17.58. The channel is deleted from next — regions own value delivery, // the unified-For design owns structure — reclaiming the insert $ll seam and core emission bytes. limit: "17.61 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 17.61 KB -> + // 18.15 KB, measured at 18.11. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "18.15 KB", modifyEsbuildConfig }, { @@ -608,6 +615,13 @@ module.exports = [ // came in UNDER its pre-fix size — see its note). The usual +4-7 B // Linux delta leaves ~20 B headroom. limit: "26.45 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 26.45 KB -> + // 27.05 KB, measured at 27.01. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "27.05 KB", modifyEsbuildConfig }, { @@ -654,6 +668,13 @@ module.exports = [ // those bytes land here, and this scenario was not ratcheted with the // hydrating ones. Ratchet on next so the branch is green again. limit: "13.01 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 13.01 KB -> + // 13.53 KB, measured at 13.49. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "13.53 KB", modifyEsbuildConfig }, { @@ -672,6 +693,13 @@ module.exports = [ // `@solidjs/signals/attribution` and is charged by the scenario below. path: "csr-app.js", limit: "14.30 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 14.30 KB -> + // 14.80 KB, measured at 14.77. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "14.80 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -686,6 +714,13 @@ module.exports = [ // in documentation/plans/observe-tier-plan.md. path: "csr-app-attribution.js", limit: "24.00 KB", + // + // mapArray SMALL-MOVE fast path (#3227, rebased 2026-09-09): 24.00 KB -> + // 24.50 KB, measured at 24.47. Scan + commit as two functions (a + // replace compiles only the scan) plus a 65-compare pre-probe in + // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold + // and warm: creation/clear at parity, swap and rotate ~0.5x. + limit: "24.50 KB", modifyEsbuildConfig: observeEsbuildConfig }, { From eeb77c36cca9f7dce61e201328a77eb81c982e2e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 9 Sep 2026 09:27:10 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(signals):=20small-move=20fast=20path=20?= =?UTF-8?q?=E2=80=94=20decline=20ambiguous=20duplicates;=20tests=20that=20?= =?UTF-8?q?prove=20engagement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External audit (2026-09-09) on #3227, two blockers: 1. DUPLICATE IDENTITIES could receive different owners than mapArray's general path. The general path pairs equal identities by occurrence order (the chained index map). The fast path's displaced↔displaced pairing is ascending on both sides and agrees — but an aligned run is matched by POSITION, so a displaced identity that also occurs inside a run could be paired with a different occurrence (old [A,B,A,C] → new [B,A,C,A]: the two A rows swapped owners; a shrink could dispose the wrong one). Now declined: a Set of the ≤64 displaced identities, one `has` per run position, on the success path only. Rotate/swap unchanged (0.41x/0.54x vs next). 2. NONE of the previous tests entered the fast path: their arity-2 mapper created index signals, which the path requires absent, and most windows were under the >64 gate. Rewritten: an arity-1 mapper, a dev-only engagement counter (`__smallMoveHits`), and an ORACLE — the same source driven through the general path via an arity-2 mapper — whose mapped OWNER order (creation sequence) and disposal count must match. Covers the 66/65 window boundary, the 32/33 displacement bound, shrink, growth exclusion, replacement and full-replace bailouts, the audit's duplicate shape (declines), displaced-only duplicates (agree), duplicate removal, and 200 rounds of random duplicate-heavy reorders. The duplicate tests fail with the decline removed. Size: +54 B CSR (decline), +110 B on the observe+attribution tier (the dev-only counter it keeps); ratcheted with notes. Co-authored-by: Cursor --- packages/signals/src/map.ts | 24 + .../signals/tests/mapArray-smallmove.test.ts | 500 +++++++++--------- scripts/size/.size-limit.js | 10 + 3 files changed, 291 insertions(+), 243 deletions(-) diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 88f6f2d5f..8b02db449 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -140,6 +140,13 @@ const pureOptions = { ownedWrite: true }; * 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( data: MapData, newItems: Item[], @@ -245,7 +252,24 @@ function commitSmallMove( 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(); + 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); diff --git a/packages/signals/tests/mapArray-smallmove.test.ts b/packages/signals/tests/mapArray-smallmove.test.ts index e431afc18..7d11f5a08 100644 --- a/packages/signals/tests/mapArray-smallmove.test.ts +++ b/packages/signals/tests/mapArray-smallmove.test.ts @@ -1,14 +1,20 @@ -import { describe, expect, it, vi } from "vitest"; -import { createSignal, flush, mapArray } from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { createRoot, createSignal, flush, mapArray, onCleanup } from "../src/index.js"; +import { __smallMoveHits } from "../src/map.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. */ +/** SMALL-MOVE fast path: after prefix/suffix trimming, a same-or-shorter + * window whose mismatches are ≤32 displaced identities commits as in-place + * patches over sliced arrays — no window Map, no staging arrays. + * + * Every test here PROVES which path ran (`__smallMoveHits`, dev-only) and + * pins semantics against an ORACLE: the same source sequence driven through + * the general path (an arity-2 mapper creates index signals, which the fast + * path declines). Mapped values carry a creation sequence number, so "same + * mapped array" means the same OWNERS ended up at the same positions — the + * duplicate-pairing contract — and disposal order is compared too. */ + +type Item = { id: number }; +type Mapped = { item: Item; seq: number }; function rotateF(a: readonly T[]): T[] { return [...a.slice(1), a[0]]; @@ -16,8 +22,8 @@ function rotateF(a: readonly T[]): T[] { function rotateB(a: readonly T[]): T[] { return [a[a.length - 1], ...a.slice(0, -1)]; } +/** Move `k` evenly spaced rows to new positions (the jfb displace shape). */ 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)); @@ -26,263 +32,271 @@ function displace(a: readonly T[], k: number): T[] { } return next; } +const ids = (m: Mapped[]) => m.map(x => x.item.id); +const seqs = (m: Mapped[]) => m.map(x => x.seq); -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 }; +/** Drive one source through BOTH paths: `fast` (arity-1 mapper, eligible) + * and `oracle` (arity-2 mapper → index signals → general path always). */ +function pair(initial: Item[]) { + let seq = 0; + const disposed: { fast: number[]; oracle: number[] } = { fast: [], oracle: [] }; + const mk = + (side: "fast" | "oracle") => + (item: Item): Mapped => { + const m = { item, seq: seq++ }; + onCleanup(() => disposed[side].push(m.seq)); + return m; + }; + const [$fast, setFast] = createSignal(initial); + const [$oracle, setOracle] = createSignal(initial); + let fast!: () => Mapped[]; + let oracle!: () => Mapped[]; + const dispose = createRoot(d => { + fast = mapArray($fast, mk("fast")); + const o = mk("oracle"); + oracle = mapArray($oracle, (item: Item, _index: () => number) => o(item)); + fast(); + oracle(); + return d; + }); + const set = (next: Item[]) => { + setFast(next); + setOracle(next); + flush(); + }; + /** After a set: mapped ids equal on both sides (correctness), and the + * ORDER of owners (seq) equals the oracle's (duplicate pairing). */ + const agree = () => { + expect(ids(fast())).toEqual(ids(oracle())); + // Owners created on the two sides interleave in seq; compare RELATIVE order. + const rank = (m: Mapped[]) => { + const sorted = [...m].map(x => x.seq).sort((a, b) => a - b); + return m.map(x => sorted.indexOf(x.seq)); + }; + expect(rank(fast())).toEqual(rank(oracle())); + expect(disposed.fast.length).toBe(disposed.oracle.length); + }; + return { set, fast, oracle, agree, disposed, dispose }; } -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); +const items = (n: number): Item[] => Array.from({ length: n }, (_, i) => ({ id: i })); +const hits = () => __smallMoveHits(); + +describe("mapArray small-move fast path — engagement", () => { + it("engages for an arity-1 mapper on a >64 window and agrees with the general path", () => { + const p = pair(items(200)); + const before = hits(); + const rotated = rotateF(p.oracle().map(m => m.item)); + p.set(rotated); + expect(hits()).toBe(before + 1); + p.agree(); + expect(ids(p.fast())).toEqual(rotated.map(i => i.id)); + p.dispose(); }); - it("rotate backward preserves identity", () => { - const { setSrc, map, mapper } = harness(); - const before = map(); - mapper.mockClear(); - setSrc(p => rotateB(p)); + it("does NOT engage for an arity-2 mapper (index signals) — the oracle path", () => { + let seq = 0; + const [$s, set] = createSignal(items(200)); + let m!: () => Mapped[]; + const dispose = createRoot(d => { + m = mapArray($s, (item: Item, _i: () => number) => ({ item, seq: seq++ })); + m(); + return d; + }); + const before = hits(); + set(rotateF(items(200).map((_, i) => m()[i].item))); 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)); + expect(hits()).toBe(before); + dispose(); }); - 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("window gate (`end - start > 64`): a 66-row changed window engages, 65 does not", () => { + for (const [window, engages] of [ + [66, true], + [65, false] + ] as const) { + // Prefix of 100 unchanged rows, then a rotation of exactly `window` + // rows: the trims leave start=100, end=100+window-1. + const src = items(100 + window); + const p = pair(src); + const head = src.slice(0, 100); + const tail = src.slice(100); + const before = hits(); + p.set([...head, ...rotateF(tail)]); + expect(hits() - before, `window ${window}`).toBe(engages ? 1 : 0); + p.agree(); + p.dispose(); } }); - 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("displacement bound: 32 displaced rows engage, 33 fall to the general path", () => { + for (const [k, engages] of [ + [32, true], + [33, false] + ] as const) { + const src = items(400); + const p = pair(src); + // Move the first k rows to the END as a block: k displaced identities. + const next = [...src.slice(k), ...src.slice(0, k)]; + const before = hits(); + p.set(next); + expect(hits() - before, `k=${k}`).toBe(engages ? 1 : 0); + p.agree(); + expect(ids(p.fast())).toEqual(next.map(i => i.id)); + p.dispose(); + } }); +}); - 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]); +describe("mapArray small-move fast path — semantics vs the general path", () => { + it("rotate forward / backward: mapped owners move with their items, nothing re-created", () => { + const p = pair(items(300)); + const created = () => p.fast().length + p.disposed.fast.length; + const c0 = created(); + for (const op of [rotateF, rotateB, rotateF, rotateF]) { + p.set(op(p.oracle().map(m => m.item))); + p.agree(); } + expect(created()).toBe(c0); + expect(p.disposed.fast).toEqual([]); + p.dispose(); }); - 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("scattered displacements k = 3..8 agree with the general path whether or not they engage", () => { + // Engagement is an optimization, not a contract: the scan may decline a + // scatter it can't realign within its lookahead. Correctness never varies. + const p = pair(items(500)); + const before = hits(); + for (let k = 3; k <= 8; k++) { + p.set( + displace( + p.oracle().map(m => m.item), + k + ) + ); + p.agree(); + } + expect(hits()).toBeGreaterThan(before); // and it does engage for most of them + p.dispose(); }); - 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("adjacent swap (jfb swap rows) engages", () => { + const p = pair(items(1000)); + const src = p.oracle().map(m => m.item); + const next = [...src]; + [next[1], next[998]] = [next[998], next[1]]; + const before = hits(); + p.set(next); + expect(hits()).toBe(before + 1); + p.agree(); + p.dispose(); }); - 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("shrink: displaced rows that leave are disposed, the same ones the general path disposes", () => { + const p = pair(items(300)); + const src = p.oracle().map(m => m.item); + // Drop 5 rows from the middle and rotate the rest by one: a non-growing move with leavers. + const kept = src.filter((_, i) => i < 100 || i >= 105); + const before = hits(); + p.set(rotateF(kept)); + expect(hits()).toBe(before + 1); + p.agree(); + expect(p.disposed.fast.length).toBe(5); + p.dispose(); }); - 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("growth (newLen > oldLen) is excluded — general path", () => { + const p = pair(items(200)); + const src = p.oracle().map(m => m.item); + const before = hits(); + p.set([...rotateF(src), { id: 9999 }]); + expect(hits()).toBe(before); + p.agree(); + p.dispose(); }); - 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("replacement inside the window bails: fresh row created, old disposed", () => { + const p = pair(items(200)); + const src = p.oracle().map(m => m.item); + const next = [...src]; + next[100] = { id: 424242 }; + const before = hits(); + p.set(next); + expect(hits()).toBe(before); + p.agree(); + expect(p.disposed.fast.length).toBe(1); + p.dispose(); }); - 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)); + it("full replace (every item fresh) bails before the scan (pre-probe)", () => { + const p = pair(items(1000)); + const before = hits(); + p.set(items(1000)); // all new objects + expect(hits()).toBe(before); + p.agree(); + expect(p.disposed.fast.length).toBe(1000); + p.dispose(); + }); +}); + +describe("mapArray small-move fast path — duplicate identities", () => { + it("a displaced identity that also occurs in an aligned run DECLINES (occurrence-order pairing preserved)", () => { + // The audit's shape, embedded in a >64 window: old [A,B,A,C] → new [B,A,C,A]. + const A = { id: 1 }, + B = { id: 2 }, + C = { id: 3 }; + const filler = items(100).map(i => ({ id: 1000 + i.id })); + const src = [A, B, A, C, ...filler]; + const p = pair(src); + const before = hits(); + p.set([B, A, C, A, ...rotateF(filler)]); + // Declined: the general path pairs the two A occurrences in order. + expect(hits()).toBe(before); + p.agree(); + p.dispose(); + }); + + it("duplicates only among DISPLACED rows pair ascending on both sides — may engage, must agree", () => { + const A = { id: 1 }; + const filler = items(200).map(i => ({ id: 1000 + i.id })); + // Two A's at the front move together to the back. + const src = [A, A, ...filler]; + const p = pair(src); + p.set([...filler, A, A]); + p.agree(); + p.dispose(); + }); + + it("duplicate removal disposes the same occurrence the general path does", () => { + const A = { id: 1 }; + const filler = items(200).map(i => ({ id: 1000 + i.id })); + const src = [A, ...filler.slice(0, 100), A, ...filler.slice(100)]; + const p = pair(src); + // Drop the second A and rotate the tail: a shrink involving a duplicate. + p.set([A, ...filler.slice(0, 100), ...rotateF(filler.slice(100))]); + p.agree(); + expect(p.disposed.fast.length).toBe(1); + p.dispose(); + }); + + it("random duplicate-heavy reorders: 200 rounds, fast path always agrees with the general path", () => { + let s = 12345; + const rnd = (n: number) => (s = (s * 1103515245 + 12345) >>> 0) % n; + const base = items(120); // 120 identities, some used twice → duplicates everywhere + const p = pair([...base, ...base.slice(0, 30)]); + for (let round = 0; round < 200; round++) { + const cur = p.oracle().map(m => m.item); + const next = [...cur]; + const moves = 1 + rnd(6); + for (let m = 0; m < moves; m++) { + const from = rnd(next.length); + const [row] = next.splice(from, 1); + next.splice(rnd(next.length), 0, row); + } + if (rnd(4) === 0) next.splice(rnd(next.length), 1); // occasional shrink + p.set(next); + p.agree(); + } + p.dispose(); }); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index abd252d6f..3270757d2 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -675,6 +675,12 @@ module.exports = [ // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold // and warm: creation/clear at parity, swap and rotate ~0.5x. limit: "13.53 KB", + // + // #3227 audit round (2026-09-09): 13.53 -> 13.55 KB, measured at 13.54 + // (+54 B: duplicate-identity decline — a displaced identity that also + // sits in an aligned run falls to the general path, preserving + // mapArray's occurrence-order pairing). + limit: "13.55 KB", modifyEsbuildConfig }, { @@ -721,6 +727,10 @@ module.exports = [ // updateKeyedMap; identity-keyed mode only. Interleaved A/B vs next, cold // and warm: creation/clear at parity, swap and rotate ~0.5x. limit: "24.50 KB", + // + // #3227 audit round (2026-09-09): 24.50 -> 24.60 KB, measured at 24.58 + // (duplicate decline + the dev-only engagement counter this tier keeps). + limit: "24.60 KB", modifyEsbuildConfig: observeEsbuildConfig }, {