Files
natsdotnet/tests/NATS.Server.Benchmark.Tests/Mqtt/MqttThroughputTests.cs
Joseph Doherty 11e01b9026 perf: optimize MQTT cross-protocol path (0.30x → 0.78x Go)
Replace per-message async fire-and-forget with direct-buffer write loop
mirroring NatsClient pattern: SpinLock-guarded buffer append, double-
buffer swap, single WriteAsync per batch.

- MqttConnection: add _directBuf/_writeBuf + RunMqttWriteLoopAsync
- MqttConnection: add EnqueuePublishNoFlush (zero-alloc PUBLISH format)
- MqttPacketWriter: add WritePublishTo(Span<byte>) + MeasurePublish
- MqttTopicMapper: add NatsToMqttBytes with bounded ConcurrentDictionary
- MqttNatsClientAdapter: synchronous SendMessageNoFlush + SignalFlush
- Skip FlushAsync on plain TCP sockets (TCP auto-flushes)
2026-03-13 14:25:13 -04:00

185 lines
6.4 KiB
C#

using MQTTnet;
using MQTTnet.Client;
using NATS.Client.Core;
using NATS.Server.Benchmark.Tests.Harness;
using NATS.Server.Benchmark.Tests.Infrastructure;
using Xunit.Abstractions;
namespace NATS.Server.Benchmark.Tests.Mqtt;
[Collection("Benchmark-Mqtt")]
public class MqttThroughputTests(MqttServerFixture fixture, ITestOutputHelper output)
{
[Fact]
[Trait("Category", "Benchmark")]
public async Task MqttPubSub_128B()
{
const int payloadSize = 128;
const int messageCount = 5_000;
var dotnetResult = await RunMqttPubSub("MQTT PubSub (128B)", "DotNet", fixture.DotNetMqttPort, payloadSize, messageCount);
if (fixture.GoAvailable)
{
var goResult = await RunMqttPubSub("MQTT PubSub (128B)", "Go", fixture.GoMqttPort, payloadSize, messageCount);
BenchmarkResultWriter.WriteComparison(output, goResult, dotnetResult);
}
else
{
BenchmarkResultWriter.WriteSingle(output, dotnetResult);
}
}
[Fact]
[Trait("Category", "Benchmark")]
public async Task MqttCrossProtocol_NatsPub_MqttSub_128B()
{
const int payloadSize = 128;
const int messageCount = 5_000;
var dotnetResult = await RunCrossProtocol("Cross-Protocol NATS→MQTT (128B)", "DotNet", fixture.DotNetMqttPort, fixture.CreateDotNetNatsClient, payloadSize, messageCount);
if (fixture.GoAvailable)
{
var goResult = await RunCrossProtocol("Cross-Protocol NATS→MQTT (128B)", "Go", fixture.GoMqttPort, fixture.CreateGoNatsClient, payloadSize, messageCount);
BenchmarkResultWriter.WriteComparison(output, goResult, dotnetResult);
}
else
{
BenchmarkResultWriter.WriteSingle(output, dotnetResult);
}
}
private static async Task<BenchmarkResult> RunMqttPubSub(string name, string serverType, int mqttPort, int payloadSize, int messageCount)
{
var payload = new byte[payloadSize];
var topic = $"bench/mqtt/pubsub/{Guid.NewGuid():N}";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var factory = new MqttFactory();
using var subscriber = factory.CreateMqttClient();
using var publisher = factory.CreateMqttClient();
var subOpts = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", mqttPort)
.WithClientId($"bench-sub-{Guid.NewGuid():N}")
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V311)
.Build();
var pubOpts = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", mqttPort)
.WithClientId($"bench-pub-{Guid.NewGuid():N}")
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V311)
.Build();
await subscriber.ConnectAsync(subOpts, cts.Token);
await publisher.ConnectAsync(pubOpts, cts.Token);
var received = 0;
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
subscriber.ApplicationMessageReceivedAsync += _ =>
{
if (Interlocked.Increment(ref received) >= messageCount)
tcs.TrySetResult();
return Task.CompletedTask;
};
await subscriber.SubscribeAsync(
factory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(topic)
.Build(),
cts.Token);
await Task.Delay(200, cts.Token);
var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < messageCount; i++)
{
await publisher.PublishAsync(
new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(payload)
.Build(),
cts.Token);
}
await tcs.Task.WaitAsync(cts.Token);
sw.Stop();
await subscriber.DisconnectAsync(cancellationToken: cts.Token);
await publisher.DisconnectAsync(cancellationToken: cts.Token);
return new BenchmarkResult
{
Name = name,
ServerType = serverType,
TotalMessages = messageCount,
TotalBytes = (long)messageCount * payloadSize,
Duration = sw.Elapsed,
};
}
private static async Task<BenchmarkResult> RunCrossProtocol(string name, string serverType, int mqttPort, Func<NatsConnection> createNatsClient, int payloadSize, int messageCount)
{
var payload = new byte[payloadSize];
var natsSubject = $"bench.mqtt.cross.{Guid.NewGuid():N}";
var mqttTopic = natsSubject.Replace('.', '/');
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var factory = new MqttFactory();
using var mqttSub = factory.CreateMqttClient();
var subOpts = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", mqttPort)
.WithClientId($"bench-cross-sub-{Guid.NewGuid():N}")
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V311)
.Build();
await mqttSub.ConnectAsync(subOpts, cts.Token);
var received = 0;
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
mqttSub.ApplicationMessageReceivedAsync += _ =>
{
if (Interlocked.Increment(ref received) >= messageCount)
tcs.TrySetResult();
return Task.CompletedTask;
};
await mqttSub.SubscribeAsync(
factory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(mqttTopic)
.Build(),
cts.Token);
await Task.Delay(200, cts.Token);
await using var natsPub = createNatsClient();
await natsPub.ConnectAsync();
await natsPub.PingAsync(cts.Token);
var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < messageCount; i++)
await natsPub.PublishAsync(natsSubject, payload, cancellationToken: cts.Token);
await natsPub.PingAsync(cts.Token);
await tcs.Task.WaitAsync(cts.Token);
sw.Stop();
await mqttSub.DisconnectAsync(cancellationToken: cts.Token);
return new BenchmarkResult
{
Name = name,
ServerType = serverType,
TotalMessages = messageCount,
TotalBytes = (long)messageCount * payloadSize,
Duration = sw.Elapsed,
};
}
}