diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index da8577d7..f0f7a704 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -131,6 +131,7 @@ add_executable(MobileGLIntegrationTest Scenarios/PoisonOmissionScenario.cpp Scenarios/HandleRecycleScenario.cpp Scenarios/CsoContentAddressingScenario.cpp + Scenarios/ResourceSubsystemControlScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -649,6 +650,20 @@ gtest_discover_tests(MobileGLIntegrationTest # emulation never runs - the ambient registration would be exercising the un-stripped path # twice and calling it coverage. With the variable set, the blocks really are emitted with no # location and the assertion is about the spelling the device gets. +# RESOURCE_LOCK, on this lane and on the three below it, and it is a bug fix rather than a +# precaution. Each of these lanes gives a WHOLE SCENARIO one MOBILEGL_LOG_FILE_PATH, and one case +# in each reads that log back to prove the pinned emulation actually armed. The library opens the +# log fopen(path, "w"), so every process in the lane TRUNCATES it - and under `ctest -j` a sibling +# case of the same lane can truncate it while the arming case is reading, which reads back as "the +# log carries no arming line" and fails a healthy lane. Measured on this tree: three runs of the +# full integration-gpu label at -j 8 produced 4 failures, 0 and 2, always one of these arming +# cases, never the same set twice. +# +# The rule stated above the verify block - a case that reads the log needs a lane whose filter +# selects it alone - would fix it by re-filtering, but that would RENAME the arming entries, and +# an existing ctest name may never disappear (gate G14). A ctest RESOURCE_LOCK named after the log +# is the same guarantee without touching a name: ctest never runs two entries holding the same lock +# at once, so the only processes that can truncate a lane's log are ones nobody is reading it for. gtest_discover_tests(MobileGLIntegrationTest TEST_PREFIX "DirectGLES.UnlocatedIoBlocks." TEST_FILTER "UnlocatedIoBlockScenario.*" @@ -656,6 +671,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK unlocated-io-blocks.log ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}" ) @@ -772,6 +788,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK primgen-query-reroute.log ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}" ) @@ -807,6 +824,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK point-size-demotion-gles.log ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) @@ -817,6 +835,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK point-size-demotion-vulkan.log ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) @@ -1066,6 +1085,129 @@ gtest_discover_tests(MobileGLIntegrationTest ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" ) +# --- G12: the P3a subsystem A/B, and G10's map-persistent-roundtrips lanes ------------ +# +# THE MASKS ARE THE TWO PHASE CONSTANTS, not hand-picked bits: 0x1ff is +# kMGPipeSubsystemsMigratedAtP3a (the push build's default) and 0x7f is +# kMGPipeSubsystemsMigratedAtP2, which is exactly that default with bits 7 (resources) and 8 +# (vertex input) cleared. MGPipe.h:79 keeps each phase's constant alive as the next phase's A/B +# control for this reason, and a lane that spelled its own bit pattern would stop being the shape +# that ships the first time the default moved. +# +# DirectGLES only. P3a migrates Espryt's buffer and VAO paths; Magma's buffer path is P7 and +# registers no MGPipeResourceOps, so a DirectVulkan arm would be measuring the client emitter +# against a backend nobody asked to change. +# +# Each reading entry gets a LOG PATH OF ITS OWN, and its ctest entry selects ONE case: the library +# opens the log fopen(path, "w") - every process in a lane truncates it - and these cases READ it. +# Two readers in one lane race under `ctest -j`, and the failure looks exactly like "the counter +# was never emitted". Same rule as the CSO lanes above and the verify arming lane below. +# +# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets a +# workload be bracketed by two swaps and read back as a window covering exactly itself. +# +# Registered in EVERY build, including the pull build where none of the counters exists, so that +# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a +# pull build MGITEST_PIPE_PUSH_BUILD is absent and every one of these cases skips saying so; a +# MOBILEGL_PIPE_PUSH value in the environment of a pull library steers nothing (Config.h declares +# the field inside the push guard), exactly as the HandleRecycle Legacy lanes already rely on. +mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=on" + "MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-on-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=off" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-off-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemControl.On." + TEST_FILTER "ResourceSubsystemControlScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemControl.Off." + TEST_FILTER "ResourceSubsystemControlScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT}" +) + +# G10's two counting entries, one per scenario that has a claim about the counter: +# StorageBufferRegrow asserts N definitions cost N round trips (never one per draw), and +# LargeArenaAdoption asserts one adoption costs exactly one. Each names a single case in its +# TEST_FILTER, for the private-log reason above. +mgl_itest_join_environment(MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=storage-buffer-regrow" + "MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-storage-buffer-regrow-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=large-arena-adoption" + "MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-large-arena-adoption-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MapPersistentRoundtrips." + TEST_FILTER "StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MapPersistentRoundtrips." + TEST_FILTER "LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT}" +) + +# LargeArenaAdoption's three behavioural cases under BOTH arms of the same A/B. This file is where +# an adopted store's whole life is exercised - the NULL-data definition that adopts it, an +# in-flight SubData, a readback and a GPU write - so if the handle path and the legacy +# BufferBackendOps path disagree about any of it, one of these two lanes goes red and names which. +# No log path: none of these three cases reads one, and giving them one would only add a file for +# four processes to truncate. The fourth case skips in both lanes for exactly that reason, saying +# so. +mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x1ff" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x7f" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemOn." + TEST_FILTER "LargeArenaAdoptionScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemOff." + TEST_FILTER "LargeArenaAdoptionScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT}" +) + if (MOBILEGL_PIPE_VERIFY) # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb # boundary and again at every accessor read, which the design budgets at 5-10x. diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h new file mode 100644 index 00000000..8f44a39c --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h @@ -0,0 +1,94 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h +// Copyright (c) 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 +// +// Reading ONE PipeStats summary window out of the library's own log, for the scenarios whose +// claim is about a counter rather than about pixels. +// +// WHY THROUGH A LOG FILE AT ALL. MG_Util::PipeStats is internal to the library and this module +// cannot link against it (ScenarioFixture.h has the long version: on Android this binary links +// the SHIPPING libMobileGL.so, built -fvisibility=hidden). The library's `MGPipe stats:` line is +// the only channel, so a lane that wants to read a counter sets MOBILEGL_PIPE_STATS=1, +// MOBILEGL_PIPE_STATS_PERIOD=1 - one line per eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of +// its OWN. +// +// THE LOG PATH HAS TO BE PRIVATE TO ONE CTEST ENTRY, and that is not a style rule: the library +// opens it fopen(path, "w"), so every process launched in a lane TRUNCATES it. Two entries of one +// lane reading the same path race under `ctest -j`, and the shape of the failure is an empty read +// that looks exactly like "the counter was never emitted". So a case that reads a window gets a +// ctest entry whose TEST_FILTER selects that case alone, with a log path nothing else writes - +// the rule PipeVerifyArmingScenario and CsoContentAddressingScenario already follow. +// +// THE WINDOW IS "SINCE THE PREVIOUS LINE" (PipeStats::FormatWindowLine), so the caller closes the +// setup window with a swap, runs the workload, swaps again, and reads the LAST line - which then +// covers the workload and nothing else. + +#pragma once + +#include +#include +#include +#include +#include + +namespace MGITest::PipeStatsWindow { + + // The lane's private log path, or empty when the lane configured none. + inline std::string LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::string(path) : std::string(); + } + + inline std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + // The last summary line in the log, verbatim. `found` is false when the library never emitted + // one, which is a different failure from "the counter read zero" and has to be reported as + // one: it means the stats channel never reached the process, not that the workload did + // nothing. + struct Window { + bool found = false; + std::string line; + }; + + inline Window Last(const std::string& log) { + Window window; + const std::string marker = "MGPipe stats:"; + const std::size_t at = log.rfind(marker); + if (at == std::string::npos) return window; + const std::size_t end = log.find('\n', at); + window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at); + window.found = true; + return window; + } + + inline Window LastFromLaneLog() { return Last(ReadWholeFile(LibraryLogPath())); } + + // One counter out of that line, by its short name ("mpr", "draws", "csom"), or -1 when the + // line does not carry it. The search includes the SEPARATOR before the name and the `=` after + // it, so "draws" cannot match "draws/f=" and "mpr" cannot match a longer name ending in it - + // a substring match here would read a neighbouring counter's value and report it as this + // one's, which is the one way a counter assertion can be wrong without ever failing. + inline long long CounterOrAbsent(const Window& window, const char* shortName) { + if (!window.found) return -1; + // A counter is preceded either by a space (` mpr=`, ` draws=`) or by its bracket's + // opening (`cso[csom=`, `bytes/f[stage-buffer=`); nothing in the line is preceded by + // anything else. + for (const char* prefix : {" ", "["}) { + const std::string key = std::string(prefix) + shortName + "="; + const std::size_t at = window.line.find(key); + if (at == std::string::npos) continue; + return std::strtoll(window.line.c_str() + at + key.size(), nullptr, 10); + } + return -1; + } + +} // namespace MGITest::PipeStatsWindow diff --git a/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp index 10bee4ba..1f8297a8 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp @@ -25,13 +25,31 @@ // * GetBufferSubData reads back the latest CPU write - the shadow IS the map; // * a compute-shader write through an SSBO binding of the same arena is read // back - the GPU-written path for adopted stores (glFinish + direct read). +// +// P3a (gate G10, G12) adds a fourth case and two more lanes, and neither of them +// changes what the three above assert: +// +// * AnAdoptionCostsExactlyOneMapPersistentRoundtrip counts the acquisition. +// ARCHITECTURE.md:474 prices the adopted store at one round trip per STORAGE +// DEFINITION; `map-persistent-roundtrips` counts every map_persistent +// emission, mint or decline (D-B2), so one definition plus a frame of draws +// must publish exactly one. It reads the library's summary line, so it needs +// a lane with the stats channel and a private log path, and it SKIPS - with +// the reason - anywhere else and on any tree that does not emit the counter. +// * the three original cases are registered TWICE MORE, with P3a's resource and +// vertex-input subsystem bits set and cleared, because this file is where an +// adopted store's whole life is exercised: definition, in-flight SubData, +// readback and a GPU write. If the handle path and the legacy BufferBackendOps +// path disagree about any of it, one of the two arms goes red here. #include +#include #include #include #include #include "../Harness/HeadlessGL.h" +#include "../Harness/PipeStatsWindow.h" #include "../Harness/ScenarioFixture.h" #ifdef GLAPI @@ -73,6 +91,19 @@ layout(std430, binding = 0) buffer Arena { uint word; }; void main() { word = 0xC0FFEEu; } )"; + // Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker, + // never read by the library. + constexpr const char* kLaneMarker = "MGITEST_MPR_LANE"; + // Draws issued against the arena inside the counted window. One definition, many draws: + // "one per definition" (1) and "one per draw" (kDrawsInTheWindow) have to be different + // numbers or the assertion cannot tell them apart. + constexpr int kDrawsInTheWindow = 5; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + struct Vertex { float x, y; float r, g, b; @@ -122,10 +153,12 @@ void main() { word = 0xC0FFEEu; } glBindVertexArray(0); if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); if (m_arena != 0) glDeleteBuffers(1, &m_arena); + if (m_secondArena != 0) glDeleteBuffers(1, &m_secondArena); if (m_program != 0) glDeleteProgram(m_program); if (m_compute != 0) glDeleteProgram(m_compute); m_vao = 0; m_arena = 0; + m_secondArena = 0; m_program = 0; m_compute = 0; } @@ -188,6 +221,40 @@ void main() { word = 0xC0FFEEu; } glDrawArrays(GL_TRIANGLES, 0, 6); } + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; the caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheRoundtripCounterIsReadableHere() { + if (std::getenv(kLaneMarker) == nullptr) { + GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry " + "sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. The ambient entries and the two subsystem " + "arms configure none of that, and their log is shared - a read there " + "would race a neighbour's bring-up."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so " + "CallClass::MapPersistentRoundtrips does not exist and the summary " + "line carries no mpr=. The entry stays registered so that " + "`ctest -L integration-gpu` names the same tests in both builds (G2)."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) { + GTEST_SKIP() << "subsystem not implemented on this tree: no source under " + "MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing " + "emits map_persistent and mpr= is structurally zero. P3a package B " + "owns that emitter; this entry arms itself when it lands."; + return; + } + if (PipeStatsWindow::LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's " + "summary line is the only channel this module has for reading " + "PipeStats"; + return; + } + } + std::array CenterPixel() { std::array px = {0, 0, 0, 0}; glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, @@ -199,6 +266,9 @@ void main() { word = 0xC0FFEEu; } unsigned int m_compute = 0; unsigned int m_vao = 0; unsigned int m_arena = 0; + // Only the counting case uses this one; see the comment there for why it does not + // simply re-specify m_arena. + unsigned int m_secondArena = 0; std::string m_buildLog; }; @@ -372,4 +442,74 @@ void main() { word = 0xC0FFEEu; } << "the compute write into the adopted arena did not reach the CPU readback"; } + // G10, the per-adoption half: ONE storage definition of an arena costs ONE map_persistent + // emission, however many draws read it afterwards. + // + // The arena SetUp defined is deliberately re-defined inside the counted window rather than + // measured from outside it: the window a summary line reports is "since the previous line", + // so the definition has to happen between the two swaps that bracket it, and a case that + // counted SetUp's definition would be reading a window it did not control. + // + // ONE reading case per lane, for the reason PipeStatsWindow.h gives: the library truncates the + // log per process, so two readers in a lane race under `ctest -j`. + TEST_F(LargeArenaAdoptionScenario, AnAdoptionCostsExactlyOneMapPersistentRoundtrip) { + if (!Ready() || IsSkipped()) return; + SkipUnlessTheRoundtripCounterIsReadableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window, SetUp's own definition included + + // One definition of a store past the 16 MiB adoption threshold, in a SECOND arena rather + // than by re-specifying SetUp's. Re-specifying an adopted store while a VAO's attributes + // still read it leaves the backend VAO bound to the retired store - `dev`'s d7655247 + // ("rebind VAOs when an adopted buffer is respecified - the immediate retire path forgot + // the buffer-id generation"), which is NOT in feat/disaggregated's history. A counting + // case that carried that crash would be red for a reason that has nothing to do with the + // counter. A fresh store is the same STORAGE DEFINITION either way, which is what + // ARCHITECTURE.md:474 prices. + glGenBuffers(1, &m_secondArena); + glBindBuffer(GL_ARRAY_BUFFER, m_secondArena); + glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW); + glBindVertexArray(m_vao); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kVertexOffset)); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kVertexOffset + 2 * sizeof(float))); + ASSERT_EQ(FirstGLError(), 0u) << "defining the second arena inside the counted window failed"; + + // ... and then a frame's worth of traffic against it, of the shape the arena exists for: + // a SubData per draw, every one of which lands in the adopted mapping and none of which + // may acquire it again. + const auto vertices = QuadVertices(0.f, 1.f, 0.f); + for (int draw = 0; draw < kDrawsInTheWindow; ++draw) { + glBindBuffer(GL_ARRAY_BUFFER, m_secondArena); + glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset, + GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data()); + DrawQuad(); + } + const auto px = CenterPixel(); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_GT(px[1], 200) << "the draws inside the counted window never landed, so the count below " + "would be a number about nothing"; + + Gl().EndFrame(); // the swap that emits the window covering exactly the work above + const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog(); + ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath() + << ": either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not " + "reach the process, or nothing reached PipeStats::OnPresent."; + RecordProperty("stats_line", window.line.c_str()); + + const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line; + EXPECT_EQ(roundtrips, 1) + << "one storage definition of an adopted arena is one map_persistent emission " + "(ARCHITECTURE.md:474, D-B2: mint OR decline, both need an answer from the resource " + "owner). This window defined the arena once and drew from it " + << kDrawsInTheWindow << " times, so 1 is the whole cost; " << kDrawsInTheWindow + << " would mean the acquisition moved onto the draw path - the ~167 ms/arena hiccup this " + "adoption removed, re-introduced - and 0 would mean the emission stopped happening. It " + "reported: " + << window.line; + } + } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp new file mode 100644 index 00000000..a7319c01 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp @@ -0,0 +1,321 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp +// Copyright (c) 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 +// +// Scenario - THE P3a SUBSYSTEM A/B IS REAL (gate G12). +// +// P3a migrates two subsystems: kMGPipeSubsystemResources (bit 7, the resource_* family) and +// kMGPipeSubsystemVertexInput (bit 8, vertex elements / buffers / index). The push build's default +// mask becomes kMGPipeSubsystemsMigratedAtP3a = 0x1ff, and P2's 0x7f survives as the control that +// clears exactly those two bits - MGPipe.h:79's rule that every phase's constant keeps meaning what +// it meant, so an operator's recorded mask is still readable a phase later. +// +// That A/B is what every "push vs pull" number in MEASUREMENTS.md is taken against, and it has one +// characteristic failure mode: the bits stop steering anything, both arms run the same code, and +// every later comparison is quietly taken against a switch that does nothing. This file is the +// entry that cannot let that happen. +// +// WHAT IT ASSERTS, per arm: +// +// on (MOBILEGL_PIPE_PUSH=0x1ff) +// The client emits map_persistent for every definition of a store past +// BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the window's +// map-persistent-roundtrips (`mpr=`) equals the number of definitions in it - one per +// storage definition, mint or decline (D-B2). +// +// off (MOBILEGL_PIPE_PUSH=0x7f, P2's default = P3a's subsystems cleared) +// The frontend dispatch falls through to the legacy BufferBackendOps arm, nothing is emitted +// through the resource family, and mpr= must read ZERO. This is the reading a dead switch +// fails: with bit 7 ignored, this arm would report the same non-zero count as the other one. +// +// both arms +// THE PIXELS MUST NOT MOVE. The arena is filled with one solid-colour quad and drawn, and +// both arms must read back that colour. "The counters moved and the picture did not" is the +// whole claim - a switch that changed what is drawn would not be an A/B, it would be a bug. +// +// WHY IT CAN SKIP. The counter is emitted by the client-side resource tracker (P3a package B), and +// this file is written against the P3a contract commit, before that package lands. Until then +// nothing emits map_persistent, mpr= is structurally zero in BOTH arms, and an assertion about the +// difference would be a statement about nothing. The build answers the question rather than a +// hand-maintained list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/ +// for the counter's name and passes the answer in as MGITEST_PIPE_RESOURCE_EMITTER_PRESENT, with a +// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale. It +// is a CONTENT probe, not a filename probe, so the owning package keeps control of its own file +// layout - P3a's new client files are headers (D-N), and a glob for `ResourceTracker.cpp` would +// have kept this control skipping forever with a reason that had become false. +// +// DirectGLES ONLY, and that is the honest scope: P3a migrates Espryt's buffer and VAO paths. +// Magma's buffer path is P7 and registers no MGPipeResourceOps, so a DirectVulkan lane here would +// be measuring the client emitter against a backend that has not been asked to change - which is +// a real question, but it is P7's, not this control's. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/PipeStatsWindow.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Set by the two ResourceSubsystemControl. ctest entries and by nothing else; a harness + // marker, never read by the library. Its absence means an ambient entry, where neither the + // stats channel nor a private log path is configured. + constexpr const char* kLaneMarker = "MGITEST_RESOURCE_SUBSYSTEM_LANE"; + constexpr const char* kLaneOn = "on"; + constexpr const char* kLaneOff = "off"; + + // Past BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the store is offered for + // adoption at all; the vertex payload sits deep inside it so a clamped or aliased adopted + // range would miss it. Same shape as LargeArenaAdoptionScenario, deliberately: this + // control's workload has to be one the buffer path really takes. + constexpr GLsizeiptr kArenaBytes = GLsizeiptr(20) * 1024 * 1024; + constexpr GLintptr kVertexOffset = GLintptr(16) * 1024 * 1024; + // Two definitions and several draws each, so "one per definition", "one per draw" and + // "none at all" are three different numbers. + // + // TWO ARENAS, EACH DEFINED ONCE, rather than one arena defined twice, and that is a + // deliberate detour around a bug that is not this branch's: re-specifying an adopted + // store while a VAO's attributes still read it leaves the backend VAO bound to the + // retired store, which `dev`'s d7655247 ("rebind VAOs when an adopted buffer is + // respecified - the immediate retire path forgot the buffer-id generation") fixes. That + // commit is NOT in feat/disaggregated's history, and this workload reproduced it as a + // hard SIGSEGV inside the vertex fetch on the first draw after the second definition. + // A control that carried a pre-existing `dev` crash would be red for a reason that has + // nothing to do with the subsystem bits it is measuring, so it defines a second arena + // instead - which is the same number of STORAGE DEFINITIONS, and therefore the same + // count, by ARCHITECTURE.md:474's own wording. The finding is recorded for the + // integrator rather than fixed here (ROADMAP.md:88: unrelated fixes do not ride the + // split work). + constexpr int kDefinitionsInTheWindow = 2; + constexpr int kDrawsPerDefinition = 3; + constexpr int kInset = 2; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kFS = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + std::vector Quad(float r, float g, float b) { + return { + {-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, + {-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b}, + }; + } + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + std::string LaneName() { + const char* lane = std::getenv(kLaneMarker); + return lane != nullptr ? std::string(lane) : std::string(); + } + + class ResourceSubsystemControlScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_lane = LaneName(); + std::string error; + m_program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(m_program, 0u) << error; + + // The VAO only. The arenas are created and defined inside the counted window - + // the window a summary line reports is "since the previous line", so a + // definition taken in SetUp would be counted in a window this case does not + // control - and their attribute pointers are declared only once each store + // exists, because an attribute whose offset is 16 MiB into a store that has not + // been defined yet is a range no driver has to accept. + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str()); + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + for (GLuint& arena : m_arenas) { + if (arena != 0) glDeleteBuffers(1, &arena); + arena = 0; + } + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + } + + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; the caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheLaneIsAssertableHere() { + if (m_lane.empty()) { + GTEST_SKIP() << "runs only in its own lane: the two ResourceSubsystemControl. ctest " + "entries set MGITEST_RESOURCE_SUBSYSTEM_LANE together with the " + "MOBILEGL_PIPE_PUSH bitmask that arm means, MOBILEGL_PIPE_STATS=1, " + "MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH. " + "None of that is configured in the ambient entries, and the ambient " + "log is shared, so a read here would race."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH: there are no " + "subsystem bits to clear, CallClass::MapPersistentRoundtrips does " + "not exist and the summary line carries no mpr=. The entry is " + "registered here anyway so that `ctest -L integration-gpu` names the " + "same tests in the pull build and the push build (gate G2)."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) { + GTEST_SKIP() << "subsystem not implemented on this tree: no source under " + "MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing " + "emits map_persistent, mpr= is structurally zero in BOTH arms and " + "the difference between them is not observable yet. P3a package B " + "owns the client-side resource tracker; this control arms itself " + "when it lands, whatever file that package puts the emitter in."; + return; + } + if (PipeStatsWindow::LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's " + "summary line is the only channel this module has for reading " + "PipeStats"; + return; + } + } + + // ONE storage definition - the NULL-data glBufferData past the adoption threshold, + // which is Minecraft's arena-creation idiom and the adoption point - then the + // attribute pointers into it and a few draws. Entirely inside one frame, so one + // summary window covers exactly this. + void DefineAnArenaAndDrawFromIt(int index, float r, float g, float b) { + glGenBuffers(1, &m_arenas[static_cast(index)]); + glBindBuffer(GL_ARRAY_BUFFER, m_arenas[static_cast(index)]); + glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW); + const std::vector vertices = Quad(r, g, b); + glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset, + GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data()); + glBindVertexArray(m_vao); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kVertexOffset)); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kVertexOffset + 2 * sizeof(float))); + glEnableVertexAttribArray(0); + glEnableVertexAttribArray(1); + glUseProgram(m_program); + for (int draw = 0; draw < kDrawsPerDefinition; ++draw) { + glDrawArrays(GL_TRIANGLES, 0, 6); + } + } + + std::string m_lane; + GLuint m_program = 0; + GLuint m_vao = 0; + std::vector m_arenas = + std::vector(static_cast(kDefinitionsInTheWindow), 0u); + }; + + // ONE case per lane, and it is a constraint rather than a preference: this case READS the + // library log, the log is a per-LANE resource (the library opens it fopen(path, "w"), so + // every process in a lane truncates it), and a second case here would race this one under + // `ctest -j` with a failure indistinguishable from "the counter was never emitted". The + // plumbing is asserted first, with its own message, inside this one process. + TEST_F(ResourceSubsystemControlScenario, ClearingTheP3aBitsStopsTheEmissionsAndNotThePixels) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + BindDefaultFramebuffer(); + Gl().EndFrame(); // close the setup window: everything below is one window + + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + for (int definition = 0; definition < kDefinitionsInTheWindow; ++definition) { + DefineAnArenaAndDrawFromIt(definition, 0.0f, 1.0f, 0.0f); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) + << "arena definition " << definition << " left a GL error behind"; + } + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); // the swap that emits the window covering exactly the work above + + const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog(); + ASSERT_TRUE(window.found) + << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath() + << ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting " + "here), so either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not reach the " + "process, or no summary line was emitted at all because nothing reached " + "PipeStats::OnPresent."; + RecordProperty("stats_line", window.line.c_str()); + + const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + ASSERT_GE(roundtrips, 0) + << "the summary line carries no mpr= field, so this build's PipeStats has no " + "map-persistent-roundtrips counter to read: " + << window.line; + + if (m_lane == kLaneOn) { + EXPECT_EQ(roundtrips, static_cast(kDefinitionsInTheWindow)) + << "with bits 7|8 SET the resource family is the path a store definition takes, so " + "each of the " << kDefinitionsInTheWindow + << " definitions in this window is one map_persistent emission (mint or decline - " + "both need an answer from the resource owner, D-B2). " + << (kDefinitionsInTheWindow * kDrawsPerDefinition) + << " would mean an acquisition per draw, and 0 would mean the emission never " + "happened on the arm that is supposed to do it. It reported: " + << window.line; + } else if (m_lane == kLaneOff) { + EXPECT_EQ(roundtrips, 0) + << "with bits 7|8 CLEARED (MOBILEGL_PIPE_PUSH=0x7f, P2's default) the frontend " + "dispatch must fall through to the legacy BufferBackendOps arm and emit nothing " + "through the resource family, so mpr= must be zero. A non-zero count here is the " + "dead-switch reading: the bits are being ignored, both arms run the same code, " + "and every push-vs-pull number taken against this A/B is measuring one arm twice. " + "It reported: " + << window.line; + } else { + FAIL() << "unknown " << kLaneMarker << " value '" << m_lane + << "': the arms are on / off. Reading an unrecognised name as either would make " + "this lane assert the other arm's expectation while claiming to test this one."; + } + + // ... and the picture is the same whichever arm ran. The arena is drawn with one solid + // colour, so both arms must read back exactly that. + EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset, + image.Height() - kInset, "green", 0.0, + "the arena draw [" + m_lane + "]")) + << "the subsystem bits changed what is DRAWN, which is not an A/B - the handle path and " + "the legacy path must produce the same pixels from the same arena."; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp index 1824ebab..e67336c5 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp @@ -19,11 +19,26 @@ // // The assertion is deliberately on the WHOLE grown range, so a partial write names the byte the // stale extent stopped at. +// +// P3a ADDS THE COST OF THAT REGROWTH (gate G10). ARCHITECTURE.md:474 prices a persistently mapped +// store at "one round trip per STORAGE DEFINITION, not one per store" - and, emphatically, not one +// per draw. `map-persistent-roundtrips` (`mpr=` in the summary line) counts every map_persistent +// EMISSION, mint or decline (D-B2), so the claim is directly countable: N definitions of an +// adopted store must publish exactly N, whatever the workload does between them. A regression that +// re-acquires per dispatch reports N x dispatches, which is the failure this case exists to name; +// a regression that stops emitting reports 0. +// +// The second case therefore respecifies a store LARGE ENOUGH TO BE ADOPTED +// (BufferObject::TryAdoptLargeStorage's 16 MiB threshold), several times, with several dispatches +// between the definitions, and reads the one window that covers exactly that workload. It skips - +// visibly, with the reason - on a tree where nothing emits the counter yet. +#include #include #include #include "../Harness/HeadlessGL.h" +#include "../Harness/PipeStatsWindow.h" #include "../Harness/ScenarioFixture.h" #ifdef GLAPI @@ -50,6 +65,31 @@ void main() { constexpr int kSmallElements = 6; // 24 bytes - the first iteration's size constexpr int kLargeElements = 24; // 96 bytes - what the second iteration grows to + // The G10 case's sizes. Every one of them is past BufferObject::TryAdoptLargeStorage's + // 16 MiB threshold, because a store below it is never offered for adoption at all and the + // window would then be asserting that nothing happened. They GROW, which is the scenario's + // subject: each glBufferData is a new storage definition, so each is one acquisition. + constexpr int kAdoptedDefinitions = 3; + constexpr int kAdoptedBaseElements = 5 * 1024 * 1024; // 20 MiB of uint + constexpr int kAdoptedGrowthElements = 1024 * 1024; // + 4 MiB per definition + // Enough dispatches per definition that "one per definition" and "one per dispatch" are + // different numbers by a wide margin (3 vs 12), and few enough to stay cheap. + constexpr int kDispatchesPerDefinition = 4; + // Only the first elements are dispatched over: the point of the large store is the + // ADOPTION, not the compute cost. + constexpr int kDispatchedElements = 6; + + // Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker, + // never read by the library. Its absence means an ambient entry, where neither the stats + // channel nor a private log path is configured - and where the shared log makes a read + // race a neighbour's bring-up. + constexpr const char* kLaneMarker = "MGITEST_MPR_LANE"; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + class StorageBufferRegrowScenario : public ScenarioTest { protected: void SetUp() override { @@ -114,6 +154,56 @@ void main() { return values; } + // The Minecraft arena idiom, and the adoption point: a NULL-data definition of a + // store past the threshold. No host-side vector, so a 28 MiB definition costs + // nothing on this side of the API. + void DefineAdoptedStore(int elements) { + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, + static_cast(static_cast(elements) * + static_cast(sizeof(unsigned int))), + nullptr, GL_DYNAMIC_DRAW); + } + + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; the caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheRoundtripCounterIsReadableHere() { + if (std::getenv(kLaneMarker) == nullptr) { + GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry " + "sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient " + "entries, and the ambient log is shared, so a read here would race a " + "neighbour's bring-up."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so " + "CallClass::MapPersistentRoundtrips does not exist (PipeStats.h " + "declares it inside the push guard, because growing the enum in a " + "pull build resizes the counter arrays and the name table - a G1 " + "break for a counter that could never leave zero) and the summary " + "line carries no mpr=. The entry is registered here anyway so that " + "`ctest -L integration-gpu` names the same tests in the pull build " + "and the push build (gate G2)."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) { + GTEST_SKIP() << "subsystem not implemented on this tree: no source under " + "MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing " + "emits map_persistent and mpr= is structurally zero. P3a package B " + "owns the client-side resource tracker; this entry arms itself when " + "it lands, whatever file that package puts the emitter in."; + return; + } + if (PipeStatsWindow::LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's " + "summary line is the only channel this module has for reading " + "PipeStats"; + return; + } + } + unsigned int m_program = 0; GLuint m_buffer = 0; std::string m_buildLog; @@ -153,4 +243,68 @@ void main() { glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0); } + + // G10. N storage definitions of an adopted store cost N map-persistent round trips - not one + // per draw, and not zero. + // + // ONE case in this lane, and that is a constraint rather than a preference: it READS the + // library log, the log is a per-lane resource (the library opens it fopen(path, "w"), so every + // process in a lane truncates it), and a second reading entry in the same lane would race this + // one under `ctest -j` with a failure that looks exactly like "the counter was never emitted". + // The plumbing is therefore asserted first, with its own message, inside this one process. + TEST_F(StorageBufferRegrowScenario, NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw) { + if (!Ready()) return; + SkipUnlessTheRoundtripCounterIsReadableHere(); + if (IsSkipped()) return; + + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer); + Gl().EndFrame(); // close the setup window: everything below is one window + + for (int definition = 0; definition < kAdoptedDefinitions; ++definition) { + DefineAdoptedStore(kAdoptedBaseElements + definition * kAdoptedGrowthElements); + ASSERT_EQ(FirstGLError(), 0u) << "definition " << definition << " of the adopted store failed"; + for (int dispatch = 0; dispatch < kDispatchesPerDefinition; ++dispatch) { + glUseProgram(m_program); + glDispatchCompute(static_cast(kDispatchedElements), 1, 1); + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + } + } + + // The store is still the one the last definition made, and it still works: a counter + // assertion over a workload that silently stopped functioning would be measuring nothing. + std::vector values(static_cast(kDispatchedElements), 0xDEADBEEFu); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, + static_cast(values.size() * sizeof(unsigned int)), values.data()); + EXPECT_EQ(FirstGLError(), 0u); + for (int i = 0; i < kDispatchedElements; ++i) { + EXPECT_EQ(values[static_cast(i)], static_cast(i + 1)) + << "the adopted store's own dispatch did not write element " << i; + } + + Gl().EndFrame(); // the swap that emits the window covering exactly the loop above + const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog(); + ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath() + << ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD " + "before getting here), so either MOBILEGL_PIPE_STATS / " + "MOBILEGL_PIPE_STATS_PERIOD did not reach the process or no summary " + "line was emitted at all because nothing reached PipeStats::OnPresent."; + RecordProperty("stats_line", window.line.c_str()); + + const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + ASSERT_GE(roundtrips, 0) + << "the summary line carries no mpr= field, so this build's PipeStats has no " + "map-persistent-roundtrips counter to read: " << window.line; + EXPECT_EQ(roundtrips, static_cast(kAdoptedDefinitions)) + << "an adopted store costs ONE map_persistent per STORAGE DEFINITION " + "(ARCHITECTURE.md:474). This window defined the store " << kAdoptedDefinitions + << " times and dispatched " << kDispatchesPerDefinition << " times against each of them, so " + << kAdoptedDefinitions << " is the whole cost. " + << (kAdoptedDefinitions * kDispatchesPerDefinition) + << " would mean an acquisition per DRAW - the regression this counter exists to catch - and 0 " + "would mean nothing emitted map_persistent at all. It reported: " + << window.line; + + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0); + } } // namespace MGITest