Skip to content

Commit 78481c7

Browse files
author
MPCoreDeveloper
committed
perf(net11): #5 struct-enumerator refactor — ExecuteQueryStruct point lookups 976->471 B/op (-52%)
Replace the two yield-iterator state machines on the StructRow point-lookup path with struct enumerators so foreach is genuinely allocation-free. - Table.ScanStructRowsWhere: now returns a StructRowWhereEnumerable struct (non-yield); the struct enumerator handles the hash-index and primary-key fast paths allocation-free, and delegates the numeric-SIMD/full-scan fallback to the yield-based core (ScanStructRowsWhereCore). Implements IEnumerable<StructRow> for LINQ/boxing via a small class-based enumerator. - SqlParser.ExecuteQueryStruct: eager plan setup (table lookup + WHERE build) returning a StructRowQueryEnumerable struct; the offset/limit logic now lives in the struct enumerator (removed the ExecuteSimpleSelectStruct yield iterator). - Database.ExecuteQueryStruct + IDatabase.ExecuteQueryStruct: return StructRowQueryEnumerable (struct) instead of IEnumerable<StructRow> — foreach on the result (including via IDatabase, the DatabaseFactory return type) is now allocation-free; callers that store the result as IEnumerable<StructRow> still compile (implicit conversion). Microbench (Release, net11): SYNC READ-STRUCT 976 -> 471 B/op (-52%), +13% ops/sec; dictionary ExecuteQuery path unchanged (911 B/op). 1,630 tests green; full CI solution builds.
1 parent 4337ee1 commit 78481c7

7 files changed

Lines changed: 421 additions & 68 deletions

File tree

‎docs/benchmarks/V198_V20_V21_PERFORMANCE_COMPARISON.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,15 @@ Changes landed:
160160

161161
Note on row-dictionary pooling (#5 headline): pooling `Dictionary<string, object>` rows is
162162
structurally unsafe for the existing API — callers retain the returned rows, so a shared pool
163-
would corrupt data across queries. The measured "zero-alloc" `ExecuteQueryStruct` path is still
164-
~1 KB/op on a point lookup: two yield-iterator state machines plus the plan-cache key, the
165-
WHERE-string build and `engine.Read`'s per-read byte[] dominate. A struct-enumerator refactor
166-
of `ExecuteSimpleSelectStruct`/`ScanStructRowsWhere` is the remaining path to genuinely
167-
allocation-free point lookups (deferred — public-API surface change, higher risk).
163+
would corrupt data across queries. The `ExecuteQueryStruct` StructRow path is now genuinely the
164+
low-alloc read path: the two yield-iterator state machines (`ExecuteSimpleSelectStruct` /
165+
`ScanStructRowsWhere`) were replaced with struct enumerators, and `IDatabase.ExecuteQueryStruct`
166+
now returns a struct enumerable (foreach is allocation-free). Post-refactor, a point lookup
167+
allocates **471 B/op** (was 976 B/op after the §6.4 allocation cuts, −52%) against 911 B/op for
168+
the dictionary `ExecuteQuery` path — the remaining bytes are the plan-cache key, the WHERE-string
169+
build, `TryParseSimpleWhereClause`'s two strings, the hash-index position list and `engine.Read`'s
170+
per-read byte[]. The full-scan/SIMD fallback paths delegate to the yield-based core (they allocate
171+
by nature), and LINQ/boxing usage goes through a small class-based enumerator.
168172

169173
### Recommendations for a follow-up benchmark
170174
- Run on a quiet machine with ≥5 repetitions per version and report medians.

‎docs/performance/V2_PERFORMANCE_PLAN.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,17 @@ four `parameters ?? []` empty-dictionary allocations removed; single-file `Execu
142142
hoists the per-query `PRAGMA table_info` regex to a compiled static field and replaces
143143
`sql.Trim().ToUpperInvariant()` with span checks.
144144

145-
**Remaining #5 work — struct-enumerator refactor:** full row-dictionary pooling is
146-
structurally unsafe (callers retain the returned rows; a shared pool would corrupt data),
147-
and `ExecuteQueryStruct` is still ~1 KB/op on a point lookup because two yield-iterator
148-
state machines (plus plan-cache key, WHERE-string build, `engine.Read` byte[]) dominate.
149-
Converting `ExecuteSimpleSelectStruct`/`ScanStructRowsWhere` to struct enumerators
150-
(source-compatible for `foreach`) is the path to genuinely allocation-free point lookups;
151-
it changes the public return-type surface and is deferred as a focused, higher-risk item.
145+
**#5 struct-enumerator refactor — DONE (2026-08-30):** full row-dictionary pooling is
146+
structurally unsafe (callers retain the returned rows; a shared pool would corrupt data), so the
147+
zero-allocation win was delivered via struct enumerators instead. `ExecuteSimpleSelectStruct` and
148+
`ScanStructRowsWhere` are no longer yield iterators: `Table.ScanStructRowsWhere` returns a
149+
`StructRowWhereEnumerable` struct whose enumerator handles the hash-index / primary-key point-lookup
150+
fast paths allocation-free (the SIMD/full-scan fallback delegates to the yield-based core), and
151+
`IDatabase.ExecuteQueryStruct` now returns a `StructRowQueryEnumerable` struct (foreach is
152+
allocation-free; LINQ/boxing goes through a small class-based enumerator). A point lookup dropped
153+
from **976 → 471 B/op (−52%)** on the StructRow path (911 B/op on the dictionary path), with +13%
154+
throughput. Remaining bytes are the plan-cache key, WHERE-string build, `TryParseSimpleWhereClause`
155+
strings, hash-index position list and `engine.Read`'s per-read byte[].
152156

153157

154158
---
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections;
5+
using System.Collections.Generic;
6+
7+
namespace SharpCoreDB.DataStructures;
8+
9+
/// <summary>
10+
/// Zero-allocation enumerable returned by <c>ExecuteQueryStruct</c>. Foreach on this concrete
11+
/// type uses <see cref="StructRowQueryEnumerator"/> (no heap allocation); treating it as
12+
/// <c>IEnumerable&lt;StructRow&gt;</c> (LINQ, boxing) uses a small class-based enumerator.
13+
/// </summary>
14+
public readonly struct StructRowQueryEnumerable : IEnumerable<StructRow>
15+
{
16+
private readonly Table? _table;
17+
private readonly string? _where;
18+
private readonly bool _hasRows;
19+
private readonly int _skipped;
20+
private readonly int? _limit;
21+
22+
internal StructRowQueryEnumerable(Table? table, string? where, bool hasRows, int skipped, int? limit)
23+
{
24+
_table = table;
25+
_where = where;
26+
_hasRows = hasRows;
27+
_skipped = skipped;
28+
_limit = limit;
29+
}
30+
31+
/// <summary>Gets the allocation-free enumerator.</summary>
32+
public StructRowQueryEnumerator GetEnumerator() => new(_table, _where, _hasRows, _skipped, _limit);
33+
34+
IEnumerator<StructRow> IEnumerable<StructRow>.GetEnumerator()
35+
=> new BoxedEnumerator(_table, _where, _hasRows, _skipped, _limit);
36+
37+
IEnumerator IEnumerable.GetEnumerator()
38+
=> ((IEnumerable<StructRow>)this).GetEnumerator();
39+
40+
private sealed class BoxedEnumerator : IEnumerator<StructRow>
41+
{
42+
// NOT readonly: MoveNext mutates the struct enumerator's state.
43+
private StructRowQueryEnumerator _inner;
44+
45+
internal BoxedEnumerator(Table? table, string? where, bool hasRows, int skipped, int? limit)
46+
{
47+
_inner = new StructRowQueryEnumerator(table, where, hasRows, skipped, limit);
48+
}
49+
50+
public StructRow Current => _inner.Current;
51+
object IEnumerator.Current => _inner.Current;
52+
53+
public bool MoveNext() => _inner.MoveNext();
54+
public void Reset() => throw new NotSupportedException();
55+
public void Dispose() => _inner.Dispose();
56+
}
57+
}
58+
59+
/// <summary>
60+
/// Allocation-free enumerator for <see cref="StructRowQueryEnumerable"/>. Drives the table-level
61+
/// <see cref="Table.StructRowWhereEnumerator"/> and applies LIMIT/OFFSET.
62+
/// </summary>
63+
public struct StructRowQueryEnumerator : IDisposable
64+
{
65+
private readonly Table? _table;
66+
private readonly string? _where;
67+
private readonly bool _hasRows;
68+
private readonly int _skipped;
69+
private readonly int? _limit;
70+
private Table.StructRowWhereEnumerator _rows;
71+
private int _index;
72+
private bool _initialized;
73+
private StructRow _current;
74+
75+
internal StructRowQueryEnumerator(Table? table, string? where, bool hasRows, int skipped, int? limit)
76+
{
77+
_table = table;
78+
_where = where;
79+
_hasRows = hasRows;
80+
_skipped = skipped;
81+
_limit = limit;
82+
_rows = default;
83+
_index = 0;
84+
_initialized = false;
85+
_current = default;
86+
}
87+
88+
/// <summary>Gets the current row.</summary>
89+
public StructRow Current => _current;
90+
91+
/// <summary>Advances to the next row (applies OFFSET then LIMIT).</summary>
92+
public bool MoveNext()
93+
{
94+
if (!_initialized)
95+
{
96+
_initialized = true;
97+
if (!_hasRows || _table is null)
98+
{
99+
return false;
100+
}
101+
102+
_rows = _table.ScanStructRowsWhere(_where).GetEnumerator();
103+
_index = 0;
104+
}
105+
106+
while (_rows.MoveNext())
107+
{
108+
if (_index < _skipped)
109+
{
110+
_index++;
111+
continue;
112+
}
113+
114+
if (_limit.HasValue && _index - _skipped >= _limit.Value)
115+
{
116+
return false;
117+
}
118+
119+
_index++;
120+
_current = _rows.Current;
121+
return true;
122+
}
123+
124+
return false;
125+
}
126+
127+
/// <summary>Releases the table-level enumerator (no-op on the allocation-free fast paths).</summary>
128+
public void Dispose() => _rows.Dispose();
129+
}

0 commit comments

Comments
 (0)