mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 22:49:53 +08:00
Sdl backend (#670)
* [audio] added sdl audio backend and in-tree atrac9 decoder * [input] replaced per-platform pad readers with sdl gamepad input * [video] added sdl window and host display plumbing * [gui] added host display options and per-game render settings * [bink] synced host movie playback to the guest audio clock * [cpu] hooked windows write faults into guest image tracking * [perf] added guest and render profiling, reserved host cpu lanes * [kernel] fixed stale pthread mutex handle alias * [host] wired the sdl session, save-data paths and project references * [audio] hoisted ajm trace stackalloc out of its loop * [video] Add guest image sync setting * [build] Strip native symbols * reuse
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Sampling profiler for guest code. Managed profilers only see the emulator's
|
||||
/// own frames — once a guest thread is running translated code it is opaque to
|
||||
/// them, so a title that burns its cores inside its own spin loops looks like
|
||||
/// unattributed native time. This walks the guest thread registry and samples
|
||||
/// each thread's host RIP, which lands directly on the guest instruction being
|
||||
/// executed.
|
||||
/// </summary>
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
private static readonly bool _profileGuestRip =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly int _profileGuestRipIntervalMs =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"),
|
||||
out var interval) && interval > 0
|
||||
? interval
|
||||
: 2;
|
||||
|
||||
private static readonly int _profileGuestRipReportSeconds =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"),
|
||||
out var report) && report > 0
|
||||
? report
|
||||
: 15;
|
||||
|
||||
private const ulong GuestImageBase = 0x0000_0008_0000_0000UL;
|
||||
private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL;
|
||||
|
||||
private int _guestRipSamplerStarted;
|
||||
private readonly ConcurrentDictionary<ulong, long> _guestRipSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestRipThreadSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestWaitSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestThreadWaitSamples = new();
|
||||
private long _guestRipTotalSamples;
|
||||
private long _guestWaitTotalSamples;
|
||||
private long _guestRipCaptureFailures;
|
||||
private long _guestRipSamplerErrors;
|
||||
private int _guestRipSampleCursor;
|
||||
|
||||
/// <summary>
|
||||
/// Names the HLE call a thread is parked in, using the guest RIP the import
|
||||
/// dispatcher left on its context.
|
||||
/// </summary>
|
||||
private string ResolveWaitLabel(GuestThreadState thread)
|
||||
{
|
||||
var context = thread.Context;
|
||||
if (context is null)
|
||||
{
|
||||
return "<no-context>";
|
||||
}
|
||||
|
||||
var importIndex = context.ActiveImportIndex;
|
||||
if ((uint)importIndex >= (uint)_importEntries.Length)
|
||||
{
|
||||
// Host code with no import in flight: the thread is parked by the
|
||||
// emulator's own scheduler. The cooperative block records why, which
|
||||
// is the part that actually identifies what the frame is waiting on.
|
||||
var blockReason = thread.BlockReason;
|
||||
return string.IsNullOrEmpty(blockReason)
|
||||
? "<idle-or-scheduler>"
|
||||
: $"blocked:{blockReason}";
|
||||
}
|
||||
|
||||
var entry = _importEntries[importIndex];
|
||||
return entry.Export?.Name ?? entry.Nid;
|
||||
}
|
||||
|
||||
internal void ClearActiveImportIndex()
|
||||
{
|
||||
if (!_profileGuestRip)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var context = ActiveCpuContext;
|
||||
if (context is not null)
|
||||
{
|
||||
context.ActiveImportIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGuestRipSampler()
|
||||
{
|
||||
if (!_profileGuestRip ||
|
||||
!OperatingSystem.IsWindows() ||
|
||||
Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sampler = new Thread(GuestRipSampleLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu guest RIP sampler",
|
||||
// Sampling suspends guest threads briefly. Keep this diagnostic below
|
||||
// the title workers so it observes them without becoming the bottleneck.
|
||||
Priority = ThreadPriority.BelowNormal,
|
||||
};
|
||||
sampler.Start();
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " +
|
||||
$"report={_profileGuestRipReportSeconds}s");
|
||||
}
|
||||
|
||||
private void GuestRipSampleLoop()
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
var lastReportMs = 0L;
|
||||
var lastReportSamples = 0L;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var guestThreads = SnapshotGuestThreads();
|
||||
var sampleIndex = guestThreads.Length == 0
|
||||
? 0
|
||||
: (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length);
|
||||
foreach (var thread in guestThreads.Skip(sampleIndex).Take(1))
|
||||
{
|
||||
var hostThreadId = Volatile.Read(ref thread.HostThreadId);
|
||||
if (hostThreadId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) ||
|
||||
!snapshot.IsValid)
|
||||
{
|
||||
Interlocked.Increment(ref _guestRipCaptureFailures);
|
||||
continue;
|
||||
}
|
||||
|
||||
_guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1);
|
||||
_guestRipThreadSamples.AddOrUpdate(
|
||||
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
Interlocked.Increment(ref _guestRipTotalSamples);
|
||||
|
||||
// A host RIP means the thread is inside the emulator rather
|
||||
// than running translated code. DispatchImport parks the
|
||||
// guest RIP on the import stub for the call being serviced,
|
||||
// so the stub address names what the thread is waiting on —
|
||||
// no hot-path bookkeeping needed to find out.
|
||||
if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_guestWaitSamples.AddOrUpdate(
|
||||
ResolveWaitLabel(thread),
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
_guestThreadWaitSamples.AddOrUpdate(
|
||||
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
Interlocked.Increment(ref _guestWaitTotalSamples);
|
||||
}
|
||||
|
||||
Thread.Sleep(_profileGuestRipIntervalMs);
|
||||
|
||||
var elapsedMs = clock.ElapsedMilliseconds;
|
||||
if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var samples = Interlocked.Read(ref _guestRipTotalSamples);
|
||||
ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0);
|
||||
lastReportMs = elapsedMs;
|
||||
lastReportSamples = samples;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A title can tear down a thread or its context during a capture.
|
||||
// The profiler must never silently die or affect guest execution.
|
||||
if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1)
|
||||
{
|
||||
Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportGuestRipSamples(long windowSamples, double windowSeconds)
|
||||
{
|
||||
var total = Interlocked.Read(ref _guestRipTotalSamples);
|
||||
if (total == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var byRip = new List<KeyValuePair<ulong, long>>(_guestRipSamples.Count + 16);
|
||||
foreach (var pair in _guestRipSamples)
|
||||
{
|
||||
byRip.Add(pair);
|
||||
}
|
||||
|
||||
// A tight spin lands on a handful of instructions; grouping by 4 KB page
|
||||
// as well shows which routine those instructions belong to.
|
||||
var byPage = new Dictionary<ulong, long>();
|
||||
foreach (var pair in byRip)
|
||||
{
|
||||
var page = pair.Key & ~0xFFFUL;
|
||||
byPage[page] = byPage.TryGetValue(page, out var existing)
|
||||
? existing + pair.Value
|
||||
: pair.Value;
|
||||
}
|
||||
|
||||
var byThread = new List<KeyValuePair<string, long>>(_guestRipThreadSamples.Count + 16);
|
||||
foreach (var pair in _guestRipThreadSamples)
|
||||
{
|
||||
byThread.Add(pair);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " +
|
||||
$"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}");
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_rip: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byRip.OrderByDescending(pair => pair.Value)
|
||||
.Take(12)
|
||||
.Select(pair =>
|
||||
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_page: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byPage.OrderByDescending(pair => pair.Value)
|
||||
.Take(8)
|
||||
.Select(pair =>
|
||||
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
var byWait = new List<KeyValuePair<string, long>>(_guestWaitSamples.Count + 16);
|
||||
foreach (var pair in _guestWaitSamples)
|
||||
{
|
||||
byWait.Add(pair);
|
||||
}
|
||||
|
||||
var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples);
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byWait.OrderByDescending(pair => pair.Value)
|
||||
.Take(12)
|
||||
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
// Per-thread spin/park split. The global wait share mixes the job pool in
|
||||
// with a dozen dormant threads, which hides the number that matters:
|
||||
// how much of a core each worker actually burns.
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] thread_split (running/parked): " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byThread.OrderByDescending(pair => pair.Value)
|
||||
.Take(10)
|
||||
.Select(pair =>
|
||||
{
|
||||
var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0;
|
||||
var running = pair.Value - parked;
|
||||
return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%";
|
||||
})));
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_thread: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byThread.OrderByDescending(pair => pair.Value)
|
||||
.Take(10)
|
||||
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tags a sampled address with the region it belongs to. Guest module code
|
||||
/// lives above the image base; anything else is emulator or system code that
|
||||
/// the managed profiler already covers.
|
||||
/// </summary>
|
||||
private string DescribeGuestAddress(ulong address)
|
||||
{
|
||||
|
||||
|
||||
|
||||
if (address >= GuestImageBase && address < GuestImageLimit)
|
||||
{
|
||||
return $"(app+0x{address - GuestImageBase:X})";
|
||||
}
|
||||
|
||||
for (var index = 0; index < _importEntries.Length; index++)
|
||||
{
|
||||
if (_importEntries[index].Address == (address & ~0xFUL))
|
||||
{
|
||||
return $"(stub:{_importEntries[index].Nid})";
|
||||
}
|
||||
}
|
||||
|
||||
return "(host)";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user