Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

My Struct Update Copied 64 Bytes Twice

I had a physics step that walked a List<Body> every frame and nudged each body's position by its velocity. Simple loop, nothing exotic. And the profiler kept pointing at it with more weight than three lines of double add

I had a physics step that walked a List<Body> every frame and nudged each body's position by its velocity. Simple loop, nothing exotic. And the profiler kept pointing at it with more weight than three lines of double addition had any right to carry. The arithmetic wasn't the problem. The copies were.

Here's the thing I'd forgotten. When you index into a List<T> of structs, you don't get the element. You get a copy of it. So this doesn't even compile:

world[i].X += world[i].Vx;

The compiler stops you cold:

error CS1612: Cannot modify the return value of 'List<Body>.this[int]'
because it is not a variable

Which is actually the compiler being honest. If it let you write that, you'd be mutating a throwaway copy and the real element would sit there unchanged, and you'd spend an afternoon wondering why your bodies never move. So instead most of us learn the dance: read the element into a local, change the local, write it back.

for (int i = 0; i < world.Count; i++)
{
    var b = world[i];   // copy the whole struct out
    b.X += b.Vx;
    b.Y += b.Vy;
    b.Z += b.Vz;
    world[i] = b;        // copy the whole struct back in
}

That works. It's also quietly copying 64 bytes out and 64 bytes back for every single body, every single tick, when all I wanted to touch was three fields. For a Body that's position, velocity, a mass, and an id, call it eight-ish doubles, the copy is bigger than the actual work.

The fix is a span over the backing array

CollectionsMarshal.AsSpan hands you the list's internal array as a Span<Body>. And a span lets you take a ref to its elements, so you can loop with foreach (ref var ...) and mutate the real thing in place:

foreach (ref var b in CollectionsMarshal.AsSpan(world))
{
    b.X += b.Vx;
    b.Y += b.Vy;
    b.Z += b.Vz;
}

No copy out. No copy back. b is a reference straight into the list's storage, so writing b.X writes the element. Same result, and the loop reads better too — the whole read-mutate-writeback ceremony just evaporates.

What it actually bought me

I didn't want to guess, so I ran both against two million bodies, forty ticks per pass, best of seven with the JIT already warm. This is a two-core container, not a lab bench, so I care about the ratio, not the absolute milliseconds.

Struct size: 64 bytes
2,000,000 bodies, 40 ticks per measured pass, best of 7

Indexer copy-back : 260.4 ms
AsSpan ref mutate : 183.3 ms
Ratio             :   1.42x
Results match     : True

Across runs it landed between about 1.2x and 1.4x faster, clustering near a third quicker. That's not a headline number. Nobody's going to put "1.3x" on a conference slide. But it's a steady third off a hot loop for deleting code, not adding it, and the faster version is the one I'd rather read six months from now. I'll take that trade every time.

One thing worth saying plainly: the win comes from the copies you stop making, so it scales with how fat your struct is. A Body at 64 bytes shows a clear gap. A struct with two ints would show almost nothing, because copying eight bytes twice is basically free and the span version has nothing to save. If you measure this on a small struct and see a wash, that's not the technique failing. There was just nothing there to win.

Where I wouldn't reach for it

AsSpan gives you a window into the list's live backing array, and that's exactly as sharp as it sounds. Don't add to or remove from the list while you're holding the span — any resize can reallocate the array and your span is now pointing at the old one. Same reason you don't hold the span across an await or hand it to code that might mutate the collection. It's a tight, synchronous "I'm going to rip through these elements right now" tool, and when you color inside those lines it's safe and great. Step outside them and you've got a bug that only shows up when the list happens to grow.

And honestly, for a list of ten things that you touch once, don't bother. The indexer copy-back is perfectly readable and the copies cost nothing at that size. This earns its keep when the struct is chunky, the list is long, and the loop runs often. That was my physics step exactly, which is why it was lit up in the profiler in the first place.

The other quiet benefit I didn't expect: once the mutation happens through a ref, there's no "oops I forgot the writeback" bug class anymore. I've shipped that bug. You read the element, change it, and forget the world[i] = b line, and now your update silently does nothing. With foreach (ref var b in ...) that whole failure mode is gone, because there's nothing to forget.

Full runnable sample is below — drop it in a net10.0 console project and dotnet run -c Release. GitHub's being uncooperative with me today so I'm pasting the whole thing inline instead of linking it:

using System.Diagnostics;
using System.Runtime.InteropServices;

struct Body
{
    public double X, Y, Z;     // position
    public double Vx, Vy, Vz;  // velocity
    public double Mass;
    public long Id;            // ~64 bytes
}

static class Program
{
    const int Count = 2_000_000, Ticks = 40, Runs = 7;

    static List<Body> MakeWorld()
    {
        var list = new List<Body>(Count);
        var rng = new Random(1234);
        for (int i = 0; i < Count; i++)
            list.Add(new Body
            {
                X = rng.NextDouble(), Y = rng.NextDouble(), Z = rng.NextDouble(),
                Vx = rng.NextDouble() - 0.5, Vy = rng.NextDouble() - 0.5, Vz = rng.NextDouble() - 0.5,
                Mass = rng.NextDouble() + 0.1, Id = i
            });
        return list;
    }

    static void StepByIndexer(List<Body> w)
    {
        for (int t = 0; t < Ticks; t++)
            for (int i = 0; i < w.Count; i++)
            {
                var b = w[i];
                b.X += b.Vx; b.Y += b.Vy; b.Z += b.Vz;
                w[i] = b;
            }
    }

    static void StepBySpan(List<Body> w)
    {
        for (int t = 0; t < Ticks; t++)
            foreach (ref var b in CollectionsMarshal.AsSpan(w))
            {
                b.X += b.Vx; b.Y += b.Vy; b.Z += b.Vz;
            }
    }

    static double BestMs(Action<List<Body>> step, List<Body> w)
    {
        double best = double.MaxValue;
        for (int r = 0; r < Runs; r++)
        {
            var sw = Stopwatch.StartNew();
            step(w);
            best = Math.Min(best, sw.Elapsed.TotalMilliseconds);
        }
        return best;
    }

    static void Main()
    {
        Console.WriteLine($"Struct size: {Marshal.SizeOf<Body>()} bytes");
        var a = MakeWorld(); var b = MakeWorld();
        StepByIndexer(a); StepBySpan(b);          // warm the JIT
        a = MakeWorld(); b = MakeWorld();
        double idx = BestMs(StepByIndexer, a);
        double spn = BestMs(StepBySpan, b);
        Console.WriteLine($"Indexer copy-back : {idx,7:F1} ms");
        Console.WriteLine($"AsSpan ref mutate : {spn,7:F1} ms");
        Console.WriteLine($"Ratio             : {idx / spn,7:F2}x");
    }
}

Anyone else have a loop that got faster just by deleting the copy nobody noticed? I'd love to hear where yours was hiding.

— still benchmarking the loops nobody asked me to profile

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.