[HLE] Remove steady-state allocations from the hot HLE paths (#190)

* [HLE] Stop allocating on the memcpy/memset and trace hot paths

memcpy/memmove no longer allocate a bounce buffer sized to the whole
copy (large copies previously landed on the LOH); they loop through a
single pooled 256 KB rental, copying high-to-low when the destination
overlaps above the source so memmove semantics survive the chunking.
memset reuses a shared zero chunk for the dominant zero-fill case and
rents/fills only min(length, 16K) bytes for non-zero values instead of
allocating and filling a fresh 16 KB array per call; the map-time
zero-fill loop shares the same zero chunk.

SHARPEMU_LOG_SEMA / SHARPEMU_LOG_VIDEOOUT are now read once into cached
bools and every TraceSemaphore/TraceVideoOut call site is guarded, so
trace messages are no longer interpolated (and the env var no longer
queried) on every semaphore op and every flip with tracing off. Trace
output when the flags are set is unchanged.

* [HLE] Remove per-frame allocations from the vblank/flip/equeue plumbing

The 60 Hz vblank pump no longer allocates per edge: PumpVblanks reuses a
pump-thread-only port list instead of a LINQ Where/ToArray, and
SignalVblank/SubmitFlip snapshot their event registrations into pooled
rentals instead of copying the List on every edge and every flip (the
snapshot must still be taken, since triggers run outside _stateGate and
a per-port reusable buffer would race the pump thread against a guest
thread's first-edge signal).

sceKernelWaitEqueue delivery rents the dequeue buffer from the pool
instead of allocating an array per wait, and event-queue wake keys are
formatted once per handle (cached in a ConcurrentDictionary, dropped on
queue delete) instead of building the string on every enqueue. The
semaphore wake key moves onto KernelSemaphoreState at creation, the
same pattern the pthread mutex state already uses, removing the
per-signal/per-wait formatting. SHARPEMU_LOG_EQUEUE is read once into a
cached bool like the sema/videoout flags.

* [HLE] Read guest C-strings without per-call buffer allocations

CpuContext.TryReadNullTerminatedUtf8 allocated a byte[capacity] and
issued one TryRead per byte for every string-argument import. It now
reads through a stack buffer (pooled above 512 bytes) in 128-byte bulk
chunks, falling back to per-byte reads only when a chunk touches an
unreadable range so a terminator sitting just before unmapped memory
still resolves exactly as before. The chunk bound also keeps the
overread past the terminator smaller than the old loop's worst case is
wide, so no fault can appear where the byte loop succeeded.

TryReadAsciiZ (dlsym/symbol resolution) drops its List<byte> + ToArray
round-trip for the same stack/pooled buffer, keeping the byte-by-byte
TryReadByteCompat reads because their Marshal.ReadByte fallback must
probe exactly up to the terminator. Only the final string is allocated
on either path now.

* [HLE] Replace blocking-wait closures with waiter continuation objects

Every wait that actually parked a guest thread allocated two capturing
lambdas (plus their display classes) for the scheduler's resume/wake
callbacks. RequestCurrentThreadBlock and the backend's blocked-thread
state now carry a single IGuestThreadBlockWaiter instead of the
Func<int>/Func<bool> pair: TryWake keeps the run-under-the-scheduler-
gate contract and Resume still produces the guest's RAX on the woken
thread. The waiter stays attached through the wake transition (the old
code nulled only the wake handler there) and is consumed at resume.

The existing waiter objects absorb the captured state as fields, so a
blocking wait now allocates exactly one object: SemaphoreWaiter,
PthreadMutexWaiter, and EventFlagWaiter implement the interface
directly, and the equeue, cond, and rwlock waits get small waiter
classes replacing their closures. Handler bodies delegate to the same
static methods with the same arguments as before; the untimed event
flag wait's mutable captured result becomes a field on its waiter.

* [HLE] Back pending event queues with a ring deque instead of LinkedList

LinkedList<KernelQueuedEvent> allocated a node object on every
non-coalesced enqueue — one per vblank/flip edge per registered queue,
60+ times a second in steady state. KernelEventDeque is a grow-only
ring buffer over a KernelQueuedEvent[] with the three operations the
queue actually uses (AddLast, RemoveFirst, find-and-update-in-place by
ident/filter), so steady-state enqueue/dequeue allocates nothing and
the coalescing update writes the struct back through an indexer instead
of a node reference. All accesses stay under _eventQueueGate, matching
the LinkedList usage it replaces.

* [HLE] Cap memcpy chunk iterations at the requested size, not the rented length

Address Copilot review: ArrayPool.Rent may return a larger array than
requested, so sizing each iteration by chunk.Length let the copy
granularity depend on pool bucketing internals instead of the intended
256 KB chunking. Behavior was already correct for any chunk size (each
iteration re-reads the source, and the overlap ordering is size-
independent), but the loop now mins against the requested chunkLength,
matching what memset already does.

* [HLE] Skip the flip/vblank snapshot rental when no events are registered

Address Copilot review: SignalVblank and SubmitFlip rented (and
returned) a pooled snapshot even with zero registrations — steady
per-frame pool traffic for games that never register flip events and
only poll flip status. Zero-count signals now skip the rental, the
copy, and the trigger loop entirely, which also retires the
Math.Max(count, 1) minimum-rent guard.
This commit is contained in:
Gutemberg Ribeiro
2026-07-15 10:57:40 +01:00
committed by GitHub
parent 6dacd59a08
commit 62e1775c5c
11 changed files with 645 additions and 261 deletions
+107 -36
View File
@@ -5,6 +5,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
using SharpEmu.Logging;
using System.Buffers;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -180,6 +181,10 @@ public static class VideoOutExports
}
}
// Only ever touched by the vblank pump thread; reused across edges so the 60 Hz
// pump does not allocate a fresh snapshot per edge.
private static readonly List<VideoOutPortState> _vblankPumpPorts = new();
private static void PumpVblanks()
{
lock (_vblankEdgeGate)
@@ -188,7 +193,7 @@ public static class VideoOutExports
Monitor.PulseAll(_vblankEdgeGate);
}
VideoOutPortState[] ports;
_vblankPumpPorts.Clear();
lock (_stateGate)
{
if (_ports.Count == 0)
@@ -198,10 +203,16 @@ public static class VideoOutExports
// Signalling reaches WakeBlockedThreads -> Pump(), which serialises on one global
// flag. Waking an unwatched queue would hold it 60x/sec and starve guest threads.
ports = _ports.Values.Where(static port => port.VblankEvents.Count != 0).ToArray();
foreach (var port in _ports.Values)
{
if (port.VblankEvents.Count != 0)
{
_vblankPumpPorts.Add(port);
}
}
}
foreach (var port in ports)
foreach (var port in _vblankPumpPorts)
{
SignalVblank(port);
}
@@ -221,6 +232,12 @@ public static class VideoOutExports
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_SYNC"),
"1",
StringComparison.Ordinal);
// Call sites must check this before building the interpolated message; the trace
// strings would otherwise be allocated on the per-frame flip path even with tracing off.
private static readonly bool _logVideoOut = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT"),
"1",
StringComparison.Ordinal);
private static readonly bool _dumpVideoOut = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_VIDEOOUT"),
"1",
@@ -586,7 +603,10 @@ public static class VideoOutExports
// Some engines wait on this queue before issuing their first flip. Provide a first
// edge now; later calls to WaitVblank advance the same notification sequence.
SignalVblank(port);
TraceVideoOut($"videoout.add_vblank_event eq=0x{equeue:X16} handle={handle} udata=0x{userData:X16}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.add_vblank_event eq=0x{equeue:X16} handle={handle} udata=0x{userData:X16}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -623,7 +643,10 @@ public static class VideoOutExports
}
}
TraceVideoOut($"videoout.add_flip_event eq=0x{equeue:X16} handle={handle} udata=0x{userData:X16}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.add_flip_event eq=0x{equeue:X16} handle={handle} udata=0x{userData:X16}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -684,7 +707,10 @@ public static class VideoOutExports
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x18, unchecked((ulong)flipArg));
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x20, currentBuffer);
TraceVideoOut($"videoout.get_flip_status handle={handle} count={count} currentBuffer={currentBuffer}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.get_flip_status handle={handle} count={count} currentBuffer={currentBuffer}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1077,33 +1103,53 @@ public static class VideoOutExports
private static void SignalVblank(VideoOutPortState port)
{
List<FlipEventRegistration> vblankEvents;
// Snapshot the registrations into a pooled rental so the triggers can run outside
// _stateGate without copying the list into a fresh allocation on every edge.
// A per-port reusable buffer would race: the pump thread and a guest thread's
// first-edge signal (AddVblankEvent) can signal the same port concurrently.
FlipEventRegistration[]? vblankEvents = null;
int vblankEventCount;
ulong eventHint;
lock (_stateGate)
{
port.VblankCount++;
eventHint = SceVideoOutInternalEventVblank |
((port.VblankCount & 0x0000_FFFF_FFFF_FFFFUL) << 16);
vblankEvents = new List<FlipEventRegistration>(port.VblankEvents);
vblankEventCount = port.VblankEvents.Count;
if (vblankEventCount != 0)
{
vblankEvents = ArrayPool<FlipEventRegistration>.Shared.Rent(vblankEventCount);
port.VblankEvents.CopyTo(vblankEvents);
}
}
var signalCount = Interlocked.Increment(ref _vblankSignalCount);
foreach (var vblankEvent in vblankEvents)
if (vblankEvents is not null)
{
_ = KernelEventQueueCompatExports.TriggerDisplayEvent(
vblankEvent.Equeue,
SceVideoOutInternalEventVblank,
OrbisKernelEventFilterVideoOut,
eventHint,
vblankEvent.UserData);
try
{
for (var i = 0; i < vblankEventCount; i++)
{
_ = KernelEventQueueCompatExports.TriggerDisplayEvent(
vblankEvents[i].Equeue,
SceVideoOutInternalEventVblank,
OrbisKernelEventFilterVideoOut,
eventHint,
vblankEvents[i].UserData);
}
}
finally
{
ArrayPool<FlipEventRegistration>.Shared.Return(vblankEvents);
}
}
if (_logVideoOutSync && (signalCount <= 8 || signalCount % 60 == 0))
{
Console.Error.WriteLine(
$"[LOADER][SYNC] vblank#{signalCount} handle={port.Handle} count={port.VblankCount} " +
$"queues={vblankEvents.Count} hint=0x{eventHint:X16}");
$"queues={vblankEventCount} hint=0x{eventHint:X16}");
}
}
@@ -1125,8 +1171,11 @@ public static class VideoOutExports
return OrbisVideoOutErrorInvalidIndex;
}
// Pooled snapshot for the same reason as SignalVblank: triggers run outside
// _stateGate, and SubmitFlip is per-frame so a fresh List copy is steady churn.
ulong eventHint;
List<FlipEventRegistration> flipEvents;
FlipEventRegistration[]? flipEvents = null;
int flipEventCount;
lock (_stateGate)
{
if (bufferIndex != -1 && port.BufferSlots[bufferIndex].GroupIndex < 0)
@@ -1138,7 +1187,12 @@ public static class VideoOutExports
port.FlipCount++;
eventHint = SceVideoOutInternalEventFlip |
((unchecked((ulong)flipArg) & 0x0000_FFFF_FFFF_FFFFUL) << 16);
flipEvents = new List<FlipEventRegistration>(port.FlipEvents);
flipEventCount = port.FlipEvents.Count;
if (flipEventCount != 0)
{
flipEvents = ArrayPool<FlipEventRegistration>.Shared.Rent(flipEventCount);
port.FlipEvents.CopyTo(flipEvents);
}
}
var guestImageSubmitted = false;
@@ -1160,14 +1214,24 @@ public static class VideoOutExports
_ = TryDumpFrame(ctx, port, bufferIndex, flipMode, flipArg);
}
foreach (var flipEvent in flipEvents)
if (flipEvents is not null)
{
_ = KernelEventQueueCompatExports.TriggerDisplayEvent(
flipEvent.Equeue,
SceVideoOutInternalEventFlip,
OrbisKernelEventFilterVideoOut,
eventHint,
flipEvent.UserData);
try
{
for (var i = 0; i < flipEventCount; i++)
{
_ = KernelEventQueueCompatExports.TriggerDisplayEvent(
flipEvents[i].Equeue,
SceVideoOutInternalEventFlip,
OrbisKernelEventFilterVideoOut,
eventHint,
flipEvents[i].UserData);
}
}
finally
{
ArrayPool<FlipEventRegistration>.Shared.Return(flipEvents);
}
}
var flipCount = Interlocked.Increment(ref _flipSubmitCount);
@@ -1176,10 +1240,13 @@ public static class VideoOutExports
Console.Error.WriteLine(
$"[LOADER][SYNC] flip#{flipCount} handle={handle} buffer={bufferIndex} " +
$"addr=0x{guestImageAddress:X16} submitted={guestImageSubmitted} " +
$"flipQueues={flipEvents.Count}");
$"flipQueues={flipEventCount}");
}
TraceVideoOut($"videoout.submit_flip handle={handle} index={bufferIndex} mode={flipMode} arg={flipArg} events={flipEvents.Count}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.submit_flip handle={handle} index={bufferIndex} mode={flipMode} arg={flipArg} events={flipEventCount}");
}
ReportFrameRate(presented: false);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1261,8 +1328,11 @@ public static class VideoOutExports
slot.AddressRight = 0;
}
TraceVideoOut(
$"videoout.register_buffers handle={port.Handle} group={groupIndex} start={startIndex} count={addresses.Length} fmt=0x{attribute.PixelFormat:X} tile={attribute.TilingMode} {attribute.Width}x{attribute.Height} pitch={attribute.PitchInPixel}");
if (_logVideoOut)
{
TraceVideoOut(
$"videoout.register_buffers handle={port.Handle} group={groupIndex} start={startIndex} count={addresses.Length} fmt=0x{attribute.PixelFormat:X} tile={attribute.TilingMode} {attribute.Width}x{attribute.Height} pitch={attribute.PitchInPixel}");
}
VulkanVideoPresenter.EnsureStarted(attribute.Width, attribute.Height);
var guestFormat = MapPixelFormatToGuestTextureFormat(attribute.PixelFormat);
@@ -1428,7 +1498,10 @@ public static class VideoOutExports
var basePath = GetFrameDumpBasePath(frameIndex, port.Handle, bufferIndex);
WriteBmp(basePath + ".bmp", attribute.Width, attribute.Height, rgb);
WriteFrameMetadata(basePath + ".txt", slot.AddressLeft, attribute, bufferIndex, flipMode, flipArg, "bmp-linear-read", fingerprint);
TraceVideoOut($"videoout.dump_frame path={basePath}.bmp addr=0x{slot.AddressLeft:X16} {attribute.Width}x{attribute.Height} fmt=0x{attribute.PixelFormat:X} fingerprint=0x{fingerprint:X16}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.dump_frame path={basePath}.bmp addr=0x{slot.AddressLeft:X16} {attribute.Width}x{attribute.Height} fmt=0x{attribute.PixelFormat:X} fingerprint=0x{fingerprint:X16}");
}
return true;
}
@@ -1467,7 +1540,10 @@ public static class VideoOutExports
var basePath = GetFrameDumpBasePath(frameIndex, handle, bufferIndex);
File.WriteAllBytes(basePath + ".raw", bytes);
WriteFrameMetadata(basePath + ".txt", address, attribute, bufferIndex, flipMode, flipArg, reason, fingerprint);
TraceVideoOut($"videoout.dump_frame path={basePath}.raw addr=0x{address:X16} bytes={byteCount} reason={reason} fingerprint=0x{fingerprint:X16}");
if (_logVideoOut)
{
TraceVideoOut($"videoout.dump_frame path={basePath}.raw addr=0x{address:X16} bytes={byteCount} reason={reason} fingerprint=0x{fingerprint:X16}");
}
return true;
}
@@ -1694,11 +1770,6 @@ public static class VideoOutExports
private static void TraceVideoOut(string message)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT"), "1", StringComparison.Ordinal))
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] {message}");
}
}