EventReader.next(), both initializers, and the Event alias are public (an internal typealias in a split file had been shadowing the public Parsing.Event); a new PureXMLPublicAPITests target compiles without testable import so facade gaps fail CI. The large-corpus benchmarks exposed document-order sorting as quadratic on flat fan-outs; the fix's first attempt was itself worse (an eager sibling table built per predicate evaluation), caught by sampling the live run, and replaced with a lazy table: single-node sets never touch order keys and a parent's table builds on its second lookup. XPath at 42 MB drops 27.1 s to 1.57 s (190x to 11.7x); at 210 MB PureXML completes in 9.4 s the query libxml2 refuses at its 10M node-set cap (#143 tracks our configurable budget). Benchmark harness: size series to 210 MB, CORPUS mode for real files (NASA, SwissProt measured), SAX stage, refusal-aware table, NULL-safe C driver. All conformance suites, Linux, and WASM verified. Closes #142.
86 lines
4.0 KiB
Markdown
86 lines
4.0 KiB
Markdown
# Benchmarks: PureXML vs libxml2 (#139)
|
|
|
|
`bash scripts/benchmark.sh [items] [iterations]` generates a deterministic
|
|
corpus (never vendored), builds PureXML in release and the C driver against
|
|
the macOS SDK's libxml2, runs both over the same bytes with internal timing
|
|
(best-of-N, IO excluded), and prints the comparison. Both drivers also
|
|
cross-check the same XPath count, so every benchmark run doubles as a
|
|
cross-implementation correctness check.
|
|
|
|
## Baseline (first measurement)
|
|
|
|
20000 items, ~4.2 MB, best of 5, Apple Silicon (Studio), release builds:
|
|
|
|
| operation | PureXML | libxml2 | ratio |
|
|
|---|---|---|---|
|
|
| parse | 0.49 s | 0.016 s | ~30x |
|
|
| serialize | 0.059 s | 0.012 s | ~5x |
|
|
| xpath | 0.42 s | 0.012 s | ~36x |
|
|
|
|
These are the honest starting numbers the #139 optimization passes burn
|
|
down; the table updates as passes land, never silently.
|
|
|
|
## Current record (size series + real corpora, best of 3)
|
|
|
|
| corpus | parse | serialize | xpath |
|
|
|---|---|---|---|
|
|
| generated 4.2 MB | 0.34 s (18.7x) | 0.064 s (5.5x) | 0.15 s (11.9x) |
|
|
| generated 42 MB | 3.49 s (18.7x) | 0.63 s (5.6x) | 1.57 s (11.7x) |
|
|
| generated 210 MB | 19.3 s (19.7x) | 3.19 s (5.6x) | 9.4 s (libxml2: **refused**) |
|
|
| NASA ADC 25 MB (real) | 1.73 s (24.5x) | 0.42 s (8.0x) | 0.76 s (19.4x) |
|
|
| SwissProt 115 MB (real) | 11.0 s (22.0x) | 1.77 s (5.5x) | 5.0 s (18.7x) |
|
|
|
|
Two findings only the large corpora could surface:
|
|
|
|
- PureXML's document-order sort was quadratic on flat fan-outs (42 MB:
|
|
27 s in XPath, ratio 190x). Fixed; and the first fix attempt was itself
|
|
quadratic in a worse place (an eager sibling-table built per predicate
|
|
evaluation: one hour at 42 MB), caught by sampling the live run. The
|
|
cache now early-outs single-node sets and builds a parent's table only
|
|
on its second lookup. XPath at 42 MB: 27.1 s -> 1.57 s.
|
|
- libxml2 refuses XPath evaluation past its XPATH_MAX_NODESET_LENGTH cap
|
|
(10M nodes): the 210 MB corpus query returns NULL by design. PureXML
|
|
completes it in 9.4 s. The protective counterpart for hostile input is
|
|
tracked as #143.
|
|
|
|
Scaling: all three PureXML operations are linear in document size (ratios
|
|
flat from 4 MB to 210 MB), and there is no 2 GB integer cliff (64-bit
|
|
offsets throughout; libxml2's xmlReadMemory takes an int byte count).
|
|
|
|
## Optimization log
|
|
|
|
| pass | change | parse | xpath | serialize |
|
|
|---|---|---|---|---|
|
|
| baseline | - | 0.494 s (30x) | 0.413 s (37x) | 0.061 s (5.5x) |
|
|
| 1 | allocation-free Reader.matches() | 0.414 s (26x) | unchanged | unchanged |
|
|
| 2 | byte-backed Reader (owned UTF-8 storage, pointer-decoded scalars, byte-level literal match/consume) | 0.375 s (23x) | unchanged | unchanged |
|
|
| 3 | bulk content runs (plain-ASCII character data scanned and consumed at the byte level, String built once per run) | 0.335 s (20x) | unchanged | unchanged |
|
|
|
|
## Profile findings (first sample, parse path)
|
|
|
|
Top of stack, release build: String.Iterator.next (the per-character pull
|
|
closure chain), _stringCompareWithSmolCheck (Character equality in
|
|
matches/peek), String._uncheckedFromUTF8 and _allASCII (small-string
|
|
materialization), bridgeObjectRelease/swift_release (ARC on Character
|
|
buffers), UnicodeScalarView.distance. The Reader's architecture: one
|
|
closure call per scalar, [Character] lookahead, Character-typed
|
|
classification. The structural fix is a byte-level (UTF-8) scanner with
|
|
scalar classification tables; that is its own #139 pass, designed, not
|
|
patched.
|
|
|
|
## Known structure of the gap
|
|
|
|
- Parse and XPath dominate; serialize is closest.
|
|
- The scanner-vs-tree split needs the SAX stage, blocked on #142 (the
|
|
public streaming surface is internal today).
|
|
- Suspects to profile, in order: Character/grapheme-based scanning versus
|
|
byte scanning, per-scalar reader buffering (#135), ARC traffic in tree
|
|
construction, XPath node-set materialization on every axis step.
|
|
|
|
## Rules
|
|
|
|
- Optimize only what these numbers indict, one suspect per pass, with the
|
|
before/after recorded here.
|
|
- The corpus generator changes only with a note here, since ratios are
|
|
only comparable over identical corpora.
|