For years, developers have used unsafe code when performance mattered.
Direct pointer access can reduce some checks and give developers more control over memory operations. In very specific workloads, that control can still be useful.
However, modern .NET has become much better at optimizing ordinary C# code. The JIT compiler can now recognize more patterns, remove redundant bounds checks, improve loop optimization, and generate better machine code without requiring developers to use pointers.
.NET 11 continues this work. Microsoft documents improvements in areas such as bounds-check elimination, loop analysis, assertion propagation, devirtualization, SIMD code generation, and other JIT optimizations.
That does not mean unsafe code is obsolete. It means the decision to use it should be based on measurement rather than the assumption that pointer-based code is automatically faster.
Why Safe C# Can Be Fast
Consider a simple array loop:
static int Sum(int[] values)
{
int total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}At the C# level, values[i] looks like it needs a bounds check on every iteration.
The JIT, however, can reason about the loop.
It already knows that:
i >= 0and:
i < values.LengthTherefore, the bounds check may be unnecessary.
Modern .NET documentation specifically describes JIT optimizations that eliminate bounds checks when the compiler can prove an access is safe. .NET 11 expands the situations where this can happen.
This is one reason developers should not automatically replace safe array access with pointers.
What Changed in .NET 11?
.NET 11 contains a broad set of JIT improvements.
Some of the areas relevant to performance-sensitive C# code include:
More bounds-check elimination
Better loop analysis
Improved assertion propagation
More devirtualization
Better constant folding
Improved register allocation
Additional SIMD and hardware-intrinsic optimizations
Better profile-guided optimization
Improvements to runtime async infrastructure
The JIT sits between C# code and the CPU:
C# Source
|
v
IL
|
v
.NET JIT
|
v
Native Machine Code
|
v
CPUThis means an improvement in the JIT can make existing C# code faster without requiring developers to rewrite that code.
Microsoft's .NET 11 performance work includes several such JIT changes.
Safe Array Access vs Unsafe Pointer Access
Consider a safe implementation:
static long SumSafe(int[] values)
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}An unsafe implementation could use pointers:
unsafe static long SumUnsafe(int[] values)
{
long total = 0;
fixed (int* ptr = values)
{
for (int i = 0; i < values.Length; i++)
{
total += ptr[i];
}
}
return total;
}The second version gives the developer direct memory access.
But that does not automatically make it faster.
The safe version gives the runtime an opportunity to optimize the loop while preserving the normal memory-safety guarantees of C#.
The correct question is therefore not:
Is unsafe code faster?It is:
Does unsafe code make this particular hot path measurably faster?Bounds Checks Are Not Always a Performance Problem
A common misconception is that array bounds checks are always expensive.
Consider:
for (int i = 0; i < values.Length; i++)
{
result += values[i];
}This is a pattern the JIT can understand very well.
The loop counter starts at zero and increases predictably. The loop condition is based directly on the array length.
That gives the JIT useful information about the valid index range.
.NET 11 improves bounds-check elimination for additional patterns, including cases involving calculations such as an index plus a constant and some index-from-end operations.
A More Complicated Loop
Now consider:
static int SumPairs(int[] values)
{
int total = 0;
for (int i = 0; i < values.Length - 1; i++)
{
total += values[i];
total += values[i + 1];
}
return total;
}The JIT has more information to reason about here.
The loop condition establishes that:
i < values.Length - 1which also implies:
i + 1 < values.Length.NET 11 improves the JIT's ability to recognize some such relationships and remove redundant checks.
This matters because performance-sensitive code does not always need to be rewritten using pointers to avoid every possible bounds check.
Safe Code Is Easier to Maintain
Compare these two approaches.
Safe C#:
static void Copy(int[] source, int[] destination)
{
for (int i = 0; i < source.Length; i++)
{
destination[i] = source[i];
}
}Unsafe code:
unsafe static void Copy(int[] source, int[] destination)
{
fixed (int* src = source)
fixed (int* dst = destination)
{
for (int i = 0; i < source.Length; i++)
{
dst[i] = src[i];
}
}
}The unsafe version introduces additional concerns:
Pointer lifetime
Pinning
Bounds correctness
Buffer size assumptions
Memory safety
Debugging complexity
If both implementations provide effectively the same production performance, the safe implementation is usually easier to understand and maintain.
This is why performance optimization should consider engineering cost as well as raw execution time.
Spans Are Another Important Option
For performance-sensitive code, Span<T> and ReadOnlySpan<T> provide a useful middle ground.
For example:
static long Sum(ReadOnlySpan<int> values)
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}This code remains safe while providing an efficient abstraction for working with contiguous memory.
You can call it with an array:
int[] values = [1, 2, 3, 4, 5];
long total = Sum(values);There is no need to expose pointers simply because the code is performance-sensitive.
When Unsafe Code Can Still Make Sense
There are legitimate scenarios for unsafe.
C# supports unsafe code for situations such as:
Native interoperability
Direct pointer-based APIs
Specialized memory operations
Hardware-oriented code
Interacting with unmanaged memory
Extremely performance-sensitive code where measurement justifies the complexity
Microsoft's documentation also notes that unsafe code can be useful for direct memory access, but it introduces safety and stability risks.
For example:
unsafe
{
int value = 42;
int* pointer = &value;
Console.WriteLine(*pointer);
}This is valid C#, but using a pointer does not automatically provide a performance benefit.
The JIT Can Optimize More Than You Might Expect
The JIT does more than remove bounds checks.
It can perform transformations such as:
Inlining methods
Removing redundant checks
Propagating known values
Devirtualizing calls
Simplifying branches
Eliminating unnecessary work
Improving register allocation
Generating vectorized code in suitable cases
.NET 11 expands several of these capabilities.
For example, if the JIT can determine that a condition is always true or false based on information it has already established, it can simplify the generated code.
This means the C# source does not always represent the actual machine-level work performed by the application.
SIMD Can Reduce the Need for Manual Loops
For workloads involving large amounts of numeric data, SIMD can process multiple values with a single vector instruction.
.NET provides APIs such as:
Vector128<T>
Vector256<T>
Vector512<T>and hardware intrinsics for more specialized scenarios.
The runtime can also determine whether hardware acceleration is available for supported vector types.
A simple example is:
static void Add(
ReadOnlySpan<int> left,
ReadOnlySpan<int> right,
Span<int> result)
{
for (int i = 0; i < left.Length; i++)
{
result[i] = left[i] + right[i];
}
}Before manually introducing pointers or processor-specific instructions, measure whether normal safe code already performs adequately.
For some workloads, higher-level APIs and JIT optimizations can provide the required performance without exposing unsafe memory operations.
Benchmark Before Changing the Code
A performance change should be measured.
BenchmarkDotNet is commonly used for controlled .NET microbenchmarks.
Join the conversation! Your thoughts help the community grow.