diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75c6f605..fb7a321f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ Notable changes, newest first. Versions follow [semantic
versioning](https://semver.org): from 1.0.0 the exported API is stable, and a
breaking change means 2.0 with a new module path. See *Stability* below.
+## Unreleased
+
+Memory and CPU fixes from go-xmlsec's measurements against v2.1.0. No API
+change; output is byte-identical.
+
+### Fixed
+
+| Change | Problem → solution | Commit |
+|---|---|---|
+| `c14n.ParsePrefixList` was quadratic in the prefixes | It deduplicated with a scan per token: 1 MiB of distinct prefixes took 10.9 s. It uses a map, keeping first-seen order: about 0.02 s. | [`ad8c47a`][ad8c47a] |
+
+### Changed — memory
+
+| Change | Problem → solution | Commit |
+|---|---|---|
+| A large text value cost up to 3.1× its size to parse | `slices.Grow` rounded the buffer past the hand-off check, so the value was copied twice. The buffer is grown exactly and handed to the tree when no later value wants it: 1 MiB 3.10 → 1.12×, 10 MiB 1.87 → 1.08×. | [`707ca28`][707ca28] |
+| A tag with many attributes or namespace declarations peaked at 13–22× its size | Duplicates past 8 attributes are found by sorting, no name index is built for such a tag, one scope index serves both lookups and repeated URIs are interned: 800K attributes 21.5 → 12.8× peak, 100K declarations 16.5 → 6.6×. | [`5475837`][5475837], [`b08624c`][b08624c] |
+| c14n allocated a map per element of a namespace node set; the namespace axis sorted through a map | The map is reused (2,000 elements: 4,016 → 18 allocations); `scopeSlots` uses one stable sort (4 → 1 allocation a call). | [`9572c44`][9572c44], [`fd9750e`][fd9750e] |
+| `/r/l[1]` built every `l` child before the predicate ran | A step whose first predicate is an integer literal stops at that match. | [`5712d9f`][5712d9f] |
+
## v2.1.0 — 2026-11-01
Three memory audits across parsing, XPath/XQuery, XSLT, validation,
@@ -1803,3 +1823,10 @@ here so every entry in this file sits under a release.
[22d32ac]: https://github.com/knroy/go-xml/commit/22d32ac
[b331aa1]: https://github.com/knroy/go-xml/commit/b331aa1
[567aa1a]: https://github.com/knroy/go-xml/commit/567aa1a
+[ad8c47a]: https://github.com/knroy/go-xml/commit/ad8c47a
+[707ca28]: https://github.com/knroy/go-xml/commit/707ca28
+[5475837]: https://github.com/knroy/go-xml/commit/5475837
+[b08624c]: https://github.com/knroy/go-xml/commit/b08624c
+[9572c44]: https://github.com/knroy/go-xml/commit/9572c44
+[fd9750e]: https://github.com/knroy/go-xml/commit/fd9750e
+[5712d9f]: https://github.com/knroy/go-xml/commit/5712d9f
diff --git a/README.md b/README.md
index 6e6d2da2..2d5d00d9 100644
--- a/README.md
+++ b/README.md
@@ -139,7 +139,7 @@ and maintains it as a project of his own.
| **XSD 1.1** | 99.98% instance (26,217 of 26,222); **99.97%** schema-validity (15,350 of 15,354); opt-in via `Version11` |
| **RELAX NG** | 100.00% of James Clark's spectest (965 of 965 assertions); XML and compact syntax, from Go and from `go-xml validate -rng` |
| **DTD** | content models, attribute defaults, enumerations, `ID`/`IDREF`; external subset, parameter entities across both subsets, conditional sections — via `dtd.Load` with a caller-supplied resolver, nothing fetched by default |
-| **Tests** | 2,950 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
+| **Tests** | 2,960 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
| **Production schemas** | UBL 2.1, UN/CEFACT CII, Factur-X/ZUGFeRD, Peppol BIS 3.0 — 88 schemas load, instances validate clean |
| **API** | 1.2; the exported surface is stable and additive over 1.1, and a breaking change means 2.0 with a new module path |
@@ -1388,7 +1388,7 @@ back, is in [docs/testing.md](docs/testing.md).
| method | what it catches | what it misses |
|---|---|---|
-| **Unit tests** (2,950 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
+| **Unit tests** (2,960 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
| **Spec inventories** | features absent entirely | features present but behaving wrongly |
| **Saxon differential** | subtle behavioural divergence on real stylesheets | constructs the corpora do not use |
| **W3C QT3 suite** | systematic conformance across 22,054 XPath and 30,517 XQuery cases | XSLT (it is an XPath suite) |
diff --git a/c14n/c14n.go b/c14n/c14n.go
index 4eda2bd0..3f70dc6e 100644
--- a/c14n/c14n.go
+++ b/c14n/c14n.go
@@ -49,7 +49,6 @@ import (
"fmt"
"hash"
"io"
- "slices"
"strings"
"github.com/knroy/go-xml/v2/xdm"
@@ -162,11 +161,13 @@ var (
// string. Repeated and empty tokens are ignored.
func ParsePrefixList(s string) []string {
var out []string
- for _, f := range strings.Fields(s) {
+ seen := map[string]struct{}{}
+ for f := range strings.FieldsSeq(s) {
if f == "#default" {
f = ""
}
- if !slices.Contains(out, f) {
+ if _, dup := seen[f]; !dup {
+ seen[f] = struct{}{}
out = append(out, f)
}
}
@@ -220,9 +221,10 @@ func (c *chunkWriter) bytes() []byte {
// sizedWriter collects everything written to it in one buffer, sized once
// from the tree under the node set's root at the first write (after the options were validated, so
// a refused call allocates nothing). The estimate runs 5% to 15% over the
-// output, so the result costs about 1.1 times its size where a buffer grown
-// by doubling cost 2.1 times and joined chunks twice; a short estimate (an
-// escaped value is longer than its text) costs one more copy.
+// output on generated documents and averages 0.96 of it on DocBook, so the
+// result costs about 1.1 times its size where a buffer grown by doubling cost
+// 2.1 times and joined chunks twice; a short estimate (an escaped value is
+// longer than its text) costs one more copy.
type sizedWriter struct {
ns NodeSet
opts Options
diff --git a/c14n/canon.go b/c14n/canon.go
index e93fc5ca..75f9927e 100644
--- a/c14n/canon.go
+++ b/c14n/canon.go
@@ -427,7 +427,13 @@ func (c *canon) namespaceNodes(e *xdm.Node, in bool) {
}
var axis map[string]string
if in {
- axis = map[string]string{}
+ // Reuse the map a finished element left in c.axes's backing array.
+ if n := len(c.axes); n < cap(c.axes) && c.axes[:n+1][n] != nil {
+ axis = c.axes[:n+1][n]
+ clear(axis)
+ } else {
+ axis = map[string]string{}
+ }
}
hasDefault := false
for prefix, uri := range c.scope.m {
diff --git a/c14n/nsaxis_alloc_test.go b/c14n/nsaxis_alloc_test.go
new file mode 100644
index 00000000..c80b65e6
--- /dev/null
+++ b/c14n/nsaxis_alloc_test.go
@@ -0,0 +1,38 @@
+package c14n
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// canon.namespaceNodes made a map per element of a namespace node set; the
+// maps of finished elements are reused now.
+func TestNamespaceNodesReuseAxisMaps(t *testing.T) {
+ const kids = 2000
+ var b strings.Builder
+ b.WriteString(``)
+ for range kids {
+ b.WriteString(``)
+ }
+ b.WriteString(``)
+ tr, err := xdm.ParseString(b.String(), xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ set, err := FromXPathFilter(tr.Root, "true()", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ allocs := testing.AllocsPerRun(3, func() {
+ if err := WriteNodeSet(io.Discard, set, Options{Algorithm: Inclusive10}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Logf("%.0f allocations for %d elements", allocs, kids)
+ if allocs > kids/2 {
+ t.Errorf("%.0f allocations for %d elements, want a map reused across them", allocs, kids)
+ }
+}
diff --git a/c14n/prefixlist_test.go b/c14n/prefixlist_test.go
new file mode 100644
index 00000000..1f0890d7
--- /dev/null
+++ b/c14n/prefixlist_test.go
@@ -0,0 +1,22 @@
+package c14n
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// ParsePrefixList scanned its result for every token: 1 MiB of distinct
+// prefixes took 12 s of CPU. A map makes it linear.
+func TestParsePrefixListLinear(t *testing.T) {
+ var b strings.Builder
+ for i := 0; b.Len() < 1<<20; i++ {
+ b.WriteString("p" + strconv.Itoa(i) + " ")
+ }
+ start := time.Now()
+ got := ParsePrefixList(b.String())
+ if d := time.Since(start); d > 2*time.Second || len(got) < 100000 {
+ t.Errorf("1 MiB of prefixes: %d distinct in %v, want linear time", len(got), d)
+ }
+}
diff --git a/docs/conformance-gaps.md b/docs/conformance-gaps.md
index 281400a0..b963d935 100644
--- a/docs/conformance-gaps.md
+++ b/docs/conformance-gaps.md
@@ -49,7 +49,7 @@ W3C disagreements: 0 + 0 + 0 + 1 + 8 + 23 + 30 + 31 + 0 = 93. Measured 2026-09-1
-The unit-test suite is 2,950 tests.
+The unit-test suite is 2,960 tests.
The last two rows are not W3C suites but real-world corpora — DocBook xslTNG's
diff --git a/docs/known-gaps.md b/docs/known-gaps.md
index cd8f4c54..a4001b8b 100644
--- a/docs/known-gaps.md
+++ b/docs/known-gaps.md
@@ -131,11 +131,16 @@ Found by the third memory audit (2026-11-01) and not fixed, each with the reason
(`withSel`, `hostWithCurrent`, the leaf-hook copy), and built-in shallow-copy
copies a document-level result twice; removing that copy changes an error
case into output.
-- **A single text value of a few to tens of MB allocates up to about 1.9× its
- size while parsing** (10 MiB: 1.87×; the tree keeps 1.09×; 60 MiB: 1.13×).
- The decoder's buffer grows in steps of eight. Sizing it to the input left
- in one step reached 1.08×, but a document of eight 3 MiB values went from
- 1.33× to 2.04×, so it was reverted.
+- **Several large text values in one document cost about 1.3× their size to
+ parse** (eight 3 MiB values: 1.30×). A single value is about 1.1× at every
+ size since v2.1.1; with several, the first value's buffer overshoots once
+ before each later value is copied exactly once.
+- **An XPath expression in 1.0 compatibility mode copies its context on every
+ evaluation** when the caller's context is not in that mode (96 bytes a call;
+ 12× of an XPath 1.0 transform's allocation in go-xmlsec). The mode can be
+ set only on a `Compiled` (`WithCompatMode`), and a `*Context` is updated in
+ place during evaluation, so a cached copy would be unsafe. Needs a
+ context-side setter: new API, so a minor release.
- **`c14n.BytesNodeSet` on a caller's own `NodeSet` still costs about 2.1× its
output.** Sizing it would call `Contains` twice per node, which the API
promises not to do; the package's `Subtree` and `ExcludeSubtree` are sized.
diff --git a/docs/security.md b/docs/security.md
index c71a9bbf..2295c02d 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1865,6 +1865,10 @@ Each line names the **direction** of the defect, because that is what decides
who was exposed: a *false accept* let an invalid input through, a *false
reject* refused a legal one, and *cost* produced the right answer too slowly.
+**Twentieth pass — go-xmlsec's measurements against v2.1.0, 2026-11-01.**
+
+- **`c14n.ParsePrefixList` was quadratic in its tokens** — cost, driven by a signed document. It parses the `PrefixList` of an `ec:InclusiveNamespaces` element, which the document under verification supplies, and deduplicated each token with a scan of those before it: 1 MiB of distinct prefixes took 10.9 s of CPU. It deduplicates with a map, keeping first-seen order: about 0.02 s. go-xmlsec caps the list at 1,024 prefixes before calling it; other callers were exposed. Pinned by `TestParsePrefixListLinear` in `c14n/prefixlist_test.go`. Fixed in v2.1.1.
+
**Nineteenth pass — a third memory audit, 2026-11-01.** The findings and figures are in the audit's own write-up; the retention and allocation fixes are in CHANGELOG under *Changed — memory*.
- **Sequences built by `,` and `!` were not counted against `MaxItems`** — availability, driven by a query or stylesheet. `fold-left(1 to 26,(1,2),function($s,$i){($s,$s)})` (70 bytes) built 134M items against the 5M limit at 6.3 GB, and the same doubling through a recursive `xsl:with-param` reached 4.6 GB on v2.0.3. `SequenceExpr` and `!` charge the copies beyond their widest operand above 64 items, and `xsl:next-iteration` values are checked against the bound. Pinned by `TestSequenceCopiesAreCharged`.
diff --git a/docs/stats.md b/docs/stats.md
index d8837195..f5baea86 100644
--- a/docs/stats.md
+++ b/docs/stats.md
@@ -23,7 +23,7 @@ claim is only as good as the command behind it.
| figure | count | counted by |
|---|---:|---|
-| Unit tests | 2,950 | `grep -rn '^func Test' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
+| Unit tests | 2,960 | `grep -rn '^func Test' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
| Fuzz targets | 12 | `grep -rn '^func Fuzz' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
| Limit boundary tests | 14 | `grep -hc '^func Test' ./*/limits_boundary*_test.go \| awk '{n += $1} END {print n + 0}'` |
diff --git a/docs/testing.md b/docs/testing.md
index 093e85c3..8fd05879 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -21,7 +21,7 @@ let something through, and the column that matters is the last one.
| layer | count | catches | misses |
|---|---:|---|---|
-| **Unit tests** | 2,950 | a plausible implementation that is quietly wrong | anything nobody thought to write a test for |
+| **Unit tests** | 2,960 | a plausible implementation that is quietly wrong | anything nobody thought to write a test for |
| **Limit boundary tests** | 14 tests | an off-by-one or an overflow at the edge of a configurable limit | a limit nobody added to the inventory |
| **Race detector** | same tests | shared state a single-goroutine run never reveals | a data race on a path no test walks |
| **W3C conformance suites** | 152,241 cases | systematic divergence from the specification | what the suites do not ask about — see below |
diff --git a/docs/todo.md b/docs/todo.md
index 52ce996e..0a598686 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -23,7 +23,7 @@ Current position:
| XSLT 3.0 | 99.80% — 11,495 of 11,518 in scope (23 failing); 3 of those need more of the §19.8 streamability analysis |
| RELAX NG | 100.00% — 965 of 965 |
| Schemas wrongly refused | 7 — 6 on XSD 1.0, 1 on 1.1 |
-| Tests | 2,950 `func Test` declarations, clean under `-race` |
+| Tests | 2,960 `func Test` declarations, clean under `-race` |
Every one of those failures, and why it is still open, is catalogued in
[known-gaps.md](known-gaps.md). This file is the forward-looking half — what
diff --git a/internal/xmltok/xmltok.go b/internal/xmltok/xmltok.go
index 3a85fd5e..296787c6 100644
--- a/internal/xmltok/xmltok.go
+++ b/internal/xmltok/xmltok.go
@@ -45,7 +45,6 @@ import (
"encoding/binary"
"fmt"
"io"
- "slices"
"strconv"
"strings"
"unicode/utf8"
@@ -120,6 +119,9 @@ type Directive []byte
// stream was read through before.
const bufSize = 4096
+// textFit: see Decoder.text.
+const textFit = 32
+
// A Decoder reads tokens from an XML byte stream.
//
// A returned token, and the byte and attribute slices in it, alias the
@@ -1060,10 +1062,20 @@ func (d *Decoder) text(quote byte, cdata bool) ([]byte, bool) {
if n > 0 {
// A run past a window long that outgrows the scratch is
// sized from what is left of the input, not by append's
- // 1.25x steps. ponytail: capped at 8x the scratch, so that a
- // run early in a huge document takes at most that.
+ // 1.25x steps, and by an exact make and copy so that the
+ // capacity is what was asked for. ponytail: the step is capped
+ // at 8x the scratch, so that a run early in a huge document
+ // takes at most that; but when what is left is within textFit
+ // times the scratch it is taken whole, so that a lone value ends
+ // its life fitted and not a step short of it or past it.
if rest := d.Size - int(d.InputOffset()); n > cap(out)-len(out) && len(out) >= bufSize && rest > n {
- out = slices.Grow(out, min(rest, 8*cap(out)))
+ grow := min(rest, 8*cap(out))
+ if rest <= textFit*cap(out) {
+ grow = rest
+ }
+ bigger := make([]byte, len(out), len(out)+grow)
+ copy(bigger, out)
+ out = bigger
}
out = append(out, run[:n]...)
if n == 1 {
diff --git a/xdm/attrs_many_test.go b/xdm/attrs_many_test.go
new file mode 100644
index 00000000..3d29987c
--- /dev/null
+++ b/xdm/attrs_many_test.go
@@ -0,0 +1,142 @@
+package xdm
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "unsafe"
+)
+
+// A tag with several repeated attributes is refused for the first repeat in
+// document order, not for the first in sort order (a, here), and an unbound
+// prefix after a repeat does not displace it.
+func TestManyAttrsReportFirstDuplicateInDocumentOrder(t *testing.T) {
+ pad := ""
+ for i := range 12 {
+ pad += fmt.Sprintf(` x%d="1"`, i)
+ }
+ for _, tc := range []struct{ tag, want string }{
+ {`b="1" a="1" b="2" a="2"`, "duplicate attribute {}b"},
+ {`a="1" b="1" a="2" b="2" a="3"`, "duplicate attribute {}a"},
+ // p and q bind alike, so p:k and q:k are the same attribute.
+ {`q:z="1" p:k="1" a="1" q:k="2" a="2"`, "duplicate attribute {urn:p}k"},
+ {`m="1" n="1" m="2" u:k="1"`, "duplicate attribute {}m"},
+ {`m="1" u:k="1" n="1" m="2"`, "no namespace declaration is in scope for prefix \"u\""},
+ } {
+ doc := ``
+ _, err := ParseString(doc, ParseOptions{})
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("%s: got %v, want %q", tc.tag, err, tc.want)
+ }
+ }
+}
+
+// The name index and the duplicate check's map made a tag of 50000
+// distinct attributes allocate over 370 bytes an attribute. The checks are
+// a sort of indexes and the index is left out for such a tag.
+func TestManyAttrsAllocation(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation sizes are not stable under -race")
+ }
+ var b strings.Builder
+ b.WriteString("")
+ doc := b.String()
+ per := allocated(func() {
+ if _, err := ParseString(doc, ParseOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Logf("%d bytes allocated for %d bytes of input", per, len(doc))
+ if per > 50000*260 {
+ t.Errorf("parsing a tag of 50000 attributes allocated %d bytes, %d an attribute, want under 260", per, per/50000)
+ }
+}
+
+// A tag that adds its names without the index must leave the table as one
+// that interned them: a name met again later, in the same tag's wake or in a
+// second big tag, is the one already there.
+func TestManyAttrsLeaveNamesUnique(t *testing.T) {
+ var b strings.Builder
+ tag := func(from, to int) {
+ for i := from; i < to; i++ {
+ fmt.Fprintf(&b, ` a%d="v"`, i)
+ }
+ }
+ b.WriteString("")
+ tree, err := ParseString(b.String(), ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[QName]bool{}
+ for _, q := range tree.names[1:] {
+ if seen[q] {
+ t.Errorf("name %+v is in the table twice", q)
+ }
+ seen[q] = true
+ }
+ if want := 1000 + 3; len(tree.names)-1 != want { // a0..a999, r, c, d
+ t.Errorf("table holds %d names, want %d", len(tree.names)-1, want)
+ }
+}
+
+func manyDeclsDoc(n, uris int) string {
+ var b strings.Builder
+ b.WriteString("")
+ return b.String()
+}
+
+// A tag of many namespace declarations kept them four times -- the stack,
+// its index, the tag's own map and the frame -- and grew each by append.
+// Measured at 100,000 declarations of 100 URIs: 22.6x the input allocated
+// before, 7.3x after.
+func TestManyDeclarationsAllocation(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation sizes are not stable under -race")
+ }
+ const n = 20000
+ doc := manyDeclsDoc(n, 100)
+ per := allocated(func() {
+ if _, err := ParseString(doc, ParseOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Logf("%d bytes allocated for %d declarations, %d of input", per, n, len(doc))
+ if per > n*400 {
+ t.Errorf("parsing %d declarations allocated %d bytes, %d each, want under 400", n, per, per/n)
+ }
+}
+
+// A URI declared again is the string the first declaration made, not a copy
+// of it; and it is a copy, never the tokeniser's arena string
+// (TestNamespaceURIDoesNotPinArena).
+func TestRepeatedNamespaceURIIsOneString(t *testing.T) {
+ tree, err := ParseString(manyDeclsDoc(2000, 3), ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ frame := tree.Root.FirstChild().frame()
+ if len(frame) != 2000 {
+ t.Fatalf("%d declarations, want 2000", len(frame))
+ }
+ distinct := map[*byte]bool{}
+ for _, b := range frame[indexAt:] { // those before the stack is indexed are copies
+ distinct[unsafe.StringData(b.uri)] = true
+ }
+ if len(distinct) != 3 {
+ t.Errorf("%d declarations of 3 URIs hold %d strings, want 3", len(frame)-indexAt, len(distinct))
+ }
+}
diff --git a/xdm/parse.go b/xdm/parse.go
index 6fbcb9ba..2ae9a931 100644
--- a/xdm/parse.go
+++ b/xdm/parse.go
@@ -835,8 +835,9 @@ func buildElement(t xml.StartElement, parent *Node, ns *nsScope, names *nameCach
// validateStartElement has refused an unbound prefix.
uri, _ := ns.lookup(t.Name.Space)
el.name = names.intern(tree, QName{Prefix: t.Name.Space, Local: t.Name.Local, URI: uri})
- if len(t.Attr) >= bigTag {
- tree.reserveNames(len(t.Attr))
+ old := -1 // how many names the table held, when this tag adds its own without the index
+ if n := len(t.Attr) - len(decls); n >= bigTag {
+ old = tree.reserveNames(n)
}
for _, a := range t.Attr {
if a.Name.Space == "xmlns" || (a.Name.Space == "" && a.Name.Local == "xmlns") {
@@ -861,11 +862,18 @@ func buildElement(t xml.StartElement, parent *Node, ns *nsScope, names *nameCach
}
}
attr := el.appendAttrRaw()
- attr.name = names.intern(tree, q)
+ if old >= 0 {
+ attr.name = tree.internNew(q, old)
+ } else {
+ attr.name = names.intern(tree, q)
+ }
if v != "" {
attr.v0, attr.v1 = tree.text.add(v)
}
}
+ if old >= 0 {
+ tree.endBulkNames()
+ }
return el, len(decls)
}
@@ -941,10 +949,13 @@ func (r *textRun) add(parent *Node, b []byte, dec *xml.Decoder) {
// A run that starts with anything but white space can be neither
// stripped nor shared, so it goes straight to the store.
if !onlySpace(b) {
- if len(b) >= textOwnFrom && cap(b)-len(b) <= len(b)/8 {
- // Long enough for a block of its own, and not much larger
- // than its value: the decoder's buffer becomes the block,
- // rather than being copied into one.
+ if len(b) >= textOwnFrom && cap(b)-len(b) <= len(b)/8 && (dec.Size <= 0 || dec.Size-int(dec.InputOffset()) < len(b)) {
+ // Long enough for a block of its own, not much larger than
+ // its value, and with less input left than its length, so
+ // that no later value will want the buffer grown for this
+ // one: the decoder's buffer becomes the block, rather than
+ // being copied into one. A document of many long values
+ // keeps the buffer and copies, or each would regrow it.
r.node.v0, r.node.v1 = r.node.tree.text.keep(dec.TakeText())
} else {
r.node.v0, r.node.v1 = r.node.tree.text.addBytes(b, 0)
diff --git a/xdm/parse_textalloc_test.go b/xdm/parse_textalloc_test.go
new file mode 100644
index 00000000..ae09d72f
--- /dev/null
+++ b/xdm/parse_textalloc_test.go
@@ -0,0 +1,44 @@
+package xdm
+
+import (
+ "bytes"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// TestParseLongTextAllocation bounds what parsing long text values allocates
+// against the document's size: the decoder's buffer is grown to fit, in exact
+// steps, and handed to the tree only when no later value will want it.
+func TestParseLongTextAllocation(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ n, size int
+ maxRatio float64
+ }{
+ {"one 1 MiB value", 1, 1 << 20, 1.3},
+ {"35 values of 700 kB", 35, 700000, 1.1},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ v := strings.Repeat("QUJD", c.size/4)
+ var b bytes.Buffer
+ b.WriteString("")
+ for i := 0; i < c.n; i++ {
+ b.WriteString("" + v + "")
+ }
+ b.WriteString("")
+ doc := b.Bytes()
+ var m0, m1 runtime.MemStats
+ runtime.ReadMemStats(&m0)
+ tr, err := Parse(bytes.NewReader(doc), ParseOptions{})
+ runtime.ReadMemStats(&m1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtime.KeepAlive(tr)
+ if r := float64(m1.TotalAlloc-m0.TotalAlloc) / float64(len(doc)); r > c.maxRatio {
+ t.Errorf("parsing allocated %.2fx the document, want at most %.2fx", r, c.maxRatio)
+ }
+ })
+ }
+}
diff --git a/xdm/record.go b/xdm/record.go
index 1ad02678..70ea8e63 100644
--- a/xdm/record.go
+++ b/xdm/record.go
@@ -7,8 +7,8 @@ import (
"math"
"math/bits"
"slices"
- "sort"
"strconv"
+ "strings"
"sync"
"sync/atomic"
"unsafe"
@@ -418,12 +418,26 @@ const bigTag = 256
// and its index once instead of through every doubling. The index is
// rebuilt only when n would at least double it, so a run of big tags that
// repeat their names rebuilds nothing.
-func (t *Tree) reserveNames(n int) {
+//
+// A tag that would at least double the table skips its index instead: the
+// attributes of a valid tag have distinct names, so each needs finding only
+// among the names from before the tag. reserveNames then reports how many
+// those are, and the tag adds its names by internNew; endBulkNames drops the
+// index, which is made again by the next intern that wants it. For 800,000
+// attributes the index was 6 times the input, to be dropped when the parse
+// ended. Otherwise it reports -1.
+func (t *Tree) reserveNames(n int) int {
if t.names == nil {
t.names = make([]QName, 1, n+1)
} else {
t.names = slices.Grow(t.names, n)
}
+ if n >= len(t.names) {
+ if t.nameIx == nil && len(t.names) > smallNames {
+ t.indexNames()
+ }
+ return len(t.names)
+ }
if t.nameIx == nil {
t.nameIx = make(map[QName]uint32, len(t.names)+n)
t.nameHot = new([64]uint32)
@@ -435,8 +449,32 @@ func (t *Tree) reserveNames(n int) {
maps.Copy(ix, t.nameIx)
t.nameIx = ix
}
+ return -1
}
+// internNew is intern for a name of a tag that reserveNames reported old
+// names before: the name is not among the tag's own, so it is looked for in
+// the first old only, and added to the table without the index.
+func (t *Tree) internNew(q QName, old int) uint32 {
+ if t.nameIx != nil {
+ if i, ok := t.nameIx[q]; ok {
+ return i
+ }
+ } else {
+ for i, x := range t.names[:old] {
+ if x == q {
+ return uint32(i)
+ }
+ }
+ }
+ t.names = append(t.names, q)
+ return uint32(len(t.names) - 1)
+}
+
+// endBulkNames ends a tag that internNew added names for: the index lacks
+// them, so it goes, and intern makes it again when next it is wanted.
+func (t *Tree) endBulkNames() { t.nameIx, t.nameHot = nil, nil }
+
// indexNames builds the name index over the names the tree holds.
func (t *Tree) indexNames() {
t.nameIx = make(map[QName]uint32, 2*len(t.names))
@@ -1377,6 +1415,9 @@ type scopeSlot struct {
frame, index, owner uint32 // owner: the element declaring it
}
+// undeclared marks a scopeSlot built from an undeclaring declaration.
+const undeclared = xmlBinding - 1
+
// scopeSlots returns n's in-scope bindings ordered by prefix.
func (n *Node) scopeSlots() []scopeSlot {
var chain []*Node
@@ -1385,22 +1426,32 @@ func (n *Node) scopeSlots() []scopeSlot {
chain = append(chain, cur)
}
}
- m := map[string]scopeSlot{"xml": {"xml", 0, xmlBinding, noIdx}}
+ // Every declaration, outermost first, then one stable sort by prefix: the
+ // last of a run of equal prefixes is the one in force, and an undeclaring
+ // one (marked by index undeclared) removes the prefix. No map, no
+ // reflection-built swapper.
+ all := make([]scopeSlot, 1, 8)
+ all[0] = scopeSlot{"xml", 0, xmlBinding, noIdx}
for i := len(chain) - 1; i >= 0; i-- {
f := chain[i].v0
for j, b := range chain[i].frame() {
if b.uri == "" {
- delete(m, b.prefix)
+ all = append(all, scopeSlot{b.prefix, f, undeclared, noIdx})
} else {
- m[b.prefix] = scopeSlot{b.prefix, f, uint32(j), chain[i].self}
+ all = append(all, scopeSlot{b.prefix, f, uint32(j), chain[i].self})
}
}
}
- out := make([]scopeSlot, 0, len(m))
- for _, s := range m {
- out = append(out, s)
+ slices.SortStableFunc(all, func(a, b scopeSlot) int { return strings.Compare(a.prefix, b.prefix) })
+ out := all[:0]
+ for i, s := range all {
+ if i+1 < len(all) && all[i+1].prefix == s.prefix {
+ continue
+ }
+ if s.index != undeclared {
+ out = append(out, s)
+ }
}
- sort.Slice(out, func(i, j int) bool { return out[i].prefix < out[j].prefix })
return out
}
diff --git a/xdm/scopeslots_test.go b/xdm/scopeslots_test.go
new file mode 100644
index 00000000..264538eb
--- /dev/null
+++ b/xdm/scopeslots_test.go
@@ -0,0 +1,52 @@
+package xdm
+
+import (
+ "sort"
+ "testing"
+)
+
+// scopeSlots must list the in-scope bindings once each, ordered by prefix,
+// with inner declarations and undeclarations in force; and must not cost a map
+// and a reflection-built sorter per call.
+func TestScopeSlotsOrderAndAllocs(t *testing.T) {
+ tr, err := ParseString(``, ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var leaf *Node
+ var walk func(n *Node)
+ walk = func(n *Node) {
+ if n.Kind() == KindElement {
+ leaf = n
+ want := []string{"xml"}
+ for p, u := range n.InScopeNamespaces() {
+ if p != "xml" && u != "" {
+ want = append(want, p)
+ }
+ }
+ sort.Strings(want)
+ var got []string
+ for _, s := range n.scopeSlots() {
+ got = append(got, s.prefix)
+ }
+ if len(got) != len(want) {
+ t.Fatalf("%s: got %v, want %v", n.Name().Local, got, want)
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Fatalf("%s: got %v, want %v", n.Name().Local, got, want)
+ }
+ }
+ }
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ walk(c)
+ }
+ }
+ walk(tr.Root)
+ if raceEnabled {
+ return // the race detector's instrumentation changes allocation counts
+ }
+ if a := testing.AllocsPerRun(20, func() { leaf.scopeSlots() }); a > 2 {
+ t.Errorf("scopeSlots allocated %v times", a)
+ }
+}
diff --git a/xdm/wellformed.go b/xdm/wellformed.go
index e5ec446a..6db17b91 100644
--- a/xdm/wellformed.go
+++ b/xdm/wellformed.go
@@ -2,6 +2,7 @@ package xdm
import (
"fmt"
+ "slices"
"strings"
xml "github.com/knroy/go-xml/v2/internal/xmltok"
@@ -162,7 +163,13 @@ func validateStartElement(t xml.StartElement, ns *nsScope, xml11 bool) error {
ns.push()
scope := tagScope{ns: ns}
if len(t.Attr) > smallTag {
- scope.m = map[string]string{}
+ decls := 0
+ for _, a := range t.Attr {
+ if _, ok := namespaceDecl(a); ok {
+ decls++
+ }
+ }
+ ns.reserve(decls)
}
for _, a := range t.Attr {
prefix, isDecl := namespaceDecl(a)
@@ -176,9 +183,6 @@ func validateStartElement(t xml.StartElement, ns *nsScope, xml11 bool) error {
return err
}
ns.declare(prefix, a.Value)
- if scope.m != nil {
- scope.m[prefix] = a.Value
- }
}
if err := requireBoundPrefix(t.Name.Space, &scope); err != nil {
@@ -191,11 +195,13 @@ func validateStartElement(t xml.StartElement, ns *nsScope, xml11 bool) error {
// XML checks raw names; Namespaces in XML strengthens that to expanded names.
// Keying on {URI, local} makes p:a and q:a collide when p and q bind alike.
// A tag's few attributes are checked by a scan of those before them, which
- // allocates nothing; past a handful the keys move into a map, so that a tag
+ // allocates nothing; a larger tag is sorted (checkManyAttrs), so that a tag
// with thousands of attributes is not checked in quadratic time.
+ if len(t.Attr) > smallTag {
+ return checkManyAttrs(t.Attr, &scope)
+ }
var small [smallTag]expandedName
seen := small[:0]
- var many map[expandedName]bool
for _, a := range t.Attr {
if _, declaration := namespaceDecl(a); declaration {
continue
@@ -208,28 +214,67 @@ func validateStartElement(t xml.StartElement, ns *nsScope, xml11 bool) error {
uri, _ = scope.lookup(a.Name.Space)
}
k := expandedName{uri, a.Name.Local}
- dup := many[k]
- for _, s := range seen {
- dup = dup || s == k
- }
- if dup {
+ if slices.Contains(seen, k) {
return fmt.Errorf("parse XML: duplicate attribute {%s}%s", uri, a.Name.Local)
}
- switch {
- case many != nil:
- many[k] = true
- case len(seen) < len(small):
- seen = append(seen, k)
- default:
- many = make(map[expandedName]bool, max(2*len(small), len(t.Attr)))
- for _, s := range seen {
- many[s] = true
+ seen = append(seen, k)
+ }
+ return nil
+}
+
+// checkManyAttrs is the attribute check of a tag with more than smallTag
+// attributes. It sorts the attributes by expanded name instead of keeping them
+// in a map, which for the 800,000 attributes of a 10 MiB tag is 6 MB rather
+// than 50. It reports what the scan in order would: the first attribute, in
+// document order, that repeats an earlier one, unless an unbound prefix comes
+// before it.
+func checkManyAttrs(attrs []xml.Attr, scope *tagScope) error {
+ var uris []string // by attribute, allocated at the first prefixed one
+ idx := make([]int, 0, len(attrs))
+ var unbound error
+ for i, a := range attrs {
+ if _, declaration := namespaceDecl(a); declaration {
+ continue
+ }
+ if unbound = requireBoundPrefix(a.Name.Space, scope); unbound != nil {
+ break // a duplicate before this one is reported first, one after is not
+ }
+ if a.Name.Space != "" {
+ if uris == nil {
+ uris = make([]string, len(attrs))
}
- many[k] = true
- seen = nil
+ uris[i], _ = scope.lookup(a.Name.Space)
}
+ idx = append(idx, i)
}
- return nil
+ uri := func(i int) string {
+ if uris == nil {
+ return ""
+ }
+ return uris[i]
+ }
+ // Equal names sort in document order, so each repeat follows the
+ // attribute it repeats.
+ slices.SortFunc(idx, func(x, y int) int {
+ if c := strings.Compare(uri(x), uri(y)); c != 0 {
+ return c
+ }
+ if c := strings.Compare(attrs[x].Name.Local, attrs[y].Name.Local); c != 0 {
+ return c
+ }
+ return x - y
+ })
+ dup := -1
+ for j := 1; j < len(idx); j++ {
+ x, y := idx[j-1], idx[j]
+ if uri(x) == uri(y) && attrs[x].Name.Local == attrs[y].Name.Local && (dup < 0 || y < dup) {
+ dup = y
+ }
+ }
+ if dup >= 0 {
+ return fmt.Errorf("parse XML: duplicate attribute {%s}%s", uri(dup), attrs[dup].Name.Local)
+ }
+ return unbound
}
// expandedName is an attribute's {namespace URI, local name}.
@@ -258,6 +303,8 @@ type nsScope struct {
own int // how many of b the innermost start tag declares
top map[string]int
prev []int
+ more int // declarations the innermost start tag has yet to make, when it said
+ uris map[string]string // the URIs copied out of the arena, once the stack is indexed
}
// indexAt is how many bindings the stack holds before it is indexed.
@@ -266,12 +313,25 @@ const indexAt = 32
// push opens a start tag's declarations, none until they are appended.
func (s *nsScope) push() { s.own = 0 }
+// reserve makes room for the n declarations the innermost start tag is about
+// to make, so that a tag of thousands grows the stack, and its index, once
+// rather than through every doubling.
+func (s *nsScope) reserve(n int) {
+ s.more = n
+ s.b = slices.Grow(s.b, n)
+ if s.top != nil {
+ s.prev = slices.Grow(s.prev, n)
+ }
+}
+
func (s *nsScope) declare(prefix, uri string) {
if s.b == nil {
s.b = make([]nsBinding, 0, 8) // a document's root declares a few
}
if s.top == nil && len(s.b) >= indexAt {
- s.top = make(map[string]int, 2*len(s.b))
+ s.top = make(map[string]int, len(s.b)+s.more)
+ s.prev = make([]int, 0, len(s.b)+s.more)
+ s.uris = map[string]string{}
for _, b := range s.b {
s.prev = append(s.prev, s.top[b.prefix]-1)
s.top[b.prefix] = len(s.prev)
@@ -284,8 +344,21 @@ func (s *nsScope) declare(prefix, uri string) {
// The URI is copied out of the tokeniser's arena: the tree keeps it in
// every name and frame it resolves, and one arena string reachable from a
// tree keeps its whole block alive.
- s.b = append(s.b, nsBinding{prefix, strings.Clone(uri)})
+ // A stack long enough to be indexed repeats its URIs, and is copied for
+ // each only once.
+ if s.uris != nil {
+ c, ok := s.uris[uri]
+ if !ok {
+ c = strings.Clone(uri)
+ s.uris[c] = c
+ }
+ uri = c
+ } else {
+ uri = strings.Clone(uri)
+ }
+ s.b = append(s.b, nsBinding{prefix, uri})
s.own++
+ s.more = max(s.more-1, 0)
}
// ownDecls is the innermost start tag's declarations.
@@ -327,21 +400,21 @@ func (s *nsScope) lookup(prefix string) (string, bool) {
return "", false
}
-// tagScope resolves the prefixes used on one start tag against the stack. A
-// tag of at most smallTag attributes finds its own declarations by a scan of
-// them; a larger one keeps them in m, so that a tag declaring thousands is not
+// tagScope resolves the prefixes used on one start tag against the stack. The
+// tag's own declarations are found by a scan of them until the stack is
+// indexed, and by the index after, so that a tag declaring thousands is not
// checked for duplicates in quadratic time.
type tagScope struct {
ns *nsScope
- m map[string]string // the tag's own declarations, when the tag is large
}
// declared reports whether the tag declares prefix among the declarations
// pushed so far.
func (s *tagScope) declared(prefix string) bool {
- if s.m != nil {
- _, ok := s.m[prefix]
- return ok
+ if s.ns.top != nil {
+ // The innermost binding of the prefix is the tag's own if it is
+ // among the last own.
+ return s.ns.top[prefix] > len(s.ns.b)-s.ns.own
}
for _, b := range s.ns.ownDecls() {
if b.prefix == prefix {
diff --git a/xpath/eval.go b/xpath/eval.go
index 85c9d797..55d23917 100644
--- a/xpath/eval.go
+++ b/xpath/eval.go
@@ -2,6 +2,7 @@ package xpath
import (
"fmt"
+ "math"
"github.com/knroy/go-xml/v2/xdm"
)
@@ -94,7 +95,7 @@ func (e *Step) Eval(ctx *Context) (xdm.Sequence, error) {
// nothing would read.
func (e *Step) evalFrom(ctx *Context, node *xdm.Node) (xdm.Sequence, error) {
var err error
- selected := e.appendFrom(nil, node)
+ selected := e.appendFromN(nil, node, e.firstPosition())
// Predicates apply in axis order, so a reverse axis numbers positions
// from the context node outwards.
@@ -113,6 +114,31 @@ func (e *Step) evalFrom(ctx *Context, node *xdm.Node) (xdm.Sequence, error) {
// appendFrom appends to selected the nodes e's axis and node test select
// from node, in axis order, before any predicate.
func (e *Step) appendFrom(selected xdm.Sequence, node *xdm.Node) xdm.Sequence {
+ return e.appendFromN(selected, node, 0)
+}
+
+// firstPosition is k when e's first predicate is the integer literal k >= 1:
+// that predicate keeps only the k-th selected node, so nothing past the k-th
+// need be built. Zero means no such bound.
+func (e *Step) firstPosition() int {
+ if len(e.Predicates) == 0 {
+ return 0
+ }
+ l, ok := e.Predicates[0].(*Literal)
+ if !ok || l.Val == nil || l.Val.Type != xdm.TypeInteger {
+ return 0
+ }
+ if r := l.Val.Rat(); r != nil && r.IsInt() && r.Num().IsInt64() {
+ if k := r.Num().Int64(); k >= 1 && k <= math.MaxInt32 {
+ return int(k)
+ }
+ }
+ return 0
+}
+
+// appendFromN is appendFrom that stops once limit nodes are selected, where
+// limit is positive and selected started empty.
+func (e *Step) appendFromN(selected xdm.Sequence, node *xdm.Node, limit int) xdm.Sequence {
principal := e.Axis.PrincipalKind()
if nt, ok := e.Test.(*NameTest); ok && (e.Axis == AxisDescendant || e.Axis == AxisDescendantOrSelf) {
// The direct walk selects what walkAxis would, in the same order.
@@ -130,6 +156,7 @@ func (e *Step) appendFrom(selected xdm.Sequence, node *xdm.Node) xdm.Sequence {
selected = make(xdm.Sequence, 0, 4)
}
selected = append(selected, n)
+ return limit == 0 || len(selected) < limit
}
return true
})
diff --git a/xpath/step_firstpos_test.go b/xpath/step_firstpos_test.go
new file mode 100644
index 00000000..5cb3d299
--- /dev/null
+++ b/xpath/step_firstpos_test.go
@@ -0,0 +1,79 @@
+package xpath
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// A step whose first predicate is an integer literal stops at that position
+// rather than building every match; the result must not change.
+func TestStepLiteralPositionStopsEarly(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString("")
+ for i := 1; i <= 2000; i++ {
+ fmt.Fprintf(&sb, ``, i)
+ if i == 3 {
+ sb.WriteString(``)
+ }
+ }
+ sb.WriteString("")
+ tr, err := xdm.ParseString(sb.String(), xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ eval := func(src string) string {
+ ctx := NewContext(tr.Root, Builtins())
+ v, err := MustCompile(src, nil).Eval(ctx)
+ if err != nil {
+ t.Fatalf("%s: %v", src, err)
+ }
+ var ids []string
+ for _, it := range v {
+ ids = append(ids, it.(*xdm.Node).AttrValue("id"))
+ }
+ return strings.Join(ids, ",")
+ }
+ for src, want := range map[string]string{
+ "/r/l[1]": "1",
+ "/r/l[3]": "3",
+ "/r/l[0]": "",
+ "/r/l[1.5]": "",
+ "/r/l[2001]": "",
+ "/r/l[last()]": "2000",
+ "/r/l[3]/following-sibling::x": "x",
+ "/r/x/preceding-sibling::l[1]": "3",
+ "/r/x/preceding-sibling::l[3]": "1",
+ "/r/x/preceding-sibling::*[4]": "",
+ "/r/x/following-sibling::l[1]": "4",
+ "/r/l[2][1]": "2",
+ "/r/l[2][2]": "",
+ "/r/l[1][@id='1']": "1",
+ "/r/l[2][@id='1']": "",
+ "/r/l[@id > 1][1]": "2",
+ "/r/*[4]": "x",
+ "(/r/l)[1]": "1",
+ "/r/l[position() = 2]": "2",
+ } {
+ if got := eval(src); got != want {
+ t.Errorf("%s = %q, want %q", src, got, want)
+ }
+ }
+ bytes := func(src string) uint64 {
+ c := MustCompile(src, nil)
+ ctx := NewContext(tr.Root, Builtins())
+ var a, b runtime.MemStats
+ runtime.ReadMemStats(&a)
+ for i := 0; i < 20; i++ {
+ c.Eval(ctx)
+ }
+ runtime.ReadMemStats(&b)
+ return b.TotalAlloc - a.TotalAlloc
+ }
+ if first, last := bytes("/r/l[1]"), bytes("/r/l[2000]"); first*4 > last {
+ t.Errorf("/r/l[1] allocated %d bytes, /r/l[2000] %d: the first did not stop early", first, last)
+ }
+}