Skip to content

Commit 9ca35fb

Browse files
committed
Fixed some allocation hotspots: resolver local functions passed as their own selfRecursive argument allocated a delegate per column resolution per row (replaced by cached self-referencing lambdas with a mutable-capture tuple slot and one hoisted RuntimeContext per loop) and join probe keys allocated per row (scratch-buffer SqlValueKey, cloned only on build-side first-occurrence insert).
1 parent 9639774 commit 9ca35fb

5 files changed

Lines changed: 153 additions & 99 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Layout: `Storage/` (pages, types, row encoder/decoder, heap, constraints, lock m
4949
`Selection.cs` + `Selection.Execution.cs` are a partial-class pair. `Parse → Selection`, `Execute → SimulatedSqlResultSet`. Correlated subqueries re-run the same plan per outer row via `outerResolver: Func<MultiPartName, SqlValue>?` (execute) and `outerTypeResolver: Func<MultiPartName, SqlType>?` (parse). Both walk arbitrary nesting depth via `ParserContext.OuterTypeResolver` + the runtime arg. **Derived tables in FROM are always deferred** (`FromSource.LateralPlan` is re-executed per outer row), matching SQL Server's "any FROM derived table can correlate" rule — required because outer references in WHERE/ON resolve through `Run`, not `GetSqlType`.
5050

5151
### Multi-source rows
52-
`FromSource[]`; rows during enumeration are `byte[]?[]`, one slot per source, null = NULL-filled outer-join side (LEFT/RIGHT/FULL/OUTER APPLY). Column resolution is qualifier-aware via `FindSourceColumn` / `ResolveAcrossTuple`; ambiguous unqualified name → Msg 209. Per-row resolution goes through a per-enumeration `SourceColumnMemo` (name → (source, column), keyed by the name's string reference identity — execution-scoped per the plan-cache shared-plan contract); un-memoized re-resolution was the single largest CPU cost of scan-bound joins/aggregates.
52+
`FromSource[]`; rows during enumeration are `byte[]?[]`, one slot per source, null = NULL-filled outer-join side (LEFT/RIGHT/FULL/OUTER APPLY). Column resolution is qualifier-aware via `FindSourceColumn` / `ResolveAcrossTuple`; ambiguous unqualified name → Msg 209. Per-row resolution goes through a per-enumeration `SourceColumnMemo` (name → (source, column), keyed by the name's string reference identity — execution-scoped per the plan-cache shared-plan contract); un-memoized re-resolution was the single largest CPU cost of scan-bound joins/aggregates. **Per-row resolver loops use the hoisted-scaffolding pattern**: one mutable-capture tuple slot + one cached *self-referencing lambda* (never a local function passed as its own `selfRecursive` argument — that allocates a delegate per column resolution per row, 41% of all bytes in the allocation profile) + one `RuntimeContext` per loop; follow it when adding executor loops.
5353

5454
### `MultiPartName`
5555
Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`, `ImmediateQualifier` (null when unqualified — pair with `Collation.Baseline.Equals(name.ImmediateQualifier, "INSERTED")`, the equality folds null into `false`), `Count`, `ToString()`. 5th segment → Msg 4104.

SqlServerSimulator/Parser/Selection.Execution.Aggregate.cs

Lines changed: 87 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,24 @@ GroupState NewGroup(int keyArity)
7373
// accepted row gets snapshotted (the inner byte[] references are
7474
// immutable, only the outer array slots get rewritten by the join
7575
// driver). Captured snapshots are then iterated per grouping set.
76+
// Row-invariant resolution scaffolding is hoisted out of every per-row
77+
// loop below: `currentTuple` is a mutable capture rewritten per row,
78+
// and the resolver is a cached self-referencing lambda (see
79+
// EnumerateJoinedRows), so each loop allocates one closure + one
80+
// delegate + one RuntimeContext TOTAL instead of several per row —
81+
// per-row delegate churn dominated the allocation profile.
7682
var buffered = new List<byte[]?[]>();
83+
var currentTuple = default(byte[]?[])!;
84+
Func<MultiPartName, SqlValue> resolveColumn = null!;
85+
resolveColumn = name => ResolveAcrossTuple(sources, currentTuple, name, batch, outerResolver, resolveColumn, memo);
86+
var rowRuntime = new RuntimeContext(resolveColumn, batch);
7787
foreach (var tuple in EnumerateJoinedRows(sources, joins, batch, outerResolver))
7888
{
79-
var localTuple = tuple;
80-
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn, memo);
81-
89+
currentTuple = tuple;
8290
var include = true;
8391
foreach (var excluder in fromClause.Excluders)
8492
{
85-
if (excluder.Run(new RuntimeContext(ResolveColumn, batch)) != true)
93+
if (excluder.Run(rowRuntime) != true)
8694
{
8795
include = false;
8896
break;
@@ -112,9 +120,7 @@ GroupState NewGroup(int keyArity)
112120

113121
foreach (var tuple in buffered)
114122
{
115-
var localTuple = tuple;
116-
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn, memo);
117-
123+
currentTuple = tuple;
118124
GroupState state;
119125
if (groupingSet.Length == 0)
120126
{
@@ -124,7 +130,7 @@ GroupState NewGroup(int keyArity)
124130
{
125131
var keyValues = new SqlValue[groupingSet.Length];
126132
for (var i = 0; i < groupingSet.Length; i++)
127-
keyValues[i] = groupingSet[i].Run(new RuntimeContext(ResolveColumn, batch));
133+
keyValues[i] = groupingSet[i].Run(rowRuntime);
128134
var key = new SqlValueKey(keyValues);
129135
if (!groups.TryGetValue(key, out state!))
130136
{
@@ -146,15 +152,15 @@ GroupState NewGroup(int keyArity)
146152
var aggregate = aggregates[i];
147153
if (aggregate.Kind == AggregateKind.StringAgg && state.Aggregators[i] is Aggregators.StringAggAggregator stringAgg)
148154
{
149-
var separatorValue = aggregate.Separator!.Run(new RuntimeContext(ResolveColumn, batch));
155+
var separatorValue = aggregate.Separator!.Run(rowRuntime);
150156
stringAgg.SetSeparator(separatorValue.IsNull ? string.Empty : separatorValue.AsString);
151157

152158
if (aggregate.OrderBy is { } orderBy)
153159
{
154160
var orderKeys = new SqlValue[orderBy.Count];
155161
for (var k = 0; k < orderBy.Count; k++)
156-
orderKeys[k] = orderBy[k].Expr!.Run(new RuntimeContext(ResolveColumn, batch));
157-
stringAgg.AddOrdered(aggregate.Operand!.Run(new RuntimeContext(ResolveColumn, batch)), orderKeys);
162+
orderKeys[k] = orderBy[k].Expr!.Run(rowRuntime);
163+
stringAgg.AddOrdered(aggregate.Operand!.Run(rowRuntime), orderKeys);
158164
continue;
159165
}
160166
}
@@ -166,8 +172,8 @@ GroupState NewGroup(int keyArity)
166172
{
167173
var orderKeys = new SqlValue[jsonOrderBy.Count];
168174
for (var k = 0; k < jsonOrderBy.Count; k++)
169-
orderKeys[k] = jsonOrderBy[k].Expr!.Run(new RuntimeContext(ResolveColumn, batch));
170-
arrayAgg.AddOrdered(aggregate.Operand!.Run(new RuntimeContext(ResolveColumn, batch)), orderKeys);
175+
orderKeys[k] = jsonOrderBy[k].Expr!.Run(rowRuntime);
176+
arrayAgg.AddOrdered(aggregate.Operand!.Run(rowRuntime), orderKeys);
171177
continue;
172178
}
173179

@@ -176,16 +182,78 @@ GroupState NewGroup(int keyArity)
176182
if (aggregate.Kind == AggregateKind.JsonObjectAgg
177183
&& state.Aggregators[i] is Aggregators.JsonObjectAggAggregator objectAgg)
178184
{
179-
objectAgg.SetKey(aggregate.KeyExpression!.Run(new RuntimeContext(ResolveColumn, batch)));
185+
objectAgg.SetKey(aggregate.KeyExpression!.Run(rowRuntime));
180186
}
181187

182188
var operand = aggregate.Operand;
183-
state.Aggregators[i].Add(operand is null ? SqlValue.Null(SqlType.Int32) : operand.Run(new RuntimeContext(ResolveColumn, batch)));
189+
state.Aggregators[i].Add(operand is null ? SqlValue.Null(SqlType.Int32) : operand.Run(rowRuntime));
184190
}
185191
}
186192

193+
// Per-group resolution scaffolding, hoisted like the per-row loops
194+
// above: `currentState` / `currentProjected` are mutable captures
195+
// rewritten per group, the resolvers are cached self-referencing
196+
// lambdas, and each runtime is allocated once per grouping set —
197+
// a large-group-count GROUP BY (one group per customer) evaluated
198+
// several expressions per group through fresh delegates otherwise.
199+
var currentState = default(GroupState)!;
200+
var currentProjected = default(SqlValue[])!;
201+
Func<MultiPartName, SqlValue> resolveByGroupKey = null!;
202+
resolveByGroupKey = name =>
203+
{
204+
for (var i = 0; i < groupingSet.Length; i++)
205+
{
206+
if (groupingSet[i] is Reference r
207+
&& BuiltInToken.Equals(r.Name, name.Leaf))
208+
{
209+
return currentState.KeyValues[i];
210+
}
211+
}
212+
// Column appears in another grouping set but not this one
213+
// — return typed NULL to surface the subtotal/total-row
214+
// semantic. The type comes from the column-type resolver.
215+
foreach (var expr in fromClause.AllGroupingExpressions)
216+
{
217+
if (expr is Reference r
218+
&& BuiltInToken.Equals(r.Name, name.Leaf))
219+
{
220+
return SqlValue.Null(expr.GetSqlType(batch, resolveColumnType));
221+
}
222+
}
223+
224+
// Column referenced inside one of this (non-empty) set's
225+
// grouping expressions: resolve against the group's
226+
// representative row. ResolveAcrossTuple itself falls back
227+
// to the outer resolver / Msg 207 when the name isn't a
228+
// source column, so this subsumes the outer-or-throw tail.
229+
return groupingSet.Length > 0 && currentState.Representative is { } rep
230+
? ResolveAcrossTuple(sources, rep, name, batch, outerResolver, resolveByGroupKey, memo)
231+
: outerResolver is not null
232+
? outerResolver(name)
233+
: throw SimulatedSqlException.InvalidColumnName(name);
234+
};
235+
var groupRuntime = new RuntimeContext(resolveByGroupKey, batch);
236+
237+
// Resolves an ORDER BY item's column references against this
238+
// group's output (alias / select-list name first), then through
239+
// the grouped-key resolver — so ORDER BY can reference a select
240+
// alias, a grouped column, or a grouping expression.
241+
SqlValue ResolveOrderName(MultiPartName name)
242+
{
243+
for (var j = 0; j < outputColumnNames.Length; j++)
244+
{
245+
if (BuiltInToken.Equals(outputColumnNames[j], name.Leaf))
246+
return currentProjected[j];
247+
}
248+
249+
return resolveByGroupKey(name);
250+
}
251+
252+
var orderRuntime = new RuntimeContext(ResolveOrderName, batch);
253+
187254
foreach (var (_, state) in groups)
188255
{
256+
currentState = state;
189257
for (var i = 0; i < aggregates.Count; i++)
190258
aggregates[i].BindResult(batch, state.Aggregators[i].Result());
191259

@@ -198,77 +266,28 @@ GroupState NewGroup(int keyArity)
198266
batch.GroupingSetExpressions = groupingSet;
199267
batch.AllGroupingExpressions = fromClause.AllGroupingExpressions;
200268

201-
var capturedSet = groupingSet;
202-
SqlValue ResolveByGroupKey(MultiPartName name)
203-
{
204-
for (var i = 0; i < capturedSet.Length; i++)
205-
{
206-
if (capturedSet[i] is Reference r
207-
&& BuiltInToken.Equals(r.Name, name.Leaf))
208-
{
209-
return state.KeyValues[i];
210-
}
211-
}
212-
// Column appears in another grouping set but not this one
213-
// — return typed NULL to surface the subtotal/total-row
214-
// semantic. The type comes from the column-type resolver.
215-
foreach (var expr in fromClause.AllGroupingExpressions)
216-
{
217-
if (expr is Reference r
218-
&& BuiltInToken.Equals(r.Name, name.Leaf))
219-
{
220-
return SqlValue.Null(expr.GetSqlType(batch, resolveColumnType));
221-
}
222-
}
223-
224-
// Column referenced inside one of this (non-empty) set's
225-
// grouping expressions: resolve against the group's
226-
// representative row. ResolveAcrossTuple itself falls back
227-
// to the outer resolver / Msg 207 when the name isn't a
228-
// source column, so this subsumes the outer-or-throw tail.
229-
return capturedSet.Length > 0 && state.Representative is { } rep
230-
? ResolveAcrossTuple(sources, rep, name, batch, outerResolver, ResolveByGroupKey, memo)
231-
: outerResolver is not null
232-
? outerResolver(name)
233-
: throw SimulatedSqlException.InvalidColumnName(name);
234-
}
235-
236-
// Resolves an ORDER BY item's column references against this
237-
// group's output (alias / select-list name first), then through
238-
// the grouped-key resolver — so ORDER BY can reference a select
239-
// alias, a grouped column, or a grouping expression.
240-
SqlValue ResolveOrderName(SqlValue[] projectedRow, MultiPartName name)
241-
{
242-
for (var j = 0; j < outputColumnNames.Length; j++)
243-
{
244-
if (BuiltInToken.Equals(outputColumnNames[j], name.Leaf))
245-
return projectedRow[j];
246-
}
247-
248-
return ResolveByGroupKey(name);
249-
}
250-
251269
try
252270
{
253-
if (fromClause.Having is { } having && having.Run(new RuntimeContext(ResolveByGroupKey, batch)) != true)
271+
if (fromClause.Having is { } having && having.Run(groupRuntime) != true)
254272
continue;
255273

256274
var projected = new SqlValue[expressions.Count];
257275
for (var i = 0; i < expressions.Count; i++)
258-
projected[i] = expressions[i].Run(new RuntimeContext(ResolveByGroupKey, batch));
276+
projected[i] = expressions[i].Run(groupRuntime);
259277

260278
// Aggregate-query ORDER BY: keys are computed here, where
261279
// each aggregate is bound and the grouping context is
262280
// published, then the whole stream is sorted before TOP /
263281
// OFFSET / FETCH apply. Ordinal items index the output row;
264282
// expression items (aggregates, grouped columns, aliases,
265283
// grouping expressions) resolve through ResolveOrderName.
284+
currentProjected = projected;
266285
var orderKeys = new SqlValue[orderByItems.Count];
267286
for (var k = 0; k < orderByItems.Count; k++)
268287
{
269288
orderKeys[k] = orderByItems[k].IsOrdinal
270289
? projected[orderByItems[k].Ordinal - 1]
271-
: orderByItems[k].Expr!.Run(new RuntimeContext(name => ResolveOrderName(projected, name), batch));
290+
: orderByItems[k].Expr!.Run(orderRuntime);
272291
}
273292

274293
output.Add((orderKeys, projected));

0 commit comments

Comments
 (0)