Skip to content

Commit 3d0739d

Browse files
committed
Optimize filter parser: compile-time query lift, HEC, pooled storage
- SelectExpressionFactory: lift JsonQueryParser.Parse to compile-time; the parsed JsonQuery is embedded in the expression tree instead of re-looked-up on every filter evaluation. Cuts per-eval cost and allocation on the runtime hot path. - FilterParser: swap Expression.Compile() for HyperbeeCompiler.Compile(). Adds PackageReference to Hyperbee.Expressions.Compiler 1.4.7; bumps Microsoft.SourceLink.GitHub 8.0.0 -> 10.0.102 for the transitive Hyperbee.Collections constraint. - FilterParser: replace Queue<ExprItem> with ArrayPool<ExprItem> + head index; convert ExprItem from class to struct; Merge/MergeItems take ref/in ExprItem. Split ThrowIfInvalidCompare into left-only and left+right overloads. - FunctionRegistry: add span-keyed TryGetActivator overload using Dictionary.GetAlternateLookup<ReadOnlySpan<char>>() on net9+ with string-alloc fallback on net8, eliminating the per-token ToString() in FunctionExpressionFactory. - Add FilterOptimizationBenchmark isolating Compile vs Evaluate cost. - Trim benchmark Config to .NET 10 (the .NET 8/9 jobs were both pinned to Core90 by mistake). - Refresh README and docs/site/jsonpath/comparison.md benchmark tables. All 1812 tests passing on net10.0.
1 parent 7bc20fd commit 3d0739d

File tree

12 files changed

+705
-343
lines changed

12 files changed

+705
-343
lines changed

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
</PropertyGroup>
55
<ItemGroup>
66
<!-- Core Application Dependencies -->
7+
<PackageVersion Include="Hyperbee.Expressions.Compiler" Version="1.4.7" />
78
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
89
<!-- Development Tools -->
9-
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
10+
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.102">
1011
<PrivateAssets>all</PrivateAssets>
1112
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1213
</PackageVersion>

README.md

Lines changed: 178 additions & 176 deletions
Large diffs are not rendered by default.

docs/site/jsonpath/comparison.md

