Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
AArnott marked this conversation as resolved.
* 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/)
Expand Down
3 changes: 2 additions & 1 deletion docfx/docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ Note when targeting .NET, using serialization overloads that neither <xref:PolyT
## Comparison to MessagePack-CSharp

Perf isn't everything, but it can be important in some scenarios.
Nerdbank.MessagePack is very fast, but not quite as fast as MessagePack-CSharp v3 with source generation turned on.
Nerdbank.MessagePack and MessagePack-CSharp v3 with source generation are in the same range for steady-state throughput.
In the small-object benchmarks below, MessagePack-CSharp is slightly faster for map-encoded round trips, while Nerdbank.MessagePack is faster for array-encoded round trips.

Features and ease of use are also important.
Nerdbank.MessagePack is much simpler to use, and comes [loaded with features](features.md#feature-comparison) that MessagePack-CSharp does not have.
Expand Down
18 changes: 10 additions & 8 deletions docfx/includes/perf.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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.
Expand All @@ -19,26 +21,26 @@ Some libraries are absent from some comparisons because they don't support a par
```mermaid
xychart-beta
x-axis "Libraries" ["NB.MessagePack", "MsgPack-CS", "STJ", "Newtonsoft"]
y-axis "Time (ns)" 0 --> 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
Expand Down
35 changes: 29 additions & 6 deletions src/Nerdbank.MessagePack/Converters/DictionaryConverter`3.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,14 +56,21 @@ public override void Write(ref MessagePackWriter writer, in TDictionary? value,
bool writingKey = true;
try
{
foreach (KeyValuePair<TKey, TValue> pair in dictionary)
if (dictionary is Dictionary<TKey, TValue> concreteDictionary)
Comment thread
AArnott marked this conversation as resolved.
{
entryKey = pair.Key;
writingKey = true;
keyConverter.Write(ref writer, entryKey, context);
this.WriteDictionary(ref writer, concreteDictionary, context, ref entryKey, ref writingKey);
}
else
{
foreach (KeyValuePair<TKey, TValue> 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))
Expand Down Expand Up @@ -333,6 +341,21 @@ private protected static string CreateReadKeyFailMessage()
/// <returns>The exception message.</returns>
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<TKey, TValue> dictionary, SerializationContext context, ref TKey? entryKey, ref bool writingKey)
{
foreach (KeyValuePair<TKey, TValue> pair in dictionary)
{
entryKey = pair.Key;
writingKey = true;
keyConverter.Write(ref writer, entryKey, context);

writingKey = false;
valueConverter.Write(ref writer, pair.Value, context);
}
}
}

/// <summary>
Expand Down
29 changes: 28 additions & 1 deletion src/Nerdbank.MessagePack/Converters/EnumerableConverter`2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -111,6 +112,7 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, TEnume
}

/// <inheritdoc/>
#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)
Expand All @@ -126,7 +128,11 @@ public override void Write(ref MessagePackWriter writer, in TEnumerable? value,

context.DepthStep();
IEnumerable<TElement> enumerable = getEnumerable(value);
if (PolyfillExtensions.TryGetNonEnumeratedCount(enumerable, out int count))
if (enumerable is List<TElement> list)
{
this.WriteList(ref writer, list, context);
}
else if (PolyfillExtensions.TryGetNonEnumeratedCount(enumerable, out int count))
{
writer.WriteArrayHeader(count);
int index = 0;
Expand Down Expand Up @@ -161,6 +167,7 @@ public override void Write(ref MessagePackWriter writer, in TEnumerable? value,
}
}
}
#pragma warning restore NBMsgPack031

/// <inheritdoc/>
public override JsonObject? GetJsonSchema(JsonSchemaContext context, ITypeShape typeShape)
Expand Down Expand Up @@ -256,6 +263,26 @@ public override bool SkipToIndexValue(ref MessagePackReader reader, object? inde
/// <returns>The element.</returns>
protected ValueTask<TElement> 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<TElement> 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);
}
}
}

/// <summary>
Expand Down
39 changes: 36 additions & 3 deletions src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,34 @@ public static DecodeResult TryRead(ReadOnlySpan<byte> 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
{
Expand Down Expand Up @@ -349,6 +374,14 @@ public static unsafe DecodeResult TryRead(ReadOnlySpan<byte> source, out Double
}
}

[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static DecodeResult TryReadInt32Fallback(ReadOnlySpan<byte> 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
Expand Down
39 changes: 39 additions & 0 deletions src/Nerdbank.MessagePack/MessagePackPrimitives.Readers.Integers.tt
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -117,6 +148,14 @@ foreach (var floatType in floatingPointTypes) { #>
}
<# } #>

[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static DecodeResult TryReadInt32Fallback(ReadOnlySpan<byte> 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" }) { #>

Expand Down
26 changes: 15 additions & 11 deletions src/Nerdbank.MessagePack/MessagePackPrimitives.Writers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,18 @@ public static bool TryWrite(Span<byte> destination, short value, out int bytesWr
/// </remarks>
public static bool TryWrite(Span<byte> destination, int value, out int bytesWritten)
{
if (unchecked((uint)(value - MessagePackRange.MinFixNegativeInt)) <= MessagePackRange.MaxFixPositiveInt - MessagePackRange.MinFixNegativeInt)
{
return TryWriteFixIntCodeUnsafe(destination, unchecked((byte)value), out bytesWritten);
}
Comment thread
AArnott marked this conversation as resolved.
Comment thread
AArnott marked this conversation as resolved.

if (value >= 0)
{
return TryWrite(destination, unchecked((uint)value), out bytesWritten);
}

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);
Expand Down Expand Up @@ -424,7 +428,7 @@ public static bool TryWrite(Span<byte> 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);
}
Expand Down Expand Up @@ -452,7 +456,7 @@ public static bool TryWrite(Span<byte> 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:
Expand Down Expand Up @@ -483,7 +487,7 @@ public static bool TryWrite(Span<byte> 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:
Expand Down Expand Up @@ -516,7 +520,7 @@ public static bool TryWrite(Span<byte> 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);
Expand Down Expand Up @@ -1065,18 +1069,18 @@ public static bool TryWriteExtensionHeader(Span<byte> destination, ExtensionHead
}

/// <summary>
/// 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 <see cref="MessagePackCode.MaxFixInt"/>.
/// Writes a fixint code into one byte of msgpack data.
/// This method does *not* ensure that <paramref name="value"/> is a fixint code.
/// The caller must ensure that the value is a positive or negative fixint code.
/// </summary>
/// <param name="destination">The buffer to write to. This should be at least 5 bytes in length to ensure success.</param>
/// <param name="value">The single-precision floating-point value to write.</param>
/// <param name="destination">The buffer to write to. This should be at least 1 byte in length to ensure success.</param>
/// <param name="value">The MessagePack fixint code to write.</param>
/// <param name="bytesWritten">The number of bytes required to write the value, whether successful or not.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="destination"/> was large enough and the value written; otherwise, <see langword="false" />.
/// When <see langword="false"/>, the value of <paramref name="bytesWritten"/> indicates how many bytes are required to write the value successfully.
/// </returns>
private static bool TryWriteFixIntUnsafe(Span<byte> destination, byte value, out int bytesWritten)
private static bool TryWriteFixIntCodeUnsafe(Span<byte> destination, byte value, out int bytesWritten)
{
ref byte destinationRef = ref MemoryMarshal.GetReference(destination);
bytesWritten = 1;
Expand Down
Loading
Loading