Three review finds, all in the runtime-toggle path or edge math (the steady-state path was confirmed correct): 1. GPU stale-slot across toggle-off/on: the disabled branch now Disposes the GpuFrameTimer (and nulls it) instead of Stop()-ing and keeping it — a kept instance would poll slots left pending from BEFORE the pause on re-enable and report temporally stale GPU samples. The re-enable branch's existing null guard rebuilds the ring fresh. Dispose is safe there: the branch runs at the top of OnRender with the GL context current. 2. Stale stage accumulation across mid-stage toggle-off: a StageScope disposed after the flag flips still calls EndStage, leaving a partial delta in _stageAccumTicks. The re-baseline branch now Array.Clear()s it alongside the GC re-baselining. 3. FrameStatsBuffer.Max() seeded with 0, clamping all-negative windows (the alloc channel can go negative if the boundary ever crosses threads) and disagreeing with Percentile(). Now seeds from the first live sample after the empty check (slots [0.._count) are always the live window regardless of ring wraparound); empty still returns 0 to match Percentile. TDD: Max_AllNegative_ReturnsTrueMax failed (returned 0) before the fix, passes after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69 lines
2.2 KiB
C#
69 lines
2.2 KiB
C#
using System;
|
|
|
|
namespace AcDream.App.Diagnostics;
|
|
|
|
/// <summary>
|
|
/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
|
|
/// (microseconds or bytes) with percentile/max over the current window.
|
|
/// Pure and allocation-free after construction: <see cref="Percentile"/>
|
|
/// sorts into a preallocated scratch array, so the 5-second report path
|
|
/// allocates nothing. Not thread-safe — owned by the window loop thread.
|
|
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
|
|
/// </summary>
|
|
public sealed class FrameStatsBuffer
|
|
{
|
|
private readonly long[] _samples;
|
|
private readonly long[] _scratch;
|
|
private int _cursor;
|
|
private int _count;
|
|
|
|
public FrameStatsBuffer(int capacity)
|
|
{
|
|
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
|
_samples = new long[capacity];
|
|
_scratch = new long[capacity];
|
|
}
|
|
|
|
public int Count => _count;
|
|
|
|
public void Push(long value)
|
|
{
|
|
_samples[_cursor] = value;
|
|
_cursor = (_cursor + 1) % _samples.Length;
|
|
if (_count < _samples.Length) _count++;
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
_cursor = 0;
|
|
_count = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nearest-rank percentile over the current window: element at
|
|
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
|
|
/// </summary>
|
|
public long Percentile(double q)
|
|
{
|
|
if (_count == 0) return 0;
|
|
Array.Copy(_samples, _scratch, _count);
|
|
Array.Sort(_scratch, 0, _count);
|
|
int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
|
|
if (rank < 1) rank = 1;
|
|
if (rank > _count) rank = _count;
|
|
return _scratch[rank - 1];
|
|
}
|
|
|
|
public long Max()
|
|
{
|
|
if (_count == 0) return 0; // documented empty behavior, matches Percentile
|
|
// Seed from the first live sample so all-negative windows (the alloc
|
|
// channel can go negative if the boundary ever crosses threads) return
|
|
// the true max instead of clamping to 0. Slots [0.._count) are always
|
|
// the live window regardless of ring wraparound.
|
|
long max = _samples[0];
|
|
for (int i = 1; i < _count; i++)
|
|
if (_samples[i] > max) max = _samples[i];
|
|
return max;
|
|
}
|
|
}
|