Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`FlatNode` — a fourth reader: read-only columnar full-DOM** (`parse(xml, FlatNode)`,
`read(file, FlatNode)`). The whole document is materialized once into a contiguous store of
isbits records (zero-copy byte ranges into the retained source; text/attribute values
entity-decoded on access), so building is fast, random access is O(1), and the GC sees a
handful of arrays instead of one object per node — *`Node`'s read half at `Cursor`'s GC
cost*. Extras over `Node`: O(1) `parent`, O(depth) 1-arg `depth`. By design: read-only,
whole-store retention, 2 GiB/`typemax(Int32)` limits (use `Node` beyond). `==`/`hash` on
`FlatNode` are positional identity (same store, same node). `Node(flatnode)` materializes
a handle as a mutable `Node`; `XML.write` accepts `FlatNode` directly. Same
well-formedness levels and error messages as the `Node` parser (#82).

### Internal

- Source layout: the `src/XML.jl` monolith (1409 lines) is split into dedicated files —
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ doc[end][2] # Second child of root

<br>

# Choosing a reader

XML.jl ships four readers behind one set of accessors (`nodetype`, `tag`, `attributes`,
`value`, `children`, `eachelement`, …). Pick by what you do with the document:

| Reader | DOM materialized? | Revisit a node | Mutable | GC cost |
|---|---|---|---|---|
| `Cursor` | no (nothing) | impossible (forward-only) | — | ~0 |
| `LazyNode` | virtual | re-decode per visit (pay-per-traversal) | no | ~0 |
| `FlatNode` | yes, compact (columnar) | O(1), paid once | no | ~0 |
| `Node` | yes, objects | O(1), paid once | **yes** | high |

Rules of thumb: one forward pass → `Cursor` · extract a little, memory-tight → `LazyNode` ·
read-heavy full document, repeated traversals → `FlatNode` (*`Node`'s read half at `Cursor`'s
GC cost*) · build or edit documents → `Node`. `FlatNode` and `LazyNode` retain the source
string as long as any handle lives.

<br>

# `Node` Interface

Every node in the XML DOM is represented by `Node`, a single type parametrized on its string storage.
Expand Down
72 changes: 72 additions & 0 deletions benchmarks/flatnode_bench.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# FlatNode exhaustive in-package benchmark — by regime, min of N runs, vs Node/LazyNode/EzXML.
# julia benchmarks/flatnode_bench.jl (self-contained temp env: dev XML from this checkout + EzXML)
using Pkg
Pkg.activate(; temp = true, io = devnull)
Pkg.develop(path = joinpath(@__DIR__, ".."), io = devnull)
Pkg.add("EzXML", io = devnull)
using XML, EzXML

const xmark = joinpath(@__DIR__, "data", "xmark.xml")
const xml = read(xmark, String)
println("corpus: ", basename(xmark), " (", round(ncodeunits(xml) / 2^20, digits = 1), " MiB)")

minN(f, n = 5) = minimum((GC.gc(); @elapsed f()) for _ in 1:n)
allocs(f) = (GC.gc(); Base.gc_num().allocd; a0 = Base.gc_bytes(); f(); Base.gc_bytes() - a0)

# ── build ──
fbuild() = parse(xml, FlatNode; wellformed = :lenient)
nbuild() = parse(xml, Node; wellformed = :lenient)
ezbuild() = EzXML.parsexml(xml)
for (nm, f) in ["FlatNode" => fbuild, "Node" => nbuild, "EzXML" => ezbuild]
println("build ", rpad(nm, 9), lpad(round(minN(f) * 1000, digits = 1), 8), " ms")
end

# ── traverse (count nodes via each reader's handles) ──
const F = fbuild(); const N = nbuild(); const EZ = ezbuild()
function fwalk(n, c = Ref(0))
c[] += 1
for ch in XML.eachchildnode(n); fwalk(ch, c); end
c[]
end
function nwalk(n, c = Ref(0))
c[] += 1
for ch in children(n); nwalk(ch, c); end
c[]
end
function curwalk()
c = Cursor(xml); n = 0
while next!(c) !== nothing; n += 1; end
n
end
println("nodes: flat=", fwalk(FlatNode(F.store, Int32(1))), " node=", nwalk(N))
for (nm, f) in ["FlatNode" => () -> fwalk(FlatNode(F.store, Int32(1))), "Node" => () -> nwalk(N), "Cursor" => curwalk]
println("walk ", rpad(nm, 9), lpad(round(minN(f) * 1000, digits = 2), 8), " ms")
end

# ── extract (tag/value byte sums through the public accessors) ──
function fextract(n, acc = Ref(0))
t = tag(n); v = value(n)
t === nothing || (acc[] += ncodeunits(t)); v === nothing || (acc[] += ncodeunits(v))
for ch in XML.eachchildnode(n); fextract(ch, acc); end
acc[]
end
function nextract(n, acc = Ref(0))
t = tag(n); v = value(n)
t === nothing || (acc[] += ncodeunits(t)); v === nothing || (acc[] += ncodeunits(v))
for ch in children(n); nextract(ch, acc); end
acc[]
end
println("extract sums: flat=", fextract(FlatNode(F.store, Int32(1))), " node=", nextract(N))
for (nm, f) in ["FlatNode" => () -> fextract(FlatNode(F.store, Int32(1))), "Node" => () -> nextract(N)]
println("extract ", rpad(nm, 9), lpad(round(minN(f) * 1000, digits = 2), 8), " ms")
end

# ── retained + build allocations ──
println("retained FlatNode ", lpad(round(Base.summarysize(F.store) / 2^20, digits = 1), 7), " MiB ",
"Node ", lpad(round(Base.summarysize(N) / 2^20, digits = 1), 7), " MiB")
println("buildalloc FlatNode ", lpad(round(allocs(fbuild) / 2^20, digits = 1), 6), " MiB ",
"Node ", lpad(round(allocs(nbuild) / 2^20, digits = 1), 6), " MiB")

# ── GC pressure: full collection time with the tree live ──
gcms(x) = (GC.gc(); t = @elapsed GC.gc(true); t * 1000)
println("GC full with FlatNode live ", round(gcms(F), digits = 2), " ms with Node live ", round(gcms(N), digits = 2), " ms")
4 changes: 2 additions & 2 deletions src/XML.jl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module XML

export
Node, LazyNode, NodeType, Attributes,
Node, LazyNode, FlatNode, NodeType, Attributes,
CData, Comment, Declaration, Document, DTD, Element, ProcessingInstruction, Text,
nodetype, tag, attributes, value, children, children!, eachchildnode, eachattribute,
eachelement, elements,
Expand All @@ -23,7 +23,7 @@ include("node.jl") # NodeType, Attributes, Node + accessors/navigation/equ
include("xpath.jl") # xpath over Node trees (needs Node + accessors)
include("lazynode.jl") # LazyNode reader (needs NodeType/Attributes; extends the generic accessors)
include("cursor.jl") # Cursor pull reader (needs NodeType/Attributes)
# (flatnode.jl will slot here — read-only columnar reader, planned for v0.5)
include("flatnode.jl") # FlatNode read-only columnar reader (needs node.jl types; checks live in parse.jl)
include("write.jl") # XML writer: _write_xml/_write_escaped + XML.write entry points
include("parse.jl") # BOM normalization, Base.read entry points, the VPA parser
include("dtd.jl") # DTD/DOCTYPE parsing (independent: uses only the tokenizer)
Expand Down
Loading
Loading