Skip to content

Commit c2c1d96

Browse files
committed
unload textures if we're too low on VRAM
1 parent 7732af0 commit c2c1d96

File tree

7 files changed

+233
-58
lines changed

7 files changed

+233
-58
lines changed

Intersect.Client.Core/Interface/Debugging/DebugWindow.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ private Table CreateGPUStatisticsTable(Base parent)
326326
table.AddRow(Strings.Debug.TextureAllocations, name: "GPUTextureAllocations").Listen(1, new DelegateDataProvider<ulong>(() => Graphics.Renderer.TextureAllocations), NoValue);
327327
table.AddRow(Strings.Debug.TextureCount, name: "GPUTextureCount").Listen(1, new DelegateDataProvider<ulong>(() => Graphics.Renderer.TextureCount), NoValue);
328328

329+
table.AddRow(Strings.Debug.UsedVRAM, name: "GPUUsedVRAM").Listen(1, new DelegateDataProvider<string>(() => FileSystemHelper.FormatSize(Graphics.Renderer.UsedMemory)), NoValue);
329330
table.AddRow(Strings.Debug.FreeVRAM, name: "GPUFreeVRAM").Listen(1, new DelegateDataProvider<string>(() => FileSystemHelper.FormatSize(PlatformStatistics.AvailableGPUMemory)), NoValue);
330331
table.AddRow(Strings.Debug.TotalVRAM, name: "GPUTotalVRAM").Listen(1, new DelegateDataProvider<string>(() => FileSystemHelper.FormatSize(PlatformStatistics.TotalGPUMemory)), NoValue);
331332

Intersect.Client.Core/Localization/Strings.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,9 @@ public partial struct Debug
973973
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
974974
public static LocalizedString TextureCount = @"Texture Assets";
975975

976+
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
977+
public static LocalizedString UsedVRAM = @"Used VRAM";
978+
976979
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
977980
public static LocalizedString FreeVRAM = @"Free VRAM";
978981

Intersect.Client.Core/MonoGame/Graphics/MonoRenderer.Texture.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Concurrent;
2+
using System.Diagnostics.CodeAnalysis;
23
using Intersect.Client.Framework.Graphics;
34
using Intersect.Core;
45
using Intersect.Framework.Reflection;
@@ -11,6 +12,51 @@ namespace Intersect.Client.MonoGame.Graphics;
1112
internal partial class MonoRenderer
1213
{
1314
private readonly ConcurrentDictionary<Texture2D, IGameTexture> _allocatedTextures = [];
15+
private long _allocatedTexturesSize;
16+
17+
public override long UsedMemory => _allocatedTexturesSize;
18+
19+
private bool AddAllocatedTexture(Texture2D platformTexture, IGameTexture gameTexture)
20+
{
21+
if (!_allocatedTextures.TryAdd(platformTexture, gameTexture))
22+
{
23+
return false;
24+
}
25+
26+
_allocatedTexturesSize += MeasureDataSize(platformTexture);
27+
return true;
28+
}
29+
30+
private bool RemoveAllocatedTexture(Texture2D platformTexture, [NotNullWhen(true)] out IGameTexture? gameTexture)
31+
{
32+
if (!_allocatedTextures.TryRemove(platformTexture, out gameTexture))
33+
{
34+
return false;
35+
}
36+
37+
_allocatedTexturesSize -= MeasureDataSize(platformTexture);
38+
return true;
39+
}
40+
41+
private static long MeasureDataSize(Texture2D platformTexture)
42+
{
43+
var width = platformTexture.Width;
44+
var height = platformTexture.Height;
45+
return width * height * 4;
46+
47+
// We don't currently use mipmaps but this may become relevant in the future:
48+
// internal static int CalculateMipLevels(int width, int height = 0, int depth = 0)
49+
// {
50+
// int mipLevels = 1;
51+
// int num = Math.Max(Math.Max(width, height), depth);
52+
// while (num > 1)
53+
// {
54+
// num /= 2;
55+
// ++mipLevels;
56+
// }
57+
// return mipLevels;
58+
// }
59+
}
1460

1561
private Texture2D? CreatePlatformTextureFromStream(MonoTexture gameTexture, Stream stream)
1662
{
@@ -21,7 +67,7 @@ internal partial class MonoRenderer
2167
}
2268

2369
platformTexture.Disposing += Texture2DOnDisposing;
24-
if (!_allocatedTextures.TryAdd(platformTexture, gameTexture))
70+
if (!AddAllocatedTexture(platformTexture, gameTexture))
2571
{
2672
throw new InvalidOperationException("Failed to record allocated texture");
2773
}
@@ -64,10 +110,17 @@ public override IGameTexture CreateTextureFromStreamFactory(string assetName, Fu
64110

65111
private void OnPlatformTextureDisposal(Texture2D platformTexture)
66112
{
67-
if (_allocatedTextures.TryRemove(platformTexture, out var gameTexture))
113+
if (RemoveAllocatedTexture(platformTexture, out var gameTexture))
68114
{
69115
MarkFreed(gameTexture);
70116
}
117+
else
118+
{
119+
ApplicationContext.CurrentContext.Logger.LogError(
120+
"Failed to remove platform texture from allocations, is it not tracked? '{TextureName}'",
121+
platformTexture.ToString()
122+
);
123+
}
71124
}
72125

73126
private class MonoTexture : GameTexture<Texture2D, MonoRenderer>

Intersect.Client.Core/MonoGame/Graphics/MonoRenderer.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
using Intersect.Core;
1414
using Intersect.Extensions;
1515
using Intersect.Framework.Core;
16-
using Intersect.Framework.SystemInformation;
1716
using Microsoft.Extensions.Logging;
1817
using Microsoft.Xna.Framework;
1918
using Microsoft.Xna.Framework.Content;
@@ -127,10 +126,6 @@ public MonoRenderer(GraphicsDeviceManager graphics, ContentManager contentManage
127126
mGameWindow = monoGame.Window;
128127
}
129128

130-
public override long AvailableMemory => PlatformStatistics.AvailableGPUMemory;
131-
132-
public override long TotalMemory => PlatformStatistics.TotalGPUMemory;
133-
134129
public IList<string> ValidVideoModes => GetValidVideoModes();
135130

136131
public void UpdateGraphicsState(int width, int height, bool initial = false)

Intersect.Client.Framework/Graphics/GameRenderer.cs

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,53 @@
11
using System.Collections.Concurrent;
2-
using System.Collections.Immutable;
32
using Intersect.Client.Framework.GenericClasses;
43
using Intersect.Core;
54
using Intersect.Framework.Collections;
5+
using Intersect.Framework.Core;
6+
using Intersect.Framework.SystemInformation;
67
using Microsoft.Extensions.Logging;
78

89
namespace Intersect.Client.Framework.Graphics;
910

1011
public abstract partial class GameRenderer : IGameRenderer, ITextHelper
1112
{
13+
// To test the unloading system, lower InactiveTimeCutoff to 5000 (5 seconds) and raise
14+
// MinimumAvailableVRAM to above your GPU RAM (if using Nvidia) or system RAM (if not using Nvidia)
15+
16+
/// <summary>
17+
/// 30 seconds in milliseconds
18+
/// </summary>
19+
private const long InactiveTimeCutoff = 30_000;
20+
1221
/// <summary>
13-
/// 128MiB, which is 2x an uncompressed RGBA 4096x4096 (and half of an uncompressed 8192x8192)
22+
/// 128MiB, which is 2x 4096px² atlases, half an 8192px² atlas
1423
/// </summary>
15-
private const int MinimumAvailableVRAM = 134217728;
24+
private const long MinimumAvailableVRAM = 134217728;
25+
26+
/// <summary>
27+
/// 30 minutes in milliseconds
28+
/// </summary>
29+
private const long StaleTimeCutoff = 30 * 60 * 1000;
30+
31+
/// <summary>
32+
/// 1GiB, which is 16x 4096px² atlases, 4x 8192px² atlases, and 1x 16384px² atlas
33+
/// </summary>
34+
private const long StaleVRAMCutoff = MinimumAvailableVRAM * 8;
35+
36+
/// <summary>
37+
/// 6 hours in milliseconds
38+
/// </summary>
39+
private const long UnusedTimeCutoff = 6 * 60 * 60 * 1000;
40+
41+
/// <summary>
42+
/// 8GiB, which is 128x 4096px² atlases, 32x 8192px² atlases, and 8x 16384px² atlases
43+
/// </summary>
44+
private const long UnusedVRAMCutoff = StaleVRAMCutoff * 8;
1645

1746
private readonly ConcurrentDictionary<string, ScreenshotRequest> _screenshotRequestLookup = [];
1847
private readonly ConcurrentConditionalDequeue<ScreenshotRequest> _screenshotRequests = [];
1948

2049
private readonly HashSet<IGameTexture> _textures = [];
21-
private readonly List<IGameTexture> _texturesSortedByExpiration = [];
50+
private readonly List<IGameTexture> _texturesSortedByAccessTime = [];
2251

2352
protected float _scale = 1.0f;
2453

@@ -47,10 +76,6 @@ public IGameTexture[] Textures
4776

4877
public ulong TextureAllocations { get; private set; }
4978

50-
public abstract long AvailableMemory { get; }
51-
52-
public abstract long TotalMemory { get; }
53-
5479
public bool HasOverrideResolution => OverrideResolution != Resolution.Empty;
5580

5681
public bool HasScreenshotRequests => _screenshotRequests.Count > 0;
@@ -246,7 +271,7 @@ protected internal void MarkAllocated(IGameTexture texture)
246271

247272
if (!texture.IsPinned)
248273
{
249-
_texturesSortedByExpiration.AddSorted(texture);
274+
_texturesSortedByAccessTime.AddSorted(texture);
250275
}
251276

252277
if (texture is IGameRenderTexture)
@@ -263,7 +288,7 @@ protected internal void MarkFreed(IGameTexture texture)
263288
{
264289
if (!texture.IsPinned)
265290
{
266-
_texturesSortedByExpiration.Remove(texture);
291+
_texturesSortedByAccessTime.Remove(texture);
267292
}
268293

269294
if (texture is IGameRenderTexture)
@@ -290,9 +315,9 @@ protected internal void MarkFreed(IGameTexture texture)
290315
TextureFreed?.Invoke(this, new TextureEventArgs(texture));
291316
}
292317

293-
internal void UpdateExpiration(IGameTexture gameTexture)
318+
internal void UpdateAccessTime(IGameTexture gameTexture)
294319
{
295-
_texturesSortedByExpiration.Resort(gameTexture);
320+
_texturesSortedByAccessTime.Resort(gameTexture);
296321
}
297322

298323
public abstract void Init();
@@ -307,17 +332,112 @@ internal void UpdateExpiration(IGameTexture gameTexture)
307332

308333
protected abstract bool RecreateSpriteBatch();
309334

335+
public abstract long UsedMemory { get; }
336+
310337
/// <summary>
311338
/// Called when the frame is done being drawn, generally used to finally display the content to the screen.
312339
/// </summary>
313340
public void End()
314341
{
315342
DoEnd();
316343

317-
while (AvailableMemory < MinimumAvailableVRAM && _texturesSortedByExpiration.FirstOrDefault() is { } texture)
344+
var usedVRAM = UsedMemory;
345+
var availableVRAM = PlatformStatistics.AvailableGPUMemory;
346+
var totalVRAM = PlatformStatistics.TotalGPUMemory;
347+
348+
if (availableVRAM < 0)
349+
{
350+
if (totalVRAM < 0)
351+
{
352+
availableVRAM = PlatformStatistics.AvailableSystemMemory;
353+
}
354+
else
355+
{
356+
availableVRAM = totalVRAM - usedVRAM;
357+
}
358+
}
359+
360+
if (totalVRAM < 0)
318361
{
319-
_texturesSortedByExpiration.Remove(texture);
320-
texture.Unload();
362+
totalVRAM = availableVRAM + usedVRAM;
363+
}
364+
365+
var now = Timing.Global.MillisecondsUtc;
366+
367+
var unusedCutoffTime = now - UnusedTimeCutoff;
368+
var staleCutoffTime = now - StaleTimeCutoff;
369+
var inactiveCutoffTime = now - InactiveTimeCutoff;
370+
371+
var unusedCutoffSize = UnusedVRAMCutoff < totalVRAM ? UnusedVRAMCutoff : totalVRAM - MinimumAvailableVRAM;
372+
var staleCutoffSize = StaleVRAMCutoff < totalVRAM ? StaleVRAMCutoff : totalVRAM - MinimumAvailableVRAM;
373+
374+
while (_texturesSortedByAccessTime.FirstOrDefault() is { } texture)
375+
{
376+
var accessTime = texture.AccessTime;
377+
if (availableVRAM > MinimumAvailableVRAM)
378+
{
379+
if (availableVRAM > staleCutoffSize)
380+
{
381+
if (availableVRAM > unusedCutoffSize)
382+
{
383+
// Don't unload anything if we've still got (at the time of writing) 8GiB of VRAM
384+
break;
385+
}
386+
387+
if (texture.AccessTime > unusedCutoffTime)
388+
{
389+
// It has been used in the last 6 hours, don't unload
390+
break;
391+
}
392+
393+
// We're below our unused VRAM threshold and the texture is considered unused, unload it
394+
ApplicationContext.CurrentContext.Logger.LogTrace(
395+
"Unloading {TextureName} because it's unused and we have {AvailableVRAM} bytes of VRAM available (cutoff is {CutoffVRAM})",
396+
texture.Name,
397+
availableVRAM,
398+
unusedCutoffSize
399+
);
400+
}
401+
else if (texture.AccessTime > staleCutoffTime)
402+
{
403+
// We are below our stale VRAM threshold, but the texture is not yet considered stale, don't unload
404+
}
405+
else
406+
{
407+
// We're below our stale VRAM threshold and the texture is considered stale, unload it
408+
ApplicationContext.CurrentContext.Logger.LogTrace(
409+
"Unloading {TextureName} because it's stale and we have {AvailableVRAM} bytes of VRAM available (cutoff is {CutoffVRAM})",
410+
texture.Name,
411+
availableVRAM,
412+
staleCutoffSize
413+
);
414+
}
415+
}
416+
else if (accessTime > inactiveCutoffTime)
417+
{
418+
// We are below our minimum VRAM threshold, but the texture is still active, don't unload
419+
break;
420+
}
421+
else
422+
{
423+
ApplicationContext.CurrentContext.Logger.LogTrace(
424+
"Unloading {TextureName} because it's inactive and we have {AvailableVRAM} bytes of VRAM available (cutoff is {CutoffVRAM})",
425+
texture.Name,
426+
availableVRAM,
427+
MinimumAvailableVRAM
428+
);
429+
}
430+
431+
// If we've reached this point in the code one of the following is true:
432+
// - We are below the unused memory threshold, and the texture is considered to be unused
433+
// - We are below the stale memory threshold, and the texture is considered to be stale
434+
// - We are below the minimum memory threshold, and the texture is considered to be inactive
435+
// In any of these three cases we want to unload the texture
436+
437+
if (texture.Unload())
438+
{
439+
_texturesSortedByAccessTime.Remove(texture);
440+
}
321441
}
322442
}
323443

0 commit comments

Comments
 (0)