Skip to main content

Performance Optimization

Performance optimization is crucial for creating responsive, efficient applications. C# provides several features and techniques to help you write high-performance code.

Span and Memory​

Span<T> and Memory<T> are powerful types for working with contiguous memory without allocations.

Span​

Span<T> is a ref struct that provides a type-safe view over contiguous memory:

// Create a Span from an array
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span = array;

// Create a Span from a part of an array
Span<int> slice = array.AsSpan(1, 3); // [2, 3, 4]

// Create a Span on the stack
Span<byte> stackSpan = stackalloc byte[100];

// Create a Span from a string
ReadOnlySpan<char> nameSpan = "John Doe".AsSpan();

// Modify through a Span
for (int i = 0; i < span.Length; i++)
{
span[i] *= 2;
}

// Pass to methods without allocations
ProcessData(array.AsSpan());

Memory​

Memory<T> is similar to Span<T> but can be stored in fields and used across async boundaries:

// Create Memory from an array
int[] array = { 1, 2, 3, 4, 5 };
Memory<int> memory = array;

// Create a slice
Memory<int> slice = memory.Slice(1, 3);

// Convert to Span when needed
Span<int> span = memory.Span;

// Use in async methods
async Task ProcessAsync(Memory<byte> buffer)
{
// Can pass Memory<T> across await points
await WriteToStreamAsync(buffer);

// Convert to Span when needed for direct access
Span<byte> span = buffer.Span;
span[0] = 42;
}

String Operations with Spans​

Using spans for string operations can significantly reduce allocations:

// Traditional string operations (allocates multiple strings)
string input = "Hello, World!";
bool traditional = input.Substring(0, 5).ToLower().Equals("hello");

// Span-based operations (zero allocations)
ReadOnlySpan<char> inputSpan = input.AsSpan();
bool spanBased = inputSpan.Slice(0, 5).Equals("hello".AsSpan(), StringComparison.OrdinalIgnoreCase);

// Parsing without allocations
ReadOnlySpan<char> numberText = "12345".AsSpan();
if (int.TryParse(numberText, out int number))
{
Console.WriteLine(number);
}

Ref Returns and Locals​

Ref returns and locals allow you to return and store references to values rather than copies.

Ref Returns​

public ref int FindValue(int[] array, int value)
{
for (int i = 0; i < array.Length; i++)
{
if (array[i] == value)
{
return ref array[i]; // Return a reference to the element
}
}

throw new ArgumentException("Value not found", nameof(value));
}

// Usage
int[] numbers = { 1, 2, 3, 4, 5 };
ref int found = ref FindValue(numbers, 3);
found = 30; // Modifies the original array
Console.WriteLine(string.Join(", ", numbers)); // 1, 2, 30, 4, 5

Ref Locals​

int[] array = { 1, 2, 3 };
ref int refLocal = ref array[1]; // Reference to array[1]
refLocal = 20; // Modifies array[1]
Console.WriteLine(array[1]); // 20

Ref Readonly Returns​

Return a reference that can't be modified:

public ref readonly int GetLargestValue(int[] array)
{
int maxIndex = 0;
for (int i = 1; i < array.Length; i++)
{
if (array[i] > array[maxIndex])
{
maxIndex = i;
}
}

return ref array[maxIndex]; // Return a readonly reference
}

// Usage
int[] numbers = { 5, 10, 3, 7 };
ref readonly int largest = ref GetLargestValue(numbers);
Console.WriteLine(largest); // 10

// Error: Cannot modify a readonly reference
// largest = 20;

Stackalloc​

The stackalloc keyword allocates memory on the stack rather than the heap, which can be faster for small, short-lived buffers:

// Allocate an array of 100 integers on the stack
Span<int> stackInts = stackalloc int[100];

// Initialize the array
for (int i = 0; i < stackInts.Length; i++)
{
stackInts[i] = i;
}

// Conditional stackalloc (C# 7.2+)
Span<byte> buffer = size <= 1024 ? stackalloc byte[size] : new byte[size];

// Stackalloc with initialization (C# 7.3+)
Span<int> values = stackalloc int[] { 1, 2, 3, 4, 5 };

Warning: Stack memory is limited. Using stackalloc with large sizes can cause a StackOverflowException.

Unsafe Code​

Unsafe code gives you direct access to memory through pointers, which can be useful for performance-critical operations:

// Enable unsafe code in project settings or use /unsafe compiler option
unsafe
{
// Declare a pointer
int* ptr;

// Create an array
int[] array = { 1, 2, 3, 4, 5 };

// Pin the array and get a pointer to it
fixed (int* p = array)
{
ptr = p;

// Access elements through the pointer
Console.WriteLine(*ptr); // 1
Console.WriteLine(*(ptr + 1)); // 2

// Modify elements
*ptr = 10;
*(ptr + 1) = 20;
}

Console.WriteLine(string.Join(", ", array)); // 10, 20, 3, 4, 5
}

Pointer Arithmetic​

unsafe
{
int[] array = { 1, 2, 3, 4, 5 };

fixed (int* p = array)
{
int* current = p;
int* end = p + array.Length;

// Iterate using pointer arithmetic
while (current < end)
{
*current *= 2;
current++;
}
}

Console.WriteLine(string.Join(", ", array)); // 2, 4, 6, 8, 10
}

Benchmarking​

Proper benchmarking is essential for performance optimization. The BenchmarkDotNet library is the standard tool for .NET benchmarking:

// Install BenchmarkDotNet NuGet package
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser] // Track memory allocations
public class StringBenchmarks
{
private const string Text = "Hello, World!";
private static readonly char[] Separators = { ' ', ',' };

[Benchmark(Baseline = true)]
public string[] SplitTraditional()
{
return Text.Split(Separators);
}

[Benchmark]
public ReadOnlySpan<char> SplitSpan()
{
ReadOnlySpan<char> span = Text.AsSpan();
int commaIndex = span.IndexOf(',');
return span.Slice(0, commaIndex);
}
}

// Run the benchmarks
BenchmarkRunner.Run<StringBenchmarks>();

Memory Pooling​

Memory pooling reduces garbage collection pressure by reusing memory buffers:

using System.Buffers;

// Get a buffer from the shared pool
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);

try
{
// Use the buffer
FillBuffer(buffer);
ProcessBuffer(buffer);
}
finally
{
// Return the buffer to the pool when done
ArrayPool<byte>.Shared.Return(buffer);
}

// Custom pool for specific sizes
ArrayPool<int> customPool = ArrayPool<int>.Create(maxArrayLength: 1024, maxArraysPerBucket: 50);
int[] customBuffer = customPool.Rent(100);
// ...
customPool.Return(customBuffer);

Object Pooling​

For custom objects, you can use ObjectPool<T>:

using Microsoft.Extensions.ObjectPool;

// Create a pool
ObjectPool<StringBuilder> pool = new DefaultObjectPool<StringBuilder>(
new DefaultPooledObjectPolicy<StringBuilder>());

// Get an object from the pool
StringBuilder sb = pool.Get();

try
{
// Use the object
sb.Append("Hello, World!");
Console.WriteLine(sb.ToString());
}
finally
{
// Clear the object before returning it
sb.Clear();

// Return the object to the pool
pool.Return(sb);
}

Struct Layout​

Controlling struct layout can improve performance, especially when interoperating with unmanaged code:

// Sequential layout (fields are laid out in the order they appear)
[StructLayout(LayoutKind.Sequential)]
struct SequentialStruct
{
public int X;
public double Y;
public bool Z;
}

// Explicit layout (you specify the exact position of each field)
[StructLayout(LayoutKind.Explicit)]
struct ExplicitStruct
{
[FieldOffset(0)] public int X;
[FieldOffset(4)] public double Y;
[FieldOffset(12)] public bool Z;
}

// Auto layout (compiler decides the layout for best performance)
[StructLayout(LayoutKind.Auto)]
struct AutoStruct
{
public int X;
public double Y;
public bool Z;
}

Struct Packing​

Control the alignment of struct fields:

// Pack the struct with 1-byte alignment
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct PackedStruct
{
public byte A;
public int B; // Starts at offset 1, not 4
public short C; // Starts at offset 5, not 6
}

Readonly Structs​

Mark structs as readonly to prevent defensive copies:

// Compiler knows this struct is immutable
readonly struct Point
{
public readonly double X;
public readonly double Y;

public Point(double x, double y)
{
X = x;
Y = y;
}

// No defensive copies needed when calling this method
public readonly double DistanceFromOrigin() => Math.Sqrt(X * X + Y * Y);
}

Performance Best Practices​

Avoid Unnecessary Allocations​

// Bad: Creates a new string for each iteration
for (int i = 0; i < 1000; i++)
{
string message = $"Processing item {i}";
Log(message);
}

// Better: Reuse a StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Clear();
sb.Append("Processing item ").Append(i);
Log(sb.ToString());
}

// Best: Use string interpolation handler (C# 10+)
for (int i = 0; i < 1000; i++)
{
Log($"Processing item {i}");
}

Use Value Types Appropriately​

// Good use of struct (small, immutable value type)
readonly struct Point3D
{
public readonly double X;
public readonly double Y;
public readonly double Z;

public Point3D(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
}

// Bad use of struct (large or mutable)
struct LargeData
{
public byte[] Buffer; // Reference type inside a value type
public int[] Values; // Another reference type

// Mutable struct with methods that modify state
public void Update() { /* ... */ }
}

Optimize LINQ Queries​

var numbers = Enumerable.Range(1, 1000).ToList();

// Bad: Multiple iterations
var result1 = numbers.Where(n => n % 2 == 0)
.OrderBy(n => n)
.Select(n => n * 2)
.ToList();

// Better: Use a single iteration with a custom method
var result2 = numbers.ToList();
ProcessInPlace(result2);

void ProcessInPlace(List<int> items)
{
for (int i = items.Count - 1; i >= 0; i--)
{
if (items[i] % 2 != 0)
{
items.RemoveAt(i);
}
else
{
items[i] *= 2;
}
}
items.Sort();
}

Use Appropriate Collection Types​

// For frequent lookups by key
Dictionary<string, User> usersByName = new Dictionary<string, User>();

// For ordered data with binary search
SortedList<int, string> sortedItems = new SortedList<int, string>();

// For unique items with fast lookup
HashSet<string> uniqueNames = new HashSet<string>();

// For frequent insertions/removals at both ends
LinkedList<int> linkedList = new LinkedList<int>();

// For concurrent access
ConcurrentDictionary<string, int> concurrentCounts = new ConcurrentDictionary<string, int>();

Resources​