Skip to main content

๐Ÿ“ Lesson 4.3: Allocations, GC, and Benchmarking

The garbage collector frees you from manual memory management โ€” but allocations aren't free. This lesson explains how the GC works, how to reduce allocation pressure, and how to measure performance rigorously instead of guessing.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the generational garbage collector and why allocations cost
  • Identify common hidden allocation sources
  • Reduce allocations with pooling, StringBuilder, and spans
  • Write benchmarks with BenchmarkDotNet
  • Read benchmark output and optimize based on data, not guesses

Estimated Time: 75 minutes

Project: Benchmark two implementations and prove which allocates less.

In This Lesson

How the GC Works

The garbage collector automatically reclaims heap objects that are no longer referenced. It's generational, based on a key observation: most objects die young (temporary variables, short-lived results), while a few live long (caches, singletons).

๐Ÿ“– The three generations

  • Gen 0 โ€” brand-new, small objects. Collected very frequently and very fast.
  • Gen 1 โ€” survived one collection; a buffer between short- and long-lived.
  • Gen 2 โ€” long-lived objects. Collected rarely, but a Gen 2 collection is the most expensive.

Objects that survive a collection are promoted to the next generation. There's also the Large Object Heap (LOH) for big allocations (โ‰ฅ 85,000 bytes), collected with Gen 2.

graph LR A["New object"] --> G0["Gen 0
(collected often, cheap)"] G0 -->|"survives"| G1["Gen 1"] G1 -->|"survives"| G2["Gen 2
(collected rarely, costly)"] G0 -->|"most die here"| X["Reclaimed"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style X fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style G2 fill:#fff3cd,stroke:#ffc107,stroke-width:2px

Because Gen 0 is cheap, short-lived garbage is inexpensive to collect. The trouble comes from volume and from objects that live just long enough to be promoted.

Why Allocations Cost

Allocating memory itself is fast (basically bumping a pointer). The cost is what comes after: every allocation brings a future collection closer. When the GC runs, it does work proportional to the live objects โ€” and may briefly pause your threads.

โš ๏ธ The real problem is allocation rate

A high allocation rate in a hot path means frequent Gen 0 collections, more promotion to Gen 1/2, and more (and longer) GC pauses. In throughput-sensitive servers or latency-sensitive apps (games, trading), this GC overhead โ€” not your logic โ€” can become the bottleneck. Reducing allocations reduces GC work.

๐Ÿ’ก Keep perspective: for typical apps, the GC is excellent and you should not fight it. This lesson's techniques are for measured hot paths. The goal isn't "zero allocations everywhere" โ€” it's "no wasteful allocations where it counts."

Hidden Allocation Sources

Many allocations are invisible in the source. Learn to spot these on hot paths:

SourceAllocates
Boxing (value type โ†’ object)a heap object per box (Lesson 4.1)
String concatenation in a loopa new string per +
LINQ in a hot loopiterators, delegates, and closures per call
Closures capturing variablesa hidden class holding the captured state
params arraysa new array per call
Substring / Splitnew strings/arrays (Lesson 4.2)
// Allocation-heavy: a new string every iteration (strings are immutable)
string result = "";
for (int i = 0; i < 10_000; i++)
{
    result += i + ",";     // each += allocates a brand-new string
}

๐Ÿ’ก Closures are sneaky

A lambda that captures a local variable (Intermediate, delegates) compiles to a hidden class instance holding that variable โ€” an allocation. In a tight loop, capturing lambdas can allocate a lot. Passing state as parameters, or using static lambdas (static x => ..., which can't capture), avoids it.

Reducing Allocations

A toolkit, roughly from most to least commonly useful:

StringBuilder for string building

var sb = new System.Text.StringBuilder();
for (int i = 0; i < 10_000; i++)
{
    sb.Append(i).Append(',');   // mutates one buffer instead of 10,000 strings
}
string result = sb.ToString();   // one allocation at the end

Span & stackalloc (Lesson 4.2)

Slice and parse without copying; use small stack buffers for scratch work.

ArrayPool<T> โ€” rent instead of allocate

For temporary arrays in hot paths, rent a buffer from a shared pool and return it, instead of allocating a fresh array each time:

using System.Buffers;

int[] buffer = ArrayPool<int>.Shared.Rent(1024);   // reuse a pooled array
try
{
    // ... use buffer (it may be larger than requested) ...
}
finally
{
    ArrayPool<int>.Shared.Return(buffer);   // give it back for reuse
}

Other techniques

  • Avoid boxing โ€” use generics and generic APIs (Lesson 4.1).
  • Hoist allocations out of loops โ€” allocate once, reuse inside.
  • Prefer struct for tiny, short-lived values where value semantics fit (Lesson 4.1).
  • Cache immutable results instead of recomputing/reallocating them.

โš ๏ธ Optimize the measured hot path โ€” not everything

Every technique here adds complexity. Applying them blindly makes code harder to read for no real gain. First measure to find the true hot spot; then optimize that; then measure again to confirm it helped. Which is exactly what benchmarking is for.

Benchmarking with BenchmarkDotNet

Timing code with Stopwatch (as we did in Module 2) is fine for a rough comparison, but it's easily misled by JIT warm-up, GC timing, and CPU scaling. BenchmarkDotNet is the industry-standard library that handles all of that โ€” warm-up, many iterations, statistics, and allocation measurement โ€” automatically.

# Add the package to a Release-mode console project
dotnet add package BenchmarkDotNet

Mark methods with [Benchmark] and add [MemoryDiagnoser] to report allocations:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]                        // also report allocations & GC
public class StringBenchmarks
{
    private readonly int[] _numbers = Enumerable.Range(0, 1000).ToArray();

    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        string result = "";
        foreach (int n in _numbers) result += n + ",";
        return result;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new System.Text.StringBuilder();
        foreach (int n in _numbers) sb.Append(n).Append(',');
        return sb.ToString();
    }
}

// In Main:
BenchmarkRunner.Run<StringBenchmarks>();

โš ๏ธ Always benchmark in Release, never under the debugger

Debug builds disable optimizations and the debugger perturbs timing โ€” results would be meaningless. Run with dotnet run -c Release. BenchmarkDotNet even refuses to run a Debug build to protect you from this mistake.

Reading the Results

BenchmarkDotNet prints a table. A simplified example for the two methods above:

MethodMeanRatioAllocated
Concatenation~52 ยตs1.00~2.9 MB
StringBuilder~9 ยตs0.17~16 KB

How to read it:

  • Mean โ€” average time per operation (lower is better).
  • Ratio โ€” relative to the baseline; 0.17 means ~6ร— faster.
  • Allocated โ€” memory allocated per operation (from [MemoryDiagnoser]). Here the StringBuilder version allocates ~180ร— less โ€” the concatenation created thousands of throwaway strings.

โœ… Data settles debates

Now the choice isn't opinion โ€” the numbers show StringBuilder is both dramatically faster and far lighter on memory for this workload. That's the value of benchmarking: it turns "I think this is faster" into "this is measurably 6ร— faster and allocates 180ร— less."

๐Ÿ’ก The optimization loop: measure โ†’ find the hot spot โ†’ optimize it โ†’ measure again. Never skip the final measurement โ€” some "optimizations" make things slower, and only data will tell you.

Exercise & Quiz

๐Ÿ‹๏ธ Exercise: Benchmark Sum Implementations

Objective: Use BenchmarkDotNet with [MemoryDiagnoser] to compare allocations of two approaches.

Instructions:

  1. Create a Release console project called Bench and add the BenchmarkDotNet package.
  2. Prepare an int[] of 1,000,000 values as a field.
  3. Write two [Benchmark] methods that sum only the even numbers: (a) LinqSum using numbers.Where(n => n % 2 == 0).Sum(); (b) LoopSum using a plain foreach with an accumulator.
  4. Add [MemoryDiagnoser] and mark LinqSum as the baseline.
  5. Run with dotnet run -c Release and compare Mean and Allocated.

Starter Code:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SumBenchmarks>();

[MemoryDiagnoser]
public class SumBenchmarks
{
    private readonly int[] _numbers = Enumerable.Range(0, 1_000_000).ToArray();

    // TODO: [Benchmark(Baseline = true)] LinqSum
    // TODO: [Benchmark] LoopSum
}
๐Ÿ’ก Hint

LINQ version: return _numbers.Where(n => n % 2 == 0).Sum(); (the lambda/iterator add small allocations). Loop version: long total = 0; foreach (var n in _numbers) if (n % 2 == 0) total += n; return total; (should allocate ~0).

โœ… Solution
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SumBenchmarks>();

[MemoryDiagnoser]
public class SumBenchmarks
{
    private readonly int[] _numbers = Enumerable.Range(0, 1_000_000).ToArray();

    [Benchmark(Baseline = true)]
    public long LinqSum() => _numbers.Where(n => n % 2 == 0).Sum();

    [Benchmark]
    public long LoopSum()
    {
        long total = 0;
        foreach (int n in _numbers)
        {
            if (n % 2 == 0) total += n;
        }
        return total;
    }
}

Typical result: LoopSum is faster and allocates essentially nothing, while LinqSum is more readable but allocates a little and runs somewhat slower. The lesson isn't "never use LINQ" โ€” it's "on a measured hot path, a plain loop can win; elsewhere, prefer LINQ's clarity."

๐ŸŽฏ Quick Quiz

Question 1: Why is the GC generational?

Question 2: In a hot loop building a big string, what reduces allocations most?

Question 3: Before optimizing for performance, you shouldโ€ฆ

Summary

๐ŸŽ‰ Key Takeaways

  • The GC is generational (Gen 0/1/2 + LOH): most objects die young, so Gen 0 collections are cheap; Gen 2 is costly.
  • Allocating is fast, but a high allocation rate drives frequent GC pauses โ€” the real performance concern on hot paths.
  • Watch for hidden allocations: boxing, string +=, LINQ/closures in tight loops, params, Substring/Split.
  • Reduce them with StringBuilder, spans/stackalloc, ArrayPool<T>, avoiding boxing/closures, and hoisting allocations out of loops.
  • Measure with BenchmarkDotNet ([Benchmark], [MemoryDiagnoser], Release mode) โ€” optimize the measured hot path, then confirm with data.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

That completes Module 4 โ€” you can reason about memory and prove performance! In the final module, we make code adaptable and well-architected. First: Lesson 5.1: Attributes and Reflection, inspecting and using types at runtime.

๐ŸŽ‰ Module 4 complete!

You understand the GC, cut allocations, and measure like a pro. Next: metaprogramming and architecture.