[Test] (Magma): descriptor pool growth and trim - the pure sizing policy adds O(log N) pools for an N-set frame, and DescriptorPoolGrowthScenario's 4096-distinct-set frame lands whole on both backends, grows at most 7 pools, and is given back after quiet frames and re-grown while they are still in flight

This commit is contained in:
2026-09-29 10:34:16 -04:00
parent 53fa16c2c2
commit 9dccabf03c
3 changed files with 441 additions and 0 deletions
@@ -129,6 +129,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/RenderbufferBlendFormatScenario.cpp
Scenarios/DualSourceBlendScenario.cpp
Scenarios/OversizedRecordingSplitScenario.cpp
Scenarios/DescriptorPoolGrowthScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -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 <algorithm>
#include <cstdint>
#include <cstdlib>
#include <sstream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#if !defined(__ANDROID__)
#include <MG_Backend/DirectVulkan/DescriptorPoolCensus.h>
#endif
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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<std::uint8_t>(16 + 32 * t), static_cast<std::uint8_t>(240 - 32 * t),
static_cast<std::uint8_t>(8 + 29 * t), 255};
}
std::string Describe(const Rgba8& c) {
std::ostringstream os;
os << "rgba(" << static_cast<int>(c.r) << ", " << static_cast<int>(c.g) << ", " << static_cast<int>(c.b)
<< ", " << static_cast<int>(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<GLsizei>(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<GLuint> 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<std::uint64_t>(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
+94
View File
@@ -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<UniformManager::DescriptorPoolBucket> GrowSlotUntil(MobileGL::Uint64 needed, bool updateAfterBind) {
std::vector<UniformManager::DescriptorPoolBucket> 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<MobileGL::Uint64>(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<MobileGL::Uint64>(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<Bucket> 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