From cd03b6afac719497c4d73fc98b61a07c5e2e8301 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sun, 19 Jul 2026 01:09:53 +0100 Subject: [PATCH 1/3] perf: use plain records to avoid V8 IC churn from null prototypes 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 --- eksml/src/converters/fromLossy.ts | 10 ++- eksml/src/converters/lossy.ts | 12 ++-- eksml/src/parser.ts | 18 ++--- eksml/src/saxEngine.ts | 94 +++++++++++---------------- eksml/src/utilities/setOwnProperty.ts | 37 +++++++++++ eksml/src/xmlParseStream.ts | 26 +++++--- eksml/test/saxEngine.test.ts | 44 ++++++++----- 7 files changed, 142 insertions(+), 99 deletions(-) create mode 100644 eksml/src/utilities/setOwnProperty.ts diff --git a/eksml/src/converters/fromLossy.ts b/eksml/src/converters/fromLossy.ts index 13d8851..dbecf63 100644 --- a/eksml/src/converters/fromLossy.ts +++ b/eksml/src/converters/fromLossy.ts @@ -33,6 +33,7 @@ import type { LossyObject, LossyMixedEntry, } from '#src/converters/lossy.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; // @generated:char-codes:begin const DOLLAR = 36; // $ // @generated:char-codes:end @@ -157,12 +158,15 @@ function convertElement( } else if (key.charCodeAt(0) === DOLLAR) { // Attribute — strip the $ prefix if (attributes === null) { - attributes = Object.create(null) as Record; + attributes = {}; } const attributeName = key.substring(1); const attributeValue = objectValue[key]; - attributes[attributeName] = - attributeValue === null ? null : String(attributeValue); + setOwnProperty( + attributes, + attributeName, + attributeValue === null ? null : String(attributeValue), + ); } else { // Element child(ren) const childValue = objectValue[key]!; diff --git a/eksml/src/converters/lossy.ts b/eksml/src/converters/lossy.ts index 4368847..f01ed10 100644 --- a/eksml/src/converters/lossy.ts +++ b/eksml/src/converters/lossy.ts @@ -52,6 +52,7 @@ */ import { parse, type TNode, type ParseOptions } from '#src/parser.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; // @generated:char-codes:begin const DOLLAR = 36; // $ // @generated:char-codes:end @@ -98,8 +99,9 @@ function convertNode(node: TNode): LossyValue { } // --- Build object with attributes --- - // Use null-prototype object to prevent __proto__ / constructor pollution - const elementObject: LossyObject = Object.create(null); + // Plain object for V8 hidden-class sharing; dangerous keys (__proto__) + // are guarded by setOwnProperty and own-key checks use Object.hasOwn. + const elementObject: LossyObject = {}; if (hasAttributes) { for (const key in attributes) { @@ -165,8 +167,10 @@ function convertNode(node: TNode): LossyValue { if (mixed !== null) { mixed.push({ [tag]: convertedValue }); } else { - if (!(tag in elementObject)) { - elementObject[tag] = convertedValue; + // Object.hasOwn (not `in`): on plain objects `in` sees inherited + // keys like `constructor`, which would corrupt sibling grouping. + if (!Object.hasOwn(elementObject, tag)) { + setOwnProperty(elementObject, tag, convertedValue); } else if (!Array.isArray(elementObject[tag])) { elementObject[tag] = [ elementObject[tag] as LossyValue, diff --git a/eksml/src/parser.ts b/eksml/src/parser.ts index b113941..5f22913 100644 --- a/eksml/src/parser.ts +++ b/eksml/src/parser.ts @@ -1,5 +1,6 @@ import { decodeXML, decodeHTML } from 'entities'; import { escapeRegExp } from '#src/utilities/escapeRegExp.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; import { filter } from '#src/utilities/filter.ts'; import { HTML_VOID_ELEMENTS, @@ -344,9 +345,8 @@ export function parse( break; } const token = S.substring(pos + 1, closePos); - if (declAttributes === null) - declAttributes = Object.create(null); - declAttributes![token] = null; + if (declAttributes === null) declAttributes = {}; + setOwnProperty(declAttributes!, token, null); pos = closePos + 1; continue; } @@ -358,8 +358,8 @@ export function parse( pos++; } const token = S.substring(tokenStart, pos); - if (declAttributes === null) declAttributes = Object.create(null); - declAttributes![token] = null; + if (declAttributes === null) declAttributes = {}; + setOwnProperty(declAttributes!, token, null); } // Skip internal DTD subset ([...]) if present @@ -460,8 +460,8 @@ export function parse( value = null; pos--; } - if (attributes === null) attributes = Object.create(null); - attributes![name] = value; + if (attributes === null) attributes = {}; + setOwnProperty(attributes!, name, value); } pos++; } @@ -636,8 +636,8 @@ export function parse( pos--; } // Allocate attributes object lazily on first attribute - if (attributes === null) attributes = Object.create(null); - attributes![name] = value; + if (attributes === null) attributes = {}; + setOwnProperty(attributes!, name, value); } pos++; } diff --git a/eksml/src/saxEngine.ts b/eksml/src/saxEngine.ts index a1ab10f..e812177 100644 --- a/eksml/src/saxEngine.ts +++ b/eksml/src/saxEngine.ts @@ -10,6 +10,8 @@ * to extract tokens via a single substring() rather than per-character +=. */ +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; + // @generated:char-codes:begin const GT = 62; // > const SLASH = 47; // / @@ -147,11 +149,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { let tagName = ''; let attributeName = ''; let attributeValue = ''; - // Null-prototype object to prevent prototype pollution via attribute names - // (e.g. `__proto__`, `constructor`). The `{ __proto__: null }` literal keeps - // the null prototype while letting V8 use fast in-object properties, which - // `Object.create(null)` does not (it forces slow dictionary mode). - let attributes: Attributes = { __proto__: null } as Attributes; + // Plain object so V8 shares hidden classes across attribute records and + // the attribute store site stays monomorphic (null-prototype objects get a + // unique dictionary-mode map each, which permanently blocks optimization + // of this function). Pollution via dangerous attribute names is prevented + // by setOwnProperty(), which stores `__proto__` as an own property. + let attributes: Attributes = {}; let special = ''; let rawTag = ''; let rawText = ''; @@ -227,7 +230,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { const tagName = '!' + body.substring(0, i); // Parse space-separated tokens as null-valued attributes - const attributes: Attributes = Object.create(null); + const attributes: Attributes = {}; while (i < bodyLength) { const charCode = body.charCodeAt(i); // Skip whitespace @@ -246,10 +249,10 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { const closeIndex = body.indexOf(quoteChar, i + 1); if (closeIndex === -1) { // Unclosed quote — take rest as token - attributes[body.substring(i + 1)] = null; + setOwnProperty(attributes, body.substring(i + 1), null); break; } - attributes[body.substring(i + 1, closeIndex)] = null; + setOwnProperty(attributes, body.substring(i + 1, closeIndex), null); i = closeIndex + 1; continue; } @@ -266,7 +269,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { break; i++; } - attributes[body.substring(tokenStart, i)] = null; + setOwnProperty(attributes, body.substring(tokenStart, i), null); } onDoctype!(tagName, attributes); @@ -427,16 +430,14 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (j >= chunkLength) { tagName = chunk.substring(i); - attributes = { __proto__: null } as Attributes; + attributes = {}; state = State.OPEN_TAG_NAME; i = chunkLength; break fastPath; } const name = chunk.substring(i, j); const attrs: Attributes | null = - handleOpenTag !== undefined - ? ({ __proto__: null } as Attributes) - : null; + handleOpenTag !== undefined ? ({} as Attributes) : null; let selfClosed = false; let c = chunk.charCodeAt(j); @@ -453,10 +454,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { body: while (true) { if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); state = State.OPEN_TAG_BODY; break fastPath; } @@ -473,10 +471,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { i++; if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); state = State.SELF_CLOSING; break fastPath; } @@ -507,10 +502,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (k >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = chunk.substring(i); state = State.ATTR_NAME; i = chunkLength; @@ -521,11 +513,11 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { i = k + 1; if (c !== EQ) { if (c === GT) { - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); break emitTag; } if (c === SLASH) { - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); i = k; // body's slash branch re-handles it continue body; } @@ -538,10 +530,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; state = State.ATTR_AFTER_NAME; break fastPath; @@ -550,7 +539,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { if (cc !== EQ) { // boolean attribute; '>' ends tag, '/' or a new attr // name is re-processed by the body loop - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); if (cc === GT) { i++; break emitTag; @@ -569,10 +558,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; state = State.ATTR_AFTER_EQ; break fastPath; @@ -585,10 +571,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ); if (quoteIndex === -1) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; attributeValue = chunk.substring(i + 1); state = @@ -600,12 +583,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { // Mirror the baseline's stale attributeValue so the // maxBufferSize check in write() behaves identically. if (maxBufferSize !== undefined) attributeValue = value; - if (attrs !== null) attrs[attrName] = value; + if (attrs !== null) setOwnProperty(attrs, attrName, value); i = quoteIndex + 1; continue body; } if (c === GT) { - if (attrs !== null) attrs[attrName] = ''; + if (attrs !== null) setOwnProperty(attrs, attrName, ''); i++; break emitTag; } @@ -626,10 +609,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (m >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; attributeValue = chunk.substring(i); state = State.ATTR_VALUE_UQ; @@ -638,7 +618,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } const value = chunk.substring(i, m); if (maxBufferSize !== undefined) attributeValue = value; - if (attrs !== null) attrs[attrName] = value; + if (attrs !== null) setOwnProperty(attrs, attrName, value); c = chunk.charCodeAt(m); if (c === GT) { i = m + 1; @@ -690,7 +670,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { state = State.OPEN_TAG_NAME; tagName = ''; - attributes = { __proto__: null } as Attributes; + attributes = {}; } continue; } @@ -784,12 +764,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { state = State.ATTR_AFTER_EQ; i = j + 1; } else if (charCode === GT) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.TEXT; i = j + 1; finishOpenTag(); } else if (charCode === SLASH) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.SELF_CLOSING; i = j + 1; } else { @@ -816,17 +796,17 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ) { i++; } else if (charCode === GT) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.TEXT; i++; finishOpenTag(); } else if (charCode === SLASH) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.SELF_CLOSING; i++; } else { // New attribute — boolean (no value) - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.ATTR_NAME; attributeName = ''; } @@ -854,7 +834,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ) { i++; } else if (charCode === GT) { - attributes[attributeName] = ''; + setOwnProperty(attributes, attributeName, ''); state = State.TEXT; i++; finishOpenTag(); @@ -877,7 +857,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { if (quoteIndex > i) attributeValue += chunk.substring(i, quoteIndex); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); state = State.OPEN_TAG_BODY; i = quoteIndex + 1; } @@ -895,7 +875,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { if (quoteIndex > i) attributeValue += chunk.substring(i, quoteIndex); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); state = State.OPEN_TAG_BODY; i = quoteIndex + 1; } @@ -927,7 +907,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } const charCode = chunk.charCodeAt(j); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); if (charCode === GT) { state = State.TEXT; i = j + 1; diff --git a/eksml/src/utilities/setOwnProperty.ts b/eksml/src/utilities/setOwnProperty.ts new file mode 100644 index 0000000..424ac12 --- /dev/null +++ b/eksml/src/utilities/setOwnProperty.ts @@ -0,0 +1,37 @@ +/** + * Assign a key onto a plain record, guarding the one key that is unsafe to + * store normally on plain objects: `__proto__`. + * + * Attribute and element records are plain `{}` objects rather than + * null-prototype objects. Null-prototype objects (via `Object.create(null)` + * or a `{ __proto__: null }` literal) get a unique dictionary-mode map per + * object in V8, which makes the store site's inline cache churn on every + * record. On large parse functions that churn permanently blocks + * optimization ("delaying optimization, IC changed"), costing up to 7x + * throughput for elements with a single attribute. Plain objects share + * hidden classes, so store sites stay monomorphic and the parsers optimize + * normally. + * + * A normal `record['__proto__'] = value` store on a plain object would walk + * the `Object.prototype.__proto__` setter instead of creating an own + * property (losing the entry, or mutating the record's prototype when the + * value is an object or `null`), so that key is defined explicitly. This + * keeps the prototype-pollution guarantees: dangerous names become own + * properties and `Object.prototype` is never touched. + */ +export function setOwnProperty( + target: Record, + key: string, + value: NoInfer, +): void { + if (key === '__proto__') { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + } else { + target[key] = value; + } +} diff --git a/eksml/src/xmlParseStream.ts b/eksml/src/xmlParseStream.ts index 06ed82c..944e861 100644 --- a/eksml/src/xmlParseStream.ts +++ b/eksml/src/xmlParseStream.ts @@ -5,6 +5,7 @@ import { HTML_VOID_ELEMENTS, HTML_RAW_CONTENT_TAGS, } from '#src/utilities/htmlConstants.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; import { convertItemToLossy } from '#src/converters/lossy.ts'; import type { LossyValue } from '#src/converters/lossy.ts'; import { convertItemToLossless } from '#src/converters/lossless.ts'; @@ -107,16 +108,17 @@ const SPACE = 32; // (space) /** * Convert a SAX `Attributes` object into the format used by `parse()`: * - Returns `null` when there are no attributes. - * - Returns an `Object.create(null)` prototype-free record otherwise. + * - Returns a plain-object record otherwise (dangerous keys such as + * `__proto__` are stored as own properties via setOwnProperty). */ function toNodeAttributes( attributes: Attributes, ): Record | null { const keys = Object.keys(attributes); if (keys.length === 0) return null; - const out: Record = Object.create(null); + const out: Record = {}; for (let i = 0; i < keys.length; i++) { - out[keys[i]!] = attributes[keys[i]!]!; + setOwnProperty(out, keys[i]!, attributes[keys[i]!]!); } return out; } @@ -130,7 +132,7 @@ function toNodeAttributes( * format that consumers expect. * * Returns `null` when the body contains no attributes, matching `parse()`. - * Uses `Object.create(null)` for prototype-free attribute records. + * Uses plain-object records; dangerous keys are guarded by setOwnProperty. */ function parsePIAttributes(body: string): Record | null { let attributes: Record | null = null; @@ -171,7 +173,7 @@ function parsePIAttributes(body: string): Record | null { const name = body.substring(nameStart, i); // Allocate attributes object lazily on first attribute - if (attributes === null) attributes = Object.create(null); + if (attributes === null) attributes = {}; // Skip whitespace while (i < bodyLength) { @@ -210,10 +212,14 @@ function parsePIAttributes(body: string): Record | null { const valueStartIndex = i; const end = body.indexOf(quoteCharacter, i); if (end === -1) { - attributes![name] = body.substring(valueStartIndex); + setOwnProperty(attributes!, name, body.substring(valueStartIndex)); i = bodyLength; } else { - attributes![name] = body.substring(valueStartIndex, end); + setOwnProperty( + attributes!, + name, + body.substring(valueStartIndex, end), + ); i = end + 1; } } else { @@ -230,14 +236,14 @@ function parsePIAttributes(body: string): Record | null { break; i++; } - attributes![name] = body.substring(valueStartIndex, i); + setOwnProperty(attributes!, name, body.substring(valueStartIndex, i)); } } else { - attributes![name] = null; + setOwnProperty(attributes!, name, null); } } else { // Boolean attribute (no value) - attributes![name] = null; + setOwnProperty(attributes!, name, null); } } diff --git a/eksml/test/saxEngine.test.ts b/eksml/test/saxEngine.test.ts index 64d605f..b320daf 100644 --- a/eksml/test/saxEngine.test.ts +++ b/eksml/test/saxEngine.test.ts @@ -734,41 +734,53 @@ describe('saxEngine', () => { // Issue 1 — Prototype pollution via plain {} for attributes // ========================================================================= describe('prototype pollution (issue 1)', () => { - it('attributes objects have null prototype (no Object.prototype properties)', () => { + it('parsing attributes does not pollute Object.prototype', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - // Object.create(null) produces an object with no prototype - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(attrs, 'bar')).toBe(true); + expect(attrs.bar).toBe('baz'); + expect(({} as Record).bar).toBeUndefined(); }); - it('attribute named "constructor" does not shadow Object.prototype.constructor', () => { + it('attribute named "constructor" becomes an own property, not the inherited one', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - // With Object.create(null), there is no inherited constructor + expect(Object.prototype.hasOwnProperty.call(attrs, 'constructor')).toBe( + true, + ); expect(attrs.constructor).toBe('bad'); - expect(Object.getPrototypeOf(attrs)).toBeNull(); + // Object.prototype.constructor must be untouched + expect({}.constructor).toBe(Object); }); - it('attribute named "__proto__" is stored as a plain key', () => { + it('attribute named "__proto__" is stored as an own key without mutating the prototype', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; + expect(Object.prototype.hasOwnProperty.call(attrs, '__proto__')).toBe( + true, + ); expect(attrs['__proto__']).toBe('polluted'); - // Should not actually pollute the prototype chain - expect(Object.getPrototypeOf(attrs)).toBeNull(); + // The record's prototype chain and Object.prototype are unaffected + expect(Object.getPrototypeOf(attrs)).toBe(Object.prototype); + expect( + ({} as Record)['polluted' as string], + ).toBeUndefined(); }); - it('DOCTYPE attributes have null prototype', () => { - const e = parseEvents(''); + it('DOCTYPE attributes with dangerous names do not pollute Object.prototype', () => { + const e = parseEvents(''); const attrs = e.doctypes[0]!.attrs; - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(attrs, '__proto__')).toBe( + true, + ); + expect(Object.getPrototypeOf(attrs)).toBe(Object.prototype); + expect(({} as Record).html).toBeUndefined(); }); - it('element with no explicit attributes still has null-prototype attributes', () => { - // When a tag opens, attributes = {} is created (line 339 in TAG_OPEN) - // Even if no attributes are parsed, the object should have null prototype + it('element with no explicit attributes gets an empty attributes record', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.keys(attrs)).toEqual([]); }); }); From 1fc80707a775c199c68524b5de8699f8c8bb0c0e Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sun, 19 Jul 2026 01:16:38 +0100 Subject: [PATCH 2/3] bench: add attr-heavy synthetic fixture mirroring sax-parser's benchmark 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. --- eksml/bench/parse.bench.ts | 33 +++++++++++++++ eksml/bench/stream.bench.ts | 42 ++++++++++++++++++++ eksml/bench/tokenize.bench.ts | 35 ++++++++++++++++ eksml/test/fixtures/attr-heavy-synthetic.xml | 1 + 4 files changed, 111 insertions(+) create mode 100644 eksml/test/fixtures/attr-heavy-synthetic.xml diff --git a/eksml/bench/parse.bench.ts b/eksml/bench/parse.bench.ts index 193cdfc..9743fd2 100644 --- a/eksml/bench/parse.bench.ts +++ b/eksml/bench/parse.bench.ts @@ -36,6 +36,10 @@ const atomFeed = fixture('atom-feed.xml'); const xhtmlPage = fixture('xhtml-page.xml'); const pomXml = fixture('pom.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each. Attribute-dense worst +// case, complements the attribute-light real fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // Pre-instantiate reusable parser instances const fxp = new XMLParser(); @@ -239,3 +243,32 @@ describe('XMLTV EPG (~9 KB)', () => { txmlParse(xmltvEpg); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — ~10 KB +// --------------------------------------------------------------------------- +describe('attr-heavy synthetic (~10 KB)', () => { + bench('eksml', () => { + parse(attrHeavy); + }); + + bench('fast-xml-parser', () => { + fxp.parse(attrHeavy); + }); + + bench('xml2js', async () => { + await parseStringPromise(attrHeavy); + }); + + bench('@xmldom/xmldom', () => { + domParser.parseFromString(attrHeavy, 'text/xml'); + }); + + bench('htmlparser2', () => { + parseDocument(attrHeavy); + }); + + bench('txml', () => { + txmlParse(attrHeavy); + }); +}); diff --git a/eksml/bench/stream.bench.ts b/eksml/bench/stream.bench.ts index 8f2c9a9..1df6b97 100644 --- a/eksml/bench/stream.bench.ts +++ b/eksml/bench/stream.bench.ts @@ -50,6 +50,10 @@ const fixture = (name: string) => const rssFeed = fixture('rss-feed.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); const pomXml = fixture('pom.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each. Attribute-dense worst +// case, complements the attribute-light real fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // --------------------------------------------------------------------------- // Shared tree node type (equivalent to eksml's TNode) @@ -76,6 +80,7 @@ const rssChunks256 = chunkString(rssFeed, 256); const xmltvChunks256 = chunkString(xmltvEpg, 256); const pomChunks256 = chunkString(pomXml, 256); const xmltvChunks64 = chunkString(xmltvEpg, 64); +const attrHeavyChunks256 = chunkString(attrHeavy, 256); // Shared tree-builder state, reset before each run. let roots: (Node | string)[] = []; @@ -468,3 +473,40 @@ describe('stream: XMLTV EPG (64 B chunks — stress)', () => { fxpSync(xmltvEpg); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — chunked streaming +// --------------------------------------------------------------------------- +describe('stream: attr-heavy synthetic (256 B chunks)', () => { + bench('eksml (XmlParseStream)', async () => { + await eksmlStream(attrHeavyChunks256); + }); + + bench('eksml (SAX)', () => { + eksmlSaxEngine(attrHeavyChunks256); + }); + + bench('sax', () => { + saxStream(attrHeavyChunks256); + }); + + bench('saxes', () => { + saxesStream(attrHeavyChunks256); + }); + + bench('htmlparser2', () => { + htmlparser2Stream(attrHeavyChunks256); + }); + + bench('@tuananh/sax-parser', () => { + tuananhStream(attrHeavyChunks256); + }); + + bench('easysax', () => { + easysaxStream(attrHeavyChunks256); + }); + + bench('fast-xml-parser (sync, no streaming)', () => { + fxpSync(attrHeavy); + }); +}); diff --git a/eksml/bench/tokenize.bench.ts b/eksml/bench/tokenize.bench.ts index 7c9d183..a790fb8 100644 --- a/eksml/bench/tokenize.bench.ts +++ b/eksml/bench/tokenize.bench.ts @@ -41,6 +41,11 @@ const fixture = (name: string) => const rssFeed = fixture('rss-feed.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); const pomXml = fixture('pom.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each, ~8 bytes per SAX +// event. Attribute-dense worst case, complements the attribute-light real +// fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // --------------------------------------------------------------------------- // Helpers @@ -58,6 +63,7 @@ const rssChunks256 = chunkString(rssFeed, 256); const xmltvChunks256 = chunkString(xmltvEpg, 256); const pomChunks256 = chunkString(pomXml, 256); const xmltvChunks64 = chunkString(xmltvEpg, 64); +const attrHeavyChunks256 = chunkString(attrHeavy, 256); // No-op function used as callback const noop = () => {}; @@ -292,3 +298,32 @@ describe('tokenize: XMLTV EPG (64 B chunks — stress)', () => { easysaxStream(xmltvChunks64); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — 256 B chunks +// --------------------------------------------------------------------------- +describe('tokenize: attr-heavy synthetic (256 B chunks)', () => { + bench('eksml', () => { + eksmlSaxEngine(attrHeavyChunks256); + }); + + bench('sax', () => { + saxStream(attrHeavyChunks256); + }); + + bench('saxes', () => { + saxesStream(attrHeavyChunks256); + }); + + bench('htmlparser2', () => { + htmlparser2Stream(attrHeavyChunks256); + }); + + bench('@tuananh/sax-parser', () => { + tuananhStream(attrHeavyChunks256); + }); + + bench('easysax', () => { + easysaxStream(attrHeavyChunks256); + }); +}); diff --git a/eksml/test/fixtures/attr-heavy-synthetic.xml b/eksml/test/fixtures/attr-heavy-synthetic.xml new file mode 100644 index 0000000..cf439d7 --- /dev/null +++ b/eksml/test/fixtures/attr-heavy-synthetic.xml @@ -0,0 +1 @@ +item-0value-0item-1value-1item-2value-2item-3value-3item-4value-4item-5value-5item-6value-6item-7value-7item-8value-8item-9value-9item-10value-10item-11value-11item-12value-12item-13value-13item-14value-14item-15value-15item-16value-16item-17value-17item-18value-18item-19value-19item-20value-20item-21value-21item-22value-22item-23value-23item-24value-24item-25value-25item-26value-26item-27value-27item-28value-28item-29value-29item-30value-30item-31value-31item-32value-32item-33value-33item-34value-34item-35value-35item-36value-36item-37value-37item-38value-38item-39value-39item-40value-40item-41value-41item-42value-42item-43value-43item-44value-44item-45value-45item-46value-46item-47value-47item-48value-48item-49value-49item-50value-50item-51value-51item-52value-52item-53value-53item-54value-54item-55value-55item-56value-56item-57value-57item-58value-58item-59value-59item-60value-60item-61value-61item-62value-62item-63value-63item-64value-64item-65value-65item-66value-66item-67value-67item-68value-68item-69value-69item-70value-70item-71value-71item-72value-72item-73value-73item-74value-74item-75value-75item-76value-76item-77value-77item-78value-78item-79value-79item-80value-80item-81value-81item-82value-82item-83value-83item-84value-84item-85value-85item-86value-86item-87value-87item-88value-88item-89value-89item-90value-90item-91value-91item-92value-92item-93value-93item-94value-94item-95value-95item-96value-96item-97value-97item-98value-98item-99value-99item-100value-100item-101value-101item-102value-102item-103value-103item-104value-104item-105value-105item-106value-106item-107value-107item-108value-108item-109value-109item-110value-110item-111value-111item-112value-112item-113value-113item-114value-114item-115value-115item-116value-116item-117value-117item-118value-118item-119value-119item-120value-120item-121value-121item-122value-122item-123value-123item-124value-124item-125value-125item-126value-126item-127value-127item-128value-128item-129value-129item-130value-130item-131value-131item-132value-132item-133value-133item-134value-134item-135value-135item-136value-136item-137value-137item-138value-138item-139value-139item-140value-140item-141value-141item-142value-142item-143value-143item-144value-144item-145value-145item-146value-146item-147value-147item-148value-148item-149value-149item-150value-150item-151value-151item-152value-152item-153value-153item-154value-154item-155value-155item-156value-156item-157value-157 \ No newline at end of file From 4355608a1b1ffe10f36a6dc140b5ffd7dc95d818 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sun, 19 Jul 2026 01:25:15 +0100 Subject: [PATCH 3/3] docs: refresh benchmark numbers, add attr-heavy fixture results 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. --- eksml/BENCHMARKS.md | 315 ++++++++++++++++++++++++-------------------- eksml/README.md | 10 +- 2 files changed, 174 insertions(+), 151 deletions(-) diff --git a/eksml/BENCHMARKS.md b/eksml/BENCHMARKS.md index 3b1433c..dff6b19 100644 --- a/eksml/BENCHMARKS.md +++ b/eksml/BENCHMARKS.md @@ -1,6 +1,6 @@ # Benchmarks -All benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) on Node.js. Fixtures range from ~100 B to ~30 KB of real-world XML (RSS feeds, Atom feeds, SOAP envelopes, Maven POMs, XMLTV EPGs). Full benchmark source is in [`bench/`](./bench/). +All benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) on Node.js. Fixtures range from ~100 B to ~30 KB of real-world XML (RSS feeds, Atom feeds, SOAP envelopes, Maven POMs, XMLTV EPGs), plus a ~10 KB synthetic attribute-heavy stress document. Full benchmark source is in [`bench/`](./bench/). Run them yourself: @@ -21,64 +21,71 @@ Parse an XML string into a tree structure. Atom (~3 KB) POM (~5 KB) EPG (~9 KB) + attr-heavy (~10 KB) Eksml - 1,367,666 op/s - 85,865 op/s - 64,349 op/s - 35,517 op/s - 13,759 op/s - 17,521 op/s + 1,221,950 op/s + 87,714 op/s + 66,135 op/s + 35,922 op/s + 13,943 op/s + 16,917 op/s + 14,420 op/s tXml - 933,149 op/s + 1,007,229 op/s -- - 48,483 op/s - 27,743 op/s - 10,431 op/s - 13,589 op/s + 45,389 op/s + 27,982 op/s + 9,383 op/s + 13,520 op/s + 11,369 op/s htmlparser2 - 504,972 op/s - 24,360 op/s - 17,565 op/s - 9,660 op/s - 3,529 op/s - 4,902 op/s + 523,318 op/s + 25,204 op/s + 17,709 op/s + 10,151 op/s + 3,590 op/s + 4,942 op/s + 3,886 op/s fast-xml-parser - 166,395 op/s - 9,365 op/s - 7,477 op/s - 3,680 op/s - 1,339 op/s - 2,342 op/s + 171,272 op/s + 9,932 op/s + 7,520 op/s + 3,745 op/s + 1,360 op/s + 2,358 op/s + 1,282 op/s xml2js - 135,079 op/s - 9,673 op/s - 6,895 op/s - 3,912 op/s - 1,525 op/s - 2,068 op/s + 133,748 op/s + 9,809 op/s + 6,727 op/s + 3,951 op/s + 1,563 op/s + 2,080 op/s + 1,510 op/s @xmldom/xmldom - 93,167 op/s - 6,088 op/s - 4,924 op/s - 2,471 op/s - 853 op/s - 1,344 op/s + 89,126 op/s + 5,640 op/s + 5,112 op/s + 2,604 op/s + 895 op/s + 1,414 op/s + 702 op/s -Eksml is **1.3-1.5x faster than tXml**, **2.7-4x faster than htmlparser2**, **7-10x faster than fast-xml-parser**, and **13-16x faster than xmldom**. +Eksml is **1.2-1.5x faster than tXml**, **2.3-3.9x faster than htmlparser2**, **7-11x faster than fast-xml-parser**, and **12-21x faster than xmldom**. > [!note] > tXml crashed on the RSS fixture @@ -94,59 +101,67 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full EPG (~9 KB) POM (~5 KB) EPG 64 B stress + attr-heavy (~10 KB) Eksml (SAX) - 83,958 op/s - 17,182 op/s - 14,213 op/s - 13,714 op/s + 95,408 op/s + 17,945 op/s + 13,932 op/s + 14,932 op/s + 13,431 op/s easysax - 65,233 op/s - 14,352 op/s - 10,898 op/s - 11,075 op/s + 66,897 op/s + 14,591 op/s + 11,235 op/s + 10,517 op/s + 10,604 op/s Eksml (XmlParseStream) - 30,238 op/s - 8,432 op/s - 7,916 op/s - 4,679 op/s + 30,549 op/s + 9,794 op/s + 8,947 op/s + 4,457 op/s + 8,690 op/s htmlparser2 - 29,467 op/s - 7,269 op/s - 5,977 op/s - 6,703 op/s + 30,083 op/s + 7,384 op/s + 5,461 op/s + 6,912 op/s + 6,877 op/s saxes - 28,439 op/s - 6,233 op/s - 6,621 op/s - 5,665 op/s + 26,185 op/s + 6,602 op/s + 6,780 op/s + 5,821 op/s + 5,634 op/s sax - 14,498 op/s - 3,757 op/s - 2,918 op/s - 3,724 op/s + 14,988 op/s + 3,561 op/s + 3,044 op/s + 3,806 op/s + 2,922 op/s @tuananh/sax-parser - 13,598 op/s - 4,283 op/s - 3,309 op/s - 2,444 op/s + 14,084 op/s + 3,918 op/s + 3,249 op/s + 2,394 op/s + 2,410 op/s -Eksml's SAX parser is **1.2-1.3x faster than easysax**, **2-3x faster than htmlparser2/saxes**, and **3.5-6x faster than sax**. +Eksml's SAX parser is **1.2-1.4x faster than easysax**, **2-3.6x faster than htmlparser2/saxes**, and **3.9-6.4x faster than sax**. > [!note] > @tuananh/sax-parser is a native C++ addon; every `write()` crosses the JS↔C++ boundary, which dominates in chunked streaming. @@ -162,52 +177,59 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw EPG (~9 KB) POM (~5 KB) EPG 64 B stress + attr-heavy (~10 KB) Eksml (SAX) - 105,293 op/s - 20,197 op/s - 16,401 op/s - 16,656 op/s + 104,122 op/s + 20,970 op/s + 19,927 op/s + 17,063 op/s + 15,605 op/s easysax - 66,841 op/s - 15,552 op/s - 12,501 op/s - 12,026 op/s + 73,389 op/s + 15,678 op/s + 11,899 op/s + 11,982 op/s + 11,933 op/s saxes - 31,800 op/s - 9,408 op/s - 8,328 op/s - 8,885 op/s + 35,355 op/s + 9,492 op/s + 7,290 op/s + 8,870 op/s + 8,562 op/s htmlparser2 - 30,724 op/s - 7,404 op/s - 5,771 op/s - 7,058 op/s + 30,520 op/s + 7,647 op/s + 6,334 op/s + 6,475 op/s + 7,145 op/s sax - 14,970 op/s - 3,936 op/s - 3,144 op/s - 3,557 op/s + 15,307 op/s + 3,679 op/s + 3,184 op/s + 3,928 op/s + 3,315 op/s @tuananh/sax-parser - 14,904 op/s - 4,513 op/s - 3,463 op/s - 2,307 op/s + 12,907 op/s + 4,197 op/s + 3,217 op/s + 2,388 op/s + 2,608 op/s -Eksml's tokenizer is **1.3-1.6x faster than easysax**, **1.9-3.4x faster than saxes/htmlparser2**, and **4.5-7x faster than sax**. +Eksml's tokenizer is **1.3-1.7x faster than easysax**, **1.8-3.4x faster than saxes/htmlparser2**, and **4.3-6.8x faster than sax**. > [!note] > easysax parses attributes lazily (`startNode` receives a `getAttr()` thunk); the benchmark invokes it so every parser materializes attributes, the work all other parsers do unconditionally for their open-tag events. @@ -227,85 +249,86 @@ Serialize a pre-parsed in-memory tree back to XML. EPG (~30 KB) - tXml - 2,485,611 op/s - -- - 225,162 op/s - 96,822 op/s - 48,394 op/s - 43,455 op/s + Eksml (validate: false) + 2,956,198 op/s + 398,598 op/s + 242,411 op/s + 105,006 op/s + 67,255 op/s + 50,517 op/s - htmlparser2 - 2,027,682 op/s - 100,516 op/s - 73,469 op/s - 30,964 op/s - 14,839 op/s - 15,867 op/s + tXml + 2,594,809 op/s + -- + 205,646 op/s + 84,696 op/s + 44,360 op/s + 39,346 op/s - Eksml (validate: false) - 1,936,294 op/s - 357,848 op/s - 204,989 op/s - 94,131 op/s - 64,960 op/s - 36,462 op/s + Eksml + 2,290,597 op/s + 319,812 op/s + 246,313 op/s + 101,814 op/s + 53,384 op/s + 36,384 op/s - Eksml - 1,749,947 op/s - 291,007 op/s - 176,028 op/s - 78,439 op/s - 53,619 op/s - 32,253 op/s + htmlparser2 + 2,144,522 op/s + 103,346 op/s + 75,736 op/s + 31,101 op/s + 14,203 op/s + 17,500 op/s @xmldom/xmldom - 1,117,265 op/s - 45,503 op/s - 39,805 op/s - 15,015 op/s - 6,973 op/s - 10,019 op/s + 1,133,615 op/s + 42,888 op/s + 40,083 op/s + 14,805 op/s + 7,392 op/s + 10,422 op/s fast-xml-parser - 347,964 op/s - 23,913 op/s - 28,453 op/s - 11,452 op/s - 4,610 op/s - 3,609 op/s + 333,373 op/s + 24,028 op/s + 29,116 op/s + 12,108 op/s + 4,785 op/s + 3,645 op/s xml2js - 239,685 op/s - 15,809 op/s - 19,398 op/s - 8,295 op/s - 3,954 op/s - 3,898 op/s + 239,138 op/s + 17,067 op/s + 20,056 op/s + 8,778 op/s + 4,332 op/s + 4,174 op/s -Eksml wins POM and RSS (where tXml crashes) and, with `validate: false`, ties tXml on Atom (0.97x); tXml keeps the lead on the small, SOAP, and EPG fixtures. Unlike tXml's stringify, Eksml's writer validates tag/attribute names by default, guards against circular references, and escapes mixed-quote attribute values — `validate: false` skips only the name validation. Eksml is **2-6x faster than @xmldom/xmldom** and **4-13x faster than fast-xml-parser/xml2js** at serialization on non-trivial documents. +With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With validation on (the default), Eksml still wins RSS, SOAP, Atom, and POM; tXml edges out the small and EPG fixtures. Unlike tXml's stringify, Eksml's writer validates tag/attribute names by default, guards against circular references, and escapes mixed-quote attribute values — `validate: false` skips only the name validation. Eksml is **2-7x faster than @xmldom/xmldom** and **7-19x faster than fast-xml-parser/xml2js** at serialization on non-trivial documents. > [!note] > tXml crashed on the RSS fixture ## Fixtures -| Fixture | Size | Description | -| ------- | -------- | ------------------------------------ | -| small | ~100 B | Minimal XML element | -| RSS | ~3 KB | Real-world RSS feed (`rss-feed.xml`) | -| SOAP | ~2-3 KB | SOAP envelope (`soap-envelope.xml`) | -| Atom | ~3-6 KB | Atom feed (`atom-feed.xml`) | -| POM | ~5-8 KB | Maven POM (`pom.xml`) | -| EPG | ~9-30 KB | XMLTV EPG listing (`xmltv-epg.xml`) | +| Fixture | Size | Description | +| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| small | ~100 B | Minimal XML element | +| RSS | ~3 KB | Real-world RSS feed (`rss-feed.xml`) | +| SOAP | ~2-3 KB | SOAP envelope (`soap-envelope.xml`) | +| Atom | ~3-6 KB | Atom feed (`atom-feed.xml`) | +| POM | ~5-8 KB | Maven POM (`pom.xml`) | +| EPG | ~9-30 KB | XMLTV EPG listing (`xmltv-epg.xml`) | +| attr-heavy | ~10 KB | Synthetic stress doc mirroring @tuananh/sax-parser's benchmark: 158 tiny elements, one attribute each (`attr-heavy-synthetic.xml`) | Size varies between tables because parsing benchmarks measure input size while serialization benchmarks measure output size (which includes indentation and formatting). diff --git a/eksml/README.md b/eksml/README.md index 10660af..043a904 100644 --- a/eksml/README.md +++ b/eksml/README.md @@ -531,12 +531,12 @@ import { ## Benchmarks -Eksml is consistently the fastest at parsing, streaming, and tokenization, and trades the serialization lead with tXml. Benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) against real-world XML fixtures from ~100 B to ~30 KB. +Eksml is consistently the fastest at parsing, streaming, and tokenization, and leads serialization with `validate: false`. Benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) against real-world XML fixtures from ~100 B to ~30 KB, plus a synthetic attribute-heavy stress document. -- **DOM parsing**: 1.3-1.5x faster than tXml, 2.7-4x faster than htmlparser2, 7-16x faster than fast-xml-parser/xml2js/xmldom -- **SAX streaming**: 1.2-1.3x faster than easysax, 2-3x faster than htmlparser2/saxes, 3.5-6x faster than sax -- **Raw tokenization**: 1.3-1.6x faster than easysax, 1.9-3.4x faster than saxes/htmlparser2, 4.5-7x faster than sax -- **Serialization**: Beats tXml on POM/RSS and ties it on Atom with `validate: false`; 2-6x faster than xmldom and 4-13x faster than fast-xml-parser/xml2js +- **DOM parsing**: 1.2-1.5x faster than tXml, 2.3-3.9x faster than htmlparser2, 7-21x faster than fast-xml-parser/xml2js/xmldom +- **SAX streaming**: 1.2-1.4x faster than easysax, 2-3.6x faster than htmlparser2/saxes, 3.9-6.4x faster than sax +- **Raw tokenization**: 1.3-1.7x faster than easysax, 1.8-3.4x faster than saxes/htmlparser2, 4.3-6.8x faster than sax +- **Serialization**: Fastest on every fixture with `validate: false`; with validation on (the default), wins RSS/SOAP/Atom/POM while tXml edges small/EPG; 2-7x faster than xmldom and 7-19x faster than fast-xml-parser/xml2js Full results with per-fixture op/s tables: **[BENCHMARKS.md](https://github.com/evoactivity/eksml/blob/main/eksml/BENCHMARKS.md)**