Measure mapped hot-body copy cost
This commit is contained in:
parent
bfaf7a8456
commit
f65352fa73
5 changed files with 184 additions and 6 deletions
12
README.md
12
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue