Skip to content

2.0.0-rc.7: mounting N rows is O(N²) when each row creates an effect over a signal written during its own creation — markHeap re-walks the whole pure heap on every memo pull #3350

Description

@thedanchez

Summary

@solidjs/signals@2.0.0-rc.7 (also solid-js / @solidjs/web rc.7, prod and dev builds).

Mounting N rows in one flush (a <For> / mapArray mount) is O(N²) when each row creates a createEffect whose source is written while the row is being created. The common case is an effect over the element ref:

const [el, setEl] = createSignal<HTMLDivElement>();
createEffect(() => el(), (el) => el && observer.observe(el));
return <div ref={setEl} />;

The ref write makes the effect dirty right after it is created. User effects run in the effect phase, so every such effect stays in the pure heap until the whole synchronous mount is done. Each later row's first memo read calls markHeap, which re-walks the entire heap because runHeap cleared its _marked flag. Total marking work is roughly N × (dirty effects so far). Doubling N costs about 4x. Without the ref effect the same mount is linear at about 1 µs per row.

Reproduction (headless, only @solidjs/signals)

mkdir solid-markheap && cd solid-markheap
npm init -y >/dev/null && npm i solid-js@2.0.0-rc.7 >/dev/null
cat > repro.mjs <<'JS'
// Headless repro (only @solidjs/signals needed): mounting N "rows" inside a
// mapArray during one flush is O(N^2) when each row creates an effect whose
// source is written while the row is being created (the ref-signal pattern).
//   node repro.mjs            -> timing table
//   node --cpu-prof repro.mjs -> markHeap/markNode dominate
import { createEffect, createMemo, createRenderEffect, createRoot, createSignal, flush, mapArray } from "@solidjs/signals";
console.warn = () => {}; // dev build: silence HUGE_FAN_OUT on the shared viewport memo (by design here)

function mount(N, { refEffects }) {
  let dispose;
  let ms = 0;
  createRoot((d) => {
    dispose = d;
    const [zoom] = createSignal(1);
    const viewport = createMemo(() => 600 / zoom());
    const [items, setItems] = createSignal([], { ownedWrite: true }); // the bench writes from inside the root
    const rows = createMemo(
      mapArray(items, (i) => {
        // per-row memo, read by a sync "binding" effect (what compiled JSX does)
        const visible = createMemo(() => i * 10 < viewport());
        createRenderEffect(() => visible(), () => {}, { sync: true });
        // effect over the row's element ref; the ref is assigned while the row
        // mounts, so the effect is DIRTY and waits in the pure heap until the
        // effect phase, i.e. for the rest of the whole synchronous mount.
        // ownedWrite: the ref write lands inside the row owner, as a compiled
        // `ref={setEl}` does (the dev owned-scope guard would throw otherwise).
        const [el, setEl] = createSignal(null, { ownedWrite: true });
        for (let k = 0; k < refEffects; k++) createEffect(() => el(), () => {});
        setEl({ i });
        return visible;
      }),
    );
    createRenderEffect(() => rows().length, () => {});
    flush();
    const t0 = performance.now();
    setItems(Array.from({ length: N }, (_, i) => i));
    flush();
    ms = performance.now() - t0;
  });
  dispose();
  return ms;
}

for (const refEffects of [0, 1, 4]) {
  console.log(`\nrefEffects per row = ${refEffects}`);
  let prev = 0;
  for (const n of [1000, 2000, 4000, 8000]) {
    const ms = mount(n, { refEffects });
    console.log(`  N=${String(n).padStart(5)}  ${ms.toFixed(1).padStart(8)} ms  ${((ms / n) * 1000).toFixed(1).padStart(6)} us/row${prev ? `  ${(ms / prev).toFixed(2)}x per 2x N` : ""}`);
    prev = ms;
  }
}
JS

echo "--- prod build ---"; node repro.mjs
echo "--- dev build ---";  node --conditions=development repro.mjs

Results (Node 24.13, macOS, Apple Silicon)

Prod build:

refEffects per row = 0
  N= 1000       3.5 ms     3.5 us/row
  N= 2000       3.5 ms     1.7 us/row  0.98x per 2x N
  N= 4000       4.5 ms     1.1 us/row  1.30x per 2x N
  N= 8000       8.8 ms     1.1 us/row  1.96x per 2x N
refEffects per row = 1
  N= 1000       4.5 ms     4.5 us/row
  N= 2000      18.1 ms     9.0 us/row  4.06x per 2x N
  N= 4000      46.6 ms    11.7 us/row  2.58x per 2x N
  N= 8000     231.4 ms    28.9 us/row  4.96x per 2x N
refEffects per row = 4
  N= 1000      12.0 ms    12.0 us/row
  N= 2000      44.1 ms    22.1 us/row  3.68x per 2x N
  N= 4000     171.8 ms    43.0 us/row  3.89x per 2x N
  N= 8000     685.8 ms    85.7 us/row  3.99x per 2x N

Dev build: same curve, steeper. 1 effect per row: 227 ms at N=8000. 4 effects per row: 1181 ms at N=8000.

node --cpu-prof repro.mjs at N=8000 with 1 effect per row: 49% self time in read, 29% in markHeap. About 78% of the mount is marking.

Browser reproduction (compiled JSX)

Same shape through the compiler: one memo read by a binding, one createEffect over the row's ref.

// Mount N rows. Each row: one memo read by a binding, one createEffect over
// the row's element ref. ?n=8000  ->  mount time is O(N^2).
import { render } from "@solidjs/web";
import { createEffect, createMemo, createSignal, For } from "solid-js";

const N = Number(new URLSearchParams(location.search).get("n") ?? 4000);
const [zoom] = createSignal(1);
const viewport = createMemo(() => 600 / zoom());
const items = Array.from({ length: N }, (_, i) => i);

const Row = (props: { i: number }) => {
  const visible = createMemo(() => props.i * 10 < viewport());
  const [el, setEl] = createSignal<HTMLDivElement>();
  // The ref signal is written while this row mounts, so this effect is dirty
  // and waits in the pure heap until the effect phase, i.e. for the whole
  // synchronous mount of every remaining row. Each later row's memo pull
  // re-marks the entire heap (markHeap): O(N^2).
  createEffect(() => el(), () => {});
  return <div ref={setEl} class={visible() ? "in" : "out"} />;
};

const t0 = performance.now();
render(() => <For each={items}>{(i) => <Row i={i} />}</For>, document.getElementById("app")!);
const ms = performance.now() - t0;
(window as unknown as { __mount: number }).__mount = ms;
document.getElementById("out")!.textContent = `N=${N} mount=${ms.toFixed(0)}ms`;
N= 1000       7 ms     7 us/row
N= 2000      15 ms     7 us/row  2.07x per 2x N
N= 4000      57 ms    14 us/row  3.92x per 2x N
N= 8000     238 ms    30 us/row  4.19x per 2x N
N=16000     799 ms    50 us/row  3.35x per 2x N

A CDP sampling profile at N=8000 puts 78% of samples in markNode/markHeap. Deleting the createEffect(() => el(), ...) line makes the mount linear (about 5 µs per row up to 16k rows). <Dynamic>, store-backed rows and per-row projections do not trigger it on their own.

Where it goes wrong (rc.7 source, heap.ts / core.ts)

  1. createEffect reads el(). ref={setEl} writes it right after. The effect is now dirty and enqueued in the pure heap. User effects run in the effect phase, so it stays there until the mount finishes.
  2. The row's compiled bindings are sync render effects. Running them runs the heap (runHeap), which clears heap._marked.
  3. The next row reads a memo for the first time (read → prepareComputed → recompute). Validation calls markHeap(dirtyQueue). The flag is clear, so it walks every heap level and calls markNode on every entry, including all the dirty effects from earlier rows, none of which can affect the memo being validated.

Instrumented in a real app (10k rows, 4 such effects per row): markHeap was called 2N + 7 = 20 007 times, the heap held up to 4N + 2 = 40 002 entries, and every entry was an effect node (_type 2). That is about 250 million heap visits in one mount.

Impact

Any component that wires its element in an effect (ResizeObserver, listeners, focus) pays this once per instance mounted in the same flush. The cost does not show up in attribution because it is marking, not computation.

In Solid Flow (@dschz/solid-flow) at 10k nodes this was about 5 s of a 9.7 s production mount. We worked around it by wiring the element from the ref callback instead (ref={(el) => runWithOwner(owner, () => wire(el))}), so no effect is dirty at creation. The mount went to 4.3 s and markHeap calls from 20,007 to 8. That fixes our own wrappers only. A user's custom node component written with the effect-over-ref pattern hits it again.

Possible directions

  • Track which heap entries have already been marked and only mark entries added since, instead of clearing _marked in runHeap.
  • Skip effect-typed nodes when marking for memo validation. A dirty user effect cannot make a memo stale.
  • Defer marking until a batch actually needs to validate a computed with a dirty ancestor in the heap.

Platform

  • macOS 15 (Darwin 24.6.0), Apple Silicon
  • Node 24.13.0; Chromium (Playwright) for the browser numbers
  • solid-js@2.0.0-rc.7, @solidjs/signals@2.0.0-rc.7, @solidjs/web@2.0.0-rc.7

Activity

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