diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index e960b6fd..f69a9993 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -128,6 +128,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp Scenarios/DualSourceBlendScenario.cpp + Scenarios/OversizedRecordingSplitScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -376,6 +377,12 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1" "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log" ${MGL_ITEST_VULKAN_ENV}) +# The command-buffer draw budget pinned far below one frame's draws, with the log path for the +# split's latched MGLOG_I - the same arming reasoning as the two entries above. +mgl_itest_join_environment(MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER=64" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/oversized-recording-split.log" + ${MGL_ITEST_VULKAN_ENV}) # The point-size demotion pinned on, per backend, with a per-lane log file for the arming # assertion - the same MOBILEGL_LOG_FILE_PATH reasoning as the UnlocatedIoBlocks lane above. # Two lanes because the demotion runs in the SHARED phase-B chain and each backend then @@ -588,6 +595,22 @@ gtest_discover_tests(MobileGLIntegrationTest ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}" ) +# OversizedRecordingSplitScenario with the command-buffer draw budget PINNED to 64. The ambient +# registrations run its 1024-draw frame under the default budget, which never splits it; this +# entry splits it 15 times, so the pixels prove a split loses nothing, and the arming assertion +# (the renderer's latched MGLOG_I) proves the split happened at all - on a renderer that records +# the whole frame into one command buffer, it is the assertion that goes red. DirectVulkan only: +# the budget steers nothing on DirectGLES, whose driver flushes on its own. +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.SplitRecording." + TEST_FILTER "OversizedRecordingSplitScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT}" +) + # The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient # registrations above cover the narrow storage - on every CI driver the widening's POST # probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the diff --git a/MobileGL/MG_IntegrationTest/Scenarios/OversizedRecordingSplitScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/OversizedRecordingSplitScenario.cpp new file mode 100644 index 00000000..41d13fbe --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/OversizedRecordingSplitScenario.cpp @@ -0,0 +1,219 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/OversizedRecordingSplitScenario.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 +// +// Scenario - A FRAME WITH MORE DRAWS THAN ONE COMMAND BUFFER MAY HOLD IS SPLIT, AND LOSES NOTHING. +// +// DirectVulkan records a frame into one command buffer, and used to submit it only at Present. +// A driver keeps a command buffer's GPU memory until the buffer is freed, and the Adreno 830 +// driver maps that memory into the process in 16 KiB chunks. Minecraft rd12's loading frame +// records 981,654 draws (3.1M commands) into one buffer. It grew to ~40k such mappings and +// aborted inside the driver's own calloc once vm.max_map_count (65530) was spent. The renderer +// now submits the open buffer after MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER draws and goes +// on recording into a fresh one (VulkanRenderer::SplitOversizedRecording). +// +// Each draw here writes exactly one pixel of a 32x32 target, in its own colour, selected by a +// per-draw uniform, so every draw carries its own uniform state, like Minecraft's chunk draws. +// After 1024 draws every pixel must hold its own draw's colour. Each of these split bugs leaves +// a pixel wrong: a re-begun render pass that does not LOAD, a draw recorded into the retired +// buffer, or state that is not re-emitted on the fresh one. +// +// The ambient registrations run the same draws unsplit, because the default budget is far above +// 1024; that is the control. The DirectVulkan.SplitRecording. entry pins the budget to 64, so +// the frame is split 15 times. There the case also asserts the renderer's latched MGLOG_I, the +// one assertion that is red on a renderer that never splits. + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kSide = 32; + constexpr int kDraws = kSide * kSide; + + // aPos spans the unit square; the uniform moves it onto cell (i % 32, i / 32) of a + // 32x32 grid, which is exactly one pixel of a 32x32 target. + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +uniform int uIndex; +void main() { + vec2 cell = vec2(float(uIndex % 32), float(uIndex / 32)); + gl_Position = vec4((cell + aPos) / 16.0 - 1.0, 0.0, 1.0); +} +)"; + + // Multiples of 8/255 survive an RGBA8 round trip exactly, so the readback bytes are + // (8x, 8y, 255, 255) for the pixel at (x, y) and nothing else can produce them. + constexpr const char* kFS = R"(#version 330 core +uniform int uIndex; +out vec4 o_color; +void main() { + o_color = vec4(float(uIndex % 32) * 8.0 / 255.0, float(uIndex / 32) * 8.0 / 255.0, 1.0, 1.0); +} +)"; + + // The budget the process was launched with, or 0 when it is unset or not a number. + unsigned long PinnedDrawBudget() { + const char* value = std::getenv("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER"); + if (value == nullptr || *value == '\0') return 0; + char* end = nullptr; + const unsigned long parsed = std::strtoul(value, &end, 10); + return (end != nullptr && *end == '\0') ? parsed : 0; + } + + // The library log, for the arming assertion. Same machinery and reasoning as + // UnlocatedIoBlockScenario: MOBILEGL_LOG_FILE_PATH is read at log-init, every process in + // the lane appends to the file, and only bytes appended after the snapshot count. + std::filesystem::path LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) : std::filesystem::path(); + } + + std::uintmax_t LibraryLogSize() { + std::error_code ec; + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return 0; + const std::uintmax_t size = std::filesystem::file_size(path, ec); + return ec ? 0 : size; + } + + std::string LibraryLogSince(std::uintmax_t offset) { + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + file.seekg(static_cast(offset)); + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + ::testing::AssertionResult EveryPixelHoldsItsOwnDraw(const Image& image) { + int wrong = 0; + int firstX = -1, firstY = -1; + Rgba8 firstSeen{}; + for (int y = 0; y < kSide; ++y) { + for (int x = 0; x < kSide; ++x) { + const Rgba8 expected{static_cast(8 * x), static_cast(8 * y), 255, 255}; + const Rgba8 seen = image.At(x, y); + if (seen != expected) { + if (wrong == 0) { + firstX = x; + firstY = y; + firstSeen = seen; + } + ++wrong; + } + } + } + if (wrong == 0) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() + << wrong << " of " << kDraws << " pixels do not hold their own draw's colour. First: (" << firstX + << ", " << firstY << ") = " << firstSeen << ", drawn by draw " << (firstY * kSide + firstX) + << " (a pixel no draw reached still holds the clear colour 0,0,0,0)"; + } + + class OversizedRecordingSplitScenario : public ScenarioTest { + protected: + // kDraws single-pixel quads into a fresh target, one uniform value per draw, with no + // readback, flush or rebind in between, so the renderer sees one uninterrupted + // recording. Then the whole target is read back. + Image DrawEveryPixelWithItsOwnDraw(std::string* error) { + const unsigned int program = CompileProgram(kVS, kFS, error); + if (program == 0) return Image(); + ColorFbo target = MakeColorFbo(kSide, kSide); + if (target.fbo == 0) { + *error = "MakeColorFbo failed"; + glDeleteProgram(program); + return Image(); + } + + static const float kUnitQuad[] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0, vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kUnitQuad), kUnitQuad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + + BindFbo(target); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + ClearTo(0.0f, 0.0f, 0.0f, 0.0f); + glUseProgram(program); + const GLint indexLocation = glGetUniformLocation(program, "uIndex"); + for (int i = 0; i < kDraws; ++i) { + glUniform1i(indexLocation, i); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + } + Image image = ReadPixels(kSide, kSide); + + glUseProgram(0); + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + BindDefaultFramebuffer(); + DestroyColorFbo(target); + glDeleteProgram(program); + return image; + } + }; + + } // namespace + + TEST_F(OversizedRecordingSplitScenario, EveryDrawOfTheFrameLandsAcrossCommandBufferSplits) { + if (!Ready()) return; + + // Taken before the draws, so the line the arming check looks for can only have been + // written by them. The renderer latches it at its first split. + const std::uintmax_t logBefore = LibraryLogSize(); + + std::string error; + const Image image = DrawEveryPixelWithItsOwnDraw(&error); + ASSERT_FALSE(image.Empty()) << error; + EXPECT_TRUE(EveryPixelHoldsItsOwnDraw(image)); + EXPECT_EQ(FirstGLError(), 0u); + + // Arming: only where the budget is pinned below this frame's draw count, on the backend + // that has the budget, and with a log to read. Everywhere else this case is the unsplit + // control and stops here. + const unsigned long budget = PinnedDrawBudget(); + const bool splitExpected = budget > 0 && budget < static_cast(kDraws) && + Gl().BackendName() == std::string("DirectVulkan") && !LibraryLogPath().empty(); + RecordProperty("arming_checked", splitExpected ? "yes" : "no"); + if (!splitExpected) { + return; + } + const std::string appended = LibraryLogSince(logBefore); + EXPECT_NE(appended.find("draws into one command buffer; submitting it"), std::string::npos) + << "MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER=" << budget << " and one frame recorded " << kDraws + << " draws, but DirectVulkan never reported splitting the recording. A renderer that does not " + "split grows a single command buffer without bound (rd12 on Adreno: ~40k driver mappings, " + "then SIGABRT). See VulkanRenderer::SplitOversizedRecording. Log appended by this test:\n" + << appended; + } + +} // namespace MGITest