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
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal file
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds how far the CPU may submit OpenGL work ahead of the GPU.
|
||||
/// Dynamic buffers are intentionally reused from frame to frame; without a
|
||||
/// frames-in-flight bound, an uncapped render loop can make the driver retain
|
||||
/// an unbounded chain of renamed backing stores while older draws are pending.
|
||||
/// </summary>
|
||||
internal interface IGpuResourceRetirementQueue
|
||||
{
|
||||
void Retire(Action release);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A physical GPU-resource release split into independently committed stages.
|
||||
/// A retirement callback may be retried after a driver or accounting failure;
|
||||
/// stages which already returned successfully are never executed twice.
|
||||
/// </summary>
|
||||
internal sealed class RetryableGpuResourceRelease
|
||||
{
|
||||
private readonly Action[] _stages;
|
||||
private int _nextStage;
|
||||
private bool _running;
|
||||
|
||||
public RetryableGpuResourceRelease(params Action[] stages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stages);
|
||||
if (stages.Length == 0)
|
||||
throw new ArgumentException("At least one release stage is required.", nameof(stages));
|
||||
if (Array.Exists(stages, static stage => stage is null))
|
||||
throw new ArgumentException("Release stages cannot contain null.", nameof(stages));
|
||||
_stages = stages;
|
||||
}
|
||||
|
||||
public int CompletedStageCount => _nextStage;
|
||||
public bool IsComplete => _nextStage == _stages.Length;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
// A release stage is allowed to call code which drains retirement
|
||||
// work. Treat that nested drain as observing the active transaction,
|
||||
// not as permission to execute the same physical mutation twice.
|
||||
if (_running)
|
||||
return;
|
||||
|
||||
_running = true;
|
||||
try
|
||||
{
|
||||
while (_nextStage < _stages.Length)
|
||||
{
|
||||
_stages[_nextStage]();
|
||||
_nextStage++;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether a throwable GPU operation changed physical ownership
|
||||
/// before surfacing its error. Stateful resource owners use this for backends
|
||||
/// or observers which can explicitly prove that ownership changed before an
|
||||
/// exception. OpenGL error validation remains in the same retryable stage as
|
||||
/// its command because a GL error means that command did not commit.
|
||||
/// </summary>
|
||||
internal sealed class GpuResourceMutationException : InvalidOperationException
|
||||
{
|
||||
public GpuResourceMutationException(
|
||||
string message,
|
||||
bool mutationCommitted,
|
||||
Exception innerException)
|
||||
: base(message, innerException) => MutationCommitted = mutationCommitted;
|
||||
|
||||
public bool MutationCommitted { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retains release ownership until a callback has either run immediately or
|
||||
/// has been accepted by the frame-flight queue. Queue insertion failure can
|
||||
/// therefore be retried without losing the only references to old GL names.
|
||||
/// </summary>
|
||||
internal sealed class GpuRetirementLedger
|
||||
{
|
||||
private readonly IGpuResourceRetirementQueue _queue;
|
||||
private readonly List<RetryableGpuResourceRelease> _awaitingPublication = [];
|
||||
private readonly HashSet<RetryableGpuResourceRelease> _publishing =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
|
||||
public GpuRetirementLedger(IGpuResourceRetirementQueue queue) =>
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
|
||||
public int AwaitingPublicationCount => _awaitingPublication.Count;
|
||||
|
||||
public void Retire(RetryableGpuResourceRelease release)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
_awaitingPublication.Add(release);
|
||||
PublishAt(_awaitingPublication.Count - 1);
|
||||
}
|
||||
|
||||
public void RetireMany(IEnumerable<RetryableGpuResourceRelease> releases)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(releases);
|
||||
RetryableGpuResourceRelease[] batch = releases.ToArray();
|
||||
if (batch.Length == 0)
|
||||
return;
|
||||
if (Array.Exists(batch, static release => release is null))
|
||||
throw new ArgumentException("Retirement batches cannot contain null.", nameof(releases));
|
||||
|
||||
// Establish ownership for the complete physical set before the first
|
||||
// queue call. If publication N fails, later resources remain reachable
|
||||
// and the next maintenance pass can publish every independent member.
|
||||
_awaitingPublication.AddRange(batch);
|
||||
List<Exception>? failures = null;
|
||||
for (int i = 0; i < batch.Length; i++)
|
||||
{
|
||||
try { Publish(batch[i]); }
|
||||
catch (Exception error) { (failures ??= []).Add(error); }
|
||||
}
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more GPU retirement callbacks could not be published.",
|
||||
failures);
|
||||
}
|
||||
|
||||
public void RetryPendingPublications()
|
||||
{
|
||||
RetryableGpuResourceRelease[] pending = _awaitingPublication.ToArray();
|
||||
List<Exception>? failures = null;
|
||||
for (int i = 0; i < pending.Length; i++)
|
||||
{
|
||||
RetryableGpuResourceRelease release = pending[i];
|
||||
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|
||||
|| _publishing.Contains(release))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Publish(release);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more GPU retirement callbacks could not be published.",
|
||||
failures);
|
||||
}
|
||||
|
||||
public void RetryPendingPublication(RetryableGpuResourceRelease release)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|
||||
|| _publishing.Contains(release))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Publish(release);
|
||||
}
|
||||
|
||||
private void PublishAt(int index)
|
||||
{
|
||||
RetryableGpuResourceRelease release = _awaitingPublication[index];
|
||||
Publish(release);
|
||||
}
|
||||
|
||||
private void Publish(RetryableGpuResourceRelease release)
|
||||
{
|
||||
if (!_publishing.Add(release))
|
||||
return;
|
||||
try
|
||||
{
|
||||
_queue.Retire(release.Run);
|
||||
_awaitingPublication.Remove(release);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// An immediate queue can throw from the callback itself. If every
|
||||
// stage committed before a later wrapper failed, no retry remains.
|
||||
if (release.IsComplete)
|
||||
_awaitingPublication.Remove(release);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishing.Remove(release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetirementQueue
|
||||
{
|
||||
public static ImmediateGpuResourceRetirementQueue Instance { get; } = new();
|
||||
|
||||
private ImmediateGpuResourceRetirementQueue()
|
||||
{
|
||||
}
|
||||
|
||||
public void Retire(Action release)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IDisposable
|
||||
{
|
||||
internal const int DefaultMaximumFramesInFlight = 3;
|
||||
private const ulong WaitSliceNanoseconds = 1_000_000;
|
||||
|
||||
private readonly IGpuFenceApi _fenceApi;
|
||||
private readonly nint[] _fences;
|
||||
private readonly long[] _fenceSerials;
|
||||
private readonly SortedDictionary<long, List<Action>> _retirements = new();
|
||||
private int _slot;
|
||||
private long _lastSubmittedSerial;
|
||||
private bool _frameOpen;
|
||||
private bool _disposed;
|
||||
|
||||
public int CurrentSlot => _slot;
|
||||
public int SlotCount => _fences.Length;
|
||||
internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count);
|
||||
|
||||
public GpuFrameFlightController(GL gl, int maximumFramesInFlight = DefaultMaximumFramesInFlight)
|
||||
: this(new SilkGpuFenceApi(gl), maximumFramesInFlight)
|
||||
{
|
||||
}
|
||||
|
||||
internal GpuFrameFlightController(
|
||||
IGpuFenceApi fenceApi,
|
||||
int maximumFramesInFlight = DefaultMaximumFramesInFlight)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fenceApi);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumFramesInFlight, 1);
|
||||
|
||||
_fenceApi = fenceApi;
|
||||
_fences = new nint[maximumFramesInFlight];
|
||||
_fenceSerials = new long[maximumFramesInFlight];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the frame currently occupying the next ring slot. The first
|
||||
/// wait flushes submitted commands so the fence can make progress; later
|
||||
/// one-millisecond slices avoid an unbounded native blocking call.
|
||||
/// </summary>
|
||||
public void BeginFrame()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_frameOpen)
|
||||
throw new InvalidOperationException("EndFrame must close the current frame before BeginFrame is called again.");
|
||||
|
||||
nint fence = _fences[_slot];
|
||||
if (fence != 0)
|
||||
RetireFence(_slot);
|
||||
|
||||
_frameOpen = true;
|
||||
}
|
||||
|
||||
private void RetireFence(int slot)
|
||||
{
|
||||
nint fence = _fences[slot];
|
||||
if (fence == 0)
|
||||
return;
|
||||
|
||||
bool flushCommands = true;
|
||||
while (true)
|
||||
{
|
||||
GpuFenceWaitResult result = _fenceApi.Wait(
|
||||
fence,
|
||||
flushCommands,
|
||||
WaitSliceNanoseconds);
|
||||
flushCommands = false;
|
||||
|
||||
if (result == GpuFenceWaitResult.Timeout)
|
||||
continue;
|
||||
if (result == GpuFenceWaitResult.Failed)
|
||||
throw new InvalidOperationException("OpenGL failed while waiting for an in-flight frame fence.");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
_fenceApi.Delete(fence);
|
||||
_fences[slot] = 0;
|
||||
long completedSerial = _fenceSerials[slot];
|
||||
_fenceSerials[slot] = 0;
|
||||
RunRetirementsThrough(completedSerial);
|
||||
}
|
||||
|
||||
/// <summary>Marks every GL command submitted by the current frame.</summary>
|
||||
public void EndFrame()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_frameOpen)
|
||||
throw new InvalidOperationException("BeginFrame must be called before EndFrame.");
|
||||
if (_fences[_slot] != 0)
|
||||
throw new InvalidOperationException("BeginFrame must retire the current frame slot before EndFrame.");
|
||||
|
||||
nint fence = _fenceApi.Insert();
|
||||
if (fence == 0)
|
||||
throw new InvalidOperationException("OpenGL did not create an in-flight frame fence.");
|
||||
|
||||
_fences[_slot] = fence;
|
||||
_fenceSerials[_slot] = ++_lastSubmittedSerial;
|
||||
_frameOpen = false;
|
||||
_slot = (_slot + 1) % _fences.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defers destruction until the fence covering every draw that could still
|
||||
/// reference the resource has signaled. Calls made during a render frame
|
||||
/// include that frame; update-thread calls cover the last submitted frame.
|
||||
/// </summary>
|
||||
public void Retire(Action release)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
|
||||
long targetSerial = _frameOpen
|
||||
? _lastSubmittedSerial + 1
|
||||
: _lastSubmittedSerial;
|
||||
if (targetSerial == 0)
|
||||
{
|
||||
release();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_retirements.TryGetValue(targetSerial, out List<Action>? releases))
|
||||
{
|
||||
releases = [];
|
||||
_retirements.Add(targetSerial, releases);
|
||||
}
|
||||
|
||||
releases.Add(release);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for all submitted GL work and runs every resource retirement it
|
||||
/// protects. Used before orderly renderer teardown while the context lives.
|
||||
/// </summary>
|
||||
public void WaitForSubmittedWork()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_frameOpen)
|
||||
throw new InvalidOperationException("Cannot drain submitted work while a render frame is open.");
|
||||
|
||||
List<Exception>? failures = null;
|
||||
for (int i = 0; i < _fences.Length; i++)
|
||||
{
|
||||
int slot = (_slot + i) % _fences.Length;
|
||||
try
|
||||
{
|
||||
RetireFence(slot);
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
(failures ??= []).AddRange(ex.InnerExceptions);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunRetirementsThrough(_lastSubmittedSerial);
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
(failures ??= []).AddRange(ex.InnerExceptions);
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException("One or more GPU resource retirements failed.", failures);
|
||||
}
|
||||
|
||||
private void RunRetirementsThrough(long completedSerial)
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
List<(long Serial, Action Release)>? retry = null;
|
||||
while (_retirements.Count != 0)
|
||||
{
|
||||
KeyValuePair<long, List<Action>> first;
|
||||
using (IEnumerator<KeyValuePair<long, List<Action>>> enumerator = _retirements.GetEnumerator())
|
||||
{
|
||||
if (!enumerator.MoveNext() || enumerator.Current.Key > completedSerial)
|
||||
break;
|
||||
first = enumerator.Current;
|
||||
}
|
||||
|
||||
if (!_retirements.Remove(first.Key))
|
||||
break;
|
||||
|
||||
List<Action> releases = first.Value;
|
||||
for (int i = 0; i < releases.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
releases[i]();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
(failures ??= []).Add(ex);
|
||||
(retry ??= []).Add((first.Key, releases[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retry is not null)
|
||||
{
|
||||
for (int i = 0; i < retry.Count; i++)
|
||||
{
|
||||
(long serial, Action release) = retry[i];
|
||||
if (!_retirements.TryGetValue(serial, out List<Action>? releases))
|
||||
{
|
||||
releases = [];
|
||||
_retirements.Add(serial, releases);
|
||||
}
|
||||
releases.Add(release);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException("One or more GPU resource retirements failed.", failures);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
if (_frameOpen)
|
||||
EndFrame();
|
||||
WaitForSubmittedWork();
|
||||
|
||||
Array.Clear(_fences);
|
||||
Array.Clear(_fenceSerials);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum GpuFenceWaitResult
|
||||
{
|
||||
Signaled,
|
||||
Timeout,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal interface IGpuFenceApi
|
||||
{
|
||||
nint Insert();
|
||||
GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds);
|
||||
void Delete(nint fence);
|
||||
}
|
||||
|
||||
internal sealed class SilkGpuFenceApi(GL gl) : IGpuFenceApi
|
||||
{
|
||||
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
||||
public nint Insert() =>
|
||||
_gl.FenceSync(SyncCondition.SyncGpuCommandsComplete, SyncBehaviorFlags.None);
|
||||
|
||||
public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds)
|
||||
{
|
||||
SyncObjectMask flags = flushCommands
|
||||
? SyncObjectMask.Bit
|
||||
: 0;
|
||||
return _gl.ClientWaitSync(fence, flags, timeoutNanoseconds) switch
|
||||
{
|
||||
GLEnum.AlreadySignaled or GLEnum.ConditionSatisfied => GpuFenceWaitResult.Signaled,
|
||||
GLEnum.TimeoutExpired => GpuFenceWaitResult.Timeout,
|
||||
GLEnum.WaitFailed => GpuFenceWaitResult.Failed,
|
||||
GLEnum value => throw new InvalidOperationException(
|
||||
$"OpenGL returned unexpected fence wait status {value} (0x{(uint)value:X})."),
|
||||
};
|
||||
}
|
||||
|
||||
public void Delete(nint fence) => _gl.DeleteSync(fence);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue