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 ref or out arguments.

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:

  1. in Modifier:
    • Callers are not required to specify in at 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 an in parameter causes the compiler to generate a defensive copy to prevent unintended state mutations.
  2. ref readonly Modifier:
    • Enforces explicit intent. Callers can explicitly write ref readonly at 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.

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 readonly when structs exceed IntPtr.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 as readonly 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.