DEV Community

Ian Cowley
Ian Cowley

Posted on

Data-Oriented Design in C#: Why Objects Are Slowing You Down

Data-Oriented Design in C#: Why Objects Are Slowing You Down

In my previous article, we talked about starving the Garbage Collector by moving away from heap-allocated class types and leaning heavily into struct, Span<T>, and ArrayPool<T>.

That’s a critical first step, but it only solves half the problem. You’ve stopped the GC from pausing your app, but you might still be leaving massive amounts of CPU performance on the table. Why? Because of how your data is structured.

It’s time to talk about Data-Oriented Design (DoD).

The Object-Oriented Trap

We are taught from day one to model our code after the real world. If you are building a social network graph, you might write something like this:

public class UserNode
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Edge> Connections { get; set; }
}

public class Edge
{
    public UserNode Target { get; set; }
    public int Weight { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

This makes perfect logical sense. A user has connections, and those connections point to other users.

But modern CPUs don't care about your logical models. A CPU only cares about reading data from memory into its L1/L2 caches as fast as possible. When a CPU reads a byte from RAM, it doesn't just read that one byte; it pulls a whole 64-byte "cache line" under the assumption that you will probably want the neighboring bytes next.

When you loop through a List<UserNode>, traversing from object to object, you are jumping randomly across the heap. The CPU pulls a cache line, reads your data, and then has to go fetch a completely different block of RAM for the next node. This is called pointer chasing, and the resulting cache misses are devastating to performance.

Enter Data-Oriented Design: Struct of Arrays (SoA)

Data-Oriented Design says: Stop modeling the real world. Model the data the way the hardware wants to consume it.

Instead of an Array of Structs (AoS) (or an array of objects), we invert the architecture to a Struct of Arrays (SoA).

If we look at how the native DataFrame engine Glacier.Polaris or the graph engine Glacier.Graph operates, there are no Node or Edge classes. Instead, we use flat, primitive arrays.

To represent a graph, Glacier.Graph uses the Compressed Sparse Row (CSR) format. The entire graph structure is flattened into a few dense integer arrays:

public class CsrGraph
{
    // The index in the _to array where a node's edges begin
    private readonly int[] _head; 

    // The target node IDs
    private readonly int[] _to;   

    // The relationship types or weights
    private readonly int[] _relation; 
}
Enter fullscreen mode Exit fullscreen mode

The Cache-Friendly Loop

Let's say we want to find all connections for Node 5. In the OOP world, we follow pointers on the heap. In the CSR world, we do this:

int startEdgeIndex = _head[5];
int endEdgeIndex = _head[6];

// Look at how perfectly sequential this is!
for (int i = startEdgeIndex; i < endEdgeIndex; i++)
{
    int targetNode = _to[i];
    int relationWeight = _relation[i];

    // Process connection...
}
Enter fullscreen mode Exit fullscreen mode

Why is this so much faster?
Because _to and _relation are dense, contiguous int arrays. As we loop through i, the CPU's pre-fetcher easily predicts our access pattern. It loads the cache lines ahead of time. By the time our loop needs _to[i+1], it is already sitting in the blazing fast L1 cache.

The Secret Weapon: SIMD

But cache lines are only the beginning. Once your data is sitting in a flat primitive array, you unlock the real superpower of modern .NET: SIMD (Single Instruction, Multiple Data).

You cannot pass an array of UserNode objects into an AVX-512 vector register to evaluate them simultaneously. But you can load 8 consecutive integers from that _relation array into a Vector256<int> and evaluate them all in a single CPU clock cycle.

When you align your memory this way, C# allows you to drop right down to the metal. This is exactly how Glacier.Chrono hits over 2 Billion operations per second without breaking a sweat.

The Bottom Line

Object-Oriented Programming is fantastic for UI components and high-level business logic. But when you drop down into the engine room—when you are building dataframes, graph databases, or processing millions of records a second—you have to think like the CPU.

Flatten your objects. Separate your properties into contiguous arrays. Design for cache hits, and your code will run orders of magnitude faster.

Top comments (0)