From f3cfd2cc7f275c4038e6aa1d0f950440d1a203e1 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 11:47:32 -0600 Subject: [PATCH 1/7] Expand benchmarks and add AI perf optimization instructions --- AGENTS.md | 12 ++ test/Benchmarks/IntegerPrimitives.cs | 134 ++++++++++++++ test/Benchmarks/LargeDataModelBenchmark.cs | 205 +++++++++++++++++++++ test/Benchmarks/SimplePoco.cs | 31 ++++ 4 files changed, 382 insertions(+) create mode 100644 test/Benchmarks/IntegerPrimitives.cs create mode 100644 test/Benchmarks/LargeDataModelBenchmark.cs diff --git a/AGENTS.md b/AGENTS.md index 6f5af52b..14d8dcbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,18 @@ dotnet run --no-build -c Release ``` Should start web server without errors (web UI testing limited in this environment). +## Performance optimization + +* Establish a BenchmarkDotNet baseline before changing a hot path. For primitive integer encoding and decoding, run: + ```bash + dotnet run --project test/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -- --filter *IntegerPrimitives* --job short + ``` +* Keep benchmark input distributions explicit and reproducible. `Small`, `Mixed`, and `Large` integer datasets exercise distinct MessagePack encodings and branch-prediction behavior; do not replace them with a single representative input. +* For branch-sensitive work, use sufficiently large randomized datasets so a branch predictor cannot learn a short repeating sequence. Preserve the fixed random seed unless intentionally changing the workload. +* Review allocation, generated assembly, branch instructions, and branch mispredictions alongside elapsed time. Hardware counters require an elevated Windows process; an unavailable counter is not evidence of zero misses. +* Benchmark changes measure behavior; they do not prove correctness. Verify all MessagePack encoding boundaries and error behavior with the relevant tests before accepting an optimization. +* Prefer narrowly targeted candidates and retain a simple, verified baseline until benchmark results and generated assembly demonstrate a repeatable improvement for the intended distributions. + ## Repository Structure ### Key Projects (src/) diff --git a/test/Benchmarks/IntegerPrimitives.cs b/test/Benchmarks/IntegerPrimitives.cs new file mode 100644 index 00000000..e1b054c8 --- /dev/null +++ b/test/Benchmarks/IntegerPrimitives.cs @@ -0,0 +1,134 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET + +using BenchmarkDotNet.Diagnosers; + +[MemoryDiagnoser] +[DisassemblyDiagnoser(maxDepth: 3)] +[HardwareCounters(HardwareCounter.BranchInstructions, HardwareCounter.BranchMispredictions)] +public class IntegerPrimitives +{ + // This must exceed branch predictor history so the mixed distribution remains unpredictable. + private const int Count = 100_000; + + private int[] values = null!; + private byte[] encodedValues = null!; + private byte[] writeBuffer = null!; + + [Params("Small", "Mixed", "Large")] + public string Distribution { get; set; } = "Mixed"; + + [GlobalSetup] + public void Setup() + { + this.values = CreateValues(this.Distribution); + this.writeBuffer = new byte[(Count * sizeof(int)) + Count]; + this.encodedValues = EncodeValues(this.values); + ValidateEncodedValues(this.values, this.encodedValues); + } + + [Benchmark(OperationsPerInvoke = Count)] + [BenchmarkCategory("integer", "write")] + public int WriteInt32() + { + int checksum = 0; + int offset = 0; + foreach (int value in this.values) + { + MessagePackPrimitives.TryWrite(this.writeBuffer.AsSpan(offset), value, out int tokenSize); + offset += tokenSize; + checksum += tokenSize; + } + + return checksum; + } + + [Benchmark(OperationsPerInvoke = Count)] + [BenchmarkCategory("integer", "read")] + public int ReadInt32() + { + int checksum = 0; + int offset = 0; + for (int i = 0; i < this.values.Length; i++) + { + MessagePackPrimitives.TryRead(this.encodedValues.AsSpan(offset), out int decodedValue, out int tokenSize); + offset += tokenSize; + checksum += decodedValue; + } + + return checksum; + } + + private static int[] CreateValues(string distribution) + { + Random random = new(42); + int[] result = new int[Count]; + for (int i = 0; i < result.Length; i++) + { + result[i] = distribution switch + { + "Small" => random.Next(MessagePackRange.MinFixNegativeInt, MessagePackCode.MaxFixInt + 1), + "Large" => random.Next(2) == 0 ? random.Next(ushort.MaxValue + 1, int.MaxValue) : random.Next(int.MinValue, short.MinValue), + "Mixed" => CreateMixedValue(random), + _ => throw new ArgumentOutOfRangeException(nameof(distribution), distribution, "Unsupported integer distribution."), + }; + } + + return result; + } + + private static int CreateMixedValue(Random random) + { + return random.Next(8) switch + { + 0 => random.Next(0, MessagePackCode.MaxFixInt + 1), + 1 => random.Next(MessagePackRange.MinFixNegativeInt, 0), + 2 => random.Next(MessagePackCode.MaxFixInt + 1, byte.MaxValue + 1), + 3 => random.Next(sbyte.MinValue, MessagePackRange.MinFixNegativeInt), + 4 => random.Next(byte.MaxValue + 1, ushort.MaxValue + 1), + 5 => random.Next(short.MinValue, sbyte.MinValue), + 6 => random.Next(ushort.MaxValue + 1, int.MaxValue), + _ => random.Next(int.MinValue, short.MinValue), + }; + } + + private static byte[] EncodeValues(int[] source) + { + byte[] destination = new byte[source.Length * (sizeof(int) + 1)]; + int offset = 0; + foreach (int value in source) + { + if (!MessagePackPrimitives.TryWrite(destination.AsSpan(offset), value, out int tokenSize)) + { + throw new InvalidOperationException("The benchmark buffer must be large enough for every integer."); + } + + offset += tokenSize; + } + + return destination.AsSpan(0, offset).ToArray(); + } + + private static void ValidateEncodedValues(int[] expectedValues, byte[] encodedValues) + { + int offset = 0; + foreach (int expectedValue in expectedValues) + { + if (MessagePackPrimitives.TryRead(encodedValues.AsSpan(offset), out int actualValue, out int tokenSize) != MessagePackPrimitives.DecodeResult.Success || actualValue != expectedValue) + { + throw new InvalidOperationException("The benchmark data did not round-trip through the primitive integer codec."); + } + + offset += tokenSize; + } + + if (offset != encodedValues.Length) + { + throw new InvalidOperationException("The benchmark data contains trailing bytes."); + } + } +} + +#endif diff --git a/test/Benchmarks/LargeDataModelBenchmark.cs b/test/Benchmarks/LargeDataModelBenchmark.cs new file mode 100644 index 00000000..8446f5f7 --- /dev/null +++ b/test/Benchmarks/LargeDataModelBenchmark.cs @@ -0,0 +1,205 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET + +using Benchmarks.DataModels; + +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkDotNet.Configs.BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class LargeDataModelBenchmark +{ + private static readonly MessagePackSerializer Serializer = new(); + private static readonly LargeDataModel Value = CreateValue(); + private static readonly byte[] SerializedValue = Serializer.Serialize(Value); + private readonly ArrayBufferWriter buffer = new(); + + [Benchmark] + [BenchmarkCategory("large-data-model", "Serialize")] + public void Serialize() + { + Serializer.Serialize(this.buffer, Value); + this.buffer.Clear(); + } + + [Benchmark] + [BenchmarkCategory("large-data-model", "Deserialize")] + public LargeDataModel? Deserialize() => Serializer.Deserialize(SerializedValue); + + private static LargeDataModel CreateValue() + { + DerivedRecord1 baseRecord = new() + { + Id = 1, + Name = "base record", + Description = "derived record", + Numbers = [1, 127, 65_536], + }; + ComplexRecord complexRecord = CreateComplexRecord(baseRecord); + DerivedClass1 baseClass = new(2, "derived class") + { + BaseName = "base class", + DerivedProp = "derived class", + Records = [complexRecord], + }; + AnotherRecord anotherRecord = new() + { + Id = 3, + Classes = [baseClass], + }; + + return new LargeDataModel + { + BaseRec = baseRecord, + BaseCls = baseClass, + CompRec = complexRecord, + CompRecStruct = CreateComplexRecordStruct(baseRecord), + LargeRec = CreateLargeRecord(baseRecord, baseClass, anotherRecord), + LargeRecStruct = CreateLargeRecordStruct(baseRecord, baseClass, anotherRecord), + AnotherRecs = [anotherRecord], + AnotherRecStructs = [new AnotherRecordStruct { Name = "record struct", Type = Enum1.Value2 }], + EnumVal1 = Enum1.Value3, + EnumVal2 = Enum2.Z, + EnumVal3 = Enum3.Large, + EnumVal4 = Enum4.Option30, + EnumVal5 = Enum5.Third, + EnumVal6 = Enum6.Item5, + TinyEnumVal = TinyEnum.Yes, + MediumEnumVal = MediumEnum.Delta, + LargeEnumVal = LargeEnum.Value50, + }; + } + + private static ComplexRecord CreateComplexRecord(BaseRecord baseRecord) + { + return new ComplexRecord + { + Prop1 = 42, + Prop2 = "complex record", + Prop3 = true, + Prop4 = Math.PI, + Prop5 = [new SimpleRecord { Value = 5 }], + Prop6 = Enum1.Value2, + Prop7 = new RecordStruct1 { X = 7, Y = "record struct" }, + Prop8 = "required", + Prop9 = 9, + Prop10 = 10.5f, + Prop11 = 11_000_000_000, + Prop12 = 12, + Prop13 = 13, + Prop14 = 'n', + Prop15 = 15.25m, + Prop16 = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Prop17 = new Guid("5D4575C4-20A6-4334-A49D-66339434A92E"), + Prop18 = TimeSpan.FromMinutes(18), + Prop19 = new Uri("https://example.test/complex"), + Prop20 = ["one", "two"], + Prop21 = new Dictionary { ["key"] = 21 }, + Prop22 = Enum2.Q, + Prop23 = new RecordStruct2 { Flag = true, Items = [new RecordStruct1 { X = 23, Y = "nested" }] }, + Prop24 = baseRecord, + }; + } + + private static ComplexRecordStruct CreateComplexRecordStruct(BaseRecord baseRecord) + { + return new ComplexRecordStruct + { + A = 31, + B = "complex struct", + C = true, + D = [new SimpleRecordStruct { Value = 3.25 }], + E = Enum3.Medium, + F = 36, + G = 37.5f, + H = 38_000_000_000, + I = 39, + J = 40, + K = 's', + L = 42.5m, + M = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc), + N = new Guid("A6FBE934-3A03-47E8-966C-118B8D97CB4A"), + O = TimeSpan.FromSeconds(45), + P = new Uri("https://example.test/struct"), + Q = ["three", "four"], + R = new Dictionary { ["struct"] = 48 }, + S = Enum4.Option20, + T = new RecordStruct1 { X = 50, Y = "value" }, + U = baseRecord, + }; + } + + private static LargeRecord CreateLargeRecord(BaseRecord baseRecord, BaseClass baseClass, AnotherRecord anotherRecord) + { + return new LargeRecord + { + Prop1 = 51, + Prop2 = "large record", + Prop3 = true, + Prop4 = 54.5, + Prop5 = [new MediumRecord { A = 55, B = "medium", C = true, D = [new TinyRecord { Value = 56 }], E = Enum5.Second }], + Prop6 = Enum2.P, + Prop7 = new RecordStruct1 { X = 57, Y = "large" }, + Prop8 = "required large", + Prop9 = 59, + Prop10 = 60.5f, + Prop11 = 61_000_000_000, + Prop12 = 62, + Prop13 = 63, + Prop14 = 'l', + Prop15 = 65.5m, + Prop16 = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc), + Prop17 = new Guid("3D6E8DD2-8B00-4ED8-9083-58B3146CA8AB"), + Prop18 = TimeSpan.FromHours(1), + Prop19 = new Uri("https://example.test/large"), + Prop20 = ["five", "six"], + Prop21 = new Dictionary { ["large"] = 69 }, + Prop22 = Enum4.Option10, + Prop23 = new RecordStruct2 { Flag = true, Items = [new RecordStruct1 { X = 71, Y = "item" }] }, + Prop24 = baseRecord, + Prop25 = baseClass, + Prop26 = anotherRecord, + Prop27 = new AnotherRecordStruct { Name = "nested struct", Type = Enum1.Value1 }, + Prop28 = TinyEnum.No, + Prop29 = MediumEnum.Gamma, + Prop30 = [], + }; + } + + private static LargeRecordStruct CreateLargeRecordStruct(BaseRecord baseRecord, BaseClass baseClass, AnotherRecord anotherRecord) + { + return new LargeRecordStruct + { + A = 81, + B = "large struct", + C = true, + D = [new MediumRecordStruct { X = 82.5, Y = [new TinyRecordStruct { Flag = true }], Z = Enum6.Item4 }], + E = Enum3.Small, + F = 86, + G = 87.5f, + H = 88_000_000_000, + I = 89, + J = 90, + K = 't', + L = 92.5m, + M = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc), + N = new Guid("00CFE314-4DC2-4E29-BA71-8478DA3DE2FA"), + O = TimeSpan.FromDays(1), + P = new Uri("https://example.test/large-struct"), + Q = ["seven", "eight"], + R = new Dictionary { ["large struct"] = 98 }, + S = Enum4.Option5, + T = new RecordStruct1 { X = 100, Y = "struct value" }, + U = baseRecord, + V = baseClass, + W = anotherRecord, + X = new AnotherRecordStruct { Name = "deep struct", Type = Enum1.Value3 }, + Y = TinyEnum.Yes, + Z = MediumEnum.Beta, + AA = [], + }; + } +} + +#endif diff --git a/test/Benchmarks/SimplePoco.cs b/test/Benchmarks/SimplePoco.cs index 81fe73a7..b95435d7 100644 --- a/test/Benchmarks/SimplePoco.cs +++ b/test/Benchmarks/SimplePoco.cs @@ -13,6 +13,7 @@ public partial class SimplePoco { private readonly MessagePackSerializer serializer = new() { SerializeDefaultValues = SerializeDefaultValuesPolicy.Always }; + private readonly MessagePackSerializer defaultSerializer = new(); private readonly ArrayBufferWriter buffer = new(); [Benchmark] @@ -45,6 +46,14 @@ public void SerializeMap() this.buffer.Clear(); } + [Benchmark] + [BenchmarkCategory("map-defaults", "Serialize")] + public void SerializeMap_DefaultSettings() + { + this.defaultSerializer.Serialize(this.buffer, Data.PocoMap.Single); + this.buffer.Clear(); + } + [Benchmark(Baseline = true)] [BenchmarkCategory("map", "Serialize")] public void SerializeMap_MsgPackCSharp() @@ -68,6 +77,13 @@ public void DeserializeMap() this.serializer.Deserialize(Data.PocoMap.SingleMsgpack); } + [Benchmark] + [BenchmarkCategory("map-defaults", "Deserialize")] + public void DeserializeMap_DefaultSettings() + { + this.defaultSerializer.Deserialize(Data.PocoMap.SingleMsgpack); + } + [Benchmark(Baseline = true)] [BenchmarkCategory("map", "Deserialize")] public void DeserializeMap_MsgPackCSharp() @@ -91,6 +107,14 @@ public void SerializeAsArray() this.buffer.Clear(); } + [Benchmark] + [BenchmarkCategory("array-defaults", "Serialize")] + public void SerializeAsArray_DefaultSettings() + { + this.defaultSerializer.Serialize(this.buffer, Data.PocoAsArray.Single); + this.buffer.Clear(); + } + [Benchmark(Baseline = true)] [BenchmarkCategory("array", "Serialize")] public void SerializeAsArray_MsgPackCSharp() @@ -106,6 +130,13 @@ public void DeserializeAsArray() this.serializer.Deserialize(Data.PocoAsArray.SingleMsgpack); } + [Benchmark] + [BenchmarkCategory("array-defaults", "Deserialize")] + public void DeserializeAsArray_DefaultSettings() + { + this.defaultSerializer.Deserialize(Data.PocoAsArray.SingleMsgpack); + } + [Benchmark(Baseline = true)] [BenchmarkCategory("array", "Deserialize")] public void DeserializeAsArray_MsgPackCSharp() From 61ae5d67ed1b12ceb9e2d75b83da8a3c00e80855 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 12:19:15 -0600 Subject: [PATCH 2/7] Optimize Int32 fixint encoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs b/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs index 1482e1a2..16fa53da 100644 --- a/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs +++ b/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs @@ -236,6 +236,11 @@ public static bool TryWrite(Span destination, short value, out int bytesWr /// public static bool TryWrite(Span destination, int value, out int bytesWritten) { + if (unchecked((uint)(value - MessagePackRange.MinFixNegativeInt)) <= MessagePackRange.MaxFixPositiveInt - MessagePackRange.MinFixNegativeInt) + { + return TryWriteFixIntUnsafe(destination, unchecked((byte)value), out bytesWritten); + } + if (value >= 0) { return TryWrite(destination, unchecked((uint)value), out bytesWritten); @@ -243,7 +248,6 @@ public static bool TryWrite(Span destination, int value, out int bytesWrit switch (value) { - case >= MessagePackRange.MinFixNegativeInt: return TryWriteNegativeFixIntUnsafe(destination, unchecked((sbyte)value), out bytesWritten); case >= sbyte.MinValue: return TryWriteInt8(destination, unchecked((sbyte)value), out bytesWritten); case >= short.MinValue: return TryWriteInt16(destination, unchecked((short)value), out bytesWritten); default: return TryWriteInt32(destination, value, out bytesWritten); From 9a82e0ae20781ac1ea79ddb7495ffad16cb27aca Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 13:05:14 -0600 Subject: [PATCH 3/7] Optimize Int32 decoding hot paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MessagePackPrimitives.Readers.Integers.cs | 39 ++++++++++++++-- .../MessagePackPrimitives.Readers.Integers.tt | 39 ++++++++++++++++ .../MessagePackPrimitivesTests.cs | 44 +++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs diff --git a/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.cs b/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.cs index d6effc3c..8ad5829b 100644 --- a/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.cs +++ b/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.cs @@ -194,9 +194,34 @@ public static DecodeResult TryRead(ReadOnlySpan source, out Int32 value, o { if (source.Length > 0) { - DecodeResult result = Decoders.Int64JumpTable[source[0]].Read(source, out long longValue, out tokenSize); - value = checked((Int32)longValue); - return result; + // Perf optimized hot-path + byte code = source[0]; + if (unchecked((byte)(code - MessagePackRange.MinFixNegativeInt)) <= MessagePackRange.MaxFixPositiveInt - MessagePackRange.MinFixNegativeInt) + { + tokenSize = 1; + value = unchecked((sbyte)code); + return DecodeResult.Success; + } + + if (code is MessagePackCode.UInt32 or MessagePackCode.Int32) + { + tokenSize = 5; + if (!TryReadBigEndian(source.Slice(1), out uint encodedValue)) + { + value = 0; + return DecodeResult.InsufficientBuffer; + } + + if (code == MessagePackCode.UInt32 && encodedValue > int.MaxValue) + { + throw new OverflowException(); + } + + value = unchecked((int)encodedValue); + return DecodeResult.Success; + } + + return TryReadInt32Fallback(source, code, out value, out tokenSize); } else { @@ -349,6 +374,14 @@ public static unsafe DecodeResult TryRead(ReadOnlySpan source, out Double } } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static DecodeResult TryReadInt32Fallback(ReadOnlySpan source, byte code, out int value, out int tokenSize) + { + DecodeResult result = Decoders.Int64JumpTable[code].Read(source, out long longValue, out tokenSize); + value = checked((int)longValue); + return result; + } + static partial class Decoders { private class ReadInt64Invalid : IReadInt64 diff --git a/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.tt b/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.tt index 39a2635f..5aec3059 100644 --- a/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.tt +++ b/src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.tt @@ -43,9 +43,40 @@ foreach (var intType in allTypes) { { if (source.Length > 0) { +<# if (intType.Name == "Int32") { #> + // Perf optimized hot-path + byte code = source[0]; + if (unchecked((byte)(code - MessagePackRange.MinFixNegativeInt)) <= MessagePackRange.MaxFixPositiveInt - MessagePackRange.MinFixNegativeInt) + { + tokenSize = 1; + value = unchecked((sbyte)code); + return DecodeResult.Success; + } + + if (code is MessagePackCode.UInt32 or MessagePackCode.Int32) + { + tokenSize = 5; + if (!TryReadBigEndian(source.Slice(1), out uint encodedValue)) + { + value = 0; + return DecodeResult.InsufficientBuffer; + } + + if (code == MessagePackCode.UInt32 && encodedValue > int.MaxValue) + { + throw new OverflowException(); + } + + value = unchecked((int)encodedValue); + return DecodeResult.Success; + } + + return TryReadInt32Fallback(source, code, out value, out tokenSize); +<# } else { #> DecodeResult result = Decoders.<#=jumpTable#>[source[0]].Read(source, out <#=bigValueType#> longValue, out tokenSize); value = checked((<#=intType.Name#>)longValue); return result; +<# } #> } else { @@ -117,6 +148,14 @@ foreach (var floatType in floatingPointTypes) { #> } <# } #> + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static DecodeResult TryReadInt32Fallback(ReadOnlySpan source, byte code, out int value, out int tokenSize) + { + DecodeResult result = Decoders.Int64JumpTable[code].Read(source, out long longValue, out tokenSize); + value = checked((int)longValue); + return result; + } + static partial class Decoders {<# foreach (string bigValueType in new[] { "Int64", "UInt64" }) { #> diff --git a/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs b/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs new file mode 100644 index 00000000..f6d506f8 --- /dev/null +++ b/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +public class MessagePackPrimitivesTests +{ + [Theory] + [InlineData(0x00, 0)] + [InlineData(0x7f, 127)] + [InlineData(0xe0, -32)] + [InlineData(0xff, -1)] + public void TryReadInt32FixInt(int code, int expected) + { + Assert.Equal(MessagePackPrimitives.DecodeResult.Success, MessagePackPrimitives.TryRead([(byte)code], out int value, out int tokenSize)); + Assert.Equal(expected, value); + Assert.Equal(1, tokenSize); + } + + [Theory] + [InlineData(MessagePackCode.UInt32, 0x7f, 0xff, 0xff, 0xff, int.MaxValue)] + [InlineData(MessagePackCode.Int32, 0x80, 0x00, 0x00, 0x00, int.MinValue)] + public void TryReadInt32Payload(int code, int byte1, int byte2, int byte3, int byte4, int expected) + { + byte[] encoded = [(byte)code, (byte)byte1, (byte)byte2, (byte)byte3, (byte)byte4]; + Assert.Equal(MessagePackPrimitives.DecodeResult.Success, MessagePackPrimitives.TryRead(encoded, out int value, out int tokenSize)); + Assert.Equal(expected, value); + Assert.Equal(5, tokenSize); + } + + [Fact] + public void TryReadInt32InsufficientPayload() + { + byte[] encoded = [MessagePackCode.Int32, 0x00, 0x00, 0x00]; + Assert.Equal(MessagePackPrimitives.DecodeResult.InsufficientBuffer, MessagePackPrimitives.TryRead(encoded, out int value, out int tokenSize)); + Assert.Equal(0, value); + Assert.Equal(5, tokenSize); + } + + [Fact] + public void TryReadInt32UnsignedOverflow() + { + byte[] encoded = [MessagePackCode.UInt32, 0x80, 0x00, 0x00, 0x00]; + Assert.Throws(() => MessagePackPrimitives.TryRead(encoded, out int _, out int _)); + } +} From cdc74003c22577219487c7c86810d74822799bf2 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 16:26:13 -0600 Subject: [PATCH 4/7] Avoid collection enumeration allocations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Converters/DictionaryConverter`3.cs | 35 +++++++++++++++---- .../Converters/EnumerableConverter`2.cs | 29 ++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/Nerdbank.MessagePack/Converters/DictionaryConverter`3.cs b/src/Nerdbank.MessagePack/Converters/DictionaryConverter`3.cs index 56417af8..d354c37e 100644 --- a/src/Nerdbank.MessagePack/Converters/DictionaryConverter`3.cs +++ b/src/Nerdbank.MessagePack/Converters/DictionaryConverter`3.cs @@ -3,6 +3,7 @@ #pragma warning disable SA1402 // File may only contain a single type +using System.Runtime.CompilerServices; using System.Text.Json.Nodes; namespace Nerdbank.MessagePack.Converters; @@ -55,14 +56,21 @@ public override void Write(ref MessagePackWriter writer, in TDictionary? value, bool writingKey = true; try { - foreach (KeyValuePair pair in dictionary) + if (dictionary is Dictionary concreteDictionary) { - entryKey = pair.Key; - writingKey = true; - keyConverter.Write(ref writer, entryKey, context); + this.WriteDictionary(ref writer, concreteDictionary, context, ref entryKey, ref writingKey); + } + else + { + foreach (KeyValuePair pair in dictionary) + { + entryKey = pair.Key; + writingKey = true; + keyConverter.Write(ref writer, entryKey, context); - writingKey = false; - valueConverter.Write(ref writer, pair.Value, context); + writingKey = false; + valueConverter.Write(ref writer, pair.Value, context); + } } } catch (Exception ex) when (ShouldWrapSerializationException(ex, context.CancellationToken)) @@ -333,6 +341,21 @@ private protected static string CreateReadKeyFailMessage() /// The exception message. private protected static string CreateReadValueFailMessage(in TKey? key) => $"An error occurred while deserializing value for key '{key}' for {typeof(TDictionary).FullName}."; + + // Keep the concrete collection implementation out of the general dictionary path. + [MethodImpl(MethodImplOptions.NoInlining)] + private void WriteDictionary(ref MessagePackWriter writer, Dictionary dictionary, SerializationContext context, ref TKey? entryKey, ref bool writingKey) + { + foreach (KeyValuePair pair in dictionary) + { + entryKey = pair.Key; + writingKey = true; + keyConverter.Write(ref writer, entryKey, context); + + writingKey = false; + valueConverter.Write(ref writer, pair.Value, context); + } + } } /// diff --git a/src/Nerdbank.MessagePack/Converters/EnumerableConverter`2.cs b/src/Nerdbank.MessagePack/Converters/EnumerableConverter`2.cs index 8e8d719e..334faafe 100644 --- a/src/Nerdbank.MessagePack/Converters/EnumerableConverter`2.cs +++ b/src/Nerdbank.MessagePack/Converters/EnumerableConverter`2.cs @@ -3,6 +3,7 @@ #pragma warning disable SA1402 // File may only contain a single type +using System.Runtime.CompilerServices; using System.Text.Json.Nodes; namespace Nerdbank.MessagePack.Converters; @@ -111,6 +112,7 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, TEnume } /// +#pragma warning disable NBMsgPack031 // The concrete collection helper writes exactly one array. public override void Write(ref MessagePackWriter writer, in TEnumerable? value, SerializationContext context) { if (getEnumerable is null) @@ -126,7 +128,11 @@ public override void Write(ref MessagePackWriter writer, in TEnumerable? value, context.DepthStep(); IEnumerable enumerable = getEnumerable(value); - if (PolyfillExtensions.TryGetNonEnumeratedCount(enumerable, out int count)) + if (enumerable is List list) + { + this.WriteList(ref writer, list, context); + } + else if (PolyfillExtensions.TryGetNonEnumeratedCount(enumerable, out int count)) { writer.WriteArrayHeader(count); int index = 0; @@ -161,6 +167,7 @@ public override void Write(ref MessagePackWriter writer, in TEnumerable? value, } } } +#pragma warning restore NBMsgPack031 /// public override JsonObject? GetJsonSchema(JsonSchemaContext context, ITypeShape typeShape) @@ -256,6 +263,26 @@ public override bool SkipToIndexValue(ref MessagePackReader reader, object? inde /// The element. protected ValueTask ReadElementAsync(MessagePackAsyncReader reader, SerializationContext context) => elementConverter.ReadAsync(reader, context)!; + + // Keep the concrete collection implementation out of the general enumerable path. + [MethodImpl(MethodImplOptions.NoInlining)] + private void WriteList(ref MessagePackWriter writer, List list, SerializationContext context) + { + int count = list.Count; + writer.WriteArrayHeader(count); + int i = 0; + try + { + for (; i < count; i++) + { + elementConverter.Write(ref writer, list[i], context); + } + } + catch (Exception ex) when (ShouldWrapSerializationException(ex, context.CancellationToken)) + { + throw new MessagePackSerializationException(CreateFailWritingValueAtIndex(typeof(TElement), i), ex); + } + } } /// From e4587a5c15e9ebcf9b1b5a87039c1a800bb5106c Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 17:10:02 -0600 Subject: [PATCH 5/7] Update perf reports --- docfx/docs/performance.md | 3 ++- docfx/includes/perf.md | 18 ++++++++++-------- test/Benchmarks/update-benchmarks-report.ps1 | 6 ++++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docfx/docs/performance.md b/docfx/docs/performance.md index eeaf8317..041d4b61 100644 --- a/docfx/docs/performance.md +++ b/docfx/docs/performance.md @@ -60,7 +60,8 @@ Note when targeting .NET, using serialization overloads that neither 1000 + y-axis "Time (ns)" 0 --> 600 title "object as map" - bar "Serialize+Deserialize" [282.96,247.46,576.95,919.38] - bar "Serialize" [90.76,84.57,105.17,342.99] + bar "Serialize+Deserialize" [156.08,148.36,281.5,542.6] + bar "Serialize" [57.76,51.74,58.27,207.64] ``` ```mermaid xychart-beta x-axis "Libraries" ["NB.MessagePack", "MsgPack-CS", "STJ", "Newtonsoft"] y-axis "Allocated (bytes)" 0 --> 4200 title "object as map" - bar "Serialize+Deserialize" [80,80,208,4112] - bar "Serialize" [0,0,128,1424] + bar "Serialize+Deserialize" [80,80,216,4120] + bar "Serialize" [0,0,136,1432] ``` ```mermaid xychart-beta x-axis "Libraries" ["NB.MessagePack", "MsgPack-CS"] - y-axis "Time (ns)" 0 --> 300 + y-axis "Time (ns)" 0 --> 200 title "object as array" - bar "Serialize+Deserialize" [210.69,203.24] - bar "Serialize" [88.55,90.09] + bar "Serialize+Deserialize" [101.37,120.51] + bar "Serialize" [38.75,47.01] ``` ```mermaid xychart-beta diff --git a/test/Benchmarks/update-benchmarks-report.ps1 b/test/Benchmarks/update-benchmarks-report.ps1 index f77e3e76..428cdb70 100644 --- a/test/Benchmarks/update-benchmarks-report.ps1 +++ b/test/Benchmarks/update-benchmarks-report.ps1 @@ -6,8 +6,8 @@ It reads the CSV report generated by BenchmarkDotNet and generates a markdown file with the results. The markdown file is used to display the benchmark results in the documentation. - This command should be run *before* running this script: - dotnet run -c release -- -f SimplePoco.* -j short + This command should be run from this script's directory *before* running this script: + dotnet run -c Release -f net10.0 -- --filter "*SimplePoco*" --job medium #> [CmdletBinding()] @@ -107,6 +107,8 @@ MsgPack-CS | MessagePack-CSharp Newtonsoft | Newtonsoft.Json STJ | System.Text.Json +These measurements were produced by the checked-in `SimplePoco` BenchmarkDotNet suite on .NET 10. + ### Object serialization comparisons Each stacked bar shows the time taken to serialize and deserialize an object. From c03e82a2bddb0798c3080ab90442f36ca5a9b8b3 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 17:16:42 -0600 Subject: [PATCH 6/7] Address performance review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- .../MessagePackPrimitives.Writers.cs | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 14d8dcbe..38d9f2ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Should start web server without errors (web UI testing limited in this environme * Establish a BenchmarkDotNet baseline before changing a hot path. For primitive integer encoding and decoding, run: ```bash - dotnet run --project test/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -- --filter *IntegerPrimitives* --job short + dotnet run --project test/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -- --filter "*IntegerPrimitives*" --job short ``` * Keep benchmark input distributions explicit and reproducible. `Small`, `Mixed`, and `Large` integer datasets exercise distinct MessagePack encodings and branch-prediction behavior; do not replace them with a single representative input. * For branch-sensitive work, use sufficiently large randomized datasets so a branch predictor cannot learn a short repeating sequence. Preserve the fixed random seed unless intentionally changing the workload. diff --git a/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs b/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs index 16fa53da..cf8aa142 100644 --- a/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs +++ b/src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs @@ -238,7 +238,7 @@ public static bool TryWrite(Span destination, int value, out int bytesWrit { if (unchecked((uint)(value - MessagePackRange.MinFixNegativeInt)) <= MessagePackRange.MaxFixPositiveInt - MessagePackRange.MinFixNegativeInt) { - return TryWriteFixIntUnsafe(destination, unchecked((byte)value), out bytesWritten); + return TryWriteFixIntCodeUnsafe(destination, unchecked((byte)value), out bytesWritten); } if (value >= 0) @@ -428,7 +428,7 @@ public static bool TryWrite(Span destination, byte value, out int bytesWri switch (value) { case <= MessagePackRange.MaxFixPositiveInt: - return TryWriteFixIntUnsafe(destination, value, out bytesWritten); + return TryWriteFixIntCodeUnsafe(destination, value, out bytesWritten); default: return TryWriteUInt8(destination, value, out bytesWritten); } @@ -456,7 +456,7 @@ public static bool TryWrite(Span destination, ushort value, out int bytesW switch (value) { case <= MessagePackRange.MaxFixPositiveInt: - return TryWriteFixIntUnsafe(destination, unchecked((byte)value), out bytesWritten); + return TryWriteFixIntCodeUnsafe(destination, unchecked((byte)value), out bytesWritten); case <= byte.MaxValue: return TryWriteUInt8(destination, unchecked((byte)value), out bytesWritten); default: @@ -487,7 +487,7 @@ public static bool TryWrite(Span destination, uint value, out int bytesWri switch (value) { case <= MessagePackRange.MaxFixPositiveInt: - return TryWriteFixIntUnsafe(destination, unchecked((byte)value), out bytesWritten); + return TryWriteFixIntCodeUnsafe(destination, unchecked((byte)value), out bytesWritten); case <= byte.MaxValue: return TryWriteUInt8(destination, unchecked((byte)value), out bytesWritten); case <= ushort.MaxValue: @@ -520,7 +520,7 @@ public static bool TryWrite(Span destination, ulong value, out int bytesWr { if (value <= MessagePackRange.MaxFixPositiveInt) { - return TryWriteFixIntUnsafe(destination, unchecked((byte)value), out bytesWritten); + return TryWriteFixIntCodeUnsafe(destination, unchecked((byte)value), out bytesWritten); } return SlowPath(destination, value, out bytesWritten); @@ -1069,18 +1069,18 @@ public static bool TryWriteExtensionHeader(Span destination, ExtensionHead } /// - /// Writes a very small integer into just one byte of msgpack data. - /// This method does *not* ensure that the value is within the range of a fixint. - /// The caller must ensure that the value is less than or equal to . + /// Writes a fixint code into one byte of msgpack data. + /// This method does *not* ensure that is a fixint code. + /// The caller must ensure that the value is a positive or negative fixint code. /// - /// The buffer to write to. This should be at least 5 bytes in length to ensure success. - /// The single-precision floating-point value to write. + /// The buffer to write to. This should be at least 1 byte in length to ensure success. + /// The MessagePack fixint code to write. /// The number of bytes required to write the value, whether successful or not. /// /// if was large enough and the value written; otherwise, . /// When , the value of indicates how many bytes are required to write the value successfully. /// - private static bool TryWriteFixIntUnsafe(Span destination, byte value, out int bytesWritten) + private static bool TryWriteFixIntCodeUnsafe(Span destination, byte value, out int bytesWritten) { ref byte destinationRef = ref MemoryMarshal.GetReference(destination); bytesWritten = 1; From ae6fbe2006329788bb0a70fdb9008c51a17be934 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 24 Jul 2026 17:23:51 -0600 Subject: [PATCH 7/7] Cover fixint write fast path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MessagePackPrimitivesTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs b/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs index f6d506f8..a2c6ee6f 100644 --- a/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs +++ b/test/Nerdbank.MessagePack.Tests/MessagePackPrimitivesTests.cs @@ -3,6 +3,19 @@ public class MessagePackPrimitivesTests { + [Theory] + [InlineData(-32, 0xe0)] + [InlineData(-1, 0xff)] + [InlineData(0, 0x00)] + [InlineData(127, 0x7f)] + public void TryWriteInt32FixInt(int value, int expectedCode) + { + Span encoded = stackalloc byte[1]; + Assert.True(MessagePackPrimitives.TryWrite(encoded, value, out int bytesWritten)); + Assert.Equal((byte)expectedCode, encoded[0]); + Assert.Equal(1, bytesWritten); + } + [Theory] [InlineData(0x00, 0)] [InlineData(0x7f, 127)]