From bf1de8f40130af5d3b490ee40dcc2fb20d460a14 Mon Sep 17 00:00:00 2001 From: Takoroka <137927095+Takoroka@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:53:15 -0400 Subject: Switch 2: suspend channels on semaphore acquires; fix pushbuffer desyncs NVN2 gates command buffers with semaphores the CPU releases after writing them. Eden read past unmet SEMAPHORED acquires, so it executed stale commands and desynchronised. Channels now suspend on an unmet acquire and resume from the same word once it is met (scheduler keeps the waiting channels, the GPU thread polls them), as in changes.patch. Also: methods split across GPFIFO entries around a called command buffer are set aside and resumed; subchannel 2 defaults to inline-to-memory; unbound subchannels and out-of-range compute/DMA methods are dropped instead of crashing; 4-byte semaphore releases no longer write 16 bytes; Ampere SEM_PAYLOAD_HI/SEM_EXECUTE no longer spin as Kepler acquires; unmapped GPU reads log instead of asserting. Debug aids: EDEN_NX2_PB_CHECK (pushbuffer changed after submit), EDEN_NX2_QUERY_TRACE, a one-time pushbuffer dump. --- src/video_core/gpu_thread.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'src/video_core/gpu_thread.cpp') diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp index a01375e7be..97e18df499 100644 --- a/src/video_core/gpu_thread.cpp +++ b/src/video_core/gpu_thread.cpp @@ -4,6 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include + #include "common/assert.h" #include "common/scope_exit.h" #include "common/settings.h" @@ -36,7 +39,16 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten auto current_context = context.Acquire(); CommandDataContainer next; while (!stop_token.stop_requested()) { - state.queue.PopWait(next, stop_token); + if (scheduler.HasPendingChannels()) { + // Suspended channels wait on semaphores the CPU releases: poll them meanwhile. + scheduler.ResumePending(system.GPU()); + if (!state.queue.TryPop(next)) { + std::this_thread::sleep_for(std::chrono::milliseconds{1}); + continue; + } + } else { + state.queue.PopWait(next, stop_token); + } if (stop_token.stop_requested()) { break; } -- cgit v1.2.3 From 598bb7750c71ea1378724ddfe9418f27330fa279 Mon Sep 17 00:00:00 2001 From: Takoroka <137927095+Takoroka@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:47:45 -0400 Subject: nx2: unmap GPU memory in submission order NormalUnmapGpuMemory4K and destroying GPU memory are jobs the console's graphics server completes behind the work already submitted. Run the GMMU unmaps on the GPU thread after the command lists queued before them (GPU::RunInOrder) and signal the job's event only then, instead of unmapping and signalling at once while Eden's GPU thread may still be behind. Also EDEN_NX2_FRAMES_AHEAD (experimental): wait for the previous frame's fence at present instead of this one's. --- .../hle/service/nvdrv/devices/nvhost_as_gpu.cpp | 3 ++- src/core/hle/service/nx2_gsv.h | 25 +++++++++++++++++++--- src/video_core/gpu.cpp | 8 +++++++ src/video_core/gpu.h | 4 ++++ src/video_core/gpu_thread.cpp | 6 ++++++ src/video_core/gpu_thread.h | 11 +++++++++- 6 files changed, 52 insertions(+), 5 deletions(-) (limited to 'src/video_core/gpu_thread.cpp') diff --git a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp index 4dc667fa18..7f00063079 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp @@ -151,7 +151,8 @@ void nvhost_as_gpu::Nx2Unmap(u64 gpu_address, u64 size) { if (!gmmu) { return; } - gmmu->Unmap(gpu_address, size); + // The GPU thread may still be running command lists that use the range. + system.GPU().RunInOrder([gmmu = gmmu.get(), gpu_address, size] { gmmu->Unmap(gpu_address, size); }); LOG_DEBUG(Service_NVDRV, "Switch 2 GPU unmapping {:#x}+{:#x}", gpu_address, size); } diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 6532fedd25..0e41117902 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -413,11 +413,18 @@ private: if (last > first) { Nvidia::Devices::Nx2GpuMemoryRegistry::Get().Unmap(first, last - first + 1); } - EventFor(w[8])->Signal(system.Kernel()); + SignalAfterGpu(w[8]); IPC::ResponseBuilder rb{ctx, 2}; rb.Push(ResultSuccess); } + // Signals a job slot's event once the GPU thread has run everything queued so far, as the + // console's graphics server completes unmap jobs behind the submitted work. + void SignalAfterGpu(u32 slot) { + auto* const event = EventFor(slot); + system.GPU().RunInOrder([this, event] { event->Signal(system.Kernel()); }); + } + // Sub 102: word 2 is the memory handle 101 returned, word 3 the job slot. The game frees the // memory (svc 0x80 clears GPU access) and reuses it as ordinary heap right after, so nothing // on the GPU side may keep it: drop the mapping 101 made at its own address. @@ -438,7 +445,7 @@ private: } else { LOG_WARNING(Service, "nx2:gpu 102 destroy of unknown handle {}", handle); } - EventFor(w[3])->Signal(system.Kernel()); + SignalAfterGpu(w[3]); IPC::ResponseBuilder rb{ctx, 2}; rb.Push(ResultSuccess); } @@ -609,7 +616,17 @@ private: LOG_INFO(Service, "nx2:vi2 229 present #{} token={:#x} slot={} image={}", m_presents, u64{w[2]} | (u64{w[3]} << 32), w[4], w[5]); } - WaitForFrame(~w[22], w[24]); + // EDEN_NX2_FRAMES_AHEAD=1: wait for the previous frame's fence instead of this one, so the + // game prepares frame N+1 while the GPU thread runs frame N (the console keeps several + // frames in flight). Experimental. + static const bool frames_ahead = std::getenv("EDEN_NX2_FRAMES_AHEAD") != nullptr; + if (frames_ahead) { + WaitForFrame(m_previous_fence_id, m_previous_fence_value); + m_previous_fence_id = ~w[22]; + m_previous_fence_value = w[24]; + } else { + WaitForFrame(~w[22], w[24]); + } Composite(w[5], ~w[22], w[24]); PaceToVblank(); constexpr u64 AvailableSlots = 0x7fff; @@ -623,6 +640,8 @@ private: // Eden's GPU thread has processed the frame. Otherwise the game laps the GPU thread and // rewrites command memory it hasn't read yet. u32 m_wait_timeouts{}; + u32 m_previous_fence_id{~0U}; // none yet (WaitForFrame ignores ids >= 192) + u32 m_previous_fence_value{}; // The console flips one frame per 60 Hz vblank and keeps the rest queued, so a game can't // present faster than that. Every slot is reported free here: hold each present until the diff --git a/src/video_core/gpu.cpp b/src/video_core/gpu.cpp index 1621b24125..2a61a66d82 100644 --- a/src/video_core/gpu.cpp +++ b/src/video_core/gpu.cpp @@ -206,6 +206,10 @@ struct GPU::Impl { gpu_thread.SubmitList(channel, std::move(entries), is_async); } + void RunInOrder(std::function&& func) { + gpu_thread.Run(std::move(func), is_async); + } + /// Notify rasterizer that any caches of the specified region should be flushed to Switch memory void FlushRegion(DAddr addr, u64 size) { gpu_thread.FlushRegion(addr, size, is_async); @@ -530,6 +534,10 @@ void GPU::FlushRegion(DAddr addr, u64 size) { impl->FlushRegion(addr, size); } +void GPU::RunInOrder(std::function&& func) { + impl->RunInOrder(std::move(func)); +} + void GPU::InvalidateRegion(DAddr addr, u64 size, bool preserve_gpu_writes) { impl->InvalidateRegion(addr, size, preserve_gpu_writes); } diff --git a/src/video_core/gpu.h b/src/video_core/gpu.h index 974d214e10..41ba186cd3 100644 --- a/src/video_core/gpu.h +++ b/src/video_core/gpu.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include "common/bit_field.h" @@ -247,6 +248,9 @@ public: /// Notify rasterizer that any caches of the specified region should be flushed to Switch memory void FlushRegion(DAddr addr, u64 size); + /// Runs func on the GPU thread after the command lists submitted so far. + void RunInOrder(std::function&& func); + /// Notify rasterizer that any caches of the specified region should be invalidated void InvalidateRegion(DAddr addr, u64 size, bool preserve_gpu_writes = false); diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp index 97e18df499..793e8e9eeb 100644 --- a/src/video_core/gpu_thread.cpp +++ b/src/video_core/gpu_thread.cpp @@ -56,6 +56,8 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten scheduler.Push(system.GPU(), submit_list->channel, std::move(submit_list->entries)); } else if (std::holds_alternative(next.data)) { system.GPU().TickWork(); + } else if (auto* run = std::get_if(&next.data)) { + run->func(); } else if (const auto* flush = std::get_if(&next.data)) { renderer.ReadRasterizer()->FlushRegion(flush->addr, flush->size); } else if (const auto* invalidate = std::get_if(&next.data)) { @@ -89,6 +91,10 @@ void ThreadManager::TickGPU(bool is_async) { PushCommand(GPUTickCommand(), false, is_async); } +void ThreadManager::Run(std::function&& func, bool is_async) { + PushCommand(RunCommand{std::move(func)}, false, is_async); +} + void ThreadManager::InvalidateRegion(DAddr addr, u64 size) { rasterizer->OnCacheInvalidation(addr, size); } diff --git a/src/video_core/gpu_thread.h b/src/video_core/gpu_thread.h index 631a680db4..796c0d89f6 100644 --- a/src/video_core/gpu_thread.h +++ b/src/video_core/gpu_thread.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include #include @@ -76,9 +77,14 @@ struct FlushAndInvalidateRegionCommand final { /// Command to make the gpu look into pending requests struct GPUTickCommand final {}; +/// Command to run a function on the GPU thread after the commands queued before it +struct RunCommand final { + std::function func; +}; + using CommandData = std::variant; + FlushAndInvalidateRegionCommand, GPUTickCommand, RunCommand>; struct CommandDataContainer { CommandDataContainer() = default; @@ -125,6 +131,9 @@ public: void TickGPU(bool is_async); + /// Runs func on the GPU thread once the commands queued before it have been processed. + void Run(std::function&& func, bool is_async); + void NotifyShutdown(); private: -- cgit v1.2.3 From 571f9b58eb8a9c102a91a5d26cd32ebae4330232 Mon Sep 17 00:00:00 2001 From: Takoroka <137927095+Takoroka@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:07:49 -0400 Subject: nx2: GPU-thread zone timers and an in-process sampling profiler EDEN_NX2_GPU_PROFILE=1 times the GPU thread's stages (command parsing, macros, draw setup by step, compute, texture uploads) and logs each one's share of every second. EDEN_NX2_SAMPLE= (Windows) pauses the named threads about 600 times a second for EDEN_NX2_SAMPLE_SECONDS, walks their stacks and writes per-function counts plus folded stacks (EDEN_NX2_SAMPLE_OUT). --- src/video_core/CMakeLists.txt | 1 + src/video_core/gpu_thread.cpp | 15 +- src/video_core/nx2_profile.h | 149 +++++++++ .../renderer_vulkan/vk_graphics_pipeline.cpp | 109 ++++--- src/video_core/renderer_vulkan/vk_rasterizer.cpp | 30 +- src/video_core/texture_cache/texture_cache.h | 2 + src/yuzu_cmd/CMakeLists.txt | 2 + src/yuzu_cmd/nx2_sampler.cpp | 333 +++++++++++++++++++++ src/yuzu_cmd/nx2_sampler.h | 16 + src/yuzu_cmd/yuzu.cpp | 2 + 10 files changed, 613 insertions(+), 46 deletions(-) create mode 100644 src/video_core/nx2_profile.h create mode 100644 src/yuzu_cmd/nx2_sampler.cpp create mode 100644 src/yuzu_cmd/nx2_sampler.h (limited to 'src/video_core/gpu_thread.cpp') 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(&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(next.data)) { + Profile::Scope scope{Profile::Zone::Tick}; system.GPU().TickWork(); } else if (auto* run = std::get_if(&next.data)) { + Profile::Scope scope{Profile::Zone::Run}; run->func(); } else if (const auto* flush = std::get_if(&next.data)) { + Profile::Scope scope{Profile::Zone::FlushInvalidate}; renderer.ReadRasterizer()->FlushRegion(flush->addr, flush->size); } else if (const auto* invalidate = std::get_if(&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 +#include +#include +#include + +#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(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(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +struct ThreadData { + std::array(Zone::Count)> exclusive_ns{}; + std::array(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(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(data.exclusive_ns[i]) * 1000.0 / window; + const double calls = static_cast(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{views.data(), - views.size()}); + { + Profile::Scope scope{Profile::Zone::RenderTargets}; + texture_cache.UpdateRenderTargets(false); + texture_cache.CheckFeedbackLoop( + std::span{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> 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(¶ms); PrepareDraw(params.is_indexed, [this, ¶ms] { @@ -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

::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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace Nx2Sampler { +namespace { + +constexpr size_t MaxDepth = 40; + +struct Sample { + unsigned thread; + unsigned depth; + std::array 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 SplitNames(const char* text) { + std::vector 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& 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 FindThreads(const std::vector& patterns) { + using GetThreadDescriptionFn = HRESULT(WINAPI*)(HANDLE, PWSTR*); + const auto get_description = reinterpret_cast( + GetProcAddress(GetModuleHandleA("KernelBase.dll"), "GetThreadDescription")); + std::vector 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& 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(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(GetProcAddress(dbghelp, "SymSetOptions")); + const auto initialize = + reinterpret_cast(GetProcAddress(dbghelp, "SymInitialize")); + from_address = reinterpret_cast(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(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(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(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 cache; +}; + +void Report(const std::vector& targets, const std::vector& 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 self; + std::map inclusive; + std::map 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 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& counts, + size_t limit) { + std::vector> 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); + + // .folded: every distinct stack with its count, root first ("thread;a;b;c count"). + std::map 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 patterns, double start, double seconds, std::string out) { + std::this_thread::sleep_for(std::chrono::duration(start)); + const std::vector targets = FindThreads(patterns); + std::fprintf(stderr, "nx2 sampler: %zu threads match, sampling %.0f s\n", targets.size(), + seconds); + if (targets.empty()) { + return; + } + std::vector samples; + samples.reserve(static_cast(seconds * 1100.0) * targets.size() + 16); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration(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(-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=: 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"); -- cgit v1.2.3 From 934eeb0e6d82458048c94361b0d7b2db1b89f041 Mon Sep 17 00:00:00 2001 From: Takoroka <137927095+Takoroka@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:27:35 -0400 Subject: nx2: poll pending channels briefly before sleeping; profile zones on the TSC - A channel waiting on a semaphore the CPU releases slept 1 ms per poll (~25 waits a second in title 1); poll for 0.5 ms first. - The GPU profile zones read the wall clock (the TSC) instead of QueryPerformanceCounter, which cost ~5% of the GPU thread when on. --- src/video_core/gpu_thread.cpp | 13 ++++++++++++- src/video_core/nx2_profile.h | 7 ++++--- 2 files changed, 16 insertions(+), 4 deletions(-) (limited to 'src/video_core/gpu_thread.cpp') diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp index cf4e159806..72da1904e1 100644 --- a/src/video_core/gpu_thread.cpp +++ b/src/video_core/gpu_thread.cpp @@ -39,6 +39,8 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten auto current_context = context.Acquire(); CommandDataContainer next; + // When a pending channel last moved or a command arrived (see the polling below). + auto last_progress = std::chrono::steady_clock::now(); namespace Profile = VideoCommon::Nx2Profile; while (!stop_token.stop_requested()) { Profile::Report(); @@ -49,8 +51,16 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten scheduler.ResumePending(system.GPU()); } if (!state.queue.TryPop(next)) { + // The CPU usually releases the semaphore soon after: poll for half a + // millisecond before sleeping, or every wait also waits out a host sleep + // (title 1 waits ~25 times a second). Profile::Scope scope{Profile::Zone::PendingSleep}; - std::this_thread::sleep_for(std::chrono::milliseconds{1}); + if (std::chrono::steady_clock::now() - last_progress < + std::chrono::microseconds{500}) { + std::this_thread::yield(); + } else { + std::this_thread::sleep_for(std::chrono::milliseconds{1}); + } continue; } } else { @@ -60,6 +70,7 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten if (stop_token.stop_requested()) { break; } + last_progress = std::chrono::steady_clock::now(); if (auto* submit_list = std::get_if(&next.data)) { Profile::Scope scope{Profile::Zone::Push}; scheduler.Push(system.GPU(), submit_list->channel, std::move(submit_list->entries)); diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h index 051403c982..a162ce16a3 100644 --- a/src/video_core/nx2_profile.h +++ b/src/video_core/nx2_profile.h @@ -10,6 +10,7 @@ #include "common/common_types.h" #include "common/logging.h" +#include "common/cpu_features.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. @@ -61,10 +62,10 @@ inline bool Enabled() { return enabled; } +// Nanoseconds; from the host's own counter where the wall clock reads it directly (the zones +// run ~150,000 times a second, and QueryPerformanceCounter cost ~5% of the GPU thread). inline u64 Now() { - return static_cast(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count()); + return static_cast(Common::g_wall_clock.GetTimeNS().count()); } struct ThreadData { -- cgit v1.2.3