sharpemu/tests/SharpEmu.Libs.Tests/Cpu/GuestFastPathStubTests.cs

449 lines
16 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
/// <summary>
/// Executes the emitted pthread_self / pthread_getspecific fast-path stubs for
/// real. The stubs are the whole point of the optimization, so the tests run
/// their machine code rather than pattern-matching bytes: a stub that answers
/// the wrong value, clobbers a caller-saved-only contract, or unbalances the
/// stack fails here instead of in a game.
/// </summary>
[Collection("GuestFastPath")]
public sealed unsafe class GuestFastPathStubTests
{
private const ulong RaxSentinel = 0x1122334455667788UL;
// pthread_key_create hands out keys from 1 upwards; stay above anything a
// test that drives the real exports can reach.
private const int KeyBase = 700;
[Fact]
public void SelfStub_ReturnsTheBoundGuestThreadHandle()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadSelf);
const ulong handle = 0x0000700011223340UL;
harness.Bind(handle);
Assert.Equal(handle, harness.Call(0));
}
[Fact]
public void SelfStub_FallsBackWhenNoGuestThreadIsBound()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadSelf);
harness.Bind(0);
// The fallback trampoline returns the incoming RAX untouched, so this
// asserts both that the slow path was taken and that the guest's RAX
// (the SysV variadic vector count) survived the attempt.
Assert.Equal(RaxSentinel, harness.Call(0));
}
[Fact]
public void GetspecificStub_ReturnsValuesWrittenThroughTheManagedApi()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
var handle = NewHandle();
harness.Bind(handle);
Assert.Equal(0UL, harness.Call(KeyBase + 7));
GuestFastPath.SetSpecific(handle, KeyBase + 7, 0xDEADBEEFCAFEF00DUL);
GuestFastPath.SetSpecific(handle, GuestFastPath.TlsSlotCount - 1, 0x55UL);
Assert.Equal(0xDEADBEEFCAFEF00DUL, harness.Call(KeyBase + 7));
Assert.Equal(0x55UL, harness.Call((ulong)(GuestFastPath.TlsSlotCount - 1)));
Assert.Equal(0UL, harness.Call(KeyBase + 8));
// pthread_key_delete must make the key read back unset everywhere.
GuestFastPath.ClearKeyEverywhere(KeyBase + 7);
Assert.Equal(0UL, harness.Call(KeyBase + 7));
}
[Fact]
public void GetspecificStub_FallsBackForKeysTheTableCannotHold()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
harness.Bind(NewHandle());
Assert.Equal(RaxSentinel, harness.Call(GuestFastPath.TlsSlotCount));
Assert.Equal(RaxSentinel, harness.Call(unchecked((ulong)-1L)));
}
[Fact]
public void GetspecificStub_FallsBackWhenNoThreadIsBound()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
harness.Bind(0);
Assert.Equal(RaxSentinel, harness.Call(KeyBase + 3));
}
[Fact]
public void GetspecificStub_AnswersOnAHostThreadWithNoGuestThreadBound()
{
if (!CanRunStubs())
{
return;
}
// The primary execution thread has no guest thread handle, and
// KernelPthreadState answers it from its own synthetic handle; the stub
// has to read that thread's values rather than bail.
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
var hostHandle = NewHandle();
harness.Bind(0);
harness.BindHost(hostHandle);
GuestFastPath.SetSpecific(hostHandle, KeyBase + 6, 0x777UL);
Assert.Equal(0x777UL, harness.Call(KeyBase + 6));
// A guest thread bound on top owns the table while it runs, and the host
// handle comes back when it unbinds.
var guestHandle = NewHandle();
GuestFastPath.SetSpecific(guestHandle, KeyBase + 6, 0x888UL);
harness.Bind(guestHandle);
Assert.Equal(0x888UL, harness.Call(KeyBase + 6));
harness.Bind(0);
Assert.Equal(0x777UL, harness.Call(KeyBase + 6));
}
[Fact]
public void SelfStub_StaysOnTheManagedPathForHostThreads()
{
if (!CanRunStubs())
{
return;
}
// pthread_self registers an unbound thread's CPU context with the guest
// scheduler, so a host thread must never be answered by the stub.
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadSelf);
harness.Bind(0);
harness.BindHost(NewHandle());
Assert.Equal(RaxSentinel, harness.Call(0));
}
[Fact]
public void GetspecificStub_ReadsTheThreadCurrentlyBound()
{
if (!CanRunStubs())
{
return;
}
using var harness = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
var first = NewHandle();
var second = NewHandle();
GuestFastPath.SetSpecific(first, KeyBase + 2, 0x1111UL);
GuestFastPath.SetSpecific(second, KeyBase + 2, 0x2222UL);
harness.Bind(first);
Assert.Equal(0x1111UL, harness.Call(KeyBase + 2));
harness.Bind(second);
Assert.Equal(0x2222UL, harness.Call(KeyBase + 2));
harness.Bind(first);
Assert.Equal(0x1111UL, harness.Call(KeyBase + 2));
}
/// <summary>
/// Measures the per-call cost the stubs replace. The managed gateway costs
/// ~2.9 us/call as measured in-game with SHARPEMU_PERF_HLE=1; the stub must
/// be orders of magnitude below that, so the bound is deliberately loose
/// enough to survive a loaded CI machine while still failing if a stub ever
/// regains a managed transition.
/// </summary>
[Fact]
public void Stubs_CostFarLessThanTheManagedGateway()
{
if (!CanRunStubs())
{
return;
}
using var self = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadSelf);
using var getspecific = new StubHarness(DirectExecutionBackend.GuestFastPathStub.PthreadGetspecific);
var handle = NewHandle();
self.Bind(handle);
GuestFastPath.SetSpecific(handle, KeyBase + 4, 0x99UL);
var selfNanos = MeasureNanosPerCall(self, 0);
getspecific.Bind(handle);
var getspecificNanos = MeasureNanosPerCall(getspecific, KeyBase + 4);
Assert.True(
selfNanos < 500,
$"pthread_self stub cost {selfNanos:F1} ns/call; the managed gateway it replaces costs ~2900 ns/call.");
Assert.True(
getspecificNanos < 500,
$"pthread_getspecific stub cost {getspecificNanos:F1} ns/call; the managed gateway costs ~2900 ns/call.");
}
private static double MeasureNanosPerCall(StubHarness harness, ulong argument)
{
const int iterations = 2_000_000;
for (var i = 0; i < 100_000; i++)
{
_ = harness.Call(argument);
}
var start = Stopwatch.GetTimestamp();
for (var i = 0; i < iterations; i++)
{
_ = harness.Call(argument);
}
var elapsed = Stopwatch.GetTimestamp() - start;
// The measured loop includes the managed calli that a guest call does not
// pay, so this is an upper bound on the stub's own cost.
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);
}
/// <summary>The memcpy stub called with the platform (SysV) convention the guest uses.</summary>
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<byte*, byte*, nuint, ulong>)_page)(destination, source, count);
public void Dispose() => _ = HostMemory.Free(_page, 0, HostMemory.MEM_RELEASE);
}
private static bool CanRunStubs() =>
OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.X64;
private static ulong NewHandle() =>
0x0000700020000000UL + ((ulong)Environment.CurrentManagedThreadId << 20) +
((ulong)Interlocked.Increment(ref _handleCounter) << 8);
private static int _handleCounter;
/// <summary>
/// Owns one emitted stub, a fallback trampoline that reports "slow path
/// taken" by returning the incoming RAX, and a caller thunk that sets up
/// RAX/RDI the way guest code would.
/// </summary>
private sealed class StubHarness : IDisposable
{
// One native TLS key for the whole fixture: GuestFastPath publishes a
// host thread's block exactly once, so a second key would never receive
// it and every stub would silently take the slow path.
private static readonly uint BlockTlsKey = EnableFastPath();
private readonly byte* _page;
private readonly delegate* unmanaged<ulong> _entry;
private readonly ulong* _argumentSlot;
private static uint EnableFastPath()
{
var key = PosixHostStubs.TlsAlloc();
Assert.NotEqual(uint.MaxValue, key);
GuestFastPath.Enable(block => PosixHostStubs.TlsSetValue(key, block));
return key;
}
public StubHarness(DirectExecutionBackend.GuestFastPathStub kind)
{
var key = BlockTlsKey;
// xunit reuses host threads, and the bindings are thread-local.
GuestFastPath.UnbindCurrentThread();
_page = (byte*)HostMemory.Alloc(
null,
4096,
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
HostMemory.PAGE_EXECUTE_READWRITE);
Assert.True(_page != null);
// The argument the thunk loads into RDI lives outside the code page,
// which becomes read-execute below.
_argumentSlot = (ulong*)NativeMemory.AllocZeroed(sizeof(ulong));
// Fallback trampoline at +0x100: `ret`, which hands back RAX exactly
// as the stub left it.
_page[0x100] = 0xC3;
var stub = DirectExecutionBackend.EmitGuestFastPathStub(kind, key, (nint)(_page + 0x100));
for (var i = 0; i < stub.Count; i++)
{
_page[0x200 + i] = stub[i];
}
// Caller thunk at +0: mov rax, sentinel / mov rdi, [argumentSlot] /
// mov r11, stub / call r11 / ret. Only caller-saved registers are
// touched, so the managed caller's state is safe.
var offset = 0;
Emit(ref offset, 0x48, 0xB8);
EmitU64(ref offset, RaxSentinel);
Emit(ref offset, 0x48, 0xBF);
EmitU64(ref offset, (ulong)(nint)_argumentSlot);
Emit(ref offset, 0x48, 0x8B, 0x3F); // mov rdi, [rdi]
Emit(ref offset, 0x49, 0xBB);
EmitU64(ref offset, (ulong)(nint)(_page + 0x200));
Emit(ref offset, 0x41, 0xFF, 0xD3); // call r11
Emit(ref offset, 0xC3);
Assert.True(offset < 0x100);
Assert.True(HostMemory.Protect(_page, 4096, HostMemory.PAGE_EXECUTE_READ, out _));
HostMemory.FlushInstructionCache(_page, 4096);
_entry = (delegate* unmanaged<ulong>)_page;
}
public void Bind(ulong guestThreadHandle) => GuestFastPath.BindGuestThread(guestThreadHandle);
public void BindHost(ulong hostThreadHandle) => GuestFastPath.BindHostThread(hostThreadHandle);
public ulong Call(ulong argument)
{
*_argumentSlot = argument;
return _entry();
}
public void Dispose()
{
GuestFastPath.UnbindCurrentThread();
NativeMemory.Free(_argumentSlot);
_ = HostMemory.Free(_page, 0, HostMemory.MEM_RELEASE);
}
private void Emit(ref int offset, params byte[] bytes)
{
foreach (var value in bytes)
{
_page[offset++] = value;
}
}
private void EmitU64(ref int offset, ulong value)
{
for (var i = 0; i < 8; i++)
{
_page[offset++] = (byte)(value >> (i * 8));
}
}
}
}