diff --git a/MobileGL/MG_Test/Program/AsyncSpirvPhaseTest.cpp b/MobileGL/MG_Test/Program/AsyncSpirvPhaseTest.cpp index 9a17128b..8ba7e184 100644 --- a/MobileGL/MG_Test/Program/AsyncSpirvPhaseTest.cpp +++ b/MobileGL/MG_Test/Program/AsyncSpirvPhaseTest.cpp @@ -28,6 +28,7 @@ #include +#include #include #include @@ -94,6 +95,9 @@ void main() { source += "uniform vec3 uTint" + n + ";\n"; source += "uniform float uArr" + n + "[4];\n"; source += "uniform float uSeed" + n + ";\n"; + // An OPAQUE uniform, so the window cases can pin the "glUniform1i(samplerLoc, unit) + // right after a link is a zero-join operation" claim the split is built around. + source += "uniform sampler2D uTex" + n + ";\n"; source += "void main() {\n"; source += " float acc = uSeed" + n + ";\n"; for (int i = 0; i < 200; ++i) { @@ -102,7 +106,8 @@ void main() { source += " vec4 p = uModel" + n + " * vec4(vPos, 1.0);\n"; source += " acc += p.x + p.y + p.z + p.w;\n"; source += " acc += uArr" + n + "[0] + uArr" + n + "[1] + uArr" + n + "[2] + uArr" + n + "[3];\n"; - source += " fragColor = vec4(uTint" + n + " * acc, 1.0);\n"; + source += " vec4 t = texture(uTex" + n + ", vPos.xy);\n"; + source += " fragColor = vec4(uTint" + n + " * acc, 1.0) * t;\n"; source += "}\n"; return source; } @@ -277,10 +282,15 @@ void main() { fragColor = thisIdentifierWasNeverDeclared; } const auto& object = Object(program); ASSERT_NE(object, nullptr); - // Phase B settled (as cancelled) rather than being left in flight, so nothing can - // block on it later, and it published nothing. - EXPECT_TRUE(object->IsSpirvComplete()); + // GetSpirvStatus() FIRST, and the order is load-bearing rather than stylistic: phase B + // is settled by a continuation that runs on whichever worker drove phase A terminal, + // and JobNode::TryTransition releases waiters (notify_all) BEFORE it runs its + // continuation list - so the GL thread can be back here with phase A published while + // that one-line lambda has not run yet. GetSpirvStatus() goes through the phase-B + // gate, which Waits; only after it has can IsSpirvComplete() be asserted without a + // race. Asserting the other way round is a rare CI flake, not a red. EXPECT_FALSE(object->GetSpirvStatus()); + EXPECT_TRUE(object->IsSpirvComplete()); EXPECT_TRUE(object->GetGeneratedSpirv().empty()); EXPECT_EQ(GetError(), GL_NO_ERROR); } @@ -305,8 +315,123 @@ void main() { fragColor = vec4(1.0); } EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "a fragment output past GL_MAX_DRAW_BUFFERS must fail the link"; const auto& object = Object(program); ASSERT_NE(object, nullptr); - EXPECT_TRUE(object->IsSpirvComplete()); + // GetSpirvStatus() before IsSpirvComplete(); see ALinkThatFailsNeverProducesSpirv. EXPECT_FALSE(object->GetSpirvStatus()); + EXPECT_TRUE(object->IsSpirvComplete()); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Linked, but the SPIR-V never arrived +// --------------------------------------------------------------------------------------- + +// The state the split invented and GL gives no way out of: phase A published LINK_STATUS +// GL_TRUE, and phase B then settled CANCELLED rather than Complete - its body threw +// (bad_alloc out of GlslangToSpv/spirv-opt under pack-load memory pressure), the pool failed +// to enqueue it, or teardown cancelled it while it was queued. The shadow is then a +// default-constructed SpirvArtifacts: empty uniformOffsets, null scratch, spirvStatus false - +// while the whole phase-A query surface, IsValidUniformLocation() included, keeps answering. +// +// Every getter and every entry point on that surface must degrade, not fault. Before the +// bounds check in GetUniformOffset this was a null dereference on the first glUniform* or +// glGetUniform* the application made. +// +// The state is produced deterministically rather than by racing a cancel: after the +// LINK_STATUS read has published phase A (m_pendingLink is null), CancelLink() can only reach +// the SPIR-V job - which is exactly the shape StopAndDrain produces for a phase B queued +// behind an already-complete phase A. +TEST_F(AsyncSpirvPhaseTest, ALinkedProgramWhoseSpirvJobWasCancelledDegradesInsteadOfFaulting) { + const AsyncModeScope async(true); + const SingleWorkerScope oneWorker; + constexpr int kPrograms = 12; + constexpr int kFirst = 38000; + + Vector sources; + const Vector programs = LinkBatch(kPrograms, kFirst, sources); + + int cancelled = 0; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + const String n = std::to_string(kFirst + i); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + const auto& object = Object(program); + ASSERT_NE(object, nullptr); + if (object->IsSpirvComplete()) continue; // already published; not the state under test + + object->CancelLink(); // phase A is published, so this reaches only the SPIR-V job + ++cancelled; + + // ---- the contract: linked, fully queryable, not drawable ---- + EXPECT_TRUE(object->GetLinkStatus()) << "a cancelled phase B must not retract LINK_STATUS"; + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); + EXPECT_FALSE(object->GetSpirvStatus()); + EXPECT_TRUE(object->IsSpirvComplete()); + // This is the expression both backends' bind gates evaluate. + EXPECT_FALSE(object->GetLinkStatus() && object->GetSpirvStatus()) + << "the backends must refuse to bind a program with no SPIR-V"; + EXPECT_TRUE(object->GetGeneratedSpirv().empty()); + EXPECT_EQ(object->GetUBOSize(), 0u); + EXPECT_EQ(object->GetUBOData(), nullptr); + + // ---- the reflection surface still answers ---- + const GLint locModel = GetUniformLocation(program, ("uModel" + n).c_str()); + const GLint locTint = GetUniformLocation(program, ("uTint" + n).c_str()); + const GLint locTex = GetUniformLocation(program, ("uTex" + n).c_str()); + ASSERT_GE(locModel, 0); + ASSERT_GE(locTint, 0); + ASSERT_GE(locTex, 0); + EXPECT_TRUE(object->IsValidUniformLocation(locTint)); + + // ---- and every write/read path degrades ---- + // Direct getter first: kInvalidUniformOffset, not an out-of-bounds index. + EXPECT_EQ(object->GetUniformOffset(static_cast(locTint)), + MG_State::GLState::ProgramObject::kInvalidUniformOffset); + EXPECT_EQ(object->GetUniformOffset(object->GetMaxUniformLocation()), + MG_State::GLState::ProgramObject::kInvalidUniformOffset); + + const GLfloat tint[3] = {1.0f, 2.0f, 3.0f}; + GLfloat model[16] = {}; + for (int c = 0; c < 16; ++c) model[c] = static_cast(c); + ProgramUniform3fv(program, locTint, 1, tint); // dropped, not faulted + ProgramUniformMatrix4fv(program, locModel, 1, GL_FALSE, model); // ditto + UseProgram(program); + Uniform3fv(locTint, 1, tint); // the glUseProgram + glUniform* entry, same verdict + UseProgram(0); + + // The opaque branch never touches phase B, so it keeps working in full. + ProgramUniform1i(program, locTex, 3); + GLint unit = -1; + GetUniformiv(program, locTex, &unit); + EXPECT_EQ(unit, 3) << "sampler units are phase-A state and must survive a lost phase B"; + + // Reads leave the caller's buffer alone rather than faulting. + GLfloat readback[16] = {}; + for (int c = 0; c < 16; ++c) readback[c] = -1.0f; + GetUniformfv(program, locModel, readback); + for (int c = 0; c < 16; ++c) { + EXPECT_FLOAT_EQ(readback[c], -1.0f) << "a program with no shadow must not write the query buffer"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + EXPECT_GT(cancelled, 0) << "no phase B was ever cancelled; this case proved nothing"; +} + +// The mirror image: with async off the state is not reachable at all, because both bodies run +// inline before glLinkProgram returns. Worth pinning - it is what makes the async-off mode a +// usable fallback for a device where the state above would be a problem. +TEST_F(AsyncSpirvPhaseTest, AsyncOffNeverProducesALinkedProgramWithoutSpirv) { + const AsyncModeScope async(false); + Vector sources; + const Vector programs = LinkBatch(4, 39000, sources); + for (const GLuint program : programs) { + const auto& object = Object(program); + ASSERT_NE(object, nullptr); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_TRUE(object->IsSpirvComplete()) << "nothing may be outstanding when async is off"; + EXPECT_TRUE(object->GetSpirvStatus()); + EXPECT_GT(object->GetUBOSize(), 0u); + } EXPECT_EQ(GetError(), GL_NO_ERROR); } @@ -331,9 +456,11 @@ TEST_F(AsyncSpirvPhaseTest, UniformWritesInsideTheWindowReplayExactly) { GLint locModel = -1; GLint locTint = -1; GLint locArr = -1; + GLint locTex = -1; GLfloat model[16] = {}; GLfloat tint[3] = {}; GLfloat arr1 = 0.0f; + GLint texUnit = 0; Bool wasBuffered = false; }; Vector expectations; @@ -352,23 +479,47 @@ TEST_F(AsyncSpirvPhaseTest, UniformWritesInsideTheWindowReplayExactly) { e.locModel = GetUniformLocation(e.program, ("uModel" + n).c_str()); e.locTint = GetUniformLocation(e.program, ("uTint" + n).c_str()); e.locArr = GetUniformLocation(e.program, ("uArr" + n + "[0]").c_str()); + e.locTex = GetUniformLocation(e.program, ("uTex" + n).c_str()); ASSERT_GE(e.locModel, 0); ASSERT_GE(e.locTint, 0); ASSERT_GE(e.locArr, 0); + ASSERT_GE(e.locTex, 0); for (int c = 0; c < 16; ++c) e.model[c] = static_cast(i) + static_cast(c) * 0.25f; e.tint[0] = 0.125f * static_cast(i); e.tint[1] = 0.25f * static_cast(i); e.tint[2] = 0.5f * static_cast(i); e.arr1 = 7.5f + static_cast(i); + e.texUnit = i % 8; + + // An OPAQUE write inside the window. The design's load-bearing claim is that this is a + // ZERO-JOIN operation - a sampler unit is phase-A state - and it is what Iris does + // immediately after every glLinkProgram, so it is asserted rather than assumed. + ProgramUniform1i(e.program, e.locTex, e.texUnit); + if (e.wasBuffered) { + EXPECT_FALSE(SpirvIsSettled(e.program)) + << "glUniform1i on a sampler must not settle phase B (program " << e.program << ")"; + } ProgramUniformMatrix4fv(e.program, e.locModel, 1, GL_FALSE, e.model); ProgramUniform3fv(e.program, e.locTint, 1, e.tint); // An element in the middle of an array, addressed by its own location. ProgramUniform1fv(e.program, e.locArr + 1, 1, &e.arr1); - // Last write wins: overwrite the tint, so the replay's ordering is under test too. + // Last write wins, and through the OTHER entry point for half the programs: the + // Minecraft/Iris shape is glUseProgram + glUniform*, which reaches Uniform_State + // through Uniformv_State/GetProgramForUniform rather than through the by-name form. e.tint[1] = 0.75f; - ProgramUniform3fv(e.program, e.locTint, 1, e.tint); + if ((i % 2) == 0) { + ProgramUniform3fv(e.program, e.locTint, 1, e.tint); + } else { + UseProgram(e.program); + Uniform3fv(e.locTint, 1, e.tint); + UseProgram(0); + } + if (e.wasBuffered) { + EXPECT_FALSE(SpirvIsSettled(e.program)) + << "no glUniform* entry point may settle phase B (program " << e.program << ")"; + } expectations.push_back(e); } @@ -389,6 +540,9 @@ TEST_F(AsyncSpirvPhaseTest, UniformWritesInsideTheWindowReplayExactly) { EXPECT_FLOAT_EQ(tint[c], e.tint[c]) << "program " << e.program << " tint component " << c; } EXPECT_FLOAT_EQ(arr1, e.arr1) << "program " << e.program << " array element 1"; + GLint unit = -1; + GetUniformiv(e.program, e.locTex, &unit); + EXPECT_EQ(unit, e.texUnit) << "program " << e.program << " sampler unit"; // Reading them settled phase B, so the program is drawable now. const auto& object = Object(e.program); ASSERT_NE(object, nullptr); @@ -397,6 +551,67 @@ TEST_F(AsyncSpirvPhaseTest, UniformWritesInsideTheWindowReplayExactly) { EXPECT_EQ(GetError(), GL_NO_ERROR); } +// The overflow valve. BufferUniformWrite declines past kMaxBufferedUniformBytes and the +// caller falls through to the direct path - which JOINS, and therefore has to replay +// everything already buffered BEFORE performing its own write, or last-write-wins breaks for +// every uniform touched after the valve trips. +TEST_F(AsyncSpirvPhaseTest, TheBufferedWriteValveFallsThroughToADirectWriteWithoutLosingOrder) { + const AsyncModeScope async(true); + const SingleWorkerScope oneWorker; + constexpr int kPrograms = 8; + constexpr int kFirst = 40000; + // kMaxBufferedUniformBytes is 4 MiB and one mat4 write buffers 4 columns x 16 bytes, so + // this many calls is comfortably past the valve. + constexpr int kFloodWrites = 80000; + + Vector sources; + const Vector programs = LinkBatch(kPrograms, kFirst, sources); + + int flooded = 0; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + const String n = std::to_string(kFirst + i); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + if (SpirvIsSettled(program)) continue; + ++flooded; + + const GLint locModel = GetUniformLocation(program, ("uModel" + n).c_str()); + const GLint locTint = GetUniformLocation(program, ("uTint" + n).c_str()); + ASSERT_GE(locModel, 0); + ASSERT_GE(locTint, 0); + + // A distinctive early value that must survive the valve: it is buffered, and the + // fall-through write has to replay it before writing its own bytes. + const GLfloat earlyTint[3] = {11.0f, 22.0f, 33.0f}; + ProgramUniform3fv(program, locTint, 1, earlyTint); + + GLfloat model[16] = {}; + for (int w = 0; w < kFloodWrites; ++w) { + for (int c = 0; c < 16; ++c) model[c] = static_cast(w) + static_cast(c); + ProgramUniformMatrix4fv(program, locModel, 1, GL_FALSE, model); + } + + // Falling through joined, so the shadow is live and holds BOTH the buffered early + // write and the last direct one. + EXPECT_TRUE(SpirvIsSettled(program)) << "the valve must have fallen through to a joining write"; + GLfloat tintReadback[3] = {}; + GLfloat modelReadback[16] = {}; + GetUniformfv(program, locTint, tintReadback); + GetUniformfv(program, locModel, modelReadback); + for (int c = 0; c < 3; ++c) { + EXPECT_FLOAT_EQ(tintReadback[c], earlyTint[c]) + << "the pre-valve buffered write was lost at component " << c; + } + for (int c = 0; c < 16; ++c) { + EXPECT_FLOAT_EQ(modelReadback[c], static_cast(kFloodWrites - 1) + static_cast(c)) + << "last-write-wins broke across the valve at component " << c; + } + } + + EXPECT_GT(flooded, 0) << "no program was ever observed inside the A->B window"; + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + // The dedupe property the live write path has, preserved across the detour: replaying a record // that really changes bytes moves the UBO content version, and a bytes-identical write made // AFTER the replay does not move it. Both matter - the first is what makes a backend re-upload @@ -499,52 +714,151 @@ TEST_F(AsyncSpirvPhaseTest, RelinkingOverAPendingSpirvJobIsClean) { const SingleWorkerScope oneWorker; constexpr int kPrograms = 16; constexpr int kFirst = 35000; + constexpr int kRelinkOffset = 500; // distinct uniform names for the second link - Vector sources; - const Vector programs = LinkBatch(kPrograms, kFirst, sources); + // Built by hand rather than through LinkBatch, because the relink has to use DIFFERENT + // source: relinking byte-identical source cannot tell the second link's artifacts from + // the first's, so it would pass even if Link()'s prologue stopped resetting m_spirv. + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + Vector firstSources; + Vector secondSources; + Vector programs; + Vector fragmentShaders; + for (int i = 0; i < kPrograms; ++i) { + firstSources.push_back(MakeUniformSource(kFirst + i)); + const char* text = firstSources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + fragmentShaders.push_back(fs); + } int relinked = 0; for (int i = 0; i < kPrograms; ++i) { const GLuint program = programs[static_cast(i)]; + const String firstName = "uTint" + std::to_string(kFirst + i); ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); if (SpirvIsSettled(program)) continue; - // Relink while phase B is queued. + // A write buffered against link #1, which the relink must DROP. If CancelLink stopped + // clearing the buffers, these bytes would be replayed into link #2's shadow at + // whatever offset its own routing tables assigned - silently overwriting a uniform + // GL 4.6 core 7.6 requires a relink to have reset to zero. + const GLint firstTint = GetUniformLocation(program, firstName.c_str()); + ASSERT_GE(firstTint, 0); + const GLfloat poison[3] = {123.0f, 456.0f, 789.0f}; + ProgramUniform3fv(program, firstTint, 1, poison); + ASSERT_FALSE(SpirvIsSettled(program)) << "the poison write should have been buffered, not applied"; + + // Relink, with different source, while phase B is queued. + secondSources.push_back(MakeUniformSource(kFirst + kRelinkOffset + i)); + const char* secondText = secondSources.back().c_str(); + ShaderSource(fragmentShaders[static_cast(i)], 1, &secondText, nullptr); + CompileShader(fragmentShaders[static_cast(i)]); LinkProgram(program); ++relinked; EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + // The artifacts really are the SECOND link's: the first link's uniform is gone. + EXPECT_EQ(GetUniformLocation(program, firstName.c_str()), -1) + << "the relink is still answering out of the previous link's reflection"; + const String secondName = "uTint" + std::to_string(kFirst + kRelinkOffset + i); + const GLint secondTint = GetUniformLocation(program, secondName.c_str()); + ASSERT_GE(secondTint, 0) << secondName; + const auto& object = Object(program); ASSERT_NE(object, nullptr); object->JoinLinkAndSpirv(); EXPECT_TRUE(object->GetSpirvStatus()) << "the relink's own phase B must have produced SPIR-V"; EXPECT_FALSE(object->GetGeneratedSpirv().empty()); + + // And the dropped buffer really was dropped: a freshly linked program's uniforms read + // back as zero. + GLfloat tint[3] = {-1.0f, -1.0f, -1.0f}; + GetUniformfv(program, secondTint, tint); + for (int c = 0; c < 3; ++c) { + EXPECT_FLOAT_EQ(tint[c], 0.0f) + << "a uniform write buffered against the cancelled link leaked into the relink, component " << c; + } } EXPECT_GT(relinked, 0) << "no relink ever landed inside the A->B window"; EXPECT_EQ(GetError(), GL_NO_ERROR); } -// Destroying a program whose phase B is still queued must not wait for it, and must not leave -// anything behind that a later join could block on. +// Destroying a program whose phase B is still queued must ABANDON it, not wait for it. The +// property is timed rather than asserted structurally, and self-calibrated: deleting a whole +// batch of programs with outstanding SPIR-V jobs has to cost a small fraction of what draining +// the same number of jobs costs. Replacing CancelLink's cooperative Cancel() with a Wait() +// would make the two times equal - which is precisely the GL-thread stall the design forbids, +// and which the previous shape of this case could not see. TEST_F(AsyncSpirvPhaseTest, DeletingAProgramWithAPendingSpirvJobDoesNotBlock) { const AsyncModeScope async(true); const SingleWorkerScope oneWorker; - constexpr int kPrograms = 16; + constexpr int kPrograms = 24; + constexpr int kHalf = kPrograms / 2; constexpr int kFirst = 36000; Vector sources; const Vector programs = LinkBatch(kPrograms, kFirst, sources); - int deleted = 0; + // Settle phase A for every program without touching phase B. for (int i = 0; i < kPrograms; ++i) { - const GLuint program = programs[static_cast(i)]; - ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); - if (!SpirvIsSettled(program)) ++deleted; - DeleteProgram(program); + ASSERT_EQ(QueryLinkStatus(programs[static_cast(i)]), GL_TRUE) + << QueryProgramInfoLog(programs[static_cast(i)]); } - EXPECT_GT(deleted, 0) << "no program was deleted inside the A->B window"; + int outstandingBeforeDelete = 0; + for (int i = 0; i < kHalf; ++i) { + const GLuint program = programs[static_cast(i)]; + if (!SpirvIsSettled(program)) ++outstandingBeforeDelete; + // Buffered writes on the destruction path, so CancelLink's drop of + // m_pendingUniformWrites/m_pendingUniformBytes is exercised rather than assumed. + const String n = std::to_string(kFirst + i); + const GLint locTint = GetUniformLocation(program, ("uTint" + n).c_str()); + if (locTint >= 0) { + const GLfloat tint[3] = {1.0f, 2.0f, 3.0f}; + ProgramUniform3fv(program, locTint, 1, tint); + } + } + + const auto deleteStart = std::chrono::steady_clock::now(); + for (int i = 0; i < kHalf; ++i) { + DeleteProgram(programs[static_cast(i)]); + } + const auto deleteEnd = std::chrono::steady_clock::now(); + + // The calibration run: the same number of phase-B jobs, actually drained. Counted first, + // so a machine that drained everything in the background cannot turn the bound below into + // a comparison between two zeroes without saying so. + int outstandingBeforeDrain = 0; + for (int i = kHalf; i < kPrograms; ++i) { + if (!SpirvIsSettled(programs[static_cast(i)])) ++outstandingBeforeDrain; + } + const auto drainStart = std::chrono::steady_clock::now(); + for (int i = kHalf; i < kPrograms; ++i) { + const auto& object = Object(programs[static_cast(i)]); + ASSERT_NE(object, nullptr); + object->JoinLinkAndSpirv(); + } + const auto drainEnd = std::chrono::steady_clock::now(); + + const auto deleteUs = + std::chrono::duration_cast(deleteEnd - deleteStart).count(); + const auto drainUs = std::chrono::duration_cast(drainEnd - drainStart).count(); + + EXPECT_GT(outstandingBeforeDelete, 0) << "no program was deleted inside the A->B window"; + EXPECT_GT(outstandingBeforeDrain, 0) << "nothing was left to drain, so the timing bound has no calibration"; + if (outstandingBeforeDelete > 0 && outstandingBeforeDrain > 0 && drainUs > 2000) { + EXPECT_LT(deleteUs, drainUs / 4) + << "deleting " << kHalf << " programs with outstanding SPIR-V jobs took " << deleteUs + << " us against " << drainUs << " us to drain the same number - glDeleteProgram is waiting for them"; + } EXPECT_EQ(GetError(), GL_NO_ERROR); } @@ -556,12 +870,37 @@ TEST_F(AsyncSpirvPhaseTest, DeletingAProgramWithAPendingSpirvJobDoesNotBlock) { // implementation. The split runs two bodies instead of one, in order, on the calling thread - // and the artifacts it produces must equal what the asynchronous path produces. TEST_F(AsyncSpirvPhaseTest, AsyncOffAndAsyncOnProduceIdenticalSpirvAndShadow) { + // The ASYNC arm runs FIRST, deliberately. Both arms must compile the same source text for + // their SPIR-V to be comparable, and the first arm to run is the one that pays for the + // cold path: it misses the per-context ShaderPreprocessCache and therefore executes + // PreprocessShaderSource, the reserved-identifier scan and both lexical extractions. Run + // the sync arm first and the async arm becomes a cache hit that never runs any of that on + // a worker - which is exactly the half this case exists to compare. + const SingleWorkerScope oneWorker; + + Vector asyncDigest; + Uint asyncUboSize = 0; + Vector asyncOffsets; Vector syncDigest; Uint syncUboSize = 0; Vector syncOffsets; const auto buildOnce = [&](const Bool async, Vector& digest, Uint& uboSize, Vector& offsets) { const AsyncModeScope scope(async); + // A witness that this arm really ran in the mode it claims: without it, any ambient + // reason for AsyncShaderCompileActive() to be false degrades the case to sync-vs-sync + // and it still passes. + ASSERT_EQ(MG_Util::Async::AsyncShaderCompileActive(), async) + << "the arm did not run in the mode it was asked for"; + + // Give the single worker a backlog to chew on, so "was it actually asynchronous" is a + // deterministic observation rather than a race with a fast pool. + Vector backlogSources; + Vector backlog; + if (async) { + backlog = LinkBatch(6, 37500, backlogSources); + } + const String source = MakeUniformSource(37000); const char* text = source.c_str(); const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); @@ -572,29 +911,35 @@ TEST_F(AsyncSpirvPhaseTest, AsyncOffAndAsyncOnProduceIdenticalSpirvAndShadow) { AttachShader(program, vs); AttachShader(program, fs); LinkProgram(program); - ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); const auto& object = Object(program); ASSERT_NE(object, nullptr); - if (!async) { + if (async) { + // Nothing has been read yet, and the worker is busy with the backlog: the link + // must genuinely still be outstanding. + EXPECT_FALSE(object->IsLinkComplete()) << "the async arm settled before anything read it"; + } else { // The whole point of the mode: nothing is outstanding when glLinkProgram returns. EXPECT_TRUE(object->IsLinkComplete()); } + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); digest = SpirvDigest(program); uboSize = object->GetUBOSize(); for (Uint location = 0; location <= object->GetMaxUniformLocation(); ++location) { offsets.push_back(object->GetUniformOffset(location)); } EXPECT_TRUE(object->GetSpirvStatus()); + + for (const GLuint backlogProgram : backlog) { + DeleteProgram(backlogProgram); + } }; - buildOnce(false, syncDigest, syncUboSize, syncOffsets); - ASSERT_FALSE(syncDigest.empty()); - - Vector asyncDigest; - Uint asyncUboSize = 0; - Vector asyncOffsets; buildOnce(true, asyncDigest, asyncUboSize, asyncOffsets); + ASSERT_FALSE(asyncDigest.empty()); + + buildOnce(false, syncDigest, syncUboSize, syncOffsets); EXPECT_EQ(asyncDigest, syncDigest) << "the two modes produced different SPIR-V"; EXPECT_EQ(asyncUboSize, syncUboSize); diff --git a/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp b/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp index 45de28e1..f3410593 100644 --- a/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp +++ b/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp @@ -341,6 +341,54 @@ TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesI EXPECT_EQ(GetError(), GL_NO_ERROR); } +// The same obligation, but for LINKS that are already in flight when the zero count arrives - +// and specifically for BOTH phases of one. A link is two chained jobs now (ProgramLinkTask, +// then ProgramSpirvTask), and GL_COMPLETION_STATUS_KHR spans both, so +// ProgramState::JoinAllPendingWork has to settle both or this query reads GL_FALSE in the one +// mode the extension says cannot have anything pending. The case above creates its program +// AFTER the zero count, so it links inline and cannot see this; here the programs are linked +// against a saturated pool BEFORE it. +TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsPendingLinksAndTheirSpirvJobs) { + const AsyncModeScope async(true); + const CompilerThreadScope threads; + MaxShaderCompilerThreadsKHR(1); + + // A backlog first, so the links below cannot all drain before the zero count lands. + Vector sources; + (void)EnqueueBacklog(24, 5000, sources); + + Vector programs; + for (int i = 0; i < 8; ++i) { + sources.push_back(MakeBulkySource(5100 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + CompileShader(vs); // this file's MakeShader only sources; it does not compile + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + } + + int outstanding = 0; + for (const GLuint program : programs) { + if (QueryProgramCompletion(program) == GL_FALSE) ++outstanding; + } + + MaxShaderCompilerThreadsKHR(0); + + for (const GLuint program : programs) { + EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) + << "glMaxShaderCompilerThreadsKHR(0) must leave neither link phase in flight"; + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); + } + EXPECT_GT(outstanding, 0) << "every link had drained before the zero count; this case proved nothing"; + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + // ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a // join, not eglInitialize. That is the documented contract, so it gets an assertion. TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {