diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index a3a71922..79501418 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -129,6 +129,7 @@ add_executable(MobileGLIntegrationTest Scenarios/RenderbufferBlendFormatScenario.cpp Scenarios/DualSourceBlendScenario.cpp Scenarios/OversizedRecordingSplitScenario.cpp + Scenarios/DescriptorPoolGrowthScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DescriptorPoolGrowthScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DescriptorPoolGrowthScenario.cpp new file mode 100644 index 00000000..8cc3c342 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DescriptorPoolGrowthScenario.cpp @@ -0,0 +1,346 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DescriptorPoolGrowthScenario.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 - ONE FRAME THAT NEEDS THOUSANDS OF DISTINCT DESCRIPTOR SETS. +// +// DirectVulkan takes a draw's descriptor set from a per-frame-slot cache and adds a pool to the +// slot when the cache runs dry (UniformManager). Two things went wrong with a burst like +// Minecraft rd12's loading frame (15,016 sets in one slot): +// - Growth was linear. The next pool was sized at twice the slot's FIRST pool of the flavour, +// which is always its oldest and smallest, so every grown pool came out the same size: the +// loading slot ended with 59 pools of 256 sets. +// - The burst was kept forever. The cache only rewinds, so the slot held all 15k sets (and the +// pools behind them) for the life of the process, while steady frames used a few dozen. +// Now each pool doubles the slot's largest one (capped), and a slot whose pools stay under a +// quarter full for MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES frames is handed back whole and re-grows. +// +// Each of kDraws draws paints its own pixel of a 64x64 target and samples one of kTextures +// textures, a different one from each of the previous draws' so neither the renderer's per-draw +// descriptor reuse memo (four entries) nor its dynamic-offset-only rebind can let two draws share +// a set: one frame needs kDraws sets. Every pixel must hold its own draw's texture colour, on both +// backends. On DirectVulkan the cases also read the pools through GetDescriptorPoolCensus - which +// only the desktop build can reach, since the integration harness links the static library there +// and the hidden-visibility shipping library on Android. + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#if !defined(__ANDROID__) +#include +#endif + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kSide = 64; + constexpr int kDraws = kSide * kSide; // well under the 16384-draw command-buffer split budget + // More than the descriptor reuse memo's four entries, so the texture a draw samples is never + // one of the last four draws'. + constexpr int kTextures = 8; + // The base pool size (VulkanRenderer's kDescriptorSetsPerFrame) and the growth cap + // (UniformManager::kMaxDescriptorPoolSets). + constexpr std::uint64_t kBaseSets = 64; + constexpr std::uint64_t kMaxPoolSets = 4096; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +uniform int uIndex; +void main() { + vec2 cell = vec2(float(uIndex % 64), float(uIndex / 64)); + gl_Position = vec4((cell + aPos) / 32.0 - 1.0, 0.0, 1.0); +} +)"; + constexpr const char* kFS = R"(#version 330 core +uniform sampler2D uTex; +out vec4 o_color; +void main() { o_color = texture(uTex, vec2(0.5)); } +)"; + + // Texture t's single texel; every channel differs between any two textures. + Rgba8 TextureColor(int t) { + return Rgba8{static_cast(16 + 32 * t), static_cast(240 - 32 * t), + static_cast(8 + 29 * t), 255}; + } + + std::string Describe(const Rgba8& c) { + std::ostringstream os; + os << "rgba(" << static_cast(c.r) << ", " << static_cast(c.g) << ", " << static_cast(c.b) + << ", " << static_cast(c.a) << ")"; + return os.str(); + } + + unsigned long EnvUnsigned(const char* name, unsigned long fallback) { + const char* value = std::getenv(name); + if (value == nullptr || *value == '\0') return fallback; + char* end = nullptr; + const unsigned long parsed = std::strtoul(value, &end, 10); + return (end != nullptr && *end == '\0') ? parsed : fallback; + } + + // Pools a geometric policy adds for n sets from an empty flavour: 64, 128, ... up to the cap, + // then one per cap. The old first-pool rule needed n/128 or n/256 of them. + std::uint64_t GeometricPoolBound(std::uint64_t n) { + std::uint64_t pools = 0, capacity = 0, size = kBaseSets; + while (capacity < n) { + capacity += size; + ++pools; + size = std::min(size * 2, kMaxPoolSets); + } + return pools; + } + + struct Census { + bool available = false; + std::uint64_t pools = 0; + std::uint64_t capacitySets = 0; + std::uint64_t cachedSets = 0; + std::uint64_t slotTrims = 0; + }; + + Census ReadCensus() { + Census out; +#if !defined(__ANDROID__) + const auto census = MobileGL::MG_Backend::DirectVulkan::GetDescriptorPoolCensus(); + out.available = census.available; + out.pools = census.pools; + out.capacitySets = census.capacitySets; + out.cachedSets = census.cachedSets; + out.slotTrims = census.slotTrims; +#endif + return out; + } + + std::string Describe(const Census& c) { + std::ostringstream os; + os << "pools=" << c.pools << " capacity=" << c.capacitySets << " cachedSets=" << c.cachedSets + << " slotTrims=" << c.slotTrims; + return os.str(); + } + + ::testing::AssertionResult EveryPixelHoldsItsDrawsTexture(const Image& image, const char* when) { + 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 = TextureColor((y * kSide + x) % kTextures); + 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(); + const int draw = firstY * kSide + firstX; + return ::testing::AssertionFailure() + << when << ": " << wrong << " of " << kDraws << " pixels do not hold their own draw's texture. " + << "First: (" << firstX << ", " << firstY << ") = " << Describe(firstSeen) << ", drawn by draw " + << draw << " with texture " << (draw % kTextures) << " = " + << Describe(TextureColor(draw % kTextures)) << " (a pixel no draw reached holds 0,0,0,0)"; + } + + class DescriptorPoolGrowthScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + static const float kVertices[] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f}; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + + m_textures.resize(kTextures); + glGenTextures(kTextures, m_textures.data()); + for (int t = 0; t < kTextures; ++t) { + const Rgba8 c = TextureColor(t); + const std::uint8_t texel[4] = {c.r, c.g, c.b, c.a}; + glBindTexture(GL_TEXTURE_2D, m_textures[t]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, texel); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + glBindTexture(GL_TEXTURE_2D, 0); + } + + void TearDown() override { + if (Ready()) { + glUseProgram(0); + glBindVertexArray(0); + glBindTexture(GL_TEXTURE_2D, 0); + BindDefaultFramebuffer(); + if (m_program != 0) glDeleteProgram(m_program); + if (!m_textures.empty()) glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + DestroyColorFbo(m_target); + } + ScenarioTest::TearDown(); + } + + ::testing::AssertionResult Prepare() { + std::string error; + m_program = CompileProgram(kVS, kFS, &error); + if (m_program == 0) return ::testing::AssertionFailure() << error; + m_indexLocation = glGetUniformLocation(m_program, "uIndex"); + m_target = MakeColorFbo(kSide, kSide); + if (m_target.fbo == 0) return ::testing::AssertionFailure() << "MakeColorFbo failed"; + glUseProgram(m_program); + glUniform1i(glGetUniformLocation(m_program, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindVertexArray(m_vao); + BindFbo(m_target); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + return ::testing::AssertionSuccess(); + } + + // Draw i paints pixel (i % 64, i / 64) with texture i % kTextures. + void Draw(int i) { + glBindTexture(GL_TEXTURE_2D, m_textures[i % kTextures]); + glUniform1i(m_indexLocation, i); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + } + + // The burst: every pixel, one draw and one descriptor set each. + void DrawBurst() { + ClearTo(0.0f, 0.0f, 0.0f, 0.0f); + for (int i = 0; i < kDraws; ++i) Draw(i); + } + + static bool IsVulkan() { return Gl().BackendName() == std::string("DirectVulkan"); } + + GLuint m_vao = 0; + GLuint m_vbo = 0; + GLuint m_program = 0; + GLint m_indexLocation = -1; + std::vector m_textures; + ColorFbo m_target; + }; + + } // namespace + + // One frame, kDraws distinct sets. Every pixel lands, and the frame slot grows O(log N) pools + // for it, not N / 128. + TEST_F(DescriptorPoolGrowthScenario, AFrameOfDistinctSetsLandsWholeAndGrowsItsPoolsGeometrically) { + if (!Ready()) return; + ASSERT_TRUE(Prepare()); + + const Census before = ReadCensus(); + DrawBurst(); + const Image image = ReadPixels(kSide, kSide); + const Census after = ReadCensus(); + + EXPECT_TRUE(EveryPixelHoldsItsDrawsTexture(image, "one frame of distinct descriptor sets")); + EXPECT_EQ(FirstGLError(), 0u); + + RecordProperty("census_checked", (IsVulkan() && after.available) ? "yes" : "no"); + if (!IsVulkan() || !after.available) return; + // Falsifiability first: had the renderer's memos collapsed the draws onto shared sets, the + // slot would never have needed to grow and the bound below would pass on any policy. + ASSERT_GE(after.cachedSets, before.cachedSets + kDraws) + << "the frame did not allocate one descriptor set per draw, so it does not exercise growth. Before: " + << Describe(before) << "; after: " << Describe(after); + const std::uint64_t added = after.pools - before.pools; + RecordProperty("census_before", Describe(before)); + RecordProperty("census_after", Describe(after)); + EXPECT_LE(added, GeometricPoolBound(kDraws + 1)) + << "a frame needing " << kDraws << " descriptor sets added " << added << " pools; doubling the slot's " + << "largest pool needs at most " << GeometricPoolBound(kDraws + 1) << ". Before: " << Describe(before) + << "; after: " << Describe(after); + } + + // Burst, then quiet frames until the renderer hands the burst back, then the burst again while + // the quiet frames are still in flight. Nothing waits between frames except where noted, so the + // trim runs with other frame slots' work outstanding. + TEST_F(DescriptorPoolGrowthScenario, AGivenBackBurstLeavesNoStaleSetBehind) { + if (!Ready()) return; + ASSERT_TRUE(Prepare()); + const unsigned long trimFrames = EnvUnsigned("MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES", 120); + const unsigned long framesInFlight = EnvUnsigned("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3); + // The trim fires once trimFrames quiet frames have followed the burst, at the burst slot's next + // turn; past this many frames it is not coming. + const unsigned long quietFrameLimit = trimFrames + 2 * framesInFlight + 16; + + DrawBurst(); + Gl().EndFrame(); + const Census afterBurst = ReadCensus(); + + // Quiet frames: one draw each, the same pixel and texture the burst gave draw 0. + const bool observe = IsVulkan() && afterBurst.available && trimFrames != 0; + unsigned long quietFrames = 0; + Census afterQuiet = afterBurst; + for (; quietFrames < quietFrameLimit; ++quietFrames) { + Draw(0); + Gl().EndFrame(); + if (observe) { + afterQuiet = ReadCensus(); + if (afterQuiet.slotTrims > afterBurst.slotTrims) { + ++quietFrames; + break; + } + } + } + + // The burst again, straight away. The slot it lands in re-grows from its base pool. + DrawBurst(); + const Image image = ReadPixels(kSide, kSide); + const Census afterSecondBurst = ReadCensus(); + + EXPECT_TRUE(EveryPixelHoldsItsDrawsTexture(image, "the burst drawn after the first one was given back")); + EXPECT_EQ(FirstGLError(), 0u); + + RecordProperty("census_checked", observe ? "yes" : "no"); + if (!observe) return; + RecordProperty("census_after_burst", Describe(afterBurst)); + RecordProperty("quiet_frames", std::to_string(quietFrames)); + RecordProperty("census_after_quiet", Describe(afterQuiet)); + RecordProperty("census_after_second_burst", Describe(afterSecondBurst)); + ASSERT_GE(afterBurst.cachedSets, static_cast(kDraws)) + << "the burst did not allocate one descriptor set per draw: " << Describe(afterBurst); + EXPECT_GT(afterQuiet.slotTrims, afterBurst.slotTrims) + << quietFrames << " frames of one draw each followed a " << kDraws << "-set frame, but no frame slot was " + << "trimmed (MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES=" << trimFrames << "). After the burst: " + << Describe(afterBurst) << "; after the quiet frames: " << Describe(afterQuiet); + EXPECT_LE(afterQuiet.cachedSets + kDraws / 2, afterBurst.cachedSets) + << "the trim did not give the burst's sets back. After the burst: " << Describe(afterBurst) + << "; after the quiet frames: " << Describe(afterQuiet); + EXPECT_LT(afterQuiet.pools, afterBurst.pools) + << "the trim did not give the burst's pools back. After the burst: " << Describe(afterBurst) + << "; after the quiet frames: " << Describe(afterQuiet); + EXPECT_GE(afterSecondBurst.cachedSets, afterQuiet.cachedSets + kDraws) + << "the second burst was not served by freshly allocated sets: " << Describe(afterSecondBurst); + } + +} // namespace MGITest diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7af54ec0..97a27320 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -3061,6 +3061,100 @@ TEST(DirectVulkanSanity, GraphicsSamplerFeedbackOnlyAliasesWritableOverlappingMi EXPECT_FALSE(UniformManager::SamplerOverlapsWritableImageSubresource(1, 3, 4, GL_WRITE_ONLY)); } +// Magma's per-frame descriptor pools (UniformManager::NextDescriptorPoolMaxSets). The old rule +// doubled the FIRST pool of the flavour, which is always the slot's oldest and smallest one, so +// every grown pool came out the same size: rd12's loading frame (15,016 sets) grew one frame slot +// to 59 update-after-bind pools of 256 sets. The policy must double the LARGEST pool of the +// flavour, up to the cap. +namespace { + using MobileGL::MG_Backend::DirectVulkan::UniformManager; + constexpr MobileGL::Uint32 kBaseSets = 64; // VulkanRenderer's kDescriptorSetsPerFrame + + // A frame slot as GrowFrameDescriptorPool leaves it after a frame needing `needed` sets of one + // flavour: the base pool (non-update-after-bind, never usable by the other flavour) plus the + // policy's next pool, added until the flavour's pools hold `needed` sets. + std::vector GrowSlotUntil(MobileGL::Uint64 needed, bool updateAfterBind) { + std::vector pools{{VK_NULL_HANDLE, kBaseSets, 0, false}}; + const auto flavourCapacity = [&]() { + MobileGL::Uint64 sum = 0; + for (const auto& pool : pools) { + if (pool.updateAfterBind == updateAfterBind) sum += pool.maxSets; + } + return sum; + }; + while (flavourCapacity() < needed && pools.size() < 100000) { + pools.push_back({VK_NULL_HANDLE, UniformManager::NextDescriptorPoolMaxSets(pools, updateAfterBind, kBaseSets), + 0, updateAfterBind}); + } + return pools; + } + + // Pools a geometric policy needs for n sets: 64, 128, ... up to the cap, then one per cap. + MobileGL::Uint64 GeometricPoolBound(MobileGL::Uint64 n) { + MobileGL::Uint64 pools = 0, capacity = 0, size = kBaseSets; + while (capacity < n) { + capacity += size; + ++pools; + size = std::min(size * 2, UniformManager::kMaxDescriptorPoolSets); + } + return pools; + } +} // namespace + +TEST(DirectVulkanSanity, DescriptorPoolGrowthIsGeometricInTheSetsAFrameNeeds) { + for (const bool updateAfterBind : {false, true}) { + for (const MobileGL::Uint64 needed : {65ull, 1000ull, 4096ull, 8128ull, 15016ull, 40000ull}) { + const auto pools = GrowSlotUntil(needed, updateAfterBind); + MobileGL::Uint64 flavourPools = 0, capacity = 0; + MobileGL::Uint32 previous = 0; + for (const auto& pool : pools) { + if (pool.updateAfterBind != updateAfterBind) continue; + ++flavourPools; + capacity += pool.maxSets; + EXPECT_LE(pool.maxSets, UniformManager::kMaxDescriptorPoolSets) << "one pool past the cap"; + EXPECT_GE(pool.maxSets, previous) << "a later pool is smaller than an earlier one"; + previous = pool.maxSets; + } + // O(log N) under the cap (8128 = 64 + ... + 4096 is where the cap is reached), then one + // pool per 4096 sets. The first-match rule needed ~N/128 here: 118 pools for 15,016. + EXPECT_LE(flavourPools, GeometricPoolBound(needed)) + << needed << " sets (updateAfterBind=" << updateAfterBind << ") took " << flavourPools << " pools"; + EXPECT_GE(capacity, needed); + // The slot does not over-reserve by more than the last pool it had to add. + EXPECT_LT(capacity - needed, static_cast(previous)); + } + } + // rd12's loading frame, the case that motivated this: 9 pools in the slot, not 59. + const auto rd12 = GrowSlotUntil(15016, true); + EXPECT_EQ(rd12.size(), 1u + 9u); +} + +TEST(DirectVulkanSanity, DescriptorPoolGrowthDoublesTheLargestPoolOfTheSameFlavour) { + using Bucket = UniformManager::DescriptorPoolBucket; + // The largest pool decides, wherever it sits in the list - a trim or a flavour switch can + // leave a small pool after a large one. + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets( + {{VK_NULL_HANDLE, 64, 0, false}, {VK_NULL_HANDLE, 512, 0, false}, {VK_NULL_HANDLE, 128, 0, false}}, + false, kBaseSets), + 1024u); + // Flavours never size each other: the base pool is non-update-after-bind, so the first + // update-after-bind pool starts at the base size however large the other flavour grew. + const std::vector mixed{{VK_NULL_HANDLE, 64, 0, false}, + {VK_NULL_HANDLE, 2048, 0, false}, + {VK_NULL_HANDLE, 64, 0, true}, + {VK_NULL_HANDLE, 128, 0, true}}; + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets(mixed, true, kBaseSets), 256u); + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets(mixed, false, kBaseSets), 4096u); + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 64, 0, false}}, true, kBaseSets), 64u); + // The cap holds, and the base size is a floor. + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 4096, 0, false}}, false, kBaseSets), + UniformManager::kMaxDescriptorPoolSets); + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 3000, 0, false}}, false, kBaseSets), + UniformManager::kMaxDescriptorPoolSets); + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 16, 0, false}}, false, kBaseSets), 64u); + EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({}, false, kBaseSets), 64u); +} + // GL_MAX_COMBINED_*_UNIFORM_COMPONENTS is components + blocks * (blockSize / 4). The product was // formed in signed 32-bit, and a Vulkan host that reports a large VkPhysicalDeviceLimits:: // maxUniformBufferRange (a Mali driver answers 0xFFFFFFFF, which the loader saturates to