Skip to content

2.0.0-rc.10 (performance, V8 only): a keyed projection record of a few hundred keys pays O(keys) for every key a derive adds or deletes — the overlay commit mutates an object V8 has made a fast-mode prototype #3689

Description

@thedanchez

Summary

@solidjs/signals@2.0.0-rc.10 (and rc.9), prod and dev builds. Performance, not correctness: the output is right, the cost is the surprise. V8 only: Chrome, Edge and Node show it; Safari (JavaScriptCore) and Firefox (SpiderMonkey) do not.

A createProjection derive that adds and deletes root keys of a keyed record (a presence record: draft[id] = true / delete draft[id]) pays O(keys) per added or deleted key when the record holds a few hundred keys. At 400 keys, a derive that swaps 100 keys costs about 5.8 ms. A map of per-key boolean signals doing the same job, with the same 200 readers re-running, costs 0.09 ms.

The cost is in flattenOverlay, and the mechanism is V8's. ensurePB opens a wide record's draft as a prototype overlay, Object.create(committed) (#3044, #3352). The derive's own-key writes on that overlay, and ordinary reads through it, make V8 convert the committed object from dictionary mode to fast properties, because it is now a prototype. flattenOverlay then adds and deletes keys directly on that object, and in fast mode each of those operations costs O(keys). Above roughly 1,000 keys V8 keeps the object in dictionary mode and the cost goes away, so the slow band is roughly 100 to 1,000 keys.

Reproduction (headless, only @solidjs/signals)

mkdir solid-overlay-prototype-fold && cd solid-overlay-prototype-fold
npm init -y >/dev/null && npm i solid-js@2.0.0-rc.10 @solidjs/signals@2.0.0-rc.10 >/dev/null
cat > repro.mjs <<'JS'
// A keyed presence record kept by a draft-form projection: each derive deletes
// F keys and adds F others (a sliding window of P present keys over R ids), and
// every id has a reader subscribed with `id in record`. The commit of each
// derive costs O(F x P) inside flattenOverlay when P is a few hundred keys,
// because the overlay commit writes into, and deletes from, the object V8 is
// holding as the prototype of the overlay. A map of per-key boolean signals
// doing the same job is the control, and the last two variants show the same
// shape in plain JS with no Solid at all.
//
// Run ONE variant per process: the cost is a V8 object-shape state, and a
// second record created later in the same process can land in a cheaper state.
//
//   node repro.mjs record  [P] [F]
//   node repro.mjs signals [P] [F]
//   node repro.mjs plain-js-overlay [P] [F]
//   node repro.mjs plain-js-no-overlay [P] [F]
import { createMemo, createProjection, createRoot, createSignal, flush } from "@solidjs/signals";

const variant = process.argv[2] ?? "record";
const P = Number(process.argv[3] ?? 400);
const F = Number(process.argv[4] ?? P / 4);
const R = 10000;
const STEPS = 120;

const median = (xs) => [...xs].sort((a, b) => a - b)[xs.length >> 1];
const report = (xs, extra = "") =>
  console.log(
    `${variant.padEnd(20)} P ${String(P).padStart(5)}  F ${String(F).padStart(4)}  median ${median(xs.slice(20)).toFixed(3).padStart(7)} ms per step${extra}`,
  );
const windowAt = (i) => {
  const ids = new Set();
  for (let k = 0; k < P; k++) ids.add(`k${(i * F + k) % R}`);
  return ids;
};

if (variant === "record" || variant === "signals") {
  createRoot(() => {
    const [step, setStep] = createSignal(0, { ownedWrite: true });
    const mirror = new Set();
    let has;
    if (variant === "record") {
      const record = createProjection(
        (draft) => {
          const next = windowAt(step());
          for (const id of mirror)
            if (!next.has(id)) {
              mirror.delete(id);
              delete draft[id];
            }
          for (const id of next)
            if (!mirror.has(id)) {
              mirror.add(id);
              draft[id] = true;
            }
        },
        {},
        { key: null },
      );
      has = (id) => id in record;
    } else {
      const signals = new Map();
      createMemo(() => {
        const next = windowAt(step());
        for (const id of mirror)
          if (!next.has(id)) {
            mirror.delete(id);
            signals.get(id)?.[1](false);
          }
        for (const id of next)
          if (!mirror.has(id)) {
            mirror.add(id);
            signals.get(id)?.[1](true);
          }
      });
      has = (id) => {
        let s = signals.get(id);
        if (!s) signals.set(id, (s = createSignal(mirror.has(id), { ownedWrite: true })));
        return s[0]();
      };
    }
    let reruns = 0;
    for (let r = 0; r < R; r++) {
      const id = `k${r}`;
      createMemo(() => {
        reruns++;
        return has(id);
      });
    }
    flush();
    const xs = [];
    for (let i = 1; i <= STEPS; i++) {
      reruns = 0;
      const t0 = performance.now();
      setStep(i);
      flush();
      xs.push(performance.now() - t0);
    }
    report(xs, `  (${reruns} readers re-ran on the last step)`);
  });
} else {
  // The overlay commit's shape without Solid: committed object v with P keys;
  // each step opens an overlay Object.create(v) (or a plain {}), puts F new
  // keys on it, copies them onto v and deletes F old keys from v.
  const v = {};
  for (let k = 0; k < P; k++) v[`k${k}`] = true;
  let lo = 0;
  let hi = P;
  const xs = [];
  for (let i = 0; i < STEPS; i++) {
    const t0 = performance.now();
    const pb = variant === "plain-js-overlay" ? Object.create(v) : {};
    for (let k = 0; k < F; k++) pb[`k${hi + k}`] = true;
    for (const key of Reflect.ownKeys(pb)) v[key] = pb[key];
    for (let k = 0; k < F; k++) delete v[`k${lo + k}`];
    lo += F;
    hi += F;
    xs.push(performance.now() - t0);
  }
  report(xs);
}
JS

node repro.mjs record
node repro.mjs signals
node repro.mjs record 400 10
node repro.mjs record 4000
node repro.mjs plain-js-overlay
node repro.mjs plain-js-no-overlay

Run one variant per process. In a warm process, a second record created later can land in a cheaper state, which hides the cost.

Results (Node 24.13, macOS, Apple Silicon)

Prod build, median ms per derive + flush over 100 steps, 10,000 ids with one id in record reader each:

Case keyed record per-key signals readers re-run per step
400 present, 100 swapped per step 5.84 0.09 200
400 present, 10 swapped 0.58 0.05 20
400 present, 1 swapped 0.09 2
40 present, 10 swapped 0.09 0.02 20
1,000 present, 250 swapped 0.51 0.18 500
4,000 present, 1,000 swapped 1.89 0.72 2,000

The dev build is the same (5.61 / 0.09 ms for the first row), and so is rc.9 (5.82 / 0.09 ms, with @solidjs/signals pinned to rc.9).

Across engines, the same code bundled for the browser and run in Playwright's builds (mean over 100 steps, timed as one block because WebKit and Firefox coarsen performance.now() to 1 ms):

400 present, 100 swapped per step keyed record per-key signals plain JS, overlay plain JS, no overlay
Chromium 151 (V8) 4.91 0.07 4.33 0.04
WebKit 26.5 (JavaScriptCore) 0.20 0.08 0.04 0.05
Firefox 153 (SpiderMonkey) 0.22 0.11 0.04 0.04

Bun (JavaScriptCore) agrees with WebKit: 0.16 ms for the record, 0.07 for the signals, and no difference between the two plain-JS variants.

The same shape in plain JS, with no Solid: a 400-key object, an overlay Object.create(v) with 100 own writes, then the adds copied onto v and 100 old keys deleted from it, costs 5.62 ms per step. Without the overlay it costs 0.04 ms. At 4,000 keys the overlay version costs 0.44 ms.

Where it goes wrong (rc.10 source, store/next/store.ts)

A CPU profile of the record case (dev build, 200 steps) puts 86% of self time in flattenOverlay:

function flattenOverlay(t, pb) {
  privatizeCommitted(t);
  const v = t.v;
  for (const key of Reflect.ownKeys(pb))
    t.sc === 2 ? ((v as any)[key] = pb[key as any]) : copyOwn(v, pb, key);
  if (t.del !== null) {
    for (const key of t.del) delete (v as any)[key];
    t.del = null;
  }
  ...

The loop is O(written) in iterations, but every assignment and delete lands on v, which is the prototype of pb. Instrumenting the function shows v is the same object on every step and already %HasFastProperties(v) === true when the flatten starts. With node --allow-natives-syntax, a 400-key dictionary-mode object stays in dictionary mode after Object.create(v) alone, and after Reflect.has / Reflect.get through the overlay. It switches to fast properties after an own-key write on the overlay, a property read through it, or the in operator through it. The derive does the first of these on every write.

Detaching the overlay at flatten time does not help. Object.setPrototypeOf(pb, null) added at the top of flattenOverlay in the rc.10 prod build left the step at 5.9 ms.

Impact

Solid Flow keeps which nodes and edges are on screen as two keyed presence records, one key per element inside the culling viewport, about 400 each at 10,000 nodes. Rows subscribed per key with id in record. Every time a pan crossed a culling quantization step, both records swapped about a quarter of their keys, and the commit cost 1.4 to 2.1 ms per record in Chrome (measured on rc.9): a ~4 ms hitch per step, and ~0.9 ms per frame while a node was dragged at the pane edge with auto-pan running. We replaced the records with per-row boolean signals, which moved our pan benchmark from 22 to 15 ms of script over a 60-move gesture. Any keyed record in the few-hundred-key band that gains and loses keys (a selection set, a visible set, an index keyed by id) pays the same cost. Our selection records still do, as far as we can tell (the time is measured by record size, not attributed per record): a box selection that grows to 6,500 nodes at 10,000 spends about 150 ms of its 1.6 s of script in commits of records with 100 to 999 keys, up to 11 ms in a single commit, in Chrome.

Possible directions

Measured only in the plain-JS model above (400 keys, 100 swapped per step, with reads through the overlay):

  • Keep written keys off the prototype chain: a side object for the overlay's writes, which the draft's traps consult before the committed backing. 0.04 ms per step. The committed object is never a prototype, so V8 has no reason to convert it.
  • Commit into a fresh object (spread of the committed backing plus the writes) instead of mutating it. 0.37 ms per step: O(keys), but without the per-operation factor. It gives up the identity-stable backing that 2.0.0 | slow and steady wins the race #3044 introduced.

Platform

  • macOS 15 (Darwin 24.6.0), Apple Silicon
  • Node 24.13.0
  • solid-js@2.0.0-rc.10, @solidjs/signals@2.0.0-rc.10 (also reproduced on @solidjs/signals@2.0.0-rc.9)
  • Browsers: Playwright's Chromium 151, WebKit 26.5, Firefox 153; Bun 1.4.2

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