Skip to content

Intern the Nodes that carry origin data - #16

Open
reuvenharrison wants to merge 2 commits into
v3from
perf/intern-origin-nodes
Open

Intern the Nodes that carry origin data#16
reuvenharrison wants to merge 2 commits into
v3from
perf/intern-origin-nodes

Conversation

@reuvenharrison

@reuvenharrison reuvenharrison commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Why

Origin data is encoded as Nodes so it survives the node -> JSON -> UnmarshalJSON conversion the decoder performs on the way to the caller's type. That makes every recorded line, column, count and field name its own heap-allocated Node carrying its own string.

On a large document those dominate. Profiling a 23 MB OpenAPI spec loaded through kin-openapi with IncludeOrigin:

486.58MB 18.27%  github.com/oasdiff/yaml3.intNode (inline)
229.04MB  8.60%  github.com/oasdiff/yaml3.(*parser).node
187.53MB  7.04%  github.com/oasdiff/yaml3.strNode (inline)
 94.01MB  3.53%  github.com/oasdiff/yaml3.addOrigin       (cum 862MB, 32%)

intNode alone was the largest single allocator in the process, and it was calling fmt.Sprintf("%d", v) per integer.

What

These nodes are immutable leaf scalars, so equal values can share one Node. Three caches, covering the three ways a spec repeats itself:

  • small non-negative integers (0..1023), which is nearly every column, line delta and count. Built once, shared across decodes. Only an absolute line number in a large document exceeds it, and there is one of those per mapping against many small ones.
  • strings, which repeat heavily inside one document: the file path on every mapping, and keys like type, description, $ref. Per-decode, so it needs no locking and is freed with the decoder.
  • the __origin__ key node, whose value is a compile-time constant, so one package-level Node serves the whole process.

intNode also drops fmt.Sprintf for strconv.Itoa on the uncached path.

What is deliberately still allocated per use: the sequence node beside the __origin__ key, since it owns per-mapping content; and integers past the small-int cache, which are absolute line numbers and therefore distinct per mapping, so interning them could not dedup anything.

On the file name

The string cache keys on the string itself and holds no notion of "the" file. The file recorded in an origin is not constant: a $ref carries the decode into another document, and the origin has to name the file the element actually came from. Interning by value is correct either way, a second file is simply a second entry.

On bounds

Neither cache needs a size bound. The strings interned are mapping keys and scalar sequence items, never values like descriptions, so the distinct set is small by construction. In the worst case the cache costs one pointer per Node that would have been allocated regardless.

Results

BenchmarkOriginDecode (new, 2000-endpoint document, -count=3):

B/op allocs/op ns/op
before 37,475,112 539,898 ~35.5M
after 20,469,469 412,405 ~28.7M
-45.4% -23.6% -19%

End to end, peak RSS loading an OpenAPI spec through kin-openapi with IncludeOrigin, median of 3:

spec before after
YAML, 16.8 MB 1794 MB 931 MB

A 48% reduction on the path that ships today.

Correctness

No behaviour change: the emitted __origin__ sequences are byte for byte what they were, which the existing golden-output tests already pin.

New tests cover the risks interning introduces:

  • two files decoded separately keep their own names in their origins
  • a shared Node is never mutated (decoding the same input twice reproduces it byte for byte, with an unrelated decode in between)
  • a line number past the small-int cache is still recorded correctly, since the cached and uncached paths build the Node differently

Full yaml3 suite passes, as do the full kin-openapi and oasdiff suites against this branch via the workspace.

Origin data is encoded as Nodes so it survives the node -> JSON ->
UnmarshalJSON conversion the decoder performs on the way to the
caller's type. That makes every recorded line, column, count and field
name its own heap-allocated Node carrying its own string. On a large
document those dominate: profiling a 23 MB OpenAPI spec put intNode
alone at 486 MB of 3.07 GB allocated, the largest single allocator in
the process.

These nodes are immutable leaf scalars, so equal values can share one
Node. Two caches cover the two ways a spec repeats itself:

- small non-negative integers, which is nearly every column, line delta
  and count, in a table built once and shared across decodes
- strings, which repeat heavily inside one document (the file path on
  every mapping, and keys like "type", "description", "$ref"), in a
  per-decode cache that needs no locking and is freed with the decoder

intNode also drops fmt.Sprintf for strconv.Itoa on the uncached path.

The string cache keys on the string itself and holds no notion of "the"
file, because the file in an origin is not constant: a $ref carries the
decode into another document and the origin must name the file the
element actually came from. Interning by value is correct either way; a
second file is simply a second entry. Neither cache needs a size bound.
The strings interned are mapping keys and scalar sequence items, never
values like descriptions, so the distinct set is small by construction,
and at worst the cache costs one pointer per Node that would have been
allocated anyway.

BenchmarkOriginDecode, 2000-endpoint document:

    B/op       37,475,112 -> 21,877,951   -41.6%
    allocs/op     539,898 ->    420,407   -22.1%
    ns/op         ~35.5M  ->     ~29.0M   -18%

End to end on a 16.8 MB OpenAPI spec loaded through kin-openapi with
IncludeOrigin, peak RSS falls 1794 MB -> 968 MB, a 46% reduction.

No behaviour change: the emitted __origin__ sequences are byte for byte
what they were, which the existing golden-output tests already pin. New
tests cover the risks interning introduces -- that two files decoded
separately keep their own names, that a shared Node is never mutated,
and that a line number past the small-int cache is still recorded
correctly, since the cached and uncached paths build the Node
differently.
The key appended to every mapping alongside its origin sequence was
allocated fresh each time, though its value is a compile-time constant.
It is the most shareable node of all: one package-level Node now serves
the whole process. Line stays 0, which is what isOrigin tests for.

The sequence node beside it still has to be fresh, since it owns the
per-mapping content.

    B/op       21,877,951 -> 20,469,469
    allocs/op     420,407 ->    412,405

Cumulative against the pre-interning baseline: B/op -45.4%,
allocs/op -23.6%, and peak RSS on a 16.8 MB spec 1794 MB -> 931 MB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant