Intern the Nodes that carry origin data - #16
Open
reuvenharrison wants to merge 2 commits into
Open
Conversation
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.
This was referenced Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Origin data is encoded as
Nodes so it survives the node -> JSON ->UnmarshalJSONconversion the decoder performs on the way to the caller's type. That makes every recorded line, column, count and field name its own heap-allocatedNodecarrying its own string.On a large document those dominate. Profiling a 23 MB OpenAPI spec loaded through kin-openapi with
IncludeOrigin:intNodealone was the largest single allocator in the process, and it was callingfmt.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:type,description,$ref. Per-decode, so it needs no locking and is freed with the decoder.__origin__key node, whose value is a compile-time constant, so one package-levelNodeserves the whole process.intNodealso dropsfmt.Sprintfforstrconv.Itoaon 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
$refcarries 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
Nodethat would have been allocated regardless.Results
BenchmarkOriginDecode(new, 2000-endpoint document,-count=3):End to end, peak RSS loading an OpenAPI spec through kin-openapi with
IncludeOrigin, median of 3: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:
Nodeis never mutated (decoding the same input twice reproduces it byte for byte, with an unrelated decode in between)NodedifferentlyFull yaml3 suite passes, as do the full kin-openapi and oasdiff suites against this branch via the workspace.