Benchmarks · XML
XML parsing and DOM operations
This page collects every measured XML race for the leptris ecosystem, with one section per language. Each section presents the standing matrix followed by the complete operation tables and their provenance, and the page closes with the consolidated measured-deficits table.
| XML parse / DOM — competitor | C / C++ | Ruby | Python |
|---|---|---|---|
| elementtree | — | — | 0.34×–23.24× |
| libxml2 | 0.51×–19.08× | — | — |
| lxml | — | — | 1.33×+ |
| minidom | — | — | 12.78×+ |
| moxml | — | 0.69×–0.69× | — |
| nokogiri | — | 1.33×+ | — |
| ox | — | 0.50×–6.56× | — |
| pugixml | 0.20×–3.36× | — | — |
A green cell with a single value means every measured operation in that race is ahead, and the value shown is the smallest margin. A neutral cell shows the range from the weakest to the strongest operation; hovering lists the operations that remain behind. All rows, including deficits, appear in the tables below.
C and C++
libleptris versus libxml2 and pugixml
libxml2 is the de facto standard C parser and is the engine beneath lxml and Nokogiri. pugixml is a C++ library optimized for zero-copy DOM construction. The comparisons in this section run in a single harness on identical fixtures with identical iteration protocols, so every ratio is a same-binary baseline.
DOM parsing of a 2 MB text-heavy document, measured in C 3.4× vs pugixml
Minimum of 20 iterations; Release builds with identical flags on the same machine
Parse, SAX, serialization and mutation
| operation | leptris side | competitor (same run) | standing |
|---|---|---|---|
| DOM parse — text-heavy 2 MB | 191 µs | 642 µs | 3.36× |
| DOM parse — text-heavy 2 MB | 191 µs | 966 µs | 5.06× |
| DOM parse — attr-heavy (5k attrs, 559 KB) | 2070 µs | 417 µs | 0.20× the parse wall on this shape: single-pass at a compiler-global optimum is ~5x behind pugixml's zero-copy attr handling; no known lever |
| DOM parse — attr-heavy (5k attrs, 559 KB) | 2070 µs | 7985 µs | 3.86× |
| DOM parse — pretty-printed 1 MB | 2449 µs | 1217 µs | 0.50× apples-to-apples caveat: leptris keeps whitespace-only text nodes (libxml2/Nokogiri semantics) while pugixml discards them; LEPTRIS_PARSE_DROP_WS_TEXT closes most of this gap on pretty shapes |
| DOM parse — pretty-printed 1 MB | 2449 µs | 23024 µs | 9.40× |
| DOM parse — catalog (5000 items, 367 KB) | 504 µs | 331 µs | 0.66× mixed-content shapes carry the index bookkeeping pugixml skips |
| DOM parse — catalog (5000 items, 367 KB) | 504 µs | 3558 µs | 7.06× |
| SAX parse — text-heavy 2 MB | 905 µs | 859 µs | 0.95× |
| SAX parse — attr-heavy (5k attrs) | 2996 µs | 2311 µs | 0.77× |
| SAX parse — pretty-printed 1 MB | 4173 µs | 5757 µs | 1.38× |
| SAX parse — catalog (5000 items) | 1189 µs | 1136 µs | 0.95× |
| Serialize — attr-heavy 559 KB | 492 µs | 621 µs | 1.26× |
| Serialize — text-heavy 2 MB | 1080 µs | 657 µs | 0.61× pugixml's zero-copy text spans win the pure-text shape; the 32-byte split-stream attr design carries its cost here |
| Append 10,000 children | 377 µs | 119 µs | 0.32× doc-level index bookkeeping — the price of the O(1) duplicate-rejecting index at scale |
| Append 10,000 children | 377 µs | 544 µs | 1.44× |
| Set 2,000 attributes (duplicate-rejecting) | 480 µs | 198 µs | 0.41× per-attr hash insert on tiny attrs; pugixml appends raw |
| Set 2,000 attributes (duplicate-rejecting) | 480 µs | 9156 µs | 19.08× |
fresh runlibleptris 1.9.143-dev (main eea85cf)·min of 20 (Release + LTO)·Apple M1 Max, macOS 14.1.1, arm64··bench_matrix — same fixtures, same iteration protocol, all three libraries
DOM read operations
| operation | leptris side | libxml2 2.9.13 | standing |
|---|---|---|---|
| DOM parse (~10 KB medium doc) | 7.43 µs | 42.38 µs | 5.70× |
| Tree traversal (full walk) | 2.19 µs | 1.11 µs | 0.51× libxml2's next/prev pointers are bare struct hops; the arena layout pays a small toll per step |
| Attribute access (100×) | 2.92 µs | 3.24 µs | 1.11× |
| Text extraction (100×) | 1.13 µs | 7.82 µs | 6.92× |
| SAX parse — small (~1 KB) | 3.37 µs | 6.75 µs | 2.00× |
| SAX parse — medium (~5 KB) | 14.38 µs | 16 µs | 1.11× |
fresh runlibleptris 1.9.143-dev (main eea85cf)·mean of 1000 (Release + LTO)·Apple M1 Max, macOS 14.1.1, arm64··DOM / SAX / XPath lane benches (bench_dom_*, bench_sax_*, bench_xpath_*)
To reproduce: configure withcmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DLEPTRIS_BUILD_BENCHMARKS=ONand run the binaries under build/benchmarks/. The harness is committed to the repository atbenchmarks.
Ruby
The leptris gem versus Nokogiri, Ox and moxml
The gem provides an API compatible with Nokogiri on top of libleptris. The operation matrix in this section was measured with the committed harness on identical fixtures. The remaining comparisons, against Ox and moxml, are carried from the performance ledger in the gem repository, and each of those rows is marked as an artifact with its provenance.
Parsing and serializing a 12 KB document through Ruby 9.2× / 3.1× vs Nokogiri
Identical fixtures and iteration counts from the committed matrix harness
Operation matrix versus Nokogiri
| operation | leptris side | nokogiri 1.19.4 | standing |
|---|---|---|---|
| Parse small (431 B) | 8.74 µs | 11.61 µs | 1.33× |
| Parse medium (12 KB) | 20.38 µs | 187.2 µs | 9.19× |
| Tree traversal (root.traverse) | 268.81 µs | 468.38 µs | 1.74× formerly the one loss vs Nokogiri — fell to the batch-accessor sprint |
| Serialize (Document#to_xml) | 25.61 µs | 79.53 µs | 3.11× |
fresh runleptris 1.9.121.0 (gem, lockstep with libleptris 1.9.121)·mean per iteration (µs)·Apple M1 Max, macOS 14.1.1, arm64; Ruby 3.4.8··leptris_vs_nokogiri.rb — same fixtures, same iteration counts
Tree traversal was the last operation to lose against Nokogiri in earlier releases and is now 1.74× ahead, following the batch-accessor work recorded in the gem changelog.
Whole-field comparison on a real-world document
| operation | leptris side | the field | standing |
|---|---|---|---|
| Real-world document, 4.38 MB — parse + serialize (10,000 records, pretty-printed, encoding-declared) | 18 ms | 123 ms | 6.83× carried from the serialbench full-field artifact behind the September site refresh; re-run when serialbench gains a local environment config |
| Real-world document, 4.38 MB — parse + serialize (10,000 records, pretty-printed, encoding-declared) | 18 ms | 118 ms | 6.56× carried from the serialbench full-field artifact behind the September site refresh; re-run when serialbench gains a local environment config |
artifactleptris gem 1.9.x line·per-iteration, same process·not recorded in the artifact··serialbench full-field run — artifact carried from the September 2026 site refresh (not re-run here)
The 4.38 MB real-world document comparison races Ox, Nokogiri, Oga and REXML in a single process. The serialbench harness is maintained in its own repository, where the leptris adapter is published.
Binding-internal improvement measurements
The following table is not a cross-library comparison. It measures the gem against earlier releases of itself on the same machine and with the same loops, using the committed read-path harness, and it is provided to show the effect of the memoization work in the 1.9 release line.
| Loop over a 300-element read-only document | gem 1.9.2 | gem 1.9.17 | Result |
|---|---|---|---|
| Namespace inspection | 0.62 s | 0.017 s | 36× faster |
| Attribute reads | 0.63 s | 0.048 s | 13× faster |
| Text content reads | 0.19 s | 0.035 s | 5× faster |
To reproduce: runbundle exec ruby -Ilib benchmark/leptris_vs_nokogiri.rbin the gem repository. The benchmark guide isin the repository.
Python
The leptris package versus lxml and the standard library
lxml is a C extension over the same libxml2 library that Nokogiri wraps, and ElementTree ships with Python with C acceleration. The leptris binding uses an abi3 C accelerator that is bound to libleptris through a fixed positional protocol. Wheels are published for macOS and manylinux on both architectures, and for Windows.
Parsing a 12 KB document through Python 4.6× vs lxml
Mean per iteration after warm-up, with garbage collection quieted during the timed loop
Operation matrix against the full field
| operation | leptris side | lxml / stdlib | standing |
|---|---|---|---|
| Parse small (431 B) | 4.25 µs | 5.64 µs | 1.33× |
| Parse medium (12 KB) | 22.61 µs | 104.85 µs | 4.64× |
| Traversal (401 elements) | 9.86 µs | 19.35 µs | 1.96× |
| Traversal (401 elements) | 9.86 µs | 3.4 µs | 0.34× ElementTree's iter() is a bare generator over a lean list — the stdlib's one crown; everything else it does is 2-23x behind |
| Serialize | 24.31 µs | 56.52 µs | 2.32× |
| Serialize | 24.31 µs | 565.07 µs | 23.24× |
| Serialize | 24.31 µs | 561.79 µs | 23.10× |
| Parse small (431 B) | 4.25 µs | 7.93 µs | 1.87× |
| Parse medium (12 KB) | 22.61 µs | 158.25 µs | 7.00× |
| Parse small (431 B) | 4.25 µs | 54.32 µs | 12.78× |
fresh runleptris 1.9.121.0 (binding) on libleptris 1.9.143-dev·mean per iteration·Apple M1 Max, macOS 14.1.1, arm64; Python 3.10.5··benchmarks/matrix.py — same fixtures as the Ruby matrix
The binding is ahead of lxml on every measured operation. The only standard-library comparison it loses is traversal against ElementTree, whose iterator is a bare generator over a compact internal list. That deficit is listed in the measured-deficits table below and is tracked in the binding repository.
To reproduce: runLEPTRIS_LIB_PATH=…/libleptris.dylib python3 benchmarks/matrix.pyin the Python binding repository, which uses the same fixtures and operations as the Ruby matrix.
XML
Measured deficits
This table lists every XML operation in the result data that remains behind its competitor, ordered from the weakest ratio. Each row carries the harness it was measured with, and none of these rows is omitted from any other table on this site.
| operation | leptris side | versus | standing |
|---|---|---|---|
| DOM parse — attr-heavy (5k attrs, 559 KB) | 2070 µs | 417 µs | 0.20× the parse wall on this shape: single-pass at a compiler-global optimum is ~5x behind pugixml's zero-copy attr handling; no known lever |
| Append 10,000 children | 377 µs | 119 µs | 0.32× doc-level index bookkeeping — the price of the O(1) duplicate-rejecting index at scale |
| Traversal (401 elements) | 9.86 µs | 3.4 µs | 0.34× ElementTree's iter() is a bare generator over a lean list — the stdlib's one crown; everything else it does is 2-23x behind |
| Set 2,000 attributes (duplicate-rejecting) | 480 µs | 198 µs | 0.41× per-attr hash insert on tiny attrs; pugixml appends raw |
| DOM parse — pretty-printed 1 MB | 2449 µs | 1217 µs | 0.50× apples-to-apples caveat: leptris keeps whitespace-only text nodes (libxml2/Nokogiri semantics) while pugixml discards them; LEPTRIS_PARSE_DROP_WS_TEXT closes most of this gap on pretty shapes |
| Native-loop traversal | — | ox 2.14.x | 0.50× Ox's C walk holds the crown across the whole Ruby field — the one place it still wins anything; ~2x per the ledger |
| Tree traversal (full walk) | 2.19 µs | 1.11 µs | 0.51× libxml2's next/prev pointers are bare struct hops; the arena layout pays a small toll per step |
| Serialize — text-heavy 2 MB | 1080 µs | 657 µs | 0.61× pugixml's zero-copy text spans win the pure-text shape; the 32-byte split-stream attr design carries its cost here |
| DOM parse — catalog (5000 items, 367 KB) | 504 µs | 331 µs | 0.66× mixed-content shapes carry the index bookkeeping pugixml skips |
| Expanded-name reads, NS-heavy loop | — | moxml 0.5.31 | 0.69× measured off the C profile: the C resolver is ~20 ns/read either way — the cost is the adapter's per-read FFI fan-out. The batched-call lever (leptris_element_expanded_name, v1.9.144) shipped as an adapter primitive but was 4-way benchmarked for the gem's read path and does not pay there: the batch's buffer+tuple allocation outweighs the saved crossing. The row stays open on the adapter seam |
| SAX parse — attr-heavy (5k attrs) | 2996 µs | 2311 µs | 0.77× |