diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h index 6a26b08d..b1374594 100644 --- a/MobileGL/MG_Impl/Pipe/CsoCache.h +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -72,7 +72,8 @@ namespace MobileGL::MG_Pipe { const Bool contentAddressed = (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; if (contentAddressed) { - const Uint64 hash = MGPipeHashPipelineBytes(bytes.data()); + const Uint64 hash = s_hashForTest != nullptr ? s_hashForTest(bytes.data()) + : MGPipeHashPipelineBytes(bytes.data()); for (SizeT i = 0; i < m_entries.size(); ++i) { if (m_entries[i].Hash != hash) continue; if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) { @@ -110,6 +111,15 @@ namespace MobileGL::MG_Pipe { SizeT Size() const { return m_entries.size(); } const Counters& GetCounters() const { return m_counters; } + // TEST SEAM, and it is here because the thing it tests cannot be reached any other + // way. A 64-bit collision between two DIFFERENT render states is silent wrong pixels + // and it is exactly what the memcmp confirm above exists to stop, so + // CsoCacheTest.HashCollisionDoesNotAliasTwoStates has to be able to make one happen. + // Null in every real build - one never-taken, perfectly-predicted branch on a path + // that runs only when the pipeline version moved, i.e. never in the steady state. + using HashForTestFn = Uint64 (*)(const void* pipelineBytes); + inline static HashForTestFn s_hashForTest = nullptr; + private: struct Entry { Uint64 Hash = 0; diff --git a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp index 6dae4b00..33428849 100644 --- a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp +++ b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp @@ -6,27 +6,177 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// The 64-entry render-state CSO cache: hash, probe, memcmp, LRU evict, and the content-addressing-off control (P2 brief D7). +// The render-state CSO cache (P2 brief D7). Owned by P2 package B (p2/tracker); the file and +// its CMake registration are the contract commit's. // -// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its -// CMakeLists.txt registration, so that the package which owns its CONTENTS -// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 -// packages edit the same file, which is what keeps the integrator's rebases clean. -// -// The placeholder case is not decoration: without it the binary has no test, and -// gtest_discover_tests on a binary with no test is a silently green lane. +// Needs the push sources, so every case is a visible SKIP in a pull build rather than a +// vanishing test. #include #include "Includes.h" #include +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#endif + using namespace MobileGL; using namespace MobileGL::MG_Pipe; namespace { - // The cache does not exist yet; the behaviour bit it is measured against does, and it - // is deliberately the TOP bit so no subsystem allocation can ever collide with it. - TEST(CsoCache, PlaceholderUntilTheOwningPackageFillsThisIn) { - EXPECT_EQ(kMGPipeBehaviourNoCsoContentAddressing, 1ull << 63); +#if !MOBILEGL_PIPE_PUSH + TEST(CsoCache, SkippedInAPullBuild) { + GTEST_SKIP() << "the CSO cache is compiled only under MOBILEGL_PIPE_PUSH"; } +#else + class CsoCacheTest : public ::testing::Test { + protected: + void SetUp() override { + m_savedPush = MG_Config::Features.PipePush; + MGPipeCsoCache::s_hashForTest = nullptr; + MGPipeApplierReset(); + } + void TearDown() override { + MG_Config::Features.PipePush = m_savedPush; + MGPipeCsoCache::s_hashForTest = nullptr; + MGPipeApplierReset(); + } + + // A render state that differs from every other `seed` in a PIPELINE byte, so each one + // is a genuinely different CSO. SampleMaskValue is in pipeline chunk P2. + static RenderStateParameters PipelineState(Uint32 seed) { + RenderStateParameters params{}; + params.SampleMaskValue = seed; + return params; + } + + Uint64 m_savedPush = 0; + }; + + TEST_F(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + const RenderStateParameters params = PipelineState(7); + const MGPipeHandle first = cache.Acquire(params, bytes); + for (int i = 0; i < 16; ++i) EXPECT_TRUE(cache.Acquire(params, bytes) == first); + EXPECT_EQ(cache.GetCounters().Mints, 1u); + EXPECT_EQ(cache.GetCounters().Hits, 16u); + EXPECT_EQ(cache.Size(), 1u); + cache.Reset(); + } + + TEST_F(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + Vector handles; + for (Uint32 i = 0; i < kMGPipeCsoCacheCapacity; ++i) { + handles.push_back(cache.Acquire(PipelineState(i), bytes)); + } + EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity); + EXPECT_EQ(cache.GetCounters().Evictions, 0u); + // Touch entry 0 so it is no longer the oldest; entry 1 becomes the victim. + EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]); + + const MGPipeHandle overflow = cache.Acquire(PipelineState(kMGPipeCsoCacheCapacity), bytes); + EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity); + EXPECT_EQ(cache.GetCounters().Evictions, 1u); + EXPECT_EQ(cache.GetCounters().Mints, kMGPipeCsoCacheCapacity + 1); + EXPECT_FALSE(overflow == handles[1]); + // The one that was touched survived; the evicted one has to be minted again. + EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]); + const MGPipeHandle reborn = cache.Acquire(PipelineState(1), bytes); + EXPECT_FALSE(reborn == handles[1]); + EXPECT_EQ(cache.GetCounters().Evictions, 2u); + cache.Reset(); + } + + // A 64-bit hash collision between two different render states would alias them onto one + // CSO, which is silent wrong pixels with no gate that can see it. The memcmp confirm is + // what stops it, and this is what proves the memcmp is doing something. + TEST_F(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) { + MGPipeCsoCache::s_hashForTest = [](const void*) -> Uint64 { return 0x1234'5678'9abc'def0ull; }; + MGPipeCsoCache cache; + Uint64 bytes = 0; + const MGPipeHandle a = cache.Acquire(PipelineState(1), bytes); + const MGPipeHandle b = cache.Acquire(PipelineState(2), bytes); + EXPECT_FALSE(a == b) << "two different render states were aliased onto one CSO"; + EXPECT_EQ(cache.GetCounters().Collisions, 1u); + EXPECT_EQ(cache.GetCounters().Mints, 2u); + EXPECT_EQ(cache.GetCounters().Hits, 0u); + cache.Reset(); + } + + // The negative control the whole CSO design is measured against (ROADMAP.md P2). It turns + // off the PROBE and the handle reuse, not the records - otherwise it would measure a + // different design rather than this one without content addressing. + TEST_F(CsoCacheTest, ContentAddressingOffMintsEveryTime) { + MG_Config::Features.PipePush |= kMGPipeBehaviourNoCsoContentAddressing; + MGPipeCsoCache cache; + Uint64 bytes = 0; + const RenderStateParameters params = PipelineState(3); + const MGPipeHandle first = cache.Acquire(params, bytes); + const MGPipeHandle second = cache.Acquire(params, bytes); + const MGPipeHandle third = cache.Acquire(params, bytes); + EXPECT_FALSE(first == second); + EXPECT_FALSE(second == third); + EXPECT_EQ(cache.GetCounters().Mints, 3u); + EXPECT_EQ(cache.GetCounters().Hits, 0u); + cache.Reset(); + } + + TEST_F(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) { + MGPipeCsoCache cache; + Uint64 bytes = 0; + cache.Acquire(PipelineState(11), bytes); + // A mint puts the descriptor and the whole pipeline half on the wire. + EXPECT_EQ(bytes, sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes); + const Uint64 afterMint = bytes; + cache.Acquire(PipelineState(11), bytes); + // A hit puts NOTHING on the wire: the 12-byte bind is the caller's, not the cache's. + EXPECT_EQ(bytes, afterMint); + cache.Reset(); + } + + // ---- the set-hash suppressor (D11) ---- + + TEST(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) { + MGPipeSetHashSuppressor suppressor; + for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) { + const auto slot = static_cast(i); + EXPECT_EQ(suppressor.LastEmitted(slot), 0u) << "slot " << i << " did not start at 0"; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " suppressed its first set"; + EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " re-emitted an unmoved set"; + } + } + + TEST(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) { + MGPipeSetHashSuppressor suppressor; + const auto slot = MGPipeSuppressorSlot::SetVertexAttribDefaults; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)); + EXPECT_EQ(suppressor.LastEmitted(slot), 1u) << "a computed 0 must not read as never emitted"; + EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)); + } + + TEST(SetHashSuppressorTest, SlotsAreIndependent) { + MGPipeSetHashSuppressor suppressor; + EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42)); + EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetSamplerViews, 42)); + EXPECT_FALSE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42)); + } + + TEST(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo) { + MGPipeSetHashSuppressor suppressor; + const auto slot = MGPipeSuppressorSlot::SetShaderImages; + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); + EXPECT_FALSE(suppressor.ShouldEmit(slot, 99)); + suppressor.Invalidate(slot); + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); + suppressor.InvalidateAll(); + EXPECT_TRUE(suppressor.ShouldEmit(slot, 99)); + } +#endif // MOBILEGL_PIPE_PUSH } // namespace diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 2bdda578..87f97694 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -18,8 +18,17 @@ #include #if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include #include #include +#include + +#include +#include #endif using namespace MobileGL; @@ -169,5 +178,222 @@ namespace { MG_State::pGLContext = Move(held); SUCCEED(); } + + // =================================================================================== + // The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7) + // =================================================================================== + // + // These drive the tracker and the cache DIRECTLY rather than through + // MGPipeValidateForVerb. That is deliberate: MGPipeValidateForVerb reaches the library's + // one process-wide tracker, and a unit test that asserts on a shared singleton is a test + // that fails when ctest runs the suite in parallel. The emission logic these reproduce is + // three lines long and is the same three lines the validate point runs. + class TrackerWalk : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + m_savedPush = MG_Config::Features.PipePush; + MGPipeApplierReset(); + } + void TearDown() override { + MG_Config::Features.PipePush = m_savedPush; + MGPipeApplierReset(); + MG_State::pGLContext = Move(m_previous); + } + + static GLContext& Ctx() { return *MG_State::pGLContext; } + + // What MGPipeValidateForVerb's step 3 does, minus the PipeStats plumbing: acquire a + // CSO when the pipeline version moved, and compute the dynamic chunk mask when + // m_version moved. + Uint32 Walk(MGPipeVerbClass verbClass = MGPipeVerbClass::kDraw) { + const Uint32 dirty = m_tracker.Update(Ctx(), verbClass); + const RenderStateParameters& live = Ctx().GetRenderStateParameters(); + m_lastDynamicMask = 0; + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { + m_lastCso = m_cache.Acquire(live, m_payloadBytes); + ++m_binds; + } + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + m_lastDynamicMask = m_tracker.FreshlyPrimed() + ? ~0u + : MGPipeDynamicChunksThatMoved(live, m_tracker.Staged()); + } + if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) { + m_tracker.Staged() = live; + } + return dirty; + } + + MGPipeTracker m_tracker; + MGPipeCsoCache m_cache; + MGPipeHandle m_lastCso = kMGPipeNullHandle; + Uint32 m_lastDynamicMask = 0; + Uint64 m_payloadBytes = 0; + Uint64 m_binds = 0; + Uint64 m_savedPush = 0; + UniquePtr m_previous; + }; + + TEST_F(TrackerWalk, EveryBitHasAName) { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + ASSERT_NE(kMGPipeDirtyNames[i], nullptr); + EXPECT_EQ(std::string(kMGPipeDirtyNames[i]).rfind("NEW_", 0), 0u); + } + } + + // The five P2 emits for each name their own subsystem; the rest name none, which is what + // makes MOBILEGL_PIPE_PUSH a per-subsystem A/B instead of one switch. + TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + const auto bit = static_cast(i); + const Bool emitted = (kMGPipeDirtyEmittedAtP2 & MGPipeDirtyBit(bit)) != 0; + EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i]; + } + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults), + kMGPipeSubsystemVertexAttribDefaults); + } + + TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) { + const Uint32 dirty = Walk(); + EXPECT_TRUE(m_tracker.FreshlyPrimed()); + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + EXPECT_NE(dirty & (Uint32{1} << static_cast(i)), 0u) + << kMGPipeDirtyNames[i] << " did not fire on a fresh context"; + } + } + + // The whole point of a validate-point tracker: two identical draws in a row cost two + // Uint16 compares and emit nothing at all. + TEST_F(TrackerWalk, SteadyStateEmitsNothing) { + Walk(); + const Uint64 mintsAfterFirst = m_cache.GetCounters().Mints; + const Uint64 bindsAfterFirst = m_binds; + for (int i = 0; i < 8; ++i) EXPECT_EQ(Walk(), 0u) << "walk " << i << " fired with nothing moved"; + EXPECT_EQ(m_cache.GetCounters().Mints, mintsAfterFirst); + EXPECT_EQ(m_binds, bindsAfterFirst); + } + + // The Blaze3D shape ARCHITECTURE.md 5.1 names as the reason push happens at validate and + // not in the setter: enable / draw / disable / draw forever mints exactly TWO CSOs and + // reuses them for every toggle after that. + TEST_F(TrackerWalk, BlendToggleReusesTwoCsos) { + constexpr int kToggles = 32; + Walk(); // prime + // The priming walk already minted and cached the blend-DISABLED state, so the cache + // starts empty here or the count below would be one short of the shape it describes. + m_cache.Reset(); + m_cache.ResetCounters(); + m_binds = 0; + for (int i = 0; i < kToggles; ++i) { + Ctx().SetCapability(CapabilityInput::Blend, true); + Walk(); + Ctx().SetCapability(CapabilityInput::Blend, false); + Walk(); + } + EXPECT_EQ(m_cache.GetCounters().Mints, 2u) + << "a two-state ping-pong must mint two CSOs and then never mint again"; + EXPECT_EQ(m_binds, static_cast(2 * kToggles)); + EXPECT_EQ(m_cache.GetCounters().Hits, static_cast(2 * kToggles - 2)); + EXPECT_EQ(m_cache.Size(), 2u); + m_cache.Reset(); + } + + // The regression RenderState.h records: a glViewport must not evict a cached pipeline. + // It mints nothing and its payload is one dynamic chunk - D0, the viewports - and not the + // other seven. + TEST_F(TrackerWalk, ViewportDoesNotMintACso) { + Walk(); // prime + m_cache.ResetCounters(); + for (Int i = 1; i <= 16; ++i) { + Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i)); + const Uint32 dirty = Walk(); + EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u); + EXPECT_EQ(dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u) + << "glViewport moved the PIPELINE version"; + EXPECT_EQ(m_lastDynamicMask, 1u) << "glViewport sent something other than chunk D0"; + } + EXPECT_EQ(m_cache.GetCounters().Mints, 0u); + EXPECT_EQ(m_binds, 1u) << "only the priming walk may bind"; + m_cache.Reset(); + } + + // RenderState's two shutters are Uint16 and the tracker widens them in its OWN state, + // never in MG_State. A wrap must cost an extra re-push at worst and never a missed one. + TEST_F(TrackerWalk, WrapAroundRePushesButNeverMisses) { + Walk(); // prime + constexpr int kMoves = 70000; // past 65535 with room to spare + Uint64 fired = 0; + for (int i = 0; i < kMoves; ++i) { + // Never the default 1.0f: a setter that early-outs on an unchanged value would + // not move m_version, and the first iteration would then be a false miss. + Ctx().SetLineWidth((i & 1) ? 2.0f : 3.0f); + if (Walk() & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) ++fired; + } + EXPECT_EQ(fired, static_cast(kMoves)) + << "a Uint16 wrap swallowed a render-state change"; + m_cache.Reset(); + } + + // The one direction the P1 verify comparator cannot see: it compares object-class fields + // by IDENTITY only, so a bound texture whose CONTENT moved looks unchanged to it. + // ARCHITECTURE.md 13.2 names under-firing as the dangerous direction, and this is the + // first test of it. + TEST_F(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) { + const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D); + ASSERT_TRUE(tex != nullptr); + Walk(); // prime + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u); + + static_cast(tex.get())->BumpContentVersion(); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u) + << "a bound texture's content moved and NEW_SAMPLER_VIEWS did not fire"; + // and it settles again, so the bit is a shutter and not a stuck flag + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u); + m_cache.Reset(); + } + + // A NaN outer level is a legal glPatchParameterfv value and has to compare equal to + // itself, which float equality does not do and a byte compare does. + TEST_F(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) { + Walk(); // prime + Ctx().SetPatchDefaultOuterLevel( + FloatVec4(std::numeric_limits::quiet_NaN(), 1.0f, 1.0f, 1.0f)); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u) + << "a NaN patch level re-fired against itself"; + m_cache.Reset(); + } + + TEST_F(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) { + Walk(); // prime + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + Ctx().SetPixelStoreParam(PixelStoreParam::PackAlignment, 8); + EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u); + // The UNPACK half has no carrier at all, so it must not move the pack shutter. + Ctx().SetPixelStoreParam(PixelStoreParam::UnpackAlignment, 8); + EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u) + << "an unpack write moved the PACK shutter"; + m_cache.Reset(); + } + + TEST_F(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) { + // PipeStats is off in a unit-test process, which is the state the ROADMAP rule about + // hot-path instrumentation cares about: the walk must cost nothing extra there. + ASSERT_FALSE(MG_Util::PipeStats::Enabled()); + Walk(); + Ctx().SetLineWidth(3.0f); + Walk(); + EXPECT_EQ(m_tracker.WalkCount(), 0u); + EXPECT_EQ(m_tracker.FireCount(MGPipeDirty::NewRenderState), 0u); + m_cache.Reset(); + } + #endif // MOBILEGL_PIPE_PUSH } // namespace