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)]onRowCount. 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:
| Method | Pattern |
|---|---|
Read_ForEach (Baseline) | foreach loop — uses the collection's own enumerator |
Read_LinqSum | collection.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:
| Type | Memory layout | Enumerator |
|---|---|---|
List<T> | Contiguous T[] internal array | Struct enumerator, increments an index — zero heap allocation |
ReadOnlyCollection<T> | Wraps a List<T> or IList<T> — same contiguous array | Interface IEnumerator<T> — virtual dispatch per MoveNext |
IEnumerable<T> | Depends on the underlying source; may be an iterator state machine | Virtual 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 asT[]but wraps it in a value type, so iterating it viaforeachuses 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:
| Strategy | Characteristic |
|---|---|
| Method A (Baseline) | Uses new List<T>(capacity) + AddRange — explicit pre-allocation, single array copy |
| Method B | Uses 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, thenAddRangedoes a singleArray.Copy. No internal resize, O(N) time, O(N) allocation.source.ToList(): LINQ internally creates aList<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()): usesArray.Copyfor 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 whenbuilder.Capacity == builder.Count(exact capacity). ImmutableArray.Create(ReadOnlySpan<T>): copies from aSpan<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
| Class | Strategy | Expected resize count |
|---|---|---|
ListWithCapacityToBench | new List<T>(N) — exact capacity | 0 (no resize) |
ListWithoutCapacityToBench | new List<T>() — no capacity hint | ⌈log₂(N)⌉ resize steps |
ListWithCapacityWithOverextendToBench | new List<T>(N) then adds 2N items | 1 resize (exactly doubles once) |
ListWithLessCapacityThanNeededToBench | new List<T>(N/2) then adds N items | 1 resize at N/2 boundary |
How List resize works
When List<T> runs out of capacity, it:
- Allocates a new array of capacity × 2.
- Copies all existing elements via
Array.Copy. - 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:
| Scenario | Recommended type | Reason |
|---|---|---|
| Read-only result, full sequential scan | ImmutableArray<T> | Struct enumerator, contiguous layout, no virtual dispatch |
| Mutable result builder, then consume | List<T> with capacity hint | Pre-allocation eliminates resize steps |
| Public API surface (read-only contract) | IReadOnlyList<T> | Interface allows List<T> backing, preserves indexing |
| Lazy pipeline, not always materialised | IEnumerable<T> | Only pay materialisation cost if consumer iterates |
| Wrapping an existing list as read-only | ReadOnlyCollection<T> | Zero-copy .AsReadOnly() wrapper |
