Skip to content

[2.0 rc.21]: a projection that reads a memo is read stale by another projection's derive, so a derive over N such readers re-runs N times (O(N²) per flush) #4782

Closed

Description

@thedanchez

Summary

@solidjs/[email protected] through rc.21, and next at ce5b288e3 (prod and dev builds). Regression since rc.13, introduced by #3774 (1b9ceb679, the hold model): its parent 41fdf9696 runs the repro below linearly, 1b9ceb679 quadratically.

A projection B that reads a leaf of another projection A can run before A has re-derived in the same flush. B then computes against A's previous value, A re-derives, and B runs again. When a projection R reads N such Bs, each late A also re-runs R, and the flush is quadratic in N.

It happens when A sits higher in the heap than B, and the simplest way to get there is for A to read a memo. A projection's leaves are plain signals, so a reader of them gets no height from the projection's derive. If A reads only store keys, A and B both stay at height 0 and creation order happens to run A first. One memo read (per-A or shared) puts A at height 1 and B runs first. The pull that should fix the order on read (pullFamily) does not bring the derive up to date for an untracked read (details below).

We hit it in @dschz/solid-flow: a 10,000-node mount went from 2.3 s on rc.13 to 17.5 s on rc.21 with the same library build (Impact, below).

Reproduction (headless, only @solidjs/signals)

A[i] reads a memo and a store key size[i]; B[i] reads A[i] and A[i+1]; R reads every B[i]. One write sets every size[i].

mkdir solid-memo-projection && cd solid-memo-projection
npm init -y >/dev/null && npm pkg set type=module && npm i @solidjs/[email protected] >/dev/null
cat > repro.mjs <<'JS'
import { createEffect, createMemo, createProjection, createRoot, createStore, flush } from "@solidjs/signals";
const MEMO = !process.argv.includes("--no-memo");
const run = (N) => {
  let bRuns = 0, rRuns = 0, setSizes;
  const dispose = createRoot((dispose) => {
    const [sizes, set] = createStore(Object.fromEntries(Array.from({ length: N }, (_, i) => [i, 0])));
    setSizes = set;
    const A = Array.from({ length: N }, (_, i) => {
      const memo = createMemo(() => ({ id: i }));
      return createProjection(() => ({ id: MEMO ? memo().id : i, size: sizes[i] }), { id: -1, size: 0 });
    });
    const B = Array.from({ length: N - 1 }, (_, i) =>
      createProjection(() => { bRuns++; return { ok: A[i].size > 0 && A[i + 1].size > 0 }; }, { ok: false }),
    );
    const R = createProjection((draft) => {
      rRuns++;
      let n = 0;
      for (const b of B) if (b.ok) n++;
      draft.ok = n;
    }, { ok: 0 });
    createEffect(() => R.ok, () => {});
    return dispose;
  });
  flush();
  bRuns = 0; rRuns = 0;
  const t0 = performance.now();
  setSizes((draft) => { for (let i = 0; i < N; i++) draft[i] = 1; });
  flush();
  const ms = Math.round(performance.now() - t0);
  dispose();
  return { N, ms, bRuns, rRuns };
};
run(100);
for (const N of [1000, 2000]) console.log(MEMO ? "memo   " : "no memo", JSON.stringify(run(N)));
JS
node repro.mjs && node repro.mjs --no-memo

For the expected output, install @solidjs/[email protected] instead. Pin signals directly: [email protected] alone pulls the newest signals through its caret range.

Results (Node 24.13, macOS, Apple Silicon)

@solidjs/signals N flush, ms B runs R runs
2.0.0-rc.13 2,000 12 1,999 1
2.0.0-rc.14 2,000 401 3,998 1,999
2.0.0-rc.21 2,000 487 3,998 1,999
2.0.0-rc.21, --no-memo 2,000 16 1,999 1
next ce5b288e3 (built from source) 2,000 480 3,998 1,999
41fdf9696 (parent of #3774) 1,000 4 999 1
1b9ceb679 (#3774) 1,000 105 1,998 999

The run order at N=4, logging each derive with the values it read:

rc.21                                       rc.13
A0(size=1)                                  A0(size=1)
B0(reads A0.size=1, A1.size=0)   <- stale   A1(size=1)
A1(size=1)                                  A2(size=1)
B0(reads A0.size=1, A1.size=1)              A3(size=1)
B1(reads A1.size=1, A2.size=0)   <- stale   B0(reads A0.size=1, A1.size=1)
R(ok=1)                                     B1(reads A1.size=1, A2.size=1)
A2(size=1)                                  B2(reads A2.size=1, A3.size=1)
B1(reads A1.size=1, A2.size=1)              R(ok=3)
B2(reads A2.size=1, A3.size=0)   <- stale
R(ok=2)
A3(size=1)
B2(reads A2.size=1, A3.size=1)
R(ok=3)

Every B[i] reads A[i+1].size before A[i+1] has derived. Each R pass reads all the Bs, so at N rows R runs N-1 times and the flush does O(N²) row reads.

Where it goes wrong (next, ce5b288e3)

An experiment that fixes the repro

Giving pullFamily's settled branch the pull a tracked read makes:

   } else {
+    let reader: any = context;
+    if (reader?._root) reader = reader._parentComputed;
+    if (reader) pullComputed(fw, reader);
     untrack(() => readNode(fw));

(plus importing pullComputed from ../core/core.js). With it, on next at ce5b288e3 and on the @solidjs/[email protected] tag, the repro runs each B and R once (N=2000: 10-13 ms, bRuns 1,999, rRuns 1) and the N=4 trace matches rc.13's. On next, the packages/signals suite gives the same result with and without it (489 files: 6,813 passed, 14 expected fail, 2 skipped). This is an experiment to locate the cause, not a reviewed fix: it ignores the verdict, lane and hold rules around the call.

Impact

Any projection read by another projection's derive is affected: per-row projections over per-row projections, keyed records over rows, anything that joins two derived stores. The cost scales with the size of the write: a write that dirties K upstream rows can re-run each downstream reader up to K times.

In @dschz/solid-flow (one projection per node that reads a per-row memo, one per edge that reads its two endpoint nodes, a keyed record over the edge rows), at 10,000 nodes, Apple M4 Pro, Chromium, production builds, medians of two passes. The rc.21 and experiment columns are the same library build on the same day. The rc.13 column is our last benchmark round on rc.13, from 2026-10-02 at a library commit a few patches older. Built today against rc.13, that library build mounts in 2.3 s and against rc.21 in 17.5 s.

Row rc.13 (2026-10-02) rc.21 rc.21 + the experiment
Mount to every node measured, ms 2,180 17,495 2,246
Selection drag, script ms 100 157 109
Move all nodes in one write, ms 315 434 360
Box selection, script ms 77 96 84
Heap on mount, MB 609 579 578

The mount profile is all inside the edge record's derive re-reading its rows: about 50 million row reads at 10k.

Possible directions

Platform

@solidjs/signals 2.0.0-rc.14 to 2.0.0-rc.21, and next at ce5b288e3 built from source. Node 24.13 on macOS (Apple Silicon), prod and dev builds (--conditions=development). The same in Chromium through solid-flow's production build.

Activity

  1. changed the title [-]rc.14: a projection that reads a memo is read stale by another projection's derive, so a derive over N such readers re-runs N times (O(N²) per flush)[/-] [+][2.0 rc.21]: a projection that reads a memo is read stale by another projection's derive, so a derive over N such readers re-runs N times (O(N²) per flush)[/+] on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions