fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -3,6 +3,23 @@ using AcDream.Content;
|
|||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
internal enum MeshStageResult
|
||||
{
|
||||
Staged,
|
||||
AlreadyStaged,
|
||||
HighWater,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable identity for one staging claim. Object ids can be released and
|
||||
/// reacquired while a render-thread upload is in flight; the generation keeps
|
||||
/// a stale completion or retry from consuming the replacement claim.
|
||||
/// </summary>
|
||||
internal readonly record struct MeshUploadQueueItem(
|
||||
ObjectMeshData Data,
|
||||
long SourceBytes,
|
||||
ulong Generation);
|
||||
|
||||
/// <summary>
|
||||
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
|
||||
/// in-flight at a time; a retry retains ownership until upload succeeds or
|
||||
|
|
@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </summary>
|
||||
internal sealed class MeshUploadStagingQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
|
||||
internal const int DefaultMaximumCount = 256;
|
||||
internal const long DefaultMaximumBytes = 128L * 1024 * 1024;
|
||||
internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024;
|
||||
|
||||
public bool Stage(ObjectMeshData data)
|
||||
private readonly object _gate = new();
|
||||
private readonly Queue<Entry> _queue = new();
|
||||
private readonly Dictionary<ulong, ulong> _generationById = new();
|
||||
private readonly int _maximumCount;
|
||||
private readonly long _maximumBytes;
|
||||
private readonly long _maximumSingleEntryBytes;
|
||||
private long _queuedBytes;
|
||||
private long _claimedBytes;
|
||||
private ulong _nextGeneration;
|
||||
|
||||
private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation)
|
||||
{
|
||||
if (!_ownedIds.TryAdd(data.ObjectId, 0))
|
||||
return false;
|
||||
|
||||
data.UploadAttempts = 0;
|
||||
_queue.Enqueue(data);
|
||||
return true;
|
||||
public MeshUploadQueueItem Item => new(Data, Bytes, Generation);
|
||||
}
|
||||
|
||||
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
|
||||
public MeshUploadStagingQueue(
|
||||
int maximumCount = DefaultMaximumCount,
|
||||
long maximumBytes = DefaultMaximumBytes,
|
||||
long maximumSingleEntryBytes = 0)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes);
|
||||
if (maximumSingleEntryBytes == 0)
|
||||
maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes);
|
||||
_maximumCount = maximumCount;
|
||||
_maximumBytes = maximumBytes;
|
||||
_maximumSingleEntryBytes = maximumSingleEntryBytes;
|
||||
}
|
||||
|
||||
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
|
||||
public bool Stage(ObjectMeshData data)
|
||||
=> TryStage(data) == MeshStageResult.Staged;
|
||||
|
||||
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
|
||||
public MeshStageResult TryStage(ObjectMeshData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_generationById.ContainsKey(data.ObjectId))
|
||||
return MeshStageResult.AlreadyStaged;
|
||||
|
||||
if (bytes > _maximumSingleEntryBytes)
|
||||
throw new NotSupportedException(
|
||||
$"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; "
|
||||
+ $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes.");
|
||||
|
||||
// Permit one explicitly bounded oversized head item so unusual
|
||||
// content cannot starve. Every other producer observes the strict
|
||||
// count/byte watermark; active decoders retain their result in the
|
||||
// bounded CPU cache and retry staging after the consumer drains.
|
||||
if (_generationById.Count != 0
|
||||
&& (_generationById.Count >= _maximumCount
|
||||
|| bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes)))
|
||||
{
|
||||
return MeshStageResult.HighWater;
|
||||
}
|
||||
|
||||
ulong generation = checked(++_nextGeneration);
|
||||
_generationById.Add(data.ObjectId, generation);
|
||||
data.UploadAttempts = 0;
|
||||
_queue.Enqueue(new Entry(data, bytes, generation));
|
||||
_queuedBytes = checked(_queuedBytes + bytes);
|
||||
_claimedBytes = checked(_claimedBytes + bytes);
|
||||
return MeshStageResult.Staged;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDequeue(out MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_queue.TryDequeue(out Entry entry))
|
||||
{
|
||||
item = default;
|
||||
return false;
|
||||
}
|
||||
_queuedBytes -= entry.Bytes;
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryPeek(out MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_queue.TryPeek(out Entry entry))
|
||||
{
|
||||
item = default;
|
||||
return false;
|
||||
}
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_gate) return _queue.Count; } }
|
||||
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
|
||||
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
|
||||
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
|
||||
public bool IsAtHighWater
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes;
|
||||
}
|
||||
}
|
||||
|
||||
public void Requeue(MeshUploadQueueItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item.Data);
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
if (item.SourceBytes > _maximumSingleEntryBytes)
|
||||
throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed.");
|
||||
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation));
|
||||
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete(MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
_generationById.Remove(item.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a dequeued, now-unowned generation and atomically hands the
|
||||
/// same CPU payload to an owner that raced in while it was dequeued. A
|
||||
/// concurrent cache hit either sees the old staging claim and this method
|
||||
/// requeues, or runs after the claim is removed and stages for itself; the
|
||||
/// mesh cannot fall through the gap between those operations.
|
||||
/// </summary>
|
||||
public bool CompleteOrRestageIfOwned(
|
||||
MeshUploadQueueItem item,
|
||||
MeshOwnershipCounter ownership)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item.Data);
|
||||
ArgumentNullException.ThrowIfNull(ownership);
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
_generationById.Remove(item.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
|
||||
if (!ownership.IsOwned(item.Data.ObjectId))
|
||||
return false;
|
||||
|
||||
ulong generation = checked(++_nextGeneration);
|
||||
_generationById.Add(item.Data.ObjectId, generation);
|
||||
item.Data.UploadAttempts = 0;
|
||||
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation));
|
||||
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
|
||||
_claimedBytes = checked(_claimedBytes + item.SourceBytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ownership);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maximum);
|
||||
int discarded = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
while (discarded < maximum
|
||||
&& _queue.TryPeek(out Entry entry)
|
||||
&& !ownership.IsOwned(entry.Data.ObjectId))
|
||||
{
|
||||
_queue.Dequeue();
|
||||
_queuedBytes -= entry.Bytes;
|
||||
if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation)
|
||||
&& generation == entry.Generation)
|
||||
{
|
||||
_generationById.Remove(entry.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - entry.Bytes);
|
||||
}
|
||||
discarded++;
|
||||
}
|
||||
}
|
||||
return discarded;
|
||||
}
|
||||
|
||||
private void ValidateCurrentGeneration(MeshUploadQueueItem item)
|
||||
{
|
||||
if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current)
|
||||
|| current != item.Generation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter
|
|||
internal sealed class CpuMeshUploadCache
|
||||
{
|
||||
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
|
||||
private readonly Dictionary<ulong, long> _bytesById = new();
|
||||
private readonly LinkedList<ulong> _lru = new();
|
||||
private readonly int _capacity;
|
||||
private readonly long _byteCapacity;
|
||||
private long _residentBytes;
|
||||
|
||||
public CpuMeshUploadCache(int capacity)
|
||||
public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1);
|
||||
_capacity = capacity;
|
||||
_byteCapacity = byteCapacity;
|
||||
}
|
||||
|
||||
internal int Count { get { lock (_data) return _data.Count; } }
|
||||
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
|
||||
|
||||
public bool TryGetAndStage(
|
||||
ulong id,
|
||||
MeshUploadStagingQueue staging,
|
||||
out ObjectMeshData? data)
|
||||
out ObjectMeshData? data,
|
||||
out MeshStageResult stageResult)
|
||||
{
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.TryGetValue(id, out data))
|
||||
if (!_data.TryGetValue(id, out data)) {
|
||||
stageResult = default;
|
||||
return false;
|
||||
}
|
||||
_lru.Remove(id);
|
||||
_lru.AddLast(id);
|
||||
staging.Stage(data);
|
||||
stageResult = staging.TryStage(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Store(ObjectMeshData data)
|
||||
public bool Store(ObjectMeshData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
|
||||
// Reject before touching the replacement/LRU state. The prior loop
|
||||
// evicted everything and then published one arbitrarily large entry,
|
||||
// allowing the cache-hit path to replay an unbounded payload forever.
|
||||
if (bytes > _byteCapacity)
|
||||
return false;
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
|
||||
if (_bytesById.Remove(data.ObjectId, out long replacedBytes))
|
||||
_residentBytes -= replacedBytes;
|
||||
|
||||
while (_lru.Count != 0
|
||||
&& (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity
|
||||
|| (_data.Count != 0 && bytes > _byteCapacity - _residentBytes)))
|
||||
{
|
||||
ulong oldest = _lru.First!.Value;
|
||||
_lru.RemoveFirst();
|
||||
_data.Remove(oldest);
|
||||
if (_bytesById.Remove(oldest, out long oldestBytes))
|
||||
_residentBytes -= oldestBytes;
|
||||
}
|
||||
|
||||
_data[data.ObjectId] = data;
|
||||
_bytesById[data.ObjectId] = bytes;
|
||||
_residentBytes = checked(_residentBytes + bytes);
|
||||
_lru.Remove(data.ObjectId);
|
||||
_lru.AddLast(data.ObjectId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache
|
|||
lock (_data)
|
||||
{
|
||||
_data.Clear();
|
||||
_bytesById.Clear();
|
||||
_lru.Clear();
|
||||
_residentBytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue