perf: speed up linting by memoizing ref target validation and reducing allocations

Merged
TR
14h ago
+403-29

Why

A recent public linter benchmark (write-up, repo) has Speakeasy CLI noticeably slower than Redocly on Stripe's spec. Profiling this library on the same inputs showed the lint rules themselves take about 20 ms. Nearly all of the time goes to parsing/validation and index building, and most of that is allocation and GC rather than real work.

Two specific hot spots:

  • BuildIndex re-ran full JSON Schema meta-validation of a $ref target for every reference to it. Resolution already returns the same cached target object each time, so a heavily referenced component was validated hundreds of times with identical results.
  • reflect.Type.Implements was being recomputed for every node during unmarshalling and population.

What changed

  • openapi.Index memoizes Validate results per resolved $ref target. Every reference still reports the target's errors, as fresh copies with their own document location, so output is unchanged.
  • reflect.Type.Implements lookups in the marshaller are cached per type pair (interfaces.TypeImplements).
  • oas3.walkSchema skips building child locations for nil single-child fields (items, not, if, etc.), which were allocated and copied for every schema even when absent.

No references, rules or validation are skipped.

Review notes

  • Equivalence was the main constraint. validateResolvedSchema / cloneValidationErrors in openapi/index.go is the part worth the closest look. It only caches when every error is a *validation.Error (so it can be copied faithfully), otherwise it just validates again.
  • An earlier revision also had an EitherValue fast path that populated the left value from a pointer to its core. It changed the source type handed to context-aware populators and was only worth about 2-3% CPU, so it was dropped after review.
  • Out of scope, but noticed while profiling: JSONSchema[T].Validate ignores its options, so $ref targets are always meta-validated against the 3.1 dialect, even in 3.0 documents. I preserved that behaviour here.

Testing

Benchmark inputs are the pinned Stripe specs from the benchmark repo. These are local runs on an M-series Mac under background load, interleaved base/candidate, 11 runs each, reporting the median.

openapi spec lintBeforeAfterCPU (user+sys)
Stripe, single file (6.1 MB)1.97 s1.37 s3.73 to 2.76 s
Stripe, split (1,874 files)2.19 s1.70 s3.83 to 2.98 s

Absolute times vary with machine load (an earlier, quieter run measured 1.50 s to 0.95 s and 1.68 s to 1.16 s), so the ratio is the useful number. On the earlier revision, library phases on Stripe (single file) went from index 590 ms to 285 ms and unmarshal/validation 1.01 s to 0.83 s, and the Speakeasy CLI built from source went 2.00 s to 1.56 s (single) and 2.28 s to 1.75 s (split).

  • Findings are byte-identical: spec lint -f json and text output match main on all 13 benchmark inputs (Stripe, Stripe split, DigitalOcean, GitHub, Cloudflare, AWS EC2, Azure, plus their bundled variants), as do the exit codes.
  • Tests: TestBuildIndex_RepeatedSchemaRefValidation_Success pins per-reference error reporting (passes on both main and this branch), TestCloneValidationErrors_* cover the copy and non-copyable paths, and TestTypeImplements_MatchesReflect_Success checks uncached and cached lookups against reflect using types unique to that test.
  • mise ci passed locally on the latest commit (fmt-check, mod-check, golangci-lint, nilaway, examples-check, test, test-cli, build) with Go 1.26.0, matching go.work.

Summary by cubic

Speeds up linting by memoizing $ref target validation results, caching reflect.Type.Implements lookups, and reducing allocations during schema walking. Lint findings and exit codes are unchanged.

Performance

  • openapi spec lint on the Stripe single file drops from 1.50 s to 0.95 s; the 1,874-file split spec from 1.68 s to 1.16 s.
  • Index build on the single file drops from 590 ms to 285 ms.

Review notes

  • validateResolvedSchema replays cached validation errors as fresh copies per reference; if any error is not a *validation.Error, it re-validates instead of caching.
  • reflect.Type.Implements results are cached per type pair in interfaces.TypeImplements.
  • walkSchema skips building child locations for nil single-child fields (items, not, if, and similar).
  • New tests cover the embedded sequenced map population helpers (getSequencedMapInterface, getSourceForPopulation).