Lines changed: 111 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -98,39 +98,117 @@ Here is a performance comparison of various queries on the standard book store d
9898
}
9999
```
100100

101-
| Method | Mean | Error | StdDev | Allocated
102-
| :----------------------- | ---------: | ----------: | ---------: | ---------:
103-
| `$..* First()`
104-
| Hyperbee_JsonElement | 2.874 us | 1.6256 us | 0.0891 us | 3.52 KB
105-
| Hyperbee_JsonNode | 3.173 us | 0.7979 us | 0.0437 us | 3.09 KB
106-
| JsonEverything_JsonNode | 3.199 us | 2.4697 us | 0.1354 us | 3.53 KB
107-
| JsonCons_JsonElement | 5.976 us | 8.4042 us | 0.4607 us | 8.48 KB
108-
| Newtonsoft_JObject | 9.219 us | 2.9245 us | 0.1603 us | 14.22 KB
109-
| | | | |
101+
.NET 10, BenchmarkDotNet `ShortRun` on an Intel i9-9980HK. `NA` indicates the library does not support that RFC 9535 feature.
102+
103+
```
104+
BenchmarkDotNet v0.15.8, Windows 11
105+
Intel Core i9-9980HK CPU 2.40GHz
106+
.NET 10.0.4, X64 RyuJIT x86-64-v3
107+
```
108+
109+
| Method | Mean | Error | StdDev | Allocated
110+
| :----------------------- | ------------: | ------------: | ------------: | --------:
111+
| `$..[?(@.price < 10)]`
112+
| Hyperbee_JsonElement | 6,589.72 ns | 13,724.37 ns | 752.279 ns | 13,408 B
113+
| Hyperbee_JsonNode | 9,511.05 ns | 14,918.98 ns | 817.759 ns | 18,112 B
114+
| JsonCons_JsonElement | 9,617.04 ns | 8,150.52 ns | 446.757 ns | 13,032 B
115+
| Newtonsoft_JObject | 13,136.51 ns | 10,565.73 ns | 579.143 ns | 26,480 B
116+
| JsonEverything_JsonNode | 24,738.29 ns | 26,520.10 ns | 1,453.656 ns | 49,304 B
117+
| | | | |
118+
| `$..['bicycle','price']`
119+
| Hyperbee_JsonElement | 2,304.00 ns | 4,789.71 ns | 262.540 ns | 3,072 B
120+
| JsonCons_JsonElement | 5,411.72 ns | 2,044.44 ns | 112.063 ns | 7,304 B
121+
| Hyperbee_JsonNode | 5,959.43 ns | 6,388.91 ns | 350.197 ns | 9,056 B
122+
| Newtonsoft_JObject | 8,732.70 ns | 7,717.27 ns | 423.010 ns | 14,904 B
123+
| JsonEverything_JsonNode | 16,081.80 ns | 32,632.20 ns | 1,788.681 ns | 29,184 B
124+
| | | | |
110125
| `$..*`
111-
| JsonCons_JsonElement | 5.674 us | 3.8650 us | 0.2119 us | 8.45 KB
112-
| Hyperbee_JsonElement | 7.934 us | 3.5907 us | 0.1968 us | 9.13 KB
113-
| Hyperbee_JsonNode | 10.457 us | 7.7120 us | 0.4227 us | 10.91 KB
114-
| Newtonsoft_JObject | 10.722 us | 4.1310 us | 0.2264 us | 14.86 KB
115-
| JsonEverything_JsonNode | 23.096 us | 10.8629 us | 0.5954 us | 36.81 KB
116-
| | | | |
117-
| `$..price`
118-
| Hyperbee_JsonElement | 4.428 us | 4.6731 us | 0.2561 us | 4.2 KB
119-
| JsonCons_JsonElement | 5.355 us | 1.1624 us | 0.0637 us | 5.65 KB
120-
| Hyperbee_JsonNode | 7.931 us | 0.6970 us | 0.0382 us | 7.48 KB
121-
| Newtonsoft_JObject | 10.334 us | 8.2331 us | 0.4513 us | 14.4 KB
122-
| JsonEverything_JsonNode | 17.000 us | 14.9812 us | 0.8212 us | 27.63 KB
123-
| | | | |
126+
| Hyperbee_JsonElement | 1,530.38 ns | 904.54 ns | 49.581 ns | 4,432 B
127+
| JsonCons_JsonElement | 5,529.29 ns | 5,095.25 ns | 279.288 ns | 8,648 B
128+
| Hyperbee_JsonNode | 6,375.70 ns | 13,107.14 ns | 718.447 ns | 9,768 B
129+
| Newtonsoft_JObject | 8,316.71 ns | 13,631.95 ns | 747.213 ns | 14,528 B
130+
| JsonEverything_JsonNode | 17,290.19 ns | 11,706.56 ns | 641.676 ns | 34,784 B
131+
| | | | |
132+
| `$..author`
133+
| Hyperbee_JsonElement | 1,624.67 ns | 304.86 ns | 16.711 ns | 3,056 B
134+
| JsonCons_JsonElement | 4,369.83 ns | 3,368.40 ns | 184.633 ns | 5,640 B
135+
| Hyperbee_JsonNode | 5,546.52 ns | 2,128.96 ns | 116.695 ns | 8,848 B
136+
| Newtonsoft_JObject | 8,346.02 ns | 12,029.11 ns | 659.356 ns | 14,544 B
137+
| JsonEverything_JsonNode | 13,113.01 ns | 6,545.11 ns | 358.760 ns | 26,728 B
138+
| | | | |
139+
| `$..book[?@.isbn]`
140+
| Hyperbee_JsonElement | 2,535.29 ns | 5,230.41 ns | 286.697 ns | 4,024 B
141+
| JsonCons_JsonElement | 5,479.99 ns | 5,162.81 ns | 282.991 ns | 7,336 B
142+
| Hyperbee_JsonNode | 6,270.33 ns | 952.73 ns | 52.222 ns | 9,776 B
143+
| JsonEverything_JsonNode | 15,563.95 ns | 10,045.42 ns | 550.624 ns | 30,696 B
144+
| Newtonsoft_JObject | NA | NA | NA | NA
145+
| | | | |
146+
| `$..book[?@.price == 8.99 && @.category == 'fiction']`
147+
| Hyperbee_JsonElement | 3,454.45 ns | 2,551.12 ns | 139.835 ns | 6,120 B
148+
| Hyperbee_JsonNode | 7,541.74 ns | 10,405.88 ns | 570.381 ns | 12,000 B
149+
| JsonCons_JsonElement | 8,161.76 ns | 6,143.02 ns | 336.720 ns | 8,640 B
150+
| JsonEverything_JsonNode | 22,294.77 ns | 27,986.15 ns | 1,534.016 ns | 40,472 B
151+
| Newtonsoft_JObject | NA | NA | NA | NA
152+
| | | | |
153+
| `$..book[0,1]`
154+
| Hyperbee_JsonElement | 1,907.37 ns | 4,405.90 ns | 241.502 ns | 3,056 B
155+
| JsonCons_JsonElement | 5,556.87 ns | 13,635.13 ns | 747.387 ns | 6,248 B
156+
| Hyperbee_JsonNode | 6,020.31 ns | 4,143.39 ns | 227.113 ns | 8,848 B
157+
| Newtonsoft_JObject | 8,536.98 ns | 6,526.24 ns | 357.725 ns | 14,792 B
158+
| JsonEverything_JsonNode | 13,979.45 ns | 19,281.48 ns | 1,056.883 ns | 27,048 B
159+
| | | | |
160+
| `$.store..price`
161+
| Hyperbee_JsonElement | 1,369.37 ns | 1,313.98 ns | 72.024 ns | 2,680 B
162+
| JsonCons_JsonElement | 4,589.72 ns | 4,689.36 ns | 257.040 ns | 5,704 B
163+
| Hyperbee_JsonNode | 5,263.10 ns | 4,128.49 ns | 226.296 ns | 8,576 B
164+
| Newtonsoft_JObject | 8,093.64 ns | 2,103.71 ns | 115.311 ns | 14,680 B
165+
| JsonEverything_JsonNode | 12,969.09 ns | 11,670.40 ns | 639.694 ns | 27,272 B
166+
| | | | |
167+
| `$.store.* #First()`
168+
| Hyperbee_JsonElement | 423.70 ns | 346.12 ns | 18.972 ns | 752 B
169+
| JsonCons_JsonElement | 2,575.12 ns | 900.27 ns | 49.347 ns | 3,384 B
170+
| Hyperbee_JsonNode | 2,999.84 ns | 3,209.10 ns | 175.902 ns | 2,944 B
171+
| JsonEverything_JsonNode | 3,612.82 ns | 4,544.38 ns | 249.093 ns | 4,648 B
172+
| Newtonsoft_JObject | 9,036.39 ns | 25,870.81 ns | 1,418.066 ns | 14,816 B
173+
| | | | |
174+
| `$.store.*`
175+
| Hyperbee_JsonElement | 437.72 ns | 309.52 ns | 16.966 ns | 712 B
176+
| JsonCons_JsonElement | 2,932.44 ns | 3,752.08 ns | 205.664 ns | 3,344 B
177+
| Hyperbee_JsonNode | 3,011.14 ns | 3,030.26 ns | 166.099 ns | 2,968 B
178+
| JsonEverything_JsonNode | 3,630.15 ns | 1,683.62 ns | 92.285 ns | 4,912 B
179+
| Newtonsoft_JObject | 7,320.97 ns | 4,230.57 ns | 231.892 ns | 14,776 B
180+
| | | | |
181+
| `$.store.bicycle.color`
182+
| Hyperbee_JsonElement | 167.21 ns | 121.79 ns | 6.676 ns | 80 B
183+
| JsonCons_JsonElement | 2,815.20 ns | 1,361.83 ns | 74.646 ns | 3,304 B
184+
| Hyperbee_JsonNode | 2,820.77 ns | 2,055.87 ns | 112.689 ns | 2,952 B
185+
| JsonEverything_JsonNode | 4,157.10 ns | 4,348.01 ns | 238.329 ns | 5,880 B
186+
| Newtonsoft_JObject | 7,383.76 ns | 12,169.49 ns | 667.051 ns | 14,840 B
187+
| | | | |
124188
| `$.store.book[?(@.price == 8.99)]`
125-
| Hyperbee_JsonElement | 4.153 us | 3.6089 us | 0.1978 us | 5.24 KB
126-
| JsonCons_JsonElement | 4.873 us | 1.0395 us | 0.0570 us | 5.05 KB
127-
| Hyperbee_JsonNode | 6.980 us | 5.1007 us | 0.2796 us | 8 KB
128-
| Newtonsoft_JObject | 10.629 us | 3.9096 us | 0.2143 us | 15.84 KB
129-
| JsonEverything_JsonNode | 11.133 us | 7.2544 us | 0.3976 us | 15.85 KB
130-
| | | | |
189+
| Hyperbee_JsonElement | 1,299.44 ns | 507.79 ns | 27.834 ns | 1,984 B
190+
| JsonCons_JsonElement | 4,567.67 ns | 592.57 ns | 32.481 ns | 5,176 B
191+
| Hyperbee_JsonNode | 5,711.16 ns | 6,198.26 ns | 339.748 ns | 7,920 B
192+
| Newtonsoft_JObject | 8,145.15 ns | 4,697.48 ns | 257.485 ns | 16,128 B
193+
| JsonEverything_JsonNode | 11,231.53 ns | 31,597.52 ns | 1,731.967 ns | 15,840 B
194+
| | | | |
195+
| `$.store.book[?(@.price > 10 && @.price < 20)]`
196+
| Hyperbee_JsonElement | 2,045.34 ns | 1,340.62 ns | 73.484 ns | 3,136 B
197+
| JsonCons_JsonElement | 6,306.07 ns | 3,978.97 ns | 218.101 ns | 6,384 B
198+
| Hyperbee_JsonNode | 6,413.33 ns | 2,512.74 ns | 137.732 ns | 9,104 B
199+
| Newtonsoft_JObject | 8,905.79 ns | 15,385.03 ns | 843.305 ns | 17,088 B
200+
| JsonEverything_JsonNode | 13,237.72 ns | 19,293.59 ns | 1,057.547 ns | 22,800 B
201+
| | | | |
131202
| `$.store.book[0]`
132-
| Hyperbee_JsonElement | 2.677 us | 2.2733 us | 0.1246 us | 2.27 KB
133-
| Hyperbee_JsonNode | 3.126 us | 3.5345 us | 0.1937 us | 2.77 KB
134-
| JsonCons_JsonElement | 3.229 us | 0.0681 us | 0.0037 us | 3.21 KB
135-
| JsonEverything_JsonNode | 4.612 us | 2.0037 us | 0.1098 us | 5.96 KB
136-
| Newtonsoft_JObject | 9.627 us | 1.1498 us | 0.0630 us | 14.56 KB
203+
| Hyperbee_JsonElement | 171.25 ns | 332.05 ns | 18.201 ns | 80 B
204+
| JsonCons_JsonElement | 2,867.70 ns | 3,098.16 ns | 169.821 ns | 3,288 B
205+
| Hyperbee_JsonNode | 3,051.39 ns | 4,436.76 ns | 243.194 ns | 2,928 B
206+
| JsonEverything_JsonNode | 3,961.99 ns | 1,245.96 ns | 68.295 ns | 5,816 B
207+
| Newtonsoft_JObject | 7,577.69 ns | 8,275.01 ns | 453.581 ns | 14,824 B
208+
| | | | |
209+
| `$`
210+
| Hyperbee_JsonElement | 29.76 ns | 10.00 ns | 0.548 ns | 56 B
211+
| JsonEverything_JsonNode | 2,361.50 ns | 2,021.81 ns | 110.822 ns | 1,928 B
212+
| Hyperbee_JsonNode | 2,428.05 ns | 1,414.84 ns | 77.552 ns | 1,792 B
213+
| JsonCons_JsonElement | 2,747.57 ns | 4,158.88 ns | 227.962 ns | 3,008 B
214+
| Newtonsoft_JObject | 7,584.68 ns | 12,872.74 ns | 705.598 ns | 14,312 B

src/Hyperbee.Json/Descriptors/FunctionRegistry.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ namespace Hyperbee.Json.Descriptors;
66

77
public sealed class FunctionRegistry
88
{
9-
private Dictionary<string, FunctionActivator> Functions { get; } = [];
9+
// Use StringComparer.Ordinal explicitly: it is required for the .NET 9+
10+
// ReadOnlySpan<char> alternate lookup, which lets the parser dispatch on
11+
// function names without allocating a string from state.Item.
12+
private Dictionary<string, FunctionActivator> Functions { get; } = new( StringComparer.Ordinal );
1013

1114
public void Register<TFunction>( string name, Func<TFunction> factory )
1215
where TFunction : ExtensionFunction
@@ -18,4 +21,14 @@ internal bool TryGetActivator( string name, out FunctionActivator functionActiva
1821
{
1922
return Functions.TryGetValue( name, out functionActivator );
2023
}
24+
25+
internal bool TryGetActivator( ReadOnlySpan<char> name, out FunctionActivator functionActivator )
26+
{
27+
#if NET9_0_OR_GREATER
28+
var lookup = Functions.GetAlternateLookup<ReadOnlySpan<char>>();
29+
return lookup.TryGetValue( name, out functionActivator );
30+
#else
31+
return Functions.TryGetValue( name.ToString(), out functionActivator );
32+
#endif
33+
}
2134
}

src/Hyperbee.Json/Hyperbee.Json.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@
3535
<PackageReference Update="Microsoft.SourceLink.GitHub" />
3636
<PackageReference Update="Nerdbank.GitVersioning" />
3737
</ItemGroup>
38+
39+
<ItemGroup>
40+
<PackageReference Include="Hyperbee.Expressions.Compiler" />
41+
</ItemGroup>
3842
</Project>

src/Hyperbee.Json/Path/Filters/Parser/Expressions/FunctionExpressionFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public static bool TryGetExpression<TNode>( ref ParserState state, out Expressio
1515
return false;
1616
}
1717

18-
if ( !descriptor.Functions.TryGetActivator( state.Item.ToString(), out var functionActivator ) )
18+
if ( !descriptor.Functions.TryGetActivator( state.Item, out var functionActivator ) )
1919
{
2020
return false;
2121
}

src/Hyperbee.Json/Path/Filters/Parser/Expressions/SelectExpressionFactory.cs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,25 @@ private static class ExpressionHelper<TNode>
3131

3232
public static MethodCallExpression GetExpression( ReadOnlySpan<char> item, bool allowDotWhitespace )
3333
{
34+
// Pre-parse the embedded JSONPath query at compile time so the runtime
35+
// call avoids per-evaluation dictionary lookups against JsonQueryParser.
36+
var query = item.ToString();
37+
var options = allowDotWhitespace
38+
? JsonQueryParserOptions.Rfc9535AllowDotWhitespace
39+
: JsonQueryParserOptions.Rfc9535;
40+
var compiledQuery = JsonQueryParser.Parse( query, options );
41+
var fromRoot = query.Length > 0 && query[0] == '$';
42+
3443
return Expression.Call(
3544
SelectMethod,
36-
Expression.Constant( item.ToString() ),
37-
Expression.Constant( allowDotWhitespace ),
45+
Expression.Constant( compiledQuery ),
46+
Expression.Constant( fromRoot ),
3847
FilterParser<TNode>.RuntimeContextExpression );
3948
}
4049

41-
private static IValueType Select( string query, bool allowDotWhitespace, FilterRuntimeContext<TNode> runtimeContext )
50+
private static IValueType Select( JsonQuery compiledQuery, bool fromRoot, FilterRuntimeContext<TNode> runtimeContext )
4251
{
43-
var options = allowDotWhitespace
44-
? JsonQueryParserOptions.Rfc9535AllowDotWhitespace
45-
: JsonQueryParserOptions.Rfc9535;
46-
47-
var compiledQuery = JsonQueryParser.Parse( query, options );
48-
49-
var value = query[0] == '$'
52+
var value = fromRoot
5053
? runtimeContext.Root
5154
: runtimeContext.Current; // @
5255

0 commit comments

Comments
 (0)