C# Is Faster Than You Think
Most people write C# like it's a scripting language and wonder why it doesn't perform. Here's what the runtime actually gives you when you use it properly.
The Managed Language Reputation
C# has a reputation for being slow. It's not a fair reputation. It comes from people writing C# the same way they'd write Python: allocating freely, ignoring GC pressure, using LINQ everywhere, and then blaming the runtime when throughput is disappointing. The runtime is capable of extremely high performance. The question is whether you're writing code that lets it achieve that.
The things that actually make C# slow are: excessive heap allocation, large object heap pressure, boxing value types, blocking on async operations, and synchronisation overhead from shared mutable state. None of these are inherent to the language. They're choices. This is about the tools the language gives you to avoid them.
I came to C# from C++ when I started building FCAT. The .NET runtime is genuinely impressive engineering and understanding what it does under the hood, rather than treating it as a magic black box, changes how you write code in it substantially.
Structs and Value Types
In C#, classes are reference types and live on the heap. Structs are value types and typically live on the stack or inline in a containing type. If you have a million small objects and they're classes, that's a million heap allocations, a million garbage collector tracking entries, and cache-hostile indirection on every access. If they're structs, they're contiguous in memory and GC pressure is essentially zero.
public readonly struct Vec3 {
public readonly float X, Y, Z;
// Lives on the stack, no GC, no indirection
}The readonly struct modifier tells the compiler the struct won't be mutated, which lets it avoid defensive copies in certain cases. This is worth caring about for hot-path code: a method that takes a large struct by value might copy it on every call unless you pass by in reference and mark it readonly. These are the kinds of things that don't matter in tutorial code and matter a lot in a high-throughput scanner.
Unsafe Contexts and Pointers
Yes, C# has native pointers. You just have to ask the compiler and isolate the code in an unsafe block. This lets you manipulate raw unmanaged memory without the GC interjecting or bounds checking saving you from yourself.
unsafe {
int value = 10;
int* p = &value;
*p = 20;
// Modified memory directly. No GC tracking.
}This matters for interop with native C++ libraries or OS APIs. Being able to pin an array and pass its raw pointer to a native driver is where the real power unlocks. In FCAT I use unsafe contexts for the hot path of the PE parser where I need to cast a byte span directly to a struct layout without copying the data. It's faster and cleaner than marshalling, and the safety boundary is well defined because you know exactly where the unsafe block starts and ends.
Span<T> Is a Big Deal
Span<T> is probably the most significant addition to C# in the last decade. It represents a memory-safe contiguous region of arbitrary memory: managed, unmanaged, or stack-allocated via stackalloc. No allocation, no copy, just a view into existing memory with bounds checking built in.
// 100 ints on the stack, zero GC pressure
Span<int> buffer = stackalloc int[100];
Span<int> slice = buffer.Slice(10, 5);Before Span, taking a substring allocated a new string on the heap. With ReadOnlySpan<char>, you take a slice of the original buffer with zero allocation. For a high-throughput parser processing thousands of files this distinction is enormous. In FCAT, the entire PE parsing layer works on a single ReadOnlySpan<byte> over the file buffer. Nothing gets copied. The entropy scanner, the string extractor, the import table walker, all work on slices of that original span.
JIT, PGO, and Native AOT
The JIT in .NET is more sophisticated than most people credit. Tiered compilation since .NET Core 2.1 means methods start in Tier 0 with minimal optimisation for fast startup, then get re-JIT'd to Tier 1 when called frequently enough. Startup time improved dramatically because you stop wasting effort fully optimising initialisation code that only runs once.
Dynamic PGO in .NET 6 and later goes further. The runtime instruments hot paths, observes which branches are actually taken, which virtual calls always resolve to the same concrete type, then re-JITs with that knowledge baked in. The result is JIT-compiled code that can outperform static AOT compilation, because it specialises for runtime conditions the AOT compiler couldn't know about at build time.
// AVX2 SIMD: process 8 ints in one instruction
var a = Vector256.LoadUnsafe(ref src[i]);
var b = Vector256.LoadUnsafe(ref mul[i]);
var result = Avx2.MultiplyLow(a, b);
result.StoreUnsafe(ref dst[i]);Native AOT compiles your entire .NET program ahead of time to a native binary with no JIT warmup and no runtime. I use this for FCAT's CLI build. Cold-start time on a large binary analysis job dropped from around 400ms to under 30ms. You lose dynamic features like runtime reflection, but for tooling where startup latency matters it's an easy trade.
What async/await Actually Compiles To
Most C# developers use async/await as a magic keyword that makes I/O non-blocking. What actually happens is more interesting. The compiler transforms every async method into a state machine, a struct implementing IAsyncStateMachine with a MoveNext() method. Each await point becomes a state transition.
// What you write:
async Task<string> FetchAsync() {
var data = await httpClient.GetStringAsync(url);
return data.ToUpper();
}await doesn't block a thread. It checks if the awaitable is already complete. If not, it registers a continuation back to MoveNext() and returns control to the caller. The thread is free to do other work. When the I/O completes the continuation fires and the state machine resumes. Where this matters practically: ConfigureAwait(false). By default, continuations attempt to resume on the original SynchronizationContext. In library code with no UI context, omitting it can cause deadlocks when mixed with blocking .Result calls. Every internal async path in FCAT uses ConfigureAwait(false).
The GC Is Not Your Enemy
The .NET GC is generational. Short-lived objects live in Gen0, which gets collected frequently and cheaply. Objects that survive a Gen0 collection promote to Gen1, then Gen2. Gen2 collections scan the entire managed heap and are expensive. The goal is to keep most objects dying in Gen0 before they ever promote. If you're seeing Gen2 collections in a latency-sensitive path, something is wrong with your allocation patterns.
The Large Object Heap is separate: allocations over 85KB go straight there and are only collected during Gen2. The LOH is not compacted by default, so repeated large allocations fragment it over time. The fix is pooling your large buffers rather than allocating fresh ones per request.
// Instead of allocating a fresh megabyte buffer every time:
var buf = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try {
DoWork(buf);
} finally {
ArrayPool<byte>.Shared.Return(buf);
}Pinning is the other footgun. When you pass a managed array to native code via a fixed pointer, the GC can't move the object during collection: it becomes pinned. Too many pinned objects fragment the heap and degrade GC performance. In FCAT, every path that touches native interop goes through MemoryMarshal and Span<T> to avoid pinning where possible, using NativeMemory.AlignedAlloc for buffers that genuinely need to outlive a GC cycle.