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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Date:** 2026-07-24
|
**Date:** 2026-07-24
|
||||||
|
|
||||||
**Status:** Slices A–D complete. Slice E is active.
|
**Status:** Slices A–D 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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
187
src/AcDream.App/Streaming/LandblockStreamResultCost.cs
Normal file
187
src/AcDream.App/Streaming/LandblockStreamResultCost.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
364
src/AcDream.App/Streaming/StreamingWorkBudget.cs
Normal file
364
src/AcDream.App/Streaming/StreamingWorkBudget.cs
Normal 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;
|
||||||
|
}
|
||||||
121
src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs
Normal file
121
src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
291
tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs
Normal file
291
tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs
Normal 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]));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue