feat(streaming): establish cost budget ledger
Add the validated frame-work profile, deterministic completion charges, and shadow admission meter before enforcing the scheduler in Slice E2. Lifecycle artifacts now expose elapsed work, would-yield limits, backlog bytes/age, and pending owner ledgers without changing accepted execution. Tests: dotnet build AcDream.slnx -c Release --no-restore; dotnet test AcDream.slnx -c Release --no-build --no-restore (8124 passed, 5 skipped)
This commit is contained in:
parent
2945896a6f
commit
ac45cb1bd7
14 changed files with 1280 additions and 71 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
|
|
@ -18,6 +19,11 @@ 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;
|
||||
|
|
@ -38,6 +44,10 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
private readonly LandblockPresentationPipeline _presentation;
|
||||
private readonly StreamingWorkBudget _workBudget;
|
||||
private StreamingWorkMeter? _activeWorkMeter;
|
||||
private StreamingWorkMeterSnapshot _lastWorkMeter;
|
||||
private long _deferredAdoptedCpuBytes;
|
||||
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
|
|
@ -133,6 +143,13 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||
public int PendingRetirementCount => _presentation.PendingRetirementCount;
|
||||
public StreamingWorkDiagnostics WorkDiagnostics => new(
|
||||
_lastWorkMeter,
|
||||
_deferredApply.Count,
|
||||
_deferredAdoptedCpuBytes,
|
||||
OldestDeferredAgeMilliseconds(),
|
||||
_presentation.PendingPublicationCount,
|
||||
_presentation.PendingRetirementCount);
|
||||
internal bool IsCollapsedToDungeon => _collapsed;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -180,7 +197,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|
||||
// Completions that were drained past a priority item get buffered here
|
||||
// so they still apply over subsequent frames without loss.
|
||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||
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
|
||||
|
|
@ -232,7 +249,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
int nearRadius,
|
||||
int farRadius,
|
||||
LandblockPresentationPipeline presentationPipeline,
|
||||
Action? clearPendingLoads = null)
|
||||
Action? clearPendingLoads = null,
|
||||
StreamingWorkBudgetOptions? workBudgetOptions = null)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
|
|
@ -241,6 +259,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_state = state;
|
||||
_presentation = presentationPipeline
|
||||
?? throw new ArgumentNullException(nameof(presentationPipeline));
|
||||
_workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default)
|
||||
.ToBudget();
|
||||
if (!_presentation.MatchesState(_state))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
|
|
@ -425,22 +445,32 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
try
|
||||
{
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
_presentation.ResumePublication(pending[i]);
|
||||
{
|
||||
LandblockStreamResult result = pending[i];
|
||||
ObserveResultOperation(
|
||||
result,
|
||||
"publication-retry",
|
||||
() => _presentation.ResumePublication(result));
|
||||
}
|
||||
}
|
||||
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(result =>
|
||||
_deferredApply.RemoveAll(entry =>
|
||||
{
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(result, pending[i]))
|
||||
return !_presentation.HasPendingPublication(result);
|
||||
if (ReferenceEquals(entry.Result, pending[i]))
|
||||
{
|
||||
return !_presentation.HasPendingPublication(
|
||||
entry.Result);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
RecalculateDeferredAdoptedBytes();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -460,59 +490,74 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
/// </summary>
|
||||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||
{
|
||||
if (_originRecenterRetirement is not null)
|
||||
if (_activeWorkMeter is not null)
|
||||
throw new InvalidOperationException(
|
||||
"StreamingController.Tick cannot be reentered.");
|
||||
|
||||
var meter = new StreamingWorkMeter(_workBudget);
|
||||
_activeWorkMeter = meter;
|
||||
try
|
||||
{
|
||||
if (_originRecenterRetirement is not null)
|
||||
{
|
||||
_presentation.AdvanceRetirements();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
AdvanceRadiiReconfiguration();
|
||||
else if (_deferredRadiiRequest is { } deferred)
|
||||
{
|
||||
_deferredRadiiRequest = null;
|
||||
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
|
||||
}
|
||||
|
||||
// Presentation-owner failures never roll back spatial state. Resume
|
||||
// unfinished owner ledgers before accepting replacement publications.
|
||||
_presentation.AdvanceRetirements();
|
||||
return;
|
||||
}
|
||||
// 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 (_pendingRadiiReconfiguration is not null)
|
||||
AdvanceRadiiReconfiguration();
|
||||
else if (_deferredRadiiRequest is { } deferred)
|
||||
{
|
||||
_deferredRadiiRequest = null;
|
||||
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
|
||||
}
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
// Presentation-owner failures never roll back spatial state. Resume
|
||||
// unfinished owner ledgers before accepting replacement publications.
|
||||
_presentation.AdvanceRetirements();
|
||||
// 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();
|
||||
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
if (_collapsed)
|
||||
{
|
||||
// Hysteresis. Cases:
|
||||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||||
// → re-collapse onto it.
|
||||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||||
// observer landblock reverts to the position-derived value, which for a
|
||||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||||
// local Y). Hold — never expand on an adjacent flicker.
|
||||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||||
// from the ocean-grid dungeon block) → expand.
|
||||
if (insideDungeon && centerId != _collapsedCenter)
|
||||
if (_collapsed)
|
||||
{
|
||||
// Hysteresis. Cases:
|
||||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||||
// → re-collapse onto it.
|
||||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||||
// observer landblock reverts to the position-derived value, which for a
|
||||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||||
// local Y). Hold — never expand on an adjacent flicker.
|
||||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||||
// from the ocean-grid dungeon block) → expand.
|
||||
if (insideDungeon && centerId != _collapsedCenter)
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||||
ExitDungeonExpand(observerCx, observerCy);
|
||||
else
|
||||
SweepCollapsed();
|
||||
}
|
||||
else if (insideDungeon)
|
||||
{
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||||
ExitDungeonExpand(observerCx, observerCy);
|
||||
}
|
||||
else
|
||||
SweepCollapsed();
|
||||
}
|
||||
else if (insideDungeon)
|
||||
{
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
NormalTick(observerCx, observerCy);
|
||||
}
|
||||
{
|
||||
NormalTick(observerCx, observerCy);
|
||||
}
|
||||
|
||||
DrainAndApply();
|
||||
DrainAndApply();
|
||||
}
|
||||
finally
|
||||
{
|
||||
meter.FinishFrame();
|
||||
_lastWorkMeter = meter.Snapshot;
|
||||
_activeWorkMeter = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void AdvanceRadiiReconfiguration()
|
||||
|
|
@ -580,7 +625,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_region = pending.Region;
|
||||
NearRadius = pending.NearRadius;
|
||||
FarRadius = pending.FarRadius;
|
||||
_deferredApply.Clear();
|
||||
ClearDeferred();
|
||||
_pendingRadiiReconfiguration = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -721,7 +766,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
ClearDeferred();
|
||||
_region = null;
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
|
|
@ -1002,7 +1047,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
if (!transaction.DeferredApplyCleared)
|
||||
{
|
||||
_deferredApply.Clear();
|
||||
ClearDeferred();
|
||||
transaction.DeferredApplyCleared = true;
|
||||
}
|
||||
if (!transaction.RegionCleared)
|
||||
|
|
@ -1061,7 +1106,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
AdvanceGeneration();
|
||||
_collapsed = false;
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
ClearDeferred();
|
||||
// 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.
|
||||
|
|
@ -1111,13 +1156,15 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
&& !_presentation.HasPendingPublication(result))
|
||||
continue;
|
||||
if (IsPublicationBlockedByRetirement(result))
|
||||
_deferredApply.Add(result);
|
||||
Defer(result);
|
||||
else if (result is LandblockStreamResult.Unloaded
|
||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||
ApplyResultObserved(
|
||||
result,
|
||||
"priority-or-unload");
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -1129,14 +1176,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// its failed result is consumed, but the untouched
|
||||
// suffix must still survive.
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
_deferredApply.Add(result);
|
||||
Defer(result);
|
||||
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
||||
_deferredApply.Add(chunk[suffix]);
|
||||
Defer(chunk[suffix]);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||||
Defer(result); // a GPU-upload load — meter it in step 2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1151,11 +1198,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
{
|
||||
while (applied < budget && read < _deferredApply.Count)
|
||||
{
|
||||
LandblockStreamResult result = _deferredApply[read];
|
||||
DeferredCompletion entry = _deferredApply[read];
|
||||
LandblockStreamResult result = entry.Result;
|
||||
if (IsPublicationBlockedByRetirement(result))
|
||||
{
|
||||
if (write != read)
|
||||
_deferredApply[write] = result;
|
||||
_deferredApply[write] = entry;
|
||||
write++;
|
||||
read++;
|
||||
continue;
|
||||
|
|
@ -1166,7 +1214,10 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// exact result and the untouched tail for retry.
|
||||
try
|
||||
{
|
||||
ApplyResult(result);
|
||||
ApplyResultObserved(
|
||||
result,
|
||||
"deferred-publication",
|
||||
entry.Cost);
|
||||
read++;
|
||||
applied++;
|
||||
}
|
||||
|
|
@ -1190,10 +1241,109 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// retained blocked prefix and the untouched FIFO tail keep their
|
||||
// original relative order.
|
||||
if (read > write)
|
||||
_deferredApply.RemoveRange(write, read - write);
|
||||
RemoveDeferredRange(write, read - write);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyResultObserved(
|
||||
LandblockStreamResult result,
|
||||
string stage,
|
||||
StreamingWorkCost? knownCost = null) =>
|
||||
ObserveResultOperation(
|
||||
result,
|
||||
stage,
|
||||
() => ApplyResult(result),
|
||||
knownCost);
|
||||
|
||||
private void ObserveResultOperation(
|
||||
LandblockStreamResult result,
|
||||
string stage,
|
||||
Action operation,
|
||||
StreamingWorkCost? knownCost = null)
|
||||
{
|
||||
StreamingWorkMeter? meter = _activeWorkMeter;
|
||||
if (meter is null)
|
||||
{
|
||||
operation();
|
||||
return;
|
||||
}
|
||||
|
||||
StreamingWorkCost cost = knownCost
|
||||
?? LandblockStreamResultCost.Estimate(result).Work;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
meter.ForceReserveObserved(cost, stage);
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
meter.Complete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue