diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index af8a236..027f864 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -19,6 +19,7 @@ on:
- tvp
- sql-server
- collections
+ - cache
# Minimal default permissions
permissions:
@@ -91,17 +92,28 @@ jobs:
- name: Wait for SQL Server to be ready
run: |
- echo "⏳ Waiting for SQL Server on port 1433..."
- for i in $(seq 1 30); do
- if nc -z -w 3 localhost 1433 2>/dev/null; then
- echo "✅ Port 1433 open (attempt $i) — waiting 5s for engine init..."
- sleep 5
+ # The service container health check already ensures SQL Server is up before
+ # steps start, but we add an explicit engine-level verification here as a
+ # belt-and-suspenders check and to produce a clear diagnostic in the log.
+ #
+ # 'sqlcmd' is in PATH on GitHub-hosted ubuntu-latest runners.
+ # The full path /opt/mssql-tools18/bin/sqlcmd is only valid INSIDE the container.
+ echo "⏳ Verifying SQL Server engine on localhost:1433..."
+ for i in $(seq 1 12); do
+ if sqlcmd \
+ -S localhost,1433 \
+ -U sa \
+ -P "$SA_PASSWORD" \
+ -Q "SELECT 1" \
+ -C -b -l 5 \
+ > /dev/null 2>&1; then
+ echo "✅ SQL Server engine verified (attempt $i)."
exit 0
fi
- echo " Attempt $i/30 — waiting 5s..."
+ echo " Attempt $i/12 — not ready yet, retrying in 5s..."
sleep 5
done
- echo "❌ SQL Server port 1433 not available after 30 attempts. Aborting."
+ echo "❌ SQL Server engine did not respond after 12 attempts. Aborting."
exit 1
- name: Run Benchmarks
@@ -153,15 +165,27 @@ jobs:
# MarkdownExporter.GitHub produces *-report-github.md files.
# Extract only table rows (lines starting with '|') to strip BDN environment headers.
+ extracted=0
+ skipped=0
for md_file in "$ARTIFACTS"/*-report-github.md; do
[ -f "$md_file" ] || continue
# Strip namespace prefix, keep ClassName only:
# e.g. "CaeriusNet.Benchmark.Workshops.Benchs.Mapping.DtoMappingBench-report-github.md"
# → "DtoMappingBench"
classname=$(basename "$md_file" "-report-github.md" | rev | cut -d'.' -f1 | rev)
- awk '/^\|/' "$md_file" > "$DOCS_DIR/${classname}.md"
- echo " ✅ ${classname}.md ($(wc -l < "$DOCS_DIR/${classname}.md") rows)"
+ out_file="$DOCS_DIR/${classname}.md"
+ awk '/^\|/' "$md_file" > "$out_file"
+ line_count=$(wc -l < "$out_file")
+ if [ "$line_count" -lt 3 ]; then
+ echo " ⚠️ ${classname}.md has only ${line_count} line(s) — skipping (expected header + separator + ≥1 data row)."
+ rm -f "$out_file"
+ skipped=$((skipped + 1))
+ else
+ echo " ✅ ${classname}.md (${line_count} lines)"
+ extracted=$((extracted + 1))
+ fi
done
+ echo "📊 Extraction summary: ${extracted} file(s) written, ${skipped} skipped."
echo "📋 Documentation results directory:"
ls -la "$DOCS_DIR"
@@ -179,7 +203,9 @@ jobs:
else
CATEGORY="${{ github.event.inputs.category || 'all' }}"
VERSION=$(grep -oP '(?<=)\d+\.\d+\.\d+(?=)' Src/CaeriusNet.csproj || echo "unknown")
- git commit -m "chore(bench): update benchmark results v${VERSION} [category:${CATEGORY}] [skip ci]"
+ COMMIT_SHA="${{ github.sha }}"
+ SHORT_SHA="${COMMIT_SHA:0:7}"
+ git commit -m "chore(bench): update benchmark results v${VERSION} [category:${CATEGORY}] [run:${SHORT_SHA}] [skip ci]"
git push
echo "✅ Benchmark results committed."
fi
diff --git a/Benchmark/Program.cs b/Benchmark/Program.cs
index 433a03a..7ce0ac2 100644
--- a/Benchmark/Program.cs
+++ b/Benchmark/Program.cs
@@ -19,6 +19,9 @@
case "collections":
RunningBenchmarks.Run_Collections_Benchmarks();
break;
+ case "cache":
+ RunningBenchmarks.Run_Cache_Benchmarks();
+ break;
default:
RunningBenchmarks.Run_All_Benchmarks();
break;
diff --git a/Benchmark/RunningBenchmarks.cs b/Benchmark/RunningBenchmarks.cs
index e02fd35..7ccbfa3 100644
--- a/Benchmark/RunningBenchmarks.cs
+++ b/Benchmark/RunningBenchmarks.cs
@@ -1,4 +1,5 @@
using CaeriusNet.Benchmark.Workshops;
+using CaeriusNet.Benchmark.Workshops.Benchs.Cache;
using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections;
using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity;
using CaeriusNet.Benchmark.Workshops.Benchs.Mapping;
@@ -20,7 +21,7 @@ private static IConfig GetConfig()
return new BenchmarkConfig();
}
- /// Runs ALL benchmarks (in-memory + TVP + collection + SQL Server).
+ /// Runs ALL benchmarks (in-memory + TVP + collection + cache + SQL Server).
public static void Run_All_Benchmarks()
{
BenchmarkRunner.Run([
@@ -50,6 +51,9 @@ public static void Run_All_Benchmarks()
typeof(ListWithCapacityToBench),
typeof(ListWithCapacityWithOverextendToBench),
typeof(ListWithLessCapacityThanNeededToBench),
+ // Cache layer: FrozenDictionary and IMemoryCache throughput
+ typeof(FrozenCacheBench),
+ typeof(InMemoryCacheBench),
// SQL Server (skipped automatically if BENCHMARK_SQL_CONNECTION not set)
typeof(SpExecutionBench),
typeof(BatchedVsSingleBench),
@@ -126,4 +130,13 @@ public static void Run_Collections_Benchmarks()
typeof(ListWithLessCapacityThanNeededToBench)
], GetConfig());
}
+
+ /// Runs cache-layer benchmarks (FrozenDictionary and IMemoryCache throughput).
+ public static void Run_Cache_Benchmarks()
+ {
+ BenchmarkRunner.Run([
+ typeof(FrozenCacheBench),
+ typeof(InMemoryCacheBench)
+ ], GetConfig());
+ }
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/BenchmarkConfig.cs b/Benchmark/Workshops/BenchmarkConfig.cs
index 1b2786d..004e320 100644
--- a/Benchmark/Workshops/BenchmarkConfig.cs
+++ b/Benchmark/Workshops/BenchmarkConfig.cs
@@ -9,7 +9,7 @@ public class BenchmarkConfig : ManualConfig
{
public BenchmarkConfig()
{
- var isCI = string.Equals(
+ var isCi = string.Equals(
Environment.GetEnvironmentVariable("CI"),
"true",
StringComparison.OrdinalIgnoreCase);
@@ -20,7 +20,7 @@ public BenchmarkConfig()
?? Path.Combine(Directory.GetCurrentDirectory(), "BenchmarkDotNet.Artifacts");
WithArtifactsPath(artifactsPath);
- if (isCI)
+ if (isCi)
{
// InProcessEmitToolchain: runs benchmarks in the host process (no child process spawning).
// This guarantees artifacts are always written to the configured path above.
@@ -28,15 +28,16 @@ public BenchmarkConfig()
AddJob(Job.Default
.WithToolchain(InProcessEmitToolchain.Instance)
.WithWarmupCount(1)
- .WithIterationCount(3));
+ .WithIterationCount(5));
AddExporter(MarkdownExporter.GitHub); // → *-report-github.md (GitHub-flavoured markdown table)
- AddExporter(JsonExporter.Full); // → *-report-full.json
+ AddExporter(JsonExporter.Full); // → *-report-full.json
}
else
{
AddJob(Job.Default);
AddExporter(HtmlExporter.Default);
+ AddExporter(MarkdownExporter.GitHub); // → *-report-github.md for offline analysis
AddExporter(JsonExporter.Full);
}
diff --git a/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs b/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs
new file mode 100644
index 0000000..f8ab841
--- /dev/null
+++ b/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs
@@ -0,0 +1,131 @@
+using System.Collections.Frozen;
+using CaeriusNet.Benchmark.Data.Generated;
+
+namespace CaeriusNet.Benchmark.Workshops.Benchs.Cache;
+
+///
+/// Benchmarks read and write performance of a
+/// at varying cache sizes — the data structure underpinning CaeriusNet's frozen cache layer.
+///
+///
+///
+/// is an immutable, read-optimised
+/// hash map: after construction it is sealed and cannot be modified, but its lookup path is heavily
+/// optimised by the JIT (inlined hash computation, no lock overhead, no resize path).
+/// This benchmark models the two dominant operations in CaeriusNet's frozen cache:
+///
+///
+///
+/// Read — sequential scan
+///
+/// Accesses every key in insertion order. Exercises the dictionary's hot path and the
+/// CPU's hardware prefetcher. Baseline; represents the steady-state read throughput
+/// once the cache is fully populated.
+///
+///
+///
+/// Read — random access
+///
+/// Accesses keys in a pseudo-random order (seeded). Stresses the hash-lookup path and
+/// produces more cache misses than sequential access. Ratio vs sequential shows the
+/// impact of access locality on FrozenDictionary lookup throughput.
+///
+///
+///
+/// Write — full dictionary rebuild
+///
+/// CaeriusNet's frozen cache must rebuild the entire FrozenDictionary on each
+/// write (immutable-by-design). This benchmark quantifies that O(N) rebuild cost and
+/// demonstrates why the frozen cache is optimised for write-once / read-many workloads.
+///
+///
+///
+///
+/// Data is generated with a fixed seed (42) so results are reproducible.
+/// Cache sizes from 100 to 10 000 keys cover realistic cache populations.
+///
+///
+[Config(typeof(BenchmarkConfig))]
+[MemoryDiagnoser]
+public class FrozenCacheBench
+{
+ private FrozenDictionary _cache = null!;
+ private string[] _keys = null!;
+ private string[] _randomKeys = null!;
+ private KeyValuePair[] _sourceEntries = null!;
+
+ /// Number of entries pre-populated in the frozen dictionary.
+ [Params(100, 1_000, 10_000)]
+ public int CacheSize { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var rng = new Random(42);
+ _keys = new string[CacheSize];
+ _sourceEntries = new KeyValuePair[CacheSize];
+
+ for (var i = 0; i < CacheSize; i++)
+ {
+ var key = $"entity:{i:D6}";
+ _keys[i] = key;
+ _sourceEntries[i] = new KeyValuePair(
+ key,
+ new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0));
+ }
+
+ _cache = _sourceEntries.ToFrozenDictionary();
+
+ // Pre-compute a shuffled key access order for the random-access benchmark
+ _randomKeys = _keys.ToArray();
+ for (var i = _randomKeys.Length - 1; i > 0; i--)
+ {
+ var j = rng.Next(i + 1);
+ (_randomKeys[i], _randomKeys[j]) = (_randomKeys[j], _randomKeys[i]);
+ }
+ }
+
+ ///
+ /// Sequential read: accesses all entries in insertion order.
+ /// Favours the CPU hardware prefetcher — best-case FrozenDictionary read throughput.
+ ///
+ [Benchmark(Baseline = true, Description = "FrozenDictionary: sequential TryGetValue all keys")]
+ public int Read_Sequential_AllKeys()
+ {
+ var hits = 0;
+ for (var i = 0; i < _keys.Length; i++)
+ if (_cache.TryGetValue(_keys[i], out _))
+ hits++;
+ return hits;
+ }
+
+ ///
+ /// Random read: accesses all entries in a shuffled order.
+ /// Stresses hash-lookup path and increases cache-miss rate vs sequential access.
+ ///
+ [Benchmark(Description = "FrozenDictionary: random-order TryGetValue all keys")]
+ public int Read_Random_AllKeys()
+ {
+ var hits = 0;
+ for (var i = 0; i < _randomKeys.Length; i++)
+ if (_cache.TryGetValue(_randomKeys[i], out _))
+ hits++;
+ return hits;
+ }
+
+ ///
+ /// Full dictionary rebuild: constructs a new
+ /// from all source entries. Models CaeriusNet's frozen cache write path (O(N) per write).
+ /// At large CacheSize this is visibly expensive — by design, writes should be infrequent.
+ ///
+ [Benchmark(Description = "FrozenDictionary: full rebuild from source entries")]
+ public FrozenDictionary Write_FullRebuild()
+ {
+ return _sourceEntries.ToFrozenDictionary();
+ }
+}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs b/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs
new file mode 100644
index 0000000..39b1920
--- /dev/null
+++ b/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs
@@ -0,0 +1,155 @@
+using CaeriusNet.Benchmark.Data.Generated;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace CaeriusNet.Benchmark.Workshops.Benchs.Cache;
+
+///
+/// Benchmarks read, write, and miss-handling performance of at
+/// varying cache sizes — the data structure underpinning CaeriusNet's in-memory cache layer.
+///
+///
+///
+/// is a concurrent, key-expiring cache backed by a
+/// .
+/// Unlike , it supports
+/// TTL-based expiration, making it suitable for mutable, time-bound cache populations.
+///
+///
+///
+/// Cache hit — TryGetValue
+///
+/// All entries are pre-populated in GlobalSetup; every TryGetValue call succeeds.
+/// Measures the hot-path cost of the ConcurrentDictionary lookup + entry validation.
+///
+///
+///
+/// Cache miss — TryGetValue
+///
+/// Looks up keys that were never stored. Measures the cost of the miss path
+/// (hash lookup + no-entry branch), which determines GetOrCreate performance when the
+/// cache is cold.
+///
+///
+///
+/// Cache write — Set with TTL
+///
+/// Stores a single entry with a 5-minute absolute expiration, mirroring CaeriusNet's
+/// default TTL. Measures allocation cost of MemoryCacheEntry + ConcurrentDictionary insert.
+///
+///
+///
+/// GetOrCreate round-trip
+///
+/// The idiomatic MemoryCache usage pattern: atomically check + populate if missing.
+/// On a warm cache every call short-circuits to the hit path. Ratio vs the pure hit
+/// benchmark shows the overhead of the GetOrCreate wrapper vs a raw TryGetValue.
+///
+///
+///
+///
+/// Data is generated with a fixed seed (42) so results are reproducible across runs.
+/// Cache sizes from 100 to 10 000 entries cover realistic hot-cache populations.
+///
+///
+[Config(typeof(BenchmarkConfig))]
+[MemoryDiagnoser]
+public class InMemoryCacheBench
+{
+ private IMemoryCache _cache = null!;
+ private BenchmarkItemDto[] _entries = null!;
+ private string[] _hitKeys = null!;
+ private string[] _missKeys = null!;
+
+ /// Number of entries pre-populated in the cache.
+ [Params(100, 1_000, 10_000)]
+ public int CacheSize { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var rng = new Random(42);
+ _cache = new MemoryCache(new MemoryCacheOptions());
+ _entries = new BenchmarkItemDto[CacheSize];
+ _hitKeys = new string[CacheSize];
+ _missKeys = new string[CacheSize];
+
+ for (var i = 0; i < CacheSize; i++)
+ {
+ _hitKeys[i] = $"entity:hit:{i:D6}";
+ _missKeys[i] = $"entity:miss:{i:D6}";
+ _entries[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
+
+ _cache.Set(_hitKeys[i], _entries[i], TimeSpan.FromMinutes(5));
+ }
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _cache.Dispose();
+ }
+
+ ///
+ /// Cache hit: TryGetValue on all pre-populated keys.
+ /// Measures the steady-state read throughput of IMemoryCache when fully warm.
+ ///
+ [Benchmark(Baseline = true, Description = "IMemoryCache: TryGetValue — warm cache hit all keys")]
+ public int Read_CacheHit_AllKeys()
+ {
+ var hits = 0;
+ for (var i = 0; i < _hitKeys.Length; i++)
+ if (_cache.TryGetValue(_hitKeys[i], out BenchmarkItemDto? _))
+ hits++;
+ return hits;
+ }
+
+ ///
+ /// Cache miss: TryGetValue on keys never stored.
+ /// Measures the miss-path cost (hash lookup + no-entry branch) — critical for cold-start scenarios.
+ ///
+ [Benchmark(Description = "IMemoryCache: TryGetValue — cold cache miss all keys")]
+ public int Read_CacheMiss_AllKeys()
+ {
+ var misses = 0;
+ for (var i = 0; i < _missKeys.Length; i++)
+ if (!_cache.TryGetValue(_missKeys[i], out BenchmarkItemDto? _))
+ misses++;
+ return misses;
+ }
+
+ ///
+ /// Cache write: stores a single entry with a 5-minute TTL.
+ /// Measures the allocation cost of MemoryCacheEntry + ConcurrentDictionary insert on the write path.
+ ///
+ [Benchmark(Description = "IMemoryCache: Set a single entry with 5-min TTL")]
+ public void Write_SingleEntry_WithTtl()
+ {
+ _cache.Set("bench:write:probe", _entries[0], TimeSpan.FromMinutes(5));
+ }
+
+ ///
+ /// GetOrCreate round-trip on a warm cache: atomically check + return existing entry.
+ /// On a warm cache the factory is never invoked. Ratio vs baseline shows the wrapper overhead.
+ ///
+ [Benchmark(Description = "IMemoryCache: GetOrCreate — warm cache (factory never called)")]
+ public int ReadWrite_GetOrCreate_WarmCache()
+ {
+ var hits = 0;
+ for (var i = 0; i < _hitKeys.Length; i++)
+ {
+ _cache.GetOrCreate(_hitKeys[i], entry =>
+ {
+ entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
+ return _entries[i];
+ });
+ hits++;
+ }
+
+ return hits;
+ }
+}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs
index 5e27798..fc9befe 100644
--- a/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs
+++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs
@@ -1,58 +1,72 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections;
+///
+/// Measures the cost of exposing a collection as from a source
+/// array across five row-count scales (1 → 100 000).
+///
+///
+/// Two paths are contrasted:
+///
+///
+/// Materialise then wrap (baseline)
+///
+/// Fills a pre-allocated and wraps it with
+/// — a zero-allocation identity cast.
+/// Dominant cost is the List construction itself.
+///
+///
+///
+/// Zero-copy array wrap
+///
+/// Calls AsEnumerable() directly on the source array — no intermediate List is
+/// created. Near-zero allocation at all scales; demonstrates why skipping materialisation
+/// matters for read-only enumeration paths.
+///
+///
+///
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class CreateEnumerableToBench
{
- private static readonly ReadOnlyCollection Data = CreateCollectionBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly Consumer _consumer = new();
+ /// Number of items in the source array per benchmark call.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
-
- [Benchmark]
- public void Set_Capacity_And_Return_1_Item_Collection_As_IEnumerable()
- {
- var list = new List(1);
- list.AddRange(_data1);
- _consumer.Consume(list.AsEnumerable());
- }
-
- [Benchmark]
- public void Set_Capacity_And_Return_10_Items_Collection_As_IEnumerable()
- {
- var list = new List(10);
- list.AddRange(_data10);
- _consumer.Consume(list.AsEnumerable());
- }
-
- [Benchmark]
- public void Set_Capacity_And_Return_100_Items_Collection_As_IEnumerable()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(100);
- list.AddRange(_data100);
- _consumer.Consume(list.AsEnumerable());
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public void Set_Capacity_And_Return_1K_Items_Collection_As_IEnumerable()
+ ///
+ /// Materialise into a List then wrap: new List(N) + AddRange + AsEnumerable().
+ /// Allocates a full backing list before the costless identity cast.
+ ///
+ [Benchmark(Baseline = true, Description = "new List(N) + AddRange + AsEnumerable()")]
+ public IEnumerable Create_WithCapacity_AsEnumerable()
{
- var list = new List(1000);
- list.AddRange(_data1K);
- _consumer.Consume(list.AsEnumerable());
+ var list = new List(RowCount);
+ list.AddRange(_source);
+ return list.AsEnumerable();
}
- [Benchmark]
- public void Set_Capacity_And_Return_10K_Items_Collection_As_IEnumerable()
+ /// Zero-copy: wrap the source array directly as IEnumerable — no List allocated.
+ [Benchmark(Description = "array.AsEnumerable() — zero-copy lazy wrap")]
+ public IEnumerable Create_Array_AsEnumerable()
{
- var list = new List(10000);
- list.AddRange(_data10K);
- _consumer.Consume(list.AsEnumerable());
+ return _source.AsEnumerable();
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs
index cbc16be..ca36ab2 100644
--- a/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs
+++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs
@@ -1,56 +1,74 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections;
+///
+/// Measures the cost of constructing an from a pre-existing
+/// source array across five row-count scales (1 → 100 000).
+///
+///
+/// Two construction paths are compared:
+///
+///
+/// CreateBuilder (baseline)
+///
+/// Pre-allocates a typed builder, fills it via AddRange, then seals it with
+/// MoveToImmutable(). When the capacity matches exactly, MoveToImmutable performs
+/// a zero-copy move of the internal array — no second allocation occurs.
+///
+///
+///
+/// ImmutableArray.Create(Span)
+///
+/// Constructs the immutable array directly from a :
+/// a single internal memcopy with no builder overhead. Fastest path for array-sourced data.
+///
+///
+///
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class CreateImmutableArrayToBench
{
- private static readonly ReadOnlyCollection Data = CreateCollectionBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
+ /// Number of items to materialise per benchmark call.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- [Benchmark]
- public ImmutableArray Set_Capacity_And_Return_1_Item_Collection_As_ImmutableArray()
+ [GlobalSetup]
+ public void Setup()
{
- var list = ImmutableArray.CreateBuilder(1);
- foreach (var item in _data1) list.Add(item);
- return list.ToImmutable();
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public ImmutableArray Set_Capacity_And_Return_10_Items_Collection_As_ImmutableArray()
+ ///
+ /// Builder path: pre-allocated capacity → AddRange → MoveToImmutable.
+ /// Zero-copy move when capacity is exact — one internal array allocation in total.
+ ///
+ [Benchmark(Baseline = true, Description = "CreateBuilder(N) + AddRange + MoveToImmutable()")]
+ public ImmutableArray Create_Builder_MoveToImmutable()
{
- var list = ImmutableArray.CreateBuilder(10);
- foreach (var item in _data10) list.Add(item);
- return list.ToImmutable();
+ var builder = ImmutableArray.CreateBuilder(RowCount);
+ builder.AddRange(_source);
+ return builder.MoveToImmutable();
}
- [Benchmark]
- public ImmutableArray Set_Capacity_And_Return_100_Items_Collection_As_ImmutableArray()
+ ///
+ /// Direct span construction: single internal memcopy from source span — no builder overhead.
+ /// Fastest path for array-sourced data; preferred when source is already an array or Span.
+ ///
+ [Benchmark(Description = "ImmutableArray.Create(ReadOnlySpan) — single memcopy")]
+ public ImmutableArray Create_FromSpan()
{
- var list = ImmutableArray.CreateBuilder(100);
- foreach (var item in _data100) list.Add(item);
- return list.ToImmutable();
- }
-
- [Benchmark]
- public ImmutableArray Set_Capacity_And_Return_1K_Items_Collection_As_ImmutableArray()
- {
- var list = ImmutableArray.CreateBuilder(1000);
- foreach (var item in _data1K) list.Add(item);
- return list.ToImmutable();
- }
-
- [Benchmark]
- public ImmutableArray Set_Capacity_And_Return_10K_Items_Collection_As_ImmutableArray()
- {
- var list = ImmutableArray.CreateBuilder(10000);
- foreach (var item in _data10K) list.Add(item);
- return list.ToImmutable();
+ return ImmutableArray.Create(_source.AsSpan());
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs
index bb19419..5a800c4 100644
--- a/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs
+++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs
@@ -1,57 +1,68 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections;
+///
+/// Measures the cost of materialising a from a pre-existing source array
+/// across five row-count scales (1 → 100 000).
+///
+///
+/// Two construction paths are compared:
+///
+///
+/// Explicit capacity + AddRange (baseline)
+///
+/// Sets capacity to so the internal array is allocated once and
+/// AddRange performs a single memcopy — no resize occurs.
+///
+///
+///
+/// LINQ ToList
+///
+/// Internally creates a without a hint and fills it via the
+/// path, which may trigger one extra reallocation step.
+///
+///
+///
+/// The Allocated column reveals the memory difference between both strategies at each scale.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class CreateListToBench
{
- private static readonly ReadOnlyCollection Data = CreateCollectionBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
+ /// Number of items to materialise per benchmark call.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- [Benchmark]
- public List Set_Capacity_And_Return_1_Item_Collection_As_List()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(1);
- list.AddRange(_data1);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_And_Return_10_Items_Collection_As_List()
- {
- var list = new List(10);
- list.AddRange(_data10);
- return list;
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public List Set_Capacity_And_Return_100_Items_Collection_As_List()
+ /// Exact capacity hint → single array allocation, single memcopy — zero resize overhead.
+ [Benchmark(Baseline = true, Description = "new List(RowCount) + AddRange — exact capacity")]
+ public List Create_WithCapacity()
{
- var list = new List(100);
- list.AddRange(_data100);
-
+ var list = new List(RowCount);
+ list.AddRange(_source);
return list;
}
- [Benchmark]
- public List Set_Capacity_And_Return_1K_Items_Collection_As_List()
+ /// LINQ ToList — convenient but may trigger an extra reallocation vs the explicit path.
+ [Benchmark(Description = "source.ToList() — no capacity hint")]
+ public List Create_LinqToList()
{
- var list = new List(1000);
- list.AddRange(_data1K);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_And_Return_10K_Items_Collection_As_List()
- {
- var list = new List(10000);
- list.AddRange(_data10K);
- return list;
+ return _source.ToList();
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs
index adc041c..e5fde24 100644
--- a/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs
+++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs
@@ -1,56 +1,57 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections;
+///
+/// Measures the cost of building a from a pre-existing
+/// source array across five row-count scales (1 → 100 000).
+///
+///
+/// wraps an by reference — no deep
+/// copy of the elements occurs during the wrap itself. The dominant cost is therefore the
+/// new List → AddRange → AsReadOnly() pipeline, not the wrapper object overhead.
+/// The LINQ variant adds an extra iteration step before the wrap.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class CreateReadOnlyCollectionToBench
{
- private static readonly ReadOnlyCollection Data = CreateCollectionBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
+ /// Number of items to materialise per benchmark call.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- [Benchmark]
- public ReadOnlyCollection Set_Capacity_And_Return_1_Item_Collection_As_ReadOnlyCollection()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(1);
- list.AddRange(_data1);
- return list.AsReadOnly();
- }
-
- [Benchmark]
- public ReadOnlyCollection Set_Capacity_And_Return_10_Items_Collection_As_ReadOnlyCollection()
- {
- var list = new List(10);
- list.AddRange(_data10);
- return list.AsReadOnly();
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public ReadOnlyCollection Set_Capacity_And_Return_100_Items_Collection_As_ReadOnlyCollection()
+ ///
+ /// Canonical pipeline: pre-allocated List → AddRange → AsReadOnly.
+ /// One allocation for the backing array, one for the ReadOnlyCollection header.
+ ///
+ [Benchmark(Baseline = true, Description = "new List(N) + AddRange + AsReadOnly() — exact capacity")]
+ public ReadOnlyCollection Create_WithCapacity_AsReadOnly()
{
- var list = new List(100);
- list.AddRange(_data100);
+ var list = new List(RowCount);
+ list.AddRange(_source);
return list.AsReadOnly();
}
- [Benchmark]
- public ReadOnlyCollection Set_Capacity_And_Return_1K_Items_Collection_As_ReadOnlyCollection()
+ /// LINQ ToList + wrap — convenience pattern that omits the capacity hint.
+ [Benchmark(Description = "source.ToList() + AsReadOnly()")]
+ public ReadOnlyCollection Create_LinqToList_AsReadOnly()
{
- var list = new List(1000);
- list.AddRange(_data1K);
- return list.AsReadOnly();
- }
-
- [Benchmark]
- public ReadOnlyCollection Set_Capacity_And_Return_10000_Items_Collection_As_ReadOnlyCollection()
- {
- var list = new List(10000);
- list.AddRange(_data10K);
- return list.AsReadOnly();
+ return _source.ToList().AsReadOnly();
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/CreateCollections/Setup/CreateCollectionBogusSetup.cs b/Benchmark/Workshops/Benchs/CreateCollections/Setup/CreateCollectionBogusSetup.cs
deleted file mode 100644
index e9268a9..0000000
--- a/Benchmark/Workshops/Benchs/CreateCollections/Setup/CreateCollectionBogusSetup.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CaeriusNet.Benchmark.Data.Simple;
-
-namespace CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections.Setup;
-
-public static class CreateCollectionBogusSetup
-{
- private static readonly Random Seeding = Randomizer.Seed = new Random(31031996);
-
- public static readonly ReadOnlyCollection Faking10KItemsDto = new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000)
- .AsReadOnly();
-}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs
index 5cec357..5ea57d4 100644
--- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs
+++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs
@@ -1,63 +1,51 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity;
+///
+/// Measures construction cost when the capacity hint equals exactly the
+/// number of items to be added — the theoretical minimum allocation scenario.
+///
+///
+/// Setting new List<T>(RowCount) allocates one internal array of exactly the
+/// required size. AddRange then performs a single Array.Copy with zero resize.
+/// This benchmark establishes the floor allocation cost for list construction and acts as the
+/// canonical baseline when comparing under- and over-allocation strategies.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ListWithCapacityToBench
{
- private static readonly ReadOnlyCollection Data = ListCapacityBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly Consumer _consumer = new();
+ /// Number of items to add; capacity is set to this exact value.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
-
- [Benchmark]
- public List Set_Capacity_With_1_Item_To_Add()
- {
- var list = new List(1);
- list.AddRange(_data1);
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_10_Items_To_Add()
- {
- var list = new List(10);
- list.AddRange(_data10);
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_100_Items_To_Add()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(100);
- list.AddRange(_data100);
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_1K_Items_To_Add()
- {
- var list = new List(1000);
- list.AddRange(_data1K);
- _consumer.Consume(list);
- return list;
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public List Set_Capacity_With_10K_Items_To_Add()
+ ///
+ /// Exact capacity: one array allocation, one Array.Copy — zero resize overhead.
+ /// Optimal pattern when row count is known ahead of the loop (e.g. from SqlDataReader.FieldCount
+ /// or a COUNT query).
+ ///
+ [Benchmark(Baseline = true, Description = "new List(exact capacity) + AddRange")]
+ public List Create_ExactCapacity()
{
- var list = new List(10_000);
- list.AddRange(_data10K);
- _consumer.Consume(list);
+ var list = new List(RowCount);
+ list.AddRange(_source);
return list;
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs
index 4c7a1c0..bfc0935 100644
--- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs
+++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs
@@ -1,68 +1,53 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity;
+///
+/// Measures construction cost when the capacity hint equals
+/// but exactly RowCount × 2 items are added — forcing a single
+/// resize beyond the initial hint.
+///
+///
+/// This models the scenario where the caller under-estimates the final item count by a factor
+/// of two and the list must double its internal array exactly once. The additional memcopy of
+/// the initial allocation is visible in the Allocated column vs the exact-capacity baseline
+/// (). The Ratio column isolates the cost of carrying one
+/// forced resize at each scale from 1 to 100 000.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ListWithCapacityWithOverextendToBench
{
- private static readonly ReadOnlyCollection Data = ListCapacityBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly Consumer _consumer = new();
+ /// Capacity hint; actual items added are RowCount × 2.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
-
- [Benchmark]
- public List Set_Capacity_With_1_Item_But_Add_1_Item_More()
- {
- var list = new List(1);
- list.AddRange(_data1);
- list.AddRange(_data10.Take(1));
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_10_Items_But_Add_10_Items_More()
- {
- var list = new List(10);
- list.AddRange(_data10);
- list.AddRange(_data10);
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_100_Items_But_Add_100_Items_More()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(100);
- list.AddRange(_data100);
- list.AddRange(_data100);
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_1K_Items_But_Add_1K_Items_More()
- {
- var list = new List(1000);
- list.AddRange(_data1K);
- list.AddRange(_data1K);
- _consumer.Consume(list);
- return list;
+ var rng = new Random(42);
+ var doubleCount = RowCount * 2;
+ _source = new BenchmarkItemDto[doubleCount];
+ for (var i = 0; i < doubleCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public List Set_Capacity_With_10000_Items_But_Add_10000_Items_More()
+ ///
+ /// Capacity=RowCount, adds RowCount×2 items — triggers exactly one internal-array doubling.
+ /// The extra memcopy shows up clearly at RowCount ≥ 10 000.
+ ///
+ [Benchmark(Baseline = true, Description = "new List(RowCount) + AddRange(2×RowCount) — one forced resize")]
+ public List Create_OverextendCapacity()
{
- var list = new List(10000);
- list.AddRange(_data10K);
- list.AddRange(_data10K);
- _consumer.Consume(list);
+ var list = new List(RowCount);
+ list.AddRange(_source);
return list;
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs
index 513e1c3..1b7d287 100644
--- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs
+++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs
@@ -1,62 +1,52 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity;
+///
+/// Measures construction cost when the capacity hint is exactly half the
+/// number of items that will be added — systematic under-allocation by a factor of two.
+///
+///
+/// Setting capacity = RowCount / 2 while adding RowCount elements forces the list
+/// to resize once: the initial half-sized array is copied into a new array of at least
+/// RowCount slots. The Ratio column vs isolates
+/// the cost of that single unnecessary copy at each scale. At 100 000 rows the difference
+/// becomes non-trivial and visible in both the Mean and Allocated columns.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ListWithLessCapacityThanNeededToBench
{
- private static readonly ReadOnlyCollection Data = ListCapacityBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly Consumer _consumer = new();
+ /// Actual item count; capacity hint is RowCount / 2.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
-
- [Benchmark]
- public List Set_Capacity_With_2_Items_But_Add_1_Item()
- {
- var list = new List(2);
- list.AddRange(_data10.Take(1));
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_10_Items_But_Add_5_Item()
- {
- var list = new List(10);
- list.AddRange(_data10.Take(5));
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_100_Items_But_Add_50_Item()
+ [GlobalSetup]
+ public void Setup()
{
- var list = new List(100);
- list.AddRange(_data100.Take(50));
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_Capacity_With_1K_Items_But_Add_500_Item()
- {
- var list = new List(1000);
- list.AddRange(_data1K.Take(500));
- _consumer.Consume(list);
- return list;
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public List Set_Capacity_With_10K_Items_But_Add_5K_Item()
+ ///
+ /// Under-capacity: hint = max(1, RowCount / 2), add RowCount items.
+ /// Forces one resize; the initial half-sized backing array is abandoned to GC.
+ ///
+ [Benchmark(Baseline = true, Description = "new List(RowCount/2) + AddRange(RowCount) — one forced resize")]
+ public List Create_UnderCapacity()
{
- var list = new List(10000);
- list.AddRange(_data10K.Take(5000));
- _consumer.Consume(list);
+ var hint = Math.Max(1, RowCount / 2);
+ var list = new List(hint);
+ list.AddRange(_source);
return list;
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs
index 381e249..25a85e4 100644
--- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs
+++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs
@@ -1,58 +1,51 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity;
+///
+/// Measures construction cost with no capacity hint (default constructor).
+///
+///
+/// Without a hint, starts with capacity 0 and doubles on each overflow:
+/// 0 → 4 → 8 → 16 → … → N. For large N this triggers O(log₂ N) reallocations, each of which
+/// copies the existing elements into a new, larger array. The overhead is visible both in the
+/// Mean column (extra copy work) and the Gen0/Allocated columns (wasted intermediate arrays).
+/// Compare against to quantify the exact cost of omitting
+/// the capacity hint at each scale.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ListWithoutCapacityToBench
{
- private static readonly ReadOnlyCollection Data = ListCapacityBogusSetup.Faking10KItemsDto;
+ private BenchmarkItemDto[] _source = null!;
- private readonly Consumer _consumer = new();
+ /// Number of items to add; no capacity hint is provided to the List constructor.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _data1 = Data.Take(1).ToList();
- private readonly List _data10 = Data.Take(10).ToList();
- private readonly List _data100 = Data.Take(100).ToList();
- private readonly List _data10K = Data.ToList();
- private readonly List _data1K = Data.Take(1000).ToList();
-
- [Benchmark]
- public List Set_No_Capacity_With_1_Item_To_Add()
- {
- var list = _data1.ToList();
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_No_Capacity_With_10_Items_To_Add()
- {
- var list = _data10.ToList();
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_No_Capacity_With_100_Items_To_Add()
+ [GlobalSetup]
+ public void Setup()
{
- var list = _data100.ToList();
- _consumer.Consume(list);
- return list;
- }
-
- [Benchmark]
- public List Set_No_Capacity_With_1K_Items_To_Add()
- {
- var list = _data1K.ToList();
- _consumer.Consume(list);
- return list;
+ var rng = new Random(42);
+ _source = new BenchmarkItemDto[RowCount];
+ for (var i = 0; i < RowCount; i++)
+ _source[i] = new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0);
}
- [Benchmark]
- public List Set_No_Capacity_With_10K_Items_To_Add()
+ ///
+ /// No capacity hint — internal array starts at 0 and doubles on every overflow.
+ /// At RowCount=100 000 this triggers ~17 doubling steps, wasting ~50 % of the final array.
+ ///
+ [Benchmark(Baseline = true, Description = "new List() + AddRange — no hint, O(log N) doublings")]
+ public List Create_NoCapacity()
{
- var list = _data10K.ToList();
- _consumer.Consume(list);
+ var list = new List();
+ list.AddRange(_source);
return list;
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ListCapacity/Setup/ListCapacityBogusSetup.cs b/Benchmark/Workshops/Benchs/ListCapacity/Setup/ListCapacityBogusSetup.cs
deleted file mode 100644
index a2f3527..0000000
--- a/Benchmark/Workshops/Benchs/ListCapacity/Setup/ListCapacityBogusSetup.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CaeriusNet.Benchmark.Data.Simple;
-
-namespace CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity.Setup;
-
-public static class ListCapacityBogusSetup
-{
- private static readonly Random Seeding = Randomizer.Seed = new Random(31031996);
-
- public static readonly ReadOnlyCollection Faking10KItemsDto = new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000)
- .AsReadOnly();
-}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs
index bef1a2c..02fab65 100644
--- a/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs
+++ b/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs
@@ -26,7 +26,8 @@ public class DtoMappingBench
private decimal[] _prices = null!;
private Guid[] _traceIds = null!;
- [Params(1, 100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public void Setup()
diff --git a/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs
index b74ce8f..3eeb5bf 100644
--- a/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs
+++ b/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs
@@ -35,7 +35,7 @@ public class NullableColumnMappingBench
private decimal[] _prices = null!;
private Guid[] _traceIds = null!;
- [Params(100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(100, 1_000, 10_000, 100_000)] public int RowCount { get; set; }
///
/// Null density in nullable columns: 0 = no nulls, 50 = ~50% nulls, 100 = all nulls.
diff --git a/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs
index eab7a56..12f56ab 100644
--- a/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs
+++ b/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs
@@ -39,7 +39,8 @@ public class WideRowDtoMappingBench
private int[] _quantities = null!;
private Guid[] _traceIds = null!;
- [Params(1, 100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public void Setup()
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadAllCollectionTypesToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadAllCollectionTypesToBench.cs
deleted file mode 100644
index 86a9c80..0000000
--- a/Benchmark/Workshops/Benchs/ReadCollections/ReadAllCollectionTypesToBench.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
-
-namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections;
-
-[MemoryDiagnoser]
-public class ReadAllCollectionTypesToBench
-{
- private static readonly ReadOnlyCollection ReadOnlyCollection =
- ReadCollectionBogusSetup.FakingReadOnlyCollectionOf10KItemsDto;
-
- private static readonly List List = ReadCollectionBogusSetup.FakingListOf10KItemsDto;
-
- private static readonly ImmutableArray ImmutableArray =
- ReadCollectionBogusSetup.FakingImmutableArrayOf10KItemsDto;
-
- private static readonly IEnumerable
- Enumerable = ReadCollectionBogusSetup.FakingIEnumerableOf10KItemsDto;
-
- private IEnumerable _enumerableOf5KItems = null!;
- private ImmutableArray _immutableArrayOf5KItems;
- private List _listOf5KItems = null!;
-
- private ReadOnlyCollection _readOnlyCollectionOf5KItems = null!;
-
- [GlobalSetup]
- public void Setup()
- {
- _readOnlyCollectionOf5KItems = ReadOnlyCollection.Take(5_000).ToList().AsReadOnly();
- _listOf5KItems = List.Take(5_000).ToList();
- _immutableArrayOf5KItems = [..ImmutableArray.Take(5_000)];
- _enumerableOf5KItems = Enumerable.Take(5_000);
- }
-
- [Benchmark]
- public void Read_ReadOnlyCollection()
- {
- var sum = _readOnlyCollectionOf5KItems.Sum(item => item.Id);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_List()
- {
- var sum = _listOf5KItems.Sum(item => item.Id);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ImmutableArray()
- {
- var sum = _immutableArrayOf5KItems.Sum(item => item.Id);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_Enumerable()
- {
- var sum = _enumerableOf5KItems.Sum(item => item.Id);
- _ = sum;
- }
-}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs
index 257ce49..5a6595c 100644
--- a/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs
+++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs
@@ -1,68 +1,57 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections;
+///
+/// Measures sequential-read throughput of across five
+/// row-count scales (1 → 100 000).
+///
+///
+/// The underlying storage is a exposed as .
+/// This models the most common public API shape: a query method returning a lazy sequence.
+/// Typed as an interface, the JIT cannot devirtualise the enumerator, adding measurable overhead
+/// compared with direct access — visible in the Ratio column when cross-
+/// referenced against .
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ReadEnumerableToBench
{
- private static readonly IEnumerable
- Enumerable = ReadCollectionBogusSetup.FakingIEnumerableOf10KItemsDto;
+ private IEnumerable _data = null!;
- private readonly Consumer _consumer = new();
+ /// Number of elements in the sequence under test.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly IEnumerable _enumerableOf100Items = Enumerable.Take(100);
- private readonly IEnumerable _enumerableOf100KItems = Enumerable.Take(100000);
- private readonly IEnumerable _enumerableOf10Items = Enumerable.Take(10);
- private readonly IEnumerable _enumerableOf10KItems = Enumerable.Take(1000);
- private readonly IEnumerable _enumerableOf1Item = Enumerable.Take(1);
- private readonly IEnumerable _enumerableOf1KItems = Enumerable.Take(1000);
-
- [Benchmark]
- public void Read_Enumerable_Of_1_Item()
- {
- var sum = _enumerableOf1Item.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_Enumerable_Of_10_Items()
- {
- var sum = _enumerableOf10Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_Enumerable_Of_100_Items()
- {
- var sum = _enumerableOf100Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_Enumerable_Of_1K_Items()
+ [GlobalSetup]
+ public void Setup()
{
- var sum = _enumerableOf1KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var rng = new Random(42);
+ var list = new List(RowCount);
+ for (var i = 0; i < RowCount; i++)
+ list.Add(new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0));
+ _data = list; // List stored as IEnumerable — prevents devirtualisation
}
- [Benchmark]
- public void Read_Enumerable_Of_10K_Items()
+ /// foreach via IEnumerable — virtual dispatch blocks JIT devirtualisation of enumerator.
+ [Benchmark(Baseline = true, Description = "foreach via IEnumerable — accumulate Sum(Id)")]
+ public int Read_ForEach()
{
- var sum = _enumerableOf10KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var sum = 0;
+ foreach (var item in _data)
+ sum += item.Id;
+ return sum;
}
- [Benchmark]
- public void Read_Enumerable_Of_100K_Items()
+ /// LINQ Sum — adds a delegate on top of the interface-dispatch chain.
+ [Benchmark(Description = "LINQ .Sum(item => item.Id)")]
+ public int Read_LinqSum()
{
- var sum = _enumerableOf100KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ return _data.Sum(item => item.Id);
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs
index f02d332..36c7f3e 100644
--- a/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs
+++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs
@@ -1,68 +1,57 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections;
+///
+/// Measures sequential-read throughput of across five
+/// row-count scales (1 → 100 000).
+///
+///
+/// stores elements in a contiguous array, enabling the JIT to
+/// generate optimal vector-load instructions. Its enumerator is a value-type (struct),
+/// so BDN's foreach path avoids boxing and virtual dispatch entirely.
+/// Comparing this class against reveals whether the immutability
+/// guarantee and contiguous layout actually yield measurable throughput improvements.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ReadImmutableArrayToBench
{
- private static readonly ImmutableArray ImmutableArray =
- ReadCollectionBogusSetup.FakingImmutableArrayOf10KItemsDto;
+ private ImmutableArray _data;
- private readonly Consumer _consumer = new();
+ /// Number of elements in the immutable array under test.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly ImmutableArray _immutableArrayOf100Items = [..ImmutableArray.Take(100)];
- private readonly ImmutableArray _immutableArrayOf100KItems = ImmutableArray;
- private readonly ImmutableArray _immutableArrayOf10Items = [..ImmutableArray.Take(10)];
- private readonly ImmutableArray _immutableArrayOf10KItems = [..ImmutableArray.Take(1000)];
- private readonly ImmutableArray _immutableArrayOf1Item = [..ImmutableArray.Take(1)];
- private readonly ImmutableArray _immutableArrayOf1KItems = [..ImmutableArray.Take(1000)];
-
- [Benchmark]
- public void Read_ImmutableArray_Of_1_Item()
- {
- var sum = _immutableArrayOf1Item.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ImmutableArray_Of_10_Items()
- {
- var sum = _immutableArrayOf10Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ImmutableArray_Of_100_Items()
- {
- var sum = _immutableArrayOf100Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ImmutableArray_Of_1K_Items()
+ [GlobalSetup]
+ public void Setup()
{
- var sum = _immutableArrayOf1KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var rng = new Random(42);
+ var builder = ImmutableArray.CreateBuilder(RowCount);
+ for (var i = 0; i < RowCount; i++)
+ builder.Add(new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0));
+ _data = builder.MoveToImmutable();
}
- [Benchmark]
- public void Read_ImmutableArray_Of_10K_Items()
+ /// Struct enumerator — zero boxing, no virtual dispatch, JIT-vectorisable inner loop.
+ [Benchmark(Baseline = true, Description = "foreach — struct enumerator, accumulate Sum(Id)")]
+ public int Read_ForEach()
{
- var sum = _immutableArrayOf10KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var sum = 0;
+ foreach (var item in _data)
+ sum += item.Id;
+ return sum;
}
- [Benchmark]
- public void Read_ImmutableArray_Of_100K_Items()
+ /// LINQ Sum — forces IEnumerable interface; loses the struct-enumerator advantage.
+ [Benchmark(Description = "LINQ .Sum(item => item.Id)")]
+ public int Read_LinqSum()
{
- var sum = _immutableArrayOf100KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ return _data.Sum(item => item.Id);
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs
index 5b4a7fb..5a5ab30 100644
--- a/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs
+++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs
@@ -1,58 +1,70 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections;
+///
+/// Measures sequential-read throughput of across five row-count scales
+/// (1 → 100 000), using a realistic 5-field DTO to approximate production result-set payloads.
+///
+///
+/// Two traversal strategies are contrasted:
+///
+///
+/// foreach (baseline)
+///
+/// Direct iteration; no delegate allocation; mirrors the inner loop emitted by the
+/// CaeriusNet source generator for MapFromDataReader().
+///
+///
+///
+/// LINQ Sum
+///
+/// Delegate-based path that goes through virtual dispatch;
+/// the Ratio column quantifies the real overhead vs raw foreach.
+///
+///
+///
+/// Data is generated once per value with a fixed seed (42) to guarantee
+/// reproducibility across runs and CI environments.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ReadListToBench
{
- private static readonly List List = ReadCollectionBogusSetup.FakingListOf10KItemsDto;
+ private List _data = null!;
- private readonly Consumer _consumer = new();
- private readonly List _listOf100Items = List.Take(100).ToList();
- private readonly List _listOf10Items = List.Take(10).ToList();
- private readonly List _listOf10KItems = List;
+ /// Number of elements in the collection under test.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly List _listOf1Item = List.Take(1).ToList();
- private readonly List _listOf1KItems = List.Take(1000).ToList();
-
- [Benchmark]
- public void Read_List_Of_1_Item()
- {
- var sum = _listOf1Item.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_List_Of_10_Items()
- {
- var sum = _listOf10Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_List_Of_100_Items()
+ [GlobalSetup]
+ public void Setup()
{
- var sum = _listOf100Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var rng = new Random(42);
+ _data = new List(RowCount);
+ for (var i = 0; i < RowCount; i++)
+ _data.Add(new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0));
}
- [Benchmark]
- public void Read_List_Of_1K_Items()
+ /// Direct foreach — zero delegate overhead; mirrors generated reader-loop pattern.
+ [Benchmark(Baseline = true, Description = "foreach — accumulate Sum(Id)")]
+ public int Read_ForEach()
{
- var sum = _listOf1KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var sum = 0;
+ foreach (var item in _data)
+ sum += item.Id;
+ return sum;
}
- [Benchmark]
- public void Read_List_Of_10K_Items()
+ /// LINQ Sum — adds a delegate + IEnumerable virtual dispatch per element.
+ [Benchmark(Description = "LINQ .Sum(item => item.Id)")]
+ public int Read_LinqSum()
{
- var sum = _listOf10KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ return _data.Sum(item => item.Id);
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs
index d34f606..4e56f11 100644
--- a/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs
+++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs
@@ -1,67 +1,56 @@
-using CaeriusNet.Benchmark.Data.Simple;
-using CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
+using CaeriusNet.Benchmark.Data.Generated;
namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections;
+///
+/// Measures sequential-read throughput of across five
+/// row-count scales (1 → 100 000).
+///
+///
+/// is a thin reference wrapper over an .
+/// Iteration goes through the IList virtual interface, which prevents JIT devirtualisation.
+/// Comparing its Ratio against quantifies the overhead introduced
+/// by the read-only wrapper in tight iteration loops.
+///
+[Config(typeof(BenchmarkConfig))]
[MemoryDiagnoser]
public class ReadReadOnlyCollectionToBench
{
- private static readonly ReadOnlyCollection ReadOnlyCollection =
- ReadCollectionBogusSetup.FakingReadOnlyCollectionOf10KItemsDto;
+ private ReadOnlyCollection _data = null!;
- private readonly Consumer _consumer = new();
+ /// Number of elements in the collection under test.
+ [Params(1, 100, 1_000, 10_000, 100_000)]
+ public int RowCount { get; set; }
- private readonly ReadOnlyCollection _readOnlyCollectionOf100Items =
- ReadOnlyCollection.Take(100).ToList().AsReadOnly();
-
- private readonly ReadOnlyCollection _readOnlyCollectionOf10Items =
- ReadOnlyCollection.Take(10).ToList().AsReadOnly();
-
- private readonly ReadOnlyCollection _readOnlyCollectionOf10KItems = ReadOnlyCollection;
-
- private readonly ReadOnlyCollection _readOnlyCollectionOf1Item =
- ReadOnlyCollection.Take(1).ToList().AsReadOnly();
-
- private readonly ReadOnlyCollection _readOnlyCollectionOf1KItems =
- ReadOnlyCollection.Take(1000).ToList().AsReadOnly();
-
- [Benchmark]
- public void Read_ReadOnlyCollection_Of_1_Item()
- {
- var sum = _readOnlyCollectionOf1Item.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ReadOnlyCollection_Of_10_Items()
- {
- var sum = _readOnlyCollectionOf10Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
- }
-
- [Benchmark]
- public void Read_ReadOnlyCollection_Of_100_Items()
+ [GlobalSetup]
+ public void Setup()
{
- var sum = _readOnlyCollectionOf100Items.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var rng = new Random(42);
+ var list = new List(RowCount);
+ for (var i = 0; i < RowCount; i++)
+ list.Add(new BenchmarkItemDto(
+ rng.Next(1, 1_000_000),
+ Guid.NewGuid(),
+ $"item_{i:D6}",
+ Math.Round((decimal)(rng.NextDouble() * 9999.99), 2),
+ i % 2 == 0));
+ _data = list.AsReadOnly();
}
- [Benchmark]
- public void Read_ReadOnlyCollection_Of_1K_Items()
+ /// Direct foreach — enumerator obtained via IList virtual call; no boxing.
+ [Benchmark(Baseline = true, Description = "foreach — accumulate Sum(Id)")]
+ public int Read_ForEach()
{
- var sum = _readOnlyCollectionOf1KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ var sum = 0;
+ foreach (var item in _data)
+ sum += item.Id;
+ return sum;
}
- [Benchmark]
- public void Read_ReadOnlyCollection_Of_10K_Items()
+ /// LINQ Sum — stacks a delegate on top of the virtual enumerator.
+ [Benchmark(Description = "LINQ .Sum(item => item.Id)")]
+ public int Read_LinqSum()
{
- var sum = _readOnlyCollectionOf10KItems.Sum(item => item.Id);
- _consumer.Consume(sum);
- _ = sum;
+ return _data.Sum(item => item.Id);
}
}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/ReadCollections/Setup/ReadCollectionBogusSetup.cs b/Benchmark/Workshops/Benchs/ReadCollections/Setup/ReadCollectionBogusSetup.cs
deleted file mode 100644
index 0117917..0000000
--- a/Benchmark/Workshops/Benchs/ReadCollections/Setup/ReadCollectionBogusSetup.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using CaeriusNet.Benchmark.Data.Simple;
-
-namespace CaeriusNet.Benchmark.Workshops.Benchs.ReadCollections.Setup;
-
-public static class ReadCollectionBogusSetup
-{
- private static readonly Random Seeding = Randomizer.Seed = new Random(31031996);
-
- public static readonly ReadOnlyCollection FakingReadOnlyCollectionOf10KItemsDto = new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000)
- .AsReadOnly();
-
- public static readonly IEnumerable FakingIEnumerableOf10KItemsDto = new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000)
- .AsEnumerable();
-
- public static readonly ImmutableArray FakingImmutableArrayOf10KItemsDto =
- [
- ..new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000)
- ];
-
- public static readonly List FakingListOf10KItemsDto = new Faker()
- .StrictMode(true)
- .RuleFor(dto => dto.Id, (faker, _) => faker.Random.Number(0, 10_000))
- .RuleFor(dto => dto.Guid, f => f.Random.Guid())
- .RuleFor(dto => dto.Name, f => f.Internet.UserName())
- .Generate(10_000);
-}
\ No newline at end of file
diff --git a/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs b/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs
index c5f8614..29f618f 100644
--- a/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs
+++ b/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs
@@ -18,7 +18,7 @@ public class BatchedVsSingleBench
private List _items = null!;
- [Params(10, 100)] public int ItemCount { get; set; }
+ [Params(10, 100, 500, 1_000, 5_000)] public int ItemCount { get; set; }
[GlobalSetup]
public async Task Setup()
diff --git a/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs b/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs
index abc9ab7..c1cce0c 100644
--- a/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs
+++ b/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs
@@ -9,7 +9,8 @@
[MemoryDiagnoser]
public class SpExecutionBench
{
- [Params(0, 10, 100, 1_000)] public int RowCount { get; set; }
+ [Params(0, 10, 100, 1_000, 5_000, 10_000, 50_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public async Task Setup()
diff --git a/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs b/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs
index 1d23027..88d9c9b 100644
--- a/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs
+++ b/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs
@@ -129,7 +129,7 @@ INSERT INTO [dbo].[BenchmarkItems] ([Name], [Price])
IF NOT EXISTS (SELECT TOP 1 1 FROM [dbo].[BenchmarkItems])
BEGIN
INSERT INTO [dbo].[BenchmarkItems] ([Name], [Price], [IsActive])
- SELECT TOP 10000
+ SELECT TOP 100000
CONCAT('Item_', ROW_NUMBER() OVER (ORDER BY (SELECT NULL))),
CAST(ABS(CHECKSUM(NEWID())) % 9999 + 1 AS DECIMAL(18,2)),
1
diff --git a/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs b/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs
index c20f2c4..a17baa2 100644
--- a/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs
+++ b/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs
@@ -33,7 +33,8 @@ public class TvpFullRoundtripBench
private List _items = null!;
- [Params(10, 100, 1_000)] public int RowCount { get; set; }
+ [Params(10, 100, 1_000, 5_000, 10_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public async Task Setup()
diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs
index b14f7dc..de23864 100644
--- a/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs
+++ b/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs
@@ -46,7 +46,8 @@ public class TvpColumnScalingBench
private List _items3 = null!;
private List _items5 = null!;
- [Params(10, 100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(10, 100, 1_000, 10_000, 50_000, 100_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public void Setup()
diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs
index 267f68d..fbdb7a3 100644
--- a/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs
+++ b/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs
@@ -19,7 +19,8 @@ public class TvpSerializationBench
private List _items = null!;
- [Params(10, 100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(10, 100, 1_000, 10_000, 50_000, 100_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public void Setup()
diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs
index 532090b..081592d 100644
--- a/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs
+++ b/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs
@@ -34,7 +34,8 @@ public class TvpVsDataTableBench
private List _items = null!;
- [Params(10, 100, 1_000, 10_000)] public int RowCount { get; set; }
+ [Params(10, 100, 1_000, 10_000, 50_000, 100_000)]
+ public int RowCount { get; set; }
[GlobalSetup]
public void Setup()
diff --git a/Documentations/docs/.vitepress/config.mts b/Documentations/docs/.vitepress/config.mts
index e05e823..1ab8e84 100644
--- a/Documentations/docs/.vitepress/config.mts
+++ b/Documentations/docs/.vitepress/config.mts
@@ -35,7 +35,7 @@ export default defineConfig({
{ text: 'Best Practices', link: '/documentation/best-practices' },
]
},
- { text: 'Performance', link: '/benchmarks/performance' }
+ { text: 'Performance', link: '/benchmarks/' }
],
sidebar: [
@@ -80,7 +80,11 @@ export default defineConfig({
text: 'Performance',
collapsed: false,
items: [
- { text: 'Benchmark Results', link: '/benchmarks/performance' }
+ { text: 'Overview & Methodology', link: '/benchmarks/' },
+ { text: 'In-Memory Benchmarks', link: '/benchmarks/in-memory' },
+ { text: 'Collection Benchmarks', link: '/benchmarks/collections' },
+ { text: 'SQL Server Benchmarks', link: '/benchmarks/sql-server' },
+ { text: 'Cache Benchmarks', link: '/benchmarks/cache' }
]
}
],
diff --git a/Documentations/docs/benchmarks/cache.md b/Documentations/docs/benchmarks/cache.md
new file mode 100644
index 0000000..9e3e8be
--- /dev/null
+++ b/Documentations/docs/benchmarks/cache.md
@@ -0,0 +1,139 @@
+---
+title: Cache Benchmarks
+description: CaeriusNet cache benchmarks — FrozenDictionary read/write throughput and IMemoryCache hit, miss, Set, and GetOrCreate performance at varying cache sizes.
+---
+
+# Cache Benchmarks
+
+These benchmarks measure the read and write throughput of CaeriusNet's two cache layers:
+
+| Layer | Implementation | Characteristic |
+|---|---|---|
+| **Frozen Cache** | `System.Collections.Frozen.FrozenDictionary` | Immutable after construction; read-optimised; no expiration |
+| **In-Memory Cache** | `Microsoft.Extensions.Caching.Memory.IMemoryCache` | Mutable, concurrent; TTL-based expiration; write-friendly |
+
+Both layers are benchmarked at `[Params(100, 1_000, 10_000)]` for `CacheSize`,
+covering three realistic cache population sizes.
+All data is generated with a fixed seed (42) for reproducibility.
+
+---
+
+## FrozenCache — FrozenDictionary Throughput
+
+**Benchmark class: `FrozenCacheBench`**
+
+### Architecture
+
+`FrozenDictionary` (introduced in .NET 8) is an immutable, read-optimised hash map
+produced by calling `.ToFrozenDictionary()` on any `IEnumerable>`.
+Once constructed, it cannot be modified.
+
+The .NET runtime optimises `FrozenDictionary` for lookup by:
+1. Selecting a hash algorithm at construction time that minimises collisions for the actual set of keys stored.
+2. Laying out buckets and values in contiguous memory arrays — maximising L1/L2 cache utilisation.
+3. Generating specialised lookup code paths per key type (e.g., `string` uses a minimal perfect hash when the
+ key count is small enough).
+
+These properties make `FrozenDictionary` the fastest .NET hash map for **stable, read-heavy data** —
+lookup throughput typically exceeds `Dictionary` by 20–40 % because the absence of a write path
+allows more aggressive layout and inline-hashing optimisations.
+
+**Trade-off:** Every write (new key, updated value, removal) requires rebuilding the **entire** dictionary
+from scratch via `.ToFrozenDictionary()`. This is an O(N) operation and allocates a new dictionary object.
+The frozen cache is therefore optimised for **write-once / read-many** access patterns — typical for
+configuration caches, static lookup tables, and reference data loaded at startup.
+
+### Benchmark methods
+
+| Method | Description |
+|---|---|
+| `Read_Sequential_AllKeys` *(Baseline)* | `TryGetValue` for all keys in insertion order — maximum hardware prefetcher benefit |
+| `Read_Random_AllKeys` | `TryGetValue` for all keys in a shuffled order — stresses the hash-lookup path |
+| `Write_FullRebuild` | `.ToFrozenDictionary()` from source entries — measures the O(N) write cost |
+
+### Key insights
+
+**Sequential vs random reads:**
+- Sequential access (insertion order) allows the CPU's hardware prefetcher to predict the next memory address
+ before it is needed, loading the bucket array into L1 cache ahead of each lookup.
+ This yields the highest possible throughput for `FrozenDictionary`.
+- Random access produces more L1/L2 cache misses (visible in the `CacheMisses` hardware counter if PMU is available).
+ The Ratio between sequential and random quantifies the locality penalty.
+- Even random-access throughput on `FrozenDictionary` is competitive with `Dictionary` because the
+ contiguous layout and minimal-hash algorithm reduce average probe length.
+
+**Write — full rebuild:**
+- The rebuild cost scales linearly with `CacheSize`: O(N) key hashing + O(N) layout optimisation.
+- At `CacheSize = 10 000`, the rebuild time is measurably larger than a single lookup cycle —
+ confirming that frozen cache writes should be batched and infrequent.
+- The rebuild allocates a new `FrozenDictionary` instance + its internal arrays. The old instance becomes
+ eligible for GC immediately after the reference is swapped (typically Gen1 or Gen2 depending on size).
+- **Practical guidance:** Use the frozen cache for data that changes at most once per deployment or on
+ a scheduled refresh cycle (e.g., every 5–60 minutes). For data that changes per-request, use `IMemoryCache`.
+
+
+
+---
+
+## InMemoryCache — IMemoryCache Throughput
+
+**Benchmark class: `InMemoryCacheBench`**
+
+### Architecture
+
+`IMemoryCache` (from `Microsoft.Extensions.Caching.Memory`) is backed by a
+`ConcurrentDictionary