Skip to content

Commit 1a0aa0d

Browse files
authored
Route FlatNode's span→view helpers through the span-native path (#114)
_fsub and _fsub_or_empty rebuilt every view with a prevind walk plus the checked constructor's unconditional nextind, paid by every accessor and the build's close-tag comparison. Both now route through the tokenizer's _noshift_substring; sound one hop removed, since every stored span is a token span or a tokenizer accessor's field pair. sourcespan/splicetext keep their prevind (character-index API). The shared helper now selects its body at load time (next to the hasmethod guard; precompile caches are keyed by the flag): --check-bounds=yes compiles the checked reference reconstruction wholesale, default builds compile the bare noshift store. Forced by a zero-allocation guard: simple_value's union return stays unboxed only while the inlined view construction is a single branch-free path, and any branch around it (empty-span early return, active @BoundsCheck) re-boxed the SubString member, wherever the validation sat. Extract profile: prevind/nextind/isvalid self time (24/14/8% of the 6.6 ms full extract) drops to zero. BenchmarkTools medians, 14 MB corpus, same-session A/B: extract FlatNode 6.69 -> 3.08 ms (-54%), now ahead of Node's direct field reads (3.66); FlatNode build 28.46 -> 27.18 ms. Allocations and retained sizes identical; Pkg.test 3999/3999 in both bounds modes. Table 4 and the README access-pattern table re-measured in this PR; CHANGELOG entry under Unreleased. Closes #113. Assisted-by: Claude (Anthropic)
1 parent 3ddec6c commit 1a0aa0d

7 files changed

