From 66451d672385f227ebc9d063959e2cba5667dd01 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Wed, 22 Jul 2026 12:20:55 +0100 Subject: [PATCH 1/2] bench: restructure BENCHMARKS.md to a table per fixture, add generator Each section opens with an overview ranking libraries by geometric mean across its fixtures (with the missing-fixture dominance rule and a vs-fastest column), followed by one table per fixture sorted by op/s with each library's speedup relative to the fixture winner. All tables are generated from a vitest --outputJson report by bench/benchmarks-to-markdown.mjs, run via pnpm bench:tables. The streaming suite now registers the same full event surface as the tokenize suite (comment and PI noops added): @tuananh/sax-parser's collector slows roughly a third on EPG once those listeners are registered, verified to be independent of the events occurring, so unequal surfaces made the suites disagree about the same parsers. fast-xml-parser is dropped from the streaming suite since it cannot stream. Also adds an opt-in large-document benchmark (pnpm bench:large): seeded faker-generated product catalogues at 10/50/250 MB, gitignored and generated on demand, delivered in 64 KB chunks with the same full event surface. The 10 MB suite is part of the standard bench:tables run and published as its own section. Hand-written speedup-range prose is removed, the per-fixture tables carry exact ratios. Remaining prose verified against the report. --- .gitignore | 6 + eksml/BENCHMARKS.md | 1330 ++++++++++++++++++++---- eksml/bench/benchmarks-to-markdown.mjs | 289 +++++ eksml/bench/generate-large-fixture.mjs | 158 +++ eksml/bench/large.bench.ts | 183 ++++ eksml/bench/stream.bench.ts | 56 +- eksml/package.json | 3 + pnpm-lock.yaml | 9 + 8 files changed, 1782 insertions(+), 252 deletions(-) create mode 100644 eksml/bench/benchmarks-to-markdown.mjs create mode 100644 eksml/bench/generate-large-fixture.mjs create mode 100644 eksml/bench/large.bench.ts diff --git a/.gitignore b/.gitignore index 6ba2fdb..28fca0d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ coverage # OS .DS_Store Thumbs.db + +# generated benchmark reports +eksml/bench-results.json + +# generated large benchmark fixtures +eksml/test/fixtures/large/ diff --git a/eksml/BENCHMARKS.md b/eksml/BENCHMARKS.md index c70d034..310f4ef 100644 --- a/eksml/BENCHMARKS.md +++ b/eksml/BENCHMARKS.md @@ -12,334 +12,1225 @@ pnpm --filter @eksml/xml bench Parse an XML string into a tree structure. +### Overview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Librarygeometric meanvs fastest
Eksml71,409 op/s
tXml35,936 op/s1.99x slower
htmlparser214,707 op/s4.86x slower
xml2js5,564 op/s12.83x slower
fast-xml-parser5,469 op/s13.06x slower
@xmldom/xmldom3,527 op/s20.25x slower
+ +> [!note] +> tXml crashed on the RSS fixture + +### small (~100 B) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml1,661,243 op/s
tXml1,008,262 op/s1.65x slower
htmlparser2543,433 op/s3.06x slower
fast-xml-parser165,332 op/s10.05x slower
xml2js129,948 op/s12.78x slower
@xmldom/xmldom96,510 op/s17.21x slower
+ +### RSS (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml139,120 op/s
htmlparser224,828 op/s5.60x slower
fast-xml-parser9,819 op/s14.17x slower
xml2js9,512 op/s14.63x slower
@xmldom/xmldom6,090 op/s22.84x slower
+ +### SOAP (~2 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml87,674 op/s
tXml48,827 op/s1.80x slower
htmlparser217,611 op/s4.98x slower
fast-xml-parser7,163 op/s12.24x slower
xml2js7,089 op/s12.37x slower
@xmldom/xmldom5,055 op/s17.34x slower
+ +### Atom (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml52,445 op/s
tXml27,496 op/s1.91x slower
htmlparser29,508 op/s5.52x slower
xml2js3,847 op/s13.63x slower
fast-xml-parser3,534 op/s14.84x slower
@xmldom/xmldom2,578 op/s20.34x slower
+ +### POM (~5 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml19,591 op/s
tXml11,172 op/s1.75x slower
htmlparser23,526 op/s5.56x slower
xml2js1,590 op/s12.32x slower
fast-xml-parser1,275 op/s15.37x slower
@xmldom/xmldom860 op/s22.77x slower
+ +### EPG (~9 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml23,252 op/s
tXml13,230 op/s1.76x slower
htmlparser24,745 op/s4.90x slower
fast-xml-parser2,198 op/s10.58x slower
xml2js2,056 op/s11.31x slower
@xmldom/xmldom1,405 op/s16.55x slower
+ +### attr-heavy (~10 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml19,559 op/s
tXml10,764 op/s1.82x slower
htmlparser23,938 op/s4.97x slower
xml2js1,498 op/s13.06x slower
fast-xml-parser1,272 op/s15.38x slower
@xmldom/xmldom733 op/s26.67x slower
+ +## SAX Streaming (256 B chunks, with tree building) + +Chunked streaming parse where each parser tokenizes SAX events and builds a full DOM tree. + +### Overview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Librarygeometric meanvs fastest
Eksml (SAX)21,566 op/s
easysax16,708 op/s1.29x slower
@tuananh/sax-parser15,546 op/s1.39x slower
Eksml (XmlParseStream)10,703 op/s2.02x slower
htmlparser28,747 op/s2.47x slower
saxes8,494 op/s2.54x slower
sax4,583 op/s4.71x slower
+ +@tuananh/sax-parser 1.6 has largely closed its streaming gap: the EPG 64 B stress fixture is a coin flip between it and Eksml, and it is clear second on EPG, POM, and attr-heavy. + +> [!note] +> @tuananh/sax-parser is a native C++ addon; marshalling event arguments across the JS↔C++ boundary dominates its chunked-streaming cost. Since 1.4 it skips argument materialization for zero-arity listeners, so the no-op benchmark callbacks declare parameters — every other parser materializes arguments unconditionally, and the comparison requires equal work. Its throughput is also sensitive to which listener types exist at all: registering comment/cdata/PI listeners costs it roughly a third on EPG, whose ~12.5 KB produce just 9 comments, 1 processing instruction, and no CDATA — the cost comes from the listeners being registered, not from the events occurring. Both suites register the same full event surface (open/close/text/cdata/comment/PI) so their numbers are comparable. + +### RSS (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)86,980 op/s
easysax66,925 op/s1.30x slower
Eksml (XmlParseStream)32,998 op/s2.64x slower
saxes29,356 op/s2.96x slower
htmlparser228,528 op/s3.05x slower
@tuananh/sax-parser23,954 op/s3.63x slower
sax14,792 op/s5.88x slower
+ +### EPG (~9 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)16,960 op/s
@tuananh/sax-parser15,829 op/s1.07x slower
easysax14,401 op/s1.18x slower
Eksml (XmlParseStream)10,874 op/s1.56x slower
htmlparser27,039 op/s2.41x slower
saxes6,612 op/s2.57x slower
sax3,826 op/s4.43x slower
+ +### POM (~5 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)15,205 op/s
@tuananh/sax-parser13,789 op/s1.10x slower
easysax11,127 op/s1.37x slower
Eksml (XmlParseStream)8,754 op/s1.74x slower
saxes6,692 op/s2.27x slower
htmlparser25,647 op/s2.69x slower
sax3,011 op/s5.05x slower
+ +### EPG 64 B stress + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
@tuananh/sax-parser15,447 op/s
Eksml (SAX)15,253 op/s1.01x slower
easysax11,398 op/s1.36x slower
htmlparser26,797 op/s2.27x slower
saxes6,169 op/s2.50x slower
Eksml (XmlParseStream)5,087 op/s3.04x slower
sax3,799 op/s4.07x slower
+ +### attr-heavy (~10 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)13,636 op/s
@tuananh/sax-parser11,243 op/s1.21x slower
easysax10,651 op/s1.28x slower
Eksml (XmlParseStream)8,787 op/s1.55x slower
htmlparser26,642 op/s2.05x slower
saxes5,518 op/s2.47x slower
sax3,121 op/s4.37x slower
+ +## Raw Tokenization (no-op callbacks) + +Pure scanner throughput with no downstream work -- isolates the tokenizer's raw speed. + +### Overview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Librarygeometric meanvs fastest
Eksml (SAX)27,205 op/s
easysax18,038 op/s1.51x slower
@tuananh/sax-parser16,027 op/s1.70x slower
saxes11,093 op/s2.45x slower
htmlparser29,308 op/s2.92x slower
sax4,665 op/s5.83x slower
+ +@tuananh/sax-parser 1.6 is now the clear second on every fixture except RSS, where it trails 5x. + +> [!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. The no-op callbacks declare parameters so @tuananh/sax-parser's zero-arity fast path (since 1.4) cannot skip argument materialization. + +### RSS (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)113,398 op/s
easysax72,718 op/s1.56x slower
saxes34,958 op/s3.24x slower
htmlparser228,807 op/s3.94x slower
@tuananh/sax-parser22,715 op/s4.99x slower
sax14,575 op/s7.78x slower
+ +### EPG (~9 KB) + - - - - - - - + + - - - - - - - - + + + - - - - - - - - + + + - - - - - - - - + + + - - - - - - - - + + + - - - - - - - - + + + - - - - - - - - + + +
Librarysmall (~100 B)RSS (~3 KB)SOAP (~2 KB)Atom (~3 KB)POM (~5 KB)EPG (~9 KB)attr-heavy (~10 KB)op/svs fastest
Eksml1,671,761 op/s136,836 op/s80,224 op/s50,820 op/s19,368 op/s23,393 op/s19,238 op/sEksml (SAX)20,910 op/s
tXml938,409 op/s--48,620 op/s27,021 op/s11,108 op/s13,098 op/s10,194 op/s@tuananh/sax-parser16,455 op/s1.27x slower
htmlparser2535,825 op/s25,042 op/s15,399 op/s9,501 op/s3,306 op/s4,455 op/s3,918 op/seasysax15,663 op/s1.33x slower
fast-xml-parser156,563 op/s9,766 op/s7,369 op/s3,294 op/s1,315 op/s2,233 op/s1,271 op/ssaxes9,285 op/s2.25x slower
xml2js119,736 op/s8,574 op/s6,932 op/s3,876 op/s1,589 op/s2,036 op/s1,406 op/shtmlparser27,642 op/s2.74x slower
@xmldom/xmldom93,498 op/s6,212 op/s4,952 op/s2,551 op/s870 op/s1,396 op/s732 op/ssax3,938 op/s5.31x slower
-Eksml is **1.7-1.9x faster than tXml**, **3-6x faster than htmlparser2**, **10-15x faster than fast-xml-parser**, and **16-26x faster than xmldom**. - -> [!note] -> tXml crashed on the RSS fixture - -## SAX Streaming (256 B chunks, with tree building) - -Chunked streaming parse where each parser tokenizes SAX events and builds a full DOM tree. +### POM (~5 KB) - - - - - + + - - - - - + + - - - - - - + + + - - - - - - + + + - - - - - + + - - - - - + + + + + + + + +
LibraryRSS (~3 KB)EPG (~9 KB)POM (~5 KB)EPG 64 B stressattr-heavy (~10 KB)op/svs fastest
Eksml (SAX)88,604 op/s18,605 op/s14,986 op/s15,003 op/s13,719 op/s19,668 op/s
easysax66,566 op/s13,357 op/s11,065 op/s11,233 op/s10,310 op/s@tuananh/sax-parser14,481 op/s1.36x slower
Eksml (XmlParseStream)33,687 op/s10,776 op/s9,020 op/s5,186 op/s9,352 op/seasysax12,674 op/s1.55x slower
saxes27,902 op/s5,884 op/s6,687 op/s6,013 op/s5,117 op/s8,199 op/s2.40x slower
htmlparser226,433 op/s7,080 op/s5,779 op/s6,598 op/s6,791 op/s6,288 op/s3.13x slower
sax3,109 op/s6.33x slower
+ +### EPG 64 B stress + + + + + + + + + + + - - - - - + + - - - - - - + + + + + + + + + + + + + - - - - - - + + +
Libraryop/svs fastest
Eksml (SAX)17,497 op/s
@tuananh/sax-parser24,238 op/s19,238 op/s12,849 op/s18,062 op/s11,681 op/s15,967 op/s1.10x slower
sax14,553 op/s3,773 op/s2,848 op/s3,775 op/s3,055 op/seasysax11,017 op/s1.59x slower
saxes8,085 op/s2.16x slower
htmlparser27,079 op/s2.47x slower
fast-xml-parser (sync, no streaming)9,296 op/s1,560 op/s1,659 op/s1,471 op/s1,271 op/ssax3,763 op/s4.65x slower
-Eksml's SAX parser is **1.3-1.4x faster than easysax**, **2.1-3.4x faster than htmlparser2/saxes**, and **4-6.1x faster than sax**. @tuananh/sax-parser 1.6 has largely closed its streaming gap: it takes the two EPG columns (its 64 B number now exceeds its 256 B number, so per-write cost is effectively gone) while Eksml holds RSS, POM, and attr-heavy. - -> [!note] -> @tuananh/sax-parser is a native C++ addon; marshalling event arguments across the JS↔C++ boundary dominates its chunked-streaming cost. Since 1.4 it skips argument materialization for zero-arity listeners, so the no-op benchmark callbacks declare parameters — every other parser materializes arguments unconditionally, and the comparison requires equal work. +### attr-heavy (~10 KB) -> [!note] -> fast-xml-parser cannot stream; its row parses the fully buffered document synchronously and is included as a reference point. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (SAX)18,262 op/s
@tuananh/sax-parser12,236 op/s1.49x slower
easysax12,009 op/s1.52x slower
saxes7,808 op/s2.34x slower
htmlparser27,128 op/s2.56x slower
sax3,290 op/s5.55x slower
-## Raw Tokenization (no-op callbacks) +## Large document (10 MB, 64 KB chunks) -Pure scanner throughput with no downstream work -- isolates the tokenizer's raw speed. +Chunked tokenization of a 10 MB document — a seeded, faker-generated product catalogue (`bench/generate-large-fixture.mjs`) with real-world shape: mixed attribute density, varied text lengths, comments, and CDATA. The fixture is generated on demand into `test/fixtures/large/` (gitignored); because generation is seeded, every machine benchmarks a byte-identical document. Handler policy matches the tokenization suite. 50 MB and 250 MB runs are available via `pnpm bench:large`. - - - - - + + - - - - - + + - - - - - + + - - - - - + + - - - - - + + - - - - - + + - - - - - + +
LibraryRSS (~3 KB)EPG (~9 KB)POM (~5 KB)EPG 64 B stressattr-heavy (~10 KB)op/svs fastest
Eksml (SAX)110,856 op/s21,940 op/s19,298 op/s16,679 op/s17,334 op/s32.0 op/s
easysax72,166 op/s14,151 op/s12,304 op/s11,824 op/s11,712 op/s21.7 op/s1.48x slower
saxes35,117 op/s8,889 op/s8,110 op/s8,692 op/s8,225 op/s12.1 op/s2.64x slower
htmlparser230,412 op/s7,587 op/s5,852 op/s7,028 op/s7,186 op/s10.1 op/s3.18x slower
@tuananh/sax-parser22,011 op/s16,577 op/s14,692 op/s15,889 op/s11,361 op/s8.5 op/s3.74x slower
sax14,798 op/s3,871 op/s3,102 op/s3,935 op/s3,229 op/s5.4 op/s5.94x slower
-Eksml's tokenizer is **1.4-1.6x faster than easysax**, **2.2-3.6x faster than saxes/htmlparser2**, and **4.2-7.5x faster than sax**. @tuananh/sax-parser 1.6 is now the clear second on the EPG and POM columns (Eksml's lead there is 1.05-1.3x), though it trails 5x on RSS. - -> [!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. The no-op callbacks declare parameters so @tuananh/sax-parser's zero-arity fast path (since 1.4) cannot skip argument materialization. - ## XML Serialization (tree to string) Serialize a pre-parsed in-memory tree back to XML. +### Overview + - - - - - - + + - - - - - - + + - - - - - - + + - - - - - - + + - - - - - - + + - - - - - - + + - - - - - - + + - - - - - - + +
Librarysmall (~100 B)RSS (~3 KB)SOAP (~3 KB)Atom (~6 KB)POM (~8 KB)EPG (~30 KB)geometric meanvs fastest
Eksml (validate: false)2,871,391 op/s397,795 op/s268,371 op/s114,966 op/s65,675 op/s48,937 op/s218,385 op/s
Eksml2,505,673 op/s333,746 op/s239,973 op/s99,309 op/s56,743 op/s44,820 op/s190,592 op/s1.15x slower
tXml2,497,332 op/s--226,516 op/s94,239 op/s48,090 op/s43,259 op/s162,265 op/s1.35x slower
htmlparser21,970,254 op/s91,344 op/s69,848 op/s31,198 op/s15,045 op/s16,766 op/s68,263 op/s3.20x slower
@xmldom/xmldom1,127,647 op/s46,274 op/s39,898 op/s14,238 op/s6,713 op/s6,579 op/s35,335 op/s6.18x slower
fast-xml-parser379,445 op/s22,185 op/s27,485 op/s10,187 op/s4,375 op/s3,526 op/s18,703 op/s11.68x slower
xml2js243,078 op/s17,016 op/s19,815 op/s8,582 op/s4,354 op/s3,735 op/s15,320 op/s14.25x slower
-Eksml leads every fixture, with and without validation (tXml crashes on RSS, and default-validation Eksml edges tXml within noise on the small fixture). 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-8x faster than @xmldom/xmldom** and **7-20x 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, Eksml holds SOAP, Atom, and POM while the small and EPG fixtures trade with tXml within run-to-run noise. 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. > [!note] > tXml crashed on the RSS fixture +### small (~100 B) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)2,595,279 op/s
tXml2,467,524 op/s1.05x slower
Eksml2,425,920 op/s1.07x slower
htmlparser22,013,627 op/s1.29x slower
@xmldom/xmldom1,146,014 op/s2.26x slower
fast-xml-parser382,971 op/s6.78x slower
xml2js245,615 op/s10.57x slower
+ +### RSS (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)395,563 op/s
Eksml331,671 op/s1.19x slower
htmlparser297,145 op/s4.07x slower
@xmldom/xmldom46,099 op/s8.58x slower
fast-xml-parser23,292 op/s16.98x slower
xml2js16,994 op/s23.28x slower
+ +### SOAP (~3 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)269,993 op/s
Eksml235,359 op/s1.15x slower
tXml232,137 op/s1.16x slower
htmlparser269,038 op/s3.91x slower
@xmldom/xmldom39,906 op/s6.77x slower
fast-xml-parser27,028 op/s9.99x slower
xml2js19,135 op/s14.11x slower
+ +### Atom (~6 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)116,037 op/s
Eksml101,175 op/s1.15x slower
tXml93,107 op/s1.25x slower
htmlparser230,473 op/s3.81x slower
@xmldom/xmldom14,260 op/s8.14x slower
fast-xml-parser11,288 op/s10.28x slower
xml2js8,607 op/s13.48x slower
+ +### POM (~8 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)66,775 op/s
Eksml57,227 op/s1.17x slower
tXml48,396 op/s1.38x slower
htmlparser214,617 op/s4.57x slower
@xmldom/xmldom6,854 op/s9.74x slower
fast-xml-parser4,429 op/s15.08x slower
xml2js4,405 op/s15.16x slower
+ +### EPG (~30 KB) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Libraryop/svs fastest
Eksml (validate: false)50,509 op/s
Eksml43,716 op/s1.16x slower
tXml43,583 op/s1.16x slower
htmlparser216,821 op/s3.00x slower
@xmldom/xmldom9,445 op/s5.35x slower
xml2js4,270 op/s11.83x slower
fast-xml-parser3,551 op/s14.22x slower
+ ## 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`) | -| attr-heavy | ~10 KB | Synthetic stress doc mirroring @tuananh/sax-parser's benchmark: 158 tiny elements, one attribute each (`attr-heavy-synthetic.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`) | +| catalog | 10-250 MB | Seeded faker-generated product catalogue, real-world shape; generated on demand into `test/fixtures/large/` (gitignored) by `bench/generate-large-fixture.mjs` | Size varies between tables because parsing benchmarks measure input size while serialization benchmarks measure output size (which includes indentation and formatting). @@ -348,9 +1239,10 @@ All fixtures are in [`test/fixtures/`](./test/fixtures/). ## Methodology - **Tool**: [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) (wraps [tinybench](https://github.com/tinylibs/tinybench)) +- **Tables**: Each section opens with an overview ranking libraries by the geometric mean of op/s across the section's fixtures (an arithmetic mean would be dominated by the smallest, fastest fixture; a library that cannot parse a fixture is not rewarded for the gap, anything that beats it on every fixture it completes ranks above it). The per-fixture tables are sorted by op/s and show each library's speedup relative to that fixture's winner. All tables are generated from a bench run by [`bench/benchmarks-to-markdown.mjs`](./bench/benchmarks-to-markdown.mjs) via `pnpm bench:tables`. - **Warmup**: Default tinybench warmup iterations - **Environment**: Single-threaded, synchronous execution on Node.js - **Parser reuse**: In the SAX streaming and tokenization suites, every parser is constructed once and reused across iterations (all measured parsers support this; verified by comparing event streams across runs). This measures steady-state parse throughput rather than constructor cost. XmlParseStream is the exception, web streams are single-use, so it pays its constructor per iteration. - **Public APIs**: Every library is measured through its public API. Eksml's SAX rows use `createSaxParser` (the `@eksml/xml/sax` export), not the internal engine. - **Comparison libraries**: tXml, htmlparser2, fast-xml-parser, xml2js, @xmldom/xmldom, sax, saxes, easysax, @tuananh/sax-parser -- **Source**: [`bench/`](./bench/) directory — `parse.bench.ts`, `stream.bench.ts`, `tokenize.bench.ts`, `writer.bench.ts`, `convert.bench.ts` +- **Source**: [`bench/`](./bench/) directory — `parse.bench.ts`, `stream.bench.ts`, `tokenize.bench.ts`, `large.bench.ts`, `writer.bench.ts`, `convert.bench.ts`, `attr-penalty.bench.ts` diff --git a/eksml/bench/benchmarks-to-markdown.mjs b/eksml/bench/benchmarks-to-markdown.mjs new file mode 100644 index 0000000..dc9447f --- /dev/null +++ b/eksml/bench/benchmarks-to-markdown.mjs @@ -0,0 +1,289 @@ +// Turns a vitest bench JSON report into the markdown tables used in +// BENCHMARKS.md. +// +// Usage: +// vitest bench --run --outputJson=bench-results.json +// node bench/benchmarks-to-markdown.mjs bench-results.json +// +// Each section gets an overview table ranking libraries by the geometric +// mean of op/s across the section's fixtures, followed by one table per +// fixture showing exact op/s and the speedup relative to that fixture's +// winner (the same ratios vitest prints in its run summary). +// +// Overview ordering: an arithmetic mean would be dominated by the +// smallest, fastest fixture, so the geometric mean is used. A library +// with a missing fixture (for example tXml crashing on RSS) is not +// rewarded for the gap: any library that beats it on every fixture it +// completes is ranked above it. +import { readFileSync } from 'node:fs'; + +// Suite names as they appear in the vitest report, mapped to the fixture +// headings used in BENCHMARKS.md. Suites not listed here (XHTML, convert, +// attr-penalty) are intentionally not tabulated. +const SECTIONS = [ + { + title: 'DOM Parsing', + fixtures: [ + { header: 'small (~100 B)', suite: 'small XML (~100 B)' }, + { header: 'RSS (~3 KB)', suite: 'RSS feed (~3 KB)' }, + { header: 'SOAP (~2 KB)', suite: 'SOAP envelope (~2 KB)' }, + { header: 'Atom (~3 KB)', suite: 'Atom feed (~3 KB)' }, + { header: 'POM (~5 KB)', suite: 'Maven POM (~5 KB)' }, + { header: 'EPG (~9 KB)', suite: 'XMLTV EPG (~9 KB)' }, + { + header: 'attr-heavy (~10 KB)', + suite: 'attr-heavy synthetic (~10 KB)', + }, + ], + }, + { + title: 'SAX Streaming (256 B chunks, with tree building)', + fixtures: [ + { header: 'RSS (~3 KB)', suite: 'stream: RSS feed (256 B chunks)' }, + { + header: 'EPG (~9 KB)', + suite: 'stream: XMLTV EPG (256 B chunks)', + }, + { + header: 'POM (~5 KB)', + suite: 'stream: Maven POM (256 B chunks)', + }, + { + header: 'EPG 64 B stress', + suite: 'stream: XMLTV EPG (64 B chunks — stress)', + }, + { + header: 'attr-heavy (~10 KB)', + suite: 'stream: attr-heavy synthetic (256 B chunks)', + }, + ], + }, + { + title: 'Raw Tokenization (no-op callbacks)', + fixtures: [ + { + header: 'RSS (~3 KB)', + suite: 'tokenize: RSS feed (256 B chunks)', + }, + { + header: 'EPG (~9 KB)', + suite: 'tokenize: XMLTV EPG (256 B chunks)', + }, + { + header: 'POM (~5 KB)', + suite: 'tokenize: Maven POM (256 B chunks)', + }, + { + header: 'EPG 64 B stress', + suite: 'tokenize: XMLTV EPG (64 B chunks — stress)', + }, + { + header: 'attr-heavy (~10 KB)', + suite: 'tokenize: attr-heavy synthetic (256 B chunks)', + }, + ], + // the tokenize bench registers eksml as plain "eksml" but the + // published table labels the row with the API it measures + renames: { eksml: 'Eksml (SAX)' }, + }, + { + title: 'Large document (10 MB, 64 KB chunks)', + fixtures: [ + { + header: 'catalog (10 MB)', + suite: 'large: catalog 10 MB (64 KB chunks)', + }, + ], + renames: { eksml: 'Eksml (SAX)' }, + }, + { + title: 'XML Serialization (tree to string)', + fixtures: [ + { header: 'small (~100 B)', suite: 'write: small XML (~100 B)' }, + { header: 'RSS (~3 KB)', suite: 'write: RSS feed (~3 KB)' }, + { header: 'SOAP (~3 KB)', suite: 'write: SOAP envelope (~3 KB)' }, + { header: 'Atom (~6 KB)', suite: 'write: Atom feed (~6 KB)' }, + { header: 'POM (~8 KB)', suite: 'write: Maven POM (~8 KB)' }, + { header: 'EPG (~30 KB)', suite: 'write: XMLTV EPG (~30 KB)' }, + ], + }, +]; + +// Bench registration names mapped to published display names. Names not +// listed pass through unchanged. +const DISPLAY_NAMES = { + eksml: 'Eksml', + 'eksml (validate: false)': 'Eksml (validate: false)', + 'eksml (SAX)': 'Eksml (SAX)', + 'eksml (XmlParseStream)': 'Eksml (XmlParseStream)', + txml: 'tXml', + 'txml (simplify)': 'tXml (simplify)', + 'txml (simplifyLostLess)': 'tXml (simplifyLostLess)', +}; + +function loadResults(path) { + const report = JSON.parse(readFileSync(path, 'utf8')); + // suite name -> { bench name -> hz } + const suites = new Map(); + for (const file of report.files) { + for (const group of file.groups) { + // fullName is "bench/file.bench.ts > suite name" + const suiteName = group.fullName.split(' > ').slice(1).join(' > '); + const entries = new Map(); + for (const bench of group.benchmarks) { + entries.set(bench.name, bench.hz); + } + suites.set(suiteName, entries); + } + } + return suites; +} + +function displayName(section, name) { + return section.renames?.[name] ?? DISPLAY_NAMES[name] ?? name; +} + +function label(display) { + return display.startsWith('Eksml') ? `${display}` : display; +} + +function opsCell(hz, isWinner) { + // low-rate benches (large documents) need the decimal to be comparable + const value = + hz >= 100 ? Math.round(hz).toLocaleString('en-US') : hz.toFixed(1); + const formatted = `${value} op/s`; + return isWinner ? `${formatted}` : formatted; +} + +function geometricMean(values) { + let logSum = 0; + for (const value of values) logSum += Math.log(value); + return Math.exp(logSum / values.length); +} + +/** True when b beats a on every fixture a completed. */ +function dominates(b, a) { + return a.values.every( + (value, i) => value === null || (b.values[i] ?? 0) > value, + ); +} + +function buildOverviewRows(section, columnData) { + const libraries = new Set(); + for (const entries of columnData) { + for (const name of entries.keys()) libraries.add(name); + } + + const rows = [...libraries].map((name) => { + const values = columnData.map((entries) => entries.get(name) ?? null); + const present = values.filter((v) => v !== null); + return { + display: displayName(section, name), + values, + geomean: geometricMean(present), + }; + }); + + rows.sort((a, b) => b.geomean - a.geomean); + + // Dominance fixup for rows with missing fixtures: their geomean is + // computed over an easier set, so bubble them below any row that beats + // them on every fixture they completed. + let changed = true; + while (changed) { + changed = false; + for (let i = 0; i < rows.length - 1; i++) { + const above = rows[i]; + const below = rows[i + 1]; + if (above.values.includes(null) && dominates(below, above)) { + rows[i] = below; + rows[i + 1] = above; + changed = true; + } + } + } + + return rows; +} + +function renderOverview(rows) { + const lines = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ]; + // rows[0] is not always the highest geomean: the dominance fixup can + // demote a library with missing fixtures, so find the true maximum + const best = Math.max(...rows.map((row) => row.geomean)); + for (const row of rows) { + const relative = + row.geomean === best ? '—' : `${(best / row.geomean).toFixed(2)}x slower`; + lines.push( + ' ', + ` `, + ` `, + ` `, + ' ', + ); + } + lines.push('
Librarygeometric meanvs fastest
${label(row.display)}${opsCell(row.geomean, row.geomean === best)}${relative}
'); + return lines.join('\n'); +} + +function renderFixture(section, entries) { + const rows = [...entries.entries()] + .map(([name, hz]) => ({ display: displayName(section, name), hz })) + .sort((a, b) => b.hz - a.hz); + const fastest = rows[0].hz; + + const lines = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ]; + for (const row of rows) { + const relative = + row.hz === fastest ? '—' : `${(fastest / row.hz).toFixed(2)}x slower`; + lines.push( + ' ', + ` `, + ` `, + ` `, + ' ', + ); + } + lines.push('
Libraryop/svs fastest
${label(row.display)}${opsCell(row.hz, row.hz === fastest)}${relative}
'); + return lines.join('\n'); +} + +const inputPath = process.argv[2] ?? 'bench-results.json'; +const suites = loadResults(inputPath); + +const output = []; +for (const section of SECTIONS) { + const columnData = section.fixtures.map(({ suite }) => { + const entries = suites.get(suite); + if (!entries) throw new Error(`missing suite in report: ${suite}`); + return entries; + }); + + output.push(`## ${section.title}`, ''); + // a single-fixture section's overview would duplicate its only table + if (section.fixtures.length > 1) { + output.push('### Overview', ''); + output.push(renderOverview(buildOverviewRows(section, columnData)), ''); + section.fixtures.forEach((fixture, i) => { + output.push(`### ${fixture.header}`, ''); + output.push(renderFixture(section, columnData[i]), ''); + }); + } else { + output.push(renderFixture(section, columnData[0]), ''); + } +} +console.log(output.join('\n')); diff --git a/eksml/bench/generate-large-fixture.mjs b/eksml/bench/generate-large-fixture.mjs new file mode 100644 index 0000000..88db13a --- /dev/null +++ b/eksml/bench/generate-large-fixture.mjs @@ -0,0 +1,158 @@ +// Generates a plausible real-world-style XML document of any target size, +// for benchmarking parsers on very large inputs. +// +// Usage: +// node bench/generate-large-fixture.mjs +// +// The document is a product catalogue: varied element depth, a mix of +// attribute-light and attribute-heavy elements, short and long text runs, +// occasional comments and CDATA sections, and entity-escaped text. Tag +// names avoid tXml's hardcoded HTML void-element list (link, meta, img, +// br, hr, input) so every comparison parser can read the output. +// +// Faker is seeded and record composition uses a seeded PRNG, so a given +// target size always produces byte-identical output. Field values are +// drawn from pre-generated pools rather than per-record faker calls, which +// keeps 250 MB generation to seconds rather than minutes; composition +// varies per record so content does not visibly repeat. +import { createWriteStream, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { once } from 'node:events'; +import { faker } from '@faker-js/faker'; + +const SEED = 424242; + +function escapeXml(text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** Deterministic PRNG (mulberry32) for record composition. */ +function createRandom(seed) { + let state = seed; + return function random() { + state |= 0; + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function buildPools() { + faker.seed(SEED); + const pool = (count, fn) => + Array.from({ length: count }, () => escapeXml(fn())); + return { + productNames: pool(2000, () => faker.commerce.productName()), + brands: pool(800, () => faker.company.name()), + descriptions: pool(2000, () => faker.lorem.sentences({ min: 1, max: 4 })), + reviewBodies: pool(1500, () => faker.lorem.sentences({ min: 1, max: 3 })), + authors: pool(1000, () => faker.person.fullName()), + categories: pool(60, () => faker.commerce.department().toLowerCase()), + warehouses: pool( + 80, + () => + `${faker.location + .city() + .toUpperCase() + .replace(/[^A-Z]/g, '')}-${faker.number.int({ min: 1, max: 9 })}`, + ), + currencies: ['USD', 'EUR', 'GBP', 'JPY', 'AUD'], + }; +} + +export async function generateLargeFixture(targetBytes, outputPath) { + const pools = buildPools(); + const random = createRandom(SEED); + const pick = (list) => list[Math.floor(random() * list.length)]; + + mkdirSync(dirname(outputPath), { recursive: true }); + const out = createWriteStream(outputPath); + let bytes = 0; + async function write(text) { + bytes += Buffer.byteLength(text); + if (!out.write(text)) await once(out, 'drain'); + } + + const header = + '\n' + + '\n'; + const footer = '\n'; + await write(header); + + let id = 0; + while (bytes < targetBytes - Buffer.byteLength(footer)) { + id++; + if (id % 50 === 1) { + await write(` \n`); + } + + const sku = `${String.fromCharCode(65 + Math.floor(random() * 26))}${String.fromCharCode(65 + Math.floor(random() * 26))}${String.fromCharCode(65 + Math.floor(random() * 26))}-${String(Math.floor(random() * 9000) + 1000)}`; + const updated = `2026-${String(1 + Math.floor(random() * 12)).padStart(2, '0')}-${String(1 + Math.floor(random() * 28)).padStart(2, '0')}T${String(Math.floor(random() * 24)).padStart(2, '0')}:${String(Math.floor(random() * 60)).padStart(2, '0')}:00Z`; + const price = (random() * 990 + 10).toFixed(2); + + let record = + ` \n` + + ` ${pick(pools.productNames)}\n` + + ` ${pick(pools.brands)}\n` + + ` ${price}\n` + + ` ${Math.floor(random() * 2000)}\n`; + + const categoryCount = 1 + Math.floor(random() * 3); + record += ' \n'; + for (let i = 0; i < categoryCount; i++) { + record += ` ${pick(pools.categories)}\n`; + } + record += ' \n'; + + record += ` ${pick(pools.descriptions)}\n`; + + const reviewCount = Math.floor(random() * 4); // 0-3 + for (let i = 0; i < reviewCount; i++) { + const rating = 1 + Math.floor(random() * 5); + const verified = random() < 0.7; + record += + ` \n` + + ` ${pick(pools.authors)}\n`; + // CDATA for roughly a third of review bodies, plain text otherwise + record += + random() < 0.33 + ? ` \n` + : ` ${pick(pools.reviewBodies)}\n`; + record += ' \n'; + } + + record += ' \n'; + await write(record); + } + + await write(footer); + out.end(); + await once(out, 'finish'); + return { bytes, products: id }; +} + +// CLI entry +if (import.meta.url === `file://${process.argv[1]}`) { + const sizeMb = Number(process.argv[2]); + const outputPath = process.argv[3]; + if (!sizeMb || !outputPath) { + console.error( + 'usage: node bench/generate-large-fixture.mjs ', + ); + process.exit(1); + } + const started = performance.now(); + const { bytes, products } = await generateLargeFixture( + sizeMb * 1024 * 1024, + outputPath, + ); + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log( + `wrote ${(bytes / 1024 / 1024).toFixed(1)} MB (${products.toLocaleString()} products) to ${outputPath} in ${seconds}s`, + ); +} diff --git a/eksml/bench/large.bench.ts b/eksml/bench/large.bench.ts new file mode 100644 index 0000000..d6887b4 --- /dev/null +++ b/eksml/bench/large.bench.ts @@ -0,0 +1,183 @@ +/** + * Very large document benchmarks — 10 MB, 50 MB, and 250 MB of plausible + * real-world XML (a faker-generated product catalogue), delivered in 64 KB + * chunks (Node's default stream highWaterMark). + * + * Opt-in because generation plus the slower parsers make this suite take + * tens of minutes: + * + * pnpm bench:large # all three sizes + * LARGE_BENCH_SIZES=10,50 pnpm bench:large + * + * Fixtures live in test/fixtures/large/ (gitignored) and are generated on + * demand by bench/generate-large-fixture.mjs; generation is seeded, so + * every machine benchmarks byte-identical documents. + * + * Handler policy matches tokenize.bench.ts: every parser registers the + * full event surface (open/close/text/cdata/comment/PI) with no-op + * callbacks that declare parameters, so no parser can skip argument + * materialization or event collection. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bench, describe } from 'vitest'; + +import { createSaxParser } from '#src/sax.ts'; + +import SaxParser from '@tuananh/sax-parser'; +import EasySax from 'easysax'; +import { Parser as Htmlparser2 } from 'htmlparser2'; +import sax from 'sax'; +import { SaxesParser } from 'saxes'; + +// @ts-expect-error plain-JS generator module without type declarations +import { generateLargeFixture } from './generate-large-fixture.mjs'; + +const ENABLED = process.env.LARGE_BENCH === '1'; +const CHUNK_BYTES = 64 * 1024; + +const ALL_SIZES = [10, 50, 250]; +const sizes = process.env.LARGE_BENCH_SIZES + ? process.env.LARGE_BENCH_SIZES.split(',').map(Number) + : ALL_SIZES; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturePath = (mb: number) => + resolve(__dirname, '../test/fixtures/large', `catalog-${mb}mb.xml`); + +if (!ENABLED) { + describe('large documents (disabled)', () => { + bench.skip('set LARGE_BENCH=1 or run pnpm bench:large', () => {}); + }); +} else { + // Generate any missing fixtures before the suites are registered. + for (const mb of sizes) { + if (!existsSync(fixturePath(mb))) { + console.log(`[large] generating ${mb} MB fixture...`); + const { products } = await generateLargeFixture( + mb * 1024 * 1024, + fixturePath(mb), + ); + console.log(`[large] wrote ${products.toLocaleString()} products`); + } + } + + // No-op callback with DECLARED parameters, matching tokenize.bench.ts: + // some parsers probe listener arity and skip materializing arguments for + // zero-arity handlers. + const noop = (_a?: unknown, _b?: unknown) => {}; + + const eksmlParser = createSaxParser(); + eksmlParser.on('openTag', noop); + eksmlParser.on('closeTag', noop); + eksmlParser.on('text', noop); + eksmlParser.on('cdata', noop); + eksmlParser.on('comment', noop); + eksmlParser.on('processingInstruction', noop); + + const saxParser = sax.parser(true); + saxParser.onopentag = noop; + saxParser.onclosetag = noop; + saxParser.ontext = noop; + saxParser.oncdata = noop; + saxParser.oncomment = noop; + saxParser.onprocessinginstruction = noop; + + const saxesParser = new SaxesParser(); + saxesParser.on('opentag', noop); + saxesParser.on('closetag', noop); + saxesParser.on('text', noop); + saxesParser.on('cdata', noop); + saxesParser.on('comment', noop); + saxesParser.on('processinginstruction', noop); + + const htmlparser2Parser = new Htmlparser2( + { + onopentag: noop, + onclosetag: noop, + ontext: noop, + oncdatastart: noop, + oncdataend: noop, + oncomment: noop, + onprocessinginstruction: noop, + }, + { xmlMode: true }, + ); + + const tuananhParser = new SaxParser(); + tuananhParser.on('startElement', noop); + tuananhParser.on('endElement', noop); + tuananhParser.on('text', noop); + tuananhParser.on('cdata', noop); + tuananhParser.on('comment', noop); + tuananhParser.on('processingInstruction', noop); + + const easysaxParser = new EasySax(); + easysaxParser.on('startNode', (_name: string, getAttr: () => unknown) => { + getAttr(); + }); + easysaxParser.on('endNode', noop); + easysaxParser.on('textNode', noop); + easysaxParser.on('cdata', noop); + easysaxParser.on('comment', noop); + easysaxParser.on('question', noop); + + const runners: Record void> = { + eksml: (chunks) => { + for (const chunk of chunks) eksmlParser.write(chunk); + eksmlParser.close(); + }, + sax: (chunks) => { + for (const chunk of chunks) saxParser.write(chunk); + saxParser.close(); + }, + saxes: (chunks) => { + for (const chunk of chunks) saxesParser.write(chunk); + saxesParser.close(); + }, + htmlparser2: (chunks) => { + for (const chunk of chunks) htmlparser2Parser.write(chunk); + htmlparser2Parser.end(); + htmlparser2Parser.reset(); + }, + '@tuananh/sax-parser': (chunks) => { + for (const chunk of chunks) tuananhParser.write(chunk); + tuananhParser.end(); + }, + easysax: (chunks) => { + for (const chunk of chunks) easysaxParser.write(chunk); + easysaxParser.end(); + }, + }; + + // Iteration counts shrink with document size so the suite finishes in + // reasonable time; time: 0 disables tinybench's duration-based looping. + const iterationsBySize: Record = { 10: 5, 50: 3, 250: 2 }; + + for (const mb of sizes) { + const doc = readFileSync(fixturePath(mb), 'utf8'); + const chunks: string[] = []; + for (let i = 0; i < doc.length; i += CHUNK_BYTES) { + chunks.push(doc.slice(i, i + CHUNK_BYTES)); + } + const options = { + time: 0, + warmupTime: 0, + warmupIterations: 1, + iterations: iterationsBySize[mb] ?? 2, + }; + + describe(`large: catalog ${mb} MB (64 KB chunks)`, () => { + for (const [name, run] of Object.entries(runners)) { + bench( + name, + () => { + run(chunks); + }, + options, + ); + } + }); + } +} diff --git a/eksml/bench/stream.bench.ts b/eksml/bench/stream.bench.ts index 939f5d5..94e69cb 100644 --- a/eksml/bench/stream.bench.ts +++ b/eksml/bench/stream.bench.ts @@ -2,7 +2,7 @@ * Streaming / SAX parsing benchmarks. * * Compares eksml's parsers against event-driven SAX parsers (sax, saxes, - * htmlparser2.Parser) and fast-xml-parser (synchronous, for reference). + * htmlparser2.Parser). * * To keep the comparison fair, every parser builds the same DOM subtree * structure: { tagName, attributes, children }. This ensures all parsers @@ -35,7 +35,6 @@ import { createSaxParser } from '#src/sax.ts'; // --- competitors --- import SaxParser from '@tuananh/sax-parser'; import EasySax from 'easysax'; -import { XMLParser } from 'fast-xml-parser'; import { Parser as Htmlparser2 } from 'htmlparser2'; import sax from 'sax'; import { SaxesParser } from 'saxes'; @@ -130,11 +129,23 @@ async function eksmlStream(chunks: string[]): Promise { await drain; } +// Comment and processing-instruction listeners are registered for every +// parser as no-ops (the trees built here do not include them) so this suite +// exposes the same full event surface as tokenize.bench.ts. Some parsers +// change behaviour based on which listeners exist — @tuananh/sax-parser's +// event collector slows measurably once comment/cdata/PI listeners are +// registered — so a smaller surface here would make the suites +// incomparable. The no-ops declare parameters for the same reason. +const noop = (_a?: unknown, _b?: unknown) => {}; + // --------------------------------------------------------------------------- // sax — persistent parser (close() re-initializes in place) // --------------------------------------------------------------------------- const saxParser = sax.parser(true); // strict mode +saxParser.oncomment = noop; +saxParser.onprocessinginstruction = noop; + saxParser.onopentag = (node) => { pushElement({ tagName: node.name, @@ -182,6 +193,8 @@ saxesParser.on('text', (text) => { saxesParser.on('cdata', (cdata) => { pushChild(cdata); }); +saxesParser.on('comment', noop); +saxesParser.on('processinginstruction', noop); saxesParser.on('closetag', () => { stack.pop(); }); @@ -208,6 +221,8 @@ const htmlparser2Parser = new Htmlparser2( onclosetag() { stack.pop(); }, + oncomment: noop, + onprocessinginstruction: noop, }, { xmlMode: true }, ); @@ -239,6 +254,8 @@ tuananhParser.on('cdata', (cdata: string) => { pushChild(cdata); }); // Parameter declared so arity probing cannot skip materializing the name +tuananhParser.on('comment', noop); +tuananhParser.on('processingInstruction', noop); tuananhParser.on('endElement', (_name: string) => { stack.pop(); }); @@ -275,6 +292,8 @@ easysaxParser.on('textNode', (text: string) => { easysaxParser.on('cdata', (cdata: string) => { pushChild(cdata); }); +easysaxParser.on('comment', noop); +easysaxParser.on('question', noop); easysaxParser.on('endNode', () => { stack.pop(); }); @@ -304,6 +323,8 @@ eksmlSaxParser.on('text', (text) => { eksmlSaxParser.on('cdata', (cdata) => { pushChild(cdata); }); +eksmlSaxParser.on('comment', noop); +eksmlSaxParser.on('processingInstruction', noop); eksmlSaxParser.on('closeTag', () => { stack.pop(); }); @@ -316,17 +337,6 @@ function eksmlSaxEngine(chunks: string[]): void { eksmlSaxParser.close(); } -// --------------------------------------------------------------------------- -// fast-xml-parser — sync, builds its own tree (included for reference) -// --------------------------------------------------------------------------- -const fxp = new XMLParser({ - ignoreAttributes: false, - preserveOrder: true, -}); -function fxpSync(xml: string): void { - fxp.parse(xml); -} - // --------------------------------------------------------------------------- // RSS feed — chunked streaming // --------------------------------------------------------------------------- @@ -358,10 +368,6 @@ describe('stream: RSS feed (256 B chunks)', () => { bench('easysax', () => { easysaxStream(rssChunks256); }); - - bench('fast-xml-parser (sync, no streaming)', () => { - fxpSync(rssFeed); - }); }); // --------------------------------------------------------------------------- @@ -395,10 +401,6 @@ describe('stream: XMLTV EPG (256 B chunks)', () => { bench('easysax', () => { easysaxStream(xmltvChunks256); }); - - bench('fast-xml-parser (sync, no streaming)', () => { - fxpSync(xmltvEpg); - }); }); // --------------------------------------------------------------------------- @@ -432,10 +434,6 @@ describe('stream: Maven POM (256 B chunks)', () => { bench('easysax', () => { easysaxStream(pomChunks256); }); - - bench('fast-xml-parser (sync, no streaming)', () => { - fxpSync(pomXml); - }); }); // --------------------------------------------------------------------------- @@ -469,10 +467,6 @@ describe('stream: XMLTV EPG (64 B chunks — stress)', () => { bench('easysax', () => { easysaxStream(xmltvChunks64); }); - - bench('fast-xml-parser (sync, no streaming)', () => { - fxpSync(xmltvEpg); - }); }); // --------------------------------------------------------------------------- @@ -506,8 +500,4 @@ describe('stream: attr-heavy synthetic (256 B chunks)', () => { bench('easysax', () => { easysaxStream(attrHeavyChunks256); }); - - bench('fast-xml-parser (sync, no streaming)', () => { - fxpSync(attrHeavy); - }); }); diff --git a/eksml/package.json b/eksml/package.json index f1d9c78..faa7ec8 100644 --- a/eksml/package.json +++ b/eksml/package.json @@ -56,6 +56,8 @@ "test:coverage": "vitest run --coverage", "test:smoke": "node scripts/smoke-test.mjs", "bench": "vitest bench", + "bench:large": "LARGE_BENCH=1 vitest bench --run bench/large.bench.ts", + "bench:tables": "LARGE_BENCH=1 LARGE_BENCH_SIZES=10 vitest bench --run --outputJson=bench-results.json && node bench/benchmarks-to-markdown.mjs bench-results.json", "typecheck": "tsc --noEmit", "prepublishOnly": "pnpm run build", "examples": "vite", @@ -82,6 +84,7 @@ "license": "MIT", "packageManager": "pnpm@10.33.0", "devDependencies": { + "@faker-js/faker": "^10.5.0", "@tuananh/sax-parser": "^1.6.0", "@types/node": "^25.5.0", "@types/sax": "^1.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 553d71c..e01d09f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: specifier: ^8.0.0 version: 8.0.0 devDependencies: + '@faker-js/faker': + specifier: ^10.5.0 + version: 10.5.0 '@tuananh/sax-parser': specifier: ^1.6.0 version: 1.6.0 @@ -1053,6 +1056,10 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} @@ -6255,6 +6262,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@faker-js/faker@10.5.0': {} + '@formkit/auto-animate@0.9.0': {} '@gar/promisify@1.1.3': {} From aed5f0716877dec0633e23fdffb1ece0108cfad1 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Wed, 22 Jul 2026 12:29:07 +0100 Subject: [PATCH 2/2] bench: splice generated tables into BENCHMARKS.md in place Each table sits between HTML comment markers (invisible on GitHub) keyed by section and fixture. pnpm bench:tables now rewrites the tables in the document directly instead of printing them for manual pasting; stdout mode remains when --write is not passed. Unknown marker keys error, and rendered tables without a marker warn so a new fixture's marker gets placed deliberately. --- eksml/BENCHMARKS.md | 114 ++++++++++++++++++++++++- eksml/bench/benchmarks-to-markdown.mjs | 96 ++++++++++++++++----- eksml/package.json | 2 +- 3 files changed, 188 insertions(+), 24 deletions(-) diff --git a/eksml/BENCHMARKS.md b/eksml/BENCHMARKS.md index 310f4ef..c2b54cc 100644 --- a/eksml/BENCHMARKS.md +++ b/eksml/BENCHMARKS.md @@ -14,6 +14,8 @@ Parse an XML string into a tree structure. ### Overview + + @@ -52,11 +54,15 @@ Parse an XML string into a tree structure.
Library
+ + > [!note] > tXml crashed on the RSS fixture ### small (~100 B) + + @@ -95,8 +101,12 @@ Parse an XML string into a tree structure.
Library
+ + ### RSS (~3 KB) + + @@ -130,8 +140,12 @@ Parse an XML string into a tree structure.
Library
+ + ### SOAP (~2 KB) + + @@ -170,8 +184,12 @@ Parse an XML string into a tree structure.
Library
+ + ### Atom (~3 KB) + + @@ -210,8 +228,12 @@ Parse an XML string into a tree structure.
Library
+ + ### POM (~5 KB) + + @@ -250,8 +272,12 @@ Parse an XML string into a tree structure.
Library
+ + ### EPG (~9 KB) + + @@ -290,8 +316,12 @@ Parse an XML string into a tree structure.
Library
+ + ### attr-heavy (~10 KB) + + @@ -330,12 +360,16 @@ Parse an XML string into a tree structure.
Library
+ + ## SAX Streaming (256 B chunks, with tree building) Chunked streaming parse where each parser tokenizes SAX events and builds a full DOM tree. ### Overview + + @@ -379,6 +413,8 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + @tuananh/sax-parser 1.6 has largely closed its streaming gap: the EPG 64 B stress fixture is a coin flip between it and Eksml, and it is clear second on EPG, POM, and attr-heavy. > [!note] @@ -386,6 +422,8 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full ### RSS (~3 KB) + + @@ -429,8 +467,12 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + ### EPG (~9 KB) + + @@ -474,8 +516,12 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + ### POM (~5 KB) + + @@ -519,8 +565,12 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + ### EPG 64 B stress + + @@ -564,8 +614,12 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + ### attr-heavy (~10 KB) + + @@ -609,12 +663,16 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full
Library
+ + ## Raw Tokenization (no-op callbacks) Pure scanner throughput with no downstream work -- isolates the tokenizer's raw speed. ### Overview + + @@ -653,6 +711,8 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + @tuananh/sax-parser 1.6 is now the clear second on every fixture except RSS, where it trails 5x. > [!note] @@ -660,6 +720,8 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw ### RSS (~3 KB) + + @@ -698,8 +760,12 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + ### EPG (~9 KB) + + @@ -738,8 +804,12 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + ### POM (~5 KB) + + @@ -778,8 +848,12 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + ### EPG 64 B stress + + @@ -818,8 +892,12 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + ### attr-heavy (~10 KB) + + @@ -858,10 +936,14 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw
Library
+ + ## Large document (10 MB, 64 KB chunks) Chunked tokenization of a 10 MB document — a seeded, faker-generated product catalogue (`bench/generate-large-fixture.mjs`) with real-world shape: mixed attribute density, varied text lengths, comments, and CDATA. The fixture is generated on demand into `test/fixtures/large/` (gitignored); because generation is seeded, every machine benchmarks a byte-identical document. Handler policy matches the tokenization suite. 50 MB and 250 MB runs are available via `pnpm bench:large`. + + @@ -900,12 +982,16 @@ Chunked tokenization of a 10 MB document — a seeded, faker-generated product c
Library
+ + ## XML Serialization (tree to string) Serialize a pre-parsed in-memory tree back to XML. ### Overview + + @@ -949,6 +1035,8 @@ Serialize a pre-parsed in-memory tree back to XML.
Library
+ + With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With validation on, Eksml holds SOAP, Atom, and POM while the small and EPG fixtures trade with tXml within run-to-run noise. 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. > [!note] @@ -956,6 +1044,8 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va ### small (~100 B) + + @@ -999,8 +1089,12 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ### RSS (~3 KB) + + @@ -1039,8 +1133,12 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ### SOAP (~3 KB) + + @@ -1084,8 +1182,12 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ### Atom (~6 KB) + + @@ -1129,8 +1231,12 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ### POM (~8 KB) + + @@ -1174,8 +1280,12 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ### EPG (~30 KB) + + @@ -1219,6 +1329,8 @@ With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With va
Library
+ + ## Fixtures | Fixture | Size | Description | @@ -1239,7 +1351,7 @@ All fixtures are in [`test/fixtures/`](./test/fixtures/). ## Methodology - **Tool**: [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) (wraps [tinybench](https://github.com/tinylibs/tinybench)) -- **Tables**: Each section opens with an overview ranking libraries by the geometric mean of op/s across the section's fixtures (an arithmetic mean would be dominated by the smallest, fastest fixture; a library that cannot parse a fixture is not rewarded for the gap, anything that beats it on every fixture it completes ranks above it). The per-fixture tables are sorted by op/s and show each library's speedup relative to that fixture's winner. All tables are generated from a bench run by [`bench/benchmarks-to-markdown.mjs`](./bench/benchmarks-to-markdown.mjs) via `pnpm bench:tables`. +- **Tables**: Each section opens with an overview ranking libraries by the geometric mean of op/s across the section's fixtures (an arithmetic mean would be dominated by the smallest, fastest fixture; a library that cannot parse a fixture is not rewarded for the gap, anything that beats it on every fixture it completes ranks above it). The per-fixture tables are sorted by op/s and show each library's speedup relative to that fixture's winner. All tables are generated from a bench run by [`bench/benchmarks-to-markdown.mjs`](./bench/benchmarks-to-markdown.mjs) and spliced into this document in place, between HTML comment markers, by `pnpm bench:tables`. Prose is written by hand; a newly added fixture needs its marker pair placed once, wherever its table should live. - **Warmup**: Default tinybench warmup iterations - **Environment**: Single-threaded, synchronous execution on Node.js - **Parser reuse**: In the SAX streaming and tokenization suites, every parser is constructed once and reused across iterations (all measured parsers support this; verified by comparing event streams across runs). This measures steady-state parse throughput rather than constructor cost. XmlParseStream is the exception, web streams are single-use, so it pays its constructor per iteration. diff --git a/eksml/bench/benchmarks-to-markdown.mjs b/eksml/bench/benchmarks-to-markdown.mjs index dc9447f..c53d985 100644 --- a/eksml/bench/benchmarks-to-markdown.mjs +++ b/eksml/bench/benchmarks-to-markdown.mjs @@ -15,7 +15,7 @@ // with a missing fixture (for example tXml crashing on RSS) is not // rewarded for the gap: any library that beats it on every fixture it // completes is ranked above it. -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; // Suite names as they appear in the vitest report, mapped to the fixture // headings used in BENCHMARKS.md. Suites not listed here (XHTML, convert, @@ -262,28 +262,80 @@ function renderFixture(section, entries) { return lines.join('\n'); } -const inputPath = process.argv[2] ?? 'bench-results.json'; -const suites = loadResults(inputPath); - -const output = []; -for (const section of SECTIONS) { - const columnData = section.fixtures.map(({ suite }) => { - const entries = suites.get(suite); - if (!entries) throw new Error(`missing suite in report: ${suite}`); - return entries; - }); - - output.push(`## ${section.title}`, ''); - // a single-fixture section's overview would duplicate its only table - if (section.fixtures.length > 1) { - output.push('### Overview', ''); - output.push(renderOverview(buildOverviewRows(section, columnData)), ''); +/** Render every table, keyed by "
:: ". */ +function renderAll(suites) { + const tables = new Map(); + for (const section of SECTIONS) { + const columnData = section.fixtures.map(({ suite }) => { + const entries = suites.get(suite); + if (!entries) throw new Error(`missing suite in report: ${suite}`); + return entries; + }); + // a single-fixture section's overview would duplicate its only table + if (section.fixtures.length > 1) { + tables.set( + `${section.title} :: Overview`, + renderOverview(buildOverviewRows(section, columnData)), + ); + } section.fixtures.forEach((fixture, i) => { - output.push(`### ${fixture.header}`, ''); - output.push(renderFixture(section, columnData[i]), ''); + tables.set( + `${section.title} :: ${fixture.header}`, + renderFixture(section, columnData[i]), + ); }); - } else { - output.push(renderFixture(section, columnData[0]), ''); } + return tables; +} + +/** + * Replace the contents of every marked block in the document: + * + * + * ...replaced... + * + * + * Errors on markers with unknown keys; warns about rendered tables that + * have no marker (a newly added fixture needs its marker placed by hand + * once, wherever it should live in the document). + */ +function spliceIntoDocument(documentPath, tables) { + const doc = readFileSync(documentPath, 'utf8'); + const seen = new Set(); + const out = doc.replace( + /[\s\S]*?/g, + (_match, key) => { + const table = tables.get(key); + if (!table) throw new Error(`marker with unknown key: ${key}`); + seen.add(key); + return `\n\n${table}\n\n`; + }, + ); + for (const key of tables.keys()) { + if (!seen.has(key)) { + console.warn(`warning: no marker in ${documentPath} for: ${key}`); + } + } + writeFileSync(documentPath, out); + return seen.size; +} + +const args = process.argv.slice(2); +const writeIndex = args.indexOf('--write'); +const documentPath = writeIndex === -1 ? null : args[writeIndex + 1]; +const inputPath = + args.filter((a, i) => i !== writeIndex && i !== writeIndex + 1)[0] ?? + 'bench-results.json'; + +const tables = renderAll(loadResults(inputPath)); + +if (documentPath) { + const count = spliceIntoDocument(documentPath, tables); + console.log(`updated ${count} tables in ${documentPath}`); +} else { + const output = []; + for (const [key, table] of tables) { + output.push(``, '', table, ''); + } + console.log(output.join('\n')); } -console.log(output.join('\n')); diff --git a/eksml/package.json b/eksml/package.json index faa7ec8..544c840 100644 --- a/eksml/package.json +++ b/eksml/package.json @@ -57,7 +57,7 @@ "test:smoke": "node scripts/smoke-test.mjs", "bench": "vitest bench", "bench:large": "LARGE_BENCH=1 vitest bench --run bench/large.bench.ts", - "bench:tables": "LARGE_BENCH=1 LARGE_BENCH_SIZES=10 vitest bench --run --outputJson=bench-results.json && node bench/benchmarks-to-markdown.mjs bench-results.json", + "bench:tables": "LARGE_BENCH=1 LARGE_BENCH_SIZES=10 vitest bench --run --outputJson=bench-results.json && node bench/benchmarks-to-markdown.mjs bench-results.json --write BENCHMARKS.md", "typecheck": "tsc --noEmit", "prepublishOnly": "pnpm run build", "examples": "vite",