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
|
|
@ -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, 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,
|
||||
default, 0d, 0d, null);
|
||||
default, default, 0d, 0d, null);
|
||||
|
||||
private static string NewDirectory()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Streaming;
|
||||
|
||||
namespace AcDream.App.Tests;
|
||||
|
||||
|
|
@ -20,6 +21,65 @@ public sealed class RuntimeOptionsTests
|
|||
_ => null);
|
||||
|
||||
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]
|
||||
|
|
|
|||
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