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);
}
}