Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 169 additions & 146 deletions eksml/BENCHMARKS.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions eksml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand Down
33 changes: 33 additions & 0 deletions eksml/bench/parse.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <item id="N"> 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();
Expand Down Expand Up @@ -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);
});
});
42 changes: 42 additions & 0 deletions eksml/bench/stream.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <item id="N"> 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)
Expand All @@ -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)[] = [];
Expand Down Expand Up @@ -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);
});
});
35 changes: 35 additions & 0 deletions eksml/bench/tokenize.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <item id="N"> 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
Expand All @@ -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 = () => {};
Expand Down Expand Up @@ -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);
});
});
10 changes: 7 additions & 3 deletions eksml/src/converters/fromLossy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string | null>;
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]!;
Expand Down
12 changes: 8 additions & 4 deletions eksml/src/converters/lossy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 9 additions & 9 deletions eksml/src/parser.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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++;
}
Expand Down Expand Up @@ -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++;
}
Expand Down
Loading