From 2edb2aa82acdb29ea0e9cc3e40001e731d343429 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 30 Sep 2026 03:44:03 -0400 Subject: [PATCH] [Test] (P8-SV): WireMipShapeScenario (3D chain on both backends x every arm, 1D depth decline on Magma split+spawn, the 3D chain under the validation layer inproc) and WireIndirectDispatchScenario (both backends x every arm, Magma gated .IndirectDispatch.), the MipmapDepthStencilAspect tally in F1's depth/stencil decline case, WireDeclinePeek, and the two parity rows --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 74 +++++ .../Harness/WireDeclinePeek.cpp | 38 +++ .../Harness/WireDeclinePeek.h | 20 ++ .../Harness/WireIndirectPeek.cpp | 1 + .../Harness/WireIndirectPeek.h | 1 + .../Scenarios/F1WireScenario.cpp | 13 + .../WireIndirectDispatchScenario.cpp | 292 ++++++++++++++++++ .../Scenarios/WireMipShapeScenario.cpp | 220 +++++++++++++ scripts/ci/spawn_lane_parity.py | 11 +- 9 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.cpp create mode 100644 MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.h create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/WireIndirectDispatchScenario.cpp create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/WireMipShapeScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 830e57f3..96483d57 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -4863,6 +4863,80 @@ if (MOBILEGL_BUILD_DISAGGREGATED) endforeach() endif() +# P8-SV: Magma split residuals (docs/Disaggregated/notes/p8/SV.md) - the 3D native mip blit's layer +# count, the 1D depth mip decline, the depth/stencil mip decline's tally row (asserted inside +# F1WireScenario) and the wire arm's native glDispatchComputeIndirect. Public GL, so the sources join +# the binary unguarded and the ambient `DirectGLES.` / `DirectVulkan.` monolith discovery runs them +# too (the Espryt entries are the control). Split arms: DirectGLES through the registration macro +# (which also replays them on Magma's informational tier), Magma's gated tier by hand: +# `.Mip3D.` and `.IndirectDispatch.` - all three transports; the counter halves read only on the +# inproc arm (WireIndirectPeek.h, WireDeclinePeek.h), the pixel/grid halves everywhere. +# `.ShaderMipDepth1D.` - split + spawn: MGITEST_MAGMA_FORCE_SHADER_MIPMAP is read BY THE SERVER, +# so a tcp copy would run the default arm (its tail is in spawn_lane_parity.py's +# MAGMA_SERVER_ENV_KNOB_NO_TCP). +# `.Mip3DValidation.` - inproc only: the 3D chain under VK_LAYER_KHRONOS_validation, red on any +# vkCmdBlitImage VUID in the output. The server's Vulkan instance must be this process for its +# stdout to be the entry's and for the case to prove the layer loaded (/proc/self/maps), so it +# is in MAGMA_INPROC_ONLY. The lane that runs integration-magma-split in CI installs the layer. +target_sources(MobileGLIntegrationTest PRIVATE Harness/WireDeclinePeek.cpp Scenarios/WireMipShapeScenario.cpp + Scenarios/WireIndirectDispatchScenario.cpp) +if (MOBILEGL_BUILD_DISAGGREGATED) + mgl_itest_register_split_arms("WireMipShapeScenario.*" "") + mgl_itest_register_split_arms("WireIndirectDispatchScenario.*" "") + set(MGL_ITEST_MAGMA_SV_FLAVOUR_ENV + "MOBILEGL_BACKEND_TYPE=DirectVulkan" + "MOBILEGL_IPC_ROLE_SPLIT_STATE=1" "MOBILEGL_IPC_STRICT_ERRORS=1" + "MOBILEGL_ITEST_REQUIRE_GPU=1" "MGITEST_SPLIT_LANE=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + foreach(mglItestArm IN LISTS MGL_ITEST_MAGMA_ARMS) + mgl_itest_magma_arm_shape(${mglItestArm}) + mgl_itest_join_environment(MGL_ITEST_MAGMA_SV_ENVIRONMENT + ${MGL_ITEST_MAGMA_SV_FLAVOUR_ENV} ${mglItestArmTransportEnv}) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.Mip3D." + TEST_FILTER "WireMipShapeScenario.VolumeChainIsGeneratedAlongItsDepth" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${MGL_ITEST_MAGMA_SV_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/magma-sv-mip3d${mglItestArmLogTag}.log") + foreach(dispatchCase + CpuWrittenGroupCountsAreReadByTheGpuAtTheirOffsets + ComputeWrittenGroupCountsDispatchWithoutAHostWait + ShaderWrittenGroupCountsWithoutAnApplicationBarrierAreOrdered) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.IndirectDispatch." + TEST_FILTER "WireIndirectDispatchScenario.${dispatchCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${MGL_ITEST_MAGMA_SV_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/magma-sv-dispatch${mglItestArmLogTag}-${dispatchCase}.log") + endforeach() + if (NOT mglItestArm STREQUAL "Tcp") + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.ShaderMipDepth1D." + TEST_FILTER "WireMipShapeScenario.Depth1DChainWithoutANativeBlitDeclinesByName" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${MGL_ITEST_MAGMA_SV_ENVIRONMENT}\;MGITEST_MAGMA_FORCE_SHADER_MIPMAP=1\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/magma-sv-depth1d${mglItestArmLogTag}.log") + endif() + endforeach() + mgl_itest_magma_arm_shape(Split) + mgl_itest_join_environment(MGL_ITEST_MAGMA_SV_ENVIRONMENT + ${MGL_ITEST_MAGMA_SV_FLAVOUR_ENV} ${mglItestArmTransportEnv}) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.Split.Mip3DValidation." + TEST_FILTER "WireMipShapeScenario.VolumeChainIsGeneratedAlongItsDepth" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + FAIL_REGULAR_EXPRESSION "VUID-vkCmdBlitImage-" + ENVIRONMENT "${MGL_ITEST_MAGMA_SV_ENVIRONMENT}\;VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation\;MGITEST_REQUIRE_VK_VALIDATION_LAYER=1\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/magma-sv-mip3d-validation.log") +endif() + # ===================================================================================== # P7 PACKAGE L: THE INFORMATIONAL MAGMA CENSUS - TIER 2 OF TWO # ===================================================================================== diff --git a/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.cpp b/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.cpp new file mode 100644 index 00000000..140cbaea --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.cpp @@ -0,0 +1,38 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "WireDeclinePeek.h" + +#if !defined(__ANDROID__) +#include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#define MGITEST_WIRE_DECLINE_PEEK_LIVE 1 +#endif +#endif + +namespace MGITest { + +#if defined(MGITEST_WIRE_DECLINE_PEEK_LIVE) + bool PeekWireDeclineCount(const char* row, unsigned long long* out) { + if (row == nullptr || out == nullptr) return false; + namespace Magma = MobileGL::MG_Backend::DirectVulkan; + for (MobileGL::SizeT i = 0; i < Magma::WireDeclineTally::kSiteCount; ++i) { + const auto site = static_cast(i); + if (std::strcmp(Magma::WireDeclineSiteName(site), row) != 0) continue; + *out = Magma::WireDeclineTally::Get(site); + return true; + } + return false; + } +#else + bool PeekWireDeclineCount(const char*, unsigned long long*) { return false; } +#endif + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.h b/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.h new file mode 100644 index 00000000..db806d1b --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.h @@ -0,0 +1,20 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/WireDeclinePeek.h +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// P8-SV: one row of the Magma wire arm's decline tally (WireDeclines.def, the counts behind the +// server's MGWIRE-DECLINES lines), read as a run total out of THIS process - WireIndirectPeek.h's +// shape. Process-wide means the SERVER's counts only where the server is this process: the inproc +// arm, never spawn or tcp, and never the monolith arm, which takes no wire path. False where the +// reading cannot be taken: a pull build, Android, or a name that is not a row. +#pragma once + +namespace MGITest { + + bool PeekWireDeclineCount(const char* row, unsigned long long* out); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.cpp index d8b4ad5f..34a4edd8 100644 --- a/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.cpp +++ b/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.cpp @@ -28,6 +28,7 @@ namespace MGITest { out->barriers = Stats::TotalCalls(Stats::CallClass::WireIndirectBarriers); out->hostWaits = Stats::TotalCalls(Stats::CallClass::WireHostWaits); out->indirectWaits = Stats::TotalCalls(Stats::CallClass::WireHostWaitsIndirect); + out->nativeDispatches = Stats::TotalCalls(Stats::CallClass::WireIndirectNativeDispatches); return true; } #else diff --git a/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.h b/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.h index 57168456..010ce2ab 100644 --- a/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.h +++ b/MobileGL/MG_IntegrationTest/Harness/WireIndirectPeek.h @@ -21,6 +21,7 @@ namespace MGITest { unsigned long long barriers = 0; // wire-indirect-barriers (`wibar`) unsigned long long hostWaits = 0; // wire-host-waits (`whw`) unsigned long long indirectWaits = 0; // wire-host-waits-indirect (`whwi`) + unsigned long long nativeDispatches = 0; // P8-SV: wire-indirect-native-dispatches (`wdsp`) }; bool PeekWireIndirectCounters(WireIndirectCounters* out); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp index 160f8288..1a37cde0 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp @@ -3,6 +3,7 @@ #include "../Harness/SplitRuntimePeek.h" #include "../Harness/PipeStatsWindow.h" #include "../Harness/SplitLane.h" +#include "../Harness/WireDeclinePeek.h" #include #include #include @@ -659,6 +660,12 @@ TEST_F(F1WireScenario, GenerateMipmapDepthStencilDeclinesAndKeepsTheSession) { glDisable(GL_SCISSOR_TEST); glClearDepth(0.5); glClear(GL_DEPTH_BUFFER_BIT); + // P8-SV: the decline is a WireDeclines.def row now (MipmapDepthStencilAspect), so the server's + // MGWIRE-DECLINES dump sees it. Readable where the server is this process: the inproc arm. + // Red once (executed, reverted): the site back to a bare MGLOG_E_ONCE - the count stays 0. + unsigned long long declinesBefore = 0; + const bool counted = PeekSplitRuntime().transportName == "inproc" && + PeekWireDeclineCount("MipmapDepthStencilAspect", &declinesBefore); glGenerateMipmap(GL_TEXTURE_2D); EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.DepthStencilMip.error"; // The session is what the retired Fatal used to take with it. A readback is a round trip @@ -669,6 +676,12 @@ TEST_F(F1WireScenario, GenerateMipmapDepthStencilDeclinesAndKeepsTheSession) { glReadPixels(1, 1, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &level0); EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.DepthStencilMip.readback"; EXPECT_NEAR(level0, 0.5f, 0.002f) << "F1.DepthStencilMip.level0"; + if (counted) { + unsigned long long declinesAfter = 0; + ASSERT_TRUE(PeekWireDeclineCount("MipmapDepthStencilAspect", &declinesAfter)); + EXPECT_EQ(declinesAfter - declinesBefore, 1u) << "F1.DepthStencilMip.tally: one named decline per refused " + "glGenerateMipmap (WireDeclines.def MipmapDepthStencilAspect)"; + } glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, 0, 0); glDeleteTextures(1, &depthStencil); glBindTexture(GL_TEXTURE_2D, texture); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/WireIndirectDispatchScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/WireIndirectDispatchScenario.cpp new file mode 100644 index 00000000..f7f20009 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/WireIndirectDispatchScenario.cpp @@ -0,0 +1,292 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/WireIndirectDispatchScenario.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// P8-SV: THE MAGMA WIRE ARM ISSUES glDispatchComputeIndirect FROM THE WIRE STORE, AS THE MONOLITH +// ARM DOES (docs/Disaggregated/notes/p8/SV.md). +// +// Before P8-SV the wire arm read the three group counts through ReadWireBuffer and dispatched them +// from the CPU, so a dispatch buffer a shader had written cost a whole-GPU wait per call - the +// dispatch twin of what P8-D removed for indirect draws (WireIndirectDrawScenario.cpp). The cases +// pin both halves: +// * THE GRID, on every arm and both backends - each dispatch marks exactly the work groups its +// counts name, and GPU-written counts are the ones the dispatch sees (the buffer starts with +// OTHER counts, so a stale read shows as the wrong grid); +// * THE SERVER'S OWN COUNTERS (Harness/WireIndirectPeek.h), where they can be read - Magma on the +// inproc arm: every call issued as vkCmdDispatchIndirect (`wdsp`), no host wait (`whw`), and the +// INDIRECT_COMMAND_READ barrier exactly once per shader write (`wibar`). +// The counters are read BEFORE the grids are read back: that readback is a host read of a store a +// shader wrote, so it takes a host wait of its own, which is the application's and not the +// dispatch's. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" +#include "../Harness/WireIndirectPeek.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include + +namespace MGITest { + namespace { + + // Grids up to 4 x 4 x 2: one cell per work group. + constexpr std::uint32_t kGridX = 4, kGridY = 4, kGridZ = 2; + constexpr std::uint32_t kCells = kGridX * kGridY * kGridZ; + + // One invocation per work group, and each group marks its own cell. + constexpr const char* kMarkSource = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 2) buffer Marks { uint marks[]; }; +void main() { + uvec3 g = gl_WorkGroupID; + atomicAdd(marks[(g.z * 4u + g.y) * 4u + g.x], 1u); +} +)"; + // Copies `source` into `words`, one word per invocation: the compute write under test. + constexpr const char* kCopySource = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 0) buffer Words { uint words[]; }; +layout(std430, binding = 1) readonly buffer Source { uint source[]; }; +void main() { words[gl_GlobalInvocationID.x] = source[gl_GlobalInvocationID.x]; } +)"; + + using Grid = std::array; + + class WireIndirectDispatchScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_magma = Gl().BackendName() == "DirectVulkan"; + m_transport = PeekSplitRuntime().transportName; + m_mark = CompileCompute(kMarkSource); + ASSERT_NE(m_mark, 0u); + m_copy = CompileCompute(kCopySource); + ASSERT_NE(m_copy, 0u); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind"; + // The counters are armed by the first peek, so it has to come before any dispatch. + if (CountersReadable()) { + ASSERT_TRUE(PeekWireIndirectCounters(&m_before)) + << "Magma on the inproc arm must be able to read the wire arm's own counters"; + } + } + + void TearDown() override { + if (!Ready()) return; + glBindBuffer(GL_DISPATCH_INDIRECT_BUFFER, 0); + for (GLuint& buffer : m_marks) { + if (buffer != 0) glDeleteBuffers(1, &buffer); + } + for (GLuint* buffer : {&m_parameters, &m_source}) { + if (*buffer != 0) glDeleteBuffers(1, buffer); + *buffer = 0; + } + if (m_mark != 0) glDeleteProgram(m_mark); + if (m_copy != 0) glDeleteProgram(m_copy); + } + + static GLuint CompileCompute(const char* source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint ok = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (ok != GL_TRUE) { + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + glGetProgramiv(program, GL_LINK_STATUS, &ok); + if (ok != GL_TRUE) { + glDeleteProgram(program); + return 0; + } + return program; + } + + static void Fill(GLuint& name, GLenum target, const std::vector& words) { + if (name == 0) glGenBuffers(1, &name); + glBindBuffer(target, name); + glBufferData(target, static_cast(words.size() * sizeof(std::uint32_t)), words.data(), + GL_DYNAMIC_COPY); + } + + // The dispatch-indirect store, holding CPU-written counts. + void Parameters(const std::vector& words) { + Fill(m_parameters, GL_DISPATCH_INDIRECT_BUFFER, words); + } + + // The compute write: the parameter store (already holding its poison) receives `words`. + void ComputeWriteParameters(const std::vector& words) { + Fill(m_source, GL_SHADER_STORAGE_BUFFER, words); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_parameters); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m_source); + glUseProgram(m_copy); + glDispatchCompute(static_cast(words.size()), 1, 1); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0); + } + + // One indirect dispatch of the mark program into a fresh, zeroed cell buffer. Returns + // the index of that buffer for ExpectGrid. + size_t DispatchIndirect(GLintptr offset) { + GLuint marks = 0; + Fill(marks, GL_SHADER_STORAGE_BUFFER, std::vector(kCells, 0u)); + m_marks.push_back(marks); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, marks); + glUseProgram(m_mark); + glBindBuffer(GL_DISPATCH_INDIRECT_BUFFER, m_parameters); + glDispatchComputeIndirect(offset); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, 0); + return m_marks.size() - 1; + } + + // A round trip that reads no buffer store, so the server has applied every dispatch + // above before the counters are read - and no host wait of its own is added. + static void Settle() { + BindDefaultFramebuffer(); + (void)ReadPixels(1, 1); + } + + void ExpectGrid(size_t which, const Grid& grid, const std::string& what) { + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + std::vector cells(kCells, 0xDEADu); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_marks[which]); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, static_cast(kCells * sizeof(std::uint32_t)), + cells.data()); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << what << ": readback"; + size_t wrong = 0; + std::string first; + for (std::uint32_t z = 0; z < kGridZ; ++z) { + for (std::uint32_t y = 0; y < kGridY; ++y) { + for (std::uint32_t x = 0; x < kGridX; ++x) { + const bool inside = x < grid[0] && y < grid[1] && z < grid[2]; + const std::uint32_t got = cells[(z * kGridY + y) * kGridX + x]; + if (got != (inside ? 1u : 0u) && wrong++ == 0) { + first = "group (" + std::to_string(x) + "," + std::to_string(y) + "," + + std::to_string(z) + ") = " + std::to_string(got); + } + } + } + } + EXPECT_EQ(wrong, 0u) << what << ": expected exactly the " << grid[0] << "x" << grid[1] << "x" + << grid[2] << " grid marked once; first wrong " << first; + } + + // Magma's server in THIS process: the inproc arm. Spawn and tcp servers are other + // processes, and the monolith arm never takes the wire path at all. + bool CountersReadable() const { return m_magma && m_transport == "inproc"; } + + WireIndirectCounters Delta() const { + WireIndirectCounters now{}; + EXPECT_TRUE(PeekWireIndirectCounters(&now)); + WireIndirectCounters delta{}; + delta.nativeDraws = now.nativeDraws - m_before.nativeDraws; + delta.cpuExpansions = now.cpuExpansions - m_before.cpuExpansions; + delta.barriers = now.barriers - m_before.barriers; + delta.hostWaits = now.hostWaits - m_before.hostWaits; + delta.indirectWaits = now.indirectWaits - m_before.indirectWaits; + delta.nativeDispatches = now.nativeDispatches - m_before.nativeDispatches; + return delta; + } + + bool m_magma = false; + std::string m_transport; + WireIndirectCounters m_before{}; + GLuint m_mark = 0, m_copy = 0, m_parameters = 0, m_source = 0; + std::vector m_marks; + }; + + // CPU-written counts at two non-zero offsets of one store. On Magma/inproc both calls must + // be issued natively, with no host wait and no barrier (no shader wrote the store). + TEST_F(WireIndirectDispatchScenario, CpuWrittenGroupCountsAreReadByTheGpuAtTheirOffsets) { + if (!Ready() || IsSkipped()) return; + Parameters({1, 1, 1, 3, 2, 1, 4, 1, 2}); + const size_t first = DispatchIndirect(12); + const size_t second = DispatchIndirect(24); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Settle(); + if (CountersReadable()) { + const WireIndirectCounters delta = Delta(); + EXPECT_EQ(delta.nativeDispatches, 2u) << "every indirect dispatch must be vkCmdDispatchIndirect"; + EXPECT_EQ(delta.hostWaits, 0u) << "a host wait was taken with no GPU write to the counts"; + EXPECT_EQ(delta.barriers, 0u) << "no shader wrote this store, so no barrier is due"; + } + ExpectGrid(first, {3, 2, 1}, "glDispatchComputeIndirect at byte 12"); + ExpectGrid(second, {4, 1, 2}, "glDispatchComputeIndirect at byte 24"); + } + + // Counts a compute shader wrote, behind the application's own GL_COMMAND_BARRIER_BIT. The + // store starts with OTHER counts, so a stale read dispatches the wrong grid. On + // Magma/inproc: issued natively and - the point of the change - NO host wait. + TEST_F(WireIndirectDispatchScenario, ComputeWrittenGroupCountsDispatchWithoutAHostWait) { + if (!Ready() || IsSkipped()) return; + Parameters({1, 1, 1}); + ComputeWriteParameters({3, 2, 1}); + glMemoryBarrier(GL_COMMAND_BARRIER_BIT); + const size_t first = DispatchIndirect(0); + + Parameters({1, 1, 1, 1, 1, 1}); + ComputeWriteParameters({1, 1, 1, 2, 2, 2}); + glMemoryBarrier(GL_COMMAND_BARRIER_BIT); + const size_t second = DispatchIndirect(12); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Settle(); + if (CountersReadable()) { + const WireIndirectCounters delta = Delta(); + EXPECT_EQ(delta.nativeDispatches, 2u) << "both dispatches must be issued natively"; + EXPECT_EQ(delta.hostWaits, 0u) + << "an indirect dispatch of shader-written counts waited for the GPU on the host"; + EXPECT_EQ(delta.barriers, 2u) << "one INDIRECT_COMMAND_READ barrier per freshly written store"; + } + ExpectGrid(first, {3, 2, 1}, "compute-written counts at byte 0"); + ExpectGrid(second, {2, 2, 2}, "compute-written counts at byte 12"); + } + + // Flywheel's shape, one call over: the counts are written and dispatched with no + // glMemoryBarrier between (GL leaves it undefined). The wire arm orders it anyway, once per + // write, as P8-D does for draws. Magma's split arms only: the monolith arm records no + // dependency here (P8-D recorded that half for dev), and Espryt's is its driver's. + TEST_F(WireIndirectDispatchScenario, ShaderWrittenGroupCountsWithoutAnApplicationBarrierAreOrdered) { + if (!Ready() || IsSkipped()) return; + if (!m_magma || m_transport == "monolith") { + GTEST_SKIP() << "the implicit INDIRECT_COMMAND_READ barrier is the Magma wire arm's (P8-D, P8-SV)"; + } + Parameters({1, 1, 1}); + ComputeWriteParameters({3, 2, 1}); + const size_t first = DispatchIndirect(0); + const size_t again = DispatchIndirect(0); + ComputeWriteParameters({1, 2, 2}); + const size_t third = DispatchIndirect(0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Settle(); + if (CountersReadable()) { + const WireIndirectCounters delta = Delta(); + EXPECT_EQ(delta.nativeDispatches, 3u); + EXPECT_EQ(delta.hostWaits, 0u) << "a host wait was taken"; + EXPECT_EQ(delta.barriers, 2u) << "exactly one barrier per write: the second dispatch reads no new write"; + } + ExpectGrid(first, {3, 2, 1}, "the first dispatch after the write"); + ExpectGrid(again, {3, 2, 1}, "a second dispatch, no write between"); + ExpectGrid(third, {1, 2, 2}, "the dispatch after a second write"); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/WireMipShapeScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/WireMipShapeScenario.cpp new file mode 100644 index 00000000..55bec3d7 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/WireMipShapeScenario.cpp @@ -0,0 +1,220 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/WireMipShapeScenario.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// P8-SV: TWO glGenerateMipmap SHAPES THE MAGMA WIRE ARM GOT WRONG (docs/Disaggregated/notes/p8/SV.md). +// +// * A 3D chain through the NATIVE blit. The wire arm passed the level's depth as the blit's +// subresource layerCount on an image with one array layer (VUID-vkCmdBlitImage-srcImage-00240, +// -srcSubresource-01707, -dstSubresource-01708). Lavapipe blits a 3D image by its z offsets and +// ignores layerCount, so the PIXELS below are right with or without the fix on this host; what +// goes red on the host is the validation layer, which the `.Mip3DValidation.` entry runs this +// case under (inproc, so the server's Vulkan instance is this process and its stdout is ours). +// The pixel half runs on both backends and every arm, and is what a driver that honours +// layerCount would fail. +// * A 1D DEPTH chain with no native blit. The depth shader arm renders 2D views, which a 1D image +// does not admit; the wire arm handed it the chain anyway. It is now the named decline +// MipmapShaderFormatOrShape, decided before the chain grows, and the session carries on. +// Magma only, and reachable on this host only with MGITEST_MAGMA_FORCE_SHADER_MIPMAP (lavapipe +// blits depth natively), which the SERVER reads: split and spawn entries only. + +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitLane.h" +#include "../Harness/SplitRuntimePeek.h" +#include "../Harness/WireDeclinePeek.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include + +namespace MGITest { + namespace { + + using Texel = std::array; + + // Is the Khronos validation layer loaded into THIS process? On the inproc arm the server's + // Vulkan instance lives here, so this is the proof that a run "under the layer" really was. + bool ValidationLayerMapped() { +#if defined(__linux__) + std::ifstream maps("/proc/self/maps"); + std::string line; + while (std::getline(maps, line)) { + if (line.find("VkLayer_khronos_validation") != std::string::npos) return true; + } +#endif + return false; + } + + class WireMipShapeScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_magma = Gl().BackendName() == "DirectVulkan"; + m_transport = PeekSplitRuntime().transportName; + } + + // The Magma server's decline tally is readable where the server is this process. + bool DeclinesReadable() const { return m_magma && m_transport == "inproc"; } + + bool m_magma = false; + std::string m_transport; + }; + + // A 3D RGBA8 chain, 8x8x8 with its full storage, whose level-0 slices come in PAIRS of one + // colour: level 1's slice k is then pair k's colour exactly, and every level below is the + // average of two slices of the level above. A blit that loses the z axis (one slice only, + // or slices read as array layers of a 2D image) cannot produce those numbers. + TEST_F(WireMipShapeScenario, VolumeChainIsGeneratedAlongItsDepth) { + if (!Ready() || IsSkipped()) return; + constexpr int kSide = 8, kLevels = 4; + const std::array pairs = {Texel{200, 40, 80, 255}, Texel{40, 200, 120, 255}, + Texel{100, 100, 240, 255}, Texel{240, 160, 0, 255}}; + GLuint volume = 0; + glGenTextures(1, &volume); + glBindTexture(GL_TEXTURE_3D, volume); + glTexStorage3D(GL_TEXTURE_3D, kLevels, GL_RGBA8, kSide, kSide, kSide); + std::vector level0(static_cast(kSide * kSide * kSide * 4)); + for (int z = 0; z < kSide; ++z) { + const Texel& texel = pairs[static_cast(z / 2)]; + for (int at = 0; at < kSide * kSide; ++at) { + for (int c = 0; c < 4; ++c) + level0[static_cast((z * kSide * kSide + at) * 4 + c)] = static_cast(texel[c]); + } + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, kSide, kSide, kSide, GL_RGBA, GL_UNSIGNED_BYTE, level0.data()); + glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "3D texture setup"; + glGenerateMipmap(GL_TEXTURE_3D); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "glGenerateMipmap(GL_TEXTURE_3D)"; + + const auto average = [](const Texel& a, const Texel& b) { + return Texel{(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2}; + }; + // expected[level][slice] + std::vector> expected(kLevels); + expected[1].assign(pairs.begin(), pairs.end()); + expected[2] = {average(pairs[0], pairs[1]), average(pairs[2], pairs[3])}; + expected[3] = {average(expected[2][0], expected[2][1])}; + glPixelStorei(GL_PACK_ALIGNMENT, 4); + for (int level = 1; level < kLevels; ++level) { + const int side = kSide >> level; + GLint width = 0, height = 0, depth = 0; + glGetTexLevelParameteriv(GL_TEXTURE_3D, level, GL_TEXTURE_WIDTH, &width); + glGetTexLevelParameteriv(GL_TEXTURE_3D, level, GL_TEXTURE_HEIGHT, &height); + glGetTexLevelParameteriv(GL_TEXTURE_3D, level, GL_TEXTURE_DEPTH, &depth); + EXPECT_EQ(width, side) << "level " << level; + EXPECT_EQ(height, side) << "level " << level; + EXPECT_EQ(depth, side) << "level " << level; + std::vector pixels(static_cast(side * side * side * 4), 0x5a); + glGetTexImage(GL_TEXTURE_3D, level, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "readback of level " << level; + for (int z = 0; z < side; ++z) { + const Texel& want = expected[static_cast(level)][static_cast(z)]; + size_t wrong = 0; + Texel first{}; + for (int at = 0; at < side * side; ++at) { + const size_t base = static_cast((z * side * side + at) * 4); + bool ok = true; + for (int c = 0; c < 4; ++c) { + const int got = pixels[base + static_cast(c)]; + if (got < want[c] - 2 || got > want[c] + 2) ok = false; + } + if (!ok && wrong++ == 0) { + for (int c = 0; c < 4; ++c) first[c] = pixels[base + static_cast(c)]; + } + } + EXPECT_EQ(wrong, 0u) << "level " << level << " slice " << z << ": expected (" << want[0] << "," + << want[1] << "," << want[2] << "," << want[3] << "), first wrong texel (" + << first[0] << "," << first[1] << "," << first[2] << "," << first[3] << ")"; + } + } + glBindTexture(GL_TEXTURE_3D, 0); + glDeleteTextures(1, &volume); + + // The `.Mip3DValidation.` entry: the blit above ran under the Khronos validation layer, + // whose VUID lines the entry's FAIL_REGULAR_EXPRESSION reads. Without the layer actually + // loaded, a clean output would prove nothing, so the entry requires it. + if (SplitLane::MarkerIsOne("MGITEST_REQUIRE_VK_VALIDATION_LAYER")) { + ASSERT_TRUE(m_magma && m_transport == "inproc") + << "the validation entry reads the server's layer output in-process: Magma, inproc"; + EXPECT_TRUE(ValidationLayerMapped()) + << "VK_LAYER_KHRONOS_validation is not loaded in this process, so the absence of " + "VUID lines in this entry's output says nothing"; + } + } + + // A 1D DEPTH chain with the native blit forced off: no mip arm renders a 1D image, so the + // wire arm declines by name (MipmapShaderFormatOrShape) before growing the chain. Pinned: + // no GL error, the session answering (two readback round trips), level 0 as uploaded, level + // 1 still the application's own sentinel - and, where the server is this process, exactly + // one MipmapShaderFormatOrShape decline counted. + TEST_F(WireMipShapeScenario, Depth1DChainWithoutANativeBlitDeclinesByName) { + if (!Ready() || IsSkipped()) return; + if (!m_magma) + GTEST_SKIP() << "Magma wire-arm decline (P8-SV): the knob and the depth shader arm it forces are " + "Magma's; Espryt (" << Gl().BackendName() << ") generates this chain itself"; + const std::string tier = SplitLane::MarkerValue("MGITEST_MAGMA_FORCE_SHADER_MIPMAP"); + if (tier.empty()) + GTEST_SKIP() << "MGITEST_MAGMA_FORCE_SHADER_MIPMAP is unset: this driver blits depth natively, " + "so the depth shader arm this case refuses is not entered"; + constexpr int kWidth = 16; + GLuint line = 0; + glGenTextures(1, &line); + glBindTexture(GL_TEXTURE_1D, line); + glTexStorage1D(GL_TEXTURE_1D, 5, GL_DEPTH_COMPONENT32F, kWidth); + const std::vector level0(kWidth, 0.25f), level1(kWidth / 2, 0.75f); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glTexSubImage1D(GL_TEXTURE_1D, 0, 0, kWidth, GL_DEPTH_COMPONENT, GL_FLOAT, level0.data()); + glTexSubImage1D(GL_TEXTURE_1D, 1, 0, kWidth / 2, GL_DEPTH_COMPONENT, GL_FLOAT, level1.data()); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "1D depth texture setup"; + unsigned long long declinesBefore = 0; + const bool counted = + DeclinesReadable() && PeekWireDeclineCount("MipmapShaderFormatOrShape", &declinesBefore); + glGenerateMipmap(GL_TEXTURE_1D); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "glGenerateMipmap(GL_TEXTURE_1D) on a depth chain"; + glPixelStorei(GL_PACK_ALIGNMENT, 4); + for (const int level : {0, 1}) { + const int width = kWidth >> level; + std::vector depths(static_cast(width), -1.0f); + glGetTexImage(GL_TEXTURE_1D, level, GL_DEPTH_COMPONENT, GL_FLOAT, depths.data()); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "readback of level " << level; + const GLfloat want = level == 0 ? 0.25f : 0.75f; + size_t wrong = 0; + GLfloat first = 0.0f; + for (const GLfloat depth : depths) { + if (depth < want - 0.0001f || depth > want + 0.0001f) { + if (wrong++ == 0) first = depth; + } + } + EXPECT_EQ(wrong, 0u) << "level " << level << " tier=" << tier << ": a declined mip must leave " + << "every level as the application wrote it (expected " << want + << ", first wrong " << first << ")"; + } + glBindTexture(GL_TEXTURE_1D, 0); + glDeleteTextures(1, &line); + if (counted) { + unsigned long long declinesAfter = 0; + ASSERT_TRUE(PeekWireDeclineCount("MipmapShaderFormatOrShape", &declinesAfter)); + EXPECT_EQ(declinesAfter - declinesBefore, 1u) + << "the 1D depth chain must be refused once, by name, before the chain grows"; + } + } + + } // namespace +} // namespace MGITest diff --git a/scripts/ci/spawn_lane_parity.py b/scripts/ci/spawn_lane_parity.py index f442a506..e1f4dc4e 100644 --- a/scripts/ci/spawn_lane_parity.py +++ b/scripts/ci/spawn_lane_parity.py @@ -64,7 +64,12 @@ FIXTURE_ENTRIES = {"TcpServer.Start", "TcpServer.Stop"} # answers unavailable, so a copy there checks pixels under a name claiming # the pools. The pixels do run on every arm: the Espryt entries and the # Magma informational and full-suite tiers. -MAGMA_INPROC_ONLY = ("MagmaRunAheadScenario.", "MagmaWireCacheScenario.", "DescriptorPoolGrowthScenario.") +# `.Mip3DValidation.` (P8-SV, a TAIL): WireMipShapeScenario's 3D chain under the Khronos validation +# layer, red on a vkCmdBlitImage VUID in the entry's own output and proving the +# layer loaded from /proc/self/maps - both need the server's Vulkan instance +# in this process. The knob-free `.Mip3D.` entries keep all three arms. +MAGMA_INPROC_ONLY = ("MagmaRunAheadScenario.", "MagmaWireCacheScenario.", "DescriptorPoolGrowthScenario.", + ".Mip3DValidation.") # P7 wave 2-B2: THE SANCTIONED ASYMMETRY THAT IS NOT INPROC-ONLY - these exist on split AND # spawn and cannot exist on tcp. Every MGITEST_MAGMA_FORCE_* knob is read BY THE SERVER @@ -140,7 +145,9 @@ MAGMA_SERVER_ENV_KNOB_NO_TCP = (".ShaderMip1.", ".ShaderMip2.", ".DepthMip.", f".PrimGenReroute.PrimitivesGeneratedNoXfbScenario.{case}" for case in PRIMGEN_REROUTE_CASES) + tuple( f".SplitRecording64.OversizedRecordingSplitScenario.{case}" for case in SPLIT_RECORDING_CASES) + ( # P8-F: the mip shader-shape decline, forced by MGITEST_MAGMA_FORCE_SHADER_MIPMAP (server-read). - ".ShaderMipDecline.",) + ".ShaderMipDecline.", + # P8-SV: the 1D depth chain's decline, forced by the same server-read knob. + ".ShaderMipDepth1D.",) # THE MIRROR SHAPE - entries that exist on tcp ALONE, by construction. Each tail must match at least