DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

I Clamped Two Million Readings and Nothing Happened

A job of mine ingests sensor readings, and one cleanup step clamps anything above 500 back down to 500. Garden-variety stuff. I wrote the loop, the tests I cared about stayed green, and the "reading over threshold" alert kept firing in staging anyway. Values above 500 were sailing straight through. The loop was right there in front of me, clearly clamping every element. Except it clamped none of them.

Here's the shape of it. The reading is a struct, because there are millions of them and I didn't want a couple million tiny objects churning through the heap:

struct Reading
{
    public long Value;
    // ...a pile of metadata fields I rarely touch

    public void Clamp()
    {
        if (Value > 500) Value = 500;
    }
}
Enter fullscreen mode Exit fullscreen mode

And the loop:

foreach (var r in readings) // readings is a List<Reading>
    r.Clamp();
Enter fullscreen mode Exit fullscreen mode

Compiles. Runs. Clamps nothing.

foreach hands you a copy

When you foreach over a List<Reading>, each r is a fresh copy of the element, because Reading is a value type. Clamp() faithfully clamps that copy, and then the copy falls out of scope at the bottom of the loop body and takes the change with it. The list never hears about it. The compiler stays quiet, because calling a method on a local copy is an ordinary thing to do. It only looks wrong once you know the method mutates this.

I proved it to myself with the smallest harness I could manage. Two million readings with values 0 to 1023, sum the field before and after, and watch whether the number moves:

before             : sum 1022942656
after foreach copy : sum 1022942656  (UNCHANGED)
Enter fullscreen mode Exit fullscreen mode

Not off by a little. Identical. Two million method calls, every one of them working on a doomed copy.

The part that stung is that C# will actually stop you if you reach for the obvious mutations directly. Both of these are compile errors, not runtime surprises:

foreach (var r in readings) r.Value = 500; // CS1654: iteration variable is readonly
readings[0].Value = 500;                    // CS1612: indexer return is not a variable
Enter fullscreen mode Exit fullscreen mode

CS1612 is the telling one. List<T>'s indexer returns the element by value, so readings[0] is a throwaway copy, and assigning to a field of a throwaway that's about to vanish means nothing. The compiler says as much. The mutating-method version slips past only because the method call wraps that same copy in a friendlier shape.

CollectionsMarshal.AsSpan gives you the real element

CollectionsMarshal.AsSpan returns a Span<Reading> over the list's actual backing array. A span indexes by reference, so there's no copy sitting between you and the storage:

using System.Runtime.InteropServices;

foreach (ref var r in CollectionsMarshal.AsSpan(readings))
    r.Clamp();
Enter fullscreen mode Exit fullscreen mode
after AsSpan clamp : sum 755330878  (changed)
Enter fullscreen mode Exit fullscreen mode

The number moved. That's the whole fix. ref var r binds to the element where it lives, Clamp() mutates that element, and the list keeps it.

It isn't faster, and I'm fine with that

I half-expected a speed win too. Skipping two million 120-byte struct copies sounds like it ought to show up somewhere. So I measured the read side, just summing the field, which is identical work either way. Best-of-50, on a shared cloud container where I trust the ratio a lot more than the absolute milliseconds:

foreach copy   :  15.3 ms/pass
foreach AsSpan :  14.5 ms/pass
copy/span      :  about 1.1x
Enter fullscreen mode Exit fullscreen mode

It bounced between a dead tie and maybe ten percent across runs. Summing is memory-bound: you're streaming the whole backing array out of RAM no matter how you walk it, and the register-sized copy into a temporary is lost in that noise. So I won't sell you AsSpan as a read-speed trick. It isn't one here. The reason to reach for it is that it edits the real element, not that it shaves microseconds.

When I'd leave it alone

The span from AsSpan is a live window onto the backing array, and it goes stale the instant the list resizes. If you Add, Remove, or Clear while you're still holding the span, you're reading the old array or slicing out of bounds. So take the span, do your pass, and let it go. Don't stash it in a field, and don't change the list's length underneath it.

And honestly, if you find yourself needing any of this, that's worth a pause on its own. A List<struct> you mutate in place is a design that fights the language a bit, and sometimes the cleaner answer is a class, or rebuilding elements instead of editing them. But when you've measured, you genuinely want the value type, and you need to edit in place, this beats the read-copy-write-back-by-index dance that people usually fall into when they hit CS1612.

My rule of thumb now: if a method mutates a struct and I'm calling it inside a foreach over a collection, I stop and ask what exactly I'm mutating. More often than I'd like, the answer is "a copy nobody keeps."

Here's the complete thing if you want to run it yourself. New console project on the .NET 10 SDK, drop this in Program.cs, dotnet run -c Release:

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

struct Reading
{
    public long Value;
    public long F1, F2, F3, F4, F5, F6, F7;
    public long F8, F9, F10, F11, F12, F13, F14; // ~120 bytes total

    public Reading(long value) => Value = value;

    public void Clamp()
    {
        if (Value > 500) Value = 500;
    }
}

static class Program
{
    const int Count = 2_000_000;
    const int Repeats = 50;

    static List<Reading> BuildReadings()
    {
        var list = new List<Reading>(Count);
        for (int i = 0; i < Count; i++)
            list.Add(new Reading(i & 1023));
        return list;
    }

    static long Sum(List<Reading> readings)
    {
        long sum = 0;
        foreach (ref readonly var r in CollectionsMarshal.AsSpan(readings))
            sum += r.Value;
        return sum;
    }

    static void MutationDemo()
    {
        var a = BuildReadings();
        long before = Sum(a);

        foreach (var r in a)   // r is a copy; this clamps nothing
            r.Clamp();

        Console.WriteLine($"before             : sum {before}");
        Console.WriteLine($"after foreach copy : sum {Sum(a)}  ({(Sum(a) == before ? "UNCHANGED" : "changed")})");

        // These two don't even compile, for the record:
        //   foreach (var r in a) r.Value = 500; // CS1654
        //   a[0].Value = 500;                    // CS1612

        var b = BuildReadings();
        long beforeB = Sum(b);

        foreach (ref var r in CollectionsMarshal.AsSpan(b)) // ref into the backing array
            r.Clamp();

        Console.WriteLine($"after AsSpan clamp : sum {Sum(b)}  ({(Sum(b) == beforeB ? "UNCHANGED" : "changed")})");
    }

    static long SumForeachCopy(List<Reading> readings)
    {
        long sum = 0;
        foreach (var r in readings)
            sum += r.Value;
        return sum;
    }

    static (double ms, long bytes) Measure(Func<List<Reading>, long> f, List<Reading> data, long expect)
    {
        for (int i = 0; i < 5; i++) _ = f(data); // warm the JIT

        long bestTicks = long.MaxValue, check = 0;
        long allocBefore = GC.GetAllocatedBytesForCurrentThread();
        var sw = new Stopwatch();
        for (int i = 0; i < Repeats; i++)
        {
            sw.Restart();
            check = f(data);
            sw.Stop();
            if (sw.ElapsedTicks < bestTicks) bestTicks = sw.ElapsedTicks;
        }
        long allocAfter = GC.GetAllocatedBytesForCurrentThread();
        if (check != expect) throw new Exception("checksum mismatch - benchmark is lying");
        return (bestTicks * 1000.0 / Stopwatch.Frequency, (allocAfter - allocBefore) / Repeats);
    }

    static void Main()
    {
        Console.WriteLine($"{RuntimeInformation.FrameworkDescription}, struct {Marshal.SizeOf<Reading>()} bytes, {Count:N0} elements, best-of-{Repeats}.\n");
        MutationDemo();

        var data = BuildReadings();
        long expect = Sum(data);
        var copy = Measure(SumForeachCopy, data, expect);
        var span = Measure(Sum, data, expect);
        Console.WriteLine($"\nforeach copy   : {copy.ms,7:F3} ms/pass");
        Console.WriteLine($"foreach AsSpan : {span.ms,7:F3} ms/pass");
        Console.WriteLine($"copy/span      : {copy.ms / span.ms:F2}x");
    }
}
Enter fullscreen mode Exit fullscreen mode

Ever shipped a loop that was technically running and practically doing nothing? I'd like to hear what tipped you off.

— still benchmarking the things nobody asked me to clamp

Top comments (0)