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..c2b54cc 100644
--- a/eksml/BENCHMARKS.md
+++ b/eksml/BENCHMARKS.md
@@ -12,334 +12,1337 @@ pnpm --filter @eksml/xml bench
Parse an XML string into a tree structure.
+### Overview
+
+
+
+
+
+ | Library |
+ geometric mean |
+ vs fastest |
+
+
+ | Eksml |
+ 71,409 op/s |
+ — |
+
+
+ | tXml |
+ 35,936 op/s |
+ 1.99x slower |
+
+
+ | htmlparser2 |
+ 14,707 op/s |
+ 4.86x slower |
+
+
+ | xml2js |
+ 5,564 op/s |
+ 12.83x slower |
+
+
+ | fast-xml-parser |
+ 5,469 op/s |
+ 13.06x slower |
+
+
+ | @xmldom/xmldom |
+ 3,527 op/s |
+ 20.25x slower |
+
+
+
+
+
+> [!note]
+> tXml crashed on the RSS fixture
+
+### small (~100 B)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 1,661,243 op/s |
+ — |
+
+
+ | tXml |
+ 1,008,262 op/s |
+ 1.65x slower |
+
+
+ | htmlparser2 |
+ 543,433 op/s |
+ 3.06x slower |
+
+
+ | fast-xml-parser |
+ 165,332 op/s |
+ 10.05x slower |
+
+
+ | xml2js |
+ 129,948 op/s |
+ 12.78x slower |
+
+
+ | @xmldom/xmldom |
+ 96,510 op/s |
+ 17.21x slower |
+
+
+
+
+
+### RSS (~3 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 139,120 op/s |
+ — |
+
+
+ | htmlparser2 |
+ 24,828 op/s |
+ 5.60x slower |
+
+
+ | fast-xml-parser |
+ 9,819 op/s |
+ 14.17x slower |
+
+
+ | xml2js |
+ 9,512 op/s |
+ 14.63x slower |
+
+
+ | @xmldom/xmldom |
+ 6,090 op/s |
+ 22.84x slower |
+
+
+
+
+
+### SOAP (~2 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 87,674 op/s |
+ — |
+
+
+ | tXml |
+ 48,827 op/s |
+ 1.80x slower |
+
+
+ | htmlparser2 |
+ 17,611 op/s |
+ 4.98x slower |
+
+
+ | fast-xml-parser |
+ 7,163 op/s |
+ 12.24x slower |
+
+
+ | xml2js |
+ 7,089 op/s |
+ 12.37x slower |
+
+
+ | @xmldom/xmldom |
+ 5,055 op/s |
+ 17.34x slower |
+
+
+
+
+
+### Atom (~3 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 52,445 op/s |
+ — |
+
+
+ | tXml |
+ 27,496 op/s |
+ 1.91x slower |
+
+
+ | htmlparser2 |
+ 9,508 op/s |
+ 5.52x slower |
+
+
+ | xml2js |
+ 3,847 op/s |
+ 13.63x slower |
+
+
+ | fast-xml-parser |
+ 3,534 op/s |
+ 14.84x slower |
+
+
+ | @xmldom/xmldom |
+ 2,578 op/s |
+ 20.34x slower |
+
+
+
+
+
+### POM (~5 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 19,591 op/s |
+ — |
+
+
+ | tXml |
+ 11,172 op/s |
+ 1.75x slower |
+
+
+ | htmlparser2 |
+ 3,526 op/s |
+ 5.56x slower |
+
+
+ | xml2js |
+ 1,590 op/s |
+ 12.32x slower |
+
+
+ | fast-xml-parser |
+ 1,275 op/s |
+ 15.37x slower |
+
+
+ | @xmldom/xmldom |
+ 860 op/s |
+ 22.77x slower |
+
+
+
+
+
+### EPG (~9 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 23,252 op/s |
+ — |
+
+
+ | tXml |
+ 13,230 op/s |
+ 1.76x slower |
+
+
+ | htmlparser2 |
+ 4,745 op/s |
+ 4.90x slower |
+
+
+ | fast-xml-parser |
+ 2,198 op/s |
+ 10.58x slower |
+
+
+ | xml2js |
+ 2,056 op/s |
+ 11.31x slower |
+
+
+ | @xmldom/xmldom |
+ 1,405 op/s |
+ 16.55x slower |
+
+
+
+
+
+### attr-heavy (~10 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml |
+ 19,559 op/s |
+ — |
+
+
+ | tXml |
+ 10,764 op/s |
+ 1.82x slower |
+
+
+ | htmlparser2 |
+ 3,938 op/s |
+ 4.97x slower |
+
+
+ | xml2js |
+ 1,498 op/s |
+ 13.06x slower |
+
+
+ | fast-xml-parser |
+ 1,272 op/s |
+ 15.38x slower |
+
+
+ | @xmldom/xmldom |
+ 733 op/s |
+ 26.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
+
+
+
+
+
+ | Library |
+ geometric mean |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 21,566 op/s |
+ — |
+
+
+ | easysax |
+ 16,708 op/s |
+ 1.29x slower |
+
+
+ | @tuananh/sax-parser |
+ 15,546 op/s |
+ 1.39x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 10,703 op/s |
+ 2.02x slower |
+
+
+ | htmlparser2 |
+ 8,747 op/s |
+ 2.47x slower |
+
+
+ | saxes |
+ 8,494 op/s |
+ 2.54x slower |
+
+
+ | sax |
+ 4,583 op/s |
+ 4.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)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 86,980 op/s |
+ — |
+
+
+ | easysax |
+ 66,925 op/s |
+ 1.30x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 32,998 op/s |
+ 2.64x slower |
+
+
+ | saxes |
+ 29,356 op/s |
+ 2.96x slower |
+
+
+ | htmlparser2 |
+ 28,528 op/s |
+ 3.05x slower |
+
+
+ | @tuananh/sax-parser |
+ 23,954 op/s |
+ 3.63x slower |
+
+
+ | sax |
+ 14,792 op/s |
+ 5.88x slower |
+
+
+
+
+
+### EPG (~9 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 16,960 op/s |
+ — |
+
+
+ | @tuananh/sax-parser |
+ 15,829 op/s |
+ 1.07x slower |
+
+
+ | easysax |
+ 14,401 op/s |
+ 1.18x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 10,874 op/s |
+ 1.56x slower |
+
+
+ | htmlparser2 |
+ 7,039 op/s |
+ 2.41x slower |
+
+
+ | saxes |
+ 6,612 op/s |
+ 2.57x slower |
+
+
+ | sax |
+ 3,826 op/s |
+ 4.43x slower |
+
+
+
+
+
+### POM (~5 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 15,205 op/s |
+ — |
+
+
+ | @tuananh/sax-parser |
+ 13,789 op/s |
+ 1.10x slower |
+
+
+ | easysax |
+ 11,127 op/s |
+ 1.37x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 8,754 op/s |
+ 1.74x slower |
+
+
+ | saxes |
+ 6,692 op/s |
+ 2.27x slower |
+
+
+ | htmlparser2 |
+ 5,647 op/s |
+ 2.69x slower |
+
+
+ | sax |
+ 3,011 op/s |
+ 5.05x slower |
+
+
+
+
+
+### EPG 64 B stress
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | @tuananh/sax-parser |
+ 15,447 op/s |
+ — |
+
+
+ | Eksml (SAX) |
+ 15,253 op/s |
+ 1.01x slower |
+
+
+ | easysax |
+ 11,398 op/s |
+ 1.36x slower |
+
+
+ | htmlparser2 |
+ 6,797 op/s |
+ 2.27x slower |
+
+
+ | saxes |
+ 6,169 op/s |
+ 2.50x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 5,087 op/s |
+ 3.04x slower |
+
+
+ | sax |
+ 3,799 op/s |
+ 4.07x slower |
+
+
+
+
+
+### attr-heavy (~10 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 13,636 op/s |
+ — |
+
+
+ | @tuananh/sax-parser |
+ 11,243 op/s |
+ 1.21x slower |
+
+
+ | easysax |
+ 10,651 op/s |
+ 1.28x slower |
+
+
+ | Eksml (XmlParseStream) |
+ 8,787 op/s |
+ 1.55x slower |
+
+
+ | htmlparser2 |
+ 6,642 op/s |
+ 2.05x slower |
+
+
+ | saxes |
+ 5,518 op/s |
+ 2.47x slower |
+
+
+ | sax |
+ 3,121 op/s |
+ 4.37x slower |
+
+
+
+
+
+## Raw Tokenization (no-op callbacks)
+
+Pure scanner throughput with no downstream work -- isolates the tokenizer's raw speed.
+
+### Overview
+
+
+
+
+
+ | Library |
+ geometric mean |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 27,205 op/s |
+ — |
+
+
+ | easysax |
+ 18,038 op/s |
+ 1.51x slower |
+
+
+ | @tuananh/sax-parser |
+ 16,027 op/s |
+ 1.70x slower |
+
+
+ | saxes |
+ 11,093 op/s |
+ 2.45x slower |
+
+
+ | htmlparser2 |
+ 9,308 op/s |
+ 2.92x slower |
+
+
+ | sax |
+ 4,665 op/s |
+ 5.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)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 113,398 op/s |
+ — |
+
+
+ | easysax |
+ 72,718 op/s |
+ 1.56x slower |
+
+
+ | saxes |
+ 34,958 op/s |
+ 3.24x slower |
+
+
+ | htmlparser2 |
+ 28,807 op/s |
+ 3.94x slower |
+
+
+ | @tuananh/sax-parser |
+ 22,715 op/s |
+ 4.99x slower |
+
+
+ | sax |
+ 14,575 op/s |
+ 7.78x slower |
+
+
+
+
+
+### EPG (~9 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 20,910 op/s |
+ — |
+
+
+ | @tuananh/sax-parser |
+ 16,455 op/s |
+ 1.27x slower |
+
+
+ | easysax |
+ 15,663 op/s |
+ 1.33x slower |
+
+
+ | saxes |
+ 9,285 op/s |
+ 2.25x slower |
+
+
+ | htmlparser2 |
+ 7,642 op/s |
+ 2.74x slower |
+
+
+ | sax |
+ 3,938 op/s |
+ 5.31x slower |
+
+
+
+
+
+### POM (~5 KB)
+
+
+
| Library |
- small (~100 B) |
- RSS (~3 KB) |
- SOAP (~2 KB) |
- Atom (~3 KB) |
- POM (~5 KB) |
- EPG (~9 KB) |
- attr-heavy (~10 KB) |
+ op/s |
+ vs fastest |
- | Eksml |
- 1,671,761 op/s |
- 136,836 op/s |
- 80,224 op/s |
- 50,820 op/s |
- 19,368 op/s |
- 23,393 op/s |
- 19,238 op/s |
+ Eksml (SAX) |
+ 19,668 op/s |
+ — |
- | tXml |
- 938,409 op/s |
- -- |
- 48,620 op/s |
- 27,021 op/s |
- 11,108 op/s |
- 13,098 op/s |
- 10,194 op/s |
+ @tuananh/sax-parser |
+ 14,481 op/s |
+ 1.36x slower |
- | htmlparser2 |
- 535,825 op/s |
- 25,042 op/s |
- 15,399 op/s |
- 9,501 op/s |
- 3,306 op/s |
- 4,455 op/s |
- 3,918 op/s |
+ easysax |
+ 12,674 op/s |
+ 1.55x slower |
- | fast-xml-parser |
- 156,563 op/s |
- 9,766 op/s |
- 7,369 op/s |
- 3,294 op/s |
- 1,315 op/s |
- 2,233 op/s |
- 1,271 op/s |
+ saxes |
+ 8,199 op/s |
+ 2.40x slower |
- | xml2js |
- 119,736 op/s |
- 8,574 op/s |
- 6,932 op/s |
- 3,876 op/s |
- 1,589 op/s |
- 2,036 op/s |
- 1,406 op/s |
+ htmlparser2 |
+ 6,288 op/s |
+ 3.13x slower |
- | @xmldom/xmldom |
- 93,498 op/s |
- 6,212 op/s |
- 4,952 op/s |
- 2,551 op/s |
- 870 op/s |
- 1,396 op/s |
- 732 op/s |
+ sax |
+ 3,109 op/s |
+ 6.33x 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)
+### EPG 64 B stress
-Chunked streaming parse where each parser tokenizes SAX events and builds a full DOM tree.
+
| Library |
- RSS (~3 KB) |
- EPG (~9 KB) |
- POM (~5 KB) |
- EPG 64 B stress |
- attr-heavy (~10 KB) |
+ op/s |
+ vs fastest |
| Eksml (SAX) |
- 88,604 op/s |
- 18,605 op/s |
- 14,986 op/s |
- 15,003 op/s |
- 13,719 op/s |
+ 17,497 op/s |
+ — |
- | easysax |
- 66,566 op/s |
- 13,357 op/s |
- 11,065 op/s |
- 11,233 op/s |
- 10,310 op/s |
+ @tuananh/sax-parser |
+ 15,967 op/s |
+ 1.10x slower |
- | Eksml (XmlParseStream) |
- 33,687 op/s |
- 10,776 op/s |
- 9,020 op/s |
- 5,186 op/s |
- 9,352 op/s |
+ easysax |
+ 11,017 op/s |
+ 1.59x slower |
| saxes |
- 27,902 op/s |
- 5,884 op/s |
- 6,687 op/s |
- 6,013 op/s |
- 5,117 op/s |
+ 8,085 op/s |
+ 2.16x slower |
| htmlparser2 |
- 26,433 op/s |
- 7,080 op/s |
- 5,779 op/s |
- 6,598 op/s |
- 6,791 op/s |
+ 7,079 op/s |
+ 2.47x slower |
+
+
+ | sax |
+ 3,763 op/s |
+ 4.65x slower |
+
+
+
+
+
+### attr-heavy (~10 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (SAX) |
+ 18,262 op/s |
+ — |
| @tuananh/sax-parser |
- 24,238 op/s |
- 19,238 op/s |
- 12,849 op/s |
- 18,062 op/s |
- 11,681 op/s |
+ 12,236 op/s |
+ 1.49x slower |
- | sax |
- 14,553 op/s |
- 3,773 op/s |
- 2,848 op/s |
- 3,775 op/s |
- 3,055 op/s |
+ easysax |
+ 12,009 op/s |
+ 1.52x slower |
+
+
+ | saxes |
+ 7,808 op/s |
+ 2.34x slower |
+
+
+ | htmlparser2 |
+ 7,128 op/s |
+ 2.56x slower |
- | fast-xml-parser (sync, no streaming) |
- 9,296 op/s |
- 1,560 op/s |
- 1,659 op/s |
- 1,471 op/s |
- 1,271 op/s |
+ sax |
+ 3,290 op/s |
+ 5.55x 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.
+
-> [!note]
-> fast-xml-parser cannot stream; its row parses the fully buffered document synchronously and is included as a reference point.
+## Large document (10 MB, 64 KB chunks)
-## Raw Tokenization (no-op callbacks)
+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`.
-Pure scanner throughput with no downstream work -- isolates the tokenizer's raw speed.
+
| Library |
- RSS (~3 KB) |
- EPG (~9 KB) |
- POM (~5 KB) |
- EPG 64 B stress |
- attr-heavy (~10 KB) |
+ op/s |
+ vs fastest |
| Eksml (SAX) |
- 110,856 op/s |
- 21,940 op/s |
- 19,298 op/s |
- 16,679 op/s |
- 17,334 op/s |
+ 32.0 op/s |
+ — |
| easysax |
- 72,166 op/s |
- 14,151 op/s |
- 12,304 op/s |
- 11,824 op/s |
- 11,712 op/s |
+ 21.7 op/s |
+ 1.48x slower |
| saxes |
- 35,117 op/s |
- 8,889 op/s |
- 8,110 op/s |
- 8,692 op/s |
- 8,225 op/s |
+ 12.1 op/s |
+ 2.64x slower |
| htmlparser2 |
- 30,412 op/s |
- 7,587 op/s |
- 5,852 op/s |
- 7,028 op/s |
- 7,186 op/s |
+ 10.1 op/s |
+ 3.18x slower |
| @tuananh/sax-parser |
- 22,011 op/s |
- 16,577 op/s |
- 14,692 op/s |
- 15,889 op/s |
- 11,361 op/s |
+ 8.5 op/s |
+ 3.74x slower |
| sax |
- 14,798 op/s |
- 3,871 op/s |
- 3,102 op/s |
- 3,935 op/s |
- 3,229 op/s |
+ 5.4 op/s |
+ 5.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
+
+
+
| Library |
- small (~100 B) |
- RSS (~3 KB) |
- SOAP (~3 KB) |
- Atom (~6 KB) |
- POM (~8 KB) |
- EPG (~30 KB) |
+ geometric mean |
+ vs fastest |
| Eksml (validate: false) |
- 2,871,391 op/s |
- 397,795 op/s |
- 268,371 op/s |
- 114,966 op/s |
- 65,675 op/s |
- 48,937 op/s |
+ 218,385 op/s |
+ — |
| Eksml |
- 2,505,673 op/s |
- 333,746 op/s |
- 239,973 op/s |
- 99,309 op/s |
- 56,743 op/s |
- 44,820 op/s |
+ 190,592 op/s |
+ 1.15x slower |
| tXml |
- 2,497,332 op/s |
- -- |
- 226,516 op/s |
- 94,239 op/s |
- 48,090 op/s |
- 43,259 op/s |
+ 162,265 op/s |
+ 1.35x slower |
| htmlparser2 |
- 1,970,254 op/s |
- 91,344 op/s |
- 69,848 op/s |
- 31,198 op/s |
- 15,045 op/s |
- 16,766 op/s |
+ 68,263 op/s |
+ 3.20x slower |
| @xmldom/xmldom |
- 1,127,647 op/s |
- 46,274 op/s |
- 39,898 op/s |
- 14,238 op/s |
- 6,713 op/s |
- 6,579 op/s |
+ 35,335 op/s |
+ 6.18x slower |
| fast-xml-parser |
- 379,445 op/s |
- 22,185 op/s |
- 27,485 op/s |
- 10,187 op/s |
- 4,375 op/s |
- 3,526 op/s |
+ 18,703 op/s |
+ 11.68x slower |
| xml2js |
- 243,078 op/s |
- 17,016 op/s |
- 19,815 op/s |
- 8,582 op/s |
- 4,354 op/s |
- 3,735 op/s |
+ 15,320 op/s |
+ 14.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)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 2,595,279 op/s |
+ — |
+
+
+ | tXml |
+ 2,467,524 op/s |
+ 1.05x slower |
+
+
+ | Eksml |
+ 2,425,920 op/s |
+ 1.07x slower |
+
+
+ | htmlparser2 |
+ 2,013,627 op/s |
+ 1.29x slower |
+
+
+ | @xmldom/xmldom |
+ 1,146,014 op/s |
+ 2.26x slower |
+
+
+ | fast-xml-parser |
+ 382,971 op/s |
+ 6.78x slower |
+
+
+ | xml2js |
+ 245,615 op/s |
+ 10.57x slower |
+
+
+
+
+
+### RSS (~3 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 395,563 op/s |
+ — |
+
+
+ | Eksml |
+ 331,671 op/s |
+ 1.19x slower |
+
+
+ | htmlparser2 |
+ 97,145 op/s |
+ 4.07x slower |
+
+
+ | @xmldom/xmldom |
+ 46,099 op/s |
+ 8.58x slower |
+
+
+ | fast-xml-parser |
+ 23,292 op/s |
+ 16.98x slower |
+
+
+ | xml2js |
+ 16,994 op/s |
+ 23.28x slower |
+
+
+
+
+
+### SOAP (~3 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 269,993 op/s |
+ — |
+
+
+ | Eksml |
+ 235,359 op/s |
+ 1.15x slower |
+
+
+ | tXml |
+ 232,137 op/s |
+ 1.16x slower |
+
+
+ | htmlparser2 |
+ 69,038 op/s |
+ 3.91x slower |
+
+
+ | @xmldom/xmldom |
+ 39,906 op/s |
+ 6.77x slower |
+
+
+ | fast-xml-parser |
+ 27,028 op/s |
+ 9.99x slower |
+
+
+ | xml2js |
+ 19,135 op/s |
+ 14.11x slower |
+
+
+
+
+
+### Atom (~6 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 116,037 op/s |
+ — |
+
+
+ | Eksml |
+ 101,175 op/s |
+ 1.15x slower |
+
+
+ | tXml |
+ 93,107 op/s |
+ 1.25x slower |
+
+
+ | htmlparser2 |
+ 30,473 op/s |
+ 3.81x slower |
+
+
+ | @xmldom/xmldom |
+ 14,260 op/s |
+ 8.14x slower |
+
+
+ | fast-xml-parser |
+ 11,288 op/s |
+ 10.28x slower |
+
+
+ | xml2js |
+ 8,607 op/s |
+ 13.48x slower |
+
+
+
+
+
+### POM (~8 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 66,775 op/s |
+ — |
+
+
+ | Eksml |
+ 57,227 op/s |
+ 1.17x slower |
+
+
+ | tXml |
+ 48,396 op/s |
+ 1.38x slower |
+
+
+ | htmlparser2 |
+ 14,617 op/s |
+ 4.57x slower |
+
+
+ | @xmldom/xmldom |
+ 6,854 op/s |
+ 9.74x slower |
+
+
+ | fast-xml-parser |
+ 4,429 op/s |
+ 15.08x slower |
+
+
+ | xml2js |
+ 4,405 op/s |
+ 15.16x slower |
+
+
+
+
+
+### EPG (~30 KB)
+
+
+
+
+
+ | Library |
+ op/s |
+ vs fastest |
+
+
+ | Eksml (validate: false) |
+ 50,509 op/s |
+ — |
+
+
+ | Eksml |
+ 43,716 op/s |
+ 1.16x slower |
+
+
+ | tXml |
+ 43,583 op/s |
+ 1.16x slower |
+
+
+ | htmlparser2 |
+ 16,821 op/s |
+ 3.00x slower |
+
+
+ | @xmldom/xmldom |
+ 9,445 op/s |
+ 5.35x slower |
+
+
+ | xml2js |
+ 4,270 op/s |
+ 11.83x slower |
+
+
+ | fast-xml-parser |
+ 3,551 op/s |
+ 14.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 +1351,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) 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.
- **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..c53d985
--- /dev/null
+++ b/eksml/bench/benchmarks-to-markdown.mjs
@@ -0,0 +1,341 @@
+// 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, 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,
+// 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 = [
+ '',
+ ' ',
+ ' | Library | ',
+ ' geometric mean | ',
+ ' vs fastest | ',
+ '
',
+ ];
+ // 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(
+ ' ',
+ ` | ${label(row.display)} | `,
+ ` ${opsCell(row.geomean, row.geomean === best)} | `,
+ ` ${relative} | `,
+ '
',
+ );
+ }
+ lines.push('
');
+ 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 = [
+ '',
+ ' ',
+ ' | Library | ',
+ ' op/s | ',
+ ' vs fastest | ',
+ '
',
+ ];
+ for (const row of rows) {
+ const relative =
+ row.hz === fastest ? '—' : `${(fastest / row.hz).toFixed(2)}x slower`;
+ lines.push(
+ ' ',
+ ` | ${label(row.display)} | `,
+ ` ${opsCell(row.hz, row.hz === fastest)} | `,
+ ` ${relative} | `,
+ '
',
+ );
+ }
+ lines.push('
');
+ return lines.join('\n');
+}
+
+/** 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) => {
+ tables.set(
+ `${section.title} :: ${fixture.header}`,
+ renderFixture(section, columnData[i]),
+ );
+ });
+ }
+ 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'));
+}
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..544c840 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 --write BENCHMARKS.md",
"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': {}