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:
Erik 2026-07-24 17:26:34 +02:00
parent 2945896a6f
commit ac45cb1bd7
14 changed files with 1280 additions and 71 deletions

View file

@ -295,7 +295,8 @@ internal sealed class SessionPlayerCompositionPhase
nearRadius: nearRadius,
farRadius: farRadius,
presentationPipeline: live.LandblockPipeline,
clearPendingLoads: streamerLease.Resource.ClearPendingLoads);
clearPendingLoads: streamerLease.Resource.ClearPendingLoads,
workBudgetOptions: d.Options.StreamingWorkBudgets);
var streamingOriginRecenter = new StreamingOriginRecenterCoordinator(
streaming,
d.WorldOrigin);

View file

@ -77,6 +77,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
long DatObjectCacheHits,
long DatObjectCacheMisses,
long DatObjectCacheEvictions,
StreamingWorkDiagnostics StreamingWork,
ResidencySnapshot Residency,
double Fps,
double FrameMilliseconds,

View file

@ -163,6 +163,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
DatObjectCacheHits: datObjectCacheStats.Hits,
DatObjectCacheMisses: datObjectCacheStats.Misses,
DatObjectCacheEvictions: datObjectCacheStats.Evictions,
StreamingWork: _streaming.WorkDiagnostics,
Residency: residency,
Fps: render.Fps,
FrameMilliseconds: render.FrameMilliseconds,

View file

@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.IO;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
namespace AcDream.App;
@ -52,7 +53,8 @@ public sealed record RuntimeOptions(
string? AutomationArtifactDirectory,
float FogStartMultiplier,
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets)
ResidencyBudgetOptions ResidencyBudgets,
StreamingWorkBudgetOptions StreamingWorkBudgets)
{
/// <summary>
/// Build options from the process environment. Used by
@ -110,7 +112,8 @@ public sealed record RuntimeOptions(
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env));
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env));
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>

View file

@ -0,0 +1,187 @@
using System.Runtime.CompilerServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Deterministic scheduling charge derived from one immutable worker result.
/// It is not a claim about CLR object-header or allocator-bucket size: those
/// implementation details are neither stable nor observable without walking
/// the managed heap. Exact array payloads and logical retained entries are
/// charged consistently so admission has a deterministic unit.
/// </summary>
public readonly record struct LandblockStreamCostEstimate(
StreamingWorkCost Work,
long TerrainPayloadBytes,
int Entities,
int MeshReferences,
int VisibilityCells,
int EnvCellShells,
int PortalConnections,
int PortalPolygonVertices,
int PhysicsEnvCells,
int PhysicsEnvironments,
int PhysicsSetups,
int PhysicsGfxObjects);
public static class LandblockStreamResultCost
{
private const int ReferenceChargeBytes = 8;
private const int DictionaryEntryChargeBytes = 16;
public static LandblockStreamCostEstimate Estimate(
LandblockStreamResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result switch
{
LandblockStreamResult.Loaded loaded =>
Estimate(loaded.Build, loaded.MeshData),
LandblockStreamResult.Promoted promoted =>
Estimate(promoted.Build, promoted.MeshData),
_ => new LandblockStreamCostEstimate(
new StreamingWorkCost(CompletionAdmissions: 1),
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0),
};
}
public static LandblockStreamCostEstimate Estimate(
LandblockBuild build,
LandblockMeshData meshData)
{
ArgumentNullException.ThrowIfNull(build);
ArgumentNullException.ThrowIfNull(meshData);
long terrainBytes = Add(
Multiply(meshData.Vertices.LongLength, Unsafe.SizeOf<TerrainVertex>()),
Multiply(meshData.Indices.LongLength, sizeof(uint)));
long retainedBytes = terrainBytes;
IReadOnlyList<WorldEntity> entities = build.Landblock.Entities;
int meshReferences = 0;
retainedBytes = Add(
retainedBytes,
Multiply(entities.Count, ReferenceChargeBytes));
for (int i = 0; i < entities.Count; i++)
{
int count = entities[i].MeshRefs.Count;
meshReferences = SaturatingAdd(meshReferences, count);
retainedBytes = Add(
retainedBytes,
Multiply(count, Unsafe.SizeOf<MeshRef>()));
}
int visibilityCells = 0;
int shells = 0;
int portalConnections = 0;
int portalPolygonVertices = 0;
if (build.EnvCells is { } envCells)
{
visibilityCells = envCells.VisibilityCells.Length;
shells = envCells.Shells.Length;
retainedBytes = Add(
retainedBytes,
Multiply(
(long)visibilityCells + shells,
ReferenceChargeBytes));
foreach (var cell in envCells.VisibilityCells)
{
portalConnections = SaturatingAdd(
portalConnections,
cell.Portals.Count);
retainedBytes = Add(
retainedBytes,
Multiply(
cell.Portals.Count,
Unsafe.SizeOf<Rendering.CellPortalInfo>()));
retainedBytes = Add(
retainedBytes,
Multiply(
cell.ClipPlanes.Count,
Unsafe.SizeOf<Rendering.PortalClipPlane>()));
retainedBytes = Add(
retainedBytes,
Multiply(
cell.VisibleCells.Count,
sizeof(uint)));
foreach (System.Numerics.Vector3[] polygon in cell.PortalPolygons)
{
portalPolygonVertices = SaturatingAdd(
portalPolygonVertices,
polygon.Length);
retainedBytes = Add(
retainedBytes,
Multiply(
polygon.LongLength,
Unsafe.SizeOf<System.Numerics.Vector3>()));
}
}
foreach (EnvCellShellPlacement shell in envCells.Shells)
{
retainedBytes = Add(
retainedBytes,
Multiply(shell.Surfaces.Length, sizeof(ushort)));
}
}
PhysicsDatBundle physics =
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
int physicsEnvCells = physics.EnvCells.Count;
int physicsEnvironments = physics.Environments.Count;
int physicsSetups = physics.Setups.Count;
int physicsGfxObjects = physics.GfxObjs.Count;
long physicsEntries = (long)physicsEnvCells
+ physicsEnvironments
+ physicsSetups
+ physicsGfxObjects;
retainedBytes = Add(
retainedBytes,
Multiply(physicsEntries, DictionaryEntryChargeBytes));
return new LandblockStreamCostEstimate(
new StreamingWorkCost(
CompletionAdmissions: 1,
AdoptedCpuBytes: retainedBytes,
EntityOperations: entities.Count,
GpuUploadBytes: terrainBytes),
terrainBytes,
entities.Count,
meshReferences,
visibilityCells,
shells,
portalConnections,
portalPolygonVertices,
physicsEnvCells,
physicsEnvironments,
physicsSetups,
physicsGfxObjects);
}
private static long Multiply(long count, int elementBytes) =>
count <= 0
? 0
: count > long.MaxValue / elementBytes
? long.MaxValue
: count * elementBytes;
private static long Add(long left, long right) =>
left > long.MaxValue - right ? long.MaxValue : left + right;
private static int SaturatingAdd(int left, int right) =>
left > int.MaxValue - right ? int.MaxValue : left + right;
}

View file

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

View file

@ -0,0 +1,364 @@
using System.Diagnostics;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable, validated per-frame streaming work profile.
/// </summary>
public readonly record struct StreamingWorkBudget
{
public StreamingWorkBudget(
TimeSpan maxUpdateTime,
int maxCompletionAdmissions,
long maxAdoptedCpuBytes,
int maxEntityOperations,
long maxGpuUploadBytes,
int maxGlRetireOperations,
float destinationReserveFraction)
{
if (maxUpdateTime <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(maxUpdateTime));
if (maxCompletionAdmissions <= 0)
throw new ArgumentOutOfRangeException(nameof(maxCompletionAdmissions));
if (maxAdoptedCpuBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maxAdoptedCpuBytes));
if (maxEntityOperations <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntityOperations));
if (maxGpuUploadBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maxGpuUploadBytes));
if (maxGlRetireOperations <= 0)
throw new ArgumentOutOfRangeException(nameof(maxGlRetireOperations));
if (!float.IsFinite(destinationReserveFraction)
|| destinationReserveFraction <= 0f
|| destinationReserveFraction >= 1f)
{
throw new ArgumentOutOfRangeException(
nameof(destinationReserveFraction));
}
MaxUpdateTime = maxUpdateTime;
MaxCompletionAdmissions = maxCompletionAdmissions;
MaxAdoptedCpuBytes = maxAdoptedCpuBytes;
MaxEntityOperations = maxEntityOperations;
MaxGpuUploadBytes = maxGpuUploadBytes;
MaxGlRetireOperations = maxGlRetireOperations;
DestinationReserveFraction = destinationReserveFraction;
}
public TimeSpan MaxUpdateTime { get; }
public int MaxCompletionAdmissions { get; }
public long MaxAdoptedCpuBytes { get; }
public int MaxEntityOperations { get; }
public long MaxGpuUploadBytes { get; }
public int MaxGlRetireOperations { get; }
public float DestinationReserveFraction { get; }
internal void Validate()
{
if (MaxUpdateTime <= TimeSpan.Zero
|| MaxCompletionAdmissions <= 0
|| MaxAdoptedCpuBytes <= 0
|| MaxEntityOperations <= 0
|| MaxGpuUploadBytes <= 0
|| MaxGlRetireOperations <= 0
|| !float.IsFinite(DestinationReserveFraction)
|| DestinationReserveFraction <= 0f
|| DestinationReserveFraction >= 1f)
{
throw new ArgumentException(
"Streaming work budget is not a valid complete profile.",
nameof(StreamingWorkBudget));
}
}
}
/// <summary>
/// Conservative, known cost of one atomic streaming operation.
/// </summary>
public readonly record struct StreamingWorkCost(
int CompletionAdmissions = 0,
long AdoptedCpuBytes = 0,
int EntityOperations = 0,
long GpuUploadBytes = 0,
int GlRetireOperations = 0)
{
public bool IsZero =>
CompletionAdmissions == 0
&& AdoptedCpuBytes == 0
&& EntityOperations == 0
&& GpuUploadBytes == 0
&& GlRetireOperations == 0;
internal void Validate()
{
if (CompletionAdmissions < 0)
throw new ArgumentOutOfRangeException(nameof(CompletionAdmissions));
if (AdoptedCpuBytes < 0)
throw new ArgumentOutOfRangeException(nameof(AdoptedCpuBytes));
if (EntityOperations < 0)
throw new ArgumentOutOfRangeException(nameof(EntityOperations));
if (GpuUploadBytes < 0)
throw new ArgumentOutOfRangeException(nameof(GpuUploadBytes));
if (GlRetireOperations < 0)
throw new ArgumentOutOfRangeException(nameof(GlRetireOperations));
}
internal StreamingWorkCost Add(StreamingWorkCost other) => new(
SaturatingAdd(CompletionAdmissions, other.CompletionAdmissions),
SaturatingAdd(AdoptedCpuBytes, other.AdoptedCpuBytes),
SaturatingAdd(EntityOperations, other.EntityOperations),
SaturatingAdd(GpuUploadBytes, other.GpuUploadBytes),
SaturatingAdd(GlRetireOperations, other.GlRetireOperations));
private static int SaturatingAdd(int left, int right) =>
left > int.MaxValue - right ? int.MaxValue : left + right;
private static long SaturatingAdd(long left, long right) =>
left > long.MaxValue - right ? long.MaxValue : left + right;
}
public enum StreamingWorkAdmission : byte
{
Admitted,
OversizedProgress,
Yielded,
}
public enum StreamingWorkLimit : byte
{
None,
Time,
CompletionAdmissions,
AdoptedCpuBytes,
EntityOperations,
GpuUploadBytes,
GlRetireOperations,
}
/// <summary>
/// Immutable observation of one frame's streaming work.
/// </summary>
public readonly record struct StreamingWorkMeterSnapshot(
StreamingWorkCost Used,
int Operations,
int CompletedOperations,
int YieldCount,
int OverrunCount,
int OversizedProgressCount,
int FailureCount,
double ElapsedMilliseconds,
string? LastStage,
StreamingWorkLimit LastLimit);
/// <summary>
/// Read-only streaming scheduler facts published to lifecycle artifacts.
/// </summary>
public readonly record struct StreamingWorkDiagnostics(
StreamingWorkMeterSnapshot LastFrame,
int DeferredCompletions,
long DeferredAdoptedCpuBytes,
double OldestDeferredAgeMilliseconds,
int PendingPublications,
int PendingRetirements);
/// <summary>
/// Single-thread, frame-scoped admission meter. Callers reserve a known cost
/// before an atomic operation and complete or fail that reservation afterward.
/// </summary>
public sealed class StreamingWorkMeter
{
private readonly StreamingWorkBudget _budget;
private readonly Func<long> _timestamp;
private readonly long _frequency;
private readonly long _start;
private StreamingWorkCost _used;
private int _operations;
private int _completed;
private int _yields;
private int _overruns;
private int _oversizedProgress;
private int _failures;
private bool _frameOverrunRecorded;
private bool _reservationActive;
private string? _activeStage;
private string? _lastStage;
private StreamingWorkLimit _lastLimit;
public StreamingWorkMeter(StreamingWorkBudget budget)
: this(budget, Stopwatch.GetTimestamp, Stopwatch.Frequency)
{
}
public StreamingWorkMeter(
StreamingWorkBudget budget,
Func<long> timestamp,
long timestampFrequency)
{
ArgumentNullException.ThrowIfNull(timestamp);
if (timestampFrequency <= 0)
throw new ArgumentOutOfRangeException(nameof(timestampFrequency));
budget.Validate();
_budget = budget;
_timestamp = timestamp;
_frequency = timestampFrequency;
_start = timestamp();
}
public StreamingWorkAdmission TryReserve(
StreamingWorkCost cost,
string stage)
{
cost.Validate();
ArgumentException.ThrowIfNullOrWhiteSpace(stage);
if (_reservationActive)
throw new InvalidOperationException(
"The prior streaming operation has not completed.");
StreamingWorkLimit limit = FindLimit(cost);
if (limit != StreamingWorkLimit.None && _operations != 0)
{
_yields++;
_lastStage = stage;
_lastLimit = limit;
return StreamingWorkAdmission.Yielded;
}
_used = _used.Add(cost);
_operations++;
_reservationActive = true;
_activeStage = stage;
_lastStage = stage;
_lastLimit = limit;
if (limit != StreamingWorkLimit.None)
{
_oversizedProgress++;
return StreamingWorkAdmission.OversizedProgress;
}
return StreamingWorkAdmission.Admitted;
}
/// <summary>
/// E1 observation seam: records work the legacy scheduler already chose to
/// execute, while naming the budget decision that would have yielded. This
/// preserves execution until the explicit scheduler takes ownership in E2.
/// </summary>
public void ForceReserveObserved(
StreamingWorkCost cost,
string stage)
{
cost.Validate();
ArgumentException.ThrowIfNullOrWhiteSpace(stage);
if (_reservationActive)
throw new InvalidOperationException(
"The prior streaming operation has not completed.");
_used = _used.Add(cost);
_operations++;
_reservationActive = true;
_activeStage = stage;
_lastStage = stage;
}
public void Complete()
{
EnsureReservation();
_completed++;
_reservationActive = false;
_activeStage = null;
ObserveElapsedOverrun();
}
public void Fail()
{
EnsureReservation();
_failures++;
_reservationActive = false;
_activeStage = null;
ObserveElapsedOverrun();
}
public void FinishFrame()
{
if (_reservationActive)
throw new InvalidOperationException(
"A streaming operation is still reserved at frame end.");
ObserveElapsedOverrun();
}
public StreamingWorkMeterSnapshot Snapshot
{
get
{
long now = _timestamp();
return new StreamingWorkMeterSnapshot(
_used,
_operations,
_completed,
_yields,
_overruns,
_oversizedProgress,
_failures,
ElapsedMilliseconds(now),
_lastStage,
_lastLimit);
}
}
private StreamingWorkLimit FindLimit(StreamingWorkCost cost)
{
if (ElapsedTicks(_timestamp()) >= _budget.MaxUpdateTime.Ticks)
return StreamingWorkLimit.Time;
if ((long)_used.CompletionAdmissions + cost.CompletionAdmissions
> _budget.MaxCompletionAdmissions)
{
return StreamingWorkLimit.CompletionAdmissions;
}
if (_used.AdoptedCpuBytes > _budget.MaxAdoptedCpuBytes - cost.AdoptedCpuBytes)
return StreamingWorkLimit.AdoptedCpuBytes;
if ((long)_used.EntityOperations + cost.EntityOperations
> _budget.MaxEntityOperations)
{
return StreamingWorkLimit.EntityOperations;
}
if (_used.GpuUploadBytes > _budget.MaxGpuUploadBytes - cost.GpuUploadBytes)
return StreamingWorkLimit.GpuUploadBytes;
if ((long)_used.GlRetireOperations + cost.GlRetireOperations
> _budget.MaxGlRetireOperations)
{
return StreamingWorkLimit.GlRetireOperations;
}
return StreamingWorkLimit.None;
}
private void EnsureReservation()
{
if (!_reservationActive)
throw new InvalidOperationException(
"No streaming operation is reserved.");
}
private void ObserveElapsedOverrun()
{
if (!_frameOverrunRecorded
&& ElapsedTicks(_timestamp()) > _budget.MaxUpdateTime.Ticks)
{
_frameOverrunRecorded = true;
_overruns++;
_lastLimit = StreamingWorkLimit.Time;
_lastStage = _activeStage ?? _lastStage;
}
}
private long ElapsedTicks(long timestamp)
{
long raw = Math.Max(0L, timestamp - _start);
return raw > long.MaxValue / TimeSpan.TicksPerSecond
? long.MaxValue
: raw * TimeSpan.TicksPerSecond / _frequency;
}
private double ElapsedMilliseconds(long timestamp) =>
Math.Max(0L, timestamp - _start) * 1000.0 / _frequency;
}

View file

@ -0,0 +1,121 @@
using System.Globalization;
namespace AcDream.App.Streaming;
/// <summary>
/// Startup-time scheduling ceilings for update-thread streaming work.
/// These values limit work per frame; they never reduce the amount or
/// quality of content that eventually becomes resident.
/// </summary>
public sealed record StreamingWorkBudgetOptions(
double MaxUpdateMilliseconds,
int MaxCompletionAdmissions,
long MaxAdoptedCpuBytes,
int MaxEntityOperations,
long MaxGpuUploadBytes,
int MaxGlRetireOperations,
float DestinationReserveFraction)
{
public const long MiB = 1024L * 1024L;
public static StreamingWorkBudgetOptions Default { get; } = new(
MaxUpdateMilliseconds: 2.0,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 8 * MiB,
MaxEntityOperations: 256,
MaxGpuUploadBytes: 8 * MiB,
MaxGlRetireOperations: 64,
DestinationReserveFraction: 0.75f);
internal static StreamingWorkBudgetOptions Parse(
Func<string, string?> env)
{
ArgumentNullException.ThrowIfNull(env);
StreamingWorkBudgetOptions defaults = Default;
return new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: ParsePositiveDouble(
env("ACDREAM_STREAM_WORK_MS"),
defaults.MaxUpdateMilliseconds),
MaxCompletionAdmissions: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_COMPLETIONS"),
defaults.MaxCompletionAdmissions),
MaxAdoptedCpuBytes: ParseMiB(
env("ACDREAM_STREAM_WORK_CPU_MIB"),
defaults.MaxAdoptedCpuBytes),
MaxEntityOperations: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_ENTITY_OPS"),
defaults.MaxEntityOperations),
MaxGpuUploadBytes: ParseMiB(
env("ACDREAM_STREAM_WORK_GPU_MIB"),
defaults.MaxGpuUploadBytes),
MaxGlRetireOperations: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_GL_RETIRE_OPS"),
defaults.MaxGlRetireOperations),
DestinationReserveFraction: ParseReservePercent(
env("ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"),
defaults.DestinationReserveFraction));
}
public StreamingWorkBudget ToBudget() => new(
TimeSpan.FromMilliseconds(MaxUpdateMilliseconds),
MaxCompletionAdmissions,
MaxAdoptedCpuBytes,
MaxEntityOperations,
MaxGpuUploadBytes,
MaxGlRetireOperations,
DestinationReserveFraction);
private static double ParsePositiveDouble(string? value, double fallback) =>
double.TryParse(
value,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double parsed)
&& double.IsFinite(parsed)
&& parsed > 0
? parsed
: fallback;
private static int ParsePositiveInt(string? value, int fallback) =>
int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: fallback;
private static long ParseMiB(string? value, long fallback)
{
if (!long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out long mebibytes)
|| mebibytes <= 0
|| mebibytes > long.MaxValue / MiB)
{
return fallback;
}
return checked(mebibytes * MiB);
}
private static float ParseReservePercent(string? value, float fallback)
{
if (!float.TryParse(
value,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out float percent)
|| !float.IsFinite(percent)
|| percent <= 0f
|| percent >= 100f)
{
return fallback;
}
return percent / 100f;
}
}