Skip to main content
C# · lesson 23 of 26
C# advanced Lesson 23 of 26

Performance in C#

High-performance C# by example: BenchmarkDotNet, Span<T> parsing, Memory<T>, ArrayPool, struct vs class, string.Create and SIMD, with measured results.

By SkillByExample · Updated

Measure First: BenchmarkDotNet

Intuition about performance is often wrong, and a Stopwatch around one call is noisy. BenchmarkDotNet warms up the JIT, runs many iterations, and reports time and allocations. Add it with dotnet add package BenchmarkDotNet.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkRunner.Run<StringBenchmarks>();

[MemoryDiagnoser]
[ShortRunJob]
public class StringBenchmarks
{
    [Params(100, 1_000)]
    public int N;

    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        string result = "";
        for (int i = 0; i < N; i++) result += i;
        return result;
    }

    [Benchmark]
    public string Builder()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++) sb.Append(i);
        return sb.ToString();
    }
}
$ dotnet run -c Release
(build, warm-up and per-iteration log trimmed; the summary table follows)
| Method        | N    | Mean         | Error         | StdDev      | Ratio | RatioSD | Gen0     | Gen1   | Allocated  | Alloc Ratio |
|-------------- |----- |-------------:|--------------:|------------:|------:|--------:|---------:|-------:|-----------:|------------:|
| Concatenation | 100  |     8.236 us |     4.9902 us |   0.2735 us |  1.00 |    0.04 |   1.2054 |      - |   20.37 KB |        1.00 |
| Builder       | 100  |     1.259 us |     0.7859 us |   0.0431 us |  0.15 |    0.01 |   0.0725 |      - |    1.25 KB |        0.06 |
|               |      |              |               |             |       |         |          |        |            |             |
| Concatenation | 1000 | 1,006.240 us | 2,342.6373 us | 128.4079 us |  1.01 |    0.16 | 164.0625 | 1.9531 | 2773.88 KB |       1.000 |
| Builder       | 1000 |    15.917 us |    43.6812 us |   2.3943 us |  0.02 |    0.00 |   0.8392 | 0.0153 |   14.37 KB |       0.005 |

The ratio column tells the story: at N = 100, StringBuilder is roughly 6-7x faster; at N = 1,000 it is 50-100x faster (the ratio moves between runs on a shared machine) and allocates about 190x less memory, because += copies the whole string every time — quadratic work. That growth with N is what a single timing would hide. [ShortRunJob] keeps the example quick (3 iterations — note the wide Error column); drop it for real measurements. BenchmarkDotNet refuses to run a Debug build, hence -c Release, which the rest of this lesson uses too.

Span<T>: Parsing Without Allocating

Substring and Split allocate new strings. Slicing a ReadOnlySpan<char> just narrows a view onto the original, so a parser can run with zero allocations.

string ip = "192.168.10.254";

long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) ParseWithSplit(ip);
Console.WriteLine($"Split:  {GC.GetAllocatedBytesForCurrentThread() - before,9:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
Span<byte> parts = stackalloc byte[4];
for (int i = 0; i < 10_000; i++) TryParseIp(ip, parts);
Console.WriteLine($"Span:   {GC.GetAllocatedBytesForCurrentThread() - before,9:N0} bytes");
Console.WriteLine(string.Join(".", parts.ToArray()));

static byte[] ParseWithSplit(string s) => s.Split('.').Select(byte.Parse).ToArray();

static bool TryParseIp(ReadOnlySpan<char> input, Span<byte> parts)
{
    for (int i = 0; i < 4; i++)
    {
        int dot = input.IndexOf('.');
        var segment = dot < 0 ? input : input[..dot];
        if (!byte.TryParse(segment, out parts[i])) return false;
        input = dot < 0 ? ReadOnlySpan<char>.Empty : input[(dot + 1)..];
    }
    return input.IsEmpty;
}
$ dotnet run -c Release
Split:  2,644,536 bytes
Span:           0 bytes
192.168.10.254

The Split version allocates an array of four strings, four strings, a LINQ iterator and a result array on every call — over two megabytes for 10,000 parses. The span version allocates nothing: it slices the input in place, parses each segment directly, and writes into a stackalloc buffer on the stack. The trade-off is that Span<T> is a ref struct: it cannot be a field of a class, be boxed, or live across an await.

Memory<T> Across await

Span<T> cannot survive an await, because the method’s stack frame is packed away while it waits. Memory<T> is the heap-safe equivalent: store it, pass it to async APIs, and take .Span when you need to touch the data synchronously.

var source = new MemoryStream(Enumerable.Range(0, 10_000).Select(i => (byte)i).ToArray());
var pipeline = new ChecksumPipeline(bufferSize: 4096);
Console.WriteLine(await pipeline.ProcessAsync(source));

public class ChecksumPipeline(int bufferSize)
{
    private readonly Memory<byte> _buffer = new byte[bufferSize];

    public async Task<string> ProcessAsync(Stream source)
    {
        int reads = 0;
        long sum = 0;
        int n;
        while ((n = await source.ReadAsync(_buffer)) > 0)
        {
            reads++;
            sum += Sum(_buffer.Span[..n]);
        }
        return $"{reads} reads, checksum {sum}";
    }

    private static long Sum(ReadOnlySpan<byte> data)
    {
        long s = 0;
        foreach (byte b in data) s += b;
        return s;
    }
}
$ dotnet run -c Release
3 reads, checksum 1273080

One 4 KB buffer is reused for every read — three reads for 10,000 bytes — rather than a new array per chunk. The Memory<byte> field crosses each await; the synchronous Sum works on a Span slice of exactly the bytes that were read.

ArrayPool<T>

Short-lived buffers are prime GC fodder. ArrayPool<T>.Shared lends out arrays and takes them back for reuse.

using System.Buffers;

int gen0 = GC.CollectionCount(0);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
{
    byte[] buffer = new byte[64 * 1024];
    buffer[0] = 1;
}
Console.WriteLine($"new byte[]: {GC.GetAllocatedBytesForCurrentThread() - before,13:N0} bytes, {GC.CollectionCount(0) - gen0} gen0 GCs");

gen0 = GC.CollectionCount(0);
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
    try { buffer[0] = 1; }
    finally { ArrayPool<byte>.Shared.Return(buffer); }
}
Console.WriteLine($"ArrayPool:  {GC.GetAllocatedBytesForCurrentThread() - before,13:N0} bytes, {GC.CollectionCount(0) - gen0} gen0 GCs");

byte[] rented = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"asked for 1000, got {rented.Length}");
ArrayPool<byte>.Shared.Return(rented);
$ dotnet run -c Release
new byte[]:   655,601,976 bytes, 37 gen0 GCs
ArrayPool:         66,784 bytes, 0 gen0 GCs
asked for 1000, got 1024

Ten thousand 64 KB arrays is about 655 MB of allocation and triggers garbage collections; the pooled loop reuses one array and allocates almost nothing. Two rules come with renting: the array may be larger than requested (1,024 for a request of 1,000), so track the length you actually use; and always return it in a finally, then never touch it again.

Struct vs Class in Hot Loops

A class instance lives on the heap and must be collected. A small readonly struct lives inline — in a local, an array slot or another object — with no allocation of its own.

using System.Diagnostics;

const int N = 10_000_000;

long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
var c = new PointClass(0, 0);
for (int i = 0; i < N; i++) c = new PointClass(c.X + 1, c.Y + 2);
Console.WriteLine($"class:  {sw.ElapsedMilliseconds,4} ms, {(GC.GetAllocatedBytesForCurrentThread() - before) / 1_000_000,4} MB allocated");

before = GC.GetAllocatedBytesForCurrentThread();
sw.Restart();
var s = new PointStruct(0, 0);
for (int i = 0; i < N; i++) s = new PointStruct(s.X + 1, s.Y + 2);
Console.WriteLine($"struct: {sw.ElapsedMilliseconds,4} ms, {(GC.GetAllocatedBytesForCurrentThread() - before) / 1_000_000,4} MB allocated");
Console.WriteLine($"{c.X == s.X} {c.Y == s.Y}");

var slots = new object[1000];
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < slots.Length; i++) slots[i] = s;
Console.WriteLine($"boxing 1,000 structs: {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");

public sealed class PointClass(float x, float y) { public float X { get; } = x; public float Y { get; } = y; }
public readonly struct PointStruct(float x, float y) { public float X { get; } = x; public float Y { get; } = y; }
$ dotnet run -c Release
class:   159 ms,  240 MB allocated
struct:   28 ms,    0 MB allocated
True True
boxing 1,000 structs: 24,000 bytes

Same arithmetic, same results; the class version allocated ten million 24-byte objects (240 MB) and ran about five times slower, while the struct version allocated nothing. The last line is the fine print: assigning a struct to object (or to an interface variable) boxes it into a new 24-byte heap object each time, silently giving back the allocation you saved.

ref and in: Avoiding Copies

A struct parameter is copied on every call. in passes a read-only reference instead; a ref return hands back a reference into existing storage.

var a = new Vector3D(1, 2, 3);
var b = new Vector3D(4, 6, 3);
Console.WriteLine(Distance(in a, in b));

var grid = new Grid(5);
ref int cell = ref grid.At(2);
cell = 42;
Console.WriteLine(string.Join(",", grid.Cells));

static double Distance(in Vector3D p, in Vector3D q)
{
    double dx = p.X - q.X, dy = p.Y - q.Y, dz = p.Z - q.Z;
    return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}

public readonly record struct Vector3D(double X, double Y, double Z);

public class Grid(int size)
{
    public int[] Cells { get; } = new int[size];
    public ref int At(int i) => ref Cells[i];
}
$ dotnet run -c Release
5
0,0,42,0,0

Distance read both 24-byte vectors through references instead of copying them — worthwhile for larger structs in hot paths, pointless for an int. Writing 42 through cell changed the array itself, because At returned a reference to the slot, not a copy of the value. Use in only with readonly structs; on a mutable struct the compiler makes a defensive copy on each member call, which defeats the purpose.

string.Create: One Allocation for a Formatted String

string.Create allocates the final string once and lets you write its characters directly into it — no intermediate strings.

Console.WriteLine(FormatUserId(4217, "ada"));
Console.WriteLine($"U{4217}:{"ada"}");

long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100_000; i++) _ = "U" + i.ToString() + ":" + "ada";
Console.WriteLine($"concat:        {GC.GetAllocatedBytesForCurrentThread() - before,10:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100_000; i++) _ = FormatUserId(i, "ada");
Console.WriteLine($"string.Create: {GC.GetAllocatedBytesForCurrentThread() - before,10:N0} bytes");

static string FormatUserId(int id, string name)
{
    int digits = id == 0 ? 1 : (int)Math.Floor(Math.Log10(id)) + 1;
    return string.Create(digits + name.Length + 2, (id, name), static (span, state) =>
    {
        span[0] = 'U';
        state.id.TryFormat(span[1..], out int written);
        span[written + 1] = ':';
        state.name.AsSpan().CopyTo(span[(written + 2)..]);
    });
}
$ dotnet run -c Release
U4217:ada
U4217:ada
concat:         7,919,920 bytes
string.Create:  4,720,000 bytes

Both produce U4217:ada. The concatenation allocates the ToString() result and then the final string; string.Create allocates only the final string, cutting allocation by about 40% here. The length must be computed exactly up front — too long and the string ends in \0 characters. For most code, plain interpolation is fine: since C# 10 it compiles to an efficient handler that avoids many intermediate allocations too.

SIMD: Many Values per Instruction

Vector256<T> processes 256 bits at once — eight floats per instruction on CPUs with AVX — through a portable API that works without unsafe.

using System.Diagnostics;
using System.Runtime.Intrinsics;

float[] data = Enumerable.Range(0, 1_000_000).Select(i => (i % 100) * 0.5f).ToArray();
Console.WriteLine($"hardware accelerated: {Vector256.IsHardwareAccelerated}");

var sw = Stopwatch.StartNew();
float scalar = 0;
for (int r = 0; r < 100; r++) scalar = SumScalar(data);
Console.WriteLine($"scalar: {scalar,12} in {sw.ElapsedMilliseconds,4} ms");

sw.Restart();
float simd = 0;
for (int r = 0; r < 100; r++) simd = SumSimd(data);
Console.WriteLine($"simd:   {simd,12} in {sw.ElapsedMilliseconds,4} ms");

static float SumScalar(float[] a)
{
    float s = 0;
    foreach (float x in a) s += x;
    return s;
}

static float SumSimd(float[] a)
{
    var acc = Vector256<float>.Zero;
    int i = 0;
    for (; i <= a.Length - Vector256<float>.Count; i += Vector256<float>.Count)
        acc += Vector256.Create(a, i);
    float s = Vector256.Sum(acc);
    for (; i < a.Length; i++) s += a[i];
    return s;
}
$ dotnet run -c Release
hardware accelerated: True
scalar:     24591114 in  182 ms
simd:       24750000 in   36 ms

The SIMD loop is about five times faster because each addition handles eight floats. Look closely at the sums, though: they differ. The exact answer is 24,750,000, and the scalar loop is the one that is wrong. A float has 24 bits of precision, so once the single running total reaches the millions, every small value added to it loses its low bits and the error accumulates. The vector version keeps eight smaller partial sums, which lose less — here none at all. Floating-point addition is not associative, so any change in summation order changes the result; use double when the total matters. Before reaching for intrinsics yourself, check whether the library already does it: TensorPrimitives.Sum (System.Numerics.Tensors) and many LINQ and span methods such as Sum over int arrays are already vectorised.

Practice

  1. Count lines without allocating. Count the '\n' characters in a 1 MB string using AsSpan().Count('\n') and compare its allocation with Split('\n').Length - 1.
Solution
string text = string.Concat(Enumerable.Repeat("some line of text\n", 60_000));

long before = GC.GetAllocatedBytesForCurrentThread();
int viaSplit = text.Split('\n').Length - 1;
Console.WriteLine($"Split: {viaSplit} lines, {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
int viaSpan = text.AsSpan().Count('\n');
Console.WriteLine($"Span:  {viaSpan} lines, {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");
$ dotnet run -c Release
Split: 60000 lines, 4,368,800 bytes
Span:  60000 lines, 0 bytes
  1. Pool a StringBuilder. Cache one StringBuilder in a [ThreadStatic] field and reuse it (after Clear()) to build 10,000 short strings. How does allocation compare with a new builder each time?
Solution
using System.Text;

long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) _ = new StringBuilder().Append("id-").Append(i).ToString();
Console.WriteLine($"new builder:    {GC.GetAllocatedBytesForCurrentThread() - before,9:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) _ = Cached.Builder.Clear().Append("id-").Append(i).ToString();
Console.WriteLine($"cached builder: {GC.GetAllocatedBytesForCurrentThread() - before,9:N0} bytes");

static class Cached
{
    [ThreadStatic] private static StringBuilder? _sb;
    public static StringBuilder Builder => _sb ??= new StringBuilder(64);
}
$ dotnet run -c Release
new builder:    1,444,704 bytes
cached builder:   399,432 bytes

The remaining allocation is the result strings themselves, which you cannot avoid if you need them.

  1. Benchmark your own change. Add a third benchmark method, StringConcat, to the BenchmarkDotNet example that builds the strings with string.Concat(Enumerable.Range(0, N)). Predict where it lands before running.
Solution
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkRunner.Run<StringBenchmarks>();

[MemoryDiagnoser]
[ShortRunJob]
public class StringBenchmarks
{
    [Params(1_000)]
    public int N;

    [Benchmark(Baseline = true)]
    public string Builder()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++) sb.Append(i);
        return sb.ToString();
    }

    [Benchmark]
    public string StringConcat() => string.Concat(Enumerable.Range(0, N));
}
$ dotnet run -c Release
| Method       | N    | Mean     | Error    | StdDev   | Ratio | RatioSD | Gen0   | Gen1   | Allocated | Alloc Ratio |
|------------- |----- |---------:|---------:|---------:|------:|--------:|-------:|-------:|----------:|------------:|
| Builder      | 1000 | 12.13 us | 16.09 us | 0.882 us |  1.00 |    0.09 | 0.8392 | 0.0153 |  14.37 KB |        1.00 |
| StringConcat | 1000 | 18.80 us | 42.66 us | 2.338 us |  1.56 |    0.19 | 0.3357 |      - |   5.71 KB |        0.40 |

string.Concat lands close to the hand-written StringBuilder — slightly slower in this run, with error bars that overlap — but allocates 60% less, because it builds into a pooled buffer instead of growing a fresh StringBuilder.

Frequently Asked Questions

When should I actually optimize for performance?
Measure first with a profiler or BenchmarkDotNet. Premature optimization wastes time and makes code harder to maintain. Optimize when you have a measured bottleneck in a hot path that matters for your application's goals.
What is the difference between Span<T> and Memory<T>?
Span<T> is a stack-only ref struct — it cannot be stored in a field, boxed, or used across await points. Memory<T> is a heap-allocated wrapper around a contiguous buffer that can cross async boundaries. Use Span<T> for synchronous processing; Memory<T> for async pipelines.
What does ArrayPool do?
ArrayPool<T>.Shared rents a pre-allocated array from a pool instead of allocating a new one. This avoids GC pressure in code that frequently needs temporary arrays. Always return rented arrays to the pool.
Why should I benchmark in Release mode?
Debug builds disable most JIT optimizations such as inlining and loop optimizations, so timings can be several times slower and the ranking of two approaches can even flip. BenchmarkDotNet refuses to run on a Debug build for this reason. Always use dotnet run -c Release, and never trust a Stopwatch measurement taken from a debugger session.
Is a struct always faster than a class in C#?
No. A small, immutable struct avoids a heap allocation and the GC work that follows, which helps in hot loops that create many short-lived values. But a large struct is copied on every assignment and method call, and boxing a struct into object or an interface allocates anyway. Keep structs small (roughly 16 bytes or less), make them readonly, and measure.