[Test] (Pipe): wire the sampler and program subsystems and pin the padding-proof CSO identity, the program-resolved unit sets, the linked-snapshot stage mask and the composite band's single release

This commit is contained in:
2026-09-08 15:24:22 -04:00
parent bc2aad2176
commit ccde29064a
6 changed files with 1075 additions and 41 deletions
+6 -2
View File
@@ -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
+40 -9
View File
@@ -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<SamplerParameters>,
"the canonical copy is memset and then assigned field by field");
SamplerParameters canon;
std::memset(static_cast<void*>(&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<void*>(&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.
@@ -50,8 +50,14 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Program/GL_ProgramPipeline.h>
#include <MG_Impl/Pipe/CompositeResolver.h>
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
// 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 <MG_State/GLState/ProgramState/ProgramArtifacts.h>
@@ -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<ProgramObject> 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();
+198 -16
View File
@@ -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 <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_Impl/Pipe/ImageEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#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<MG_State::GLState::ProgramObject>& 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<Uint32>(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<Uint32>(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<Uint32>(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();
+245 -14
View File
@@ -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 <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Pipe/PipeApply.h>
// 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 <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#include <MG_State/GLState/Core.h>
#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<Uint32>(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<ProgramObject>& 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<ProgramObject>& 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<ProgramObject>& 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<ProgramObject>& 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();
+372
View File
@@ -53,8 +53,16 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <cstring>
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#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<ITextureObject>& 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<ITextureObject>& texture) {
Ctx().GetTextureUnitObject(static_cast<Int>(unit))
.GetBindingSlot(TextureTarget::Texture2D)
.Bind(texture);
Ctx().NoteTextureUnitTouched(static_cast<Int>(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<Uint8*>(&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<Uint8>(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<ITextureObject> 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<ITextureObject> 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<Uint8>(TextureTarget::Texture2D));
EXPECT_EQ(Emitter().LastCreatedView().InternalFormat, static_cast<Uint32>(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<ITextureObject> sampled = MakeCompleteTexture(sampledName, 4);
const SharedPtr<ITextureObject> 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<ITextureObject> 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<MG_State::GLState::SamplerObject>& 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();