Compare commits

..
22 Commits
Author SHA1 Message Date
swung0x48 9eae98581f [Test] (SelfTest, POST): probe upload/draw ordering in persistently mapped vertex arenas - native GLES controls distinguish mapped destination corruption from staging and synchronization failures
Add the persistent buffer ordering probe to the GLES POST's known-driver-bug inventory. Queue updates and draws into independent FBOs before reading them back, covering SubData and copies from both coherent persistent and ordinary staging buffers. Retry fresh mapped allocations for intermittent corruption.

Require passing never-mapped and fully serialized controls before reporting a finding. Include finish-before, map-then-unmap and barrier diagnostics, preserve caller GL state, and treat setup, allocation and GL errors as inconclusive. This adds detection and reporting only; no rendering workaround is enabled.

Validation: all 46 DriverBugProbesTest tests pass, including 11 new ordering, control, collector and cleanup cases. The Android API 26 / NDK 27 native probe detects all three upload paths on Mali-G1-Ultra r54p1 with clean controls and no GL errors. llvmpipe reports no finding after 240 mapped FBO checks per upload path.
2026-09-07 00:44:08 -04:00
swung0x48 d7655247f7 [Fix, Test] (DirectGLES, Integration): rebind VAOs when an adopted buffer is respecified - the immediate retire path forgot the buffer-id generation, so cached vertex and element bindings kept the deleted store
Advance the buffer-id generation when Ops_Respecify retires immutable storage on the context thread, matching the existing adoption and deferred-retirement paths.

Add pixel regression coverage for unchanged VBO/IBO attachments across same-size redefinition, growth and shrinkage, including bound and unbound VAOs sharing a vertex arena and an index arena returning to shadow storage.
2026-09-06 22:55:06 -04:00
swung0x48 50fb13430f [Merge] (MG_State, ShaderTranspiler, tools/cts): land the write-map landing and open-ended fp64 storage block fixes that take KHR-Single-GL45.subgroups to 100% on Magma 2026-09-05 05:43:34 -04:00
swung0x48 d4f8adcf6d [Tools, Test] (tools/cts, MG_Test): make the CTS runner's chunk timeout idle-based so a healthy 20-minute invocation is no longer killed and its in-flight case mis-recorded as a crash, and link MSVC test executables with /WHOLEARCHIVE so the dllimport gl* references in GetProcAddress.cpp resolve 2026-09-05 05:36:50 -04:00
swung0x48 795e08f7e6 [Fix, Test] (ShaderTranspiler): flatten fp64 storage blocks whose last member is a runtime array - the pass declined them, so the fp64 demotion re-derived ArrayStride 4 over the application's 8/16/32-byte double buffer and every double/dvecN data[] SSBO read raw words 2026-09-05 05:36:49 -04:00
swung0x48 1e7ecab4db [Fix, Test] (MG_State, BufferObject): land a non-persistent write map's staged bytes into a GPU-resident store at unmap and explicit flush instead of dropping them - SSBO binding and large-store adoption make resident stores reachable through glMapBufferRange, so every per-draw re-initialisation was silently lost 2026-09-05 05:36:48 -04:00
swung0x48 81b17c0b75 [Test] (ShaderTranspiler, Integration): pin the five reworked shapes - the read-only capture stage through the real link, the seeded carrier's ESSL declaration, the metadata-driven block redeclaration and its decline, the control-stage-less evaluation decline, and a carrier clearing an i64vec4 2026-08-28 06:22:12 -04:00
swung0x48 d1edf765f5 [Fix] (Link, Async): carry the resolved gl_PointSize capture request into the SPIR-V handoff and let a deferred verdict name its own severity - the demotion read the request off a reflection slice phase A never fills it into, so its forced carrier was dead code, and its decline reason replayed at a level no shipped build keeps 2026-08-28 06:22:12 -04:00
swung0x48 97e07190ac [Fix] (ShaderTranspiler): seed a forced point-size carrier nothing writes and decline a control stage whose live clip/cull distance would still print gl_PointSize - an ES front end deletes a never-written output, and SPIRV-Cross redeclares that block from member decorations rather than access 2026-08-28 06:22:11 -04:00
swung0x48 a4dcdf989e [Fix] (ShaderTranspiler): stop the point-size carrier landing on a location a live varying owns, and decline the evaluation stage a synthesized pass-through control stage cannot feed - an i64vec4 counted as one location, and a located input carrier trips both backends' pass-through guard 2026-08-28 06:22:11 -04:00
swung0x48 1c113e4b26 [Fix] (ShaderTranspiler): count a 64-bit integer vector as two locations in the XFB flattener's LocationSpan - it read 64-bitness off the element's float type alone, so an i64vec3/4 member packed the members after it onto locations it already owns 2026-08-28 06:22:11 -04:00
swung0x48 bf9cfb3079 [Test] (Integration): PointSizeDemotionScenario - the demoted value chain is client-invisible in both configurations, with pinned per-backend lanes and a log-latch arming guard 2026-08-28 06:22:10 -04:00
swung0x48 e1818d497a [Test] (ShaderTranspiler): pin the point-size demotion's shapes - capability stripped, carriers named and located past the program's varyings, byte-identical no-ops, whole-struct-copy decline, and the two new L1 key bits 2026-08-28 06:21:31 -04:00
swung0x48 d7f66722d1 [Fix] (ShaderTranspiler, Link, DirectGLES, DirectVulkan): demote tessellation/geometry gl_PointSize to an ordinary varying where the device cannot host the built-in - the value survives for gl_in reads and by-name capture, both backends' declines stay for shapes the pass refuses, and the verdict rides the L1 key 2026-08-28 06:21:30 -04:00
swung0x48 92dc41ebf9 [Test] (DirectVulkan, SelfTest): pin the probe's fence-timeout teardown against a fake driver, the never-worse arming refusal, and paused-span patch/instanced counting against an unpaused control 2026-08-28 06:19:41 -04:00
swung0x48 feea131d8b [Fix] (DirectVulkan, SelfTest): honour the primitives-generated probe's leak-on-timeout contract in both of its callers, count paused-span draws the frontend cannot price, refuse a substitute measured worse than the stream query, and derive the POST row's failure clause from the measurement 2026-08-28 06:19:40 -04:00
swung0x48 19f4402fbf [Test] (DirectVulkan): pin the primitives-generated probe's verdict and override mapping, and hold the reroute's GL answers and arming observable on the integration lane 2026-08-28 06:19:40 -04:00
swung0x48 1350031368 [Fix] (DirectVulkan): count GL_PRIMITIVES_GENERATED for transform-feedback-inactive draws - a bring-up probe measures the silent stream query and reroutes such draws through the dedicated primitives-generated query or a clipping-statistics pool, reported from the Vulkan POST 2026-08-28 06:19:39 -04:00
swung0x48 0ee3384b22 [Fix] (Espryt): land a SubData into an adopted store as a GPU-ordered copy - the in-place coherent write tore the frames still reading the old section bytes 2026-08-28 04:50:07 -04:00
swung0x48 ba3f8d6774 [Test] (Integration): pin the adopted mesh-arena store - cross-frame SubData visibility, readback identity, and the GPU-written readback 2026-08-28 04:19:11 -04:00
swung0x48 3327784fd0 [Fix] (Espryt): adopt mesh-arena-sized stores into coherent persistent maps at definition, and stop the flush tiers from re-synchronizing them 2026-08-28 04:19:11 -04:00
swung0x48 ff426da3a9 [Fix, Test] (MG_State, DirectVulkan): order host writes to an adopted store after recorded GPU work - a SubData issued after a dispatch landed in coherent memory before the deferred dispatch executed, so its increments overwrote the newer bytes; un-skip the DirectVulkan half of the SubData-after-dispatch scenario 2026-08-28 03:01:15 -04:00
61 changed files with 9563 additions and 179 deletions
+3
View File
@@ -307,13 +307,16 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp
MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp
MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp
MobileGL/MG_Util/Texture/PixelStoreProcessor.cpp
MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp
+38
View File
@@ -152,6 +152,19 @@ namespace MobileGL::MG_Config {
// lavapipe carry a located block correctly and would otherwise never run this code -
// and ForceOff is the negative control. See StripIoBlockLocationsPass.
QuirkOverride EsprytUnlocatedIoBlocks = QuirkOverride::Auto;
// MOBILEGL_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs
// on a device that advertises neither EXT/OES_tessellation_point_size /
// geometry_point_size (DirectGLES) nor shaderTessellationAndGeometryPointSize
// (DirectVulkan). Auto arms it exactly where the detection says the capability is
// absent, which is the right setting everywhere. ForceOn exists so the demotion can
// be exercised on a healthy driver - llvmpipe and lavapipe host the built-in
// natively and would otherwise never run this code, which is what the pinned
// integration lane uses - and ForceOff restores the plain declines (escape hatch /
// negative control). Cross-backend by design: the demotion runs in the shared
// phase-B chain, so one switch covers both. See DemotePointSizePass.
QuirkOverride PointSizeDemotion = QuirkOverride::Auto;
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
@@ -184,6 +197,15 @@ namespace MobileGL::MG_Config {
// on Mali both the immediate glBufferSubData and a staged copy into a busy
// mutable store ghost the whole destination on the CPU.
Bool EsprytDisableInvalidateFlush = false;
// MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION: keep mesh-arena-sized buffer stores
// (>= 16MiB) on the CPU-shadow model instead of backing them with the backend's
// persistently+coherently mapped storage at definition time (negative control /
// escape hatch). Frontend-scoped: it engages only where the active backend
// provides AcquirePersistentMap. With adoption on, an app SubData into a busy
// 128MB arena is a plain memcpy into GPU-visible memory; every driver-mediated
// route for the same write stalls the thread or ghost-copies the whole arena on
// this class of Mali driver, and the arena stops costing its size again in RAM.
Bool DisableLargeBufferAdoption = false;
// MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES
// depth/stencil reads and always go through the shader-sampling emulation. Core GL
// ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does,
@@ -278,6 +300,22 @@ namespace MobileGL::MG_Config {
// negative control that replays the corruption. Costs 2x the memory of the affected
// formats where it engages, which is why Auto is probe-gated rather than always-on.
QuirkOverride EsprytWidenPacked16Storage = QuirkOverride::Auto;
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE: DirectVulkan's GL_PRIMITIVES_GENERATED
// reroute for draws made while transform feedback is INACTIVE. The stream query
// (VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT primitivesNeeded) is defined to count
// them, but a Mali driver - and Mesa lavapipe - answers 0 unless a capture span is
// open, which is exactly the shape the CTS uses to measure the tessellator, so ~29
// tessellation tests per tree size a capture buffer from the 0 and die on the
// zero-length map. Auto defers to a device probe at renderer bring-up
// (SelfTest::RunPrimitivesGeneratedNoXfbProbe), which measures two substitutes on
// the same capture-less draws and arms the best proven one: the dedicated
// VK_EXT_primitives_generated_query (exact semantics by definition; lavapipe passes
// it, rasterizer discard included), else a clipping-invocations pipeline-statistics
// pool (see the verdict vocabulary for its rasterizer-discard split). ForceOn pins
// the reroute structurally wherever a pool can exist (the arming-observable lane,
// immune to the probe's verdict moving), and ForceOff is the negative control that
// replays the driver's silence.
QuirkOverride MagmaPrimGenQueryReroute = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+3
View File
@@ -181,12 +181,14 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytUnlocatedIoBlocks = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.EsprytDisableUboRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UBO_RING");
features.EsprytDisableUnpackRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UNPACK_RING");
features.EsprytDisableUploadRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING");
features.EsprytDisableInvalidateFlush = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH");
features.DisableLargeBufferAdoption = QueryEnvFlag("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION");
features.EsprytForceDepthStencilReadbackEmulation =
QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION");
features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS");
@@ -204,6 +206,7 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION");
features.EsprytWidenPacked16Storage =
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE");
features.MagmaPrimGenQueryReroute = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE");
}
inline void InitBackendType() {
+18
View File
@@ -495,6 +495,24 @@ namespace MobileGL {
// halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat)
// still see one consistent world.
Bool SupportsFloat64VertexAttributes = false;
// Whether a TESSELLATION stage of this backend may access gl_PointSize - i.e.
// whether a module declaring OpCapability TessellationPointSize can reach the
// driver at all. DirectVulkan sets both this and the geometry twin from the one
// shaderTessellationAndGeometryPointSize feature; DirectGLES sets them
// independently from the EXT/OES_tessellation_point_size /
// geometry_point_size extension pairs (PointSizeTier), which really do come
// separately. When absent, ProgramSpirvTask demotes the built-in to an ordinary
// varying program-wide (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram); MOBILEGL_POINT_SIZE_DEMOTION
// overrides the detection in either direction at backend init.
//
// Defaults TRUE, deliberately against the house "assume absent" rule: false
// ARMS a rewrite, so the conservative no-backend answer (standalone compiles,
// unit tests) is the one that leaves modules untouched. A backend that never
// sets it gets standard modules and, at worst, the old honest declines.
Bool SupportsTessellationPointSize = true;
// The geometry-stage twin (OpCapability GeometryPointSize).
Bool SupportsGeometryPointSize = true;
SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024;
Uint32 SubgroupSize = 0;
Uint32 SubgroupSupportedStages = 0;
@@ -1479,6 +1479,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Follows the line above, and must: OpenGL ES has no double-precision vertex format and no
// fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to land here.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
// Whether a tessellation / geometry stage's ESSL may name gl_PointSize at all: the two
// extension pairs the loader probed, independently, because they really do come
// separately. False arms the shared phase-B demotion
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), whose ESSL then
// never names the built-in in those stages and needs no extension.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends both are absent so the demotion can be
// exercised on a healthy driver (the pinned integration lane); =0 restores the
// detected answer's declines.
m_dynamicParameters.SupportsTessellationPointSize =
m_GLESCapabilities.TessellationPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
m_dynamicParameters.SupportsGeometryPointSize =
m_GLESCapabilities.GeometryPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
m_dynamicParameters.SupportsTessellationPointSize = false;
m_dynamicParameters.SupportsGeometryPointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the driver's extensions");
m_dynamicParameters.SupportsTessellationPointSize = true;
m_dynamicParameters.SupportsGeometryPointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers;
m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments;
m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances;
+174 -20
View File
@@ -783,6 +783,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource.storageInitialized = true;
resource.pendingRespecify = false;
resource.pendingRanges.clear();
resource.pendingResidentWrites.clear();
resource.syncedChangeSerial = bufferObject.GetChangeSerial();
// A GROWN store keeps its indexed bindings, and BindBufferBaseCached skips a
// rebind whenever the shadow already records this id at that index - so on a
@@ -828,6 +829,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
return RingAvailable(g_uploadRing);
}
// A partial range below this goes through the staging ring instead of a
// range-invalidating map: the map's page-substitution fast path needs a
// sizeable (page-coverable) range to engage, and below it the driver
// falls back to waiting out the WAR hazard on the CPU.
constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u;
// Push every queued range of `resource` from the shadow into the backend
// store, without ever letting a driver resolve the WAR hazard against
// in-flight frames at the WHOLE BUFFER's expense. Three tiers:
@@ -879,11 +886,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT start = std::min(range.start, end);
const SizeT size = end - start;
if (size == 0) continue;
if (mapUsable) {
// The invalidating map's fast path is SHAPE-dependent on this Mali
// driver: a whole-buffer invalidation renames the store outright,
// and a large range gets fresh pages - but a small unaligned range
// of a busy store makes the map WAIT (osup_sync_object_wait, ~9%
// of a Minecraft 26.3 replay). So: whole buffer -> orphan-map;
// large range -> range-invalidating map; small range -> the staged
// ring copy, whose worst case (a whole-destination ghost) is only
// ever the small destination itself.
//
// The map covers EXACTLY the queued range: only those bytes are the
// shadow's to rewrite. Widening to page bounds looked free and was
// not - the widened bytes clobbered GPU-written data (an SSBO
// counter beside the app's SubData) with the stale shadow.
const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize;
if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) {
BindBufferId(TempBufferTarget, resource.id);
const GLbitfield access =
GL_MAP_WRITE_BIT |
(wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT);
void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start,
(GLsizeiptr)size,
GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
(GLsizeiptr)size, access);
if (dst) {
Memcpy(dst, bufferObject.MappedData() + start, size);
g_GLESFuncs.glUnmapBuffer(TempBufferTarget);
@@ -904,6 +927,49 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// Land the app bytes queued for an ADOPTED store on the GPU timeline: staged
// into the upload ring and delivered by glCopyBufferSubData. The destination
// is the IMMUTABLE persistent store, which the driver can neither rename nor
// ghost, so the copy is plain job ordering - after every in-flight reader,
// before the next consumer - which is exactly glBufferSubData's contract.
// (The in-place host write these bytes replaced tore the frames still
// reading the old vertex data: one-frame wrong geometry during fast camera
// movement.) Fallback: direct glBufferSubData - the adopted store carries
// DYNAMIC_STORAGE, and immutability again forbids the whole-store ghost.
void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
Vector<GLESBufferResource::PendingResidentWrite> writes;
{
const std::lock_guard<std::mutex> lock(resource.pendingMutex);
if (resource.pendingResidentWrites.empty()) return;
writes = std::move(resource.pendingResidentWrites);
resource.pendingResidentWrites.clear();
}
const SizeT limit = resource.storageSize;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& write : writes) {
if (write.offset >= limit) continue;
const SizeT size = std::min(write.bytes.size(), limit - write.offset);
if (size == 0) continue;
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
Memcpy(g_uploadRing.store.mappedPtr + ringOffset, write.bytes.data(), size);
BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id);
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)write.offset,
(GLsizeiptr)size);
} else {
BindBufferId(TempBufferTarget, resource.id);
g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)write.offset, (GLsizeiptr)size,
write.bytes.data());
}
}
}
// EXT_buffer_storage bit values (same numeric values as the desktop ARB
// tokens); defined locally so this compiles regardless of which GLES headers
// expose the EXT tokens.
@@ -989,6 +1055,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return ptr;
@@ -1014,6 +1081,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id != 0 && CanTouchGLNow() &&
resource->contextGeneration == g_bufferContextGeneration) {
NoteBufferIdDeleted(resource->id);
// Frontend VAO bindings survive respecification; force their
// backend twins to bind the replacement buffer name.
++g_bufferBackendIdGeneration;
g_GLESFuncs.glDeleteBuffers(1, &resource->id);
resource->id = 0;
resource->immutableStorage = false;
@@ -1026,12 +1096,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration) {
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (bufferObject.GetSize() == 0) {
@@ -1039,6 +1111,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = false;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
RespecifyStorageNow(*resource, bufferObject);
@@ -1055,17 +1128,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->pendingRanges.Add({offset, offset + size});
return;
}
// An adopted zero-copy persistent store already HAS the bytes (the
// frontend wrote them through the coherent mapping); a driver upload
// here would be a self-copy that re-synchronizes what coherent mapping
// made free.
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// An immediate glBufferSubData resolves the WAR hazard against frames
// still referencing this store on the CPU on some drivers - Mali parks
// the thread in osup_sync_object_wait until every referencing job
// retires, which serialized Minecraft 26.3's per-frame UBO/chunk-mesh
// update streams into ~1 fps. Queue the range instead (the shadow
// already holds the bytes) and let draw-time sync push the merged
// ranges through the staging ring. The zero-copy persistent store
// keeps the legacy immediate upload: draw-time sync never flushes
// ranges for it, and its mapping publishes writes by itself.
if ((resource->persistentMapped && resource->persistentPtr) ||
MG_Config::Features.EsprytDisableUploadRing) {
// ranges through the staging ring.
if (MG_Config::Features.EsprytDisableUploadRing) {
UploadRangeNow(*resource, bufferObject, offset, offset + size);
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
@@ -1074,6 +1152,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->pendingRanges.Add({offset, offset + size});
}
// App bytes for an ADOPTED store: queue them untouched-by-the-mapping; the
// draw-time sync (or a readback) lands them GPU-ordered through
// DrainResidentWritesNow. No GL here, so the op is thread-agnostic.
void Ops_ResidentSubData(BufferObject& bufferObject, SizeT offset, DataPtr data) {
auto* resource = ResourceOf(bufferObject);
if (!resource || data.size == 0) return;
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
auto& write = resource->pendingResidentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void Ops_FlushMappedRange(BufferObject& bufferObject, Range1D range,
Flags<BufferMappingAccessBit> appAccess) {
auto* resource = ResourceOf(bufferObject);
@@ -1087,13 +1178,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
return;
}
// Same WAR-hazard rule as Ops_SubData: an immediate upload (mapped or
// glBufferSubData) can park the thread on Mali until the frames still
// referencing this store retire. Queue the range for the staged-copy
// flush at draw-time sync; only the zero-copy persistent store and the
// negative-control kill switch keep the immediate paths below.
if (!(resource->persistentMapped && resource->persistentPtr) &&
!MG_Config::Features.EsprytDisableUploadRing) {
// An adopted zero-copy persistent store already HAS the bytes: the
// frontend shadow IS the coherent mapping the app (or UploadSubData)
// wrote into, so publishing is free. The self-copy that used to run
// here mapped a buffer this backend keeps persistently mapped (an
// INVALID_OPERATION whose fallback was a WAR-stalling
// glBufferSubData).
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// Same WAR-hazard rule as Ops_SubData: an immediate synchronized upload
// (mapped or glBufferSubData) can park the thread on Mali until the
// frames still referencing this store retire. Queue the range for the
// staged flush at draw-time sync; the negative-control kill switch
// keeps the immediate paths below.
if (!MG_Config::Features.EsprytDisableUploadRing) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add(range);
return;
@@ -1137,8 +1238,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!resource || resource->id == 0 || !resource->storageInitialized) return;
if (!CanTouchGLNow() || resource->contextGeneration != g_bufferContextGeneration) return;
if (resource->persistentMapped) {
// Host writes to a persistent map must not race shader writes already queued
// on this context. There is no backend copy to read back in this case.
// Queued resident SubData bytes land first (GPU-ordered), then the
// finish makes them - and any shader writes already queued on this
// context - visible through the coherent mapping the reads use.
// There is no backend copy to read back in this case.
DrainResidentWritesNow(*resource, bufferObject);
if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish();
return;
}
@@ -1204,6 +1308,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Ops_SubData(bufferObject, offset, size);
BumpBufferMutationEpoch();
}
void Ops_ResidentSubDataTracked(BufferObject& bufferObject, SizeT offset, DataPtr data) {
Ops_ResidentSubData(bufferObject, offset, data);
BumpBufferMutationEpoch();
}
void Ops_FlushMappedRangeTracked(BufferObject& bufferObject, Range1D range,
Flags<BufferMappingAccessBit> appAccess) {
Ops_FlushMappedRange(bufferObject, range, appAccess);
@@ -1228,6 +1336,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const BufferBackendOps g_glesBufferBackendOps = {
.Respecify = Ops_RespecifyTracked,
.SubData = Ops_SubDataTracked,
.ResidentSubData = Ops_ResidentSubDataTracked,
.FlushMappedRange = Ops_FlushMappedRangeTracked,
.OnDestroy = Ops_OnDestroyTracked,
.AcquirePersistentMap = Ops_AcquirePersistentMapTracked,
@@ -1244,7 +1353,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
// See the declaration: re-mints of a live resource's driver id. Written only on
// the context thread (both re-mint sites run there), read only by the VAO sync.
// the context thread (all re-mint sites run there), read only by the VAO sync.
Uint64 g_bufferBackendIdGeneration = 0;
void RegisterBufferBackendOps() {
@@ -1329,7 +1438,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id == 0) return false;
// Zero-copy coherent persistent store: EnsureBufferResource's own early-out —
// the app writes straight into the mapped GPU storage, nothing to sync.
if (resource->persistentMapped) return resource->persistentPtr != nullptr;
// Except queued resident SubData bytes, which land through the sync path
// (same unlocked emptiness probe as pendingRanges below).
if (resource->persistentMapped) {
return resource->persistentPtr != nullptr && resource->pendingResidentWrites.empty();
}
// A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push
// (persistent maps mutate the shadow without bumping the change serial).
if (frontend->IsMapped()) return false;
@@ -1361,6 +1474,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
resource->contextGeneration = g_bufferContextGeneration;
// The persistent map (and its pointer) died with the old context; the
// frontend re-acquires a fresh one on its next map.
@@ -1390,6 +1504,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// persistently mapped immutable store, so there is nothing to (re)upload at
// draw time. This is where the per-draw whole-buffer glBufferSubData used to run.
if (resource->persistentMapped && resource->persistentPtr && resource->id != 0) {
DrainResidentWritesNow(*resource, *bufferObject);
return resource;
}
@@ -1411,6 +1526,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject->GetChangeSerial();
} else {
@@ -2628,7 +2744,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
}
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) {
// TEMP-EXP (leak texture deletes): /sdcard/MG/exp_leak_texture_deletes.
// Discriminator for the mali-mem-purge hiccup theory: never hand the
// driver a texture free, so the purge daemon has nothing to reclaim.
static const Bool s_expLeakTextureDeletes = [] {
FILE* f = std::fopen("/sdcard/MG/exp_leak_texture_deletes", "rb");
if (!f) return false;
std::fclose(f);
return true;
}();
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures &&
!s_expLeakTextureDeletes) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
if (m_bufferImageSplitViewId != 0) {
g_GLESFuncs.glDeleteTextures(1, &m_bufferImageSplitViewId);
@@ -7219,6 +7345,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendProgramUsable = false;
return;
}
if (stateProgramObject->PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that
// pins MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because
// every rendering assertion stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectGLES is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this driver cannot host the built-in "
"in those stages.");
}
MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId);
for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) {
if (!ref.shader) continue;
@@ -7889,6 +8024,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// for; it has the variable that replaced it. Everything else - including a
// member of a block that was left alone - keeps the application's spelling.
// Storage first, pointers after: xfbNames holds pointers into these strings.
//
// Same rule for a demoted gl_PointSize: the capture stage's ESSL no longer
// spells the built-in at all - the value lives in the carrier the demotion
// named - so the driver-side request has to follow it there. Only when the
// capture stage IS a demoted one (geometry, else evaluation): a program whose
// capture stage is the vertex shader keeps the built-in and its spelling,
// whatever happened to a control stage behind it.
Bool captureStageDemoted = false;
if (stateProgramObject->PointSizeDemoted()) {
for (const ShaderStage linkedStage : linkedStages) {
if (linkedStage == ShaderStage::TessEval || linkedStage == ShaderStage::Geometry) {
captureStageDemoted = true;
break;
}
}
}
Vector<String> rewrittenXfbNames(xfbVaryings.size());
for (SizeT nameIndex = 0; nameIndex < xfbVaryings.size(); ++nameIndex) {
String flatName;
@@ -7896,6 +8047,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::ShaderCompiler::RewriteXfbCaptureNameForFlattenedBlock(
xfbVaryings[nameIndex].name, flattenedXfbBlockNames, flatName)) {
rewrittenXfbNames[nameIndex] = std::move(flatName);
} else if (captureStageDemoted && xfbVaryings[nameIndex].name == "gl_PointSize") {
rewrittenXfbNames[nameIndex] =
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME;
} else {
rewrittenXfbNames[nameIndex] = xfbVaryings[nameIndex].name;
}
+10
View File
@@ -461,6 +461,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the owning thread replaying them: guard both fields with pendingMutex.
Bool pendingRespecify = false;
VecRange1D pendingRanges;
// App bytes for an ADOPTED store, awaiting their GPU-ordered landing (ring
// stage + glCopyBufferSubData at the next sync; see
// BufferBackendOps::ResidentSubData). The frontend keeps such writes out of
// the coherent mapping - an in-place host write tears the in-flight frames
// still reading the old bytes. Guarded by pendingMutex like pendingRanges.
struct PendingResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<PendingResidentWrite> pendingResidentWrites;
std::mutex pendingMutex;
// Buffer-mutation epoch (see CurrentBufferMutationEpoch) at which this
// resource last probed IsBufferDrawClean == true, 0 = never (epochs start
@@ -1081,6 +1081,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// report VK_FALSE, so on every real mobile device this is false and the demotion runs
// exactly as it always has.
m_dynamicParameters.SupportsShaderFloat64 = m_vulkanCaps.SupportsShaderFloat64;
// shaderTessellationAndGeometryPointSize, both stage families from the one feature.
// False arms the shared phase-B point-size demotion, whose modules then carry no
// TessellationPointSize/GeometryPointSize capability and build without the feature.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends it is absent so the demotion can be
// exercised on a healthy driver (lavapipe advertises the feature); =0 restores the
// detected answer's declines.
{
Bool supportsStagePointSize = m_vulkanCaps.SupportsTessellationAndGeometryPointSize;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
supportsStagePointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the device feature");
supportsStagePointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.SupportsTessellationPointSize = supportsStagePointSize;
m_dynamicParameters.SupportsGeometryPointSize = supportsStagePointSize;
}
// Never, on any device, and DELIBERATELY NOT COUPLED to the line above even though it
// once tracked the same feature. It used to, because a `dvec` input needed Float64 to
// exist in the module at all; a 64-bit vertex FETCH was already impossible
@@ -1206,6 +1206,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
SharedPtr<VkTimerQueryManager::TimestampRecord> end;
// Kind::Occlusion - pool slots recorded between Begin/End; summed at result time.
Vector<Uint32> occlusionSlots;
// Kind::XfbGenerated - reroute-pool slots for the span's XFB-INACTIVE
// draws, where the renderer's reroute is armed (the affected driver's
// stream query counts nothing without an open capture; see
// VulkanRenderer::BeginXfbQueryForDraw). Summed alongside the stream
// slots above, which keep the span's XFB-active draws.
Vector<Uint32> rerouteSlots;
// Renderer generation the records were written under (see
// g_rendererGeneration). A stale generation resolves as available
// with a final zero result: the records' pool indices and frame
@@ -1215,11 +1221,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// stale queries are always safe to delete.
Uint64 rendererGeneration = 0;
// Kind::XfbGenerated - the frontend's paused-draw primitive counter when the
// query began. VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT counts only what the
// capture saw, so a draw made while the span was paused is invisible to it -
// but GL_PRIMITIVES_GENERATED counts what the last vertex processing stage
// emitted regardless. The delta closes that gap at result time.
// query began. On the affected drivers VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT
// counts only what the capture saw, so a draw made while the span was paused is
// invisible to it - but GL_PRIMITIVES_GENERATED counts what the last vertex
// processing stage emitted regardless. The delta closes that gap at result time.
Uint64 pausedPrimitiveSnapshot = 0;
// ...unless the GPU already counted those paused draws when the span opened -
// through the reroute pool (VulkanRenderer::BeginXfbQueryForDraw reroutes every
// draw with no open capture, paused ones included) or, where the probe measured
// the stream query as counting capture-less draws, through the stream slot the
// paused draw still takes. Adding the CPU delta on top would count them twice,
// and the CPU counter is the weaker source anyway: only 3 of the ~15 draw entry
// points write it and it answers 0 for GL_PATCHES.
Bool pausedPrimitivesCountedByGpu = false;
};
} // namespace
@@ -1313,12 +1327,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (query->kind == VulkanTimerQuery::Kind::XfbWritten ||
query->kind == VulkanTimerQuery::Kind::XfbGenerated) {
Uint64 primitives = 0;
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots,
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots, query->rerouteSlots,
query->kind == VulkanTimerQuery::Kind::XfbGenerated,
primitives)) {
return false;
}
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated && MG_State::pGLContext != nullptr) {
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated &&
!query->pausedPrimitivesCountedByGpu && MG_State::pGLContext != nullptr) {
primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() -
query->pausedPrimitiveSnapshot;
}
@@ -1367,6 +1382,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
query->rendererGeneration = GetRendererGeneration();
query->pausedPrimitiveSnapshot =
MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0;
// Read AFTER StartXfbQueryCapture, which is where a failed reroute-pool creation
// disarms: the answer is then what this span will actually do for every draw.
query->pausedPrimitivesCountedByGpu = generated && pVulkanRenderer->ArePausedDrawsGpuCounted();
return query;
}
@@ -1377,7 +1395,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return;
}
pVulkanRenderer->StopXfbQueryCapture(
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots);
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots,
query->rerouteSlots);
}
BackendQueryHandle BeginOcclusionQuery() {
@@ -1428,6 +1428,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
if (varying.name == "gl_PointSize") {
// A demoted module (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) no longer ACCESSES the
// built-in member - the value lives in the carrier variable the demotion
// named - so the capture binds to the carrier directly. The mirror below
// must not run for it: reading the now-unwritten member would capture
// garbage, and the read itself is the capability access the demotion
// exists to remove. Detected off the module's own debug names, so a
// composite built from another program's stage answers for the module it
// actually contains.
const auto carrierIt = idsByName.find(
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME);
if (carrierIt != idsByName.end()) {
decorateForXfb(carrierIt->second, varying.bufferIndex, varying.offsetBytes);
modified = true;
continue;
}
needsPointSizeMirror = true;
pointSizeBufferIndex = varying.bufferIndex;
pointSizeOffset = varying.offsetBytes;
@@ -3454,6 +3470,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
if (program.PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that pins
// MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because every
// rendering assertion above it stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectVulkan is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this device cannot host the built-in "
"in those stages.");
}
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
// Unconditional now: the two ValidateTransformedSpirv calls below run in every build,
@@ -28,6 +28,7 @@
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Math/HalfFloat.h"
#include "MG_Util/Metrics/TextureMetrics.h"
#include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/Texture/PixelStoreProcessor.h"
#include <Config.h>
#include <algorithm>
@@ -3277,6 +3278,17 @@ void main() {
vkDestroyQueryPool(m_device, m_xfbQueryPool, nullptr);
m_xfbQueryPool = VK_NULL_HANDLE;
}
if (m_primGenReroutePool != VK_NULL_HANDLE) {
vkDestroyQueryPool(m_device, m_primGenReroutePool, nullptr);
m_primGenReroutePool = VK_NULL_HANDLE;
}
m_primGenRerouteActiveSlots.clear();
m_primGenRerouteSlotCursor = 0;
m_primGenRerouteSlotOpen = false;
// Not sticky across renderers: the next bring-up re-decides both (from the
// per-process probe memo, so it re-decides without re-probing).
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
m_primGenStreamCountsXfbInactiveDraws = false;
m_bufferManager.Shutdown();
// Device is idle (vkDeviceWaitIdle above); query pools can be destroyed.
@@ -11299,7 +11311,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDraw(commandBuffer,
payload.params.vertexCount,
@@ -11385,23 +11397,81 @@ void main() {
}
s_vkResetQueryPool(m_device, m_xfbQueryPool, 0, kXfbQuerySlots);
}
// The reroute pool, on the first GENERATED span that needs it. A creation
// failure disarms rather than failing the capture: the stream path still
// answers (with the driver's defect), which beats answering nothing.
if (kind == 1 && m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool == VK_NULL_HANDLE) {
VkQueryPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
poolInfo.queryCount = kXfbQuerySlots;
if (m_primGenRerouteKind == MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt) {
// The query Vulkan defines for this GL target; counts vertex stream 0
// when begun with plain vkCmdBeginQuery.
poolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
} else {
poolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
// The clipping-stage INVOCATION counter: one per primitive reaching
// primitive clipping (GL's CLIPPING_INPUT_PRIMITIVES) - post-tess/GS,
// pre-clip, and per spec still counted under rasterizer discard, which
// is exactly the set GL_PRIMITIVES_GENERATED is defined over. The
// stage's OUTPUT count (CLIPPING_PRIMITIVES_BIT) would be wrong:
// clipping may drop or split primitives.
poolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
}
if (vkCreateQueryPool(m_device, &poolInfo, nullptr, &m_primGenReroutePool) != VK_SUCCESS) {
MGLOG_E_ONCE("StartXfbQueryCapture: reroute pool creation failed; the "
"PRIMITIVES_GENERATED reroute is disarmed and XFB-inactive draws keep "
"the stream query");
m_primGenReroutePool = VK_NULL_HANDLE;
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
} else {
s_vkResetQueryPool(m_device, m_primGenReroutePool, 0, kXfbQuerySlots);
}
}
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = true;
if (kind == 1) {
m_primGenRerouteActiveSlots.clear();
}
return true;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots) {
Bool VulkanRenderer::ArePausedDrawsGpuCounted() const {
// Exactly the gate BeginXfbQueryForDraw applies per draw, so a span told "armed"
// really does get a reroute slot for every draw with no open capture - a paused
// span's draws included.
const Bool rerouteArmed = m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE;
// Otherwise the paused draw takes a stream slot, which is an exact count of it
// on a driver the probe measured as counting capture-less draws.
return rerouteArmed || m_primGenStreamCountsXfbInactiveDraws;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots,
Vector<Uint32>& outRerouteSlots) {
if (kind > 1) {
return;
}
outSlots = Move(m_xfbQueryActiveSlots[kind]);
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = false;
outRerouteSlots.clear();
if (kind == 1) {
outRerouteSlots = Move(m_primGenRerouteActiveSlots);
m_primGenRerouteActiveSlots.clear();
}
}
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives) {
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives) {
outPrimitives = 0;
if (slots.empty() || m_xfbQueryPool == VK_NULL_HANDLE) {
const Bool haveStreamSlots = !slots.empty() && m_xfbQueryPool != VK_NULL_HANDLE;
// Reroute slots only ever accumulate the GENERATED target (see
// BeginXfbQueryForDraw); WRITTEN never opens one.
const Bool haveRerouteSlots =
wantGenerated && !rerouteSlots.empty() && m_primGenReroutePool != VK_NULL_HANDLE;
if (!haveStreamSlots && !haveRerouteSlots) {
return true;
}
auto& frame = m_frameContext.GetCurrent();
@@ -11413,44 +11483,108 @@ void main() {
return false;
}
}
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
if (haveStreamSlots) {
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
}
}
}
if (haveRerouteSlots) {
for (const Uint32 slot : rerouteSlots) {
// Both reroute pool kinds answer one 64-bit primitive count per slot.
Uint64 generated = 0;
const VkResult result = vkGetQueryPoolResults(
m_device, m_primGenReroutePool, slot, 1, sizeof(generated), &generated,
sizeof(generated), VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += generated;
}
}
}
return true;
}
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer) {
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive) {
m_xfbQuerySlotOpen = false;
m_primGenRerouteSlotOpen = false;
if ((!m_xfbQueryCaptureActive[0] && !m_xfbQueryCaptureActive[1]) || m_xfbQueryPool == VK_NULL_HANDLE) {
return;
}
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
for (Uint32 kind = 0; kind < 2; ++kind) {
if (m_xfbQueryCaptureActive[kind]) {
m_xfbQueryActiveSlots[kind].push_back(slot);
// Every draw with no OPEN capture is the stream query's silent case, and that
// includes a draw made while the GL span is merely PAUSED (the pause closes the
// capture, so BeginXfbCaptureForDraw already answered false for it). Paused
// draws are rerouted like any other: the frontend's CPU paused-primitive
// counter cannot stand in for them - it is written by only 3 of the ~15 draw
// entry points (never the instanced, indirect or multi-draw ones) and answers 0
// for GL_PATCHES by design, since the tessellator's amplification is not
// knowable on the CPU - which is exactly the CTS's shape. Double counting is
// prevented on the other side instead: a GENERATED span opened while this
// reroute is armed ignores that CPU counter entirely (see
// ArePausedDrawsGpuCounted and DirectVulkan.cpp's XfbGenerated resolve), so
// every XFB-inactive draw in the span is priced exactly once, by this pool.
const Bool rerouteGenerated = m_xfbQueryCaptureActive[1] &&
m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE && !xfbActive;
// The stream slot stays for WRITTEN whatever the reroute does (with capture
// inactive its primitivesWritten is 0, which is the correct WRITTEN answer),
// and for GENERATED wherever this draw is not rerouted - so one GL query span
// may accumulate stream slots (XFB-active draws) and reroute slots
// (XFB-inactive draws) side by side.
const Bool wantStreamSlot =
m_xfbQueryCaptureActive[0] || (m_xfbQueryCaptureActive[1] && !rerouteGenerated);
if (wantStreamSlot) {
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
if (m_xfbQueryCaptureActive[0]) {
m_xfbQueryActiveSlots[0].push_back(slot);
}
if (m_xfbQueryCaptureActive[1] && !rerouteGenerated) {
m_xfbQueryActiveSlots[1].push_back(slot);
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
if (rerouteGenerated) {
// Latched at INFO on purpose: it is the pinned integration lane's arming
// observable (the shape UnlocatedIoBlockScenario asserts), and the builds
// CI runs compile INFO in.
MGLOG_I_ONCE("PRIMITIVES_GENERATED reroute engaged: an XFB-inactive draw accumulates "
"through the %s pool",
m_primGenRerouteKind ==
MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations statistics");
const Uint32 slot = m_primGenRerouteSlotCursor;
m_primGenRerouteSlotCursor = (m_primGenRerouteSlotCursor + 1) % kXfbQuerySlots;
// Same recycle-at-begin discipline as the stream pool. Both pool kinds
// are begun with plain vkCmdBeginQuery (a PRIMITIVES_GENERATED_EXT
// query begun this way counts vertex stream 0).
s_vkResetQueryPool(m_device, m_primGenReroutePool, slot, 1);
vkCmdBeginQuery(commandBuffer, m_primGenReroutePool, slot, 0);
m_primGenRerouteActiveSlots.push_back(slot);
m_primGenRerouteSlotOpen = true;
m_primGenRerouteOpenSlot = slot;
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
void VulkanRenderer::EndXfbQueryForDraw(VkCommandBuffer commandBuffer) {
if (!m_xfbQuerySlotOpen) {
return;
if (m_xfbQuerySlotOpen) {
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
if (m_primGenRerouteSlotOpen) {
vkCmdEndQuery(commandBuffer, m_primGenReroutePool, m_primGenRerouteOpenSlot);
m_primGenRerouteSlotOpen = false;
}
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
Bool VulkanRenderer::BeginOcclusionForDraw(VkCommandBuffer commandBuffer) {
@@ -11500,7 +11634,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDrawIndexed(commandBuffer,
payload.params.indexCount,
@@ -13333,6 +13467,13 @@ void main() {
// occlusion result still satisfies any-samples-style consumers.
deviceFeatures.occlusionQueryPrecise = supportedDeviceFeatures.occlusionQueryPrecise;
m_occlusionQueryPreciseEnabled = deviceFeatures.occlusionQueryPrecise == VK_TRUE;
m_tessellationShaderFeatureEnabled = deviceFeatures.tessellationShader == VK_TRUE;
// Backs the GL_PRIMITIVES_GENERATED reroute's statistics tier (see the
// m_primGenReroute* members): a VK_QUERY_TYPE_PIPELINE_STATISTICS pool may only
// be created with this feature enabled. Enabled wherever the device has it - the
// feature alone costs nothing; pools exist only where the reroute is armed.
deviceFeatures.pipelineStatisticsQuery = supportedDeviceFeatures.pipelineStatisticsQuery;
m_pipelineStatisticsQueryFeatureEnabled = deviceFeatures.pipelineStatisticsQuery == VK_TRUE;
VkDeviceCreateInfo deviceCreateInfo{};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
@@ -13660,6 +13801,38 @@ void main() {
MGLOG_I("Enabled optional device extension: %s", VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME);
}
}
// VK_EXT_primitives_generated_query - the query Vulkan defines for GL's
// GL_PRIMITIVES_GENERATED precisely because the stream query above needs no
// capture by spec but drivers disagree. Taken with BOTH the base feature and the
// rasterizer-discard feature or not at all: without the latter, a discarding draw
// inside the query is invalid usage, and GL applications toggle discard freely.
// Only the PRIMITIVES_GENERATED reroute consumes it (see ArmPrimGenReroute).
m_primitivesGeneratedQueryFeatureEnabled = false;
m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT primitivesGeneratedQueryFeatures{};
primitivesGeneratedQueryFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (IsExtensionSupported(availableExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME) &&
getPhysicalDeviceFeatures2 != nullptr) {
VkPhysicalDeviceFeatures2 featureQuery{};
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
featureQuery.pNext = &primitivesGeneratedQueryFeatures;
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
if (primitivesGeneratedQueryFeatures.primitivesGeneratedQuery == VK_TRUE &&
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions,
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
enabledDeviceExtensions.push_back(VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithNonZeroStreams = VK_FALSE;
primitivesGeneratedQueryFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
deviceCreateInfo.pNext = &primitivesGeneratedQueryFeatures;
m_primitivesGeneratedQueryFeatureEnabled = true;
m_primitivesGeneratedQueryDiscardFeatureEnabled = true;
MGLOG_I("Enabled optional device extension: %s",
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
}
// VK_EXT_provoking_vertex. Two independent features live behind one extension:
// provokingVertexLast -> flat varyings, gl_Layer/gl_ViewportIndex and
// the input-assembler capture order.
@@ -14009,6 +14182,123 @@ void main() {
m_timerQuerySupported = m_timestampValidBits > 0 && m_timestampPeriodNs > 0.0f;
MGLOG_I("Timer queries %s (timestampValidBits=%u, timestampPeriod=%f ns/tick)",
m_timerQuerySupported ? "supported" : "not supported", m_timestampValidBits, m_timestampPeriodNs);
// Last, because it records on m_graphicsQueue: decide the PRIMITIVES_GENERATED
// reroute for XFB-inactive draws. Nothing else has touched the queue yet.
ArmPrimGenReroute();
}
void VulkanRenderer::ArmPrimGenReroute() {
using namespace MG_Util::SelfTest;
m_primGenRerouteKind = PrimGenRerouteKind::None;
const MG_Config::QuirkOverride overrideSetting = MG_Config::Features.MagmaPrimGenQueryReroute;
// Without stream queries the GENERATED path never opens a slot at all, so
// there is nothing to reroute - whatever the override says.
if (!m_xfbQueriesSupported || !m_hostQueryResetEnabled) {
return;
}
const Bool primitivesGeneratedQueryUsable =
m_primitivesGeneratedQueryFeatureEnabled && m_primitivesGeneratedQueryDiscardFeatureEnabled;
PrimitivesGeneratedNoXfbVerdict verdict = PrimitivesGeneratedNoXfbVerdict::Inconclusive;
// The probe only matters under Auto (ForceOn bypasses the verdict, ForceOff
// never asks), and the answer is a device property - so it is memoized per
// process rather than re-paid on every renderer recreation.
if (overrideSetting == MG_Config::QuirkOverride::Auto) {
static const PrimitivesGeneratedNoXfbMeasurement s_measurement = [&]() {
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = m_device;
probeContext.queue = m_graphicsQueue;
probeContext.queueFamilyIndex =
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily);
probeContext.transformFeedbackQueriesUsable = m_xfbQueriesSupported;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = m_pipelineStatisticsQueryFeatureEnabled;
probeContext.tessellationEnabled = m_tessellationShaderFeatureEnabled;
auto& fns = probeContext.fns;
fns.vkCreateCommandPool = vkCreateCommandPool;
fns.vkDestroyCommandPool = vkDestroyCommandPool;
fns.vkAllocateCommandBuffers = vkAllocateCommandBuffers;
fns.vkBeginCommandBuffer = vkBeginCommandBuffer;
fns.vkEndCommandBuffer = vkEndCommandBuffer;
fns.vkCreateQueryPool = vkCreateQueryPool;
fns.vkDestroyQueryPool = vkDestroyQueryPool;
fns.vkCmdResetQueryPool = vkCmdResetQueryPool;
fns.vkCmdBeginQuery = vkCmdBeginQuery;
fns.vkCmdEndQuery = vkCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = s_vkCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = s_vkCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = vkCreateRenderPass;
fns.vkDestroyRenderPass = vkDestroyRenderPass;
fns.vkCreateFramebuffer = vkCreateFramebuffer;
fns.vkDestroyFramebuffer = vkDestroyFramebuffer;
fns.vkCmdBeginRenderPass = vkCmdBeginRenderPass;
fns.vkCmdEndRenderPass = vkCmdEndRenderPass;
fns.vkCreateShaderModule = vkCreateShaderModule;
fns.vkDestroyShaderModule = vkDestroyShaderModule;
fns.vkCreatePipelineLayout = vkCreatePipelineLayout;
fns.vkDestroyPipelineLayout = vkDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = vkCreateGraphicsPipelines;
fns.vkDestroyPipeline = vkDestroyPipeline;
fns.vkCmdBindPipeline = vkCmdBindPipeline;
fns.vkCmdDraw = vkCmdDraw;
fns.vkCreateFence = vkCreateFence;
fns.vkDestroyFence = vkDestroyFence;
fns.vkQueueSubmit = vkQueueSubmit;
fns.vkWaitForFences = vkWaitForFences;
fns.vkGetQueryPoolResults = vkGetQueryPoolResults;
fns.vkDeviceWaitIdle = vkDeviceWaitIdle;
return RunPrimitivesGeneratedNoXfbProbe(probeContext);
}();
verdict = EvaluatePrimitivesGeneratedNoXfbVerdict(s_measurement);
if (s_measurement.fenceWaitTimedOut) {
// The probe's submission never signaled within its bound, so it left its
// command pool, query pools, render pass, framebuffer, shader modules,
// pipeline layout, pipelines and fence alive on purpose. This device is the
// renderer's own and outlives them, so nothing here may destroy them or
// wait the device idle - the queue may still be executing that submission,
// and an idle wait is the hang the bound exists to prevent. They leak for
// the process's life; a device this sick has bigger problems.
MGLOG_W("PRIMITIVES_GENERATED probe timed out waiting on its own submission (%s); its "
"Vulkan objects are deliberately leaked and XFB-inactive draws keep the stream "
"query", s_measurement.failureReason.c_str());
} else if (!s_measurement.ran) {
MGLOG_W("PRIMITIVES_GENERATED probe did not run (%s); XFB-inactive draws keep the "
"stream query", s_measurement.failureReason.c_str());
} else {
const auto logShape = [](const char* name,
const MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement&
shape) {
MGLOG_I("PRIMITIVES_GENERATED probe %s: drawn=%d stream=%llu/%llu pgq=%llu(%d) "
"stat=%llu(%d)",
name, shape.drawn ? 1 : 0,
static_cast<unsigned long long>(shape.streamGenerated),
static_cast<unsigned long long>(shape.expectedPrimitives),
static_cast<unsigned long long>(shape.primitivesGeneratedExt),
shape.primitivesGeneratedExtMeasured ? 1 : 0,
static_cast<unsigned long long>(shape.statisticsClippingInput),
shape.statisticsMeasured ? 1 : 0);
};
logShape("triangles", s_measurement.trianglesPlain);
logShape("triangles+discard", s_measurement.trianglesDiscard);
logShape("patches+discard", s_measurement.patchesDiscard);
}
}
// A driver whose stream query counts capture-less draws counts a PAUSED span's
// draws through the stream slot they take, so that span's result must not have
// the frontend's CPU paused counter added on top of it either (the pre-reroute
// accounting did exactly that, double counting every paused draw the CPU could
// price). Measured, not assumed: the forced arms never ask the probe and leave
// this false.
m_primGenStreamCountsXfbInactiveDraws = verdict == PrimitivesGeneratedNoXfbVerdict::StreamCounts;
m_primGenRerouteKind = ChoosePrimitivesGeneratedReroute(
overrideSetting, verdict, primitivesGeneratedQueryUsable, m_pipelineStatisticsQueryFeatureEnabled);
if (m_primGenRerouteKind != PrimGenRerouteKind::None) {
MGLOG_I("PRIMITIVES_GENERATED for XFB-inactive draws will accumulate through a %s pool%s",
m_primGenRerouteKind == PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations pipeline-statistics",
overrideSetting == MG_Config::QuirkOverride::ForceOn ? " (forced on)" : "");
}
}
void VulkanRenderer::CreateAllocator() {
@@ -24,6 +24,7 @@
#include "MG_Util/Math/VectorTypes.h"
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
#include <vk_mem_alloc.h>
#include "../VkIncludes.h"
@@ -717,15 +718,74 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Uint32> m_xfbQueryActiveSlots[2];
Bool m_xfbQuerySlotOpen = false;
Uint32 m_xfbQueryOpenSlot = 0;
// GL_PRIMITIVES_GENERATED reroute for draws made while transform feedback is
// INACTIVE. The stream pool's primitivesNeeded is defined to count those draws
// too, but a Mali driver (and Mesa lavapipe) answers 0 unless a capture span
// is open (the CTS's tessellator-measuring shape). Where the bring-up probe
// finds that defect with a working control - or
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE forces it - such draws accumulate the
// GENERATED count through this pool instead, whose type the arming picks:
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT where the device hosts the dedicated
// query with its rasterizer-discard feature (exact semantics by definition -
// the extension exists because GL needs this count without a capture), else a
// VK_QUERY_TYPE_PIPELINE_STATISTICS pool over clipping-stage invocations (one
// per primitive reaching primitive clipping - after every vertex processing
// stage, before rasterizer discard - which is the same set).
// XFB-ACTIVE draws keep the stream slot (exact today, and WRITTEN needs it);
// every draw with no open capture - a PAUSED span's draws included - takes a
// reroute slot, and the span then ignores the frontend's CPU paused-primitive
// counter rather than adding it on top (see IsPrimGenRerouteArmed): that
// counter is written by only 3 of the ~15 draw entry points and answers 0 for
// GL_PATCHES, so it cannot price the draws this reroute exists to repair. One
// GL query span may therefore hold slots of both pools.
Bool m_pipelineStatisticsQueryFeatureEnabled = false;
// VK_EXT_primitives_generated_query: base feature, and the
// ...WithRasterizerDiscard feature without which a discarding draw inside the
// query is invalid usage (so the reroute never picks the dedicated pool on a
// base-only device - GL applications toggle discard freely).
Bool m_primitivesGeneratedQueryFeatureEnabled = false;
Bool m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
// tessellationShader was enabled at device creation (it is taken whenever the
// device advertises it); gates the probe's PATCHES shape.
Bool m_tessellationShaderFeatureEnabled = false;
MG_Util::SelfTest::PrimGenRerouteKind m_primGenRerouteKind =
MG_Util::SelfTest::PrimGenRerouteKind::None;
// The bring-up probe measured this device's stream query as counting draws made
// with no capture span open (the StreamCounts verdict) - so it counts the
// PAUSED-span ones too, through the stream slot they take when nothing is
// rerouted. Only the probe can know this, so it stays false wherever the probe
// is not consulted (the forced arms), which keeps those lanes' accounting as it
// was.
Bool m_primGenStreamCountsXfbInactiveDraws = false;
VkQueryPool m_primGenReroutePool = VK_NULL_HANDLE;
Uint32 m_primGenRerouteSlotCursor = 0;
Vector<Uint32> m_primGenRerouteActiveSlots;
Bool m_primGenRerouteSlotOpen = false;
Uint32 m_primGenRerouteOpenSlot = 0;
// Runs the bring-up probe (memoized per process) and decides
// m_primGenRerouteKind. Called at the end of device creation: it records on
// m_graphicsQueue, which nothing else is using yet.
void ArmPrimGenReroute();
public:
// Whether a GENERATED span opened now will have the draws made while the GL
// span is PAUSED counted on the GPU - through the reroute pool, which takes
// every draw with no open capture, or (where the reroute is not armed because
// the stream query was measured to count capture-less draws) through the stream
// slot such a draw still takes. The frontend's CPU paused-primitive counter
// must not be added on top of either: it would double count, and it cannot
// price the draws that matter anyway - only 3 of the ~15 draw entry points
// write it and it answers 0 for GL_PATCHES. Read once per span, after
// StartXfbQueryCapture (whose pool creation may disarm the reroute).
Bool ArePausedDrawsGpuCounted() const;
// kind: 0 = PRIMITIVES_WRITTEN, 1 = PRIMITIVES_GENERATED.
Bool StartXfbQueryCapture(Uint32 kind);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots, Vector<Uint32>& outRerouteSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives);
private:
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer);
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive);
void EndXfbQueryForDraw(VkCommandBuffer commandBuffer);
VkCommandPool m_commandPool = VK_NULL_HANDLE;
@@ -101,8 +101,10 @@ add_executable(MobileGLIntegrationTest
Scenarios/VertexAttribBindingScenario.cpp
Scenarios/XfbCaptureBufferReuseScenario.cpp
Scenarios/XfbPrimitiveQueryScenario.cpp
Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
Scenarios/XfbRepeatedCaptureScenario.cpp
Scenarios/TessellationXfbCaptureScenario.cpp
Scenarios/PointSizeDemotionScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
@@ -113,6 +115,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/LayeredAttachmentShapeScenario.cpp
Scenarios/LayeredTextureReadbackScenario.cpp
Scenarios/AtomicCounterScenario.cpp
Scenarios/LargeArenaAdoptionScenario.cpp
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/SpirvShaderBinaryScenario.cpp
@@ -365,6 +368,25 @@ mgl_itest_join_environment(MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1" ${MGL_ITEST_COMMON_ENV})
# Same shape as the UnlocatedIoBlocks entry: the log path is where the reroute's latched
# MGLOG_I lands, and the arming case only trusts the bytes written after it started.
mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log"
${MGL_ITEST_VULKAN_ENV})
# The point-size demotion pinned on, per backend, with a per-lane log file for the arming
# assertion - the same MOBILEGL_LOG_FILE_PATH reasoning as the UnlocatedIoBlocks lane above.
# Two lanes because the demotion runs in the SHARED phase-B chain and each backend then
# consumes it differently (Espryt respells the driver-side capture request, Magma binds the
# SPIR-V Xfb decorations to the carrier).
mgl_itest_join_environment(MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-gles.log"
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-vulkan.log"
${MGL_ITEST_VULKAN_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
@@ -545,6 +567,25 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT}"
)
# PrimitivesGeneratedNoXfbScenario again, with the GL_PRIMITIVES_GENERATED statistics
# reroute PINNED ON. The ambient DirectVulkan registration runs the same cases under the
# bring-up probe's Auto verdict, so between the two entries both accounting paths answer
# the same GL questions and must produce the same numbers - the "two pools must agree"
# gate this machine can hold that the affected device cannot. The pinned entry is also
# the only one whose arming case runs: it asserts the renderer's latched MGLOG_I, so a
# silently-disarmed reroute (an inverted override mapping, a lost gate) fails here
# instead of leaving every equality case vacuously green. DirectVulkan only - the flag
# steers nothing on DirectGLES.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PrimGenReroute."
TEST_FILTER "PrimitivesGeneratedNoXfbScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
)
# The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient
# registrations above cover the narrow storage - on every CI driver the widening's POST
# probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the
@@ -562,3 +603,30 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT}"
)
# PointSizeDemotionScenario with the demotion PINNED ON, per backend, for the reason every
# pinned lane above exists: llvmpipe and lavapipe both HOST gl_PointSize in tessellation and
# geometry stages, so the ambient registrations run these captures through the built-in and
# the demotion - the path every affected Mali device actually takes - would execute nowhere.
# The ambient runs stay the negative control: same scenario, same CPU-computed bytes, native
# path. Both backends, because the demotion is shared phase-B work with two different
# consumers (the ESSL capture respelling vs the SPIR-V Xfb carrier binding).
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
@@ -226,25 +226,21 @@ void main() {
}
// A CPU glBufferSubData issued AFTER a dispatch, read back with NO further GPU work in
// between. The DirectGLES backend queues app SubData ranges for the draw-time staged-copy
// flush (the upload ring) instead of uploading in place, and readback of a GPU-written
// buffer overwrites the frontend shadow with the driver copy - so if the readback path
// forgets to flush the queued range first, the newer CPU write is REVERTED by the readback
// and offset 0 reads the dispatch's value instead of the reseed. Offset 4 pins the other
// direction: the flush must not clobber GPU results outside the written range.
// between. Each backend has its own way to invert this pair, and both are pinned here.
// DirectGLES queues app SubData ranges for the draw-time staged-copy flush (the upload
// ring) instead of uploading in place, and readback of a GPU-written buffer overwrites
// the frontend shadow with the driver copy - so if the readback path forgets to flush the
// queued range first, the newer CPU write is REVERTED by the readback and offset 0 reads
// the dispatch's value instead of the reseed. DirectVulkan adopts the buffer into
// coherent GPU memory the moment the dispatch resolves its descriptor, so the SubData
// write lands in the very bytes the GPU reads - while the dispatch still sits recorded in
// the deferred frame command buffer. Unless the frontend retires that pending work before
// writing the adopted store (BufferObject::UploadSubData), the dispatch executes ON TOP
// of the reseed and offset 0 reads reseed + increments instead of the reseed. Offset 4
// pins the other direction for both: the upload must leave bytes outside its range - the
// dispatch's results - untouched.
TEST_F(AtomicCounterScenario, SubDataAfterDispatchSurvivesAnImmediateReadback) {
if (!Ready() || IsSkipped()) return;
// DirectGLES-only for now. DirectVulkan fails this case with or without the upload
// ring, on revisions that predate it: its buffer uploads submit immediately while the
// dispatch sits in the deferred frame command buffer, so the GPU increments the
// RESEEDED value (reads 4242 + increments instead of 4242) - a pre-existing
// upload-vs-recorded-work ordering gap in that backend, kept visible here rather than
// silently absorbed. Un-skip once DirectVulkan orders app uploads against already
// recorded GPU work.
if (Gl().BackendName() != std::string("DirectGLES")) {
GTEST_SKIP() << "SubData-after-dispatch ordering is a known DirectVulkan gap; this case pins the "
"DirectGLES readback pre-flush only";
}
const GLuint zero = MakeCounterBuffer(0, {0u, 0u});
MakeCounterBuffer(1, {0u});
@@ -0,0 +1,375 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp
// Copyright (c) 2025-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
//
// Scenario - MESH-ARENA-SIZED BUFFERS, END TO END.
//
// A buffer store of at least 16MiB is adopted into the backend's persistently and
// coherently mapped GPU storage the moment it is defined (BufferObject::
// TryAdoptLargeStorage): the CPU shadow is dropped and every later write lands
// directly in GPU-visible memory with no per-write driver call. Minecraft 26.3
// streams chunk meshes into 128MB vertex arenas with plain glNamedBufferSubData -
// on Mali, every driver-mediated route for that write into a busy mutable store
// either parks the calling thread or ghost-copies the whole arena on a driver
// worker (~167ms per touched arena: the recurring in-world hiccup this adoption
// removed). Every existing buffer scenario uses stores far below the threshold,
// so without this file the adopted path would have zero coverage.
//
// What is pinned, deliberately through the same API mix Minecraft uses:
// * a glBufferSubData written AFTER the arena was drawn (in flight) reaches the
// next draw - the write-visibility contract adoption must not weaken;
// * GetBufferSubData reads back the latest CPU write - the shadow IS the map;
// * a compute-shader write through an SSBO binding of the same arena is read
// back - the GPU-written path for adopted stores (glFinish + direct read).
#include <array>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Comfortably past the 16MiB adoption threshold, and the vertex payload sits
// deep inside the store so an implementation that quietly clamped or aliased
// the adopted range would miss it.
constexpr GLsizeiptr kArenaBytes = GLsizeiptr(24) * 1024 * 1024;
constexpr GLintptr kVertexOffset = GLintptr(20) * 1024 * 1024;
constexpr const char* kVertexSource = R"(#version 430 core
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec3 a_color;
out vec3 v_color;
void main() {
v_color = a_color;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 430 core
in vec3 v_color;
out vec4 o_color;
void main() { o_color = vec4(v_color, 1.0); }
)";
constexpr const char* kMarkerComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Arena { uint word; };
void main() { word = 0xC0FFEEu; }
)";
struct Vertex {
float x, y;
float r, g, b;
};
// A full-viewport quad, colored uniformly so one center readback speaks for
// the whole draw.
std::vector<Vertex> QuadVertices(float r, float g, float b) {
return {
{-1.f, -1.f, r, g, b}, {1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b},
{-1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b}, {-1.f, 1.f, r, g, b},
};
}
class LargeArenaAdoptionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = LinkProgram(kVertexSource, kFragmentSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_arena);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
// The NULL-data definition is the adoption point (and Minecraft's
// arena-creation idiom).
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
ConfigureVertexArray(m_vao);
}
void ConfigureVertexArray(GLuint vao) {
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset + 2 * sizeof(float)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_arena != 0) glDeleteBuffers(1, &m_arena);
if (m_program != 0) glDeleteProgram(m_program);
if (m_compute != 0) glDeleteProgram(m_compute);
m_vao = 0;
m_arena = 0;
m_program = 0;
m_compute = 0;
}
unsigned int CompileStage(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
return shader;
}
unsigned int LinkProgram(const char* vs, const char* fs) {
const GLuint v = CompileStage(GL_VERTEX_SHADER, vs);
if (v == 0) return 0;
const GLuint f = CompileStage(GL_FRAGMENT_SHADER, fs);
if (f == 0) {
glDeleteShader(v);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, v);
glAttachShader(program, f);
glLinkProgram(program);
glDeleteShader(v);
glDeleteShader(f);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
void UploadQuad(float r, float g, float b) {
const auto vertices = QuadVertices(r, g, b);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
}
void DrawQuad(GLuint vao = 0) {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glBindVertexArray(vao != 0 ? vao : m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
std::array<unsigned char, 4> CenterPixel() {
std::array<unsigned char, 4> px = {0, 0, 0, 0};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
px.data());
return px;
}
unsigned int m_program = 0;
unsigned int m_compute = 0;
unsigned int m_vao = 0;
unsigned int m_arena = 0;
std::string m_buildLog;
};
} // namespace
// The Minecraft shape: the arena is drawn, the frame retires, and a
// glBufferSubData rewrites the SAME vertex bytes while the previous frame's
// draw may still be in flight. The next draw must show the NEW bytes.
TEST_F(LargeArenaAdoptionScenario, SubDataAfterAnInFlightDrawReachesTheNextDraw) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[0], 200) << "the first draw from the adopted arena never landed";
EXPECT_LT(px[1], 50);
Gl().EndFrame();
UploadQuad(0.f, 1.f, 0.f);
DrawQuad();
px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[1], 200) << "the cross-frame rewrite of the adopted arena did not reach the draw; "
"the old color means the write went to bytes the draw no longer reads";
EXPECT_LT(px[0], 50) << "the draw still shows the previous frame's bytes";
}
// Respecifying a frontend buffer preserves its VAO attachments even when the
// backend replaces the adopted store's GL name. Keep every attribute binding
// unchanged so a stale backend VAO cannot be repaired by a frontend rebind.
TEST_F(LargeArenaAdoptionScenario, RespecifiedVertexArenaKeepsVaoBindings) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
ASSERT_GT(CenterPixel()[0], 200);
ASSERT_EQ(FirstGLError(), 0u);
GLuint otherVao = 0;
glGenVertexArrays(1, &otherVao);
ConfigureVertexArray(otherVao);
DrawQuad(otherVao);
EXPECT_GT(CenterPixel()[0], 200);
EXPECT_EQ(FirstGLError(), 0u);
constexpr std::array<GLsizeiptr, 3> sizes = {
kArenaBytes, kArenaBytes + 4096, kArenaBytes - 4096,
};
constexpr std::array<std::array<float, 3>, 3> colors = {{
{0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, {1.f, 0.f, 0.f},
}};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
UploadQuad(colors[i][0], colors[i][1], colors[i][2]);
// The unbound VAO can retain the deleted store; the current VAO's
// attachments can be cleared by deletion. Both must be repaired.
for (GLuint vao : {m_vao, otherVao}) {
SCOPED_TRACE(vao);
DrawQuad(vao);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
for (std::size_t channel = 0; channel < 3; ++channel) {
if (colors[i][channel] != 0.f) {
EXPECT_GT(px[channel], 200) << "VAO did not fetch the replacement vertex store";
} else {
EXPECT_LT(px[channel], 50) << "VAO still fetched the previous vertex store";
}
}
}
}
glDeleteVertexArrays(1, &otherVao);
}
TEST_F(LargeArenaAdoptionScenario, RespecifiedIndexArenaKeepsVaoBinding) {
if (!Ready() || IsSkipped()) return;
auto vertices = QuadVertices(1.f, 0.f, 0.f);
const auto green = QuadVertices(0.f, 1.f, 0.f);
vertices.insert(vertices.end(), green.begin(), green.end());
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
GLuint indices = 0;
glGenBuffers(1, &indices);
glBindVertexArray(m_vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indices);
// Redefine through COPY_WRITE_BUFFER so the element binding slot never
// changes. The small final store also exercises returning to shadow storage.
glBindBuffer(GL_COPY_WRITE_BUFFER, indices);
constexpr std::array<GLsizeiptr, 4> sizes = {
kArenaBytes, kArenaBytes, kArenaBytes + 4096, 4096,
};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
const GLuint first = (i % 2) == 0 ? 0u : 6u;
const std::array<GLuint, 6> elements = {
first, first + 1, first + 2, first + 3, first + 4, first + 5,
};
glBufferData(GL_COPY_WRITE_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, sizeof(elements), elements.data());
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[first == 0 ? 0 : 1], 200) << "VAO did not fetch the replacement index store";
EXPECT_LT(px[first == 0 ? 1 : 0], 50) << "VAO still fetched the previous index store";
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glDeleteBuffers(1, &indices);
}
// The shadow IS the mapping: a readback straight after a CPU write must hand
// back exactly those bytes.
TEST_F(LargeArenaAdoptionScenario, ReadbackSeesTheLatestCpuWrite) {
if (!Ready() || IsSkipped()) return;
const auto vertices = QuadVertices(0.25f, 0.5f, 0.75f);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
std::vector<Vertex> read(vertices.size());
glGetBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(read.size() * sizeof(Vertex)), read.data());
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(0, std::memcmp(read.data(), vertices.data(), read.size() * sizeof(Vertex)))
<< "GetBufferSubData of the adopted arena returned different bytes than the SubData wrote";
}
// A GPU write through an SSBO binding of the adopted arena must be visible to
// a CPU readback - the path that waits out the GPU and reads the coherent
// mapping directly.
TEST_F(LargeArenaAdoptionScenario, GpuWriteIntoTheArenaIsReadBack) {
if (!Ready() || IsSkipped()) return;
GLint maxComputeStorageBlocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &maxComputeStorageBlocks);
if (maxComputeStorageBlocks < 1) {
GTEST_SKIP() << "no compute shader storage blocks on this driver";
}
const GLuint compute = CompileStage(GL_COMPUTE_SHADER, kMarkerComputeSource);
ASSERT_NE(compute, 0u) << m_buildLog;
m_compute = glCreateProgram();
glAttachShader(m_compute, compute);
glLinkProgram(m_compute);
glDeleteShader(compute);
GLint linked = 0;
glGetProgramiv(m_compute, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const unsigned int seed = 0u;
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(seed), &seed);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, m_arena, 0, sizeof(unsigned int));
glUseProgram(m_compute);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_BUFFER_UPDATE_BARRIER_BIT);
unsigned int marker = 0;
glGetBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(marker), &marker);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(marker, 0xC0FFEEu)
<< "the compute write into the adopted arena did not reach the CPU readback";
}
} // namespace MGITest
@@ -0,0 +1,522 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PointSizeDemotionScenario.cpp
// Copyright (c) 2025-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
//
// Scenario - THE gl_PointSize DEMOTION IS CLIENT-INVISIBLE, AND IT ACTUALLY ARMS.
//
// On a device that hosts the built-in in tessellation/geometry stages (llvmpipe and
// lavapipe both do), gl_PointSize travels as itself; on one that does not (the Mali
// devices this exists for), phase B demotes it to an ordinary varying
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram) and the capture
// machinery follows it there. This scenario runs in BOTH configurations and must hand
// back identical bytes: the ambient registrations take the native path, and the
// PointSizeDemotion. registrations pin MOBILEGL_POINT_SIZE_DEMOTION=1 so the demotion
// runs on the same healthy drivers - CopyImagePacked16Scenario's dual-configuration
// contract, applied to a value chain instead of a storage format.
//
// The VALUE is the whole contract: every case writes gl_PointSize in one stage, reads it
// back out of gl_in[] in the next, and captures it by name under rasterizer discard, so
// one wrong link anywhere in VS -> TCS -> TES -> GS -> capture lands in the readback.
// The RASTERIZED size is deliberately not asserted anywhere: with the built-in unhosted
// it falls back to 1.0 by spec on both targets, which is exactly the honest residue the
// demotion documents (point_rendering-style bodies keep failing truthfully).
//
// The assertions are on the captured BYTES against a CPU-computed reference, never on
// the absence of a GL error: every failure this guards against is silent.
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr float kPoison = -987654.0f;
const char* const kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
// The full chain, with per-vertex VARIATION seeded in the vertex stage so a control
// invocation that read or wrote the wrong slot changes the sum: 2,3,4 arrive, 3,4,5
// leave, the evaluation stage sums its patch to 12, the geometry stage doubles what
// it read to 24.
const char* const kChainVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 2.0 + float(gl_VertexID);
}
)";
const char* const kChainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main()
{
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* const kChainTessEvalSource = R"(#version 460 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* const kChainGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
// The geometry-only chain: no tessellation required of the stack at all.
const char* const kPointVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 7.0;
}
)";
const char* const kPointGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
EmitVertex();
EndPrimitive();
}
)";
// A capture stage that only READS the incoming point size and never writes its own.
// Legal GL, and the shape that separates "the demotion arms" from "the demotion knows
// a capture is coming": with the built-in gone, only the capture request can put a
// carrier back for a by-name capture to bind to.
const char* const kReadOnlyGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main()
{
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* const kEchoFragmentSource = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(g_echo, 0.0, 0.0, 1.0);
}
)";
class PointSizeDemotionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
DrainErrors();
}
void TearDown() override {
if (Ready()) {
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true) + "\n--- source ---\n" + source;
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()),
varyings.data(), GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span over `vertexCount` vertices of `drawMode`, recorded as
// GL_POINTS. The buffer is poison-filled first so bytes the capture never wrote
// name themselves.
std::vector<float> RunCaptureSpan(GLuint program, GLenum drawMode, GLsizei vertexCount,
std::size_t capturedFloats) {
const std::vector<float> poison(capturedFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(drawMode, 0, vertexCount);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> readback(capturedFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
readback.data());
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult ComponentIs(const std::vector<float>& data,
std::size_t index, float expected,
float epsilon = 1e-4f) {
if (index >= data.size()) {
return ::testing::AssertionFailure()
<< "component " << index << " is past the capture buffer";
}
const float actual = data[index];
if (actual == kPoison) {
return ::testing::AssertionFailure()
<< "component " << index << " still holds the poison value - the capture "
<< "never reached these bytes (expected " << expected << ")";
}
if (std::isnan(actual) || std::abs(actual - expected) > epsilon) {
return ::testing::AssertionFailure()
<< "component " << index << " is " << actual << ", expected " << expected;
}
return ::testing::AssertionSuccess();
}
// The library log, for the arming case. Same machinery and same reasoning as
// UnlocatedIoBlockScenario: MOBILEGL_LOG_FILE_PATH is read at log-init, the file
// is appended to by every process in the lane, and only bytes appended after the
// snapshot may satisfy an assertion.
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
std::string m_buildLog;
private:
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
};
// The five-stage chain. 24.0 can only arrive if the vertex mirror, both control-stage
// redirects (read AND write), the evaluation stage's three gl_in reads and the
// geometry stage's read all carried the right value - one wrong link and the sum
// moves. point_mode with every level at 1 emits three points; the first record proves
// the mechanism, exactly as TessellationXfbCaptureScenario reasons.
TEST_F(PointSizeDemotionScenario, TheValueSurvivesTheFiveStageChainIntoTheCapture) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_GEOMETRY_SHADER, kChainGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 24.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same chain without a geometry stage: the capture then binds to the evaluation
// stage's value (the sum, 12.0) - which is also the boundary where a demoted program
// switches its capture carrier from the Io chain to the capture name.
TEST_F(PointSizeDemotionScenario, TheEvaluationStageOwnsTheCaptureWithoutAGeometryStage) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 12.0f));
// The GL query surface keeps the truthful spelling whatever the backends renamed
// underneath: reflection is a phase-A product and the demotion happens after it.
char varyingName[64] = {};
GLsizei nameLength = 0;
GLsizei varyingSize = 0;
GLenum varyingType = 0;
glGetTransformFeedbackVarying(program, 0, sizeof(varyingName), &nameLength, &varyingSize,
&varyingType, varyingName);
EXPECT_STREQ(varyingName, "gl_PointSize");
EXPECT_EQ(varyingType, static_cast<GLenum>(GL_FLOAT));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The geometry-only chain: gl_in[0].gl_PointSize read straight off the vertex stage,
// no tessellation involved - the VS -> GS boundary of the demotion on its own.
TEST_F(PointSizeDemotionScenario, AGeometryOnlyChainCarriesTheVertexValue) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE CAPTURE-REQUEST PATH, END TO END - the half no unit test can reach, because the
// request travels from glTransformFeedbackVaryings through phase A's resolved capture
// set and the phase-B handoff before it reaches the demotion.
//
// The geometry stage READS gl_in[0].gl_PointSize and never writes gl_PointSize, which
// is enough to arm the demotion (glslang declares GeometryPointSize on a read) but not
// enough to create an output carrier on its own. Only the capture request can, and if
// that request never arrives the program does not merely lose the point-size column:
// DirectGLES respells the driver-side capture to a name no stage declares and the
// WHOLE capture set fails to link, while DirectVulkan mirrors a built-in the demotion
// just removed and can unwind far enough to drop the Xfb execution mode. Either way
// g_echo - an ordinary varying with nothing to do with point size - comes back poison,
// which is what this asserts. gl_PointSize itself is captured but never asserted: no
// stage writes it, so GL leaves its value undefined.
TEST_F(PointSizeDemotionScenario, ACaptureSurvivesAStageThatOnlyReadsThePointSize) {
if (!Ready()) return;
// The NATIVE Espryt path cannot do this at all, and never could: with the built-in
// hosted, the geometry stage's ESSL simply does not declare gl_PointSize unless it
// writes it, so the driver rejects the capture request with "varying undeclared"
// and the program becomes unusable. That is a pre-existing ES limitation the
// demotion happens to REPAIR - the carrier is a real, seeded, declared varying -
// so this case has something to assert only where the demotion is armed. Magma
// consumes SPIR-V and answers on both paths, which keeps the negative control.
if (Gl().BackendName() == "DirectGLES" &&
AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "Espryt cannot capture a gl_PointSize its capture stage never "
"writes without the demotion; the PointSizeDemotion. ctest entry "
"runs this same case with MOBILEGL_POINT_SIZE_DEMOTION=1";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kReadOnlyGeometrySource},
{GL_FRAGMENT_SHADER, kEchoFragmentSource}},
{"g_echo", "gl_PointSize"});
ASSERT_NE(program, 0u)
<< "the capture set failed to link. On a demoting configuration this is the "
"capture request never reaching the demotion, so the point-size capture was "
"respelled to a carrier no stage declares. Build log: "
<< m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 2);
EXPECT_TRUE(ComponentIs(captured, 0, 7.0f))
<< "the unrelated varying captured alongside gl_PointSize did not survive; the "
"point-size capture took the whole set with it";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE ONE CASE THAT CAN FAIL WHEN THE DEMOTION SILENTLY STOPS BEING ARMED.
//
// Everything above captures the right bytes on llvmpipe and lavapipe whether the
// demotion ran or not - these machines host the built-in - so those cases pin that
// the demotion does no HARM and can say nothing about whether it happened. The
// arming is where the cheap mistake lives: MOBILEGL_POINT_SIZE_DEMOTION maps onto
// the two Supports*PointSize capability bits INVERTED (forcing the demotion on
// means declaring the built-in UNHOSTED), and a swap of those arms - or a dropped
// env bit anywhere between ConfigLoader, the backend init, CompileEnv and the L1
// key - would disable the device repair with every rendering case still green.
//
// Same machinery as UnlocatedIoBlockScenario's arming case: the environment says
// the demotion is pinned on, therefore the library must SAY it demoted something.
// The observable is the latched MGLOG_I each backend emits when it first builds a
// demoted program; both spell "demoted to an ordinary varying", so this one case
// covers both pinned lanes without a backend gate.
TEST_F(PointSizeDemotionScenario, TheDemotionIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the demotion pinned ON for the whole process, which "
"is what the PointSizeDemotion. ctest entries do with "
"MOBILEGL_POINT_SIZE_DEMOTION=1; with the variable unset the detected "
"capabilities decide, and on this machine the built-in is hosted - so "
"there would be nothing to observe";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_POINT_SIZE_DEMOTION is pinned on but MOBILEGL_LOG_FILE_PATH "
"is not set, so the library has nowhere to record that it demoted "
"anything; the PointSizeDemotion. ctest entries set both";
}
// Taken BEFORE the program is built, so the line this looks for can only be one
// this process wrote.
const std::uintmax_t before = LibraryLogSize();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
// Drawn as well as built, so a stack that defers its backend program to first
// use still reaches the build the latched line fires in - and the capture must
// STILL be right through the carrier.
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f))
<< "the pinned-on lane did not even capture correctly";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("demoted to an ordinary varying"), std::string::npos)
<< "MOBILEGL_POINT_SIZE_DEMOTION is pinned ON, a geometry program reading and "
"writing gl_PointSize was built and captured, and no backend ever reported "
"demoting it. The demotion is not armed - check the override mapping in the "
"backend inits (it is inverted on purpose), the CompileEnv accessors, and "
"ProgramSpirvTask's verdict plumbing. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,554 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PrimitivesGeneratedNoXfbScenario.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
//
// Scenario - GL_PRIMITIVES_GENERATED COUNTS DRAWS MADE WITH TRANSFORM FEEDBACK
// INACTIVE.
//
// GL 4.6 core 13.4: the query counts what the last vertex processing stage emits,
// capture or no capture. The CTS leans its whole tessellation suite on that - the
// tessellator's output is MEASURED by an XFB-inactive PATCHES draw under
// rasterizer discard inside a GENERATED query, and the capture buffers of ~29
// tessellation tests are sized from the answer - so a backend that answers 0
// hands them a zero-byte buffer and an INVALID_OPERATION off its zero-length map.
//
// DirectVulkan serves the query from the transform-feedback stream query's
// primitivesNeeded, which VK_EXT_transform_feedback defines to count whether or
// not a capture span is open. Both the Mali-G1-Ultra driver AND Mesa lavapipe
// disagree with that definition: with no vkCmdBeginTransformFeedbackEXT recorded,
// the pair reads back 0. Where the bring-up probe measures that defect with a
// working control - or MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1 pins it on - the
// renderer accumulates XFB-inactive draws through the best proven substitute
// pool: VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (which lavapipe hosts and passes,
// rasterizer discard included), else pipeline statistics over clipping-stage
// invocations (GL's CLIPPING_INPUT_PRIMITIVES). These cases assert the GL-visible
// answer, so on this machine they hold the reroute to the same numbers the
// healthy stream path must produce - the "two pools must agree" assertion - and
// on a healthy driver they pin the stream path itself.
//
// DirectVulkan only: DirectGLES has no GPU counter for an XFB-inactive draw at
// all (ES has no PRIMITIVES_GENERATED without a capture), and its CPU accounting
// is a different mechanism with its own tests.
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <initializer_list>
#include <iterator>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
GLuint CompileShaderStage(GLenum type, const char* source, std::string* log) {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// A capture-capable vertex-only program: the varying gives glBeginTransformFeedback
// something to capture for the mixed-span case; the XFB-inactive cases draw with the
// same program and simply never begin a span.
const char* const kVertexSource = R"(#version 430 core
out vec4 vs_out_value;
void main() {
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
vs_out_value = vec4(1.0);
gl_Position = vec4(corners[gl_VertexID % 3], 0.0, 1.0);
}
)";
// A passthrough tessellation pipeline whose all-1 levels emit exactly one
// triangle per patch - the count the tessellation cases assert.
const char* const kTessVertexSource = R"(#version 430 core
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 430 core
layout(vertices = 1) out;
void main() {
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
)";
const char* const kTessEvalSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
// The same tessellation pipeline with something to capture, so that
// glBeginTransformFeedback accepts it: the paused-span PATCHES case needs an
// open (but paused) capture span AND a tessellator in one program.
const char* const kTessEvalCaptureSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
out vec4 te_out_value;
void main() {
te_out_value = vec4(1.0);
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
class PrimitivesGeneratedNoXfbScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != std::string("DirectVulkan")) {
GTEST_SKIP() << "the stream-query defect and its reroute are DirectVulkan's; "
<< Gl().BackendName()
<< " answers this query from a different mechanism";
}
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenQueries(2, m_queries);
ASSERT_NE(m_queries[0], 0u);
ASSERT_NE(m_queries[1], 0u);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (m_queries[0] != 0 || m_queries[1] != 0) glDeleteQueries(2, m_queries);
m_queries[0] = m_queries[1] = 0;
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
ScenarioTest::TearDown();
}
// captureVarying: the name to record with glTransformFeedbackVaryings, or
// nullptr for a program that can never open a capture span.
GLuint BuildProgram(std::initializer_list<std::pair<GLenum, const char*>> stages,
const char* captureVarying) {
std::vector<GLuint> shaders;
for (const auto& [type, source] : stages) {
const GLuint shader = CompileShaderStage(type, source, &m_buildLog);
if (shader == 0) {
for (const GLuint built : shaders) glDeleteShader(built);
return 0;
}
shaders.push_back(shader);
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
if (captureVarying != nullptr) {
glTransformFeedbackVaryings(program, 1, &captureVarying, GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
for (const GLuint shader : shaders) glDeleteShader(shader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
m_buildLog = buffer.data();
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
GLuint BuildCaptureProgram() {
return BuildProgram({{GL_VERTEX_SHADER, kVertexSource}}, "vs_out_value");
}
GLuint BuildTessellationProgram(bool withCaptureVarying = false) {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
while (glGetError() != GL_NO_ERROR) {
}
if (maxTessGenLevel < 1) return 0;
return BuildProgram(
{{GL_VERTEX_SHADER, kTessVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER,
withCaptureVarying ? kTessEvalCaptureSource : kTessEvalSource}},
withCaptureVarying ? "te_out_value" : nullptr);
}
// A capture span that is open but PAUSED. The pause closes the capture, so
// every draw inside it is XFB-inactive at the backend - the stream query's
// silent case - while the GL span stays active. `program` must be the one
// that is bound: GL requires the same program at resume.
void BeginPausedSpan() {
glGenBuffers(1, &m_captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 64 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginTransformFeedback(GL_TRIANGLES);
glPauseTransformFeedback();
}
void EndPausedSpan() {
glResumeTransformFeedback();
glEndTransformFeedback();
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
if (m_captureBuffer != 0) glDeleteBuffers(1, &m_captureBuffer);
m_captureBuffer = 0;
}
// GENERATED query around `record()`, answered with GL_QUERY_RESULT.
GLuint QueryGenerated(const std::function<void()>& record) {
glBeginQuery(GL_PRIMITIVES_GENERATED, m_queries[1]);
record();
glEndQuery(GL_PRIMITIVES_GENERATED);
GLuint generated = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[1], GL_QUERY_RESULT, &generated);
return generated;
}
static GLenum DrainGLErrors() {
const GLenum first = glGetError();
while (glGetError() != GL_NO_ERROR) {
}
return first;
}
const std::string& BuildLog() const { return m_buildLog; }
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
GLuint m_vao = 0;
GLuint m_queries[2] = {0, 0}; // [0]=written, [1]=generated
GLuint m_captureBuffer = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// The plain shape: no capture object was ever bound, no span begun, no
// rasterizer discard - just a GENERATED query around two triangles. On a
// healthy driver the stream query answers it; on an affected one the armed
// reroute must produce the same 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsADrawMadeWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 2u)
<< "GL_PRIMITIVES_GENERATED must count a draw made while transform feedback is "
"inactive (GL 4.6 core 13.4)";
}
// THE CTS SHAPE (esextcTessellationShaderUtils.cpp, captureTessellationData):
// rasterizer discard ON, transform feedback INACTIVE, the draw inside a
// GENERATED query. This is the exact query whose 0 sizes ~29 tessellation
// tests' capture buffers on the affected device.
//
// On lavapipe this case holds through the dedicated
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT reroute (its discard feature is
// what makes a discarded draw countable there - llvmpipe's clipping
// statistics AND stream query both read 0 under discard).
//
// The value-conditioned skip below is deliberate and narrow, for a stack
// with NO counter that survives discard: there this case is unfalsifiable,
// and a red would indict MobileGL for a hole the bring-up probe already
// measures and reports (StatisticsSubstitutePlainOnly / Unfixable). The
// exact-zero answer IS the capability signal - any wrong nonzero count
// still fails - and on every driver that counts discarded draws at all the
// full assertion runs. The device probe list holds this shape on the Mali.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsUnderRasterizerDiscardWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(DrainGLErrors(), 0u);
if (generated == 0u) {
GTEST_SKIP() << "no counter this backend can reach (stream query, dedicated "
"primitives-generated query, clipping statistics) survives "
"rasterizer discard for an XFB-inactive draw on this stack - the "
"shape is unfalsifiable here; the bring-up probe measures the same "
"hole and the POST row reports it";
}
EXPECT_EQ(generated, 2u)
<< "rasterizer discard drops primitives after clipping and must not hide them from "
"GL_PRIMITIVES_GENERATED - this is the exact shape the CTS measures the "
"tessellator with";
}
// The tessellation flavour: a PATCHES draw whose all-1 levels emit exactly
// one triangle - the count the CTS's getAmountOfVerticesGeneratedByTessellator
// protocol derives everything from. Undiscarded, so that the answer is
// holdable on this machine through whichever accounting path is armed (the
// discard interaction is the case above's business, measured separately).
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsATessellatedPatchWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram();
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u)
<< "a triangles-domain patch with every level 1 tessellates to exactly one "
"triangle, and GL_PRIMITIVES_GENERATED must say so with no capture active";
}
// One query span holding BOTH kinds of draw: an XFB-inactive draw, then a
// captured one, then another XFB-inactive one. The GENERATED answer must
// accumulate across the two accounting paths the armed reroute splits them
// into (stream slots for the captured draw, statistics slots for the
// others), and WRITTEN must stay exactly the captured draw's count - the
// pairing the stream path exists to keep exact. Undiscarded, so the
// accumulation invariant is holdable on this machine (see the discard
// case's comment); the triangles rasterize into the harness framebuffer,
// which nothing here reads.
TEST_F(PrimitivesGeneratedNoXfbScenario, ASpanMixingActiveAndInactiveDrawsAccumulatesBoth) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 3 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, m_queries[0]);
const GLuint generated = QueryGenerated([]() {
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive
glBeginTransformFeedback(GL_TRIANGLES);
glDrawArrays(GL_TRIANGLES, 0, 3); // captured
glEndTransformFeedback();
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive again
});
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
GLuint written = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[0], GL_QUERY_RESULT, &written);
glDeleteBuffers(1, &captureBuffer);
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 3u) << "one triangle before the span, one inside it, one after";
EXPECT_EQ(written, 1u) << "only the draw inside the span writes anything";
}
// ===================== DRAWS INSIDE A PAUSED SPAN =====================
//
// glPauseTransformFeedback closes the capture without closing the span, so a
// draw made while paused is XFB-INACTIVE at the backend - the stream query is
// exactly as silent for it as for a draw with no span at all - while
// GL_PRIMITIVES_GENERATED must still count what the last vertex processing
// stage emitted (GL 4.6 core 13.4; the WRITTEN query is the one the pause
// silences). The frontend does keep a CPU counter for paused draws, but it can
// price only 3 of the ~15 draw entry points and answers 0 for GL_PATCHES, so
// these draws are the reroute's business like any other - and the trap on the
// other side is counting them TWICE, once in each accounting.
//
// Each case measures the SAME draw twice: once with no span open at all (the
// capability control - what this stack can count) and once inside the paused
// span, and requires the two to agree. That differential is what makes these
// cases falsifying rather than vacuous: a stack where no counter reaches a
// capture-less draw fails the control and skips, while a stack that counts the
// unpaused draw and answers 0 for the paused one - which is what excluding
// paused draws from the reroute produced - fails, instead of skipping into
// green.
// The draw the CPU counter CAN price: if the span both reroutes it and adds the
// CPU delta, this reads 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsACpuPricedDrawExactlyOnce) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") == AmbientQuirk::Off) {
GTEST_SKIP() << "the negative control replays the pre-probe accounting, whose paused "
"draws are CPU-counted on top of whatever the stream query says";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half of the comparison proves nothing; the "
"bring-up probe measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u) << "the control itself: one triangle is one primitive";
EXPECT_EQ(paused, unpaused)
<< "one triangle drawn while the capture span is paused is still one primitive "
"generated - counted once, by whichever accounting owns it, never by two of them "
"(a reroute slot AND the frontend's CPU paused counter reads 2)";
}
// The draw the CPU counter CANNOT price: GL_PATCHES, whose amplification is not
// knowable on the CPU (CountPrimitivesForDraw answers 0 for it by design) - and
// the CTS's tessellator-measuring shape. Excluding paused draws from the
// reroute left this counted by nothing at all on the affected device.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsATessellatedPatchExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram(/*withCaptureVarying=*/true);
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less patch draw "
"on this stack, so the paused half proves nothing; the bring-up probe "
"measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u)
<< "the control itself: a triangles-domain patch with every level 1 tessellates to "
"exactly one triangle";
EXPECT_EQ(paused, unpaused)
<< "pausing the capture does not stop the tessellator from generating that triangle, "
"and the frontend's CPU paused counter answers 0 for GL_PATCHES - so a paused "
"patch draw left out of the reroute is counted by nothing at all";
}
// The other half of the same hole: the instanced entry points never reach the
// frontend's paused accounting either, so a paused instanced draw excluded from
// the reroute is likewise counted by nothing.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsAnInstancedDrawExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused =
QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half proves nothing";
}
EXPECT_EQ(unpaused, 4u) << "the control itself: four instances of one triangle";
EXPECT_EQ(paused, unpaused)
<< "four instances generate four primitives whether or not the capture span is "
"paused, and no instanced entry point reaches the frontend's paused accounting";
}
// THE ONE CASE THAT CAN FAIL WHEN THE REROUTE SILENTLY STOPS BEING ARMED -
// the UnlocatedIoBlockScenario shape, for the same reason: every case above
// is green here whether the reroute ran or not (that is the "two pools
// agree" point), so none of them can say the pinned lane actually exercised
// a reroute pool. This one asserts a LIBRARY OBSERVABLE against the
// environment: with MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE pinned on, an
// XFB-inactive draw inside a GENERATED span must make the renderer say -
// through its latched MGLOG_I - that it engaged the reroute. It reads
// MG_Config not at all (on Android this module links the shipping library)
// and trusts only the log bytes appended after it started.
TEST_F(PrimitivesGeneratedNoXfbScenario, TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the reroute pinned ON for the whole process, which "
"is what the PrimGenReroute. ctest entry does with "
"MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1; unset, the bring-up probe "
"decides and this machine's verdict is its own business";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned on but "
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
"record that it rerouted anything; the PrimGenReroute. ctest "
"entry sets both";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
// Taken BEFORE the draw, so the line this looks for can only be one this
// process wrote for this span. The latch fires on the FIRST rerouted
// draw, which is inside the query below.
const std::uintmax_t before = LibraryLogSize();
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u) << "the pinned-on lane did not even count correctly";
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("PRIMITIVES_GENERATED reroute engaged"), std::string::npos)
<< "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned ON, an XFB-inactive draw ran inside "
"a GENERATED query, and the renderer never reported engaging the reroute. The "
"quirk is not armed - check the override mapping "
"(ChoosePrimitivesGeneratedReroute) and the arming gate in "
"VulkanRenderer::BeginXfbQueryForDraw. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -8,6 +8,8 @@
#include "BufferObject.h"
#include <Config.h>
#include <atomic>
namespace MobileGL::MG_State::GLState {
@@ -65,6 +67,14 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::NotifyContentWrite(SizeT offset, SizeT size) {
if (size == 0) {
// An empty write moves the serial and nothing else, exactly as NotifySubData
// and NotifyFlushMappedRange do: it wrote no byte, so it must not promote an
// undefined store to "has content" - that would cost the next orphaning
// respecification a full-size upload of bytes the application never wrote.
++m_changeSerial;
return;
}
m_hasDefinedContent = true;
if (m_resource.IsGpuResident()) {
// The write already landed in coherent GPU memory; the backend has no separate
@@ -109,7 +119,10 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
// The store a live mapping wrote into is about to be replaced, so landing those
// bytes into it would copy a whole mapped range (an adopted arena's map is the
// arena) into storage the next line hands back.
ReleaseMemory(false);
RedefineStorage(size);
if (data && size > 0) {
Memcpy(m_resource.Bytes(), data, size);
@@ -126,6 +139,7 @@ namespace MobileGL::MG_State::GLState {
// distinguishes the two cases, and it is cleared just above.
m_storageFlags = GL_DYNAMIC_STORAGE_BIT | GL_MAP_READ_BIT | GL_MAP_WRITE_BIT;
NotifyRespecify();
TryAdoptLargeStorage();
}
void BufferObject::Resize(SizeT size) {
@@ -133,7 +147,9 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::AllocateImmutableStorage(SizeT size, const void* data, GLbitfield storageFlags) {
ReleaseMemory();
// Same as Respecify: the bytes a live mapping staged have nowhere to land, the
// store they belong to is being replaced.
ReleaseMemory(false);
RedefineStorage(size);
if (data) {
Memcpy(m_resource.Bytes(), data, size);
@@ -144,6 +160,33 @@ namespace MobileGL::MG_State::GLState {
m_isImmutableStorage = true;
m_storageFlags = storageFlags;
NotifyRespecify();
TryAdoptLargeStorage();
}
// Back a LARGE store with the backend's persistently+coherently mapped GPU
// storage the moment it is (re)defined, without waiting for the app to map it.
// Minecraft 26.3 streams chunk meshes into 128MB vertex arenas with plain
// glNamedBufferSubData - the one write API that carries no synchronization
// hint - and on Mali every route that hands the driver a write into a busy
// MUTABLE store either parks the calling thread (glBufferSubData, and
// glMapBufferRange even with GL_MAP_UNSYNCHRONIZED_BIT) or ghost-copies the
// whole destination on a driver worker (staged glCopyBufferSubData, and a
// range-invalidating map: ~167ms per touched arena, the recurring in-world
// hiccup). An adopted coherent map is the one shape with NO per-write driver
// call at all: every SubData lands as a plain memcpy into GPU-visible memory,
// and the shadow copy is dropped (a 128MB arena stops costing 128MB of RAM).
// Only attempted for stores the size of mesh arenas: small buffers keep the
// shadow model whose draw-time flush already prices them correctly.
void BufferObject::TryAdoptLargeStorage() {
constexpr SizeT kLargeBufferAdoptBytes = 16u * 1024u * 1024u;
if (MG_Config::Features.DisableLargeBufferAdoption) return;
if (m_size < kLargeBufferAdoptBytes) return;
if (m_resource.IsGpuResident()) return;
if (m_isMapped) return;
if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return;
if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) {
m_resource.AdoptPersistentMap(base);
}
}
void BufferObject::UploadData(DataPtr data, SizeT atOffset) {
@@ -160,24 +203,45 @@ namespace MobileGL::MG_State::GLState {
m_usage = usage;
}
void BufferObject::ReleaseMemory() {
void BufferObject::ReleaseMemory(Bool landStagedWrites) {
if (!m_isMapped) return;
if (m_mappingAccess & BufferMappingAccessBit::Write) { // if we wrote to the buffer
// A persistent GPU-resident map wrote straight into coherent GPU memory, so
// there is nothing to copy back and no range to push down on unmap.
if (!m_resource.IsGpuResident() &&
!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
m_mappedRange.end - m_mappedRange.start);
if (landStagedWrites &&
(m_mappingAccess & BufferMappingAccessBit::Write)) { // if we wrote to the buffer
if (!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
const SizeT mappedLength = m_mappedRange.end - m_mappedRange.start;
if (m_resource.IsGpuResident()) {
// A persistent map of an adopted store wrote straight into coherent
// GPU memory: nothing to copy back, no range to push down. A
// NON-persistent write map is a different thing: the application
// wrote a staging copy (glMapBuffer and glMapBufferRange hand one out
// regardless of where the store lives), and GL requires those bytes
// to be visible to every later command the moment glUnmapBuffer
// returns. Residency used to come only from a coherent persistent
// map, which never has a staging copy, so the copy-back was simply
// skipped for a resident store; residency now also comes from a
// shader storage binding (EnsureGpuResidentStorage at draw time) and
// from large-store adoption (TryAdoptLargeStorage), both of which an
// application then re-initialises through an ordinary map/write/unmap.
// Skipping the copy-back dropped every one of those writes. Land the
// staged bytes through the same route glBufferSubData takes into an
// adopted store - the backend's flush op is for stores it keeps a
// separate copy of and must not run here.
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
LandBytesIntoResidentStore(m_mappedRange.start,
{m_stagingData.data() + m_stagingBias, mappedLength});
}
} else {
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
mappedLength);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
m_stagingData.clear();
}
m_stagingData.clear();
m_isMapped = false;
m_mappingAccess = BufferMappingAccessBit::Null;
m_mappedRange = {0, 0};
@@ -197,8 +261,21 @@ namespace MobileGL::MG_State::GLState {
MOBILEGL_ASSERT(end <= m_mappedRange.end, "Flush range out of bounds: mappedRange.end (%zu) < end (%zu)",
m_mappedRange.end, end);
// FLUSH_EXPLICIT maps are never GPU-resident (only coherent maps are adopted), so
// the staged bytes must be copied into the shadow before the backend reads them.
// A FLUSH_EXPLICIT map can sit on an adopted store: the map itself never adopts
// (only a coherent persistent one does), but a shader storage binding or
// large-store adoption may have made the buffer resident before the map. The
// flushed bytes then take the same landing as any other CPU write into an
// adopted store - a persistent map already wrote them in place and only has
// to publish the change, a non-persistent map staged them and has to land
// them. The backend's flush op is for stores it keeps a separate copy of.
if (m_resource.IsGpuResident()) {
if (m_mappingAccess & BufferMappingAccessBit::Persistent) {
NotifyContentWrite(start, length);
} else {
LandBytesIntoResidentStore(start, {m_stagingData.data() + m_stagingBias + offset, length});
}
return;
}
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + m_stagingBias + offset, length);
}
@@ -253,10 +330,51 @@ namespace MobileGL::MG_State::GLState {
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
data.size, m_size);
// An adopted store's Bytes() IS the memory in-flight frames are reading, and
// GL orders a glBufferSubData after those already-submitted reads: the write
// has to take the resident landing, never a plain host write into the mapping.
// Shadow-backed stores need none of this: the Memcpy below touches only the
// shadow, and the backend's SubData op does its own ordering against in-flight
// work.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(atOffset, data);
return;
}
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
NotifyContentWrite(atOffset, data.size);
}
// A backend that can land the bytes on the GPU timeline takes them here, untouched
// by the mapping - an in-place host write into coherent memory tore the frames
// still reading the old bytes (Minecraft patches LIVE chunk sections this way).
// The bytes are then not current in the mapping until the backend's ordered copy
// executes, so reads reconcile through the same gate GPU-written buffers use.
//
// Without that op the write lands in place, after retiring the GPU writes this store
// is known to be waiting on: a backend that defers work (DirectVulkan's frame command
// buffer) may still be holding a recorded-but-unsubmitted dispatch that GL orders this
// write AFTER, and writing the mapping now would land the bytes underneath that
// dispatch - its increments then execute on top of the newer data and invert the call
// order. That gate only knows about work that WROTE the store (MarkGpuWritten); work
// that merely READS it - a draw sourcing an adopted vertex arena - is not tracked here,
// so a backend without the op still owes the ordering against its own recorded reads.
// NotifyContentWrite on a resident store only bumps the serial: the backend has no
// separate copy to sync, so no transfer op runs.
void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) {
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
return;
}
SyncGpuWrites();
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
NotifyContentWrite(offset, bytes.size);
}
void BufferObject::FillSubData(DataPtr pattern, SizeT atOffset, SizeT size) {
MOBILEGL_ASSERT(pattern.data != nullptr && pattern.size > 0,
"FillSubData requires a non-empty pattern.");
@@ -269,9 +387,29 @@ namespace MobileGL::MG_State::GLState {
"Cannot fill data while buffer is non-persistently mapped.");
if (size == 0) return;
// A clear is ordered after all earlier GPU writes. Partial clears additionally need the
// retained shadow bytes; whole-store clears need the same synchronization before writing
// an adopted persistent mapping that the GPU may still be accessing.
// An adopted store takes the same landing as UploadSubData: the in-place write
// below would tear in-flight readers of the mapping. The pattern is expanded
// first because the landing takes the final bytes, not a repeat rule - which is
// why only a backend that actually takes them comes through here. Without that
// op the landing would memcpy the expansion into the mapping the loop below
// fills in place anyway, so a whole-arena clear would allocate a whole arena
// for nothing.
if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
Vector<Uint8> expanded(size);
if (pattern.size == 1) {
Memset(expanded.data(), *static_cast<const Uint8*>(pattern.data), size);
} else {
for (SizeT at = 0; at < size; at += pattern.size) {
Memcpy(expanded.data() + at, pattern.data, pattern.size);
}
}
LandBytesIntoResidentStore(atOffset, {expanded.data(), size});
return;
}
// A clear is ordered after all earlier GPU writes; partial clears additionally need
// the retained shadow bytes, and a resident store the backend cannot take the bytes
// for is written in place, which needs the same synchronization the landing does.
SyncGpuWrites();
Uint8* dst = m_resource.Bytes() + atOffset;
@@ -305,6 +443,14 @@ namespace MobileGL::MG_State::GLState {
size, m_size);
src->SyncGpuWrites();
// An adopted DESTINATION takes the same landing as UploadSubData: the in-place
// write below would tear in-flight readers of the mapping, and pending recorded
// GPU writes to it must retire before the copy lands or they would execute on
// top of it.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(dstOffset, {src->m_resource.Bytes() + srcOffset, size});
return;
}
Memcpy(m_resource.Bytes() + dstOffset, src->m_resource.Bytes() + srcOffset, size);
NotifyContentWrite(dstOffset, size);
}
@@ -340,6 +486,16 @@ namespace MobileGL::MG_State::GLState {
if (m_resource.IsGpuResident()) {
return true;
}
// Adoption releases the CPU shadow, and a live mapping may BE that shadow: a
// persistent map that did not itself adopt (a FLUSH_EXPLICIT one, or a read map)
// handed the application shadow + offset, and GL keeps that pointer valid while
// the buffer is drawn with - which is exactly when this runs, on the storage
// binding walk. Freeing it under the application is a use-after-free, so a mapped
// buffer keeps the shadow model until it is unmapped; the binding that follows
// adopts then. Same rule as TryAdoptLargeStorage.
if (m_isMapped) {
return false;
}
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
return false;
}
@@ -358,7 +514,20 @@ namespace MobileGL::MG_State::GLState {
// The app is about to look at the bytes; a shader may have rewritten them since
// the shadow was last authoritative. Also needed for a write map without an
// invalidate bit, whose staging copy is seeded from the shadow.
SyncGpuWrites();
//
// One map shape looks at nothing: a non-persistent write map that discards the
// range it maps gets a staging copy the seeding below skips, so no reader of the
// store exists between here and the unmap. Reconciling an ADOPTED store would
// still cost the backend's full drain-and-wait (its queued landings are made
// visible to the CPU by finishing the pipeline), once per map, on exactly the
// streaming arena the adoption exists to keep cheap. The outstanding-write flag
// stays set, so the first read that DOES look at the bytes still pays for it.
const Bool discardsWhatItMaps =
(access & BufferMappingAccessBit::Write) && !(access & BufferMappingAccessBit::Persistent) &&
(access & (BufferMappingAccessBit::InvalidateRange | BufferMappingAccessBit::InvalidateBuffer));
if (!(m_resource.IsGpuResident() && discardsWhatItMaps)) {
SyncGpuWrites();
}
m_isMapped = true;
m_mappingAccess = access;
m_mappedRange = range;
@@ -80,6 +80,19 @@ namespace MobileGL {
void (*Respecify)(BufferObject& bufferObject) = nullptr;
// Contents update of [offset, offset + size) from the shadow.
void (*SubData)(BufferObject& bufferObject, SizeT offset, SizeT size) = nullptr;
// Contents update of an ADOPTED (GPU-resident) store. `data` holds the app's
// bytes, valid for the duration of the call only (a write map's staging
// store is freed the moment the unmap that lands it returns); the frontend
// has NOT touched the resident mapping. GL orders a
// glBufferSubData after already-submitted GPU reads of the store, and an
// in-place host write into the coherent mapping tears the frames still
// reading the old bytes (Minecraft patches LIVE chunk sections this way -
// the tear shows as one-frame wrong geometry/UVs during fast movement). The
// backend lands the bytes on the GPU timeline instead: after in-flight
// readers, before the next consumer. The frontend marks the buffer
// gpu-write-pending so reads reconcile through ReadbackFromGpu. Backends
// without this op keep the legacy ordered in-place host write.
void (*ResidentSubData)(BufferObject& bufferObject, SizeT offset, DataPtr data) = nullptr;
// Write-map flush (glUnmapBuffer / glFlushMappedBufferRange). Carries the
// app's real mapping flags so the backend can honour INVALIDATE_* /
// UNSYNCHRONIZED semantics per call instead of merging them.
@@ -146,9 +159,14 @@ namespace MobileGL {
// Adopt backend host-visible coherent GPU storage as the source of truth
// (used for GPU-written targets like transform feedback capture, so
// MapBuffer/GetBufferSubData read real GPU results). No-op when already
// resident or when the backend declines.
// resident, while the buffer is mapped (adoption releases the shadow a
// mapping may have handed the application), or when the backend declines.
Bool EnsureGpuResidentStorage();
void ReleaseMemory();
// Unmap. A write map's staged bytes land in the store on the way out, unless
// the caller is about to replace that store (a respecification) and passes
// false - landing them there would copy a whole mapped range into storage
// being handed back on the next line.
void ReleaseMemory(Bool landStagedWrites = true);
void FlushMemoryRange(SizeT offset, SizeT length);
// Pushes the persistently-mapped write range to the backend; called by
@@ -211,6 +229,10 @@ namespace MobileGL {
// Sizes the store for a (re)definition, renewing an adopted GPU-resident
// mapping across it. See the definition for why the renewal is not optional.
void RedefineStorage(SizeT size);
// Backend-initiated coherent adoption for mesh-arena-sized stores; see the
// definition for the driver behavior that makes every other write route to
// a busy large mutable store a frame-scale stall.
void TryAdoptLargeStorage();
void NotifyRespecify();
void NotifySubData(SizeT offset, SizeT size);
void NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess);
@@ -219,6 +241,12 @@ namespace MobileGL {
// so this only bumps the change serial; otherwise it dispatches a backend
// SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size);
// The one route CPU-sourced bytes take into an ADOPTED (GPU-resident) store:
// glBufferSubData, a buffer clear, a buffer copy, and the landing of a
// non-persistent write map at unmap / explicit flush all go through it, so
// the routes cannot drift apart again. Carries no mapping asserts on
// purpose - the unmap landing runs while the buffer is still mapped.
void LandBytesIntoResidentStore(SizeT offset, DataPtr bytes);
static Uint64 AllocateLifetimeId();
@@ -517,7 +517,9 @@ namespace MobileGL::MG_State::GLState {
};
} // namespace
void ProgramLinkTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
void ProgramLinkTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramLinkTask::SubmitAfter(const Vector<SharedPtr<ShaderCompileTask>>& deps) {
// +1 for the guard this function releases itself. Without it, a dependency that
@@ -855,6 +857,18 @@ namespace MobileGL::MG_State::GLState {
spirvHandoff.reflection.uniformReflection = artifacts.uniformReflection;
spirvHandoff.reflection.blockReflection = artifacts.blockReflection;
spirvHandoff.reflection.tProgramBlockIndexToGl = artifacts.tProgramBlockIndexToGl;
// The capture set is NOT part of that slice (see the handoff's own comment), and the
// point-size demotion needs exactly one bit out of it: whether anything asked to
// capture gl_PointSize. Derived here, where ResolveTransformFeedbackVaryings has
// just filled artifacts.xfbVaryings and before the join moves them away, because a
// capture stage that only READS the built-in still has to declare the carrier the
// capture binds to - and phase B has no other way to learn that.
for (const ProgramObject::XfbVarying& varying : artifacts.xfbVaryings) {
if (varying.name == "gl_PointSize") {
spirvHandoff.captureRequestsPointSize = true;
break;
}
}
// Phase B pairs this with its own SpirvArtifacts to insert the completed front end.
// A COPY, because the GL-thread join moves `artifacts` out of this node before phase B
// runs - and with the TProgram dropped, because a memo must never hold a glslang arena.
@@ -896,6 +910,11 @@ namespace MobileGL::MG_State::GLState {
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
// The second and third capability bits, under exactly the same rule: each arms a
// phase-B rewrite of the cached modules (the point-size demotion), read from the
// same env snapshot that phase B will consult, so key and bytes cannot disagree.
keyInputs.demoteTessellationPointSize = env.DemotesTessellationPointSize();
keyInputs.demoteGeometryPointSize = env.DemotesGeometryPointSize();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
@@ -117,6 +117,19 @@ namespace MobileGL::MG_State::GLState {
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// Whether the RESOLVED transform-feedback capture set names gl_PointSize - the
// one fact about `artifacts.xfbVaryings` phase B needs, carried as a derived
// bool rather than by widening the slice above, which is deliberately the five
// (now eight) fields BuildGlobalUboRouting consumes and nothing else.
//
// It has to be here and cannot be re-derived: the point-size demotion forces the
// capture-capable stage to declare its carrier even when that stage never WRITES
// the built-in (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram's
// `captureRequestsPointSize`), and by phase B the only record of the request is
// this bit. No new L1 key material: the key already covers
// `requestedXfbVaryings`, of which this is a function.
Bool captureRequestsPointSize = false;
// L1 shader-translation memo key for this program's SPIR-V (see
// MG_Util/ShaderTranspiler/TranslationCache.h). Built HERE, at the tail of phase
// A, and not by phase B - two reasons, both structural:
@@ -201,8 +214,10 @@ namespace MobileGL::MG_State::GLState {
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
// join, on the GL thread, where a serial implementation would have printed it.
// Logging straight from a worker interleaves mid-line with the GL thread's output and
// lands out of order relative to the glLinkProgram that caused it.
void DeferLog(String line);
// lands out of order relative to the glLinkProgram that caused it. `level` is the
// severity the replay uses; DEBUG (the default) is compiled out of every shipped
// build, so a line that has to survive one names its own.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
// Counts down to zero exactly once. Starts at deps + 1: the extra guard is released
// by SubmitAfter itself, so a dependency that settles while the edges are still being
@@ -603,6 +603,11 @@ namespace MobileGL::MG_State::GLState {
// other question about the global UBO's layout - and it is one: it decides how wide a
// `double` uniform's slot is.
Bool UsesNativeFloat64() const { return Spirv().nativeFloat64; }
// Whether gl_PointSize was demoted out of this program's tessellation/geometry
// modules into the ordinary carrier varying. Joins phase B: it is a fact about the
// generated modules, and its readers (the backends' capture-name respelling) already
// hold the phase-B join.
Bool PointSizeDemoted() const { return Spirv().pointSizeDemoted; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
@@ -1429,6 +1434,18 @@ namespace MobileGL::MG_State::GLState {
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
// Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host
// the built-in there. Per PROGRAM by construction - a consumer whose producer
// kept the built-in would read garbage - and recorded here rather than
// re-derived because it cannot be: the rewrite's whole point is that the final
// bytes no longer declare the capability that armed it. The backends read it to
// respell a "gl_PointSize" transform-feedback capture as the carrier
// (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface
// deliberately keeps answering "gl_PointSize": demotion happens after phase A,
// so every query keeps the truthful GL spelling.
Bool pointSizeDemoted = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -16,10 +16,29 @@
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <atomic>
#include <cstring>
namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
namespace {
// The MGLOG_*_ONCE latch, moved to the SOURCE of a deferred line. It cannot live at
// the replay: Async::ApplyDeferredDiagnostics is ONE site shared by every job in the
// tree, so a latch there would silence unrelated lines. And it has to exist: a shader
// pack hands the same refusal to program after program, and a per-program WARN on a
// path like that is exactly the repeated production logging the house rule forbids.
// First occurrence at WARN - the one a bug report needs - every later one back at
// DEBUG, which shipped builds compile out.
Int FirstTimeWarnLevel(std::atomic_flag& latch) {
return latch.test_and_set(std::memory_order_relaxed) ? MOBILEGL_LOG_LEVEL_DEBUG
: MOBILEGL_LOG_LEVEL_WARN;
}
std::atomic_flag g_pointSizeDeclineReported;
std::atomic_flag g_pointSizeOptimizerFailureReported;
} // namespace
void ProgramSpirvTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramSpirvTask::SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing");
@@ -128,8 +147,15 @@ namespace MobileGL::MG_State::GLState {
// with (ProgramLinkTask::BuildSpirvCacheKey reads the same env) or a memo written under
// one answer could be handed back under the other.
const Bool nativeFloat64 = m_phaseA->in.env != nullptr && m_phaseA->in.env->ConsumesFloat64Natively();
// The point-size demotion verdicts, read from the SAME snapshot for the same reason
// - and the same bits BuildSpirvCacheKey put in the L1 key, so a memo written under
// one answer can never be handed back under the other.
const Bool demoteTessellationPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesTessellationPointSize();
const Bool demoteGeometryPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesGeometryPointSize();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
@@ -188,7 +214,9 @@ namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation, const Bool nativeFloat64) {
const Bool enableSpirvValidation, const Bool nativeFloat64,
const Bool demoteTessellationPointSize,
const Bool demoteGeometryPointSize) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -267,6 +295,50 @@ namespace MobileGL::MG_State::GLState {
}
}
artifacts.spirvStatus = allOptimized;
// The point-size demotion, program-wide and after the sanitize chain, so it works
// on the final shared bytes both backends consume and nothing downstream can trim
// the carriers it declares. Only the env half of the verdict lives here (and in the
// L1 key); whether the program actually declares the capability is probed inside,
// so the common case on an affected device - a program that never touches point
// size in those stages - pays one module parse per stage and no rewrite.
artifacts.pointSizeDemoted = false;
if (allOptimized && (demoteTessellationPointSize || demoteGeometryPointSize)) {
// Read off the HANDOFF's own derived bit, not off `handoff.reflection`: that
// field is the routing slice phase A fills with eight named members, and
// xfbVaryings is not one of them - reading it there answered "no capture ever
// asks for gl_PointSize" on every production link, which left a read-only
// capture stage without the carrier its capture binds to.
const Bool captureRequestsPointSize = handoff.captureRequestsPointSize;
ShaderCompiler::PointSizeDemotionOutcome outcome;
if (!ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
artifacts.generatedSpirv, handoff.shaderTypes, demoteTessellationPointSize,
demoteGeometryPointSize, captureRequestsPointSize, outcome,
!deferOutputValidationForDirectVulkan, enableSpirvValidation)) {
// Optimizer failure: modules untouched, so the capability is still declared
// and the backends' existing refusals stay in charge - honest, just slower.
DeferLog(std::format("ProgramObject {}: point-size demotion failed in the optimizer; the "
"program keeps its built-in and the device's declines apply",
externalIndex),
FirstTimeWarnLevel(g_pointSizeOptimizerFailureReported));
} else if (outcome.demoted) {
artifacts.pointSizeDemoted = true;
DeferLog(std::format("ProgramObject {}: gl_PointSize demoted to an ordinary varying across "
"the tessellation/geometry chain (value preserved for capture and "
"gl_in reads; rasterized size falls back to 1.0)",
externalIndex));
} else if (!outcome.declineDetail.empty()) {
// THE MOST VALUABLE LINE THIS FEATURE PRODUCES: which module shape the pass
// refused, and therefore why an affected device is still about to lose the
// program. Nothing else records it - `declineDetail` has no other runtime
// surface - so at the deferred channel's DEBUG default it was formatted and
// then dropped by every INFO build, i.e. every device and every CI artifact.
DeferLog(std::format("ProgramObject {}: point-size demotion declined ({}); the program "
"keeps its built-in and the device's declines apply",
externalIndex, outcome.declineDetail),
FirstTimeWarnLevel(g_pointSizeDeclineReported));
}
}
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
@@ -67,12 +67,14 @@ namespace MobileGL::MG_State::GLState {
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
Bool nativeFloat64, Bool demoteTessellationPointSize,
Bool demoteGeometryPointSize);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
// ProgramLinkTask::DeferLog.
void DeferLog(String line);
// ProgramLinkTask::DeferLog, and the same severity rule: DEBUG is compiled out of
// every shipped build, so a line that has to survive one names its own level.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
SharedPtr<ProgramLinkTask> m_phaseA;
};
@@ -384,12 +384,15 @@ namespace MobileGL::MG_State::GLState {
// the log, for every failing shader. The info log is what names the offending
// line; the source is recoverable from the application.
const SizeT firstLineEnd = artifacts.infoLog.find('\n');
diagnostics.logLines.push_back(std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos ? artifacts.infoLog.length()
: firstLineEnd)));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG,
std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos
? artifacts.infoLog.length()
: firstLineEnd))});
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
+900
View File
@@ -1568,6 +1568,15 @@ namespace {
int respecifyCalls = 0;
int flushCalls = 0;
Bool provideMap = true; // false => backend declines, exercising the shadow fallback
// Only recorded by the variant of the ops table that offers ResidentSubData: the
// bytes a CPU write handed the backend for a GPU-ordered landing into an adopted
// store, held back from `gpu` until a readback "retires" them.
struct ResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<ResidentWrite> residentWrites;
int readbackCalls = 0;
};
ZeroCopyMockBackend* g_zeroCopyMock = nullptr;
@@ -1605,6 +1614,39 @@ namespace {
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
};
// The same backend with the GPU-ordered landing ops a staging-ring backend offers: a
// CPU write into an adopted store is queued (the mapping is NOT written through), and
// a readback is what lands the queue before the application reads.
void ZeroCopyMock_ResidentSubData(MG_State::GLState::BufferObject&, SizeT offset, DataPtr data) {
if (!g_zeroCopyMock) return;
auto& write = g_zeroCopyMock->residentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void ZeroCopyMock_ReadbackFromGpu(MG_State::GLState::BufferObject&) {
if (!g_zeroCopyMock) return;
++g_zeroCopyMock->readbackCalls;
for (const auto& write : g_zeroCopyMock->residentWrites) {
// Reported, not asserted: an ASSERT here would return out of the readback and
// leave the remaining landings unapplied, which reads as a different failure.
EXPECT_LE(write.offset + write.bytes.size(), g_zeroCopyMock->gpu.size());
if (write.offset + write.bytes.size() > g_zeroCopyMock->gpu.size()) continue;
Memcpy(g_zeroCopyMock->gpu.data() + write.offset, write.bytes.data(), write.bytes.size());
}
g_zeroCopyMock->residentWrites.clear();
}
const MG_State::GLState::BufferBackendOps kResidentSubDataMockOps = {
.Respecify = ZeroCopyMock_Respecify,
.SubData = ZeroCopyMock_SubData,
.ResidentSubData = ZeroCopyMock_ResidentSubData,
.FlushMappedRange = ZeroCopyMock_Flush,
.OnDestroy = ZeroCopyMock_OnDestroy,
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
};
struct ScopedBackendOps {
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
MG_State::GLState::SetBufferBackendOps(ops);
@@ -2074,3 +2116,861 @@ TEST_F(BufferTest, RedefiningANonAdoptedBufferIsUnchanged) {
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// A NON-persistent write map of an ADOPTED store. glMapBuffer / glMapBufferRange hand
// the application a staging copy regardless of where the store lives, and GL requires
// the bytes it wrote there to be visible to every later command once glUnmapBuffer
// returns. Residency used to come only from a coherent persistent map - which writes
// in place and never has a staging copy - so the unmap simply skipped the copy-back
// for a resident store. Residency now also comes from a shader storage binding
// (EnsureGpuResidentStorage at draw time) and from large-store adoption, both of which
// an application then re-initialises through an ordinary map/write/unmap: the
// conformance suite re-seeds every SSBO that way before each draw, and every re-seed
// after the first draw was dropped on the floor. These pin the landing for each map
// shape, on the backend that writes the coherent mapping in place and on the one that
// takes the bytes for a GPU-ordered landing, plus the shadow path as the control.
namespace {
constexpr SizeT kAdoptedInts = 16;
// A buffer of kAdoptedInts sequential ints, adopted by the mock backend exactly as an
// SSBO binding does at draw time. The per-write counters are zeroed afterwards so a
// test only sees the traffic of the map it makes.
SharedPtr<MG_State::GLState::BufferObject> MakeAdoptedBuffer(ZeroCopyMockBackend& mock, GLenum target,
GLuint& buffer) {
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
EXPECT_NE(bufferObject, nullptr);
if (bufferObject == nullptr) return nullptr;
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(mock.gpu.data()));
mock.subDataCalls = 0;
mock.flushCalls = 0;
mock.respecifyCalls = 0;
return bufferObject;
}
const GLint* GpuInts(const ZeroCopyMockBackend& mock) {
return reinterpret_cast<const GLint*>(mock.gpu.data());
}
} // namespace
TEST_F(BufferTest, ANonPersistentReadWriteRangeMapOfAnAdoptedStoreLandsAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// The conformance suite's shape: the whole store, READ|WRITE, then a full rewrite.
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// A non-persistent map is a staging copy, seeded from the adopted store...
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(i));
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1000 + static_cast<GLint>(i);
// ...that the store does not see until the unmap.
EXPECT_EQ(GpuInts(mock)[0], 0);
bufferObject->ReleaseMemory();
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], 1000 + static_cast<GLint>(i)) << "int " << i;
}
EXPECT_EQ(std::memcmp(bufferObject->MappedData(), mock.gpu.data(), mock.gpu.size()), 0);
// The landing publishes the change for cached consumers...
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
// ...but dispatches no transfer op: the backend keeps no separate copy of an
// adopted store, and its flush op would only upload the mapping onto itself.
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.acquireMapCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlMapBufferWriteOnlyAndReadWriteOfAnAdoptedStoreLandAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// glMapBuffer(GL_WRITE_ONLY): the staging copy is still seeded (no invalidate bit),
// so a partial write keeps the untouched ints.
Uint64 serial = bufferObject->GetChangeSerial();
auto* writeOnly = static_cast<GLint*>(bufferObject->AcquireMemory(true, false, true));
ASSERT_NE(writeOnly, nullptr);
EXPECT_NE(static_cast<void*>(writeOnly), static_cast<void*>(mock.gpu.data()));
writeOnly[0] = 100;
writeOnly[1] = 200;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 2);
EXPECT_EQ(GpuInts(mock)[kAdoptedInts - 1], static_cast<GLint>(kAdoptedInts - 1));
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
// glMapBuffer(GL_READ_WRITE): reads see the previous landing, and the next one lands too.
serial = bufferObject->GetChangeSerial();
auto* readWrite = static_cast<GLint*>(bufferObject->AcquireMemory(true, true, true));
ASSERT_NE(readWrite, nullptr);
EXPECT_EQ(readWrite[0], 100);
EXPECT_EQ(readWrite[1], 200);
readWrite[2] = 300;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 300);
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, AWriteMapInvalidatingAnAdoptedStoreLandsTheWholeRangeAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateBuffer));
ASSERT_NE(mapped, nullptr);
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
// The whole range is undefined by contract, so the application rewrites all of it.
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = -static_cast<GLint>(i) - 1;
bufferObject->ReleaseMemory();
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], -static_cast<GLint>(i) - 1) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A range map at an offset off the alignment grid: the staging store is biased by the
// offset's phase (see AcquireMemoryRange), and the landing has to read from the biased
// start and write to the mapped offset - not from data(), not to 0.
TEST_F(BufferTest, ARangeMapAtAnUnalignedOffsetOfAnAdoptedStoreLandsInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 3..6, i.e. byte offset 12 - inside the first alignment, so the bias is non-zero.
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
ASSERT_NE(range.start % MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT, 0u);
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// Seeded from the right place...
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(kFirst + i));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 500 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// ...and landed in the right place, with everything outside the range untouched.
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 500 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// FLUSH_EXPLICIT on an adopted store: only the flushed bytes land, at the flush, and the
// unmap lands nothing more - the application promised to flush what it wanted kept.
TEST_F(BufferTest, AnExplicitFlushOfAWriteMapOfAnAdoptedStoreLandsOnlyTheFlushedBytes) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 2..13 mapped (offset 8, off the grid again), all of them rewritten...
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 12;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 700 + static_cast<GLint>(i);
// ...but only ints 5..8 (map-relative ints 3..6) flushed.
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 4;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
const Uint64 flushSerial = bufferObject->GetChangeSerial();
EXPECT_GT(flushSerial, baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
auto expectOnlyFlushedBytesLanded = [&](const char* when) {
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const Bool flushed = i >= kFirst + kFlushFirst && i < kFirst + kFlushFirst + kFlushCount;
const GLint expected = flushed ? 700 + static_cast<GLint>(i - kFirst) : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << when << ": int " << i;
}
};
expectOnlyFlushedBytesLanded("after the flush");
bufferObject->ReleaseMemory();
expectOnlyFlushedBytesLanded("after the unmap");
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The other kind of backend: one that takes the bytes for a GPU-ordered landing instead
// of letting the frontend write the coherent mapping in place. The unmap hands it the
// mapped offset and the bias-adjusted bytes, leaves the mapping alone, and marks a GPU
// write outstanding so the next read reconciles through the readback.
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheUnmappedBytesForAGpuOrderedLanding) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 5;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 900 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// The op got exactly the mapped range's bytes at the mapped offset...
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, range.start);
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], 900 + static_cast<GLint>(i)) << "int " << i;
// ...the mapping itself was not written through...
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the pending flag makes the next read pull the landing back first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_TRUE(mock.residentWrites.empty());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 900 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
// A second read has nothing outstanding to reconcile.
bufferObject->AcquireMemory(false, true, false);
EXPECT_EQ(mock.readbackCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAnExplicitlyFlushedRangeTheSameWay) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 800 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 5;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, (kFirst + kFlushFirst) * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kFlushCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
EXPECT_EQ(handed[0], 800 + static_cast<GLint>(kFlushFirst));
EXPECT_EQ(handed[1], 800 + static_cast<GLint>(kFlushFirst + 1));
EXPECT_EQ(mock.flushCalls, 0);
// The unmap of a FLUSH_EXPLICIT map adds nothing.
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The CTS idiom end to end through the GL entry points: an SSBO made resident by a
// draw, re-seeded with glMapBufferRange(READ|WRITE) + glUnmapBuffer.
TEST_F(BufferTest, MapBufferRangeAndUnmapBufferReseedAnAdoptedShaderStorageBuffer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
for (GLint pass = 1; pass <= 3; ++pass) {
auto* mapped = static_cast<GLint*>(
MapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)),
GL_MAP_READ_BIT | GL_MAP_WRITE_BIT));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = pass * 100 + static_cast<GLint>(i);
EXPECT_TRUE(UnmapBuffer(GL_SHADER_STORAGE_BUFFER));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], pass * 100 + static_cast<GLint>(i)) << "pass " << pass << " int " << i;
}
}
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The control: a store the backend declined to adopt keeps the shadow model exactly as
// before - the staging copy is written back into the shadow and the backend's flush op
// carries the range down.
TEST_F(BufferTest, ANonPersistentWriteMapOfAShadowBackedStoreStillFlushesThroughTheBackend) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
mock.flushCalls = 0;
mock.subDataCalls = 0;
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 600 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.flushCalls, 1);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
const auto* shadow = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 600 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(shadow[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// The other three CPU-sourced writes that share the unmap landing's route into an
// adopted store - glBufferSubData, a clear, and a copy - on both kinds of backend: the
// one that lets the frontend write the coherent mapping in place, and the one that takes
// the bytes for a GPU-ordered landing, where the offset it is handed is the only thing
// deciding where they end up.
TEST_F(BufferTest, GlBufferSubDataIntoAnAdoptedStoreLandsInPlaceWithoutABackendTransfer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAGlBufferSubDataAtItsOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), sizeof(updated));
EXPECT_EQ(std::memcmp(mock.residentWrites[0].bytes.data(), updated, sizeof(updated)), 0);
// The mapping itself is left alone until the backend's ordered copy runs.
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlClearBufferSubDataRepeatsItsPatternThroughAnAdoptedStoreInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// A four-byte pattern, so the repeat - not a memset - is what fills the range.
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheExpandedClearPattern) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// The backend takes the FINAL bytes, so the pattern arrives already repeated.
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], value) << "int " << i;
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
namespace {
// A plain (never adopted) buffer of kAdoptedInts ints, each `bias` above its index,
// bound to `target` as the source of a copy.
GLuint MakeCopySource(GLenum target, GLint bias) {
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> bytes(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) bytes[i] = bias + static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), bytes.data(), GL_STATIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
return buffer;
}
} // namespace
TEST_F(BufferTest, GlCopyBufferSubDataIntoAnAdoptedStoreLandsAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
// Deliberately different source and destination offsets: only the destination one
// may decide where the bytes land.
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kDstFirst && i < kDstFirst + kCount)
? 900 + static_cast<GLint>(kSrcFirst + i - kDstFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesACopyAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kDstFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(handed[i], 900 + static_cast<GLint>(kSrcFirst + i)) << "int " << i;
}
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
// A PERSISTENT map of an adopted store is the one write shape that needs no landing at
// all: it wrote the coherent mapping in place. Its explicit flush therefore publishes the
// change and dispatches nothing - not the backend's flush op (whose upload would be the
// mapping onto itself) and not the resident landing op (whose bytes are already there).
TEST_F(BufferTest, AnExplicitFlushOfAPersistentMapOfAnAdoptedStoreOnlyPublishesTheChange) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
// The application writes the store itself: the map IS the adopted memory.
EXPECT_EQ(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data() + range.start));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 400 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_TRUE(bufferObject->HasDefinedContent());
// Every byte the map wrote is in the store, flushed or not - it was written there.
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(GpuInts(mock)[kFirst + i], 400 + static_cast<GLint>(i)) << "int " << i;
}
const Uint64 flushSerial = bufferObject->GetChangeSerial();
bufferObject->ReleaseMemory();
EXPECT_EQ(bufferObject->GetChangeSerial(), flushSerial); // the unmap adds nothing
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A flush of nothing wrote no byte, so it may not report the store as written: an
// orphaning respecification prices a "has content" store as a full-size upload.
TEST_F(BufferTest, AZeroLengthExplicitFlushOfAnAdoptedStoreLeavesItUndefined) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), nullptr,
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
ASSERT_FALSE(bufferObject->HasDefinedContent());
ASSERT_TRUE(bufferObject->EnsureGpuResidentStorage());
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)},
BufferMappingAccessBit::Write |
BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit);
ASSERT_NE(mapped, nullptr);
bufferObject->FlushMemoryRange(0, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_FALSE(bufferObject->HasDefinedContent());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A write map that discards the range it maps reads nothing of the store: its staging
// copy is not seeded from it. Reconciling an adopted store at map time would run the
// backend's drain-and-wait for no reader, once per map, on the streaming arena the
// adoption exists to keep cheap - so it is deferred, not dropped: the first map that DOES
// read the bytes still pays for it, and every queued landing is still applied, in order.
TEST_F(BufferTest, AWriteMapThatDiscardsWhatItMapsDoesNotReconcileAnAdoptedStoreAtMapTime) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// An earlier write is queued for its GPU-ordered landing...
const GLint firstInt = 55;
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the map that discards its range does not wait for it.
constexpr SizeT kFirst = 8;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateRange));
ASSERT_NE(mapped, nullptr);
EXPECT_EQ(mock.readbackCalls, 0);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 300 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
ASSERT_EQ(mock.residentWrites.size(), 2u);
// The first read reconciles both landings, oldest first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_EQ(readBack[0], firstInt);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(readBack[kFirst + i], 300 + static_cast<GLint>(i)) << "int " << i;
// The control: a map that keeps what it maps still reconciles before seeding.
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto* seeded = static_cast<GLint*>(
bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(seeded, nullptr);
EXPECT_EQ(mock.readbackCalls, 2);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(seeded[i], 300 + static_cast<GLint>(i)) << "int " << i;
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Adoption releases the CPU shadow, and a persistent map that did not itself adopt
// (FLUSH_EXPLICIT is excluded from adoption) handed the application a pointer into that
// shadow which GL keeps valid while the buffer is drawn with - which is exactly when a
// storage binding asks for residency. So a mapped buffer keeps the shadow model.
TEST_F(BufferTest, AStorageBindingDoesNotAdoptTheStoreWhileTheApplicationHoldsAMapping) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
const auto* shadowBase = bufferObject->MappedData();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(static_cast<const void*>(mapped), static_cast<const void*>(shadowBase + range.start));
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.acquireMapCalls, 0);
// The application's pointer is still the store's: it survived the binding.
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(shadowBase));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 250 + static_cast<GLint>(i);
bufferObject->FlushMemoryRange(0, kCount * sizeof(GLint));
EXPECT_EQ(mock.flushCalls, 1);
const auto* shadowInts = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(shadowInts[kFirst + i], 250 + static_cast<GLint>(i));
// Unmapped, the next binding adopts as usual.
bufferObject->ReleaseMemory();
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Respecifying a store hands any adoption back and replaces the bytes, so the staged
// bytes of a map that is still live have nowhere to land: copying a whole mapped range
// into storage that is released on the next line is pure waste.
TEST_F(BufferTest, RespecifyingAStoreWhileItIsMappedDoesNotLandTheStagedBytesIntoIt) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, RespecifyingAShadowBackedStoreWhileItIsMappedPushesNoRangeDown) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_ARRAY_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts, 7);
BufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
mock.flushCalls = 0;
mock.subDataCalls = 0;
mock.respecifyCalls = 0;
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
+9
View File
@@ -64,6 +64,15 @@ set(LINK_LIBRARIES
include(GoogleTest)
gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
if (MSVC)
# The GL headers declare gl* as dllimport on Windows, so any test that pulls
# GetProcAddress.cpp out of the static library references __imp_gl*, which only
# resolves when the in-library entry-point definitions are part of the link.
# Applies to every test executable below, the way the DirectVulkan and
# integration test targets already do it for themselves.
add_link_options(/WHOLEARCHIVE:MobileGL_s)
endif()
add_subdirectory(BackendLoader)
add_subdirectory(Buffer)
# The heap-address-is-not-an-identity invariant the backends' per-object memos
+11
View File
@@ -23,6 +23,17 @@ target_link_libraries(
${LINK_LIBRARIES}
)
if (MSVC)
# This test compiles library sources of its own; pulling the whole static
# library in as well (the directory-wide MG_Test link option) would define
# them twice, so that option is dropped for this one target.
get_target_property(_program_util_link_options ProgramUtilTest LINK_OPTIONS)
if (_program_util_link_options)
list(REMOVE_ITEM _program_util_link_options /WHOLEARCHIVE:MobileGL_s)
set_target_properties(ProgramUtilTest PROPERTIES LINK_OPTIONS "${_program_util_link_options}")
endif()
endif()
add_executable(
ProgramTest
ProgramTest.cpp
+17
View File
@@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
add_executable(
DriverBugProbesTest
DriverBugProbesTest.cpp
PersistentBufferOrderingProbeTest.cpp
)
target_include_directories(DriverBugProbesTest PRIVATE
@@ -30,6 +31,22 @@ target_link_libraries(DriverBugProbesTest PRIVATE
${LINK_LIBRARIES}
)
add_executable(
PrimitivesGeneratedNoXfbProbeTest
PrimitivesGeneratedNoXfbProbeTest.cpp
)
target_include_directories(PrimitivesGeneratedNoXfbProbeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(PrimitivesGeneratedNoXfbProbeTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(DriverPostIterationRPWitnessTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(DriverBugProbesTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(PrimitivesGeneratedNoXfbProbeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,326 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.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 <gtest/gtest.h>
#include <MG_Util/SelfTest/PersistentBufferOrderingProbe.h>
#include <map>
#include <set>
using namespace MobileGL;
using namespace MobileGL::MG_Util::SelfTest;
namespace {
// Deferred vertex fetch, not canned ReadPixels answers: a broken mapped destination
// reads its current bytes at Finish instead of the bytes at DrawArrays. ReadPixels also
// drains these jobs, so inserting an early readback into the probe hides the bug here too.
struct FakeDriver {
struct Buffer {
Bool mapped = false;
Bool arena = false;
Bool copied = false;
Int channel = 0;
Vector<Uint8> staging;
};
struct Draw { GLuint fbo, buffer; Int channel; Bool late; };
std::map<GLuint, Buffer> buffers;
std::map<GLuint, GLuint> vaoBuffers;
std::map<GLuint, Int> colors;
Vector<Draw> draws;
std::set<GLuint> live;
std::map<GLenum, GLint> state = {
{GL_CURRENT_PROGRAM, 1}, {GL_VERTEX_ARRAY_BINDING, 2}, {GL_ARRAY_BUFFER, 3},
{GL_COPY_READ_BUFFER, 4}, {GL_COPY_WRITE_BUFFER, 5},
{GL_DRAW_FRAMEBUFFER_BINDING, 6}, {GL_READ_FRAMEBUFFER_BINDING, 7},
{GL_TEXTURE_BINDING_2D, 8}, {GL_PIXEL_PACK_BUFFER, 9},
{GL_PACK_ALIGNMENT, 8}, {GL_PACK_ROW_LENGTH, 31},
{GL_PACK_SKIP_PIXELS, 4}, {GL_PACK_SKIP_ROWS, 5}};
std::map<GLenum, GLboolean> enabled = {{GL_BLEND, GL_TRUE}, {GL_SCISSOR_TEST, GL_TRUE},
{GL_SAMPLE_MASK, GL_TRUE}, {GL_RASTERIZER_DISCARD, GL_TRUE}};
std::array<GLint, 4> viewport = {3, 4, 5, 6};
std::array<GLfloat, 4> clear = {.25f, .5f, .75f, 0};
std::array<GLboolean, 4> mask = {GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE};
GLuint next = 100;
GLenum error = GL_NO_ERROR;
Bool extension = true, corruptSubData = false, corruptCopy = false;
Bool corruptUnmapped = false, corruptSerialized = false;
Bool failMap = false, failAllocation = false, failFramebuffer = false;
Int failReadbackAt = 0, readbacks = 0;
Uint arenaAllocations = 0;
Uint8 pointerSentinel = 0;
GLuint Create() { live.insert(next); return next++; }
void Generate(GLsizei count, GLuint* ids) { for (Int i = 0; i < count; ++i) ids[i] = Create(); }
void Delete(GLsizei count, const GLuint* ids) {
for (Int i = 0; i < count; ++i) {
live.erase(ids[i]);
buffers.erase(ids[i]);
}
}
GLint Get(GLenum name) const {
const auto found = state.find(name);
return found == state.end() ? 0 : found->second;
}
static Int Channel(const void* data) {
GLfloat color[3];
std::memcpy(color, static_cast<const Uint8*>(data) + 2 * sizeof(GLfloat), sizeof(color));
return color[0] > .5f ? 0 : color[1] > .5f ? 1 : 2;
}
void Finish() {
for (const auto& draw : draws) {
const auto& buffer = buffers.at(draw.buffer);
colors[draw.fbo] = draw.late ? buffer.channel : draw.channel;
if (buffer.mapped && corruptSerialized) colors[draw.fbo] = (draw.channel + 1) % 3;
}
draws.clear();
}
} driver;
MG_External::GLESFunctionsTable Table() {
MG_External::GLESFunctionsTable gl{};
gl.glGetIntegerv = [](GLenum name, GLint* out) {
if (name == GL_MAJOR_VERSION) *out = 3;
else if (name == GL_MINOR_VERSION) *out = 2;
else if (name == GL_NUM_EXTENSIONS) *out = driver.extension ? 1 : 0;
else if (name == GL_VIEWPORT) std::copy(driver.viewport.begin(), driver.viewport.end(), out);
else if (name == GL_ARRAY_BUFFER_BINDING) *out = driver.Get(GL_ARRAY_BUFFER);
else if (name == GL_PIXEL_PACK_BUFFER_BINDING) *out = driver.Get(GL_PIXEL_PACK_BUFFER);
else *out = driver.Get(name);
};
gl.glGetBooleanv = [](GLenum, GLboolean* out) { std::copy(driver.mask.begin(), driver.mask.end(), out); };
gl.glGetFloatv = [](GLenum, GLfloat* out) { std::copy(driver.clear.begin(), driver.clear.end(), out); };
gl.glGetStringi = [](GLenum, GLuint) { return reinterpret_cast<const GLubyte*>("GL_EXT_buffer_storage"); };
gl.glGetError = []() { return std::exchange(driver.error, GL_NO_ERROR); };
gl.glIsEnabled = [](GLenum name) -> GLboolean { return driver.enabled[name]; };
gl.glEnable = [](GLenum name) { driver.enabled[name] = GL_TRUE; };
gl.glDisable = [](GLenum name) { driver.enabled[name] = GL_FALSE; };
gl.glCreateShader = [](GLenum) { return driver.Create(); };
gl.glShaderSource = [](GLuint, GLsizei, const GLchar* const*, const GLint*) {};
gl.glCompileShader = [](GLuint) {};
gl.glGetShaderiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetShaderInfoLog = [](GLuint, GLsizei, GLsizei*, GLchar* out) { *out = 0; };
gl.glDeleteShader = [](GLuint id) { driver.Delete(1, &id); };
gl.glCreateProgram = []() { return driver.Create(); };
gl.glAttachShader = [](GLuint, GLuint) {};
gl.glLinkProgram = [](GLuint) {};
gl.glGetProgramiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetProgramInfoLog = gl.glGetShaderInfoLog;
gl.glDeleteProgram = gl.glDeleteShader;
gl.glUseProgram = [](GLuint id) { driver.state[GL_CURRENT_PROGRAM] = id; };
gl.glGenBuffers = [](GLsizei count, GLuint* ids) { driver.Generate(count, ids); };
gl.glBindBuffer = [](GLenum target, GLuint id) { driver.state[target] = id; };
gl.glBufferStorageEXT = [](GLenum target, GLsizeiptr size, const void*, GLbitfield) {
auto& buffer = driver.buffers[driver.Get(target)];
buffer.arena = size >= 16 * 1024 * 1024;
if (buffer.arena) ++driver.arenaAllocations;
if (driver.failAllocation) driver.error = GL_OUT_OF_MEMORY;
if (!buffer.arena) buffer.staging.resize(size);
};
gl.glBufferData = [](GLenum target, GLsizeiptr size, const void*, GLenum) {
driver.buffers[driver.Get(target)].staging.resize(size);
};
gl.glMapBufferRange = [](GLenum target, GLintptr offset, GLsizeiptr, GLbitfield) -> void* {
if (driver.failMap) return nullptr;
auto& buffer = driver.buffers[driver.Get(target)];
buffer.mapped = true;
return buffer.arena ? &driver.pointerSentinel : buffer.staging.data() + offset;
};
gl.glUnmapBuffer = [](GLenum) -> GLboolean { return GL_TRUE; }; // Preserve allocation history.
gl.glBufferSubData = [](GLenum target, GLintptr offset, GLsizeiptr size, const void* data) {
auto& buffer = driver.buffers[driver.Get(target)];
if (buffer.arena) {
buffer.channel = FakeDriver::Channel(data);
buffer.copied = false;
} else std::memcpy(buffer.staging.data() + offset, data, size);
};
gl.glCopyBufferSubData = [](GLenum read, GLenum write, GLintptr offset, GLintptr, GLsizeiptr) {
auto& source = driver.buffers[driver.Get(read)];
auto& dest = driver.buffers[driver.Get(write)];
dest.channel = FakeDriver::Channel(source.staging.data() + offset);
dest.copied = true;
};
gl.glDeleteBuffers = [](GLsizei count, const GLuint* ids) { driver.Delete(count, ids); };
gl.glGenVertexArrays = gl.glGenBuffers;
gl.glBindVertexArray = [](GLuint id) { driver.state[GL_VERTEX_ARRAY_BINDING] = id; };
gl.glVertexAttribPointer = [](GLuint, GLint, GLenum, GLboolean, GLsizei, const void*) {
driver.vaoBuffers[driver.Get(GL_VERTEX_ARRAY_BINDING)] = driver.Get(GL_ARRAY_BUFFER);
};
gl.glEnableVertexAttribArray = [](GLuint) {};
gl.glDeleteVertexArrays = gl.glDeleteBuffers;
gl.glGenTextures = gl.glGenBuffers;
gl.glBindTexture = [](GLenum, GLuint id) { driver.state[GL_TEXTURE_BINDING_2D] = id; };
gl.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {};
gl.glDeleteTextures = gl.glDeleteBuffers;
gl.glGenFramebuffers = gl.glGenBuffers;
gl.glBindFramebuffer = [](GLenum target, GLuint id) {
if (target != GL_READ_FRAMEBUFFER) driver.state[GL_DRAW_FRAMEBUFFER_BINDING] = id;
if (target != GL_DRAW_FRAMEBUFFER) driver.state[GL_READ_FRAMEBUFFER_BINDING] = id;
};
gl.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
gl.glCheckFramebufferStatus = [](GLenum) -> GLenum {
return driver.failFramebuffer ? GL_FRAMEBUFFER_UNSUPPORTED : GL_FRAMEBUFFER_COMPLETE;
};
gl.glDeleteFramebuffers = gl.glDeleteBuffers;
gl.glViewport = [](GLint x, GLint y, GLsizei w, GLsizei h) { driver.viewport = {x, y, w, h}; };
gl.glColorMask = [](GLboolean r, GLboolean g, GLboolean b, GLboolean a) { driver.mask = {r, g, b, a}; };
gl.glClearColor = [](GLfloat r, GLfloat g, GLfloat b, GLfloat a) { driver.clear = {r, g, b, a}; };
gl.glClear = [](GLbitfield) {};
gl.glDrawArrays = [](GLenum, GLint, GLsizei) {
const GLuint id = driver.vaoBuffers.at(driver.Get(GL_VERTEX_ARRAY_BINDING));
const auto& buffer = driver.buffers.at(id);
const Bool late = buffer.mapped ? (buffer.copied ? driver.corruptCopy : driver.corruptSubData)
: driver.corruptUnmapped;
driver.draws.push_back({GLuint(driver.Get(GL_DRAW_FRAMEBUFFER_BINDING)), id, buffer.channel, late});
};
gl.glFinish = []() { driver.Finish(); };
gl.glMemoryBarrier = [](GLbitfield) {};
gl.glPixelStorei = [](GLenum name, GLint value) { driver.state[name] = value; };
gl.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum, GLenum, void* data) {
driver.Finish(); // Models the implicit wait that must NOT occur between subject draws.
if (++driver.readbacks == driver.failReadbackAt) {
driver.error = GL_INVALID_OPERATION;
return;
}
EXPECT_EQ(driver.Get(GL_PIXEL_PACK_BUFFER), 0);
EXPECT_EQ(driver.Get(GL_PACK_ROW_LENGTH), 0);
const Int channel = driver.colors.at(driver.Get(GL_READ_FRAMEBUFFER_BINDING));
auto* pixels = static_cast<Uint8*>(data);
for (Int i = 0; i < width * height; ++i)
for (Int c = 0; c < 4; ++c) pixels[4 * i + c] = c == channel || c == 3 ? 255 : 0;
};
return gl;
}
class PersistentBufferOrderingProbeTest : public ::testing::Test {
protected:
void SetUp() override { driver = FakeDriver{}; }
void TearDown() override { EXPECT_TRUE(driver.live.empty()); EXPECT_TRUE(driver.draws.empty()); }
};
}
TEST_F(PersistentBufferOrderingProbeTest, RequiresExtensionAndCompleteDispatchBeforeAllocating) {
auto gl = Table();
driver.extension = false;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
driver.extension = true;
gl.glCopyBufferSubData = nullptr;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
EXPECT_EQ(driver.arenaAllocations, 0u);
}
TEST_F(PersistentBufferOrderingProbeTest, OrderedDriverPassesAllUploadsAndRestoresCallerState) {
const auto saved = driver;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
ASSERT_TRUE(measurement.supported);
for (const auto& row : measurement.uploads) {
EXPECT_TRUE(row.unmapped.Passed());
EXPECT_TRUE(row.mapped.Passed());
EXPECT_EQ(row.mapped.frames, 240u); // Three fresh attempts before a negative result.
EXPECT_EQ(row.finishBoth.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved.state);
EXPECT_EQ(driver.viewport, saved.viewport);
EXPECT_EQ(driver.clear, saved.clear);
EXPECT_EQ(driver.mask, saved.mask);
for (const auto& [cap, value] : driver.enabled) {
const auto found = saved.enabled.find(cap);
EXPECT_EQ(value, found == saved.enabled.end() ? GL_FALSE : found->second);
}
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredMappedSubDataFetchIsDetectedWithPassingControls) {
driver.corruptSubData = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_TRUE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[0].finishBefore.Passed());
EXPECT_GT(measurement.uploads[0].mapThenUnmap.badFrames, 0u);
EXPECT_GT(measurement.uploads[0].barrierBefore.badFrames, 0u);
EXPECT_FALSE(measurement.uploads[1].Detected());
EXPECT_FALSE(measurement.uploads[2].Detected());
const auto finding = DescribePersistentBufferOrderingBug(measurement);
ASSERT_TRUE(finding);
EXPECT_EQ(finding->verdict, DriverBugVerdict::Unfixable);
EXPECT_NE(finding->detail.find("SubData:"), String::npos);
EXPECT_NE(finding->detail.find("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1"), String::npos);
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredCopyFetchIsDetectedWithBothStagingSources) {
driver.corruptCopy = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[1].Detected());
EXPECT_TRUE(measurement.uploads[2].Detected());
}
TEST_F(PersistentBufferOrderingProbeTest, PostCollectorIncludesTheMeasuredFinding) {
driver.corruptSubData = true;
const auto findings = CollectGlesKnownDriverBugs(Table());
const auto found = std::find_if(findings.begin(), findings.end(), [](const auto& finding) {
return finding.name == "Persistent-mapped vertex buffers lose upload/draw ordering";
});
ASSERT_NE(found, findings.end());
EXPECT_NE(found->detail.find("never-mapped 0/80"), String::npos);
EXPECT_EQ(found->verdict, DriverBugVerdict::Unfixable);
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptNeverMappedControlCannotAccusePersistentMapping) {
driver.corruptSubData = driver.corruptCopy = driver.corruptUnmapped = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_GT(row.unmapped.badFrames, 0u);
EXPECT_EQ(row.mapped.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptSerializedControlCannotConfirmOrderingDefect) {
driver.corruptSubData = driver.corruptCopy = driver.corruptSerialized = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) EXPECT_GT(row.finishBoth.badFrames, 0u);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, FailedMappingIsInconclusiveAndReleasesResources) {
driver.failMap = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[1].unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, AllocationFailureIsInconclusiveAndRestoresBindings) {
driver.failAllocation = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_EQ(row.unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(row.unmapped.error, GLenum(GL_OUT_OF_MEMORY));
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved);
}
TEST_F(PersistentBufferOrderingProbeTest, ReadbackErrorAfterAMismatchDoesNotProduceAFinding) {
driver.corruptSubData = true;
driver.failReadbackAt = 82; // Eighty clean control readbacks, then one corrupt subject FBO.
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_GT(measurement.uploads[0].mapped.badFrames, 0u);
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[0].mapped.error, GLenum(GL_INVALID_OPERATION));
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, IncompleteFramebufferIsInconclusiveAndRestoresBindings) {
driver.failFramebuffer = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.arenaAllocations, 0u);
EXPECT_EQ(driver.state, saved);
}
@@ -0,0 +1,546 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PrimitivesGeneratedNoXfbProbeTest.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
//
// The primitives-generated-without-transform-feedback probe's VERDICT and ARMING
// logic, pinned over synthetic measurements. Recording the probe for real needs a
// GPU; the two pure functions are where the cheap mistakes live - a verdict that reads a
// half-broken driver as healthy, an override arm swapped so ForceOn disarms, a
// substitute ranked below a worse one - and every driver the campaign has
// characterised is written down here as a fake measurement so the mapping cannot
// drift without a red:
// - a conforming driver (stream counts everywhere),
// - Mesa lavapipe as measured 2026-08: stream silent everywhere, the dedicated
// VK_EXT_primitives_generated_query exact everywhere (discard included), and
// the statistics control exact on the plain shape but dead under rasterizer
// discard (llvmpipe's discard short-circuit),
// - the same driver without the dedicated query - the statistics tiers,
// - a device with the defect and no working substitute,
// - a substitute that would be WORSE than the stream query on some shape (the
// never-worse rule the plain-only arm has to prove before it may arm),
// - and the refuse-to-guess shapes (half counts, missing mandatory shapes).
//
// The last section pins the probe's TEARDOWN CONTRACT instead, driving the real
// RunPrimitivesGeneratedNoXfbProbe against a fake Vulkan driver whose fence wait
// can be made to expire: no GPU is needed for that, only the entry points the
// probe is handed, and what it does on that path is what keeps a hung driver from
// hanging the POST.
#include <gtest/gtest.h>
#include <cstdint>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
using MobileGL::Bool;
using MobileGL::Uint32;
using MobileGL::Uint64;
using MobileGL::MG_Config::QuirkOverride;
using MobileGL::MG_Util::SelfTest::EvaluatePrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::ChoosePrimitivesGeneratedReroute;
using MobileGL::MG_Util::SelfTest::PrimGenRerouteKind;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbProbeContext;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::RunPrimitivesGeneratedNoXfbProbe;
namespace {
struct ShapeAnswers {
Uint64 stream = 0;
// Negative-free encoding: measured flags separate from values.
Bool pgqMeasured = false;
Uint64 pgq = 0;
Bool statMeasured = false;
Uint64 stat = 0;
};
PrimitivesGeneratedNoXfbShapeMeasurement Shape(const ShapeAnswers& answers) {
PrimitivesGeneratedNoXfbShapeMeasurement shape;
shape.drawn = true;
shape.expectedPrimitives = 1;
shape.streamGenerated = answers.stream;
shape.primitivesGeneratedExtMeasured = answers.pgqMeasured;
shape.primitivesGeneratedExt = answers.pgq;
shape.statisticsMeasured = answers.statMeasured;
shape.statisticsClippingInput = answers.stat;
return shape;
}
PrimitivesGeneratedNoXfbMeasurement Measurement(PrimitivesGeneratedNoXfbShapeMeasurement plain,
PrimitivesGeneratedNoXfbShapeMeasurement discard,
PrimitivesGeneratedNoXfbShapeMeasurement patches) {
PrimitivesGeneratedNoXfbMeasurement measurement;
measurement.ran = true;
measurement.trianglesPlain = plain;
measurement.trianglesDiscard = discard;
measurement.patchesDiscard = patches;
return measurement;
}
PrimitivesGeneratedNoXfbShapeMeasurement NotDrawn() {
return PrimitivesGeneratedNoXfbShapeMeasurement{};
}
constexpr ShapeAnswers kHealthy{1, true, 1, true, 1};
// The lavapipe measurement: stream silent, dedicated query exact, statistics
// exact only where nothing is discarded.
constexpr ShapeAnswers kLavapipePlain{0, true, 1, true, 1};
constexpr ShapeAnswers kLavapipeDiscard{0, true, 1, true, 0};
} // namespace
// A conforming driver: the stream query counts every capture-less shape exactly.
// Controls agreeing changes nothing - health is decided by the subject.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AConformingDriverReadsStreamCounts) {
const auto measurement = Measurement(Shape(kHealthy), Shape(kHealthy), Shape(kHealthy));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// ...and stays healthy with no tessellation stage to draw the patches shape with,
// and with no control at all - a control is only required to QUALIFY a
// substitute, never to certify health.
TEST(PrimitivesGeneratedNoXfbVerdictTest, HealthNeedsNeitherTessellationNorAControl) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape(kHealthy), Shape(kHealthy), NotDrawn())),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({1}), Shape({1}), Shape({1}))),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// Mesa lavapipe as measured (2026-08): stream silent for every capture-less
// draw, the dedicated primitives-generated query exact on every shape (discard
// included), the statistics control dead under discard. The dedicated query must
// win - it is the only substitute that covers the CTS shape there.
TEST(PrimitivesGeneratedNoXfbVerdictTest, LavapipeShapedMeasurementTakesTheDedicatedQuery) {
const auto measurement =
Measurement(Shape(kLavapipePlain), Shape(kLavapipeDiscard), Shape(kLavapipeDiscard));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// The affected-device hypothesis with no dedicated query: statistics exact on
// every shape, the CTS's discarded shapes included.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsExactEverywhereIsTheFullStatisticsSubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// A dedicated query that is silent in the same way the stream query is must not
// be armed - the statistics tier decides instead.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASilentDedicatedQueryFallsThroughToStatistics) {
const auto measurement = Measurement(Shape({0, true, 0, true, 1}), Shape({0, true, 0, true, 1}),
Shape({0, true, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// The llvmpipe statistics hole without the dedicated query to rescue it: exact on
// the plain shape, dead under discard. Repairs undiscarded queries only, and the
// verdict must say so.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsDeadUnderDiscardIsThePlainOnlySubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// THE DOMINATION RULE. The plain-only substitute is armed for EVERY XFB-inactive
// draw, so it may only be armed where it is never worse than what it replaces:
// each shape it gets wrong must be one the stream query already answered 0 for.
// Here the discarded triangle is one the stream query answers EXACTLY (a driver
// whose silence is selective) and whose statistics read 0 - rerouting would turn
// that correct 1 into a 0, so the honest verdict is that nothing may be armed.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASubstituteWorseThanTheStreamOnAnyShapeIsRefused) {
const auto measurement = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// The same shape with the statistics slot MISSING on the stream-exact shape is
// the same trade: an unmeasured control cannot be assumed to answer.
const auto unmeasured = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, false, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(unmeasured),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// ...while the same selective silence WITH a substitute that covers the shapes
// it must still qualifies: every shape the statistics miss read 0 anyway.
const auto dominating = Measurement(Shape({1, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(dominating),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// The defect with no substitute: no control, controls silent, or a control that
// OVERCOUNTS the plain shape (as disqualifying as one that reads 0 - an exact
// match is what qualifies a substitute).
TEST(PrimitivesGeneratedNoXfbVerdictTest, StreamSilentWithoutAWorkingPlainControlIsUnfixable) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0}), Shape({0}), Shape({0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 0, true, 0}), Shape({0, true, 0, true, 0}),
Shape({0, true, 0, true, 0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 2, true, 2}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
}
// Refuse-to-guess shapes. A nonzero-but-wrong stream answer fits neither the
// defect (exact silence) nor health (the exact count), whichever shape carries
// it; and a probe that never ran, or lost its mandatory shapes, says nothing.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AnswersFittingNeitherHealthNorTheDefectAreInconclusive) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({2, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 1, true, 1}), Shape({3, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
PrimitivesGeneratedNoXfbMeasurement neverRan;
neverRan.ran = false;
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(neverRan),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
const auto missingMandatoryShape = Measurement(Shape({0, true, 1, true, 1}), NotDrawn(), NotDrawn());
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(missingMandatoryShape),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// A partial silence is still the defect: the plain shape counts but the discarded
// ones read 0 (a driver that gates the stream counter on rasterization rather
// than on the capture). With a whole control the substitute is whole.
TEST(PrimitivesGeneratedNoXfbVerdictTest, SilenceOnOnlyTheDiscardShapesIsStillTheDefect) {
const auto measurement = Measurement(Shape({1, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// ===================== THE OVERRIDE MAPPING =====================
//
// The one-line swap this exists to catch: ForceOn and ForceOff exchanging arms,
// Auto arming on a verdict that never qualified a substitute, or the pool ranking
// inverting. Every cell of the (override x verdict) table is written out.
namespace {
constexpr PrimitivesGeneratedNoXfbVerdict kAllVerdicts[] = {
PrimitivesGeneratedNoXfbVerdict::Inconclusive,
PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
PrimitivesGeneratedNoXfbVerdict::Unfixable,
};
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOffNeverReroutes) {
for (const auto verdict : kAllVerdicts) {
for (const Bool pgqUsable : {false, true}) {
for (const Bool statsUsable : {false, true}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOff, verdict, pgqUsable,
statsUsable),
PrimGenRerouteKind::None);
}
}
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOnBypassesTheVerdictButNeverTheStructuralChecks) {
for (const auto verdict : kAllVerdicts) {
// The dedicated query wins where the device can host it...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, false),
PrimGenRerouteKind::PrimitivesGeneratedExt);
// ...statistics stand in where only they exist...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, true),
PrimGenRerouteKind::ClippingStatistics);
// ...and no pool means no reroute, forced or not.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, false),
PrimGenRerouteKind::None);
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, AutoFollowsExactlyTheSubstituteVerdicts) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, true),
PrimGenRerouteKind::ClippingStatistics);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
false, true),
PrimGenRerouteKind::ClippingStatistics);
// The statistics verdicts never take the dedicated pool: that verdict only
// exists when the dedicated query did NOT qualify.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, true, true),
PrimGenRerouteKind::ClippingStatistics);
for (const auto verdict :
{PrimitivesGeneratedNoXfbVerdict::Inconclusive, PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::Unfixable}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::Auto, verdict, true, true),
PrimGenRerouteKind::None);
}
// The structural checks bind Auto too.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
false, true),
PrimGenRerouteKind::None);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, false),
PrimGenRerouteKind::None);
}
// ===================== THE FENCE-TIMEOUT CONTRACT =====================
//
// A driver whose queue never signals the probe's fence inside 5 s is the one case
// where the probe must NOT clean up: the submission may still be executing, so
// vkDeviceWaitIdle can block forever and destroying in-flight objects is
// undefined. It therefore leaks everything it made and says so in the measurement
// (`fenceWaitTimedOut`), which is what lets its callers make the same choice for
// the object THEY own - the driver POST leaks its throwaway VkDevice instead of
// destroying it under live children (vkDestroyDevice would be the very hang the
// bound exists to prevent), and the renderer, whose device is the real one, must
// not idle-wait it either. Neither guard is reachable from a unit test - the POST
// probe lives in an anonymous namespace and the renderer needs a GPU - so this
// pins the contract they both key on, at the boundary where it is produced.
//
// The fake driver below is the whole Vulkan surface the probe touches, with a
// dialable fence-wait result and per-entry-point call counters.
namespace {
struct FakeDriverState {
VkResult fenceWaitResult = VK_SUCCESS;
Uint32 objectsCreated = 0;
Uint32 destroyCalls = 0;
Uint32 deviceWaitIdleCalls = 0;
Uint32 queueSubmitCalls = 0;
Uint64 streamGenerated = 1;
};
FakeDriverState g_fake;
template <typename Handle>
Handle FakeHandle() {
++g_fake.objectsCreated;
// One cast form for both handle flavours: a pointer on 64-bit builds, a
// uint64_t on 32-bit ones. The probe only ever compares against
// VK_NULL_HANDLE, so any distinct nonzero value will do.
return (Handle)(std::uintptr_t)(0x1000u + g_fake.objectsCreated * 0x10u);
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateCommandPool(VkDevice, const VkCommandPoolCreateInfo*,
const VkAllocationCallbacks*, VkCommandPool* out) {
*out = FakeHandle<VkCommandPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyCommandPool(VkDevice, VkCommandPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeAllocateCommandBuffers(VkDevice, const VkCommandBufferAllocateInfo*,
VkCommandBuffer* out) {
*out = FakeHandle<VkCommandBuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeBeginCommandBuffer(VkCommandBuffer, const VkCommandBufferBeginInfo*) {
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeEndCommandBuffer(VkCommandBuffer) { return VK_SUCCESS; }
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateQueryPool(VkDevice, const VkQueryPoolCreateInfo*,
const VkAllocationCallbacks*, VkQueryPool* out) {
*out = FakeHandle<VkQueryPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyQueryPool(VkDevice, VkQueryPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdResetQueryPool(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQuery(VkCommandBuffer, VkQueryPool, uint32_t, VkQueryControlFlags) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQuery(VkCommandBuffer, VkQueryPool, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t,
VkQueryControlFlags, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateRenderPass(VkDevice, const VkRenderPassCreateInfo*,
const VkAllocationCallbacks*, VkRenderPass* out) {
*out = FakeHandle<VkRenderPass>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyRenderPass(VkDevice, VkRenderPass, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFramebuffer(VkDevice, const VkFramebufferCreateInfo*,
const VkAllocationCallbacks*, VkFramebuffer* out) {
*out = FakeHandle<VkFramebuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFramebuffer(VkDevice, VkFramebuffer, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginRenderPass(VkCommandBuffer, const VkRenderPassBeginInfo*,
VkSubpassContents) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndRenderPass(VkCommandBuffer) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateShaderModule(VkDevice, const VkShaderModuleCreateInfo*,
const VkAllocationCallbacks*, VkShaderModule* out) {
*out = FakeHandle<VkShaderModule>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyShaderModule(VkDevice, VkShaderModule, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreatePipelineLayout(VkDevice, const VkPipelineLayoutCreateInfo*,
const VkAllocationCallbacks*,
VkPipelineLayout* out) {
*out = FakeHandle<VkPipelineLayout>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipelineLayout(VkDevice, VkPipelineLayout,
const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateGraphicsPipelines(VkDevice, VkPipelineCache, uint32_t count,
const VkGraphicsPipelineCreateInfo*,
const VkAllocationCallbacks*, VkPipeline* out) {
for (uint32_t i = 0; i < count; ++i) {
out[i] = FakeHandle<VkPipeline>();
}
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipeline(VkDevice, VkPipeline, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBindPipeline(VkCommandBuffer, VkPipelineBindPoint, VkPipeline) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdDraw(VkCommandBuffer, uint32_t, uint32_t, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFence(VkDevice, const VkFenceCreateInfo*,
const VkAllocationCallbacks*, VkFence* out) {
*out = FakeHandle<VkFence>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFence(VkDevice, VkFence, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeQueueSubmit(VkQueue, uint32_t, const VkSubmitInfo*, VkFence) {
++g_fake.queueSubmitCalls;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeWaitForFences(VkDevice, uint32_t, const VkFence*, VkBool32, uint64_t) {
return g_fake.fenceWaitResult;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeGetQueryPoolResults(VkDevice, VkQueryPool, uint32_t, uint32_t,
size_t dataSize, void* data, VkDeviceSize,
VkQueryResultFlags) {
// The stream pool's {primitivesWritten, primitivesNeeded} pair; the probe
// reads primitivesNeeded, and this fake device counts capture-less draws.
if (data == nullptr || dataSize < 2 * sizeof(Uint64)) {
return VK_INCOMPLETE;
}
auto* pair = static_cast<Uint64*>(data);
pair[0] = 0;
pair[1] = g_fake.streamGenerated;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeDeviceWaitIdle(VkDevice) {
++g_fake.deviceWaitIdleCalls;
return VK_SUCCESS;
}
PrimitivesGeneratedNoXfbProbeContext FakeProbeContext() {
g_fake = FakeDriverState{};
PrimitivesGeneratedNoXfbProbeContext context;
context.device = (VkDevice)(std::uintptr_t)0xD0D0;
context.queue = (VkQueue)(std::uintptr_t)0xC0C0;
context.transformFeedbackQueriesUsable = true;
// No controls and no tessellation: this fixture is about the teardown
// contract, and the fewer optional slots the fewer moving parts.
auto& fns = context.fns;
fns.vkCreateCommandPool = FakeCreateCommandPool;
fns.vkDestroyCommandPool = FakeDestroyCommandPool;
fns.vkAllocateCommandBuffers = FakeAllocateCommandBuffers;
fns.vkBeginCommandBuffer = FakeBeginCommandBuffer;
fns.vkEndCommandBuffer = FakeEndCommandBuffer;
fns.vkCreateQueryPool = FakeCreateQueryPool;
fns.vkDestroyQueryPool = FakeDestroyQueryPool;
fns.vkCmdResetQueryPool = FakeCmdResetQueryPool;
fns.vkCmdBeginQuery = FakeCmdBeginQuery;
fns.vkCmdEndQuery = FakeCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = FakeCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = FakeCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = FakeCreateRenderPass;
fns.vkDestroyRenderPass = FakeDestroyRenderPass;
fns.vkCreateFramebuffer = FakeCreateFramebuffer;
fns.vkDestroyFramebuffer = FakeDestroyFramebuffer;
fns.vkCmdBeginRenderPass = FakeCmdBeginRenderPass;
fns.vkCmdEndRenderPass = FakeCmdEndRenderPass;
fns.vkCreateShaderModule = FakeCreateShaderModule;
fns.vkDestroyShaderModule = FakeDestroyShaderModule;
fns.vkCreatePipelineLayout = FakeCreatePipelineLayout;
fns.vkDestroyPipelineLayout = FakeDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = FakeCreateGraphicsPipelines;
fns.vkDestroyPipeline = FakeDestroyPipeline;
fns.vkCmdBindPipeline = FakeCmdBindPipeline;
fns.vkCmdDraw = FakeCmdDraw;
fns.vkCreateFence = FakeCreateFence;
fns.vkDestroyFence = FakeDestroyFence;
fns.vkQueueSubmit = FakeQueueSubmit;
fns.vkWaitForFences = FakeWaitForFences;
fns.vkGetQueryPoolResults = FakeGetQueryPoolResults;
fns.vkDeviceWaitIdle = FakeDeviceWaitIdle;
return context;
}
} // namespace
// The hung driver. Nothing the probe created may be destroyed, the device may not
// be idle-waited, and the measurement must SAY the wait timed out - a caller that
// owns the device reads that flag to leak it too, and `ran == false` alone cannot
// tell this apart from an ordinary setup failure (where teardown already ran and
// destroying the device is correct).
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, AFenceTimeoutLeaksEverythingAndReportsItself) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_TIMEOUT;
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_FALSE(measurement.ran);
EXPECT_TRUE(measurement.fenceWaitTimedOut)
<< "without this flag the POST destroys its throwaway VkDevice while the probe's children "
"are alive and its submission may still be executing";
EXPECT_GT(g_fake.queueSubmitCalls, 0u) << "the timeout must be the SUBMITTED probe's, not a setup failure";
EXPECT_EQ(g_fake.destroyCalls, 0u)
<< "a probe that timed out must destroy nothing: the submission may still be executing";
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 0u)
<< "vkDeviceWaitIdle on a queue that missed a 5 s deadline is the hang the bound exists to "
"prevent";
// The verdict must not read a timed-out probe as anything but "no verdict".
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// The control: a driver that signals normally gets the ordinary teardown - idle
// wait, every object destroyed, no timeout flag - so the case above is testing the
// timeout branch and not a probe that never cleans up at all.
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, ASignalledFenceTearsDownNormally) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_SUCCESS;
g_fake.streamGenerated = 1; // healthy: the capture-less draws are counted
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_TRUE(measurement.ran) << measurement.failureReason;
EXPECT_FALSE(measurement.fenceWaitTimedOut);
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 1u);
EXPECT_GT(g_fake.destroyCalls, 0u);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
@@ -18,6 +18,7 @@ add_executable(
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
DemotePointSizeTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,720 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DemotePointSizeTest.cpp
// Copyright (c) 2025-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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include "spirv-tools/libspirv.hpp"
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
// Compiles and LINKS a whole program, then returns one sanitized module per stage - the
// exact bytes ProgramSpirvTask hands the demotion in production, so every shape assertion
// below is made against what the backends would really receive.
Vector<Vector<Uint32>> CompileProgramToSpirv(const Vector<Pair<GLenum, const char*>>& stages) {
using namespace MG_Util::ShaderTranspiler;
Vector<SharedPtr<glslang::TShader>> shaders;
Vector<GLenum> types;
for (const auto& [stage, source] : stages) {
// sourceStr is a StringView; the literals handed in are static, so the view
// stays valid for the whole compile.
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
shaders.push_back(shaderResult.value());
types.push_back(stage);
}
ProgramAttrib programAttrib{.shaders = shaders};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = types, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult) return {};
Vector<Vector<Uint32>> modules = Move(binaryResult.value());
for (auto& module : modules) {
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
}
return modules;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
String Transpile(const Vector<Uint32>& spirv) {
SpvcSession session(spirv, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? essl.value() : String{};
}
Bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
return tools.Validate(spirv);
}
// The five-stage shape of the KHR-GL4x transform-feedback / tessellation capture bodies:
// the value is WRITTEN in the vertex stage, READ from gl_in and re-written in every stage
// after it, and the rasterized size never matters (the captures run under rasterizer
// discard). This is exactly the class the demotion exists to rescue.
const char* kVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
gl_PointSize = 2.0;
}
)";
const char* kTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_TessCoord.x * gl_in[0].gl_Position + gl_TessCoord.y * gl_in[1].gl_Position +
gl_TessCoord.z * gl_in[2].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* kGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
const char* kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
// A control chain that never touches point size: the demotion must prove it changed
// NOTHING here, byte for byte, because this is the overwhelming majority of programs on
// an affected device.
const char* kPlainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kPlainTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
}
)";
const char* kPlainVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
}
)";
// A control stage that also carries CLIP DISTANCE. SPIRV-Cross force-redeclares the whole
// gl_PerVertex output block for exactly this stage/builtin combination, and prints its
// members from the struct's DECORATIONS rather than from what the module accesses - so a
// demoted module's untouched PointSize member would still reach the driver's ESSL.
const char* kClipDistanceTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// The same clip-distance write and NO point-size access anywhere: the shape a
// successfully demoted module would have been left in. glslang emits the whole
// four-member gl_PerVertex block regardless, which is what makes it the exact
// "declared but unaccessed" state the pass header's premise is about.
const char* kClipDistanceUnusedPointSizeTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// A tessellation evaluation module reaching PointSize through a WHOLE-STRUCT load - the
// one shape the pass must refuse rather than half-rewrite. glslang never emits it, so it
// is assembled by hand.
const char* kWholeStructCopyTessEvalAsm = R"(
OpCapability Tessellation
OpCapability TessellationPointSize
OpMemoryModel Logical GLSL450
OpEntryPoint TessellationEvaluation %main "main" %gl_in %out_block
OpExecutionMode %main Triangles
OpExecutionMode %main SpacingEqual
OpExecutionMode %main VertexOrderCcw
OpMemberDecorate %gl_PerVertex 0 BuiltIn Position
OpMemberDecorate %gl_PerVertex 1 BuiltIn PointSize
OpDecorate %gl_PerVertex Block
%void = OpTypeVoid
%fn_ty = OpTypeFunction %void
%float = OpTypeFloat 32
%v4float = OpTypeVector %float 4
%gl_PerVertex = OpTypeStruct %v4float %float
%uint = OpTypeInt 32 0
%uint_32 = OpConstant %uint 32
%arr = OpTypeArray %gl_PerVertex %uint_32
%ptr_in_arr = OpTypePointer Input %arr
%gl_in = OpVariable %ptr_in_arr Input
%ptr_out_s = OpTypePointer Output %gl_PerVertex
%out_block = OpVariable %ptr_out_s Output
%ptr_in_s = OpTypePointer Input %gl_PerVertex
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%main = OpFunction %void None %fn_ty
%entry = OpLabel
%p = OpAccessChain %ptr_in_s %gl_in %int_0
%v = OpLoad %gl_PerVertex %p
OpStore %out_block %v
OpReturn
OpFunctionEnd
)";
} // namespace
class DemotePointSizeTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
m_validationFailuresBefore = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresBefore)
<< "a demoted module did not survive spirv-val";
}
private:
Uint64 m_validationFailuresBefore = 0;
};
TEST_F(DemotePointSizeTest, DemotesAFiveStageProgramWholesale) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_GEOMETRY_SHADER, kGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 5u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The defect, pinned first: every tessellation/geometry stage really does declare the
// capability the device lacks - the same probe production's declines use.
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// THE PRODUCTION GATE, as the arming guard: after demotion neither decline can arm.
// Magma's refusal and Espryt's missing-extension failure both key off exactly these.
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
for (const auto& module : modules) {
EXPECT_TRUE(Validates(module));
}
// The carrier chain, boundary by boundary. No user varyings, so the shared location is 0.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpName %mg_PointSizeIo0"), String::npos) << vs;
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos)
<< "the vertex stage must mirror its built-in into the carrier:\n"
<< vs;
EXPECT_NE(vs.find("BuiltIn PointSize"), String::npos)
<< "the vertex stage KEEPS its core built-in - only tess/geometry stages demote:\n"
<< vs;
const String tcs = Disassemble(modules[1]);
EXPECT_EQ(tcs.find("OpCapability TessellationPointSize"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo0"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo1"), String::npos) << tcs;
const String tes = Disassemble(modules[2]);
EXPECT_EQ(tes.find("OpCapability TessellationPointSize"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo1"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "with a geometry stage present the evaluation stage feeds the Io2 boundary, not the "
"capture carrier:\n"
<< tes;
const String gs = Disassemble(modules[3]);
EXPECT_EQ(gs.find("OpCapability GeometryPointSize"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeIo2"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 0"), String::npos) << gs;
EXPECT_NE(gs.find("OpStore %mg_PointSizeCapture"), String::npos) << gs;
// The struct keeps its member - declared, decorated, unaccessed - which is the shape a
// point-size-free glslang module already has on every extension-less driver.
EXPECT_NE(tes.find("BuiltIn PointSize"), String::npos) << tes;
// What SPIRV-Cross then prints: no gl_PointSize anywhere in a demoted stage's ESSL (the
// token DirectGLES's extension gate greps for), the carriers in its place. The CONTROL
// stage is transpiled too, and deliberately: it is the one stage SPIRV-Cross can be made
// to redeclare the whole output block for, which is why the clip-distance combination
// declines instead of demoting.
const String tcsEssl = Transpile(modules[1]);
EXPECT_EQ(tcsEssl.find("gl_PointSize"), String::npos) << tcsEssl;
EXPECT_NE(tcsEssl.find("mg_PointSizeIo1"), String::npos) << tcsEssl;
const String tesEssl = Transpile(modules[2]);
EXPECT_EQ(tesEssl.find("gl_PointSize"), String::npos) << tesEssl;
EXPECT_NE(tesEssl.find("mg_PointSizeIo1"), String::npos) << tesEssl;
const String gsEssl = Transpile(modules[3]);
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
// Demotion is idempotent by construction: with the capability gone, a second pass over
// the same modules finds nothing to arm on and must not touch a byte.
Vector<Vector<Uint32>> again = modules;
ShaderCompiler::PointSizeDemotionOutcome secondOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
again, types, true, true, true, secondOutcome, true, true));
EXPECT_FALSE(secondOutcome.demoted);
EXPECT_TRUE(secondOutcome.declineDetail.empty()) << secondOutcome.declineDetail;
EXPECT_EQ(again, modules);
}
TEST_F(DemotePointSizeTest, WithoutAGeometryStageTheEvaluationStageOwnsTheCaptureCarrier) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_NE(tes.find("OpStore %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_EQ(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "no geometry stage, no Io2 boundary:\n"
<< tes;
}
TEST_F(DemotePointSizeTest, AGeometryOnlyProgramReadsTheVertexBoundary) {
const char* geometryReadingVs = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, geometryReadingVs},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, /*demoteTessellation=*/false, /*demoteGeometry=*/true, false, outcome, true,
true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the geometry stage's input boundary is fed by the vertex stage:\n"
<< gs;
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos) << vs;
}
TEST_F(DemotePointSizeTest, TheCarrierLandsPastTheProgramsOwnVaryings) {
const char* vsWithVarying = R"(#version 460 core
out vec4 v_color;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_color = vec4(0.5);
}
)";
const char* gsWithVarying = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
in vec4 v_color[];
out vec4 g_color;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_color = v_color[0];
EmitVertex();
EndPrimitive();
}
)";
const char* fsWithVarying = R"(#version 460 core
in vec4 g_color;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = g_color; }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vsWithVarying},
{GL_GEOMETRY_SHADER, gsWithVarying},
{GL_FRAGMENT_SHADER, fsWithVarying}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_color / g_color occupy location 0, so every carrier must sit at 1 - in every stage,
// because producer and consumer match by location.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 1"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, APointSizeFreeProgramStaysByteIdentical) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kPlainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_TRUE(outcome.declineDetail.empty()) << outcome.declineDetail;
EXPECT_EQ(modules, before);
}
TEST_F(DemotePointSizeTest, AHostingDeviceStaysByteIdentical) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
// Both verdicts say the device hosts the built-in: the un-forced lane's contract.
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, false, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_EQ(modules, before);
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the un-demoted module must still arm the existing declines";
}
TEST_F(DemotePointSizeTest, ACaptureRequestForcesTheCarrierOnANonWritingCaptureStage) {
// The control stage writes point size (arming the demotion); the evaluation stage never
// does - but a by-name capture must still find the carrier declared there, holding
// whatever an unwritten varying holds, exactly as the unwritten built-in would have.
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_TRUE(Validates(modules[2]));
// And the driver-side half of the same contract: the ESSL DirectGLES hands its driver
// has to DECLARE the carrier, because DirectGLES respells the glTransformFeedbackVaryings
// request to that name. A carrier the transpile dropped would take the whole capture set
// down with an ES link error naming a variable the application never wrote.
const String tesEssl = Transpile(modules[2]);
EXPECT_NE(tesEssl.find("mg_PointSizeCapture"), String::npos) << tesEssl;
}
// THE PRODUCTION SHAPE THE FORCED CARRIER EXISTS FOR, and the one the flag's own unit test
// could not reach: the capture stage never WRITES gl_PointSize, it only reads the incoming
// one. The demotion still arms - glslang declares GeometryPointSize on a READ - so the
// built-in leaves the module, and only the capture request can put a carrier back. In
// production that request arrives as ProgramLinkTask::SpirvHandoff::captureRequestsPointSize;
// this is the same value one layer down.
TEST_F(DemotePointSizeTest, AReadOnlyCaptureStageStillDeclaresTheCaptureCarrier) {
const char* readOnlyGeometry = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main() {
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* echoFragment = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(g_echo); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The premise: a stage that only READS the built-in still declares the capability, so the
// device still refuses it and the demotion still arms.
ASSERT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "a geometry stage that only reads gl_in[].gl_PointSize must still declare "
"GeometryPointSize, or this whole class of program was never affected";
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the read still has to reach the vertex stage's mirrored value:\n"
<< gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos)
<< "the capture request must force the carrier even though this stage never writes "
"the built-in; without it DirectGLES respells the capture to a name no stage "
"declares and the whole capture set fails to link:\n"
<< gs;
const String gsEssl = Transpile(modules[1]);
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
// Without the request there is nothing to bind a by-name capture to - which is exactly
// what production did on every link while the request never reached this call.
Vector<Vector<Uint32>> unrequested = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(unrequested.size(), 3u);
ShaderCompiler::PointSizeDemotionOutcome unrequestedOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
unrequested, types, false, true, /*captureRequestsPointSize=*/false, unrequestedOutcome, true,
true));
EXPECT_TRUE(unrequestedOutcome.demoted) << unrequestedOutcome.declineDetail;
EXPECT_EQ(Disassemble(unrequested[1]).find("OpName %mg_PointSizeCapture"), String::npos)
<< "with no capture asking for it, the carrier must not be declared";
}
// THE PREMISE THE PASS HEADER USED TO STATE UNIVERSALLY: "declared but no longer accessed"
// is invisible to the ES hop. It is not, for one stage/builtin combination - and this case
// pins the mechanism with no demotion involved at all, so a future SPIRV-Cross that emitted
// by ACCESS would fail here first and the decline below could be relaxed.
TEST_F(DemotePointSizeTest, ARedeclaredControlBlockPrintsAnUnaccessedPointSizeMember) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceUnusedPointSizeTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
// Nothing in this control stage touches point size, so nothing declares the capability -
// it is byte-for-byte the state a demoted module would be left in.
ASSERT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
const String tcs = Disassemble(modules[1]);
EXPECT_NE(tcs.find("BuiltIn PointSize"), String::npos)
<< "the member has to still be declared for this case to say anything:\n"
<< tcs;
const String tcsEssl = Transpile(modules[1]);
EXPECT_NE(tcsEssl.find("gl_PointSize"), String::npos)
<< "SPIRV-Cross force-redeclares a control stage's gl_PerVertex output block when its "
"clip/cull distances are live, and prints the block's members from their "
"decorations rather than from what is accessed. DirectGLES's extension gate is a "
"text search for this token over exactly this string:\n"
<< tcsEssl;
}
// ... and therefore this program declines rather than demoting: a mutated module that the
// driver still rejects is strictly worse than the honest refusal, because it also flips the
// program-wide verdict and the L1 key.
TEST_F(DemotePointSizeTest, AControlStageCarryingClipDistanceDeclinesTheProgram) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("clip/cull"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the declined program must still arm the existing honest refusals";
}
// A legal desktop-GL shape the passthrough machinery explicitly serves: an evaluation stage
// sitting straight on the vertex stage. Both backends synthesize the missing control stage,
// and that synthesized stage forwards gl_Position and nothing else - so the input carrier the
// demotion would create has no producer, and each backend's "reads a located input" guard
// would decline the program against a varying name the application never wrote. Declining the
// demotion instead keeps the modules, and the diagnostics, honest.
TEST_F(DemotePointSizeTest, AnEvaluationStageWithNoControlStageDeclines) {
const char* readingTessEval = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
}
)";
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_EVALUATION_SHADER, readingTessEval},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_EVALUATION_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("control stage"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_FALSE(ShaderCompiler::ModuleReadsLocatedInput(modules[1]))
<< "the declined evaluation stage must not have acquired the located input carrier "
"that both backends' pass-through guard refuses";
}
// The carrier is placed one past the highest location any stage CONSUMES, and a 64-bit
// vector consumes two of them. GL 4.6 core 11.1.2.1 says so for doubles, and
// ARB_gpu_shader_int64 - which DirectVulkan advertises unconditionally - extends the rule
// verbatim to i64/u64. An i64vec4 counted as one location would put the carrier on the
// SECOND location that varying already owns: two Output variables at one location, an
// invalid Vulkan interface and an ES link error naming a variable the application never
// wrote. This is the one direction the placement is not allowed to be wrong in.
TEST_F(DemotePointSizeTest, TheCarrierClearsA64BitIntegerVectorVarying) {
const char* wideVertex = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat out i64vec4 v_wide;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_wide = i64vec4(1, 2, 3, 4);
}
)";
const char* wideGeometry = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(points) in;
layout(points, max_vertices = 1) out;
layout(location = 0) flat in i64vec4 v_wide[];
layout(location = 0) flat out i64vec4 g_wide;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_wide = v_wide[0];
EmitVertex();
EndPrimitive();
}
)";
const char* wideFragment = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat in i64vec4 g_wide;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(float(g_wide.x)); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, wideVertex},
{GL_GEOMETRY_SHADER, wideGeometry},
{GL_FRAGMENT_SHADER, wideFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_wide / g_wide sit at location 0 and occupy 0 AND 1, so every carrier must clear 2.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos)
<< "the carrier landed on a location the i64vec4 varying already owns:\n"
<< vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 2"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, AWholeStructCopyDeclinesTheProgramByteIdentically) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
ASSERT_TRUE(tools.Assemble(kWholeStructCopyTessEvalAsm, &module));
ASSERT_TRUE(tools.Validate(module));
Vector<Vector<Uint32>> modules{module};
const Vector<GLenum> types{GL_TESS_EVALUATION_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_FALSE(outcome.declineDetail.empty())
<< "a shape the pass cannot express must say so, not silently no-op";
EXPECT_EQ(modules[0], module) << "a decline must not leave a half-demoted module behind";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[0]))
<< "the declined module must still arm the existing honest refusals";
}
@@ -22,6 +22,8 @@
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
@@ -38,6 +40,7 @@ namespace {
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeRuntimeArray = 29;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
@@ -116,6 +119,18 @@ namespace {
return {elementTypeId, length};
}
// The element type id of OpTypeRuntimeArray <arrayId>, or 0 when it is not one - which is
// what a BOUNDED flattened member (an OpTypeArray) answers too, so the two shapes can be told
// apart by the pair of helpers.
Uint32 RuntimeArrayElementOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeRuntimeArray || wordCount < 3 || words[1] != arrayId) return;
elementTypeId = words[2];
});
return elementTypeId;
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
@@ -140,6 +155,61 @@ namespace {
return text;
}
// How many lines of a disassembly hold BOTH fragments - "OpIMul %uint" and "%uint_8", say -
// which is how the index arithmetic the pass emits is pinned without a host that could run it.
Uint32 CountLinesWith(const String& text, const String& first, const String& second) {
Uint32 count = 0;
SizeT lineStart = 0;
while (lineStart < text.size()) {
SizeT lineEnd = text.find('\n', lineStart);
if (lineEnd == String::npos) lineEnd = text.size();
const String line = text.substr(lineStart, lineEnd - lineStart);
if (line.find(first) != String::npos && line.find(second) != String::npos) ++count;
lineStart = lineEnd + 1;
}
return count;
}
// What every test of the open-ended shape asserts: the block collapsed to ONE member, which
// is a `uint[]` RUNTIME array of stride 4 rather than a bounded one, and nothing 64-bit is
// left for the demotion to find. Returns the disassembly for the arithmetic checks.
String ExpectOpenEndedWordArray(const Vector<Uint32>& output, const String& blockName) {
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, blockName);
EXPECT_NE(structId, 0u) << text;
if (structId == 0) return text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
EXPECT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n" << text;
if (members.size() != 1) return text;
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const Uint32 elementTypeId = RuntimeArrayElementOf(output, members[0]);
EXPECT_NE(elementTypeId, 0u) << "member 0 is not a runtime array\n" << text;
EXPECT_EQ(ArrayShapeOf(output, members[0]).first, 0u)
<< "an open-ended block must not be given a bounded length\n"
<< text;
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
return text;
}
// The compute shape every failing KHR-Single-GL45.subgroups fp64 case binds: one runtime
// array of doubles, indexed by an invocation id, read whole-element.
String OpenEndedComputeSource(const String& elementType) {
return String(R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { )") +
elementType + R"( data[]; };
void main() {
)" + elementType +
R"( value = data[gl_LocalInvocationID.x] * data[0];
result[gl_GlobalInvocationID.x] = uint(value)" +
(elementType == "double" ? String{} : String(".x")) + R"();
}
)";
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
@@ -345,3 +415,679 @@ TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityA
EXPECT_EQ(explicitlyDemoted, defaulted);
EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted);
}
// ---------------------------------------------------------------------------
// The open-ended shape: a block whose last member is a runtime array. Before this was accepted
// the pass declined it and the demotion re-derived ArrayStride 4 for the now-float element, so
// `double data[]` read the application's 8-byte-stride buffer as 32-bit words - every fp64
// KHR-Single-GL45.subgroups case failed on exactly that.
// ---------------------------------------------------------------------------
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockOfDoublesBecomesAWordRuntimeArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Element i of the original array starts at word 2i, so the dynamic index is scaled by 2 ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the constant `data[0]` is the pair of words at 0 and 1, reached through the one
// member the block has left.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, EachDoubleVectorWidthStepsByItsOwnStride) {
struct Shape {
const char* element;
// std430 strides: dvec2 16 bytes, dvec3 and dvec4 32 bytes - i.e. 4, 8 and 8 words.
const char* strideWords;
// The last component's word offset inside one element, and the first one past it.
const char* lastComponentWords;
const char* firstWordPastIt;
};
const Shape shapes[] = {{"dvec2", "%uint_4", "%uint_2", "%uint_4"},
{"dvec3", "%uint_8", "%uint_4", "%uint_6"},
{"dvec4", "%uint_8", "%uint_6", "%uint_8"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.element);
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource(shape.element));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", shape.strideWords), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", shape.lastComponentWords), 1u) << text;
// A dvec3 is six words in a stride of eight: nothing may be read from the padding.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", shape.firstWordPastIt), 0u) << text;
}
}
TEST_F(FlattenFloat64StorageBlockTest, AFixedPrefixBeforeTheRuntimeArrayIsAddedToEveryIndex) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = head.x + uint(data[gl_LocalInvocationID.x]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId), (Vector<Uint32>{0, 16})) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// The 16-byte prefix is 4 words: element i is at word 4 + 2i.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
// And the prefix member itself is still word 0.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
// OpArrayLength on the flattened member counts WORDS. GL's `.length()` is the number of whole
// elements the bound range holds past the array's offset, so the count has to be rebased and
// divided - in unsigned arithmetic, and clamped rather than wrapped when the range is shorter
// than the prefix.
namespace {
// A prefix, an open-ended array of doubles, and a `.length()` of it - the one shape whose
// rewrite is an instruction SPIRV-Cross has to spell rather than plain arithmetic.
constexpr const char* kOpenEndedLengthSource = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length()) + head.y;
}
)";
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfAnOpenEndedBlockIsRewrittenToAnElementCount) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
// glslang asks for member 1's length and signs the answer.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpArrayLength %uint", " 1"), 1u) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Re-aimed at the one member left, ...
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 1"), 0u) << text;
// ... rebased past the 4-word prefix, clamped at zero when the range does not reach it, ...
EXPECT_EQ(CountLinesWith(text, "OpISub %uint", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpULessThan %bool", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect %uint", "%uint_0"), 1u) << text;
// ... and divided by the 2-word stride, with glslang's own sign conversion still downstream.
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpBitcast %int", ""), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfABlockWithNoPrefixNeedsNoClamp) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dvec2 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length());
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
// Nothing to subtract, so nothing to clamp: the word count over the 4-word stride is it.
EXPECT_EQ(CountLinesWith(text, "OpISub", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_4"), 1u) << text;
}
// The graphics shape of the same CTS group: a fragment stage reading a `readonly` block. The
// NonWritable the qualifier became is a promise about the whole block, and has to be on the one
// member the flattened block keeps.
TEST_F(FlattenFloat64StorageBlockTest, AReadOnlyOpenEndedBlockKeepsNonWritable) {
const String source = R"(#version 450 core
layout(binding = 4, std430) readonly buffer Buffer4 { dvec3 data[]; };
layout(location = 0) out vec4 o_color;
void main() {
uint index = uint(gl_FragCoord.x);
o_color = vec4(float(data[index].z));
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_FRAGMENT_SHADER, source);
ASSERT_FALSE(input.empty());
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer4");
EXPECT_EQ(CountLinesWith(text, "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u) << text;
// dvec3: stride 8 words, .z at +4.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_8"), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
}
// Writing through an open-ended block, which no CTS case does but any shader may: the store
// is decomposed into the same words the load would have read, so the bytes the application
// gets back are the ones GL says it wrote.
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockIsWrittenThroughTheSameWords) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 1) buffer Data { double data[]; };
void main() {
data[gl_LocalInvocationID.x] = double(gl_LocalInvocationID.y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// One dynamic index, scaled to the 2-word element ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the double left as exactly two word stores, nothing wider.
EXPECT_EQ(CountLinesWith(text, "OpStore", ""), 2u) << text;
EXPECT_EQ(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", ""), 2u) << text;
}
// The exact compute shader KHR-Single-GL45.subgroups.arithmetic.compute.subgroupmul_double
// generates, so the CTS shape is pinned as it is and not as a paraphrase of it.
TEST_F(FlattenFloat64StorageBlockTest, TheSubgroupMulDoubleComputeShaderIsFlattened) {
const String source = R"(#version 450
#extension GL_KHR_shader_subgroup_arithmetic: enable
#extension GL_KHR_shader_subgroup_ballot: enable
layout (local_size_x = 16, local_size_y = 1, local_size_z = 1) in;
layout(binding = 0, std430) buffer Buffer0
{
uint result[];
};
layout(binding = 1, std430) buffer Buffer1
{
double data[];
};
void main (void)
{
uvec3 globalSize = gl_NumWorkGroups * gl_WorkGroupSize;
highp uint offset = globalSize.x * ((globalSize.y * gl_GlobalInvocationID.z) + gl_GlobalInvocationID.y) + gl_GlobalInvocationID.x;
uvec4 mask = subgroupBallot(true);
uint start = 0u, end = gl_SubgroupSize;
double ref = double(1);
uint tempResult = 0u;
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult = (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x1u : 0u;
if (1u == (gl_SubgroupInvocationID % 2u))
{
mask = subgroupBallot(true);
ref = double(1);
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult |= (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x2u : 0u;
}
else
{
tempResult |= 0x2u;
}
result[offset] = tempResult;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer1");
// Four reads of the array, each scaled to the 2-word element.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 4u) << text;
// The result block holds no double and is not the pass's business.
const Uint32 resultStructId = StructIdNamed(output, "Buffer0");
ASSERT_NE(resultStructId, 0u) << text;
const Vector<Uint32> resultMembers = MemberTypesOf(output, resultStructId);
ASSERT_EQ(resultMembers.size(), 1u);
EXPECT_TRUE(IsUint32Type(output, RuntimeArrayElementOf(output, resultMembers[0]))) << text;
}
// A runtime array whose element is a MATRIX. The member's own MatrixStride and RowMajor
// decorations describe those elements, so a row-major one has to be declined - its columns are
// not contiguous, and addressing it in column order against a row-major buffer would be silently
// wrong bytes rather than a refusal. The column-major twin must flatten, stepping by the
// element's stride and then by the column's.
TEST_F(FlattenFloat64StorageBlockTest, ARowMajorMatrixRuntimeArrayIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1, row_major) buffer Data { dmat4 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x][1][2]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
// The premise: glslang really did mark the member row-major.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Data 0 RowMajor", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// Still a runtime array of matrices - narrowed to fp32 by the demotion, not re-addressed.
EXPECT_NE(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u)
<< "a row-major matrix element must not have been flattened into words\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, AColumnMajorMatrixRuntimeArrayStepsByItsColumnStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dmat2x4 data[]; };
void main() {
dvec4 column = data[gl_LocalInvocationID.x][1];
result[gl_GlobalInvocationID.x] = uint(column.w);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// dmat2x4: two columns of dvec4, column stride 32 bytes, so one element is 64 bytes -
// 16 words - and column 1 starts 8 words into it.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_16"), 1u) << text;
// Exactly one +8: the column's own offset inside the element. A second would mean a word
// past the column was being addressed off that same base.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_8"), 1u) << text;
// All eight words of that column are read - the last of its four doubles ends at +7 ...
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_7"), 1u) << text;
// ... and the column that was not asked for is not touched: nothing is read at +9 or past.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_9"), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_10"), 0u) << text;
}
// A runtime array whose element is a STRUCT: the same walk, and the same decline test, as a
// bounded array of them - a shape no other open-ended case reaches.
TEST_F(FlattenFloat64StorageBlockTest, AStructRuntimeArrayStepsByItsElementStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Pair { double a; float b; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Pair data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].a) +
uint(data[gl_LocalInvocationID.x].b);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// std430 rounds `{ double a; float b; }` up to its 8-byte alignment: 16 bytes, 4 words,
// with `b` two words in.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_4"), 2u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_2"), 1u) << text;
}
// The leaf cap bounds ONE load or store, not a member's size: a block whose element is far too
// big to expand whole is still flattened while every access to it names a scalar. Declining it
// would leave the application's 8-byte-stride doubles to the demotion's re-derived stride 4 -
// the exact defect the open-ended shape exists to avoid.
TEST_F(FlattenFloat64StorageBlockTest, AHugeRuntimeArrayElementIsStillFlattenedWhenAccessesAreSmall) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Big { dvec4 v[300]; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Big data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].v[3].y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// 300 dvec4 of 32 bytes each: 9600 bytes, 2400 words per element - 1200 scalars, well past
// the per-access cap that a whole-element load would have to respect and this never does.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2400"), 1u) << text;
// v[3].y is 3 * 8 + 2 = 26 words into the element.
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_26"), 1u) << text;
}
// The flatten preserves a byte layout ACROSS a narrowing; where the backend consumes 64-bit
// floats itself there is nothing to preserve, and the open-ended block has to keep its runtime
// array of doubles exactly as the driver would lay it out.
TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesAnOpenEndedBlockAndItsDoublesAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
EXPECT_NE(RuntimeArrayElementOf(output, members[0]), 0u)
<< "the member should still be a runtime array\n"
<< text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 8u)
<< "the array must keep the 8-byte stride the application bound\n"
<< text;
EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u)
<< "nothing narrows here, so the doubles must survive\n"
<< text;
}
// The other backend prints the flattened module through SPIRV-Cross: an open-ended `uint[]`
// member has to come out as ESSL that names no 64-bit type. The `.length()` shape is here too,
// because the OpArrayLength the rewrite re-issues is the one instruction in it whose ESSL
// spelling is not plain arithmetic - if that backend ever refused it on the flattened member,
// a DirectGLES shader asking an fp64 buffer its length would fail at link and nowhere else.
namespace {
String TranspileToEssl(const Vector<Uint32>& spirv) {
using namespace MG_Util::ShaderTranspiler;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
EXPECT_EQ(session.CreateOptions(&options), SPVC_SUCCESS);
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
EXPECT_EQ(session.SetOptions(options), SPVC_SUCCESS);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? *essl : String{};
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("dvec4"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
EXPECT_NE(essl.find("uint"), String::npos) << essl;
}
TEST_F(FlattenFloat64StorageBlockTest, TheRewrittenLengthCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
// The length survived as a length - it was not folded away or dropped on the floor.
EXPECT_NE(essl.find(".length()"), String::npos) << essl;
}
// ---------------------------------------------------------------------------
// The gate from the other side: a runtime array anywhere but the block's own last member is a
// shape GLSL cannot spell and this pass does not describe. SPIR-V can spell it, so both are
// hand-written, and both are invalid Vulkan SPIR-V - the chain runs without its validator here,
// which is also why neither can be a validation-failure count.
// ---------------------------------------------------------------------------
namespace {
// `buffer Odd { double data[]; uint tail; }`, the runtime array FIRST.
const char* kRuntimeArrayNotLastAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Odd "Odd"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpDecorate %Odd Block
OpMemberDecorate %Odd 0 Offset 0
OpMemberDecorate %Odd 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Odd = OpTypeStruct %_runtimearr_double %uint
%_ptr_StorageBuffer_Odd = OpTypePointer StorageBuffer %Odd
%var = OpVariable %_ptr_StorageBuffer_Odd StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
// `struct Inner { double data[]; }; buffer Outer { uint head; Inner inner; }`: the runtime
// array IS last, but of a member rather than of the block.
const char* kRuntimeArrayNestedAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Outer "Outer"
OpName %Inner "Inner"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpMemberDecorate %Inner 0 Offset 0
OpDecorate %Outer Block
OpMemberDecorate %Outer 0 Offset 0
OpMemberDecorate %Outer 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Inner = OpTypeStruct %_runtimearr_double
%Outer = OpTypeStruct %uint %Inner
%_ptr_StorageBuffer_Outer = OpTypePointer StorageBuffer %Outer
%var = OpVariable %_ptr_StorageBuffer_Outer StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_1 %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
Vector<Uint32> AssembleUnchecked(const char* asmText) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
EXPECT_TRUE(tools.Assemble(asmText, &module));
return module;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, ARuntimeArrayThatIsNotTheBlocksLastMemberIsLeftToTheDemotion) {
struct Shape {
const char* asmText;
const char* blockName;
};
const Shape shapes[] = {{kRuntimeArrayNotLastAsm, "Odd"}, {kRuntimeArrayNestedAsm, "Outer"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.blockName);
const Vector<Uint32> input = AssembleUnchecked(shape.asmText);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, false, false));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
// Declined: both members are still there, and the demotion narrowed them the old way.
const Uint32 structId = StructIdNamed(output, shape.blockName);
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
}
// ---------------------------------------------------------------------------
// The front end declares types in first-use order, so a block that is the first thing the
// shader touches is declared before the module's `uint` - and the flattened member is an array
// OF `uint`. For an OPEN-ENDED block the pass moves that operand-less type up in front of the
// block rather than declining, so that where a buffer of doubles stands in the shader does not
// decide whether its bytes survive. A BOUNDED block in the same position keeps the decline it
// has always had: widening that is a change to a path this fix does not need, and the pair below
// pins both halves.
// ---------------------------------------------------------------------------
namespace {
// The position of <id>'s declaration in instruction order, or 0 when it has none.
Uint32 DeclarationIndexOf(const Vector<Uint32>& spirv, Uint32 id) {
Uint32 index = 0;
Uint32 found = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
++index;
if (found != 0 || wordCount < 2) return;
// Every OpType* has its result id in word 1; that is all this is asked about.
if (opcode >= kOpTypeInt && opcode <= kOpTypeStruct && words[1] == id) found = index;
});
return found;
}
Uint32 Uint32TypeIdOf(const Vector<Uint32>& spirv) {
Uint32 typeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeInt && wordCount >= 4 && words[2] == 32u && words[3] == 0u) typeId = words[1];
});
return typeId;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockDeclaredBeforeTheModulesUintIsStillFlattened) {
// The block is the first thing main touches, and nothing before it needs a uint - not even
// an array length, which is a uint constant and would declare one.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Data { double data[]; };
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
result[0] = float(data[0] + data[1]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
// The premise: the module's uint really is declared after the block (or not at all).
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// And the uint now stands in front of the block it is an element of.
EXPECT_LT(DeclarationIndexOf(output, RuntimeArrayElementOf(output, members[0])),
DeclarationIndexOf(output, structId))
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, ABoundedBlockDeclaredBeforeTheModulesUintIsLeftToTheDemotion) {
// The same position, a bounded block: this is the shape that has always been declined, and
// it stays declined - its members and the demotion's own repacking come through untouched.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Wide {
double data0;
dvec2 data1;
} g_wide;
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
double sum = g_wide.data0 + g_wide.data1.y;
result[0] = float(sum);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u)
<< "a bounded block in this position must keep the decline it shipped with\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
@@ -499,6 +499,19 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
v.nativeFloat64 = true;
variants.emplace_back("nativeFloat64", BuildSpirvTranslationKey(v));
}
{ // CompileEnv::DemotesTessellationPointSize(): phase B rewrites the cached modules
// under it (the point-size demotion), so one key shape would describe two module
// sets - built-in kept vs carried as a varying with the capability stripped.
SpirvTranslationKeyInputs v = base;
v.demoteTessellationPointSize = true;
variants.emplace_back("demoteTessellationPointSize", BuildSpirvTranslationKey(v));
}
{ // ... and its geometry twin, keyed separately because the ES loader really does
// probe the two extension families independently.
SpirvTranslationKeyInputs v = base;
v.demoteGeometryPointSize = true;
variants.emplace_back("demoteGeometryPointSize", BuildSpirvTranslationKey(v));
}
// ---- inputs the WIDENED payload pulled into the key ----
// They cannot move a word of the generated SPIR-V, but they do shape the reflection the
// payload now carries, so they have to split the key. This is the group that would go
@@ -651,6 +664,48 @@ TEST_F(TranslationCacheTest, NativeFloat64IsOutOfTheFrontendFingerprintAndInside
<< "one L1 entry would then describe two different module sets";
}
// The second and third capability bits under the same placement rule as nativeFloat64:
// out of the front-end fingerprint (glslang produces the same thing either way), inside
// the L1 key (phase B's point-size demotion rewrites the cached modules under them). The
// accessor direction is pinned too, because it is INVERTED relative to the params field
// and a swap of the arms would disable the device repair with every rendering test green.
TEST_F(TranslationCacheTest, PointSizeDemotionBitsAreOutOfTheFrontendFingerprintAndInsideTheL1Key) {
CompileEnv none; // no backend at all: never demote, standalone compiles stay standard
CompileEnv hosting; // a backend that hosts the built-in
CompileEnv demoting; // a backend that cannot
hosting.backend = BackendType::DirectVulkan;
demoting.backend = BackendType::DirectVulkan;
demoting.params.SupportsTessellationPointSize = false;
demoting.params.SupportsGeometryPointSize = false;
EXPECT_FALSE(none.DemotesTessellationPointSize());
EXPECT_FALSE(none.DemotesGeometryPointSize());
EXPECT_FALSE(hosting.DemotesTessellationPointSize());
EXPECT_FALSE(hosting.DemotesGeometryPointSize());
EXPECT_TRUE(demoting.DemotesTessellationPointSize());
EXPECT_TRUE(demoting.DemotesGeometryPointSize());
EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(hosting), ComputeFrontendCompileEnvFingerprint(demoting))
<< "the point-size capability leaked into the front-end fingerprint";
EXPECT_NE(ComputeCompileEnvFingerprint(hosting), ComputeCompileEnvFingerprint(demoting))
<< "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters "
"field, hashed by object representation";
const Vector<SpirvTranslationKeyInputs::Stage> stages{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}};
SpirvTranslationKeyInputs demotedKey = BaselineSpirvInputs(stages);
demotedKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(demoting);
demotedKey.demoteTessellationPointSize = demoting.DemotesTessellationPointSize();
demotedKey.demoteGeometryPointSize = demoting.DemotesGeometryPointSize();
SpirvTranslationKeyInputs keptKey = BaselineSpirvInputs(stages);
keptKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(hosting);
keptKey.demoteTessellationPointSize = hosting.DemotesTessellationPointSize();
keptKey.demoteGeometryPointSize = hosting.DemotesGeometryPointSize();
EXPECT_FALSE(BuildSpirvTranslationKey(demotedKey) == BuildSpirvTranslationKey(keptKey))
<< "one L1 entry would then describe two different module sets";
}
// The other direction, one case per input that was KEPT. Each is a limit the front end
// really consumes - everything BuildTBuiltInResource copies into TBuiltInResource, plus the
// two inputs to the reflection vertex-attrib limit - so each must still split the key.
+1 -1
View File
@@ -441,7 +441,7 @@ TEST(JobNodeException, AnExceptionEscapingABodyCancelsTheJobInsteadOfTerminating
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
EXPECT_NE(job->diagnostics.logLines[0].find("boom"), String::npos);
EXPECT_NE(job->diagnostics.logLines[0].text.find("boom"), String::npos);
}
TEST(JobNodeException, ANonStandardExceptionIsContainedToo) {
+24 -5
View File
@@ -95,11 +95,13 @@ namespace MobileGL::MG_Util::Async {
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, std::format("Job body threw: {}", e.what())});
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, String("Job body threw a non-std exception")});
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
@@ -162,10 +164,27 @@ namespace MobileGL::MG_Util::Async {
"being written");
if (!node.diagnostics.logLines.empty()) {
Vector<String> lines;
Vector<DeferredLogLine> lines;
lines.swap(node.diagnostics.logLines);
for (const String& line : lines) {
MGLOG_D("%s", line.c_str());
for (const DeferredLogLine& line : lines) {
// Per-line severity, because a shipped build compiles MGLOG_D away entirely
// and a verdict that only this channel records would vanish with it. The
// levels are the compile-time constants, so a suppressed one costs nothing
// beyond the string the worker already built.
switch (line.level) {
case MOBILEGL_LOG_LEVEL_INFO:
MGLOG_I("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_WARN:
MGLOG_W("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_ERROR:
MGLOG_E("%s", line.text.c_str());
break;
default:
MGLOG_D("%s", line.text.c_str());
break;
}
}
}
+19 -2
View File
@@ -9,6 +9,7 @@
#pragma once
#include <Includes.h>
#include <MG_Util/Types.h>
#include <MG_Util/Debug/Log.h>
#include <MG_State/GLState/ErrorState/ErrorCode.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
@@ -35,9 +36,25 @@ namespace MobileGL::MG_Util::Async {
UniquePtr<ErrorInfo> info;
};
// One line of worker-side MGLOG text, with the severity the join replays it at.
//
// DEBUG is the default and stays the default: nearly every deferred line is per-program
// trace that a shipped build compiles out, which is the whole reason this channel could
// be a plain string vector for as long as it was. A line a SHIPPED build has to show -
// the reason a repair refused, which no other surface records - has to name its level
// here, or it is formatted on the worker and then thrown away at replay under the INFO
// level every device and CI build pins. Callers that sit on a repeated path latch at
// the SOURCE (a per-call-site atomic, exactly what MGLOG_*_ONCE does): the replay below
// is one shared site for every job in the tree, so a latch there would silence
// unrelated lines.
struct DeferredLogLine {
Int level = MOBILEGL_LOG_LEVEL_DEBUG;
String text;
};
struct JobDiagnostics {
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<String> logLines; // worker-side MGLOG text, flushed in order by the join
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<DeferredLogLine> logLines; // worker-side MGLOG text, flushed in order by the join
};
// The scheduling primitive every asynchronous compile and link is built on. A node owns
@@ -222,6 +222,12 @@ namespace MobileGL::MG_Util::BackendLoader {
vkGetPhysicalDeviceFeatures(physicalDevice, &supportedFeatures);
caps.SupportsWideLines = supportedFeatures.wideLines == VK_TRUE;
caps.SupportsShaderFloat64 = supportedFeatures.shaderFloat64 == VK_TRUE;
// One feature covers both stage families here, unlike the ES loader's two extension
// tiers; the renderer enables it on the device whenever advertised
// (VulkanRenderer::CreateLogicalDeviceAndQueues), so this probe and that enable can
// never disagree about the physical device.
caps.SupportsTessellationAndGeometryPointSize =
supportedFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
caps.SupportsImageCubeArray = supportedFeatures.imageCubeArray == VK_TRUE;
{
// Probe the formats a colour render target actually uses. A driver that refuses the flag
@@ -350,6 +356,7 @@ namespace MobileGL::MG_Util::BackendLoader {
FillFragmentInterpolationLimits(caps, properties.limits);
caps.SupportsWideLines = false;
caps.SupportsShaderFloat64 = false;
caps.SupportsTessellationAndGeometryPointSize = false;
caps.SupportsImageCubeArray = false;
caps.Supports2DArrayCompatible3DImages = false;
// This helper only receives properties, not VkPhysicalDeviceFeatures. Leave optional
@@ -87,6 +87,13 @@ namespace MobileGL {
// needs it, which includes every 64-bit vertex attribute: the attribute itself arrives
// as 32-bit words, but the bitcast result and everything computed from it is Float64.
Bool SupportsShaderFloat64 = false;
// VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize. Any
// tessellation/geometry module declaring OpCapability TessellationPointSize /
// GeometryPointSize needs it (VUID-VkShaderModuleCreateInfo-pCode-08740's
// capability table); without it the shared phase-B chain demotes the built-in
// to an ordinary varying. One feature for both stage families, unlike the ES
// loader's two extension tiers.
Bool SupportsTessellationAndGeometryPointSize = false;
// VkPhysicalDeviceFeatures::imageCubeArray. Required before a
// VK_IMAGE_VIEW_TYPE_CUBE_ARRAY view may be created at all
// (VUID-VkImageViewCreateInfo-viewType-01004), which is every cube map array texture -
@@ -7,6 +7,7 @@
// End of Source File Header
#include "DriverBugProbes.h"
#include "PersistentBufferOrderingProbe.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
@@ -2445,6 +2446,10 @@ namespace MobileGL::MG_Util::SelfTest {
DriverBugVerdict::Unfixable, detail};
}
Optional<DriverBugFinding> ProbePersistentBufferOrderingBug(const GLESFunctionsTable& gl) {
return DescribePersistentBufferOrderingBug(ProbePersistentBufferUpdateOrdering(gl));
}
// The table. One row per known driver bug; see the header for how to add a sibling.
using DriverBugProbeFn = Optional<DriverBugFinding> (*)(const GLESFunctionsTable&);
constexpr DriverBugProbeFn kGlesDriverBugProbes[] = {
@@ -2457,6 +2462,7 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeLayeredBlitDestinationBug,
&ProbeLocatedIoBlockPayloadBug,
&ProbeCopyImagePacked16FieldOrderBug,
&ProbePersistentBufferOrderingBug,
};
} // namespace
+376
View File
@@ -9,6 +9,7 @@
#include "DriverPost.h"
#include "DriverPostIterationRPWitness.h"
#include "DriverPostIterationRPWitnessSpv.h"
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/BackendLoaders/OpenGL/Loader.h"
#include <Config.h>
#include <MGGitHash.h>
@@ -1555,6 +1556,377 @@ namespace MobileGL::MG_Util::SelfTest {
disabledNote);
}
// GL_PRIMITIVES_GENERATED for draws made with transform feedback INACTIVE. GL
// defines the query to count them; the DirectVulkan backend serves it from the
// stream query's primitivesNeeded, and an affected Mali driver answers 0 there
// unless a capture span is open - the exact shape the CTS uses to measure the
// tessellator (see PrimitivesGeneratedNoXfbProbe.h). One row:
// PASS - the stream query counts the capture-less draw exactly.
// WARN - it answers 0, and the CLIPPING_INPUT_PRIMITIVES statistics control
// on the same draw answers exactly right, so the renderer substitutes
// a pipeline-statistics pool for such draws (the same probe, run at
// renderer bring-up, is what arms it).
// FAIL (optional) - it answers 0 with no working substitute, or the probe
// could not reach a verdict; applications sizing capture buffers from
// the query get 0.
// Throwaway device on purpose, like every probe here: the row reports the
// driver, not the renderer's configuration - MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE
// steers the renderer, never this row.
void ProbeVulkanPrimitivesGeneratedNoXfb(ReportBuilder& builder,
PFN_vkGetInstanceProcAddr getInstanceProcAddr,
VkInstance instance, VkPhysicalDevice physicalDevice,
Uint32 graphicsQueueFamilyIndex,
const Vector<VkExtensionProperties>& deviceExtensions,
const VkPhysicalDeviceFeatures& features,
PFN_vkGetPhysicalDeviceFeatures2 getFeatures2,
PFN_vkGetPhysicalDeviceProperties2 getProperties2) {
constexpr const char* RowName = "Primitives-generated query without capture";
const auto fail = [&](String detail) { builder.FailOptional(RowName, Move(detail)); };
if (!HasVkExtension(deviceExtensions, VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME) ||
getFeatures2 == nullptr || getProperties2 == nullptr) {
fail("VK_EXT_transform_feedback is unavailable, so the backend has no GPU counter for "
"GL_PRIMITIVES_GENERATED at all - with or without a capture");
return;
}
VkPhysicalDeviceTransformFeedbackFeaturesEXT xfbFeatures{};
xfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
VkPhysicalDeviceFeatures2 features2{};
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
features2.pNext = &xfbFeatures;
getFeatures2(physicalDevice, &features2);
VkPhysicalDeviceTransformFeedbackPropertiesEXT xfbProperties{};
xfbProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_PROPERTIES_EXT;
VkPhysicalDeviceProperties2 properties2{};
properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
properties2.pNext = &xfbProperties;
getProperties2(physicalDevice, &properties2);
if (xfbFeatures.transformFeedback != VK_TRUE || xfbProperties.transformFeedbackQueries != VK_TRUE) {
fail("the device has VK_EXT_transform_feedback but no usable stream queries "
"(transformFeedbackQueries = false); GL_PRIMITIVES_GENERATED and "
"GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN queries answer 0");
return;
}
const auto vkCreateDeviceFn =
reinterpret_cast<PFN_vkCreateDevice>(getInstanceProcAddr(instance, "vkCreateDevice"));
const auto vkDestroyDeviceFn =
reinterpret_cast<PFN_vkDestroyDevice>(getInstanceProcAddr(instance, "vkDestroyDevice"));
const auto vkGetDeviceQueueFn =
reinterpret_cast<PFN_vkGetDeviceQueue>(getInstanceProcAddr(instance, "vkGetDeviceQueue"));
if (vkCreateDeviceFn == nullptr || vkDestroyDeviceFn == nullptr || vkGetDeviceQueueFn == nullptr) {
fail("vkGetInstanceProcAddr could not resolve the device-creation entry points");
return;
}
const Float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueInfo{};
queueInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueInfo.queueFamilyIndex = graphicsQueueFamilyIndex;
queueInfo.queueCount = 1;
queueInfo.pQueuePriorities = &queuePriority;
// Only what the probe itself needs: the transform feedback feature (a
// stream-query pool may not be created without it), the two candidate
// substitutes' features, and tessellationShader for the PATCHES shape -
// each only where the device has it. The dedicated
// primitives-generated query is taken with BOTH its bits or not at all,
// mirroring the renderer (without the discard bit two of the three
// shapes may not run inside it).
VkPhysicalDeviceFeatures enabledFeatures{};
enabledFeatures.pipelineStatisticsQuery = features.pipelineStatisticsQuery;
enabledFeatures.tessellationShader = features.tessellationShader;
VkPhysicalDeviceTransformFeedbackFeaturesEXT enabledXfbFeatures{};
enabledXfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
enabledXfbFeatures.transformFeedback = VK_TRUE;
const char* enabledExtensions[2] = {VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME, nullptr};
Uint32 enabledExtensionCount = 1;
Bool primitivesGeneratedQueryUsable = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT enabledPgqFeatures{};
enabledPgqFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (HasVkExtension(deviceExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT pgqQuery{};
pgqQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
VkPhysicalDeviceFeatures2 pgqFeatures2{};
pgqFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
pgqFeatures2.pNext = &pgqQuery;
getFeatures2(physicalDevice, &pgqFeatures2);
if (pgqQuery.primitivesGeneratedQuery == VK_TRUE &&
pgqQuery.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
primitivesGeneratedQueryUsable = true;
enabledPgqFeatures.primitivesGeneratedQuery = VK_TRUE;
enabledPgqFeatures.primitivesGeneratedQueryWithRasterizerDiscard = VK_TRUE;
enabledPgqFeatures.pNext = &enabledXfbFeatures;
enabledExtensions[enabledExtensionCount++] =
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME;
}
}
VkDeviceCreateInfo deviceInfo{};
deviceInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceInfo.pNext = primitivesGeneratedQueryUsable
? static_cast<const void*>(&enabledPgqFeatures)
: static_cast<const void*>(&enabledXfbFeatures);
deviceInfo.queueCreateInfoCount = 1;
deviceInfo.pQueueCreateInfos = &queueInfo;
deviceInfo.enabledExtensionCount = enabledExtensionCount;
deviceInfo.ppEnabledExtensionNames = enabledExtensions;
deviceInfo.pEnabledFeatures = &enabledFeatures;
VkDevice device = VK_NULL_HANDLE;
const VkResult createResult = vkCreateDeviceFn(physicalDevice, &deviceInfo, nullptr, &device);
if (createResult != VK_SUCCESS || device == VK_NULL_HANDLE) {
fail(format("vkCreateDevice failed (VkResult = {})", static_cast<Int>(createResult)));
return;
}
// The probe's own teardown destroys (and idle-waits) everything it created -
// EXCEPT when its bounded fence wait expires, where it deliberately leaks
// every child object rather than touch a possibly hung GPU. This device must
// then leak with them: vkDestroyDevice requires its children destroyed and its
// queues idle, and on the driver that just missed a 5 s deadline the realistic
// outcome is a block inside vkDestroyDevice - the POST hang the bound exists to
// prevent. Same shape as the timestamp probe's guard above and the iterationRP
// witness's below.
Bool probeFenceWaitTimedOut = false;
const ScopeGuard destroyDevice([&]() {
if (probeFenceWaitTimedOut) {
return;
}
vkDestroyDeviceFn(device, nullptr);
});
VkQueue queue = VK_NULL_HANDLE;
vkGetDeviceQueueFn(device, graphicsQueueFamilyIndex, 0, &queue);
if (queue == VK_NULL_HANDLE) {
fail("vkGetDeviceQueue returned a null graphics queue");
return;
}
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = device;
probeContext.queue = queue;
probeContext.queueFamilyIndex = graphicsQueueFamilyIndex;
probeContext.transformFeedbackQueriesUsable = true;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = enabledFeatures.pipelineStatisticsQuery == VK_TRUE;
probeContext.tessellationEnabled = enabledFeatures.tessellationShader == VK_TRUE;
auto& fns = probeContext.fns;
const auto resolve = [&](const char* name) { return getInstanceProcAddr(instance, name); };
fns.vkCreateCommandPool = reinterpret_cast<PFN_vkCreateCommandPool>(resolve("vkCreateCommandPool"));
fns.vkDestroyCommandPool =
reinterpret_cast<PFN_vkDestroyCommandPool>(resolve("vkDestroyCommandPool"));
fns.vkAllocateCommandBuffers =
reinterpret_cast<PFN_vkAllocateCommandBuffers>(resolve("vkAllocateCommandBuffers"));
fns.vkBeginCommandBuffer =
reinterpret_cast<PFN_vkBeginCommandBuffer>(resolve("vkBeginCommandBuffer"));
fns.vkEndCommandBuffer = reinterpret_cast<PFN_vkEndCommandBuffer>(resolve("vkEndCommandBuffer"));
fns.vkCreateQueryPool = reinterpret_cast<PFN_vkCreateQueryPool>(resolve("vkCreateQueryPool"));
fns.vkDestroyQueryPool = reinterpret_cast<PFN_vkDestroyQueryPool>(resolve("vkDestroyQueryPool"));
fns.vkCmdResetQueryPool = reinterpret_cast<PFN_vkCmdResetQueryPool>(resolve("vkCmdResetQueryPool"));
fns.vkCmdBeginQuery = reinterpret_cast<PFN_vkCmdBeginQuery>(resolve("vkCmdBeginQuery"));
fns.vkCmdEndQuery = reinterpret_cast<PFN_vkCmdEndQuery>(resolve("vkCmdEndQuery"));
fns.vkCmdBeginQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdBeginQueryIndexedEXT>(resolve("vkCmdBeginQueryIndexedEXT"));
fns.vkCmdEndQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdEndQueryIndexedEXT>(resolve("vkCmdEndQueryIndexedEXT"));
fns.vkCreateRenderPass = reinterpret_cast<PFN_vkCreateRenderPass>(resolve("vkCreateRenderPass"));
fns.vkDestroyRenderPass =
reinterpret_cast<PFN_vkDestroyRenderPass>(resolve("vkDestroyRenderPass"));
fns.vkCreateFramebuffer =
reinterpret_cast<PFN_vkCreateFramebuffer>(resolve("vkCreateFramebuffer"));
fns.vkDestroyFramebuffer =
reinterpret_cast<PFN_vkDestroyFramebuffer>(resolve("vkDestroyFramebuffer"));
fns.vkCmdBeginRenderPass =
reinterpret_cast<PFN_vkCmdBeginRenderPass>(resolve("vkCmdBeginRenderPass"));
fns.vkCmdEndRenderPass = reinterpret_cast<PFN_vkCmdEndRenderPass>(resolve("vkCmdEndRenderPass"));
fns.vkCreateShaderModule =
reinterpret_cast<PFN_vkCreateShaderModule>(resolve("vkCreateShaderModule"));
fns.vkDestroyShaderModule =
reinterpret_cast<PFN_vkDestroyShaderModule>(resolve("vkDestroyShaderModule"));
fns.vkCreatePipelineLayout =
reinterpret_cast<PFN_vkCreatePipelineLayout>(resolve("vkCreatePipelineLayout"));
fns.vkDestroyPipelineLayout =
reinterpret_cast<PFN_vkDestroyPipelineLayout>(resolve("vkDestroyPipelineLayout"));
fns.vkCreateGraphicsPipelines =
reinterpret_cast<PFN_vkCreateGraphicsPipelines>(resolve("vkCreateGraphicsPipelines"));
fns.vkDestroyPipeline = reinterpret_cast<PFN_vkDestroyPipeline>(resolve("vkDestroyPipeline"));
fns.vkCmdBindPipeline = reinterpret_cast<PFN_vkCmdBindPipeline>(resolve("vkCmdBindPipeline"));
fns.vkCmdDraw = reinterpret_cast<PFN_vkCmdDraw>(resolve("vkCmdDraw"));
fns.vkCreateFence = reinterpret_cast<PFN_vkCreateFence>(resolve("vkCreateFence"));
fns.vkDestroyFence = reinterpret_cast<PFN_vkDestroyFence>(resolve("vkDestroyFence"));
fns.vkQueueSubmit = reinterpret_cast<PFN_vkQueueSubmit>(resolve("vkQueueSubmit"));
fns.vkWaitForFences = reinterpret_cast<PFN_vkWaitForFences>(resolve("vkWaitForFences"));
fns.vkGetQueryPoolResults =
reinterpret_cast<PFN_vkGetQueryPoolResults>(resolve("vkGetQueryPoolResults"));
fns.vkDeviceWaitIdle = reinterpret_cast<PFN_vkDeviceWaitIdle>(resolve("vkDeviceWaitIdle"));
const PrimitivesGeneratedNoXfbMeasurement measurement =
RunPrimitivesGeneratedNoXfbProbe(probeContext);
// Before any return below: the guard above owns the device and must know.
probeFenceWaitTimedOut = measurement.fenceWaitTimedOut;
if (!measurement.ran) {
fail(format("the probe could not run ({}); the renderer's bring-up probe decides the "
"reroute independently",
measurement.failureReason));
return;
}
const auto shapeFacts = [](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn) {
return format("{} not drawn (no tessellationShader)", name);
}
String facts = format("{}: stream answered {} of {} expected", name, shape.streamGenerated,
shape.expectedPrimitives);
if (shape.primitivesGeneratedExtMeasured) {
facts += format(", dedicated query answered {}", shape.primitivesGeneratedExt);
}
if (shape.statisticsMeasured) {
facts += format(", statistics control answered {}", shape.statisticsClippingInput);
}
if (!shape.primitivesGeneratedExtMeasured && !shape.statisticsMeasured) {
facts += ", no control (neither VK_EXT_primitives_generated_query with its "
"discard feature nor pipelineStatisticsQuery is available)";
}
return facts;
};
const String facts = shapeFacts("triangles", measurement.trianglesPlain) + "; " +
shapeFacts("triangles under discard", measurement.trianglesDiscard) +
"; " + shapeFacts("patches under discard", measurement.patchesDiscard);
const auto statisticsExactOn = [](const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
return shape.statisticsMeasured && shape.statisticsClippingInput == shape.expectedPrimitives;
};
// What the PLAIN-ONLY verdict actually measured, named from the numbers rather
// than assumed: the shape the substitute misses may be the tessellated one
// alone, and a missed shape may read a wrong NONZERO count rather than 0. A
// row that always blamed rasterizer discard would put a false statement about
// the driver into the campaign's evidence artifact, contradicted by the facts
// string printed right after it.
const auto describeMissedStatisticsShapes = [&]() {
String missed;
const auto note = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape)) {
return;
}
if (!missed.empty()) {
missed += " and ";
}
missed += name;
missed += shape.statisticsMeasured
? format(" (read {} of {} expected)", shape.statisticsClippingInput,
shape.expectedPrimitives)
: String(" (its statistics slot did not read back)");
};
note("the plain draw", measurement.trianglesPlain);
note("triangles under rasterizer discard", measurement.trianglesDiscard);
note("patches under rasterizer discard", measurement.patchesDiscard);
return missed;
};
// The CTS's tessellator-measuring shape is a PATCHES draw under discard; say
// whether THIS driver's substitute covers it instead of assuming it does not.
const auto describeCtsShape = [&]() -> String {
if (!measurement.patchesDiscard.drawn) {
return "the CTS's tessellator-measuring shape (a PATCHES draw under discard) could "
"not be measured here - this device has no tessellationShader - so whether "
"the substitute covers it is unknown";
}
return statisticsExactOn(measurement.patchesDiscard)
? "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"NOT among them: the substitute answers it exactly, so those tests are "
"repaired"
: "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"among them, so those tests stay broken on this driver";
};
switch (EvaluatePrimitivesGeneratedNoXfbVerdict(measurement)) {
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
builder.Pass(RowName,
"the stream query counts a draw made with no capture span open, as "
"VK_EXT_transform_feedback defines (" +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT query around an identical replay answers "
"exactly right, rasterizer discard included, so the renderer "
"accumulates GL_PRIMITIVES_GENERATED for such draws through the "
"dedicated query instead (one extra query slot per XFB-inactive draw "
"inside a GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"clipping-invocations statistics query around an identical replay answers exactly "
"right, rasterizer discard included, so the renderer accumulates "
"GL_PRIMITIVES_GENERATED for such draws through a pipeline-statistics "
"pool instead (one extra query slot per XFB-inactive draw inside a "
"GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute counts the plain draw exactly but "
"misses " +
describeMissedStatisticsShapes() +
" - each of them a shape the stream query answered 0 for as well, so the renderer "
"reroutes XFB-inactive draws (repairing every shape the substitute answers, at no "
"cost to the rest, which is what the verdict requires); " +
describeCtsShape() + " (" + facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::Unfixable: {
// Two ways to land here, and the report must not conflate them: no
// substitute answers even the plain draw, or one does but it is WRONG on a
// shape the stream query answers EXACTLY - arming it would trade a correct
// answer for a wrong one, so MobileGL refuses (see the verdict's
// domination rule).
String downgradeShapes;
const auto noteDowngrade = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape) ||
shape.streamGenerated != shape.expectedPrimitives) {
return;
}
if (!downgradeShapes.empty()) {
downgradeShapes += " and ";
}
downgradeShapes += name;
};
noteDowngrade("the plain draw", measurement.trianglesPlain);
noteDowngrade("triangles under rasterizer discard", measurement.trianglesDiscard);
noteDowngrade("patches under rasterizer discard", measurement.patchesDiscard);
if (statisticsExactOn(measurement.trianglesPlain) && !downgradeShapes.empty()) {
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute repairs the plain draw but is wrong "
"on " +
downgradeShapes +
", which the stream query answers exactly - rerouting every XFB-inactive draw "
"would trade a correct count for a wrong one, so MobileGL arms nothing and the "
"capture-less query keeps the driver's answers (" +
facts + ")");
return;
}
fail("the stream query answers 0 for a draw made with no capture span open and the "
"device offers no working statistics substitute; an application sizing a capture "
"buffer from GL_PRIMITIVES_GENERATED gets 0 (" +
facts + ")");
return;
}
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
break;
}
fail("the probe reached no verdict - the answers fit neither the defect nor health, and "
"MobileGL declines to repair a driver it does not understand (" +
facts + ")");
}
// Native iterationRP compute witness. This deliberately uses a separate
// throwaway Vulkan device rather than the real renderer's queues, and it
// treats MOBILEGL_MAGMA_DISABLE_SUBGROUP as irrelevant: the row reports what the
@@ -2652,6 +3024,10 @@ namespace MobileGL::MG_Util::SelfTest {
ProbeVulkanIterationRPWitness(builder, getInstanceProcAddr, instance, physicalDevice, computeQueueFamilyIndex,
properties, subgroupPropertiesAvailable, subgroupProperties);
ProbeVulkanPrimitivesGeneratedNoXfb(builder, getInstanceProcAddr, instance, physicalDevice,
graphicsQueueFamilyIndex, deviceExtensions, features,
vkGetPhysicalDeviceFeatures2Fn, vkGetPhysicalDeviceProperties2Fn);
if (HasVkExtension(deviceExtensions, VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME)) {
builder.Pass("VK_KHR_draw_indirect_count",
"supported (count-buffer indirect draws run as single native "
@@ -0,0 +1,413 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.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 "PersistentBufferOrderingProbe.h"
#include <MG_Util/Debug/Log.h>
#include <array>
#include <cmath>
#include <cstring>
#include <new>
namespace MobileGL::MG_Util::SelfTest {
namespace {
using MG_External::GLESFunctionsTable;
constexpr GLbitfield kPersistent = 0x0040;
constexpr GLbitfield kCoherent = 0x0080;
constexpr GLbitfield kDynamicStorage = 0x0100;
constexpr GLbitfield kMapFlags = GL_MAP_WRITE_BIT | kPersistent | kCoherent;
constexpr GLsizeiptr kArenaSize = 128 * 1024 * 1024;
constexpr GLintptr kOffset = 96 * 1024 * 1024 + 28;
constexpr GLsizei kSide = 128;
constexpr GLsizei kSlots = 8;
constexpr Int kBatches = 10;
constexpr Int kDraws = 32;
constexpr GLsizei kQuads = 64 * 32;
constexpr Int kAttempts = 3;
constexpr std::array<const char*, 3> kUploadNames = {"SubData", "Copy/persistent staging",
"Copy/SubData staging"};
enum class Shape { Unmapped, Mapped, FinishBefore, FinishBoth, MapThenUnmap, BarrierBefore };
struct Vertex { GLfloat x, y, r, g, b; };
constexpr GLsizeiptr kPayloadSize = kQuads * 6 * sizeof(Vertex);
static_assert(kOffset + kPayloadSize <= kArenaSize);
void DrainErrors(const GLESFunctionsTable& gl) {
for (Int i = 0; i < 32 && gl.glGetError() != GL_NO_ERROR; ++i) {}
}
Bool CanProbe(const GLESFunctionsTable& gl) {
if (!(gl.glGetIntegerv && gl.glGetBooleanv && gl.glGetFloatv && gl.glGetError &&
gl.glGetStringi && gl.glIsEnabled && gl.glEnable && gl.glDisable &&
gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv &&
gl.glGetShaderInfoLog && gl.glDeleteShader && gl.glCreateProgram && gl.glAttachShader &&
gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteProgram &&
gl.glUseProgram && gl.glGenBuffers && gl.glBindBuffer && gl.glBufferStorageEXT &&
gl.glMapBufferRange && gl.glUnmapBuffer && gl.glBufferData && gl.glBufferSubData &&
gl.glCopyBufferSubData && gl.glDeleteBuffers && gl.glGenVertexArrays &&
gl.glBindVertexArray && gl.glVertexAttribPointer && gl.glEnableVertexAttribArray &&
gl.glDeleteVertexArrays && gl.glGenTextures && gl.glBindTexture && gl.glTexStorage2D &&
gl.glDeleteTextures && gl.glGenFramebuffers && gl.glBindFramebuffer &&
gl.glFramebufferTexture2D && gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers &&
gl.glViewport && gl.glColorMask && gl.glClearColor && gl.glClear && gl.glDrawArrays &&
gl.glFinish && gl.glMemoryBarrier && gl.glPixelStorei && gl.glReadPixels)) return false;
DrainErrors(gl);
GLint major = 0, minor = 0, count = 0;
gl.glGetIntegerv(GL_MAJOR_VERSION, &major);
gl.glGetIntegerv(GL_MINOR_VERSION, &minor);
gl.glGetIntegerv(GL_NUM_EXTENSIONS, &count);
if (gl.glGetError() != GL_NO_ERROR || major < 3 || (major == 3 && minor < 1)) return false;
for (GLint i = 0; i < count; ++i) {
const auto* extension = gl.glGetStringi(GL_EXTENSIONS, i);
if (extension && std::strcmp(reinterpret_cast<const char*>(extension),
"GL_EXT_buffer_storage") == 0) return true;
}
return false;
}
// This probe touches no images/SSBO bindings. Keep its scope independent from the
// other POST probes, including pack state and the caller's currently active texture unit.
struct StateScope {
const GLESFunctionsTable& gl;
GLint program = 0, vao = 0, array = 0, copyRead = 0, copyWrite = 0;
GLint drawFbo = 0, readFbo = 0, texture = 0, packBuffer = 0;
GLint viewport[4]{};
GLfloat clear[4]{};
GLboolean colorMask[4]{};
static constexpr std::array<GLenum, 10> enables = {
GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, GL_CULL_FACE, GL_SCISSOR_TEST,
GL_RASTERIZER_DISCARD, GL_DITHER, GL_SAMPLE_ALPHA_TO_COVERAGE,
GL_SAMPLE_COVERAGE, GL_SAMPLE_MASK};
static constexpr std::array<GLenum, 4> packNames = {
GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS};
std::array<GLboolean, enables.size()> enabled{};
std::array<GLint, packNames.size()> pack{};
explicit StateScope(const GLESFunctionsTable& api) : gl(api) {
gl.glGetIntegerv(GL_CURRENT_PROGRAM, &program);
gl.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
gl.glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &array);
gl.glGetIntegerv(GL_COPY_READ_BUFFER_BINDING, &copyRead);
gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, &copyWrite);
gl.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo);
gl.glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &readFbo);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture);
gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &packBuffer);
gl.glGetIntegerv(GL_VIEWPORT, viewport);
gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, clear);
gl.glGetBooleanv(GL_COLOR_WRITEMASK, colorMask);
for (SizeT i = 0; i < enables.size(); ++i) enabled[i] = gl.glIsEnabled(enables[i]);
for (SizeT i = 0; i < packNames.size(); ++i) gl.glGetIntegerv(packNames[i], &pack[i]);
}
void Prepare() {
for (auto cap : enables) gl.glDisable(cap);
gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
for (auto name : packNames) gl.glPixelStorei(name, name == GL_PACK_ALIGNMENT ? 1 : 0);
gl.glViewport(0, 0, kSide, kSide);
gl.glClearColor(0, 0, 0, 1);
}
~StateScope() {
gl.glUseProgram(program);
gl.glBindVertexArray(vao);
gl.glBindBuffer(GL_ARRAY_BUFFER, array);
gl.glBindBuffer(GL_COPY_READ_BUFFER, copyRead);
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, copyWrite);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, packBuffer);
gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, drawFbo);
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, readFbo);
gl.glBindTexture(GL_TEXTURE_2D, texture);
gl.glViewport(viewport[0], viewport[1], viewport[2], viewport[3]);
gl.glClearColor(clear[0], clear[1], clear[2], clear[3]);
gl.glColorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]);
for (SizeT i = 0; i < enables.size(); ++i) {
if (enabled[i]) gl.glEnable(enables[i]); else gl.glDisable(enables[i]);
}
for (SizeT i = 0; i < packNames.size(); ++i) gl.glPixelStorei(packNames[i], pack[i]);
}
};
struct Resources {
const GLESFunctionsTable& gl;
GLuint program = 0, vao = 0;
std::array<GLuint, kSlots> fbos{}, textures{};
explicit Resources(const GLESFunctionsTable& api) : gl(api) {}
~Resources() {
gl.glDeleteFramebuffers(kSlots, fbos.data());
gl.glDeleteTextures(kSlots, textures.data());
gl.glDeleteVertexArrays(1, &vao);
if (program) gl.glDeleteProgram(program);
}
GLuint Compile(GLenum type, const char* source) {
GLuint shader = gl.glCreateShader(type);
if (!shader) return 0;
gl.glShaderSource(shader, 1, &source, nullptr);
gl.glCompileShader(shader);
GLint compiled = 0;
gl.glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512]{};
gl.glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: shader failed: %s", log);
gl.glDeleteShader(shader);
return 0;
}
return shader;
}
Bool Setup() {
const GLuint vs = Compile(GL_VERTEX_SHADER,
"#version 310 es\nlayout(location=0) in vec2 pos; layout(location=1) in vec3 color;\n"
"out highp vec3 vColor; void main(){gl_Position=vec4(pos,0,1);vColor=color;}\n");
const GLuint fs = Compile(GL_FRAGMENT_SHADER,
"#version 310 es\nprecision highp float; in highp vec3 vColor;\n"
"layout(location=0) out vec4 outColor; void main(){outColor=vec4(vColor,1);}\n");
if (vs && fs) {
program = gl.glCreateProgram();
if (program) {
gl.glAttachShader(program, vs);
gl.glAttachShader(program, fs);
gl.glLinkProgram(program);
}
}
if (vs) gl.glDeleteShader(vs);
if (fs) gl.glDeleteShader(fs);
if (!program) return false;
GLint linked = 0;
gl.glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) {
char log[512]{};
gl.glGetProgramInfoLog(program, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: link failed: %s", log);
return false;
}
gl.glUseProgram(program);
gl.glGenVertexArrays(1, &vao);
gl.glBindVertexArray(vao);
gl.glGenFramebuffers(kSlots, fbos.data());
gl.glGenTextures(kSlots, textures.data());
for (Int i = 0; i < kSlots; ++i) {
if (!vao || !fbos[i] || !textures[i]) return false;
gl.glBindTexture(GL_TEXTURE_2D, textures[i]);
gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSide, kSide);
gl.glBindFramebuffer(GL_FRAMEBUFFER, fbos[i]);
gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
textures[i], 0);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false;
}
return gl.glGetError() == GL_NO_ERROR;
}
};
struct Buffers {
const GLESFunctionsTable& gl;
GLuint arena = 0, staging = 0;
explicit Buffers(const GLESFunctionsTable& api) : gl(api) {}
~Buffers() {
// All normal batches finish before cleanup; also retire a partially queued
// batch on an error path before destroying a mapped staging source.
gl.glFinish();
gl.glDeleteBuffers(1, &arena);
gl.glDeleteBuffers(1, &staging);
}
};
void FillVertices(Vector<Vertex>& vertices, Int channel) {
constexpr std::array<Vertex, 6> quad = {{{-1,-1,0,0,0}, {1,-1,0,0,0}, {1,1,0,0,0},
{-1,-1,0,0,0}, {1,1,0,0,0}, {-1,1,0,0,0}}};
for (SizeT k = 0; k < vertices.size(); ++k) {
auto& v = vertices[k];
v = quad[k % 6];
const SizeT q = k / 6;
v.x = v.x / 64.f - 1.f + (2 * (q % 64) + 1) / 64.f;
v.y = v.y / 32.f - 1.f + (2 * (q / 64) + 1) / 32.f;
v.r = channel == 0 ? 1.f : 0.f;
v.g = channel == 1 ? 1.f : 0.f;
v.b = channel == 2 ? 1.f : 0.f;
}
}
BufferOrderingSample Run(const GLESFunctionsTable& gl, const Resources& resources,
const Vector<Uint8>& seed, Int upload, Shape shape) {
BufferOrderingSample sample;
sample.status = BufferOrderingProbeStatus::Failed;
Buffers buffers(gl);
Vector<Vertex> payload(kQuads * 6);
Vector<Uint8> pixels(kSide * kSide * 4);
DrainErrors(gl);
do {
gl.glGenBuffers(1, &buffers.arena);
if (!buffers.arena) break;
gl.glBindBuffer(GL_ARRAY_BUFFER, buffers.arena);
gl.glBufferStorageEXT(GL_ARRAY_BUFFER, kArenaSize, seed.data(), kMapFlags | kDynamicStorage);
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
if (shape != Shape::Unmapped) {
// Deliberately never dereference the destination pointer. All destination
// writes below are ordered GL commands, with no client mapping accesses.
if (!gl.glMapBufferRange(GL_ARRAY_BUFFER, 0, kArenaSize, kMapFlags)) break;
if (shape == Shape::MapThenUnmap && !gl.glUnmapBuffer(GL_ARRAY_BUFFER)) break;
}
gl.glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset));
gl.glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset + 2 * sizeof(GLfloat)));
gl.glEnableVertexAttribArray(0);
gl.glEnableVertexAttribArray(1);
void* sourceMap = nullptr;
if (upload != 0) {
gl.glGenBuffers(1, &buffers.staging);
if (!buffers.staging) break;
gl.glBindBuffer(GL_COPY_READ_BUFFER, buffers.staging);
if (upload == 1) {
gl.glBufferStorageEXT(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, kMapFlags);
sourceMap = gl.glMapBufferRange(GL_COPY_READ_BUFFER, 0, kSlots * kPayloadSize, kMapFlags);
if (!sourceMap) break;
} else {
gl.glBufferData(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, GL_STREAM_DRAW);
}
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, buffers.arena);
}
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int batch = 0; batch < kBatches; ++batch) {
for (Int slot = 0; slot < kSlots; ++slot) {
FillVertices(payload, (batch * kSlots + slot) % 3);
if (shape == Shape::FinishBefore || shape == Shape::FinishBoth) gl.glFinish();
if (shape == Shape::BarrierBefore) gl.glMemoryBarrier(GL_ALL_BARRIER_BITS);
if (upload == 0) {
gl.glBufferSubData(GL_ARRAY_BUFFER, kOffset, kPayloadSize, payload.data());
} else {
// No slot is reused until the entire batch has finished on the GPU.
if (upload == 1) {
std::memcpy(static_cast<Uint8*>(sourceMap) + slot * kPayloadSize,
payload.data(), kPayloadSize);
} else {
gl.glBufferSubData(GL_COPY_READ_BUFFER, slot * kPayloadSize,
kPayloadSize, payload.data());
}
gl.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
slot * kPayloadSize, kOffset, kPayloadSize);
}
if (shape == Shape::FinishBoth) gl.glFinish();
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glClear(GL_COLOR_BUFFER_BIT);
for (Int draw = 0; draw < kDraws; ++draw) gl.glDrawArrays(GL_TRIANGLES, 0, kQuads * 6);
}
// No readback/Finish between subject update/draw pairs. Early readback
// would hide precisely the old-reader/new-writer overlap being tested.
gl.glFinish();
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int slot = 0; slot < kSlots; ++slot) {
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glReadPixels(0, 0, kSide, kSide, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
const Int channel = (batch * kSlots + slot) % 3;
Uint bad = 0;
for (Int pixel = 0; pixel < kSide * kSide; ++pixel) {
for (Int c = 0; c < 3; ++c) {
const Int expected = c == channel ? 255 : 0;
if (std::abs(Int(pixels[pixel * 4 + c]) - expected) > 8) ++bad;
}
}
++sample.frames;
if (bad != 0) ++sample.badFrames;
sample.badComponents += bad;
}
if (sample.error != GL_NO_ERROR) break;
}
if (sample.error == GL_NO_ERROR && sample.frames == kBatches * kSlots)
sample.status = BufferOrderingProbeStatus::Complete;
} while (false);
if (sample.error == GL_NO_ERROR) sample.error = gl.glGetError();
return sample;
}
String Describe(const BufferOrderingSample& sample) {
if (sample.status == BufferOrderingProbeStatus::NotRun) return "not run";
if (sample.status == BufferOrderingProbeStatus::Failed)
return format("inconclusive (GL error 0x{:x}, {} readbacks)", sample.error, sample.frames);
return format("{}/{} bad FBOs ({} components)", sample.badFrames, sample.frames, sample.badComponents);
}
String DescribeUpload(const BufferOrderingUploadMeasurement& row, Int upload) {
return format("{}: mapped {}, never-mapped {}, Finish-before {}, Finish-both {}, "
"map-then-unmap {}, barrier-before {}", kUploadNames[upload], Describe(row.mapped),
Describe(row.unmapped), Describe(row.finishBefore), Describe(row.finishBoth),
Describe(row.mapThenUnmap), Describe(row.barrierBefore));
}
} // namespace
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(const GLESFunctionsTable& gl) try {
PersistentBufferOrderingMeasurement measurement;
if (!CanProbe(gl)) return measurement;
measurement.supported = true;
StateScope state(gl);
state.Prepare();
Resources resources(gl);
if (gl.glGetError() != GL_NO_ERROR || !resources.Setup()) {
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
MGLOG_I("[driver-bug] persistent buffer ordering: setup failed; inconclusive");
return measurement;
}
Vector<Uint8> seed(kArenaSize, 0);
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
auto& row = measurement.uploads[upload];
row.unmapped = Run(gl, resources, seed, upload, Shape::Unmapped);
if (row.unmapped.Passed()) {
// A single allocation can miss on Mali. Stop once a mismatch is measured,
// otherwise retry with fresh storage rather than treating one pass as proof.
for (Int attempt = 0; attempt < kAttempts; ++attempt) {
const auto sample = Run(gl, resources, seed, upload, Shape::Mapped);
row.mapped.status = sample.status;
row.mapped.error = sample.error;
row.mapped.frames += sample.frames;
row.mapped.badFrames += sample.badFrames;
row.mapped.badComponents += sample.badComponents;
if (sample.status != BufferOrderingProbeStatus::Complete || sample.badFrames) break;
}
if (row.mapped.status == BufferOrderingProbeStatus::Complete && row.mapped.badFrames) {
row.finishBoth = Run(gl, resources, seed, upload, Shape::FinishBoth);
row.finishBefore = Run(gl, resources, seed, upload, Shape::FinishBefore);
row.mapThenUnmap = Run(gl, resources, seed, upload, Shape::MapThenUnmap);
row.barrierBefore = Run(gl, resources, seed, upload, Shape::BarrierBefore);
}
}
MGLOG_I("[driver-bug] persistent buffer ordering: %s; %s", DescribeUpload(row, upload).c_str(),
row.Detected() ? "detected" : "not detected or inconclusive");
}
return measurement;
} catch (const std::bad_alloc&) {
// The CPU initializer is arena-sized too. An allocation failure must not discard
// the rest of the POST report or turn a partially sampled case into a finding.
MGLOG_I("[driver-bug] persistent buffer ordering: host allocation failed; inconclusive");
PersistentBufferOrderingMeasurement measurement;
measurement.supported = true;
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
return measurement;
}
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement) {
String detail;
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
if (!measurement.uploads[upload].Detected()) continue;
if (!detail.empty()) detail += "; ";
detail += DescribeUpload(measurement.uploads[upload], upload);
}
if (detail.empty()) return std::nullopt;
detail += ". A 128 MiB immutable vertex destination was mapped WRITE|PERSISTENT|COHERENT, "
"but never accessed through its client pointer. Queued uploads/draws corrupt vertex data; "
"identical never-mapped and Finish-before-and-after controls pass. "
"This POST does not enable a workaround. MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1 "
"avoids automatic arena adoption; explicit application mappings remain separate. "
"FBO counts describe this bounded stress probe, not application flicker frequency.";
return DriverBugFinding{"Persistent-mapped vertex buffers lose upload/draw ordering",
DriverBugVerdict::Unfixable, Move(detail)};
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,66 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.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
#pragma once
#include "DriverBugProbes.h"
#include <array>
namespace MobileGL::MG_Util::SelfTest {
enum class BufferOrderingProbeStatus : Uint8 { NotRun, Complete, Failed };
struct BufferOrderingSample {
BufferOrderingProbeStatus status = BufferOrderingProbeStatus::NotRun;
Uint frames = 0; // Independent FBO readbacks, not draw calls or application frames.
Uint badFrames = 0;
Uint badComponents = 0;
GLenum error = GL_NO_ERROR;
Bool Passed() const { return status == BufferOrderingProbeStatus::Complete && badFrames == 0; }
};
struct BufferOrderingUploadMeasurement {
BufferOrderingSample unmapped;
BufferOrderingSample mapped;
BufferOrderingSample finishBefore;
BufferOrderingSample finishBoth;
BufferOrderingSample mapThenUnmap;
BufferOrderingSample barrierBefore;
Bool Detected() const {
return unmapped.Passed() && finishBoth.Passed() &&
mapped.status == BufferOrderingProbeStatus::Complete && mapped.badFrames != 0;
}
};
struct PersistentBufferOrderingMeasurement {
Bool supported = false;
// SubData; CopyBufferSubData from coherent persistent staging; CopyBufferSubData
// from ordinary SubData staging. Each has its OWN otherwise-identical controls.
std::array<BufferOrderingUploadMeasurement, 3> uploads;
};
// POST-only: native GLES calls, no MobileGL buffers, renderer-name rules or config changes.
// The Mali r54p1 finding: updating an immutable vertex arena that has been persistently
// mapped can corrupt queued draws even when the application never accesses that mapping.
// Queue eight update/draw pairs into separate FBOs BEFORE any Finish/readback, then check
// every pixel of both old and new draws. Staging slots never overlap while in flight.
//
// Each upload runs a never-mapped control with identical storage flags. Try up to three
// fresh mapped allocations to catch intermittent failures. On corruption, measure explicit
// waits, map-then-unmap and a barrier as diagnostics. Only a passing never-mapped AND
// Finish-before-and-after control permits a finding. Setup/GL failures are inconclusive.
// Explicit allocations are one 128 MiB arena, its initializer, and small staging/FBOs;
// allocations, batches and draws are bounded. Every touched GL state is restored.
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(
const MG_External::GLESFunctionsTable& gl);
// Used by the POST collector. A report never labels an inconclusive sample as a bug.
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement);
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,556 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.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
//
// See the header for what is being measured and why. The plumbing here is shaped
// like the POST timestamp probe (DriverPost.cpp, ProbeVulkanTimerQuery): one
// throwaway command buffer, a bounded fence wait that deliberately leaks the
// device objects rather than idle-wait a hung GPU, and teardown on every path.
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "PrimitivesGeneratedNoXfbProbeSpv.h"
namespace MobileGL::MG_Util::SelfTest {
namespace {
template <typename Callable>
struct ProbeScopeGuard {
explicit ProbeScopeGuard(Callable callable) : onExit(Move(callable)) {}
ProbeScopeGuard(const ProbeScopeGuard&) = delete;
ProbeScopeGuard& operator=(const ProbeScopeGuard&) = delete;
~ProbeScopeGuard() { onExit(); }
private:
Callable onExit;
};
Bool AllRequiredFnsPresent(const PrimitivesGeneratedNoXfbProbeFns& fns) {
return fns.vkCreateCommandPool != nullptr && fns.vkDestroyCommandPool != nullptr &&
fns.vkAllocateCommandBuffers != nullptr && fns.vkBeginCommandBuffer != nullptr &&
fns.vkEndCommandBuffer != nullptr && fns.vkCreateQueryPool != nullptr &&
fns.vkDestroyQueryPool != nullptr && fns.vkCmdResetQueryPool != nullptr &&
fns.vkCmdBeginQuery != nullptr && fns.vkCmdEndQuery != nullptr &&
fns.vkCmdBeginQueryIndexedEXT != nullptr && fns.vkCmdEndQueryIndexedEXT != nullptr &&
fns.vkCreateRenderPass != nullptr && fns.vkDestroyRenderPass != nullptr &&
fns.vkCreateFramebuffer != nullptr && fns.vkDestroyFramebuffer != nullptr &&
fns.vkCmdBeginRenderPass != nullptr && fns.vkCmdEndRenderPass != nullptr &&
fns.vkCreateShaderModule != nullptr && fns.vkDestroyShaderModule != nullptr &&
fns.vkCreatePipelineLayout != nullptr && fns.vkDestroyPipelineLayout != nullptr &&
fns.vkCreateGraphicsPipelines != nullptr && fns.vkDestroyPipeline != nullptr &&
fns.vkCmdBindPipeline != nullptr && fns.vkCmdDraw != nullptr &&
fns.vkCreateFence != nullptr && fns.vkDestroyFence != nullptr &&
fns.vkQueueSubmit != nullptr && fns.vkWaitForFences != nullptr &&
fns.vkGetQueryPoolResults != nullptr && fns.vkDeviceWaitIdle != nullptr;
}
} // namespace
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context) {
PrimitivesGeneratedNoXfbMeasurement measurement;
const auto fail = [&](String reason) {
measurement.ran = false;
measurement.failureReason = Move(reason);
return measurement;
};
if (!context.transformFeedbackQueriesUsable) {
return fail("transform feedback stream queries are not usable on this device, so the "
"probe has no subject");
}
if (context.device == VK_NULL_HANDLE || context.queue == VK_NULL_HANDLE) {
return fail("no device/queue was supplied");
}
const PrimitivesGeneratedNoXfbProbeFns& fns = context.fns;
if (!AllRequiredFnsPresent(fns)) {
return fail("a required Vulkan entry point was not resolved");
}
VkDevice device = context.device;
// Slot i of each pool belongs to shape i (0 = triangles plain, 1 = triangles
// under discard, 2 = patches under discard). Unused slots are reset either
// way; reset needs no feature and an unqueried reset slot is never read.
constexpr Uint32 kShapeSlots = 3;
const Bool drawPatches = context.tessellationEnabled;
const Bool measureStatistics = context.pipelineStatisticsEnabled;
// Only with BOTH feature bits: without ...WithRasterizerDiscard, a
// discarding draw inside the query is invalid usage
// (VUID-vkCmdDraw-primitivesGeneratedQueryWithRasterizerDiscard-06708),
// and two of the three shapes discard.
const Bool measurePrimitivesGeneratedExt = context.primitivesGeneratedQueryUsable;
VkCommandPool commandPool = VK_NULL_HANDLE;
VkQueryPool streamQueryPool = VK_NULL_HANDLE;
VkQueryPool primitivesGeneratedQueryPool = VK_NULL_HANDLE;
VkQueryPool statisticsQueryPool = VK_NULL_HANDLE;
VkRenderPass renderPass = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
VkShaderModule vertModule = VK_NULL_HANDLE;
VkShaderModule tescModule = VK_NULL_HANDLE;
VkShaderModule teseModule = VK_NULL_HANDLE;
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
VkPipeline trianglePlainPipeline = VK_NULL_HANDLE;
VkPipeline triangleDiscardPipeline = VK_NULL_HANDLE;
VkPipeline patchDiscardPipeline = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
// Teardown on every path. When the fence wait timed out the submission may
// still be executing on a hung GPU: vkDeviceWaitIdle could block forever
// and destroying in-flight objects is undefined, so everything is
// deliberately leaked - a hung GPU must not hang the caller. The same flag
// is returned in the measurement, because a caller that OWNS the device must
// make the same choice for it (see the header): destroying a device whose
// children are alive and whose queue may still be executing is the very hang
// this bound exists to prevent.
const ProbeScopeGuard teardown([&]() {
if (measurement.fenceWaitTimedOut) {
return;
}
fns.vkDeviceWaitIdle(device);
if (fence != VK_NULL_HANDLE) fns.vkDestroyFence(device, fence, nullptr);
if (trianglePlainPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, trianglePlainPipeline, nullptr);
if (triangleDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, triangleDiscardPipeline, nullptr);
if (patchDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, patchDiscardPipeline, nullptr);
if (pipelineLayout != VK_NULL_HANDLE) fns.vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
if (vertModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, vertModule, nullptr);
if (tescModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, tescModule, nullptr);
if (teseModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, teseModule, nullptr);
if (framebuffer != VK_NULL_HANDLE) fns.vkDestroyFramebuffer(device, framebuffer, nullptr);
if (renderPass != VK_NULL_HANDLE) fns.vkDestroyRenderPass(device, renderPass, nullptr);
if (statisticsQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, statisticsQueryPool, nullptr);
if (primitivesGeneratedQueryPool != VK_NULL_HANDLE)
fns.vkDestroyQueryPool(device, primitivesGeneratedQueryPool, nullptr);
if (streamQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, streamQueryPool, nullptr);
if (commandPool != VK_NULL_HANDLE) fns.vkDestroyCommandPool(device, commandPool, nullptr);
});
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = context.queueFamilyIndex;
if (fns.vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
return fail("vkCreateCommandPool failed");
}
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
if (fns.vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
return fail("vkAllocateCommandBuffers failed");
}
VkQueryPoolCreateInfo streamPoolInfo{};
streamPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
streamPoolInfo.queryType = VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT;
streamPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &streamPoolInfo, nullptr, &streamQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(TRANSFORM_FEEDBACK_STREAM) failed");
}
if (measurePrimitivesGeneratedExt) {
VkQueryPoolCreateInfo pgqPoolInfo{};
pgqPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
pgqPoolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
pgqPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &pgqPoolInfo, nullptr, &primitivesGeneratedQueryPool) !=
VK_SUCCESS) {
return fail("vkCreateQueryPool(PRIMITIVES_GENERATED_EXT) failed");
}
}
if (measureStatistics) {
VkQueryPoolCreateInfo statPoolInfo{};
statPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
statPoolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
statPoolInfo.queryCount = kShapeSlots;
// CLIPPING_INVOCATIONS counts the primitives PROCESSED BY (i.e. reaching)
// primitive clipping - GL's CLIPPING_INPUT_PRIMITIVES - which is the
// pre-clip, post-vertex-processing set PRIMITIVES_GENERATED is defined
// over. CLIPPING_PRIMITIVES (the stage's OUTPUT count) would be wrong:
// clipping may drop or split primitives.
statPoolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
if (fns.vkCreateQueryPool(device, &statPoolInfo, nullptr, &statisticsQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(PIPELINE_STATISTICS) failed");
}
}
// Zero-attachment render pass + 1x1 framebuffer: the draw is discarded
// before rasterization, nothing is ever written, but vkCmdDraw needs a
// render pass instance to live in.
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
VkRenderPassCreateInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
if (fns.vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {
return fail("vkCreateRenderPass failed");
}
VkFramebufferCreateInfo framebufferInfo{};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.width = 1;
framebufferInfo.height = 1;
framebufferInfo.layers = 1;
if (fns.vkCreateFramebuffer(device, &framebufferInfo, nullptr, &framebuffer) != VK_SUCCESS) {
return fail("vkCreateFramebuffer failed");
}
const auto makeModule = [&](const std::uint32_t* words, std::size_t wordCount, VkShaderModule& out) {
VkShaderModuleCreateInfo moduleInfo{};
moduleInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleInfo.codeSize = wordCount * sizeof(std::uint32_t);
moduleInfo.pCode = words;
return fns.vkCreateShaderModule(device, &moduleInfo, nullptr, &out) == VK_SUCCESS;
};
if (!makeModule(kPrimitivesGeneratedNoXfbProbeVertSpv, kPrimitivesGeneratedNoXfbProbeVertSpvWordCount,
vertModule)) {
return fail("vkCreateShaderModule(vert) failed");
}
if (drawPatches) {
if (!makeModule(kPrimitivesGeneratedNoXfbProbeTescSpv, kPrimitivesGeneratedNoXfbProbeTescSpvWordCount,
tescModule) ||
!makeModule(kPrimitivesGeneratedNoXfbProbeTeseSpv, kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount,
teseModule)) {
return fail("vkCreateShaderModule(tesc/tese) failed");
}
}
VkPipelineLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
if (fns.vkCreatePipelineLayout(device, &layoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {
return fail("vkCreatePipelineLayout failed");
}
// With rasterizerDiscardEnable the viewport and multisample state are
// ignored by the spec, but well-formed ones are supplied anyway: the probe
// must never be the thing that trips a picky driver. The discard-off
// variant rasterizes into the zero-attachment subpass, which writes
// nothing anywhere.
const auto makePipeline = [&](Bool tessellated, Bool rasterizerDiscard, VkPipeline& out) {
VkPipelineShaderStageCreateInfo stages[3] = {};
Uint32 stageCount = 0;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_VERTEX_BIT;
stages[stageCount].module = vertModule;
stages[stageCount].pName = "main";
++stageCount;
if (tessellated) {
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT;
stages[stageCount].module = tescModule;
stages[stageCount].pName = "main";
++stageCount;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT;
stages[stageCount].module = teseModule;
stages[stageCount].pName = "main";
++stageCount;
}
VkPipelineVertexInputStateCreateInfo vertexInput{};
vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology =
tessellated ? VK_PRIMITIVE_TOPOLOGY_PATCH_LIST : VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineTessellationStateCreateInfo tessellation{};
tessellation.sType = VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO;
tessellation.patchControlPoints = 1;
VkViewport viewport{};
viewport.width = 1.0f;
viewport.height = 1.0f;
viewport.maxDepth = 1.0f;
VkRect2D scissor{};
scissor.extent.width = 1;
scissor.extent.height = 1;
VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;
VkPipelineRasterizationStateCreateInfo rasterization{};
rasterization.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterization.rasterizerDiscardEnable = rasterizerDiscard ? VK_TRUE : VK_FALSE;
rasterization.polygonMode = VK_POLYGON_MODE_FILL;
rasterization.cullMode = VK_CULL_MODE_NONE;
rasterization.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterization.lineWidth = 1.0f;
VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkGraphicsPipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = stageCount;
pipelineInfo.pStages = stages;
pipelineInfo.pVertexInputState = &vertexInput;
pipelineInfo.pInputAssemblyState = &inputAssembly;
pipelineInfo.pTessellationState = tessellated ? &tessellation : nullptr;
pipelineInfo.pViewportState = &viewportState;
pipelineInfo.pRasterizationState = &rasterization;
pipelineInfo.pMultisampleState = &multisample;
pipelineInfo.layout = pipelineLayout;
pipelineInfo.renderPass = renderPass;
pipelineInfo.subpass = 0;
return fns.vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &out) ==
VK_SUCCESS;
};
if (!makePipeline(false, false, trianglePlainPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles) failed");
}
if (!makePipeline(false, true, triangleDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles, discard) failed");
}
if (drawPatches && !makePipeline(true, true, patchDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(patches, discard) failed");
}
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (fns.vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
return fail("vkBeginCommandBuffer failed");
}
fns.vkCmdResetQueryPool(commandBuffer, streamQueryPool, 0, kShapeSlots);
if (measurePrimitivesGeneratedExt) {
fns.vkCmdResetQueryPool(commandBuffer, primitivesGeneratedQueryPool, 0, kShapeSlots);
}
if (measureStatistics) {
fns.vkCmdResetQueryPool(commandBuffer, statisticsQueryPool, 0, kShapeSlots);
}
VkRenderPassBeginInfo renderPassBegin{};
renderPassBegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassBegin.renderPass = renderPass;
renderPassBegin.framebuffer = framebuffer;
renderPassBegin.renderArea.extent.width = 1;
renderPassBegin.renderArea.extent.height = 1;
fns.vkCmdBeginRenderPass(commandBuffer, &renderPassBegin, VK_SUBPASS_CONTENTS_INLINE);
// Each query kind wraps ITS OWN replay of the shape's draw, never a shared
// one. Not pedantry - a co-active control CONTAMINATES the subject:
// measured on lavapipe, a dedicated primitives-generated query active
// around the same draw switches llvmpipe's primitive collection on, and
// the stream query on that draw then answers the exact count it answers 0
// for when it is alone - which is how the renderer actually runs it. A
// probe that measured them together certified this driver healthy and
// repaired nothing. The replays are identical recordings of a
// deterministic draw, so the per-shape comparison loses nothing.
const auto recordShape = [&](Uint32 slot, VkPipeline pipeline, Uint32 vertexCount) {
fns.vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
// THE SUBJECT, alone: no vkCmdBeginTransformFeedbackEXT anywhere in
// this command buffer - the stream query wraps a draw with transform
// feedback inactive, exactly the CTS's tessellator-measuring shape.
fns.vkCmdBeginQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0);
if (measurePrimitivesGeneratedExt) {
// Plain vkCmdBeginQuery: a PRIMITIVES_GENERATED_EXT query begun
// this way counts vertex stream 0, which is where every non-GS
// (and default-stream GS) primitive goes.
fns.vkCmdBeginQuery(commandBuffer, primitivesGeneratedQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, primitivesGeneratedQueryPool, slot);
}
if (measureStatistics) {
fns.vkCmdBeginQuery(commandBuffer, statisticsQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, statisticsQueryPool, slot);
}
};
recordShape(0, trianglePlainPipeline, 3); // one rasterized triangle
recordShape(1, triangleDiscardPipeline, 3); // one discarded triangle
if (drawPatches) {
// one 1-vertex patch -> one tessellated, discarded triangle
recordShape(2, patchDiscardPipeline, 1);
}
fns.vkCmdEndRenderPass(commandBuffer);
if (fns.vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
return fail("vkEndCommandBuffer failed");
}
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
if (fns.vkCreateFence(device, &fenceInfo, nullptr, &fence) != VK_SUCCESS) {
return fail("vkCreateFence failed");
}
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
if (fns.vkQueueSubmit(context.queue, 1, &submitInfo, fence) != VK_SUCCESS) {
return fail("vkQueueSubmit failed");
}
constexpr Uint64 kFenceTimeoutNs = 5'000'000'000ull; // a probe must never hang its caller
if (fns.vkWaitForFences(device, 1, &fence, VK_TRUE, kFenceTimeoutNs) != VK_SUCCESS) {
// Set BEFORE failing: the scope guard reads it to skip every destroy, and
// the caller reads it out of the measurement to skip destroying the device.
measurement.fenceWaitTimedOut = true;
return fail("the probe submission did not complete within 5 s");
}
const auto readShape = [&](Uint32 slot, Uint64 expected, PrimitivesGeneratedNoXfbShapeMeasurement& out) {
Uint64 streamPair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
if (fns.vkGetQueryPoolResults(device, streamQueryPool, slot, 1, sizeof(streamPair), streamPair,
sizeof(streamPair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) != VK_SUCCESS) {
return false;
}
out.drawn = true;
out.expectedPrimitives = expected;
out.streamGenerated = streamPair[1];
if (measurePrimitivesGeneratedExt) {
Uint64 generated = 0;
if (fns.vkGetQueryPoolResults(device, primitivesGeneratedQueryPool, slot, 1, sizeof(generated),
&generated, sizeof(generated),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.primitivesGeneratedExtMeasured = true;
out.primitivesGeneratedExt = generated;
}
}
if (measureStatistics) {
Uint64 clippingInput = 0;
if (fns.vkGetQueryPoolResults(device, statisticsQueryPool, slot, 1, sizeof(clippingInput),
&clippingInput, sizeof(clippingInput),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.statisticsMeasured = true;
out.statisticsClippingInput = clippingInput;
}
}
return true;
};
if (!readShape(0, 1, measurement.trianglesPlain)) {
return fail("vkGetQueryPoolResults(triangles) failed");
}
if (!readShape(1, 1, measurement.trianglesDiscard)) {
return fail("vkGetQueryPoolResults(triangles, discard) failed");
}
if (drawPatches && !readShape(2, 1, measurement.patchesDiscard)) {
return fail("vkGetQueryPoolResults(patches, discard) failed");
}
measurement.ran = true;
return measurement;
}
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement) {
if (!measurement.ran || !measurement.trianglesPlain.drawn || !measurement.trianglesDiscard.drawn) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
const PrimitivesGeneratedNoXfbShapeMeasurement* shapes[3] = {&measurement.trianglesPlain,
&measurement.trianglesDiscard,
&measurement.patchesDiscard};
Bool anyStreamSilent = false;
Bool allStreamExact = true;
Bool allPrimitivesGeneratedExtExact = true;
Bool allStatisticsExact = true;
// Whether the statistics substitute DOMINATES the stream query shape by shape:
// every shape the statistics do not answer exactly must be one the stream query
// answered 0 for anyway. Without this, a plain-shape-only substitute could be
// armed on a device whose stream query was RIGHT on a shape the statistics get
// wrong - and the renderer reroutes every XFB-inactive draw, so that shape would
// be downgraded from correct to wrong. "Never worse per draw" is what makes
// arming on an uncharacterised driver defensible; it has to be measured, not
// assumed.
Bool statisticsDominateStream = true;
for (const auto* shape : shapes) {
if (!shape->drawn) {
continue;
}
if (shape->streamGenerated == 0) {
anyStreamSilent = true;
}
if (shape->streamGenerated != shape->expectedPrimitives) {
allStreamExact = false;
// A nonzero wrong answer is neither the defect nor health: refuse
// a verdict rather than repair a driver the probe does not
// understand.
if (shape->streamGenerated != 0) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
}
if (!shape->primitivesGeneratedExtMeasured ||
shape->primitivesGeneratedExt != shape->expectedPrimitives) {
allPrimitivesGeneratedExtExact = false;
}
if (!shape->statisticsMeasured ||
shape->statisticsClippingInput != shape->expectedPrimitives) {
allStatisticsExact = false;
// Only a shape the stream query was silent on may be left behind by
// the substitute; a shape it answered exactly must not be traded away.
if (shape->streamGenerated == shape->expectedPrimitives) {
statisticsDominateStream = false;
}
}
}
if (allStreamExact) {
return PrimitivesGeneratedNoXfbVerdict::StreamCounts;
}
// At this point at least one drawn shape answered exactly 0.
MOBILEGL_ASSERT(anyStreamSilent, "verdict fell through with no silent shape");
if (allPrimitivesGeneratedExtExact) {
return PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute;
}
if (allStatisticsExact) {
return PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute;
}
const auto& plain = measurement.trianglesPlain;
const Bool plainStatisticsExact =
plain.statisticsMeasured && plain.statisticsClippingInput == plain.expectedPrimitives;
// Both halves are required: the substitute must repair the plain shape, AND it
// must not cost any shape an answer the stream query already had right.
return (plainStatisticsExact && statisticsDominateStream)
? PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly
: PrimitivesGeneratedNoXfbVerdict::Unfixable;
}
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled) {
switch (overrideSetting) {
case MG_Config::QuirkOverride::ForceOff:
return PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::ForceOn:
// ForceOn bypasses the device verdict, never the structural checks:
// without a hostable pool there is nothing to route through. The
// dedicated query wins where both exist - its semantics are the GL
// target's by definition.
if (primitivesGeneratedQueryUsable) {
return PrimGenRerouteKind::PrimitivesGeneratedExt;
}
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::Auto:
break;
}
switch (verdict) {
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
return primitivesGeneratedQueryUsable ? PrimGenRerouteKind::PrimitivesGeneratedExt
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
case PrimitivesGeneratedNoXfbVerdict::Unfixable:
break;
}
return PrimGenRerouteKind::None;
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,258 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.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
#pragma once
#include <Config.h>
#include <Includes.h>
namespace MobileGL::MG_Util::SelfTest {
// ============ PRIMITIVES GENERATED WITHOUT TRANSFORM FEEDBACK ============
//
// GL_PRIMITIVES_GENERATED counts what the last vertex processing stage emits
// whether or not a transform feedback capture is active (GL 4.6 core 13.4), and
// the DirectVulkan backend serves it from the second result
// (primitivesNeeded) of a VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT pool
// slot wrapped around each draw. VK_EXT_transform_feedback defines that value
// as the primitives the vertex stream produced, capture or no capture - but a
// Mali driver (G1-Ultra, observed against the gl44/gl45/gl46 CTS) answers 0
// for every draw made while no vkCmdBeginTransformFeedbackEXT span is open,
// while answering exactly right as soon as one is. The tessellation suites
// measure the tessellator by exactly that shape (rasterizer discard on,
// transform feedback INACTIVE, a PATCHES draw inside a GENERATED query;
// esextcTessellationShaderUtils.cpp, captureTessellationData), size their
// capture buffers from the answer, and die on the zero-byte buffer the 0
// produces - about 29 tessellation tests per tree plus all 13
// tessellation_shader.vertex bodies.
//
// THE PROBE draws three shapes through pipelines with no Xfb execution mode
// and no transform feedback begun, each inside its own stream-query slot:
// - one triangle, plainly (no rasterizer discard);
// - one triangle with rasterizer discard baked into the pipeline;
// - one PATCHES draw with discard, through a passthrough tessellation
// pipeline whose all-1 levels emit exactly one triangle (when the device
// has tessellationShader) - the CTS shape verbatim.
// Alongside each stream slot it measures the TWO candidate substitutes, each
// around ITS OWN identical replay of the shape's draw - never co-active with
// the subject, because a co-active control contaminates it: on lavapipe a
// dedicated primitives-generated query active around the same draw switches
// the driver's primitive collection on and the stream query then counts a
// draw it answers 0 for when alone, which is how the renderer actually runs
// it. The substitutes:
// - a VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT slot, where the device has
// VK_EXT_primitives_generated_query with BOTH primitivesGeneratedQuery and
// primitivesGeneratedQueryWithRasterizerDiscard (without the discard
// feature the spec forbids the query around a discarding draw at all -
// VUID-vkCmdDraw-...-06708 - and GL applications toggle discard freely, so
// a base-feature-only device cannot use this tier). The extension exists
// precisely because GL needs PRIMITIVES_GENERATED without a capture, so
// its semantics are exact by definition - what remains to prove is that
// the DRIVER's implementation is not silent in the same way its stream
// query is;
// - a VK_QUERY_TYPE_PIPELINE_STATISTICS slot counting CLIPPING_INVOCATIONS
// (when the device has pipelineStatisticsQuery): one invocation of the
// primitive clipping stage per primitive reaching it - GL's
// CLIPPING_INPUT_PRIMITIVES - which sits AFTER every vertex processing
// stage (post-tess, post-GS) and, per spec, BEFORE rasterizer discard, so
// for an XFB-inactive draw it is definitionally the number
// PRIMITIVES_GENERATED must answer. (A geometry stage's non-zero vertex
// streams never reach clipping, but non-indexed GL_PRIMITIVES_GENERATED
// counts stream 0 alone, so the sets still agree. The stage's OUTPUT
// count - CLIPPING_PRIMITIVES - would not: clipping drops and splits.)
//
// THE CONTROL DISCIPLINE (DriverBugProbes.h): the substitute slots are the
// probe's controls, and the DISCARD dimension is measured separately because
// it is a real fault line, not paranoia: Mesa llvmpipe short-circuits its
// clipping statistics under rasterizer discard (reading 0 there while counting
// the identical undiscarded draw exactly) while its dedicated
// primitives-generated query counts both - measured 2026-08, and the reason
// the verdict ranks the dedicated query first. A substitute qualifies only by
// answering the exact expected count on every shape it is required to cover;
// a device where no substitute qualifies even for the plain shape gets none
// (the honest verdict is the current behaviour); anything that fits neither
// the defect nor health is INCONCLUSIVE and must never arm anything. The
// expected counts are exact (1 triangle per shape), not merely nonzero, so a
// driver that half-counts cannot arm a half-right repair.
// What one drawn shape of the probe measured.
struct PrimitivesGeneratedNoXfbShapeMeasurement {
// The shape's draw was recorded and its query slots were read back.
Bool drawn = false;
// Primitives the draw is defined to emit (1 for every shape).
Uint64 expectedPrimitives = 0;
// The stream-query slot's primitivesNeeded answer - what the renderer's
// GL_PRIMITIVES_GENERATED path would have returned.
Uint64 streamGenerated = 0;
// Whether the dedicated primitives-generated slot ran (it needs the
// extension with both feature bits, see above).
Bool primitivesGeneratedExtMeasured = false;
// Its answer for the same draw.
Uint64 primitivesGeneratedExt = 0;
// Whether the statistics slot ran (it needs pipelineStatisticsQuery).
Bool statisticsMeasured = false;
// The clipping-stage invocation count for the same draw.
Uint64 statisticsClippingInput = 0;
};
struct PrimitivesGeneratedNoXfbMeasurement {
// The probe submitted and read back at least the two triangle shapes.
// False when any setup step failed; failureReason then names the step.
Bool ran = false;
// The probe's bounded fence wait expired with the submission possibly
// still executing. The probe then deliberately LEAKED every child object
// it created (no vkDeviceWaitIdle, no destroys - a hung GPU must not hang
// the caller), so a caller that owns the device MUST NOT destroy or
// idle-wait it either: vkDestroyDevice with live children and in-flight
// work is the exact hang the bound exists to prevent. The POST leaks its
// throwaway device on this flag, mirroring its sibling probes.
Bool fenceWaitTimedOut = false;
String failureReason;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesPlain;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesDiscard;
// drawn = false when the device has no tessellationShader.
PrimitivesGeneratedNoXfbShapeMeasurement patchesDiscard;
};
// The device-level entry points the probe records with. Supplied by the caller
// because the two callers resolve them differently: the renderer passes its
// statically linked symbols (and its vkGetDeviceProcAddr-resolved EXT
// pointers), the driver POST passes vkGetInstanceProcAddr trampolines.
struct PrimitivesGeneratedNoXfbProbeFns {
PFN_vkCreateCommandPool vkCreateCommandPool = nullptr;
PFN_vkDestroyCommandPool vkDestroyCommandPool = nullptr;
PFN_vkAllocateCommandBuffers vkAllocateCommandBuffers = nullptr;
PFN_vkBeginCommandBuffer vkBeginCommandBuffer = nullptr;
PFN_vkEndCommandBuffer vkEndCommandBuffer = nullptr;
PFN_vkCreateQueryPool vkCreateQueryPool = nullptr;
PFN_vkDestroyQueryPool vkDestroyQueryPool = nullptr;
PFN_vkCmdResetQueryPool vkCmdResetQueryPool = nullptr;
PFN_vkCmdBeginQuery vkCmdBeginQuery = nullptr;
PFN_vkCmdEndQuery vkCmdEndQuery = nullptr;
PFN_vkCmdBeginQueryIndexedEXT vkCmdBeginQueryIndexedEXT = nullptr;
PFN_vkCmdEndQueryIndexedEXT vkCmdEndQueryIndexedEXT = nullptr;
PFN_vkCreateRenderPass vkCreateRenderPass = nullptr;
PFN_vkDestroyRenderPass vkDestroyRenderPass = nullptr;
PFN_vkCreateFramebuffer vkCreateFramebuffer = nullptr;
PFN_vkDestroyFramebuffer vkDestroyFramebuffer = nullptr;
PFN_vkCmdBeginRenderPass vkCmdBeginRenderPass = nullptr;
PFN_vkCmdEndRenderPass vkCmdEndRenderPass = nullptr;
PFN_vkCreateShaderModule vkCreateShaderModule = nullptr;
PFN_vkDestroyShaderModule vkDestroyShaderModule = nullptr;
PFN_vkCreatePipelineLayout vkCreatePipelineLayout = nullptr;
PFN_vkDestroyPipelineLayout vkDestroyPipelineLayout = nullptr;
PFN_vkCreateGraphicsPipelines vkCreateGraphicsPipelines = nullptr;
PFN_vkDestroyPipeline vkDestroyPipeline = nullptr;
PFN_vkCmdBindPipeline vkCmdBindPipeline = nullptr;
PFN_vkCmdDraw vkCmdDraw = nullptr;
PFN_vkCreateFence vkCreateFence = nullptr;
PFN_vkDestroyFence vkDestroyFence = nullptr;
PFN_vkQueueSubmit vkQueueSubmit = nullptr;
PFN_vkWaitForFences vkWaitForFences = nullptr;
PFN_vkGetQueryPoolResults vkGetQueryPoolResults = nullptr;
PFN_vkDeviceWaitIdle vkDeviceWaitIdle = nullptr;
};
struct PrimitivesGeneratedNoXfbProbeContext {
VkDevice device = VK_NULL_HANDLE;
VkQueue queue = VK_NULL_HANDLE;
Uint32 queueFamilyIndex = 0;
// The device was created with VK_EXT_transform_feedback, its
// transformFeedback feature, and advertises transformFeedbackQueries.
// Without this the probe has no subject and reports "did not run".
Bool transformFeedbackQueriesUsable = false;
// The device was created with VK_EXT_primitives_generated_query and BOTH
// its primitivesGeneratedQuery and ...WithRasterizerDiscard features;
// gates the dedicated-query control slots.
Bool primitivesGeneratedQueryUsable = false;
// The device was created with the pipelineStatisticsQuery feature; gates
// the statistics control slots. A probe with no control at all can still
// DETECT, but never qualifies a substitute.
Bool pipelineStatisticsEnabled = false;
// The device was created with the tessellationShader feature; gates the
// PATCHES shape.
Bool tessellationEnabled = false;
PrimitivesGeneratedNoXfbProbeFns fns;
};
// Records, submits and reads back the probe. Synchronous: waits on its own
// fence (bounded; on timeout it deliberately leaks its device objects rather
// than idle-wait a possibly hung GPU, mirroring the POST timestamp probe) and
// destroys everything it created. Never touches MG_State or renderer state -
// the caller only lends it a device and an otherwise idle queue.
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context);
// The verdict vocabulary. Pure function of the measurement, split from the
// Vulkan plumbing so a unit test can pin every mapping with synthetic numbers.
enum class PrimitivesGeneratedNoXfbVerdict : Uint8 {
// The probe did not run, or answered something that is neither healthy nor
// the defect (a half-count, a nonzero-but-wrong stream answer). Must never
// arm the reroute and must never be reported as the bug.
Inconclusive,
// Every drawn shape's stream query answered its exact expected count: the
// driver counts XFB-inactive draws and the existing path is correct.
StreamCounts,
// The defect is present (a drawn shape's stream query answered exactly 0)
// and the dedicated primitives-generated query answered every drawn shape
// exactly, the rasterizer-discard shapes included: the substitution is
// proven whole through the query Vulkan defines for exactly this GL
// target.
PrimitivesGeneratedExtSubstitute,
// The defect is present, the dedicated query did not qualify (absent, or
// silent like the stream query), and the statistics control answered EVERY
// drawn shape exactly - discard shapes included: the substitution is
// proven whole through clipping statistics.
StatisticsSubstitute,
// The defect is present and the statistics control is exact on the PLAIN
// shape but not on every drawn shape (llvmpipe's discard short-circuit
// does this to its statistics - its dedicated query is what rescues it to
// the verdict above). This verdict additionally GUARANTEES domination:
// every shape the statistics missed measured exactly 0 through the stream
// query too, so rerouting is never worse per draw - it repairs every
// shape the statistics answer exactly and leaves the rest at the 0 they
// already read. A measurement where the stream was EXACT on a shape the
// statistics missed does not qualify (rerouting would downgrade that
// shape) and falls to Unfixable instead. The shapes the substitute
// misses - the CTS's discarded shapes among them wherever they are the
// missed ones - stay broken, and the report must say which.
StatisticsSubstitutePlainOnly,
// The defect is present and no substitute qualifies: none is exact
// everywhere, and the plain-only fallback either misses the plain shape
// or fails the domination rule above. The honest verdict is the current
// behaviour.
Unfixable,
};
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement);
// Which query pool the renderer routes GL_PRIMITIVES_GENERATED accumulation
// for XFB-inactive draws through.
enum class PrimGenRerouteKind : Uint8 {
None,
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (needs the extension with both
// feature bits - see the context flag).
PrimitivesGeneratedExt,
// VK_QUERY_TYPE_PIPELINE_STATISTICS over clipping invocations (needs
// pipelineStatisticsQuery).
ClippingStatistics,
};
// The arming decision. Pure, so the override mapping is unit-pinnable:
// - ForceOff never reroutes;
// - ForceOn bypasses the verdict but never the structural checks: it takes
// the dedicated query where the device can host it, the statistics pool
// where only that exists, and nothing where neither does;
// - Auto follows the verdict: the dedicated query on
// PrimitivesGeneratedExtSubstitute, the statistics pool on
// StatisticsSubstitute and StatisticsSubstitutePlainOnly (each already
// implies its feature-backed control), and nothing otherwise.
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled);
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,24 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tesc
// 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
//
// Tessellation control stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Every level is 1, so with
// the evaluation stage's triangles domain the tessellator emits exactly one
// triangle per patch - the expected count the probe checks the queries against.
#version 450
layout(vertices = 1) out;
void main() {
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
@@ -0,0 +1,19 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tese
// 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
//
// Tessellation evaluation stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Triangles domain: with the
// control stage's all-1 levels the tessellator emits exactly one triangle per
// patch. Like the vertex stage, it deliberately carries no Xfb execution mode.
#version 450
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
@@ -0,0 +1,22 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.vert
// 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
//
// Vertex stage of the primitives-generated-without-transform-feedback probe
// (PrimitivesGeneratedNoXfbProbe.cpp), used by both triangle shapes (with and
// without rasterizer discard) and as the tessellation shapes' vertex stage.
// Deliberately carries NO Xfb execution mode: the probe's whole subject is what
// the transform-feedback stream query answers for a pipeline that captures
// nothing. Positions are distinct (a full-viewport triangle per three vertices)
// so no driver can excuse the primitive as degenerate before it reaches
// primitive assembly.
#version 450
void main() {
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
gl_Position = vec4(corners[gl_VertexIndex % 3], 0.0, 1.0);
}
@@ -0,0 +1,153 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbeSpv.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
// Generated from PrimitivesGeneratedNoXfbProbe.{vert,tesc,tese} with:
// glslangValidator --target-env vulkan1.1 -V PrimitivesGeneratedNoXfbProbe.<stage>
// (SPIR-V words dumped little-endian, six per line.)
//
// Regenerate whenever a probe shader changes; nothing else in the probe depends on
// the exact binary. None of the modules carries an Xfb execution mode - that is the
// probe's subject, see PrimitivesGeneratedNoXfbProbe.cpp.
#pragma once
#include <cstddef>
#include <cstdint>
namespace MobileGL::MG_Util::SelfTest {
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeVertSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000002a, 0x00000000, 0x00020011,
0x00000001, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000000,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x0000001a, 0x00030003,
0x00000002, 0x000001c2, 0x00040005, 0x00000004, 0x6e69616d, 0x00000000,
0x00060005, 0x0000000b, 0x505f6c67, 0x65567265, 0x78657472, 0x00000000,
0x00060006, 0x0000000b, 0x00000000, 0x505f6c67, 0x7469736f, 0x006e6f69,
0x00070006, 0x0000000b, 0x00000001, 0x505f6c67, 0x746e696f, 0x657a6953,
0x00000000, 0x00070006, 0x0000000b, 0x00000002, 0x435f6c67, 0x4470696c,
0x61747369, 0x0065636e, 0x00070006, 0x0000000b, 0x00000003, 0x435f6c67,
0x446c6c75, 0x61747369, 0x0065636e, 0x00030005, 0x0000000d, 0x00000000,
0x00060005, 0x0000001a, 0x565f6c67, 0x65747265, 0x646e4978, 0x00007865,
0x00050005, 0x0000001f, 0x65646e69, 0x6c626178, 0x00000065, 0x00030047,
0x0000000b, 0x00000002, 0x00050048, 0x0000000b, 0x00000000, 0x0000000b,
0x00000000, 0x00050048, 0x0000000b, 0x00000001, 0x0000000b, 0x00000001,
0x00050048, 0x0000000b, 0x00000002, 0x0000000b, 0x00000003, 0x00050048,
0x0000000b, 0x00000003, 0x0000000b, 0x00000004, 0x00040047, 0x0000001a,
0x0000000b, 0x0000002a, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040017, 0x00000007,
0x00000006, 0x00000004, 0x00040015, 0x00000008, 0x00000020, 0x00000000,
0x0004002b, 0x00000008, 0x00000009, 0x00000001, 0x0004001c, 0x0000000a,
0x00000006, 0x00000009, 0x0006001e, 0x0000000b, 0x00000007, 0x00000006,
0x0000000a, 0x0000000a, 0x00040020, 0x0000000c, 0x00000003, 0x0000000b,
0x0004003b, 0x0000000c, 0x0000000d, 0x00000003, 0x00040015, 0x0000000e,
0x00000020, 0x00000001, 0x0004002b, 0x0000000e, 0x0000000f, 0x00000000,
0x00040017, 0x00000010, 0x00000006, 0x00000002, 0x0004002b, 0x00000008,
0x00000011, 0x00000003, 0x0004001c, 0x00000012, 0x00000010, 0x00000011,
0x0004002b, 0x00000006, 0x00000013, 0xbf800000, 0x0005002c, 0x00000010,
0x00000014, 0x00000013, 0x00000013, 0x0004002b, 0x00000006, 0x00000015,
0x40400000, 0x0005002c, 0x00000010, 0x00000016, 0x00000015, 0x00000013,
0x0005002c, 0x00000010, 0x00000017, 0x00000013, 0x00000015, 0x0006002c,
0x00000012, 0x00000018, 0x00000014, 0x00000016, 0x00000017, 0x00040020,
0x00000019, 0x00000001, 0x0000000e, 0x0004003b, 0x00000019, 0x0000001a,
0x00000001, 0x0004002b, 0x0000000e, 0x0000001c, 0x00000003, 0x00040020,
0x0000001e, 0x00000007, 0x00000012, 0x00040020, 0x00000020, 0x00000007,
0x00000010, 0x0004002b, 0x00000006, 0x00000023, 0x00000000, 0x0004002b,
0x00000006, 0x00000024, 0x3f800000, 0x00040020, 0x00000028, 0x00000003,
0x00000007, 0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003,
0x000200f8, 0x00000005, 0x0004003b, 0x0000001e, 0x0000001f, 0x00000007,
0x0004003d, 0x0000000e, 0x0000001b, 0x0000001a, 0x0005008b, 0x0000000e,
0x0000001d, 0x0000001b, 0x0000001c, 0x0003003e, 0x0000001f, 0x00000018,
0x00050041, 0x00000020, 0x00000021, 0x0000001f, 0x0000001d, 0x0004003d,
0x00000010, 0x00000022, 0x00000021, 0x00050051, 0x00000006, 0x00000025,
0x00000022, 0x00000000, 0x00050051, 0x00000006, 0x00000026, 0x00000022,
0x00000001, 0x00070050, 0x00000007, 0x00000027, 0x00000025, 0x00000026,
0x00000023, 0x00000024, 0x00050041, 0x00000028, 0x00000029, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000029, 0x00000027, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeVertSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTescSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000001d, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000001,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000b, 0x0000001a, 0x00040010,
0x00000004, 0x0000001a, 0x00000001, 0x00030003, 0x00000002, 0x000001c2,
0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00070005, 0x0000000b,
0x545f6c67, 0x4c737365, 0x6c657665, 0x6574754f, 0x00000072, 0x00070005,
0x0000001a, 0x545f6c67, 0x4c737365, 0x6c657665, 0x656e6e49, 0x00000072,
0x00040047, 0x0000000b, 0x0000000b, 0x0000000b, 0x00030047, 0x0000000b,
0x0000000f, 0x00040047, 0x0000001a, 0x0000000b, 0x0000000c, 0x00030047,
0x0000001a, 0x0000000f, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040015, 0x00000007,
0x00000020, 0x00000000, 0x0004002b, 0x00000007, 0x00000008, 0x00000004,
0x0004001c, 0x00000009, 0x00000006, 0x00000008, 0x00040020, 0x0000000a,
0x00000003, 0x00000009, 0x0004003b, 0x0000000a, 0x0000000b, 0x00000003,
0x00040015, 0x0000000c, 0x00000020, 0x00000001, 0x0004002b, 0x0000000c,
0x0000000d, 0x00000000, 0x0004002b, 0x00000006, 0x0000000e, 0x3f800000,
0x00040020, 0x0000000f, 0x00000003, 0x00000006, 0x0004002b, 0x0000000c,
0x00000011, 0x00000001, 0x0004002b, 0x0000000c, 0x00000013, 0x00000002,
0x0004002b, 0x0000000c, 0x00000015, 0x00000003, 0x0004002b, 0x00000007,
0x00000017, 0x00000002, 0x0004001c, 0x00000018, 0x00000006, 0x00000017,
0x00040020, 0x00000019, 0x00000003, 0x00000018, 0x0004003b, 0x00000019,
0x0000001a, 0x00000003, 0x00050036, 0x00000002, 0x00000004, 0x00000000,
0x00000003, 0x000200f8, 0x00000005, 0x00050041, 0x0000000f, 0x00000010,
0x0000000b, 0x0000000d, 0x0003003e, 0x00000010, 0x0000000e, 0x00050041,
0x0000000f, 0x00000012, 0x0000000b, 0x00000011, 0x0003003e, 0x00000012,
0x0000000e, 0x00050041, 0x0000000f, 0x00000014, 0x0000000b, 0x00000013,
0x0003003e, 0x00000014, 0x0000000e, 0x00050041, 0x0000000f, 0x00000016,
0x0000000b, 0x00000015, 0x0003003e, 0x00000016, 0x0000000e, 0x00050041,
0x0000000f, 0x0000001b, 0x0000001a, 0x0000000d, 0x0003003e, 0x0000001b,
0x0000000e, 0x00050041, 0x0000000f, 0x0000001c, 0x0000001a, 0x00000011,
0x0003003e, 0x0000001c, 0x0000000e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTescSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTeseSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x00000021, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000002,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x00000012, 0x00030010,
0x00000004, 0x00000016, 0x00030010, 0x00000004, 0x00000001, 0x00030010,
0x00000004, 0x00000004, 0x00030003, 0x00000002, 0x000001c2, 0x00040005,
0x00000004, 0x6e69616d, 0x00000000, 0x00060005, 0x0000000b, 0x505f6c67,
0x65567265, 0x78657472, 0x00000000, 0x00060006, 0x0000000b, 0x00000000,
0x505f6c67, 0x7469736f, 0x006e6f69, 0x00070006, 0x0000000b, 0x00000001,
0x505f6c67, 0x746e696f, 0x657a6953, 0x00000000, 0x00070006, 0x0000000b,
0x00000002, 0x435f6c67, 0x4470696c, 0x61747369, 0x0065636e, 0x00070006,
0x0000000b, 0x00000003, 0x435f6c67, 0x446c6c75, 0x61747369, 0x0065636e,
0x00030005, 0x0000000d, 0x00000000, 0x00060005, 0x00000012, 0x545f6c67,
0x43737365, 0x64726f6f, 0x00000000, 0x00030047, 0x0000000b, 0x00000002,
0x00050048, 0x0000000b, 0x00000000, 0x0000000b, 0x00000000, 0x00050048,
0x0000000b, 0x00000001, 0x0000000b, 0x00000001, 0x00050048, 0x0000000b,
0x00000002, 0x0000000b, 0x00000003, 0x00050048, 0x0000000b, 0x00000003,
0x0000000b, 0x00000004, 0x00040047, 0x00000012, 0x0000000b, 0x0000000d,
0x00020013, 0x00000002, 0x00030021, 0x00000003, 0x00000002, 0x00030016,
0x00000006, 0x00000020, 0x00040017, 0x00000007, 0x00000006, 0x00000004,
0x00040015, 0x00000008, 0x00000020, 0x00000000, 0x0004002b, 0x00000008,
0x00000009, 0x00000001, 0x0004001c, 0x0000000a, 0x00000006, 0x00000009,
0x0006001e, 0x0000000b, 0x00000007, 0x00000006, 0x0000000a, 0x0000000a,
0x00040020, 0x0000000c, 0x00000003, 0x0000000b, 0x0004003b, 0x0000000c,
0x0000000d, 0x00000003, 0x00040015, 0x0000000e, 0x00000020, 0x00000001,
0x0004002b, 0x0000000e, 0x0000000f, 0x00000000, 0x00040017, 0x00000010,
0x00000006, 0x00000003, 0x00040020, 0x00000011, 0x00000001, 0x00000010,
0x0004003b, 0x00000011, 0x00000012, 0x00000001, 0x00040017, 0x00000013,
0x00000006, 0x00000002, 0x0004002b, 0x00000006, 0x00000016, 0x40000000,
0x0004002b, 0x00000006, 0x00000018, 0x3f800000, 0x0004002b, 0x00000006,
0x0000001b, 0x00000000, 0x00040020, 0x0000001f, 0x00000003, 0x00000007,
0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8,
0x00000005, 0x0004003d, 0x00000010, 0x00000014, 0x00000012, 0x0007004f,
0x00000013, 0x00000015, 0x00000014, 0x00000014, 0x00000000, 0x00000001,
0x0005008e, 0x00000013, 0x00000017, 0x00000015, 0x00000016, 0x00050050,
0x00000013, 0x00000019, 0x00000018, 0x00000018, 0x00050083, 0x00000013,
0x0000001a, 0x00000017, 0x00000019, 0x00050051, 0x00000006, 0x0000001c,
0x0000001a, 0x00000000, 0x00050051, 0x00000006, 0x0000001d, 0x0000001a,
0x00000001, 0x00070050, 0x00000007, 0x0000001e, 0x0000001c, 0x0000001d,
0x0000001b, 0x00000018, 0x00050041, 0x0000001f, 0x00000020, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000020, 0x0000001e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv[0]);
} // namespace MobileGL::MG_Util::SelfTest
@@ -154,6 +154,20 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// demoted module is the one that works everywhere, so it is what a standalone compile
// (an internal shader object, a unit test) gets.
Bool ConsumesFloat64Natively() const { return HasBackend() && params.SupportsShaderFloat64; }
// Whether the tessellation / geometry gl_PointSize demotion is ARMED for this env -
// i.e. the backend declared it cannot host the capability. Deliberately requiring a
// backend, opposite in shape to ConsumesFloat64Natively's fallback but for the same
// conservatism: the fp64 demotion is the module that works everywhere, while this
// one rewrites interfaces and capture names, so the no-backend answer (standalone
// compiles, unit tests) is the untouched module. Like nativeFloat64, each bit is L1
// key material of its own (SpirvTranslationKeyInputs), never part of the frontend
// fingerprint: glslang produces the same thing either way.
Bool DemotesTessellationPointSize() const {
return HasBackend() && !params.SupportsTessellationPointSize;
}
Bool DemotesGeometryPointSize() const {
return HasBackend() && !params.SupportsGeometryPointSize;
}
// Matches the historical rule exactly: with no active backend every extension counts
// as advertised, because the frontend then has nothing to gate against.
Bool IsExtensionAdvertised(GLExtension extension) const {
@@ -52,6 +52,7 @@
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeResourceArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "SpirvPasses/DemotePointSizePass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -816,6 +817,326 @@ namespace MobileGL {
return false;
}
namespace {
namespace opt_analysis = spvtools::opt::analysis;
// Locations one value of `type` consumes (GL 4.6 core 11.1.2.1). Unknown
// shapes OVERESTIMATE (4) rather than fail: this feeds the free-location
// choice for the demoted point-size carrier, where an overestimate wastes a
// couple of slots and an underestimate aliases a live varying.
Uint32 ConservativeLocationSpan(const opt_analysis::Type* type) {
constexpr Uint32 kUnknownSpan = 4;
if (type == nullptr) return kUnknownSpan;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
type->AsBool() != nullptr) {
return 1u;
}
if (const auto* vector = type->AsVector()) {
// 64-bit INTEGER elements count exactly like 64-bit floats:
// ARB_gpu_shader_int64 extends 11.1.2.1's double-precision rule
// verbatim to i64/u64, and DirectVulkan advertises that extension
// unconditionally - so answering "one location" for an i64vec4 would
// place the carrier on the SECOND location that varying already owns,
// which is the underestimate this function's header forbids.
const auto* element = vector->element_type();
const auto* elementFloat = element->AsFloat();
const auto* elementInteger = element->AsInteger();
const Bool is64Bit = (elementFloat != nullptr && elementFloat->width() == 64) ||
(elementInteger != nullptr && elementInteger->width() == 64);
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
return ConservativeLocationSpan(matrix->element_type()) * matrix->element_count();
}
if (const auto* array = type->AsArray()) {
const auto& lengthWords = array->length_info().words;
if (lengthWords.size() != 2 ||
lengthWords[0] !=
static_cast<Uint32>(opt_analysis::Array::LengthInfo::kConstant)) {
return kUnknownSpan;
}
return ConservativeLocationSpan(array->element_type()) * std::max(lengthWords[1], 1u);
}
if (const auto* strct = type->AsStruct()) {
Uint32 sum = 0;
for (const auto* member : strct->element_types()) {
sum += ConservativeLocationSpan(member);
}
return std::max(sum, 1u);
}
return kUnknownSpan;
}
// One BuildModule per module answers all three questions the program-scoped
// demotion driver asks: which point-size capability the module declares, and
// one past the highest Input/Output location slot it consumes (so the carrier
// can be placed beyond every varying of every stage).
struct PointSizeModuleProbe {
Bool parsed = false;
Bool declaresTessellationPointSize = false;
Bool declaresGeometryPointSize = false;
Uint32 locationSlotEnd = 0;
};
PointSizeModuleProbe ProbePointSizeModule(const Vector<Uint32>& spirv) {
PointSizeModuleProbe probe;
if (spirv.empty()) {
probe.parsed = true; // an absent stage constrains nothing
return probe;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ProbePointSizeModule"), spirv.data(),
spirv.size());
if (!context) return probe;
probe.parsed = true;
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize) {
probe.declaresTessellationPointSize = true;
} else if (declared == spv::Capability::GeometryPointSize) {
probe.declaresGeometryPointSize = true;
}
}
spv::ExecutionModel model = spv::ExecutionModel::Max;
for (spvtools::opt::Instruction& entryPoint : context->module()->entry_points()) {
model = static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0));
break;
}
// Per-vertex interfaces are arrayed one level deeper than the locations
// they consume; peel that level, but never off a per-patch output.
const Bool peelInputs = model == spv::ExecutionModel::TessellationControl ||
model == spv::ExecutionModel::TessellationEvaluation ||
model == spv::ExecutionModel::Geometry;
const Bool peelOutputs = model == spv::ExecutionModel::TessellationControl;
std::unordered_set<Uint32> patchDecorated;
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Patch) {
patchDecorated.insert(annotation.GetSingleWordInOperand(0));
}
}
auto* defUse = context->get_def_use_mgr();
auto* typeMgr = context->get_type_mgr();
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Location) {
const Uint32 location = annotation.GetSingleWordInOperand(2);
Uint32 span = 1;
spvtools::opt::Instruction* var =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
const auto storage =
static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
// Two location namespaces are NOT varying slots and must not
// shrink the carrier budget: vertex-stage inputs (attribute
// locations) and fragment-stage outputs (draw buffers).
if ((model == spv::ExecutionModel::Vertex &&
storage == spv::StorageClass::Input) ||
(model == spv::ExecutionModel::Fragment &&
storage == spv::StorageClass::Output)) {
continue;
}
spvtools::opt::Instruction* pointerType = defUse->GetDef(var->type_id());
if (pointerType != nullptr &&
pointerType->opcode() == spv::Op::OpTypePointer) {
const opt_analysis::Type* pointee =
typeMgr->GetType(pointerType->GetSingleWordInOperand(1));
const Bool peel =
((storage == spv::StorageClass::Input && peelInputs) ||
(storage == spv::StorageClass::Output && peelOutputs)) &&
patchDecorated.count(var->result_id()) == 0;
if (peel && pointee != nullptr && pointee->AsArray() != nullptr) {
pointee = pointee->AsArray()->element_type();
}
span = ConservativeLocationSpan(pointee);
}
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
} else if (annotation.opcode() == spv::Op::OpMemberDecorate &&
annotation.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::Location) {
const Uint32 member = annotation.GetSingleWordInOperand(1);
const Uint32 location = annotation.GetSingleWordInOperand(3);
Uint32 span = 1;
spvtools::opt::Instruction* structType =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (structType != nullptr && structType->opcode() == spv::Op::OpTypeStruct &&
member < structType->NumInOperands()) {
span = ConservativeLocationSpan(
typeMgr->GetType(structType->GetSingleWordInOperand(member)));
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
}
}
return probe;
}
// Interior boundary carrier names, spelled by the PRODUCING stage so both
// sides of one boundary agree textually as well as by location. The capture
// stage's output uses POINT_SIZE_CAPTURE_CARRIER_NAME instead. None of these
// may embed the token "gl_PointSize" - see the constant's comment.
const char* PointSizeBoundaryCarrierName(const GLenum producerStage) {
switch (producerStage) {
case GL_VERTEX_SHADER:
return "mg_PointSizeIo0";
case GL_TESS_CONTROL_SHADER:
return "mg_PointSizeIo1";
case GL_TESS_EVALUATION_SHADER:
return "mg_PointSizeIo2";
default:
return "mg_PointSizeIo0";
}
}
// Past this the carrier would sit above what a minimum-spec varying budget can
// address; such a program keeps its honest decline instead.
constexpr Uint32 kMaxDemotedPointSizeCarrierLocation = 30;
} // namespace
Bool ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
const Bool demoteTessellation, const Bool demoteGeometry,
const Bool captureRequestsPointSize, PointSizeDemotionOutcome& outcome,
const bool validateOutput, const bool enableSpirvValidation) {
outcome = {};
if (!demoteTessellation && !demoteGeometry) return true;
// The pre-rasterization chain, in pipeline order, as indices into `modules`.
Int stageIndex[4] = {-1, -1, -1, -1}; // VS, TCS, TES, GS
for (SizeT i = 0; i < shaderTypes.size() && i < modules.size(); ++i) {
switch (shaderTypes[i]) {
case GL_VERTEX_SHADER: stageIndex[0] = static_cast<Int>(i); break;
case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast<Int>(i); break;
case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast<Int>(i); break;
case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast<Int>(i); break;
default: break;
}
}
if (stageIndex[1] < 0 && stageIndex[2] < 0 && stageIndex[3] < 0) return true;
// One probe per module: the capability facts arm the verdict, the location
// scan places the carrier past every varying of every stage (the location is
// shared program-wide, so it has to clear all of them at once).
Bool anyTessellationUse = false;
Bool anyGeometryUse = false;
Uint32 carrierLocation = 0;
for (const auto& module : modules) {
const PointSizeModuleProbe probe = ProbePointSizeModule(module);
if (!probe.parsed) {
// Unparseable is not a verdict; the module is already broken for
// other reasons and owns its own failure.
return true;
}
anyTessellationUse |= probe.declaresTessellationPointSize;
anyGeometryUse |= probe.declaresGeometryPointSize;
carrierLocation = std::max(carrierLocation, probe.locationSlotEnd);
}
if (!((anyTessellationUse && demoteTessellation) ||
(anyGeometryUse && demoteGeometry))) {
return true;
}
if (carrierLocation > kMaxDemotedPointSizeCarrierLocation) {
outcome.declineDetail = std::format(
"the program's varyings already reach location {}, past the carrier budget",
carrierLocation);
return true;
}
// GL 4.6 core 13.3: capture reads the last capture-capable stage - geometry,
// else evaluation, else the vertex stage (whose built-in needs no demotion).
const Int captureStage = stageIndex[3] >= 0 ? 3 : (stageIndex[2] >= 0 ? 2 : -1);
constexpr GLenum kStageEnum[4] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER};
// Back to front, so each stage's "I now read the carrier" report can force the
// producing stage's output carrier into existence - Vulkan requires every
// consumed input to be produced (VUID-RuntimeSpirv-OpEntryPoint-08743), and an
// ES link may reject a statically read input with no producing output.
Vector<Vector<Uint32>> rewritten(modules.size());
Bool rewrote[4] = {false, false, false, false};
Bool forceOutput[4] = {false, false, false, false};
if (captureStage >= 0 && captureRequestsPointSize) {
forceOutput[captureStage] = true;
}
for (Int stage = 3; stage >= 0; --stage) {
const Int moduleIndex = stageIndex[stage];
if (moduleIndex < 0) continue;
Int producer = stage - 1;
while (producer >= 0 && stageIndex[producer] < 0) --producer;
DemotePointSizeOptions options;
options.location = carrierLocation;
options.inputCarrierName = PointSizeBoundaryCarrierName(
producer >= 0 ? kStageEnum[producer]
// A separable program whose first present stage already
// consumes the carrier: the producer lives in another
// program. Name by the conventional producer of this
// stage's boundary; matching across programs is by
// location and is documented residue either way.
: kStageEnum[stage > 0 ? stage - 1 : 0]);
options.outputCarrierName = stage == captureStage
? String(POINT_SIZE_CAPTURE_CARRIER_NAME)
: String(PointSizeBoundaryCarrierName(kStageEnum[stage]));
options.forceOutputCarrier = forceOutput[stage];
// A vertex stage with nothing downstream consuming the carrier needs no
// mirror and stays byte-identical without an optimizer round trip.
if (stage == 0 && !options.forceOutputCarrier) continue;
DemotePointSizeReport report;
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
DemotePointSizePass::CreateDemotePointSizePass(options, &report));
if (!RunOptimizerChecked("DemoteTessellationGeometryPointSizeForProgram", optimizer,
modules[moduleIndex], rewritten[moduleIndex],
validateOutput, enableSpirvValidation)) {
return false; // modules untouched: nothing was committed
}
if (report.declined) {
outcome.declineDetail = Move(report.declineReason);
return true; // byte-identical decline; the existing refusals stay armed
}
// AN EVALUATION STAGE WITH NO CONTROL STAGE THAT NOW READS A LOCATED
// INPUT. GL lets the evaluation stage sit straight on the vertex stage,
// and both backends stand a SYNTHESIZED pass-through control stage in
// between - one that forwards gl_Position and nothing else. Their guard
// for that is literally "does this module read a located input"
// (ModuleReadsLocatedInput / ReflectPassthroughTessControlNeed), so the
// carrier this pass just created would turn the very program the demotion
// exists to rescue into a declined one, reported against a varying name
// the application never wrote. Declining here keeps the modules
// byte-identical and leaves the honest built-in refusal in charge; only
// teaching the synthesized stage to forward the carrier could do better.
if (stage == 2 && stageIndex[1] < 0 && report.createdInputCarrier) {
outcome.declineDetail =
"an evaluation stage reads gl_in point size with no control stage to "
"carry it; the synthesized pass-through cannot forward the carrier";
return true;
}
rewrote[stage] = true;
if (report.createdInputCarrier && producer >= 0) {
forceOutput[producer] = true;
}
}
// Atomic commit: every stage rewritten together or none at all.
for (Int stage = 0; stage < 4; ++stage) {
if (!rewrote[stage]) continue;
modules[stageIndex[stage]] = Move(rewritten[stageIndex[stage]]);
}
outcome.demoted = true;
return true;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -562,6 +562,61 @@ namespace MobileGL {
// the module parse costs nothing on a device that has it.
static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv);
// ---- gl_PointSize demotion for devices without the capability above ----
// The name of the demoted program's LAST capture-capable stage's point-size
// carrier. It is the contract three parties meet at: the demotion pass names
// the variable, DirectVulkan's XfbCaptureDecoratePass binds a "gl_PointSize"
// capture to it instead of mirroring the (no longer accessed) built-in, and
// DirectGLES respells the driver-side glTransformFeedbackVaryings request
// with it. Deliberately NOT containing the substring "gl_PointSize":
// DirectGLES's extension-request gate is a text search for that token over
// the emitted ESSL, and a carrier name embedding it would re-arm the decline
// this demotion exists to retire.
static constexpr const char* POINT_SIZE_CAPTURE_CARRIER_NAME = "mg_PointSizeCapture";
// What the program-scoped demotion left behind. `demoted` false with an empty
// detail means the program never needed it (no tessellation/geometry stage
// accesses the built-in, or the device hosts it); false WITH a detail means a
// module shape the pass cannot express - the modules are byte-identical and
// the existing decline paths (Espryt's missing-extension compile failure,
// Magma's pointSizeCapabilityUnsupported refusal) stay in charge of it.
struct PointSizeDemotionOutcome {
Bool demoted = false;
String declineDetail;
};
// Demotes gl_PointSize across a WHOLE program's pre-rasterization chain into
// ordinary float varyings at one shared free location, so a device that
// advertises neither ES tessellation/geometry_point_size extension nor
// Vulkan's shaderTessellationAndGeometryPointSize can still run programs
// whose tessellation/geometry stages merely CARRY the value (transform
// feedback and gl_in[].gl_PointSize reads). Runs after
// SanitizeAndOptimizeBinary, on the final shared modules both backends
// consume, and is atomic per program: every stage is rewritten or none is,
// because a consumer whose producer kept the built-in would read garbage.
// `demoteTessellation` / `demoteGeometry` are the env verdicts (the device
// LACKS that capability); the per-program half of the decision - whether any
// module actually declares TessellationPointSize / GeometryPointSize - is
// probed here. `captureRequestsPointSize` forces the capture-capable last
// stage to declare its carrier even when it never writes the built-in, so a
// by-name capture always has something to bind to. Returns false only when
// the optimizer itself failed (modules untouched); a shape decline is
// reported through `outcome` and also leaves the modules untouched. See
// DemotePointSizePass for the per-module rewrite and its honest residue.
//
// Two declines are PROGRAM-shaped and therefore live here rather than in the
// pass: a carrier that would land past the minimum-spec varying budget, and
// an evaluation stage reading gl_in point size with NO control stage - the
// synthesized pass-through control stage both backends stand in that gap
// forwards gl_Position alone, so the input carrier would strand the value and
// trip the backends' own "reads a located input" refusal against a name the
// application never wrote.
static Bool DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
Bool demoteTessellation, Bool demoteGeometry, Bool captureRequestsPointSize,
PointSizeDemotionOutcome& outcome, bool validateOutput = true,
bool enableSpirvValidation = false);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the
// module (see its header for the two operations that make it decline), which is
@@ -0,0 +1,805 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
// Copyright (c) 2025-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 "DemotePointSizePass.h"
#include "spirv.hpp"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include "source/util/string_utils.h"
#include <format>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
spv::ExecutionModel EntryExecutionModel(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return static_cast<spv::ExecutionModel>(ep.GetSingleWordInOperand(0));
}
return spv::ExecutionModel::Max;
}
Instruction* EntryPoint(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return &ep;
}
return nullptr;
}
// OpTypePointer <storage-class> <pointee>
uint32_t VariablePointeeType(IRContext* ctx, Instruction* var) {
Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(var->type_id());
if (ptrType == nullptr || ptrType->opcode() != spv::Op::OpTypePointer) return 0;
return ptrType->GetSingleWordInOperand(1);
}
bool IsFloat32Type(IRContext* ctx, uint32_t typeId) {
Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId);
return t != nullptr && t->opcode() == spv::Op::OpTypeFloat &&
t->NumInOperands() >= 1 && t->GetSingleWordInOperand(0) == 32;
}
// The value of a plain 32-bit OpConstant, or false (spec constants and anything
// else make the caller decline rather than guess).
bool PlainConstantValue(IRContext* ctx, uint32_t id, uint32_t& outValue) {
Instruction* def = ctx->get_def_use_mgr()->GetDef(id);
if (def == nullptr || def->opcode() != spv::Op::OpConstant) return false;
if (def->NumInOperands() != 1) return false;
outValue = def->GetSingleWordInOperand(0);
return true;
}
uint32_t Float32Type(IRContext* ctx) {
analysis::Float f(32);
return ctx->get_type_mgr()->GetTypeInstruction(&f);
}
// An OpTypeArray of float32 with the given length constant, reusing an existing
// declaration when one exists.
uint32_t ArrayOfFloat32Type(IRContext* ctx, uint32_t lengthConstId, uint32_t lengthValue) {
analysis::Float f(32);
analysis::Type* floatReg = ctx->get_type_mgr()->GetRegisteredType(&f);
const analysis::Array::LengthInfo lengthInfo{
lengthConstId,
{static_cast<uint32_t>(analysis::Array::LengthInfo::kConstant), lengthValue}};
analysis::Array arr(floatReg, lengthInfo);
return ctx->get_type_mgr()->GetTypeInstruction(&arr);
}
void AddNameFor(IRContext* ctx, uint32_t id, const String& name) {
std::vector<Operand> operands;
operands.push_back({SPV_OPERAND_TYPE_ID, {id}});
operands.push_back(
{SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(name)});
ctx->AddDebug2Inst(
spvtools::MakeUnique<Instruction>(ctx, spv::Op::OpName, 0, 0, operands));
}
void AddLocationDecoration(IRContext* ctx, uint32_t id, uint32_t location) {
ctx->AddAnnotationInst(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {id}},
{SPV_OPERAND_TYPE_DECORATION,
{static_cast<uint32_t>(spv::Decoration::Location)}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {location}}}));
}
// A fresh interface variable: declared, named, located, listed on the entry
// point, and registered with the def-use manager so ReplaceAllUsesWith may name
// it before the end-of-pass invalidation.
uint32_t CreateCarrierVariable(IRContext* ctx, Instruction* entryPoint, uint32_t pointeeTypeId,
spv::StorageClass storage, const String& name,
uint32_t location) {
const uint32_t ptrTypeId = ctx->get_type_mgr()->FindPointerToType(pointeeTypeId, storage);
if (ptrTypeId == 0) return 0;
const uint32_t varId = ctx->TakeNextId();
auto var = spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpVariable, ptrTypeId, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<uint32_t>(storage)}}});
Instruction* varInst = var.get();
ctx->AddGlobalValue(std::move(var));
ctx->get_def_use_mgr()->AnalyzeInstDefUse(varInst);
AddNameFor(ctx, varId, name);
AddLocationDecoration(ctx, varId, location);
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {varId}});
return varId;
}
} // namespace
spvtools::opt::Pass::Status DemotePointSizePass::Process() {
auto* ctx = context();
auto* defUse = ctx->get_def_use_mgr();
const spv::ExecutionModel model = EntryExecutionModel(ctx);
Instruction* entryPoint = EntryPoint(ctx);
if (entryPoint == nullptr) return Status::SuccessWithoutChange;
const bool isVertex = model == spv::ExecutionModel::Vertex;
const bool isTessControl = model == spv::ExecutionModel::TessellationControl;
const bool isTessEval = model == spv::ExecutionModel::TessellationEvaluation;
const bool isGeometry = model == spv::ExecutionModel::Geometry;
if (!isVertex && !isTessControl && !isTessEval && !isGeometry) {
return Status::SuccessWithoutChange;
}
const auto decline = [&](String reason) {
if (m_report != nullptr) {
m_report->declined = true;
m_report->declineReason = Move(reason);
}
return Status::SuccessWithoutChange;
};
// ---- discovery: where does PointSize live in this module ------------------
// Member form: every struct type with a member decorated BuiltIn PointSize.
struct MemberSite {
uint32_t structId = 0;
uint32_t memberIndex = 0;
};
std::vector<MemberSite> memberSites;
// Standalone form: a variable decorated BuiltIn PointSize directly.
std::vector<Instruction*> standaloneVars;
std::vector<Instruction*> standaloneBuiltInDecorations;
// Where clip and cull distance live, by (struct, member). DECLARATION is not
// the question - glslang emits the whole four-member gl_PerVertex block into
// every stage, touched or not - so these sites are only the starting point
// for the ACCESS scan the control-stage decline below performs, which is the
// same thing SPIRV-Cross's own clip_distance_count counts.
std::vector<MemberSite> clipCullSites;
std::vector<Instruction*> standaloneClipCullVars;
const auto isClipOrCull = [](const uint32_t builtIn) {
return static_cast<spv::BuiltIn>(builtIn) == spv::BuiltIn::ClipDistance ||
static_cast<spv::BuiltIn>(builtIn) == spv::BuiltIn::CullDistance;
};
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn) {
if (static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(3)) ==
spv::BuiltIn::PointSize) {
memberSites.push_back(
{ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)});
} else if (isClipOrCull(ann.GetSingleWordInOperand(3))) {
clipCullSites.push_back(
{ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)});
}
} else if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::BuiltIn) {
if (static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(2)) ==
spv::BuiltIn::PointSize) {
Instruction* var = defUse->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
standaloneVars.push_back(var);
standaloneBuiltInDecorations.push_back(&ann);
}
} else if (isClipOrCull(ann.GetSingleWordInOperand(2))) {
Instruction* var = defUse->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
standaloneClipCullVars.push_back(var);
}
}
}
}
const auto memberIndexIn = [&](uint32_t structId, uint32_t& outMember) {
for (const MemberSite& site : memberSites) {
if (site.structId == structId) {
outMember = site.memberIndex;
return true;
}
}
return false;
};
// The gl_PerVertex-shaped interface variables: Input/Output variables whose
// pointee is (an array of) a struct carrying a PointSize member.
struct BlockVar {
Instruction* var = nullptr;
spv::StorageClass storage = spv::StorageClass::Output;
bool arrayed = false;
uint32_t arrayLengthConstId = 0;
uint32_t arrayLengthValue = 0;
uint32_t memberIndex = 0;
};
std::vector<BlockVar> blockVars;
for (Instruction& inst : ctx->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) continue;
const auto storage = static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
uint32_t pointeeId = VariablePointeeType(ctx, &inst);
if (pointeeId == 0) continue;
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee == nullptr) continue;
BlockVar entry;
entry.var = &inst;
entry.storage = storage;
if (pointee->opcode() == spv::Op::OpTypeArray) {
entry.arrayed = true;
entry.arrayLengthConstId = pointee->GetSingleWordInOperand(1);
if (!PlainConstantValue(ctx, entry.arrayLengthConstId, entry.arrayLengthValue)) {
continue; // spec-constant-sized interface array: not glslang's shape
}
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
if (pointee == nullptr) continue;
}
if (pointee->opcode() != spv::Op::OpTypeStruct) continue;
if (!memberIndexIn(pointee->result_id(), entry.memberIndex)) continue;
blockVars.push_back(entry);
}
// ---- vertex stage: mirror, never demote -----------------------------------
if (isVertex) {
if (!m_options.forceOutputCarrier) return Status::SuccessWithoutChange;
if (m_options.outputCarrierName.empty()) {
return decline("vertex mirror requested without a carrier name");
}
const uint32_t floatTypeId = Float32Type(ctx);
// The source of the mirrored value: the output block's PointSize member,
// a standalone output variable, or - with neither declared - the constant
// 1.0 GL's default point size names.
Instruction* blockVar = nullptr;
uint32_t memberIndex = 0;
for (const BlockVar& candidate : blockVars) {
if (candidate.storage == spv::StorageClass::Output && !candidate.arrayed) {
blockVar = candidate.var;
memberIndex = candidate.memberIndex;
break;
}
}
Instruction* standaloneOut = nullptr;
for (Instruction* candidate : standaloneVars) {
if (static_cast<spv::StorageClass>(candidate->GetSingleWordInOperand(0)) ==
spv::StorageClass::Output &&
IsFloat32Type(ctx, VariablePointeeType(ctx, candidate))) {
standaloneOut = candidate;
break;
}
}
const uint32_t carrierId =
CreateCarrierVariable(ctx, entryPoint, floatTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
if (carrierId == 0) return decline("could not declare the vertex mirror carrier");
uint32_t memberConstId = 0;
uint32_t ptrOutputFloatId = 0;
if (blockVar != nullptr) {
memberConstId = ctx->get_constant_mgr()->GetSIntConstId(
static_cast<int32_t>(memberIndex));
ptrOutputFloatId =
ctx->get_type_mgr()->FindPointerToType(floatTypeId, spv::StorageClass::Output);
if (ptrOutputFloatId == 0) return decline("no Output float pointer type");
}
uint32_t defaultOneId = 0;
if (blockVar == nullptr && standaloneOut == nullptr) {
defaultOneId = ctx->get_constant_mgr()->GetFloatConstId(1.0f);
}
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
bool mirrored = false;
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end(); ++funcIt) {
if (funcIt->result_id() != entryFunctionId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() != spv::Op::OpReturn &&
inst->opcode() != spv::Op::OpReturnValue) {
return;
}
uint32_t valueId = 0;
if (blockVar != nullptr) {
const uint32_t chainId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpAccessChain, ptrOutputFloatId, chainId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {blockVar->result_id()}},
{SPV_OPERAND_TYPE_ID, {memberConstId}}}));
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {chainId}}}));
} else if (standaloneOut != nullptr) {
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {standaloneOut->result_id()}}}));
} else {
valueId = defaultOneId;
}
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {carrierId}},
{SPV_OPERAND_TYPE_ID, {valueId}}}));
mirrored = true;
});
}
if (!mirrored) {
// An entry function with no return is not a module glslang produces;
// the carrier stays declared (the consumer's read is undefined, as an
// unwritten built-in's would have been).
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
// ---- tessellation / geometry: redirect and strip --------------------------
// Phase 1: ANALYSIS ONLY. Every plan is collected before anything mutates, so
// a decline leaves the module byte-identical.
struct ArrayedRedirect {
Instruction* chain = nullptr;
bool input = false;
};
std::vector<ArrayedRedirect> arrayedRedirects; // gl_in[i].ps / gl_out[i].ps
std::vector<Instruction*> scalarOutputChains; // non-arrayed out block's member
const BlockVar* arrayedInput = nullptr;
const BlockVar* arrayedOutput = nullptr;
for (const BlockVar& blockVar : blockVars) {
if (blockVar.arrayed) {
if (blockVar.storage == spv::StorageClass::Input) {
arrayedInput = &blockVar;
} else {
arrayedOutput = &blockVar;
}
}
bool declined = false;
String reason;
defUse->ForEachUser(blockVar.var, [&](Instruction* user) {
if (declined) return;
switch (user->opcode()) {
case spv::Op::OpEntryPoint:
case spv::Op::OpName:
case spv::Op::OpDecorate:
return;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain: {
const uint32_t indexCount = user->NumInOperands() - 1;
if (!blockVar.arrayed) {
if (indexCount < 1) {
declined = true;
reason = "an index-less pointer to the whole gl_PerVertex block";
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(1), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 1) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
scalarOutputChains.push_back(user);
return;
}
// Arrayed (gl_in / gl_out): [vertex, member, ...].
if (indexCount < 2) {
// A pointer that stops at the whole per-vertex struct can still
// reach PointSize through a second chain; following that split
// is not worth the shapes it would have to prove absent.
bool touchesPointSize = false;
defUse->ForEachUser(user, [&](Instruction* chainUser) {
if ((chainUser->opcode() == spv::Op::OpAccessChain ||
chainUser->opcode() == spv::Op::OpInBoundsAccessChain) &&
chainUser->NumInOperands() >= 2) {
uint32_t member = 0;
if (PlainConstantValue(ctx, chainUser->GetSingleWordInOperand(1),
member) &&
member == blockVar.memberIndex) {
touchesPointSize = true;
}
} else if (chainUser->opcode() == spv::Op::OpLoad ||
chainUser->opcode() == spv::Op::OpStore ||
chainUser->opcode() == spv::Op::OpCopyMemory) {
touchesPointSize = true; // whole-struct copy
}
});
if (touchesPointSize) {
declined = true;
reason = "a split access chain or whole-struct copy reaching PointSize";
}
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(2), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 2) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
arrayedRedirects.push_back(
{user, blockVar.storage == spv::StorageClass::Input});
return;
}
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpCopyMemory:
declined = true;
reason = "a whole-aggregate load/store/copy of the gl_PerVertex interface";
return;
default:
declined = true;
reason = std::format("SPIR-V opcode {} reaching the gl_PerVertex interface",
static_cast<uint32_t>(user->opcode()));
return;
}
});
if (declined) return decline(Move(reason));
}
// Standalone variables: swapping the decoration is only sound for the float /
// float-array shapes the built-in is allowed to have; mixing forms in one
// direction never comes out of glslang and declines.
struct StandaloneSwap {
Instruction* var = nullptr;
Instruction* builtInDecoration = nullptr;
bool input = false;
};
std::vector<StandaloneSwap> standaloneSwaps;
for (SizeT i = 0; i < standaloneVars.size(); ++i) {
Instruction* var = standaloneVars[i];
const auto storage = static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
const bool input = storage == spv::StorageClass::Input;
uint32_t pointeeId = VariablePointeeType(ctx, var);
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
}
if (pointee == nullptr || pointee->opcode() != spv::Op::OpTypeFloat) {
return decline("a standalone PointSize variable of an unexpected type");
}
if (input && arrayedInput != nullptr) {
return decline("PointSize declared both as a block member and standalone (input)");
}
if (!input && (arrayedOutput != nullptr || !scalarOutputChains.empty())) {
return decline("PointSize declared both as a block member and standalone (output)");
}
standaloneSwaps.push_back({var, standaloneBuiltInDecorations[i], input});
}
bool needsInputCarrier = false;
bool needsOutputCarrier = m_options.forceOutputCarrier;
for (const ArrayedRedirect& redirect : arrayedRedirects) {
(redirect.input ? needsInputCarrier : needsOutputCarrier) = true;
// Only a control stage has an ARRAYED output block; anywhere else this
// shape would hand a scalar carrier an extra index.
if (!redirect.input && !isTessControl) {
return decline("an arrayed PointSize output outside a control stage");
}
}
if (!scalarOutputChains.empty()) {
needsOutputCarrier = true;
// And only evaluation/geometry stages have the non-arrayed output block.
if (isTessControl) {
return decline("a non-arrayed PointSize output in a control stage");
}
}
bool standaloneInputSwapped = false;
bool standaloneOutputSwapped = false;
for (const StandaloneSwap& swap : standaloneSwaps) {
(swap.input ? standaloneInputSwapped : standaloneOutputSwapped) = true;
}
if (needsInputCarrier && arrayedInput == nullptr) {
return decline("a PointSize read with no arrayed input block to size the carrier by");
}
if (needsInputCarrier && m_options.inputCarrierName.empty()) {
return decline("a PointSize read with no input carrier name to bind it to");
}
if ((needsOutputCarrier && !standaloneOutputSwapped) &&
m_options.outputCarrierName.empty()) {
return decline("a PointSize write with no output carrier name to bind it to");
}
// A FORCED carrier with nothing redirected onto it is a carrier no instruction
// ever writes - and a declared-but-unwritten output does not survive the ES
// hop: the driver's GLSL front end drops it, and a transform-feedback request
// naming it then fails the link with "varying undeclared", taking every other
// capture in the set down with it. The pass therefore SEEDS such a carrier
// (below) with GL's default point size, which is also what an unhosted
// built-in rasterizes at. GL leaves the value of an unwritten output
// undefined, so a defined 1.0 is a legal choice and a far better one than a
// varying the driver deletes.
const bool outputCarrierHasWriter = [&] {
if (!scalarOutputChains.empty()) return true;
for (const ArrayedRedirect& redirect : arrayedRedirects) {
if (!redirect.input) return true;
}
return false;
}();
// The seed the control stage would need is per-INVOCATION - gl_out[
// gl_InvocationID] - and synthesizing that means inventing the InvocationId
// built-in for a stage that may not declare it. A control stage asked to
// produce a value it never computes is also a program reading undefined data
// either way, so this declines rather than growing the pass for it.
if (needsOutputCarrier && !standaloneOutputSwapped && !outputCarrierHasWriter &&
isTessControl) {
return decline("a control stage asked to produce a point-size carrier it never writes");
}
// The TCS output carrier is arrayed per vertex; its length comes from gl_out,
// or - for a forced carrier in a control stage that never declared gl_out -
// from the OutputVertices execution mode.
uint32_t outputArrayLengthConstId = 0;
uint32_t outputArrayLengthValue = 0;
if (isTessControl && needsOutputCarrier && !standaloneOutputSwapped) {
if (arrayedOutput != nullptr) {
outputArrayLengthConstId = arrayedOutput->arrayLengthConstId;
outputArrayLengthValue = arrayedOutput->arrayLengthValue;
} else {
for (Instruction& mode : ctx->module()->execution_modes()) {
if (mode.NumInOperands() >= 3 &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::OutputVertices) {
outputArrayLengthValue = mode.GetSingleWordInOperand(2);
break;
}
}
if (outputArrayLengthValue == 0) {
return decline("a control stage with neither gl_out nor OutputVertices");
}
outputArrayLengthConstId =
ctx->get_constant_mgr()->GetUIntConstId(outputArrayLengthValue);
}
}
const bool anyWork = needsInputCarrier || needsOutputCarrier ||
!standaloneSwaps.empty();
// Even with no access left to redirect (a dead read the sanitize chain already
// removed), a declared TessellationPointSize/GeometryPointSize capability must
// still be stripped - it alone makes the module unbuildable on the device.
std::vector<Instruction*> capabilitiesToStrip;
for (Instruction& capability : ctx->module()->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize ||
declared == spv::Capability::GeometryPointSize) {
capabilitiesToStrip.push_back(&capability);
}
}
if (!anyWork && capabilitiesToStrip.empty()) return Status::SuccessWithoutChange;
// THE ONE SHAPE WHERE "declared but unaccessed" IS NOT ENOUGH. SPIRV-Cross
// force-emits the whole redeclared gl_PerVertex OUTPUT block for a control
// stage whose clip or cull distances are LIVE (should_force_emit_builtin_block,
// spirv_glsl.cpp), and that emission is driven by the struct's member
// DECORATIONS, never by access - so it prints "float gl_PointSize;" into a
// block no instruction touches any more. On the extension-less ES drivers this
// pass exists for, that redeclaration is exactly as illegal as the access was,
// so the demoted program would still be lost - only now with the capability
// stripped, the program-wide verdict flipped and a diagnostic naming a
// built-in the module no longer mentions. Declining keeps the honest refusal,
// and keeps the header's claim true for every shape that IS demoted.
//
// LIVE, not declared: glslang emits the whole four-member gl_PerVertex block
// into every stage whether or not it is touched, and SPIRV-Cross counts clip
// and cull distance from ACCESSES (Compiler::ActiveBuiltinHandler). Keying
// this on the decorations alone would decline every control stage there is.
// Only a block-MEMBER PointSize can be left behind to be printed; a standalone
// variable leaves with the demotion.
if (isTessControl && !blockVars.empty()) {
bool clipOrCullIsLive = false;
const auto memberIsClipOrCull = [&](const uint32_t structId,
const uint32_t member) {
for (const MemberSite& site : clipCullSites) {
if (site.structId == structId && site.memberIndex == member) return true;
}
return false;
};
for (Instruction& inst : ctx->module()->types_values()) {
if (clipOrCullIsLive) break;
if (inst.opcode() != spv::Op::OpVariable) continue;
const auto storage =
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input &&
storage != spv::StorageClass::Output) {
continue;
}
// A standalone clip/cull variable counts the moment anything but its
// own declaration touches it.
bool standaloneClipCull = false;
for (Instruction* candidate : standaloneClipCullVars) {
if (candidate == &inst) standaloneClipCull = true;
}
Instruction* pointee = defUse->GetDef(VariablePointeeType(ctx, &inst));
bool arrayed = false;
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
arrayed = true;
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
}
const bool blockCarriesClipCull =
pointee != nullptr && pointee->opcode() == spv::Op::OpTypeStruct &&
[&] {
for (const MemberSite& site : clipCullSites) {
if (site.structId == pointee->result_id()) return true;
}
return false;
}();
if (!standaloneClipCull && !blockCarriesClipCull) continue;
const uint32_t structId = blockCarriesClipCull ? pointee->result_id() : 0;
const uint32_t memberOperand = arrayed ? 2u : 1u;
defUse->ForEachUser(&inst, [&](Instruction* user) {
if (clipOrCullIsLive) return;
switch (user->opcode()) {
case spv::Op::OpEntryPoint:
case spv::Op::OpName:
case spv::Op::OpMemberName:
case spv::Op::OpDecorate:
case spv::Op::OpMemberDecorate:
return;
default:
break;
}
if (standaloneClipCull) {
clipOrCullIsLive = true;
return;
}
if (user->opcode() != spv::Op::OpAccessChain &&
user->opcode() != spv::Op::OpInBoundsAccessChain) {
// A whole-block load, copy or anything else that cannot be
// narrowed reaches every member, clip distance included.
clipOrCullIsLive = true;
return;
}
uint32_t member = 0;
if (user->NumInOperands() <= memberOperand ||
!PlainConstantValue(ctx, user->GetSingleWordInOperand(memberOperand),
member)) {
clipOrCullIsLive = true; // cannot prove it misses clip/cull
return;
}
if (memberIsClipOrCull(structId, member)) clipOrCullIsLive = true;
});
}
if (clipOrCullIsLive) {
for (const BlockVar& blockVar : blockVars) {
if (blockVar.storage == spv::StorageClass::Output) {
return decline(
"a control stage with live clip/cull distance, whose "
"redeclared output block would still print gl_PointSize");
}
}
}
}
// Phase 2: MUTATION. Nothing below may decline.
const uint32_t floatTypeId = Float32Type(ctx);
uint32_t inputCarrierId = 0;
if (needsInputCarrier) {
const uint32_t arrayTypeId = ArrayOfFloat32Type(
ctx, arrayedInput->arrayLengthConstId, arrayedInput->arrayLengthValue);
inputCarrierId =
CreateCarrierVariable(ctx, entryPoint, arrayTypeId, spv::StorageClass::Input,
m_options.inputCarrierName, m_options.location);
}
uint32_t outputCarrierId = 0;
if (needsOutputCarrier && !standaloneOutputSwapped) {
uint32_t pointeeTypeId = floatTypeId;
if (isTessControl) {
pointeeTypeId =
ArrayOfFloat32Type(ctx, outputArrayLengthConstId, outputArrayLengthValue);
}
outputCarrierId =
CreateCarrierVariable(ctx, entryPoint, pointeeTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
}
// Seed a forced carrier nothing writes, so the ES hop keeps it (see the
// reasoning at outputCarrierHasWriter). In a GEOMETRY stage the store has to
// go before every EmitVertex, because that is when the outputs of one vertex
// are latched; anywhere else the ends of the entry function will do.
if (outputCarrierId != 0 && !outputCarrierHasWriter) {
const uint32_t defaultPointSizeId = ctx->get_constant_mgr()->GetFloatConstId(1.0f);
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
std::vector<Instruction*> seedSites;
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end();
++funcIt) {
if (funcIt->result_id() != entryFunctionId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
const bool emit = inst->opcode() == spv::Op::OpEmitVertex ||
inst->opcode() == spv::Op::OpEmitStreamVertex;
const bool ret = inst->opcode() == spv::Op::OpReturn ||
inst->opcode() == spv::Op::OpReturnValue;
if (isGeometry ? emit : ret) seedSites.push_back(inst);
});
// A geometry stage with no EmitVertex emits nothing at all; seeding the
// ends of the function still keeps the varying alive for the capture.
if (isGeometry && seedSites.empty()) {
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() == spv::Op::OpReturn ||
inst->opcode() == spv::Op::OpReturnValue) {
seedSites.push_back(inst);
}
});
}
}
for (Instruction* site : seedSites) {
site->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {outputCarrierId}},
{SPV_OPERAND_TYPE_ID, {defaultPointSizeId}}}));
}
}
// Scalar output chains first, while the def-use index still knows their uses.
for (Instruction* chain : scalarOutputChains) {
ctx->ReplaceAllUsesWith(chain->result_id(), outputCarrierId);
ctx->KillInst(chain);
}
// Arrayed chains are rewritten in place: same result id, same result type
// (pointer-to-float in the same storage class), one fewer index.
for (const ArrayedRedirect& redirect : arrayedRedirects) {
const uint32_t carrierId = redirect.input ? inputCarrierId : outputCarrierId;
const Operand vertexIndex = redirect.chain->GetInOperand(1);
redirect.chain->SetInOperands(Instruction::OperandList{
{SPV_OPERAND_TYPE_ID, {carrierId}}, vertexIndex});
}
// Standalone form: the variable becomes its own carrier.
for (const StandaloneSwap& swap : standaloneSwaps) {
ctx->KillInst(swap.builtInDecoration);
AddLocationDecoration(ctx, swap.var->result_id(), m_options.location);
std::vector<Instruction*> oldNames;
for (Instruction& debugInst : ctx->module()->debugs2()) {
if (debugInst.opcode() == spv::Op::OpName &&
debugInst.GetSingleWordInOperand(0) == swap.var->result_id()) {
oldNames.push_back(&debugInst);
}
}
for (Instruction* oldName : oldNames) ctx->KillInst(oldName);
AddNameFor(ctx, swap.var->result_id(),
swap.input ? m_options.inputCarrierName : m_options.outputCarrierName);
}
for (Instruction* capability : capabilitiesToStrip) {
ctx->KillInst(capability);
}
if (m_report != nullptr) {
m_report->createdInputCarrier = needsInputCarrier || standaloneInputSwapped;
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken DemotePointSizePass::CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report) {
return spvtools::Optimizer::PassToken(
MakeUnique<DemotePointSizePass>(Move(options), report));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,118 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.h
// Copyright (c) 2025-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
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Demotes gl_PointSize traffic in ONE tessellation or geometry module (or mirrors it
// out of a vertex module) into an ordinary inter-stage float varying, for devices
// that cannot host the built-in in those stages at all: no
// EXT/OES_tessellation_point_size / geometry_point_size on the ES driver, and
// shaderTessellationAndGeometryPointSize == VK_FALSE on the Vulkan one. Desktop GL
// treats the built-in as an ordinary per-vertex output, so the programs this rescues
// are legal GL - only the targets cannot spell them.
//
// What "demoted" means, precisely. In a tessellation/geometry stage every access
// chain that reaches the PointSize member of a gl_PerVertex block (gl_in[i]
// .gl_PointSize, gl_out[i].gl_PointSize, the non-arrayed output block's member) is
// redirected onto a plain float varying at the caller-chosen location - an arrayed
// Input for gl_in reads, an arrayed Output for TCS gl_out writes, a scalar Output
// for the TES/GS output - and the TessellationPointSize / GeometryPointSize
// capability is stripped. The gl_PerVertex STRUCT keeps its PointSize member,
// declared and decorated but no longer accessed: that is exactly the shape glslang
// produces for a program that never touches point size (it defers the capability to
// first use). A standalone PointSize VARIABLE (never glslang's shape, but legal
// SPIR-V) is demoted in place: BuiltIn swapped for the Location, and the variable
// renamed to the carrier's name.
//
// "Declared but unaccessed" is only safe while the ES hop PRINTS by access, and
// there is one shape where it does not. SPIRV-Cross redeclares the whole
// gl_PerVertex output block for a CONTROL stage whose clip or cull distances are
// live (should_force_emit_builtin_block), and that redeclaration walks the
// struct's member DECORATIONS - so it would print "float gl_PointSize;" into a
// block nothing touches any more, which an extension-less ES driver rejects
// exactly as it rejected the access. That combination therefore DECLINES, below,
// rather than shipping a module that is mutated and still lost. Every other
// demoted shape leaves the member genuinely invisible to the ES hop, which is
// what the pinned transpile assertions hold.
//
// A VERTEX module is never capability-limited (gl_PointSize is core there on both
// targets), so it keeps its built-in untouched and, when the next stage consumes the
// carrier, MIRRORS the built-in's value into the carrier at every return of the
// entry function - the VS->TCS half of the chain.
//
// The VALUE is what survives: gl_in[].gl_PointSize reads and transform-feedback
// captures see exactly what the upstream stage wrote. The RASTERIZED point size is
// what does not - with the built-in unhosted, both targets rasterize such pipelines
// at the default size 1.0 (Vulkan: the shaderTessellationAndGeometryPointSize
// feature description; ES: PointSizeRange default) - so rasterization-verified
// point_rendering tests keep failing honestly and nothing may be gated on them.
//
// Anything the pass cannot express - a whole gl_PerVertex struct load/store/copy, a
// pointer that escapes into an opcode it cannot follow, an access-chain split across
// two chains - DECLINES the module byte-identically, reported through the report
// struct, so the caller keeps the existing honest refusal paths instead of shipping
// a half-demoted program.
//
// One module per run; the PROGRAM-wide contract (every stage demoted or none, one
// shared location, matching carrier names across each boundary) is owned by
// ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram, the only caller.
struct DemotePointSizeOptions {
// The Location every carrier of this program uses; chosen by the caller past
// every location any stage of the program already consumes.
Uint32 location = 0;
// Name for the arrayed Input carrier (empty forbids creating one: a module that
// reads gl_in[].gl_PointSize with no name to give the carrier declines).
String inputCarrierName;
// Name for the Output carrier (scalar in VS/TES/GS, arrayed in TCS).
String outputCarrierName;
// Create the Output carrier even when this module never writes PointSize: the
// next stage reads it (Vulkan requires every consumed input to be produced,
// VUID-RuntimeSpirv-OpEntryPoint-08743), or a transform-feedback capture of
// gl_PointSize binds to it. Such a carrier is SEEDED with 1.0 - GL's default
// point size, and what an unhosted built-in rasterizes at - rather than left
// unwritten: GL calls the value of an unwritten output undefined, but an ES
// driver's front end DELETES a never-written output, and a capture naming a
// deleted varying fails the link and takes the whole capture set with it. A
// control stage cannot be seeded this way (the write is per-invocation) and
// declines instead.
Bool forceOutputCarrier = false;
};
struct DemotePointSizeReport {
Bool declined = false;
String declineReason;
// The module reads incoming PointSize, so an Input carrier now exists - which
// obliges the PREVIOUS stage to produce the matching Output carrier. The driver
// walks the stages back-to-front off exactly this bit.
Bool createdInputCarrier = false;
};
class DemotePointSizePass : public spvtools::opt::Pass {
public:
DemotePointSizePass(DemotePointSizeOptions options, DemotePointSizeReport* report)
: m_options(Move(options)), m_report(report) {}
const char* name() const override { return "mobilegl-demote-point-size"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report);
private:
DemotePointSizeOptions m_options;
DemotePointSizeReport* m_report;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -84,8 +84,18 @@ namespace MobileGL {
struct BlockPlan {
Instruction* structType = nullptr;
uint32_t storageClass = 0;
// A bounded block's length in words. For an open-ended block - one whose
// last member is a runtime array - the FIXED PREFIX in words, i.e. the
// runtime array's own offset, which is where its element 0 starts.
uint32_t wordCount = 0;
bool openEnded = false;
// The original runtime array's stride in words; what one element of it
// steps by, and what its word count divides by to become a length.
uint32_t tailStrideWords = 0;
std::vector<ChainPlan> chains;
// The OpArrayLength users of an open-ended block's variables, which count
// WORDS once the member is a `uint[]` and so have to be rewritten too.
std::vector<Instruction*> arrayLengths;
};
bool IsDoubleType(const Instruction* type) {
@@ -178,8 +188,9 @@ namespace MobileGL {
}
// Byte size of a type as it is laid out INSIDE a block, or 0 when this pass
// cannot describe it (a runtime array, a width it does not carry, a matrix with
// no stride or a row-major one).
// cannot describe it (a runtime array - the one place a block may have one is
// its last member, which MeasureBlock handles above this - a width it does not
// carry, a matrix with no stride or a row-major one).
uint32_t LaidOutByteSize(IRContext* context, const TypeCursor& cursor) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return 0;
@@ -231,8 +242,17 @@ namespace MobileGL {
}
// Whether this type decomposes into scalars the rewrite can move one word at a
// time, counting them so a whole-aggregate access can be refused before it is
// expanded.
// time. |leafCount| counts them, so a whole-aggregate access can be refused
// before it is expanded; passing NULL asks the SHAPE question alone - is this
// type addressable at all - and then identical array elements and vector
// components are walked once instead of once each, because the answer cannot
// differ between them and the walk of a big one would not be free.
//
// The two questions are separate because only a LOAD or a STORE expands into
// leaves, and the cap bounds one of those. How large a runtime array's element
// is says nothing about how many scalars a single access to it moves, so
// MeasureBlock asks for the shape and BuildPlans applies the cap where it
// belongs - per chain, to the type that chain actually names.
bool CanDecompose(IRContext* context, const TypeCursor& cursor, uint32_t* leafCount) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
@@ -240,12 +260,15 @@ namespace MobileGL {
case spv::Op::OpTypeInt:
case spv::Op::OpTypeFloat:
if (ScalarByteSize(type) == 0) return false;
if (leafCount == nullptr) return true;
++*leafCount;
return *leafCount <= kMaxLeavesPerAccess;
case spv::Op::OpTypeVector: {
TypeCursor component;
component.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, component, leafCount)) return false;
}
return true;
@@ -257,7 +280,9 @@ namespace MobileGL {
}
TypeCursor column;
column.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, column, leafCount)) return false;
}
return true;
@@ -273,10 +298,12 @@ namespace MobileGL {
context->get_constant_mgr()->FindDeclaredConstant(type->GetSingleWordInOperand(1));
if (length == nullptr || length->AsIntConstant() == nullptr) return false;
const uint32_t count = length->AsIntConstant()->GetU32BitValue();
if (count == 0 || count > kMaxLeavesPerAccess) return false;
if (count == 0) return false;
if (leafCount != nullptr && count > kMaxLeavesPerAccess) return false;
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < count; ++i) {
const uint32_t repeats = leafCount == nullptr ? 1u : count;
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, element, leafCount)) return false;
}
return true;
@@ -300,6 +327,63 @@ namespace MobileGL {
}
}
// Measures the block struct itself. A bounded block reports its laid-out byte
// size; a block whose LAST member is a runtime array - the only place GLSL lets
// one stand, and the only place SPIR-V lets a Block have one - reports the byte
// offset that array starts at and says so through |openEnded|, with the array's
// stride alongside. A runtime array anywhere else, one without a stride the
// words can step by, or one whose element the rewrite could not take apart is a
// shape this pass does not describe, and so is a bounded block it cannot size.
bool MeasureBlock(IRContext* context, const Instruction* structType, uint32_t* bytes,
bool* openEnded, uint32_t* tailStrideBytes) {
*bytes = 0;
*openEnded = false;
*tailStrideBytes = 0;
const uint32_t structId = structType->result_id();
const uint32_t memberCount = structType->NumInOperands();
uint64_t end = 0;
for (uint32_t member = 0; member < memberCount; ++member) {
uint32_t offset = 0;
if (!TryGetMemberDecorationLiteral(context, structId, member, spv::Decoration::Offset,
&offset)) {
return false;
}
const TypeCursor cursor = MemberCursor(context, structType, member);
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
if (type->opcode() == spv::Op::OpTypeRuntimeArray) {
if (member + 1 != memberCount) return false;
uint32_t stride = 0;
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride) ||
stride == 0 || stride % kWordBytes != 0) {
return false;
}
// The member's own matrix decorations describe the array's ELEMENTS,
// exactly as they do for a bounded array of matrices. Only the shape
// is asked for: how big one element is decides nothing about how
// many scalars one access moves, and a leaf cap here would decline a
// block over a member the shader may never read whole.
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
if (!CanDecompose(context, element, nullptr)) return false;
// Element 0 has to start past every fixed member, or the words the
// prefix owns and the words the array owns would overlap.
if (offset < end) return false;
end = offset;
*openEnded = true;
*tailStrideBytes = stride;
break;
}
const uint32_t size = LaidOutByteSize(context, cursor);
if (size == 0) return false;
end = std::max<uint64_t>(end, static_cast<uint64_t>(offset) + size);
}
if (end > kMaxBlockBytes) return false;
*bytes = static_cast<uint32_t>(end);
return true;
}
bool TypeContainsFloat64(IRContext* context, uint32_t typeId,
std::unordered_set<uint32_t>& visiting) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
@@ -366,6 +450,9 @@ namespace MobileGL {
switch (type->opcode()) {
case spv::Op::OpTypeArray:
// A runtime array steps exactly like a bounded one; only its end is
// unknown, and a chain never needs that.
case spv::Op::OpTypeRuntimeArray:
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride)) {
return false;
@@ -611,6 +698,39 @@ namespace MobileGL {
}
}
// Replaces an OpArrayLength of an open-ended block with the element count
// of the ORIGINAL runtime array. The instruction now counts the words of
// the flattened `uint[]`, so the length is `(words - prefix) / stride`, in
// unsigned arithmetic and clamped at zero when the bound range does not
// even reach the array's offset - a wrapped subtraction would otherwise
// report a few billion elements. The division floors, which is what GL
// defines `.length()` as for a range that is not a whole number of
// elements. A fresh OpArrayLength is issued rather than the old one re-aimed,
// so the uses being redirected are never the ones the arithmetic just made.
void RewriteArrayLength(Instruction* arrayLength, uint32_t prefixWords, uint32_t strideWords) {
InstructionBuilder builder(m_context, arrayLength, kPreservedAnalyses);
const uint32_t variableId = arrayLength->GetSingleWordInOperand(0);
const uint32_t wordsId = m_context->TakeNextId();
builder.AddInstruction(MakeUnique<Instruction>(
m_context, spv::Op::OpArrayLength, m_uintTypeId, wordsId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {variableId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {0u}}}));
uint32_t count = wordsId;
if (prefixWords != 0) {
const uint32_t prefixId = UintConstant(prefixWords);
const uint32_t past = Binary(builder, spv::Op::OpISub, m_uintTypeId, count, prefixId);
const uint32_t tooShort =
Binary(builder, spv::Op::OpULessThan, m_boolTypeId, count, prefixId);
count = Select(builder, tooShort, UintConstant(0), past);
}
if (strideWords != 1) {
count = Binary(builder, spv::Op::OpUDiv, m_uintTypeId, count,
UintConstant(strideWords));
}
m_context->ReplaceAllUsesWith(arrayLength->result_id(), count);
m_context->KillInst(arrayLength);
}
private:
uint32_t ComponentWords(uint32_t componentTypeId) {
return ScalarByteSize(m_context->get_def_use_mgr()->GetDef(componentTypeId)) /
@@ -788,38 +908,72 @@ namespace MobileGL {
return false;
}
// A fresh `uint[length]` with ArrayStride 4, spliced in immediately BEFORE the
// block that will name it - SPIR-V has no forward references between types, so
// appending it at the end of the section would make the module invalid. A
// duplicate OpTypeArray is legal (SPIR-V 2.8 exempts aggregates from the
// uniqueness rule, and so does spirv-val), so no search for an existing one is
// needed; the LENGTH CONSTANT is not exempt, and if the module already declares
// it after the block there is nowhere legal to put the array - the block is then
// declined and keeps today's behaviour. Returns 0 for that, and for a uint type
// that is itself declared too late.
// A fresh `uint[length]` with ArrayStride 4 - or, for an open-ended block, a
// `uint[]` runtime array with the same stride and no length at all - spliced in
// immediately BEFORE the block that will name it: SPIR-V has no forward
// references between types, so appending it at the end of the section would make
// the module invalid. A duplicate OpTypeArray or OpTypeRuntimeArray is legal
// (SPIR-V 2.8 exempts aggregates from the uniqueness rule, and so does
// spirv-val), so no search for an existing one is needed; the LENGTH CONSTANT is
// not exempt, and if the module already declares it after the block there is
// nowhere legal to put the array - the block is then declined and keeps today's
// behaviour. Returns 0 for that; an open-ended block has no length constant to
// place, so that reason cannot reach it.
//
// The `uint` element type is a different matter, and only for an OPEN-ENDED
// block. The front end declares types in first-use order, so a block that is the
// first thing a shader touches sits BEFORE the module's `uint` (or the module has
// none, and the one the pass asked for was appended at the end). Declining there
// would send exactly the buffers this rewrite exists for back to the demotion on
// nothing but where they stand in the source. OpTypeInt has no operands, and
// nothing that names it can precede where it was, so moving it up in front of the
// block is always legal. A BOUNDED block keeps declining instead: that is what it
// has always done, and widening it is a change to a path this one does not need.
//
// NOTHING IS WRITTEN until every reason to decline has been ruled out, so a block
// this returns 0 for leaves the module as it found it - which is what lets
// Process() truthfully report SuccessWithoutChange for a module of only those.
uint32_t CreateWordArrayTypeBefore(IRContext* context, Instruction* structType,
uint32_t uintTypeId, uint32_t length) {
if (!DeclaredBefore(context, uintTypeId, structType->result_id())) return 0;
uint32_t uintTypeId, uint32_t length, bool openEnded) {
Instruction* uintType = context->get_def_use_mgr()->GetDef(uintTypeId);
if (uintType == nullptr || uintType->opcode() != spv::Op::OpTypeInt) return 0;
const bool hoistUint = !DeclaredBefore(context, uintTypeId, structType->result_id());
if (hoistUint && !openEnded) return 0;
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintType = context->get_type_mgr()->GetType(uintTypeId);
if (uintType == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintType, {length});
if (lengthConstant == nullptr) return 0;
uint32_t lengthConstantId = 0;
if (!openEnded) {
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintDescriptor =
context->get_type_mgr()->GetType(uintTypeId);
if (uintDescriptor == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintDescriptor, {length});
if (lengthConstant == nullptr) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
Instruction* lengthInst = constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
// Created in front of the block when it is not there yet, so the only way
// this declines is a constant the module already declares after it.
Instruction* lengthInst =
constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
lengthConstantId = lengthInst->result_id();
}
const uint32_t arrayTypeId = context->TakeNextId();
if (arrayTypeId == 0) return 0;
auto arrayType = MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthInst->result_id()}}});
if (hoistUint) uintType->InsertBefore(structType);
std::unique_ptr<Instruction> arrayType =
openEnded
? MakeUnique<Instruction>(
context, spv::Op::OpTypeRuntimeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}}})
: MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthConstantId}}});
Instruction* inserted = structType->InsertBefore(std::move(arrayType));
context->AnalyzeDefUse(inserted);
context->get_decoration_mgr()->AddDecorationVal(
@@ -948,8 +1102,14 @@ namespace MobileGL {
Instruction* structType = defUseMgr->GetDef(structId);
TypeCursor blockCursor;
blockCursor.typeId = structId;
const uint32_t blockBytes = LaidOutByteSize(context, blockCursor);
if (blockBytes == 0 || blockBytes % kWordBytes != 0) {
uint32_t blockBytes = 0;
bool openEnded = false;
uint32_t tailStrideBytes = 0;
// An open-ended block whose runtime array is its only member measures a
// prefix of 0 bytes and is perfectly describable; only a BOUNDED block of
// no bytes is not, and MeasureBlock already refuses to size one of those.
if (!MeasureBlock(context, structType, &blockBytes, &openEnded, &tailStrideBytes) ||
blockBytes % kWordBytes != 0 || (!openEnded && blockBytes == 0)) {
MGLOG_D("[spirv] storage block %%%u holds a double but its byte layout cannot be "
"described exactly; left to the fp64 demotion",
structId);
@@ -960,11 +1120,15 @@ namespace MobileGL {
plan.structType = structType;
plan.storageClass = storageClassByStruct[structId];
plan.wordCount = blockBytes / kWordBytes;
plan.openEnded = openEnded;
plan.tailStrideWords = tailStrideBytes / kWordBytes;
const uint32_t lastMember = structType->NumInOperands() - 1;
bool expressible = true;
for (Instruction* variable : variablesByStruct[structId]) {
std::vector<Instruction*> chains;
std::unordered_set<uint32_t> seenChains;
std::unordered_set<uint32_t> seenLengths;
defUseMgr->ForEachUser(variable, [&](Instruction* user) {
if (!expressible) return;
switch (user->opcode()) {
@@ -982,6 +1146,27 @@ namespace MobileGL {
}
expressible = false;
return;
case spv::Op::OpArrayLength: {
// Only an open-ended block has a length to ask for, and
// only of its last member; the result has to be the 32-bit
// uint the rewrite's arithmetic is typed in, which is the
// only result type the instruction allows anyway.
const Instruction* resultType = defUseMgr->GetDef(user->type_id());
const bool isUint = resultType != nullptr &&
resultType->opcode() == spv::Op::OpTypeInt &&
resultType->GetSingleWordInOperand(0) == 32u &&
resultType->GetSingleWordInOperand(1) == 0u;
if (openEnded && isUint && user->NumInOperands() >= 2 &&
user->GetSingleWordInOperand(0) == variable->result_id() &&
user->GetSingleWordInOperand(1) == lastMember) {
if (seenLengths.insert(user->result_id()).second) {
plan.arrayLengths.push_back(user);
}
return;
}
expressible = false;
return;
}
default:
expressible = false;
return;
@@ -1058,16 +1243,25 @@ namespace MobileGL {
Emitter emitter(irContext, uintTypeId, boolTypeId, floatTypeId);
bool modified = false;
// Every block declines before anything is written for it, so |touched| only ever
// parts company with |modified| on a shape that cannot happen without the module
// running out of ids - and even then the status must not claim the bytes are
// untouched, because the caller relies on that to skip invalidating its analyses.
bool touched = false;
for (BlockPlan& plan : plans) {
const uint32_t structId = plan.structType->result_id();
const uint32_t arrayTypeId =
CreateWordArrayTypeBefore(irContext, plan.structType, uintTypeId, plan.wordCount);
const uint32_t arrayTypeId = CreateWordArrayTypeBefore(
irContext, plan.structType, uintTypeId, plan.wordCount, plan.openEnded);
if (arrayTypeId == 0) {
// Nothing was written for it, so the module is still the one that came in.
MGLOG_D("[spirv] storage block %%%u: no legal place for the flattened word array; "
"left to the fp64 demotion",
structId);
continue;
}
// Past this point the module HAS been written to, so an abandoned block would
// leave a dead type behind - the status has to say so even then.
touched = true;
const uint32_t wordPointerTypeId = irContext->get_type_mgr()->FindPointerToType(
uintTypeId, static_cast<spv::StorageClass>(plan.storageClass));
if (wordPointerTypeId == 0) continue;
@@ -1095,6 +1289,9 @@ namespace MobileGL {
}
irContext->KillInst(chainPlan.chain);
}
for (Instruction* arrayLength : plan.arrayLengths) {
emitter.RewriteArrayLength(arrayLength, plan.wordCount, plan.tailStrideWords);
}
const std::vector<spv::Decoration> surviving = SurvivingAccessQualifiers(
irContext, structId, plan.structType->NumInOperands());
@@ -1113,12 +1310,19 @@ namespace MobileGL {
{SPV_OPERAND_TYPE_DECORATION, {static_cast<uint32_t>(kind)}}});
}
modified = true;
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members keep "
"the byte layout the application bound",
structId, plan.wordCount);
if (plan.openEnded) {
MGLOG_D("[spirv] storage block %%%u: flattened into an open-ended word array (%u-word "
"prefix, %u-word elements) so its 64-bit members keep the byte layout the "
"application bound",
structId, plan.wordCount, plan.tailStrideWords);
} else {
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members "
"keep the byte layout the application bound",
structId, plan.wordCount);
}
}
if (!modified) {
if (!modified && !touched) {
return Status::SuccessWithoutChange;
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
@@ -66,17 +66,32 @@ namespace MobileGL {
// fp32 promise DemoteFloat64Pass already makes - what changes is only that the
// BYTES around the value stay where the application put them.
//
// AN OPEN-ENDED BLOCK - one whose last member is a runtime array, the
// `buffer B { double data[]; }` every unsized storage buffer is spelled as - is
// flattened the same way: the members before the array are the fixed prefix, and
// the flattened member is itself a `uint[]` runtime array, ArrayStride 4, with no
// length for the driver to re-derive. Element i of the original array lives at
// word `prefix + i * stride` of it, which is where the application put it. The
// block's `.length()` is rewritten too, because OpArrayLength on the flattened
// member counts WORDS: it becomes `(words - prefix) / stride` in unsigned
// arithmetic, clamped at zero when the bound range is shorter than the prefix,
// which is the floor GL defines `.length()` as.
//
// DECLINES, leaving the block exactly as it was for DemoteFloat64Pass to handle the
// old way, whenever it meets something it cannot rewrite exactly:
// - a block whose variable is used as anything but an access-chain base (loaded
// whole, handed to a function, asked its OpArrayLength);
// whole, handed to a function), or asked an OpArrayLength it is not open-ended
// for;
// - an access chain that is not rooted at the variable, or whose result feeds
// anything but a plain OpLoad / OpStore (an atomic, OpCopyMemory, a further
// chain);
// - a non-constant index into a struct, a runtime array anywhere in the block, a
// RowMajor matrix (its columns are not contiguous, so a whole-column access is
// not one range), a member width other than 32 or 64 bits, or an offset or
// stride that is not a multiple of 4;
// chain), or one that names a whole runtime array rather than an element of it;
// - a non-constant index into a struct, a runtime array that is not the last
// member of the block itself (nested in a member, or followed by another -
// shapes GLSL cannot spell but SPIR-V can), a runtime array without an
// ArrayStride or whose element the pass cannot decompose, a RowMajor matrix (its
// columns are not contiguous, so a whole-column access is not one range), a
// member width other than 32 or 64 bits, or an offset or stride that is not a
// multiple of 4;
// - a load or store whose type decomposes into more scalars than the cap below,
// so legalizing a block can never explode the module.
//
@@ -78,9 +78,9 @@ namespace MobileGL {
}
// Locations one value of `type` occupies (GL 4.6 core 11.1.2.1 / 15.2): a
// matrix takes one per column, a double-precision vector wider than two takes
// two, an array takes its element's span once per element. 0 means "this pass
// cannot place it", which declines the whole block rather than guessing.
// matrix takes one per column, a 64-bit vector wider than two takes two, an
// array takes its element's span once per element. 0 means "this pass cannot
// place it", which declines the whole block rather than guessing.
Uint32 LocationSpan(const analysis::Type* type) {
if (type == nullptr) return 0;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
@@ -93,8 +93,14 @@ namespace MobileGL {
element->AsBool() == nullptr) {
return 0;
}
// 64-bit INTEGERS span two locations exactly like doubles do:
// ARB_gpu_shader_int64 extends 11.1.2.1's double-precision rule
// verbatim to i64/u64. Answering 1 for an i64vec4 would pack the
// members after it onto locations that varying already owns.
const auto* elementFloat = element->AsFloat();
const Bool is64Bit = elementFloat != nullptr && elementFloat->width() == 64;
const auto* elementInteger = element->AsInteger();
const Bool is64Bit = (elementFloat != nullptr && elementFloat->width() == 64) ||
(elementInteger != nullptr && elementInteger->width() == 64);
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
@@ -34,7 +34,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// gated, so one L1 key shape can describe two materially different module sets (real
// doubles vs demoted-and-flattened) and a blob written under 4 says nothing about
// which one it holds.
constexpr Uint32 kKeyLayoutVersion = 5u;
// 6: L1 gained the two point-size demotion bits (demoteTessellationPointSize /
// demoteGeometryPointSize). Phase B now rewrites the cached modules on a device
// that cannot host gl_PointSize in tessellation/geometry stages, so a blob
// written under 5 says nothing about whether its modules were demoted.
constexpr Uint32 kKeyLayoutVersion = 6u;
// The repo's existing cache epoch (MG_Config::CacheVersion, the seed
// ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory
@@ -128,6 +132,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.Value(inputs.shaderCompileFlags);
builder.Value(static_cast<Uint8>(inputs.enableSpirvValidation));
builder.Value(static_cast<Uint8>(inputs.nativeFloat64));
builder.Value(static_cast<Uint8>(inputs.demoteTessellationPointSize));
builder.Value(static_cast<Uint8>(inputs.demoteGeometryPointSize));
builder.Value(static_cast<Uint64>(inputs.stages.size()));
for (const auto& stage : inputs.stages) {
builder.Value(static_cast<Uint32>(stage.type));
@@ -411,9 +411,17 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
const UnorderedMap<String, Uint>* explicitFragmentOutIndices = nullptr;
Uint32 shaderCompileFlags = 0;
Bool enableSpirvValidation = false;
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. The
// one backend capability bit in this key; see the note above for why it has to be.
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. See
// the note above for why it has to be here.
Bool nativeFloat64 = false;
// CompileEnv::DemotesTessellationPointSize() / DemotesGeometryPointSize() - the
// second and third capability bits under the same rule as nativeFloat64: each ARMS
// a phase-B rewrite of the cached modules themselves
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), so the same GLSL
// produces materially different module sets under the two answers - built-in
// point size kept, or carried as an ordinary varying with the capability stripped.
Bool demoteTessellationPointSize = false;
Bool demoteGeometryPointSize = false;
// ---- inputs that only matter because the PAYLOAD now carries the reflection ----
// When the payload was SPIR-V alone these were provably irrelevant: transform
// feedback is resolved by READING the linked intermediates and never writes an XFB
+47 -4
View File
@@ -51,6 +51,48 @@ def wait_for_device(serial, attempts=20, delay=15):
return False
def device_file_size(serial, path):
r = adb(serial, "shell", f"stat -c %s {path} 2>/dev/null || echo 0", timeout=30)
m = re.search(r"(\d+)", r.stdout or "")
return int(m.group(1)) if m else 0
def run_chunk(serial, cmd, dev_qpa, dev_list, idle_timeout, poll_interval=15):
"""Run one glcts invocation; give up only when the log stops growing.
A chunk is thousands of cases and legitimately runs for an hour, so a fixed
wall-clock cap would kill healthy invocations and record whichever case was
in flight as a crash. A GPU hang, by contrast, stops the .qpa from growing.
The timeout is therefore measured from the last observed growth of the
device-side log. On expiry the device-side glcts is killed (matched by the
caselist path this runner alone uses, so other processes are left alone) and
returncode 124 is reported, the same signal a hard timeout used to give.
"""
proc = subprocess.Popen(["adb", "-s", serial, "shell", cmd],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
last_size = -1
last_growth = time.time()
while True:
try:
out, err = proc.communicate(timeout=poll_interval)
return subprocess.CompletedProcess(proc.args, proc.returncode, out, err)
except subprocess.TimeoutExpired:
pass
size = device_file_size(serial, dev_qpa)
now = time.time()
if size != last_size:
last_size = size
last_growth = now
elif now - last_growth > idle_timeout:
adb(serial, "shell", f"pkill -f {dev_list}", timeout=30)
proc.kill()
try:
proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
pass
return subprocess.CompletedProcess(proc.args, 124, "", "idle timeout")
def mem_available_kb(serial):
r = adb(serial, "shell", "grep MemAvailable /proc/meminfo", timeout=30)
m = re.search(r"(\d+)", r.stdout or "")
@@ -152,7 +194,8 @@ def main():
ap.add_argument("--min-mem-kb", type=int, default=400000,
help="pause when the device drops below this much available memory")
ap.add_argument("--chunk-timeout", type=int, default=900,
help="seconds before giving up on one glcts invocation (a GPU hang never returns)")
help="seconds without any growth of the device-side .qpa before the glcts "
"invocation is declared hung and killed (a GPU hang never returns)")
ap.add_argument("--skip-file", default=None,
help="file of case names to exclude, e.g. cases known to hang the device")
ap.add_argument("--env", action="append", default=[], metavar="K=V",
@@ -239,10 +282,10 @@ def main():
f"--deqp-log-images=disable --deqp-log-shader-sources=disable "
f"--deqp-log-filename={dev_qpa} > /dev/null 2>&1; rc=$?; sync; echo RC=$rc"
)
run = adb(args.serial, "shell", cmd, timeout=args.chunk_timeout)
run = run_chunk(args.serial, cmd, dev_qpa, dev_list, args.chunk_timeout)
if run.returncode == 124:
print(f"[run_cts] chunk {chunk:04d} timed out after {args.chunk_timeout}s "
f"(likely a GPU hang)", file=sys.stderr)
print(f"[run_cts] chunk {chunk:04d}: no log growth for {args.chunk_timeout}s "
f"(likely a GPU hang); killed glcts", file=sys.stderr)
# Some cases hang the GPU hard enough to reboot the device. The log on
# /data/local/tmp survives that, so wait for the device to come back and