Skip to content

Commit 03a4248

Browse files
committed
Result-drain hot path stops re-laying-out row geometry per column read: RowDecoder.ColumnsFor caches the type-only-schema HeapColumn[] conversion by schema-array identity so SqlValueCursor and the subquery decode sites hit RowLayout's identity-keyed cache instead of allocating a fresh array per call.
1 parent 44c330d commit 03a4248

6 files changed

Lines changed: 59 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ A full build + test cycle runs 20–30s; single-test filter (`--filter "FullyQua
4242
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).
4343

4444
### Storage
45-
8KB heap pages. Rows encoded as bytes, navigated column-by-column without rehydrating; single-column reads via an array-typed schema take the `RowLayout` fast path (per-schema geometry cached by array identity in a `ConditionalWeakTable`, making `RowDecoder.DecodeColumn` O(1) vs two O(columns) walks — the per-row resolvers' path). Every non-NULL variable-length column carries a 1-byte inline/pointer marker. LOB-eligible types (`varchar/nvarchar/varbinary(MAX)`, `text`/`ntext`/`image`) flow through a parallel 8KB-LOB-page chain. Bounded `varchar/nvarchar/varbinary(N)` start inline; the encoder pushes the largest off-row greedily until the row fits 8060 bytes. Allocation is a flat page list (no IAM/PFS).
45+
8KB heap pages. Rows encoded as bytes, navigated column-by-column without rehydrating; single-column reads via an array-typed schema take the `RowLayout` fast path (per-schema geometry cached by array identity in a `ConditionalWeakTable`, making `RowDecoder.DecodeColumn` O(1) vs two O(columns) walks — the per-row resolvers' path). Type-only `SqlType[]` schemas reach that same fast path through `RowDecoder.ColumnsFor`, which caches the `HeapColumn[]` conversion by schema-array identity — **never convert per call**: a fresh array defeats the layout cache's identity key and re-lays-out the geometry every read (measured at a third of result-drain CPU; the reader-cursor and subquery decode sites are the precedent consumers). Every non-NULL variable-length column carries a 1-byte inline/pointer marker. LOB-eligible types (`varchar/nvarchar/varbinary(MAX)`, `text`/`ntext`/`image`) flow through a parallel 8KB-LOB-page chain. Bounded `varchar/nvarchar/varbinary(N)` start inline; the encoder pushes the largest off-row greedily until the row fits 8060 bytes. Allocation is a flat page list (no IAM/PFS).
4646

4747
### Type system
4848
`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 — single source of truth for `TwoSidedExpression.GetSqlType` + `DecimalArithmetic`; static/runtime parity required since the row encoder rejects type mismatches).

SqlServerSimulator.Tests.Internal/Storage/DecodeColumnTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,20 @@ public void OutOfRange_Throws()
8484
_ = Throws<ArgumentOutOfRangeException>(() => RowDecoder.DecodeColumn(schema, bytes, 1));
8585
_ = Throws<ArgumentOutOfRangeException>(() => RowDecoder.DecodeColumn(schema, bytes, -1));
8686
}
87+
88+
/// <summary>
89+
/// The type-only schema's <see cref="HeapColumn"/>[] conversion must be
90+
/// cached by schema-array identity: a fresh array per call would defeat
91+
/// <see cref="RowLayout"/>'s identity-keyed cache, re-laying-out the row
92+
/// geometry on every single-column read (measured at a third of
93+
/// result-drain CPU before the conversion was cached).
94+
/// </summary>
95+
[TestMethod]
96+
public void ColumnsFor_SameSchemaArray_ReturnsCachedInstance()
97+
{
98+
SqlType[] schema = [SqlType.Int32, SqlType.Varchar];
99+
100+
AreSame(RowDecoder.ColumnsFor(schema), RowDecoder.ColumnsFor(schema));
101+
AreNotSame(RowDecoder.ColumnsFor(schema), RowDecoder.ColumnsFor([SqlType.Int32, SqlType.Varchar]));
102+
}
87103
}

SqlServerSimulator/SimulatedSqlResultSet.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public override bool MoveNext()
131131
/// </remarks>
132132
private sealed class SqlValueCursor(SqlType[] schema, IEnumerator<byte[]> source) : RowCursor
133133
{
134+
private readonly HeapColumn[] columns = RowDecoder.ColumnsFor(schema);
134135
private byte[]? buffered;
135136
private byte[]? current;
136137
private bool peeked;
@@ -186,7 +187,7 @@ public override bool MoveNext()
186187

187188
public override SqlValue this[int ordinal] => current is null
188189
? throw new InvalidOperationException("No current row.")
189-
: RowDecoder.DecodeColumn(schema, current, ordinal);
190+
: RowDecoder.DecodeColumn(columns, current, ordinal);
190191

191192
protected override void DisposeCore() => source.Dispose();
192193
}

SqlServerSimulator/Storage/RowDecoder.cs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,32 @@ public static SqlValue[] DecodeRow(ReadOnlySpan<HeapColumn> schema, ReadOnlySpan
106106
/// Decodes a single column from a row's bytes without materializing the
107107
/// other columns. The data reader uses this to navigate row bytes directly
108108
/// per <see cref="System.Data.Common.DbDataReader"/> accessor call.
109+
/// Routes through <see cref="ColumnsFor"/> so repeated reads against the
110+
/// same schema array reuse one <see cref="HeapColumn"/>[] — and therefore
111+
/// one cached <see cref="RowLayout"/> — instead of allocating and
112+
/// re-laying-out per call (the per-call rebuild dominated result-drain CPU
113+
/// at 34% and its discarded arrays defeated the layout cache's identity
114+
/// key).
109115
/// </summary>
110-
public static SqlValue DecodeColumn(ReadOnlySpan<SqlType> schema, ReadOnlySpan<byte> bytes, int ordinal)
111-
{
112-
var columns = new HeapColumn[schema.Length];
113-
for (var i = 0; i < schema.Length; i++)
114-
columns[i] = new HeapColumn(string.Empty, schema[i], maxLength: null, nullable: true);
115-
return DecodeColumn(columns, bytes, ordinal, lobStore: null);
116-
}
116+
public static SqlValue DecodeColumn(SqlType[] schema, ReadOnlySpan<byte> bytes, int ordinal) =>
117+
DecodeColumn(ColumnsFor(schema), bytes, ordinal, lobStore: null);
118+
119+
/// <summary>
120+
/// The nameless all-nullable <see cref="HeapColumn"/>[] equivalent of a
121+
/// type-only schema, cached by the schema array's identity (schema arrays
122+
/// are per-result-set and long-lived, mirroring <see cref="RowLayout"/>'s
123+
/// keying).
124+
/// </summary>
125+
public static HeapColumn[] ColumnsFor(SqlType[] schema) =>
126+
typeOnlyColumns.GetValue(schema, static s =>
127+
{
128+
var columns = new HeapColumn[s.Length];
129+
for (var i = 0; i < s.Length; i++)
130+
columns[i] = new HeapColumn(string.Empty, s[i], maxLength: null, nullable: true);
131+
return columns;
132+
});
133+
134+
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<SqlType[], HeapColumn[]> typeOnlyColumns = [];
117135

118136
/// <summary>
119137
/// Array-schema fast path of

0 commit comments

Comments
 (0)