Skip to content

perf: use plain records to avoid V8 IC churn from null prototypes#12

Merged
evoactivity merged 3 commits into
mainfrom
perf/attr-object-map-churn
Jul 19, 2026
Merged

perf: use plain records to avoid V8 IC churn from null prototypes#12
evoactivity merged 3 commits into
mainfrom
perf/attr-object-map-churn

Conversation

@evoactivity

Copy link
Copy Markdown
Owner

Problem

Elements with exactly one attribute parsed up to 7x slower than elements with zero or several attributes:

0 attrs:  25.1M events/s
1 attr:    3.6M events/s   <- cliff
2 attrs:  15.1M events/s

Profiling showed processChunk never reaches TurboFan on 1-attr workloads. --trace-opt-verbose reports [delaying optimization of processChunk, IC changed] indefinitely: null-prototype objects (Object.create(null) / { __proto__: null } literals) get a unique dictionary-mode map per object in V8, so the attribute store site's inline cache churns on every tag and V8 permanently postpones optimizing the parse functions. With 2+ attributes the IC goes megamorphic (stable), which is why only the single-attribute case fell off the cliff.

Fix

Attribute and lossy element records are now plain {} objects, which share hidden classes so store sites stay monomorphic.

Prototype pollution remains impossible:

  • New setOwnProperty helper stores __proto__ via Object.defineProperty, so dangerous names become own properties and Object.prototype is never touched.
  • Lossy sibling grouping now uses Object.hasOwn instead of in. On plain objects, in matches inherited keys, so a <constructor> child would previously have taken the wrong grouping branch.
  • Security tests updated to assert the security property itself (own-property storage, no pollution, prototype unmutated) rather than prototype identity.

Results (Node 22.22, 10KB doc, isolated processes)

1-attr cliff, before vs after:

path before after
sax (createSaxParser) 3,106 ops/s 20,351 ops/s (+6.6x)
dom (parse) 3,473 ops/s 14,054 ops/s (+4.0x)
stream (XmlParseStream) 2,615 ops/s 9,611 ops/s (+3.7x)
lossy converter 1,859 ops/s 9,023 ops/s (+4.9x)

All other attribute counts improve or are unchanged (within noise); the 0-4 attr curve is now monotonic. 838/838 tests pass.

Null-prototype objects (Object.create(null) and { __proto__: null }
literals) get a unique dictionary-mode map per object in V8, making
the record store site's inline cache churn on every element. On the
large parse functions this permanently blocks TurboFan ("delaying
optimization, IC changed"), costing up to 7x throughput for elements
with exactly one attribute.

Attribute and lossy element records are now plain objects, which share
hidden classes so store sites stay monomorphic. Prototype pollution
stays impossible: the new setOwnProperty helper stores __proto__ via
defineProperty so dangerous names become own properties and
Object.prototype is never touched. Lossy sibling grouping now uses
Object.hasOwn instead of `in`, which on plain objects would have
matched inherited keys like `constructor`.

1-attr cliff, before -> after (ops/s, 10KB doc):
  sax 3.1k -> 20.4k, parse 3.5k -> 14.1k,
  stream 2.6k -> 9.6k, lossy 1.9k -> 9.0k
@evoactivity evoactivity added the bug Something isn't working label Jul 19, 2026
Byte-identical to the ~10 KB document generated by
@tuananh/sax-parser's benchmark: 158 tiny elements with one attribute
each. This attribute-dense shape exposed the null-prototype IC churn
cliff that the existing attribute-light real-world fixtures never
triggered. Wired into the tokenize, parse, and stream comparison
suites as a regression guard.
Rerun of the full suite on Node 24 after the plain-record perf fix.
Adds the attr-heavy synthetic column to the parsing, streaming, and
tokenization tables. Serialization table reordered: with the fix,
eksml (validate: false) now leads every fixture, so tXml no longer
holds the small/SOAP/Atom/EPG leads.
@evoactivity
evoactivity merged commit c2774b0 into main Jul 19, 2026
4 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant