Skip to content

perf: allocate the node end marker without symbol keys in the literal - #338

Open
zirkelc wants to merge 1 commit into
WebReflection:mainfrom
zirkelc:perf-node-end-marker
Open

zirkelc wants to merge 1 commit into
WebReflection:mainfrom
zirkelc:perf-node-end-marker

Conversation

@zirkelc

@zirkelc zirkelc commented Sep 23, 2026

Copy link
Copy Markdown

Context: performance optimization campaign. This is one of 4 PRs from a systematic performance campaign on linkedom, run as an automated research loop: 18 isolated experiments, 8 kept, 10 discarded. Every candidate change was

  • benchmarked with an A/B harness that loads two git revisions of esm/ into one process and times 14 workloads in alternation. Module instances carry a stable load-order bias of several percent, so the harness runs both load orders in separate child processes and combines them with a geometric mean. The machine was shared and has cores of two speeds, so the reported delta is the median of the paired ratios (both sides run back to back in every iteration), not a ratio of minima.
  • measured on 14 deterministic workloads, half of them mirroring test/benchmark/content.js (parse, crawl childNodes/children, cloneNode(true), querySelectorAll, getElementsByTagName, remove, outerHTML, innerHTML round trip on w3c.html and dom.html), half a real content-extraction pipeline on 14 full Shopify storefront pages (skip-link and main detection, contains, compareDocumentPosition, section cloning, ten removal passes by selector, outerHTML). test/benchmark/*.js loads one version per process, so using it for A/B would mean comparing two standalone runs, and run-to-run drift is larger than most effect sizes here. It was kept as an external cross-check.
  • gated by a calibrated noise floor (1.8% on the suite total, measured with identical code on both sides): changes under 3.5% total, or under 6% on a targeted case, were discarded, and every keep required a second confirming run.
  • verified behaviour-preserving by a characterisation guard (hashes of the serialized output, query results, text content and document-order answers of all 14 workloads) plus npm test, both green after every commit. Coverage stays at 100% lines and branches.

The numbers below are fresh verification runs of this branch alone against main (two independent runs; an identical-source control run measured -0.16% total, i.e. noise). Negative = faster.

What this PR does

ParentNode's constructor creates the end marker of every node as an object literal whose first three keys are the NEXT, PREV and START symbols. A literal with computed symbol keys does not get a compile-time boilerplate in V8: it is built key by key at runtime, and this literal is created for every element, document and fragment that is parsed or cloned.

The change keeps the same object with the same six properties, but starts the literal with its three string keys and assigns the three symbol links immediately afterwards. Nothing else moves.

I found this while looking for the cost of node creation, and measured three variants against each other:

variant clone-deep tree walks (crawl, querySelectorAll, textContent)
literal with symbol keys (today) baseline baseline
string keys first, symbols assigned after (this PR) -42% unchanged
a small class NodeEnd instead of the literal -45% +16% to +31% slower

The class variant was the fastest at allocating but made every walk that reads nodeType from both nodes and markers slower, so it was dropped. Moving the symbol keys to the end of the same literal was worse than today (clone-deep +26%): what costs is their presence in the literal, not their position.

Verification (this branch vs main)

case run 1 run 2 speed-up vs main
clone-deep (documentElement.cloneNode(true)) -14.6% -15.2% 1.18x
bench-dom (full content.js sequence on dom.html) -9.1% -9.6% 1.10x
extract-pages (extraction pipeline, /pages/*) -9.7% -11.0% 1.12x
parse-shop (parse 2.7 MB of storefront HTML) -5.6% -7.6% 1.07x
extract-products / extract-home -4.1% / -3.3% -4.7% / -3.0% ~1.04x
serialize (documentElement.outerHTML) +1.4% +2.9% see below
query-simple +0.7% +13.0% n/a (runs disagree)
suite TOTAL -7.7% -7.9% 1.08x
GEOMEAN (every case weighted equally) -4.0% -2.1% 1.03x

Retained heap per parsed w3c.html document is unchanged (-0.6%): the marker keeps the same six properties, only the way it is built changes.

The trade-off: Element.toString() reads next[START] on every end marker, and a symbol property assigned after the literal lives in the property backing store instead of inside the object. In isolation that costs 1 to 3% on serialize, which is close to this case's noise band. Measured on top of the other PRs of this campaign, where serialization is already about twice as fast, the same effect showed as +8 to +9% on that case. I tried recovering it by keeping the open elements on a stack in toString() instead of following the marker link; that gave 3.8% back, not enough to keep as its own change. If you prefer, this PR can be dropped and the other three stand on their own.

Observable surface

  • The end marker keeps the same six properties (nodeType, ownerDocument, parentNode, and the NEXT, PREV, START symbols) with the same values. Only their insertion order changes, so Object.keys on a marker (internal object, not reachable from user code) would list them in the same string-key order as before.
  • No API, descriptor or enumerability change on any node, no new fields, no dependency change, no change to the worker build.
  • Behaviour is covered by npm test and by the characterisation guard (serialized output, query results, text content and document-order answers over 14 real pages plus w3c.html and dom.html).

Reproducing the numbers

git clone https://github.com/WebReflection/linkedom && cd linkedom && npm ci
git fetch origin pull/338/head:pr-338

The harness lives on the campaign branch of my fork: zirkelc/linkedom@perf/autoresearch. It holds perf/*.mts (A/B harness, characterisation guard, memory harness, profiler, the 14 cases), perf/plan.md (method, calibration, every experiment and why it was kept or discarded) and perf/experiments.tsv (the log, including the 10 discarded experiments and their numbers).

git remote add campaign https://github.com/zirkelc/linkedom && git fetch campaign
git checkout campaign/perf/autoresearch -- perf && git reset -- perf
perf/fetch-fixtures.sh                # 14 public storefront pages, ~13 MB, not committed
node perf/guard.mts --update          # record the current behaviour of your checkout
node perf/ab.mts main main            # noise control: expect well under 2% on TOTAL
node perf/ab.mts main pr-338          # the measurement, twice

One run takes about 4.5 minutes on an idle machine. Judge a case only when both runs agree; the suite total is the headline number, and GEOMEAN weights every case equally. The fixtures are live pages, so a fresh download changes the absolute milliseconds (not the ratios); perf/fetch-fixtures.sh lists the URLs.


Companion PRs from the same campaign, independent of each other and of this one:

Stacked, the four together measure 2.02x on the suite total and -62% on retained heap per document; on npm run benchmark:html (the 12 MB page) parsing goes from 767/711 ms to 259/235 ms, cloneNode(true) from 591/527 ms to 84/85 ms and the total benchmark time from 4.23/4.09 s to 2.43/2.41 s. Each PR can be taken or left on its own; the numbers in each body are that branch measured alone against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant