perf(compilers/openapi): index the mappings a pointer descends - #379
Open
OmarAlJarrah wants to merge 1 commit into
Open
perf(compilers/openapi): index the mappings a pointer descends#379OmarAlJarrah wants to merge 1 commit into
OmarAlJarrah wants to merge 1 commit into
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.
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.
maxCachedPairsbudget and gated on the same testmemoizeapplies. One bound still covers everything a view retains, and sincecachedPairsonlygrows, a mapping
memoizeturned away fails the identical test here — so the index can never holdan expansion the pairs do not.
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).In wall time,
BenchmarkPointerPath_IntoAWideMapping— added here to hold the shape rather than thenumber — goes from 897 µs to 243 µs per pass over a 1,024-entry mapping, and its per-component cost
stops growing with width (242 → 315 → 876 ns before; 277 → 232 → 237 ns after). The whole
pre-lowering scan phase falls 11.4% at 3,200 components, 6.4% at 1,600 and 5.1% at 400. On petstore
it is unchanged (145.0 → 145.9 µs, +8 allocations), so nothing regresses at ordinary sizes.
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.
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.
TestKeyIndex_PastTheBudgetTheScanStillAnswers, not charging the index reddensTestKeyIndex_IsChargedToThePairBudget, letting a repeated key survive an expansion reddensTestChildByToken_IndexAgreesWithTheScanItReplaces, and routingRawChildNodethrough the viewreddens all three cases of
TestRawChildNode_IsNotTheMergeAwareViewwhile the rest of thecompiler suite stays green.
memoizedeclines onexactly the same budget test, so it could not fire on its own and no planted defect reddened
anything through it.
Closes #338