using System.Diagnostics; using System.Text.Json; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; using MessagePack; using R3; var quick = args.Contains("--quick", StringComparer.OrdinalIgnoreCase); var idleDuration = quick ? TimeSpan.FromMilliseconds(250) : TimeSpan.FromSeconds(10); var activeDuration = quick ? TimeSpan.FromMilliseconds(500) : TimeSpan.FromSeconds(10); var documentCounts = quick ? new[] { 1, 100, 1_000 } : new[] { 1, 100, 1_000 }; var results = new List(); foreach (var documentCount in documentCounts) results.Add(await MeasureAsync(documentCount, idleDuration, activeDuration)); Console.WriteLine(JsonSerializer.Serialize(new { workload = new { documentCounts, payloadBytes = 16 * 1024, idleSeconds = idleDuration.TotalSeconds, activeSeconds = activeDuration.TotalSeconds, updateRateHz = 60, changedFraction = 0.01 }, results }, new JsonSerializerOptions { WriteIndented = true })); return results.All(result => result.ActualPublishes == result.ExpectedPublishes && result.PayloadBytesPublished > 0 && result.AllocationToPayloadRatio < 10 && result.P99PublishLatencyMilliseconds < 250) ? 0 : 1; static async Task MeasureAsync( int documentCount, TimeSpan idleDuration, TimeSpan activeDuration) { var payload = new string('x', 16 * 1024); var changedDocumentCount = Math.Max(1, documentCount / 100); var subjects = new Subject[documentCount]; var reactive = new CultMeshReactiveDocument[documentCount]; var publishSignals = new TaskCompletionSource?[documentCount]; var updateStartedAt = new long[documentCount]; var latencies = new List(); var measurementClock = Stopwatch.StartNew(); long payloadBytesPublished = 0; var publishes = 0; for (var index = 0; index < documentCount; index++) { var documentIndex = index; var current = new PerformanceDocument { Id = "performance:" + index, Payload = payload, Revision = 1 }; var subject = new Subject(); subjects[index] = subject; var handle = CultMesh.Document( "performance:" + index, CultMesh.Verse("performance", "probe"), _ => Task.FromResult(current), _ => subject, value => { Interlocked.Add( ref payloadBytesPublished, CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(PerformanceDocument)).Length); lock (latencies) { latencies.Add(Stopwatch.GetElapsedTime(updateStartedAt[documentIndex]).TotalMilliseconds); } Interlocked.Increment(ref publishes); current = value; subject.OnNext(value); publishSignals[documentIndex]?.TrySetResult(true); return Task.CompletedTask; }); reactive[index] = await handle.AuthoritativeWriter().ReactiveAsync( new CultMeshReactiveDocumentOptions { FlushDelay = TimeSpan.Zero }); } try { ForceCollection(); var process = Process.GetCurrentProcess(); var idleAllocatedStart = GC.GetTotalAllocatedBytes(precise: true); var idleCpuStart = process.TotalProcessorTime; var idleThreadsStart = process.Threads.Count; await Task.Delay(idleDuration); process.Refresh(); var idleAllocatedBytes = GC.GetTotalAllocatedBytes(precise: true) - idleAllocatedStart; var idleCpu = process.TotalProcessorTime - idleCpuStart; var idleThreadsEnd = process.Threads.Count; ForceCollection(); var activeAllocatedStart = GC.GetTotalAllocatedBytes(precise: true); var activeCpuStart = process.TotalProcessorTime; var activeThreadsStart = process.Threads.Count; var activeClock = Stopwatch.StartNew(); var frameInterval = TimeSpan.FromSeconds(1.0 / 60.0); var frames = 0; while (activeClock.Elapsed < activeDuration) { var completions = new Task[changedDocumentCount]; for (var index = 0; index < changedDocumentCount; index++) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); publishSignals[index] = completion; updateStartedAt[index] = Stopwatch.GetTimestamp(); reactive[index].Update(document => document.Revision++); completions[index] = completion.Task; } await Task.WhenAll(completions); frames++; var nextFrameAt = frameInterval * frames; var remaining = nextFrameAt - activeClock.Elapsed; if (remaining > TimeSpan.Zero) await Task.Delay(remaining); } process.Refresh(); var activeAllocatedBytes = GC.GetTotalAllocatedBytes(precise: true) - activeAllocatedStart; var activeCpu = process.TotalProcessorTime - activeCpuStart; var measuredActiveSeconds = activeClock.Elapsed.TotalSeconds; var activeThreadsEnd = process.Threads.Count; double[] orderedLatencies; lock (latencies) orderedLatencies = latencies.OrderBy(value => value).ToArray(); return new ProbeResult( documentCount, changedDocumentCount, frames, frames * changedDocumentCount, publishes, payloadBytesPublished, idleAllocatedBytes, idleCpu.TotalMilliseconds, idleThreadsStart, idleThreadsEnd, activeAllocatedBytes, activeCpu.TotalMilliseconds, activeThreadsStart, activeThreadsEnd, activeAllocatedBytes / measuredActiveSeconds, payloadBytesPublished / measuredActiveSeconds, (double)activeAllocatedBytes / payloadBytesPublished, Percentile(orderedLatencies, 0.50), Percentile(orderedLatencies, 0.95), Percentile(orderedLatencies, 0.99), measurementClock.Elapsed.TotalSeconds); } finally { foreach (var document in reactive) document.Dispose(); foreach (var subject in subjects) subject.Dispose(); } } static double Percentile(double[] ordered, double percentile) { if (ordered.Length == 0) return 0; var index = (int)Math.Ceiling(percentile * ordered.Length) - 1; return ordered[Math.Clamp(index, 0, ordered.Length - 1)]; } static void ForceCollection() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } internal sealed record ProbeResult( int DocumentCount, int ChangedDocumentCount, int Frames, int ExpectedPublishes, int ActualPublishes, long PayloadBytesPublished, long IdleAllocatedBytes, double IdleCpuMilliseconds, int IdleThreadsStart, int IdleThreadsEnd, long ActiveAllocatedBytes, double ActiveCpuMilliseconds, int ActiveThreadsStart, int ActiveThreadsEnd, double ActiveAllocatedBytesPerSecond, double PublishedPayloadBytesPerSecond, double AllocationToPayloadRatio, double P50PublishLatencyMilliseconds, double P95PublishLatencyMilliseconds, double P99PublishLatencyMilliseconds, double ScenarioWallSeconds); [CultDocument("gamecult.mesh.performance_probe", "gamecult.mesh.performance_probe.v1")] [MessagePackObject] public sealed class PerformanceDocument { [Key(0), CultName] public string Id { get; set; } = string.Empty; [Key(1)] public string Payload { get; set; } = string.Empty; [Key(2)] public int Revision { get; set; } }