Conversation
This was referenced Sep 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
ParentNode's constructor creates the end marker of every node as an object literal whose first three keys are theNEXT,PREVandSTARTsymbols. 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:
class NodeEndinstead of the literalThe class variant was the fastest at allocating but made every walk that reads
nodeTypefrom 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)maindocumentElement.cloneNode(true))content.jssequence ondom.html)/pages/*)documentElement.outerHTML)Retained heap per parsed
w3c.htmldocument is unchanged (-0.6%): the marker keeps the same six properties, only the way it is built changes.The trade-off:
Element.toString()readsnext[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% onserialize, 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 intoString()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
nodeType,ownerDocument,parentNode, and theNEXT,PREV,STARTsymbols) with the same values. Only their insertion order changes, soObject.keyson a marker (internal object, not reachable from user code) would list them in the same string-key order as before.npm testand by the characterisation guard (serialized output, query results, text content and document-order answers over 14 real pages plusw3c.htmlanddom.html).Reproducing the numbers
The harness lives on the campaign branch of my fork:
zirkelc/linkedom@perf/autoresearch. It holdsperf/*.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) andperf/experiments.tsv(the log, including the 10 discarded experiments and their numbers).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
GEOMEANweights every case equally. The fixtures are live pages, so a fresh download changes the absolute milliseconds (not the ratios);perf/fetch-fixtures.shlists 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 againstmain.