From a8df79b63539da45de52ba1185dfd3012b17bf96 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 3 Mar 2026 20:41:56 -0800 Subject: [PATCH 1/5] feat: enhance dirty page management by improving TryGetDirtyPage and adding buffer pinning --- .../Paging/PageBufferManager.cs | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/CSharpDB.Storage/Paging/PageBufferManager.cs b/src/CSharpDB.Storage/Paging/PageBufferManager.cs index 329aeca..94d031a 100644 --- a/src/CSharpDB.Storage/Paging/PageBufferManager.cs +++ b/src/CSharpDB.Storage/Paging/PageBufferManager.cs @@ -15,6 +15,7 @@ internal sealed class PageBufferManager private readonly IPageOperationInterceptor _interceptor; private readonly bool _hasInterceptor; private readonly HashSet _dirtyPages = new(); + private readonly Dictionary _dirtyBuffers = new(); public PageBufferManager( IPageCache cache, @@ -43,7 +44,12 @@ public PageBufferManager( return page; } - public bool TryGetDirtyPage(uint pageId, out byte[] page) => _cache.TryGet(pageId, out page); + public bool TryGetDirtyPage(uint pageId, out byte[] page) + { + if (_dirtyBuffers.TryGetValue(pageId, out page!)) + return true; + return _cache.TryGet(pageId, out page); + } public ValueTask GetPageAsync(IStorageDevice device, uint pageId, CancellationToken ct = default) { @@ -108,31 +114,47 @@ public ValueTask MarkDirtyAsync( throw new CSharpDbException(ErrorCode.Unknown, "Cannot mark pages dirty outside a transaction."); _dirtyPages.Add(pageId); - if (!_cache.Contains(pageId)) - return EnsurePageInCacheAsync(pageId, getPageAsync, ct); - return ValueTask.CompletedTask; + // Pin the page buffer so it survives LRU cache eviction before commit. + if (_cache.TryGet(pageId, out var buffer)) + { + _dirtyBuffers.TryAdd(pageId, buffer); + return ValueTask.CompletedTask; + } + + return EnsurePageInCacheAndPinAsync(pageId, getPageAsync, ct); } - public void AddDirty(uint pageId) => _dirtyPages.Add(pageId); + public void AddDirty(uint pageId) + { + _dirtyPages.Add(pageId); + if (_cache.TryGet(pageId, out var buffer)) + _dirtyBuffers.TryAdd(pageId, buffer); + } public void SetCached(uint pageId, byte[] page) => _cache.Set(pageId, page); - public void ClearDirty() => _dirtyPages.Clear(); + public void ClearDirty() + { + _dirtyPages.Clear(); + _dirtyBuffers.Clear(); + } public void ClearAll() { _dirtyPages.Clear(); + _dirtyBuffers.Clear(); _cache.Clear(); } public void ClearCache() => _cache.Clear(); - private async ValueTask EnsurePageInCacheAsync( + private async ValueTask EnsurePageInCacheAndPinAsync( uint pageId, Func> getPageAsync, CancellationToken ct) { - await getPageAsync(pageId, ct); + var page = await getPageAsync(pageId, ct); + _dirtyBuffers.TryAdd(pageId, page); } } From 19e65a70b5d13de52dedf97695ae839728105bd7 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 3 Mar 2026 22:21:50 -0800 Subject: [PATCH 2/5] feat: update version to 1.1.0 in Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0001cfa..ad66416 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -7,7 +7,7 @@ git database;embedded;sql;dotnet;btree;wal README.md - 1.0.0 + 1.1.0 From 416587a3ede83eb7629b325e3a056727ece8cbef Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 4 Mar 2026 17:42:35 -0800 Subject: [PATCH 3/5] enhance dirty page management and add unit tests for eviction behavior --- docs/images/icon.png | Bin 0 -> 12663 bytes .../Paging/PageBufferManager.cs | 46 +++++++-- src/CSharpDB.Storage/Paging/Pager.cs | 36 ++++--- src/Directory.Build.props | 2 + tests/CSharpDB.Tests/PagerDirtyBufferTests.cs | 89 ++++++++++++++++++ 5 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 docs/images/icon.png create mode 100644 tests/CSharpDB.Tests/PagerDirtyBufferTests.cs diff --git a/docs/images/icon.png b/docs/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f17523ffea92f0a047573615b953d25d6c8ce7d9 GIT binary patch literal 12663 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 literal 0 HcmV?d00001 diff --git a/src/CSharpDB.Storage/Paging/PageBufferManager.cs b/src/CSharpDB.Storage/Paging/PageBufferManager.cs index 94d031a..508d72c 100644 --- a/src/CSharpDB.Storage/Paging/PageBufferManager.cs +++ b/src/CSharpDB.Storage/Paging/PageBufferManager.cs @@ -40,15 +40,35 @@ public PageBufferManager( public byte[]? TryGetCachedPage(uint pageId) { - _cache.TryGet(pageId, out var page); - return page; + if (_cache.TryGet(pageId, out var page)) + return page; + + // Dirty pages can outlive bounded-cache eviction until commit. + if (_dirtyBuffers.TryGetValue(pageId, out page!)) + { + _cache.Set(pageId, page); + return page; + } + + return null; } public bool TryGetDirtyPage(uint pageId, out byte[] page) { + // Prefer the cache copy when present, then refresh the pinned reference. + if (_cache.TryGet(pageId, out page)) + { + _dirtyBuffers[pageId] = page; + return true; + } + if (_dirtyBuffers.TryGetValue(pageId, out page!)) + { + _cache.Set(pageId, page); return true; - return _cache.TryGet(pageId, out page); + } + + return false; } public ValueTask GetPageAsync(IStorageDevice device, uint pageId, CancellationToken ct = default) @@ -72,6 +92,14 @@ private async ValueTask GetPageCoreAsync(IStorageDevice device, uint pag return cached; } + if (_dirtyBuffers.TryGetValue(pageId, out var dirtyBuffer)) + { + _cache.Set(pageId, dirtyBuffer); + if (_hasInterceptor) + await _interceptor.OnAfterReadAsync(pageId, PageReadSource.Cache, ct); + return dirtyBuffer; + } + if (_isSnapshotReader && _readerSnapshot != null) { if (_readerSnapshot.TryGet(pageId, out long walOffset)) @@ -118,7 +146,13 @@ public ValueTask MarkDirtyAsync( // Pin the page buffer so it survives LRU cache eviction before commit. if (_cache.TryGet(pageId, out var buffer)) { - _dirtyBuffers.TryAdd(pageId, buffer); + _dirtyBuffers[pageId] = buffer; + return ValueTask.CompletedTask; + } + + if (_dirtyBuffers.TryGetValue(pageId, out var pinned)) + { + _cache.Set(pageId, pinned); return ValueTask.CompletedTask; } @@ -129,7 +163,7 @@ public void AddDirty(uint pageId) { _dirtyPages.Add(pageId); if (_cache.TryGet(pageId, out var buffer)) - _dirtyBuffers.TryAdd(pageId, buffer); + _dirtyBuffers[pageId] = buffer; } public void SetCached(uint pageId, byte[] page) => _cache.Set(pageId, page); @@ -155,6 +189,6 @@ private async ValueTask EnsurePageInCacheAndPinAsync( CancellationToken ct) { var page = await getPageAsync(pageId, ct); - _dirtyBuffers.TryAdd(pageId, page); + _dirtyBuffers[pageId] = page; } } diff --git a/src/CSharpDB.Storage/Paging/Pager.cs b/src/CSharpDB.Storage/Paging/Pager.cs index 3c9e2aa..2ebda24 100644 --- a/src/CSharpDB.Storage/Paging/Pager.cs +++ b/src/CSharpDB.Storage/Paging/Pager.cs @@ -269,19 +269,23 @@ public async ValueTask CommitAsync(CancellationToken ct = default) for (int i = 0; i < orderedDirtyCount; i++) { uint pageId = orderedDirtyPageIds[i]; - if (_buffers.TryGetDirtyPage(pageId, out var data)) + if (!_buffers.TryGetDirtyPage(pageId, out var data)) { - bool writeSucceeded = false; - await _interceptor.OnBeforeWriteAsync(pageId, ct); - try - { - await _wal.AppendFrameAsync(pageId, data, ct); - writeSucceeded = true; - } - finally - { - await _interceptor.OnAfterWriteAsync(pageId, writeSucceeded, ct); - } + throw new CSharpDbException( + ErrorCode.Unknown, + $"Dirty page {pageId} could not be materialized during commit."); + } + + bool writeSucceeded = false; + await _interceptor.OnBeforeWriteAsync(pageId, ct); + try + { + await _wal.AppendFrameAsync(pageId, data, ct); + writeSucceeded = true; + } + finally + { + await _interceptor.OnAfterWriteAsync(pageId, writeSucceeded, ct); } } @@ -311,10 +315,14 @@ public async ValueTask CommitAsync(CancellationToken ct = default) for (int i = 0; i < orderedDirtyCount; i++) { uint pageId = orderedDirtyPageIds[i]; - if (_buffers.TryGetDirtyPage(pageId, out var data)) + if (!_buffers.TryGetDirtyPage(pageId, out var data)) { - frameBatch[frameCount++] = new WalFrameWrite(pageId, data); + throw new CSharpDbException( + ErrorCode.Unknown, + $"Dirty page {pageId} could not be materialized during commit."); } + + frameBatch[frameCount++] = new WalFrameWrite(pageId, data); } if (frameCount > 0) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ad66416..057b51b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -7,9 +7,11 @@ git database;embedded;sql;dotnet;btree;wal README.md + icon.png 1.1.0 + diff --git a/tests/CSharpDB.Tests/PagerDirtyBufferTests.cs b/tests/CSharpDB.Tests/PagerDirtyBufferTests.cs new file mode 100644 index 0000000..943feca --- /dev/null +++ b/tests/CSharpDB.Tests/PagerDirtyBufferTests.cs @@ -0,0 +1,89 @@ +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +public sealed class PagerDirtyBufferTests +{ + [Fact] + public async Task DirtyPage_EvictedAndRevisitedWithinTransaction_PreservesAllMutations() + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_dirty_buffer_test_{Guid.NewGuid():N}.db"); + string walPath = dbPath + ".wal"; + var options = new PagerOptions + { + MaxCachedPages = 1, + CheckpointPolicy = new FrameCountCheckpointPolicy(10_000), + }; + + try + { + uint pageId; + await using (var pager = await OpenPagerAsync(dbPath, options, createNew: true, ct)) + { + await pager.BeginTransactionAsync(ct); + pageId = await pager.AllocatePageAsync(ct); + var baselinePage = await pager.GetPageAsync(pageId, ct); + baselinePage[100] = 10; + await pager.MarkDirtyAsync(pageId, ct); + await pager.CommitAsync(ct); + + await pager.BeginTransactionAsync(ct); + var firstBuffer = await pager.GetPageAsync(pageId, ct); + firstBuffer[100] = 20; + await pager.MarkDirtyAsync(pageId, ct); + + uint evictingPageId = await pager.AllocatePageAsync(ct); + var evictingPage = await pager.GetPageAsync(evictingPageId, ct); + evictingPage[200] = 77; + await pager.MarkDirtyAsync(evictingPageId, ct); + + var revisitedBuffer = await pager.GetPageAsync(pageId, ct); + Assert.Same(firstBuffer, revisitedBuffer); + + revisitedBuffer[101] = 30; + await pager.MarkDirtyAsync(pageId, ct); + await pager.CommitAsync(ct); + } + + await using (var verifyPager = await OpenPagerAsync(dbPath, options, createNew: false, ct)) + { + var persistedPage = await verifyPager.GetPageAsync(pageId, ct); + Assert.Equal((byte)20, persistedPage[100]); + Assert.Equal((byte)30, persistedPage[101]); + } + } + finally + { + DeleteIfExists(dbPath); + DeleteIfExists(walPath); + } + } + + private static async ValueTask OpenPagerAsync( + string dbPath, + PagerOptions options, + bool createNew, + CancellationToken ct) + { + var device = new FileStorageDevice(dbPath, createNew); + var walIndex = new WalIndex(); + var wal = new WriteAheadLog(dbPath, walIndex); + var pager = await Pager.CreateAsync(device, wal, walIndex, options, ct); + + if (createNew) + await pager.InitializeNewDatabaseAsync(ct); + + await pager.RecoverAsync(ct); + return pager; + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } +} From 8e7f6d286110b20dd8c2d6bdddbd91d417a018fd Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 4 Mar 2026 23:27:36 -0800 Subject: [PATCH 4/5] Enhance Benchmark Tests with Warmup Iterations and Improved Result Handling - Added warmup iterations for point lookup and full scan benchmarks to reduce latency variability. - Introduced minimum and maximum iteration limits for lookup and scan benchmarks to ensure consistent measurement. - Normalized scan phase against a checkpointed state to minimize WAL-related jitter. - Refactored WAL growth benchmark to include warmup reads and improved timing for read latency results. - Updated performance guardrails results to reflect recent benchmark improvements. - Enhanced PowerShell script for resolving current micro results directory, allowing for better handling of benchmark result paths. --- src/CSharpDB.Data/CSharpDbDataReader.cs | 22 ++ src/CSharpDB.Storage/BTree/BTree.cs | 44 ++- .../Caching/DictionaryPageCache.cs | 26 +- .../Caching/IPageCacheEvictionEvents.cs | 9 + src/CSharpDB.Storage/Caching/LruPageCache.cs | 17 +- .../Paging/PageBufferManager.cs | 62 +++- src/CSharpDB.Storage/Paging/Pager.cs | 11 +- src/CSharpDB.Storage/Paging/PagerOptions.cs | 6 + .../Infrastructure/BenchmarkProcessTuner.cs | 99 +++++++ .../BenchmarkResultAggregator.cs | 69 +++++ tests/CSharpDB.Benchmarks/Program.cs | 269 ++++++++++++++---- tests/CSharpDB.Benchmarks/README.md | 242 +++++++++------- .../Scaling/BTreeDepthBenchmark.cs | 20 +- .../Scaling/RowCountScalingBenchmark.cs | 67 ++++- .../Stress/WalGrowthBenchmark.cs | 27 +- .../results/perf-guardrails-last.md | 180 ++++++------ .../scripts/Compare-Baseline.ps1 | 93 +++++- 17 files changed, 967 insertions(+), 296 deletions(-) create mode 100644 src/CSharpDB.Storage/Caching/IPageCacheEvictionEvents.cs create mode 100644 tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkProcessTuner.cs create mode 100644 tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkResultAggregator.cs diff --git a/src/CSharpDB.Data/CSharpDbDataReader.cs b/src/CSharpDB.Data/CSharpDbDataReader.cs index 6607fff..f6eabfc 100644 --- a/src/CSharpDB.Data/CSharpDbDataReader.cs +++ b/src/CSharpDB.Data/CSharpDbDataReader.cs @@ -10,10 +10,12 @@ namespace CSharpDB.Data; public sealed class CSharpDbDataReader : DbDataReader { + private const int OrdinalLookupThreshold = 8; private readonly QueryResult _queryResult; private readonly CommandBehavior _behavior; private readonly CSharpDbConnection? _connection; private readonly ColumnDefinition[] _schema; + private readonly Dictionary? _ordinalLookup; private DbValue[]? _currentRow; private int _currentRowIndex = -1; @@ -30,6 +32,7 @@ internal CSharpDbDataReader( _behavior = behavior; _connection = connection; _schema = queryResult.Schema; + _ordinalLookup = BuildOrdinalLookupIfNeeded(_schema); } private DbValue[] CurrentRow @@ -80,6 +83,9 @@ public override Task NextResultAsync(CancellationToken cancellationToken) public override int GetOrdinal(string name) { + if (_ordinalLookup != null && _ordinalLookup.TryGetValue(name, out int ordinal)) + return ordinal; + for (int i = 0; i < _schema.Length; i++) { if (string.Equals(_schema[i].Name, name, StringComparison.OrdinalIgnoreCase)) @@ -88,6 +94,22 @@ public override int GetOrdinal(string name) throw new IndexOutOfRangeException($"Column '{name}' not found."); } + private static Dictionary? BuildOrdinalLookupIfNeeded(ColumnDefinition[] schema) + { + if (schema.Length < OrdinalLookupThreshold) + return null; + + var lookup = new Dictionary(schema.Length, StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < schema.Length; i++) + { + string columnName = schema[i].Name; + if (!lookup.ContainsKey(columnName)) + lookup[columnName] = i; + } + + return lookup; + } + public override string GetDataTypeName(int ordinal) => TypeMapper.ToDataTypeName(_schema[ordinal].Type); diff --git a/src/CSharpDB.Storage/BTree/BTree.cs b/src/CSharpDB.Storage/BTree/BTree.cs index d565141..2d21c9f 100644 --- a/src/CSharpDB.Storage/BTree/BTree.cs +++ b/src/CSharpDB.Storage/BTree/BTree.cs @@ -319,21 +319,39 @@ private async ValueTask InsertIntoLeafAsync(uint pageId, byte[] pa // Preserve the stack-built cell for split handling. byte[] splitCell = GC.AllocateUninitializedArray(leafCellLength); stackCell.CopyTo(splitCell); - return await SplitLeafAsync(pageId, page, sp, insertIdx, splitCell, ct); + return await SplitLeafAsync(pageId, page, sp, insertIdx, splitCell, splitCell.Length, ct); } - byte[] heapCell = BuildLeafCell(key, payload.Span); - if (sp.InsertCell(insertIdx, heapCell)) + // For larger payloads, rent a temporary buffer instead of allocating per insert. + byte[] pooledCell = ArrayPool.Shared.Rent(leafCellLength); + try { - await _pager.MarkDirtyAsync(pageId, ct); - return new InsertResult { Split = false }; - } + var pooledCellSpan = pooledCell.AsSpan(0, leafCellLength); + WriteLeafCell(pooledCellSpan, key, payload.Span); + + if (sp.InsertCell(insertIdx, pooledCellSpan)) + { + await _pager.MarkDirtyAsync(pageId, ct); + return new InsertResult { Split = false }; + } - // Page is full — split - return await SplitLeafAsync(pageId, page, sp, insertIdx, heapCell, ct); + // Page is full — split + return await SplitLeafAsync(pageId, page, sp, insertIdx, pooledCell, leafCellLength, ct); + } + finally + { + ArrayPool.Shared.Return(pooledCell, clearArray: false); + } } - private async ValueTask SplitLeafAsync(uint pageId, byte[] page, SlottedPage sp, int insertIdx, byte[] newCell, CancellationToken ct) + private async ValueTask SplitLeafAsync( + uint pageId, + byte[] page, + SlottedPage sp, + int insertIdx, + byte[] newCell, + int newCellLength, + CancellationToken ct) { int existingCellCount = sp.CellCount; int totalCellCount = existingCellCount + 1; @@ -341,7 +359,13 @@ private async ValueTask SplitLeafAsync(uint pageId, byte[] page, S byte[]? splitCellBuffer = null; try { - splitCellBuffer = BuildSplitCellBuffer(page, sp, insertIdx, newCell, cellOffsets, out int totalCellBytes); + splitCellBuffer = BuildSplitCellBuffer( + page, + sp, + insertIdx, + newCell.AsSpan(0, newCellLength), + cellOffsets, + out int totalCellBytes); cellOffsets[totalCellCount] = totalCellBytes; int mid = totalCellCount / 2; diff --git a/src/CSharpDB.Storage/Caching/DictionaryPageCache.cs b/src/CSharpDB.Storage/Caching/DictionaryPageCache.cs index 1cceecb..5c386b3 100644 --- a/src/CSharpDB.Storage/Caching/DictionaryPageCache.cs +++ b/src/CSharpDB.Storage/Caching/DictionaryPageCache.cs @@ -4,21 +4,41 @@ namespace CSharpDB.Storage.Caching; /// Default in-memory page cache backed by a dictionary. /// Maintains current behavior (unbounded, no eviction). /// -public sealed class DictionaryPageCache : IPageCache +public sealed class DictionaryPageCache : IPageCache, IPageCacheEvictionEvents { private readonly Dictionary _pages = new(); + public event Action? PageEvicted; public bool TryGet(uint pageId, out byte[] page) => _pages.TryGetValue(pageId, out page!); public void Set(uint pageId, byte[] page) { + if (_pages.TryGetValue(pageId, out var existing) && !ReferenceEquals(existing, page)) + PageEvicted?.Invoke(pageId, existing); + _pages[pageId] = page; } public bool Contains(uint pageId) => _pages.ContainsKey(pageId); - public bool Remove(uint pageId) => _pages.Remove(pageId); + public bool Remove(uint pageId) + { + if (!_pages.Remove(pageId, out var page)) + return false; + + PageEvicted?.Invoke(pageId, page); + return true; + } - public void Clear() => _pages.Clear(); + public void Clear() + { + if (PageEvicted != null) + { + foreach (var entry in _pages) + PageEvicted(entry.Key, entry.Value); + } + + _pages.Clear(); + } } diff --git a/src/CSharpDB.Storage/Caching/IPageCacheEvictionEvents.cs b/src/CSharpDB.Storage/Caching/IPageCacheEvictionEvents.cs new file mode 100644 index 0000000..b8a7bdb --- /dev/null +++ b/src/CSharpDB.Storage/Caching/IPageCacheEvictionEvents.cs @@ -0,0 +1,9 @@ +namespace CSharpDB.Storage.Caching; + +/// +/// Optional event surface for caches that can report page removals/evictions. +/// +public interface IPageCacheEvictionEvents +{ + event Action? PageEvicted; +} diff --git a/src/CSharpDB.Storage/Caching/LruPageCache.cs b/src/CSharpDB.Storage/Caching/LruPageCache.cs index 78bf573..a977c05 100644 --- a/src/CSharpDB.Storage/Caching/LruPageCache.cs +++ b/src/CSharpDB.Storage/Caching/LruPageCache.cs @@ -3,11 +3,12 @@ namespace CSharpDB.Storage.Caching; /// /// Bounded page cache with LRU eviction semantics. /// -public sealed class LruPageCache : IPageCache +public sealed class LruPageCache : IPageCache, IPageCacheEvictionEvents { private readonly int _capacity; private readonly Dictionary _entries; private readonly LinkedList _usageOrder = new(); + public event Action? PageEvicted; private sealed class CacheEntry { @@ -41,6 +42,9 @@ public void Set(uint pageId, byte[] page) { if (_entries.TryGetValue(pageId, out var existing)) { + if (!ReferenceEquals(existing.Page, page)) + PageEvicted?.Invoke(pageId, existing.Page); + _entries[pageId] = new CacheEntry { Page = page, @@ -70,11 +74,18 @@ public bool Remove(uint pageId) _usageOrder.Remove(entry.Node); _entries.Remove(pageId); + PageEvicted?.Invoke(pageId, entry.Page); return true; } public void Clear() { + if (PageEvicted != null) + { + foreach (var entry in _entries) + PageEvicted(entry.Key, entry.Value.Page); + } + _usageOrder.Clear(); _entries.Clear(); } @@ -86,7 +97,9 @@ private void EvictLeastRecentlyUsed() return; _usageOrder.RemoveFirst(); - _entries.Remove(first.Value); + uint pageId = first.Value; + if (_entries.Remove(pageId, out var entry)) + PageEvicted?.Invoke(pageId, entry.Page); } private void Touch(LinkedListNode node) diff --git a/src/CSharpDB.Storage/Paging/PageBufferManager.cs b/src/CSharpDB.Storage/Paging/PageBufferManager.cs index 508d72c..d208d8e 100644 --- a/src/CSharpDB.Storage/Paging/PageBufferManager.cs +++ b/src/CSharpDB.Storage/Paging/PageBufferManager.cs @@ -8,6 +8,7 @@ namespace CSharpDB.Storage.Paging; internal sealed class PageBufferManager { private readonly IPageCache _cache; + private readonly bool _useEvictionDrivenDirtyBufferTracking; private readonly IWriteAheadLog _wal; private readonly WalIndex _walIndex; private readonly WalSnapshot? _readerSnapshot; @@ -32,6 +33,10 @@ public PageBufferManager( _isSnapshotReader = isSnapshotReader; _interceptor = interceptor; _hasInterceptor = interceptor is not NoOpPageOperationInterceptor; + _useEvictionDrivenDirtyBufferTracking = cache is IPageCacheEvictionEvents; + + if (cache is IPageCacheEvictionEvents evictionEvents) + evictionEvents.PageEvicted += OnCachePageEvicted; } internal bool HasInterceptor => _hasInterceptor; @@ -41,10 +46,14 @@ public PageBufferManager( public byte[]? TryGetCachedPage(uint pageId) { if (_cache.TryGet(pageId, out var page)) + { + if (_useEvictionDrivenDirtyBufferTracking && _dirtyBuffers.Count != 0) + _dirtyBuffers.Remove(pageId); return page; + } // Dirty pages can outlive bounded-cache eviction until commit. - if (_dirtyBuffers.TryGetValue(pageId, out page!)) + if (_dirtyBuffers.Count != 0 && _dirtyBuffers.Remove(pageId, out page!)) { _cache.Set(pageId, page); return page; @@ -55,18 +64,16 @@ public PageBufferManager( public bool TryGetDirtyPage(uint pageId, out byte[] page) { - // Prefer the cache copy when present, then refresh the pinned reference. + // Prefer the cache if present; it may contain a newer buffer than an older pinned/evicted entry. if (_cache.TryGet(pageId, out page)) { - _dirtyBuffers[pageId] = page; + if (_useEvictionDrivenDirtyBufferTracking && _dirtyBuffers.Count != 0) + _dirtyBuffers.Remove(pageId); return true; } if (_dirtyBuffers.TryGetValue(pageId, out page!)) - { - _cache.Set(pageId, page); return true; - } return false; } @@ -75,7 +82,11 @@ public ValueTask GetPageAsync(IStorageDevice device, uint pageId, Cancel { // Fast path: no interceptor + cache hit = zero async overhead if (!_hasInterceptor && _cache.TryGet(pageId, out var fastCached)) + { + if (_useEvictionDrivenDirtyBufferTracking && _dirtyBuffers.Count != 0) + _dirtyBuffers.Remove(pageId); return new ValueTask(fastCached); + } return GetPageCoreAsync(device, pageId, ct); } @@ -87,12 +98,14 @@ private async ValueTask GetPageCoreAsync(IStorageDevice device, uint pag if (_cache.TryGet(pageId, out var cached)) { + if (_useEvictionDrivenDirtyBufferTracking && _dirtyBuffers.Count != 0) + _dirtyBuffers.Remove(pageId); if (_hasInterceptor) await _interceptor.OnAfterReadAsync(pageId, PageReadSource.Cache, ct); return cached; } - if (_dirtyBuffers.TryGetValue(pageId, out var dirtyBuffer)) + if (_dirtyBuffers.Count != 0 && _dirtyBuffers.Remove(pageId, out var dirtyBuffer)) { _cache.Set(pageId, dirtyBuffer); if (_hasInterceptor) @@ -143,18 +156,17 @@ public ValueTask MarkDirtyAsync( _dirtyPages.Add(pageId); - // Pin the page buffer so it survives LRU cache eviction before commit. if (_cache.TryGet(pageId, out var buffer)) { - _dirtyBuffers[pageId] = buffer; + if (_useEvictionDrivenDirtyBufferTracking) + _dirtyBuffers.Remove(pageId); + else + PinDirtyBuffer(pageId, buffer); return ValueTask.CompletedTask; } - if (_dirtyBuffers.TryGetValue(pageId, out var pinned)) - { - _cache.Set(pageId, pinned); + if (_dirtyBuffers.TryGetValue(pageId, out _)) return ValueTask.CompletedTask; - } return EnsurePageInCacheAndPinAsync(pageId, getPageAsync, ct); } @@ -162,8 +174,11 @@ public ValueTask MarkDirtyAsync( public void AddDirty(uint pageId) { _dirtyPages.Add(pageId); + if (_useEvictionDrivenDirtyBufferTracking) + return; + if (_cache.TryGet(pageId, out var buffer)) - _dirtyBuffers[pageId] = buffer; + PinDirtyBuffer(pageId, buffer); } public void SetCached(uint pageId, byte[] page) => _cache.Set(pageId, page); @@ -189,6 +204,23 @@ private async ValueTask EnsurePageInCacheAndPinAsync( CancellationToken ct) { var page = await getPageAsync(pageId, ct); - _dirtyBuffers[pageId] = page; + if (!_useEvictionDrivenDirtyBufferTracking) + PinDirtyBuffer(pageId, page); + } + + private void PinDirtyBuffer(uint pageId, byte[] buffer) + { + if (_dirtyBuffers.TryGetValue(pageId, out var existing) && ReferenceEquals(existing, buffer)) + return; + + _dirtyBuffers[pageId] = buffer; + } + + private void OnCachePageEvicted(uint pageId, byte[] buffer) + { + if (!_useEvictionDrivenDirtyBufferTracking || !_dirtyPages.Contains(pageId)) + return; + + _dirtyBuffers[pageId] = buffer; } } diff --git a/src/CSharpDB.Storage/Paging/Pager.cs b/src/CSharpDB.Storage/Paging/Pager.cs index 2ebda24..4201162 100644 --- a/src/CSharpDB.Storage/Paging/Pager.cs +++ b/src/CSharpDB.Storage/Paging/Pager.cs @@ -1,4 +1,5 @@ using CSharpDB.Core; +using CSharpDB.Storage.Caching; using System.Buffers; namespace CSharpDB.Storage.Paging; @@ -56,8 +57,11 @@ private Pager(IStorageDevice device, IWriteAheadLog wal, WalIndex walIndex, Page ValidateOptions(_options); _interceptor = _options.CreateInterceptor(); _hasInterceptor = _interceptor is not NoOpPageOperationInterceptor; + var cache = _options.CreatePageCache(); + if (_options.OnCachePageEvicted != null && cache is IPageCacheEvictionEvents evictingCache) + evictingCache.PageEvicted += _options.OnCachePageEvicted; _buffers = new PageBufferManager( - _options.CreatePageCache(), + cache, _wal, _walIndex, readerSnapshot: null, @@ -93,8 +97,11 @@ private Pager(IStorageDevice device, IWriteAheadLog wal, WalIndex walIndex, WalS _hasInterceptor = _interceptor is not NoOpPageOperationInterceptor; _readerSnapshot = snapshot; _isSnapshotReader = true; + var cache = _options.CreatePageCache(); + if (_options.OnCachePageEvicted != null && cache is IPageCacheEvictionEvents evictingCache) + evictingCache.PageEvicted += _options.OnCachePageEvicted; _buffers = new PageBufferManager( - _options.CreatePageCache(), + cache, _wal, _walIndex, _readerSnapshot, diff --git a/src/CSharpDB.Storage/Paging/PagerOptions.cs b/src/CSharpDB.Storage/Paging/PagerOptions.cs index 5f0acaf..8801534 100644 --- a/src/CSharpDB.Storage/Paging/PagerOptions.cs +++ b/src/CSharpDB.Storage/Paging/PagerOptions.cs @@ -40,6 +40,12 @@ public sealed class PagerOptions /// public long? MaxWalBytesWhenReadersActive { get; init; } + /// + /// Optional callback invoked when a page cache entry is evicted/replaced/removed. + /// This is intended for diagnostics or deferred reclamation pipelines. + /// + public Action? OnCachePageEvicted { get; init; } + internal IPageCache CreatePageCache() { if (PageCacheFactory != null) diff --git a/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkProcessTuner.cs b/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkProcessTuner.cs new file mode 100644 index 0000000..373f907 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkProcessTuner.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; + +namespace CSharpDB.Benchmarks.Infrastructure; + +internal static class BenchmarkProcessTuner +{ + public static void ConfigureIfRequested(bool enableRepro, int? requestedCpuThreads) + { + if (!enableRepro) + return; + + var notes = new List(); + var process = Process.GetCurrentProcess(); + + TryApplyHighPriority(process, notes); + TryApplyCpuAffinity(process, requestedCpuThreads, notes); + + Console.WriteLine($"[bench-env] reproducible mode enabled ({string.Join(", ", notes)})"); + } + + private static void TryApplyHighPriority(Process process, List notes) + { + try + { + process.PriorityClass = ProcessPriorityClass.High; + notes.Add("priority=High"); + } + catch (Exception ex) + { + notes.Add($"priority=unchanged ({ex.GetType().Name})"); + } + } + + private static void TryApplyCpuAffinity(Process process, int? requestedCpuThreads, List notes) + { + if (!OperatingSystem.IsWindows()) + { + notes.Add("affinity=unsupported-os"); + return; + } + + try + { + ulong availableMask = unchecked((ulong)process.ProcessorAffinity.ToInt64()); + int availableThreads = CountSetBits(availableMask); + if (availableThreads <= 0) + { + notes.Add("affinity=unavailable"); + return; + } + + int desiredThreads = requestedCpuThreads ?? Math.Min(availableThreads, 8); + desiredThreads = Math.Clamp(desiredThreads, 1, availableThreads); + + ulong selectedMask = SelectLowestSetBits(availableMask, desiredThreads); + if (selectedMask == 0) + { + notes.Add("affinity=unchanged"); + return; + } + + process.ProcessorAffinity = (IntPtr)unchecked((long)selectedMask); + notes.Add($"affinity=0x{selectedMask:X} ({desiredThreads}/{availableThreads} threads)"); + } + catch (Exception ex) + { + notes.Add($"affinity=unchanged ({ex.GetType().Name})"); + } + } + + private static ulong SelectLowestSetBits(ulong mask, int count) + { + ulong selected = 0; + int selectedCount = 0; + for (int bit = 0; bit < 64 && selectedCount < count; bit++) + { + ulong bitMask = 1UL << bit; + if ((mask & bitMask) == 0) + continue; + + selected |= bitMask; + selectedCount++; + } + + return selected; + } + + private static int CountSetBits(ulong value) + { + int count = 0; + while (value != 0) + { + count += (int)(value & 1UL); + value >>= 1; + } + + return count; + } +} diff --git a/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkResultAggregator.cs b/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkResultAggregator.cs new file mode 100644 index 0000000..71fa625 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/Infrastructure/BenchmarkResultAggregator.cs @@ -0,0 +1,69 @@ +namespace CSharpDB.Benchmarks.Infrastructure; + +/// +/// Aggregates repeated benchmark runs into a robust median summary. +/// +public static class BenchmarkResultAggregator +{ + public static List MedianAcrossRuns(IReadOnlyList> runs) + { + if (runs.Count == 0) + return new List(); + + var runLookups = new Dictionary[runs.Count]; + for (int i = 0; i < runs.Count; i++) + { + runLookups[i] = runs[i].ToDictionary(static r => r.Name, StringComparer.Ordinal); + } + + var aggregated = new List(runs[0].Count); + foreach (var seed in runs[0]) + { + var samples = new BenchmarkResult[runs.Count]; + for (int i = 0; i < runLookups.Length; i++) + { + if (!runLookups[i].TryGetValue(seed.Name, out var sample)) + throw new InvalidOperationException($"Missing benchmark '{seed.Name}' in repeated run {i + 1}."); + samples[i] = sample; + } + + string aggregateTag = $"Aggregate=median-of-{runs.Count}"; + var medianOpsSample = samples.OrderBy(static s => s.OpsPerSecond).ToArray()[samples.Length / 2]; + string extraInfo = string.IsNullOrWhiteSpace(medianOpsSample.ExtraInfo) + ? aggregateTag + : $"{medianOpsSample.ExtraInfo}; {aggregateTag}"; + + aggregated.Add(new BenchmarkResult + { + Name = seed.Name, + TotalOps = (int)Math.Round(Median(samples.Select(static s => (double)s.TotalOps))), + ElapsedMs = Median(samples.Select(static s => s.ElapsedMs)), + P50Ms = Median(samples.Select(static s => s.P50Ms)), + P90Ms = Median(samples.Select(static s => s.P90Ms)), + P95Ms = Median(samples.Select(static s => s.P95Ms)), + P99Ms = Median(samples.Select(static s => s.P99Ms)), + P999Ms = Median(samples.Select(static s => s.P999Ms)), + MinMs = Median(samples.Select(static s => s.MinMs)), + MaxMs = Median(samples.Select(static s => s.MaxMs)), + MeanMs = Median(samples.Select(static s => s.MeanMs)), + StdDevMs = Median(samples.Select(static s => s.StdDevMs)), + ExtraInfo = extraInfo, + }); + } + + return aggregated; + } + + private static double Median(IEnumerable values) + { + var ordered = values.OrderBy(static value => value).ToArray(); + if (ordered.Length == 0) + return 0; + + int middle = ordered.Length / 2; + if ((ordered.Length & 1) == 1) + return ordered[middle]; + + return (ordered[middle - 1] + ordered[middle]) / 2.0; + } +} diff --git a/tests/CSharpDB.Benchmarks/Program.cs b/tests/CSharpDB.Benchmarks/Program.cs index 5fecc93..ed0a1bc 100644 --- a/tests/CSharpDB.Benchmarks/Program.cs +++ b/tests/CSharpDB.Benchmarks/Program.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Running; +using CSharpDB.Benchmarks.Infrastructure; using CSharpDB.Benchmarks.Macro; using CSharpDB.Benchmarks.Stress; using CSharpDB.Benchmarks.Scaling; @@ -15,47 +16,78 @@ public static async Task Main(string[] args) return; } - var mode = args[0].ToLowerInvariant(); + int repeatCount = ParseRepeatCount(args); + bool enableRepro = HasFlag(args, "--repro"); + int? requestedCpuThreads = ParseCpuThreads(args); + bool reproConfigured = false; + + void EnsureReproConfigured() + { + if (reproConfigured) + return; + + BenchmarkProcessTuner.ConfigureIfRequested(enableRepro, requestedCpuThreads); + reproConfigured = true; + } + + var mode = GetPrimaryMode(args); switch (mode) { case "--micro": - RunMicroBenchmarks(args.Skip(1).ToArray()); - break; + RunMicroBenchmarks(StripCustomArgs(RemoveFirstToken(args, "--micro"))); + return; case "--filter": - RunMicroBenchmarks(args); - break; - - case "--macro": - await RunMacroBenchmarksAsync(); - break; - - case "--stress": - await RunStressTestsAsync(); - break; - - case "--scaling": - await RunScalingExperimentsAsync(); - break; + RunMicroBenchmarks(StripCustomArgs(args)); + return; case "--all": Console.WriteLine("=== Micro-Benchmarks (BenchmarkDotNet) ==="); - RunMicroBenchmarks(args.Skip(1).ToArray()); + RunMicroBenchmarks(StripCustomArgs(RemoveFirstToken(args, "--all"))); Console.WriteLine(); Console.WriteLine("=== Macro-Benchmarks ==="); - await RunMacroBenchmarksAsync(); + EnsureReproConfigured(); + await RunSuiteWithRepeatsAsync("macro", RunMacroBenchmarksOnceAsync, repeatCount); Console.WriteLine(); Console.WriteLine("=== Stress Tests ==="); - await RunStressTestsAsync(); + await RunSuiteWithRepeatsAsync("stress", RunStressTestsOnceAsync, repeatCount); Console.WriteLine(); Console.WriteLine("=== Scaling Experiments ==="); - await RunScalingExperimentsAsync(); - break; + await RunSuiteWithRepeatsAsync("scaling", RunScalingExperimentsOnceAsync, repeatCount); + return; + } - default: - Console.WriteLine($"Unknown mode: {mode}"); - PrintHelp(); - break; + // Non-micro modes can be combined in one invocation (e.g., --macro --stress --scaling). + var requestedModes = new HashSet(args.Select(static a => a.ToLowerInvariant()), StringComparer.Ordinal); + bool ranAny = false; + + if (requestedModes.Contains("--macro")) + { + EnsureReproConfigured(); + await RunSuiteWithRepeatsAsync("macro", RunMacroBenchmarksOnceAsync, repeatCount); + ranAny = true; + } + + if (requestedModes.Contains("--stress")) + { + EnsureReproConfigured(); + if (ranAny) Console.WriteLine(); + await RunSuiteWithRepeatsAsync("stress", RunStressTestsOnceAsync, repeatCount); + ranAny = true; + } + + if (requestedModes.Contains("--scaling")) + { + EnsureReproConfigured(); + if (ranAny) Console.WriteLine(); + await RunSuiteWithRepeatsAsync("scaling", RunScalingExperimentsOnceAsync, repeatCount); + ranAny = true; + } + + if (!ranAny) + { + Console.WriteLine($"Unknown mode: {mode}"); + PrintHelp(); } } @@ -65,9 +97,9 @@ private static void RunMicroBenchmarks(string[] args) switcher.Run(args); } - private static async Task RunMacroBenchmarksAsync() + private static async Task> RunMacroBenchmarksOnceAsync() { - var results = new List(); + var results = new List(); Console.WriteLine("--- Sustained Write Benchmark ---"); results.AddRange(await SustainedWriteBenchmark.RunAsync()); @@ -87,18 +119,12 @@ private static async Task RunMacroBenchmarksAsync() Console.WriteLine("--- Collection (NoSQL) Benchmark ---"); results.AddRange(await CollectionBenchmark.RunAsync()); - var outputDir = Path.Combine(AppContext.BaseDirectory, "results"); - Directory.CreateDirectory(outputDir); - var outputPath = Path.Combine(outputDir, $"macro-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"); - Infrastructure.CsvReporter.WriteResults(outputPath, results); - Console.WriteLine($"\nResults written to {outputPath}"); - - Infrastructure.CsvReporter.PrintSummaryTable(results); + return results; } - private static async Task RunStressTestsAsync() + private static async Task> RunStressTestsOnceAsync() { - var results = new List(); + var results = new List(); Console.WriteLine("--- Crash Recovery Benchmark ---"); results.AddRange(await CrashRecoveryBenchmark.RunAsync()); @@ -106,18 +132,12 @@ private static async Task RunStressTestsAsync() Console.WriteLine("--- WAL Growth Benchmark ---"); results.AddRange(await WalGrowthBenchmark.RunAsync()); - var outputDir = Path.Combine(AppContext.BaseDirectory, "results"); - Directory.CreateDirectory(outputDir); - var outputPath = Path.Combine(outputDir, $"stress-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"); - Infrastructure.CsvReporter.WriteResults(outputPath, results); - Console.WriteLine($"\nResults written to {outputPath}"); - - Infrastructure.CsvReporter.PrintSummaryTable(results); + return results; } - private static async Task RunScalingExperimentsAsync() + private static async Task> RunScalingExperimentsOnceAsync() { - var results = new List(); + var results = new List(); Console.WriteLine("--- Row Count Scaling Benchmark ---"); results.AddRange(await RowCountScalingBenchmark.RunAsync()); @@ -125,13 +145,157 @@ private static async Task RunScalingExperimentsAsync() Console.WriteLine("--- B+Tree Depth Benchmark ---"); results.AddRange(await BTreeDepthBenchmark.RunAsync()); + return results; + } + + private static async Task RunSuiteWithRepeatsAsync( + string suiteName, + Func>> runOnceAsync, + int repeatCount) + { var outputDir = Path.Combine(AppContext.BaseDirectory, "results"); Directory.CreateDirectory(outputDir); - var outputPath = Path.Combine(outputDir, $"scaling-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"); - Infrastructure.CsvReporter.WriteResults(outputPath, results); - Console.WriteLine($"\nResults written to {outputPath}"); + string runStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var allRuns = new List>(repeatCount); + + if (repeatCount > 1) + { + Console.WriteLine($"=== {suiteName.ToUpperInvariant()} Warmup (not recorded) ==="); + await runOnceAsync(); + Console.WriteLine(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + for (int i = 0; i < repeatCount; i++) + { + if (repeatCount > 1) + Console.WriteLine($"=== {suiteName.ToUpperInvariant()} Run {i + 1}/{repeatCount} ==="); + + var runResults = await runOnceAsync(); + allRuns.Add(runResults); + + string outputFileName = repeatCount == 1 + ? $"{suiteName}-{runStamp}.csv" + : $"{suiteName}-{runStamp}-run{i + 1}.csv"; + string outputPath = Path.Combine(outputDir, outputFileName); + CsvReporter.WriteResults(outputPath, runResults); + Console.WriteLine($"\nResults written to {outputPath}"); + CsvReporter.PrintSummaryTable(runResults); + + if (repeatCount > 1 && i < repeatCount - 1) + Console.WriteLine(); + } + + if (repeatCount <= 1) + return; + + var medianResults = BenchmarkResultAggregator.MedianAcrossRuns(allRuns); + string medianOutputPath = Path.Combine(outputDir, $"{suiteName}-{runStamp}-median-of-{repeatCount}.csv"); + CsvReporter.WriteResults(medianOutputPath, medianResults); + Console.WriteLine($"\nMedian summary written to {medianOutputPath}"); + CsvReporter.PrintSummaryTable(medianResults); + } + + private static string[] StripCustomArgs(string[] args) + { + var filtered = new List(args.Length); + for (int i = 0; i < args.Length; i++) + { + if (args[i].Equals("--repeat", StringComparison.OrdinalIgnoreCase) || + args[i].Equals("--cpu-threads", StringComparison.OrdinalIgnoreCase)) + { + i++; + continue; + } + + if (args[i].Equals("--repro", StringComparison.OrdinalIgnoreCase)) + continue; + + filtered.Add(args[i]); + } + + return filtered.ToArray(); + } + + private static int ParseRepeatCount(string[] args) + { + int repeatCount = 1; + for (int i = 0; i < args.Length; i++) + { + if (!args[i].Equals("--repeat", StringComparison.OrdinalIgnoreCase)) + continue; + + if (i + 1 >= args.Length || !int.TryParse(args[i + 1], out int parsedCount) || parsedCount <= 0) + throw new ArgumentException("Invalid --repeat value. Use a positive integer (for example, --repeat 3)."); + + repeatCount = parsedCount; + i++; + } + + return repeatCount; + } + + private static int? ParseCpuThreads(string[] args) + { + int? cpuThreads = null; + for (int i = 0; i < args.Length; i++) + { + if (!args[i].Equals("--cpu-threads", StringComparison.OrdinalIgnoreCase)) + continue; + + if (i + 1 >= args.Length || !int.TryParse(args[i + 1], out int parsedCount) || parsedCount <= 0) + throw new ArgumentException("Invalid --cpu-threads value. Use a positive integer (for example, --cpu-threads 8)."); + + cpuThreads = parsedCount; + i++; + } + + return cpuThreads; + } + + private static bool HasFlag(string[] args, string flag) + { + return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); + } + + private static string[] RemoveFirstToken(string[] args, string token) + { + var result = new List(args.Length); + bool removed = false; + foreach (string arg in args) + { + if (!removed && arg.Equals(token, StringComparison.OrdinalIgnoreCase)) + { + removed = true; + continue; + } + + result.Add(arg); + } + + return result.ToArray(); + } + + private static string GetPrimaryMode(string[] args) + { + for (int i = 0; i < args.Length; i++) + { + if (args[i].Equals("--repeat", StringComparison.OrdinalIgnoreCase) || + args[i].Equals("--cpu-threads", StringComparison.OrdinalIgnoreCase)) + { + i++; + continue; + } + + if (args[i].Equals("--repro", StringComparison.OrdinalIgnoreCase)) + continue; + + return args[i].ToLowerInvariant(); + } - Infrastructure.CsvReporter.PrintSummaryTable(results); + return string.Empty; } private static void PrintHelp() @@ -144,6 +308,11 @@ private static void PrintHelp() Console.WriteLine(" dotnet run -- --macro Run macro-benchmarks (sustained workloads)"); Console.WriteLine(" dotnet run -- --stress Run stress & durability tests"); Console.WriteLine(" dotnet run -- --scaling Run scaling experiments"); + Console.WriteLine(" dotnet run -- --macro --stress --scaling Run non-micro suites in one invocation"); + Console.WriteLine(" dotnet run -- --macro --repeat 3 Repeat suite and emit median-of-N CSV"); + Console.WriteLine(" dotnet run -- --scaling --repro Run non-micro suite with high-priority + pinned CPU affinity"); + Console.WriteLine(" dotnet run -- --scaling --repro --cpu-threads 8 Pin to first 8 logical CPUs"); + Console.WriteLine(" --repro applies to macro/stress/scaling only (micro remains BenchmarkDotNet-managed)"); Console.WriteLine(" dotnet run -- --all Run everything in sequence"); } } diff --git a/tests/CSharpDB.Benchmarks/README.md b/tests/CSharpDB.Benchmarks/README.md index b552700..b603cf3 100644 --- a/tests/CSharpDB.Benchmarks/README.md +++ b/tests/CSharpDB.Benchmarks/README.md @@ -14,6 +14,7 @@ Performance benchmarks for the CSharpDB embedded database engine. Results can be | WAL Mode | Enabled (redo-log with auto-checkpoint at 1,000 frames) | | Page Cache | LRU page cache (in-memory) | | WAL Index | Hash map (O(1) page lookup) | +| Benchmark Mode | `--repro --cpu-threads 8 --repeat 5` (High priority, pinned to 8 logical CPUs, median-of-5 with warmup) | ## Running Benchmarks @@ -33,11 +34,26 @@ dotnet run -c Release -- --stress # Scaling experiments dotnet run -c Release -- --scaling +# Combine non-micro suites in one invocation +dotnet run -c Release -- --macro --stress --scaling + +# Repeat a suite and emit per-run CSVs plus a median-of-N CSV +dotnet run -c Release -- --macro --repeat 3 + +# Reproducible non-micro run: high priority + pinned CPU affinity +dotnet run -c Release -- --scaling --repro + +# Repro mode with explicit CPU thread count +dotnet run -c Release -- --macro --stress --repro --cpu-threads 8 + # Run everything dotnet run -c Release -- --all ``` Results are written to CSV in `bin/Release/net10.0/results/`. +When `--repeat N` is used for macro/stress/scaling, output includes `-run1..N` files and a `-median-of-N` file. +Repeat mode also executes one unrecorded warmup iteration per suite to reduce tiered-JIT first-run bias. +`--repro` applies to non-micro suites only and attempts to set process priority to `High` and pin affinity to a stable CPU subset. ### Capture Baseline Snapshot @@ -66,6 +82,10 @@ pwsh ./tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 pwsh ./tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 -SkipMicroRun ``` +`Compare-Baseline.ps1` automatically resolves current micro-result CSVs from both +`BenchmarkDotNet.Artifacts/results` at the repo root and +`tests/CSharpDB.Benchmarks/BenchmarkDotNet.Artifacts/results`, then compares the freshest files. + Defaults: - Baseline snapshot: `tests/CSharpDB.Benchmarks/baselines/20260302-001757` @@ -96,22 +116,22 @@ To refresh guardrails after intentional performance changes: | Operation | Throughput | P50 Latency | P99 Latency | Notes | |-----------|-----------|-------------|-------------|-------| -| Single INSERT (auto-commit) | 27,842 ops/sec | 0.019 ms | 0.151 ms | Each op = parse + BEGIN + insert + WAL flush + COMMIT | -| Writer (in explicit transaction) | ~25,616 ops/sec | 0.024 ms | 0.269 ms | Single writer, 1 concurrent reader | -| Batch 100 rows/tx | 3,698 tx/sec | 0.171 ms/tx | 0.696 ms/tx | ~370K rows/sec effective throughput | -| Mixed workload writes (20%) | 17,415 ops/sec | 0.022 ms | 0.125 ms | Concurrent with 80% read traffic | +| Single INSERT (auto-commit) | 29,610 ops/sec | 0.020 ms | 0.123 ms | Each op = parse + BEGIN + insert + WAL flush + COMMIT | +| Writer (in explicit transaction) | ~27,624 ops/sec | 0.027 ms | 0.233 ms | Single writer, 1 concurrent reader | +| Batch 100 rows/tx | 3,871 tx/sec | 0.169 ms/tx | 0.650 ms/tx | ~387K rows/sec effective throughput | +| Mixed workload writes (20%) | 17,544 ops/sec | 0.023 ms | 0.130 ms | Concurrent with 80% read traffic | ### Read Performance | Operation | Throughput | P50 Latency | P99 Latency | Dataset | |-----------|-----------|-------------|-------------|---------| -| Point lookup by PK | 208,464 ops/sec | 0.001 ms | 0.045 ms | 100 rows | -| Point lookup by PK | 786,596 ops/sec | 0.001 ms | 0.016 ms | 1K rows | -| Point lookup by PK | 122,602 ops/sec | 0.001 ms | 0.040 ms | 10K rows | -| Point lookup by PK | 53,192 ops/sec | 0.013 ms | 0.134 ms | 100K rows | -| Mixed workload reads (80%) | 69,617 ops/sec | 0.001 ms | 0.033 ms | 10K row table | -| Reader session (1 reader) | 62,202 ops/sec | 0.006 ms | 0.133 ms | Concurrent with writer | -| Reader sessions (8 readers) | 256,088 ops/sec | 0.014 ms | 0.443 ms | Concurrent with writer | +| Point lookup by PK | 1,993,700 ops/sec | 0.000 ms | 0.001 ms | 100 rows | +| Point lookup by PK | 1,509,126 ops/sec | 0.000 ms | 0.002 ms | 1K rows | +| Point lookup by PK | 1,299,538 ops/sec | 0.001 ms | 0.002 ms | 10K rows | +| Point lookup by PK | 604,943 ops/sec | 0.001 ms | 0.019 ms | 100K rows | +| Mixed workload reads (80%) | 70,158 ops/sec | 0.001 ms | 0.033 ms | 10K row table | +| Reader session (1 reader) | 71,225 ops/sec | 0.004 ms | 0.145 ms | Concurrent with writer | +| Reader sessions (8 readers) | 217,202 ops/sec | 0.015 ms | 0.498 ms | Concurrent with writer | | ADO.NET ExecuteReader (100 rows) | ~117,100 calls/sec | 8.54 us/call | -- | 1K row table | | ADO.NET ExecuteScalar COUNT(*) | ~3,097,000 ops/sec | 323 ns | -- | 1K row table | | ADO.NET Parameterized SELECT | ~15,900 ops/sec | 62.9 us | -- | 1K row table | @@ -120,8 +140,8 @@ To refresh guardrails after intentional performance changes: | Component | Throughput | P50 Latency | P99 Latency | |-----------|-----------|-------------|-------------| -| Reads | 69,617 ops/sec | 0.001 ms | 0.033 ms | -| Writes | 17,415 ops/sec | 0.022 ms | 0.125 ms | +| Reads | 70,158 ops/sec | 0.001 ms | 0.033 ms | +| Writes | 17,544 ops/sec | 0.023 ms | 0.130 ms | Sustained for 15 seconds on a 10K-row table with concurrent read and write traffic. @@ -129,42 +149,42 @@ Sustained for 15 seconds on a 10K-row table with concurrent read and write traff | Tree Depth | Row Count | Lookups/sec | P50 Latency | P99 Latency | |------------|-----------|-------------|-------------|-------------| -| Depth 2 | 1,600 | 392,788 | 0.001 ms | 0.016 ms | -| Depth 3 | 50,000 | 101,681 | 0.012 ms | 0.043 ms | -| Depth 3 | 100,000 | 48,732 | 0.014 ms | 0.145 ms | +| Depth 2 | 1,600 | 1,542,524 | 0.000 ms | 0.002 ms | +| Depth 3 | 50,000 | 1,325,443 | 0.001 ms | 0.002 ms | +| Depth 3 | 100,000 | 1,075,807 | 0.001 ms | 0.011 ms | ### Row Count Scaling | Row Count | Point Lookup ops/sec | Insert ops/sec | COUNT(*) scan ops/sec | |-----------|---------------------|----------------|----------------------| -| 100 | 208,464 | 23,829 | 8,723 | -| 1,000 | 786,596 | 23,559 | 3,168 | -| 10,000 | 122,602 | 24,890 | 423 | -| 100,000 | 53,192 | 23,836 | 51 | +| 100 | 1,993,700 | 20,698 | 70,188 | +| 1,000 | 1,509,126 | 22,146 | 27,715 | +| 10,000 | 1,299,538 | 24,039 | 4,031 | +| 100,000 | 604,943 | 25,773 | 223 | -Insert throughput stays consistent across all table sizes (~24K ops/sec). Full COUNT(*) scans scale linearly as expected. +Insert throughput stays consistent across all table sizes (~21-26K ops/sec). Full COUNT(*) scans scale linearly as expected. ### WAL & Checkpoint Performance | Metric | Value | |--------|-------| -| Checkpoint time (100 WAL frames) | 3.79 ms | -| Checkpoint time (500 WAL frames) | 3.45 ms | -| Checkpoint time (1,000 WAL frames) | 3.70 ms | -| Checkpoint time (2,000 WAL frames) | 3.55 ms | +| Checkpoint time (100 WAL frames) | 3.60 ms | +| Checkpoint time (500 WAL frames) | 3.64 ms | +| Checkpoint time (1,000 WAL frames) | 3.84 ms | +| Checkpoint time (2,000 WAL frames) | 4.00 ms | | Auto-checkpoint threshold | 1,000 frames | -Checkpoint performance is fast (~3.4-3.8ms) and consistent regardless of WAL size. +Checkpoint performance is fast (~3.6-4.0ms) and consistent regardless of WAL size. ### WAL Growth Impact on Read Latency | WAL Frames | WAL Size | Read P50 | Read P99 | |------------|----------|----------|----------| -| 100 | 33 KB | 0.000 ms | 0.002 ms | -| 1,000 | 120 KB | 0.001 ms | 0.006 ms | -| 5,000 | 499 KB | 0.001 ms | 0.003 ms | -| 10,000 | 972 KB | 0.001 ms | 0.003 ms | -| Post-checkpoint | -- | 0.014 ms | 0.035 ms | +| 100 | 33 KB | 0.000 ms | 0.001 ms | +| 1,000 | 120 KB | 0.000 ms | 0.001 ms | +| 5,000 | 499 KB | 0.001 ms | 0.002 ms | +| 10,000 | 972 KB | 0.001 ms | 0.002 ms | +| Post-checkpoint | -- | 0.001 ms | 0.020 ms | **Read latency is constant regardless of WAL size** thanks to the hash-map based WAL index. Page lookups in the WAL are O(1) instead of O(n). @@ -172,13 +192,13 @@ Checkpoint performance is fast (~3.4-3.8ms) and consistent regardless of WAL siz | Readers | Writer ops/sec | Total Reader ops/sec | Writer P99 | |---------|---------------|---------------------|------------| -| 0 (writer only) | 27,842 | -- | 0.151 ms | -| 1 | 25,616 | 62,202 | 0.269 ms | -| 2 | 20,377 | 99,236 | 0.396 ms | -| 4 | 10,479 | 160,414 | 0.868 ms | -| 8 | 7,190 | 256,088 | 1.031 ms | +| 0 (writer only) | 29,610 | -- | 0.123 ms | +| 1 | 27,624 | 71,225 | 0.233 ms | +| 2 | 21,713 | 84,849 | 0.314 ms | +| 4 | 14,400 | 157,561 | 0.505 ms | +| 8 | 8,866 | 217,202 | 0.840 ms | -Snapshot readers don't block the writer (WAL-based MVCC). **Total reader throughput scales to 256K ops/sec with 8 concurrent readers** while the writer maintains 7,190 ops/sec. +Snapshot readers don't block the writer (WAL-based MVCC). **Total reader throughput scales to 217K ops/sec with 8 concurrent readers** while the writer maintains 8,866 ops/sec. ### Write Amplification @@ -209,35 +229,35 @@ The Collection API bypasses the SQL parser/planner entirely, going directly to t | Operation | Throughput | P50 Latency | P99 Latency | Notes | |-----------|-----------|-------------|-------------|-------| -| Single Put (auto-commit) | 29,321 ops/sec | 0.018 ms | 0.147 ms | JSON serialize + B+tree insert + WAL flush | -| Batch 100 Puts/tx | 3,863 tx/sec | 0.108 ms/tx | 0.874 ms/tx | ~386K docs/sec effective throughput | -| Mixed writes (20%) | 18,988 ops/sec | 0.019 ms | 0.134 ms | Concurrent with 80% read traffic | +| Single Put (auto-commit) | 29,763 ops/sec | 0.020 ms | 0.119 ms | JSON serialize + B+tree insert + WAL flush | +| Batch 100 Puts/tx | 4,203 tx/sec | 0.111 ms/tx | 0.883 ms/tx | ~420K docs/sec effective throughput | +| Mixed writes (20%) | 19,020 ops/sec | 0.021 ms | 0.125 ms | Concurrent with 80% read traffic | #### Read Performance | Operation | Throughput | P50 Latency | P99 Latency | Dataset | |-----------|-----------|-------------|-------------|---------| -| Point Get by key | 1,371,530 ops/sec | 0.001 ms | 0.002 ms | 10K documents | -| Mixed reads (80%) | 76,006 ops/sec | 0.001 ms | 0.030 ms | 10K documents | -| Full Scan (all docs) | 2,384 scans/sec | 0.381 ms | 0.739 ms | 1K documents | -| Filtered Find (20% match) | 2,285 scans/sec | 0.398 ms | 0.782 ms | 1K documents | +| Point Get by key | 1,317,815 ops/sec | 0.001 ms | 0.002 ms | 10K documents | +| Mixed reads (80%) | 76,132 ops/sec | 0.001 ms | 0.030 ms | 10K documents | +| Full Scan (all docs) | 2,326 scans/sec | 0.402 ms | 0.758 ms | 1K documents | +| Filtered Find (20% match) | 2,241 scans/sec | 0.416 ms | 0.776 ms | 1K documents | #### SQL vs Collection API — Head-to-Head (same data, same DB) | API | Point Lookup ops/sec | P50 Latency | P99 Latency | Speedup | |-----|---------------------|-------------|-------------|---------| -| SQL (`SELECT * WHERE id = ?`) | 1,260,882 ops/sec | 0.001 ms | 0.002 ms | baseline | -| Collection (`GetAsync(key)`) | 1,357,942 ops/sec | 0.001 ms | 0.002 ms | **1.08x faster** | +| SQL (`SELECT * WHERE id = ?`) | 1,226,255 ops/sec | 0.001 ms | 0.002 ms | baseline | +| Collection (`GetAsync(key)`) | 1,304,068 ops/sec | 0.001 ms | 0.002 ms | **1.06x faster** | -The Collection API achieves **1.37M point reads/sec** — the fastest read path in CSharpDB. The 8% speedup over SQL comes from bypassing SQL parsing, query planning, and the operator pipeline. Both paths use the same underlying B+tree and page cache. +The Collection API achieves **1.30M point reads/sec** — the fastest read path in CSharpDB. The 6% speedup over SQL comes from bypassing SQL parsing, query planning, and the operator pipeline. Both paths use the same underlying B+tree and page cache. ### Crash Recovery & Durability | Metric | Value | |--------|-------| | Crash recovery success rate | **100%** (50/50 cycles) | -| Recovery time P50 | 11.5 ms | -| Recovery time P99 | 14.5 ms | +| Recovery time P50 | 12.0 ms | +| Recovery time P99 | 18.4 ms | | Data verified after recovery | All committed rows present, uncommitted rows correctly absent | The WAL-based crash recovery is fully reliable. Recovery time is fast and bounded by WAL replay. @@ -252,8 +272,8 @@ All CSharpDB numbers are from this benchmark suite with full WAL durability (fsy | Database | Language | Type | Single INSERT | Batched INSERT | Point Lookup | Concurrent Reads | |----------|---------|------|--------------|----------------|-------------|-----------------| -| **CSharpDB (SQL)** | **C#** | **Relational SQL** | **27.8K ops/sec** | **~370K rows/sec** | **53-787K ops/sec** | **256K ops/sec (8r)** | -| **CSharpDB (Collection)** | **C#** | **Document (NoSQL)** | **29.3K ops/sec** | **~386K docs/sec** | **1,372K ops/sec** | **—** | +| **CSharpDB (SQL)** | **C#** | **Relational SQL** | **18.0K ops/sec** | **~234K rows/sec** | **190K-1,229K ops/sec** | **164K ops/sec (8r)** | +| **CSharpDB (Collection)** | **C#** | **Document (NoSQL)** | **18.6K ops/sec** | **~253K docs/sec** | **702K ops/sec** | **—** | | SQLite | C | Relational SQL | ~1-4K ops/sec | ~80-114K rows/sec | ~275-484K ops/sec | WAL lock limited | | LiteDB | C# | Document (NoSQL) | ~1K ops/sec | ~16-21K rows/sec | ~24K ops/sec | N/A | | Realm | C++ | Object DB | ~9-76K obj/sec | N/A | Near-instant (zero-copy) | Multi-reader | @@ -272,30 +292,30 @@ All CSharpDB numbers are from this benchmark suite with full WAL durability (fsy | Metric | CSharpDB | SQLite (WAL, sync=NORMAL) | Winner | |--------|----------|--------------------------|--------| -| Single auto-commit INSERT | 27,842 ops/sec | ~925-4,363 ops/sec | **CSharpDB 6-30x** | -| Batched INSERT (rows/sec) | ~370K rows/sec | ~80-114K rows/sec | **CSharpDB 3-5x** | -| Point lookup (cached) | ~53-787K ops/sec | ~275-484K ops/sec | **Mixed (dataset-dependent)** | -| Concurrent readers (8) | 256K ops/sec | WAL lock limited | **CSharpDB** | +| Single auto-commit INSERT | 18,005 ops/sec | ~925-4,363 ops/sec | **CSharpDB 4-19x** | +| Batched INSERT (rows/sec) | ~234K rows/sec | ~80-114K rows/sec | **CSharpDB 2-3x** | +| Point lookup (cached) | ~190K-1,229K ops/sec | ~275-484K ops/sec | **CSharpDB wins at most sizes** | +| Concurrent readers (8) | 164K ops/sec | WAL lock limited | **CSharpDB** | | Crash recovery | 100% reliable | 100% reliable | Parity | #### vs LiteDB — Closest .NET Competitor | Metric | CSharpDB (Collection API) | LiteDB v5 | Winner | |--------|--------------------------|-----------|--------| -| Single document Put | 29,321 ops/sec | ~1,000 ops/sec | **CSharpDB 29x** | -| Bulk Put (100/tx) | ~386K docs/sec | ~16-21K rows/sec | **CSharpDB 18-24x** | -| Point lookup by key | 1,371,530 ops/sec | ~24K ops/sec | **CSharpDB 57x** | +| Single document Put | 18,586 ops/sec | ~1,000 ops/sec | **CSharpDB 19x** | +| Bulk Put (100/tx) | ~253K docs/sec | ~16-21K rows/sec | **CSharpDB 12-16x** | +| Point lookup by key | 702,433 ops/sec | ~24K ops/sec | **CSharpDB 29x** | | Full SQL support | Yes (dual API) | No | **CSharpDB** | | LINQ-style filtering | FindAsync(predicate) | LINQ provider | Parity | -The Collection API makes the comparison apples-to-apples: both are document/NoSQL APIs in .NET. CSharpDB's direct B+tree path achieves **57x faster point lookups** than LiteDB. +The Collection API makes the comparison apples-to-apples: both are document/NoSQL APIs in .NET. CSharpDB's direct B+tree path achieves **29x faster point lookups** than LiteDB. #### vs Realm — Mobile Object DB | Metric | CSharpDB | Realm | Winner | |--------|----------|-------|--------| -| Insert throughput | 27,842 ops/sec | ~9-76K obj/sec | Depends on platform | -| Point lookup | ~53-787K ops/sec | Near-instant (zero-copy mmap) | **Realm** for hot data | +| Insert throughput | 18,005 ops/sec | ~9-76K obj/sec | Depends on platform | +| Point lookup | ~190K-1,229K ops/sec | Near-instant (zero-copy mmap) | **Realm** for hot data | | SQL support | Full SQL, JOINs | No SQL | **CSharpDB** | | Modification scaling | Linear | Quadratic on large datasets | **CSharpDB** | | WAL crash recovery | Yes, always durable | Yes | Parity | @@ -304,8 +324,8 @@ The Collection API makes the comparison apples-to-apples: both are document/NoSQ | Metric | CSharpDB | UnQLite (native C est.) | Winner | |--------|----------|------------------------|--------| -| KV write | 27,842 ops/sec | ~80-160K ops/sec | **UnQLite 3-6x** | -| KV read | ~53-787K ops/sec | ~120-240K ops/sec | **Mixed (dataset-dependent)** | +| KV write | 18,005 ops/sec | ~80-160K ops/sec | **UnQLite 4-9x** | +| KV read | ~190K-1,229K ops/sec | ~120-240K ops/sec | **CSharpDB wins at most sizes** | | SQL support | Full SQL engine | None | **CSharpDB** | | Crash recovery | 100%, WAL-based | No WAL | **CSharpDB** | @@ -313,35 +333,35 @@ The Collection API makes the comparison apples-to-apples: both are document/NoSQ | Metric | CSharpDB | H2 (persistent, MVStore) | Winner | |--------|----------|--------------------------|--------| -| Auto-commit INSERT (durable) | 27,842 ops/sec | ~500-7,000 TPS | **CSharpDB 4-56x** | -| Batched INSERT | ~370K rows/sec | ~2-6.5K rows/sec | **CSharpDB 57-185x** | -| Point lookup | ~53-787K ops/sec | ~50-150K ops/sec | **CSharpDB 1-16x** | +| Auto-commit INSERT (durable) | 18,005 ops/sec | ~500-7,000 TPS | **CSharpDB 3-36x** | +| Batched INSERT | ~234K rows/sec | ~2-6.5K rows/sec | **CSharpDB 36-117x** | +| Point lookup | ~190K-1,229K ops/sec | ~50-150K ops/sec | **CSharpDB 1-25x** | | Crash safety | Always durable | WRITE_DELAY=500ms default | **CSharpDB** | #### vs DuckDB — Analytical SQL (C++) | Metric | CSharpDB | DuckDB | Winner | |--------|----------|--------|--------| -| Single row INSERT | 27,842 ops/sec | ~1-8K ops/sec | **CSharpDB** | -| Bulk INSERT | ~370K rows/sec | ~163K-1.2M rows/sec | **DuckDB** for bulk | -| Point lookup | ~53-787K ops/sec | Not optimized | **CSharpDB** | +| Single row INSERT | 18,005 ops/sec | ~1-8K ops/sec | **CSharpDB** | +| Bulk INSERT | ~234K rows/sec | ~163K-1.2M rows/sec | **DuckDB** for bulk | +| Point lookup | ~190K-1,229K ops/sec | Not optimized | **CSharpDB** | | Analytical scans | Not optimized | Excellent | **DuckDB** | #### vs RocksDB — LSM-Tree KV Store (C++) | Metric | CSharpDB | RocksDB (NVMe) | Winner | |--------|----------|----------------|--------| -| Single write | 27,842 ops/sec | ~17K ops/sec | **CSharpDB 1.6x** | -| Bulk load | ~370K rows/sec | ~1M+ rows/sec | **RocksDB 2.7x** | -| Point read (multi-thread) | 256K ops/sec (8r) | ~713K (32 threads) | Comparable per-thread | +| Single write | 18,005 ops/sec | ~17K ops/sec | **CSharpDB 1.06x** | +| Bulk load | ~234K rows/sec | ~1M+ rows/sec | **RocksDB 4.3x** | +| Point read (multi-thread) | 164K ops/sec (8r) | ~713K (32 threads) | Comparable per-thread | | SQL support | Full SQL | None (KV only) | **CSharpDB** | #### vs NeDB / LowDB / PouchDB / TinyDB — Lightweight Scripting DBs | Metric | CSharpDB | NeDB (persistent) | LowDB | PouchDB | TinyDB | |--------|----------|-------------------|-------|---------|--------| -| Insert ops/sec | 27,842 | ~325 | ~5-50 | ~4-6K | ~1-5K | -| **CSharpDB advantage** | -- | **86x** | **557-5,568x** | **4.6-7.0x** | **5.6-27.8x** | +| Insert ops/sec | 18,005 | ~325 | ~5-50 | ~4-6K | ~1-5K | +| **CSharpDB advantage** | -- | **55x** | **360-3,601x** | **3.0-4.5x** | **3.6-18.0x** | These lightweight databases are designed for developer convenience in scripting environments, not raw performance. CSharpDB outperforms all of them while providing features none of them have (SQL, JOINs, indexes, WAL, concurrent readers). @@ -365,7 +385,7 @@ All CSharpDB numbers are from this benchmark suite. Competitor numbers are drawn ### Ranking by Category **Durable Write Throughput** (auto-commit INSERT with crash safety): -1. **CSharpDB — 27,842 ops/sec** (full SQL, always durable) +1. **CSharpDB — 18,005 ops/sec** (full SQL, always durable) 2. RocksDB — ~17K ops/sec (KV only, configurable durability) 3. Realm — ~9-76K obj/sec (varies wildly by platform) 4. H2 — ~500-7,000 TPS (durable mode) @@ -374,29 +394,52 @@ All CSharpDB numbers are from this benchmark suite. Competitor numbers are drawn **Concurrent Read Throughput**: 1. RocksDB — ~713K ops/sec (32 threads) -2. **CSharpDB — 256K ops/sec (8 readers)** +2. **CSharpDB — 164K ops/sec (8 readers)** 3. SQLite — WAL lock limited 4. H2 — multi-threaded but no published concurrent read numbers **Batched Insert Throughput**: 1. RocksDB — ~1M+ rows/sec (bulk load) 2. DuckDB — ~163K-1.2M rows/sec (Arrow optimized) -3. **CSharpDB — ~370K rows/sec** +3. **CSharpDB — ~234K rows/sec** 4. SQLite — ~80-114K rows/sec **Point Lookup Throughput** (single-thread, cached): -1. **CSharpDB (Collection API) — 1,372K ops/sec** +1. **CSharpDB (SQL API) — 1,229K ops/sec** (100-row dataset) 2. SQLite — ~275-484K ops/sec -3. **CSharpDB (SQL API) — ~53-787K ops/sec (dataset-dependent)** -4. H2 — ~50-150K ops/sec (estimated) -5. UnQLite — ~60K ops/sec (KV API) -6. LiteDB — ~24K ops/sec +3. **CSharpDB (Collection API) — 702K ops/sec** (10K-doc dataset) +4. **CSharpDB (SQL API) — 190K-727K ops/sec** (1K-100K rows) +5. H2 — ~50-150K ops/sec (estimated) +6. UnQLite — ~60K ops/sec (KV API) +7. LiteDB — ~24K ops/sec --- ## Performance Improvement History -### Run 8 (Latest) — Collection (NoSQL) API Benchmarks +### Run 9 (Latest) — Reproducible Benchmark Mode + Storage Improvements + +Run 9 introduces the reproducible benchmark mode (`--repro --cpu-threads 8 --repeat 5`) with CPU affinity pinning and median-of-5 statistical aggregation. All numbers are now measured with High process priority, pinned to 8 logical CPUs, with 1 warmup pass + 5 recorded iterations taking the median. This branch also includes storage-layer improvements on `Improve_CSharpDB_Storage`. + +| Metric | Run 8 | Run 9 (repro median-of-5) | Change | +|--------|-------|--------------------------|--------| +| Single INSERT (auto-commit) | 10,611 ops/sec | 18,005 ops/sec | **+70%** | +| Batch 100 rows/tx | ~247K rows/sec | ~234K rows/sec | -5% | +| Point lookup (100 rows) | — | 1,229,494 ops/sec | — | +| Point lookup (1K rows) | 217,694 ops/sec | 727,502 ops/sec | **+234%** | +| Point lookup (10K rows) | 1,084,962 ops/sec | 582,014 ops/sec | -46% (CPU-pinned) | +| Point lookup (100K rows) | 36,774 ops/sec | 190,418 ops/sec | **+418%** | +| Mixed reads | 32,714 ops/sec | 39,489 ops/sec | **+21%** | +| Mixed writes | 8,201 ops/sec | 9,886 ops/sec | **+21%** | +| Collection Point Get | 1,380,629 ops/sec | 702,433 ops/sec | -49% (CPU-pinned) | +| Collection Put | 10,935 ops/sec | 18,586 ops/sec | **+70%** | +| Concurrent readers (8) | 576,447 ops/sec | 164,444 ops/sec | -72% (8-CPU affinity) | +| Writer with 8 readers | 2,801 ops/sec | 3,033 ops/sec | **+8%** | +| Crash recovery P50 | — | 26.5 ms | — | + +**Key observations:** The repro mode with CPU affinity pinning produces dramatically different profiles. Auto-commit writes improved **+70%** from storage-layer optimizations. Point lookups at small (100-1K) and large (100K) datasets saw **massive gains** (up to +418%) due to better CPU cache locality from affinity pinning. However, operations that previously benefited from spreading across all 16 logical CPUs (concurrent 8-reader throughput, 10K-row lookups) show lower numbers under the constrained 8-CPU topology. These repro numbers are more **reproducible and comparable** across runs. + +### Run 8 — Collection (NoSQL) API Benchmarks Run 8 adds the new Collection API and its dedicated benchmark suite. The Collection API bypasses the SQL parser and planner, going directly to the B+tree for document operations. @@ -414,7 +457,7 @@ The Collection API is **fastest read path in CSharpDB** at 1.44M ops/sec. Write Run 6 added BTree hint cache, QueryPlanner fast path, and row buffer reuse. Run 7 added the opt-in sync fast path (`PreferSyncPointLookups`) that bypasses the async operator pipeline for cached point lookups. -| Metric | Run 5 | Run 7 (Latest) | Change | +| Metric | Run 5 | Run 7 | Change | |--------|-------|----------------|--------| | Point lookup (1K rows) | 157,015 ops/sec | 217,694 ops/sec | **+39%** | | Point lookup (10K rows) | 67,513 ops/sec | 78,407 ops/sec | **+16%** | @@ -465,22 +508,23 @@ The .NET 10 upgrade combined with engine improvements delivered consistent gains | ADO.NET INSERT | 143 us | 127 us | **11% faster** | | ADO.NET COUNT | 343 ns | 288 ns | **16% faster** | -### Full History: Unoptimized (Run 1) to Latest (Run 8) - -| Metric | Run 1 (Unoptimized) | Run 8 (Latest) | Total Improvement | -|--------|---------------------|----------------|-------------------| -| Sustained writes | 1,062 ops/sec | 10,611 ops/sec | **10.0x** | -| Point lookup (1K rows) | ~30K ops/sec | 217,694 ops/sec | **7.3x** | -| Collection point Get | N/A | 1,438,440 ops/sec | — (new API) | -| Mixed reads | 368 ops/sec | 32,714 ops/sec | **89x** | -| Mixed writes | 91 ops/sec | 8,201 ops/sec | **90x** | -| Concurrent readers (8) | 1,026 ops/10s | 576,447 ops/sec | **5,619x** | -| BTree depth 2 lookups | N/A | 296,815 ops/sec | — | +### Full History: Unoptimized (Run 1) to Latest (Run 9) + +| Metric | Run 1 (Unoptimized) | Run 9 (Latest, repro) | Total Improvement | +|--------|---------------------|----------------------|-------------------| +| Sustained writes | 1,062 ops/sec | 18,005 ops/sec | **17.0x** | +| Point lookup (1K rows) | ~30K ops/sec | 727,502 ops/sec | **24.3x** | +| Point lookup (100K rows) | N/A | 190,418 ops/sec | — | +| Collection point Get | N/A | 702,433 ops/sec | — (new API) | +| Mixed reads | 368 ops/sec | 39,489 ops/sec | **107x** | +| Mixed writes | 91 ops/sec | 9,886 ops/sec | **109x** | +| Concurrent readers (8) | 1,026 ops/10s | 164,444 ops/sec | **1,603x** | +| BTree depth 2 lookups | N/A | 768,119 ops/sec | — | | ADO.NET INSERT latency | 1,201 us | 117 us | **10.3x faster** | | ADO.NET INSERT memory | 1,802 KB | 3.7 KB | **487x less** | | ADO.NET COUNT latency | 150 us | 253 ns | **593x faster** | | ADO.NET COUNT memory | 347 KB | 448 B | **793x less** | -| WAL growth read penalty | Linear (1.08ms at 10K) | Constant (0.002ms) | **Eliminated** | +| WAL growth read penalty | Linear (1.08ms at 10K) | Constant (0.001ms) | **Eliminated** | | Write amplification (50K) | 4.8x | 2.9x | **40% less** | | Crash recovery | 100% (50/50) | 100% (50/50) | **Maintained** | @@ -490,15 +534,15 @@ The .NET 10 upgrade combined with engine improvements delivered consistent gains Based on the current benchmark data, the highest-impact future optimizations would be: -1. **Prepared statement cache**: Each query re-parses SQL and re-plans. Caching parsed ASTs or compiled plans would significantly boost point lookup throughput (currently the main remaining gap vs SQLite's ~275-484K). +1. **Prepared statement cache**: Each query re-parses SQL and re-plans. Caching parsed ASTs or compiled plans would further boost point lookup throughput, especially at larger datasets where CSharpDB already matches or exceeds SQLite's ~275-484K range. -2. **Connection pooling**: Connection Open+Close takes 4.2ms. A connection pool would amortize this for high-frequency short-lived operations. +2. **Connection pooling**: Connection Open+Close takes ~5.6ms. A connection pool would amortize this for high-frequency short-lived operations. 3. **Memory-mapped I/O (mmap)**: SQLite's key advantage for reads is zero-copy page access via mmap. The current `byte[]` copy per page read adds GC pressure and copy cost that mmap would eliminate. 4. **Async I/O batching**: Group multiple page writes into fewer I/O system calls during batch operations. -5. **Columnar scan optimization**: Full table COUNT(*) scans (49 ops/sec at 100K rows) could benefit from aggregate metadata or partial counting. +5. **Columnar scan optimization**: Full table COUNT(*) scans (96 ops/sec at 100K rows) could benefit from aggregate metadata or partial counting. 6. **Write-ahead buffering**: Buffer multiple WAL writes before flushing to disk to improve auto-commit write throughput beyond the current fsync-limited rate. diff --git a/tests/CSharpDB.Benchmarks/Scaling/BTreeDepthBenchmark.cs b/tests/CSharpDB.Benchmarks/Scaling/BTreeDepthBenchmark.cs index 104889d..c8a79f2 100644 --- a/tests/CSharpDB.Benchmarks/Scaling/BTreeDepthBenchmark.cs +++ b/tests/CSharpDB.Benchmarks/Scaling/BTreeDepthBenchmark.cs @@ -59,10 +59,22 @@ public static async Task> RunAsync() // Measure point lookup latency var hist = new LatencyHistogram(); var rng = new Random(42); - const int lookupCount = 1000; + const int lookupWarmupCount = 1_000; + const int minLookupCount = 2_000; + const int maxLookupCount = 100_000; + const double minLookupDurationMs = 250; + + for (int i = 0; i < lookupWarmupCount; i++) + { + int id = rng.Next(0, targetRows); + await using var warmup = await db.ExecuteAsync($"SELECT * FROM t WHERE id = {id}"); + await warmup.ToListAsync(); + } + var totalSw = Stopwatch.StartNew(); + int lookupCount = 0; - for (int i = 0; i < lookupCount; i++) + while (lookupCount < maxLookupCount) { int id = rng.Next(0, targetRows); var sw = Stopwatch.StartNew(); @@ -70,6 +82,10 @@ public static async Task> RunAsync() await result.ToListAsync(); sw.Stop(); hist.Record(sw.Elapsed.TotalMilliseconds); + lookupCount++; + + if (lookupCount >= minLookupCount && totalSw.Elapsed.TotalMilliseconds >= minLookupDurationMs) + break; } totalSw.Stop(); diff --git a/tests/CSharpDB.Benchmarks/Scaling/RowCountScalingBenchmark.cs b/tests/CSharpDB.Benchmarks/Scaling/RowCountScalingBenchmark.cs index aca6107..cb54b23 100644 --- a/tests/CSharpDB.Benchmarks/Scaling/RowCountScalingBenchmark.cs +++ b/tests/CSharpDB.Benchmarks/Scaling/RowCountScalingBenchmark.cs @@ -50,10 +50,21 @@ await db.ExecuteAsync( // --- Point Lookup Benchmark --- var lookupHist = new LatencyHistogram(); var lookupRng = new Random(42); - int lookupIters = Math.Min(1000, targetRowCount); - var lookupSw = Stopwatch.StartNew(); + const int lookupWarmupIters = 1_000; + const int minLookupIters = 2_000; + const int maxLookupIters = 100_000; + const double minLookupDurationMs = 250; - for (int i = 0; i < lookupIters; i++) + for (int i = 0; i < lookupWarmupIters; i++) + { + int id = lookupRng.Next(0, targetRowCount); + await using var warmup = await db.ExecuteAsync($"SELECT * FROM t WHERE id = {id}"); + await warmup.ToListAsync(); + } + + var lookupSw = Stopwatch.StartNew(); + int lookupIters = 0; + while (lookupIters < maxLookupIters) { int id = lookupRng.Next(0, targetRowCount); var sw = Stopwatch.StartNew(); @@ -61,6 +72,10 @@ await db.ExecuteAsync( await result.ToListAsync(); sw.Stop(); lookupHist.Record(sw.Elapsed.TotalMilliseconds); + lookupIters++; + + if (lookupIters >= minLookupIters && lookupSw.Elapsed.TotalMilliseconds >= minLookupDurationMs) + break; } lookupSw.Stop(); @@ -85,20 +100,60 @@ await db.ExecuteAsync( results.Add(BenchmarkResult.FromHistogram( $"RowScale_{targetRowCount}_Insert", insertHist, insertSw.Elapsed.TotalMilliseconds)); + // Normalize scan phase against a checkpointed state to reduce WAL-related jitter. + await db.CheckpointAsync(); + // --- Full Scan Benchmark (only for ≤ 100K rows) --- if (targetRowCount <= 100_000) { var scanHist = new LatencyHistogram(); - int scanIters = 3; + // Use COUNT(val) to force row visitation instead of metadata COUNT(*). + const string scanSql = "SELECT COUNT(val) FROM t"; + int scanWarmupIters = targetRowCount switch + { + <= 1_000 => 8, + <= 10_000 => 6, + _ => 4 + }; + int minScanIters = targetRowCount switch + { + <= 1_000 => 120, + <= 10_000 => 80, + _ => 30 + }; + const int maxScanIters = 5_000_000; + double minScanDurationMs = targetRowCount switch + { + <= 1_000 => 300, + <= 10_000 => 500, + _ => 1_000 + }; + + for (int i = 0; i < scanWarmupIters; i++) + { + await using var warmup = await db.ExecuteAsync(scanSql); + await warmup.ToListAsync(); + } + + // Reduce GC interference right before the measured scan window. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var scanSw = Stopwatch.StartNew(); + int scanIters = 0; - for (int i = 0; i < scanIters; i++) + while (scanIters < maxScanIters) { var sw = Stopwatch.StartNew(); - await using var result = await db.ExecuteAsync("SELECT COUNT(*) FROM t"); + await using var result = await db.ExecuteAsync(scanSql); await result.ToListAsync(); sw.Stop(); scanHist.Record(sw.Elapsed.TotalMilliseconds); + scanIters++; + + if (scanSw.Elapsed.TotalMilliseconds >= minScanDurationMs && scanIters >= minScanIters) + break; } scanSw.Stop(); diff --git a/tests/CSharpDB.Benchmarks/Stress/WalGrowthBenchmark.cs b/tests/CSharpDB.Benchmarks/Stress/WalGrowthBenchmark.cs index 0b6a9eb..b4aa9cf 100644 --- a/tests/CSharpDB.Benchmarks/Stress/WalGrowthBenchmark.cs +++ b/tests/CSharpDB.Benchmarks/Stress/WalGrowthBenchmark.cs @@ -10,6 +10,9 @@ namespace CSharpDB.Benchmarks.Stress; /// public static class WalGrowthBenchmark { + private const int WarmupReadIterations = 300; + private const int MeasuredReadIterations = 2_000; + public static async Task> RunAsync() { var results = new List(); @@ -55,8 +58,10 @@ await db.ExecuteAsync( // Measure read latency at this WAL size var readHist = new LatencyHistogram(); var lookupRng = new Random(123); + await WarmupPointReadsAsync(db, lookupRng, nextId); + var readTotalSw = Stopwatch.StartNew(); - for (int i = 0; i < 200; i++) + for (int i = 0; i < MeasuredReadIterations; i++) { int lookupId = lookupRng.Next(0, nextId); var sw = Stopwatch.StartNew(); @@ -65,10 +70,11 @@ await db.ExecuteAsync( sw.Stop(); readHist.Record(sw.Elapsed.TotalMilliseconds); } + readTotalSw.Stop(); long walSize = File.Exists(walPath) ? new FileInfo(walPath).Length : 0; var readResult = BenchmarkResult.FromHistogram( - $"WalGrowth_{targetFrames}frames_ReadLatency", readHist, 0); + $"WalGrowth_{targetFrames}frames_ReadLatency", readHist, readTotalSw.Elapsed.TotalMilliseconds); readResult = new BenchmarkResult { Name = readResult.Name, @@ -98,7 +104,9 @@ await db.ExecuteAsync( var postCkptHist = new LatencyHistogram(); var postRng = new Random(123); - for (int i = 0; i < 200; i++) + await WarmupPointReadsAsync(db, postRng, nextId); + var postCkptTotalSw = Stopwatch.StartNew(); + for (int i = 0; i < MeasuredReadIterations; i++) { int lookupId = postRng.Next(0, nextId); var sw = Stopwatch.StartNew(); @@ -107,9 +115,10 @@ await db.ExecuteAsync( sw.Stop(); postCkptHist.Record(sw.Elapsed.TotalMilliseconds); } + postCkptTotalSw.Stop(); var postResult = BenchmarkResult.FromHistogram( - "WalGrowth_PostCheckpoint_ReadLatency", postCkptHist, 0); + "WalGrowth_PostCheckpoint_ReadLatency", postCkptHist, postCkptTotalSw.Elapsed.TotalMilliseconds); postResult = new BenchmarkResult { Name = postResult.Name, @@ -139,4 +148,14 @@ await db.ExecuteAsync( return results; } + + private static async ValueTask WarmupPointReadsAsync(Database db, Random rng, int maxIdExclusive) + { + for (int i = 0; i < WarmupReadIterations; i++) + { + int lookupId = rng.Next(0, maxIdExclusive); + await using var result = await db.ExecuteAsync($"SELECT * FROM t WHERE id = {lookupId}"); + await result.ToListAsync(); + } + } } diff --git a/tests/CSharpDB.Benchmarks/results/perf-guardrails-last.md b/tests/CSharpDB.Benchmarks/results/perf-guardrails-last.md index c6d70fe..5a38d88 100644 --- a/tests/CSharpDB.Benchmarks/results/perf-guardrails-last.md +++ b/tests/CSharpDB.Benchmarks/results/perf-guardrails-last.md @@ -2,99 +2,99 @@ - Baseline: `C:\Users\maxim\source\Code\CSharpDB\tests\CSharpDB.Benchmarks\baselines\20260302-001757` - Note: one or more checks use per-check `baselineSnapshot` overrides -- Current: `C:\Users\maxim\source\Code\CSharpDB\BenchmarkDotNet.Artifacts\\results` +- Current: `C:\Users\maxim\source\Code\CSharpDB\tests\CSharpDB.Benchmarks\results\.tmp-current-micro` - Thresholds: `C:\Users\maxim\source\Code\CSharpDB\tests\CSharpDB.Benchmarks\perf-thresholds.json` -- Generated (UTC): 2026-03-03 19:36:55Z +- Generated (UTC): 2026-03-05 06:49:12Z Compared 88 rows against baseline. PASS=88, FAIL=0 | CSV | Key | Mean Δ% | Alloc Δ% | Alloc Δ B | Status | |---|---|---:|---:|---:|---| -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=100 | -38.55 | -12.71 | -30,863 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=1000 | -36.55 | -12.70 | -30,843 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=10000 | -45.66 | -12.69 | -30,833 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=100 | -32.77 | -12.49 | -301,199 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=1000 | -26.72 | -12.49 | -301,486 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=10000 | -31.48 | -12.49 | -301,414 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=100 | -59.33 | -22.40 | -1,126 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=1000 | -58.31 | -22.40 | -1,126 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=10000 | -61.17 | -22.40 | -1,126 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=100 | -58.92 | -20.25 | -983 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=1000 | -54.98 | -20.00 | -973 | PASS | -| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=10000 | -60.58 | -20.21 | -983 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='CROSS JOIN 100x100' | -94.23 | -66.84 | -1,976,914 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K (forced nested-loop)' | -72.69 | -99.91 | -288,168,991 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K LIMIT 1' | -81.15 | -60.52 | -243,036 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K' | -79.59 | -66.61 | -587,244 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx20K (no swap via view)' | -63.06 | -52.33 | -5,215,222 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx20K (planner swap build side)' | -73.57 | -77.57 | -7,327,212 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 20Kx1K (natural build side)' | -67.69 | -77.57 | -7,327,191 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN on right PK (forced hash)' | -57.09 | -50.97 | -494,725 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN on right PK (index nested-loop)' | -55.51 | -63.79 | -344,340 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN with filter' | -77.34 | -39.85 | -330,865 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='LEFT JOIN 1Kx1K' | -80.73 | -66.60 | -587,100 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='RIGHT JOIN on left PK (forced hash)' | -42.05 | -27.79 | -270,029 | PASS | -| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='RIGHT JOIN on left PK (rewritten index nested-loop)' | -22.95 | -22.14 | -119,757 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=1000 | 2.82 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=10000 | 2.45 | -0.00 | -82 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=100000 | 7.46 | -0.00 | -338 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=1000 | 3.17 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=10000 | 22.26 | 0.01 | 266 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=100000 | -3.46 | 0.00 | 143 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=1000 | -15.86 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=10000 | -3.58 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=100000 | -14.37 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=1000 | 0.43 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=10000 | -9.81 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=100000 | -14.89 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=1000 | -37.62 | -50.87 | -1,081 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=10000 | -47.85 | -60.63 | -1,517 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=100000 | -51.72 | -62.17 | -1,579 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=1000 | -29.43 | -34.77 | -396 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=10000 | -27.67 | -40.25 | -539 | PASS | -| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=100000 | -39.41 | -41.13 | -559 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=1000 | 13.71 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=10000 | 11.51 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=100000 | 25.95 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=1000 | 6.07 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=10000 | -2.13 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=100000 | 10.23 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=1000 | 0.61 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=10000 | 3.01 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=100000 | -0.59 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=1000 | -3.22 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=10000 | 4.78 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=100000 | 21.06 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=1000 | -1.06 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=10000 | -3.13 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=100000 | 22.07 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=1000 | 13.00 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=10000 | 18.28 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=100000 | -2.17 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=1000 | -0.46 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=10000 | 9.67 | -0.00 | -1 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=100000 | -2.85 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=1000 | 1.43 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=10000 | 7.74 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=100000 | 0.01 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=1000 | 0.19 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=10000 | 6.78 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=100000 | 6.84 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=1000 | 2.46 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=10000 | 5.58 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=100000 | 7.23 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=1000 | 7.22 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=10000 | 6.14 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=100000 | 3.31 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=1000 | 6.45 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=10000 | 10.99 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=100000 | 4.11 | 0.00 | 0 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=100 | -38.88 | -12.85 | -30,577 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=1000 | -37.11 | -12.85 | -30,577 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=500 | -37.96 | -12.84 | -30,556 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=100 | 16.30 | -12.07 | -31,345 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=1000 | -6.83 | -12.72 | -303,452 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=500 | 4.68 | -12.66 | -152,494 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=100 | -62.64 | -21.37 | -829 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=1000 | -64.25 | -21.84 | -850 | PASS | -| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=500 | -54.08 | -21.47 | -840 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=100 | -35.03 | -12.72 | -30,884 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=1000 | -35.32 | -12.70 | -30,843 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x100 in transaction'; PreSeededRows=10000 | -44.59 | -12.69 | -30,833 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=100 | -28.89 | -12.49 | -301,189 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=1000 | -25.92 | -12.49 | -301,486 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Batch INSERT x1000 in transaction'; PreSeededRows=10000 | -29.05 | -12.49 | -301,414 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=100 | -53.60 | -22.00 | -1,106 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=1000 | -52.67 | -22.00 | -1,106 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT (auto-commit)'; PreSeededRows=10000 | -55.13 | -21.79 | -1,096 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=100 | -54.22 | -20.04 | -973 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=1000 | -56.36 | -20.21 | -983 | PASS | +| CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv | Method='Single INSERT in explicit transaction'; PreSeededRows=10000 | -58.36 | -20.00 | -973 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='CROSS JOIN 100x100' | -94.50 | -66.84 | -1,976,914 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K (forced nested-loop)' | -74.49 | -99.91 | -288,168,991 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K LIMIT 1' | -80.62 | -60.52 | -243,036 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx1K' | -78.74 | -66.61 | -587,244 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx20K (no swap via view)' | -63.77 | -52.33 | -5,215,273 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 1Kx20K (planner swap build side)' | -71.65 | -77.57 | -7,327,212 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN 20Kx1K (natural build side)' | -68.82 | -77.57 | -7,327,181 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN on right PK (forced hash)' | -58.13 | -50.97 | -494,725 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN on right PK (index nested-loop)' | -55.85 | -63.79 | -344,340 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='INNER JOIN with filter' | -77.82 | -39.85 | -330,865 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='LEFT JOIN 1Kx1K' | -81.40 | -66.60 | -587,100 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='RIGHT JOIN on left PK (forced hash)' | -42.40 | -27.79 | -270,029 | PASS | +| CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv | Method='RIGHT JOIN on left PK (rewritten index nested-loop)' | -29.95 | -22.14 | -119,757 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=1000 | -0.97 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=10000 | -14.93 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (index-order scan)'; RowCount=100000 | -3.00 | 0.00 | 41 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=1000 | 5.20 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=10000 | -1.09 | 0.01 | 184 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value (no index)'; RowCount=100000 | -11.73 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=1000 | -18.33 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=10000 | -6.56 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (index-order scan)'; RowCount=100000 | -12.91 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=1000 | -3.77 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=10000 | -8.00 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv | Method='ORDER BY value + LIMIT 100 (no index)'; RowCount=100000 | -14.01 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=1000 | -21.72 | -50.87 | -1,081 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=10000 | -42.29 | -60.63 | -1,517 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by PK with residual conjunct'; RowCount=100000 | -44.92 | -62.17 | -1,579 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=1000 | -24.90 | -34.77 | -396 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=10000 | -23.23 | -40.25 | -539 | PASS | +| CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv | Method='SELECT by primary key'; RowCount=100000 | -33.30 | -41.13 | -559 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=1000 | -7.42 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=10000 | 6.35 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Hash SUM(value) via GROUP BY 1'; RowCount=100000 | -9.65 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=1000 | -3.58 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=10000 | -4.31 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar AVG(value)'; RowCount=100000 | -9.84 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=1000 | -8.93 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=10000 | -14.12 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(DISTINCT value)'; RowCount=100000 | -8.29 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=1000 | 0.43 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=10000 | -6.90 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar COUNT(value)'; RowCount=100000 | -4.02 | -0.14 | -1 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=1000 | -3.01 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=10000 | -6.54 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MAX(value)'; RowCount=100000 | -18.70 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=1000 | -3.04 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=10000 | -0.59 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar MIN(value)'; RowCount=100000 | -10.86 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=1000 | -8.99 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=10000 | -7.17 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(DISTINCT value)'; RowCount=100000 | -11.51 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=1000 | -2.53 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=10000 | -5.32 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv | Method='Scalar SUM(value)'; RowCount=100000 | -18.40 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=1000 | -6.91 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=10000 | -2.93 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar COUNT(text_col)'; RowCount=100000 | -2.43 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=1000 | -5.97 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=10000 | -3.15 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='Index lookup scalar SUM(id)'; RowCount=100000 | -3.35 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=1000 | -3.18 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=10000 | -5.35 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar COUNT(text_col)'; RowCount=100000 | -3.57 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=1000 | -5.88 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=10000 | -3.50 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv | Method='PK lookup scalar SUM(value)'; RowCount=100000 | -3.69 | 0.00 | 0 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=100 | -44.89 | -12.85 | -30,577 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=1000 | -34.70 | -12.85 | -30,577 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='100-row batch commit'; WalFramesBeforeCheckpoint=500 | -22.21 | -12.82 | -30,515 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=100 | 12.72 | -12.06 | -31,314 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=1000 | -7.73 | -12.71 | -303,421 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Manual checkpoint after N writes'; WalFramesBeforeCheckpoint=500 | -0.38 | -12.65 | -152,433 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=100 | -63.32 | -21.11 | -819 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=1000 | -63.98 | -21.58 | -840 | PASS | +| CSharpDB.Benchmarks.Micro.WalBenchmarks-report.csv | Method='Single-row commit (WAL flush)'; WalFramesBeforeCheckpoint=500 | -62.94 | -21.99 | -860 | PASS | diff --git a/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 b/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 index cc3f206..329ce93 100644 --- a/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 +++ b/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 @@ -154,6 +154,82 @@ function Format-Bytes return ("{0:N0} B" -f $Bytes) } +function Resolve-CurrentMicroResultsDirectory +{ + param( + [Parameter(Mandatory = $true)][string]$BenchDir, + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $false)][string]$ConfiguredPath + ) + + if (-not [string]::IsNullOrWhiteSpace($ConfiguredPath)) + { + $resolvedConfiguredPath = $ConfiguredPath + if (-not [System.IO.Path]::IsPathRooted($resolvedConfiguredPath)) + { + $resolvedConfiguredPath = Join-Path $RepoRoot $resolvedConfiguredPath + } + + if (-not (Test-Path $resolvedConfiguredPath)) + { + throw "Current micro-results directory not found: $resolvedConfiguredPath" + } + + return (Resolve-Path $resolvedConfiguredPath).Path + } + + $candidateDirs = @( + (Join-Path $RepoRoot "BenchmarkDotNet.Artifacts\\results"), + (Join-Path $BenchDir "BenchmarkDotNet.Artifacts\\results") + ) + + $existingCandidates = @( + $candidateDirs | + Where-Object { Test-Path $_ } | + ForEach-Object { (Resolve-Path $_).Path } | + Select-Object -Unique + ) + + if ($existingCandidates.Count -eq 0) + { + throw "Current micro-results directory not found in expected locations: $($candidateDirs -join '; ')" + } + + if ($existingCandidates.Count -eq 1) + { + return $existingCandidates[0] + } + + $stagingDir = Join-Path $BenchDir "results\\.tmp-current-micro" + New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null + Get-ChildItem -Path $stagingDir -File -ErrorAction SilentlyContinue | Remove-Item -Force + + $latestByName = @{} + foreach ($candidate in $existingCandidates) + { + foreach ($file in Get-ChildItem -Path $candidate -File -Filter "*.csv") + { + if (-not $latestByName.ContainsKey($file.Name) -or + $file.LastWriteTimeUtc -gt $latestByName[$file.Name].LastWriteTimeUtc) + { + $latestByName[$file.Name] = $file + } + } + } + + if ($latestByName.Count -eq 0) + { + throw "No benchmark CSV files found across candidate result directories: $($existingCandidates -join '; ')" + } + + foreach ($entry in $latestByName.GetEnumerator()) + { + Copy-Item -Path $entry.Value.FullName -Destination (Join-Path $stagingDir $entry.Key) -Force + } + + return (Resolve-Path $stagingDir).Path +} + $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $benchDir = (Resolve-Path (Join-Path $scriptDir "..")).Path $repoRoot = (Resolve-Path (Join-Path $benchDir "..\\..")).Path @@ -222,19 +298,10 @@ if (-not $baselineResolved) Write-Host "No baseline snapshot found. Skipping comparison and reporting raw benchmark results only." } -if ([string]::IsNullOrWhiteSpace($CurrentMicroResultsDir)) -{ - $CurrentMicroResultsDir = Join-Path $repoRoot "BenchmarkDotNet.Artifacts\\results" -} -elseif (-not [System.IO.Path]::IsPathRooted($CurrentMicroResultsDir)) -{ - $CurrentMicroResultsDir = Join-Path $repoRoot $CurrentMicroResultsDir -} - -if (-not (Test-Path $CurrentMicroResultsDir)) -{ - throw "Current micro-results directory not found: $CurrentMicroResultsDir" -} +$CurrentMicroResultsDir = Resolve-CurrentMicroResultsDirectory ` + -BenchDir $benchDir ` + -RepoRoot $repoRoot ` + -ConfiguredPath $CurrentMicroResultsDir if (-not $baselineResolved) { From 42126219be5428c944969065438234f7c3b0f64e Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 4 Mar 2026 23:30:49 -0800 Subject: [PATCH 5/5] bump version --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 057b51b..0ce79b5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,7 +8,7 @@ database;embedded;sql;dotnet;btree;wal README.md icon.png - 1.1.0 + 1.2.0