[GPU/HLE] Cut per-draw and per-call managed allocations on hot paths (#1054)

* [GPU/HLE] Cut per-draw and per-call allocations on hot paths

* [HLE] Keep nested tracker uploads from sharing rollback scratch
This commit is contained in:
kuba 2026-10-07 11:36:27 +02:00 committed by GitHub
parent 98f223ce69
commit 8acc081205
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 277 additions and 95 deletions

View File

@ -242,50 +242,108 @@ public sealed class GuestPageTracker
}
}
// Region locks stay held across uploadFunc only for a written range, which turns GPU-dirty.
// Receives the CPU-dirty runs an upload copies, then performs the upload while the regions are held.
public interface IUploadRangeSink
{
void Range(ulong address, ulong size);
void Upload();
}
private struct DelegateUploadSink(Action<ulong, ulong> rangeFunc, Action uploadFunc) : IUploadRangeSink
{
public readonly void Range(ulong address, ulong size) => rangeFunc(address, size);
public readonly void Upload() => uploadFunc();
}
// Forwards a region's upload runs to the sink and remembers the runs it cleared, for rollback.
private struct RegionUploadVisitor<TSink>(TSink sink, List<(TrackedRegion Region, ulong Address, ulong Size)> cleared)
: TrackedRegion.ICpuUploadVisitor
where TSink : struct, IUploadRangeSink
{
public TSink Sink = sink;
public readonly void Cleared(TrackedRegion region, ulong address, ulong size) => cleared.Add((region, address, size));
public void Upload(ulong address, ulong size) => Sink.Range(address, size);
}
// Uploads run on every bound buffer of every draw, so the bookkeeping lists are reused per thread.
[ThreadStatic]
private static List<TrackedRegion>? _scratchHeld;
[ThreadStatic]
private static List<(TrackedRegion Region, ulong Address, ulong Size)>? _scratchCleared;
public void ForEachUploadRange(ulong vaddr, ulong size, bool isWritten, Action<ulong, ulong> rangeFunc, Action uploadFunc,
bool preserveCpuWriteHotPages = true)
{
var sink = new DelegateUploadSink(rangeFunc, uploadFunc);
ForEachUploadRange(vaddr, size, isWritten, ref sink, preserveCpuWriteHotPages);
}
// Region locks stay held across the upload only for a written range, which turns GPU-dirty.
public void ForEachUploadRange<TSink>(ulong vaddr, ulong size, bool isWritten, ref TSink sink, bool preserveCpuWriteHotPages = true)
where TSink : struct, IUploadRangeSink
{
RejectUploadCallbackReentry();
VisitRegions(vaddr, size, create: true, static (_, _, _) => false);
var previousOwner = _uploadOwner;
_uploadOwner = this;
var held = new List<TrackedRegion>();
var cleared = new List<(TrackedRegion Region, ulong Address, ulong Size)>();
// Another tracker may upload from this callback. It needs its own rollback
// state; the reentry guard only rejects reentry into this tracker.
var held = previousOwner is null
? _scratchHeld ??= new List<TrackedRegion>()
: new List<TrackedRegion>();
var cleared = previousOwner is null
? _scratchCleared ??= new List<(TrackedRegion Region, ulong Address, ulong Size)>()
: new List<(TrackedRegion Region, ulong Address, ulong Size)>();
held.Clear();
cleared.Clear();
try
{
using (GpuMemoryAccessProfile.Measure(GpuMemoryAccessProfile.Operation.UploadTracking, size))
{
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
var visitor = new RegionUploadVisitor<TSink>(sink, cleared);
var preserveHotPages = !isWritten && preserveCpuWriteHotPages;
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
{
var bytes = Math.Min(BlockBytes - offset, remaining);
remaining -= bytes;
if (Volatile.Read(ref _regions[index]) is not { } region)
{
continue;
}
region.Lock.Enter();
held.Add(region);
var address = region.BaseAddress + offset;
region.ForEachCpuUploadRange(
preserveHotPages: !isWritten && preserveCpuWriteHotPages,
address,
bytes,
(runAddress, runSize) => cleared.Add((region, runAddress, runSize)),
rangeFunc);
region.ForEachCpuUploadRange(preserveHotPages, region.BaseAddress + offset, bytes, ref visitor);
if (!isWritten)
{
region.Lock.Exit();
held.Remove(region);
held.RemoveAt(held.Count - 1);
}
}
return false;
});
sink = visitor.Sink;
}
uploadFunc();
sink.Upload();
if (isWritten)
{
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
{
var bytes = Math.Min(BlockBytes - offset, remaining);
remaining -= bytes;
if (Volatile.Read(ref _regions[index]) is not { } region)
{
continue;
}
region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + offset, bytes);
region.Lock.Exit();
held.Remove(region);
return false;
});
}
}
}
catch
@ -333,6 +391,8 @@ public sealed class GuestPageTracker
region.Lock.Exit();
}
held.Clear();
cleared.Clear();
_uploadOwner = previousOwner;
}
}

View File

@ -144,20 +144,24 @@ public sealed class TrackedRegion
}
}
// Receives the runs one upload clears and the runs it copies; a struct visitor keeps the walk allocation-free.
public interface ICpuUploadVisitor
{
void Cleared(TrackedRegion region, ulong address, ulong size);
void Upload(ulong address, ulong size);
}
// Hot read-only pages stay writable and dirty, so each obtain observes current CPU bytes.
public void ForEachCpuUploadRange(
bool preserveHotPages,
ulong address,
ulong size,
Action<ulong, ulong> visitCleared,
Action<ulong, ulong> visitUpload)
public void ForEachCpuUploadRange<TVisitor>(bool preserveHotPages, ulong address, ulong size, ref TVisitor visitor)
where TVisitor : struct, ICpuUploadVisitor
{
var (start, end) = GetPageRange(address, size);
var upload = new PageMask(_cpuDirty, start, end);
var cleared = preserveHotPages ? upload & ~_hotCpuWrites : upload;
foreach (var (runStart, runEnd) in cleared)
{
visitCleared(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
visitor.Cleared(this, BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
_cpuDirty.UnsetRange(runStart, runEnd);
_recentCpuUploads.SetRange(runStart, runEnd);
}
@ -165,7 +169,7 @@ public sealed class TrackedRegion
UpdateCpuProtection(track: true);
foreach (var (runStart, runEnd) in upload)
{
visitUpload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
visitor.Upload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
}
}

View File

@ -455,11 +455,15 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
}
// The fill pattern staged per thread; DCC fills arrive with every compressed target clear.
[ThreadStatic]
private static uint[]? _fillPattern;
private bool TryWriteFillToBacking(ulong guestAddress, ulong size, uint value)
{
var values = new uint[(int)Math.Min(size / sizeof(uint), 4096)];
Array.Fill(values, value);
var bytes = MemoryMarshal.AsBytes<uint>(values);
var values = (_fillPattern ??= new uint[4096]).AsSpan(0, (int)Math.Min(size / sizeof(uint), 4096));
values.Fill(value);
var bytes = MemoryMarshal.AsBytes(values);
for (ulong offset = 0; offset < size;)
{
var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length);
@ -2043,20 +2047,11 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
profileScope.SwitchPhase(isWritten
? RenderPhaseProfile.Phase.BufferDirtySyncWritten
: isTexelBuffer ? RenderPhaseProfile.Phase.BufferDirtySyncTexel : RenderPhaseProfile.Phase.BufferDirtySyncUpload);
var copies = new List<BufferCopy>();
var totalSize = 0UL;
GpuBuffer? source = null;
_tracker.ForEachUploadRange(
guestAddress,
size,
isWritten,
(address, bytes) =>
{
copies.Add(new BufferCopy(totalSize, buffer.Offset(address), bytes));
totalSize += bytes;
},
() => source = _uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), totalSize, guestAddress, size),
preserveCpuWriteHotPages);
var copies = _syncCopies ??= new List<BufferCopy>();
copies.Clear();
var sink = new UploadSink(_uploader, buffer, copies, guestAddress, size);
_tracker.ForEachUploadRange(guestAddress, size, isWritten, ref sink, preserveCpuWriteHotPages);
var source = sink.Source;
if (source != null)
{
buffer.NoteGpuWrite();
@ -2098,7 +2093,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
ulong hotBytes = 0;
foreach (var copy in copies)
hotBytes += _tracker.CountCpuWriteHotBytes(buffer.CpuAddress + copy.DstOffset, copy.Size);
BufferUploadProfile.Record(guestAddress, size, copies.Count, totalSize, hotBytes, elapsedTicks);
BufferUploadProfile.Record(guestAddress, size, copies.Count, sink.TotalSize, hotBytes, elapsedTicks);
}
if (isTexelBuffer)
@ -2115,6 +2110,28 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return false;
}
// The copies of one synchronization; the tracker forbids nesting, so one list per thread is reused.
[ThreadStatic]
private static List<BufferCopy>? _syncCopies;
// Collects a buffer's CPU-dirty runs as copies, then stages them while the tracker holds the regions.
private struct UploadSink(GuestBufferUploader uploader, GpuBuffer buffer, List<BufferCopy> copies, ulong guestAddress, ulong size)
: GuestPageTracker.IUploadRangeSink
{
public ulong TotalSize { get; private set; }
public GpuBuffer? Source { get; private set; }
public void Range(ulong address, ulong bytes)
{
copies.Add(new BufferCopy(TotalSize, buffer.Offset(address), bytes));
TotalSize += bytes;
}
public void Upload() =>
Source = uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), TotalSize, guestAddress, size);
}
private GpuBuffer? FindOwner(ulong guestAddress, ulong size)
{
return _registry.TryGetRegisteredBuffer(_registry.FindContainingBuffer(guestAddress, size));

View File

@ -40,9 +40,12 @@ public sealed unsafe partial class CachedImage
};
// Barriers for the requested state; a repeated write always gets a barrier.
private static readonly List<ImageMemoryBarrier2> NoBarriers = new(0);
public (List<ImageMemoryBarrier2> Barriers, PipelineStageFlags SourceStages) GetBarriers(ImageLayout layout, AccessFlags access, PipelineStageFlags stage, SubresourceRange? range)
{
var barriers = new List<ImageMemoryBarrier2>();
// Most requests need no barrier; the list exists only once one does. Callers never add to it.
List<ImageMemoryBarrier2>? barriers = null;
PipelineStageFlags sourceStages = 0;
if ((access & (WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit)) != 0)
{
@ -95,7 +98,7 @@ public sealed unsafe partial class CachedImage
}
else if (state.Layout != layout || state.Access != access || repeatedWrite)
{
barriers.Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
(barriers ??= new List<ImageMemoryBarrier2>(1)).Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
sourceStages |= state.Stage;
states[index] = new ImageAccessState(stage, access, layout);
}
@ -113,21 +116,21 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout == layout && state.Access == access && !repeatedWrite)
{
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
if (IsReadAfterRead(state, layout, access))
{
Backing.State = new ImageAccessState(state.Stage | stage, state.Access | access, layout);
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
barriers.Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
(barriers ??= new List<ImageMemoryBarrier2>(1)).Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
sourceStages |= state.Stage;
}
Backing.State = new ImageAccessState(stage, access, layout);
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
// The layout every subresource in the range is in, or null when they differ.
@ -217,7 +220,7 @@ public sealed unsafe partial class CachedImage
private void RecordBarriers(CommandBuffer command, PipelineStageFlags sourceStages, PipelineStageFlags destinationStages, BufferMemoryBarrier2* bufferBarrier, List<ImageMemoryBarrier2> imageBarriers)
{
var images = imageBarriers.ToArray();
var images = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(imageBarriers);
fixed (ImageMemoryBarrier2* imagePointer = images)
{
VulkanSynchronization.PipelineBarrier(_device.Vk,

View File

@ -2358,41 +2358,53 @@ public static partial class KernelMemoryCompatExports
positionBefore = -1;
}
var buffer = GC.AllocateUninitializedArray<byte>(requested);
var read = stream.Read(buffer, 0, requested);
if (read > 0 && useBinkCompletionShim)
{
// The patched NumFrames field is what tells the guest "this
// movie is fully consumed" - hold that specific read until the
// host has actually finished showing it, so guest-side game
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
long positionAfter;
// Guest reads are frequent and mostly small: a pooled buffer spares each one an allocation.
var pooled = requested <= MaxPooledReadBytes;
var buffer = pooled ? ArrayPool<byte>.Shared.Rent(requested) : GC.AllocateUninitializedArray<byte>(requested);
try
{
positionAfter = stream.Position;
var read = stream.Read(buffer, 0, requested);
if (read > 0 && useBinkCompletionShim)
{
// The patched NumFrames field is what tells the guest "this
// movie is fully consumed" - hold that specific read until the
// host has actually finished showing it, so guest-side game
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
long positionAfter;
try
{
positionAfter = stream.Position;
}
catch (IOException)
{
positionAfter = -1;
}
LogIoTrace(
"read",
stream.Name,
$"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}");
ctx[CpuRegister.Rax] = unchecked((ulong)read);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
catch (IOException)
finally
{
positionAfter = -1;
if (pooled)
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
LogIoTrace(
"read",
stream.Name,
$"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}");
ctx[CpuRegister.Rax] = unchecked((ulong)read);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@ -7206,9 +7218,41 @@ public static partial class KernelMemoryCompatExports
}
// Read once: file-heavy titles call LogIoTrace for every resolved path.
private const int MaxPooledReadBytes = 1 << 20;
private static readonly bool _logIo =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IO"), "1", StringComparison.Ordinal);
// Builds an I/O trace detail only while SHARPEMU_LOG_IO is on: its holes (byte previews of
// every read) are otherwise never evaluated.
[System.Runtime.CompilerServices.InterpolatedStringHandler]
private ref struct IoTraceDetail
{
private System.Runtime.CompilerServices.DefaultInterpolatedStringHandler _inner;
public IoTraceDetail(int literalLength, int formattedCount, out bool enabled)
{
enabled = _logIo;
_inner = enabled ? new System.Runtime.CompilerServices.DefaultInterpolatedStringHandler(literalLength, formattedCount) : default;
}
public void AppendLiteral(string value) => _inner.AppendLiteral(value);
public void AppendFormatted<T>(T value) => _inner.AppendFormatted(value);
public void AppendFormatted<T>(T value, string? format) => _inner.AppendFormatted(value, format);
public string ToStringAndClear() => _inner.ToStringAndClear();
}
private static void LogIoTrace(string operation, string path, IoTraceDetail detail)
{
if (_logIo)
{
LogIoTrace(operation, path, detail.ToStringAndClear());
}
}
private static void LogIoTrace(string operation, string path, string detail)
{
if (!_logIo)

View File

@ -1054,12 +1054,7 @@ public static class KernelPthreadCompatExports
}
if (canCooperativelyBlock && waiter is not null &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_mutex_lock",
waiter.WakeKey,
() => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter),
() => TryGrantBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)))
RequestBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter))
{
TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@ -1070,6 +1065,21 @@ public static class KernelPthreadCompatExports
return hostResult;
}
// The continuations capture the lock's arguments in a closure; keeping them out of
// PthreadMutexLockCore spares every uncontended lock that allocation.
private static bool RequestBlockedMutexLock(
CpuContext ctx,
ulong mutexAddress,
ulong resolvedAddress,
PthreadMutexState state,
PthreadMutexWaiter waiter) =>
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_mutex_lock",
waiter.WakeKey,
() => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter),
() => TryGrantBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter));
private static int PthreadMutexUnlockCore(CpuContext ctx, ulong mutexAddress, bool requireOwner)
{
if (mutexAddress == 0)

View File

@ -62,14 +62,7 @@ internal static class ScalarGraphDiskCache
Directory.CreateDirectory(directory);
// Keep a bounded cache across compiler versions too. No live
// analysis objects or guest memory are retained by the cache.
var entries = new DirectoryInfo(directory).GetFiles("*.graph").OrderBy(f => f.LastWriteTimeUtc).ToArray();
var total = entries.Sum(f => f.Length) + stream.Length + 32;
foreach (var entry in entries)
{
if (total <= MaxCacheBytes) break;
total -= entry.Length;
entry.Delete();
}
EvictOldest(directory, stream.Length + 32);
var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
@ -79,6 +72,8 @@ internal static class ScalarGraphDiskCache
file.Write(bytes);
}
File.Move(temporary, path, true);
_entries!.Enqueue((path, stream.Length + 32));
_totalBytes += stream.Length + 32;
}
finally { File.Delete(temporary); }
}
@ -89,6 +84,30 @@ internal static class ScalarGraphDiskCache
return graph;
}
// The entries of the cache directory, oldest first, and their total size. The directory is
// listed once per process: a new build misses on every shader, and listing it per miss cost
// more than the analysis it saved. Caller holds WriteLock.
private static string? _entriesDirectory;
private static Queue<(string Path, long Bytes)>? _entries;
private static long _totalBytes;
private static void EvictOldest(string directory, long incoming)
{
if (_entries is null || _entriesDirectory != directory)
{
var files = new DirectoryInfo(directory).GetFiles("*.graph").OrderBy(f => f.LastWriteTimeUtc).ToArray();
_entries = new Queue<(string Path, long Bytes)>(files.Select(f => (f.FullName, f.Length)));
_totalBytes = files.Sum(f => f.Length);
_entriesDirectory = directory;
}
while (_totalBytes + incoming > MaxCacheBytes && _entries.TryDequeue(out var entry))
{
_totalBytes -= entry.Bytes;
File.Delete(entry.Path);
}
}
internal static string Key(Gen5ShaderProgram program, uint userDataBase, uint userDataCount,
IReadOnlySet<uint>? fixedFunctionVertexLoads, uint waveSize)
{

View File

@ -739,6 +739,31 @@ public sealed class GuestPageTrackerTests : IDisposable
Release(address, Page);
}
[NativePageProtectionFact]
public void NestedUploadOnAnotherTrackerKeepsTheOuterRollbackState()
{
using var other = new GuestPageTrackerTests();
var address = Allocate(1);
var otherAddress = other.Allocate(1);
Assert.Throws<InvalidOperationException>(() =>
_tracker.ForEachUploadRange(address, Page, false, NoRange, () =>
{
other._tracker.ForEachUploadRange(otherAddress, Page, false, NoRange, NoUpload);
throw new InvalidOperationException("outer upload failed");
}));
Assert.True(_tracker.HasCpuDirtyPages(address, Page));
Assert.True(IsWritable(address));
Assert.False(other._tracker.HasCpuDirtyPages(otherAddress, Page));
var uploaded = 0;
_tracker.ForEachUploadRange(address, Page, false, (_, _) => uploaded++, NoUpload);
Assert.Equal(1, uploaded);
_tracker.UntrackMemory(address, Page);
Release(address, Page);
other._tracker.UntrackMemory(otherAddress, Page);
other.Release(otherAddress, Page);
}
[NativePageProtectionTheory]
[InlineData(false, true)]
[InlineData(true, true)]