Skip to content

Collection Benchmarks

These benchmarks compare the performance of different .NET collection types for reading and creating result sets returned by CaeriusNet, and the impact of List<T> capacity pre-allocation.

Understanding these numbers helps developers choose the right return type for their use case — the choice between IReadOnlyList<T>, IEnumerable<T>, and ImmutableArray<T> has measurable consequences for throughput and GC pressure under load.

All read and create benchmarks use [Params(1, 100, 1_000, 10_000, 100_000)] on RowCount. ListCapacity benchmarks use [Params(100, 1_000, 10_000, 100_000)] (sizes ≥ 100 are meaningful for capacity decisions). See Methodology & Overview for BDN configuration details.


Reading Collections

These benchmarks model the consumer side of CaeriusNet: after mapping rows into a typed collection, how efficiently can downstream code iterate that collection?

Each read benchmark exposes two methods:

MethodPattern
Read_ForEach (Baseline)foreach loop — uses the collection's own enumerator
Read_LinqSumcollection.Sum(x => x.Id) — LINQ aggregation through the IEnumerable<T> interface

Memory layout and cache locality

The fundamental performance difference between collection types lies in how elements are laid out in memory and what the enumerator does at each step:

TypeMemory layoutEnumerator
List<T>Contiguous T[] internal arrayStruct enumerator, increments an index — zero heap allocation
ReadOnlyCollection<T>Wraps a List<T> or IList<T> — same contiguous arrayInterface IEnumerator<T> — virtual dispatch per MoveNext
IEnumerable<T>Depends on the underlying source; may be an iterator state machineVirtual dispatch per MoveNext; possible heap allocation for the enumerator
ImmutableArray<T>Contiguous T[] internal array (sealed, value-type wrapper)Struct enumerator on the ImmutableArray<T> type itself — zero virtual dispatch, zero heap allocation

ImmutableArray<T> has the same memory layout as T[] but wraps it in a value type, so iterating it via foreach uses the struct enumerator (no boxing, no virtual call). This makes it the fastest collection type for read-only, cache-friendly iteration when the full array is traversed sequentially.

ReadListToBench

Benchmark class: ReadListToBench

Iterates a List<BenchmarkItemDto> using foreach and via LINQ Sum. List<T> uses a struct enumerator internally — foreach compiles to a direct index-loop with no virtual dispatch.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ReadReadOnlyCollectionToBench

Benchmark class: ReadReadOnlyCollectionToBench

Iterates a ReadOnlyCollection<BenchmarkItemDto> (returned by .AsReadOnly() on a List<T>). ReadOnlyCollection<T> implements IEnumerable<T> via explicit interface — foreach on the concrete type still dispatches through the virtual IEnumerator<T> interface, adding one virtual call per element vs List<T>.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ReadEnumerableToBench

Benchmark class: ReadEnumerableToBench

Iterates an IEnumerable<BenchmarkItemDto> backed by a List<T> cast to the interface. Every MoveNext and Current access goes through virtual dispatch. At large row counts the overhead accumulates into a measurable Ratio vs the List<T> baseline.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ReadImmutableArrayToBench

Benchmark class: ReadImmutableArrayToBench

Iterates an ImmutableArray<BenchmarkItemDto>. The foreach over ImmutableArray<T> uses its own value-type struct enumerator, which the JIT inlines directly — no virtual call, no heap allocation. For sequential full-traversal workloads, this is the most cache-friendly option.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


Creating Collections

These benchmarks model the producer side of CaeriusNet: after streaming rows from SqlDataReader, how efficiently can a typed collection be constructed?

Each create benchmark compares two strategies:

StrategyCharacteristic
Method A (Baseline)Uses new List<T>(capacity) + AddRange — explicit pre-allocation, single array copy
Method BUses source.ToList() or ImmutableArray.Create() — relies on internal copy/builder paths

CreateListToBench

Benchmark class: CreateListToBench

Compares new List<T>(capacity) { AddRange(source) } vs source.ToList().

  • new List<T>(capacity): pre-allocates the internal array once, then AddRange does a single Array.Copy. No internal resize, O(N) time, O(N) allocation.
  • source.ToList(): LINQ internally creates a List<T> without a capacity hint, triggering logarithmic resize steps (×2 growth) until the source is exhausted.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


CreateReadOnlyCollectionToBench

Benchmark class: CreateReadOnlyCollectionToBench

