From c63044115208a9a3a132ea9f372c283be8f0c562 Mon Sep 17 00:00:00 2001 From: ParantezTech <12572227+par274@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:05:55 +0300 Subject: [PATCH] [cpu] hooked windows write faults into guest image tracking --- .../DirectExecutionBackend.Exceptions.cs | 9 + .../Native/DirectExecutionBackend.Imports.cs | 58 ++++++- src/SharpEmu.Libs/Agc/AgcExports.cs | 11 ++ src/SharpEmu.Libs/Agc/GpuWaitProfile.cs | 160 ++++++++++++++++++ 4 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 src/SharpEmu.Libs/Agc/GpuWaitProfile.cs diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index b76069ee..fb3812e2 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend } _rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub); Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}"); + + // The raw handler carries the guest-image write-fault bridge, so the + // path must be compiled before the first protected-page store can + // reach it. Guest code has not started yet, so warming here cannot + // race a real fault. + SharpEmu.HLE.GuestImageWriteTracker.WarmUp(); + Console.Error.WriteLine( + "[LOADER][INFO] Guest image CPU write tracking: " + + $"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}"); } else { diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs index c352b2c4..f6e5dd94 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs @@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend var startTicks = System.Diagnostics.Stopwatch.GetTimestamp(); var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr); RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks); + directExecutionBackend.ClearActiveImportIndex(); return r; } - return directExecutionBackend.DispatchImport(importIndex, argPackPtr); + var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr); + directExecutionBackend.ClearActiveImportIndex(); + return result; } catch (Exception ex) { @@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo) { - EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord; - if (exceptionRecord->ExceptionCode == 3221225477u && - exceptionRecord->NumberParameters >= 2 && - SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault( - exceptionRecord->ExceptionInformation[1])) + if (TryHandleGuestImageWriteFault(exceptionInfo)) { return -1; } @@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend return TryRecoverUnresolvedSentinel(exceptionInfo); } + /// + /// Windows counterpart of the POSIX SIGSEGV bridge into + /// . Guest code runs natively, + /// so a store into a surface the GPU backend has cached is an ordinary CPU + /// write with nothing to intercept — the page is write-protected instead and + /// the resulting fault is what tells the backend to re-upload. Without this + /// the cache serves the first upload forever, and anything the guest CPU + /// draws (a software-decoded movie frame, a memset fog layer) never reaches + /// the screen. + /// + private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo) + { + if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled) + { + return false; + } + + var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord; + // STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation + // is [accessKind, address] with 0=read, 1=write, 8=DEP execute. + if (exceptionRecord->ExceptionCode != 3221225477u || + exceptionRecord->NumberParameters < 2 || + exceptionRecord->ExceptionInformation[0] != 1uL) + { + return false; + } + + return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault( + exceptionRecord->ExceptionInformation[1]); + } + private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo) { return TryRecoverUnresolvedSentinel(exceptionInfo); @@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend { RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid); } + if (_profileGuestRip) + { + EnsureGuestRipSampler(); + } int num2 = Volatile.Read(in _rawSentinelRecoveries); if (num2 != _lastReportedRawSentinelRecoveries) { @@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend } cpuContext.Rip = importStubEntry.Address; + if (_profileGuestRip) + { + cpuContext.ActiveImportIndex = importIndex; + } LoadImportVolatileArguments(cpuContext, argPackPtr); cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr; cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8); @@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) && result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT; var expectedMutexTrylockBusy = - string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) && + (nid is "K-jXhbt2gn4" or "upoVrzMHFeE") && result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; var expectedSemaphoreTrywaitAgain = string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) && @@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend var expectedPrivacyInvalidParameter = string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) && resultValue == unchecked((int)0x80960009); + var expectedPlayGoChunkEnumerationEnd = + string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) && + resultValue == unchecked((int)0x80B2000C); if (!expectedFileProbeMiss && !expectedTimedWaitTimeout && !expectedEqueueTimeout && @@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend !expectedPollSemaBusy && !expectedNetAcceptWouldBlock && !expectedUserServiceNoEvent && - !expectedPrivacyInvalidParameter) + !expectedPrivacyInvalidParameter && + !expectedPlayGoChunkEnumerationEnd) { return true; } diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 3ee6acfc..cc919c0e 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -4383,6 +4383,14 @@ public static partial class AgcExports _tracedProducerlessWaits.Clear(); } + if (!stale) + { + // Count before the deduplication below: the warning fires once + // per label, so on its own it cannot say how often a queue + // actually suspends. + GpuWaitProfile.RecordSuspend(producer is not null); + } + if (!stale && producer is null && !_tracedProducerlessWaits.Add( (memory, waiter.WaitAddress))) @@ -5648,6 +5656,8 @@ public static partial class AgcExports SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot( ctx.Memory); + GpuWaitProfile.RecordMonitorPoll(resumed != 0); + GpuWaitProfile.ReportIfDue(remaining); if (remaining == 0) { gpuState.WaitMonitorRunning = false; @@ -5841,6 +5851,7 @@ public static partial class AgcExports $"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " + $"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " + $"waited_ms={waitedMilliseconds:F3}"); + GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds); if (remainingDwords == 0) { state.IsSuspended = false; diff --git a/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs new file mode 100644 index 00000000..39aa169f --- /dev/null +++ b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs @@ -0,0 +1,160 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Diagnostics; + +namespace SharpEmu.Libs.Agc; + +/// +/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with +/// SHARPEMU_PROFILE_GPU_WAIT=1. +/// +/// The existing agc.wait_suspended warning is deduplicated per label, so +/// a label that suspends every frame is reported once and then goes silent — +/// which makes the log useless for judging whether GPU waits cost frame time. +/// This counts every suspension and every resume, and reports how long the +/// queues actually sat blocked. +/// +internal static class GpuWaitProfile +{ + public static readonly bool Enabled = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"), + "1", + StringComparison.Ordinal); + + private static readonly double _reportSeconds = + double.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"), + System.Globalization.CultureInfo.InvariantCulture, + out var seconds) && seconds > 0 + ? seconds + : 5.0; + + private static readonly object _gate = new(); + private static readonly Dictionary _byLabel = new(); + private static long _suspensions; + private static long _resumes; + private static long _producerless; + private static long _monitorPolls; + private static long _monitorEmptyPolls; + private static double _totalWaitMilliseconds; + private static double _maxWaitMilliseconds; + private static long _windowStart = Stopwatch.GetTimestamp(); + + public static void RecordSuspend(bool hasProducer) + { + if (!Enabled) + { + return; + } + + lock (_gate) + { + _suspensions++; + if (!hasProducer) + { + _producerless++; + } + } + } + + public static void RecordResume(ulong label, double waitedMilliseconds) + { + if (!Enabled) + { + return; + } + + lock (_gate) + { + _resumes++; + _totalWaitMilliseconds += waitedMilliseconds; + if (waitedMilliseconds > _maxWaitMilliseconds) + { + _maxWaitMilliseconds = waitedMilliseconds; + } + + if (_byLabel.Count < 4096) + { + var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default; + _byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds); + } + } + } + + /// + /// Called once per wake of the wait monitor. An empty poll means the monitor + /// burned a wakeup without resuming anything, which is the cost of the + /// backoff loop rather than of the wait itself. + /// + public static void RecordMonitorPoll(bool resumedAny) + { + if (!Enabled) + { + return; + } + + lock (_gate) + { + _monitorPolls++; + if (!resumedAny) + { + _monitorEmptyPolls++; + } + } + } + + public static void ReportIfDue(int remainingWaiters) + { + if (!Enabled) + { + return; + } + + string line; + lock (_gate) + { + var now = Stopwatch.GetTimestamp(); + var elapsedTicks = now - _windowStart; + if (elapsedTicks < _reportSeconds * Stopwatch.Frequency) + { + return; + } + + _windowStart = now; + var seconds = elapsedTicks / (double)Stopwatch.Frequency; + + // Total blocked time across all queues. Above 1000ms/s the queues are + // overlapping their stalls, so compare it against the frame budget, + // not against wall time. + var top = _byLabel + .OrderByDescending(entry => entry.Value.Milliseconds) + .Take(5) + .Select(entry => + $"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" + + $"/n{entry.Value.Count}") + .ToArray(); + + line = + $"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " + + $"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " + + $"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " + + $"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " + + $"max_ms={_maxWaitMilliseconds:F1} " + + $"monitor_polls/s={_monitorPolls / seconds:F0} " + + $"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " + + $"outstanding={remainingWaiters} top: {string.Join(" | ", top)}"; + + _suspensions = 0; + _resumes = 0; + _producerless = 0; + _monitorPolls = 0; + _monitorEmptyPolls = 0; + _totalWaitMilliseconds = 0; + _maxWaitMilliseconds = 0; + _byLabel.Clear(); + } + + Console.Error.WriteLine(line); + } +}