Skip to content

perf: cache the class token value and skip the token list while parsing - #336

Open
zirkelc wants to merge 2 commits into
WebReflection:mainfrom
zirkelc:perf-class-attributes
Open

zirkelc wants to merge 2 commits into
WebReflection:mainfrom
zirkelc:perf-class-attributes

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

What this PR does

Two commits, both about the class attribute.

DOMTokenList: keep the joined value until a token changes. get value() rebuilt the string with [...this].join(' ') on every read. css-select asks for it through the adapter on every element it tests against a class selector, so a querySelectorAll('.foo') over a large document joins the same tokens thousands of times. The value is now kept in the instance and dropped again in every path that changes a token: addTokens, toggle, replace, and the inherited clear and delete, which are overridden for that reason (remove() and className's setter go through them). The public value can therefore not drift from the token set.

Parser: normalise a class value without building a token list. While parsing, attribute() in parse-from-string.js called element.className = value, which built a DOMTokenList, split the value, added every token, joined them again and wrote the result back through the Attr value setter. The only lasting effect of that round trip is the normalised attribute value (no empty entries, no duplicates, single spaces), which classTokens() now produces directly. The token list itself is built lazily, by the existing classList getter, for the elements that actually need one. The same commit replaces htmlClasses.has(localName) followed by htmlClasses.get(localName) with a single get in createHTMLElement.

Verification (this branch vs main)

case run 1 run 2 speed-up vs main
query-scraper (every selector of the extractor, read-only) -31.2% -30.7% 1.45x
extract-pages (extraction pipeline, /pages/*) -18.1% -17.3% 1.21x
extract-products -16.6% -17.0% 1.21x
extract-home -16.1% -17.0% 1.20x
clone-deep (documentElement.cloneNode(true)) -8.5% -6.0% 1.07x
text-content -9.5% -8.4% 1.09x
query-first -7.6% -5.7% 1.07x
parse-shop (parse 2.7 MB of storefront HTML) -1.0% -6.4% n/a (runs disagree)
bench-dom (full content.js sequence on dom.html) -2.5% -1.2% n/a (within noise)
suite TOTAL -7.6% -7.1% 1.08x
GEOMEAN (every case weighted equally) -8.2% -8.3% 1.09x

clone-deep improves although this PR does not touch cloning: elements no longer carry a token
list built during parsing, so there is less per-element state to walk. parse-shop is the case
the second commit targets, but its two runs disagree (-1.0% and -6.4%), so I do not claim a
number for it; the parse effect is visible in the extraction cases, which parse a page per
iteration as well.

Observable surface

  • DOMTokenList instances carry one more internal field, keyed by the existing VALUE symbol from shared/symbols.js. It is not enumerable in for...in over the instance (symbol key), does not appear in Object.keys, and does not change Set iteration, size or length.
  • clear() and delete() are now own methods of DOMTokenList instead of inherited Set methods. They call super, so the return values and semantics are those of Set. Code that compares classList.delete === Set.prototype.delete would see a difference.
  • classList.value returns the same string as before in every path, including after add, remove, toggle, replace, className = ... and removeAttribute('class').
  • Parsed class attribute values are normalised exactly as before: the tokens, their order and the single-space joining are those a DOMTokenList produces. getAttribute('class'), className and the serialized output are unchanged (the characterisation guard hashes the full outerHTML of 14 real pages).
  • Custom element and mutation callbacks during parsing are unchanged. The parser already reported the attribute itself (attributeChangedCallback(element, name, null, value)); the removed round trip through the Attr setter fired no observer records during parsing. npm test's MutationObserver benchmark reports the same count (2) before and after.
  • element.classList is now created on first use instead of during parsing. It is the same object on every later access, as before.
  • No new own properties on elements, 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/336/head:pr-336

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