Compares new List<T>(capacity) { AddRange } .AsReadOnly() vs source.ToList().AsReadOnly().

.AsReadOnly() is a zero-copy wrapper — it allocates one ReadOnlyCollection<T> object that references the existing List<T> internal array. The creation cost difference is therefore identical to CreateListToBench, plus a fixed O(1) wrapper allocation.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


CreateEnumerableToBench

Benchmark class: CreateEnumerableToBench

Compares two lazy strategies for producing IEnumerable<T>:

  • Materialise + cast (new List<T>(capacity).AsEnumerable()): pays O(N) upfront to build the list, then returns a zero-copy interface cast. Consumers pay zero additional allocation on enumeration.
  • Array + cast (source.ToArray().AsEnumerable()): uses Array.Copy for a compact, read-only backing store. Useful when the element count is known and the consumer only ever iterates (never indexes).

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


CreateImmutableArrayToBench

Benchmark class: CreateImmutableArrayToBench

Compares two ImmutableArray<T> construction paths:

  • Builder pattern (ImmutableArray.CreateBuilder<T>(capacity).MoveToImmutable()): pre-allocates a mutable builder, fills it, then transfers ownership — zero-copy transition to the immutable array. MoveToImmutable() is valid only when builder.Capacity == builder.Count (exact capacity).
  • ImmutableArray.Create(ReadOnlySpan<T>): copies from a Span<T> (e.g., a stack-allocated or array slice). Ideal when the source data is already in a contiguous buffer.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


List Capacity Pre-Allocation

These benchmarks measure the performance impact of the List<T> capacity hint parameter. The four classes together form a cross-class comparison set — use the Ratio column relative to ListWithCapacityToBench (exact capacity, the ideal baseline) to understand the overhead of each variant.

Capacity strategies compared

ClassStrategyExpected resize count
ListWithCapacityToBenchnew List<T>(N) — exact capacity0 (no resize)
ListWithoutCapacityToBenchnew List<T>() — no capacity hint⌈log₂(N)⌉ resize steps
ListWithCapacityWithOverextendToBenchnew List<T>(N) then adds 2N items1 resize (exactly doubles once)
ListWithLessCapacityThanNeededToBenchnew List<T>(N/2) then adds N items1 resize at N/2 boundary

How List resize works

When List<T> runs out of capacity, it:

  1. Allocates a new array of capacity × 2.
  2. Copies all existing elements via Array.Copy.
  3. Releases the old array (eligible for GC).

Each resize is O(capacity) in both time and allocation. With no capacity hint (new List<T>()), starting from an empty list and adding N elements triggers ⌈log₂(N)⌉ resizes, which means at N = 100 000, the internal array is reallocated ~17 times and the total number of element copies is ~2N.

ListWithCapacityToBench

Benchmark class: ListWithCapacityToBench

Exact capacity — no resize. This is the ideal baseline for all capacity comparisons.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ListWithoutCapacityToBench

Benchmark class: ListWithoutCapacityToBench

No capacity hint — List<T>() default. Triggers O(log N) resize steps. At large RowCount values, the time and allocation overhead vs the exact-capacity case becomes significant.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ListWithCapacityWithOverextendToBench

Benchmark class: ListWithCapacityWithOverextendToBench

Capacity is set to N, but 2N items are added — forces exactly one resize at the N boundary. This models the scenario where the estimated row count from SQL is correct but additional items are appended later.

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


ListWithLessCapacityThanNeededToBench

Benchmark class: ListWithLessCapacityThanNeededToBench

Capacity is set to N/2, but N items are added — forces one resize at the N/2 boundary. This models an underestimated capacity hint (e.g., using the previous page's row count for the current page).

ℹ️ No benchmark data yet. Real results are generated automatically when a GitHub Release is published. You can also trigger the benchmark workflow manually.


Collection Type Recommendations

Based on the benchmark results above, use the following decision guide:

ScenarioRecommended typeReason
Read-only result, full sequential scanImmutableArray<T>Struct enumerator, contiguous layout, no virtual dispatch
Mutable result builder, then consumeList<T> with capacity hintPre-allocation eliminates resize steps
Public API surface (read-only contract)IReadOnlyList<T>Interface allows List<T> backing, preserves indexing
Lazy pipeline, not always materialisedIEnumerable<T>Only pay materialisation cost if consumer iterates
Wrapping an existing list as read-onlyReadOnlyCollection<T>Zero-copy .AsReadOnly() wrapper

Released under the MIT License.