diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index 80ea2807..1e4866e4 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -47,8 +47,12 @@ namespace MobileGL::MG_Pipe { - // 0 until the emitters below have bodies; see FramebufferEmit.h's note. - inline constexpr Uint64 kMGPipeWiredProgramSubsystem = 0; + // WIRED. create/bind/delete_shader_state, set_draw_program, set_dispatch_program and + // set_global_constants all have bodies, so this family contributes its bit to + // kMGPipeWiredSubsystems. See SamplerEmit.h's note for what the bit does and does not do: + // it states what this build emits for, and the EMISSION gate is the runtime + // MOBILEGL_PIPE_PUSH mask through the validate point's `wants()`, not this constant. + inline constexpr Uint64 kMGPipeWiredProgramSubsystem = kMGPipeSubsystemPrograms; // D-H6. ~0u is the BACKENDS' "never uploaded" sentinel for a global-constants version, and // ProgramObject::MarkUBOContentDirty skips it on the wrap for exactly that reason. The diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h index 86e0a69d..d2ffc712 100644 --- a/MobileGL/MG_Impl/Pipe/SamplerEmit.h +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -57,8 +57,19 @@ namespace MobileGL::MG_Pipe { - // 0 until the emitters below and in ImageEmit.h have bodies; see FramebufferEmit.h's note. - inline constexpr Uint64 kMGPipeWiredSamplerSubsystem = 0; + // WIRED. The sampler CSO, the sampler view and all three unit sets - set_shader_images + // included, whose emitter lives in ImageEmit.h - have bodies, so this file's family + // contributes its bit to kMGPipeWiredSubsystems. ONE bit for the whole family, because an + // operator switching samplers off has to get the whole family's legacy arm rather than two + // thirds of it. + // + // WHAT THE BIT DOES AND DOES NOT DO, said plainly because it is not what a reader expects: + // it is the honest statement of what this build emits for, and it feeds + // EmittedCallSuppliesTheWholeField's guard. It is NOT the emission gate - the validate + // point's `wants()` asks MGPipeSubsystemForDirty and the runtime MOBILEGL_PIPE_PUSH mask, + // so these emitters go live the moment their bodies exist and the mask carries bit 11. The + // A/B that switches this family off is the MASK, not this constant. + inline constexpr Uint64 kMGPipeWiredSamplerSubsystem = kMGPipeSubsystemSamplers; // --------------------------------------------------------------------------------- // D-F1: the canonical SamplerParameters copy, and why it is not a memcpy @@ -83,10 +94,15 @@ namespace MobileGL::MG_Pipe { // // ONE COPY PER MINT ATTEMPT, never per draw: the version-first skip in the two emitters // below decides whether to come here at all. - inline SamplerParameters MGPipeCanonicalSamplerParameters(const SamplerParameters& src) { + // THE PRIMITIVE TAKES AN OUT-PARAMETER, and that is not a style preference either. A + // returned SamplerParameters is copied, and a copy of a trivially copyable type leaves the + // padding UNSPECIFIED - so a canonicaliser that returned by value would hand its caller a + // value whose three trailing bytes are whatever the copy left there, which is the very + // thing this function exists to make deterministic. Every producer of canonical bytes in + // this file, and every consumer that stores them, goes through this and through memcpy. + inline void MGPipeCanonicaliseSamplerParameters(const SamplerParameters& src, SamplerParameters& canon) { static_assert(std::is_trivially_copyable_v, "the canonical copy is memset and then assigned field by field"); - SamplerParameters canon; std::memset(static_cast(&canon), 0, sizeof(canon)); canon.wrapS = src.wrapS; canon.wrapT = src.wrapT; @@ -109,13 +125,19 @@ namespace MobileGL::MG_Pipe { // of Espryt's redundancy filters compare all four. Dropping this one line is G7's // scripted negative control and SamplerEmit's suite must go red naming it. canon.borderColorForm = src.borderColorForm; - return canon; } inline Uint64 MGPipeHashSamplerParameters(const SamplerParameters& canon) { return XXH64(&canon, sizeof(canon), 0); } + // Canonicalise and hash in one step, for a caller that wants only the hash. + inline Uint64 MGPipeHashOfSamplerParameters(const SamplerParameters& src) { + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(src, canon); + return MGPipeHashSamplerParameters(canon); + } + // --------------------------------------------------------------------------------- // D-F1: the content-addressed sampler CSO cache, capacity 256 // --------------------------------------------------------------------------------- @@ -169,7 +191,8 @@ namespace MobileGL::MG_Pipe { // exactly the sharing that makes content addressing the right answer for this kind. MGPipeHandle Acquire(const SamplerParameters& params, Uint64& payloadBytes) { ++m_counters.Acquisitions; - const SamplerParameters canon = MGPipeCanonicalSamplerParameters(params); + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(params, canon); const Uint64 hash = MGPipeHashSamplerParameters(canon); for (SizeT i = 0; i < m_entries.size(); ++i) { if (m_entries[i].Hash != hash) continue; @@ -245,12 +268,20 @@ namespace MobileGL::MG_Pipe { desc.Parameters.Offset = 0; desc.Parameters.Size = 0; - Entry entry; + // BUILT IN PLACE AND FILLED WITH A MEMCPY, not assigned from a local. This is the + // padding trap one level deeper than the one the design names: an assignment copies + // the sixteen MEMBERS and leaves the three trailing padding bytes of the + // destination at whatever was there, so the next probe's memcmp would reject its + // own entry, count a collision that never happened, evict and mint a fresh CSO - a + // cache with a hit rate of zero whose failure depends on heap contents, which is + // why it passes a case run alone and fails the same case run in a suite. The bytes + // stored here have to be the bytes compared later, padding included. + m_entries.push_back(Entry{}); + Entry& entry = m_entries.back(); entry.Hash = hash; entry.LastUsed = ++m_clock; entry.Cso = cso; - entry.Params = canon; - m_entries.push_back(entry); + std::memcpy(static_cast(&entry.Params), &canon, sizeof(canon)); // The applier is handed the CACHE's copy, so the pointer stays valid for the whole // call and the bytes it stores are provably the bytes the memcmp will confirm // against later. diff --git a/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp index f4ba7db5..44cde3bd 100644 --- a/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp +++ b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp @@ -50,8 +50,14 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include "Init.h" +#include +#include +#include +#include #include #include +#include // create_shader_state takes the two artefact structs by pointer beside the record, so a case // that mints a composite record needs their definitions. #include @@ -347,6 +353,211 @@ TEST(CompositeResolver, ASlotAtTheShaderCsoLimitIsRefusedWhileTheLastBandSlotIsN #endif } +#if !MOBILEGL_PIPE_PUSH +// G2 requires the pull and push ctest name sets to be identical, name for name. +#define MGL_COMPOSITE_RESOLVER_TEST_LIST(X) \ + X(CompositeResolver, ACompositeIsMintedFromTheReservedBand) \ + X(CompositeResolver, ASignatureThatHasNotMovedReusesOneComposite) \ + X(CompositeResolver, TwoPipelinesWithTheSameSignatureKeepTheirOwnComposite) \ + X(CompositeResolver, EvictionThenDestructionFreesTheSlotExactlyOnce) \ + X(CompositeResolver, DestructionThenEvictionFreesTheSlotExactlyOnce) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } +MGL_COMPOSITE_RESOLVER_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + +namespace { + namespace GL = MobileGL::MG_Impl::GLImpl; + using GLContext = MG_State::GLState::GLContext; + using MG_State::GLState::ProgramObject; + + struct ResolverScope { + ResolverScope() { Clear(); } + ~ResolverScope() { + GL::BindProgramPipeline(0); + GL::UseProgram(0); + Clear(); + } + ResolverScope(const ResolverScope&) = delete; + ResolverScope& operator=(const ResolverScope&) = delete; + + static void Clear() { + MGPipeProgramEmitterInstance().Reset(); + MGPipeProgramEmitterInstance().ResetCounters(); + MGPipeCompositeResolverInstance().ResetCounters(); + } + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + + const char* kVs = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + const char* kFs = R"(#version 430 core +out vec4 o_color; +void main() { o_color = vec4(1.0); } +)"; + + // Built by hand rather than through glCreateShaderProgramv, for ProgramPipelineCompositeTest's + // reason: that entry point detaches the shader right after linking, so a relink would leave + // the stage program with nothing to composite from. + GLuint MakeSeparableProgram(GLenum stage, const char* source) { + const GLuint shader = GL::CreateShader(stage); + GL::ShaderSource(shader, 1, &source, nullptr); + GL::CompileShader(shader); + const GLuint program = GL::CreateProgram(); + GL::ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE); + GL::AttachShader(program, shader); + GL::LinkProgram(program); + GLint linked = GL_FALSE; + GL::GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link"; + return program; + } + + GLuint MakeBoundPipeline(GLuint vs, GLuint fs) { + GLuint pipeline = 0; + GL::GenProgramPipelines(1, &pipeline); + GL::BindProgramPipeline(pipeline); + GL::UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + GL::UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + GL::UseProgram(0); + return pipeline; + } + + TEST(CompositeResolver, ACompositeIsMintedFromTheReservedBand) { + ResolverScope scope; + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs); + MakeBoundPipeline(vs, fs); + + const SharedPtr composite = Ctx().GetProgramForDraw(); + ASSERT_TRUE(composite) << "the frontend has to flatten the pipeline for this to mean anything"; + // The composite is the one ProgramObject in the system with external index 0: it is + // deliberately not a named program, must not answer glIsProgram and must not consume a + // name, and glCreateProgram never returns 0. + EXPECT_TRUE(MGPipeProgramIsPipelineComposite(*composite)); + EXPECT_EQ(composite->GetExternalIndex(), 0u); + + ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u); + const MGPipeHandle cso = MGPipeProgramEmitterInstance().DrawCso(); + ASSERT_FALSE(MGPipeHandleIsNull(cso)); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(cso.Slot)) + << "a composite's slot comes out of the reserved band and nowhere else"; + // AND IT IS AN ORDINARY create_shader_state. The server never learns it is a composite. + EXPECT_EQ(MGPipeProgramEmitterInstance().LastProgramDesc().Cso, cso); + EXPECT_TRUE(MGPipeProgramEmitterInstance().RecordIsPublished(cso)); + EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Mints, 1u); + } + + TEST(CompositeResolver, ASignatureThatHasNotMovedReusesOneComposite) { + ResolverScope scope; + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs); + MakeBoundPipeline(vs, fs); + + ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u); + const MGPipeHandle first = MGPipeProgramEmitterInstance().DrawCso(); + ASSERT_FALSE(MGPipeHandleIsNull(first)); + ASSERT_EQ(MGPipeProgramEmitterInstance().CreateCount(), 1u); + + // The stage set did not move, so the frontend hands back the cached composite and the + // resolver reuses its handle - no second identity, no second record, and nothing + // released. THE KEY IS ComputeDrawProgramSignature's {lifetimeId, linkVersion} array + // and deliberately NOT GetBackendStateVersion, which a glUniform1i to a sampler moves + // and which used to rebuild the composite on every draw. + MGPipeProgramEmitterInstance().EmitShaderState(Ctx()); + EXPECT_EQ(MGPipeProgramEmitterInstance().DrawCso(), first); + EXPECT_EQ(MGPipeProgramEmitterInstance().CreateCount(), 1u); + EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Releases, 0u); + EXPECT_GE(MGPipeCompositeResolverInstance().GetCounters().Reuses, 1u); + } + + // [deviation] The brief names this case "TwoPipelinesWithTheSameSignatureShareOneComposite". + // Sharing one HANDLE between two pipeline objects is not implementable safely and the + // property that is true is the opposite one, so the case is named for what it asserts. + // + // The reason is the death path: a composite is an ordinary ProgramObject with its OWN + // lifetime id, and the client-side death helper resolves the handle FROM that lifetime id. + // Two composites sharing one handle would put only one of the two ids in the allocator's + // map, so the first ~ProgramObject would free a slot the second still names - a premature + // free that reappears later as slot theft, which is the exact class this whole band exists + // to prevent. The frontend does not share either: each ProgramPipelineObject carries its + // own one-slot draw-program cache, so two pipeline objects with identical stage sets are + // two composites in the frontend too. + TEST(CompositeResolver, TwoPipelinesWithTheSameSignatureKeepTheirOwnComposite) { + ResolverScope scope; + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs); + + MakeBoundPipeline(vs, fs); + ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u); + const MGPipeHandle firstCso = MGPipeProgramEmitterInstance().DrawCso(); + ASSERT_FALSE(MGPipeHandleIsNull(firstCso)); + + MakeBoundPipeline(vs, fs); // a SECOND pipeline object, the same stage set + MGPipeProgramEmitterInstance().EmitShaderState(Ctx()); + const MGPipeHandle secondCso = MGPipeProgramEmitterInstance().DrawCso(); + ASSERT_FALSE(MGPipeHandleIsNull(secondCso)); + + EXPECT_NE(firstCso, secondCso); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(firstCso.Slot)); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(secondCso.Slot)); + // Both are still resolvable, which is the property a shared handle would have broken. + EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, firstCso)); + EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, secondCso)); + } + + // THE TWO RELEASE ORDERS, driven at the level where both of them are representable. Either + // order frees the slot exactly once and the second call is a PROVEN no-op, because + // MGPipeSlotAllocator::Free refuses a slot that is not live at that generation and bumps no + // generation of its own - so a double release cannot skip a generation either. + TEST(CompositeResolver, EvictionThenDestructionFreesTheSlotExactlyOnce) { + ResolverScope scope; + constexpr Uint64 kCompositeLifetimeId = 918273645ull; + const Uint32 liveBefore = MGPipeSlots().LiveCount(MGPipeKind::ShaderCso); + const MGPipeHandle cso = MGPipeSlots().AllocateComposite(kCompositeLifetimeId); + ASSERT_FALSE(MGPipeHandleIsNull(cso)); + ASSERT_TRUE(MGPipeIsCompositeShaderSlot(cso.Slot)); + ASSERT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore + 1); + + // 1. the pipeline cache drops it + MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId); + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, cso)); + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore); + // 2. and then ~ProgramObject runs, and finds nothing to do + MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId); + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore); + } + + TEST(CompositeResolver, DestructionThenEvictionFreesTheSlotExactlyOnce) { + ResolverScope scope; + constexpr Uint64 kCompositeLifetimeId = 918273646ull; + const Uint32 liveBefore = MGPipeSlots().LiveCount(MGPipeKind::ShaderCso); + const MGPipeHandle cso = MGPipeSlots().AllocateComposite(kCompositeLifetimeId); + ASSERT_FALSE(MGPipeHandleIsNull(cso)); + + // The mirror order, and it is the one that actually happens today: the frontend's + // one-slot cache drops its SharedPtr as it overwrites it, so ~ProgramObject usually + // runs first and the resolver's release is the second, redundant path. + MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId); + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, cso)); + MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId); + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore); + + // And the slot really came back: the next composite is handed the same slot with a + // bumped generation, so the stale handle can never resolve to it. + const MGPipeHandle recycled = MGPipeSlots().AllocateComposite(kCompositeLifetimeId + 1); + EXPECT_EQ(recycled.Slot, cso.Slot); + EXPECT_NE(recycled.Gen, cso.Gen); + MGPipeSlots().Free(MGPipeKind::ShaderCso, recycled); + } +} // namespace +#endif // MOBILEGL_PIPE_PUSH + int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = @@ -358,6 +569,9 @@ int main(int argc, char** argv) { _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); #else setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif +#if MOBILEGL_PIPE_PUSH + MobileGL::Initialize(); #endif ::testing::InitGoogleTest(&argc, argv); const int rc = RUN_ALL_TESTS(); diff --git a/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp index ce8747fc..6387c652 100644 --- a/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp @@ -6,20 +6,16 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P4a's image-unit set: set_shader_images, the third of the three kVarTail unit sets. It rides -// the sampler family's subsystem bit - one family, one A/B - and has its own suite because its -// content hash has to cover two fields the other two sets do not carry. +// P4a's third kVarTail unit set, set_shader_images. It rides the sampler family's subsystem +// bit: one family, one A/B. // -// THE TWO CASES THIS SUITE EXISTS FOR: an ACCESS-mode change alone, and an INTERNAL-FORMAT -// change alone, each has to move the hash and emit the set. Both are live glBindImageTexture -// state, the format-less image bake keys on the format the shader was built against, and a -// hash over the bindings alone would suppress exactly the record that says the bake is stale. -// The behavioural gates beside them are the format-less bake and non-core-format scenarios, -// and the photon fixture on desktop retrace - the only fixture that has ever caught an -// image-binding-semantics regression, and one that must never be run on the Adreno. +// WHAT THIS SUITE IS FOR. The image set is the one whose ContentHash has to cover more than +// the binding: InternalFormat and Access are live glBindImageTexture state that the +// format-less image bake keys on, so a set whose only movement is an access mode still has to +// go out. And the zero early-out is the property an optimisation deletes by accident - it is +// what makes every draw of every application that never binds an image pay one integer test. // -// THE SUITE IS `ImageEmit`, not `ImageEmitTest`: the file is XTest.cpp and the suite is X, -// this directory's convention, and it is what the gates grep for. +// THE SUITE IS `ImageEmit`, not `ImageEmitTest`: the file is XTest.cpp and the suite is X. // // THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. // @@ -48,8 +44,14 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include "Init.h" +#include +#include #include +#include +#include #include +#include #endif using namespace MobileGL; @@ -157,10 +159,9 @@ namespace { TEST(ImageEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { #if MOBILEGL_PIPE_PUSH EXPECT_EQ(&MGPipeImageEmitterInstance(), &MGPipeImageEmitterInstance()); - // The image set's window is bounded by the same merged unit space the other two sets use; - // there is no separate image-unit capacity and there must not be one, because a record - // whose window is checked against a different bound from the array it indexes is the shape - // the applier's Fatal{ProtocolCorruption} exists to make impossible. + // The image set has no bit of its own: set_shader_images rides the SAMPLER subsystem, + // because the three unit sets are one family and an operator switching them off has to get + // the whole family's legacy arm. EXPECT_EQ(kMGPipeMaxImageUnits, kMGPipeMaxTextureUnits); #else GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; @@ -323,6 +324,184 @@ TEST(ImageEmit, AMakeCurrentClearsTheImageSetAndAdvancesItsSerial) { #endif } +#if !MOBILEGL_PIPE_PUSH +// G2 requires the pull and push ctest name sets to be identical, name for name. +#define MGL_IMAGE_EMIT_TEST_LIST(X) \ + X(ImageEmit, AZeroHighWaterMarkEmitsNothingWithoutHashing) \ + X(ImageEmit, AnAccessModeChangeAloneStillEmitsTheSet) \ + X(ImageEmit, AnInternalFormatChangeAloneStillEmitsTheSet) \ + X(ImageEmit, TheApplicationsFormatAndAccessTravelUnrecast) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } +MGL_IMAGE_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + +namespace { + namespace GL = MobileGL::MG_Impl::GLImpl; + using GLContext = MG_State::GLState::GLContext; + + struct EmitterScope { + EmitterScope() { Clear(); } + ~EmitterScope() { Clear(); } + EmitterScope(const EmitterScope&) = delete; + EmitterScope& operator=(const EmitterScope&) = delete; + + static void Clear() { + MGPipeImageEmitterInstance().Reset(); + MGPipeImageEmitterInstance().ResetCounters(); + MGPipeProgramOpaqueUnitsShared().Invalidate(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + MGPipeImageEmitter& Emitter() { return MGPipeImageEmitterInstance(); } + + GLuint MakeComputeProgram(const char* source) { + const GLuint shader = GL::CreateShader(GL_COMPUTE_SHADER); + GL::ShaderSource(shader, 1, &source, nullptr); + GL::CompileShader(shader); + const GLuint program = GL::CreateProgram(); + GL::AttachShader(program, shader); + GL::LinkProgram(program); + GLint linked = GL_FALSE; + GL::GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "the compute program did not link"; + return program; + } + + GLuint MakeImageTexture() { + GLuint name = 0; + GL::GenTextures(1, &name); + GL::BindTexture(GL_TEXTURE_2D, name); + GL::TexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4); + return name; + } + + // PROPERTY 1 OF D-G4, and it is the one an optimisation deletes: an application that never + // binds an image pays one integer test per draw, taken BEFORE any hash and before any + // 192-entry walk. + // + // The frontend has no image-unit high-water mark of its own - NoteUnitTouched is the + // TEXTURE-unit path and glBindImageTexture does not reach it - so the window is derived + // from the highest image unit the CURRENT PROGRAM names. A program with no image uniform + // names none, and the set is not emitted at all. + TEST(ImageEmit, AZeroHighWaterMarkEmitsNothingWithoutHashing) { + EmitterScope scope; + static const char* kNoImages = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 0) buffer Out { uint value; } outBuf; +void main() { outBuf.value = 1u; } +)"; + const GLuint program = MakeComputeProgram(kNoImages); + GL::UseProgram(program); + + // A texture IS bound to an image unit. The set still does not go out, because no + // shader can read it - which is the whole point of deriving the window from the + // program rather than walking 192 units to find out. + const GLuint texture = MakeImageTexture(); + GL::BindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + + EXPECT_EQ(Emitter().EmitShaderImages(Ctx()), 0u); + EXPECT_EQ(Emitter().ImageSetCount(), 0u); + EXPECT_EQ(Emitter().Window(), 0u); + GL::UseProgram(0); + } + + const char* kImageCompute = R"(#version 430 core +layout(local_size_x = 1) in; +layout(binding = 1, rgba8) uniform image2D img; +void main() { imageStore(img, ivec2(0), vec4(1.0)); } +)"; + + // The record carries the APPLICATION's format and access verbatim. The bind-format recast - + // a GL_RG32F bind is INVALID_VALUE on most non-core formats on Adreno - and the + // buffer-texture split view are SERVER-side and stay there, so a client that pre-applied + // either of them would be answering a driver question from the wrong side of the boundary. + TEST(ImageEmit, TheApplicationsFormatAndAccessTravelUnrecast) { + EmitterScope scope; + const GLuint program = MakeComputeProgram(kImageCompute); + GL::UseProgram(program); + const GLuint texture = MakeImageTexture(); + GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8); + + // THE FIXTURE HAS TO SET UP WHAT THE CASE IS ABOUT, and it is asserted rather than + // assumed: the window comes from the program's own image uniforms, so a shader whose + // image uniform did not reach the reflection would make every case below pass for the + // wrong reason - by emitting nothing at all. + const SharedPtr& object = Ctx().GetProgramObject(program); + ASSERT_TRUE(object); + const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(object.get()); + ASSERT_EQ(resolution.MaxImageUnit, 1) + << "maxUniformLocation=" << object->GetMaxUniformLocation() + << " unit@0=" << object->GetUniformSamplerOrImageUnitIndex(0) + << " linked=" << object->GetLinkStatus(); + + // ASSERTED ON THE COUNTER, NOT ON THIS CALL'S RETURN VALUE, and the reason is worth + // writing down because it surprised this suite: the glBindImageTexture above ALREADY + // reached the validate point and emitted the set, so a direct call afterwards is + // correctly suppressed as unchanged. What the case is about is what went out, not who + // sent it. + Emitter().EmitShaderImages(Ctx()); + ASSERT_GE(Emitter().ImageSetCount(), 1u); + ASSERT_GE(Emitter().LastShaderImages().Count, 2u); + const MGPImageView& view = Emitter().LastImageViews()[1]; + EXPECT_EQ(view.Unit, 1u); + EXPECT_FALSE(MGPipeHandleIsNull(view.Res)); + EXPECT_EQ(view.InternalFormat, static_cast(GL_RGBA8)); + EXPECT_EQ(view.Access, 1u) << "GL_WRITE_ONLY, folded into the one byte the wire carries"; + EXPECT_EQ(view.Level, 0u); + EXPECT_EQ(view.Layered, 0u); + EXPECT_EQ(view.Layer, 0u); + GL::UseProgram(0); + } + + TEST(ImageEmit, AnAccessModeChangeAloneStillEmitsTheSet) { + EmitterScope scope; + const GLuint program = MakeComputeProgram(kImageCompute); + GL::UseProgram(program); + const GLuint texture = MakeImageTexture(); + GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + Emitter().EmitShaderImages(Ctx()); + const Uint64 before = Emitter().ImageSetCount(); + ASSERT_GE(before, 1u); + ASSERT_EQ(Emitter().LastImageViews()[1].Access, 0u) << "GL_READ_ONLY"; + + // The same texture, the same unit, the same format - only the access mode moves. The + // hash has to cover it, or a shader that now writes where it used to read is bound with + // the previous barrier and coherence semantics. + GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8); + Emitter().EmitShaderImages(Ctx()); + EXPECT_GT(Emitter().ImageSetCount(), before); + EXPECT_EQ(Emitter().LastImageViews()[1].Access, 2u); + GL::UseProgram(0); + } + + TEST(ImageEmit, AnInternalFormatChangeAloneStillEmitsTheSet) { + EmitterScope scope; + const GLuint program = MakeComputeProgram(kImageCompute); + GL::UseProgram(program); + const GLuint texture = MakeImageTexture(); + GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + Emitter().EmitShaderImages(Ctx()); + const Uint64 before = Emitter().ImageSetCount(); + ASSERT_GE(before, 1u); + ASSERT_EQ(Emitter().LastImageViews()[1].InternalFormat, static_cast(GL_RGBA8)); + + // The format the shader was built against is live glBindImageTexture state, and the + // format-less image bake keys on it: a set suppressed because "the binding did not + // move" would leave the server baking against the previous format. + GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8UI); + Emitter().EmitShaderImages(Ctx()); + EXPECT_GT(Emitter().ImageSetCount(), before); + EXPECT_EQ(Emitter().LastImageViews()[1].InternalFormat, static_cast(GL_RGBA8UI)); + GL::UseProgram(0); + } +} // namespace +#endif // MOBILEGL_PIPE_PUSH + int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = @@ -334,6 +513,9 @@ int main(int argc, char** argv) { _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); #else setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif +#if MOBILEGL_PIPE_PUSH + MobileGL::Initialize(); #endif ::testing::InitGoogleTest(&argc, argv); const int rc = RUN_ALL_TESTS(); diff --git a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp index cce1a5da..01b073bf 100644 --- a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp @@ -6,20 +6,23 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P4a's program family: create/bind/delete_shader_state, set_draw_program, +// P4a's program family on the client: create/bind/delete_shader_state, set_draw_program, // set_dispatch_program and set_global_constants. // -// THE ONE PIN THAT IS EASIEST TO LOSE AND WORST TO LOSE: set_global_constants' Version is -// GetUBOContentVersion(), and ~0u is the BACKENDS' "never uploaded" sentinel - the wrap skips -// it - so the client must never emit it. A record carrying the sentinel would tell a backend -// that a block it has just been handed was never uploaded. +// THE TWO PROPERTIES THIS SUITE EXISTS FOR, and both are invisible from the emitted bytes: +// * THE STAGE MASK COMES FROM THE LINKED SNAPSHOT, never from the live attach list. +// glAttachShader and glCompileShader take effect only at the NEXT link and neither moves +// the link version, so a descriptor built from the attach list describes a program that +// does not exist yet - and it would agree with nothing, because the SPIR-V array beside it +// is indexed by the snapshot. +// * THE EMITTER JOINS AND THE TRACKER DOES NOT. Bit 6's shutter reads GetCurrentProgram() +// deliberately and not GetProgramForDraw(), because the tracker must not force a compile +// just to answer "did the shader move"; the join belongs to the emitter, which makes the +// same call the verb is about to make anyway. // -// THE SUITE IS `ProgramEmit`, not `ProgramEmitTest`: the file is XTest.cpp and the suite is X, -// this directory's convention, and it is what the gates grep for. +// THE SUITE IS `ProgramEmit`, not `ProgramEmitTest`: the file is XTest.cpp and the suite is X. // -// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT: the -// applier-side cases are the wire commits' and the emitter-side cases are the client -// package's, and neither has to come back to MG_Test/Pipe/CMakeLists.txt to add one. +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. // // IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull // build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the @@ -46,12 +49,17 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include "Init.h" +#include #include +#include +#include #include // The applier takes the two artefact structs BY POINTER beside the record, so a case that // drives create_shader_state needs their definitions - the applier's own header deliberately // only forward-declares them. #include +#include #endif using namespace MobileGL; @@ -161,10 +169,6 @@ TEST(ProgramEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { EXPECT_EQ(&MGPipeProgramEmitterInstance(), &MGPipeProgramEmitterInstance()); EXPECT_TRUE(kMGPipeWiredProgramSubsystem == 0 || kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms); - // The record the applier starts from carries the sentinel, not 0: a program that has never - // published a default-uniform-block image must not look like one that published version 0. - const MGPipeShaderCsoRecord fresh{}; - EXPECT_EQ(fresh.GlobalConstantsVersion, ~Uint32{0}); #else GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; #endif @@ -471,6 +475,230 @@ TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot #endif } +#if !MOBILEGL_PIPE_PUSH +// G2 requires the pull and push ctest name sets to be identical, name for name. +#define MGL_PROGRAM_EMIT_TEST_LIST(X) \ + X(ProgramEmit, TheStageMaskComesFromTheLinkedSnapshotAndNotTheAttachList) \ + X(ProgramEmit, TheNeverUploadedSentinelIsNeverEmitted) \ + X(ProgramEmit, TheEmitterJoinsAndTheTrackerDoesNot) \ + X(ProgramEmit, AReLinkReIssuesOnTheSameHandle) \ + X(ProgramEmit, TheDrawAndDispatchProgramsAreTwoIndependentSlots) \ + X(ProgramEmit, AnUnchangedProgramEmitsNothingAtAll) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } +MGL_PROGRAM_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + +namespace { + namespace GL = MobileGL::MG_Impl::GLImpl; + using GLContext = MG_State::GLState::GLContext; + using MG_State::GLState::ProgramObject; + + struct EmitterScope { + EmitterScope() { Clear(); } + ~EmitterScope() { + GL::UseProgram(0); + Clear(); + } + EmitterScope(const EmitterScope&) = delete; + EmitterScope& operator=(const EmitterScope&) = delete; + + static void Clear() { + MGPipeProgramEmitterInstance().Reset(); + MGPipeProgramEmitterInstance().ResetCounters(); + } + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + MGPipeProgramEmitter& Emitter() { return MGPipeProgramEmitterInstance(); } + + const char* kVs = R"(#version 430 core +uniform vec4 u_value; +void main() { gl_Position = u_value; } +)"; + const char* kFs = R"(#version 430 core +out vec4 o_color; +void main() { o_color = vec4(1.0); } +)"; + const char* kGs = R"(#version 430 core +layout(points) in; +layout(points, max_vertices = 1) out; +void main() { gl_Position = vec4(0.0); EmitVertex(); } +)"; + + GLuint MakeShader(GLenum stage, const char* source) { + const GLuint shader = GL::CreateShader(stage); + GL::ShaderSource(shader, 1, &source, nullptr); + GL::CompileShader(shader); + return shader; + } + + GLuint MakeVsFsProgram() { + const GLuint program = GL::CreateProgram(); + GL::AttachShader(program, MakeShader(GL_VERTEX_SHADER, kVs)); + GL::AttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFs)); + GL::LinkProgram(program); + GLint linked = GL_FALSE; + GL::GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "the vertex/fragment program did not link"; + return program; + } + + constexpr Uint32 StageBit(ShaderStage stage) { return Uint32{1} << static_cast(stage); } + + // ProgramObject.h says it in as many words and this is the case that holds it: a stage mask + // built from the ATTACH list would describe a program that does not exist yet, because + // glAttachShader takes effect only at the next link and does not move the link version. + // The SPIR-V array beside the mask is indexed by the same snapshot, so the two halves of + // the descriptor agree by construction rather than by care. + TEST(ProgramEmit, TheStageMaskComesFromTheLinkedSnapshotAndNotTheAttachList) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + const SharedPtr& program = Ctx().GetProgramObject(name); + ASSERT_TRUE(program); + const Uint32 linkedMask = MGPipeStageMaskOf(*program); + EXPECT_EQ(linkedMask, StageBit(ShaderStage::Vertex) | StageBit(ShaderStage::Fragment)); + + // A third stage is attached and NOT linked. The live attach list now has three + // shaders; the mask must not move. + GL::AttachShader(name, MakeShader(GL_GEOMETRY_SHADER, kGs)); + EXPECT_EQ(program->GetAttachedShaders().size(), 3u) << "the attach really has to land"; + EXPECT_EQ(MGPipeStageMaskOf(*program), linkedMask) + << "glAttachShader takes effect at the NEXT link and moves no link version"; + + GL::LinkProgram(name); + GLint linked = GL_FALSE; + GL::GetProgramiv(name, GL_LINK_STATUS, &linked); + if (linked == GL_TRUE) { + EXPECT_EQ(MGPipeStageMaskOf(*program), linkedMask | StageBit(ShaderStage::Geometry)) + << "and after the relink the snapshot really does carry it"; + } + } + + // D-H6. ~0u is the backends' "never uploaded" sentinel and the frontend's own wrap skips + // it; the client must never put it on the wire either, or a server would read its own + // record as "nothing has ever been uploaded here" and re-upload for ever. + // + // PINNED AS A PREDICATE rather than by driving the counter to ~0u, and the reason is + // written down instead of hidden: reaching that value takes four billion + // MarkUBOContentDirty calls, which is not a test. The predicate is the thing + // EmitGlobalConstants consults, so pinning it pins the behaviour, and a deletion of the + // guard is a compile error here. + TEST(ProgramEmit, TheNeverUploadedSentinelIsNeverEmitted) { + EmitterScope scope; + EXPECT_EQ(kMGPipeGlobalConstantsNeverUploaded, ~Uint32{0}); + EXPECT_FALSE(MGPipeGlobalConstantsVersionIsEmittable(kMGPipeGlobalConstantsNeverUploaded)); + EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(0u)); + EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(1u)); + EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(~Uint32{0} - 1u)); + + // And the live path never produces it either: whatever the frontend's counter is at, + // the record the emitter last built carries an emittable version. + const GLuint name = MakeVsFsProgram(); + GL::UseProgram(name); + const SharedPtr& program = Ctx().GetProgramObject(name); + ASSERT_TRUE(program); + program->MarkUBOContentDirty(); + if (Emitter().EmitGlobalConstants(Ctx()) > 0u) { + EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(Emitter().LastGlobalConstants().Version)); + EXPECT_EQ(Emitter().LastGlobalConstants().Version, program->GetUBOContentVersion()); + EXPECT_EQ(Emitter().LastGlobalConstants().Blob.Size, 0u) + << "the one Blob rule: a monolith emission does not declare its blob"; + } + } + + // D-H4, and it is a statement about the TRACKER as much as about the emitter: Update() may + // not move a program's link completeness in either direction, because answering "did the + // shader move" from a version counter is what keeps a compile off the dirty walk. The + // emitter is where the join belongs, and it is the same GetProgramForDraw() the verb is + // about to make anyway. + TEST(ProgramEmit, TheEmitterJoinsAndTheTrackerDoesNot) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + GL::UseProgram(name); + const SharedPtr& program = Ctx().GetProgramObject(name); + ASSERT_TRUE(program); + + const Bool completeBefore = program->IsLinkComplete(); + MGPipeTrackerInstance().Update(Ctx(), MGPipeVerbClass::kDraw); + EXPECT_EQ(program->IsLinkComplete(), completeBefore) + << "the dirty walk must not force a compile, in either direction"; + + Emitter().EmitShaderState(Ctx()); + EXPECT_TRUE(program->IsLinkComplete()) << "the emitter joins, because the verb would"; + MGPipeTrackerInstance().Reset(); + } + + TEST(ProgramEmit, AReLinkReIssuesOnTheSameHandle) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + GL::UseProgram(name); + const SharedPtr& program = Ctx().GetProgramObject(name); + ASSERT_TRUE(program); + + ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u); + ASSERT_EQ(Emitter().CreateCount(), 1u); + const MGPipeHandle cso = Emitter().LastProgramDesc().Cso; + EXPECT_FALSE(MGPipeHandleIsNull(cso)); + EXPECT_EQ(Emitter().DrawCso(), cso); + + // Nothing moved: no second record, no second bind. + Emitter().EmitShaderState(Ctx()); + EXPECT_EQ(Emitter().CreateCount(), 1u); + EXPECT_EQ(Emitter().BindCount(), 1u); + + const Uint32 linkVersionBefore = program->GetLinkVersion(); + GL::LinkProgram(name); + ASSERT_NE(program->GetLinkVersion(), linkVersionBefore) << "the relink really has to move it"; + + Emitter().EmitShaderState(Ctx()); + EXPECT_EQ(Emitter().CreateCount(), 2u); + // THE SAME HANDLE. Gen increments only on slot reuse and never on a respecify, so a + // relinked program is the same GL object and the server's twin table must not be asked + // to mint a second identity for it. + EXPECT_EQ(Emitter().LastProgramDesc().Cso, cso); + EXPECT_EQ(Emitter().BindCount(), 1u) << "and a re-issue on the bound handle is not a rebind"; + } + + // Two calls because the frontend has two joins and two PipeInputs slots. With a plain + // glUseProgram they name one object, and the record has to say so rather than leaving the + // server to guess which of the two a verb meant. + TEST(ProgramEmit, TheDrawAndDispatchProgramsAreTwoIndependentSlots) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + GL::UseProgram(name); + ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u); + EXPECT_EQ(Emitter().DrawCso(), Emitter().DispatchCso()); + EXPECT_EQ(Emitter().BoundCso(), Emitter().DrawCso()); + EXPECT_EQ(Emitter().DrawProgramSetCount(), 1u); + EXPECT_EQ(Emitter().DispatchProgramSetCount(), 1u); + + // A null handle is legal and means exactly "nothing bound". + GL::UseProgram(0); + Emitter().EmitShaderState(Ctx()); + EXPECT_TRUE(MGPipeHandleIsNull(Emitter().DrawCso())); + EXPECT_TRUE(MGPipeHandleIsNull(Emitter().DispatchCso())); + EXPECT_TRUE(MGPipeHandleIsNull(Emitter().BoundCso())); + EXPECT_EQ(Emitter().DrawProgramSetCount(), 2u); + } + + TEST(ProgramEmit, AnUnchangedProgramEmitsNothingAtAll) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + GL::UseProgram(name); + ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u); + // The version-first skip: nothing hashed, nothing copied, nothing emitted, and the + // return value is the bytes that went on the wire - zero. + EXPECT_EQ(Emitter().EmitShaderState(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 1u); + EXPECT_EQ(Emitter().BindCount(), 1u); + EXPECT_EQ(Emitter().DrawProgramSetCount(), 1u); + } +} // namespace +#endif // MOBILEGL_PIPE_PUSH + int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = @@ -482,6 +710,9 @@ int main(int argc, char** argv) { _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); #else setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif +#if MOBILEGL_PIPE_PUSH + MobileGL::Initialize(); #endif ::testing::InitGoogleTest(&argc, argv); const int rc = RUN_ALL_TESTS(); diff --git a/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp index 279c2dad..fba2167c 100644 --- a/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp @@ -53,8 +53,16 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include + +#include "Init.h" +#include +#include #include +#include +#include #include +#include #endif using namespace MobileGL; @@ -524,6 +532,365 @@ TEST(SamplerEmit, AMakeCurrentTakesTheUnitSetsAndLeavesTheCsoAndViewRecordsStand #endif } +#if !MOBILEGL_PIPE_PUSH +// G2 requires the pull and push ctest name sets to be identical, name for name, so every +// push-only case is present here and SKIPS rather than being absent. +#define MGL_SAMPLER_EMIT_TEST_LIST(X) \ + X(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) \ + X(SamplerEmit, PaddingCannotChangeTheHash) \ + X(SamplerEmit, TwoIdenticalSamplersShareOneCso) \ + X(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) \ + X(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) \ + X(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) \ + X(SamplerEmit, AnUnchangedTextureReIssuesNothing) \ + X(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) \ + X(SamplerEmit, AnUnchangedSetEmitsNothing) \ + X(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } +MGL_SAMPLER_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + +namespace { + using GLContext = MG_State::GLState::GLContext; + using MG_State::GLState::ITextureObject; + using MG_State::GLState::SamplerObject; + + // AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason: both gates + // grep `ctest -R 'SamplerEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and + // gtest refuses to mix TEST and TEST_F under one suite name. + // + // UNLIKE VertexInputEmitTest's, this scope does NOT replace pGLContext: half these cases + // need a really linked program, so the process is initialised once in main() and the cases + // share that context, each using GL names of its own. What the scope does is put the + // emitter, its counters and the suppressor back to a known state. + struct EmitterScope { + EmitterScope() { Clear(); } + ~EmitterScope() { Clear(); } + EmitterScope(const EmitterScope&) = delete; + EmitterScope& operator=(const EmitterScope&) = delete; + + static void Clear() { + MGPipeSamplerEmitterInstance().Reset(); + MGPipeSamplerEmitterInstance().ResetCounters(); + MGPipeSamplerCsoCacheInstance().ResetForTest(); + MGPipeSamplerCsoCacheInstance().ResetCounters(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + MGPipeSamplerEmitter& Emitter() { return MGPipeSamplerEmitterInstance(); } + MGPipeSamplerCsoCache& Cache() { return MGPipeSamplerCsoCacheInstance(); } + + // Every field of SamplerParameters set to a value that is not its default, so a conversion + // that dropped one is caught by the field's own EXPECT rather than by a count. + SamplerParameters DistinctParameters() { + SamplerParameters params{}; + params.wrapS = SamplerWrapMode::ClampToBorder; + params.wrapT = SamplerWrapMode::MirroredRepeat; + params.wrapR = SamplerWrapMode::MirrorClampToEdge; + params.minFilter = SamplerFilterMode::Linear; + params.magFilter = SamplerFilterMode::Nearest; + params.mipmapMode = SamplerMipmapMode::Nearest; + params.minLod = -3.5f; + params.maxLod = 7.25f; + params.lodBias = 1.5f; + params.maxAnisotropy = 2.0f; + params.compareFunc = SamplerCompareFunc::Greater; + params.compareMode = SamplerCompareMode::CompareToTexture; + params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f}; + params.borderColorI = {-1, 2, -3, 4}; + params.borderColorUI = {5u, 6u, 7u, 8u}; + params.borderColorForm = BorderColorForm::Int; + return params; + } + + // A 2D texture with a single level and a mipmap mode of None, so it is mipmap-complete for + // its filter and therefore actually reaches the emitted set. A texture that samples as + // incomplete is dropped to a null view on purpose, which is the resolution DirectGLES + // performs by leaving the native target unbound. + const SharedPtr& MakeCompleteTexture(GLuint& name, Int width) { + namespace GL = MobileGL::MG_Impl::GLImpl; + GL::GenTextures(1, &name); + GL::BindTexture(GL_TEXTURE_2D, name); + GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, width, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + return Ctx().GetTextureObject(name); + } + + void BindTextureToUnit(Uint32 unit, const SharedPtr& texture) { + Ctx().GetTextureUnitObject(static_cast(unit)) + .GetBindingSlot(TextureTarget::Texture2D) + .Bind(texture); + Ctx().NoteTextureUnitTouched(static_cast(unit)); + } + + // ============================ D-F1: the CSO cache ============================ + + // G6 for this family: every one of SamplerParameters' sixteen members survives the + // canonical copy the cache hashes and confirms over. Each member is its own EXPECT naming + // that member, which is what G7's scripted control needs - it stops the conversion copying + // borderColorForm and expects this case to go red NAMING it. + TEST(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) { + EmitterScope scope; + const SamplerParameters source = DistinctParameters(); + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(source, canon); + + EXPECT_EQ(canon.wrapS, source.wrapS); + EXPECT_EQ(canon.wrapT, source.wrapT); + EXPECT_EQ(canon.wrapR, source.wrapR); + EXPECT_EQ(canon.minFilter, source.minFilter); + EXPECT_EQ(canon.magFilter, source.magFilter); + EXPECT_EQ(canon.mipmapMode, source.mipmapMode); + EXPECT_EQ(canon.minLod, source.minLod); + EXPECT_EQ(canon.maxLod, source.maxLod); + EXPECT_EQ(canon.lodBias, source.lodBias); + EXPECT_EQ(canon.maxAnisotropy, source.maxAnisotropy); + EXPECT_EQ(canon.compareFunc, source.compareFunc); + EXPECT_EQ(canon.compareMode, source.compareMode); + EXPECT_EQ(canon.borderColor, source.borderColor); + EXPECT_EQ(canon.borderColorI, source.borderColorI); + EXPECT_EQ(canon.borderColorUI, source.borderColorUI); + EXPECT_EQ(canon.borderColorForm, source.borderColorForm) + << "borderColorForm decides which of glSamplerParameterIiv / fv applies, and the " + "three representations are always numerically populated, so the value alone " + "cannot say"; + } + + // THE PADDING TRAP. SamplerParameters is 100 bytes and its members occupy 97 of them, so + // bytes 97..99 are padding no writer ever touches. A cache that hashed the object's own + // bytes would read them, miss on every probe and mint a fresh CSO per call - a 256-entry + // cache with a hit rate of zero that nobody notices, because the pixels are right. + TEST(SamplerEmit, PaddingCannotChangeTheHash) { + EmitterScope scope; + SamplerParameters clean = DistinctParameters(); + SamplerParameters dirty = clean; + auto* bytes = reinterpret_cast(&dirty); + // Written THROUGH A BYTE POINTER, past the last member and inside the object, which is + // the only way to make the difference this case is about. + for (SizeT i = 97; i < sizeof(SamplerParameters); ++i) bytes[i] = static_cast(0xA5 + i); + + SamplerParameters canonClean; + SamplerParameters canonDirty; + MGPipeCanonicaliseSamplerParameters(clean, canonClean); + MGPipeCanonicaliseSamplerParameters(dirty, canonDirty); + EXPECT_EQ(MGPipeHashSamplerParameters(canonClean), MGPipeHashSamplerParameters(canonDirty)); + // And the CONFIRM, not only the hash: the cache reuses a handle on a memcmp over these + // same canonical bytes, so the padding has to be deterministically zero in both. + EXPECT_EQ(std::memcmp(&canonClean, &canonDirty, sizeof(SamplerParameters)), 0); + + Uint64 payload = 0; + const MGPipeHandle first = Cache().Acquire(clean, payload); + const MGPipeHandle second = Cache().Acquire(dirty, payload); + EXPECT_EQ(first, second) << "uninitialised padding must not mint a second CSO"; + EXPECT_EQ(Cache().GetCounters().Mints, 1u); + EXPECT_EQ(Cache().GetCounters().Hits, 1u); + } + + TEST(SamplerEmit, TwoIdenticalSamplersShareOneCso) { + EmitterScope scope; + Uint64 payload = 0; + const SamplerParameters params = DistinctParameters(); + const MGPipeHandle a = Cache().Acquire(params, payload); + const MGPipeHandle b = Cache().Acquire(params, payload); + EXPECT_FALSE(MGPipeHandleIsNull(a)); + EXPECT_EQ(a, b); + EXPECT_EQ(Cache().Size(), 1u); + EXPECT_EQ(Cache().GetCounters().Mints, 1u); + // A SamplerObject is a pure value with no driver-side per-object binding state, so two + // identical samplers really can share one CSO and one server-side twin. That is what + // makes content addressing right for this kind and wrong for vertex elements. + EXPECT_GT(payload, 0u); + } + + TEST(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) { + EmitterScope scope; + Uint64 payload = 0; + SamplerParameters params = DistinctParameters(); + const MGPipeHandle asInt = Cache().Acquire(params, payload); + params.borderColorForm = BorderColorForm::Uint; + const MGPipeHandle asUint = Cache().Acquire(params, payload); + EXPECT_NE(asInt, asUint) << "the form is the only thing that says which driver entry " + "point applies; the three colour values did not move"; + EXPECT_EQ(Cache().GetCounters().Mints, 2u); + } + + // The memcmp confirm exists because a bare 64-bit equality would alias two DIFFERENT + // sampler states onto one CSO - silent wrong filtering with no gate that can see it. Two + // states whose hashes happen to agree cannot be manufactured here, so the property is + // driven the other way: two states that differ in ONE field never share a handle, however + // small the difference is. + TEST(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) { + EmitterScope scope; + Uint64 payload = 0; + SamplerParameters base = DistinctParameters(); + const MGPipeHandle first = Cache().Acquire(base, payload); + base.maxLod = base.maxLod + 0.0009765625f; // one representable step, nothing else moves + const MGPipeHandle second = Cache().Acquire(base, payload); + EXPECT_NE(first, second); + EXPECT_EQ(Cache().GetCounters().Collisions, 0u) + << "a genuine collision would have been rejected by the memcmp, not accepted"; + } + + // ============================ D-F2: the sampler view ============================ + + TEST(SamplerEmit, AnUnchangedTextureReIssuesNothing) { + EmitterScope scope; + GLuint name = 0; + const SharedPtr texture = MakeCompleteTexture(name, 4); + ASSERT_TRUE(texture); + Uint64 payload = 0; + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId()); + const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload); + ASSERT_FALSE(MGPipeHandleIsNull(view)); + EXPECT_EQ(Emitter().ViewCreateCount(), 1u); + // THE VERSION-FIRST SKIP: nothing moved, so nothing is hashed, copied or emitted. + EXPECT_EQ(Emitter().AcquireSamplerView(*texture, handle, payload), view); + EXPECT_EQ(Emitter().ViewCreateCount(), 1u); + } + + TEST(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) { + EmitterScope scope; + namespace GL = MobileGL::MG_Impl::GLImpl; + GLuint name = 0; + const SharedPtr texture = MakeCompleteTexture(name, 4); + ASSERT_TRUE(texture); + Uint64 payload = 0; + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId()); + const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload); + ASSERT_FALSE(MGPipeHandleIsNull(view)); + ASSERT_EQ(Emitter().ViewCreateCount(), 1u); + const Uint64 shapeBefore = texture->GetShapeVersion(); + + GL::BindTexture(GL_TEXTURE_2D, name); + GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + ASSERT_NE(texture->GetShapeVersion(), shapeBefore) << "the storage really has to move"; + + // THE SAME HANDLE, a second record. Gen increments only on slot reuse and never on a + // respecify, so re-issuing on the same handle is legal and is what keeps a server's + // twin table from minting a second identity for one texture. + const MGPipeHandle reissued = Emitter().AcquireSamplerView(*texture, handle, payload); + EXPECT_EQ(reissued, view); + EXPECT_EQ(Emitter().ViewCreateCount(), 2u); + EXPECT_EQ(Emitter().LastCreatedView().Cso, view); + EXPECT_EQ(Emitter().LastCreatedView().Texture, handle); + EXPECT_EQ(Emitter().LastCreatedView().Target, static_cast(TextureTarget::Texture2D)); + EXPECT_EQ(Emitter().LastCreatedView().InternalFormat, static_cast(texture->GetFormat())); + // An ordinary texture carries no view restriction, and zero is that statement rather + // than a second spelling of it: glTextureView always writes NumLevels >= 1. + EXPECT_EQ(Emitter().LastCreatedView().NumLevels, 0u); + EXPECT_EQ(Emitter().LastCreatedView().NumLayers, 0u); + } + + // ============================ D-G: the two unit sets ============================ + + Uint MakeSamplerProgram() { + namespace GL = MobileGL::MG_Impl::GLImpl; + static const char* kVs = "#version 330 core\nvoid main(){ gl_Position = vec4(0.0); }\n"; + static const char* kFs = + "#version 330 core\n" + "uniform sampler2D sampled;\n" + "out vec4 color;\n" + "void main(){ color = texture(sampled, vec2(0.0)); }\n"; + const Uint program = GL::CreateProgram(); + const Uint vs = GL::CreateShader(GL_VERTEX_SHADER); + GL::ShaderSource(vs, 1, &kVs, nullptr); + GL::CompileShader(vs); + GL::AttachShader(program, vs); + const Uint fs = GL::CreateShader(GL_FRAGMENT_SHADER); + GL::ShaderSource(fs, 1, &kFs, nullptr); + GL::CompileShader(fs); + GL::AttachShader(program, fs); + GL::LinkProgram(program); + return program; + } + + // ARCHITECTURE's third merge rule: the client emits the PROGRAM-RESOLVED set. A unit the + // shader does not sample carries no view, whatever is bound to it - which is exactly the + // gallium one-view-per-slot resolved form, and exactly what DirectGLES arrives at by + // asking the program which of a unit's aliased bindings is the sampled one. + TEST(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) { + EmitterScope scope; + namespace GL = MobileGL::MG_Impl::GLImpl; + const Uint program = MakeSamplerProgram(); + GLint linked = 0; + GL::GetProgramiv(program, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + GL::UseProgram(program); + const GLint location = GL::GetUniformLocation(program, "sampled"); + ASSERT_GE(location, 0); + GL::Uniform1i(location, 3); + + GLuint sampledName = 0; + GLuint unsampledName = 0; + const SharedPtr sampled = MakeCompleteTexture(sampledName, 4); + const SharedPtr unsampled = MakeCompleteTexture(unsampledName, 4); + BindTextureToUnit(3, sampled); + BindTextureToUnit(5, unsampled); + + ASSERT_GT(Emitter().EmitSamplerViews(Ctx()), 0u); + ASSERT_EQ(Emitter().ViewSetCount(), 1u); + ASSERT_GE(Emitter().LastSamplerViews().Count, 6u); + EXPECT_EQ(Emitter().LastSamplerViews().Start, 0u); + + const MGPBoundView& resolved = Emitter().LastBoundViews()[3]; + EXPECT_EQ(resolved.Unit, 3u); + EXPECT_FALSE(MGPipeHandleIsNull(resolved.Texture)) << "unit 3 is what the shader samples"; + EXPECT_FALSE(MGPipeHandleIsNull(resolved.View)); + + const MGPBoundView& ignored = Emitter().LastBoundViews()[5]; + EXPECT_EQ(ignored.Unit, 5u); + EXPECT_TRUE(MGPipeHandleIsNull(ignored.Texture)) + << "unit 5 has a texture bound but no sampler uniform resolves to it"; + EXPECT_TRUE(MGPipeHandleIsNull(ignored.View)); + + GL::UseProgram(0); + } + + TEST(SamplerEmit, AnUnchangedSetEmitsNothing) { + EmitterScope scope; + GLuint name = 0; + const SharedPtr texture = MakeCompleteTexture(name, 4); + BindTextureToUnit(1, texture); + Emitter().EmitSamplerViews(Ctx()); + const Uint64 after = Emitter().ViewSetCount(); + // The suppressor's whole job: an unchanged resolved set is not sent again. Without it + // this would be a several-hundred-byte variable-length record per batch, because a + // redundant re-bind moves the bind generation and the dirty bit with it. + Emitter().EmitSamplerViews(Ctx()); + EXPECT_EQ(Emitter().ViewSetCount(), after); + } + + TEST(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) { + EmitterScope scope; + const SharedPtr& sampler = Ctx().CreateSamplerObject(4131); + ASSERT_TRUE(sampler); + Ctx().GetTextureUnitObject(2).SetSamplerObject(sampler); + Ctx().NoteTextureUnitTouched(2); + + ASSERT_GT(Emitter().EmitSamplerStates(Ctx()), 0u); + ASSERT_EQ(Emitter().StateSetCount(), 1u); + ASSERT_GE(Emitter().LastSamplerStates().Count, 3u); + EXPECT_FALSE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[2])); + EXPECT_TRUE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[0])) + << "a unit with no sampler object carries the null handle, and the texture's " + "built-in sampler then applies exactly as today"; + + // 26.2's idiom: the same sampler object re-bound at every texture-unit switch. The bind + // generation moves, so the dirty bit fires and this emitter runs - and emits nothing. + Ctx().BumpTextureBindGeneration(); + Emitter().EmitSamplerStates(Ctx()); + EXPECT_EQ(Emitter().StateSetCount(), 1u); + EXPECT_EQ(Cache().GetCounters().Mints, 1u) << "and it mints no second CSO either"; + } +} // namespace +#endif // MOBILEGL_PIPE_PUSH + int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = @@ -535,6 +902,11 @@ int main(int argc, char** argv) { _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); #else setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif +#if MOBILEGL_PIPE_PUSH + // ONE process-wide context for the whole suite, because half these cases need a really + // linked program and glslang lives behind this call. Each case uses GL names of its own. + MobileGL::Initialize(); #endif ::testing::InitGoogleTest(&argc, argv); const int rc = RUN_ALL_TESTS();