perf(render): bound animation and alpha scratch residency

Complete Slice D3 by replacing the unbounded animation dictionary with a concurrent byte/count LRU and by putting the three retail alpha scratch owners behind one typed aggregate budget. Preserve immediate growth and draw order while reclaiming one-frame density spikes after sustained under-use. Close stale bounds-cache issue evidence without inventing a cache.
This commit is contained in:
Erik 2026-07-24 16:34:28 +02:00
parent 3e18fc2730
commit f2644d42c2
18 changed files with 857 additions and 34 deletions

View file

@ -84,6 +84,7 @@ internal readonly record struct ResidencyCharges(
long LogicalBytes = 0,
long CpuPreparedBytes = 0,
long DecodedBytes = 0,
long ScratchBytes = 0,
long PinnedBytes = 0,
long StagingBytes = 0,
long GpuRequestedBytes = 0,
@ -97,6 +98,7 @@ internal readonly record struct ResidencyCharges(
LogicalBytes
+ CpuPreparedBytes
+ DecodedBytes
+ ScratchBytes
+ StagingBytes);
public long PhysicalGpuBytes => checked(
@ -111,6 +113,7 @@ internal readonly record struct ResidencyCharges(
ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes);
ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(ScratchBytes);
ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes);
ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes);
@ -126,6 +129,7 @@ internal readonly record struct ResidencyCharges(
checked(left.LogicalBytes + right.LogicalBytes),
checked(left.CpuPreparedBytes + right.CpuPreparedBytes),
checked(left.DecodedBytes + right.DecodedBytes),
checked(left.ScratchBytes + right.ScratchBytes),
checked(left.PinnedBytes + right.PinnedBytes),
checked(left.StagingBytes + right.StagingBytes),
checked(left.GpuRequestedBytes + right.GpuRequestedBytes),

View file

@ -0,0 +1,105 @@
namespace AcDream.App.Rendering.Residency;
/// <summary>
/// Splits the one deferred-alpha scratch ceiling between the three physical
/// owners. The final share receives the division remainder so the aggregate
/// remains exact for every valid startup budget.
/// </summary>
internal readonly record struct AlphaScratchBudgetProfile(
long QueueBytes,
long DispatcherBytes,
long ParticleBytes)
{
public long TotalBytes => checked(
QueueBytes + DispatcherBytes + ParticleBytes);
public static AlphaScratchBudgetProfile Create(long totalBytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(totalBytes);
long queue = totalBytes / 4;
long particles = totalBytes / 4;
long dispatcher = checked(totalBytes - queue - particles);
return new AlphaScratchBudgetProfile(queue, dispatcher, particles);
}
}
/// <summary>
/// Capacity retention policy for render-thread scratch storage. Growth is
/// always immediate at the physical owner. This policy only recommends a
/// smaller warmed capacity after several completed low-demand observations,
/// preventing one dense alpha frame from pinning its high-water arrays for the
/// rest of the process without introducing an elapsed-time workaround.
/// </summary>
internal sealed class RetainedScratchCapacityPolicy(
long budgetBytes,
int lowDemandSamplesBeforeShrink = 3)
{
private readonly long _budgetBytes =
budgetBytes > 0
? budgetBytes
: throw new ArgumentOutOfRangeException(nameof(budgetBytes));
private readonly int _lowDemandSamplesBeforeShrink =
lowDemandSamplesBeforeShrink > 0
? lowDemandSamplesBeforeShrink
: throw new ArgumentOutOfRangeException(
nameof(lowDemandSamplesBeforeShrink));
private int _lowDemandSamples;
public long BudgetBytes => _budgetBytes;
public int ObserveAndSelectCapacity(
int currentCapacity,
int requiredCapacity,
int bytesPerUnit,
int minimumCapacity,
int growthQuantum)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentCapacity);
ArgumentOutOfRangeException.ThrowIfNegative(requiredCapacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytesPerUnit);
ArgumentOutOfRangeException.ThrowIfNegative(minimumCapacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
if (requiredCapacity > currentCapacity)
{
_lowDemandSamples = 0;
return RoundUp(requiredCapacity, growthQuantum);
}
int budgetCapacity = checked((int)Math.Min(
int.MaxValue,
Math.Max(minimumCapacity, _budgetBytes / bytesPerUnit)));
bool demandHasFallen =
currentCapacity > budgetCapacity
&& requiredCapacity <= budgetCapacity
&& (requiredCapacity == 0
|| (long)currentCapacity >= (long)requiredCapacity * 4);
if (!demandHasFallen)
{
_lowDemandSamples = 0;
return currentCapacity;
}
_lowDemandSamples++;
if (_lowDemandSamples < _lowDemandSamplesBeforeShrink)
return currentCapacity;
_lowDemandSamples = 0;
long warmedDemand = Math.Max(
minimumCapacity,
Math.Min(int.MaxValue, (long)requiredCapacity * 2));
int target = RoundUp(
checked((int)Math.Min(warmedDemand, budgetCapacity)),
growthQuantum);
target = Math.Max(requiredCapacity, Math.Min(target, budgetCapacity));
return Math.Min(currentCapacity, target);
}
private static int RoundUp(int value, int quantum)
{
if (value == 0)
return 0;
long rounded = ((long)value + quantum - 1) / quantum * quantum;
return checked((int)Math.Min(int.MaxValue, rounded));
}
}