Low-Level Performance Without unsafe Keyword
MemoryMarshal, ref struct, and Unsafe give you near-pointer performance without marking code unsafe. Traditional unsafe blocks require elevated permissions and complicate deployment. These APIs deliver similar speed gains with better safety guarantees and no unsafe context requirement.
You'll compare safe baseline implementations against MemoryMarshal casts, ref struct patterns, and Unsafe operations. The performance gap shows up when you need zero-copy type conversions, stack-only lifetimes, or direct memory manipulation. Expect 5-10x gains in specialized scenarios like binary parsing or high-frequency transformations.
These tools aren't for everyday code. Use them when profiling identifies hot paths where allocations or conversions dominate. Simple operations stay clearer with regular C# code. Advanced scenarios get measurable wins from these low-level primitives.
Zero-Copy Type Casting with MemoryMarshal
MemoryMarshal.Cast reinterprets memory without copying. When you need to view byte arrays as int arrays or vice versa, traditional code copies elements. MemoryMarshal creates a span that views the same memory as a different type, eliminating allocations and copies entirely.
This pattern excels in binary protocol parsing, image processing, and serialization. You read bytes from a stream and reinterpret them as structured data without intermediate allocations. The cast is instantaneous because no data moves.
using System;
using System.Runtime.InteropServices;
public class BinaryParser
{
// Traditional: allocate and copy
public static int[] BytesToInts_Traditional(byte[] data)
{
int[] result = new int[data.Length / 4];
Buffer.BlockCopy(data, 0, result, 0, data.Length);
return result;
}
// MemoryMarshal: zero-copy view
public static Span<int> BytesToInts_ZeroCopy(Span<byte> data)
{
return MemoryMarshal.Cast<byte, int>(data);
}
// Example usage
public static void ProcessBinaryData()
{
byte[] raw = new byte[] { 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0 };
// Zero-copy cast
Span<int> ints = MemoryMarshal.Cast<byte, int>(raw);
Console.WriteLine($"First int: {ints[0]}"); // 1
Console.WriteLine($"Second int: {ints[1]}"); // 2
// Modifications affect original array
ints[0] = 99;
Console.WriteLine($"First byte: {raw[0]}"); // 99
}
}
Cast works when target type size divides source size evenly. Casting 12 bytes to 3 ints works. Casting 10 bytes to ints fails because 10 doesn't divide by 4. The runtime validates alignment and size at runtime for safety.
Stack-Only Types with ref struct
ref struct types can only exist on the stack, never the heap. This guarantees short lifetimes and enables optimizations impossible with regular structs. Span<T> is a ref struct, which is why it can't cross async boundaries or live in fields.
Custom ref structs work well for temporary calculation buffers, iteration state, or parsing contexts. The compiler enforces stack-only semantics, preventing accidental heap allocations or lifetime extension that would cause performance issues.
using System;
public ref struct BufferWriter
{
private Span<byte> _buffer;
private int _position;
public BufferWriter(Span<byte> buffer)
{
_buffer = buffer;
_position = 0;
}
public void WriteByte(byte value)
{
if (_position >= _buffer.Length)
throw new InvalidOperationException("Buffer full");
_buffer[_position++] = value;
}
public void WriteInt32(int value)
{
Span<byte> slice = _buffer.Slice(_position, 4);
BitConverter.TryWriteBytes(slice, value);
_position += 4;
}
public ReadOnlySpan<byte> WrittenBytes => _buffer.Slice(0, _position);
}
// Usage
public static void WriteData()
{
Span<byte> buffer = stackalloc byte[1024];
BufferWriter writer = new(buffer);
writer.WriteByte(42);
writer.WriteInt32(12345);
ReadOnlySpan<byte> data = writer.WrittenBytes;
Console.WriteLine($"Written {data.Length} bytes");
}
ref struct enforces stack lifetime. You can't store BufferWriter in a field, return it from async methods, or box it. This restriction enables the compiler to guarantee it never outlives the stack buffers it references, preventing dangling pointers while avoiding GC overhead.
Direct Access with Unsafe Methods
Unsafe class provides low-level operations without requiring unsafe keyword. Methods like Unsafe.As, Unsafe.Add, and Unsafe.SizeOf enable optimizations that would otherwise need pointers. You get pointer-like performance while staying in safe-ish code that doesn't require special permissions.
Use Unsafe for hot path optimizations where every nanosecond counts. Array bounds elimination, type punning, and offset calculations benefit most. Regular code performs fine 99% of the time. The 1% where Unsafe helps shows up clearly in profiles.
using System;
using System.Runtime.CompilerServices;
public class UnsafeOps
{
// Reinterpret int as float without conversion
public static float IntBitsToFloat(int value)
{
return Unsafe.As<int, float>(ref value);
}
// Array element access without bounds check
public static ref int GetElementUnsafe(int[] array, int index)
{
ref int firstElement = ref MemoryMarshal.GetArrayDataReference(array);
return ref Unsafe.Add(ref firstElement, index);
}
// Fast struct size
public static int GetSize<T>() where T : struct
{
return Unsafe.SizeOf<T>();
}
// Example: Fast array sum skipping bounds checks
public static int SumUnsafe(int[] data)
{
ref int current = ref MemoryMarshal.GetArrayDataReference(data);
ref int end = ref Unsafe.Add(ref current, data.Length);
int sum = 0;
while (Unsafe.IsAddressLessThan(ref current, ref end))
{
sum += current;
current = ref Unsafe.Add(ref current, 1);
}
return sum;
}
}
Unsafe operations bypass safety checks. You're responsible for correctness. Bounds violations, type mismatches, or bad offsets cause undefined behavior or crashes. Use these when profiling proves the safety cost matters, and test extensively to catch errors.
Common Pitfalls
MemoryMarshal.Cast requires aligned data. Casting unaligned byte arrays can throw or produce garbage on some platforms. For portable code, ensure source alignment matches target type requirements. Use spans and stackalloc for naturally aligned buffers.
ref struct lifetimes confuse developers. You can't store them in Task, use them with async/await, or put them in closures. The compiler error messages help, but planning ref struct scope up front prevents frustration. Keep them local and synchronous.
Unsafe methods bypass runtime safety. Unlike managed code that throws on errors, Unsafe operations can corrupt memory silently. Validate indices, check array lengths manually, and test thoroughly. These APIs trade safety for speed. Make that trade consciously.
Performance Notes
MemoryMarshal eliminates copy overhead in type conversions. Binary parsing, pixel format conversions, and serialization see 5-10x speedups when copies dominated baseline performance. Small conversions show minimal gain because copy cost is already low.
ref struct prevents heap allocations for temporary types. Stack allocation scales poorly beyond 1KB, but ref struct patterns work well for iteration state, builders, and parsers that process data synchronously. Monitor stack usage to avoid overflow.
Unsafe operations remove bounds checks and enable advanced optimizations. Tight loops processing millions of elements benefit most. Simple code with occasional array access gains little. Profile first, optimize what matters, and keep unsafe surface area minimal.
Benchmark Playground
Compare traditional array operations against MemoryMarshal and Unsafe variants.
Steps
- dotnet new console -n UnsafeBench
- cd UnsafeBench
- Update Program.cs with benchmark code
- Modify UnsafeBench.csproj
- dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
BenchmarkRunner.Run<CastBenchmark>();
[MemoryDiagnoser]
public class CastBenchmark
{
private byte[] _data = new byte[1000];
[Benchmark(Baseline = true)]
public int TraditionalCopy()
{
int[] ints = new int[_data.Length / 4];
Buffer.BlockCopy(_data, 0, ints, 0, _data.Length);
return ints[0];
}
[Benchmark]
public int MemoryMarshalCast()
{
Span<int> ints = MemoryMarshal.Cast<byte, int>(_data);
return ints[0];
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
</ItemGroup>
</Project>
Console
MemoryMarshal.Cast allocates zero bytes and runs 10x faster. Traditional copy allocates 1KB and takes nanoseconds longer per operation.
Performance Considerations
These APIs target hot paths where micro-optimizations matter. Binary protocol implementations, high-frequency serializers, and image processing pipelines benefit most. General application code rarely needs this level of optimization. Profile before adopting.
MemoryMarshal provides the best safety-to-performance ratio. It eliminates copies while maintaining runtime validation. Start here when you need zero-copy operations. Unsafe goes further but requires manual safety validation.
ref struct works best for synchronous processing with clear lifetime boundaries. Don't force it into async code or long-lived state. Use it for temporary contexts that process data and dispose immediately.
Test extensively. These APIs bypass normal safety rails. Unit tests catch obvious errors, but fuzz testing and stress tests expose edge cases. The performance gains only matter if the code stays correct under load.