Skip to content

Instantly share code, notes, and snippets.

@BastianBlokland
Last active September 10, 2021 12:31
Show Gist options
  • Save BastianBlokland/bc2e3d00c7b16e40be1d16874c429f60 to your computer and use it in GitHub Desktop.
Save BastianBlokland/bc2e3d00c7b16e40be1d16874c429f60 to your computer and use it in GitHub Desktop.
Perf difference of different ways of serializing a integer in dotnet.

Perf difference of different binary serialization methods

Quick benchmark to see the performance difference between the different ways of 'serializing' data to a binary blob in dotnet. For simplicity this only deals with writing 32 bit integers.

Methods this compares:

  • Shifting and assigning the four bytes that make up the integer.
  • Using MemoryMarshal to write the memory.
  • Directly assigning a reference.
  • Directly assigning a pointer.
  • Block copying the entire memory.

Results for writing 500 million integers:

BenchmarkDotNet=v0.11.5, OS=macOS Mojave 10.14.5 (18F203) [Darwin 18.6.0]
Intel Core i9-8950HK CPU 2.90GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.0.100-preview5-011568
  [Host]     : .NET Core 3.0.0-preview5-27626-15 (CoreCLR 4.6.27622.75, CoreFX 4.700.19.22408), 64bit RyuJIT
  Job-ZJUYCG : .NET Core 3.0.0-preview5-27626-15 (CoreCLR 4.6.27622.75, CoreFX 4.700.19.22408), 64bit RyuJIT
Method Mean Error StdDev Ratio
Serialize_ShiftSegments 1,182.9 ms 9.605 ms 8.515 ms 1.00
Serialize_MemoryMarshal 603.0 ms 7.056 ms 6.255 ms 0.51
Serialize_RefAssignment 401.7 ms 5.995 ms 5.006 ms 0.34
Serialize_MemAssign 282.4 ms 2.670 ms 2.084 ms 0.24
Serialize_BlockCopy 252.1 ms 1.526 ms 1.353 ms 0.21

As you can see obviously just copying the entire memory directly is fastest but its interesting to see how little overhead there actually is when you have a loop copy the data instead of a single block copy.

Here's some considerations on which you should use:

  • The shifting of the individual segments works across endianness (in this case would always write little-endian).
  • Both the shifting and MemoryMarshal do proper bounds checking (Which is a very good thing 😅).
  • The pointer assignment requires a unsafe context (even though the other techniques are not really safe either).
  • Because of alignment constraints you can almost never use BlockCopy.

So if you need cross endianness support then shifting is the obvious choice and if not i think the MemoryMarshal approach gives a nice perf boost while staying pretty safe with proper bounds checking.

Some more notes on binary serialization in dotnet: https://gist.github.com/BastianBlokland/f97f832dafa4461f091a6d2851c3e46d

public unsafe class WriteIntBenchmark
{
    private const int DataCount = 500_000_000;
    private readonly int[] integers = new int[DataCount];
    private readonly byte[] byteArray = new byte[DataCount * 4];

    public WriteIntBenchmark()
    {
        // Create a random set of integers.
        var random = new Random(Seed: 42);
        for (int i = 0; i < DataCount; i++)
            this.integers[i] = random.Next();
    }

    [IterationCleanup]
    public void IterationCleanup()
    {
        // Zero out the entire array, just in case it matters.
        Array.Clear(this.byteArray, index: 0, length: this.byteArray.Length);
    }

    [Benchmark(Baseline = true)]
    public void Serialize_ShiftSegments()
    {
        for (int i = 0; i < DataCount; i++)
        {
            var baseOffset = i * 4;
            this.byteArray[baseOffset] = (byte)this.integers[i];
            this.byteArray[baseOffset + 1] = (byte)(this.integers[i] >> 8);
            this.byteArray[baseOffset + 2] = (byte)(this.integers[i] >> 16);
            this.byteArray[baseOffset + 3] = (byte)(this.integers[i] >> 24);
        }
    }

