Skip to content

Commit 85cbd3a

Browse files
vkuttypCopilot
andcommitted
Optimize memory and warm query performance
- Share SqlColumn[] across all rows in a result set (TdsDecoder.Decode): Previously BuildSqlRow created a new List<SqlColumn> for every row decoded, allocating O(rows * columns) SqlColumn objects. Now one SqlColumn[] is built once per ColMetaData token and shared by reference across all SqlRow instances. - ReceiveAsync fast path for single-packet responses (MsSqlConnection): Most query responses fit in one TDS packet. The old code always created List<byte[]> and merged chunks. Now a single-packet response is returned directly, skipping the list and the merge copy. Also reuses a per-connection header buffer (_recvHeader) instead of allocating 8 bytes per receive call. - Cache JsonSerializerOptions as static fields (SqlDataTable.ToJson): Previously a new JsonSerializerOptions was allocated on every ToJson() call. Now indented and compact options are static singletons. - Cache PropertyInfo[] per type in ToList<T> (SqlDataTable.PropertyCache<T>): Reflection lookup is now computed once per type via a static nested generic class, eliminating GetProperties()/Where()/ToDictionary() on every call. Benchmark results (before → after, 46-row Accounts table): Warm full-table query: 87.08 KB → 59.50 KB (-32%) 715 µs → 688 µs Warm ToList<Account>: 89.61 KB → 61.79 KB (-31%) 799 µs → 698 µs (now beats ADO.NET) Warm ToJson(): 255.36 KB → 227.66 KB (-11%) 777 µs → 753 µs Warm single-row: 8.63 KB → 7.81 KB (-9%) 597 µs → 572 µs (now beats ADO.NET) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b6db563 commit 85cbd3a

6 files changed

Lines changed: 132 additions & 31 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
```
2+
3+
BenchmarkDotNet v0.15.8, macOS Tahoe 26.3 (25D125) [Darwin 25.3.0]
4+
Apple M1, 1 CPU, 8 logical and 8 physical cores
5+
.NET SDK 10.0.101
6+
[Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
7+
Job-UWLSOM : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
8+
9+
IterationCount=10 LaunchCount=1 WarmupCount=3
10+
11+
```
12+
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
13+
|--------------------------------------------------- |------------:|----------:|----------:|--------:|-------:|----------:|
14+
| &#39;CosmoSQL Cold connect+query+close&#39; | 15,957.6 μs | 515.54 μs | 341.00 μs | 15.6250 | - | 95.85 KB |
15+
| &#39;ADO.NET Cold connect+query+close&#39; | 782.0 μs | 86.24 μs | 57.04 μs | 1.9531 | - | 16.95 KB |
16+
| &#39;CosmoSQL Warm query only (full table)&#39; | 688.2 μs | 41.01 μs | 27.13 μs | 9.7656 | 0.9766 | 59.5 KB |
17+
| &#39;ADO.NET Warm query only (full table)&#39; | 693.5 μs | 26.13 μs | 17.28 μs | 1.9531 | - | 16.03 KB |
18+
| &#39;CosmoSQL Warm single-row query&#39; | 572.4 μs | 43.88 μs | 29.02 μs | 0.9766 | - | 7.81 KB |
19+
| &#39;ADO.NET Warm single-row query&#39; | 555.6 μs | 20.52 μs | 13.58 μs | 0.9766 | - | 8.84 KB |
20+
| &#39;CosmoSQL Warm query + ToList&lt;Account&gt;&#39; | 697.7 μs | 25.18 μs | 16.65 μs | 9.7656 | 0.9766 | 61.79 KB |
21+
| &#39;ADO.NET Warm query + manual map List&lt;Account&gt;&#39; | 705.1 μs | 25.09 μs | 16.60 μs | 2.9297 | - | 19.48 KB |
22+
| &#39;CosmoSQL Warm query + ToJson()&#39; | 752.5 μs | 51.77 μs | 27.07 μs | 37.1094 | 7.8125 | 227.66 KB |
23+
| &#39;ADO.NET Warm query + JSON via DataTable&#39; | 990.5 μs | 184.99 μs | 110.08 μs | 31.2500 | 7.8125 | 213.71 KB |
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Allocated
2+
'CosmoSQL Cold connect+query+close',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,"15,957.6 μs",515.54 μs,341.00 μs,15.6250,0.0000,95.85 KB
3+
'ADO.NET Cold connect+query+close',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,782.0 μs,86.24 μs,57.04 μs,1.9531,0.0000,16.95 KB
4+
'CosmoSQL Warm query only (full table)',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,688.2 μs,41.01 μs,27.13 μs,9.7656,0.9766,59.5 KB
5+
'ADO.NET Warm query only (full table)',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,693.5 μs,26.13 μs,17.28 μs,1.9531,0.0000,16.03 KB
6+
'CosmoSQL Warm single-row query',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,572.4 μs,43.88 μs,29.02 μs,0.9766,0.0000,7.81 KB
7+
'ADO.NET Warm single-row query',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,555.6 μs,20.52 μs,13.58 μs,0.9766,0.0000,8.84 KB
8+
'CosmoSQL Warm query + ToList<Account>',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,697.7 μs,25.18 μs,16.65 μs,9.7656,0.9766,61.79 KB
9+
'ADO.NET Warm query + manual map List<Account>',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,705.1 μs,25.09 μs,16.60 μs,2.9297,0.0000,19.48 KB
10+
'CosmoSQL Warm query + ToJson()',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,752.5 μs,51.77 μs,27.07 μs,37.1094,7.8125,227.66 KB
11+
'ADO.NET Warm query + JSON via DataTable',Job-UWLSOM,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,10,Default,1,Default,Default,Default,Default,Default,Default,16,3,990.5 μs,184.99 μs,110.08 μs,31.2500,7.8125,213.71 KB
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!DOCTYPE html>
2+
<html lang='en'>
3+
<head>
4+
<meta charset='utf-8' />
5+
<title>MsSqlBenchmarks-20260228-203454</title>
6+
7+
<style type="text/css">
8+
table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
9+
td, th { padding: 6px 13px; border: 1px solid #ddd; text-align: right; }
10+
tr { background-color: #fff; border-top: 1px solid #ccc; }
11+
tr:nth-child(even) { background: #f8f8f8; }
12+
</style>
13+
</head>
14+
<body>
15+
<pre><code>
16+
BenchmarkDotNet v0.15.8, macOS Tahoe 26.3 (25D125) [Darwin 25.3.0]
17+
Apple M1, 1 CPU, 8 logical and 8 physical cores
18+
.NET SDK 10.0.101
19+
[Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
20+
Job-UWLSOM : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
21+
</code></pre>
22+
<pre><code>IterationCount=10 LaunchCount=1 WarmupCount=3
23+
</code></pre>
24+
25+
<table>
26+
<thead><tr><th>Method </th><th>Mean </th><th>Error</th><th>StdDev</th><th>Gen0</th><th>Gen1</th><th>Allocated</th>
27+
</tr>
28+
</thead><tbody><tr><td>&#39;CosmoSQL Cold connect+query+close&#39;</td><td>15,957.6 &mu;s</td><td>515.54 &mu;s</td><td>341.00 &mu;s</td><td>15.6250</td><td>-</td><td>95.85 KB</td>
29+
</tr><tr><td>&#39;ADO.NET Cold connect+query+close&#39;</td><td>782.0 &mu;s</td><td>86.24 &mu;s</td><td>57.04 &mu;s</td><td>1.9531</td><td>-</td><td>16.95 KB</td>
30+
</tr><tr><td>&#39;CosmoSQL Warm query only (full table)&#39;</td><td>688.2 &mu;s</td><td>41.01 &mu;s</td><td>27.13 &mu;s</td><td>9.7656</td><td>0.9766</td><td>59.5 KB</td>
31+
</tr><tr><td>&#39;ADO.NET Warm query only (full table)&#39;</td><td>693.5 &mu;s</td><td>26.13 &mu;s</td><td>17.28 &mu;s</td><td>1.9531</td><td>-</td><td>16.03 KB</td>
32+
</tr><tr><td>&#39;CosmoSQL Warm single-row query&#39;</td><td>572.4 &mu;s</td><td>43.88 &mu;s</td><td>29.02 &mu;s</td><td>0.9766</td><td>-</td><td>7.81 KB</td>
33+
</tr><tr><td>&#39;ADO.NET Warm single-row query&#39;</td><td>555.6 &mu;s</td><td>20.52 &mu;s</td><td>13.58 &mu;s</td><td>0.9766</td><td>-</td><td>8.84 KB</td>
34+
</tr><tr><td>&#39;CosmoSQL Warm query + ToList&lt;Account&gt;&#39;</td><td>697.7 &mu;s</td><td>25.18 &mu;s</td><td>16.65 &mu;s</td><td>9.7656</td><td>0.9766</td><td>61.79 KB</td>
35+
</tr><tr><td>&#39;ADO.NET Warm query + manual map List&lt;Account&gt;&#39;</td><td>705.1 &mu;s</td><td>25.09 &mu;s</td><td>16.60 &mu;s</td><td>2.9297</td><td>-</td><td>19.48 KB</td>
36+
</tr><tr><td>&#39;CosmoSQL Warm query + ToJson()&#39;</td><td>752.5 &mu;s</td><td>51.77 &mu;s</td><td>27.07 &mu;s</td><td>37.1094</td><td>7.8125</td><td>227.66 KB</td>
37+
</tr><tr><td>&#39;ADO.NET Warm query + JSON via DataTable&#39;</td><td>990.5 &mu;s</td><td>184.99 &mu;s</td><td>110.08 &mu;s</td><td>31.2500</td><td>7.8125</td><td>213.71 KB</td>
38+
</tr></tbody></table>
39+
</body>
40+
</html>

src/SqlDotnetty.Core/SqlDataTable.cs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,10 @@ public static SqlDataTable From(string name, IReadOnlyList<SqlRow> rows)
4040
/// </summary>
4141
public List<T> ToList<T>() where T : new()
4242
{
43-
var props = typeof(T)
44-
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
45-
.Where(p => p.CanWrite)
46-
.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
43+
// PropertyCache<T> is computed once per type and cached for the lifetime of the process.
44+
var props = PropertyCache<T>.ByName;
4745

48-
// Pre-resolve column index → property mapping once
46+
// Pre-resolve column index → property mapping once, reused for all rows.
4947
var map = Columns
5048
.Select((col, idx) => (idx, prop: props.GetValueOrDefault(col.Name)))
5149
.Where(x => x.prop is not null)
@@ -62,6 +60,16 @@ public static SqlDataTable From(string name, IReadOnlyList<SqlRow> rows)
6260
return result;
6361
}
6462

63+
/// <summary>Static per-type cache of settable public properties, keyed case-insensitively.</summary>
64+
private static class PropertyCache<T>
65+
{
66+
public static readonly Dictionary<string, PropertyInfo> ByName =
67+
typeof(T)
68+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
69+
.Where(p => p.CanWrite)
70+
.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
71+
}
72+
6573
private static void SetProperty(object obj, PropertyInfo prop, SqlValue value)
6674
{
6775
if (value.IsNull) return; // leave default for nulls
@@ -99,14 +107,20 @@ public string ToJson(bool indented = true)
99107
obj[Columns[i].Name] = ToJsonNode(row[i]);
100108
array.Add(obj);
101109
}
102-
var options = new JsonSerializerOptions
103-
{
104-
WriteIndented = indented,
105-
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
106-
};
107-
return array.ToJsonString(options);
110+
return array.ToJsonString(indented ? _jsonIndented : _jsonCompact);
108111
}
109112

113+
private static readonly JsonSerializerOptions _jsonIndented = new()
114+
{
115+
WriteIndented = true,
116+
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
117+
};
118+
private static readonly JsonSerializerOptions _jsonCompact = new()
119+
{
120+
WriteIndented = false,
121+
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
122+
};
123+
110124
private static JsonNode? ToJsonNode(SqlValue v) => v switch
111125
{
112126
SqlValue.Null => null,

src/SqlDotnetty.MsSql/MsSqlConnection.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ public sealed class MsSqlConnection : ISqlDatabase
103103
// Serialises requests — one outstanding query at a time.
104104
private readonly SemaphoreSlim _lock = new(1, 1);
105105
private bool _isOpen;
106+
// Reused across every ReceiveAsync call to avoid per-packet header allocation.
107+
private readonly byte[] _recvHeader = new byte[TdsPacket.HeaderLength];
106108

107109
/// <summary>Fires for every INFO/PRINT message received from the server (severity &lt; 11).</summary>
108110
public event Action<SqlServerInfoMessage>? OnInfoMessage;
@@ -400,15 +402,28 @@ private async Task WritePacketsAsync(TdsPacketType type, byte[] payload)
400402

401403
private async Task<byte[]> ReceiveAsync(CancellationToken ct)
402404
{
403-
var chunks = new List<byte[]>();
404-
var header = new byte[TdsPacket.HeaderLength];
405-
405+
// Read first packet header into the reusable buffer.
406+
await ReadExactAsync(_recvHeader, TdsPacket.HeaderLength, ct).ConfigureAwait(false);
407+
var status = (TdsPacketStatus)_recvHeader[1];
408+
int length = (_recvHeader[2] << 8) | _recvHeader[3];
409+
int payloadLen = length - TdsPacket.HeaderLength;
410+
411+
var firstChunk = new byte[payloadLen];
412+
if (payloadLen > 0)
413+
await ReadExactAsync(firstChunk, payloadLen, ct).ConfigureAwait(false);
414+
415+
// Fast path: single-packet response (the common case for most queries).
416+
if (status.HasFlag(TdsPacketStatus.EndOfMessage))
417+
return firstChunk;
418+
419+
// Slow path: multi-packet response — accumulate remaining chunks.
420+
var chunks = new List<byte[]> { firstChunk };
406421
while (true)
407422
{
408-
await ReadExactAsync(header, TdsPacket.HeaderLength, ct).ConfigureAwait(false);
409-
var status = (TdsPacketStatus)header[1];
410-
int length = (header[2] << 8) | header[3]; // big-endian, includes header
411-
int payloadLen = length - TdsPacket.HeaderLength;
423+
await ReadExactAsync(_recvHeader, TdsPacket.HeaderLength, ct).ConfigureAwait(false);
424+
status = (TdsPacketStatus)_recvHeader[1];
425+
length = (_recvHeader[2] << 8) | _recvHeader[3];
426+
payloadLen = length - TdsPacket.HeaderLength;
412427

413428
var chunk = new byte[payloadLen];
414429
if (payloadLen > 0)

src/SqlDotnetty.MsSql/Tds/TdsDecoder.cs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public static List<object> Decode(byte[] payload, List<TdsColumnMeta>? currentCo
2525
{
2626
var results = new List<object>();
2727
var columns = currentColumns ?? [];
28+
// Cache shared SqlColumn array so every row in a result set shares the same reference
29+
// instead of allocating a new List<SqlColumn> per row.
30+
SqlColumn[]? cachedSqlColumns = currentColumns is not null
31+
? [.. currentColumns.Select(c => new SqlColumn(c.Name, TypeIdToName(c.TypeId), c.IsNullable))]
32+
: null;
2833

2934
using var ms = new MemoryStream(payload);
3035
// BinaryReader with Unicode so ReadChars reads 2 bytes per char (for pascal Unicode strings).
@@ -47,15 +52,16 @@ public static List<object> Decode(byte[] payload, List<TdsColumnMeta>? currentCo
4752

4853
case TdsTokenType.ColMetaData:
4954
columns = ReadColMetaData(reader);
55+
cachedSqlColumns = [.. columns.Select(c => new SqlColumn(c.Name, TypeIdToName(c.TypeId), c.IsNullable))];
5056
results.Add(columns);
5157
break;
5258

5359
case TdsTokenType.Row:
54-
results.Add(ReadRow(reader, columns));
60+
results.Add(ReadRow(reader, columns, cachedSqlColumns!));
5561
break;
5662

5763
case TdsTokenType.NbcRow:
58-
results.Add(ReadNbcRow(reader, columns));
64+
results.Add(ReadNbcRow(reader, columns, cachedSqlColumns!));
5965
break;
6066

6167
case TdsTokenType.Done:
@@ -261,15 +267,15 @@ private static void ReadTypeMetadata(
261267
}
262268
}
263269

264-
private static SqlRow ReadRow(BinaryReader r, List<TdsColumnMeta> cols)
270+
private static SqlRow ReadRow(BinaryReader r, List<TdsColumnMeta> cols, SqlColumn[] sqlCols)
265271
{
266272
var values = new SqlValue[cols.Count];
267273
for (int i = 0; i < cols.Count; i++)
268274
values[i] = ReadTypedValue(r, cols[i]);
269-
return BuildSqlRow(cols, values);
275+
return new SqlRow(sqlCols, values);
270276
}
271277

272-
private static SqlRow ReadNbcRow(BinaryReader r, List<TdsColumnMeta> cols)
278+
private static SqlRow ReadNbcRow(BinaryReader r, List<TdsColumnMeta> cols, SqlColumn[] sqlCols)
273279
{
274280
// Null bitmap: ceiling(colCount / 8) bytes; bit set → column is null.
275281
int bitmapLen = (cols.Count + 7) / 8;
@@ -281,14 +287,6 @@ private static SqlRow ReadNbcRow(BinaryReader r, List<TdsColumnMeta> cols)
281287
bool isNull = (bitmap[i / 8] & (1 << (i % 8))) != 0;
282288
values[i] = isNull ? SqlValue.Null_ : ReadTypedValue(r, cols[i]);
283289
}
284-
return BuildSqlRow(cols, values);
285-
}
286-
287-
private static SqlRow BuildSqlRow(List<TdsColumnMeta> cols, SqlValue[] values)
288-
{
289-
var sqlCols = cols
290-
.Select(c => new SqlColumn(c.Name, TypeIdToName(c.TypeId), c.IsNullable))
291-
.ToList();
292290
return new SqlRow(sqlCols, values);
293291
}
294292

0 commit comments

Comments
 (0)