feat(render): establish typed residency policy ledger

Define Slice D's transition and concurrency contract, add generation-safe asset handles and owner leases, and parse the existing cache ceilings through typed runtime budgets. Stale generations cannot release or revive replacements, and worker observations are bounded and coalesced.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-24 16:05:46 +02:00
parent a564c4b782
commit 1866ea0c6d
8 changed files with 1696 additions and 2 deletions

View file

@ -0,0 +1,199 @@
namespace AcDream.App.Rendering.Residency;
internal enum ResidencyDomain : byte
{
ObjectMeshes,
PreparedMeshCpu,
MeshStaging,
GlobalMeshArena,
CompositeTextures,
StandaloneTextures,
PreparedPackage,
Animations,
Audio,
AlphaScratch,
}
internal enum ResidencyPriority : byte
{
Speculative,
Far,
Near,
Visible,
DestinationCritical,
}
internal enum AssetResidencyState : byte
{
Absent,
Requested,
Prepared,
UploadPending,
Resident,
Retiring,
Cancelled,
Missing,
Corrupt,
Failed,
}
internal enum ResidencyOwnerKind : byte
{
Session,
Landblock,
Entity,
ParticleEmitter,
UserInterface,
Tooling,
}
internal readonly record struct ResidencyAssetKey(
ResidencyDomain Domain,
ulong ContentKey,
ulong Variant = 0);
internal readonly record struct AssetHandle<TAsset>(uint Index, ushort Generation)
{
public bool IsValid => Index != 0 && Generation != 0;
internal AssetReference Untyped =>
new(Index, Generation, typeof(TAsset).TypeHandle);
}
internal readonly record struct OwnerToken(uint Index, ushort Generation)
{
public bool IsValid => Index != 0 && Generation != 0;
}
internal readonly record struct AssetLease<TAsset>(
AssetHandle<TAsset> Handle,
OwnerToken Owner);
internal readonly record struct AssetReference(
uint Index,
ushort Generation,
RuntimeTypeHandle AssetType)
{
public bool IsValid =>
Index != 0
&& Generation != 0
&& !AssetType.Equals(default(RuntimeTypeHandle));
}
internal readonly record struct ResidencyCharges(
long LogicalBytes = 0,
long CpuPreparedBytes = 0,
long DecodedBytes = 0,
long PinnedBytes = 0,
long StagingBytes = 0,
long GpuRequestedBytes = 0,
long GpuResidentBytes = 0,
long RetiringBytes = 0,
long MappedVirtualBytes = 0)
{
public static ResidencyCharges Zero => default;
public long CommittedCpuBytes => checked(
LogicalBytes
+ CpuPreparedBytes
+ DecodedBytes
+ StagingBytes);
public long PhysicalGpuBytes => checked(
GpuRequestedBytes
+ GpuResidentBytes
+ RetiringBytes);
public bool IsZero => this == default;
public void Validate()
{
ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes);
ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes);
ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(GpuResidentBytes);
ArgumentOutOfRangeException.ThrowIfNegative(RetiringBytes);
ArgumentOutOfRangeException.ThrowIfNegative(MappedVirtualBytes);
}
public static ResidencyCharges operator +(
ResidencyCharges left,
ResidencyCharges right) =>
new(
checked(left.LogicalBytes + right.LogicalBytes),
checked(left.CpuPreparedBytes + right.CpuPreparedBytes),
checked(left.DecodedBytes + right.DecodedBytes),
checked(left.PinnedBytes + right.PinnedBytes),
checked(left.StagingBytes + right.StagingBytes),
checked(left.GpuRequestedBytes + right.GpuRequestedBytes),
checked(left.GpuResidentBytes + right.GpuResidentBytes),
checked(left.RetiringBytes + right.RetiringBytes),
checked(left.MappedVirtualBytes + right.MappedVirtualBytes));
}
internal readonly record struct ResidencyEntrySnapshot(
AssetReference Asset,
ResidencyAssetKey Key,
AssetResidencyState State,
ResidencyPriority Priority,
ulong WorldGeneration,
long LastUsedFrame,
long RebuildCost,
int OwnerCount,
ResidencyCharges Charges,
string? Failure);
internal readonly record struct ResidencyTrimRequest(
AssetReference Asset,
ResidencyAssetKey Key,
ResidencyCharges Charges);
internal readonly record struct ResidencyDomainSnapshot(
ResidencyDomain Domain,
int EntryCount,
int OwnerCount,
ResidencyCharges Charges,
long BudgetBytes = 0,
long CapacityBytes = 0,
long UsedBytes = 0,
long LargestFreeBytes = 0,
long Evictions = 0)
{
public long FreeBytes => Math.Max(0, CapacityBytes - UsedBytes);
public long FragmentedFreeBytes =>
Math.Max(0, FreeBytes - LargestFreeBytes);
}
internal readonly record struct ResidencySnapshot(
IReadOnlyList<ResidencyDomainSnapshot> Domains,
ResidencyCharges TotalCharges)
{
public ResidencyDomainSnapshot Get(ResidencyDomain domain) =>
Domains.FirstOrDefault(snapshot => snapshot.Domain == domain);
}
internal interface IResidencyDomainSource
{
ResidencyDomain Domain { get; }
ResidencyDomainSnapshot CaptureResidency();
}
internal enum ResidencyObservationKind : byte
{
Transition,
Touch,
}
internal readonly record struct ResidencyObservation(
AssetReference Asset,
ResidencyObservationKind Kind,
AssetResidencyState State,
ResidencyCharges Charges,
ResidencyPriority Priority,
long Frame,
string? Failure = null);

