Skip to content

Commit 1eb78fc

Browse files
authored
Add BenchmarkDotNet benchmarks for consume pipeline and serializers (#1893)
* Add BenchmarkDotNet benchmarks for consume pipeline and serializers Add EasyNetQ.Benchmarks project with: - ConsumePipelineBenchmarks: full consume hot path (middleware + deserialization + handler dispatch) with small/medium/large payloads - SerializerBenchmarks: compare SystemTextJson vs V2 vs Newtonsoft serialize/deserialize across payload sizes Also fix AssemblyOriginatorKeyFile to use $(MSBuildThisFileDirectory) so BenchmarkDotNet's auto-generated projects can resolve the SNK path. * Add benchmark results for consume pipeline and serializers
1 parent 65da313 commit 1eb78fc

9 files changed

Lines changed: 376 additions & 2 deletions

Source/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
<IncludeSymbols>true</IncludeSymbols>
1717
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
1818
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
19-
<AssemblyOriginatorKeyFile>..\..\Assets\EasyNetQ.snk</AssemblyOriginatorKeyFile>
19+
<AssemblyOriginatorKeyFile>$(MSBuildThisFileDirectory)..\Assets\EasyNetQ.snk</AssemblyOriginatorKeyFile>
2020
<SignAssembly>true</SignAssembly>
2121
<PublicSign>false</PublicSign>
2222
<NoWarn>$(NoWarn);1591;8002</NoWarn>

Source/Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
<Project>
1+
<Project>
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
55
<NoWarn>$(NoWarn);NU1507</NoWarn>
66
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
77
</PropertyGroup>
88
<ItemGroup>
9+
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
910
<PackageVersion Include="docker.dotnet" Version="3.125.15" />
1011
<PackageVersion Include="EasyNetQ.Management.Client" Version="3.0.1" />
1112
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using BenchmarkDotNet.Attributes;
2+
using EasyNetQ.Consumer;
3+
using EasyNetQ.Serialization.SystemTextJson;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
8+
namespace EasyNetQ.Benchmarks;
9+
10+
[MemoryDiagnoser]
11+
public class ConsumePipelineBenchmarks
12+
{
13+
private ConsumeDelegate consumeDelegate = null!;
14+
private ConsumeContext smallContext;
15+
private ConsumeContext mediumContext;
16+
private ConsumeContext largeContext;
17+
18+
[GlobalSetup]
19+
public void GlobalSetup()
20+
{
21+
var serializer = new SystemTextJsonSerializerV2();
22+
var typeNameSerializer = new DefaultTypeNameSerializer();
23+
var messageSerializationStrategy = new DefaultMessageSerializationStrategy(
24+
typeNameSerializer, serializer, new DefaultCorrelationIdGenerationStrategy()
25+
);
26+
27+
// Minimal DI container matching the real consume pipeline dependencies
28+
var services = new ServiceCollection();
29+
services.AddSingleton<IConsumeErrorStrategy>(SimpleConsumeErrorStrategy.Ack);
30+
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
31+
var provider = services.BuildServiceProvider();
32+
33+
// Handler collection with a no-op typed handler (simulates user handler)
34+
var handlerCollection = new HandlerCollection();
35+
handlerCollection.Add<SmallMessage>((_, _, _) => Task.FromResult(AckStrategies.AckAsync));
36+
handlerCollection.Add<MediumMessage>((_, _, _) => Task.FromResult(AckStrategies.AckAsync));
37+
handlerCollection.Add<LargeMessage>((_, _, _) => Task.FromResult(AckStrategies.AckAsync));
38+
39+
// Build the pipeline as RabbitAdvancedBus does:
40+
// UseConsumeErrorStrategy -> UseConsumeInterceptors -> Deserialize + Dispatch
41+
consumeDelegate = new ConsumePipelineBuilder()
42+
.UseConsumeErrorStrategy()
43+
.UseConsumeInterceptors()
44+
.Use(_ => ctx =>
45+
{
46+
var deserializedMessage = messageSerializationStrategy.DeserializeMessage(ctx.Properties, ctx.Body);
47+
var handler = handlerCollection.GetHandler(deserializedMessage.MessageType);
48+
return new ValueTask<AckStrategyAsync>(handler(deserializedMessage, ctx.ReceivedInfo, ctx.CancellationToken));
49+
})
50+
.Build();
51+
52+
var receivedInfo = new MessageReceivedInfo("consumer", 1UL, false, "exchange", "routing.key", "queue");
53+
54+
smallContext = CreateContext(messageSerializationStrategy, SampleMessages.CreateSmall(), receivedInfo, provider);
55+
mediumContext = CreateContext(messageSerializationStrategy, SampleMessages.CreateMedium(), receivedInfo, provider);
56+
largeContext = CreateContext(messageSerializationStrategy, SampleMessages.CreateLarge(), receivedInfo, provider);
57+
}
58+
59+
private static ConsumeContext CreateContext<T>(
60+
IMessageSerializationStrategy strategy, T message, MessageReceivedInfo receivedInfo, IServiceProvider services)
61+
{
62+
using var serialized = strategy.SerializeMessage(new Message<T>(message));
63+
return new ConsumeContext(receivedInfo, serialized.Properties, serialized.Body.ToArray(), services, CancellationToken.None);
64+
}
65+
66+
[Benchmark]
67+
public ValueTask<AckStrategyAsync> Consume_Small() => consumeDelegate(smallContext);
68+
69+
[Benchmark]
70+
public ValueTask<AckStrategyAsync> Consume_Medium() => consumeDelegate(mediumContext);
71+
72+
[Benchmark]
73+
public ValueTask<AckStrategyAsync> Consume_Large() => consumeDelegate(largeContext);
74+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="BenchmarkDotNet" />
11+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
12+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\EasyNetQ\EasyNetQ.csproj" />
17+
<ProjectReference Include="..\EasyNetQ.Serialization.NewtonsoftJson\EasyNetQ.Serialization.NewtonsoftJson.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using EasyNetQ;
2+
3+
namespace EasyNetQ.Benchmarks;
4+
5+
public class SmallMessage
6+
{
7+
public int Id { get; set; }
8+
public string Name { get; set; } = "";
9+
}
10+
11+
public class MediumMessage
12+
{
13+
public int Id { get; set; }
14+
public string Name { get; set; } = "";
15+
public string Email { get; set; } = "";
16+
public string Description { get; set; } = "";
17+
public DateTime CreatedAt { get; set; }
18+
public List<string> Tags { get; set; } = [];
19+
public Dictionary<string, string> Metadata { get; set; } = [];
20+
}
21+
22+
public class LargeMessage
23+
{
24+
public int Id { get; set; }
25+
public string Name { get; set; } = "";
26+
public string Email { get; set; } = "";
27+
public string Description { get; set; } = "";
28+
public DateTime CreatedAt { get; set; }
29+
public List<string> Tags { get; set; } = [];
30+
public Dictionary<string, string> Metadata { get; set; } = [];
31+
public List<LargeMessageItem> Items { get; set; } = [];
32+
}
33+
34+
public class LargeMessageItem
35+
{
36+
public int Id { get; set; }
37+
public string Title { get; set; } = "";
38+
public decimal Price { get; set; }
39+
public int Quantity { get; set; }
40+
}
41+
42+
[Exchange(Name = "custom-exchange")]
43+
[Queue(Name = "custom-queue")]
44+
public class AttributedMessage
45+
{
46+
public int Id { get; set; }
47+
public string Name { get; set; } = "";
48+
}
49+
50+
public static class SampleMessages
51+
{
52+
public static SmallMessage CreateSmall() => new()
53+
{
54+
Id = 1,
55+
Name = "Test"
56+
};
57+
58+
public static MediumMessage CreateMedium() => new()
59+
{
60+
Id = 42,
61+
Name = "John Doe",
62+
Email = "john@example.com",
63+
Description = "A medium-sized message with several properties for benchmarking purposes.",
64+
CreatedAt = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc),
65+
Tags = ["benchmark", "test", "performance", "easynetq"],
66+
Metadata = new Dictionary<string, string>
67+
{
68+
["source"] = "benchmark",
69+
["version"] = "1.0",
70+
["environment"] = "production"
71+
}
72+
};
73+
74+
public static LargeMessage CreateLarge() => new()
75+
{
76+
Id = 99,
77+
Name = "Jane Smith",
78+
Email = "jane@example.com",
79+
Description = "A large message containing a collection of items for benchmarking serialization performance with realistic payloads.",
80+
CreatedAt = new DateTime(2024, 6, 1, 14, 0, 0, DateTimeKind.Utc),
81+
Tags = ["benchmark", "test", "performance", "easynetq", "large", "payload", "serialization"],
82+
Metadata = new Dictionary<string, string>
83+
{
84+
["source"] = "benchmark",
85+
["version"] = "2.0",
86+
["environment"] = "production",
87+
["region"] = "us-east-1",
88+
["priority"] = "high"
89+
},
90+
Items = Enumerable.Range(1, 50).Select(i => new LargeMessageItem
91+
{
92+
Id = i,
93+
Title = $"Item {i} - Product with a moderately long description for realism",
94+
Price = 9.99m + i,
95+
Quantity = i * 2
96+
}).ToList()
97+
};
98+
99+
public static object Create(string size) => size switch
100+
{
101+
"Small" => CreateSmall(),
102+
"Medium" => CreateMedium(),
103+
"Large" => CreateLarge(),
104+
_ => throw new ArgumentException($"Unknown size: {size}", nameof(size))
105+
};
106+
107+
public static Type GetType(string size) => size switch
108+
{
109+
"Small" => typeof(SmallMessage),
110+
"Medium" => typeof(MediumMessage),
111+
"Large" => typeof(LargeMessage),
112+
_ => throw new ArgumentException($"Unknown size: {size}", nameof(size))
113+
};
114+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using BenchmarkDotNet.Running;
2+
3+
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# EasyNetQ Benchmark Results
2+
3+
``` ini
4+
BenchmarkDotNet v0.14.0, macOS 26.3 (25D125) [Darwin 25.3.0]
5+
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
6+
.NET SDK 10.0.103
7+
[Host] : .NET 8.0.24 (8.0.2426.7010), Arm64 RyuJIT AdvSIMD
8+
DefaultJob : .NET 8.0.24 (8.0.2426.7010), Arm64 RyuJIT AdvSIMD
9+
```
10+
11+
## Consume Pipeline
12+
13+
Full end-to-end consume hot path: error strategy middleware &rarr; interceptor middleware &rarr; deserialization (TypeNameSerializer + SystemTextJsonV2 + MessageFactory) &rarr; handler dispatch.
14+
15+
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
16+
|--------------- |------------:|----------:|----------:|-------:|-------:|----------:|
17+
| Consume_Small | 383.5 ns | 1.67 ns | 1.56 ns | 0.0334 | - | 280 B |
18+
| Consume_Medium | 1,257.8 ns | 8.36 ns | 7.82 ns | 0.2193 | - | 1,848 B |
19+
| Consume_Large | 17,041.9 ns | 112.89 ns | 105.60 ns | 1.6174 | 0.0610 | 13,736 B |
20+
21+
### Payload sizes
22+
23+
- **Small** (2 fields): ~29 bytes JSON
24+
- **Medium** (7 fields incl. list + dictionary): ~300 bytes JSON
25+
- **Large** (7 fields + 50-item collection): ~5 KB JSON
26+
27+
### Key observations
28+
29+
- Small message consume takes **~384 ns** with **280 B** allocated per message
30+
- Cost scales roughly linearly with payload: dominated by JSON deserialization
31+
- Large messages trigger Gen1 GC collections due to 13.7 KB allocations
32+
33+
## Serializer Comparison
34+
35+
Comparing SystemTextJson (v1), SystemTextJson V2 (default), and Newtonsoft.Json across three payload sizes.
36+
37+
| Method | Size | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
38+
|----------------------------- |------- |------------:|---------:|---------:|-------:|-------:|----------:|
39+
| SystemTextJson_Serialize | Small | 151.0 ns | 0.80 ns | 0.75 ns | 0.0238 | - | 200 B |
40+
| SystemTextJsonV2_Serialize | Small | 151.0 ns | 0.68 ns | 0.53 ns | 0.0238 | - | 200 B |
41+
| Newtonsoft_Serialize | Small | 351.8 ns | 1.80 ns | 1.69 ns | 0.3133 | 0.0010 | 2,624 B |
42+
| SystemTextJson_Deserialize | Small | 148.1 ns | 0.38 ns | 0.36 ns | 0.0076 | - | 64 B |
43+
| SystemTextJsonV2_Deserialize | Small | 148.1 ns | 0.49 ns | 0.46 ns | 0.0076 | - | 64 B |
44+
| Newtonsoft_Deserialize | Small | 429.2 ns | 4.72 ns | 4.41 ns | 0.4358 | 0.0019 | 3,648 B |
45+
| SystemTextJson_Serialize | Medium | 600.6 ns | 3.40 ns | 3.18 ns | 0.0610 | - | 512 B |
46+
| SystemTextJsonV2_Serialize | Medium | 609.3 ns | 10.17 ns | 9.52 ns | 0.0610 | - | 512 B |
47+
| Newtonsoft_Serialize | Medium | 1,117.6 ns | 5.61 ns | 5.25 ns | 0.3452 | 0.0019 | 2,896 B |
48+
| SystemTextJson_Deserialize | Medium | 980.3 ns | 6.47 ns | 5.73 ns | 0.1945 | - | 1,632 B |
49+
| SystemTextJsonV2_Deserialize | Medium | 986.6 ns | 8.66 ns | 8.10 ns | 0.1945 | - | 1,632 B |
50+
| Newtonsoft_Deserialize | Medium | 1,790.0 ns | 4.59 ns | 4.07 ns | 0.5798 | 0.0038 | 4,856 B |
51+
| SystemTextJson_Serialize | Large | 9,113.0 ns | 47.82 ns | 44.73 ns | 0.0610 | - | 512 B |
52+
| SystemTextJsonV2_Serialize | Large | 9,205.5 ns | 53.04 ns | 47.02 ns | 0.0610 | - | 512 B |
53+
| Newtonsoft_Serialize | Large | 18,564.7 ns | 48.38 ns | 42.89 ns | 1.2207 | - | 11,640 B |
54+
| SystemTextJson_Deserialize | Large | 16,806.9 ns | 98.04 ns | 81.87 ns | 1.5869 | 0.0610 | 13,520 B |
55+
| SystemTextJsonV2_Deserialize | Large | 16,732.1 ns | 73.65 ns | 68.89 ns | 1.5869 | 0.0610 | 13,520 B |
56+
| Newtonsoft_Deserialize | Large | 31,849.6 ns | 66.68 ns | 62.37 ns | 2.4414 | 0.0610 | 20,424 B |
57+
58+
### Key observations
59+
60+
- **SystemTextJson V1 and V2 perform identically** across all payload sizes
61+
- **Newtonsoft is ~2x slower** than SystemTextJson for both serialization and deserialization
62+
- **Serialization allocations**: SystemTextJson allocates a fixed 200-512 B (pooled buffer) regardless of payload growth; Newtonsoft allocates 2.6-11.6 KB scaling with size
63+
- **Deserialization allocations**: SystemTextJson allocates 64 B - 13.5 KB (dominated by the deserialized object); Newtonsoft adds 3.6-6.9 KB overhead on top
64+
- For small messages, Newtonsoft allocates **57x more** on serialize (2,624 B vs 200 B)
65+
66+
## Running benchmarks
67+
68+
```bash
69+
# Full run
70+
dotnet run --project Source/EasyNetQ.Benchmarks -c Release -- --filter '*'
71+
72+
# Consume pipeline only
73+
dotnet run --project Source/EasyNetQ.Benchmarks -c Release -- --filter '*ConsumePipelineBenchmarks*'
74+
75+
# Serializer comparison only
76+
dotnet run --project Source/EasyNetQ.Benchmarks -c Release -- --filter '*SerializerBenchmarks*'
77+
78+
# Quick smoke test (dry run)
79+
dotnet run --project Source/EasyNetQ.Benchmarks -c Release -- --filter '*' --job dry
80+
```
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using BenchmarkDotNet.Attributes;
2+
using EasyNetQ.Serialization.NewtonsoftJson;
3+
using EasyNetQ.Serialization.SystemTextJson;
4+
5+
namespace EasyNetQ.Benchmarks;
6+
7+
[MemoryDiagnoser]
8+
public class SerializerBenchmarks
9+
{
10+
private ISerializer systemTextJson = null!;
11+
private ISerializer systemTextJsonV2 = null!;
12+
private ISerializer newtonsoft = null!;
13+
14+
private object message = null!;
15+
private Type messageType = null!;
16+
17+
private ReadOnlyMemory<byte> systemTextJsonBytes;
18+
private ReadOnlyMemory<byte> systemTextJsonV2Bytes;
19+
private ReadOnlyMemory<byte> newtonsoftBytes;
20+
21+
[Params("Small", "Medium", "Large")]
22+
public string Size { get; set; } = null!;
23+
24+
[GlobalSetup]
25+
public void GlobalSetup()
26+
{
27+
systemTextJson = new SystemTextJsonSerializer();
28+
systemTextJsonV2 = new SystemTextJsonSerializerV2();
29+
newtonsoft = new NewtonsoftJsonSerializer();
30+
31+
message = SampleMessages.Create(Size);
32+
messageType = SampleMessages.GetType(Size);
33+
34+
using var stj = systemTextJson.MessageToBytes(messageType, message);
35+
systemTextJsonBytes = stj.Memory.ToArray();
36+
37+
using var stj2 = systemTextJsonV2.MessageToBytes(messageType, message);
38+
systemTextJsonV2Bytes = stj2.Memory.ToArray();
39+
40+
using var nj = newtonsoft.MessageToBytes(messageType, message);
41+
newtonsoftBytes = nj.Memory.ToArray();
42+
}
43+
44+
[Benchmark]
45+
public void SystemTextJson_Serialize()
46+
{
47+
using var result = systemTextJson.MessageToBytes(messageType, message);
48+
}
49+
50+
[Benchmark]
51+
public void SystemTextJsonV2_Serialize()
52+
{
53+
using var result = systemTextJsonV2.MessageToBytes(messageType, message);
54+
}
55+
56+
[Benchmark]
57+
public void Newtonsoft_Serialize()
58+
{
59+
using var result = newtonsoft.MessageToBytes(messageType, message);
60+
}
61+
62+
[Benchmark]
63+
public object SystemTextJson_Deserialize()
64+
{
65+
return systemTextJson.BytesToMessage(messageType, systemTextJsonBytes);
66+
}
67+
68+
[Benchmark]
69+
public object SystemTextJsonV2_Deserialize()
70+
{
71+
return systemTextJsonV2.BytesToMessage(messageType, systemTextJsonV2Bytes);
72+
}
73+
74+
[Benchmark]
75+
public object Newtonsoft_Deserialize()
76+
{
77+
return newtonsoft.BytesToMessage(messageType, newtonsoftBytes);
78+
}
79+
}

0 commit comments

Comments
 (0)