aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/video_core/CMakeLists.txt1
-rw-r--r--src/video_core/gpu_thread.cpp15
-rw-r--r--src/video_core/nx2_profile.h149
-rw-r--r--src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp109
-rw-r--r--src/video_core/renderer_vulkan/vk_rasterizer.cpp30
-rw-r--r--src/video_core/texture_cache/texture_cache.h2
-rw-r--r--src/yuzu_cmd/CMakeLists.txt2
-rw-r--r--src/yuzu_cmd/nx2_sampler.cpp333
-rw-r--r--src/yuzu_cmd/nx2_sampler.h16
-rw-r--r--src/yuzu_cmd/yuzu.cpp2
10 files changed, 613 insertions, 46 deletions
diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt
index d17deacacd..513a644166 100644
--- a/src/video_core/CMakeLists.txt
+++ b/src/video_core/CMakeLists.txt
@@ -103,6 +103,7 @@ add_library(video_core STATIC
native_qmd.h
native_query_trace.h
nx2.h
+ nx2_profile.h
present.h
pte_kind.h
query_cache/bank_base.h
diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp
index 793e8e9eeb..cf4e159806 100644
--- a/src/video_core/gpu_thread.cpp
+++ b/src/video_core/gpu_thread.cpp
@@ -17,6 +17,7 @@
#include "video_core/dma_pusher.h"
#include "video_core/gpu.h"
#include "video_core/gpu_thread.h"
+#include "video_core/nx2_profile.h"
#include "video_core/host1x/host1x.h"
#include "video_core/renderer_base.h"
@@ -38,29 +39,41 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten
auto current_context = context.Acquire();
CommandDataContainer next;
+ namespace Profile = VideoCommon::Nx2Profile;
while (!stop_token.stop_requested()) {
+ Profile::Report();
if (scheduler.HasPendingChannels()) {
// Suspended channels wait on semaphores the CPU releases: poll them meanwhile.
- scheduler.ResumePending(system.GPU());
+ {
+ Profile::Scope scope{Profile::Zone::ResumePending};
+ scheduler.ResumePending(system.GPU());
+ }
if (!state.queue.TryPop(next)) {
+ Profile::Scope scope{Profile::Zone::PendingSleep};
std::this_thread::sleep_for(std::chrono::milliseconds{1});
continue;
}
} else {
+ Profile::Scope scope{Profile::Zone::Idle};
state.queue.PopWait(next, stop_token);
}
if (stop_token.stop_requested()) {
break;
}
if (auto* submit_list = std::get_if<SubmitListCommand>(&next.data)) {
+ Profile::Scope scope{Profile::Zone::Push};
scheduler.Push(system.GPU(), submit_list->channel, std::move(submit_list->entries));
} else if (std::holds_alternative<GPUTickCommand>(next.data)) {
+ Profile::Scope scope{Profile::Zone::Tick};
system.GPU().TickWork();
} else if (auto* run = std::get_if<RunCommand>(&next.data)) {
+ Profile::Scope scope{Profile::Zone::Run};
run->func();
} else if (const auto* flush = std::get_if<FlushRegionCommand>(&next.data)) {
+ Profile::Scope scope{Profile::Zone::FlushInvalidate};
renderer.ReadRasterizer()->FlushRegion(flush->addr, flush->size);
} else if (const auto* invalidate = std::get_if<InvalidateRegionCommand>(&next.data)) {
+ Profile::Scope scope{Profile::Zone::FlushInvalidate};
renderer.ReadRasterizer()->OnCacheInvalidation(invalidate->addr, invalidate->size);
} else {
ASSERT(false);
diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h
new file mode 100644
index 0000000000..051403c982
--- /dev/null
+++ b/src/video_core/nx2_profile.h
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+#include <chrono>
+#include <cstdlib>
+#include <string>
+
+#include "common/common_types.h"
+#include "common/logging.h"
+
+// EDEN_NX2_GPU_PROFILE: time where the GPU thread spends its time. Each zone counts its own
+// (exclusive) time, nested zones excluded; every 10 s the thread logs each zone's share.
+namespace VideoCommon::Nx2Profile {
+
+enum class Zone : u32 {
+ Other, ///< GPU thread time outside every zone below (register writes, command parsing)
+ Idle,
+ PendingSleep,
+ ResumePending,
+ Push,
+ Tick,
+ Run,
+ FlushInvalidate,
+ Macro,
+ Draw,
+ Clear,
+ Compute,
+ FlushWork,
+ PipelineLookup,
+ Configure,
+ SyncDescriptors,
+ ReadHandles,
+ FillImageViews,
+ TextureBuffers,
+ UpdateBuffers,
+ DescriptorAcquire,
+ BindStageBuffers,
+ PushDescriptors,
+ RenderTargets,
+ ConfigureDraw,
+ DynamicState,
+ DrawRecord,
+ TextureUpload,
+ Count,
+};
+
+inline constexpr std::array<const char*, static_cast<size_t>(Zone::Count)> zone_names{
+ "other", "idle", "pending sleep", "resume pending", "push",
+ "tick", "run", "flush/inval", "macro", "draw",
+ "clear", "compute", "flush work", "pipeline lookup", "configure",
+ "sync descriptors", "read handles", "fill image views", "texture buffers", "update buffers",
+ "descriptor acquire", "bind stage buffers", "push descriptors", "render targets",
+ "configure draw", "dynamic state", "draw record", "texture upload",
+};
+
+inline bool Enabled() {
+ static const bool enabled = std::getenv("EDEN_NX2_GPU_PROFILE") != nullptr;
+ return enabled;
+}
+
+inline u64 Now() {
+ return static_cast<u64>(std::chrono::duration_cast<std::chrono::nanoseconds>(
+ std::chrono::steady_clock::now().time_since_epoch())
+ .count());
+}
+
+struct ThreadData {
+ std::array<u64, static_cast<size_t>(Zone::Count)> exclusive_ns{};
+ std::array<u64, static_cast<size_t>(Zone::Count)> count{};
+ u64 child_ns{}; ///< Time of the zones nested in the current one
+ u64 window_start{};
+};
+
+inline thread_local ThreadData t_data;
+
+class Scope {
+public:
+ explicit Scope(Zone zone_) : zone{zone_}, enabled{Enabled()} {
+ if (!enabled) {
+ return;
+ }
+ parent_child_ns = t_data.child_ns;
+ t_data.child_ns = 0;
+ start = Now();
+ }
+
+ ~Scope() {
+ if (!enabled) {
+ return;
+ }
+ const u64 elapsed = Now() - start;
+ auto& data = t_data;
+ const auto index = static_cast<size_t>(zone);
+ data.exclusive_ns[index] += elapsed - data.child_ns;
+ ++data.count[index];
+ data.child_ns = parent_child_ns + elapsed;
+ }
+
+ Scope(const Scope&) = delete;
+ Scope& operator=(const Scope&) = delete;
+
+private:
+ Zone zone;
+ bool enabled;
+ u64 start{};
+ u64 parent_child_ns{};
+};
+
+/// Called by the GPU thread between commands: logs and resets the totals every 10 s.
+inline void Report() {
+ if (!Enabled()) {
+ return;
+ }
+ auto& data = t_data;
+ const u64 now = Now();
+ if (data.window_start == 0) {
+ data.window_start = now;
+ data.child_ns = 0;
+ return;
+ }
+ const u64 window = now - data.window_start;
+ if (window < 10'000'000'000ULL) {
+ return;
+ }
+ // Time in no zone at all is the wall time minus the zones' totals.
+ u64 zoned{};
+ for (size_t i = 1; i < data.exclusive_ns.size(); ++i) {
+ zoned += data.exclusive_ns[i];
+ }
+ data.exclusive_ns[0] = window > zoned ? window - zoned : 0;
+ std::string line;
+ for (size_t i = 0; i < data.exclusive_ns.size(); ++i) {
+ if (data.exclusive_ns[i] == 0 && data.count[i] == 0) {
+ continue;
+ }
+ // Per second of wall time: milliseconds spent and calls made.
+ const double ms = static_cast<double>(data.exclusive_ns[i]) * 1000.0 / window;
+ const double calls = static_cast<double>(data.count[i]) * 1e9 / window;
+ line += fmt::format(" | {} {:.1f} ms ({:.0f}/s)", zone_names[i], ms, calls);
+ }
+ LOG_INFO(HW_GPU, "GPU thread per second:{}", line);
+ data = {};
+ data.window_start = now;
+}
+
+} // namespace VideoCommon::Nx2Profile
diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp
index 172ee4dcaa..f737ad9f70 100644
--- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp
+++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp
@@ -29,6 +29,7 @@
#include "video_core/vulkan_common/vulkan_device.h"
#include "video_core/gpu_logging/gpu_logging.h"
#include "common/settings.h"
+#include "video_core/nx2_profile.h"
#if defined(_MSC_VER) && defined(NDEBUG)
#define LAMBDA_FORCEINLINE [[msvc::forceinline]]
@@ -355,7 +356,11 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) {
views.reserve(num_image_elements);
samplers.reserve(num_textures);
- texture_cache.SynchronizeDescriptors(false);
+ namespace Profile = VideoCommon::Nx2Profile;
+ {
+ Profile::Scope scope{Profile::Zone::SyncDescriptors};
+ texture_cache.SynchronizeDescriptors(false);
+ }
buffer_cache.SetUniformBuffersState(enabled_uniform_buffer_masks, &uniform_buffer_sizes);
@@ -432,24 +437,31 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) {
return true;
}};
- if constexpr (Spec::enabled_stages[0]) {
- config_stage(0);
- }
- if constexpr (Spec::enabled_stages[1]) {
- config_stage(1);
- }
- if constexpr (Spec::enabled_stages[2]) {
- config_stage(2);
- }
- if constexpr (Spec::enabled_stages[3]) {
- config_stage(3);
- }
- if constexpr (Spec::enabled_stages[4]) {
- config_stage(4);
+ {
+ Profile::Scope scope{Profile::Zone::ReadHandles};
+ if constexpr (Spec::enabled_stages[0]) {
+ config_stage(0);
+ }
+ if constexpr (Spec::enabled_stages[1]) {
+ config_stage(1);
+ }
+ if constexpr (Spec::enabled_stages[2]) {
+ config_stage(2);
+ }
+ if constexpr (Spec::enabled_stages[3]) {
+ config_stage(3);
+ }
+ if constexpr (Spec::enabled_stages[4]) {
+ config_stage(4);
+ }
}
ASSERT(views.size() == num_image_elements);
ASSERT(samplers.size() == num_textures);
- texture_cache.FillImageViews(std::span(views.data(), views.size()), false, Spec::has_images);
+ {
+ Profile::Scope scope{Profile::Zone::FillImageViews};
+ texture_cache.FillImageViews(std::span(views.data(), views.size()), false,
+ Spec::has_images);
+ }
VideoCommon::ImageViewInOut* texture_buffer_it{views.data()};
const auto bind_stage_info{[&](size_t stage) LAMBDA_FORCEINLINE {
@@ -493,39 +505,54 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) {
texture_buffer_it += Shader::NumDescriptors(info.image_descriptors);
}
}};
- if constexpr (Spec::enabled_stages[0]) {
- bind_stage_info(0);
- }
- if constexpr (Spec::enabled_stages[1]) {
- bind_stage_info(1);
- }
- if constexpr (Spec::enabled_stages[2]) {
- bind_stage_info(2);
- }
- if constexpr (Spec::enabled_stages[3]) {
- bind_stage_info(3);
- }
- if constexpr (Spec::enabled_stages[4]) {
- bind_stage_info(4);
+ {
+ Profile::Scope scope{Profile::Zone::TextureBuffers};
+ if constexpr (Spec::enabled_stages[0]) {
+ bind_stage_info(0);
+ }
+ if constexpr (Spec::enabled_stages[1]) {
+ bind_stage_info(1);
+ }
+ if constexpr (Spec::enabled_stages[2]) {
+ bind_stage_info(2);
+ }
+ if constexpr (Spec::enabled_stages[3]) {
+ bind_stage_info(3);
+ }
+ if constexpr (Spec::enabled_stages[4]) {
+ bind_stage_info(4);
+ }
}
if (regs.transform_feedback_enabled != 0) {
scheduler.RequestOutsideRenderPassOperationContext();
}
- buffer_cache.UpdateGraphicsBuffers(is_indexed);
- buffer_cache.BindHostGeometryBuffers(is_indexed);
+ {
+ Profile::Scope scope{Profile::Zone::UpdateBuffers};
+ buffer_cache.UpdateGraphicsBuffers(is_indexed);
+ buffer_cache.BindHostGeometryBuffers(is_indexed);
+ }
- guest_descriptor_queue.Acquire(scheduler, num_descriptor_entries, uses_descriptor_buffer);
+ {
+ Profile::Scope scope{Profile::Zone::DescriptorAcquire};
+ guest_descriptor_queue.Acquire(scheduler, num_descriptor_entries, uses_descriptor_buffer);
+ }
RescalingPushConstant rescaling;
RenderAreaPushConstant render_area;
const VideoCommon::SamplerId* samplers_it{samplers.data()};
const VideoCommon::ImageViewInOut* views_it{views.data()};
const auto prepare_stage{[&](size_t stage) LAMBDA_FORCEINLINE {
- buffer_cache.BindHostStageBuffers(stage);
- PushImageDescriptors(texture_cache, guest_descriptor_queue, stage_infos[stage], rescaling,
- samplers_it, views_it);
+ {
+ Profile::Scope scope{Profile::Zone::BindStageBuffers};
+ buffer_cache.BindHostStageBuffers(stage);
+ }
+ {
+ Profile::Scope scope{Profile::Zone::PushDescriptors};
+ PushImageDescriptors(texture_cache, guest_descriptor_queue, stage_infos[stage],
+ rescaling, samplers_it, views_it);
+ }
const auto& info{stage_infos[stage]};
if (info.uses_render_area) {
render_area.uses_render_area = true;
@@ -555,12 +582,16 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) {
buffer_cache.runtime.PostCopyBarrier();
buffer_cache.any_buffer_uploaded = false;
}
- texture_cache.UpdateRenderTargets(false);
- texture_cache.CheckFeedbackLoop(std::span<const VideoCommon::ImageViewInOut>{views.data(),
- views.size()});
+ {
+ Profile::Scope scope{Profile::Zone::RenderTargets};
+ texture_cache.UpdateRenderTargets(false);
+ texture_cache.CheckFeedbackLoop(
+ std::span<const VideoCommon::ImageViewInOut>{views.data(), views.size()});
+ }
if (IsBuilt() && !pipeline) {
return false;
}
+ Profile::Scope scope{Profile::Zone::ConfigureDraw};
return ConfigureDraw(rescaling, render_area);
}
diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp
index 609ac12fb4..decfb0d136 100644
--- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp
+++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp
@@ -49,6 +49,7 @@
#include "video_core/texture_cache/texture_cache_base.h"
#include "video_core/vulkan_common/vulkan_device.h"
#include "video_core/vulkan_common/vulkan_wrapper.h"
+#include "video_core/nx2_profile.h"
namespace Vulkan {
@@ -253,8 +254,12 @@ void RasterizerVulkan::PrepareDraw(bool is_indexed, Func&& draw_func) {
LOG_DEBUG(HW_GPU, "Native graphics draw failed; refusing submission completion");
}
};
- FlushWork();
- gpu_memory->FlushCaching();
+ namespace Profile = VideoCommon::Nx2Profile;
+ {
+ Profile::Scope scope{Profile::Zone::FlushWork};
+ FlushWork();
+ gpu_memory->FlushCaching();
+ }
const auto report_failure = [&](std::string_view reason) {
static std::set<std::tuple<u64, u64, std::string_view>> seen;
@@ -266,7 +271,10 @@ void RasterizerVulkan::PrepareDraw(bool is_indexed, Func&& draw_func) {
regs.rt[0].Address(), regs.rt[0].width, regs.rt[0].height);
}
};
- GraphicsPipeline* const pipeline{pipeline_cache.CurrentGraphicsPipeline()};
+ GraphicsPipeline* const pipeline{[&] {
+ Profile::Scope scope{Profile::Zone::PipelineLookup};
+ return pipeline_cache.CurrentGraphicsPipeline();
+ }()};
if (!pipeline) {
report_failure("no pipeline");
return;
@@ -274,17 +282,23 @@ void RasterizerVulkan::PrepareDraw(bool is_indexed, Func&& draw_func) {
std::scoped_lock lock{buffer_cache.mutex, texture_cache.mutex};
// update engine as channel may be different.
pipeline->SetEngine(maxwell3d, gpu_memory);
- if (!pipeline->Configure(is_indexed)) {
+ if (Profile::Scope scope{Profile::Zone::Configure}; !pipeline->Configure(is_indexed)) {
report_failure("configure failed");
return;
}
- UpdateDynamicStates();
+ {
+ Profile::Scope scope{Profile::Zone::DynamicState};
+ UpdateDynamicStates();
+ }
query_cache.NotifySegment(true);
HandleTransformFeedback();
query_cache.CounterEnable(VideoCommon::QueryType::ZPassPixelCount64, maxwell3d->regs.zpass_pixel_count_enable);
- draw_func();
+ {
+ Profile::Scope scope{Profile::Zone::DrawRecord};
+ draw_func();
+ }
submitted = true;
static u32 native_capture_count{};
if (maxwell3d->GetNativeMacroEngine().native_turing &&
@@ -412,6 +426,7 @@ void TraceWindowTarget(const Tegra::Engines::Maxwell3D& maxwell3d, Tegra::Memory
} // Anonymous namespace
void RasterizerVulkan::Draw(bool is_indexed, u32 instance_count) {
+ VideoCommon::Nx2Profile::Scope profile_scope{VideoCommon::Nx2Profile::Zone::Draw};
TraceWindowTarget(*maxwell3d, *gpu_memory, is_indexed ? "draw-indexed" : "draw",
is_indexed ? maxwell3d->draw_manager.draw_state.index_buffer.count
: maxwell3d->draw_manager.draw_state.vertex_buffer.count);
@@ -554,6 +569,7 @@ void RasterizerVulkan::Draw(bool is_indexed, u32 instance_count) {
}
void RasterizerVulkan::DrawIndirect() {
+ VideoCommon::Nx2Profile::Scope profile_scope{VideoCommon::Nx2Profile::Zone::Draw};
const auto& params = maxwell3d->draw_manager.indirect_state;
buffer_cache.SetDrawIndirect(&params);
PrepareDraw(params.is_indexed, [this, &params] {
@@ -659,6 +675,7 @@ void RasterizerVulkan::DrawTexture() {
}
void RasterizerVulkan::Clear(u32 layer_count) {
+ VideoCommon::Nx2Profile::Scope profile_scope{VideoCommon::Nx2Profile::Zone::Clear};
if (Tegra::Engines::g_nx2_trace_draws_after_upscale > 0) {
const auto& regs = maxwell3d->regs;
LOG_INFO(HW_GPU, "after upscale: clear rt0 {:#x} {}x{} fmt {:#x} surface {:#x} colour {} {} {} {}",
@@ -852,6 +869,7 @@ void RasterizerVulkan::Clear(u32 layer_count) {
}
void RasterizerVulkan::DispatchCompute() {
+ VideoCommon::Nx2Profile::Scope profile_scope{VideoCommon::Nx2Profile::Zone::Compute};
FlushWork();
gpu_memory->FlushCaching();
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index 75df31dc27..ad1804877a 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -28,6 +28,7 @@
#include "video_core/texture_cache/samples_helper.h"
#include "video_core/texture_cache/texture_cache_base.h"
#include "video_core/texture_cache/util.h"
+#include "video_core/nx2_profile.h"
#include "video_core/textures/decoders.h"
namespace VideoCommon {
@@ -1251,6 +1252,7 @@ void TextureCache<P>::RefreshContents(Image& image, ImageId image_id) {
// Only upload modified images
return;
}
+ VideoCommon::Nx2Profile::Scope profile_scope{VideoCommon::Nx2Profile::Zone::TextureUpload};
image.flags &= ~ImageFlagBits::CpuModified;
diff --git a/src/yuzu_cmd/CMakeLists.txt b/src/yuzu_cmd/CMakeLists.txt
index 17a1545167..a87b4e5288 100644
--- a/src/yuzu_cmd/CMakeLists.txt
+++ b/src/yuzu_cmd/CMakeLists.txt
@@ -31,6 +31,8 @@ add_executable(yuzu-cmd
emu_window/emu_window_sdl3_null.h
emu_window/emu_window_sdl3_vk.cpp
emu_window/emu_window_sdl3_vk.h
+ nx2_sampler.cpp
+ nx2_sampler.h
sdl_config.cpp
sdl_config.h
yuzu.cpp
diff --git a/src/yuzu_cmd/nx2_sampler.cpp b/src/yuzu_cmd/nx2_sampler.cpp
new file mode 100644
index 0000000000..c01b812650
--- /dev/null
+++ b/src/yuzu_cmd/nx2_sampler.cpp
@@ -0,0 +1,333 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "yuzu_cmd/nx2_sampler.h"
+
+#ifdef _WIN32
+
+#include <algorithm>
+#include <array>
+#include <chrono>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <map>
+#include <set>
+#include <string>
+#include <thread>
+#include <unordered_map>
+#include <vector>
+
+#include <windows.h>
+#include <tlhelp32.h>
+#include <dbghelp.h>
+
+namespace Nx2Sampler {
+namespace {
+
+constexpr size_t MaxDepth = 40;
+
+struct Sample {
+ unsigned thread;
+ unsigned depth;
+ std::array<DWORD64, MaxDepth> frames;
+};
+
+struct Target {
+ HANDLE handle;
+ std::string name;
+};
+
+double EnvSeconds(const char* name, double fallback) {
+ const char* text = std::getenv(name);
+ return text != nullptr ? std::strtod(text, nullptr) : fallback;
+}
+
+std::vector<std::string> SplitNames(const char* text) {
+ std::vector<std::string> names;
+ std::string current;
+ for (const char* c = text; *c != '\0'; ++c) {
+ if (*c == ',') {
+ names.push_back(current);
+ current.clear();
+ } else {
+ current += *c;
+ }
+ }
+ if (!current.empty()) {
+ names.push_back(current);
+ }
+ return names;
+}
+
+bool Matches(const std::string& thread_name, const std::vector<std::string>& patterns) {
+ for (const auto& pattern : patterns) {
+ if (!pattern.empty() && pattern.back() == '*') {
+ if (thread_name.compare(0, pattern.size() - 1, pattern, 0, pattern.size() - 1) == 0) {
+ return true;
+ }
+ } else if (thread_name == pattern) {
+ return true;
+ }
+ }
+ return false;
+}
+
+std::vector<Target> FindThreads(const std::vector<std::string>& patterns) {
+ using GetThreadDescriptionFn = HRESULT(WINAPI*)(HANDLE, PWSTR*);
+ const auto get_description = reinterpret_cast<GetThreadDescriptionFn>(
+ GetProcAddress(GetModuleHandleA("KernelBase.dll"), "GetThreadDescription"));
+ std::vector<Target> targets;
+ if (get_description == nullptr) {
+ return targets;
+ }
+ const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
+ if (snapshot == INVALID_HANDLE_VALUE) {
+ return targets;
+ }
+ THREADENTRY32 entry{};
+ entry.dwSize = sizeof(entry);
+ const DWORD self = GetCurrentThreadId();
+ for (BOOL ok = Thread32First(snapshot, &entry); ok; ok = Thread32Next(snapshot, &entry)) {
+ if (entry.th32OwnerProcessID != GetCurrentProcessId() || entry.th32ThreadID == self) {
+ continue;
+ }
+ const HANDLE handle = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT |
+ THREAD_QUERY_LIMITED_INFORMATION,
+ FALSE, entry.th32ThreadID);
+ if (handle == nullptr) {
+ continue;
+ }
+ PWSTR wide{};
+ std::string name;
+ if (SUCCEEDED(get_description(handle, &wide)) && wide != nullptr) {
+ char buffer[256]{};
+ WideCharToMultiByte(CP_UTF8, 0, wide, -1, buffer, sizeof(buffer) - 1, nullptr,
+ nullptr);
+ name = buffer;
+ LocalFree(wide);
+ }
+ if (Matches(name, patterns)) {
+ targets.push_back({handle, name});
+ } else {
+ CloseHandle(handle);
+ }
+ }
+ CloseHandle(snapshot);
+ return targets;
+}
+
+// Walks a suspended thread's stack from its context. Nothing here may allocate: the thread
+// could be paused holding the heap lock.
+unsigned Walk(CONTEXT context, std::array<DWORD64, MaxDepth>& frames) {
+ unsigned depth = 0;
+ while (depth < MaxDepth && context.Rip != 0) {
+ frames[depth++] = context.Rip;
+ DWORD64 image_base{};
+ const PRUNTIME_FUNCTION function = RtlLookupFunctionEntry(context.Rip, &image_base, nullptr);
+ if (function == nullptr) {
+ // A leaf function (no unwind data): its return address is on top of the stack. JIT
+ // code has none either; past the first frame, stop there.
+ if (depth != 1) {
+ break;
+ }
+ context.Rip = *reinterpret_cast<const DWORD64*>(context.Rsp);
+ context.Rsp += 8;
+ continue;
+ }
+ PVOID handler_data{};
+ DWORD64 establisher_frame{};
+ RtlVirtualUnwind(UNW_FLAG_NHANDLER, image_base, context.Rip, function, &context,
+ &handler_data, &establisher_frame, nullptr);
+ }
+ return depth;
+}
+
+struct Symbolizer {
+ using SymInitializeFn = BOOL(WINAPI*)(HANDLE, PCSTR, BOOL);
+ using SymSetOptionsFn = DWORD(WINAPI*)(DWORD);
+ using SymFromAddrFn = BOOL(WINAPI*)(HANDLE, DWORD64, PDWORD64, PSYMBOL_INFO);
+
+ Symbolizer() {
+ const HMODULE dbghelp = LoadLibraryA("dbghelp.dll");
+ if (dbghelp == nullptr) {
+ return;
+ }
+ const auto set_options =
+ reinterpret_cast<SymSetOptionsFn>(GetProcAddress(dbghelp, "SymSetOptions"));
+ const auto initialize =
+ reinterpret_cast<SymInitializeFn>(GetProcAddress(dbghelp, "SymInitialize"));
+ from_address = reinterpret_cast<SymFromAddrFn>(GetProcAddress(dbghelp, "SymFromAddr"));
+ if (set_options == nullptr || initialize == nullptr || from_address == nullptr) {
+ from_address = nullptr;
+ return;
+ }
+ set_options(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS);
+ if (!initialize(GetCurrentProcess(), nullptr, TRUE)) {
+ from_address = nullptr;
+ }
+ }
+
+ const std::string& Name(DWORD64 address) {
+ const auto [it, is_new] = cache.try_emplace(address);
+ if (!is_new) {
+ return it->second;
+ }
+ if (from_address != nullptr) {
+ alignas(SYMBOL_INFO) char buffer[sizeof(SYMBOL_INFO) + 1024]{};
+ auto* const symbol = reinterpret_cast<SYMBOL_INFO*>(buffer);
+ symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
+ symbol->MaxNameLen = 1024;
+ DWORD64 displacement{};
+ if (from_address(GetCurrentProcess(), address, &displacement, symbol)) {
+ it->second.assign(symbol->Name, std::min<size_t>(symbol->NameLen, 160));
+ return it->second;
+ }
+ }
+ HMODULE module{};
+ if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
+ GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+ reinterpret_cast<LPCSTR>(address), &module)) {
+ char path[MAX_PATH] = "?";
+ GetModuleFileNameA(module, path, sizeof(path));
+ const char* base = std::strrchr(path, '\\');
+ it->second = std::string{base != nullptr ? base + 1 : path} + "!?";
+ } else {
+ it->second = "(jit)";
+ }
+ return it->second;
+ }
+
+ SymFromAddrFn from_address{};
+ std::unordered_map<DWORD64, std::string> cache;
+};
+
+void Report(const std::vector<Target>& targets, const std::vector<Sample>& samples,
+ const char* path) {
+ Symbolizer symbolizer;
+ std::FILE* const file = std::fopen(path, "w");
+ if (file == nullptr) {
+ return;
+ }
+ for (unsigned thread = 0; thread < targets.size(); ++thread) {
+ std::map<std::string, unsigned> self;
+ std::map<std::string, unsigned> inclusive;
+ std::map<std::string, unsigned> self_caller;
+ unsigned total = 0;
+ for (const Sample& sample : samples) {
+ if (sample.thread != thread || sample.depth == 0) {
+ continue;
+ }
+ ++total;
+ const std::string& top = symbolizer.Name(sample.frames[0]);
+ ++self[top];
+ if (sample.depth > 1) {
+ ++self_caller[top + " <- " + symbolizer.Name(sample.frames[1])];
+ }
+ std::set<std::string> seen;
+ for (unsigned i = 0; i < sample.depth; ++i) {
+ const std::string& name = symbolizer.Name(sample.frames[i]);
+ if (seen.insert(name).second) {
+ ++inclusive[name];
+ }
+ }
+ }
+ const auto print = [&](const char* title, const std::map<std::string, unsigned>& counts,
+ size_t limit) {
+ std::vector<std::pair<unsigned, std::string>> sorted;
+ for (const auto& [name, count] : counts) {
+ sorted.emplace_back(count, name);
+ }
+ std::sort(sorted.rbegin(), sorted.rend());
+ std::fprintf(file, "-- %s\n", title);
+ for (size_t i = 0; i < std::min(limit, sorted.size()); ++i) {
+ std::fprintf(file, "%6u %5.1f%% %s\n", sorted[i].first,
+ 100.0 * sorted[i].first / std::max(total, 1U),
+ sorted[i].second.c_str());
+ }
+ };
+ std::fprintf(file, "=== thread \"%s\": %u samples\n", targets[thread].name.c_str(), total);
+ print("self (where the thread is)", self, 60);
+ print("self <- caller", self_caller, 50);
+ print("inclusive (on the stack)", inclusive, 90);
+ std::fprintf(file, "\n");
+ }
+ std::fclose(file);
+
+ // <out>.folded: every distinct stack with its count, root first ("thread;a;b;c count").
+ std::map<std::string, unsigned> folded;
+ for (const Sample& sample : samples) {
+ std::string line = targets[sample.thread].name;
+ for (unsigned i = sample.depth; i-- > 0;) {
+ line += ';';
+ line += symbolizer.Name(sample.frames[i]);
+ }
+ ++folded[line];
+ }
+ if (std::FILE* const out = std::fopen((std::string{path} + ".folded").c_str(), "w")) {
+ for (const auto& [line, count] : folded) {
+ std::fprintf(out, "%s %u\n", line.c_str(), count);
+ }
+ std::fclose(out);
+ }
+}
+
+void Run(std::vector<std::string> patterns, double start, double seconds, std::string out) {
+ std::this_thread::sleep_for(std::chrono::duration<double>(start));
+ const std::vector<Target> targets = FindThreads(patterns);
+ std::fprintf(stderr, "nx2 sampler: %zu threads match, sampling %.0f s\n", targets.size(),
+ seconds);
+ if (targets.empty()) {
+ return;
+ }
+ std::vector<Sample> samples;
+ samples.reserve(static_cast<size_t>(seconds * 1100.0) * targets.size() + 16);
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration<double>(seconds);
+ while (std::chrono::steady_clock::now() < deadline &&
+ samples.size() + targets.size() <= samples.capacity()) {
+ for (unsigned i = 0; i < targets.size(); ++i) {
+ const HANDLE handle = targets[i].handle;
+ if (SuspendThread(handle) == static_cast<DWORD>(-1)) {
+ continue;
+ }
+ CONTEXT context{};
+ context.ContextFlags = CONTEXT_FULL;
+ if (GetThreadContext(handle, &context)) {
+ Sample& sample = samples.emplace_back();
+ sample.thread = i;
+ sample.depth = Walk(context, sample.frames);
+ }
+ ResumeThread(handle);
+ }
+ Sleep(1);
+ }
+ Report(targets, samples, out.c_str());
+ std::fprintf(stderr, "nx2 sampler: %zu samples written to %s\n", samples.size(), out.c_str());
+ for (const auto& target : targets) {
+ CloseHandle(target.handle);
+ }
+}
+
+} // Anonymous namespace
+
+void StartFromEnvironment() {
+ const char* names = std::getenv("EDEN_NX2_SAMPLE");
+ if (names == nullptr || *names == '\0') {
+ return;
+ }
+ const char* out = std::getenv("EDEN_NX2_SAMPLE_OUT");
+ std::thread{Run, SplitNames(names), EnvSeconds("EDEN_NX2_SAMPLE_START", 60.0),
+ EnvSeconds("EDEN_NX2_SAMPLE_SECONDS", 30.0),
+ std::string{out != nullptr ? out : "nx2_samples.txt"}}
+ .detach();
+}
+
+} // namespace Nx2Sampler
+
+#else
+
+namespace Nx2Sampler {
+void StartFromEnvironment() {}
+} // namespace Nx2Sampler
+
+#endif
diff --git a/src/yuzu_cmd/nx2_sampler.h b/src/yuzu_cmd/nx2_sampler.h
new file mode 100644
index 0000000000..a73c8fa302
--- /dev/null
+++ b/src/yuzu_cmd/nx2_sampler.h
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+namespace Nx2Sampler {
+
+// EDEN_NX2_SAMPLE=<thread names>: a sampling profiler for named host threads (Windows only).
+// Names are comma separated; a name ending in '*' matches by prefix (CPUCore_*). From
+// EDEN_NX2_SAMPLE_START seconds (default 60) for EDEN_NX2_SAMPLE_SECONDS (default 30), each
+// thread is paused about 1000 times a second and its call stack recorded; the counts per
+// function (where the thread is, and everything on its stack) go to EDEN_NX2_SAMPLE_OUT
+// (default nx2_samples.txt).
+void StartFromEnvironment();
+
+} // namespace Nx2Sampler
diff --git a/src/yuzu_cmd/yuzu.cpp b/src/yuzu_cmd/yuzu.cpp
index bf256766fb..ffa1876c4f 100644
--- a/src/yuzu_cmd/yuzu.cpp
+++ b/src/yuzu_cmd/yuzu.cpp
@@ -54,6 +54,7 @@
#endif
#include "yuzu_cmd/emu_window/emu_window_sdl3_null.h"
#include "yuzu_cmd/emu_window/emu_window_sdl3_vk.h"
+#include "yuzu_cmd/nx2_sampler.h"
#ifdef _WIN32
// windows.h needs to be included before shellapi.h
@@ -246,6 +247,7 @@ extern "C" SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) {
#ifdef _WIN32
SetUnhandledExceptionFilter(WriteCrashDump);
#endif
+ Nx2Sampler::StartFromEnvironment();
// SDL calls SDL_AppIterate back to back by default, which kept a host core at 100% polling
// input; 240 Hz is plenty for input and the key script.
SDL_SetHint(SDL_HINT_MAIN_CALLBACK_RATE, "240");