View file

@ -0,0 +1,113 @@
using System.Globalization;
namespace AcDream.App.Rendering.Residency;
/// <summary>
/// Startup-time residency ceilings. Values preserve the pre-Slice-D cache
/// behavior by default and are changed atomically as one profile.
/// </summary>
public sealed record ResidencyBudgetOptions(
long ObjectMeshGpuBytes,
int ObjectMeshUnownedEntries,
long PreparedMeshCpuBytes,
int PreparedMeshCpuEntries,
long MeshStagingBytes,
int MeshStagingEntries,
long CompositePhysicalBytes,
long CompositeUnownedBytes,
long StandaloneUnownedBytes,
int StandaloneUnownedEntries,
long AnimationBytes,
int AnimationEntries,
long AlphaScratchBytes)
{
public const long MiB = 1024L * 1024L;
public static ResidencyBudgetOptions Default { get; } = new(
ObjectMeshGpuBytes: 1024 * MiB,
ObjectMeshUnownedEntries: 50,
PreparedMeshCpuBytes: 128 * MiB,
PreparedMeshCpuEntries: 100,
MeshStagingBytes: 128 * MiB,
MeshStagingEntries: 256,
CompositePhysicalBytes: 128 * MiB,
CompositeUnownedBytes: 64 * MiB,
StandaloneUnownedBytes: 32 * MiB,
StandaloneUnownedEntries: 256,
AnimationBytes: 64 * MiB,
AnimationEntries: 512,
AlphaScratchBytes: 16 * MiB);
internal static ResidencyBudgetOptions Parse(
Func<string, string?> env)
{
ArgumentNullException.ThrowIfNull(env);
ResidencyBudgetOptions defaults = Default;
return new ResidencyBudgetOptions(
ObjectMeshGpuBytes: ParseMiB(
env("ACDREAM_RESIDENCY_MESH_GPU_MIB"),
defaults.ObjectMeshGpuBytes),
ObjectMeshUnownedEntries: ParseCount(
env("ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"),
defaults.ObjectMeshUnownedEntries),
PreparedMeshCpuBytes: ParseMiB(
env("ACDREAM_RESIDENCY_PREPARED_MESH_MIB"),
defaults.PreparedMeshCpuBytes),
PreparedMeshCpuEntries: ParseCount(
env("ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES"),
defaults.PreparedMeshCpuEntries),
MeshStagingBytes: ParseMiB(
env("ACDREAM_RESIDENCY_MESH_STAGING_MIB"),
defaults.MeshStagingBytes),
MeshStagingEntries: ParseCount(
env("ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES"),
defaults.MeshStagingEntries),
CompositePhysicalBytes: ParseMiB(
env("ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB"),
defaults.CompositePhysicalBytes),
CompositeUnownedBytes: ParseMiB(
env("ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB"),
defaults.CompositeUnownedBytes),
StandaloneUnownedBytes: ParseMiB(
env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB"),
defaults.StandaloneUnownedBytes),
StandaloneUnownedEntries: ParseCount(
env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES"),
defaults.StandaloneUnownedEntries),
AnimationBytes: ParseMiB(
env("ACDREAM_RESIDENCY_ANIMATION_MIB"),
defaults.AnimationBytes),
AnimationEntries: ParseCount(
env("ACDREAM_RESIDENCY_ANIMATION_ENTRIES"),
defaults.AnimationEntries),
AlphaScratchBytes: ParseMiB(
env("ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB"),
defaults.AlphaScratchBytes));
}
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 int ParseCount(string? value, int fallback) =>
int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int count)
&& count > 0
? count
: fallback;
}

View file

@ -0,0 +1,614 @@
namespace AcDream.App.Rendering.Residency;
/// <summary>
/// Single-writer logical residency owner. It has no dependency on OpenGL and
/// never stores physical resource names.
/// </summary>
internal sealed class ResidencyManager
{
private sealed class AssetSlot
{
public required RuntimeTypeHandle AssetType { get; init; }
public required ushort Generation { get; init; }
public required ResidencyAssetKey Key { get; init; }
public required ulong WorldGeneration { get; init; }
public AssetResidencyState State { get; set; }
public ResidencyPriority Priority { get; set; }
public long LastUsedFrame { get; set; }
public long RebuildCost { get; set; }
public ResidencyCharges Charges { get; set; }
public string? Failure { get; set; }
public HashSet<OwnerToken> Owners { get; } = [];
}
private sealed class OwnerSlot
{
public required ushort Generation { get; init; }
public required ResidencyOwnerKind Kind { get; init; }
public required ulong LogicalId { get; init; }
public required ulong WorldGeneration { get; init; }
public HashSet<AssetReference> Assets { get; } = [];
}
private readonly record struct TypedAssetKey(
RuntimeTypeHandle AssetType,
ResidencyAssetKey Key);
private readonly List<AssetSlot?> _assets = [null];
private readonly List<ushort> _assetGenerations = [0];
private readonly Stack<uint> _freeAssets = [];
private readonly Dictionary<TypedAssetKey, uint> _assetByKey = [];
private readonly List<OwnerSlot?> _owners = [null];
private readonly List<ushort> _ownerGenerations = [0];
private readonly Stack<uint> _freeOwners = [];
private readonly List<IResidencyDomainSource> _domainSources = [];
private readonly List<ResidencyObservation> _observationScratch = [];
private readonly List<ResidencyDomainSnapshot> _snapshotScratch = [];
public int ActiveAssetCount => _assetByKey.Count;
public int ActiveOwnerCount => _owners.Count(owner => owner is not null);
public void RegisterDomainSource(IResidencyDomainSource source)
{
ArgumentNullException.ThrowIfNull(source);
if (_domainSources.Any(candidate => candidate.Domain == source.Domain))
{
throw new InvalidOperationException(
$"Residency domain {source.Domain} is already registered.");
}
_domainSources.Add(source);
}
public OwnerToken CreateOwner(
ResidencyOwnerKind kind,
ulong logicalId,
ulong worldGeneration)
{
uint index = AllocateIndex(_owners, _ownerGenerations, _freeOwners);
ushort generation = NextGeneration(_ownerGenerations, index);
_owners[checked((int)index)] = new OwnerSlot
{
Generation = generation,
Kind = kind,
LogicalId = logicalId,
WorldGeneration = worldGeneration,
};
return new OwnerToken(index, generation);
}
public bool RetireOwner(OwnerToken owner)
{
if (!TryGetOwner(owner, out OwnerSlot slot))
return false;
AssetReference[] assets = slot.Assets.ToArray();
for (int i = 0; i < assets.Length; i++)
{
if (TryGetAsset(assets[i], out AssetSlot asset))
asset.Owners.Remove(owner);
}
_owners[checked((int)owner.Index)] = null;
_freeOwners.Push(owner.Index);
return true;
}
public AssetHandle<TAsset> Request<TAsset>(
ResidencyAssetKey key,
ResidencyPriority priority,
ulong worldGeneration,
long frame,
long rebuildCost = 0)
{
ArgumentOutOfRangeException.ThrowIfNegative(frame);
ArgumentOutOfRangeException.ThrowIfNegative(rebuildCost);
RuntimeTypeHandle type = typeof(TAsset).TypeHandle;
var typedKey = new TypedAssetKey(type, key);
if (_assetByKey.TryGetValue(typedKey, out uint existingIndex))
{
AssetSlot existing = _assets[checked((int)existingIndex)]
?? throw new InvalidOperationException(
"Residency key index did not resolve to a live slot.");
if (IsTerminal(existing.State))
{
RecycleAsset(existingIndex, existing);
}
else
{
existing.Priority = Max(existing.Priority, priority);
existing.LastUsedFrame = Math.Max(existing.LastUsedFrame, frame);
existing.RebuildCost = Math.Max(existing.RebuildCost, rebuildCost);
return new AssetHandle<TAsset>(
existingIndex,
existing.Generation);
}
}
uint index = AllocateIndex(_assets, _assetGenerations, _freeAssets);
ushort generation = NextGeneration(_assetGenerations, index);
var slot = new AssetSlot
{
AssetType = type,
Generation = generation,
Key = key,
WorldGeneration = worldGeneration,
State = AssetResidencyState.Requested,
Priority = priority,
LastUsedFrame = frame,
RebuildCost = rebuildCost,
};
_assets[checked((int)index)] = slot;
_assetByKey.Add(typedKey, index);
return new AssetHandle<TAsset>(index, generation);
}
public AssetLease<TAsset> Acquire<TAsset>(
AssetHandle<TAsset> handle,
OwnerToken owner,
ResidencyPriority priority,
long frame)
{
AssetSlot asset = GetAsset(handle);
OwnerSlot ownerSlot = GetOwner(owner);
if (asset.State is AssetResidencyState.Retiring
or AssetResidencyState.Cancelled
or AssetResidencyState.Missing
or AssetResidencyState.Corrupt
or AssetResidencyState.Failed)
{
throw new InvalidOperationException(
$"Cannot acquire an asset in {asset.State} state.");
}
if (ownerSlot.WorldGeneration != asset.WorldGeneration
&& ownerSlot.Kind is not ResidencyOwnerKind.Session
and not ResidencyOwnerKind.UserInterface
and not ResidencyOwnerKind.Tooling)
{
throw new InvalidOperationException(
"A world-scoped owner cannot acquire an asset from another generation.");
}
var lease = new AssetLease<TAsset>(handle, owner);
if (asset.Owners.Add(owner))
ownerSlot.Assets.Add(handle.Untyped);
asset.Priority = Max(asset.Priority, priority);
asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame);
return lease;
}
public bool Release<TAsset>(AssetLease<TAsset> lease)
{
if (!TryGetAsset(lease.Handle.Untyped, out AssetSlot asset)
|| !TryGetOwner(lease.Owner, out OwnerSlot owner))
{
return false;
}
bool removed = asset.Owners.Remove(lease.Owner);
if (removed)
{
owner.Assets.Remove(lease.Handle.Untyped);
if (asset.Owners.Count == 0)
asset.Priority = ResidencyPriority.Speculative;
}
return removed;
}
public bool Touch<TAsset>(
AssetHandle<TAsset> handle,
ResidencyPriority priority,
long frame)
{
if (!TryGetAsset(handle.Untyped, out AssetSlot asset))
return false;
asset.Priority = Max(asset.Priority, priority);
asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame);
return true;
}
public bool Transition<TAsset>(
AssetHandle<TAsset> handle,
AssetResidencyState next,
ResidencyCharges charges,
long frame,
string? failure = null) =>
Transition(handle.Untyped, next, charges, frame, failure);
public bool CompleteRetirement<TAsset>(AssetHandle<TAsset> handle) =>
CompleteRetirement(handle.Untyped);
public bool CompleteRetirement(AssetReference asset)
{
if (!TryGetAsset(asset, out AssetSlot slot))
return false;
if (slot.State != AssetResidencyState.Retiring)
{
throw new InvalidOperationException(
$"Only a retiring asset can become absent; current={slot.State}.");
}
if (slot.Owners.Count != 0)
throw new InvalidOperationException(
"A retiring asset retained logical owners.");
RecycleAsset(asset.Index, slot);
return true;
}
public bool TryGetSnapshot<TAsset>(
AssetHandle<TAsset> handle,
out ResidencyEntrySnapshot snapshot) =>
TryGetSnapshot(handle.Untyped, out snapshot);
public int Drain(ResidencyObservationJournal journal)
{
ArgumentNullException.ThrowIfNull(journal);
_observationScratch.Clear();
journal.DrainTo(_observationScratch);
_observationScratch.Sort(static (left, right) =>
{
int byIndex = left.Asset.Index.CompareTo(right.Asset.Index);
if (byIndex != 0)
return byIndex;
return left.Kind.CompareTo(right.Kind);
});
int applied = 0;
for (int i = 0; i < _observationScratch.Count; i++)
{
ResidencyObservation observation = _observationScratch[i];
bool accepted = observation.Kind switch
{
ResidencyObservationKind.Touch =>
Touch(
observation.Asset,
observation.Priority,
observation.Frame),
ResidencyObservationKind.Transition =>
Transition(
observation.Asset,
observation.State,
observation.Charges,
observation.Frame,
observation.Failure),
_ => throw new ArgumentOutOfRangeException(
nameof(observation),
observation.Kind,
"unknown residency observation kind"),
};
if (accepted)
applied++;
}
return applied;
}
public IReadOnlyList<ResidencyTrimRequest> SelectEvictions(
ResidencyDomain domain,
long bytesToRelease,
int maximumCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(bytesToRelease);
ArgumentOutOfRangeException.ThrowIfNegative(maximumCount);
if (bytesToRelease == 0 || maximumCount == 0)
return Array.Empty<ResidencyTrimRequest>();
List<(uint Index, AssetSlot Slot)> candidates = [];
for (uint index = 1; index < _assets.Count; index++)
{
AssetSlot? slot = _assets[checked((int)index)];
if (slot is null
|| slot.Key.Domain != domain
|| slot.Owners.Count != 0
|| slot.State is not (AssetResidencyState.Resident
or AssetResidencyState.Prepared))
{
continue;
}
candidates.Add((index, slot));
}
candidates.Sort(static (left, right) =>
{
int order = left.Slot.Priority.CompareTo(right.Slot.Priority);
if (order != 0)
return order;
order = left.Slot.LastUsedFrame.CompareTo(right.Slot.LastUsedFrame);
if (order != 0)
return order;
order = left.Slot.RebuildCost.CompareTo(right.Slot.RebuildCost);
return order != 0
? order
: left.Index.CompareTo(right.Index);
});
List<ResidencyTrimRequest> requests = [];
long selectedBytes = 0;
for (int i = 0;
i < candidates.Count
&& requests.Count < maximumCount
&& selectedBytes < bytesToRelease;
i++)
{
(uint index, AssetSlot slot) = candidates[i];
ResidencyCharges retiring = ToRetiringCharges(slot.Charges);
var asset = new AssetReference(
index,
slot.Generation,
slot.AssetType);
Transition(
asset,
AssetResidencyState.Retiring,
retiring,
slot.LastUsedFrame);
requests.Add(new ResidencyTrimRequest(asset, slot.Key, retiring));
selectedBytes = checked(
selectedBytes + Math.Max(
1,
retiring.RetiringBytes
+ retiring.CommittedCpuBytes));
}
return requests;
}
public ResidencySnapshot CaptureSnapshot()
{
_snapshotScratch.Clear();
for (int i = 0; i < _domainSources.Count; i++)
_snapshotScratch.Add(_domainSources[i].CaptureResidency());
ResidencyCharges totals = default;
for (uint index = 1; index < _assets.Count; index++)
{
AssetSlot? slot = _assets[checked((int)index)];
if (slot is not null)
totals += slot.Charges;
}
for (int i = 0; i < _snapshotScratch.Count; i++)
totals += _snapshotScratch[i].Charges;
return new ResidencySnapshot(_snapshotScratch.ToArray(), totals);
}
private bool Transition(
AssetReference asset,
AssetResidencyState next,
ResidencyCharges charges,
long frame,
string? failure = null)
{
if (!TryGetAsset(asset, out AssetSlot slot))
return false;
ArgumentOutOfRangeException.ThrowIfNegative(frame);
charges.Validate();
ValidateTransition(slot.State, next);
ValidateCharges(next, charges);
if (next == AssetResidencyState.Retiring && slot.Owners.Count != 0)
{
throw new InvalidOperationException(
"An owned asset cannot enter retiring state.");
}
slot.State = next;
slot.Charges = charges;
slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame);
slot.Failure = next is AssetResidencyState.Corrupt
or AssetResidencyState.Failed
? failure
: null;
if (IsTerminal(next))
DetachAllOwners(asset, slot);
return true;
}
private bool Touch(
AssetReference asset,
ResidencyPriority priority,
long frame)
{
if (!TryGetAsset(asset, out AssetSlot slot))
return false;
slot.Priority = Max(slot.Priority, priority);
slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame);
return true;
}
private bool TryGetSnapshot(
AssetReference asset,
out ResidencyEntrySnapshot snapshot)
{
if (!TryGetAsset(asset, out AssetSlot slot))
{
snapshot = default;
return false;
}
snapshot = new ResidencyEntrySnapshot(
asset,
slot.Key,
slot.State,
slot.Priority,
slot.WorldGeneration,
slot.LastUsedFrame,
slot.RebuildCost,
slot.Owners.Count,
slot.Charges,
slot.Failure);
return true;
}
private AssetSlot GetAsset<TAsset>(AssetHandle<TAsset> handle) =>
TryGetAsset(handle.Untyped, out AssetSlot slot)
? slot
: throw new InvalidOperationException("Asset handle is stale or invalid.");
private OwnerSlot GetOwner(OwnerToken owner) =>
TryGetOwner(owner, out OwnerSlot slot)
? slot
: throw new InvalidOperationException("Owner token is stale or invalid.");
private bool TryGetAsset(
AssetReference asset,
out AssetSlot slot)
{
slot = null!;
if (!asset.IsValid || asset.Index >= _assets.Count)
return false;
AssetSlot? candidate = _assets[checked((int)asset.Index)];
if (candidate is null
|| candidate.Generation != asset.Generation
|| !candidate.AssetType.Equals(asset.AssetType))
{
return false;
}
slot = candidate;
return true;
}
private bool TryGetOwner(OwnerToken owner, out OwnerSlot slot)
{
slot = null!;
if (!owner.IsValid || owner.Index >= _owners.Count)
return false;
OwnerSlot? candidate = _owners[checked((int)owner.Index)];
if (candidate is null || candidate.Generation != owner.Generation)
return false;
slot = candidate;
return true;
}
private void RecycleAsset(uint index, AssetSlot slot)
{
if (slot.Owners.Count != 0)
throw new InvalidOperationException(
"An asset cannot be recycled while owners remain.");
_assetByKey.Remove(new TypedAssetKey(slot.AssetType, slot.Key));
_assets[checked((int)index)] = null;
_freeAssets.Push(index);
}
private void DetachAllOwners(AssetReference asset, AssetSlot slot)
{
if (slot.Owners.Count == 0)
return;
OwnerToken[] owners = slot.Owners.ToArray();
for (int i = 0; i < owners.Length; i++)
{
if (TryGetOwner(owners[i], out OwnerSlot owner))
owner.Assets.Remove(asset);
}
slot.Owners.Clear();
}
private static uint AllocateIndex<T>(
List<T?> slots,
List<ushort> generations,
Stack<uint> free)
where T : class
{
if (free.TryPop(out uint reused))
return reused;
uint index = checked((uint)slots.Count);
slots.Add(null);
generations.Add(0);
return index;
}
private static ushort NextGeneration(
List<ushort> generations,
uint index)
{
ushort generation = unchecked(
(ushort)(generations[checked((int)index)] + 1));
if (generation == 0)
generation = 1;
generations[checked((int)index)] = generation;
return generation;
}
private static ResidencyPriority Max(
ResidencyPriority left,
ResidencyPriority right) =>
left >= right ? left : right;
private static bool IsTerminal(AssetResidencyState state) =>
state is AssetResidencyState.Cancelled
or AssetResidencyState.Missing
or AssetResidencyState.Corrupt
or AssetResidencyState.Failed;
private static void ValidateTransition(
AssetResidencyState current,
AssetResidencyState next)
{
bool legal = current switch
{
AssetResidencyState.Requested =>
next is AssetResidencyState.Prepared
or AssetResidencyState.Cancelled
or AssetResidencyState.Missing
or AssetResidencyState.Corrupt
or AssetResidencyState.Failed
or AssetResidencyState.Retiring,
AssetResidencyState.Prepared =>
next is AssetResidencyState.UploadPending
or AssetResidencyState.Retiring,
AssetResidencyState.UploadPending =>
next is AssetResidencyState.Resident
or AssetResidencyState.Prepared
or AssetResidencyState.Retiring
or AssetResidencyState.Cancelled
or AssetResidencyState.Corrupt
or AssetResidencyState.Failed,
AssetResidencyState.Resident =>
next is AssetResidencyState.Retiring,
_ => false,
};
if (!legal)
{
throw new InvalidOperationException(
$"Illegal residency transition {current} -> {next}.");
}
}
private static void ValidateCharges(
AssetResidencyState state,
ResidencyCharges charges)
{
if (state is AssetResidencyState.Cancelled
or AssetResidencyState.Missing
or AssetResidencyState.Corrupt
or AssetResidencyState.Failed)
{
if (!charges.IsZero)
{
throw new InvalidOperationException(
$"Terminal state {state} cannot retain residency charges.");
}
return;
}
if (state == AssetResidencyState.Retiring
&& (charges.GpuRequestedBytes != 0
|| charges.GpuResidentBytes != 0
|| charges.StagingBytes != 0))
{
throw new InvalidOperationException(
"Retiring charges must move transient and resident GPU bytes into RetiringBytes.");
}
}
private static ResidencyCharges ToRetiringCharges(
ResidencyCharges current) =>
new(
LogicalBytes: current.LogicalBytes,
CpuPreparedBytes: current.CpuPreparedBytes,
DecodedBytes: current.DecodedBytes,
PinnedBytes: current.PinnedBytes,
RetiringBytes: checked(
current.GpuRequestedBytes
+ current.GpuResidentBytes
+ current.RetiringBytes));
}

