Skip to content

perf: walk and serialize the tree without intermediate arrays - #337

Open
zirkelc wants to merge 4 commits into
WebReflection:mainfrom
zirkelc:perf-serialization
Open

zirkelc wants to merge 4 commits into
WebReflection:mainfrom
zirkelc:perf-serialization

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 -1.13% total, i.e. noise). Negative = faster.

What this PR does

Four commits, all about walking the tree and turning it into a string.

escape(): skip the pass when there is nothing to escape. Every text node and every attribute value of a non-HTML document went through replace.call(es, /[<>&\xA0]/g, pe), a regex replace with a callback, although most strings contain none of those characters. A test with a non-global copy of the same character class decides first (test on the global regex would move its lastIndex). Attr.toString gets the same treatment for its &quot; replace, which ran on every attribute.

childNodes and children: one pass over the list. Both getters called nextSibling() (and children additionally nextElementSibling(), which loops over the non-element nodes in between) for every entry. Since the list already stores an element followed by its subtree and its end marker, one walk that jumps from an element to element[END][NEXT] collects the same nodes in the same order, without the helper calls. It also removes the nextElementSibling import from the mixin.

Element.toString(): build one string. The serializer pushed every tag, attribute and text fragment into an array and joined it at the end; for a 2 MB document that is hundreds of thousands of array entries. Concatenating into a string instead lets V8 keep a rope and flatten once. The attribute case now compares 'id'/'class'/'style' instead of ' id'/' class'/' style', because the leading space is added where the attribute is appended.

textContent, innerText, wholeText: the same change for the text getters. wholeText also drops an unshift per preceding text node, which moved the whole array each time.

Verification (this branch vs main)

case run 1 run 2 speed-up vs main
serialize (documentElement.outerHTML) -47.0% -46.2% 1.87x
text-content (body.textContent, document.title) -41.0% -44.1% 1.74x
crawl (recursive childNodes and children) -13.1% -13.4% 1.15x
bench-dom (full content.js sequence on dom.html) -8.7% -6.0% 1.08x
clone-deep -2.3% -5.2% n/a (within noise)
extract-pages -6.1% -1.7% n/a (runs disagree)
suite TOTAL -6.6% -4.8% 1.06x
GEOMEAN (every case weighted equally) -10.0% -10.1% 1.11x

These runs were taken while the machine had other load, so the absolute milliseconds are higher
than in the other PRs of this campaign; the ratios are what matters, and the two runs agree on
every case that is claimed. The control run with identical code on both sides measured -1.13%
on the total under the same conditions.

Observable surface

  • escape() returns the input string itself when nothing needs escaping, instead of a new string with identical content. Only observable through identity (=== on two separately produced strings), never through content.
  • Serialized markup is byte-identical. The characterisation guard hashes outerHTML of 14 real pages plus w3c.html and dom.html, and npm test covers the void, SVG, XML and empty-attribute branches.
  • childNodes and children return the same NodeList contents in the same order, including attribute nodes being skipped and text, comment and CDATA nodes being kept. Both still return a fresh NodeList on every access, so linkedom/cached's memoisation of these getters is unaffected.
  • textContent, innerText and wholeText return the same strings, including innerText's whitespace collapsing and its \n between block elements.
  • No API, descriptor or enumerability change, no new internal fields, no dependency change, no change to the worker build.

Reproducing the numbers

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

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-337          # 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