perf: second optimization round (render -78%, JSON -89%, geomean -57%) + low NodeMap API break - #641
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
daveshanley
left a comment
There was a problem hiding this comment.
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.
|
Ready for re-review at 8ef6d09. Your review summary asked for four things:
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
left a comment
There was a problem hiding this comment.
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.
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>
5aac5c4 to
8ef6d09
Compare
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.
perf: cut render, JSON and build costs across the pipelinemakes no API change and no change to output.perf(datamodel/low)!: index node lines lazily, drop NodeReference.Contextis a breaking change to the low-levelNodeMap/NodeReferenceAPI.The move to
github.com/pb33f/go-yamlis a separate PR stacked on this one (#642).Since the first review
mainwith 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 currentmain; see "Against currentmain" below.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.ClearAllCaches' comment now lists the encode cache.Results
pipeline_bench_test.goadds 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:Against current
main. I reran both commits on this head (rebased onto currentmain) against currentmain: interleaved, n=4, load average around 20. Allocations don't depend on load; times are noisier.Commit 2, measured against commit 1:
Commit 1: what changed
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 memoizesyaml.Node.Encodeby node content.ClearAllCaches.filepath.Absandos.Getwdwere 49% of validation-render CPU.internal/jsonnode. A JSON parser that builds exactly theyaml.Nodetree yaml v4 builds.\/escapes, surrogate pairs, or a key and its colon on different lines. The YAML parser then handles that input as before.json.YAMLNodeToJSON. It writes compact JSON directly and indents withjson.Indent. The old converter is kept as the fallback.SetField. It switches on field types computed once. Itscaseexpressions were allocating anorderedmapon every call.SchemaProxy. It readsMergeReferencedPropertiesfrom the rolodex config. It used to build a wholeDocumentConfigurationfor everySchema()call.Encodedesolves[]*yaml.Nodevalues in place, so rendering stripped the!!strtag from enum nodes.encodeSafeValuenow clones slices as well.Commit 2: breaking changes
NodeMap.Nodesis now*low.NodeLines, not*sync.Map.map[int]anyis built the first time it is read, and most models are never read.Store(line int, value any)andLoad(line int)take int line numbers.Range(func(line int, value any) bool)visits lines in ascending order, and its callback may write.ExtractNodesandExtractNodesRecursivereturn*low.NodeLines.ExtractExtensionNodesandMergeRecursiveNodesIfLineAbsenttake*low.NodeLines.NodeReference.Contextis removed.NodeReferencecarried acontext.Context, but the only reader wasPathItem.Build.PathItem.Buildnow 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.mdhas the full migration: signature tables, thesync.MapmethodsNodeLinesdoesn't provide, andpathItem.Get.Value.GetContext()as the replacement forpathItem.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
mainand against this head (rebased onto currentmain):go build ./...None of them use
Nodesas async.Map, the node extraction helpers, orNodeReference.Context.Two libopenapi-validator tests fail on both
mainand this PR:TestCompileSchemaForValidation_NestedResourceRenderFailureTestSingleSchemaCompilePreferred_ResolvedExternalReferenceUsesSingleSchemaCompilerThey 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
mainand with this branch applied, and found 0 differences. The outputs were:Render,RenderInlineandRenderJSONTwo 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, andgo test -racepasses 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
additionalOperationsis covered by what-changed tests. The encode cache's bounds andNodeLineswrite/read ordering and concurrency have their own tests.🤖 Generated with Claude Code