diff --git a/README.md b/README.md index 68ee88d6..2d787739 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,18 @@ npm test --workspace packages/cultmesh-ts npm test --workspace packages/cultmesh-browser ``` +Measured reactive, retained-session, and shared-memory hot-body gates: + +```powershell +pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick +pwsh -File ./scripts/verify-eve-getting-started.ps1 +``` + +The first command measures the same 16 KiB document workload in C#, +TypeScript, and Python and includes a mapped hot-body probe with explicit copy +telemetry. The second includes 10,000 real WebSocket operations on one retained +identity-owned C# session after provider route replacement. + Rust package tests: ```powershell diff --git a/src/GameCult.Mesh/CultMeshFrameBodies.cs b/src/GameCult.Mesh/CultMeshFrameBodies.cs index 5c4d26d0..9f8464fd 100644 --- a/src/GameCult.Mesh/CultMeshFrameBodies.cs +++ b/src/GameCult.Mesh/CultMeshFrameBodies.cs @@ -12,6 +12,27 @@ using Microsoft.Win32.SafeHandles; namespace GameCult.Mesh { + /// Reports bounded shared-memory frame publication and explicit copy telemetry. + public sealed class CultMeshFrameBodyPublisherStats + { + internal CultMeshFrameBodyPublisherStats( + ulong publishedFrames, + ulong blockedWrites, + ulong unavoidableCopyCount) + { + PublishedFrames = publishedFrames; + BlockedWrites = blockedWrites; + UnavoidableCopyCount = unavoidableCopyCount; + } + + /// Gets the number of atomically committed generations. + public ulong PublishedFrames { get; } + /// Gets the number of write reservations refused by bounded slots. + public ulong BlockedWrites { get; } + /// Gets copies explicitly declared by copy-fallback publication. + public ulong UnavoidableCopyCount { get; } + } + // Owner: this publisher owns mapping lifetime, logical generation, and slot commit. // Consumers own only read leases; mappings, capabilities, and slot selection remain CultMesh state. public sealed class CultMeshFrameBodyPublisher : IDisposable @@ -34,6 +55,9 @@ namespace GameCult.Mesh private readonly string _token; private int _cursor; private long _nextSequence; + private ulong _publishedFrames; + private ulong _blockedWrites; + private ulong _unavoidableCopyCount; private CultMeshFrameBodyWriteLease? _activeWrite; private bool _disposed; @@ -88,7 +112,7 @@ namespace GameCult.Mesh using (write) { body.CopyTo(write.Span); - descriptor = write.Commit(body.Length, nowUtc); + descriptor = write.Commit(body.Length, nowUtc, unavoidableCopyCount: 1); return true; } } @@ -103,6 +127,7 @@ namespace GameCult.Mesh ThrowIfDisposed(); if (_activeWrite != null) { + _blockedWrites++; lease = null!; return false; } @@ -124,6 +149,7 @@ namespace GameCult.Mesh }); if (slot < 0) { + _blockedWrites++; lease = null!; return false; } @@ -149,7 +175,8 @@ namespace GameCult.Mesh internal CultMeshBodyDescriptor Commit( CultMeshFrameBodyWriteLease lease, int byteLength, - DateTimeOffset nowUtc) + DateTimeOffset nowUtc, + int unavoidableCopyCount) { lock (_gate) { @@ -158,6 +185,8 @@ namespace GameCult.Mesh throw new InvalidOperationException("The CultMesh frame write lease is stale or is not owned by this publisher."); if (byteLength < 0 || byteLength > SlotByteLength) throw new ArgumentOutOfRangeException(nameof(byteLength)); + if (unavoidableCopyCount < 0) + throw new ArgumentOutOfRangeException(nameof(unavoidableCopyCount)); var semanticHash = CultMeshBodyDescriptorValidator.ComputeSemanticHash(lease.Span[..byteLength]); WithMutex(() => { @@ -174,6 +203,8 @@ namespace GameCult.Mesh }); var descriptor = Descriptor(lease.SlotIndex, byteLength, lease.Sequence, semanticHash, nowUtc); _nextSequence++; + _publishedFrames++; + _unavoidableCopyCount += (ulong)Math.Max(0, unavoidableCopyCount); _activeWrite = null; lease.Complete(); return descriptor; @@ -194,6 +225,19 @@ namespace GameCult.Mesh } } + /// Gets current publication, backpressure, and explicit fallback-copy counters. + public CultMeshFrameBodyPublisherStats Stats() + { + lock (_gate) + { + ThrowIfDisposed(); + return new CultMeshFrameBodyPublisherStats( + _publishedFrames, + _blockedWrites, + _unavoidableCopyCount); + } + } + private CultMeshBodyDescriptor Descriptor( int slot, int byteLength, @@ -514,9 +558,12 @@ namespace GameCult.Mesh public Memory Memory => _memory.Memory; public Span Span => Memory.Span; - public CultMeshBodyDescriptor Commit(int byteLength, DateTimeOffset nowUtc) => + public CultMeshBodyDescriptor Commit( + int byteLength, + DateTimeOffset nowUtc, + int unavoidableCopyCount = 0) => (_owner ?? throw new ObjectDisposedException(nameof(CultMeshFrameBodyWriteLease))) - .Commit(this, byteLength, nowUtc); + .Commit(this, byteLength, nowUtc, unavoidableCopyCount); internal void Complete() { diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 174ff84a..e5706595 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -208,6 +208,13 @@ portable machinery that makes it feel native. slices, or browser typed arrays. `CultMesh.DescribeNativeSliceView(...)` exposes the full view id, schema id, row count, columns, route, native handle, and dense stride for tools and adapters. +- `CultMeshFrameBodyPublisher` and `CultMeshMappedFrameBodyCursor` carry + fixed-layout hot bodies through a triple-buffered memory mapping. Producers + reserve a writable slot and commit in place; consumers retain one bootstrap + capability and acquire only newer read leases. `Stats()` reports committed + frames, blocked writes, and unavoidable copies. Direct slot writes report + zero copies; the convenience `TryPublish(ReadOnlySpan)` path reports + one explicit fallback copy instead of laundering it as zero-copy transport. - `CultMeshRouteHint`, `CultMeshRouteRecord`, and `CultMeshLocalityKind` name locality choices without forcing application code to choose transport-specific APIs. `CultMesh.RouteRecord(...)` is the shared way to flatten and rehydrate diff --git a/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs b/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs index 2d69015d..ede0e3c4 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs @@ -30,6 +30,22 @@ public sealed class CultMeshFrameBodyTests lease.ReadByte(0).Should().Be(1); } + [Test] + public void PublisherTelemetryDistinguishesDirectWritesFromCopyFallback() + { + using var publisher = Publisher(); + publisher.TryPublish(new byte[] { 1, 2, 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); + + publisher.TryAcquireWrite(out var direct).Should().BeTrue(); + direct.Span[..3].Fill(7); + direct.Commit(3, DateTimeOffset.UtcNow); + + var stats = publisher.Stats(); + stats.PublishedFrames.Should().Be(2); + stats.BlockedWrites.Should().Be(0); + stats.UnavoidableCopyCount.Should().Be(1); + } + [Test] public void MappedCursorObservesNewGenerationsWithoutNewControlDescriptors() { diff --git a/tools/GameCult.Mesh.PerformanceProbe/Program.cs b/tools/GameCult.Mesh.PerformanceProbe/Program.cs index 948d8e90..0a367d36 100644 --- a/tools/GameCult.Mesh.PerformanceProbe/Program.cs +++ b/tools/GameCult.Mesh.PerformanceProbe/Program.cs @@ -14,6 +14,7 @@ var results = new List(); foreach (var documentCount in documentCounts) results.Add(await MeasureAsync(documentCount, idleDuration, activeDuration)); +var hotBody = await MeasureHotBodyAsync(activeDuration); Console.WriteLine(JsonSerializer.Serialize(new { @@ -26,17 +27,98 @@ Console.WriteLine(JsonSerializer.Serialize(new updateRateHz = 60, changedFraction = 0.01 }, - results + results, + hotBody }, new JsonSerializerOptions { WriteIndented = true })); return results.All(result => result.ActualPublishes == result.ExpectedPublishes && result.PayloadBytesPublished > 0 && result.AllocationToPayloadRatio < 10 && - result.P99PublishLatencyMilliseconds < 250) + result.P99PublishLatencyMilliseconds < 250) && + hotBody.ActualFrames == hotBody.ExpectedFrames && + hotBody.UnavoidableCopyCount == 0 && + hotBody.BlockedWrites == 0 && + hotBody.P99RoundTripMilliseconds < 250 ? 0 : 1; +static async Task MeasureHotBodyAsync(TimeSpan activeDuration) +{ + const int payloadBytes = 16 * 1024; + using var publisher = new CultMeshFrameBodyPublisher( + "performance:hot-body", + "gamecult.mesh.performance_hot_body.v1", + layoutVersion: 1, + capacity: payloadBytes, + producerEpoch: 1, + slotByteLength: payloadBytes, + leaseDuration: TimeSpan.FromMinutes(1)); + if (!publisher.TryAcquireWrite(out var bootstrapWrite)) + throw new InvalidOperationException("Hot-body probe could not reserve its bootstrap slot."); + CultMeshBodyDescriptor bootstrap; + using (bootstrapWrite) + { + bootstrapWrite.Span[..payloadBytes].Fill(1); + bootstrap = bootstrapWrite.Commit(payloadBytes, DateTimeOffset.UtcNow); + } + using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); + if (!cursor.TryAcquireLatest(out var bootstrapRead)) + throw new InvalidOperationException("Hot-body probe could not acquire its bootstrap generation."); + bootstrapRead.Dispose(); + + ForceCollection(); + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var allocatedStart = GC.GetTotalAllocatedBytes(precise: true); + var privateBytesStart = process.PrivateMemorySize64; + var cpuStart = process.TotalProcessorTime; + var clock = Stopwatch.StartNew(); + var frameInterval = TimeSpan.FromSeconds(1.0 / 60.0); + var latencies = new List(); + var frames = 0; + while (clock.Elapsed < activeDuration) + { + var started = Stopwatch.GetTimestamp(); + if (!publisher.TryAcquireWrite(out var write)) + throw new InvalidOperationException("Hot-body probe encountered unexpected write backpressure."); + var marker = (byte)(frames % 251 + 1); + using (write) + { + write.Span[..payloadBytes].Fill(marker); + write.Commit(payloadBytes, DateTimeOffset.UtcNow); + } + if (!cursor.TryAcquireLatest(out var read)) + throw new InvalidOperationException("Hot-body cursor missed a committed generation."); + using (read) + { + if (read.Descriptor.ByteSize != payloadBytes || read.ReadByte(0) != marker || read.ReadByte(payloadBytes - 1) != marker) + throw new InvalidOperationException("Hot-body cursor observed torn or mismatched bytes."); + } + latencies.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + frames++; + var remaining = frameInterval * frames - clock.Elapsed; + if (remaining > TimeSpan.Zero) await Task.Delay(remaining); + } + + process.Refresh(); + var stats = publisher.Stats(); + var ordered = latencies.OrderBy(value => value).ToArray(); + return new HotBodyProbeResult( + ExpectedFrames: frames + 1, + ActualFrames: checked((int)stats.PublishedFrames), + PayloadBytes: payloadBytes, + P50RoundTripMilliseconds: Percentile(ordered, 0.50), + P95RoundTripMilliseconds: Percentile(ordered, 0.95), + P99RoundTripMilliseconds: Percentile(ordered, 0.99), + AllocatedBytes: GC.GetTotalAllocatedBytes(precise: true) - allocatedStart, + PrivateBytesBefore: privateBytesStart, + PrivateBytesAfter: process.PrivateMemorySize64, + CpuMilliseconds: (process.TotalProcessorTime - cpuStart).TotalMilliseconds, + BlockedWrites: stats.BlockedWrites, + UnavoidableCopyCount: stats.UnavoidableCopyCount); +} + static async Task MeasureAsync( int documentCount, TimeSpan idleDuration, @@ -201,6 +283,20 @@ internal sealed record ProbeResult( double P99PublishLatencyMilliseconds, double ScenarioWallSeconds); +internal sealed record HotBodyProbeResult( + int ExpectedFrames, + int ActualFrames, + int PayloadBytes, + double P50RoundTripMilliseconds, + double P95RoundTripMilliseconds, + double P99RoundTripMilliseconds, + long AllocatedBytes, + long PrivateBytesBefore, + long PrivateBytesAfter, + double CpuMilliseconds, + ulong BlockedWrites, + ulong UnavoidableCopyCount); + [CultDocument("gamecult.mesh.performance_probe", "gamecult.mesh.performance_probe.v1")] [MessagePackObject] public sealed class PerformanceDocument