Lines changed: 75 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6060
lex −7 %, `Node` build −10 %, `FlatNode` build −19 % (now ~1.7× ahead of libxml2),
6161
`Cursor` full stream −12 %, `LazyNode` full walk −8 %; allocations unchanged (#111).
6262

63+
- **`FlatNode`'s span→view helpers use the span-native path too**: every accessor
64+
(`tag`, `value`, attributes, by-key lookups, `sourcetext`) and the build's close-tag
65+
comparison rebuilt views with a `prevind` walk plus the checked constructor's
66+
unconditional `nextind`; they now route through the tokenizer's `_noshift_substring`,
67+
sound one hop removed (stored spans are token spans or tokenizer-accessor field
68+
pairs). Full extract on the 14 MB corpus drops from 6.6 to 3.1 ms (−54 %) — the flat
69+
store now out-extracts `Node`'s direct field reads — and the build gains ~4 %. Under
70+
`--check-bounds=yes` (as in `Pkg.test`) the build compiles the checked reference
71+
reconstruction wholesale, selected at load time; allocations unchanged (#113).
72+
6373
## [0.4.4] - 2026-07-31
6474

6575
### Added

PERFORMANCE-v0.4.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ The headline cross-library figures live in the [README](README.md#benchmarks). T
44

55
## The theory behind "optimal"
66

7-
XML parsing splits into two language-theory levels, and v0.4 hits the **asymptotic lower bound** of each — the sense in which the lexer and parser are "optimal". The gap to a C library like [libxml2](https://en.wikipedia.org/wiki/Libxml2) is constant-factor (C tuning, a leaner non-Julia-heap tree), not asymptotic — and only on the pointer-tree `Node` build; `FlatNode` now out-builds libxml2 (Table 4), and at streaming XML.jl is ~2.5× *faster* (Table 1).
7+
XML parsing splits into two language-theory levels, and v0.4 hits the **asymptotic lower bound** of each — the sense in which the lexer and parser are "optimal". The gap to a C library like [libxml2](https://en.wikipedia.org/wiki/Libxml2) is constant-factor (C tuning, a leaner non-Julia-heap tree), not asymptotic — and only on the pointer-tree `Node` build; `FlatNode` out-builds libxml2 (Table 4), and at streaming XML.jl is ~2.5× *faster* (Table 1).
88

99
### Level 1 — lexing is finite-state
1010

@@ -118,17 +118,17 @@ Measured on the same XMark document:
118118
119119
| Full DOM, per reader | build (incl. GC) | walk every node | extract all values | DOM size in memory |
120120
|---|--:|--:|--:|--:|
121-
| **`FlatNode`** | **28.3 ms (GC 0.1)** | **2.97 ms** | 6.5 ms | **54.9 MiB** |
122-
| `Node` | 72.9 ms (GC 23) | 3.34 ms | **3.6 ms** | 71.6 MiB |
123-
| EzXML (libxml2) | 47.2 ms | — | — | — |
121+
| **`FlatNode`** | **27.2 ms (GC 0.1)** | **2.97 ms** | **3.1 ms** | **54.9 MiB** |
122+
| `Node` | 70.7 ms (GC 23) | 3.46 ms | 3.7 ms | 71.6 MiB |
123+
| EzXML (libxml2) | 46.6 ms | — | — | — |
124124
125125
_Table 4 — per-reader full-DOM comparison; *build* is the whole `parse` call, and *DOM size* is the **retained** live tree (`Base.summarysize`), not allocations._[^flatbench]
126126
127-
Build allocations: 42.2 MiB (`FlatNode`) vs 99.8 MiB (`Node` — down from 122.3 MiB before the scratch-stack build), and the *build* ranking now puts `FlatNode` ahead of libxml2 itself (~1.7× faster; `Node` remains ~1.5× behind the C library). The GC cells say why `FlatNode` builds so cheaply: its build allocates a handful of arrays instead of 882 K objects, so its median GC share is ~0.1 ms where `Node`'s is ~23 ms. Access on the finished stores: whole-tree walks are close (2.97 vs 3.34 ms — exact-size children vectors keep `Node`'s locality sharp), `parent`/`depth` stay O(1) index hops on `FlatNode` where `Node` must search down from the root, and pure value extraction on an already-built tree is the pattern where `Node`'s direct field reads win outright (3.6 vs 6.5 ms, ~1.8× — a computed `SubString` view per value is the flat store's toll).
127+
Build allocations: 42.2 MiB (`FlatNode`) vs 99.8 MiB (`Node`), and the *build* ranking puts `FlatNode` ahead of libxml2 itself (~1.7× faster; `Node` sits ~1.5× behind the C library). The GC cells say why `FlatNode` builds so cheaply: its build allocates a handful of arrays instead of 882 K objects, so its median GC share is ~0.1 ms where `Node`'s is ~23 ms. Access on the finished stores: whole-tree walks are close (2.97 vs 3.46 ms — exact-size children vectors keep `Node`'s locality sharp), `parent`/`depth` stay O(1) index hops on `FlatNode` where `Node` must search down from the root, and pure value extraction is close too, flat store slightly ahead (3.1 vs 3.7 ms — a per-value `SubString` view costs two integer stores).
128128
129129
### Choosing
130130
131-
Stream / low-memory / read-only full-DOM / repeated traversal → **XML.jl**; `FlatNode` now out-builds the libxml2 binder (~1.7×: 28.3 vs 47.2 ms), and the C library's remaining win is the one-shot *`Node`* build-and-extract (~1.3× end-to-end, Table 2) — either way, pure Julia, no C dependency. Against its own past, v0.4 is **~5× faster and ~12× leaner than 0.3.9** (which used ~1.4 GiB for this file) — see [`benchmarks/profile.jl`](benchmarks/profile.jl), [`benchmarks/profile_vs_039.jl`](benchmarks/profile_vs_039.jl), [`benchmarks/compare.jl`](benchmarks/compare.jl).
131+
Stream / low-memory / read-only full-DOM / repeated traversal → **XML.jl**; `FlatNode` out-builds the libxml2 binder (~1.7×: 27.2 vs 46.6 ms), and the C library's one win is the one-shot *`Node`* build-and-extract (~1.3× end-to-end, Table 2) — either way, pure Julia, no C dependency. Against its own past, v0.4 is **~5× faster and ~12× leaner than 0.3.9** (which used ~1.4 GiB for this file) — see [`benchmarks/profile.jl`](benchmarks/profile.jl), [`benchmarks/profile_vs_039.jl`](benchmarks/profile_vs_039.jl), [`benchmarks/compare.jl`](benchmarks/compare.jl).
132132
133133
> [!NOTE]
134134
> **`:strict`** adds a character-range scan over text (a second O(content) pass); the overhead scales with the document's *text share* — ~1.1× on the markup-heavy XMark corpus, up to ~20× on a pure-text document; `:lenient` / `:structural` are unaffected.

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,13 @@ One number cannot rank the readers — cost depends on what you do with the docu
337337

338338
| | build | walk every node | extract all values | DOM size in memory |
339339
|---|--:|--:|--:|--:|
340-
| `Cursor` | — (streams) | 23.1 ms (its one scan) || — (no DOM) |
340+
| `Cursor` | — (streams) | 23.2 ms (its one scan) || — (no DOM) |
341341
| `LazyNode` | ~0 (a wrapper) | 171 ms (re-tokenizes) || — (source only) |
342-
| `FlatNode` | 28.3 ms | 2.97 ms | 6.5 ms | 54.9 MiB |
343-
| `Node` | 72.9 ms | 3.34 ms | 3.6 ms | 71.6 MiB |
344-
| EzXML (libxml2) | 47.2 ms ||||
342+
| `FlatNode` | 27.2 ms | 2.97 ms | 3.1 ms | 54.9 MiB |
343+
| `Node` | 70.7 ms | 3.46 ms | 3.7 ms | 71.6 MiB |
344+
| EzXML (libxml2) | 46.6 ms ||||
345345

346-
Reading the table: `Cursor`'s walk *is* its parse — one tokenizing scan, nothing retained. `LazyNode` opens for free and pays per node visited — unbeatable for touching a *fraction* of a large document, and (as the walk column shows) the wrong tool for visiting all of it. `FlatNode` builds ~2.6× faster than `Node`, holds ~23% less memory, and its `parent`/`depth` are O(1) where `Node` searches from the root; whole-tree walks are close (`Node`'s exact-size children vectors keep its locality sharp), and pure value extraction on an already-built tree is the one pattern where `Node`'s direct fields win outright, by ~1.8×. `FlatNode` now out-builds even libxml2 (~1.7×), and `Node`'s remaining gap to the C library is materialization, not scanning (see [PERFORMANCE-v0.4.md](PERFORMANCE-v0.4.md)).
346+
Reading the table: `Cursor`'s walk *is* its parse — one tokenizing scan, nothing retained. `LazyNode` opens for free and pays per node visited — unbeatable for touching a *fraction* of a large document, and (as the walk column shows) the wrong tool for visiting all of it. `FlatNode` builds ~2.6× faster than `Node`, holds ~23% less memory, and its `parent`/`depth` are O(1) where `Node` searches from the root; whole-tree walks are close (`Node`'s exact-size children vectors keep its locality sharp), and pure value extraction is close too, flat store slightly ahead (3.1 vs 3.7 ms — a per-value `SubString` view costs two integer stores). `FlatNode` out-builds even libxml2 (~1.7×), and `Node`'s gap to the C library is materialization, not scanning (see [PERFORMANCE-v0.4.md](PERFORMANCE-v0.4.md)).
347347

348348
_Measured 2026-08-04, Apple M5 (single-threaded), Julia 1.12.6, EzXML 1.2.3; BenchmarkTools medians._
349349

src/XML.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export
1515
include("XMLTokenizer.jl")
1616
using .XMLTokenizer:
1717
XMLTokenizer, tokenize, tag_name, attr_value, pi_target, raw,
18-
TokenKinds, Token, Tokenizer, TokenizerState
18+
TokenKinds, Token, Tokenizer, TokenizerState, _noshift_substring
1919

2020
# Include order is the dependency contract: types before readers, readers before entry points.
2121
include("escape.jl") # ESCAPE_CHARS + escape/unescape — leaf, everything below may call it

src/XMLTokenizer.jl

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -69,46 +69,32 @@ end
6969
@inline Token(kind::TokenKinds.Kind, has_entities::Bool, raw::SubString) =
7070
Token(kind, has_entities, raw.offset, raw.ncodeunits)
7171

72-
# Every span the scanner emits has both edges on UTF-8 character boundaries, so a view
73-
# can be rebuilt straight from the stored (offset, ncodeunits) fields with no index
74-
# walk. The invariant is structural: `NAME_BYTE_TABLE` classifies every byte 0x80–0xFF
75-
# (the UTF-8 lead/continuation bytes) as a name byte, so name scans only stop on an
76-
# ASCII byte or EOF, and every other span edge lands on an ASCII sentinel ('<', '>',
77-
# '/', quotes, '-->', ']]>', '?>') or EOF. ASCII bytes are character boundaries by
78-
# construction — even in malformed input. This is the package's one deliberate
79-
# relaxation of the "never index ± 1 on strings" rule; see issue #109.
80-
#
81-
# `SubString`'s internal `Val(:noshift)` constructor takes the two fields directly
82-
# (`base/strings/substring.jl:39-46` as of 1.12.6), unlike the checked constructor
83-
# whose unconditional `nextind` re-derives the byte length even under `@inbounds`.
84-
# The `@boundscheck` block preserves full validation under `--check-bounds=yes` (as in
85-
# `Pkg.test`); callers wrap the call in `@inbounds`, so production builds elide it.
86-
# Elision reaches one inlining level only, and only into callees that actually inline,
87-
# so the constructor call below carries its own `@inbounds` plus a callsite `@inline`:
88-
# Base's constructor validates under its own `@boundscheck` (a `prevind` plus two
89-
# `isvalid` — `substring.jl:40-44`) and does not inline on its own, which would leave
90-
# that validation running. The block above already covers it, and `--check-bounds=yes`
91-
# re-enables every check regardless (#111).
92-
# If Base ever drops the constructor, the checked index-walking fallback takes over.
93-
if hasmethod(SubString{String}, Tuple{String, Int, Int, Val{:noshift}})
94-
@inline function _noshift_substring(s::T, offset::Int, ncu::Int) where {T <: AbstractString}
95-
@boundscheck begin
96-
n = ncodeunits(s)
97-
(0 <= offset && 0 <= ncu && offset + ncu <= n) ||
98-
throw(BoundsError(s, (offset + 1):(offset + ncu)))
99-
if ncu != 0
100-
isvalid(s, offset + 1) || throw(StringIndexError(s, offset + 1))
101-
offset + ncu == n || isvalid(s, offset + ncu + 1) ||
102-
throw(StringIndexError(s, offset + ncu + 1))
103-
end
104-
end
72+
# Token views are rebuilt straight from the stored (offset, ncodeunits) fields — the
73+
# package's one deliberate relaxation of the "never index ± 1 on strings" rule (#109).
74+
# Safe because every span edge the scanner emits is an ASCII byte or EOF:
75+
# `NAME_BYTE_TABLE` classifies 0x80–0xFF as name bytes, so scans only stop on ASCII,
76+
# and ASCII bytes are UTF-8 character boundaries even in malformed input.
77+
78+
# Checked reference reconstruction: index walks through the public constructor, which
79+
# validates both edges and yields the empty view natively (branchless on purpose — see
80+
# the union-return note below).
81+
@inline _checked_substring(s::AbstractString, offset::Int, ncu::Int) =
82+
SubString(s, offset + 1, prevind(s, offset + ncu + 1))
83+
84+
# One body per build, selected at load time (precompile caches are keyed by the flag):
85+
# `--check-bounds=yes` (as in `Pkg.test`) compiles the checked reconstruction wholesale
86+
# — an intra-body `@boundscheck` split would leave two construction paths and re-box
87+
# accessor chains' union returns (#113). Default builds compile the bare noshift store
88+
# (`substring.jl:39-46` @ 1.12.6); its call needs `@inbounds` plus a callsite
89+
# `@inline`, since elision reaches one inlining level and Base's self-validating
90+
# constructor does not inline on its own (#111).
91+
if hasmethod(SubString{String}, Tuple{String, Int, Int, Val{:noshift}}) &&
92+
Base.JLOptions().check_bounds != 1
93+
@inline _noshift_substring(s::T, offset::Int, ncu::Int) where {T <: AbstractString} =
10594
@inbounds @inline SubString{T}(s, offset, ncu, Val(:noshift))
106-
end
10795
else
108-
@inline function _noshift_substring(s::AbstractString, offset::Int, ncu::Int)
109-
ncu == 0 && return SubString(s, 1, 0)
110-
SubString(s, offset + 1, prevind(s, offset + ncu + 1))
111-
end
96+
_noshift_substring(s::AbstractString, offset::Int, ncu::Int) =
97+
@inline _checked_substring(s, offset, ncu)
11298
end
11399

114100
# Recover the token's text as a zero-copy `SubString` of its source `data` — a direct

src/flatnode.jl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ struct FlatNode
7070
end
7171

7272
@inline _rec(n::FlatNode) = @inbounds n.store.recs[n.i]
73+
# Span→view via the tokenizer's fully-elided noshift path: every span the store records
74+
# is a token span or the field pair of a tokenizer accessor's SubString (see `_frng`
75+
# below), so both edges are UTF-8 character boundaries by construction — the same
76+
# invariant as XMLTokenizer's `_noshift_substring`, one hop removed (#113).
77+
# `--check-bounds=yes` (as in Pkg.test) still validates every span.
7378
@inline _fsub(store::FlatStore, off::Int32, len::Int32) =
74-
@inbounds SubString(store.source, off + 1, prevind(store.source, off + len + 1))
79+
@inbounds _noshift_substring(store.source, Int(off), Int(len))
7580

7681
# byte range of a SubString into its (root) parent string
7782
@inline _frng(s::SubString{String}) = (Int32(s.offset), Int32(s.ncodeunits))
@@ -270,7 +275,7 @@ function _flat_parse(xml::String, ::Val{W}) where {W}
270275
end
271276

272277
@inline _fsub_or_empty(source::String, off::Int32, len::Int32) =
273-
len > 0 ? (@inbounds SubString(source, off + 1, prevind(source, off + len + 1))) : SubString(source, 1, 0)
278+
len > 0 ? (@inbounds _noshift_substring(source, Int(off), Int(len))) : SubString(source, 1, 0)
274279

275280
#-----------------------------------------------------------------------------# parse / read entry points
276281
Base.parse(xml::AbstractString, ::Type{FlatNode}; wellformed::Symbol=:structural) =

test/test_flatnode.jl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,25 @@ if @isdefined(valid_tests)
257257
@info "W3C not-wf: identical verdicts on $n_agree documents"
258258
end
259259
end
260+
261+
@testset "multibyte accessor surface matches Node" begin
262+
# 2-/3-/4-byte UTF-8 across element names, attribute names/values, text and
263+
# source slices — pins the span→view reconstruction on every accessor.
264+
xml = """<café idée="héllo wörld" 名前="日本語テキスト" emoji="🎈🎉" vide=""><日本語 attr='単一引用符'>中身テキスト</日本語><t🎈>été 🎯</t🎈><vidé/></café>"""
265+
f = parse(xml, FlatNode)
266+
n = parse(xml, Node)
267+
@test f == n
268+
froot = f[end]; nroot = n[end]
269+
@test tag(froot) == tag(nroot) == "café"
270+
@test tag(froot) isa SubString{String}
271+
@test [k => v for (k, v) in attributes(froot)] == [k => v for (k, v) in attributes(nroot)]
272+
@test get(froot, "名前", "") == "日本語テキスト"
273+
@test get(froot, "vide", missing) == ""
274+
fels = children(froot); nels = children(nroot)
275+
@test [tag(c) for c in fels] == [tag(c) for c in nels] == ["日本語", "t🎈", "vidé"]
276+
@test value(only(children(fels[1]))) == "中身テキスト"
277+
@test fels[1]["attr"] == "単一引用符"
278+
@test value(only(children(fels[2]))) == "été 🎯"
279+
@test XML.sourcetext(fels[1]) == "<日本語 attr='単一引用符'>中身テキスト</日本語>"
280+
@test XML.sourcetext(fels[3]) == "<vidé/>"
281+
end

0 commit comments

Comments
 (0)