// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.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 - glBufferData GROWS A BUFFER THAT IS ALREADY BOUND AT AN INDEXED POINT. // // GL says the indexed binding follows the buffer object, so after the store is re-specified the // shader sees the NEW extent. DirectGLES shadows the indexed bindings so a redundant // glBindBufferBase can be skipped, and nothing used to invalidate that shadow when the store was // re-specified underneath it - so on a driver that resolves a whole-buffer indexed binding's // extent at BIND time (Adreno does; Mali does not) the shader kept seeing the OLD, smaller range. // Stores past it are dropped and loads return zero, which is exactly what // KHR-GL43.compute_shader.dispatch-indirect reported: the first iteration's 6 elements correct and // everything past byte 24 zero, after the same buffer was re-specified from 24 to 96 bytes. // // 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 #undef GLAPI #endif #define GL_GLEXT_PROTOTYPES #include #include #undef GL_GLEXT_PROTOTYPES namespace MGITest { namespace { constexpr const char* kComputeSource = R"(#version 430 core layout(local_size_x = 1) in; layout(std430, binding = 0) buffer Output { uint g_data[]; }; void main() { g_data[gl_GlobalInvocationID.x] = gl_GlobalInvocationID.x + 1u; } )"; 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 { ScenarioTest::SetUp(); if (!Ready()) return; m_program = CompileComputeProgram(kComputeSource); ASSERT_NE(m_program, 0u) << m_buildLog; glGenBuffers(1, &m_buffer); } void TearDown() override { if (!Ready()) return; if (m_buffer != 0) glDeleteBuffers(1, &m_buffer); if (m_program != 0) glDeleteProgram(m_program); } unsigned int CompileComputeProgram(const char* source) { const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader); GLint compiled = 0; glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); if (compiled == GL_FALSE) { char log[2048] = {}; glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); m_buildLog = std::string("compute shader did not compile: ") + log; glDeleteShader(shader); return 0; } const GLuint program = glCreateProgram(); glAttachShader(program, shader); glLinkProgram(program); glDeleteShader(shader); GLint linked = 0; glGetProgramiv(program, GL_LINK_STATUS, &linked); if (linked == GL_FALSE) { char log[2048] = {}; glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); m_buildLog = std::string("compute program did not link: ") + log; glDeleteProgram(program); return 0; } return program; } void RespecifyTo(int elements) { const std::vector zeros(static_cast(elements), 0u); glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer); glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast(zeros.size() * sizeof(unsigned int)), zeros.data(), GL_DYNAMIC_COPY); } std::vector DispatchAndRead(int elements) { glUseProgram(m_program); glDispatchCompute(static_cast(elements), 1, 1); glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); std::vector values(static_cast(elements), 0xDEADBEEFu); glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer); glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, static_cast(values.size() * sizeof(unsigned int)), values.data()); 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; }; } // namespace TEST_F(StorageBufferRegrowScenario, AGrownStoreIsVisibleThroughItsExistingIndexedBinding) { if (!Ready() || IsSkipped()) return; // Iteration one: 24 bytes, bound once, six groups. RespecifyTo(kSmallElements); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer); ASSERT_EQ(FirstGLError(), 0u); const std::vector small = DispatchAndRead(kSmallElements); ASSERT_EQ(FirstGLError(), 0u); for (int i = 0; i < kSmallElements; ++i) { ASSERT_EQ(small[static_cast(i)], static_cast(i + 1)) << "the 24-byte iteration itself did not write element " << i; } // Iteration two: the SAME buffer grows to 96 bytes with NO new glBindBufferBase, which is // what the application is entitled to do and what the shadow used to swallow. RespecifyTo(kLargeElements); ASSERT_EQ(FirstGLError(), 0u); const std::vector large = DispatchAndRead(kLargeElements); EXPECT_EQ(FirstGLError(), 0u); for (int i = 0; i < kLargeElements; ++i) { EXPECT_EQ(large[static_cast(i)], static_cast(i + 1)) << "element " << i << " (byte " << i * 4 << ") of the grown store came back as " << large[static_cast(i)] << "; zero from element " << kSmallElements << " on means the shader still saw the pre-growth extent"; } 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