Skip to content
20 changes: 5 additions & 15 deletions .github/workflows/Downstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,8 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
# XLSX's XML-v0.4 adaptation (JuliaData/XLSX.jl#415). The *registered* XLSX
# pins XML 0.3, so testing it against this branch fails by design; test the
# adaptation PR instead. Once #415 merges and releases, point this back at
# the registered package (PackageSpec(name=...) as before).
- package: XLSX
repository: JuliaData/XLSX.jl
ref: refs/pull/415/head
package:
- "XLSX"
steps:
- uses: actions/checkout@v7
- uses: julia-actions/setup-julia@v3
Expand All @@ -28,21 +22,17 @@ jobs:
arch: x64
show-versioninfo: true
- uses: julia-actions/julia-buildpkg@latest
- uses: actions/checkout@v7
with:
repository: ${{ matrix.repository }}
ref: ${{ matrix.ref }}
path: downstream
- name: Load this and run the downstream tests
shell: julia --color=yes {0}
run: |
using Pkg
Pkg.Registry.update()
Pkg.activate(;temp=true)
# this branch's XML + the downstream package's adaptation branch
# force it to use this PR's version of the package
ENV["JULIA_PKG_DEVDIR"]= mktempdir()
Pkg.develop([
PackageSpec(path="."),
PackageSpec(path="downstream"),
PackageSpec(name="${{ matrix.package }}"),
])
Pkg.update()
Pkg.test("${{ matrix.package }}")
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