    [Benchmark]
    public void Serialize_MemoryMarshal()
    {
        for (int i = 0; i < DataCount; i++)
            MemoryMarshal.Write(this.byteArray.AsSpan(start: i * 4), ref this.integers[i]);
    }

    [Benchmark]
    public void Serialize_RefAssignment()
    {
        for (int i = 0; i < DataCount; i++)
            Unsafe.As<byte, int>(ref this.byteArray[i * 4]) = this.integers[i];
    }

    [Benchmark]
    public void Serialize_MemAssign()
    {
        fixed (byte* targetBytePointer = this.byteArray)
        {
            int* targetIntPointer = (int*)targetBytePointer;
            for (int i = 0; i < DataCount; i++)
                targetIntPointer[i] = this.integers[i];
        }
    }

    [Benchmark]
    public void Serialize_BlockCopy()
    {
        Unsafe.CopyBlock(
            destination: ref this.byteArray[0],
            source: ref Unsafe.As<int, byte>(ref this.integers[0]),
            byteCount: DataCount * 4);
    }
}

And a test to verify that these all actually do the same thing:

public unsafe class WriteIntBenchmarkTests
{
    [Fact]
    public void AllMethodsGiveSameResults()
    {
        // Create source data.
        var source = new int[100];
        var random = new Random(Seed: 42);
        for (int i = 0; i < source.Length; i++)
            source[i] = random.Next();

        // Create output arrays.
        var shiftSegmentsOutput = new byte[source.Length * 4];
        var memoryMarshalOutput = new byte[source.Length * 4];
        var refAssignOutput = new byte[source.Length * 4];
        var memAssignOutput = new byte[source.Length * 4];
        var blockCopyOutput = new byte[source.Length * 4];

        // Run serialization.
        Serialize_ShiftSegments(source, shiftSegmentsOutput);
        Serialize_MemoryMarshal(source, memoryMarshalOutput);
        Serialize_RefAssignment(source, refAssignOutput);
        Serialize_MemAssign(source, memAssignOutput);
        Serialize_BlockCopy(source, blockCopyOutput);

        // Assert all results equal.
        Assert.True(shiftSegmentsOutput.AsSpan().SequenceEqual(memoryMarshalOutput.AsSpan()));
        Assert.True(shiftSegmentsOutput.AsSpan().SequenceEqual(refAssignOutput.AsSpan()));
        Assert.True(shiftSegmentsOutput.AsSpan().SequenceEqual(memAssignOutput.AsSpan()));
        Assert.True(shiftSegmentsOutput.AsSpan().SequenceEqual(blockCopyOutput.AsSpan()));
    }

    private static void Serialize_ShiftSegments(int[] source, byte[] dest)
    {
        for (int i = 0; i < source.Length; i++)
        {
            var baseOffset = i * 4;
            dest[baseOffset] = (byte)source[i];
            dest[baseOffset + 1] = (byte)(source[i] >> 8);
            dest[baseOffset + 2] = (byte)(source[i] >> 16);
            dest[baseOffset + 3] = (byte)(source[i] >> 24);
        }
    }

    private static void Serialize_MemoryMarshal(int[] source, byte[] dest)
    {
        for (int i = 0; i < source.Length; i++)
        {
            var span = dest.AsSpan(start: i * 4);
            MemoryMarshal.Write(span, ref source[i]);
        }
    }

    private static void Serialize_RefAssignment(int[] source, byte[] dest)
    {
        for (int i = 0; i < source.Length; i++)
            Unsafe.As<byte, int>(ref dest[i * 4]) = source[i];
    }

    private static void Serialize_MemAssign(int[] source, byte[] dest)
    {
        fixed (byte* targetBytePointer = dest)
        {
            int* targetIntPointer = (int*)targetBytePointer;
            for (int i = 0; i < source.Length; i++)
                targetIntPointer[i] = source[i];
        }
    }

    private static void Serialize_BlockCopy(int[] source, byte[] dest)
    {
        Unsafe.CopyBlock(
            destination: ref dest[0],
            source: ref Unsafe.As<int, byte>(ref source[0]),
            byteCount: (uint)source.Length * 4);
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment