Skip to content

Commit 9639774

Browse files
committed
Fixed three profiled constant-factor costs of scan-bound execution: per-row column-name resolution (per-enumeration SourceColumnMemo keyed by string reference identity), RowDecoder.DecodeColumn's two O(columns) walks (per-schema RowLayout cache + O(1) array-overload fast path), and HashEquiJoin's per-key List growth (forward-linked ordinal chains).
1 parent 9cd9d0e commit 9639774

11 files changed

Lines changed: 280 additions & 27 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Full suite runs in ~3s; single-test filter (`--filter "FullyQualifiedName~Foo"`)
4040
Layout: `Storage/` (pages, types, row encoder/decoder, heap, constraints, lock manager + DMVs), `Parser/` (tokenizer, expressions, query planning + execution), `Simulation/` (per-statement-kind partials), `Schemas/` (`SchemaObject` hierarchy + alias/catalog-view/full-text/spatial/xml-schema-collection types), `Errors/` (exception factory partials), root (`Simulated*` ADO.NET front-door + `Simulation` / `Database` / `Schema` / supporting types).
4141

4242
### Storage
43-
8KB heap pages. Rows encoded as bytes, navigated column-by-column without rehydrating. Every non-NULL variable-length column carries a 1-byte inline/pointer marker. LOB-eligible types (`varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` / `text` / `ntext` / `image`) flow through a parallel chain of 8KB LOB pages. Bounded `varchar(N)` / `nvarchar(N)` / `varbinary(N)` start inline; the encoder pushes the largest off-row greedily until the row fits within 8060 bytes. Allocation tracking is a flat page list (no IAM/PFS).
43+
8KB heap pages. Rows encoded as bytes, navigated column-by-column without rehydrating; single-column reads through an array-typed schema take the `RowLayout` fast path (per-schema geometry cached by array identity via `ConditionalWeakTable`, making `RowDecoder.DecodeColumn` O(1) instead of two O(columns) walks — the per-row execution resolvers' path). Every non-NULL variable-length column carries a 1-byte inline/pointer marker. LOB-eligible types (`varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` / `text` / `ntext` / `image`) flow through a parallel chain of 8KB LOB pages. Bounded `varchar(N)` / `nvarchar(N)` / `varbinary(N)` start inline; the encoder pushes the largest off-row greedily until the row fits within 8060 bytes. Allocation tracking is a flat page list (no IAM/PFS).
4444

4545
### Type system
4646
`SqlType` / `SqlValue` is the storage-layer pair. Three coercion paths: `SqlValue.Coerce` (runtime values), `SqlType.Promote` (static unification for CASE / set ops / COALESCE), `SqlType.PromoteForArithmetic(a, b, op)` (per-operator decimal/integer/money/float result type — the single source of truth for both `TwoSidedExpression.GetSqlType` and `DecimalArithmetic`; static/runtime parity required because the row encoder rejects type mismatches).
@@ -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.
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.
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.Cursor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,12 @@ internal List<CursorRow> EnumerateForCursor(BatchContext batch)
8282
var sources = new[] { source };
8383
var orderBy = this.CursorOrderBy ?? [];
8484

85+
var memo = new SourceColumnMemo();
8586
var rows = new List<CursorRow>();
8687
foreach (var (pageIndex, slotIndex, bytes) in table.Heap.EnumerateRowsWithAddress())
8788
{
8889
var tuple = new byte[]?[] { bytes };
89-
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, tuple, name, batch, null, Resolve);
90+
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, tuple, name, batch, null, Resolve, memo);
9091

9192
var keep = true;
9293
foreach (var excluder in profile.Excluders)

SqlServerSimulator/Parser/Selection.Execution.Aggregate.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ private static List<SqlValue[]> BuildAggregateProjectionRows(
4141
{
4242
if (topCount == 0)
4343
return [];
44+
var memo = new SourceColumnMemo();
4445

4546
var aggregateOperandTypes = new SqlType[aggregates.Count];
4647
var aggregateResultTypes = new SqlType[aggregates.Count];
@@ -76,7 +77,7 @@ GroupState NewGroup(int keyArity)
7677
foreach (var tuple in EnumerateJoinedRows(sources, joins, batch, outerResolver))
7778
{
7879
var localTuple = tuple;
79-
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn);
80+
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn, memo);
8081

8182
var include = true;
8283
foreach (var excluder in fromClause.Excluders)
@@ -112,7 +113,7 @@ GroupState NewGroup(int keyArity)
112113
foreach (var tuple in buffered)
113114
{
114115
var localTuple = tuple;
115-
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn);
116+
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn, memo);
116117

117118
GroupState state;
118119
if (groupingSet.Length == 0)
@@ -226,7 +227,7 @@ SqlValue ResolveByGroupKey(MultiPartName name)
226227
// to the outer resolver / Msg 207 when the name isn't a
227228
// source column, so this subsumes the outer-or-throw tail.
228229
return capturedSet.Length > 0 && state.Representative is { } rep
229-
? ResolveAcrossTuple(sources, rep, name, batch, outerResolver, ResolveByGroupKey)
230+
? ResolveAcrossTuple(sources, rep, name, batch, outerResolver, ResolveByGroupKey, memo)
230231
: outerResolver is not null
231232
? outerResolver(name)
232233
: throw SimulatedSqlException.InvalidColumnName(name);

SqlServerSimulator/Parser/Selection.Execution.Joins.cs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ private static SqlValue ResolveAcrossTuple(
3030
MultiPartName name,
3131
BatchContext batch,
3232
Func<MultiPartName, SqlValue>? outerResolver,
33-
Func<MultiPartName, SqlValue> selfRecursive)
33+
Func<MultiPartName, SqlValue> selfRecursive,
34+
SourceColumnMemo memo)
3435
{
35-
var (s, c) = FindSourceColumn(sources, name);
36+
var (s, c) = memo.Find(sources, name);
3637
if (s == -1)
3738
{
3839
return outerResolver is not null
@@ -74,9 +75,10 @@ private static SqlValue ResolveAcrossTuple(
7475
Func<MultiPartName, SqlValue>? outerResolver)
7576
{
7677
var tuple = new byte[]?[sources.Length];
78+
var memo = new SourceColumnMemo();
7779

7880
SqlValue Resolve(MultiPartName name) =>
79-
ResolveAcrossTuple(sources, tuple, name, batch, outerResolver, Resolve);
81+
ResolveAcrossTuple(sources, tuple, name, batch, outerResolver, Resolve, memo);
8082

8183
var rowset = EnumerateLeftmost(sources[0], tuple, batch, outerResolver);
8284
for (var level = 1; level < sources.Length; level++)
@@ -674,18 +676,33 @@ internal static JoinSpec[] RewriteCommaJoinsToEquiJoins(
674676
{
675677
var runtime = new RuntimeContext(resolve, batch);
676678

679+
// Bucket membership is a forward-linked chain over row ordinals —
680+
// buckets[key] holds the chain's (head, tail) and next[ordinal] links
681+
// to the same key's following row — rather than a List<int> per key:
682+
// one shared next list replaces the per-key list allocations and their
683+
// growth churn, which profiling showed as the hash build's dominant
684+
// cost on a 228k-row build side. Forward links keep probe emission in
685+
// build order, matching the per-key List's insertion order exactly.
677686
var rightRows = new List<byte[]>();
678-
var index = new Dictionary<SqlValueKey, List<int>>();
687+
var next = new List<int>();
688+
var buckets = new Dictionary<SqlValueKey, (int Head, int Tail)>();
679689
foreach (var row in right.Rows)
680690
{
681691
tuple[level] = row;
682692
var ordinal = rightRows.Count;
683693
rightRows.Add(row);
694+
next.Add(-1);
684695
if (TryComputeKey(plan.Keys, runtime, rightSide: true, out var buildKey))
685696
{
686-
if (!index.TryGetValue(buildKey, out var bucket))
687-
index[buildKey] = bucket = [];
688-
bucket.Add(ordinal);
697+
if (buckets.TryGetValue(buildKey, out var chain))
698+
{
699+
next[chain.Tail] = ordinal;
700+
buckets[buildKey] = (chain.Head, ordinal);
701+
}
702+
else
703+
{
704+
buckets[buildKey] = (ordinal, ordinal);
705+
}
689706
}
690707
}
691708
tuple[level] = null;
@@ -696,9 +713,9 @@ internal static JoinSpec[] RewriteCommaJoinsToEquiJoins(
696713
{
697714
var matchedLeft = false;
698715
if (TryComputeKey(plan.Keys, runtime, rightSide: false, out var probeKey)
699-
&& index.TryGetValue(probeKey, out var bucket))
716+
&& buckets.TryGetValue(probeKey, out var probeChain))
700717
{
701-
foreach (var ordinal in bucket)
718+
for (var ordinal = probeChain.Head; ordinal != -1; ordinal = next[ordinal])
702719
{
703720
tuple[level] = rightRows[ordinal];
704721
if (ResidualMatches(plan.Residual, runtime))

SqlServerSimulator/Parser/Selection.Execution.Window.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
5555
// cloned. For each buffered tuple, also pre-compute every window's
5656
// partition + order keys so the per-row resolver doesn't have to
5757
// be re-bound during window evaluation.
58+
var memo = new SourceColumnMemo();
5859
var buffered = new List<byte[]?[]>();
5960
var perWindowKeys = new List<(SqlValue[] PartitionKeys, SqlValue[] OrderKeys)[]>();
6061
foreach (var tuple in EnumerateJoinedRows(sources, joins, batch, outerResolver))
6162
{
6263
var localTuple = tuple;
63-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
64+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
6465

6566
var include = true;
6667
foreach (var excluder in excluders)
@@ -357,14 +358,14 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
357358
else
358359
{
359360
var localTuple = buffered[indices[i]];
360-
SqlValue ResolveSelf(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSelf);
361+
SqlValue ResolveSelf(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSelf, memo);
361362
results[indices[i]] = win.DefaultArg.Run(new RuntimeContext(ResolveSelf, batch));
362363
}
363364
}
364365
else
365366
{
366367
var targetTuple = buffered[indices[targetIdx]];
367-
SqlValue ResolveTarget(MultiPartName name) => ResolveAcrossTuple(sources, targetTuple, name, batch, outerResolver, ResolveTarget);
368+
SqlValue ResolveTarget(MultiPartName name) => ResolveAcrossTuple(sources, targetTuple, name, batch, outerResolver, ResolveTarget, memo);
368369
results[indices[i]] = win.Operand.Run(new RuntimeContext(ResolveTarget, batch));
369370
}
370371
}
@@ -396,7 +397,7 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
396397
}
397398
var refIdx = isLast ? frameEnd : frameStart;
398399
var refTuple = buffered[indices[refIdx]];
399-
SqlValue ResolveRef(MultiPartName name) => ResolveAcrossTuple(sources, refTuple, name, batch, outerResolver, ResolveRef);
400+
SqlValue ResolveRef(MultiPartName name) => ResolveAcrossTuple(sources, refTuple, name, batch, outerResolver, ResolveRef, memo);
400401
results[indices[i]] = win.Operand.Run(new RuntimeContext(ResolveRef, batch));
401402
}
402403
}
@@ -443,7 +444,7 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
443444
foreach (var i in indices)
444445
{
445446
var localTuple = buffered[i];
446-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
447+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
447448
var operandValue = aggregate.Operand is null
448449
? SqlValue.Null(SqlType.Int32)
449450
: aggregate.Operand.Run(new RuntimeContext(ResolveSource, batch));
@@ -468,7 +469,7 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
468469
for (var p = 0; p < count; p++)
469470
{
470471
var localTuple = buffered[indices[p]];
471-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
472+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
472473
operandByPos[p] = aggregate.Operand is null
473474
? SqlValue.Null(SqlType.Int32)
474475
: aggregate.Operand.Run(new RuntimeContext(ResolveSource, batch));
@@ -538,7 +539,7 @@ private static IEnumerable<SqlValue[]> ProjectWindowedRows(
538539
windows[w].BindResult(batch, perWindowResults[w][i]);
539540

540541
var localTuple = buffered[i];
541-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
542+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
542543

543544
var projected = new SqlValue[expressions.Count];
544545
for (var j = 0; j < expressions.Count; j++)
@@ -595,6 +596,7 @@ private static void ComputeJsonObjectAggWindow(
595596
Func<MultiPartName, SqlValue>? outerResolver)
596597
{
597598
var aggregate = win.AggregateInfo!;
599+
var memo = new SourceColumnMemo();
598600
foreach (var (_, indices) in partitions)
599601
{
600602
if (orderByList.Count > 0)
@@ -609,7 +611,7 @@ private static void ComputeJsonObjectAggWindow(
609611
for (var p = 0; p < count; p++)
610612
{
611613
var localTuple = buffered[indices[p]];
612-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
614+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
613615
var runtime = new RuntimeContext(ResolveSource, batch);
614616
keys[p] = aggregate.KeyExpression!.Run(runtime);
615617
values[p] = aggregate.Operand!.Run(runtime);
@@ -771,7 +773,8 @@ private static SqlValue EvaluateScalarArg(
771773
if (buffered.Count == 0)
772774
return SqlValue.Null(SqlType.Int32);
773775
var firstTuple = buffered[0];
774-
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, firstTuple, name, batch, outerResolver, Resolve);
776+
var memo = new SourceColumnMemo();
777+
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, firstTuple, name, batch, outerResolver, Resolve, memo);
775778
return arg.Run(new RuntimeContext(Resolve, batch));
776779
}
777780
}

SqlServerSimulator/Parser/Selection.Execution.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,11 @@ private static IEnumerable<SqlValue[]> ProjectStreaming(
334334

335335
IEnumerable<SqlValue[]> InnerStream()
336336
{
337+
var memo = new SourceColumnMemo();
337338
foreach (var tuple in EnumerateJoinedRows(sources, joins, batch, outerResolver))
338339
{
339340
var localTuple = tuple;
340-
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn);
341+
SqlValue ResolveColumn(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveColumn, memo);
341342

342343
var include = true;
343344
foreach (var excluder in excluders)
@@ -380,10 +381,11 @@ private static IEnumerable<SqlValue[]> ProjectBuffered(
380381
{
381382
var buffer = new List<(SqlValue[] Projected, SqlValue[] Keys)>();
382383

384+
var memo = new SourceColumnMemo();
383385
foreach (var tuple in EnumerateJoinedRows(sources, joins, batch, outerResolver))
384386
{
385387
var localTuple = tuple;
386-
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource);
388+
SqlValue ResolveSource(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, ResolveSource, memo);
387389

388390
var include = true;
389391
foreach (var excluder in excluders)

SqlServerSimulator/Parser/Selection.Pivot.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,11 @@ private static IEnumerable<byte[]> UnpivotRows(
241241
BatchContext batch, Func<MultiPartName, SqlValue>? outerResolver)
242242
{
243243
FromSource[] sources = [source];
244+
var memo = new SourceColumnMemo();
244245
foreach (var tuple in EnumerateJoinedRows(sources, [], batch, outerResolver))
245246
{
246247
var localTuple = tuple;
247-
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, Resolve);
248+
SqlValue Resolve(MultiPartName name) => ResolveAcrossTuple(sources, localTuple, name, batch, outerResolver, Resolve, memo);
248249

249250
foreach (var col in unpivotColumns)
250251
{

0 commit comments

Comments
 (0)