perf: optimize parsing V3.1 documents#2748
Conversation
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
Optimizes OpenAPI v3.1/v3.2 schema parsing by avoiding JSON pointer location construction unless it’s needed for $ref handling, and extends the benchmark suite to include “GHES Next” OpenAPI descriptions to measure performance impact on larger v3.1 documents.
Changes:
- Lazily compute
ParsingContext.GetLocation()only when a schema$refpointer is present (v3.1/v3.2 schema deserializers). - Optimize
ParsingContext.GetLocation()implementation to reduce allocations (StringBuilder-based pointer construction). - Add GHES Next YAML/JSON benchmarks and update committed BenchmarkDotNet artifact reports.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs | Avoids computing location unless $ref is present. |
| src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs | Avoids computing location unless $ref is present. |
| src/Microsoft.OpenApi/Reader/ParsingContext.cs | Optimizes JSON pointer location string construction. |
| performance/benchmark/Descriptions.cs | Adds GHES Next YAML/JSON benchmarks and setup downloads. |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.EmptyModels-report.html | Updated benchmark output artifact. |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.EmptyModels-report.csv | Updated benchmark output artifact. |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.EmptyModels-report-github.md | Updated benchmark output artifact. |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.json | Updated benchmark output artifact (includes GHES Next results). |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html | Updated benchmark output artifact (includes GHES Next results). |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv | Updated benchmark output artifact (includes GHES Next results). |
| performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md | Updated benchmark output artifact (includes GHES Next results). |
| var segment = segments[i]; | ||
|
|
||
| // Escape ~ and / per RFC 6901 | ||
| if (segment.Contains("~") || segment.Contains("/")) |
There was a problem hiding this comment.
We could improve this further by using the char overload and passing a string comparison when the target is net standard 2.1 or greater, etc... (using conditional compilation).
(see this example )
There was a problem hiding this comment.
@baywet I have now tried this locally and it didn't make much of a difference. I also found a more performant solution instead see: #2748 (comment)
But let me know if you'd rather like the StringBuilder implementation for better readability with the char overload added, then I'll update the PR 😄
| if (i > 0) | ||
| { | ||
| sb.Append('/'); | ||
| } |
There was a problem hiding this comment.
I don't believe the current implementation terminates with a slash.
There was a problem hiding this comment.
Hi @baywet, maybe I'm misunderstanding, but the code shouldn't be terminating with a slash. I've added some tests to verify that ParsingContextTests.cs let me know if that is overkill 😄
| return "#/"; | ||
| } | ||
|
|
||
| var sb = new StringBuilder("#/"); |
There was a problem hiding this comment.
nit: we technically could use a span instead since we kind of know the size in advance. Would that yield any true performance benefits?
Also this would probably need to be conditional because of the target frameworks.
There was a problem hiding this comment.
Hi @baywet, I've tried creating a more performant implementation now it should also be much better for allocation. Let me know what version you prefer 😄
For reference I did some local bench marking of the 3 ways of doing the GetLocation():
ArrayPool (Current implementation)
StringBuilder (Initial implementation with the suggested char overload)
LinqJoin (Original implementation)
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2)
AMD Ryzen 7 7800X3D 4.20GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.418
[Host] : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4
ShortRun : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4
Job=ShortRun IterationCount=3 LaunchCount=1
WarmupCount=3
| Method | Scenario | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|-------------- |----------------- |------------:|------------:|----------:|-------:|--------:|-------:|----------:|------------:|
| ArrayPool | Empty | 0.2657 ns | 1.2196 ns | 0.0669 ns | 1.05 | 0.34 | - | - | NA |
| StringBuilder | Empty | 0.1835 ns | 1.8641 ns | 0.1022 ns | 0.72 | 0.40 | - | - | NA |
| LinqJoin | Empty | 49.7601 ns | 0.1247 ns | 0.0068 ns | 196.25 | 46.62 | 0.0020 | 104 B | NA |
| | | | | | | | | | |
| ArrayPool | Long | 107.2965 ns | 35.4506 ns | 1.9432 ns | 1.00 | 0.02 | 0.0055 | 280 B | 1.00 |
| StringBuilder | Long | 148.4526 ns | 71.0306 ns | 3.8934 ns | 1.38 | 0.04 | 0.0162 | 824 B | 2.94 |
| LinqJoin | Long | 374.3515 ns | 88.2121 ns | 4.8352 ns | 3.49 | 0.07 | 0.0219 | 1120 B | 4.00 |
| | | | | | | | | | |
| ArrayPool | Short | 41.2347 ns | 10.4656 ns | 0.5737 ns | 1.00 | 0.02 | 0.0020 | 104 B | 1.00 |
| StringBuilder | Short | 40.0271 ns | 9.2822 ns | 0.5088 ns | 0.97 | 0.02 | 0.0041 | 208 B | 2.00 |
| LinqJoin | Short | 141.3804 ns | 33.8513 ns | 1.8555 ns | 3.43 | 0.06 | 0.0081 | 416 B | 4.00 |
| | | | | | | | | | |
| ArrayPool | WithSpecialChars | 69.0026 ns | 24.3098 ns | 1.3325 ns | 1.00 | 0.02 | 0.0035 | 176 B | 1.00 |
| StringBuilder | WithSpecialChars | 132.8347 ns | 31.1054 ns | 1.7050 ns | 1.93 | 0.04 | 0.0119 | 608 B | 3.45 |
| LinqJoin | WithSpecialChars | 244.7669 ns | 105.0945 ns | 5.7606 ns | 3.55 | 0.09 | 0.0157 | 800 B | 4.55 |For reference the test data:
"Empty" => new Stack<string>(),
"Short" => BuildStack(["paths", "pets", "get"]),
"Long" => BuildStack(["components", "schemas", "Pet", "properties", "name", "type", "string", "minLength", "1", "maxLength"]),
"WithSpecialChars" => BuildStack(["paths", "/pets/{id}", "get", "responses", "200~ok"]),
Also the results of the descriptions benchmark now looks like:
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2)
AMD Ryzen 7 7800X3D 4.20GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.418
[Host] : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4
ShortRun : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4
Job=ShortRun IterationCount=3 LaunchCount=1
WarmupCount=3
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|------------- |-------------:|--------------:|-------------:|-----------:|----------:|----------:|-------------:|
| PetStoreYaml | 261.1 μs | 105.89 μs | 5.80 μs | 5.8594 | - | - | 361.38 KB |
| PetStoreJson | 101.9 μs | 48.20 μs | 2.64 μs | 4.3945 | 0.9766 | - | 223.52 KB |
| GHESYaml | 602,932.7 μs | 170,410.86 μs | 9,340.79 μs | 9000.0000 | 8000.0000 | 2000.0000 | 345336.55 KB |
| GHESJson | 254,976.7 μs | 111,875.43 μs | 6,132.27 μs | 4000.0000 | 3000.0000 | 1000.0000 | 206858.06 KB |
| GHESNextYaml | 729,602.0 μs | 357,122.29 μs | 19,575.08 μs | 13000.0000 | 9000.0000 | 2000.0000 | 541566.37 KB |
| GHESNextJson | 378,208.4 μs | 109,458.45 μs | 5,999.79 μs | 8000.0000 | 5000.0000 | 1000.0000 | 406762.41 KB |
Pull Request
Description
Optimizes
ParsingContext.GetLocation()and its usage inOpenApiSchemaDeserializeronly loading the location when needed.Also adds GHES Next descriptions to performance tests to measure impact in V3.1 documents.
Measured baseline before change:
Measured after change:
Type of Change
Related Issue(s)
Changes Made
ParsingContext.GetLocation()nodeLocationwhen needed inOpenApiSchemaDeserializerTesting
Checklist
Versions applicability
See the contributing guidelines for more information about how patches are applied across multiple versions.
Additional Notes