perf: speed up linting by memoizing ref target validation and reducing allocations
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:
BuildIndexre-ran full JSON Schema meta-validation of a$reftarget 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.Implementswas being recomputed for every node during unmarshalling and population.
What changed
openapi.IndexmemoizesValidateresults per resolved$reftarget. Every reference still reports the target's errors, as fresh copies with their own document location, so output is unchanged.reflect.Type.Implementslookups in the marshaller are cached per type pair (interfaces.TypeImplements).oas3.walkSchemaskips 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/cloneValidationErrorsinopenapi/index.gois 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
EitherValuefast 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].Validateignores its options, so$reftargets 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 lint | Before | After | CPU (user+sys) |
|---|---|---|---|
| Stripe, single file (6.1 MB) | 1.97 s | 1.37 s | 3.73 to 2.76 s |
| Stripe, split (1,874 files) | 2.19 s | 1.70 s | 3.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 jsonand text output matchmainon 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_Successpins per-reference error reporting (passes on bothmainand this branch),TestCloneValidationErrors_*cover the copy and non-copyable paths, andTestTypeImplements_MatchesReflect_Successchecks uncached and cached lookups against reflect using types unique to that test. mise cipassed locally on the latest commit (fmt-check, mod-check, golangci-lint, nilaway, examples-check, test, test-cli, build) with Go 1.26.0, matchinggo.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 linton 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
validateResolvedSchemareplays cached validation errors as fresh copies per reference; if any error is not a*validation.Error, it re-validates instead of caching.reflect.Type.Implementsresults are cached per type pair ininterfaces.TypeImplements.walkSchemaskips building child locations for nil single-child fields (items,not,if, and similar).- New tests cover the embedded sequenced map population helpers (
getSequencedMapInterface,getSourceForPopulation).