Files
MobileGL/MobileGL/MG_IntegrationTest/Scenarios/IterationRPScratchFixScenario.cpp
T
swung0x48 c7e36986e7 [Fix] (ShaderTranspiler, DirectVulkan, MG_IntegrationTest): patch both of iterationRP's under-declared subgroup scratch arrays
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.
2026-08-19 16:36:06 -04:00

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