Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
119 commits
Select commit Hold shift + click to select a range
20958aa
xsd: clip document strings in validation messages
knroy Oct 31, 2026
18d374d
xdm: copy namespace URIs out of the tokeniser arena
knroy Oct 31, 2026
a5b385d
xslt: hold fn:transform cache resolvers weakly
knroy Oct 31, 2026
8dcb07f
dtd: render the error path only for a recorded failure
knroy Oct 31, 2026
ba9e7a0
xquery: share a FLWOR stream's tuple names slice
knroy Oct 31, 2026
bd865e4
dtd: clip values quoted in validity messages
knroy Oct 31, 2026
f890740
xquery: return a FLWOR's value without rebuilding it
knroy Oct 31, 2026
9e13f70
xmltok: size the string arena's first block to the input
knroy Oct 31, 2026
a8d6e7b
xquery: install the query's XPath version once per evaluation
knroy Oct 31, 2026
5d8fceb
xsd: scan base64Binary bytes in place
knroy Oct 31, 2026
24b963c
xquery: reserve a FLWOR's result from its tuple stream
knroy Oct 31, 2026
cf65d62
xslt: stream the text method, CDATA sections, character-mapped text a…
knroy Oct 31, 2026
73af7a8
xquery: resolve lifted operands without building a library
knroy Oct 31, 2026
490fc5a
xslt: intern compiled namespace maps, lists and resolvers by content
knroy Oct 31, 2026
e056467
xsd: reuse two NFA state sets in matchNames
knroy Oct 31, 2026
ec1ac54
xdm: keep line starts, not the source text, for positions
knroy Oct 31, 2026
f09c1a3
xsd: record each ID value in one map
knroy Oct 31, 2026
f148247
xpath: append a predicate-free forward step straight onto the path re…
knroy Oct 31, 2026
70ff980
xpath: step an int64-bounded range without big arithmetic
knroy Oct 31, 2026
dcb15d1
xslt: keep namespace maps, not elements, in compiled instructions
knroy Oct 31, 2026
2ad3a32
xdm: hand a long text's tokeniser buffer to the text store
knroy Oct 31, 2026
ef5f064
xpath: key distinct-values' promoted doubles only once one is needed
knroy Oct 31, 2026
14c84b5
xsd: parse bound facets once with the memoised chain
knroy Oct 31, 2026
a5ab736
xdm: trim a parsed tree's last text block
knroy Oct 31, 2026
4440665
xdm: drop the build-only indexes when a parse freezes its tree
knroy Oct 31, 2026
e3b6288
xslt: pass with-param values as a slice, not two maps
knroy Oct 31, 2026
599b2f9
serialize: stream the json and adaptive methods to the writer
knroy Oct 31, 2026
b5aaa52
xslt: build the function memo key in a reused buffer
knroy Oct 31, 2026
f235795
c14n: join Bytes' output chunks once instead of doubling a buffer
knroy Oct 31, 2026
37206b1
xslt: build Result.String and SerializeAsXML from the flushed chunks
knroy Oct 31, 2026
6345114
xdm: pool a parse's read windows
knroy Oct 31, 2026
1f18b1b
xslt: pool serialize's 32 KiB write buffer
knroy Oct 31, 2026
b42f04a
xslt: set a literal element's namespace bindings in one step
knroy Oct 31, 2026
9496810
c14n: give BytesNodeSet the same chunk join as Bytes
knroy Oct 31, 2026
b6d4e4e
xslt: build type-conversion error descriptions only on failure
knroy Oct 31, 2026
c501fc4
xslt: build SecondaryResult.String from the flushed chunks too
knroy Oct 31, 2026
327b09e
xslt: bind globals from one cell slice and a shared name table
knroy Oct 31, 2026
beb6072
xslt: point the benchmarks at the workloads testdata/bench holds
knroy Oct 31, 2026
5115714
Merge branch 'mem/xdm' into perf/memory-audit
knroy Oct 31, 2026
e18caf5
Merge branch 'mem/xsd' into perf/memory-audit
knroy Oct 31, 2026
94caa1c
Merge branch 'mem/xpath' into perf/memory-audit
knroy Oct 31, 2026
62e5404
Merge branch 'mem/xslt' into perf/memory-audit
knroy Oct 31, 2026
6334d9a
Merge branch 'mem/ser' into perf/memory-audit
knroy Oct 31, 2026
8a704d3
docs: record the memory audit fixes
knroy Oct 31, 2026
4e8f0a5
xslt: refuse a result tree that overran MaxNodes instead of returning…
knroy Oct 31, 2026
e139525
xdm: skip the XInclude copy when the tree has no xi element
knroy Oct 31, 2026
21995a6
xslt: charge retained xsl:key entries and group-by keys to the item b…
knroy Oct 31, 2026
3b71501
xdm: clip the TrackPositions line index at the end of the parse
knroy Oct 31, 2026
de4a515
xslt: bound the FileResolver document cache by source bytes as well a…
knroy Oct 31, 2026
9f0db9c
relaxng: publish nameMemo stores as amortised snapshots instead of co…
knroy Oct 31, 2026
5dc120c
xsd, dtd: dedup small state sets by scan; reuse child-name buffer; sk…
knroy Oct 31, 2026
da05b16
xsdnet: share one guarded Transport across fetches
knroy Oct 31, 2026
967dfb3
xdm: hold the shared typing names weakly so a dropped schema can be c…
knroy Oct 31, 2026
69414ae
xslt: build internBindings keys in a reused buffer so a hit does not …
knroy Oct 31, 2026
887d5d3
xslt: do not keep an oversized function-call key buffer for the whole…
knroy Oct 31, 2026
702c6e6
Refuse deeply nested maps and arrays instead of overflowing the stack
knroy Oct 31, 2026
59ac569
array:append, insert-before and join copy members once
knroy Oct 31, 2026
c1c2bb9
Bound regex program size, charge replace before building, cap picture…
knroy Oct 31, 2026
58ac243
Merge branch 'fix2/deep' into perf/memory-audit
knroy Oct 31, 2026
7b37239
Merge branch 'fix2/regex' into perf/memory-audit
knroy Oct 31, 2026
77c1db9
Merge branch 'fix2/xslt' into perf/memory-audit
knroy Oct 31, 2026
a7c4e49
Merge branch 'fix2/xdm' into perf/memory-audit
knroy Oct 31, 2026
5f36785
Merge branch 'fix2/valid' into perf/memory-audit
knroy Oct 31, 2026
9c3a983
xdm: return the input from ProcessXInclude only when it is a frozen p…
knroy Oct 31, 2026
4611566
docs: record the second memory audit's fixes
knroy Oct 31, 2026
014a862
xpath: bound deep-equal and map:find nesting at 1000 apart from funct…
knroy Nov 1, 2026
a9ad280
xslt: join each run of adjacent atomics once instead of by repeated c…
knroy Nov 1, 2026
b35ec14
xslt: charge xsl:copy-of of a document node against MaxNodes
knroy Nov 1, 2026
676ad52
relaxng: publish pending nameMemo entries after a few locked hits
knroy Nov 1, 2026
331bc96
xpath: do not cache a compiled regex over 4,096 instructions
knroy Nov 1, 2026
dcb49b2
xpath: charge the copies a sequence constructor and ! make
knroy Nov 1, 2026
15273c7
xslt: memoise function results under a 128-bit hash, not the key string
knroy Nov 1, 2026
40cd52c
xslt: share and charge sequence-valued accumulator values
knroy Nov 1, 2026
5951f24
xpath: bind a dynamic call's host names in one scope each, not one pe…
knroy Nov 1, 2026
a4f316d
xquery: bind a tuple and a window condition's variables in one scope
knroy Nov 1, 2026
8577906
xpath: Env.MaxDigits refuses over-long numeric casts and arithmetic r…
knroy Nov 1, 2026
6bab64a
xpath: fn:replace estimates its result from the matched text and retu…
knroy Nov 1, 2026
40085f6
xpath: int64 fast paths for mod, idiv, unary minus and integer compar…
knroy Nov 1, 2026
6ea432f
xslt: slim sortValue and allocate xsl:sort keys in one array
knroy Nov 1, 2026
c73a06b
xpath: charge the lines of unparsed-text-lines and the members of par…
knroy Nov 1, 2026
98811f6
xpath: build parse-json objects with xdm.MapBuilder
knroy Nov 1, 2026
951f0d9
xslt: check XTDE1490 against sets instead of scanning earlier result …
knroy Nov 1, 2026
8b97c7c
xpath: pass value depth down deep-equal and map:find instead of growi…
knroy Nov 1, 2026
f650a19
xslt: skip the focus copy for built-in rules in a mode with no rules;…
knroy Nov 1, 2026
6317d49
xpath: fn:serialize stops at the first refused write
knroy Nov 1, 2026
30c7c07
xdm: allocate range integers a chunk at a time
knroy Nov 1, 2026
4b0b564
xslt: walk namespace frames instead of building the in-scope map per …
knroy Nov 1, 2026
469c95e
xslt: test array emptiness lazily, stopping at the first deciding leaf
knroy Nov 1, 2026
a3d8ef6
xslt: carry xsl:iterate parameters in slices and charge xsl:next-iter…
knroy Nov 1, 2026
44229aa
relaxng: merge choice chains alternative by alternative so (a?){n} a{…
knroy Nov 1, 2026
a6f2eaa
xslt: parse fn:doc files as they are read, not through ReadAll and st…
knroy Nov 1, 2026
f49ff92
xslt: FileResolver does not evict to admit an entry that does not fit…
knroy Nov 1, 2026
be5afc3
xdm: store each DTD default value once and alias it in every element
knroy Nov 1, 2026
f3449f7
cmd/go-xml: drop to GOGC=100 once the live heap passes 128 MB
knroy Nov 1, 2026
b5aaf06
xpath: an inline function closure keeps only the variables its body r…
knroy Nov 1, 2026
6293e98
xdm: index DTD attribute defaults and types by element name
knroy Nov 1, 2026
996b7db
xsd: extend a key scope's entries instead of rebuilding them per level
knroy Nov 1, 2026
1f5821c
Merge branch 'fix3/eval' into perf/memory-audit
knroy Nov 1, 2026
049cfdf
Merge branch 'fix3/io' into perf/memory-audit
knroy Nov 1, 2026
139105d
Merge branch 'fix3/func' into perf/memory-audit
knroy Nov 1, 2026
81f7b8e
Merge branch 'fix3/xslta' into perf/memory-audit
knroy Nov 1, 2026
01fbcce
Merge branch 'fix3/xsltb' into perf/memory-audit
knroy Nov 1, 2026
4eac950
Merge branch 'fix3/valid' into perf/memory-audit
knroy Nov 1, 2026
de0ac46
docs: record the third memory audit's fixes
knroy Nov 1, 2026
b37796b
xdm: add Node.Index, Node.SubtreeEnd, Tree.Len and Tree.NodeAt
knroy Nov 1, 2026
b331aa1
xslt: hold accumulator values in slices indexed by node position
knroy Nov 1, 2026
73249e2
xdm: make namespace nodes without rebuilding the scope per node; add …
knroy Nov 1, 2026
28d41c8
xmltok: size a long text run in one step, so a 10 MiB value allocates…
knroy Nov 1, 2026
127b2f2
c14n: pool the output buffer instead of allocating 80 KiB per call
knroy Nov 1, 2026
5d6454a
c14n: size Bytes and BytesNodeSet output from the tree for Subtree an…
knroy Nov 1, 2026
22d32ac
xdm, xmltok: size the per-attribute structures of a many-attribute ta…
knroy Nov 1, 2026
567aa1a
xpath: fewer allocations per predicate step
knroy Nov 1, 2026
0da3fba
Merge branch 'fix4/c14n' into perf/memory-audit
knroy Nov 1, 2026
13eac16
Merge branch 'fix4/parse' into perf/memory-audit
knroy Nov 1, 2026
044c439
Merge branch 'fix4/index' into perf/memory-audit
knroy Nov 1, 2026
9ab768a
Merge branch 'fix4/ns' into perf/memory-audit
knroy Nov 1, 2026
91a41e1
Merge branch 'fix4/eval' into perf/memory-audit
knroy Nov 1, 2026
63e509a
Revert "xmltok: size a long text run in one step, so a 10 MiB value a…
knroy Nov 1, 2026
c58525b
docs: record the go-xmlsec memory fixes
knroy Nov 1, 2026
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
177 changes: 177 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,854 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
| **Tests** | 2,950 `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 |

Expand Down Expand Up @@ -669,7 +669,7 @@ exact signal.

`FileResolver`'s document cache exists because `fn:doc` is defined to return
the *same* node for the same URI within one execution, so `doc('x') is doc('x')`
requires it. It is mutex-guarded, and now capped at 256 documents. It had been
requires it. It is mutex-guarded, and now capped at 256 documents and 32 MB of source. It had been
unbounded on the reasoning that the directories the caller opened with
`-allow-dir` bound it in practice — true for the size of the *set*, but the
process holds every document it has ever parsed for its whole lifetime, and a
Expand Down Expand Up @@ -1388,7 +1388,7 @@ back, is in [docs/testing.md](docs/testing.md).

| method | what it catches | what it misses |
|---|---|---|
| **Unit tests** (2,854 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
| **Unit tests** (2,950 `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) |
Expand Down
47 changes: 47 additions & 0 deletions c14n/bytes_alloc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package c14n

import (
"runtime"
"testing"
)

// Bytes and BytesNodeSet grew a bytes.Buffer by doubling from empty, 3.7
// times the canonical form in all, then kept the writer's chunks and joined
// them, 2.1 times, and Equal paid it for its first operand. They now size
// the result from the node set and write into it once, about 1.1 times.
func TestBytesAllocation(t *testing.T) {
// 3 MB: doubling cost 3.1x the output here and 3.7x at 10 MB.
root := cachedDoc(t, 3<<20).FirstChild()
out, err := Bytes(root, Options{Algorithm: Inclusive10})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
f func()
}{
{"Bytes", func() { _, _ = Bytes(root, Options{Algorithm: Inclusive10}) }},
{"BytesNodeSet", func() { _, _ = BytesNodeSet(Subtree(root), Options{Algorithm: Inclusive10}) }},
{"Equal", func() { _, _ = Equal(root, root, Options{Algorithm: Inclusive10}) }},
} {
got := heapAllocated(tc.f)
t.Logf("%s: %d bytes allocated for %d bytes of output (%.2fx)", tc.name, got, len(out), float64(got)/float64(len(out)))
if got > uint64(len(out))*7/5 {
t.Errorf("%s allocated %d bytes for %d bytes of output, want under 1.4x", tc.name, got, len(out))
}
}
}

// heapAllocated is the heap f allocates, the least of three runs so that a
// collection landing inside one does not count against it.
func heapAllocated(f func()) uint64 {
best := ^uint64(0)
for range 3 {
var a, b runtime.MemStats
runtime.ReadMemStats(&a)
f()
runtime.ReadMemStats(&b)
best = min(best, b.TotalAlloc-a.TotalAlloc)
}
return best
}
108 changes: 102 additions & 6 deletions c14n/c14n.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,95 @@ func FormatPrefixList(prefixes []string) string {
// descendants are, with the namespace and xml:* axis treatment the
// chosen algorithm specifies for a subset apex.
func Bytes(n *xdm.Node, opts Options) ([]byte, error) {
var b bytes.Buffer
err := Write(&b, n, opts)
return b.Bytes(), err
if n == nil || (n.Kind() != xdm.KindDocument && n.Kind() != xdm.KindElement) {
return nil, ErrUnsupportedNode
}
return BytesNodeSet(Subtree(n), opts)
}

// chunkWriter keeps a copy of everything written to it.
type chunkWriter struct{ chunks [][]byte }

func (c *chunkWriter) Write(p []byte) (int, error) {
c.chunks = append(c.chunks, bytes.Clone(p))
return len(p), nil
}

// bytes joins the chunks. No output is nil, as from an empty bytes.Buffer.
func (c *chunkWriter) bytes() []byte {
switch len(c.chunks) {
case 0:
return nil
case 1:
return c.chunks[0]
}
return bytes.Join(c.chunks, nil)
}

// 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.
type sizedWriter struct {
ns NodeSet
opts Options
buf []byte
}

func (s *sizedWriter) Write(p []byte) (int, error) {
if s.buf == nil {
s.buf = make([]byte, 0, max(estimateSize(s.ns, s.opts), len(p)))
}
if need := len(s.buf) + len(p); need > cap(s.buf) {
// A short estimate: grow by a quarter, not by append's doubling.
s.buf = append(make([]byte, 0, need+need/4), s.buf...)
}
s.buf = append(s.buf, p...)
return len(p), nil
}

// estimateSize adds up the markup and values of ns's members. It reads the
// tree once, a sixth of the time canonicalizing it takes. It counts every
// declaration an element has and no escaping, and adds an eighth for the
// escapes, so it is an estimate. It does not ask ns which nodes are members
// (a NodeSet is asked once per node), so it is for the sets this package
// made: a Subtree is counted exactly, an ExcludeSubtree with what it excludes.
func estimateSize(ns NodeSet, opts Options) int {
root := ns.Root()
if root == nil {
return 0
}
comments := opts.Algorithm.WithComments()
n := 0
visit := func(d *xdm.Node) {
switch d.Kind() {
case xdm.KindElement:
q := d.Name()
n += 2*(len(q.Local)+len(q.Prefix)) + 6
for a := range d.Attrs() {
q := a.Name()
n += len(q.Local) + len(q.Prefix) + len(a.Value()) + 5
}
for p, u := range d.DeclaredNamespaces() {
n += len(p) + len(u) + 9
}
case xdm.KindComment:
if comments {
n += len(d.Value()) + 8
}
case xdm.KindPI:
n += len(d.Name().Local) + len(d.Value()) + 8
case xdm.KindText:
n += len(d.Value())
}
}
visit(root)
for d := range root.Descendants() {
visit(d)
}
return n + n/8
}

// Digest canonicalizes n and writes the result into h, returning h's sum.
Expand Down Expand Up @@ -277,9 +363,19 @@ func WriteNodeSet(w io.Writer, ns NodeSet, opts Options) error {

// BytesNodeSet mirrors Bytes for a subset.
func BytesNodeSet(ns NodeSet, opts Options) ([]byte, error) {
var b bytes.Buffer
err := WriteNodeSet(&b, ns, opts)
return b.Bytes(), err
switch ns.(type) {
case subtree, excludeSet:
c := sizedWriter{ns: ns, opts: opts}
err := WriteNodeSet(&c, ns, opts)
return c.buf, err
}
// Any other set is the caller's, and Contains is called once per node:
// the output cannot be sized without calling it again, so it arrives in
// chunks of about outBufSize, kept and joined once, about twice the
// canonical form in all.
var c chunkWriter
err := WriteNodeSet(&c, ns, opts)
return c.bytes(), err
}

// DigestNodeSet mirrors Digest for a subset.
Expand Down
31 changes: 28 additions & 3 deletions c14n/canon.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"regexp"
"slices"
"strings"
"sync"

"github.com/knroy/go-xml/v2/xdm"
)
Expand Down Expand Up @@ -96,7 +97,10 @@ type canon struct {
const noNode = "\x00"

func canonicalize(w io.Writer, ns NodeSet, opts Options) error {
_, err := run(w, ns, opts)
c, err := run(w, ns, opts)
if c != nil {
c.w.release()
}
return err
}

Expand All @@ -108,7 +112,6 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
}
_, all := ns.(subtree)
c := &canon{
w: newOutBuf(w),
set: ns,
all: all,
excl: opts.Algorithm.Exclusive(),
Expand Down Expand Up @@ -139,6 +142,7 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
c.scope.m[prefix] = uri
}
}
c.w = newOutBuf(w)
var err error
switch root.Kind() {
case xdm.KindDocument:
Expand All @@ -150,6 +154,7 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
c.leaf(root)
}
default:
c.w.release()
return nil, ErrUnsupportedNode
}
if err == nil {
Expand All @@ -170,12 +175,32 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
// into nothing.
type outBuf struct {
buf []byte
pb *[]byte // the pool entry buf came from
w io.Writer
err error
}

// outBufPool keeps the buffers between calls: a fixed 80 KiB allocated per
// call was 81 KB of garbage for each Digest of a tiny document.
var outBufPool = sync.Pool{New: func() any {
b := make([]byte, 0, outBufSize+outBufSize/4)
return &b
}}

func newOutBuf(w io.Writer) *outBuf {
return &outBuf{buf: make([]byte, 0, outBufSize+outBufSize/4), w: w}
pb := outBufPool.Get().(*[]byte)
return &outBuf{buf: (*pb)[:0], pb: pb, w: w}
}

// release returns the buffer to the pool unless output grew it far past its
// size, so that one large text node does not stay allocated. o is not used
// afterwards.
func (o *outBuf) release() {
if cap(o.buf) <= 2*outBufSize {
*o.pb = o.buf[:0]
outBufPool.Put(o.pb)
}
o.buf, o.pb = nil, nil
}

// flush hands buf to w. A short write without an error is io.ErrShortWrite,
Expand Down
27 changes: 27 additions & 0 deletions c14n/digest_alloc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package c14n

import (
"crypto/sha256"
"testing"
)

// Every call allocated an 80 KiB output buffer, 81 KB of garbage for each
// Digest of a tiny document. The buffers now come from a pool. The bound is half the unpooled
// figure because the race detector makes a pool drop a quarter of its puts.
func TestDigestSmallDocumentAllocation(t *testing.T) {
root := parse(t, `<a><b>x</b></a>`).FirstChild()
h := sha256.New()
const calls = 101
got := heapAllocated(func() {
for range calls {
h.Reset()
if _, err := Digest(h, root, Options{Algorithm: Inclusive10}); err != nil {
t.Fatal(err)
}
}
})
t.Logf("%d Digest calls allocated %d bytes", calls, got)
if got > 4<<20 {
t.Errorf("%d Digest calls allocated %d bytes, want under 4 MiB (8.3 MB with a fresh buffer each)", calls, got)
}
}
20 changes: 20 additions & 0 deletions cmd/go-xml/gc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,23 @@ func TestLowerGCRespectsGOGC(t *testing.T) {
t.Errorf("with GOGC=100: GC percent %d, want the environment's 100", got)
}
}

// Past 128 MB of live heap the command drops from GOGC=200 to 100; below it
// the setting is left alone.
func TestRelaxGCOnBigLiveHeap(t *testing.T) {
orig := debug.SetGCPercent(cliGCPercent)
defer debug.SetGCPercent(orig)

if relaxGC(cliGCBigHeap) {
t.Error("relaxed at exactly the threshold, want only past it")
}
if got := debug.SetGCPercent(cliGCPercent); got != cliGCPercent {
t.Errorf("a small heap changed GC percent to %d", got)
}
if !relaxGC(cliGCBigHeap + 1) {
t.Error("did not relax past the threshold")
}
if got := debug.SetGCPercent(cliGCPercent); got != 100 {
t.Errorf("GC percent after a big heap = %d, want 100", got)
}
}
44 changes: 43 additions & 1 deletion cmd/go-xml/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"runtime/debug"
"runtime/metrics"
"strings"
"sync"
"time"

"github.com/knroy/go-xml/v2/xdm"
Expand Down Expand Up @@ -51,12 +54,51 @@ func main() {
// never in the library, whose callers own their process's GC policy.
const cliGCPercent = 200

// cliGCBigHeap is the live heap, in bytes, past which the command drops to
// GOGC=100. At 200 the heap may reach three times what is live, and past this
// size that is what costs: peak RSS on DocBook table-cals.049 was 1,160 MB at
// 200 and 789 MB at 100, and on a 100 MB identity transform 1,840-1,956 MB
// against 1,360-1,400, for the same wall time and 5-10% more CPU. Below it a
// small heap is cheap to hold and the extra CPU is what 200 saves. The size of
// the input is no guide -- DocBook's is small -- so it is the live heap that
// is read.
const cliGCBigHeap = 128 << 20

// lowerGC applies cliGCPercent unless GOGC is set, so a user who tunes the
// collector keeps their setting.
// collector keeps their setting, and watches the live heap to relax it.
func lowerGC() {
if os.Getenv("GOGC") == "" {
debug.SetGCPercent(cliGCPercent)
watchLiveHeap.Do(armGCSentinel)
}
}

var watchLiveHeap sync.Once

type gcSentinel struct{ _ [16]byte }

// armGCSentinel runs relaxGC after each collection until it has acted. A
// finalizer on an unreachable object runs once the cycle that found it has
// marked, so the live-heap metric it reads is that cycle's.
func armGCSentinel() {
runtime.SetFinalizer(new(gcSentinel), func(*gcSentinel) {
s := []metrics.Sample{{Name: "/gc/heap/live:bytes"}}
metrics.Read(s)
if s[0].Value.Kind() == metrics.KindUint64 && relaxGC(s[0].Value.Uint64()) {
return
}
armGCSentinel()
})
}

// relaxGC drops to GOGC=100 when live bytes are past cliGCBigHeap, and
// reports whether it did.
func relaxGC(live uint64) bool {
if live <= cliGCBigHeap {
return false
}
debug.SetGCPercent(100)
return true
}

// paramFlag collects repeated -p name=value flags.
Expand Down
Loading
Loading