// MobileGL - MobileGL/MG_Test/Util/PipeStatsTest.cpp // Copyright (c) 2025-2026 MobileGL-Dev // Licensed under the GNU Lesser General Public License v3.0: // https://www.gnu.org/licenses/gpl-3.0.txt // https://www.gnu.org/licenses/lgpl-3.0.txt // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // The MGPipe boundary counters (plan B section 11 P0, corollary in section 2.3.1). // No GL context and no driver: the module is arithmetic over a fixed set of counters, // which is exactly what has to be pinned before anyone reads a number off a device. #include #include #include #include #include #include namespace { namespace PS = MobileGL::MG_Util::PipeStats; using MobileGL::String; using MobileGL::Uint32; using MobileGL::Uint64; class PipeStatsTest : public ::testing::Test { protected: void SetUp() override { PS::ResetForTesting(); PS::SetEnabledForTesting(true); } void TearDown() override { PS::SetEnabledForTesting(false); PS::ResetForTesting(); } }; // The off latch is the whole cost argument: every counting site in the two backends is // written as `if (Enabled()) ...`, so a false latch has to mean "nothing is counted". TEST_F(PipeStatsTest, EnabledLatchIsTheOnlyGate) { PS::SetEnabledForTesting(false); EXPECT_FALSE(PS::Enabled()); PS::SetEnabledForTesting(true); EXPECT_TRUE(PS::Enabled()); } TEST_F(PipeStatsTest, ByteClassesAccumulateIndependently) { PS::AddBytes(PS::ByteClass::StageBuffer, 100); PS::AddBytes(PS::ByteClass::StageBuffer, 40); PS::AddBytes(PS::ByteClass::StageTexture, 7); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageBuffer), 140u); EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageBuffer), 140u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 7u); // 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); } // The frame accumulator is what feeds TracyPlot; the run total is what feeds the JSON // dump. A present must clear the first and keep the second. TEST_F(PipeStatsTest, PresentClearsTheFrameButKeepsTheTotal) { PS::AddBytes(PS::ByteClass::StageTexture, 512); PS::AddCalls(PS::CallClass::Draws, 3); PS::CountGate(PS::Gate::EsprytRenderState, /*hit=*/true); PS::OnPresent(); EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageTexture), 0u); EXPECT_EQ(PS::FrameCalls(PS::CallClass::Draws), 0u); EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 512u); EXPECT_EQ(PS::TotalCalls(PS::CallClass::Draws), 3u); EXPECT_EQ(PS::TotalGateHits(PS::Gate::EsprytRenderState), 1u); EXPECT_EQ(PS::FrameCount(), 1u); } TEST_F(PipeStatsTest, InitLatchesTheSummaryPeriodFromTheConfigAndNeverKeepsZero) { // The device retrace harness never reaches the teardown dump, so the summary // cadence is the only way a short fixture yields numbers at all: it must follow // MOBILEGL_PIPE_STATS_PERIOD, and a zero must fall back rather than divide. const Uint32 saved = MobileGL::MG_Config::Features.PipeStatsPeriod; MobileGL::MG_Config::Features.PipeStatsPeriod = 7; PS::Init(); EXPECT_EQ(PS::SummaryFramePeriod(), 7u); MobileGL::MG_Config::Features.PipeStatsPeriod = 0; PS::Init(); EXPECT_EQ(PS::SummaryFramePeriod(), PS::kDefaultSummaryFramePeriod); MobileGL::MG_Config::Features.PipeStatsPeriod = saved; PS::Init(); } TEST_F(PipeStatsTest, GateHitsAndMissesAreSeparateCounters) { for (Uint32 i = 0; i < 5; ++i) { PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/true); } PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/false); PS::CountGate(PS::Gate::MagmaDrawFastPath, /*hit=*/false); EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaPipelineMemo), 5u); EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaPipelineMemo), 1u); EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaDrawFastPath), 0u); EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaDrawFastPath), 1u); } // Bucket 0 is "no payload"; bucket n>0 is [2^(n-1), 2^n). The placeholder histogram is // the SEG_CMD sizing input (section 4.5.7), so its bucketing is pinned now rather than // when a generator first calls it. TEST_F(PipeStatsTest, PayloadHistogramBucketsByPowerOfTwo) { PS::RecordDrawPayloadBytes(0); PS::RecordDrawPayloadBytes(1); // [1, 2) -> bucket 1 PS::RecordDrawPayloadBytes(2); // [2, 4) -> bucket 2 PS::RecordDrawPayloadBytes(3); // [2, 4) -> bucket 2 PS::RecordDrawPayloadBytes(48); // [32, 64) -> bucket 6 PS::RecordDrawPayloadBytes(64); // [64, 128)-> bucket 7 EXPECT_EQ(PS::TotalPayloadBucket(0), 1u); EXPECT_EQ(PS::TotalPayloadBucket(1), 1u); EXPECT_EQ(PS::TotalPayloadBucket(2), 2u); EXPECT_EQ(PS::TotalPayloadBucket(6), 1u); EXPECT_EQ(PS::TotalPayloadBucket(7), 1u); } // A record far larger than the last bucket must land in the last bucket, not past the // end of the array. TEST_F(PipeStatsTest, PayloadHistogramSaturatesInsteadOfOverflowing) { PS::RecordDrawPayloadBytes(~Uint64{0}); EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets - 1), 1u); EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets), 0u); } // The summary line's shape is what an operator greps and what the smoke check in this // package matches, so it is pinned here rather than left to the log reader's memory. TEST_F(PipeStatsTest, SummaryLineCarriesEveryClassAndGate) { PS::AddCalls(PS::CallClass::Draws, 4); PS::AddCalls(PS::CallClass::AccessorCalls, 50); PS::AddBytes(PS::ByteClass::StageBuffer, 4096); PS::OnPresent(); 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.00"), String::npos) << line; for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { EXPECT_NE(line.find("="), String::npos); } EXPECT_NE(line.find("gates["), String::npos) << line; EXPECT_NE(line.find("tex[emit="), String::npos) << line; #if MOBILEGL_PIPE_PUSH // P2's two render-state CSO counters ride the same line, short-named. Push-only: // the pull build has no CSO to mint and must stay symbol-identical. EXPECT_NE(line.find("cso[csom="), String::npos) << line; EXPECT_NE(line.find("csob="), String::npos) << line; // P3a's persistent-map acquisition attempts ride the same bracket. It is the counter // the storage-regrow gate reads, so its short name is pinned where an operator's // grep would break. EXPECT_NE(line.find("mpr="), String::npos) << line; #endif } // 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. 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::FormatWindowLine(); EXPECT_NE(first.find("draws=10"), String::npos) << first; PS::AdvanceSummaryWindow(); PS::AddCalls(PS::CallClass::Draws, 3); PS::OnPresent(); const String second = PS::FormatWindowLine(); EXPECT_NE(second.find("draws=3"), String::npos) << second; EXPECT_NE(second.find("frames=2"), String::npos) << second; } // 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 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) { PS::AddBytes(PS::ByteClass::StageUboNamed, 256); PS::CountGate(PS::Gate::MagmaDynamicTail, /*hit=*/false); PS::RecordDrawPayloadBytes(9); PS::OnPresent(); const String json = PS::FormatJson(); for (Uint32 i = 0; i < static_cast(PS::ByteClass::Count); ++i) { const String name = PS::NameOf(static_cast(i)); EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; } for (Uint32 i = 0; i < static_cast(PS::CallClass::Count); ++i) { const String name = PS::NameOf(static_cast(i)); EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; } for (Uint32 i = 0; i < static_cast(PS::Gate::Count); ++i) { const String name = PS::NameOf(static_cast(i)); EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json; } EXPECT_NE(json.find("\"stage-ubo-named\": 256"), String::npos) << json; EXPECT_NE(json.find("\"frames\": 1"), String::npos) << json; EXPECT_NE(json.find("cmd-bytes-per-draw-histogram"), String::npos) << json; } // The counter names are the TracyPlot series names and the JSON keys; a rename is a // breaking change for every recorded baseline, so the whole set is pinned. TEST_F(PipeStatsTest, CounterNamesAreStable) { EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageBuffer), "stage-buffer"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageTexture), "stage-texture"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboGlobal), "stage-ubo-global"); 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"); #if MOBILEGL_PIPE_PUSH EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints"); EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds"); EXPECT_STREQ(PS::NameOf(PS::CallClass::MapPersistentRoundtrips), "map-persistent-roundtrips"); #endif EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch"); EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDrawFastPath), "magma-draw-fastpath"); EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaPipelineMemo), "magma-pipeline-memo"); EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDynamicTail), "magma-dynamic-tail"); } // A summary is emitted every kSummaryFramePeriod presents. The period is a constant the // smoke check depends on, so a change to it has to break a test. TEST_F(PipeStatsTest, SummaryPeriodIsOneHundredAndTwentyFrames) { EXPECT_EQ(PS::SummaryFramePeriod(), 120u); for (Uint64 i = 0; i < PS::SummaryFramePeriod(); ++i) { PS::OnPresent(); } EXPECT_EQ(PS::FrameCount(), PS::SummaryFramePeriod()); } } // namespace