Benchmark realtime QUIC against legacy RUDP
This commit is contained in:
parent
5e1633698e
commit
899aea980f
3 changed files with 234 additions and 8 deletions
|
|
@ -84,3 +84,22 @@ reached 10.7 MiB/s while emitting 18,112,867 wire bytes. CultMesh TCP includes
|
|||
chunk hashing, durable checkpoints, final hashing, and atomic promotion, so this
|
||||
is not a claim of raw protocol parity. It is a repeatable workload comparison
|
||||
and leaves the remaining framework overhead visible.
|
||||
|
||||
The same harness now has a state-only workload. On 2026-07-19, 1,000 latest-only
|
||||
frames with 16 KiB payloads produced the following loopback result:
|
||||
|
||||
| Plane | Offered rate | Delivered | Highest generation | Settled time |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| CultMesh QUIC latest-only | 23.8 MiB/s | 989 / 1,000 | 999 | 655.4 ms |
|
||||
| CultNet RUDP latest | 45.6 MiB/s | 373 / 1,000 | 516 | 602.7 ms |
|
||||
|
||||
Offered rate measures publisher-side enqueue/write completion, not successful
|
||||
delivery. RUDP accepted bytes faster but failed to deliver the terminal
|
||||
generation after a 250 ms drain; that is a delivery failure, not useful
|
||||
throughput. QUIC's reliable independent streams reached the terminal generation
|
||||
while the receiver coalesced eleven superseded frames. Reproduce the workload
|
||||
with:
|
||||
|
||||
```text
|
||||
dotnet run --project tools/GameCult.TransportParity -- --state-only --state-bytes 16384 --state-frames 1000
|
||||
```
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
<ProjectReference Include="..\..\src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Net;
|
||||
using System.Net.Quic;
|
||||
|
|
@ -9,6 +10,7 @@ using System.Security.Cryptography;
|
|||
using System.Security.Cryptography.X509Certificates;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Mesh.Quic;
|
||||
using GameCult.Networking;
|
||||
|
||||
var payloadBytes = ReadPayloadBytes(args);
|
||||
|
|
@ -17,17 +19,29 @@ new Random(0xAE7E).NextBytes(payload);
|
|||
var expectedHash = Convert.ToHexString(SHA256.HashData(payload));
|
||||
|
||||
Console.WriteLine($"payloadBytes={payloadBytes} quicSupported={QuicListener.IsSupported}");
|
||||
Print(MeasureMappedFile(payload));
|
||||
var tcp = await MeasureTcpAsync(payload, expectedHash);
|
||||
Print(tcp);
|
||||
Print(await MeasureCultMeshTcpAsync(payload));
|
||||
if (!args.Contains("--state-only", StringComparer.Ordinal))
|
||||
{
|
||||
Print(MeasureMappedFile(payload));
|
||||
var tcp = await MeasureTcpAsync(payload, expectedHash);
|
||||
Print(tcp);
|
||||
Print(await MeasureCultMeshTcpAsync(payload));
|
||||
|
||||
if (QuicListener.IsSupported)
|
||||
Print(await MeasureQuicAsync(payload, expectedHash));
|
||||
else
|
||||
Console.WriteLine("plane=quic status=unsupported");
|
||||
|
||||
Print(await MeasureCultMeshRudpAsync(payload));
|
||||
}
|
||||
|
||||
var statePayloadBytes = ReadPositiveOption(args, "--state-bytes", 16 * 1024);
|
||||
var stateFrameCount = ReadPositiveOption(args, "--state-frames", 1_000);
|
||||
Console.WriteLine($"statePayloadBytes={statePayloadBytes} stateFrames={stateFrameCount}");
|
||||
if (QuicListener.IsSupported)
|
||||
Print(await MeasureQuicAsync(payload, expectedHash));
|
||||
PrintState(await MeasureCultMeshQuicStateAsync(statePayloadBytes, stateFrameCount));
|
||||
else
|
||||
Console.WriteLine("plane=quic status=unsupported");
|
||||
|
||||
Print(await MeasureCultMeshRudpAsync(payload));
|
||||
Console.WriteLine("plane=cultmesh-quic-latest status=unsupported");
|
||||
PrintState(await MeasureCultNetRudpLatestStateAsync(statePayloadBytes, stateFrameCount));
|
||||
|
||||
static int ReadPayloadBytes(string[] args)
|
||||
{
|
||||
|
|
@ -39,12 +53,29 @@ static int ReadPayloadBytes(string[] args)
|
|||
return bytes;
|
||||
}
|
||||
|
||||
static int ReadPositiveOption(string[] args, string option, int defaultValue)
|
||||
{
|
||||
var index = Array.IndexOf(args, option);
|
||||
if (index < 0) return defaultValue;
|
||||
if (index + 1 >= args.Length || !int.TryParse(args[index + 1], out var value) || value <= 0)
|
||||
throw new ArgumentException($"{option} requires a positive 32-bit integer.");
|
||||
return value;
|
||||
}
|
||||
|
||||
static void Print(Result result) =>
|
||||
Console.WriteLine(
|
||||
$"plane={result.Plane} elapsedMs={result.Elapsed.TotalMilliseconds:F1} " +
|
||||
(result.GoodputMiBps.HasValue ? $"goodputMiBps={result.GoodputMiBps.Value:F1}" : "") +
|
||||
(result.WireBytes.HasValue ? $" wireBytes={result.WireBytes.Value}" : string.Empty));
|
||||
|
||||
static void PrintState(StateResult result) =>
|
||||
Console.WriteLine(
|
||||
$"plane={result.Plane} sendElapsedMs={result.SendElapsed.TotalMilliseconds:F1} " +
|
||||
$"settledElapsedMs={result.SettledElapsed.TotalMilliseconds:F1} " +
|
||||
$"offeredMiBps={result.OfferedMiBps:F1} deliveredFrames={result.DeliveredFrames}/{result.SentFrames} " +
|
||||
$"highestSequence={result.HighestSequence}" +
|
||||
(result.WireBytes.HasValue ? $" wireBytes={result.WireBytes.Value}" : string.Empty));
|
||||
|
||||
static Result MeasureMappedFile(byte[] payload)
|
||||
{
|
||||
var source = TempPath("mapped-source");
|
||||
|
|
@ -152,6 +183,143 @@ static async Task<Result> MeasureQuicAsync(byte[] payload, string expectedHash)
|
|||
return Result.Create("quic-msquic", payload.LongLength, elapsed.Elapsed);
|
||||
}
|
||||
|
||||
static async Task<StateResult> MeasureCultMeshQuicStateAsync(int payloadBytes, int frameCount)
|
||||
{
|
||||
using var certificate = CreateCertificate();
|
||||
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(
|
||||
new CultMeshQuicRealtimeServerOptions
|
||||
{
|
||||
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
||||
ServerCertificate = certificate
|
||||
});
|
||||
var endpoint = $"{CultMeshQuicRealtimeTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}";
|
||||
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
|
||||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
Array.Empty<ICultMeshContentTransportConnector>(),
|
||||
[new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
|
||||
{
|
||||
ValidateProviderCertificate = (_, _, _, _) => true
|
||||
})]);
|
||||
var session = await sessions.ConnectRealtimeAsync(CultMeshEndpointId.Parse("service:parity.provider"));
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (server.ConnectionCount == 0)
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline) throw new TimeoutException("QUIC state client did not connect.");
|
||||
await Task.Delay(1);
|
||||
}
|
||||
|
||||
var receivedFrames = 0;
|
||||
var highestSequence = -1L;
|
||||
using var receiveDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var receive = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var frame = await session.ReceiveAsync(receiveDeadline.Token);
|
||||
receivedFrames++;
|
||||
highestSequence = Math.Max(highestSequence, frame.Sequence);
|
||||
if (frame.ProducerEpoch == 1 && frame.Sequence == frameCount - 1) return;
|
||||
}
|
||||
});
|
||||
var payload = new byte[payloadBytes];
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
for (var sequence = 0; sequence < frameCount; sequence++)
|
||||
{
|
||||
await server.BroadcastAsync(new CultMeshRealtimeFrame
|
||||
{
|
||||
ChannelId = "parity.entities",
|
||||
SchemaId = "parity.entity_soa.v1",
|
||||
BodyId = "body:parity:entities",
|
||||
ProducerEpoch = 1,
|
||||
Sequence = sequence,
|
||||
Delivery = CultMeshRealtimeDelivery.LatestOnly,
|
||||
Payload = payload
|
||||
});
|
||||
}
|
||||
var sendElapsed = elapsed.Elapsed;
|
||||
await receive;
|
||||
elapsed.Stop();
|
||||
return StateResult.Create(
|
||||
"cultmesh-quic-latest",
|
||||
payloadBytes,
|
||||
frameCount,
|
||||
receivedFrames,
|
||||
highestSequence,
|
||||
sendElapsed,
|
||||
elapsed.Elapsed);
|
||||
}
|
||||
|
||||
static async Task<StateResult> MeasureCultNetRudpLatestStateAsync(int payloadBytes, int frameCount)
|
||||
{
|
||||
using var serverSocket = BindUdpSocket();
|
||||
using var clientSocket = BindUdpSocket();
|
||||
const uint connectionId = 0x50415249;
|
||||
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "parity-rudp-server",
|
||||
Socket = serverSocket,
|
||||
Mode = CultNetRudpSocketMode.Server,
|
||||
ConnectionId = connectionId,
|
||||
InitialSequence = 100,
|
||||
MaxFragmentBytes = 1024
|
||||
});
|
||||
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "parity-rudp-client",
|
||||
Socket = clientSocket,
|
||||
Mode = CultNetRudpSocketMode.Client,
|
||||
RemoteEndPoint = serverSocket.LocalEndPoint,
|
||||
ConnectionId = connectionId,
|
||||
InitialSequence = 1,
|
||||
MaxFragmentBytes = 1024
|
||||
});
|
||||
client.Connect("parity");
|
||||
var handshakeDeadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!client.Connected || !server.Connected)
|
||||
{
|
||||
server.ReceiveOnce();
|
||||
client.ReceiveOnce();
|
||||
if (DateTime.UtcNow >= handshakeDeadline) throw new TimeoutException("RUDP state peers did not connect.");
|
||||
}
|
||||
|
||||
var receivedFrames = 0;
|
||||
var highestSequence = -1L;
|
||||
using var receiveDeadline = new CancellationTokenSource();
|
||||
var receive = Task.Run(() =>
|
||||
{
|
||||
while (!receiveDeadline.IsCancellationRequested)
|
||||
{
|
||||
var frame = server.ReceiveOnce();
|
||||
if (frame == null) continue;
|
||||
receivedFrames++;
|
||||
highestSequence = Math.Max(highestSequence, BinaryPrimitives.ReadInt64LittleEndian(frame.Payload));
|
||||
}
|
||||
});
|
||||
var payload = new byte[Math.Max(payloadBytes, sizeof(long))];
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
for (var sequence = 0; sequence < frameCount; sequence++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt64LittleEndian(payload, sequence);
|
||||
client.SendLatest(payload);
|
||||
}
|
||||
var sendElapsed = elapsed.Elapsed;
|
||||
await Task.Delay(250);
|
||||
receiveDeadline.Cancel();
|
||||
await receive;
|
||||
elapsed.Stop();
|
||||
return StateResult.Create(
|
||||
"cultnet-rudp-latest",
|
||||
payload.Length,
|
||||
frameCount,
|
||||
receivedFrames,
|
||||
highestSequence,
|
||||
sendElapsed,
|
||||
elapsed.Elapsed,
|
||||
client.Stats.BytesSent);
|
||||
}
|
||||
|
||||
static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
|
||||
{
|
||||
var artifact = CultMesh.PackCdnArtifact(
|
||||
|
|
@ -274,6 +442,14 @@ static X509Certificate2 CreateCertificate()
|
|||
static string TempPath(string plane) =>
|
||||
Path.Combine(Path.GetTempPath(), $"cultmesh-parity-{plane}-{Guid.NewGuid():N}.body");
|
||||
|
||||
static Socket BindUdpSocket()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
socket.ReceiveTimeout = 20;
|
||||
return socket;
|
||||
}
|
||||
|
||||
static void VerifyAndDelete(string path, string expectedHash)
|
||||
{
|
||||
try
|
||||
|
|
@ -317,3 +493,33 @@ internal sealed record Result(string Plane, TimeSpan Elapsed, double? GoodputMiB
|
|||
public static Result CreateOpen(string plane, TimeSpan elapsed) =>
|
||||
new(plane, elapsed, null, null);
|
||||
}
|
||||
|
||||
internal sealed record StateResult(
|
||||
string Plane,
|
||||
int SentFrames,
|
||||
int DeliveredFrames,
|
||||
long HighestSequence,
|
||||
TimeSpan SendElapsed,
|
||||
TimeSpan SettledElapsed,
|
||||
double OfferedMiBps,
|
||||
long? WireBytes)
|
||||
{
|
||||
public static StateResult Create(
|
||||
string plane,
|
||||
int payloadBytes,
|
||||
int sentFrames,
|
||||
int deliveredFrames,
|
||||
long highestSequence,
|
||||
TimeSpan sendElapsed,
|
||||
TimeSpan settledElapsed,
|
||||
long? wireBytes = null) =>
|
||||
new(
|
||||
plane,
|
||||
sentFrames,
|
||||
deliveredFrames,
|
||||
highestSequence,
|
||||
sendElapsed,
|
||||
settledElapsed,
|
||||
(long)payloadBytes * sentFrames / 1024d / 1024d / sendElapsed.TotalSeconds,
|
||||
wireBytes);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue