Measure mapped hot-body copy cost

This commit is contained in:
Meta Vens 2026-08-18 04:03:06 +02:00
commit f65352fa73
5 changed files with 184 additions and 6 deletions

View file

@ -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

View file

@ -12,6 +12,27 @@ using Microsoft.Win32.SafeHandles;
namespace GameCult.Mesh
{
/// <summary>Reports bounded shared-memory frame publication and explicit copy telemetry.</summary>
public sealed class CultMeshFrameBodyPublisherStats
{
internal CultMeshFrameBodyPublisherStats(
ulong publishedFrames,
ulong blockedWrites,
ulong unavoidableCopyCount)
{
PublishedFrames = publishedFrames;
BlockedWrites = blockedWrites;
UnavoidableCopyCount = unavoidableCopyCount;
}
/// <summary>Gets the number of atomically committed generations.</summary>
public ulong PublishedFrames { get; }
/// <summary>Gets the number of write reservations refused by bounded slots.</summary>
public ulong BlockedWrites { get; }
/// <summary>Gets copies explicitly declared by copy-fallback publication.</summary>
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
}
}
/// <summary>Gets current publication, backpressure, and explicit fallback-copy counters.</summary>
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<byte> Memory => _memory.Memory;
public Span<byte> 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()
{

View file

@ -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<byte>)` 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

View file

@ -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()
{

View file

@ -14,6 +14,7 @@ var results = new List<ProbeResult>();
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<HotBodyProbeResult> 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<double>();
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<ProbeResult> 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