feat(streaming): enforce typed completion queues
Replace the flat deferred list, priority scan, unload bypass, and count-only execution cap with exact destination/control/unload/Near/Far FIFOs behind one typed frame meter. Price worker results before adoption, retain exact retry identity, reject stale generations without payload retention, and publish queue pressure through lifecycle diagnostics. Tests: dotnet build AcDream.slnx -c Release --no-restore; dotnet test AcDream.slnx -c Release --no-restore (8138 passed, 5 skipped)
This commit is contained in:
parent
ac45cb1bd7
commit
b8f6317fe1
15 changed files with 1261 additions and 336 deletions
|
|
@ -290,7 +290,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.WorldOrigin.CenterX,
|
||||
d.WorldOrigin.CenterY))),
|
||||
enqueueUnload: streamerLease.Resource.EnqueueUnload,
|
||||
drainCompletions: streamerLease.Resource.DrainCompletions,
|
||||
completionSource: streamerLease.Resource,
|
||||
state: live.WorldState,
|
||||
nearRadius: nearRadius,
|
||||
farRadius: farRadius,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ namespace AcDream.App.Streaming;
|
|||
/// methods are thread-safe.
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
public sealed class LandblockStreamer : IDisposable
|
||||
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Default drain batch size. Tuned to cap GPU upload work the render
|
||||
|
|
@ -61,6 +61,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
private readonly object _inboxGate = new();
|
||||
private Thread? _worker;
|
||||
private Exception? _workerFailure;
|
||||
private int _completionBacklog;
|
||||
private int _disposed;
|
||||
private readonly object _disposeGate = new();
|
||||
private bool _disposeCompleted;
|
||||
|
|
@ -276,11 +277,37 @@ public sealed class LandblockStreamer : IDisposable
|
|||
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = DefaultDrainBatchSize)
|
||||
{
|
||||
var batch = new List<LandblockStreamResult>(maxBatchSize);
|
||||
while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result))
|
||||
while (batch.Count < maxBatchSize && TryRead(out var result))
|
||||
{
|
||||
if (result is null)
|
||||
throw new InvalidOperationException(
|
||||
"The completion channel returned a null result.");
|
||||
batch.Add(result);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
public int BacklogCount => Math.Max(
|
||||
0,
|
||||
System.Threading.Volatile.Read(ref _completionBacklog));
|
||||
|
||||
public bool TryPeek(out LandblockStreamResult? result) =>
|
||||
_outbox.Reader.TryPeek(out result);
|
||||
|
||||
public bool TryRead(out LandblockStreamResult? result)
|
||||
{
|
||||
if (!_outbox.Reader.TryRead(out result))
|
||||
return false;
|
||||
System.Threading.Interlocked.Decrement(ref _completionBacklog);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void PublishResult(LandblockStreamResult result)
|
||||
{
|
||||
if (_outbox.Writer.TryWrite(result))
|
||||
System.Threading.Interlocked.Increment(ref _completionBacklog);
|
||||
}
|
||||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
var highPriority = new Queue<LandblockStreamJob>();
|
||||
|
|
@ -337,7 +364,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
_workerFailure = ex;
|
||||
_inbox.Writer.TryComplete(ex);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
|
||||
PublishResult(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -426,13 +453,13 @@ public sealed class LandblockStreamer : IDisposable
|
|||
var build = _loadLandblock(load.Request);
|
||||
if (build is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
PublishResult(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
if (build.Origin != load.Origin)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
PublishResult(new LandblockStreamResult.Failed(
|
||||
load.LandblockId,
|
||||
$"Landblock build origin {build.Origin} did not match request origin {load.Origin}",
|
||||
load.Generation));
|
||||
|
|
@ -444,18 +471,18 @@ public sealed class LandblockStreamer : IDisposable
|
|||
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (promotedMesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
PublishResult(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
||||
PublishResult(new LandblockStreamResult.Promoted(
|
||||
load.LandblockId, build, promotedMesh, load.Generation));
|
||||
break;
|
||||
}
|
||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (mesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
PublishResult(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
|
|
@ -485,18 +512,18 @@ public sealed class LandblockStreamer : IDisposable
|
|||
PhysicsDatBundle.Empty);
|
||||
build = new LandblockBuild(lb, Origin: build.Origin);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
PublishResult(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId, tier, build, mesh, load.Generation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
PublishResult(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, ex.ToString(), load.Generation));
|
||||
}
|
||||
break;
|
||||
|
||||
case LandblockStreamJob.Unload unload:
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
|
||||
PublishResult(new LandblockStreamResult.Unloaded(
|
||||
unload.LandblockId,
|
||||
unload.Generation));
|
||||
break;
|
||||
|
|
|
|||
270
src/AcDream.App/Streaming/StreamingCompletionQueue.cs
Normal file
270
src/AcDream.App/Streaming/StreamingCompletionQueue.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free single-consumer view of the streaming worker's completion
|
||||
/// channel. Peek plus read lets the update thread price a payload before
|
||||
/// adopting it into the scheduler.
|
||||
/// </summary>
|
||||
public interface ILandblockCompletionSource
|
||||
{
|
||||
int BacklogCount { get; }
|
||||
bool TryPeek(out LandblockStreamResult? result);
|
||||
bool TryRead(out LandblockStreamResult? result);
|
||||
}
|
||||
|
||||
internal enum StreamingCompletionPriority : byte
|
||||
{
|
||||
Destination = 0,
|
||||
Control = 1,
|
||||
Unload = 2,
|
||||
Near = 3,
|
||||
Far = 4,
|
||||
}
|
||||
|
||||
internal readonly record struct StreamingQueuedCompletion(
|
||||
LandblockStreamResult Result,
|
||||
LandblockStreamCostEstimate Estimate,
|
||||
StreamingCompletionPriority Priority,
|
||||
ulong Generation,
|
||||
long Sequence,
|
||||
long EnqueuedTimestamp)
|
||||
{
|
||||
public StreamingWorkCost AdmissionCost => new(
|
||||
CompletionAdmissions: Estimate.Work.CompletionAdmissions,
|
||||
AdoptedCpuBytes: Estimate.Work.AdoptedCpuBytes);
|
||||
|
||||
public StreamingWorkCost ExecutionCost => new(
|
||||
EntityOperations: Estimate.Work.EntityOperations,
|
||||
GpuUploadBytes: Estimate.Work.GpuUploadBytes,
|
||||
GlRetireOperations:
|
||||
Result is LandblockStreamResult.Unloaded ? 1 : 0);
|
||||
}
|
||||
|
||||
internal readonly record struct StreamingCompletionQueueSnapshot(
|
||||
int Count,
|
||||
long RetainedCpuBytes,
|
||||
double OldestAgeMilliseconds,
|
||||
int Destination,
|
||||
int Control,
|
||||
int Unload,
|
||||
int Near,
|
||||
int Far);
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread-owned stable FIFO queues. Ordering is exact inside one
|
||||
/// generation/priority; callbacks append and never invalidate an active node.
|
||||
/// </summary>
|
||||
internal sealed class StreamingCompletionQueue
|
||||
{
|
||||
private readonly Queue<StreamingQueuedCompletion>[] _queues =
|
||||
Enumerable.Range(0, Enum.GetValues<StreamingCompletionPriority>().Length)
|
||||
.Select(static _ => new Queue<StreamingQueuedCompletion>())
|
||||
.ToArray();
|
||||
private long _retainedCpuBytes;
|
||||
private int _count;
|
||||
|
||||
public int Count => _count;
|
||||
public long RetainedCpuBytes => _retainedCpuBytes;
|
||||
|
||||
public void Enqueue(StreamingQueuedCompletion completion)
|
||||
{
|
||||
_queues[(int)completion.Priority].Enqueue(completion);
|
||||
_count++;
|
||||
_retainedCpuBytes = SaturatingAdd(
|
||||
_retainedCpuBytes,
|
||||
completion.Estimate.Work.AdoptedCpuBytes);
|
||||
}
|
||||
|
||||
public bool TryPeekNext(
|
||||
Func<LandblockStreamResult, bool> isBlocked,
|
||||
out StreamingQueuedCompletion? completion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(isBlocked);
|
||||
for (int priority = 0; priority < _queues.Length; priority++)
|
||||
{
|
||||
Queue<StreamingQueuedCompletion> queue = _queues[priority];
|
||||
if (queue.Count == 0)
|
||||
continue;
|
||||
StreamingQueuedCompletion head = queue.Peek();
|
||||
if (isBlocked(head.Result))
|
||||
continue;
|
||||
|
||||
completion = head;
|
||||
return true;
|
||||
}
|
||||
|
||||
completion = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RemoveHead(StreamingQueuedCompletion completion)
|
||||
{
|
||||
Queue<StreamingQueuedCompletion> queue =
|
||||
_queues[(int)completion.Priority];
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Streaming completion priority FIFO is empty.");
|
||||
}
|
||||
|
||||
StreamingQueuedCompletion head = queue.Peek();
|
||||
if (head.Sequence != completion.Sequence
|
||||
|| !ReferenceEquals(head.Result, completion.Result))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Streaming completion is not the head of its priority FIFO.");
|
||||
}
|
||||
|
||||
queue.Dequeue();
|
||||
Release(completion);
|
||||
}
|
||||
|
||||
public int RemoveResults(
|
||||
IReadOnlyList<LandblockStreamResult> results,
|
||||
Func<LandblockStreamResult, bool> shouldRemove)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(results);
|
||||
ArgumentNullException.ThrowIfNull(shouldRemove);
|
||||
int removed = 0;
|
||||
for (int priority = 0; priority < _queues.Length; priority++)
|
||||
{
|
||||
Queue<StreamingQueuedCompletion> queue = _queues[priority];
|
||||
int count = queue.Count;
|
||||
for (int entry = 0; entry < count; entry++)
|
||||
{
|
||||
StreamingQueuedCompletion current = queue.Dequeue();
|
||||
bool matches = false;
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(current.Result, results[i]))
|
||||
{
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches && shouldRemove(current.Result))
|
||||
{
|
||||
Release(current);
|
||||
removed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
queue.Enqueue(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
for (int i = 0; i < _queues.Length; i++)
|
||||
_queues[i].Clear();
|
||||
_count = 0;
|
||||
_retainedCpuBytes = 0;
|
||||
}
|
||||
|
||||
public StreamingCompletionQueueSnapshot CaptureSnapshot()
|
||||
{
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
long oldest = now;
|
||||
bool found = false;
|
||||
for (int i = 0; i < _queues.Length; i++)
|
||||
{
|
||||
foreach (StreamingQueuedCompletion completion in _queues[i])
|
||||
{
|
||||
oldest = Math.Min(oldest, completion.EnqueuedTimestamp);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new StreamingCompletionQueueSnapshot(
|
||||
_count,
|
||||
_retainedCpuBytes,
|
||||
found
|
||||
? Math.Max(0L, now - oldest) * 1000.0 / Stopwatch.Frequency
|
||||
: 0,
|
||||
_queues[(int)StreamingCompletionPriority.Destination].Count,
|
||||
_queues[(int)StreamingCompletionPriority.Control].Count,
|
||||
_queues[(int)StreamingCompletionPriority.Unload].Count,
|
||||
_queues[(int)StreamingCompletionPriority.Near].Count,
|
||||
_queues[(int)StreamingCompletionPriority.Far].Count);
|
||||
}
|
||||
|
||||
private void Release(StreamingQueuedCompletion completion)
|
||||
{
|
||||
_count--;
|
||||
_retainedCpuBytes = Math.Max(
|
||||
0,
|
||||
_retainedCpuBytes - completion.Estimate.Work.AdoptedCpuBytes);
|
||||
}
|
||||
|
||||
private static long SaturatingAdd(long left, long right) =>
|
||||
left > long.MaxValue - right ? long.MaxValue : left + right;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility adapter for deterministic tests that still provide the
|
||||
/// former batch-drain delegate. Production uses
|
||||
/// <see cref="ILandblockCompletionSource"/> directly.
|
||||
/// </summary>
|
||||
internal sealed class DelegateLandblockCompletionSource(
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drain)
|
||||
: ILandblockCompletionSource
|
||||
{
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drain =
|
||||
drain ?? throw new ArgumentNullException(nameof(drain));
|
||||
private LandblockStreamResult? _peeked;
|
||||
|
||||
public int BacklogCount => _peeked is null ? 0 : 1;
|
||||
|
||||
public bool TryPeek(out LandblockStreamResult? result)
|
||||
{
|
||||
if (_peeked is null)
|
||||
{
|
||||
IReadOnlyList<LandblockStreamResult> batch = _drain(1);
|
||||
if (batch.Count > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A single-result completion drain returned more than one result.");
|
||||
}
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
_peeked = batch[0];
|
||||
}
|
||||
|
||||
result = _peeked;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRead(out LandblockStreamResult? result)
|
||||
{
|
||||
if (_peeked is not null)
|
||||
{
|
||||
result = _peeked;
|
||||
_peeked = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
IReadOnlyList<LandblockStreamResult> batch = _drain(1);
|
||||
if (batch.Count > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A single-result completion drain returned more than one result.");
|
||||
}
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = batch[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,17 +19,12 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class StreamingController : IStreamingFrameBackend
|
||||
{
|
||||
private readonly record struct DeferredCompletion(
|
||||
LandblockStreamResult Result,
|
||||
StreamingWorkCost Cost,
|
||||
long EnqueuedTimestamp);
|
||||
|
||||
private sealed class OriginRecenterRetirement
|
||||
{
|
||||
public bool RadiiConverged;
|
||||
public bool GenerationAdvanced;
|
||||
public bool PendingLoadsCleared;
|
||||
public bool DeferredApplyCleared;
|
||||
public bool CompletionQueueCleared;
|
||||
public bool RegionCleared;
|
||||
public List<uint>? ResidentIds;
|
||||
public int RetirementCursor;
|
||||
|
|
@ -41,13 +36,18 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|
||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||
private readonly Action<uint, ulong> _enqueueUnload;
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly ILandblockCompletionSource _completionSource;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
private readonly LandblockPresentationPipeline _presentation;
|
||||
private readonly StreamingWorkBudget _workBudget;
|
||||
private readonly Func<LandblockStreamResult, bool>
|
||||
_isPublicationBlockedByRetirement;
|
||||
private readonly StreamingWorkBudgetOptions _configuredWorkBudgetOptions;
|
||||
private StreamingWorkBudget _workBudget;
|
||||
private StreamingWorkMeter? _activeWorkMeter;
|
||||
private StreamingWorkMeterSnapshot _lastWorkMeter;
|
||||
private long _deferredAdoptedCpuBytes;
|
||||
private readonly StreamingCompletionQueue _completionQueue = new();
|
||||
private long _nextCompletionSequence;
|
||||
private int _legacyCompletionProfile = 4;
|
||||
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
|
|
@ -88,34 +88,28 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
public int FarRadius { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
|
||||
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
|
||||
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
|
||||
/// applying 25 of them in a single frame produces a memory spike
|
||||
/// (observed: out-of-memory crash on the 5×5 first-frame load).
|
||||
///
|
||||
/// <para>
|
||||
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
|
||||
/// load over ~7 frames (~116ms at 60fps), which is below the human
|
||||
/// perception threshold. Spawn races that previously dropped entities
|
||||
/// while landblocks were in flight are now handled by
|
||||
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
|
||||
/// completions doesn't lose any data.
|
||||
/// </para>
|
||||
/// Compatibility quality selector for callers that still express the
|
||||
/// streaming profile as a completion count. Four preserves the configured
|
||||
/// High profile; other positive values scale every typed time/count/byte
|
||||
/// dimension together. It is not an independent execution throttle.
|
||||
/// </summary>
|
||||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||||
public int MaxCompletionsPerFrame
|
||||
{
|
||||
get => _legacyCompletionProfile;
|
||||
set
|
||||
{
|
||||
StreamingWorkBudgetOptions profile =
|
||||
_configuredWorkBudgetOptions
|
||||
.ScaleForLegacyCompletionCount(value);
|
||||
_legacyCompletionProfile = value;
|
||||
_workBudget = profile.ToBudget();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #138: the teleport destination landblock id. When non-zero,
|
||||
/// <see cref="DrainAndApply"/> applies this landblock's
|
||||
/// <see cref="LandblockStreamResult.Loaded"/> or
|
||||
/// <see cref="LandblockStreamResult.Promoted"/> completion immediately
|
||||
/// — even if it sits past the <see cref="MaxCompletionsPerFrame"/>
|
||||
/// position in the outbox — so the player can materialise at the
|
||||
/// destination without waiting for every earlier-queued landblock to
|
||||
/// drain first. Non-priority completions drained past it are buffered
|
||||
/// in <see cref="_deferredApply"/> and applied over subsequent frames,
|
||||
/// so no completions are lost and there is no GPU spike.
|
||||
/// #138: the teleport destination landblock id. Loaded or promoted results
|
||||
/// inside its priority ring enter the destination FIFO, ahead of ordinary
|
||||
/// Near/Far work, but still consume the same typed frame budget.
|
||||
///
|
||||
/// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
|
||||
/// once the destination landblock has been applied (or when the
|
||||
|
|
@ -124,32 +118,39 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
public uint PriorityLandblockId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
|
||||
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies
|
||||
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
|
||||
/// original priority behaviour. A teleport sets this to the near ring so the player's
|
||||
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
|
||||
/// just the one landblock they stand on. Without it, only the destination landblock
|
||||
/// applies immediately and everything around it drains at <see cref="MaxCompletionsPerFrame"/>,
|
||||
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
|
||||
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
|
||||
/// transient walk-through-walls). The far ring still drains at the budget. The eager
|
||||
/// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
|
||||
/// Radius in landblocks (Chebyshev distance) around
|
||||
/// <see cref="PriorityLandblockId"/> classified as destination work.
|
||||
/// Zero means only the center. This changes queue priority, never budget
|
||||
/// enforcement or atomic publication semantics.
|
||||
/// </summary>
|
||||
public int PriorityRadius { get; set; }
|
||||
|
||||
// [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing
|
||||
// deferred-LOAD backlog. A non-zero value during/after a teleport is the
|
||||
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||
// accepted completion backlog. A non-zero value during/after a teleport is
|
||||
// the typed-budget publication tail. Removable compatibility probe surface.
|
||||
public int DeferredApplyBacklog => _completionQueue.Count;
|
||||
public int PendingRetirementCount => _presentation.PendingRetirementCount;
|
||||
public StreamingWorkDiagnostics WorkDiagnostics => new(
|
||||
_lastWorkMeter,
|
||||
_deferredApply.Count,
|
||||
_deferredAdoptedCpuBytes,
|
||||
OldestDeferredAgeMilliseconds(),
|
||||
_presentation.PendingPublicationCount,
|
||||
_presentation.PendingRetirementCount);
|
||||
public StreamingWorkDiagnostics WorkDiagnostics
|
||||
{
|
||||
get
|
||||
{
|
||||
StreamingCompletionQueueSnapshot queued =
|
||||
_completionQueue.CaptureSnapshot();
|
||||
return new StreamingWorkDiagnostics(
|
||||
_lastWorkMeter,
|
||||
queued.Count,
|
||||
queued.RetainedCpuBytes,
|
||||
queued.OldestAgeMilliseconds,
|
||||
_presentation.PendingPublicationCount,
|
||||
_presentation.PendingRetirementCount,
|
||||
_completionSource.BacklogCount,
|
||||
queued.Destination,
|
||||
queued.Control,
|
||||
queued.Unload,
|
||||
queued.Near,
|
||||
queued.Far);
|
||||
}
|
||||
}
|
||||
internal bool IsCollapsedToDungeon => _collapsed;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -195,9 +196,6 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
public int FullWindowRetirementCount { get; private set; }
|
||||
public int LastFullWindowRetirementLandblockCount { get; private set; }
|
||||
|
||||
// Completions that were drained past a priority item get buffered here
|
||||
// so they still apply over subsequent frames without loss.
|
||||
private readonly List<DeferredCompletion> _deferredApply = new();
|
||||
/// <summary>
|
||||
/// Internal compatibility seam for hermetic controller policy tests. Live
|
||||
/// composition cannot supply presentation callbacks; it must use the
|
||||
|
|
@ -216,7 +214,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
Action? clearPendingLoads = null,
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
|
||||
LandblockRetirementCoordinator? retirementCoordinator = null)
|
||||
LandblockRetirementCoordinator? retirementCoordinator = null,
|
||||
StreamingWorkBudgetOptions? workBudgetOptions = null)
|
||||
: this(
|
||||
enqueueLoad,
|
||||
enqueueUnload,
|
||||
|
|
@ -232,7 +231,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
retirementCoordinator,
|
||||
removeTerrain,
|
||||
demoteNearLayer),
|
||||
clearPendingLoads)
|
||||
clearPendingLoads,
|
||||
workBudgetOptions)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
/// retirement owner, so no legacy publication delegate can be supplied or
|
||||
/// silently ignored.
|
||||
/// </summary>
|
||||
public StreamingController(
|
||||
internal StreamingController(
|
||||
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||
Action<uint, ulong> enqueueUnload,
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||
|
|
@ -251,16 +251,47 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
LandblockPresentationPipeline presentationPipeline,
|
||||
Action? clearPendingLoads = null,
|
||||
StreamingWorkBudgetOptions? workBudgetOptions = null)
|
||||
: this(
|
||||
enqueueLoad,
|
||||
enqueueUnload,
|
||||
new DelegateLandblockCompletionSource(drainCompletions),
|
||||
state,
|
||||
nearRadius,
|
||||
farRadius,
|
||||
presentationPipeline,
|
||||
clearPendingLoads,
|
||||
workBudgetOptions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free production completion path. Peek/read is single-consumer
|
||||
/// and lets the scheduler price a result before adopting it.
|
||||
/// </summary>
|
||||
public StreamingController(
|
||||
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||
Action<uint, ulong> enqueueUnload,
|
||||
ILandblockCompletionSource completionSource,
|
||||
GpuWorldState state,
|
||||
int nearRadius,
|
||||
int farRadius,
|
||||
LandblockPresentationPipeline presentationPipeline,
|
||||
Action? clearPendingLoads = null,
|
||||
StreamingWorkBudgetOptions? workBudgetOptions = null)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_completionSource = completionSource
|
||||
?? throw new ArgumentNullException(nameof(completionSource));
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_state = state;
|
||||
_presentation = presentationPipeline
|
||||
?? throw new ArgumentNullException(nameof(presentationPipeline));
|
||||
_workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default)
|
||||
.ToBudget();
|
||||
_isPublicationBlockedByRetirement =
|
||||
IsPublicationBlockedByRetirement;
|
||||
_configuredWorkBudgetOptions =
|
||||
workBudgetOptions ?? StreamingWorkBudgetOptions.Default;
|
||||
_workBudget = _configuredWorkBudgetOptions.ToBudget();
|
||||
if (!_presentation.MatchesState(_state))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
|
|
@ -400,7 +431,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
Action? clearPendingLoads = null,
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
|
||||
LandblockRetirementCoordinator? retirementCoordinator = null)
|
||||
LandblockRetirementCoordinator? retirementCoordinator = null,
|
||||
StreamingWorkBudgetOptions? workBudgetOptions = null)
|
||||
: this(
|
||||
(id, kind, _) => enqueueLoad(id, kind),
|
||||
(id, _) => enqueueUnload(id),
|
||||
|
|
@ -414,7 +446,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
clearPendingLoads,
|
||||
onLandblockLoaded,
|
||||
ensureEnvCellMeshes,
|
||||
retirementCoordinator)
|
||||
retirementCoordinator,
|
||||
workBudgetOptions)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -429,48 +462,47 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_presentation.BeginNearLayerRetirement(canonical);
|
||||
}
|
||||
|
||||
private void AdvanceGeneration()
|
||||
private bool AdvanceGeneration()
|
||||
{
|
||||
ConvergePendingPublications();
|
||||
if (!ConvergePendingPublications())
|
||||
return false;
|
||||
_generation = unchecked(_generation + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ConvergePendingPublications()
|
||||
private bool ConvergePendingPublications()
|
||||
{
|
||||
IReadOnlyList<LandblockStreamResult> pending =
|
||||
_presentation.GetPendingPublicationResults();
|
||||
if (pending.Count == 0)
|
||||
return;
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
bool progressed = false;
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
LandblockStreamResult result = pending[i];
|
||||
ObserveResultOperation(
|
||||
if (!TryObserveResultOperation(
|
||||
result,
|
||||
"publication-retry",
|
||||
() => _presentation.ResumePublication(result));
|
||||
() => _presentation.ResumePublication(result),
|
||||
ensureProgress: !progressed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
progressed = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A completed staged result can still be present in the deferred
|
||||
// FIFO that originally retained it. Remove only those exact
|
||||
// objects; unrelated accepted completions keep their order.
|
||||
_deferredApply.RemoveAll(entry =>
|
||||
{
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(entry.Result, pending[i]))
|
||||
{
|
||||
return !_presentation.HasPendingPublication(
|
||||
entry.Result);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
RecalculateDeferredAdoptedBytes();
|
||||
_completionQueue.RemoveResults(
|
||||
pending,
|
||||
result => !_presentation.HasPendingPublication(result));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -518,7 +550,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// Complete an admitted publication before this frame can mutate its
|
||||
// desired tier or spatial residence. A later recenter may then demote
|
||||
// or retire the fully known owner set through the normal ledger.
|
||||
ConvergePendingPublications();
|
||||
if (!ConvergePendingPublications())
|
||||
return;
|
||||
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
|
|
@ -574,7 +607,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|
||||
if (!pending.GenerationAdvanced)
|
||||
{
|
||||
AdvanceGeneration();
|
||||
if (!AdvanceGeneration())
|
||||
return;
|
||||
pending.GenerationAdvanced = true;
|
||||
}
|
||||
|
||||
|
|
@ -625,7 +659,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_region = pending.Region;
|
||||
NearRadius = pending.NearRadius;
|
||||
FarRadius = pending.FarRadius;
|
||||
ClearDeferred();
|
||||
_completionQueue.Clear();
|
||||
_pendingRadiiReconfiguration = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -762,11 +796,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
bool logTransition = !_collapsed || _collapsedCenter != centerId;
|
||||
if (logTransition)
|
||||
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||
AdvanceGeneration();
|
||||
if (!AdvanceGeneration())
|
||||
return;
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
_clearPendingLoads?.Invoke();
|
||||
ClearDeferred();
|
||||
_completionQueue.Clear();
|
||||
_region = null;
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
|
|
@ -811,7 +846,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
/// <summary>
|
||||
/// True when <paramref name="id"/> is the priority center or within
|
||||
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport
|
||||
/// near ring that <see cref="DrainAndApply"/> eager-applies. With the default radius 0
|
||||
/// near ring that <see cref="DrainAndApply"/> prioritizes. With the default radius 0
|
||||
/// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
|
||||
/// single-landblock priority behaviour).
|
||||
/// </summary>
|
||||
|
|
@ -829,7 +864,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
Console.WriteLine(
|
||||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||
AdvanceGeneration();
|
||||
if (!AdvanceGeneration())
|
||||
return;
|
||||
_collapsed = false;
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
|
|
@ -1027,7 +1063,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
if (!transaction.GenerationAdvanced)
|
||||
{
|
||||
AdvanceGeneration();
|
||||
if (!AdvanceGeneration())
|
||||
return false;
|
||||
transaction.GenerationAdvanced = true;
|
||||
}
|
||||
if (!transaction.PendingLoadsCleared)
|
||||
|
|
@ -1045,10 +1082,10 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
return false;
|
||||
}
|
||||
}
|
||||
if (!transaction.DeferredApplyCleared)
|
||||
if (!transaction.CompletionQueueCleared)
|
||||
{
|
||||
ClearDeferred();
|
||||
transaction.DeferredApplyCleared = true;
|
||||
_completionQueue.Clear();
|
||||
transaction.CompletionQueueCleared = true;
|
||||
}
|
||||
if (!transaction.RegionCleared)
|
||||
{
|
||||
|
|
@ -1103,10 +1140,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|
||||
private void BeginFullWindowRetirement()
|
||||
{
|
||||
AdvanceGeneration();
|
||||
if (!AdvanceGeneration())
|
||||
return;
|
||||
_collapsed = false;
|
||||
_clearPendingLoads?.Invoke();
|
||||
ClearDeferred();
|
||||
_completionQueue.Clear();
|
||||
// Commit the old region boundary before any presentation owner is
|
||||
// advanced. New same-id publications are fenced by the presentation
|
||||
// pipeline's retryable retirement ledger.
|
||||
|
|
@ -1120,164 +1158,171 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
|
||||
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a
|
||||
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over
|
||||
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
|
||||
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
|
||||
/// metering them at the load rate left the previous location's terrain resident for seconds
|
||||
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
|
||||
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
|
||||
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
|
||||
/// (<see cref="PriorityRadius"/>, applied during portal travel) likewise bypass the budget so the
|
||||
/// player materialises in a loaded world.
|
||||
/// Adopts immutable worker results and advances explicit priority FIFOs
|
||||
/// under the one typed frame meter. Destination and unload work win queue
|
||||
/// order but no class bypasses time, bytes, entities, uploads, or retire
|
||||
/// operation limits.
|
||||
/// </summary>
|
||||
private void DrainAndApply()
|
||||
{
|
||||
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
|
||||
// loads immediately; defer every other (budget-metered) load. Draining the whole
|
||||
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
|
||||
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
|
||||
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
|
||||
// (the prior version returned once the budget hit 0, stranding the outbox).
|
||||
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
||||
int iter = 0;
|
||||
while (iter++ < MaxDrainIterations)
|
||||
{
|
||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||
if (chunk.Count == 0) break;
|
||||
for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++)
|
||||
{
|
||||
LandblockStreamResult result = chunk[chunkIndex];
|
||||
// Reject obsolete hard-window work before it can occupy the
|
||||
// metered deferred queue. ApplyResult repeats this invariant
|
||||
// for direct callers and future drain paths.
|
||||
if (IsStaleGeneration(result)
|
||||
&& !_presentation.HasPendingPublication(result))
|
||||
continue;
|
||||
if (IsPublicationBlockedByRetirement(result))
|
||||
Defer(result);
|
||||
else if (result is LandblockStreamResult.Unloaded
|
||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyResultObserved(
|
||||
result,
|
||||
"priority-or-unload");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The concrete presentation pipeline retains the exact
|
||||
// unfinished stage. Preserve it and every untouched
|
||||
// completion from this already-drained chunk in
|
||||
// original FIFO order. Internal compatibility tests may
|
||||
// use a callback pipeline that cannot retain a receipt;
|
||||
// its failed result is consumed, but the untouched
|
||||
// suffix must still survive.
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
Defer(result);
|
||||
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
||||
Defer(chunk[suffix]);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
Defer(result); // a GPU-upload load — meter it in step 2
|
||||
}
|
||||
}
|
||||
StreamingWorkMeter meter = _activeWorkMeter
|
||||
?? throw new InvalidOperationException(
|
||||
"Completion scheduling requires an active frame meter.");
|
||||
|
||||
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
|
||||
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
|
||||
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
|
||||
int budget = MaxCompletionsPerFrame;
|
||||
int applied = 0;
|
||||
int read = 0;
|
||||
int write = 0;
|
||||
try
|
||||
AdmitCompletions(meter);
|
||||
bool executed = false;
|
||||
while (_completionQueue.TryPeekNext(
|
||||
_isPublicationBlockedByRetirement,
|
||||
out StreamingQueuedCompletion? completion))
|
||||
{
|
||||
while (applied < budget && read < _deferredApply.Count)
|
||||
{
|
||||
DeferredCompletion entry = _deferredApply[read];
|
||||
LandblockStreamResult result = entry.Result;
|
||||
if (IsPublicationBlockedByRetirement(result))
|
||||
{
|
||||
if (write != read)
|
||||
_deferredApply[write] = entry;
|
||||
write++;
|
||||
read++;
|
||||
continue;
|
||||
}
|
||||
StreamingQueuedCompletion work = completion
|
||||
?? throw new InvalidOperationException(
|
||||
"The completion queue returned a null head.");
|
||||
StreamingWorkCost executionCost =
|
||||
IsStaleGeneration(work.Result)
|
||||
? default
|
||||
: work.ExecutionCost;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
executionCost,
|
||||
$"execute-{work.Priority}",
|
||||
ensureProgress: !executed);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
break;
|
||||
|
||||
// Advance read only after the apply commits. If it throws, the
|
||||
// finally block removes prior committed entries but retains this
|
||||
// exact result and the untouched tail for retry.
|
||||
try
|
||||
{
|
||||
ApplyResultObserved(
|
||||
result,
|
||||
"deferred-publication",
|
||||
entry.Cost);
|
||||
read++;
|
||||
applied++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A concrete suffix-stage failure keeps its pipeline
|
||||
// receipt and must remain at this exact FIFO position. The
|
||||
// internal compatibility callback cannot report its exact
|
||||
// committed prefix and therefore removes its receipt;
|
||||
// consume only that failed result so a later frame cannot
|
||||
// replay it automatically.
|
||||
if (!_presentation.HasPendingPublication(result))
|
||||
read++;
|
||||
throw;
|
||||
}
|
||||
try
|
||||
{
|
||||
ApplyResult(work.Result);
|
||||
_completionQueue.RemoveHead(work);
|
||||
meter.Complete();
|
||||
executed = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Concrete publication failures retain their exact owner
|
||||
// receipt and queue head. A compatibility callback without a
|
||||
// receipt cannot safely replay, so consume only that result.
|
||||
if (!_presentation.HasPendingPublication(work.Result))
|
||||
_completionQueue.RemoveHead(work);
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// One linear tail shift replaces budget-many RemoveAt shifts. The
|
||||
// retained blocked prefix and the untouched FIFO tail keep their
|
||||
// original relative order.
|
||||
if (read > write)
|
||||
RemoveDeferredRange(write, read - write);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyResultObserved(
|
||||
LandblockStreamResult result,
|
||||
string stage,
|
||||
StreamingWorkCost? knownCost = null) =>
|
||||
ObserveResultOperation(
|
||||
result,
|
||||
stage,
|
||||
() => ApplyResult(result),
|
||||
knownCost);
|
||||
private void AdmitCompletions(StreamingWorkMeter meter)
|
||||
{
|
||||
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
|
||||
{
|
||||
LandblockStreamResult result = peeked
|
||||
?? throw new InvalidOperationException(
|
||||
"The completion source returned a null peek.");
|
||||
bool stale = IsStaleGeneration(result)
|
||||
&& !_presentation.HasPendingPublication(result);
|
||||
LandblockStreamCostEstimate estimate =
|
||||
LandblockStreamResultCost.Estimate(result);
|
||||
StreamingWorkCost admissionCost = stale
|
||||
? new StreamingWorkCost(CompletionAdmissions: 1)
|
||||
: new StreamingWorkCost(
|
||||
CompletionAdmissions:
|
||||
estimate.Work.CompletionAdmissions,
|
||||
AdoptedCpuBytes:
|
||||
estimate.Work.AdoptedCpuBytes);
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
admissionCost,
|
||||
"completion-admission");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return;
|
||||
|
||||
private void ObserveResultOperation(
|
||||
try
|
||||
{
|
||||
if (!_completionSource.TryRead(
|
||||
out LandblockStreamResult? consumed)
|
||||
|| !ReferenceEquals(result, consumed))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The single-consumer completion source changed between peek and read.");
|
||||
}
|
||||
|
||||
if (!stale)
|
||||
{
|
||||
_completionQueue.Enqueue(new StreamingQueuedCompletion(
|
||||
result,
|
||||
estimate,
|
||||
ClassifyCompletion(result),
|
||||
result.Generation,
|
||||
checked(_nextCompletionSequence++),
|
||||
Stopwatch.GetTimestamp()));
|
||||
}
|
||||
meter.Complete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StreamingCompletionPriority ClassifyCompletion(
|
||||
LandblockStreamResult result)
|
||||
{
|
||||
if (result is LandblockStreamResult.Loaded
|
||||
or LandblockStreamResult.Promoted
|
||||
&& IsWithinPriorityRing(result.LandblockId))
|
||||
{
|
||||
return StreamingCompletionPriority.Destination;
|
||||
}
|
||||
|
||||
return result switch
|
||||
{
|
||||
LandblockStreamResult.Failed
|
||||
or LandblockStreamResult.WorkerCrashed =>
|
||||
StreamingCompletionPriority.Control,
|
||||
LandblockStreamResult.Unloaded =>
|
||||
StreamingCompletionPriority.Unload,
|
||||
LandblockStreamResult.Promoted
|
||||
or LandblockStreamResult.Loaded
|
||||
{
|
||||
Tier: LandblockStreamTier.Near,
|
||||
} =>
|
||||
StreamingCompletionPriority.Near,
|
||||
_ => StreamingCompletionPriority.Far,
|
||||
};
|
||||
}
|
||||
|
||||
private bool TryObserveResultOperation(
|
||||
LandblockStreamResult result,
|
||||
string stage,
|
||||
Action operation,
|
||||
StreamingWorkCost? knownCost = null)
|
||||
StreamingWorkCost? knownCost = null,
|
||||
bool ensureProgress = false)
|
||||
{
|
||||
StreamingWorkMeter? meter = _activeWorkMeter;
|
||||
if (meter is null)
|
||||
{
|
||||
operation();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
StreamingWorkCost cost = knownCost
|
||||
StreamingWorkCost aggregate = knownCost
|
||||
?? LandblockStreamResultCost.Estimate(result).Work;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
|
||||
StreamingWorkCost cost = new(
|
||||
EntityOperations: aggregate.EntityOperations,
|
||||
GpuUploadBytes: aggregate.GpuUploadBytes,
|
||||
GlRetireOperations:
|
||||
result is LandblockStreamResult.Unloaded ? 1 : 0);
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
cost,
|
||||
stage,
|
||||
ensureProgress);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
meter.ForceReserveObserved(cost, stage);
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
meter.Complete();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -1286,69 +1331,10 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
}
|
||||
|
||||
private void Defer(LandblockStreamResult result)
|
||||
{
|
||||
StreamingWorkCost cost = LandblockStreamResultCost.Estimate(result).Work;
|
||||
_deferredApply.Add(new DeferredCompletion(
|
||||
result,
|
||||
cost,
|
||||
Stopwatch.GetTimestamp()));
|
||||
_deferredAdoptedCpuBytes = SaturatingAdd(
|
||||
_deferredAdoptedCpuBytes,
|
||||
cost.AdoptedCpuBytes);
|
||||
}
|
||||
|
||||
private void ClearDeferred()
|
||||
{
|
||||
_deferredApply.Clear();
|
||||
_deferredAdoptedCpuBytes = 0;
|
||||
}
|
||||
|
||||
private void RemoveDeferredRange(int index, int count)
|
||||
{
|
||||
for (int i = index; i < index + count; i++)
|
||||
{
|
||||
_deferredAdoptedCpuBytes = Math.Max(
|
||||
0,
|
||||
_deferredAdoptedCpuBytes
|
||||
- _deferredApply[i].Cost.AdoptedCpuBytes);
|
||||
}
|
||||
_deferredApply.RemoveRange(index, count);
|
||||
}
|
||||
|
||||
private void RecalculateDeferredAdoptedBytes()
|
||||
{
|
||||
long total = 0;
|
||||
for (int i = 0; i < _deferredApply.Count; i++)
|
||||
{
|
||||
total = SaturatingAdd(
|
||||
total,
|
||||
_deferredApply[i].Cost.AdoptedCpuBytes);
|
||||
}
|
||||
_deferredAdoptedCpuBytes = total;
|
||||
}
|
||||
|
||||
private double OldestDeferredAgeMilliseconds()
|
||||
{
|
||||
if (_deferredApply.Count == 0)
|
||||
return 0;
|
||||
|
||||
long oldest = _deferredApply[0].EnqueuedTimestamp;
|
||||
for (int i = 1; i < _deferredApply.Count; i++)
|
||||
oldest = Math.Min(oldest, _deferredApply[i].EnqueuedTimestamp);
|
||||
return Math.Max(0L, Stopwatch.GetTimestamp() - oldest)
|
||||
* 1000.0
|
||||
/ Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
private static long SaturatingAdd(long left, long right) =>
|
||||
left > long.MaxValue - right ? long.MaxValue : left + right;
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single <see cref="LandblockStreamResult"/> with the full side-
|
||||
/// effects: terrain upload, GPU state, and the re-hydration callback.
|
||||
/// Extracted from the inline switch in the original <c>DrainAndApply</c>
|
||||
/// so both the priority-hunt path and the normal drain path share it.
|
||||
/// All priority queues route through this one publication path.
|
||||
/// </summary>
|
||||
private void ApplyResult(LandblockStreamResult result)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -159,7 +159,13 @@ public readonly record struct StreamingWorkDiagnostics(
|
|||
long DeferredAdoptedCpuBytes,
|
||||
double OldestDeferredAgeMilliseconds,
|
||||
int PendingPublications,
|
||||
int PendingRetirements);
|
||||
int PendingRetirements,
|
||||
int WorkerCompletionBacklog,
|
||||
int DestinationBacklog,
|
||||
int ControlBacklog,
|
||||
int UnloadBacklog,
|
||||
int NearBacklog,
|
||||
int FarBacklog);
|
||||
|
||||
/// <summary>
|
||||
/// Single-thread, frame-scoped admission meter. Callers reserve a known cost
|
||||
|
|
@ -180,6 +186,7 @@ public sealed class StreamingWorkMeter
|
|||
private int _failures;
|
||||
private bool _frameOverrunRecorded;
|
||||
private bool _reservationActive;
|
||||
private bool _ensuredProgressGranted;
|
||||
private string? _activeStage;
|
||||
private string? _lastStage;
|
||||
private StreamingWorkLimit _lastLimit;
|
||||
|
|
@ -207,7 +214,8 @@ public sealed class StreamingWorkMeter
|
|||
|
||||
public StreamingWorkAdmission TryReserve(
|
||||
StreamingWorkCost cost,
|
||||
string stage)
|
||||
string stage,
|
||||
bool ensureProgress = false)
|
||||
{
|
||||
cost.Validate();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stage);
|
||||
|
|
@ -216,7 +224,11 @@ public sealed class StreamingWorkMeter
|
|||
"The prior streaming operation has not completed.");
|
||||
|
||||
StreamingWorkLimit limit = FindLimit(cost);
|
||||
if (limit != StreamingWorkLimit.None && _operations != 0)
|
||||
bool grantEnsuredProgress =
|
||||
ensureProgress && !_ensuredProgressGranted;
|
||||
if (limit != StreamingWorkLimit.None
|
||||
&& _operations != 0
|
||||
&& !grantEnsuredProgress)
|
||||
{
|
||||
_yields++;
|
||||
_lastStage = stage;
|
||||
|
|
@ -228,14 +240,21 @@ public sealed class StreamingWorkMeter
|
|||
_operations++;
|
||||
_reservationActive = true;
|
||||
_activeStage = stage;
|
||||
_lastStage = stage;
|
||||
_lastLimit = limit;
|
||||
if (ensureProgress)
|
||||
_ensuredProgressGranted = true;
|
||||
if (limit != StreamingWorkLimit.None)
|
||||
{
|
||||
_lastStage = stage;
|
||||
_lastLimit = limit;
|
||||
_oversizedProgress++;
|
||||
return StreamingWorkAdmission.OversizedProgress;
|
||||
}
|
||||
|
||||
// Preserve the most recent constrained stage so later successful work
|
||||
// in another budget dimension cannot erase the reason this frame
|
||||
// yielded. With no constraint, retain the ordinary last-stage fact.
|
||||
if (_lastLimit == StreamingWorkLimit.None)
|
||||
_lastStage = stage;
|
||||
return StreamingWorkAdmission.Admitted;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,31 @@ public sealed record StreamingWorkBudgetOptions(
|
|||
MaxGlRetireOperations,
|
||||
DestinationReserveFraction);
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility bridge for the pre-Slice-E quality setting. The former
|
||||
/// landblock count now selects a complete work profile instead of acting
|
||||
/// as a second hidden execution throttle. Four is the historical High
|
||||
/// profile and therefore preserves this configured profile exactly.
|
||||
/// </summary>
|
||||
public StreamingWorkBudgetOptions ScaleForLegacyCompletionCount(int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
|
||||
double scale = count / 4.0;
|
||||
return this with
|
||||
{
|
||||
MaxUpdateMilliseconds = Math.Max(
|
||||
0.25,
|
||||
MaxUpdateMilliseconds * scale),
|
||||
MaxCompletionAdmissions = Scale(MaxCompletionAdmissions, scale),
|
||||
MaxAdoptedCpuBytes = Scale(MaxAdoptedCpuBytes, scale),
|
||||
MaxEntityOperations = Scale(MaxEntityOperations, scale),
|
||||
MaxGpuUploadBytes = Scale(MaxGpuUploadBytes, scale),
|
||||
MaxGlRetireOperations = Scale(MaxGlRetireOperations, scale),
|
||||
};
|
||||
}
|
||||
|
||||
private static double ParsePositiveDouble(string? value, double fallback) =>
|
||||
double.TryParse(
|
||||
value,
|
||||
|
|
@ -118,4 +143,22 @@ public sealed record StreamingWorkBudgetOptions(
|
|||
|
||||
return percent / 100f;
|
||||
}
|
||||
|
||||
private static int Scale(int value, double scale)
|
||||
{
|
||||
if (scale >= int.MaxValue / (double)value)
|
||||
return int.MaxValue;
|
||||
return Math.Max(
|
||||
1,
|
||||
(int)Math.Round(value * scale, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
private static long Scale(long value, double scale)
|
||||
{
|
||||
if (scale >= long.MaxValue / (double)value)
|
||||
return long.MaxValue;
|
||||
return Math.Max(
|
||||
1L,
|
||||
(long)Math.Round(value * scale, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue