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

@ -708,6 +708,16 @@ replace state produced by another job. CPU mesh extraction may still be schedule
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
partially hydrated cell membership or shell placement to the renderer. partially hydrated cell membership or shell placement to the renderer.
`RuntimeOptions.StreamingWorkBudgets` is the single startup profile for
update-thread streaming cost. `StreamingWorkMeter` owns the frame-scoped
admission ledger; `LandblockStreamResultCost` assigns deterministic charges to
immutable completion arrays and logical retained entries without claiming to
measure CLR allocator overhead. During Slice E1 the ledger is observational:
the existing scheduler still executes its accepted work, while lifecycle
artifacts record would-yield limits, elapsed work, retained backlog bytes/age,
and pending publication/retirement counts. Enforcement belongs to the explicit
Slice E scheduler, not to presentation owners or the worker.
### World-reveal readiness ownership ### World-reveal readiness ownership
`WorldRevealCoordinator` is the single App-layer owner of each login or portal `WorldRevealCoordinator` is the single App-layer owner of each login or portal

View file

@ -2,7 +2,7 @@
**Date:** 2026-07-24 **Date:** 2026-07-24
**Status:** Slices AD complete. Slice E is active. **Status:** Slices AD complete. Slice E is active (E0/E1 complete).
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
@ -726,6 +726,12 @@ checkpoint retained staged or retiring bytes. Evidence:
**Active plan:** [`2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md`](2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md). **Active plan:** [`2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md`](2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md).
**Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current
owner inventory. E1 landed the validated work profile, deterministic meter and
completion charge model, plus lifecycle-artifact shadow diagnostics while
preserving the prior scheduler's execution. E2 is the first enforcement
cutover.
**Purpose:** Remove update-thread portal transactions. **Purpose:** Remove update-thread portal transactions.
- Introduce explicit stage queues and `StreamingWorkBudget`. - Introduce explicit stage queues and `StreamingWorkBudget`.

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice E — Cost-budgeted streaming and retirement # Modern Runtime Slice E — Cost-budgeted streaming and retirement
**Status:** active — E0 contract/inventory complete **Status:** active — E0/E1 complete; E2 explicit admission queues next
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice D closeout commit `66690805` **Baseline:** Slice D closeout commit `66690805`
**Behavior contract:** no visual-quality or retail-behavior reduction **Behavior contract:** no visual-quality or retail-behavior reduction
@ -232,6 +232,20 @@ the active destination generation. Far-ring work may continue after reveal.
lifecycle artifacts. lifecycle artifacts.
- Preserve current execution while measuring the would-yield decisions. - Preserve current execution while measuring the would-yield decisions.
**Complete 2026-07-24.** `RuntimeOptions` now owns one validated scheduling
profile (time, admissions, retained CPU bytes, entity operations, requested GPU
bytes, GL retire operations, and destination reserve). The single-thread meter
has a deterministic clock seam, first-operation progress rule, explicit
yield/oversize/failure/overrun facts, and no effect on legacy execution at this
checkpoint. Immutable completion payloads receive deterministic charges for
their exact array data and logical retained entries; this is deliberately not
misreported as CLR heap size. Lifecycle JSON now includes last-frame work,
deferred retained bytes/age, and pending publication/retirement counts.
Validation: complete App suite 3,643 passed / 3 skipped; complete solution
8,124 passed / 5 skipped; Release solution build green. Existing priority,
retry, stale-generation, and deferred-compaction tests remain green.
### E2 — Explicit admission queues ### E2 — Explicit admission queues
- Replace `_deferredApply` with stable generation/priority queues. - Replace `_deferredApply` with stable generation/priority queues.

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
namespace AcDream.App; namespace AcDream.App;
@ -52,7 +53,8 @@ public sealed record RuntimeOptions(
string? AutomationArtifactDirectory, string? AutomationArtifactDirectory,
float FogStartMultiplier, float FogStartMultiplier,
float FogEndMultiplier, float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets) ResidencyBudgetOptions ResidencyBudgets,
StreamingWorkBudgetOptions StreamingWorkBudgets)
{ {
/// <summary> /// <summary>
/// Build options from the process environment. Used by /// Build options from the process environment. Used by
@ -110,7 +112,8 @@ public sealed record RuntimeOptions(
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, 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> /// <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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
@ -18,6 +19,11 @@ namespace AcDream.App.Streaming;
/// </summary> /// </summary>
public sealed class StreamingController : IStreamingFrameBackend public sealed class StreamingController : IStreamingFrameBackend
{ {
private readonly record struct DeferredCompletion(
LandblockStreamResult Result,
StreamingWorkCost Cost,
long EnqueuedTimestamp);
private sealed class OriginRecenterRetirement private sealed class OriginRecenterRetirement
{ {
public bool RadiiConverged; public bool RadiiConverged;
@ -38,6 +44,10 @@ public sealed class StreamingController : IStreamingFrameBackend
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action? _clearPendingLoads; private readonly Action? _clearPendingLoads;
private readonly LandblockPresentationPipeline _presentation; private readonly LandblockPresentationPipeline _presentation;
private readonly StreamingWorkBudget _workBudget;
private StreamingWorkMeter? _activeWorkMeter;
private StreamingWorkMeterSnapshot _lastWorkMeter;
private long _deferredAdoptedCpuBytes;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
@ -133,6 +143,13 @@ public sealed class StreamingController : IStreamingFrameBackend
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count; public int DeferredApplyBacklog => _deferredApply.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount; public int PendingRetirementCount => _presentation.PendingRetirementCount;
public StreamingWorkDiagnostics WorkDiagnostics => new(
_lastWorkMeter,
_deferredApply.Count,
_deferredAdoptedCpuBytes,
OldestDeferredAgeMilliseconds(),
_presentation.PendingPublicationCount,
_presentation.PendingRetirementCount);
internal bool IsCollapsedToDungeon => _collapsed; internal bool IsCollapsedToDungeon => _collapsed;
/// <summary> /// <summary>
@ -180,7 +197,7 @@ public sealed class StreamingController : IStreamingFrameBackend
// Completions that were drained past a priority item get buffered here // Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss. // so they still apply over subsequent frames without loss.
private readonly List<LandblockStreamResult> _deferredApply = new(); private readonly List<DeferredCompletion> _deferredApply = new();
/// <summary> /// <summary>
/// Internal compatibility seam for hermetic controller policy tests. Live /// Internal compatibility seam for hermetic controller policy tests. Live
/// composition cannot supply presentation callbacks; it must use the /// composition cannot supply presentation callbacks; it must use the
@ -232,7 +249,8 @@ public sealed class StreamingController : IStreamingFrameBackend
int nearRadius, int nearRadius,
int farRadius, int farRadius,
LandblockPresentationPipeline presentationPipeline, LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null) Action? clearPendingLoads = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
{ {
_enqueueLoad = enqueueLoad; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
@ -241,6 +259,8 @@ public sealed class StreamingController : IStreamingFrameBackend
_state = state; _state = state;
_presentation = presentationPipeline _presentation = presentationPipeline
?? throw new ArgumentNullException(nameof(presentationPipeline)); ?? throw new ArgumentNullException(nameof(presentationPipeline));
_workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default)
.ToBudget();
if (!_presentation.MatchesState(_state)) if (!_presentation.MatchesState(_state))
{ {
throw new ArgumentException( throw new ArgumentException(
@ -425,22 +445,32 @@ public sealed class StreamingController : IStreamingFrameBackend
try try
{ {
for (int i = 0; i < pending.Count; i++) for (int i = 0; i < pending.Count; i++)
_presentation.ResumePublication(pending[i]); {
LandblockStreamResult result = pending[i];
ObserveResultOperation(
result,
"publication-retry",
() => _presentation.ResumePublication(result));
}
} }
finally finally
{ {
// A completed staged result can still be present in the deferred // A completed staged result can still be present in the deferred
// FIFO that originally retained it. Remove only those exact // FIFO that originally retained it. Remove only those exact
// objects; unrelated accepted completions keep their order. // objects; unrelated accepted completions keep their order.
_deferredApply.RemoveAll(result => _deferredApply.RemoveAll(entry =>
{ {
for (int i = 0; i < pending.Count; i++) for (int i = 0; i < pending.Count; i++)
{ {
if (ReferenceEquals(result, pending[i])) if (ReferenceEquals(entry.Result, pending[i]))
return !_presentation.HasPendingPublication(result); {
return !_presentation.HasPendingPublication(
entry.Result);
}
} }
return false; return false;
}); });
RecalculateDeferredAdoptedBytes();
} }
} }
@ -460,59 +490,74 @@ public sealed class StreamingController : IStreamingFrameBackend
/// </summary> /// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false) 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(); _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) uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume if (_collapsed)
// unfinished owner ledgers before accepting replacement publications. {
_presentation.AdvanceRetirements(); // Hysteresis. Cases:
// Complete an admitted publication before this frame can mutate its // - Still in the SAME dungeon landblock → hold (sweep stragglers).
// desired tier or spatial residence. A later recenter may then demote // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// or retire the fully known owner set through the normal ledger. // → re-collapse onto it.
ConvergePendingPublications(); // - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); // dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
if (_collapsed) // - Genuinely left to a DISTANT landblock (portal/teleport out, always far
{ // from the ocean-grid dungeon block) → expand.
// Hysteresis. Cases: if (insideDungeon && centerId != _collapsedCenter)
// - Still in the SAME dungeon landblock → hold (sweep stragglers). EnterDungeonCollapse(observerCx, observerCy, centerId);
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon) else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
// → re-collapse onto it. ExitDungeonExpand(observerCx, observerCy);
// - CurrCell flickered null but the player hasn't gone anywhere: the else
// observer landblock reverts to the position-derived value, which for a SweepCollapsed();
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell- }
// local Y). Hold — never expand on an adjacent flicker. else if (insideDungeon)
// - 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); EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1) }
ExitDungeonExpand(observerCx, observerCy);
else else
SweepCollapsed(); {
} NormalTick(observerCx, observerCy);
else if (insideDungeon) }
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply(); DrainAndApply();
}
finally
{
meter.FinishFrame();
_lastWorkMeter = meter.Snapshot;
_activeWorkMeter = null;
}
} }
private void AdvanceRadiiReconfiguration() private void AdvanceRadiiReconfiguration()
@ -580,7 +625,7 @@ public sealed class StreamingController : IStreamingFrameBackend
_region = pending.Region; _region = pending.Region;
NearRadius = pending.NearRadius; NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius; FarRadius = pending.FarRadius;
_deferredApply.Clear(); ClearDeferred();
_pendingRadiiReconfiguration = null; _pendingRadiiReconfiguration = null;
} }
} }
@ -721,7 +766,7 @@ public sealed class StreamingController : IStreamingFrameBackend
_collapsed = true; _collapsed = true;
_collapsedCenter = centerId; _collapsedCenter = centerId;
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
_deferredApply.Clear(); ClearDeferred();
_region = null; _region = null;
foreach (var id in _state.LoadedLandblockIds) foreach (var id in _state.LoadedLandblockIds)
@ -1002,7 +1047,7 @@ public sealed class StreamingController : IStreamingFrameBackend
} }
if (!transaction.DeferredApplyCleared) if (!transaction.DeferredApplyCleared)
{ {
_deferredApply.Clear(); ClearDeferred();
transaction.DeferredApplyCleared = true; transaction.DeferredApplyCleared = true;
} }
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
@ -1061,7 +1106,7 @@ public sealed class StreamingController : IStreamingFrameBackend
AdvanceGeneration(); AdvanceGeneration();
_collapsed = false; _collapsed = false;
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
_deferredApply.Clear(); ClearDeferred();
// Commit the old region boundary before any presentation owner is // Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by the presentation // advanced. New same-id publications are fenced by the presentation
// pipeline's retryable retirement ledger. // pipeline's retryable retirement ledger.
@ -1111,13 +1156,15 @@ public sealed class StreamingController : IStreamingFrameBackend
&& !_presentation.HasPendingPublication(result)) && !_presentation.HasPendingPublication(result))
continue; continue;
if (IsPublicationBlockedByRetirement(result)) if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result); Defer(result);
else if (result is LandblockStreamResult.Unloaded else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result))) || IsWithinPriorityRing(ResultLandblockId(result)))
{ {
try try
{ {
ApplyResult(result); // free (unload) or behind-the-fade near ring ApplyResultObserved(
result,
"priority-or-unload");
} }
catch catch
{ {
@ -1129,14 +1176,14 @@ public sealed class StreamingController : IStreamingFrameBackend
// its failed result is consumed, but the untouched // its failed result is consumed, but the untouched
// suffix must still survive. // suffix must still survive.
if (_presentation.HasPendingPublication(result)) if (_presentation.HasPendingPublication(result))
_deferredApply.Add(result); Defer(result);
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++) for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
_deferredApply.Add(chunk[suffix]); Defer(chunk[suffix]);
throw; throw;
} }
} }
else 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) while (applied < budget && read < _deferredApply.Count)
{ {
LandblockStreamResult result = _deferredApply[read]; DeferredCompletion entry = _deferredApply[read];
LandblockStreamResult result = entry.Result;
if (IsPublicationBlockedByRetirement(result)) if (IsPublicationBlockedByRetirement(result))
{ {
if (write != read) if (write != read)
_deferredApply[write] = result; _deferredApply[write] = entry;
write++; write++;
read++; read++;
continue; continue;
@ -1166,7 +1214,10 @@ public sealed class StreamingController : IStreamingFrameBackend
// exact result and the untouched tail for retry. // exact result and the untouched tail for retry.
try try
{ {
ApplyResult(result); ApplyResultObserved(
result,
"deferred-publication",
entry.Cost);
read++; read++;
applied++; applied++;
} }
@ -1190,10 +1241,109 @@ public sealed class StreamingController : IStreamingFrameBackend
// retained blocked prefix and the untouched FIFO tail keep their // retained blocked prefix and the untouched FIFO tail keep their
// original relative order. // original relative order.
if (read > write) 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> /// <summary>
/// Apply a single <see cref="LandblockStreamResult"/> with the full side- /// Apply a single <see cref="LandblockStreamResult"/> with the full side-
/// effects: terrain upload, GPU state, and the re-hydration callback. /// 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;
}
}

View file

@ -307,7 +307,7 @@ public sealed class WorldLifecycleAutomationControllerTests
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
default, 0d, 0d, null); default, default, 0d, 0d, null);
private static string NewDirectory() private static string NewDirectory()
{ {

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.App; using AcDream.App;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
namespace AcDream.App.Tests; namespace AcDream.App.Tests;
@ -20,6 +21,65 @@ public sealed class RuntimeOptionsTests
_ => null); _ => null);
Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets); Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets);
Assert.Equal(
StreamingWorkBudgetOptions.Default,
options.StreamingWorkBudgets);
}
[Fact]
public void StreamingWorkBudgetOverridesAreOneTypedProfile()
{
var values = new Dictionary<string, string?>
{
["ACDREAM_STREAM_WORK_MS"] = "1.75",
["ACDREAM_STREAM_WORK_COMPLETIONS"] = "31",
["ACDREAM_STREAM_WORK_CPU_MIB"] = "6",
["ACDREAM_STREAM_WORK_ENTITY_OPS"] = "144",
["ACDREAM_STREAM_WORK_GPU_MIB"] = "5",
["ACDREAM_STREAM_WORK_GL_RETIRE_OPS"] = "23",
["ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"] = "60",
};
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => values.GetValueOrDefault(name));
Assert.Equal(1.75, options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(31, options.StreamingWorkBudgets.MaxCompletionAdmissions);
Assert.Equal(
6 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxAdoptedCpuBytes);
Assert.Equal(144, options.StreamingWorkBudgets.MaxEntityOperations);
Assert.Equal(
5 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxGpuUploadBytes);
Assert.Equal(23, options.StreamingWorkBudgets.MaxGlRetireOperations);
Assert.Equal(0.60f, options.StreamingWorkBudgets.DestinationReserveFraction);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("NaN")]
[InlineData("Infinity")]
[InlineData("bad")]
public void InvalidStreamingWorkValuesFallBackIndependently(string value)
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => name switch
{
"ACDREAM_STREAM_WORK_MS" => value,
"ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT" => value,
_ => null,
});
Assert.Equal(
StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds,
options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(
StreamingWorkBudgetOptions.Default.DestinationReserveFraction,
options.StreamingWorkBudgets.DestinationReserveFraction);
} }
[Fact] [Fact]

View file

@ -0,0 +1,291 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class StreamingWorkBudgetTests
{
private static StreamingWorkBudget Budget(
double milliseconds = 2,
int completions = 4,
long cpuBytes = 100,
int entityOperations = 8,
long gpuBytes = 100,
int retireOperations = 4) => new(
TimeSpan.FromMilliseconds(milliseconds),
completions,
cpuBytes,
entityOperations,
gpuBytes,
retireOperations,
0.75f);
[Fact]
public void MeterYieldsBeforeSecondOperationThatExceedsAnyDimension()
{
long now = 0;
var meter = new StreamingWorkMeter(
Budget(),
() => now,
timestampFrequency: 1_000);
Assert.Equal(
StreamingWorkAdmission.Admitted,
meter.TryReserve(
new StreamingWorkCost(
CompletionAdmissions: 2,
AdoptedCpuBytes: 60,
EntityOperations: 3,
GpuUploadBytes: 40,
GlRetireOperations: 1),
"first"));
meter.Complete();
Assert.Equal(
StreamingWorkAdmission.Yielded,
meter.TryReserve(
new StreamingWorkCost(AdoptedCpuBytes: 41),
"second"));
StreamingWorkMeterSnapshot snapshot = meter.Snapshot;
Assert.Equal(1, snapshot.Operations);
Assert.Equal(1, snapshot.CompletedOperations);
Assert.Equal(1, snapshot.YieldCount);
Assert.Equal(StreamingWorkLimit.AdoptedCpuBytes, snapshot.LastLimit);
Assert.Equal("second", snapshot.LastStage);
}
[Fact]
public void FirstOversizedOperationProgressesAndNamesTheOversize()
{
var meter = new StreamingWorkMeter(
Budget(completions: 1),
static () => 0,
timestampFrequency: 1_000);
Assert.Equal(
StreamingWorkAdmission.OversizedProgress,
meter.TryReserve(
new StreamingWorkCost(CompletionAdmissions: 2),
"oversized"));
meter.Complete();
StreamingWorkMeterSnapshot snapshot = meter.Snapshot;
Assert.Equal(1, snapshot.OversizedProgressCount);
Assert.Equal(
StreamingWorkLimit.CompletionAdmissions,
snapshot.LastLimit);
}
[Fact]
public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure()
{
long now = 0;
var meter = new StreamingWorkMeter(
Budget(milliseconds: 2),
() => now,
timestampFrequency: 1_000);
Assert.Equal(
StreamingWorkAdmission.Admitted,
meter.TryReserve(new StreamingWorkCost(), "slow"));
now = 3;
meter.Fail();
StreamingWorkMeterSnapshot snapshot = meter.Snapshot;
Assert.Equal(3, snapshot.ElapsedMilliseconds);
Assert.Equal(1, snapshot.OverrunCount);
Assert.Equal(1, snapshot.FailureCount);
Assert.Equal(0, snapshot.CompletedOperations);
Assert.Equal(StreamingWorkLimit.Time, snapshot.LastLimit);
}
[Fact]
public void MeterRejectsReentrantReservationAndNegativeCost()
{
var meter = new StreamingWorkMeter(
Budget(),
static () => 0,
timestampFrequency: 1_000);
meter.TryReserve(new StreamingWorkCost(), "first");
Assert.Throws<InvalidOperationException>(() =>
meter.TryReserve(new StreamingWorkCost(), "reentrant"));
meter.Complete();
Assert.Throws<ArgumentOutOfRangeException>(() =>
meter.TryReserve(
new StreamingWorkCost(AdoptedCpuBytes: -1),
"negative"));
Assert.Throws<ArgumentException>(() =>
new StreamingWorkMeter(
default,
static () => 0,
timestampFrequency: 1_000));
}
[Fact]
public void CompletionCostChargesExactArraysAndDeterministicLogicalEntries()
{
const uint landblockId = 0xA9B4FFFFu;
var first = Entity(1, meshRefs: 2);
var second = Entity(2, meshRefs: 1);
var cell = new LoadedCell
{
CellId = 0xA9B40100u,
Portals =
[
new CellPortalInfo(1, 2, 3, 4),
new CellPortalInfo(5, 6, 7, 8),
],
ClipPlanes = [new PortalClipPlane(), new PortalClipPlane()],
PortalPolygons =
[
new Vector3[3],
new Vector3[4],
],
VisibleCells = new uint[3],
};
var shell = new EnvCellShellPlacement(
cell.CellId,
1,
2,
3,
ImmutableArray.Create<ushort>(4, 5, 6),
Vector3.Zero,
Quaternion.Identity,
Matrix4x4.Identity,
new WbBoundingBox(Vector3.Zero, Vector3.One),
new WbBoundingBox(Vector3.Zero, Vector3.One));
var physics = new PhysicsDatBundle(
new LandBlockInfo(),
new Dictionary<uint, EnvCell> { [1] = new EnvCell() },
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>
{
[2] = new DatReaderWriter.DBObjs.Environment(),
},
new Dictionary<uint, Setup> { [3] = new Setup() },
new Dictionary<uint, GfxObj> { [4] = new GfxObj() });
var build = new LandblockBuild(
new LoadedLandblock(
landblockId,
new LandBlock(),
[first, second],
physics),
new EnvCellLandblockBuild(landblockId, [cell], [shell]));
var mesh = new LandblockMeshData(
new TerrainVertex[2],
new uint[3]);
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(build, mesh);
Assert.Equal(92, estimate.TerrainPayloadBytes);
// MeshRef is 80 bytes on the supported x64 runtime: the Matrix4x4
// retains its 16-byte alignment after the 4-byte GfxObj id.
Assert.Equal(586, estimate.Work.AdoptedCpuBytes);
Assert.Equal(1, estimate.Work.CompletionAdmissions);
Assert.Equal(2, estimate.Work.EntityOperations);
Assert.Equal(92, estimate.Work.GpuUploadBytes);
Assert.Equal(3, estimate.MeshReferences);
Assert.Equal(1, estimate.VisibilityCells);
Assert.Equal(1, estimate.EnvCellShells);
Assert.Equal(2, estimate.PortalConnections);
Assert.Equal(7, estimate.PortalPolygonVertices);
Assert.Equal(1, estimate.PhysicsEnvCells);
Assert.Equal(1, estimate.PhysicsEnvironments);
Assert.Equal(1, estimate.PhysicsSetups);
Assert.Equal(1, estimate.PhysicsGfxObjects);
}
[Fact]
public void NonPayloadCompletionCostsOnlyOneAdmission()
{
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(
new LandblockStreamResult.Failed(0x1234FFFFu, "expected"));
Assert.Equal(
new StreamingWorkCost(CompletionAdmissions: 1),
estimate.Work);
Assert.Equal(0, estimate.TerrainPayloadBytes);
}
[Fact]
public void LegacySchedulerPublishesShadowBudgetAndRetainedBacklogFacts()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
uint neighbor = StreamingRegion.EncodeLandblockIdForTest(32, 33);
var outbox = new Queue<LandblockStreamResult>(
[
Loaded(center),
Loaded(neighbor),
]);
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: max =>
{
var drained = new List<LandblockStreamResult>(max);
while (drained.Count < max && outbox.TryDequeue(out var result))
drained.Add(result);
return drained;
},
applyTerrain: static (_, _) => { },
state: new GpuWorldState(),
nearRadius: 1,
farRadius: 1)
{
MaxCompletionsPerFrame = 1,
};
controller.Tick(32, 32);
StreamingWorkDiagnostics first = controller.WorkDiagnostics;
Assert.Equal(1, first.DeferredCompletions);
Assert.Equal(44, first.DeferredAdoptedCpuBytes);
Assert.True(first.OldestDeferredAgeMilliseconds >= 0);
Assert.Equal(1, first.LastFrame.Operations);
Assert.Equal(1, first.LastFrame.CompletedOperations);
Assert.Equal(44, first.LastFrame.Used.AdoptedCpuBytes);
controller.Tick(32, 32);
StreamingWorkDiagnostics second = controller.WorkDiagnostics;
Assert.Equal(0, second.DeferredCompletions);
Assert.Equal(0, second.DeferredAdoptedCpuBytes);
Assert.Equal(0, second.OldestDeferredAgeMilliseconds);
Assert.Equal(1, second.LastFrame.CompletedOperations);
}
private static WorldEntity Entity(uint id, int meshRefs)
{
var refs = new MeshRef[meshRefs];
for (int i = 0; i < refs.Length; i++)
refs[i] = new MeshRef((uint)(0x01000000 + i), Matrix4x4.Identity);
return new WorldEntity
{
Id = id,
SourceGfxObjOrSetupId = 0x01000000u + id,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = refs,
};
}
private static LandblockStreamResult.Loaded Loaded(uint landblockId) => new(
landblockId,
LandblockStreamTier.Near,
new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()),
new LandblockMeshData(
new TerrainVertex[1],
new uint[1]));
}