#include "gpu_prof.hpp" #include "../internal.hpp" #include "gpu.hpp" #include #ifdef TRACY_ENABLE #include #include #include #include #include #include #include #include #include #include #include #include namespace aurora::webgpu::gpu_prof { namespace { Module Log("aurora::webgpu::gpu_prof"); // Each zone consumes a begin/end timestamp pair; the last pair is reserved // for the frame zone. The readback ring is deep enough that a slot is // normally free again by the time its turn comes around; frames that catch // their slot still in flight are skipped. constexpr uint32_t MaxZones = 127; constexpr uint32_t QueryCount = MaxZones * 2 + 2; constexpr uint32_t FrameBeginQuery = MaxZones * 2; constexpr uint32_t FrameEndQuery = MaxZones * 2 + 1; constexpr uint64_t ReadbackSize = QueryCount * sizeof(uint64_t); constexpr size_t RingDepth = 4; constexpr uint8_t ContextId = 0; enum class EventKind : uint8_t { ZoneBegin, PassBegin, End, }; struct Event { const char* name; // interned, program lifetime; nullptr for End uint32_t query; EventKind kind; }; enum class SlotState : uint8_t { Free, Recording, InFlight, Mapped, Failed, }; struct Slot { wgpu::Buffer readback; std::vector events; uint32_t passCount = 0; int64_t submitNs = 0; // Written by the MapAsync callback, which fires on whichever thread pumps // instance events; read by after_submit on the encoding thread. std::atomic state{SlotState::Free}; }; bool g_enabled = false; bool g_encoderZones = false; wgpu::QuerySet g_querySet; wgpu::Buffer g_resolveBuffer; std::array g_slots; size_t g_recordSlot = 0; size_t g_emitSlot = 0; bool g_frameActive = false; bool g_framePending = false; uint32_t g_zoneCount = 0; bool g_contextEmitted = false; uint16_t g_queryId = 0; uint64_t g_lastEmittedTs = 0; uint64_t g_lastFrameEnd = 0; int64_t now_ns() { return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) .count(); } // Tracy keeps references to zone names; intern them with program lifetime. const char* intern_name(std::string_view name) { static absl::flat_hash_map names; const auto it = names.find(name); if (it != names.end()) { return it->second; } char* stable = new char[name.size() + 1]; std::memcpy(stable, name.data(), name.size()); stable[name.size()] = '\0'; names.emplace(name, stable); return stable; } uint8_t tracy_context_type(wgpu::BackendType backend) { // tracy::GpuContextType values; the enum isn't exposed through TracyC.h. switch (backend) { case wgpu::BackendType::OpenGL: case wgpu::BackendType::OpenGLES: return 1; // OpenGl case wgpu::BackendType::Vulkan: return 2; // Vulkan case wgpu::BackendType::D3D12: return 4; // Direct3D12 case wgpu::BackendType::D3D11: return 5; // Direct3D11 case wgpu::BackendType::Metal: return 6; // Metal default: return 7; // Custom } } void emit_context(const Slot& slot, uint64_t frameBegin) { // Tracy has no notion of WebGPU's opaque timestamp epoch, so the context // anchor pairs a GPU timestamp with "now" at emission. Shift the timestamp // by the time elapsed since this frame was submitted, so the frame zone // lands at the submit point on the timeline instead of trailing it by the // readback latency. Residual error is the GPU's submit-to-execute delay. // Zone widths and gaps are exact regardless; only the track offset (and, // on hosts where CPU and GPU clocks drift, long-capture alignment) is // approximate. WebGPU timestamps are nanoseconds on every backend. const int64_t anchor = int64_t(frameBegin) + (now_ns() - slot.submitNs); ___tracy_emit_gpu_new_context_serial({ .gpuTime = anchor, .period = 1.0f, .context = ContextId, .flags = 0, .type = tracy_context_type(g_backendType), }); const auto& info = adapter_info(); const std::string name = fmt::format("{} ({})", std::string_view{info.device}, magic_enum::enum_name(g_backendType)); ___tracy_emit_gpu_context_name_serial({ .context = ContextId, .name = name.c_str(), .len = uint16_t(std::min(name.size(), UINT16_MAX)), }); } void emit_zone_begin(const char* name, uint64_t gpuNs) { const uint64_t srcloc = ___tracy_alloc_srcloc_name(0, "aurora", 6, "gpu_prof", 8, name, std::strlen(name), 0); const uint16_t queryId = g_queryId++; ___tracy_emit_gpu_zone_begin_alloc_serial({.srcloc = srcloc, .queryId = queryId, .context = ContextId}); ___tracy_emit_gpu_time_serial({.gpuTime = int64_t(gpuNs), .queryId = queryId, .context = ContextId}); } void emit_zone_end(uint64_t gpuNs) { const uint16_t queryId = g_queryId++; ___tracy_emit_gpu_zone_end_serial({.queryId = queryId, .context = ContextId}); ___tracy_emit_gpu_time_serial({.gpuTime = int64_t(gpuNs), .queryId = queryId, .context = ContextId}); } void emit_frame(Slot& slot) { const auto* ts = static_cast(slot.readback.GetConstMappedRange(0, ReadbackSize)); if (ts == nullptr) { return; } // Frame bounds; fall back to the recorded zones when encoder-level // timestamps are unavailable. uint64_t frameBegin = g_encoderZones ? ts[FrameBeginQuery] : 0; uint64_t frameEnd = g_encoderZones ? ts[FrameEndQuery] : 0; if (frameBegin == 0 || frameEnd == 0) { for (const auto& event : slot.events) { const uint64_t t = ts[event.query]; if (t == 0) { continue; } if (frameBegin == 0 || t < frameBegin) { frameBegin = t; } if (t > frameEnd) { frameEnd = t; } } } if (frameBegin == 0 || frameEnd <= frameBegin) { return; } if (!g_contextEmitted) { const uint64_t lastFrameEnd = std::exchange(g_lastFrameEnd, frameEnd); // In on-demand builds events sent before a profiler connects are // dropped, but the context itself is deferred and replayed on // (re)connect, so it only needs to be emitted once. if (!TracyIsConnected) { return; } // Timestamp warm-up: some backends report a bogus epoch for the first // frames (Dawn re-correlates its Metal CPU/GPU timestamp mapping after // startup). Only anchor the context once two consecutive frames are // mutually consistent, discarding frames until then. constexpr uint64_t SaneFrameGapNs = UINT64_C(5'000'000'000); if (lastFrameEnd == 0 || frameBegin < lastFrameEnd || frameBegin - lastFrameEnd >= SaneFrameGapNs) { return; } emit_context(slot, frameBegin); g_contextEmitted = true; } // Tracy requires GPU zones within a context to be properly nested in // time, and treats large backward jumps as timer wraparound. The recorded // events mirror encode order, which matches GPU execution order, but // individual timestamps can still misbehave (driver bugs, resets); clamp // to keep the stream monotonic — bogus zones shrink to zero width instead // of corrupting the track. uint64_t prev = std::max(g_lastEmittedTs, frameBegin); const uint64_t endBound = std::max(frameEnd, prev); const auto clamped = [&prev, endBound](uint64_t t) { prev = std::min(std::max(t, prev), endBound); return prev; }; emit_zone_begin(intern_name("Frame"), clamped(frameBegin)); uint32_t depth = 0; uint64_t topLevelEnd = prev; uint64_t idleNs = 0; // Zones whose timestamps were never written resolve to 0 (e.g. Metal on // Apple GPUs cannot sample encoder-level timestamps); drop those as // begin/end pairs to keep the stream balanced. std::bitset dropped; for (const auto& event : slot.events) { if (event.kind == EventKind::End) { if (dropped[event.query / 2]) { continue; } const uint64_t t = clamped(ts[event.query]); emit_zone_end(t); if (--depth == 0) { topLevelEnd = t; } } else { if (ts[event.query] == 0 && ts[event.query + 1] == 0) { dropped[event.query / 2] = true; continue; } const uint64_t t = clamped(ts[event.query]); if (depth++ == 0 && t > topLevelEnd) { idleNs += t - topLevelEnd; } emit_zone_begin(event.name, t); } } const uint64_t end = clamped(frameEnd); if (end > topLevelEnd) { idleNs += end - topLevelEnd; } emit_zone_end(end); g_lastEmittedTs = prev; TracyPlot("aurora: gpuFrameMs", double(frameEnd - frameBegin) * 1e-6); TracyPlot("aurora: gpuIdleMs", double(idleNs) * 1e-6); TracyPlot("aurora: gpuPasses", int64_t(slot.passCount)); } Slot& record_slot() { return g_slots[g_recordSlot]; } uint32_t alloc_zone() { if (!g_frameActive || g_zoneCount >= MaxZones) { return UINT32_MAX; } return g_zoneCount++; } } // namespace void initialize() { g_enabled = g_device.HasFeature(wgpu::FeatureName::TimestampQuery); if (!g_enabled) { Log.info("Timestamp queries unsupported; GPU profiling disabled"); return; } // Dawn only allows encoder-level WriteTimestamp with allow_unsafe_apis, // which gpu.cpp enables in Tracy builds. #ifdef WEBGPU_DAWN g_encoderZones = true; #endif const wgpu::QuerySetDescriptor querySetDescriptor{ .label = "GPU profiler timestamps", .type = wgpu::QueryType::Timestamp, .count = QueryCount, }; g_querySet = g_device.CreateQuerySet(&querySetDescriptor); const wgpu::BufferDescriptor resolveDescriptor{ .label = "GPU profiler resolve", .usage = wgpu::BufferUsage::QueryResolve | wgpu::BufferUsage::CopySrc, .size = ReadbackSize, }; g_resolveBuffer = g_device.CreateBuffer(&resolveDescriptor); const wgpu::BufferDescriptor readbackDescriptor{ .label = "GPU profiler readback", .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, .size = ReadbackSize, }; for (auto& slot : g_slots) { slot.readback = g_device.CreateBuffer(&readbackDescriptor); slot.events.reserve(MaxZones * 2); slot.state = SlotState::Free; } g_recordSlot = 0; g_emitSlot = 0; g_framePending = false; TracyPlotConfig("aurora: gpuFrameMs", tracy::PlotFormatType::Number, false, true, 0); TracyPlotConfig("aurora: gpuIdleMs", tracy::PlotFormatType::Number, false, true, 0); TracyPlotConfig("aurora: gpuPasses", tracy::PlotFormatType::Number, true, true, 0); Log.info("GPU profiling enabled ({} zones max)", MaxZones); } void shutdown() { g_querySet = {}; g_resolveBuffer = {}; for (auto& slot : g_slots) { slot.readback = {}; slot.events.clear(); slot.passCount = 0; slot.state = SlotState::Free; } g_enabled = false; g_encoderZones = false; g_frameActive = false; g_framePending = false; } void frame_begin(const wgpu::CommandEncoder& encoder) { if (!g_enabled) { return; } auto& slot = record_slot(); if (slot.state != SlotState::Free) { g_frameActive = false; return; } slot.state = SlotState::Recording; slot.events.clear(); slot.passCount = 0; g_zoneCount = 0; g_frameActive = true; if (g_encoderZones) { encoder.WriteTimestamp(g_querySet, FrameBeginQuery); } } void frame_end(const wgpu::CommandEncoder& encoder) { if (!g_enabled || !g_frameActive) { return; } g_frameActive = false; auto& slot = record_slot(); if (slot.events.empty() && !g_encoderZones) { slot.state = SlotState::Free; return; } if (g_encoderZones) { encoder.WriteTimestamp(g_querySet, FrameEndQuery); } // Resolve the full set (unwritten queries resolve to 0); partial resolve // destinations must be 256-byte aligned, so one full resolve is simpler // than splitting out the used range. encoder.ResolveQuerySet(g_querySet, 0, QueryCount, g_resolveBuffer, 0); encoder.CopyBufferToBuffer(g_resolveBuffer, 0, slot.readback, 0, ReadbackSize); g_framePending = true; } void after_submit() { if (!g_enabled) { return; } if (g_framePending) { g_framePending = false; auto& slot = record_slot(); slot.submitNs = now_ns(); slot.state = SlotState::InFlight; slot.readback.MapAsync(wgpu::MapMode::Read, 0, ReadbackSize, wgpu::CallbackMode::AllowProcessEvents, [&slot](wgpu::MapAsyncStatus status, wgpu::StringView) { slot.state = status == wgpu::MapAsyncStatus::Success ? SlotState::Mapped : SlotState::Failed; }); g_recordSlot = (g_recordSlot + 1) % RingDepth; } // Map callbacks only fire while instance events are pumped; the rest of // aurora only pumps while blocked on staging buffer maps, which can be // never. Without this the ring fills after RingDepth frames and profiling // stops. Emission happens here, on the encoding thread, so frames are // emitted in submission order even if a callback fires from another // thread's pump. g_instance.ProcessEvents(); while (true) { auto& slot = g_slots[g_emitSlot]; const auto state = slot.state.load(std::memory_order_acquire); if (state == SlotState::Mapped) { emit_frame(slot); slot.readback.Unmap(); } else if (state != SlotState::Failed) { break; } slot.state.store(SlotState::Free, std::memory_order_release); g_emitSlot = (g_emitSlot + 1) % RingDepth; } } const wgpu::PassTimestampWrites* pass_writes(std::string_view name) { const uint32_t index = alloc_zone(); if (index == UINT32_MAX) { return nullptr; } auto& slot = record_slot(); slot.events.push_back({intern_name(name), index * 2, EventKind::PassBegin}); slot.events.push_back({nullptr, index * 2 + 1, EventKind::End}); ++slot.passCount; // Rotating storage so a returned pointer stays valid while another pass // descriptor is being built. static std::array writes; static size_t writesIndex = 0; auto& out = writes[writesIndex++ % writes.size()]; out = { .querySet = g_querySet, .beginningOfPassWriteIndex = index * 2, .endOfPassWriteIndex = index * 2 + 1, }; return &out; } Zone::Zone(const wgpu::CommandEncoder& encoder, std::string_view name) { if (!g_encoderZones) { return; } const uint32_t index = alloc_zone(); if (index == UINT32_MAX) { return; } record_slot().events.push_back({intern_name(name), index * 2, EventKind::ZoneBegin}); encoder.WriteTimestamp(g_querySet, index * 2); m_encoder = &encoder; m_endQuery = index * 2 + 1; } Zone::~Zone() { if (m_encoder == nullptr) { return; } record_slot().events.push_back({nullptr, m_endQuery, EventKind::End}); m_encoder->WriteTimestamp(g_querySet, m_endQuery); } } // namespace aurora::webgpu::gpu_prof #else namespace aurora::webgpu::gpu_prof { void initialize() {} void shutdown() {} void frame_begin(const wgpu::CommandEncoder&) {} void frame_end(const wgpu::CommandEncoder&) {} void after_submit() {} const wgpu::PassTimestampWrites* pass_writes(std::string_view) { return nullptr; } Zone::Zone(const wgpu::CommandEncoder&, std::string_view) {} Zone::~Zone() = default; } // namespace aurora::webgpu::gpu_prof #endif