From 5fb1cbacdc4498714b328f0ab342e17614f73e2c Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 28 Sep 2026 18:16:28 +0200 Subject: [PATCH] [CPU] Run libc memcpy/memmove as a native guest-side stub Guest memcpy went through the managed gateway on every call: two write tracking notifications, the memory reader lock and two region lookups, about 0.8 us per call under Rosetta even for a copy of a few bytes. Dreaming Sarah makes roughly 45k of those calls a second. Guest memory is identity-mapped, and every fault a copy can take -- write tracking, lazy commit -- is resolved from the fault address alone, so the copy can run as a forward rep movsb in a fast-path stub. A destination that sits inside the source, where a forward copy would read bytes it has already written, and the write-watch diagnostic both keep the managed export. --- docs/sharpemu-gui-undocumented-env-vars.md | 1 + .../Native/DirectExecutionBackend.FastPath.cs | 44 ++++++++- .../Cpu/GuestFastPathStubTests.cs | 97 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/docs/sharpemu-gui-undocumented-env-vars.md b/docs/sharpemu-gui-undocumented-env-vars.md index 864fbfc..af25b1d 100644 --- a/docs/sharpemu-gui-undocumented-env-vars.md +++ b/docs/sharpemu-gui-undocumented-env-vars.md @@ -52,6 +52,7 @@ These variables change how the emulator operates. | --- | --- | --- | --- | | `SHARPEMU_AGC_VERSION13_DEFAULTS` | text (`legacy`, `generic` or `0`) | Selects the register defaults for AGC version 13 requests. The default uses the version 11 compact tables. The values `legacy`, `generic` and `0` use the older generic layout. | `AgcExports.Init.cs` | | `SHARPEMU_DISABLE_LLE_LIBC` | `1` | Stops the use of the guest libc module for libc exports. The emulator then uses its HLE handlers for these exports. | `DirectExecutionBackend.cs` | +| `SHARPEMU_HLE_FAST_MEMCPY` | `0` | Set to `0` to stop the native fast-path stub for the guest libc `memcpy` and `memmove`, so those imports use the managed HLE gateway again. | `DirectExecutionBackend.FastPath.cs` | | `SHARPEMU_HLE_FAST_PATH` | `0` | Set to `0` to stop the native fast-path stubs for `pthread_self` and `pthread_getspecific`, so those imports use the managed HLE gateway again. The stubs are on by default on macOS x64 and are off on any run that traces imports or pthreads. | `DirectExecutionBackend.FastPath.cs` | | `SHARPEMU_HOLD_FIRST_FLIP_MS` | number | Stops the thread that submits the flip for this number of milliseconds on one guest flip. The range is 0 to 60000 and the default is 0 (off). `SHARPEMU_HOLD_FLIP_NUMBER` selects the flip. | `VideoOutExports.cs` | | `SHARPEMU_HOLD_FLIP_NUMBER` | number | Selects the guest flip that `SHARPEMU_HOLD_FIRST_FLIP_MS` holds. The minimum is 1 and the default is 1 (the first flip). | `VideoOutExports.cs` | diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.FastPath.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.FastPath.cs index e6f9f34..433464e 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.FastPath.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.FastPath.cs @@ -22,6 +22,7 @@ public sealed unsafe partial class DirectExecutionBackend None, PthreadSelf, PthreadGetspecific, + MemoryCopy, } // scePthreadSelf / pthread_self. @@ -32,6 +33,16 @@ public sealed unsafe partial class DirectExecutionBackend private const string PthreadGetspecificNid = "eoht7mQOCmo"; private const string PosixPthreadGetspecificNid = "0-KXaS70xy4"; + // memcpy / memmove. + private const string MemcpyNid = "Q3VBxCXhUHs"; + private const string MemmoveNid = "+P6FRGH4LfA"; + + // Guest memory is identity-mapped and every fault the copy can take (write tracking, lazy + // commit) is resolved from the fault address alone, so the copy may run as guest-side code. + // SHARPEMU_HLE_FAST_MEMCPY=0 keeps it on the managed export. + private static readonly bool GuestMemoryCopyStubEnabled = + !string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_HLE_FAST_MEMCPY"), "0", StringComparison.Ordinal); + private uint _fastPathBlockTlsIndex = uint.MaxValue; private bool _guestFastPathEnabled; private int _guestFastPathStubCount; @@ -88,6 +99,7 @@ public sealed unsafe partial class DirectExecutionBackend { PthreadSelfNid or PosixPthreadSelfNid => GuestFastPathStub.PthreadSelf, PthreadGetspecificNid or PosixPthreadGetspecificNid => GuestFastPathStub.PthreadGetspecific, + MemcpyNid or MemmoveNid => GuestFastPathStub.MemoryCopy, _ => GuestFastPathStub.None, }; @@ -105,7 +117,9 @@ public sealed unsafe partial class DirectExecutionBackend } var kind = ClassifyGuestFastPathStub(nid); - if (kind == GuestFastPathStub.None) + if (kind == GuestFastPathStub.None || + // The write watch inspects every managed guest write, which a native copy never reaches. + (kind == GuestFastPathStub.MemoryCopy && (!GuestMemoryCopyStubEnabled || GuestWriteWatch.Armed))) { return false; } @@ -154,6 +168,11 @@ public sealed unsafe partial class DirectExecutionBackend uint blockTlsIndex, nint fallbackTrampoline) { + if (kind == GuestFastPathStub.MemoryCopy) + { + return EmitGuestMemoryCopyStub(fallbackTrampoline); + } + var code = new List(48); var slowPathFixups = new List(); @@ -241,4 +260,27 @@ public sealed unsafe partial class DirectExecutionBackend return code; } + + /// + /// memcpy/memmove(dst, src, n) as a forward rep movsb, returning dst. The libc + /// export copies with memmove semantics, so a destination that starts inside the source + /// (where a forward copy would read bytes it already overwrote) takes the managed path. + /// Only RAX, RCX, RSI, RDI and the flags change - all caller-saved - and the direction + /// flag is clear on entry per the SysV ABI. + /// + internal static List EmitGuestMemoryCopyStub(nint fallbackTrampoline) + { + var code = new List(40); + // mov rcx, rdi / sub rcx, rsi / cmp rcx, rdx / jb slow: dst - src < n (unsigned) is + // exactly a destination in (src, src + n) or equal to src with a non-empty copy. + code.AddRange([0x48, 0x89, 0xF9, 0x48, 0x29, 0xF1, 0x48, 0x39, 0xD1, 0x72, 0x00]); + var slowPathFixup = code.Count - 1; + // mov rax, rdi / mov rcx, rdx / rep movsb / ret + code.AddRange([0x48, 0x89, 0xF8, 0x48, 0x89, 0xD1, 0xF3, 0xA4, 0xC3]); + code[slowPathFixup] = checked((byte)(code.Count - (slowPathFixup + 1))); + // jmp qword [rip+0] with the absolute trampoline address after it. + code.AddRange([0xFF, 0x25, 0x00, 0x00, 0x00, 0x00]); + code.AddRange(BitConverter.GetBytes((long)fallbackTrampoline)); + return code; + } } diff --git a/tests/SharpEmu.Libs.Tests/Cpu/GuestFastPathStubTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/GuestFastPathStubTests.cs index 0f61cbb..4b84faf 100644 --- a/tests/SharpEmu.Libs.Tests/Cpu/GuestFastPathStubTests.cs +++ b/tests/SharpEmu.Libs.Tests/Cpu/GuestFastPathStubTests.cs @@ -235,6 +235,103 @@ public sealed unsafe class GuestFastPathStubTests return elapsed * 1_000_000_000.0 / Stopwatch.Frequency / iterations; } + private const ulong MemoryCopySlowPathMarker = 0x5105_1A7B_AC4E_0001UL; + + [Fact] + public void MemoryCopyStub_CopiesForwardAndReturnsTheDestination() + { + if (!CanRunStubs()) + { + return; + } + + using var stub = new MemoryCopyStub(); + var source = Enumerable.Range(1, 300).Select(value => (byte)value).ToArray(); + var destination = new byte[300]; + fixed (byte* sourcePointer = source) + fixed (byte* destinationPointer = destination) + { + Assert.Equal((ulong)destinationPointer, stub.Call(destinationPointer, sourcePointer, 300)); + Assert.Equal((ulong)destinationPointer, stub.Call(destinationPointer + 7, sourcePointer, 0) - 7); + } + + Assert.Equal(source, destination); + } + + [Fact] + public void MemoryCopyStub_HandlesADestinationBelowAnOverlappingSource() + { + if (!CanRunStubs()) + { + return; + } + + using var stub = new MemoryCopyStub(); + var buffer = Enumerable.Range(0, 64).Select(value => (byte)value).ToArray(); + fixed (byte* pointer = buffer) + { + Assert.Equal((ulong)pointer, stub.Call(pointer, pointer + 8, 40)); + } + + Assert.Equal(Enumerable.Range(8, 40).Select(value => (byte)value), buffer.Take(40)); + Assert.Equal(Enumerable.Range(40, 24).Select(value => (byte)value), buffer.Skip(40)); + } + + [Fact] + public void MemoryCopyStub_LeavesADestinationInsideTheSourceToTheManagedExport() + { + if (!CanRunStubs()) + { + return; + } + + using var stub = new MemoryCopyStub(); + var buffer = Enumerable.Range(0, 64).Select(value => (byte)value).ToArray(); + var original = buffer.ToArray(); + fixed (byte* pointer = buffer) + { + Assert.Equal(MemoryCopySlowPathMarker, stub.Call(pointer + 8, pointer, 40)); + Assert.Equal(MemoryCopySlowPathMarker, stub.Call(pointer, pointer, 1)); + } + + Assert.Equal(original, buffer); + } + + /// The memcpy stub called with the platform (SysV) convention the guest uses. + private sealed class MemoryCopyStub : IDisposable + { + private readonly byte* _page; + + public MemoryCopyStub() + { + _page = (byte*)HostMemory.Alloc( + null, + 4096, + HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE, + HostMemory.PAGE_EXECUTE_READWRITE); + Assert.True(_page != null); + + // Fallback at +0x100: mov rax, marker / ret. + _page[0x100] = 0x48; + _page[0x101] = 0xB8; + *(ulong*)(_page + 0x102) = MemoryCopySlowPathMarker; + _page[0x10A] = 0xC3; + + var stub = DirectExecutionBackend.EmitGuestMemoryCopyStub((nint)(_page + 0x100)); + for (var i = 0; i < stub.Count; i++) + { + _page[i] = stub[i]; + } + + Assert.True(HostMemory.Protect(_page, 4096, HostMemory.PAGE_EXECUTE_READ, out _)); + } + + public ulong Call(byte* destination, byte* source, nuint count) => + ((delegate* unmanaged)_page)(destination, source, count); + + public void Dispose() => _ = HostMemory.Free(_page, 0, HostMemory.MEM_RELEASE); + } + private static bool CanRunStubs() => OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.X64;