perf(compilers/openapi): index the mappings a pointer descends - #379
Merged
Conversation
Resolving an internal $ref walks the pointer from the document root, and each hop scanned every effective pair of the mapping it descended. The mapping a pointer passes through is components/schemas, so the scan cost grew with the number of components while the number of walks grew with the number of references -- both of which grow together in a real document, making pointer resolution quadratic in the document's own size. nodeview.View already memoizes each mapping's expansion. It now projects that memo into a key map on first descent, so a hop is a map read rather than a scan. The index is built from MappingPairs itself, which is what keeps it from becoming a second statement of how a mapping is read: expandContent yields each key once, so a map cannot answer differently from the first-match scan it replaces. Its entries are charged to the existing pair budget and gated on the same test memoize applies, so one bound still covers everything the view retains and the index never holds an expansion the pairs do not. Measured as pairs read while resolving pointers, over specs whose every component is referenced once: 5,550 -> 105 at 100 components, 82,200 -> 405 at 400, and 5,137,600 -> 3,205 at 3,200. The scan phase falls 11.4% at 3,200 components and is unchanged on petstore. The keyword lookups named in the same issue are deliberately left alone. RawChildNode reads OpenAPI objects, never the wide mappings a pointer descends: across the whole corpus it is called 9,715 times and the largest mapping it ever scans holds six pairs. Indexing them measured slower, and RawChildNode's raw reading -- first match wins, no alias dereference, no merge expansion -- is deliberately not what this view does, so the two now have a test pinning where they diverge.
The branch forked before #328, #356 and #373, so three of the four files needed more than a textual resolution: - The test helpers it calls were consolidated while it was open. ymap, yscalar, yalias and ymerge became ynode.Map/Scalar/Alias/Merge in #373, and yamlNode became openapitest.YAMLNode in #328. The bodies are identical, so these are renames. - TestRawChildNode_IsNotTheMergeAwareView asserted that a repeated key resolves to opposite ends in the two readers. #356 made RawChildNode take the last pair, as the parser does, so they now agree. The case is rewritten to assert the agreement rather than deleted: a reader drifting back to first-wins is worth failing on. - nodeview.go kept main's DocumentPath, walkPointer and tokenless, and main's ynode.MergeTag over the local const this branch predates. The index is additive to all of it.
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
OmarAlJarrah
commented
Aug 13, 2026
Follow-up on the same branch, from reviewing what the index actually bought. Four changes, each measured rather than reasoned about. The quadratic was only half removed. refScan.traverse follows every PointerPath with a PureRefTarget call over the same nodes, and that scanned every pair of the mapping ChildByToken had just stopped scanning. Reading the index there too takes the pairs it scans from 330,807 / 1,301,607 / 5,163,207 at N=400/800/1,600 — quadrupling per doubling — to 10,807 / 21,607 / 43,207. The index was a loss at the widths a document is mostly made of, so minIndexedPairs declines one below 16 pairs. Nearly every mapping a pointer descends is narrow and read once; a map allocated to answer one lookup costs more than the scan it replaced. It is no longer charged to maxCachedPairs. An index holds one entry per pair of a mapping the memo kept, so that bound already covers it, while charging it halved a memo that exists to stop a merge chain going cubic — a bound against a hang, not a speed budget. It is gated on the memo's presence rather than on a copy of memoize's arithmetic, which answers the same today and stops tracking it the day that test changes. An empty mapping charged nothing and so was indexed unconditionally, leaving v.keys growing after the budget was spent; the width gate ends that. The warm path reads the built index before re-deriving the pairs, and New no longer allocates a map most views never use. Tests: the agreement table was green with the index disabled, and every fixture was narrower than the gate now admits — both fixed, and the index asserted present. PureRefTarget's test plants a divergence between index and pairs, because both answer alike on any real document and asserting the answer alone cannot see the index bypassed. Corpus output is byte-identical to main across 127 specs.
Width was the wrong condition on its own. It says a mapping is expensive to scan, not that anything will scan it twice — and a walk that reads a wide mapping once pays for an index it never reads again. That is not hypothetical. declaresResourceIDAbove builds a view per call and reads each node on the path exactly once, so crossing the width threshold cost it time and half again its allocations for nothing: at 16 pairs, 2,043 ns and 3,416 B against 1,762 ns and 2,240 B just below it. Reuse is now recorded rather than predicted. A mapping arrives with no entry and leaves with a nil one; only a read finding that marker builds the map. An index therefore exists exactly where a walk came back, which is the only place it can be repaid. The same shape now costs 1,768 ns and 2,432 B at width 16 — the build gone, the marker all that is left — while the pointer walk keeps its per-component cost flat, since a walk resolving R references reads each mapping R times. A nil entry cannot be read as an empty index: no mapping below minIndexedPairs is stored, so a marker is the only nil this interprets. Also documents what ChildByToken does not do. MappingPairs and PureRefTarget both dereference an alias standing in for a whole mapping; ChildByToken matches neither arm and answers nil. Every caller reaches a node through a walk that dereferences as it goes, so the difference is unreachable rather than harmless — worth writing down beside a sibling promising the opposite. Corpus output stays byte-identical to main across 127 specs, and the pairs PureRefTargetOf reads still grow linearly: 11,207 / 22,407 / 44,807 at N=400/800/1,600.
The reuse markers keyIndex writes are bounded, and more tightly than the indexes they precede: one exists only for a mapping whose pairs the memo kept, and each of those charged at least minIndexedPairs, so v.keys holds at most maxCachedPairs/minIndexedPairs entries. Measured at saturation the bound is exact — 131,072 entries, with 5,000 further mappings adding none — which the bounded-everything rule wants written down rather than derived by a reader. Also drops a memory claim the count bound does not support, and rewraps the line that carried it: a map entry costs more than a Pair, so the ceiling with indexes in play is a multiple of the pairs figure, not that figure.
BenchmarkPointerPath_IntoAWideMapping reads a flat per-component cost as the index being reached, and nothing runs it in CI — so the claim its comment makes was checked by nobody. The half of it that needs no stopwatch is now a test: a walk resolving many pointers through one view must leave an index on the mapping every one of them descends, and if a gate stops admitting that mapping the walk returns to scanning in silence. It bites on all three ways that can happen — the width gate raised past the mapping, the reuse marker never promoted, and the index never built. Also wraps the table rows and comment lines this branch had left longer than anything else in the files, and closes a paragraph break the narrow-width note ran into.
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.
Summary
Resolving an internal
$refwalks the pointer from the document root, and each hop scanned everyeffective pair of the mapping it descended. The mapping a pointer passes through is
components/schemas, so the scan cost grows with the number of components while the number ofwalks grows with the number of references — and both grow together in a real document. Pointer
resolution was therefore quadratic in the document's own size.
nodeview.Viewalready memoizes each mapping's expansion. It now projects that memo into a key mapon first descent, so a hop is a map read instead of a scan. Three properties keep that from being a
behaviour change:
MappingPairsitself, so it is not a second statement of how a mapping isread.
expandContentyields each key once, so a map cannot answer differently from the first-matchscan it replaces — asserted key by key, and reddened by letting a repeated key survive an expansion.
it is bounded by that memo rather than charged against it: one entry per pair of a mapping the memo
kept means
maxCachedPairsalready covers it. Charging it too would halve a memo that exists tostop a merge chain going cubic — a bound against a hang, not a speed budget — and would bring openapi: nodeview's expansion memo makes an answer depend on the depth a node was first expanded at #404
within reach at half the document size.
Compile.Pairs read while resolving pointers, over specs whose every component is referenced once:
Before, that count quadruples per doubling; after, it doubles. On the two specs the issue names it
falls from 63 to 12 (
testdata/golden/openapi/petstore.yaml) and from 53 to 16(
testdata/conformance/openapi/allof-oneof-cooccurrence.yaml).That table counts the pointer walk alone, and the walk was only half the scan.
refScan.traversefollows every
PointerPathwith aPureRefTargetcall over the same nodes, and that scanned everypair of the mapping the walk had just stopped scanning — so the quadratic survived in the sibling of
the call that removed it. Reading the index there too is what closes it:
Pairs read by
PureRefTargetOfduring a compile, same specs:ChildByTokenonlyIn wall time,
BenchmarkPointerPath_IntoAWideMapping— added here to hold the shape rather than thenumber — has a per-component cost that stops growing with width. End to end, a 1 MB spec of 3,000
schemas and 9,000 references compiles in 537 ms on
main, 525 ms with the walk indexed alone, and512 ms with both. The gain is real but modest against a whole compile: the reference scan is a small
fraction of it, and parsing and allocation dominate. On petstore nothing regresses.
Two gates keep the index from costing more than it saves, because a map allocated to answer a single
lookup is slower than the scan it replaced.
Width. Nearly every mapping a pointer descends is narrow; below
minIndexedPairs(16) the walkscans. The benchmark now carries n=2 and n=8 so a regression at those widths is visible.
Reuse. Width says a mapping is expensive to scan, not that anything will scan it twice. A mapping
arrives with no entry and leaves with a nil marker; only a read that finds the marker builds the map,
so an index exists exactly where a walk came back. This matters for
schema.declaresResourceIDAbove,which builds a view per call and reads each node on the path once: at width 16 it was paying 2,043 ns
and 3,416 B against 1,762 ns and 2,240 B just below the threshold, and now pays 1,768 ns and 2,432 B.
The pointer walk is unaffected, since resolving R references reads each mapping R times.
What this deliberately does not do
The issue also asks for a per-node key map behind
annotation.RawChildNodeandRawPropertyNode.That is not done, because measuring it first showed it costs more than it saves.
RawChildNodeis only ever handed an OpenAPI object — a schema, parameter, response, header,media type or security scheme — whose key set is the spec's fixed field list. It is never handed the
wide mappings a pointer descends. Instrumenting a compile of every source under
testdata/counts9,715 calls in which the largest mapping scanned holds six pairs and the mean holds 1.81. The
call counts the issue quotes are real, but each call is a scan of about two elements.
Prototyping the index confirmed the arithmetic: a per-node key map takes a petstore compile from
1.207 ms to 1.590 ms (+31.7%), and from 15,004 to 15,085 allocations. Building a map for a mapping
of 1/2/3/6 pairs costs 19/28/35/59 ns against 6.8/9.5/11.2/16.8 ns for the scan it replaces, and the
per-call node→map lookup then costs about as much again as the scan did. Since the whole cost of
these lookups is under 0.3% of a compile, no index can win more than that, and this one loses.
So the plumbing the issue anticipates — carrying an index on
lowering.Ctxand widening thesignatures beneath it into
annotation— is not introduced. Nothing in this change reacheslowering,schema,operationorauth, and no call site is left half-converted: the keywordreaders are exactly as they were.
What that half of the issue did surface is that the constraint protecting it was untested.
RawChildNodereads the raw tree — first match wins, no alias dereference, no<<expansion —which is deliberately not what
nodeviewdoes, and the corpus cannot see the difference: its only<<fixture is refused before lowering. RewritingRawChildNodeto answer through the view leavesevery compiler test green.
TestRawChildNode_IsNotTheMergeAwareViewnow pins the three ways the twotrees diverge, and all three of its cases redden under that rewrite.
Two adjacent findings, not touched here:
schema.declaresResourceIDAboveandschema.rawMappingKeyseach construct a freshnodeview.Viewper call, so neither shares thememoization with anything. The first is reached only from the
$dynamicRefpath and neither is hoton any corpus spec.
declaresResourceIDAbovereads each node at most twice through a view it thendiscards, which is exactly the shape the width gate exists for — below 16 pairs it now scans, as it
did before this change.
One divergence is documented rather than changed:
ChildByTokendoes not dereference its receiver,while
MappingPairsandPureRefTargetbeside it both do. Every caller arrives through a walk thatdereferences at each hop, so an alias never reaches it — but the sibling promising the opposite is one
line away, so it is now stated at
ChildByToken.On #404, which records that the pairs memo is depth-sensitive and asks for that to be settled before
this lookup work proceeds: the index is a projection of that memo and holds no state of its own, so
it can be neither more nor less correct than the entry it is built from, and it adds no second way
for a view to answer two things. It inherits #404 rather than widening it. Not charging the index to
the pair budget also keeps the memo at full capacity, where charging it would have made #404's
mechanism reachable at half the document size. This is stated at
keyIndexas well as here.Test plan
gofmt,go vet,golangci-lint(0 issues),go build, and./scripts/check-coverage.shat exactly 100%.Beyond that, both binaries compiled all 176 sources under
testdata/, capturing the emitteddocument, stderr diagnostics and exit code for each;
diff -rover the two output trees is empty.cmd/morphic-harness testdata— the oracles, including the two-order comparison — producesbyte-identical output before and after.
charging the index to the pair budget each redden
TestKeyIndex_DeclinesWhatThePairsMemoDidNotKeepandTestKeyIndex_IsBoundedByThePairsMemoRatherThanChargedrespectively; disabling the index outrightreddens those two plus
TestChildByToken_IndexAgreesWithTheScanItReplaces; and dropping the indexread from
PureRefTargetreddensTestPureRefTarget_ReadsTheIndexWhereTheWalkBuiltOne.green with the index disabled — it compared the fallback scan against itself — and every fixture
in it was narrower than the width gate now admits, so it asserts
v.keysis populated and usesfixtures wide enough to reach it.
PureRefTarget's test asserted only the answer, which bothreadings give, so it now plants a divergence between index and pairs and asserts which side won.
RawChildNodethrough the view reddens two of the three cases ofTestRawChildNode_IsNotTheMergeAwareView, not three. The repeated-key case cannot redden: fix(compilers/openapi): keep keys the source model does not name #356made the raw read take the last pair as the parser does, so the two readers agree there now. That
case is kept, asserting the agreement, and the subtest and its name say so.
memoize's budget arithmetic. At the depth thisruns at the two select the same mappings, so this is a coupling choice, not a behaviour one: an
index is a projection of a memo entry, so "is there an entry" is the question it has to ask, and a
restatement of
memoize's test answers alike today and stops tracking it the day that test moves.No test claims to distinguish them, because none can.
Closes #338