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) + } +}