Skip to content

perf(compilers/openapi): index the mappings a pointer descends - #379

Merged
OmarAlJarrah merged 6 commits into
mainfrom
perf/openapi-index-lookups
Aug 13, 2026
Merged

perf(compilers/openapi): index the mappings a pointer descends#379
OmarAlJarrah merged 6 commits into
mainfrom
perf/openapi-index-lookups

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

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 grows with the number of components while the number of
walks 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.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 instead of a scan. Three properties keep that from being a
behaviour change:

  • The index is built from MappingPairs itself, so it is not 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 — asserted key by key, and reddened by letting a repeated key survive an expansion.
  • It is gated on the pairs memo's presence, so it can never hold an expansion the pairs do not, and
    it is bounded by that memo rather than charged against it: one entry per pair of a mapping the memo
    kept means maxCachedPairs already covers it. Charging it too would halve a memo that exists to
    stop 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.
  • The view is still a value created per walk. Nothing is memoized across a Compile.

Pairs read while resolving pointers, over specs whose every component is referenced once:

components before after
100 5,550 105
400 82,200 405
800 324,400 805
1,600 1,288,800 1,605
3,200 5,137,600 3,205

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.traverse
follows every PointerPath with a PureRefTarget call over the same nodes, and that scanned every
pair 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 PureRefTargetOf during a compile, same specs:

components index in ChildByToken only index in both
400 330,807 10,807
800 1,301,607 21,607
1,600 5,163,207 43,207

In wall time, BenchmarkPointerPath_IntoAWideMapping — added here to hold the shape rather than the
number — 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, and
512 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 walk
scans. 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.RawChildNode and RawPropertyNode.
That is not done, because measuring it first showed it costs more than it saves.

RawChildNode is 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/ counts
9,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.Ctx and widening the
signatures beneath it into annotation — is not introduced. Nothing in this change reaches
lowering, schema, operation or auth, and no call site is left half-converted: the keyword
readers are exactly as they were.

What that half of the issue did surface is that the constraint protecting it was untested.
RawChildNode reads the raw tree — first match wins, no alias dereference, no << expansion —
which is deliberately not what nodeview does, and the corpus cannot see the difference: its only
<< fixture is refused before lowering. Rewriting RawChildNode to answer through the view leaves
every compiler test green. TestRawChildNode_IsNotTheMergeAwareView now pins the three ways the two
trees diverge, and all three of its cases redden under that rewrite.

Two adjacent findings, not touched here: schema.declaresResourceIDAbove and
schema.rawMappingKeys each construct a fresh nodeview.View per call, so neither shares the
memoization with anything. The first is reached only from the $dynamicRef path and neither is hot
on any corpus spec. declaresResourceIDAbove reads each node at most twice through a view it then
discards, 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: ChildByToken does not dereference its receiver,
while MappingPairs and PureRefTarget beside it both do. Every caller arrives through a walk that
dereferences 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 keyIndex as well as here.

Test plan

  • Full gate passes in order: gofmt, go vet, golangci-lint (0 issues), go build, and
    ./scripts/check-coverage.sh at exactly 100%.
  • Output does not move. No golden or conformance snapshot changed and none was regenerated.
    Beyond that, both binaries compiled all 176 sources under testdata/, capturing the emitted
    document, stderr diagnostics and exit code for each; diff -r over the two output trees is empty.
    cmd/morphic-harness testdata — the oracles, including the two-order comparison — produces
    byte-identical output before and after.
  • Each new test was checked to bite by planting the defect it names: removing the width gate and
    charging the index to the pair budget each redden
    TestKeyIndex_DeclinesWhatThePairsMemoDidNotKeep and
    TestKeyIndex_IsBoundedByThePairsMemoRatherThanCharged respectively; disabling the index outright
    reddens those two plus TestChildByToken_IndexAgreesWithTheScanItReplaces; and dropping the index
    read from PureRefTarget reddens TestPureRefTarget_ReadsTheIndexWhereTheWalkBuiltOne.
  • Two of those guards were rewritten because they did not bite as written. The agreement table was
    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.keys is populated and uses
    fixtures wide enough to reach it. PureRefTarget's test asserted only the answer, which both
    readings give, so it now plants a divergence between index and pairs and asserts which side won.
  • Routing RawChildNode through the view reddens two of the three cases of
    TestRawChildNode_IsNotTheMergeAwareView, not three. The repeated-key case cannot redden: fix(compilers/openapi): keep keys the source model does not name #356
    made 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.
  • The gate is the pairs memo rather than a copy of memoize's budget arithmetic. At the depth this
    runs 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

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.
Comment thread compilers/openapi/internal/nodeview/nodeview.go
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go
Comment thread compilers/openapi/internal/nodeview/nodeview.go
Comment thread compilers/openapi/internal/nodeview/nodeview.go
Comment thread compilers/openapi/internal/nodeview/pointerpath_bench_test.go
Comment thread compilers/openapi/internal/annotation/readers_internal_test.go
Comment thread compilers/openapi/internal/nodeview/nodeview_internal_test.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go
Comment thread compilers/openapi/internal/annotation/readers_internal_test.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview_internal_test.go Outdated
Comment thread compilers/openapi/internal/nodeview/nodeview.go
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.
@OmarAlJarrah
OmarAlJarrah merged commit b595c2e into main Aug 13, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the perf/openapi-index-lookups branch August 13, 2026 15:23
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.

openapi: keyword and pointer lookups still rescan the nodes the index already walked

1 participant