Why Span<T> Outperforms Unsafe ref T in High-Performance .NET Loops
The Unsafe Paradox in Modern .NET
In high-performance .NET development, conventional wisdom suggests that bypassing bounds checks using raw pointers or ref pointers like Unsafe.Add will yield better performance than idiomatic C# types. However, developers benchmarking on recent versions of .NET frequently observe the opposite: indexing a standard Span<T> can actually outperform manual address offset calculations with Unsafe.Add.
Understanding the Benchmark Results
Consider a simple element-wise array addition benchmark where two source arrays are summed into an output array over 1,000,000 elements. A typical implementation using Span<T> indexes through the loop directly, while an unsafe implementation uses MemoryMarshal.GetArrayDataReference coupled with Unsafe.Add(ref ptr, index) inside the loop body.
Despite Unsafe.Add skipping runtime bounds checking, Span<T> consistently comes out ahead. Here are two main reasons why this occurs in the .NET Just-In-Time (JIT) compiler:
1. JIT Auto-Vectorization and SIMD Optimizations
Modern .NET JIT compilers (especially in .NET 8, 9, and newer) excel at pattern-matching idiomatic C# structures. When the JIT analyzes a clean loop operating on Span<T> or arrays, it can automatically apply auto-vectorization using hardware intrinsics (such as AVX2 or SSE2 instructions).
In a vectorized loop, the CPU processes multiple integers per instruction cycle (e.g., operating on 8 or 16 integers simultaneously using 256-bit registers). The Unsafe.Add(ref, index) pattern obfuscates the access pattern from the JIT optimizer, often breaking loop idiom recognition and preventing the JIT from emitting optimized SIMD instructions.
2. Address Calculation Overhead
Inside the unsafe loop, calling Unsafe.Add(ref ro, i) on every iteration calculates the memory offset dynamically by executing scale-index math: base_address + (i * sizeof(T)).
In contrast, when indexing a Span<T>, the JIT uses Bounds Check Elimination (BCE). Because the JIT can prove the loop bounds 0 .. Stress stay within the span's length, it removes the bounds check entirely and transforms the iteration into simple pointer increments—yielding cleaner and faster assembly instructions than explicit index calculations.
Writing Unsafe Ref Code Correctly
If you want to use reference arithmetic to achieve maximum speed, you must advance the reference pointer sequentially instead of re-calculating offsets using an index variable inside the loop: