The garbage collector is fast, and most code should ignore it. When a hot path shows up in a profile, the first fixes are usually the same few: stop copying buffers, stop allocating temporaries per call, and stop paying twice for the same enumeration.
Measure before you optimize allocation
Allocation is the default behaviour of idiomatic C#, and rewriting a cold path around a span buys nothing but harder code. Profile first, then treat only the frames that actually show up.
Slice with a span instead of copyingCA1846
Span<T> and ReadOnlySpan<T> are a view over existing memory: a slice is a pointer and a length, with no copy and no allocation. A Substring, a ToArray or a LINQ slice allocates the whole piece.
Preferred
ReadOnlySpan<char> name = line.AsSpan(header.Length);Avoid
string name = line.Substring(header.Length);Corpus: Memory and spans · CA1846: Prefer AsSpan over Substring
stackalloc for a small buffer that never escapes
A stack-allocated buffer costs nothing to reclaim, but it must be small and it must not outlive the method that created it. Keep the size bounded, and never put an unbounded length into it.
Preferred
Span<byte> header = stackalloc byte[16];Avoid
Span<byte> buffer = stackalloc byte[length]; // length comes from the requestCorpus: stackalloc expression
Span for the call, Memory when it has to be stored
Span<T> is a ref struct, so it cannot be a field, a generic argument, or something that survives an await. When a buffer has to cross either boundary, the parameter and the field are Memory<T> and the span is taken at the point of use.
Corpus: Memory<T> usage guidelines
Rent a buffer instead of allocating a large one
ArrayPool<T>.Shared rents an array that the next call can reuse, which keeps a large temporary out of the collector. A rented buffer must be returned in a finally, so every path gives it back.
Preferred
var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try { await _stream.ReadAsync(buffer.AsMemory(0, 64 * 1024), ct); }
finally { ArrayPool<byte>.Shared.Return(buffer); }Avoid
var buffer = new byte[64 * 1024]; // every call, foreverCorpus: Memory and spans
Array.Empty<T>() instead of an empty array literalCA1825
A new T[0] is an allocation for a value that has no state. Array.Empty<T>() returns the same instance every time, which matters most in the code paths that return "nothing" often.
Preferred
return Array.Empty<string>();Avoid
return new string[0];An array created at the call site is created at every callCA1861
A constant array passed as an argument is a real allocation on each call, and in a loop that is one array per iteration. Hoist it to a static readonly field, or use a span over a collection expression.
Preferred
private static readonly string[] Prefixes = ["api", "cdn"];
static bool IsAsset(string path) =>
Prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal));Avoid
if (new[] { "api", "cdn" }.Any(p => path.StartsWith(p, StringComparison.Ordinal)))Use the char overloadsCA1847
Contains(char), IndexOf(char) and StringBuilder.Append(char) avoid the one-character string literal, and the versions that take a StringComparison avoid a culture-aware search where none is wanted.
Preferred
if (line.Contains(':'))
{
builder.Append(':');
}Avoid
if (line.Contains(":"))
{
builder.Append(":");
}Corpus: CA1847: Use string.Contains(char) for single character string searches · CA1834: Use StringBuilder.Append(char) for single character strings
Parse the format string once with CompositeFormatCA1863
string.Format parses its format string on every call, so a format string held in a const or a static field pays that parse again and again. CompositeFormat.Parse does it once, and CA1863 flags the calls that could take the parsed form.
Preferred
private static readonly CompositeFormat RowFormat = CompositeFormat.Parse("{0}: {1}");
static string WriteRow(Row row) =>
string.Format(CultureInfo.InvariantCulture, RowFormat, row.Id, row.Name);Avoid
private const string RowFormat = "{0}: {1}";
static void WriteRow(Row row) =>
Console.WriteLine(string.Format(RowFormat, row.Id, row.Name));Corpus: CA1863: Use CompositeFormat
Pass a large struct by in or ref
A struct bigger than a couple of machine words is copied on every call. in passes it by reference for reading and keeps the call site unchanged; ref is for when the callee writes back.
Preferred
public double Length(in Matrix4x4 matrix)Avoid
public double Length(Matrix4x4 matrix) // 64 bytes copied per callCorpus: Ref safety and ref structs
Let an interpolated string handler skip the formatting
An interpolated string is formatted eagerly unless the callee takes an interpolated string handler, which is how ILogger avoids building a message that the log level discards. Pass the interpolation to the logger rather than a formatted string.
Corpus: Interpolated string handlers
Enumerate a sequence onceCA1851
A LINQ query re-runs its work every time it is enumerated, so Any() followed by iteration, or a Count() followed by a loop, does the whole query twice. Materialise it into a list when it is needed more than once.
Preferred
var items = query.ToList();
if (items.Count > 0)
{
foreach (var item in items) Use(item);
}Avoid
if (query.Any())
{
foreach (var item in query) Use(item);
}Corpus: CA1851: Possible multiple enumerations of IEnumerable collection
Jagged arrays are faster than rectangular onesCA1814
A multidimensional array is one object whose index arithmetic the runtime computes, and it has no single-item slice. An array of arrays is faster to walk and each row can be replaced or sliced on its own.
Preferred
int[][] grid = new int[rows][];Avoid
int[,] grid = new int[rows, columns];