Skip to content

perf: second optimization round (render -78%, JSON -89%, geomean -57%) + low NodeMap API break - #641

Merged
daveshanley merged 3 commits into
mainfrom
claude/lipopen-performance-optimization-9ad48f
Sep 28, 2026
Merged

daveshanley merged 3 commits into
mainfrom
claude/lipopen-performance-optimization-9ad48f

Conversation

@daveshanley

@daveshanley daveshanley commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

This is the second round of profile-driven performance work: render, JSON and build paths, plus two small API breaks. It has two commits so the breaking change can be reviewed on its own.

  1. perf: cut render, JSON and build costs across the pipeline makes no API change and no change to output.
  2. perf(datamodel/low)!: index node lines lazily, drop NodeReference.Context is a breaking change to the low-level NodeMap / NodeReference API.

The move to github.com/pb33f/go-yaml is a separate PR stacked on this one (#642).

Since the first review

  • Rebased onto current main with no conflicts, so the history is linear. go test -race ./... passes on the head, and each commit builds on its own. The gains hold against current main; see "Against current main" below.
  • Migration guide: MIGRATING.md (8ef6d09), linked from the README. It covers both API changes, with signature tables and complete programs. I compiled and ran each program and compared its output byte for byte with the doc.
  • Downstream projects compile and pass their tests. See "Downstream compatibility" below.
  • ClearAllCaches' comment now lists the encode cache.

Results

pipeline_bench_test.go adds end-to-end benchmarks (go test -run '^$' -bench 'BenchmarkPipeline_' -benchmem .). The runs were interleaved A/B on an Apple M4 Max, n=10, before commit 1 vs after it:

benchmark time B/op allocs/op
Render Stripe -78% (744 → 164 ms) -42% -80%
Render DocuSign (JSON) -89% (507 → 56 ms) -82% -91%
Inline-render every Stripe schema -80% (1.33 s → 266 ms) -52% -76%
Build DocuSign (JSON) -58% -81% -55%
Build K8s (Swagger JSON) -65% -77% -68%
Build Xsoar (Swagger JSON) -58% -83% -56%
Build Stripe (YAML) ~ -7% -9%
Build Asana -6% -15% -20%
what-changed Stripe -10% -11% -20%
Bundle Stripe -15% -3% -21%
geomean -57% -56% -60%

Against current main. I reran both commits on this head (rebased onto current main) against current main: interleaved, n=4, load average around 20. Allocations don't depend on load; times are noisier.

benchmark time B/op allocs/op
Render Stripe -77% -39% -78%
Render DocuSign (JSON) -87% -79% -90%
Inline-render every Stripe schema -80% -51% -75%
Build DocuSign / K8s / Xsoar (JSON) -54% / -65% / -69% -81% / -79% / -83% -62% / -68% / -56%
Build Stripe / Asana ~ / ~ -9% / -21% -17% / -35%
what-changed Stripe -10% -25% -37%
Bundle Stripe ~ (p=0.057) -3% -23%
geomean -58% -57% -61%

Commit 2, measured against commit 1:

  • what-changed Stripe: -16% bytes and -22% allocs.
  • Document builds: -2% to -7% bytes.
  • Time is within noise.

Commit 1: what changed

  • NodeBuilder. It caches field metadata per (high, low) type pair. It used to reflect over every field of every model on every render.
  • orderedmap ToYamlNode. It finds keys and values through an index built once per map. Before, it searched the map linearly for each entry, which was quadratic.
  • datamodel/high/encode_cache.go. This memoizes yaml.Node.Encode by node content.
    • Bounded: 4096 entries, 64 nodes, 2 KB key.
    • Cleared by ClearAllCaches.
  • Inline schema rendering. The circular-reference check hoists its invariants out of the loop. filepath.Abs and os.Getwd were 49% of validation-render CPU.
  • internal/jsonnode. A JSON parser that builds exactly the yaml.Node tree yaml v4 builds.
    • It declines any input it can't reproduce exactly, such as \/ escapes, surrogate pairs, or a key and its colon on different lines. The YAML parser then handles that input as before.
    • It is used for JSON specs, rolodex files and overlays.
    • Differential and fuzz tests compare it against yaml v4.
  • json.YAMLNodeToJSON. It writes compact JSON directly and indents with json.Indent. The old converter is kept as the fallback.
  • SetField. It switches on field types computed once. Its case expressions were allocating an orderedmap on every call.
  • Low SchemaProxy. It reads MergeReferencedProperties from the rolodex config. It used to build a whole DocumentConfiguration for every Schema() call.
  • Bug fix: rendering no longer mutates the model. Encode desolves []*yaml.Node values in place, so rendering stripped the !!str tag from enum nodes. encodeSafeValue now clones slices as well.

Commit 2: breaking changes

  • NodeMap.Nodes is now *low.NodeLines, not *sync.Map.
    • Writes go to a log under a mutex. The map[int]any is built the first time it is read, and most models are never read.
    • Store(line int, value any) and Load(line int) take int line numbers.
    • Range(func(line int, value any) bool) visits lines in ascending order, and its callback may write.
    • ExtractNodes and ExtractNodesRecursive return *low.NodeLines.
    • ExtractExtensionNodes and MergeRecursiveNodesIfLineAbsent take *low.NodeLines.
  • NodeReference.Context is removed.
    • Every NodeReference carried a context.Context, but the only reader was PathItem.Build.
    • PathItem.Build now keeps each operation's context in a map keyed by *Operation. Additional operations are built by a second translate pass, which rules out a parallel slice.

MIGRATING.md has the full migration: signature tables, the sync.Map methods NodeLines doesn't provide, and pathItem.Get.Value.GetContext() as the replacement for pathItem.Get.Context.

This goes in a breaking release, not a patch. libopenapi is still v0.x, so it can ship as the next minor.

Downstream compatibility

I built each project at its default branch against current main and against this head (rebased onto current main):

  • go build ./...
  • type-checked every package's tests
  • ran the full test suite
project compiles tests passing (main / this PR)
libopenapi-validator yes 1945 / 1945
doctor yes 1655 / 1655
vacuum yes 2403 / 2403
wiretap yes 216 / 216
openapi-changes yes 443 / 443
printing-press yes 58 / 58

None of them use Nodes as a sync.Map, the node extraction helpers, or NodeReference.Context.

Two libopenapi-validator tests fail on both main and this PR:

  • TestCompileSchemaForValidation_NestedResourceRenderFailure
  • TestSingleSchemaCompilePreferred_ResolvedExternalReferenceUsesSingleSchemaCompiler

They pass with libopenapi v0.38.6 and fail with v0.40.1, so they aren't from this PR.

How this was verified

  • Byte-identical output. A golden harness hashed every public output for all 72 spec fixtures, both at main and with this branch applied, and found 0 differences. The outputs were:

    • Render, RenderInline and RenderJSON
    • Per-schema inline renders
    • Bundles and composed bundles
    • what-changed reports

    Two fixtures (xsoar.json and petstorev2-badref.json) report whichever "schema build failed" goroutine loses a race. That nondeterminism exists before this change too, so the harness normalizes it.

  • Tests. go test ./... passes after each commit, and go test -race passes for the path item and what-changed packages.

  • Coverage. New and changed code is at 100%.

  • Unit tests for the parts the golden corpus can't reach. Inline additionalOperations is covered by what-changed tests. The encode cache's bounds and NodeLines write/read ordering and concurrency have their own tests.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (5ddd4e4) to head (8ef6d09).

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #641    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          298       300     +2     
  Lines        37677     38450   +773     
==========================================
+ Hits         37677     38450   +773     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@daveshanley daveshanley left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 058a9ef for API and app impact. The two inline findings are intentional compile-time breaks in the exported low-level API (NodeMap.Nodes/node extraction signatures and NodeReference.Context). I found no confirmed output regression in the render, JSON, bundling, or diff changes.

Before release: document both migrations with a short example; compile the known downstream apps against this head (especially vacuum, wiretap, openapi-changes, and printing press); then validate the PR with current main and rerun exact-head checks. GitHub’s Linux and Windows build jobs pass. The changed packages I could run passed locally; broader local tests needing loopback sockets were blocked by this sandbox. Treat this as a breaking library release unless compatibility is retained.

Comment thread datamodel/low/node_map.go
Comment thread datamodel/low/reference.go
@daveshanley

daveshanley commented Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Ready for re-review at 8ef6d09. Your review summary asked for four things:

  • Document both migrations with a short example. Done in MIGRATING.md, linked from the README. It has a signature table and a complete program for each change. I ran both programs and compared their output byte for byte with the doc.
  • Compile the known downstream apps against this head. libopenapi-validator, doctor, vacuum, wiretap, openapi-changes and printing-press all build. Their tests type-check, and their full suites pass with the same results as against main. The table is in the PR description.
  • Validate with current main. The branch is rebased onto current main with no conflicts, and go test -race ./... passes. An interleaved A/B against current main shows the same gains: geomean -58% time, -57% bytes, -61% allocs.
  • Rerun exact-head checks. All CI checks pass on 8ef6d09. codecov reports 100% for both the patch and the project.

The two inline threads have replies and are resolved. #642 (the go-yaml move, stacked on this PR) is updated to this head.

🤖 Generated with Claude Code

@daveshanley daveshanley left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed head 5aac5c4. The two intentional low-level API breaks are now documented in MIGRATING.md and linked from the README; I compiled and ran both examples and matched their stated output. Current main is merged. Exact-head Linux, Windows, CodeQL and Codecov checks pass; the only skipped check is [code]smith. Focused local tests and race checks pass. I also compile-checked libasyncapi against this head; it passes as another direct low-level consumer beyond the six projects in the PR table. No new PR blocker found. Ship this as a breaking minor release with the migration notes.

daveshanley and others added 3 commits September 28, 2026 10:55
Profile-driven fixes for time and allocation, with no change to output.
A golden harness hashing every public output (Render, RenderInline,
RenderJSON, per-schema renders, bundles, composed bundles, what-changed
reports) across all 72 spec fixtures is byte-identical before and after.

- NodeBuilder caches per-type field metadata instead of reflecting over
  every field of every model on every render.
- orderedmap ToYamlNode finds keys and values through an index built once
  per map, replacing a linear search per entry.
- datamodel/high/encode_cache.go memoizes yaml Node.Encode by content,
  bounded to 4096 entries of at most 64 nodes / 2 KB, and is cleared by
  ClearAllCaches.
- Inline schema rendering hoists the circular reference invariants
  (filepath.Abs and os.Getwd were 49% of validation-render CPU).
- internal/jsonnode parses JSON straight into the exact yaml.Node tree
  yaml v4 builds, and declines anything it cannot reproduce, so the YAML
  parser is skipped for JSON specs, rolodex files and overlays.
- json.YAMLNodeToJSON writes JSON directly and indents with json.Indent;
  the old converter remains the fallback.
- SetField compares against field types computed once; its case
  expressions were allocating an orderedmap per call.
- The low SchemaProxy reads MergeReferencedProperties from the rolodex
  config instead of building a DocumentConfiguration per Schema().

Rendering used to mutate model enum nodes (Tag "!!str" became "") because
Encode desolves []*yaml.Node values in place; encodeSafeValue now clones
slices too.

pipeline_bench_test.go adds end-to-end benchmarks with a retained-B/op
metric. Interleaved A/B, geomean: -57% time, -56% bytes, -60% allocs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…text

Every low model kept its line -> node map in a sync.Map, so each node it
recorded paid for a Load and a Store, a sync.Map entry and a boxed int
key. Most models are built and never asked for their nodes. NodeLines records writes in a plain
log under a mutex and builds the map[int]any the first time it is read.

NodeReference.Context held a context.Context on every NodeReference built
anywhere. Its only reader was PathItem.Build, which now keeps each
operation's context in a map keyed by the operation for the one build that
needs it.

what-changed on the Stripe spec: -16% bytes, -22% allocs; document builds
-2% to -7% bytes. Time is within noise. Outputs across the 72 golden
fixtures are unchanged.

BREAKING CHANGE: NodeMap.Nodes is a *low.NodeLines instead of a
*sync.Map. Its Store, Load and Range take int line numbers
(Range(func(line int, value any) bool) visits lines in ascending order).
ExtractNodes and ExtractNodesRecursive return *low.NodeLines;
ExtractExtensionNodes and MergeRecursiveNodesIfLineAbsent take one.
NodeReference.Context is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MIGRATING.md covers the two breaking changes in this branch, each with a
table of old and new signatures and a complete program whose output is
shown verbatim:

- NodeMap.Nodes is a *low.NodeLines: Range, Load and Store take int
  lines, the extraction helpers take and return *low.NodeLines, and
  sync.Map's other methods are not provided.
- NodeReference.Context is removed: libopenapi only ever set it on the
  operations of a PathItem, where Operation.GetContext() returns the same
  context.

README links to it. ClearAllCaches' comment now lists the encode cache
among the content-keyed caches it empties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@daveshanley
daveshanley force-pushed the claude/lipopen-performance-optimization-9ad48f branch from 5aac5c4 to 8ef6d09 Compare September 28, 2026 14:56
@daveshanley
daveshanley added this pull request to stack #643 September 28, 2026 15:03
@daveshanley
daveshanley merged commit 900f64e into main Sep 28, 2026
8 checks passed
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