// 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;
///
/// 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.
///
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 _guestRipSamples = new();
private readonly ConcurrentDictionary _guestRipThreadSamples = new();
private readonly ConcurrentDictionary _guestWaitSamples = new();
private readonly ConcurrentDictionary _guestThreadWaitSamples = new();
private long _guestRipTotalSamples;
private long _guestWaitTotalSamples;
private long _guestRipCaptureFailures;
private long _guestRipSamplerErrors;
private int _guestRipSampleCursor;
///
/// Names the HLE call a thread is parked in, using the guest RIP the import
/// dispatcher left on its context.
///
private string ResolveWaitLabel(GuestThreadState thread)
{
var context = thread.Context;
if (context is null)
{
return "";
}
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)
? ""
: $"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) ? "" : 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) ? "" : 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>(_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();
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>(_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>(_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}%")));
}
///
/// 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.
///
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)";
}
}