mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
The previous commit's fingerprint was pinned to one array's incidental
dimensions - workgroup exactly 32x16x1, element exactly vec2, length
exactly 32 - which is the auto-exposure reduction and nothing else. The
pack ships the same idiom twice:
- auto-exposure: 32x16 (512 invocations), shared vec2 prefixSumCache[32]
- RTW warp: 1024 invocations, shared float prefixSumCache[64]
so the warp kept writing 128 subgroups into 64 entries on an 8-lane
device and the retrace stayed bit-identically wrong (ssim 0.027902).
Key the fingerprint on the pack's idiom instead of one array's shape: a
workgroup array of 32-bit floats indexed by gl_SubgroupID, fed by a
subgroup scan, whose declared length is below ceil(invocations / native
width). Three properties keep that a targeted repair rather than a
general array resizer:
- the index must BE gl_SubgroupID (through OpCopyObject, a signedness
OpBitcast, or a spill whose every store is that id), so an index
masked or clamped into range is left alone;
- the >= 16-lane early-out is retained, so every module on the devices
the pack was written for passes through byte-identical;
- growth is certified against maxComputeSharedMemorySize using a
natural-alignment layout model, and declined outright when a
declaration cannot be sized, so a patched module can never fail
pipeline creation where the original would not have.
Verified against the shaders the CI trace actually contains: of the 14
compute modules in the fixture exactly these two change, the other
twelve are byte-identical, and all fourteen pass spirv-val. The
integration scenario grows a second case for the 1024-invocation shape;
both abort with heap corruption when the patch is disabled.
303 lines
14 KiB
C++
303 lines
14 KiB
C++
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/IterationRPScratchFixScenario.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 FIXTURE-SHAPED SUBGROUP REDUCTION, ON WHATEVER WIDTH THE DEVICE HAS.
|
|
//
|
|
// iterationRP hard-sizes the scratch its subgroup prefix scans write through
|
|
// prefixSumCache[gl_SubgroupID], and ships that idiom twice: the auto-exposure pass
|
|
// declares `shared vec2 prefixSumCache[32]` for a 512-invocation workgroup, and the
|
|
// RTW importance warp declares `shared float prefixSumCache[64]` for a 1024-invocation
|
|
// one. Both algorithms are width-agnostic; only the static lengths bake in "at most 32
|
|
// (respectively 64) subgroups", which every desktop capture satisfies and an 8-lane
|
|
// device (lavapipe: 64 and 128 subgroups) does not. DirectVulkan patches exactly that with
|
|
// FixIterationRPSubgroupScratchPass, growing the array to ceil(invocations / native
|
|
// width) on the modules that match the pack's reduction fingerprint.
|
|
//
|
|
// This scenario replays the fixture's reduction shape verbatim - the same 32-entry
|
|
// declaration, the same last-lane handoff, the same findMSB combine loop, and NO
|
|
// domain guard - and asserts only the width-independent result: the workgroup total.
|
|
// The inputs are small integers, so the fp32 sum is exact under any lane order and any
|
|
// association; a correct run produces the exact constant on a 4-lane device and a
|
|
// 128-lane device alike. Without the patch, a sub-16-lane device indexes the
|
|
// 32-entry array out of bounds - on lavapipe that is literal heap corruption - and
|
|
// this scenario is the regression test that keeps the patch working, and it runs on every device that
|
|
// has basic+arithmetic compute subgroups (unlike IterationRPFirstReductionScenario,
|
|
// which probes the UNREPAIRED source contract and must skip outside [16, 256]).
|
|
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
#include "../Harness/HeadlessGL.h"
|
|
#include "../Harness/ScenarioFixture.h"
|
|
|
|
#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 std::uint32_t kInvocationCount = 512u;
|
|
// sum of 0..511, exactly representable and associativity-proof in fp32.
|
|
constexpr float kExpectedTotal = 130816.0f;
|
|
// The RTW warp's shape: 1024 invocations into a 64-entry float scratch.
|
|
constexpr std::uint32_t kWideInvocationCount = 1024u;
|
|
// sum of 0..1023, likewise exact in fp32.
|
|
constexpr float kWideExpectedTotal = 523776.0f;
|
|
|
|
constexpr const char* kComputeSource = R"(#version 430 core
|
|
#extension GL_KHR_shader_subgroup_basic : require
|
|
#extension GL_KHR_shader_subgroup_arithmetic : require
|
|
|
|
layout(local_size_x = 32, local_size_y = 16, local_size_z = 1) in;
|
|
|
|
layout(std430, binding = 0) buffer Output {
|
|
float total;
|
|
uint numSubgroups;
|
|
uint maxSubgroupId;
|
|
} outputData;
|
|
|
|
shared vec2 prefixSumCache[32];
|
|
|
|
void main() {
|
|
vec2 sampleLuminance = vec2(float(gl_LocalInvocationIndex), 0.0);
|
|
sampleLuminance = subgroupInclusiveAdd(sampleLuminance);
|
|
if (gl_SubgroupInvocationID == gl_SubgroupSize - 1u)
|
|
prefixSumCache[gl_SubgroupID] = sampleLuminance;
|
|
barrier();
|
|
|
|
uint loopLength = uint(findMSB(gl_NumSubgroups));
|
|
loopLength += uint(gl_NumSubgroups - (1u << (loopLength - 1u)) > 0u);
|
|
|
|
for (uint scanStage = 0u; scanStage < loopLength; ++scanStage) {
|
|
if ((gl_SubgroupID & (1u << scanStage)) > 0u) {
|
|
sampleLuminance += prefixSumCache[(gl_SubgroupID >> scanStage << scanStage) - 1u];
|
|
if (gl_SubgroupInvocationID == gl_SubgroupSize - 1u)
|
|
prefixSumCache[gl_SubgroupID] = sampleLuminance;
|
|
}
|
|
barrier();
|
|
}
|
|
|
|
if (gl_LocalInvocationIndex == 511u) {
|
|
outputData.total = sampleLuminance.x;
|
|
outputData.numSubgroups = gl_NumSubgroups;
|
|
}
|
|
atomicMax(outputData.maxSubgroupId, gl_SubgroupID);
|
|
}
|
|
)";
|
|
|
|
// The RTW importance warp's shape: a plain float scan over 1024 invocations
|
|
// into a 64-entry scratch. Same idiom, different dimensions - which is exactly
|
|
// what a fingerprint pinned to the exposure pass's shape walks past.
|
|
constexpr const char* kWideComputeSource = R"(#version 430 core
|
|
#extension GL_KHR_shader_subgroup_basic : require
|
|
#extension GL_KHR_shader_subgroup_arithmetic : require
|
|
|
|
layout(local_size_x = 1024) in;
|
|
|
|
layout(std430, binding = 0) buffer Output {
|
|
float total;
|
|
uint numSubgroups;
|
|
uint maxSubgroupId;
|
|
} outputData;
|
|
|
|
shared float prefixSumCache[64];
|
|
|
|
void main() {
|
|
float importance = float(gl_LocalInvocationID.x);
|
|
float prefixSum = subgroupInclusiveAdd(importance);
|
|
if (gl_SubgroupInvocationID == gl_SubgroupSize - 1u)
|
|
prefixSumCache[gl_SubgroupID] = prefixSum;
|
|
barrier();
|
|
|
|
uint loopLength = uint(findMSB(gl_NumSubgroups));
|
|
loopLength += uint(gl_NumSubgroups - (1u << (loopLength - 1u)) > 0u);
|
|
|
|
for (uint scanStage = 0u; scanStage < loopLength; ++scanStage) {
|
|
if ((gl_SubgroupID & (1u << scanStage)) > 0u) {
|
|
prefixSum += prefixSumCache[(gl_SubgroupID >> scanStage << scanStage) - 1u];
|
|
if (gl_SubgroupInvocationID == gl_SubgroupSize - 1u)
|
|
prefixSumCache[gl_SubgroupID] = prefixSum;
|
|
}
|
|
barrier();
|
|
}
|
|
|
|
if (gl_LocalInvocationID.x == 1023u) {
|
|
outputData.total = prefixSum;
|
|
outputData.numSubgroups = gl_NumSubgroups;
|
|
}
|
|
atomicMax(outputData.maxSubgroupId, gl_SubgroupID);
|
|
}
|
|
)";
|
|
|
|
struct OutputBlock {
|
|
float total = -1.0f;
|
|
std::uint32_t numSubgroups = 0;
|
|
std::uint32_t maxSubgroupId = 0;
|
|
};
|
|
|
|
bool HasExtension(const char* wanted) {
|
|
GLint extensionCount = 0;
|
|
glGetIntegerv(GL_NUM_EXTENSIONS, &extensionCount);
|
|
for (GLint i = 0; i < extensionCount; ++i) {
|
|
const auto* extension =
|
|
reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, static_cast<GLuint>(i)));
|
|
if (extension != nullptr && std::string(extension) == wanted) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
class IterationRPScratchFixScenario : public ScenarioTest {
|
|
protected:
|
|
void SetUp() override {
|
|
ScenarioTest::SetUp();
|
|
if (!Ready()) return;
|
|
|
|
GLint stages = 0;
|
|
GLint features = 0;
|
|
GLint invocations = 0;
|
|
const bool subgroupExtension = HasExtension("GL_KHR_shader_subgroup");
|
|
if (subgroupExtension) {
|
|
glGetIntegerv(GL_SUBGROUP_SUPPORTED_STAGES_KHR, &stages);
|
|
glGetIntegerv(GL_SUBGROUP_SUPPORTED_FEATURES_KHR, &features);
|
|
}
|
|
glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &invocations);
|
|
const GLbitfield requiredFeatures =
|
|
GL_SUBGROUP_FEATURE_BASIC_BIT_KHR | GL_SUBGROUP_FEATURE_ARITHMETIC_BIT_KHR;
|
|
if (!subgroupExtension || (static_cast<GLbitfield>(stages) & GL_COMPUTE_SHADER_BIT) == 0 ||
|
|
(static_cast<GLbitfield>(features) & requiredFeatures) != requiredFeatures ||
|
|
invocations < static_cast<GLint>(kInvocationCount)) {
|
|
GTEST_SKIP() << "needs GL_KHR_shader_subgroup basic+arithmetic in compute and a "
|
|
"512-invocation workgroup";
|
|
}
|
|
|
|
m_maxInvocations = static_cast<std::uint32_t>(invocations);
|
|
|
|
glGenBuffers(1, &m_output);
|
|
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
|
|
// maxSubgroupId starts at zero HOST-side: the word is touched only by
|
|
// atomicMax during the dispatch, since a plain shader-side zeroing store
|
|
// would race the other invocations' atomics (barrier() orders shared
|
|
// memory, not SSBO stores).
|
|
const OutputBlock poison{-1.0f, 0xa5a5a5a5u, 0u};
|
|
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(OutputBlock), &poison, GL_DYNAMIC_READ);
|
|
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_output);
|
|
}
|
|
|
|
void TearDown() override {
|
|
if (!Ready()) return;
|
|
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
|
|
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
|
if (m_output != 0) glDeleteBuffers(1, &m_output);
|
|
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;
|
|
}
|
|
|
|
// Re-poisons the block, compiles the shape under test and runs it once.
|
|
OutputBlock Dispatch(const char* source) {
|
|
const OutputBlock poison{-1.0f, 0xa5a5a5a5u, 0u};
|
|
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
|
|
glBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(OutputBlock), &poison);
|
|
m_program = CompileComputeProgram(source);
|
|
EXPECT_NE(m_program, 0u) << m_buildLog;
|
|
if (m_program == 0u) return OutputBlock{};
|
|
glUseProgram(m_program);
|
|
glDispatchCompute(1, 1, 1);
|
|
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
|
|
OutputBlock block{};
|
|
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
|
|
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(OutputBlock), &block);
|
|
return block;
|
|
}
|
|
|
|
GLuint m_program = 0;
|
|
GLuint m_output = 0;
|
|
std::uint32_t m_maxInvocations = 0;
|
|
std::string m_buildLog;
|
|
};
|
|
} // namespace
|
|
|
|
TEST_F(IterationRPScratchFixScenario, FixtureShapedReductionSumsEveryInvocation) {
|
|
const OutputBlock block = Dispatch(kComputeSource);
|
|
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
|
|
|
|
// The topology diagnostics catch the failure modes by name before the sum does:
|
|
// an out-of-bounds handoff corrupts the total, a wrong gl_NumSubgroups breaks
|
|
// the combine loop's length.
|
|
ASSERT_NE(block.numSubgroups, 0xa5a5a5a5u) << "invocation 511 never reached its store";
|
|
EXPECT_GE(block.numSubgroups, 1u);
|
|
EXPECT_LE(block.numSubgroups, kInvocationCount);
|
|
EXPECT_LT(block.maxSubgroupId, block.numSubgroups)
|
|
<< "gl_SubgroupID exceeds gl_NumSubgroups - the inconsistency "
|
|
"DeriveNumSubgroupsPass exists to repair";
|
|
|
|
// Integer-valued fp32 inputs: the workgroup total is exact under any subgroup
|
|
// width, lane order, and association. This is the value iterationRP's exposure
|
|
// average is built from; without FixIterationRPSubgroupScratchPass an 8-lane
|
|
// device writes prefixSumCache[32..63] out of bounds and this comparison fails.
|
|
EXPECT_EQ(block.total, kExpectedTotal)
|
|
<< "workgroup reduction produced " << block.total << " with gl_NumSubgroups="
|
|
<< block.numSubgroups;
|
|
}
|
|
|
|
// The pack's second instance of the same bug, and the one that kept the CI
|
|
// retrace red after the exposure pass alone was patched.
|
|
TEST_F(IterationRPScratchFixScenario, WideFixtureShapedReductionSumsEveryInvocation) {
|
|
if (m_maxInvocations < kWideInvocationCount) {
|
|
GTEST_SKIP() << "needs a " << kWideInvocationCount << "-invocation workgroup";
|
|
}
|
|
const OutputBlock block = Dispatch(kWideComputeSource);
|
|
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
|
|
|
|
ASSERT_NE(block.numSubgroups, 0xa5a5a5a5u) << "invocation 1023 never reached its store";
|
|
EXPECT_GE(block.numSubgroups, 1u);
|
|
EXPECT_LE(block.numSubgroups, kWideInvocationCount);
|
|
EXPECT_LT(block.maxSubgroupId, block.numSubgroups)
|
|
<< "gl_SubgroupID exceeds gl_NumSubgroups - the inconsistency "
|
|
"DeriveNumSubgroupsPass exists to repair";
|
|
|
|
// Without the patch an 8-lane device writes prefixSumCache[64..127] out of
|
|
// bounds and this comparison fails.
|
|
EXPECT_EQ(block.total, kWideExpectedTotal)
|
|
<< "workgroup reduction produced " << block.total << " with gl_NumSubgroups="
|
|
<< block.numSubgroups;
|
|
}
|
|
} // namespace MGITest
|