diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 30b60aad..56eb9298 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -1538,6 +1538,13 @@ namespace MobileGL::MG_Backend::DirectGLES { BindBufferId(TempBufferTarget, reused); g_GLESFuncs.glBufferSubData(TempBufferTarget, 0, (GLsizeiptr)poolSize, bufferObject->MappedData()); + if (MG_Util::PipeStats::Enabled()) { + // The pool-recycle reseed is a whole-buffer upload on the hot path, + // not a bookkeeping detail: it moves the same bytes a fresh + // glBufferData would. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(poolSize)); + } { const std::lock_guard lock(resource->pendingMutex); resource->pendingRanges.clear(); @@ -2695,6 +2702,12 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(converted.size() * sizeof(Float)), converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + // The VBO-backed half of the 64-bit narrowing. Same population as the + // client-array half above: a stream the backend synthesises per draw. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } stream.valid = true; stream.sourceLifetimeId = sourceLifetimeId; stream.sourceChangeSerial = sourceChangeSerial; diff --git a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp index d3dde2ce..9d9f012d 100644 --- a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp +++ b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp @@ -9,6 +9,7 @@ #include "MultiDraw.h" #include "Managers.h" #include +#include #include #include @@ -156,7 +157,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // are bound as storage blocks. Respecifies rather than sub-updates: glBufferData // orphans the previous store, so the upload never waits on a dispatch still reading // the old contents out of the same name. - Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data) { + // statsClass: which MGPipe byte population these bytes belong to. Counted here + // rather than at the four call sites so a new tier cannot forget it. + Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass) { if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id); @@ -169,6 +173,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { buffer.cursor = 0; if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } return true; } @@ -183,7 +190,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { constexpr SizeT kRingAlignment = 16; // >= 4, so both command and uint32-index offsets stay legal constexpr SizeT kMinRingBytes = 1u << 16; - Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, SizeT& outOffset) { + Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass, SizeT& outOffset) { outOffset = 0; if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; @@ -207,6 +215,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast(outOffset), static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } buffer.cursor += aligned; return true; @@ -417,7 +428,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { const SizeT commandBytes = g_commandStaging.size() * sizeof(DrawElementsIndirectCommand); SizeT commandBase = 0; - if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), commandBase)) { + if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd, commandBase)) { return false; } @@ -532,7 +544,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { } SizeT indexBase = 0; - if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), indexBase)) { + if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndexClient, indexBase)) { return false; } @@ -737,10 +750,16 @@ void main() { if (total == 0) return; // nothing to draw; the ordinary tiers no-op just as well if (!EnsureComputeProgram()) return; - if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data())) { + if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd)) { + return; + } + // data == nullptr: pure respecify, the compute pass writes the contents, so no + // host bytes cross here and nothing is counted. + if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr, + MG_Util::PipeStats::ByteClass::StageIndexClient)) { return; } - if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr)) return; BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, sourceResource->id); BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 1, g_drawInfo.id); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index a34a5c9f..de816301 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -2059,14 +2059,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { } out.payload = outData; out.payloadSize = outSize; - if (MG_Util::PipeStats::Enabled()) { - // D-B8: these are the bytes Magma repacks into its own UBO ring, i.e. exactly - // the host payload a split build would have to ship with set_shader_buffers. - // Espryt binds the frontend buffer to the driver and contributes nothing here, - // which is why the class is named for the payload and not for the call. - MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, - static_cast(outSize)); - } // Zero-copy direct bind: for a persistent-mapped coherent app buffer whose full reflected // block fits within the aligned bound range, point the descriptor straight at the app's @@ -2085,6 +2077,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { out.dynamicOffset = rangeStart; } } + if (MG_Util::PipeStats::Enabled() && !out.directBindable) { + // D-B8: the bytes Magma repacks into its own UBO ring, i.e. exactly the host + // payload a split build would have to ship with set_shader_buffers. Espryt binds + // the frontend buffer to the driver and contributes nothing here, which is why + // the class is named for the payload and not for the call. Counted AFTER the + // zero-copy direct-bind decision: a direct bind repacks nothing, and counting it + // here reported a copy that never happened. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, + static_cast(outSize)); + } return true; } @@ -2292,6 +2294,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { outBuffer = slice.buffer; outRange = ubo.payloadSize; outDynamicOffset = static_cast(slice.offset); + if (isGlobalUbo && MG_Util::PipeStats::Enabled()) { + // Magma's half of stage-ubo-global, so the class means the same on both + // backends. The memo hit above returns before this, so a frame that reuses the + // slice correctly contributes nothing. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(ubo.payloadSize)); + } if (isGlobalUbo) { m_globalUboMemo[m_globalUboMemoNext] = GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index 29f214d7..47394b3f 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -10,6 +10,8 @@ #include "../DirectVulkan.h" #include "VulkanRenderer.h" +#include "MG_Util/Metrics/PipeStats.h" + namespace MobileGL::MG_Backend::DirectVulkan { namespace { constexpr VmaAllocationCreateFlags kResidentBufferAllocationFlags = @@ -229,8 +231,38 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VkBufferManager::UploadTransient(BufferKind kind, Uint32 frameIndex, const void* data, VkDeviceSize size, VkDeviceSize alignment, BufferSlice& outSlice) { - (void)kind; - return m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice); + if (!m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice)) { + return false; + } + if (MG_Util::PipeStats::Enabled()) { + // The single chokepoint for Magma's per-draw staging. Uniform is deliberately + // absent: its bytes are counted by the caller, which is the only place that + // knows whether the payload is the default block (stage-ubo-global) or a named + // one repacked into the ring (stage-ubo-named), and counting here as well would + // double every uniform byte. + switch (kind) { + case BufferKind::Vertex: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(size)); + break; + case BufferKind::Index: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient, + static_cast(size)); + break; + case BufferKind::Indirect: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndirectCmd, + static_cast(size)); + break; + case BufferKind::TextureBuffer: + case BufferKind::ShaderStorage: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + break; + case BufferKind::Uniform: + break; + } + } + return true; } Bool VkBufferManager::InitializeTransientArenas() { @@ -339,6 +371,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource.pendingFullUpload = true; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource.pendingFullUpload = false; return true; } @@ -353,6 +388,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), 16, staging)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + // The staging fill is the host copy; the vkCmdCopyBuffer below is the device + // half of the same bytes and is not counted twice. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } VkCommandBuffer commandBuffer = m_copyProvider->AcquireBufferCopyCommandBuffer(); if (commandBuffer == VK_NULL_HANDLE) { return false; @@ -422,6 +462,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (!resource->buffer.Upload(bufferObject.MappedData(), size, 0)) { MGLOG_E_ONCE("VkBufferManager::OnRespecify: in-place upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); } } @@ -447,6 +489,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnSubData: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -484,6 +529,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnFlushMappedRange: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -554,6 +602,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint8* seed = bufferObject.MappedData(); if (seed != nullptr) { resource->buffer.Upload(seed, size, 0); + if (MG_Util::PipeStats::Enabled()) { + // The one-time seed of a persistent map. Everything the app writes AFTER + // this goes straight through the mapping and is persistent-map-push + // territory (unwired, D4/D-B4), not this class. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } } resource->persistentMapped = true; resource->pendingFullUpload = false; @@ -602,6 +657,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource->usageFlags = 0; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } resource->pendingFullUpload = false; } @@ -681,6 +740,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { outSlice)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource->transientSlice = outSlice; resource->transientFrameSerial = m_frameSerial; resource->transientChangeSerial = changeSerial; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 222e7cd9..0335f23b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -13,6 +13,7 @@ #include "MG_State/GLState/Core.h" #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include #include @@ -3150,6 +3151,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { packBox(dst, item.regionLo, item.regionSize); } + if (MG_Util::PipeStats::Enabled()) { + // Same shape split as Espryt's: one union box per item, or one job per rect of + // a refined rect list. The box/rect decision is invisible to SSIM and is what + // the +6 ms/frame Mali cliff of section 7.3 was, so it is counted apart from + // the bytes. + Uint64 boxEmissions = 0; + Uint64 rectEmissions = 0; + Uint64 jobs = 0; + for (const auto& item : uploadItems) { + if (item.rects.empty()) { + ++boxEmissions; + jobs += isCombinedDepthStencil ? 2u : 1u; + } else { + ++rectEmissions; + jobs += static_cast(item.rects.size()); + } + } + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture, + static_cast(stagingSize)); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadEmissions, + static_cast(uploadItems.size())); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadBoxEmissions, boxEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadRectEmissions, rectEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, jobs); + } + const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format); VkPipelineStageFlags uploadSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags uploadSrcAccessMask = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 80d69b49..e365603d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -5168,17 +5168,6 @@ void main() { syntheticVertexInputState.pNext = vis.state.pNext; pipelineVertexInputState = &syntheticVertexInputState; } - if (MG_Util::PipeStats::Enabled()) { - // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo - // miss. Counted as a constant: the unconditional accessor reads between here - // and the end of the payload build (the six capability reads, the draw-FBO - // slot, the two stencil faces, the polygon mode, sample shading + min sample - // shading, patch vertices, the depth mask and the depth func, and the second - // draw-FBO slot read). Reads that are themselves conditional - the cull-mode - // ternary, the logic-op fetch, the two tessellation default-level reads - are - // deliberately excluded, so this stays a LOWER bound like every other tally. - MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); - } auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace); auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest); auto polygonOffsetFillEnabled = @@ -5264,6 +5253,24 @@ void main() { return VK_NULL_HANDLE; } + if (MG_Util::PipeStats::Enabled()) { + // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo miss. + // Counted as a constant, and counted HERE rather than at the top of the walk: + // the list-topology primitive-restart refusal above returns VK_NULL_HANDLE after + // only ten of these reads have run, and a tally that fires before an early return + // is an OVER-count, which breaks the lower-bound contract every other tally keeps. + // + // The 15 are: the six capability reads (cull face, depth test, polygon offset + // fill, rasterizer discard, colour logic op, stencil test), the draw-FBO slot + // read that gates depth/stencil, the two stencil face states, the polygon mode, + // the min sample shading value, the patch vertex count, the depth mask, the depth + // func, and the second draw-FBO slot read below. The sample-shading CAPABILITY + // read is the one excluded: it sits behind && on m_sampleRateShadingFeatureEnabled + // and does not run on a device without the feature. The other conditional reads - + // the cull-mode ternary, the logic-op fetch, the two tessellation default-level + // reads - are excluded for the same reason, so this stays a LOWER bound. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); + } PipelineFactory::PipelineCreatePayload payload { .programHash = programObj.hash, .vertexInputHash = vertexLayoutHash, diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 97e8bbcf..4d329ed7 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -56,6 +56,7 @@ namespace { // Every other class untouched, the residual-value-block placeholder included. EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboGlobal), 0u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboNamed), 0u); + EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageIndirectCmd), 0u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::ResidualValueBlock), 0u); } @@ -123,12 +124,12 @@ namespace { PS::AddBytes(PS::ByteClass::StageBuffer, 4096); PS::OnPresent(); - const String line = PS::FormatSummaryLine(); + const String line = PS::FormatWindowLine(); EXPECT_NE(line.find("MGPipe stats:"), String::npos) << line; EXPECT_NE(line.find("draws=4"), String::npos) << line; // 50 accessor calls over 4 draws, two decimals, no . EXPECT_NE(line.find("acc/draw=12.50"), String::npos) << line; - EXPECT_NE(line.find("buf=4096"), String::npos) << line; + EXPECT_NE(line.find("buf=4096.00"), String::npos) << line; for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { EXPECT_NE(line.find("="), String::npos); } @@ -136,26 +137,82 @@ namespace { EXPECT_NE(line.find("tex[emit="), String::npos) << line; } + // Per-frame fields carry two decimals for the same reason acc/draw does: they are small + // and load-bearing (bytes/f sizes SEG_STAGE), and integer division silently rounds a + // whole unit off each of them. 26 draws over 14 frames is 1.86, not 1. + TEST_F(PipeStatsTest, PerFrameFieldsKeepTwoDecimals) { + PS::AddCalls(PS::CallClass::Draws, 26); + PS::AddBytes(PS::ByteClass::StageBuffer, 1360); + for (Uint32 i = 0; i < 14; ++i) { + PS::OnPresent(); + } + + const String line = PS::FormatWindowLine(); + EXPECT_NE(line.find("draws/f=1.86"), String::npos) << line; + EXPECT_NE(line.find("buf=97.14"), String::npos) << line; + } + // Successive summaries report WINDOWS, not run totals: a run total over a workload that // changes shape (load, then steady state) averages away the very number section 2.3.1 - // wants. + // wants. Advancing the window is an explicit call, not a side effect of formatting. TEST_F(PipeStatsTest, SummaryLinesReportDisjointWindows) { PS::AddCalls(PS::CallClass::Draws, 10); PS::OnPresent(); - const String first = PS::FormatSummaryLine(); + const String first = PS::FormatWindowLine(); EXPECT_NE(first.find("draws=10"), String::npos) << first; + PS::AdvanceSummaryWindow(); PS::AddCalls(PS::CallClass::Draws, 3); PS::OnPresent(); - const String second = PS::FormatSummaryLine(); + const String second = PS::FormatWindowLine(); EXPECT_NE(second.find("draws=3"), String::npos) << second; EXPECT_NE(second.find("frames=2"), String::npos) << second; } - TEST_F(PipeStatsTest, SummaryLineSurvivesZeroDraws) { + // FormatWindowLine is pure. It used to rewrite the window bases as a side effect of + // formatting, so any second reader - a probe, a test, a second reporting channel - + // silently zeroed the next window. + TEST_F(PipeStatsTest, FormattingTwiceDoesNotConsumeTheWindow) { + PS::AddCalls(PS::CallClass::Draws, 7); PS::OnPresent(); - const String line = PS::FormatSummaryLine(); - EXPECT_NE(line.find("acc/draw=0.00"), String::npos) << line; + + const String first = PS::FormatWindowLine(); + const String second = PS::FormatWindowLine(); + EXPECT_EQ(first, second) << first << "\n" << second; + EXPECT_NE(second.find("draws=7"), String::npos) << second; + + // ...and advancing explicitly does close it. + PS::AdvanceSummaryWindow(); + const String third = PS::FormatWindowLine(); + EXPECT_NE(third.find("draws=0"), String::npos) << third; + } + + TEST_F(PipeStatsTest, SummaryLineSurvivesZeroDraws) { + PS::AddCalls(PS::CallClass::AccessorCalls, 12); + PS::OnPresent(); + const String line = PS::FormatWindowLine(); + // No draw in the window means there is no per-draw number - and "0.00" beside a + // non-zero acc= would read as one. + EXPECT_NE(line.find("acc/draw=n/a"), String::npos) << line; + EXPECT_NE(line.find("acc=12"), String::npos) << line; + } + + // A window with no Present in it has no per-frame reading at all. This used to divide by + // a faked 1 and print the window TOTALS under a "/f" label: a scenario slice that draws + // 47 times and never presents reported 1,404,550 staged bytes as a per-frame figure, + // which is a 47x overstatement of the SEG_STAGE sizing input this package exists to + // produce. + TEST_F(PipeStatsTest, SummaryLineSurvivesZeroFrames) { + PS::AddCalls(PS::CallClass::Draws, 47); + PS::AddBytes(PS::ByteClass::StageBuffer, 1404550); + + const String line = PS::FormatWindowLine(); + EXPECT_EQ(PS::FrameCount(), 0u); + EXPECT_NE(line.find("window=0"), String::npos) << line; + EXPECT_NE(line.find("draws/f=n/a"), String::npos) << line; + // The bracket is relabelled rather than divided: totals, and marked as totals. + EXPECT_EQ(line.find("bytes/f["), String::npos) << line; + EXPECT_NE(line.find("bytes[buf=1404550"), String::npos) << line; } TEST_F(PipeStatsTest, JsonDumpNamesEveryCounter) { @@ -191,6 +248,7 @@ namespace { EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboNamed), "stage-ubo-named"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageVertexClient), "stage-vertex-client"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndexClient), "stage-index-client"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 1fada1b1..e1212315 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -15,42 +15,69 @@ // --------------------------------------------------------------------------------------- // SITE INVENTORY - what these counters DO and DO NOT cover. // +// This list is the contract. A byte class that reads 0 while a real copy runs uncounted is +// worse than a missing counter, because the zero is then read as an answer, so every path +// that moves bytes and is NOT wired is named here by file and function. +// // Byte classes -// stage-buffer DirectGLES Managers.cpp: RespecifyStorageNow's glBufferData, -// FlushPendingRangesNow's three shapes (map-write, glBufferSubData, -// upload-ring stage). Covers every byte Espryt hands the driver for -// a buffer object's contents. -// NOT covered: DirectVulkan's own buffer staging (its buffer bytes -// reach the GPU through a persistent map the frontend already owns, -// so there is no second copy to count) - see the note on +// stage-buffer ESPRYT (DirectGLES Managers.cpp): RespecifyStorageNow's +// glBufferData, FlushPendingRangesNow's three shapes (map-write, +// glBufferSubData, upload-ring stage), and the pool-recycle reseed +// in SyncBufferObject. +// MAGMA (DirectVulkan VkBufferManager.cpp): every host->device copy +// of a buffer object's contents - SwapStorageAndUploadAll, the +// StagedRangeCopy staging fill, the in-place uploads in OnRespecify / +// OnSubData / OnFlushMappedRange, the AcquirePersistentMap seed, the +// AcquireResidentSlice initial upload and the AcquireStreamedSlice +// arena fill. +// NOT covered: bytes an app writes THROUGH a persistent map. Those +// never pass through either backend (D4/D-B4) - see // persistent-map-push. -// stage-texture DirectGLES Managers.cpp texture upload: the bytes of whichever of +// stage-texture ESPRYT (Managers.cpp texture upload): the bytes of whichever of // the three upload shapes ran (rect list / union box / whole level). -// NOT covered: the DirectVulkan texture staging path, and Espryt's -// compressed-texture and readback paths. -// stage-ubo-global DirectGLES.cpp default-uniform-block image, both the UBO-ring -// memcpy and the glBufferSubData fallback. +// MAGMA (VkTextureManager.cpp): the packed staging slice of an +// upload batch item set. +// NOT covered: Espryt's compressed-texture path, and both backends' +// readback (device->host) paths, which are a different direction and +// want their own class when the reverse channel of section 7 exists. +// stage-ubo-global ESPRYT (DirectGLES.cpp): the default-uniform-block image, both the +// UBO-ring memcpy and the glBufferSubData fallback. +// MAGMA (UniformManager::ResolveDynamicUboDescriptor): the same +// image, counted after the per-frame slice memo, so a frame that +// re-uses the slice correctly contributes nothing. // stage-ubo-named DirectVulkan UniformManager::ResolveUniformBufferPayload - the -// bytes Magma repacks into its own UBO ring. Espryt contributes -// nothing by construction (D-B8). -// stage-vertex-client DirectGLES BackendVertexArrayObject::SyncClientSideAttributesFor- -// DrawArrays, both the Float64-narrowing and the verbatim shapes. -// NOT covered: the DirectVulkan converted-vertex-stream cache. -// stage-index-client DirectGLES index rewriting (the primitive-restart substitution -// buffer). -// NOT covered: DirectVulkan's index staging. +// bytes Magma repacks into its own UBO ring, counted AFTER the +// zero-copy direct-bind decision (a direct bind repacks nothing). +// Espryt contributes nothing by construction (D-B8). +// stage-vertex-client ESPRYT: BackendVertexArrayObject::SyncClientSideAttributesFor- +// DrawArrays (both the Float64-narrowing and the verbatim shapes) +// and the VBO-backed Float64->Float32 narrowing scratch upload. +// MAGMA: VkBufferManager::UploadTransient(BufferKind::Vertex), which +// is the single chokepoint for the converted-vertex-stream and +// client-array staging. +// stage-index-client ESPRYT: the primitive-restart substitution buffer, and MultiDraw's +// rewritten (rebased) index stream. +// MAGMA: VkBufferManager::UploadTransient(BufferKind::Index). +// stage-indirect-cmd ESPRYT MultiDraw.cpp: the DrawElementsIndirectCommand array staged +// for the indirect tiers, and the compute tier's per-draw info +// array. Kept out of stage-index-client because these are draw +// PARAMETERS - the population that becomes MGPipe command-record +// payload, not resource bytes. +// NOT covered: Magma builds no such array (it issues one vkCmdDraw* +// per sub-draw), so this class is Espryt-only by construction. // persistent-map-push Not wired in P0: today a persistent map is a permanent address // space donation (D4/D-B4) that survives the whole monolith track, // so there is no push to count until the IPC track breaks it. // residual-value-block Placeholder, always 0 until P2 (plan section 6.3). // // Call classes -// draws DirectGLES PrepareForDraw and DirectVulkan TrySetupDrawFastPath's -// caller-visible entry. A dispatch is not a draw and is not counted. +// draws DirectGLES PrepareForDraw and DirectVulkan SetupDraw's entry. A +// dispatch is not a draw and is not counted. // accessor-calls STATIC TALLIES at the instrumented entry points, NOT a wrapper // around all 293 pGLContext-> sites. Each instrumented function adds // the number of GLContext accessor calls that its OWN body executed -// on the path taken. Covered: PrepareForDraw's own reads, +// on the path taken, and each tally sits AFTER the last early return +// that would skip those reads. Covered: PrepareForDraw's own reads, // SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindings- // Epoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath, // GetOrCreatePipeline and ApplyDynamicDrawStateTail. NOT covered: @@ -59,7 +86,7 @@ // memo miss), and every non-draw entry point. The number is // therefore a LOWER BOUND on the per-draw accessor count, and it is // the bound over exactly the six gates section 2.3.1 tabulates. -// texture-* DirectGLES texture upload, per (target, level) emission. +// texture-* Per (target, level) emission, both backends. // // Gates: the six of section 2.3.1, each counted exactly once per probe. // @@ -122,9 +149,24 @@ namespace MobileGL::MG_Util::PipeStats { return bucket; } + // Two decimals without . Every per-frame and per-draw field in the summary + // goes through this: the numbers are small (a per-draw accessor count in the 10-25 + // band, a per-frame byte count that sizes SEG_STAGE), so truncating integer division + // loses up to a whole unit on exactly the figures the package exists to produce. + // A zero denominator is "n/a" rather than a division by a faked 1. + String FormatFixed2(Uint64 numerator, Uint64 denominator) { + if (denominator == 0) { + return "n/a"; + } + const Uint64 hundredths = (numerator * 100 + denominator / 2) / denominator; + return std::to_string(hundredths / 100) + "." + (hundredths % 100 < 10 ? "0" : "") + + std::to_string(hundredths % 100); + } + const char* const kByteClassNames[kByteClassCount] = { - "stage-buffer", "stage-texture", "stage-ubo-global", "stage-ubo-named", - "stage-vertex-client", "stage-index-client", "persistent-map-push", "residual-value-block", + "stage-buffer", "stage-texture", "stage-ubo-global", + "stage-ubo-named", "stage-vertex-client", "stage-index-client", + "stage-indirect-cmd", "persistent-map-push", "residual-value-block", }; const char* const kCallClassNames[kCallClassCount] = { "draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect", @@ -134,19 +176,57 @@ namespace MobileGL::MG_Util::PipeStats { "espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch", "magma-draw-fastpath", "magma-pipeline-memo", "magma-dynamic-tail", }; + // Tracy needs a stable string literal per series, and a gate is TWO series: plotting + // only the misses (which is what the first cut did) hides the denominator, and a + // gate's whole point is the ratio. + const char* const kGateHitPlotNames[kGateCount] = { + "espryt-render-state-hit", "espryt-texture-sync-list-hit", "espryt-unit-bindings-epoch-hit", + "magma-draw-fastpath-hit", "magma-pipeline-memo-hit", "magma-dynamic-tail-hit", + }; + const char* const kGateMissPlotNames[kGateCount] = { + "espryt-render-state-miss", "espryt-texture-sync-list-miss", "espryt-unit-bindings-epoch-miss", + "magma-draw-fastpath-miss", "magma-pipeline-memo-miss", "magma-dynamic-tail-miss", + }; // Short forms, so the per-120-frame line stays one terminal line wide. - const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", - "vtxc", "idxc", "pmap", "resid"}; + const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", "vtxc", + "idxc", "icmd", "pmap", "resid"}; const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"}; + void ResetCounters() { + for (Uint32 i = 0; i < kByteClassCount; ++i) { + g_frameBytes[i].store(0, std::memory_order_relaxed); + g_totalBytes[i].store(0, std::memory_order_relaxed); + g_windowBaseBytes[i] = 0; + } + for (Uint32 i = 0; i < kCallClassCount; ++i) { + g_frameCalls[i].store(0, std::memory_order_relaxed); + g_totalCalls[i].store(0, std::memory_order_relaxed); + g_windowBaseCalls[i] = 0; + } + for (Uint32 i = 0; i < kGateCount; ++i) { + g_frameGateHit[i].store(0, std::memory_order_relaxed); + g_totalGateHit[i].store(0, std::memory_order_relaxed); + g_frameGateMiss[i].store(0, std::memory_order_relaxed); + g_totalGateMiss[i].store(0, std::memory_order_relaxed); + g_windowBaseGateHit[i] = 0; + g_windowBaseGateMiss[i] = 0; + } + for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { + g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed); + } + g_frameCount.store(0, std::memory_order_relaxed); + g_windowBaseFrames = 0; + } + void EmitSummaryLine() { - const String line = FormatSummaryLine(); + const String line = FormatWindowLine(); // MGLOG_I on purpose, against the project's usual "MGLOG_D for anything // non-critical" rule: the line has to survive an INFO build (that is the only // build a device ever runs), it is emitted at most once per 120 frames, and it // exists at all only when the operator set MOBILEGL_PIPE_STATS=1. It is an // opt-in measurement channel, not per-frame noise. MGLOG_I("%s", line.c_str()); + AdvanceSummaryWindow(); } void WriteJsonDump() { @@ -170,7 +250,7 @@ namespace MobileGL::MG_Util::PipeStats { } // namespace void Init() { - ResetForTesting(); + ResetCounters(); g_shutdownDone = false; g_pipeStatsEnabled = MG_Config::Features.PipeStats; if (g_pipeStatsEnabled) { @@ -218,7 +298,13 @@ namespace MobileGL::MG_Util::PipeStats { void OnPresent() { #ifdef TRACY_ENABLE // One plot per counter, the frame's value. Tracy keeps the series by name, and the - // names are the static literals above, which is what TracyPlot requires. + // names are the static literals above, which is what TracyPlot requires. A gate is + // two series - hits and misses - because the ratio is the deliverable and a miss + // count alone cannot be read. + // + // The payload histogram is deliberately NOT plotted: it is a run-total distribution + // over draws (section 4.5.7), not a per-frame scalar, and Tracy has no histogram + // series. It reaches the operator through the JSON dump. for (Uint32 i = 0; i < kByteClassCount; ++i) { TracyPlot(kByteClassNames[i], static_cast(Read(g_frameBytes[i]))); } @@ -226,7 +312,8 @@ namespace MobileGL::MG_Util::PipeStats { TracyPlot(kCallClassNames[i], static_cast(Read(g_frameCalls[i]))); } for (Uint32 i = 0; i < kGateCount; ++i) { - TracyPlot(kGateNames[i], static_cast(Read(g_frameGateMiss[i]))); + TracyPlot(kGateHitPlotNames[i], static_cast(Read(g_frameGateHit[i]))); + TracyPlot(kGateMissPlotNames[i], static_cast(Read(g_frameGateMiss[i]))); } #endif for (Uint32 i = 0; i < kByteClassCount; ++i) { @@ -260,12 +347,16 @@ namespace MobileGL::MG_Util::PipeStats { const char* NameOf(CallClass callClass) { return kCallClassNames[static_cast(callClass)]; } const char* NameOf(Gate gate) { return kGateNames[static_cast(gate)]; } - String FormatSummaryLine() { + String FormatWindowLine() { // Window values: everything since the previous summary. A run total over a workload // whose shape changes (load, then steady state) hides exactly the number P2 wants. const Uint64 frames = Read(g_frameCount); const Uint64 windowFrames = frames - g_windowBaseFrames; - const Uint64 divisorFrames = windowFrames == 0 ? 1 : windowFrames; + // A window with no Present in it (teardown before the first frame, or a slice whose + // whole workload runs off-screen) has NO per-frame reading. Printing the window + // totals under a "/f" label there is how a 47x overstatement of the SEG_STAGE sizing + // input got printed as a per-frame figure; the label changes instead. + const Bool perFrame = windowFrames != 0; Uint64 bytes[kByteClassCount]; for (Uint32 i = 0; i < kByteClassCount; ++i) { @@ -289,21 +380,21 @@ namespace MobileGL::MG_Util::PipeStats { line += " frames=" + std::to_string(frames); line += " window=" + std::to_string(windowFrames); line += " draws=" + std::to_string(draws); - line += " draws/f=" + std::to_string(draws / divisorFrames); + line += " draws/f=" + FormatFixed2(draws, windowFrames); line += " acc=" + std::to_string(accessorCalls); - // Two decimals without : the per-draw accessor count is the number section - // 2.3.1 wants to an integer's worth of precision, and it is small (10-25). - const Uint64 accPerDrawHundredths = draws == 0 ? 0 : (accessorCalls * 100 + draws / 2) / draws; - line += " acc/draw=" + std::to_string(accPerDrawHundredths / 100) + "." + - (accPerDrawHundredths % 100 < 10 ? "0" : "") + std::to_string(accPerDrawHundredths % 100); - line += " bytes/f["; + // Same rule as the per-frame fields: a window with no draw in it has no per-draw + // number, and "0.00" next to a non-zero acc= is the same lie in a smaller font. + line += " acc/draw=" + FormatFixed2(accessorCalls, draws); + // "bytes/f[...]" only when there IS a frame to divide by; otherwise the bracket is + // labelled "bytes[...]" and carries the window totals verbatim. + line += perFrame ? " bytes/f[" : " bytes["; for (Uint32 i = 0; i < kByteClassCount; ++i) { if (i != 0) { line += " "; } line += kByteClassShort[i]; line += "="; - line += std::to_string(bytes[i] / divisorFrames); + line += perFrame ? FormatFixed2(bytes[i], windowFrames) : std::to_string(bytes[i]); } line += "] tex[emit=" + std::to_string(calls[static_cast(CallClass::TextureUploadEmissions)]); line += " box=" + std::to_string(calls[static_cast(CallClass::TextureUploadBoxEmissions)]); @@ -321,7 +412,10 @@ namespace MobileGL::MG_Util::PipeStats { line += std::to_string(gateMiss[i]); } line += "]"; + return line; + } + void AdvanceSummaryWindow() { for (Uint32 i = 0; i < kByteClassCount; ++i) { g_windowBaseBytes[i] = Read(g_totalBytes[i]); } @@ -332,8 +426,7 @@ namespace MobileGL::MG_Util::PipeStats { g_windowBaseGateHit[i] = Read(g_totalGateHit[i]); g_windowBaseGateMiss[i] = Read(g_totalGateMiss[i]); } - g_windowBaseFrames = frames; - return line; + g_windowBaseFrames = Read(g_frameCount); } String FormatJson() { @@ -374,30 +467,6 @@ namespace MobileGL::MG_Util::PipeStats { void SetEnabledForTesting(Bool enabled) { g_pipeStatsEnabled = enabled; } - void ResetForTesting() { - for (Uint32 i = 0; i < kByteClassCount; ++i) { - g_frameBytes[i].store(0, std::memory_order_relaxed); - g_totalBytes[i].store(0, std::memory_order_relaxed); - g_windowBaseBytes[i] = 0; - } - for (Uint32 i = 0; i < kCallClassCount; ++i) { - g_frameCalls[i].store(0, std::memory_order_relaxed); - g_totalCalls[i].store(0, std::memory_order_relaxed); - g_windowBaseCalls[i] = 0; - } - for (Uint32 i = 0; i < kGateCount; ++i) { - g_frameGateHit[i].store(0, std::memory_order_relaxed); - g_totalGateHit[i].store(0, std::memory_order_relaxed); - g_frameGateMiss[i].store(0, std::memory_order_relaxed); - g_totalGateMiss[i].store(0, std::memory_order_relaxed); - g_windowBaseGateHit[i] = 0; - g_windowBaseGateMiss[i] = 0; - } - for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) { - g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed); - } - g_frameCount.store(0, std::memory_order_relaxed); - g_windowBaseFrames = 0; - } + void ResetForTesting() { ResetCounters(); } } // namespace MobileGL::MG_Util::PipeStats diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index 6b2348f2..367a323e 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -32,9 +32,12 @@ // perfectly-predicted branch, and none of the counter state is touched. The counters // themselves are relaxed atomics rather than plain integers because texture and buffer // staging can be reached from more than one thread; relaxed adds cost nothing extra on the -// off path, which never reaches them. +// off path, which never reaches them. The off-path cost is not a guess: see the paired +// A/B in the branch's evidence. // -// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. +// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. That inventory +// is the contract - it names every path that is NOT wired, because a byte class that reads +// zero while a real copy runs uncounted is worse than a missing counter. namespace MobileGL::MG_Util::PipeStats { // Byte classes. Every one of these names a population of bytes that would have to be @@ -42,9 +45,11 @@ namespace MobileGL::MG_Util::PipeStats { // the frontend, which is why they are grouped this way rather than by call site. enum class ByteClass : Uint32 { // Buffer object contents flushed to the driver: glBufferData / glBufferSubData / - // map-write ranges / the persistent upload ring. + // map-write ranges / the persistent upload ring (Espryt), and every host->device + // copy of a buffer object's contents (Magma). StageBuffer = 0, - // Texel bytes handed to glTexSubImage & friends, whichever upload shape was chosen. + // Texel bytes handed to glTexSubImage & friends / packed into the Vulkan upload + // staging slice, whichever upload shape was chosen. StageTexture, // The default-uniform-block ("global UBO") image, uploaded at most once per program // per frame. @@ -53,10 +58,16 @@ namespace MobileGL::MG_Util::PipeStats { // ring. Espryt binds the frontend buffer straight to the driver and contributes // nothing here - which is exactly the asymmetry D-B8 is about. StageUboNamed, - // Client-memory vertex arrays uploaded into a scratch VBO on the draw path. + // Client-memory vertex arrays uploaded into a scratch VBO / transient arena slice on + // the draw path. StageVertexClient, // Client-memory / rewritten index data staged on the draw path. StageIndexClient, + // Draw-parameter bytes a backend synthesises and stages for the draw itself: the + // indirect-command array and the compute path's per-draw info array. These are the + // bytes that become MGPipe command-record payload once the boundary is explicit, + // which is why they are not folded into the index class. + StageIndirectCmd, // Bytes pushed because a persistently mapped range was published to the backend. PersistentMapPush, // PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The @@ -126,7 +137,7 @@ namespace MobileGL::MG_Util::PipeStats { inline Bool Enabled() { return g_pipeStatsEnabled; } - // Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and resets every + // Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and clears every // counter. Called from MobileGL::Initialize() right after the config load. void Init(); @@ -158,13 +169,20 @@ namespace MobileGL::MG_Util::PipeStats { const char* NameOf(CallClass callClass); const char* NameOf(Gate gate); - // The compact fixed-format one-liner MGLOG_I prints. Same text in the log and in the - // test, so the format is pinned by a test rather than by the log reader's memory. - String FormatSummaryLine(); + // The compact fixed-format one-liner MGLOG_I prints, covering the CURRENT window (see + // AdvanceSummaryWindow). PURE: calling it twice returns the same text and changes no + // counter, so a probe, a test or a second reporting channel can format the window + // without stealing it from the log. + String FormatWindowLine(); + // Closes the current window: the run totals as of now become the base the next + // FormatWindowLine() subtracts. Emitting the line and advancing the window are separate + // on purpose - the pair used to be one function whose name promised a formatter. + void AdvanceSummaryWindow(); // The teardown dump. Run totals only: a per-frame JSON stream is a different tool. String FormatJson(); - // Test hooks. Not used by any shipping path. + // Test hooks, used by no shipping path. Init() clears the counters through an internal + // ResetCounters() rather than by calling ResetForTesting(). void SetEnabledForTesting(Bool enabled); void ResetForTesting();