View file

@ -0,0 +1,74 @@
using System.Collections.Concurrent;
namespace AcDream.App.Rendering.Residency;
/// <summary>
/// Worker-safe, coalescing observation seam. The single update-thread owner
/// drains immutable observations into <see cref="ResidencyManager"/>.
/// </summary>
internal sealed class ResidencyObservationJournal
{
private readonly ConcurrentDictionary<
(AssetReference Asset, ResidencyObservationKind Kind),
ResidencyObservation> _pending = new();
private readonly int _maximumEntries;
public ResidencyObservationJournal(int maximumEntries)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1);
_maximumEntries = maximumEntries;
}
public int Count => _pending.Count;
public bool TryPublish(in ResidencyObservation observation)
{
if (!observation.Asset.IsValid)
throw new ArgumentException(
"Residency observations require a valid asset reference.",
nameof(observation));
observation.Charges.Validate();
var key = (observation.Asset, observation.Kind);
while (true)
{
if (_pending.TryGetValue(key, out ResidencyObservation current))
{
// Network/decode callbacks may arrive concurrently. Preserve
// the newest immutable fact instead of letting a late older
// callback overwrite it.
if (observation.Frame < current.Frame)
return true;
if (_pending.TryUpdate(key, observation, current))
return true;
continue;
}
if (_pending.Count >= _maximumEntries)
return false;
if (_pending.TryAdd(key, observation))
return true;
}
}
public int DrainTo(List<ResidencyObservation> destination)
{
ArgumentNullException.ThrowIfNull(destination);
int start = destination.Count;
foreach (KeyValuePair<
(AssetReference Asset, ResidencyObservationKind Kind),
ResidencyObservation> pair in _pending)
{
// Remove only the exact value enumerated. If a worker replaced it
// after enumeration, the newer value remains for the next drain.
if (((ICollection<KeyValuePair<
(AssetReference Asset, ResidencyObservationKind Kind),
ResidencyObservation>>)_pending).Remove(pair))
{
destination.Add(pair.Value);
}
}
return destination.Count - start;
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
using AcDream.App.Rendering.Residency;
namespace AcDream.App;
@ -50,7 +51,8 @@ public sealed record RuntimeOptions(
string? UiProbeScript,
string? AutomationArtifactDirectory,
float FogStartMultiplier,
float FogEndMultiplier)
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets)
{
/// <summary>
/// Build options from the process environment. Used by
@ -107,7 +109,8 @@ public sealed record RuntimeOptions(
AutomationArtifactDirectory:
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
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));
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>