C# Ref Readonly Parameters: Optimize Large Structs
This article provides a comprehensive overview of
ref readonly parameters in C#, detailing how they enable
high-performance memory management when handling large value types. You
will learn the mechanics behind passing arguments by reference without
sacrificing immutability, the differences between standard value
passing, the in modifier, and ref readonly,
and how to effectively eliminate expensive stack-copying operations in
performance-critical applications.
The Problem: Value Types and Large Structs
In C#, structs are value types. When a struct is passed to a method as a standard argument, the runtime creates a full bitwise copy of the entire data structure on the call stack.
For small structs (such as System.Drawing.Point with two
4-byte integers), copying overhead is negligible and often fits within
CPU registers. However, when structs grow larger—such as 4x4
transformation matrices, custom physics vectors, or large memory buffers
exceeding 16 bytes—copying the entire payload across multiple method
boundaries introduces significant CPU and memory bandwidth overhead.
What are
ref readonly Parameters?
Introduced in modern C# to refine memory semantics, the
ref readonly parameter modifier specifies that an argument
is passed by reference (avoiding copies) while strictly enforcing
read-only access within the called method (preventing mutation).
Under the hood:
- Reference Passing: Instead of copying the 32-, 64-, or 128-byte struct, the runtime passes a managed pointer (4 bytes on 32-bit runtimes, 8 bytes on 64-bit runtimes).
- Read-Only Enforcement: The compiler treats the
parameter as a variable that cannot be assigned to, modified, or passed
to other methods that accept writable
reforoutarguments.
ref readonly vs.
in Parameters
While in and ref readonly both declare
parameters passed by read-only reference, they differ in how strictly
they handle call-site variables and compiler-generated defensive
copies:
inModifier:- Callers are not required to specify
inat the call site. - If the argument passed is an rvalue (such as a method return value, an expression, or a literal), the compiler silently creates a temporary variable on the stack and passes a reference to that temporary copy.
- If the struct is not declared as a
readonly struct, calling any member on aninparameter causes the compiler to generate a defensive copy to prevent unintended state mutations.
- Callers are not required to specify
ref readonlyModifier:- Enforces explicit intent. Callers can explicitly write
ref readonlyat the call site to document that passing by reference is mandatory. - Prevents passing temporary values unless explicitly permitted by overloads, eliminating hidden allocations and accidental copy operations.
- Enforces explicit intent. Callers can explicitly write
Practical Implementation
The following example demonstrates how to define a large struct and
consume it using ref readonly to avoid stack copies:
public readonly struct Matrix4x4
{
public readonly float M11, M12, M13, M14;
public readonly float M21, M22, M23, M24;
public readonly float M31, M32, M33, M34;
public readonly float M41, M42, M43, M44;
public Matrix4x4(float initialValue)
{
M11 = M12 = M13 = M14 = initialValue;
M21 = M22 = M23 = M24 = initialValue;
M31 = M32 = M33 = M34 = initialValue;
M41 = M42 = M43 = M44 = initialValue;
}
}
public class Renderer
{
// Accepts the 64-byte struct by read-only reference
public static float CalculateDeterminant(ref readonly Matrix4x4 matrix)
{
// Compiler error if mutation is attempted:
// matrix.M11 = 5.0f;
return matrix.M11 * matrix.M22 * matrix.M33 * matrix.M44;
}
public static void Run()
{
var worldMatrix = new Matrix4x4(1.0f);
// Passed by reference; zero memory copying occurs
float det = CalculateDeterminant(ref readonly worldMatrix);
}
}Performance Benefits and When to Use
- Threshold for Optimization: Use
ref readonlywhen structs exceedIntPtr.Size * 2(16 bytes on 64-bit systems). Below this threshold, passing by value often performs faster because the struct can fit directly into processor registers without pointer dereferencing. - Pair with
readonly struct: Always declare the target struct asreadonly struct. This guarantees to the compiler that no methods, properties, or fields within the struct can mutate state, completely eliminating hidden defensive copies when accessing members. - High-Throughput Scenarios: Ideal for game engines, high-frequency trading platforms, graphics pipelines, and scientific computing where methods are invoked millions of times per frame or second.