mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 21:58:31 +09:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c20e2f2b67 | ||
|
|
680ea63360 | ||
|
|
959ca30810 | ||
|
|
0bff6875b3 | ||
|
|
707bced438 | ||
|
|
83c8101a9b | ||
|
|
2a5e0195b8 | ||
|
|
afda613bd9 | ||
|
|
433f51a065 | ||
|
|
9ba5d7ba1e | ||
|
|
f0beefa85c | ||
|
|
78ff014547 | ||
|
|
3e298c9ad1 | ||
|
|
fd6b5bdbf2 | ||
|
|
8d3cd67b94 | ||
|
|
f6c7dcd1b6 | ||
|
|
4ad88214cd | ||
|
|
4412cef00c | ||
|
|
6e9943b36e | ||
|
|
0cb46fe2bd | ||
|
|
83302ca247 | ||
|
|
3c55e02797 | ||
|
|
75ea7ee2df | ||
|
|
13b380feda | ||
|
|
ed83424c75 | ||
|
|
df055eab13 | ||
|
|
511b3752c0 | ||
|
|
b28058f37c | ||
|
|
14137bc9a6 | ||
|
|
39933613ae | ||
|
|
816373ffd9 | ||
|
|
7047331a41 | ||
|
|
7ef8cb93fc | ||
|
|
b97a228cea | ||
|
|
3840cf734b | ||
|
|
68db6db7f8 | ||
|
|
cd05de504e | ||
|
|
ce24e2a734 | ||
|
|
4e44650199 | ||
|
|
9951961d9c | ||
|
|
31e370bed5 | ||
|
|
0c55560510 | ||
|
|
f11e78b0a4 | ||
|
|
d9bde13127 | ||
|
|
cc427ec4de | ||
|
|
b11bb9650a | ||
|
|
14efd6eb24 | ||
|
|
874d1ee77d | ||
|
|
b9eaa47480 | ||
|
|
42d43af25b | ||
|
|
12e6bfcf14 | ||
|
|
e6452ce948 | ||
|
|
56366331dc | ||
|
|
355c60b901 | ||
|
|
6eb0e675ad | ||
|
|
45c8f1a8be | ||
|
|
5cb826b01e | ||
|
|
e01c0ccc53 | ||
|
|
c036900d72 | ||
|
|
44c2b5cf3a | ||
|
|
738b289df8 | ||
|
|
a9778eaabe | ||
|
|
c73ae7d443 | ||
|
|
55d2af9bd1 | ||
|
|
2d690754dd | ||
|
|
7a2e256133 | ||
|
|
bb2a236d5f | ||
|
|
13d7e32b7b | ||
|
|
b1c37699b1 | ||
|
|
e5603f9a46 | ||
|
|
08d14d85ef | ||
|
|
af20dba6db | ||
|
|
a5d1136c02 | ||
|
|
d704401a56 | ||
|
|
ce9f44a24c | ||
|
|
1a012f2820 | ||
|
|
b9c137e146 | ||
|
|
e9499d38bd | ||
|
|
f1780b9000 | ||
|
|
e5c032c89e | ||
|
|
a174a06c79 | ||
|
|
46841ac706 | ||
|
|
01179c54d2 | ||
|
|
3594f03c4e | ||
|
|
43f8b47088 | ||
|
|
c74c4819fb | ||
|
|
96c544514e | ||
|
|
59191cd296 | ||
|
|
8d0ed5b82c | ||
|
|
f15b0fdf4b | ||
|
|
8f66c374aa | ||
|
|
067b186677 | ||
|
|
5d4d91fe7e | ||
|
|
bb781df527 | ||
|
|
c574043c13 | ||
|
|
3ab394e2b8 | ||
|
|
43bf97cc87 | ||
|
|
3302ee82b5 | ||
|
|
7dec32a574 | ||
|
|
dcfa5ad311 | ||
|
|
aa64c91052 | ||
|
|
fcd4ad3799 | ||
|
|
de532f55a9 | ||
|
|
3d1a866e82 | ||
|
|
df784c6752 | ||
|
|
c9dd173201 | ||
|
|
f5bd1a0412 | ||
|
|
6cb7d1b83b | ||
|
|
7c97fcfee3 | ||
|
|
caa0a7221b | ||
|
|
eb81705130 | ||
|
|
e10f5d6750 | ||
|
|
5a3c0616b7 | ||
|
|
9a8369296e | ||
|
|
149e26a79a | ||
|
|
3160c4b85b | ||
|
|
bd2092f4eb | ||
|
|
f4dbea2300 | ||
|
|
9eae98581f | ||
|
|
d7655247f7 | ||
|
|
842af23331 | ||
|
|
d1a7c5f159 | ||
|
|
ce370a3e84 | ||
|
|
bee07c3273 | ||
|
|
7c2c1456f8 | ||
|
|
ad1238bd6f | ||
|
|
a9bb99a46a | ||
|
|
02b970e9c1 | ||
|
|
eec92cd221 | ||
|
|
810850b13a | ||
|
|
9c6a8a25d8 |
+145
-12
@@ -420,13 +420,20 @@ jobs:
|
||||
echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the two checks below could not have failed honestly"
|
||||
exit 1
|
||||
fi
|
||||
for entry in MGPipeVerifyInputs MGPipeFillForVerb; do
|
||||
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "${entry}"; then
|
||||
echo "::error::libMobileGL.so defines no ${entry}: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "libMobileGL.so defines MGPipeVerifyInputs and MGPipeFillForVerb (${defined} defined symbols)"
|
||||
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeVerifyInputs"; then
|
||||
echo "::error::libMobileGL.so defines no MGPipeVerifyInputs: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing"
|
||||
exit 1
|
||||
fi
|
||||
# The per-verb entry point, under EITHER of its two names. P2 renames
|
||||
# MGPipeFillForVerb to MGPipeValidateForVerb (the body becomes the tracker's walk and
|
||||
# the fill is one of its five steps), so this check has to accept both or it goes red on
|
||||
# the rename for a reason that has nothing to do with what it tests. What it tests is
|
||||
# unchanged: that the library HAS a per-verb entry point compiled in.
|
||||
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -qE "MGPipeValidateForVerb|MGPipeFillForVerb"; then
|
||||
echo "::error::libMobileGL.so defines neither MGPipeValidateForVerb nor MGPipeFillForVerb: there is no per-verb entry point in this artifact, so nothing fills the block the comparator compares"
|
||||
exit 1
|
||||
fi
|
||||
echo "libMobileGL.so defines MGPipeVerifyInputs and a per-verb entry point (${defined} defined symbols)"
|
||||
|
||||
- name: Show ccache stats
|
||||
if: always()
|
||||
@@ -542,6 +549,72 @@ jobs:
|
||||
ctest --output-on-failure -L integration-verify --no-tests=error
|
||||
fi
|
||||
|
||||
# The push-only unit tests, on the verify runtime.
|
||||
#
|
||||
# WHY HERE AND NOT IN `test`. The entries themselves are registered in EVERY build - they
|
||||
# have to be, or `ctest -N` would stop matching name-for-name between the pull and the push
|
||||
# build (gate G2). What is push-only is what they assert about: MGPipeRenderStateSpans.cpp
|
||||
# and PipeApply.cpp are appended to SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block,
|
||||
# which is exactly how the pull build stays symbol-identical, so in a pull build each case
|
||||
# opens with `#if !MOBILEGL_PIPE_PUSH GTEST_SKIP() << "push not compiled in"`. The `test`
|
||||
# job therefore runs G6's chunk-table walk and G10's residual assertions as a column of
|
||||
# skips: CI executes the NAMES and never one of the assertions. This job unpacks a build
|
||||
# that compiled them, so it is the first place in CI where they actually run.
|
||||
#
|
||||
# This artifact already carries them - the packaging step above tars
|
||||
# ${BUILD_DIR}/MobileGL/MG_Test whole - so the whole cost is the run, which is ~14 s for
|
||||
# ~1490 entries. --no-tests=error, because a packaging change that stopped shipping the
|
||||
# unit binaries would otherwise report a green run of nothing.
|
||||
- name: Unit tests on the verify runtime (G6, G10)
|
||||
working-directory: build-verify
|
||||
run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)"
|
||||
|
||||
# The always-on P2 and P3a negative controls (G8, G10, G12), which are labelled
|
||||
# integration-gpu and not integration-verify - they are about the handle key, the subsystem
|
||||
# switch and the map-persistent counter, not about the comparator - so the lane above does
|
||||
# not reach them. They are run HERE because this is the only CI job that unpacks a
|
||||
# MOBILEGL_PIPE_PUSH build: every one of them reads a counter out of the library's summary
|
||||
# line, and both the counters and their brackets are #if MOBILEGL_PIPE_PUSH, so in the pull
|
||||
# `integration` job the entries exist (gate G2 requires the same names in both builds) but
|
||||
# have nothing to assert.
|
||||
#
|
||||
# An arm whose subsystem has not landed on this tree SKIPS with the reason (never absent,
|
||||
# never a green that asserted nothing), so this step is green through the P2 and P3a landing
|
||||
# orders and starts asserting as each package arrives.
|
||||
#
|
||||
# The environment is the sibling step's, deliberately and in full: these entries run the
|
||||
# same DirectVulkan binary through the same runner, so the three MOBILEGL_MAGMA_* fixes it
|
||||
# needs apply here too, and a crash here has to leave a core for the same black-box flow.
|
||||
# The step above is the only reason those lines exist in this job; a control that crashed
|
||||
# without one would be the hardest failure in the job to diagnose.
|
||||
#
|
||||
# THE -R ALTERNATIVES ARE TEST-NAME PREFIXES, NOT LANE LABELS, and each one is deliberately
|
||||
# the SHORTEST string that still selects only what it means to. `ResourceSubsystem` (not
|
||||
# `ResourceSubsystemControl`) is what reaches the eight
|
||||
# DirectGLES.ResourceSubsystemOn./Off.LargeArenaAdoptionScenario.* entries - the A/B lanes
|
||||
# whose entire purpose is that the handle path and the legacy BufferBackendOps path must
|
||||
# agree about an adopted store - as well as the two ResourceSubsystemControl. entries.
|
||||
# `MapPersistentRoundtrip` is singular because LargeArenaAdoptionScenario's case is
|
||||
# `AnAdoptionCostsExactlyOneMapPersistentRoundtrip`; the plural matched only the lane PREFIX
|
||||
# of the other one. Both mistakes were silent: this is the only CI job that unpacks a push
|
||||
# build, so an entry the filter misses is either never run under the P3a bits at all or runs
|
||||
# only in the pull `integration` job, where a MOBILEGL_PIPE_PUSH value steers nothing
|
||||
# (Config.h declares the field inside the push guard) and both arms are the same legacy path.
|
||||
# A lane that cannot go red where it is installed is not a gate (ROADMAP.md:7).
|
||||
- name: The handle-ABA, CSO and P3a subsystem controls (G8, G10, G12)
|
||||
working-directory: build-verify
|
||||
env:
|
||||
MOBILEGL_ITEST_REQUIRE_GPU: "1"
|
||||
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
|
||||
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1"
|
||||
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1"
|
||||
run: |
|
||||
ulimit -c unlimited
|
||||
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
|
||||
ctest --output-on-failure -L integration-gpu \
|
||||
-R 'HandleRecycle|CsoContentAddressing|ResourceSubsystem|MapPersistentRoundtrip' \
|
||||
--no-tests=error -j 4
|
||||
|
||||
# The arming lanes' logs, and ONLY those. Each lane shares one MOBILEGL_LOG_FILE_PATH and the
|
||||
# library opens it fopen(path, "w"), so after an ambient lane of 400-odd processes the file
|
||||
# holds the LAST one - grepping it would say nothing about the other 405 and would red a
|
||||
@@ -1483,10 +1556,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Deliberately independent of build-linux: these are source-level gates, they take
|
||||
# seconds, and a broken build must not hide a drifted interface.
|
||||
env:
|
||||
# P3a's base ref, for the G5 region gate below. It is 44c2b5cf and NOT the workflow's
|
||||
# baseline_sha input: that input is the SYMBOL baseline (087685d1, P1's G1 reading) and it
|
||||
# is empty on a push, whereas this gate asks "did the buffer pool, the deferred-release
|
||||
# drain and the three rings move since the phase started". INTEGRATOR-DECISIONS ID-1 is
|
||||
# where the ref comes from.
|
||||
BASELINE: "44c2b5cf"
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# The G5 gate reads Managers.cpp at BASELINE with `git show`, which a depth-1 checkout
|
||||
# does not have. Nothing else in this job needs history.
|
||||
fetch-depth: 0
|
||||
|
||||
# The seven generators all read MG_Pipe/*.def, so regenerating and diffing is what
|
||||
# keeps the two interface tables, the wire records, the verify comparators, the
|
||||
@@ -1525,11 +1609,60 @@ jobs:
|
||||
fi
|
||||
echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State"
|
||||
|
||||
# Informational: the frontend mutation surface an MGPipe aggregate generation has to
|
||||
# cover. It becomes a gate in P2, when the mapping file exists to diff against
|
||||
# (ROADMAP.md:18 puts the first mapping round in P2, not P1).
|
||||
- name: MGPipe dirty-surface report
|
||||
run: python3 scripts/gen_pipe_dirty_surface.py --summary
|
||||
# A GATE as of P2, which is when MG_Pipe/DirtySurface.def exists to diff the scan against
|
||||
# (ROADMAP.md:18 puts the first mapping round in P2). --check fails BOTH directions: a
|
||||
# mutator the scanner finds with no row in the def, and a row naming a mutator the scan no
|
||||
# longer finds - so a deleted mutator cannot leave a stale row behind claiming coverage.
|
||||
#
|
||||
# --self-test is the half that keeps --check honest, and it is not optional. A completeness
|
||||
# check that silently stopped checking produces exactly the same green as a complete
|
||||
# mapping; the self-test feeds it two canned negative controls (a mutator withheld from the
|
||||
# def, a row naming a function that does not exist) and fails if either fails to trip. Same
|
||||
# shape as gen_pipe.py --self-test and check_include_closure.py above.
|
||||
#
|
||||
# What this gate does NOT cover is written into DirtySurface.def's header rather than left
|
||||
# implicit: the scanner attributes a mutation inside a lambda to the enclosing function,
|
||||
# reads a mutation published through a helper as deferred, and scans only MG_Impl/GLImpl -
|
||||
# so the four MGP_NOTE_MUTATION sites in MG_State are outside it entirely. This is a
|
||||
# completeness gate over what the scanner can see; the semantic proof is the verify lane.
|
||||
- name: MGPipe dirty-surface mapping is complete (G9)
|
||||
run: |
|
||||
python3 scripts/gen_pipe_dirty_surface.py --check
|
||||
python3 scripts/gen_pipe_dirty_surface.py --self-test
|
||||
|
||||
# A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the
|
||||
# buffer pool, the deferred-release drain and the three persistently mapped rings move
|
||||
# VERBATIM, and ARCHITECTURE.md:515 says why - their retire happens only inside Present, so
|
||||
# a batching or ordering change there starves them, and nothing else in this workflow can
|
||||
# see it. P3a rewrites the rest of Managers.cpp by design, so a file diff says nothing; the
|
||||
# script extracts the ELEVEN named bodies and compares their hashes on their own.
|
||||
#
|
||||
# Eleven and not ten (ID-15): Managers.cpp carries the three-tier flush drain TWICE, once
|
||||
# per preprocessor arm, and a push build compiles only FlushPendingRangesFrom while the
|
||||
# untouched FlushPendingRangesNow lives in the `#else`. Hashing the pull name alone would
|
||||
# protect text no shipping build compiles, so both are hashed - the pull ladder against
|
||||
# BASELINE, the push ladder against a sha pinned in the script at 3e298c9a, because that
|
||||
# one was born in P3a and has no body at the base ref to compare with.
|
||||
#
|
||||
# Scoped to the disaggregation branch and to a manual dispatch, deliberately: the question
|
||||
# is "did these eleven move since P3a started", and BASELINE is P3a's base ref. On dev,
|
||||
# where unrelated buffer fixes land on their own schedule, the same comparison would be
|
||||
# asking a question nobody posed - it belongs with the TEMPORARY trigger lines at the top
|
||||
# of this file and retires with them.
|
||||
#
|
||||
# --self-test is the half that keeps it honest, and it is not optional: a comparison that
|
||||
# silently stopped comparing produces exactly the same green as eleven untouched bodies. It
|
||||
# runs six canned controls - eleven bodies extracted, an untouched copy compared equal, an
|
||||
# edit OUTSIDE them ignored, and each of the three perturbation targets (ClearBufferPool
|
||||
# and BOTH flush ladders) reported BY NAME - and fails if any of them does not answer.
|
||||
# Same shape as gen_pipe.py --self-test above.
|
||||
- name: The buffer pool, the deferred-release drain and the rings did not move (G5)
|
||||
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
|
||||
run: bash scripts/p3a_untouched_regions.sh "${BASELINE}" HEAD
|
||||
|
||||
- name: The untouched-region gate can still fail (G5)
|
||||
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
|
||||
run: bash scripts/p3a_untouched_regions.sh --self-test
|
||||
|
||||
# Warning only for now: the disaggregation documents are still being written, and a
|
||||
# lint that fails a rewrite in progress teaches people to ignore it. It becomes
|
||||
|
||||
@@ -27,6 +27,13 @@ option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery
|
||||
# MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0).
|
||||
option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF)
|
||||
option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF)
|
||||
# Track H's old-versus-new arm (ARCHITECTURE.md 9.6). With a MOBILEGL_PIPE_PUSH bit clear
|
||||
# the backend would still run the RE-KEYED memo code, so the bitmask alone stops being a
|
||||
# valid A/B the moment a handle wave lands: this option compiles the pre-handle arm - the
|
||||
# registries, OwnerEquals, the TwinLookupMemos, g_fbSlotCache, ComputePipelineStateHash,
|
||||
# the address-keyed VaoDrawMemo - beside it, behind the same PipeInputs interface. ON for
|
||||
# the whole migration window; it retires with the pull path itself at P13.
|
||||
option(MOBILEGL_PIPE_LEGACY_MEMOS "Compile the pre-handle memo arm beside the {slot, gen} arm so Track H has a real A/B (ARCHITECTURE.md 9.6)" ON)
|
||||
set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro")
|
||||
set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds")
|
||||
|
||||
@@ -328,6 +335,7 @@ set(SOURCE_FILES
|
||||
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
|
||||
@@ -464,11 +472,25 @@ if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH)
|
||||
set(MOBILEGL_PIPE_PUSH ON)
|
||||
endif()
|
||||
|
||||
# In a pull build the legacy arm is the ONLY arm, so the option cannot be off there.
|
||||
# A normal variable, not a forced cache write, for the same reason as the two above.
|
||||
if (NOT MOBILEGL_PIPE_PUSH AND NOT MOBILEGL_PIPE_LEGACY_MEMOS)
|
||||
message(STATUS "MobileGL: MOBILEGL_PIPE_PUSH=OFF forces MOBILEGL_PIPE_LEGACY_MEMOS ON for this "
|
||||
"configure: with nothing pushed it is the only arm there is")
|
||||
set(MOBILEGL_PIPE_LEGACY_MEMOS ON)
|
||||
endif()
|
||||
|
||||
if (MOBILEGL_PIPE_PUSH)
|
||||
message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources")
|
||||
list(APPEND SOURCE_FILES
|
||||
MobileGL/MG_Backend/MGPipe/PipeInputs.cpp
|
||||
MobileGL/MG_Impl/Pipe/PipeFill.cpp
|
||||
# P2's contract: the chunk table and its subset hash, the in-process applier, and
|
||||
# the client's {slot, gen} allocator. All three are push-only, which is how the
|
||||
# pull build gains no symbol from P2 (G1) - a declaration emits nothing.
|
||||
MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp
|
||||
MobileGL/MG_Pipe/PipeApply.cpp
|
||||
MobileGL/MG_Impl/Pipe/SlotAllocator.cpp
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -551,6 +573,9 @@ endif()
|
||||
if (MOBILEGL_PIPE_VERIFY)
|
||||
list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1)
|
||||
endif()
|
||||
if (MOBILEGL_PIPE_LEGACY_MEMOS)
|
||||
list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_LEGACY_MEMOS=1)
|
||||
endif()
|
||||
|
||||
message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}")
|
||||
|
||||
|
||||
+29
-5
@@ -14,7 +14,7 @@ namespace MobileGL::MG_Config {
|
||||
inline const String ProjectName = "MobileGL";
|
||||
inline const String CoreName = "MobileGL Core";
|
||||
inline const String CoreVendor = "MobileGL-Dev (BZLZHH, Swung0x48, Tungsten)";
|
||||
inline const Version CoreVersion = {26, 8, 0, "-dev", VersionType::Development};
|
||||
inline const Version CoreVersion = {26, 9, 0, "-dev", VersionType::Development};
|
||||
inline const VersionStringFormatAttrib DefaultVersionStringFormatAttrib = {2, 2, 0, true, true};
|
||||
inline const Uint64 CacheVersion = 0;
|
||||
|
||||
@@ -319,10 +319,22 @@ namespace MobileGL::MG_Config {
|
||||
// --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) ---
|
||||
// MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend
|
||||
// PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext.
|
||||
// 0 - the default and the only shipped value until the migration lands - is "pull
|
||||
// everything", i.e. exactly today's behaviour. One bit of it also turns OFF
|
||||
// client-side content addressing of CSOs, which is the negative control the CSO
|
||||
// design is measured against. Accepts decimal or 0x-prefixed hex.
|
||||
// 0 - the only shipped value until the migration lands - is "pull everything",
|
||||
// i.e. exactly today's behaviour, and is the default of a PULL build, where the
|
||||
// knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so
|
||||
// far (MG_Pipe::kMGPipeSubsystemsMigratedAtP3a), so MOBILEGL_PIPE_PUSH=0 in the
|
||||
// environment is the all-pull control and 0x7f (kMGPipeSubsystemsMigratedAtP2) is
|
||||
// the "P2 only" control P3a's A/B is run against. Accepts decimal or 0x-prefixed
|
||||
// hex, and operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h
|
||||
// owns them):
|
||||
// 0x01 render state (create/bind_render_state + set_dynamic_state)
|
||||
// 0x02 pixel pack 0x04 patch state 0x08 vertex attrib defaults
|
||||
// 0x10 residual values 0x20 Espryt slots 0x40 Magma vertex input
|
||||
// 0x80 resources (the resource_* family: the seven BufferBackendOps hooks)
|
||||
// 0x100 vertex input (vertex elements / vertex buffers / index buffer)
|
||||
// 1<<63 NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of
|
||||
// CSOs, so every pipeline-version change mints a fresh CSO and the map is
|
||||
// never probed. The negative control the CSO design is measured against.
|
||||
Uint64 PipePush = 0;
|
||||
// MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state
|
||||
// against a snapshot taken from GLContext the old way, printing the first field
|
||||
@@ -349,6 +361,18 @@ namespace MobileGL::MG_Config {
|
||||
// Fatal{UnmigratedPipeInput} (negative control B). Unknown name is
|
||||
// Fatal{PipeVerifyBadKnob}.
|
||||
String PipePoisonOmit;
|
||||
// MOBILEGL_PIPE_HANDLE_ABA_CONTROL (negative control C, P2 brief D18): replace the
|
||||
// OBJECT IDENTITY in every DirectVulkan vertex-input memo key with a constant, on
|
||||
// whichever arm the run is on - the pre-handle (address, lifetime id) pair AND the
|
||||
// handle arm's {slot, gen} generation - so a replacement object inherits its dead
|
||||
// predecessor's resolved vertex bindings and HandleRecycleScenario.AbaControl asserts
|
||||
// the WRONG pixels. That is what proves the reproducer still reproduces. D18 wrote
|
||||
// this as "hash the raw BufferObject* instead of its lifetime id"; measured, the heap
|
||||
// block is never handed back, so that spelling collided with nothing and the control
|
||||
// went vacuous - see MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity for the
|
||||
// measurement and for what the control still leaves standing. Under
|
||||
// MOBILEGL_PIPE_PUSH only, so it cannot exist in a shipping pull build.
|
||||
Bool PipeHandleAbaControl = false;
|
||||
#endif
|
||||
// MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips,
|
||||
// texture pulls, upload shapes, residual-block bytes, index mirror bytes).
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "Config.h"
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// For kMGPipeSubsystemsMigratedAtP3a, the push build's PipePush default (the P2 constant
|
||||
// beside it is the phase-by-phase control, not the default). Push-only, so the pull
|
||||
// build's translation unit is unchanged.
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#endif
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
@@ -242,7 +248,18 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
// MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables
|
||||
// accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name
|
||||
// that starts with MOBILEGL_ is visible to these queries by construction.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// A push build with the knob unset runs every subsystem migrated so far, so the
|
||||
// shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the
|
||||
// environment is the all-subsystems-pull control that reproduces P1 exactly, and
|
||||
// kMGPipeSubsystemsMigratedAtP2 (0x7f) is the phase-by-phase control - P3a's two
|
||||
// subsystems off, everything P2 landed still on.
|
||||
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP3a);
|
||||
#else
|
||||
// Meaningless in a pull build: there is nothing to push. Config.h documents 0 as
|
||||
// "pull everything" and that stays literally true.
|
||||
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0);
|
||||
#endif
|
||||
features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY");
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Defaults ON: read as a tri-state so only an explicitly falsy value turns it off.
|
||||
@@ -250,6 +267,7 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff;
|
||||
QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, "");
|
||||
QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, "");
|
||||
features.PipeHandleAbaControl = QueryEnvFlag("MOBILEGL_PIPE_HANDLE_ABA_CONTROL");
|
||||
#endif
|
||||
features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS");
|
||||
// Defaults ON, so the flag has to be read as a tri-state rather than as a plain
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
#include <MG_Util/Metrics/TextureMetrics.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Pipe/PipeInputsSwitch.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a: the applier's vertex-input records the re-keyed draw-buffer memo is validated against.
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#endif
|
||||
#include <MG_State/GLState/ErrorState/Error.h>
|
||||
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
@@ -57,9 +61,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static SharedPtr<MG_State::GLState::SamplerObject> g_rawDepthFetchSamplerState;
|
||||
static SharedPtr<SamplerImpl::BackendSamplerObject> g_rawDepthFetchSamplerBackend;
|
||||
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// Two objects are the same binding iff they share a control block. Raw addresses lie
|
||||
// (a freed object's heap slot is reused), but a held weak_ptr pins the control block,
|
||||
// so no later object can ever owner-equal a snapshot of its predecessor.
|
||||
//
|
||||
// This is the pre-handle identity mechanism, and it is compiled only for the legacy arm.
|
||||
// On the {slot, gen} arm nothing needs it: a handle already cannot be reproduced by a
|
||||
// recycled address, so there is no snapshot to owner-compare.
|
||||
template <typename T>
|
||||
static Bool OwnerEquals(const WeakPtr<T>& snapshot, const SharedPtr<T>& current) {
|
||||
return !snapshot.owner_before(current) && !current.owner_before(snapshot);
|
||||
@@ -130,6 +139,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// FBOs; 64 slots is plenty.
|
||||
static TwinLookupMemo<MG_State::GLState::FramebufferObject, FramebufferImpl::BackendFramebufferObject, 6>
|
||||
g_fboTwinLookupMemo;
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
|
||||
// Cached addresses of the frontend's framebuffer binding slots. The frontend
|
||||
// getter linear-scans its slot array per call and the draw path asks for these
|
||||
@@ -142,9 +152,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// exactly the pointer compare below.
|
||||
using FbBindingSlot =
|
||||
std::remove_reference_t<decltype(MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw))>;
|
||||
#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
static const void* g_fbSlotCacheContext = nullptr;
|
||||
static Array<FbBindingSlot*, SizeT(FramebufferTarget::FramebufferTargetCount)> g_fbSlotCache = {};
|
||||
static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) {
|
||||
#endif
|
||||
// P2 step e4. On the {slot, gen} arm this is an ORDINARY read of pushed state and the cache
|
||||
// above is not consulted, which closes the P1 accessor bypass: the cached raw pointer ran
|
||||
// the checked accessor once per context change and then handed out the pointee forever, so
|
||||
// at all five call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify
|
||||
// read-hook (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills
|
||||
// GetFramebufferBindingSlot could not be caught here, and a verify build compared the field
|
||||
// only where the slow accessor was used.
|
||||
//
|
||||
// The cache stays on the LEGACY arm, gated on the same subsystem bit as the rest of this
|
||||
// slice, so that MOBILEGL_PIPE_PUSH=0 keeps being the faithful all-subsystems-pull control
|
||||
// ConfigLoader.cpp documents - "reproduces P1's behaviour exactly" has to include this
|
||||
// path, or the integrator's A/B measures e4 on both arms and attributes it to neither. In
|
||||
// the pull build MGB_CTX is the live GLContext, there is no poison to bypass and the
|
||||
// frontend getter still linear-scans, so the cache is exactly the code it was.
|
||||
static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return MGB_CTX->GetFramebufferBindingSlot(target);
|
||||
}
|
||||
#endif
|
||||
#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
const void* ctx = MGB_CTX_IDENTITY;
|
||||
if (ctx != g_fbSlotCacheContext) {
|
||||
auto& live = *MGB_CTX;
|
||||
@@ -154,6 +186,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_fbSlotCacheContext = ctx;
|
||||
}
|
||||
return *g_fbSlotCache[SizeT(target)];
|
||||
#else
|
||||
// No legacy arm compiled: EsprytSlotTablesEnabled() is unconditionally true above.
|
||||
return MGB_CTX->GetFramebufferBindingSlot(target);
|
||||
#endif
|
||||
}
|
||||
|
||||
static Bool IsDualSourceBlendFactor(BlendFactor v) {
|
||||
@@ -465,7 +501,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
for (SizeT i = 0; i < bindingPointCnt; ++i) {
|
||||
const auto& obj =
|
||||
MGB_CTX->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a (D-D): announced on the reverse channel on the handle arm, poked into
|
||||
// the object on the legacy one. MarkBufferGpuWritten is the one place that
|
||||
// decides, so the three announcement sites stay one line each.
|
||||
MarkBufferGpuWritten(obj);
|
||||
#else
|
||||
if (obj) obj->MarkGpuWritten();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +552,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// conformance case reads the result back with glMapBufferRange or
|
||||
// glGetBufferSubData - which serve the frontend's CPU shadow until the buffer is
|
||||
// flagged (BufferObject::SyncGpuWrites), exactly as for a storage buffer.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
MarkBufferGpuWritten(obj);
|
||||
#else
|
||||
obj->MarkGpuWritten();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +578,112 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
BindBufferId(glTarget, backendResource->id);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The handle arm of the resolved-draw-buffers memo (D-G4). Two substitutions and
|
||||
// nothing else:
|
||||
//
|
||||
// validity the frontend VAO's wrapping configuration version is replaced by the
|
||||
// bound vertex-elements CSO ({slot, gen} AND its server-owned content
|
||||
// serial) plus the vertex-buffer set's own serial - so an emission the
|
||||
// suppressor let through is what re-opens the memo, not a counter the
|
||||
// backend reads out of the frontend;
|
||||
// identity an entry names its resource by handle and the clean probe compares that,
|
||||
// instead of a raw frontend address that a successor object can reproduce.
|
||||
//
|
||||
// The WALK itself still reads the frontend VAO's attributes, and deliberately: the
|
||||
// buffers a draw needs ensured is a pull site P3a does not migrate (dirty bits 15-17
|
||||
// are P4b's and the resolution move is P8's), and EnsureBufferResource still owes
|
||||
// BufferObject::SyncPersistentMappedRange one call (D-N).
|
||||
void SyncVaoAttributeBuffersByHandle(const SharedPtr<MG_State::GLState::VertexArrayObject>& currentVAOObject,
|
||||
VertexArrayImpl::BackendVertexArrayObject::ResolvedDrawBuffers* memo,
|
||||
Uint64 bufferEpoch) {
|
||||
const auto& st = MG_Pipe::MGPipeApplier();
|
||||
const MG_Pipe::MGPipeHandle elements = st.BoundVertexElements;
|
||||
Uint64 elementsSerial = 0;
|
||||
Bool haveElementsRecord = false;
|
||||
if (!MG_Pipe::MGPipeHandleIsNull(elements) && elements.Slot < st.VertexElementsCsos.size()) {
|
||||
const auto& record = st.VertexElementsCsos[elements.Slot];
|
||||
if (record.Live && record.Gen == elements.Gen) {
|
||||
elementsSerial = record.ContentSerial;
|
||||
haveElementsRecord = true;
|
||||
}
|
||||
}
|
||||
const Uint64 buffersSerial = st.VertexBuffersSerial;
|
||||
|
||||
// NO LIVE ELEMENTS RECORD IS A MISS, NEVER A HIT. With none, the key above is
|
||||
// {null, 0, buffersSerial} - a key that describes no configuration at all and that
|
||||
// NEVER CHANGES while the state stays that way, so a memo stamped with it would hit
|
||||
// on every later draw of a VAO whose attributes have moved. That state is reachable:
|
||||
// MGPipeApplierReset() empties VertexElementsCsos and BoundVertexElements at every
|
||||
// change of the current context, and the client re-emits only at its next
|
||||
// create/bind. The legacy arm's configuration version caught exactly this by moving.
|
||||
const Bool memoKeyIsMeaningful = haveElementsRecord;
|
||||
|
||||
if (memo && memo->valid && memoKeyIsMeaningful && memo->elementsHandle == elements &&
|
||||
memo->elementsSerial == elementsSerial && memo->buffersSerial == buffersSerial) {
|
||||
if (memo->vboCleanEpoch != bufferEpoch) {
|
||||
Bool allClean = true;
|
||||
for (Uint i = 0; i < memo->count; ++i) {
|
||||
auto& entry = memo->entries[i];
|
||||
// entry.frontend is the same object the legacy arm probes and is kept
|
||||
// alive by the VAO attribute's SharedPtr for as long as this memo is
|
||||
// valid; it answers the live-map question no record carries in P3a.
|
||||
if (IsBufferDrawCleanByHandle(entry.handle, entry.resource, entry.frontend)) continue;
|
||||
allClean = false;
|
||||
entry.resource =
|
||||
EnsureBufferResource(currentVAOObject->GetAttribute(entry.attribIndex).Buffer);
|
||||
}
|
||||
memo->vboCleanEpoch = allClean ? bufferEpoch : 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Full walk, once per distinct buffer, rebuilding the memo as it goes.
|
||||
MG_State::GLState::BufferObject* syncedBuffers[MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS];
|
||||
Uint syncedBufferCount = 0;
|
||||
const auto& allAttributes = currentVAOObject->GetAllAttributes();
|
||||
for (Uint attribIndex = 0; attribIndex < allAttributes.size(); ++attribIndex) {
|
||||
const auto& attrib = allAttributes[attribIndex];
|
||||
if (!attrib.Enabled) continue;
|
||||
const auto& bufferObject = attrib.Buffer;
|
||||
if (!bufferObject) continue;
|
||||
|
||||
auto* const bufferKey = bufferObject.get();
|
||||
Bool alreadySynced = false;
|
||||
for (Uint i = 0; i < syncedBufferCount; ++i) {
|
||||
if (syncedBuffers[i] == bufferKey) {
|
||||
alreadySynced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alreadySynced) continue;
|
||||
|
||||
auto* resource = EnsureBufferResource(bufferObject);
|
||||
if (memo) {
|
||||
auto& entry = memo->entries[syncedBufferCount];
|
||||
entry.frontend = bufferKey;
|
||||
entry.attribIndex = static_cast<Uint8>(attribIndex);
|
||||
entry.resource = resource;
|
||||
entry.handle = HandleOfBuffer(bufferKey);
|
||||
}
|
||||
syncedBuffers[syncedBufferCount++] = bufferKey;
|
||||
}
|
||||
if (memo) {
|
||||
memo->count = syncedBufferCount;
|
||||
memo->elementsHandle = elements;
|
||||
memo->elementsSerial = elementsSerial;
|
||||
memo->buffersSerial = buffersSerial;
|
||||
// Only a key that describes a real configuration is worth remembering; see
|
||||
// memoKeyIsMeaningful above. The walk still ran and the buffers are ensured -
|
||||
// this only refuses to let the NEXT draw skip it.
|
||||
memo->valid = memoKeyIsMeaningful;
|
||||
// Rebuilt via EnsureBufferResource, not probed clean: the next probe pass
|
||||
// stamps the epoch.
|
||||
memo->vboCleanEpoch = 0;
|
||||
}
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
|
||||
// `vaoConfigVersion` is the caller's early read of currentVAOObject->GetConfigVersion():
|
||||
// the VAO's config fields live on a cache line the draw path touches nowhere else, and
|
||||
// cycling section VAOs makes that a guaranteed miss - reading it at the top of
|
||||
@@ -570,6 +723,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const Uint64 bufferEpoch = CurrentBufferMutationEpoch();
|
||||
auto* memo = vaoTwin ? &vaoTwin->GetResolvedDrawBuffersMemo() : nullptr;
|
||||
const Uint32 configVersion = vaoConfigVersion;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (VertexInputSubsystemEnabled()) {
|
||||
SyncVaoAttributeBuffersByHandle(currentVAOObject, memo, bufferEpoch);
|
||||
} else
|
||||
#endif
|
||||
if (memo && memo->valid && memo->configVersion == configVersion) {
|
||||
if (memo->vboCleanEpoch != bufferEpoch) {
|
||||
Bool allClean = true;
|
||||
@@ -640,6 +798,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// rebind another buffer with no epoch (and no config-version) move,
|
||||
// so the identity compare always runs; only the clean PROBE is
|
||||
// elided while the stamp holds.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (ResourceSubsystemEnabled()) {
|
||||
// Same three cases, with the identity re-keyed off the raw frontend
|
||||
// address onto the resource's {slot, gen} (D-G4).
|
||||
const MG_Pipe::MGPipeHandle iboHandle = HandleOfBuffer(possibleIBO.get());
|
||||
if (memo && memo->iboHandle == iboHandle && memo->iboCleanEpoch == bufferEpoch) {
|
||||
// probed fully clean at this epoch; nothing can have dirtied it
|
||||
} else if (memo && memo->iboHandle == iboHandle &&
|
||||
IsBufferDrawCleanByHandle(iboHandle, memo->iboResource,
|
||||
possibleIBO.get())) {
|
||||
memo->iboCleanEpoch = bufferEpoch;
|
||||
} else {
|
||||
auto* resource = EnsureBufferResource(possibleIBO);
|
||||
if (memo) {
|
||||
memo->iboHandle = iboHandle;
|
||||
memo->iboFrontend = possibleIBO.get();
|
||||
memo->iboResource = resource;
|
||||
// Repaired, not probed clean: stamp on the next clean probe.
|
||||
memo->iboCleanEpoch = 0;
|
||||
}
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
if (memo && memo->iboFrontend == possibleIBO.get() && memo->iboCleanEpoch == bufferEpoch) {
|
||||
// probed fully clean at this epoch; nothing can have dirtied it
|
||||
} else if (memo && memo->iboFrontend == possibleIBO.get() &&
|
||||
@@ -1197,12 +1378,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// the result to the buffer sync (resolved-buffers memo host), the VAO sync and
|
||||
// the draw-time bind, which each used to run their own registry Find. The raw
|
||||
// pointer stays valid for the whole draw: the frontend VAO is pinned by the
|
||||
// context binding, and a live object's registry entry is never erased nor its
|
||||
// twin replaced (see TwinLookupMemo's contract).
|
||||
// context binding, and a live object's twin is never erased nor replaced - on the
|
||||
// legacy arm that is TwinLookupMemo's contract, and on the {slot, gen} arm it is
|
||||
// simply that nothing but the sweep frees a slot and the sweep only takes slots
|
||||
// whose frontend object is already gone.
|
||||
BackendVertexArrayObject* ResolveVaoTwin(const SharedPtr<MG_State::GLState::VertexArrayObject>& vao) {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
// No memo on this arm. The memo existed to turn the registry's hash probe into
|
||||
// an array index; the slot table's Find already IS the array index, and the
|
||||
// memo's whole safety argument - owner-equality against a recycled heap address
|
||||
// - is answered by {slot, gen} instead of re-derived per lookup.
|
||||
auto* slot = g_backendVertexArrayObjects.Find(vao.get());
|
||||
auto& backendObj = slot ? *slot : g_backendVertexArrayObjects.GetOrCreate(vao);
|
||||
if (!backendObj) {
|
||||
backendObj = MakeShared<BackendVertexArrayObject>();
|
||||
}
|
||||
return backendObj.get();
|
||||
}
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
if (auto* twin = g_vaoTwinLookupMemo.Lookup(vao)) {
|
||||
return twin;
|
||||
}
|
||||
@@ -1213,6 +1411,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
g_vaoTwinLookupMemo.Store(vao, backendObj.get());
|
||||
return backendObj.get();
|
||||
#else
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
void SyncCurrentVAO(const SharedPtr<MG_State::GLState::VertexArrayObject>& currentVAOObject,
|
||||
@@ -1317,6 +1518,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// relocate every entry - leaving the reference dangling. Holding the object itself
|
||||
// keeps the calls below working on the right twin regardless; the slot is re-resolved
|
||||
// at the end for the reference this function returns.
|
||||
//
|
||||
// The slot-table arm has no such hazard - an entry is an array element and a nested
|
||||
// insert can only reallocate the vector, which the re-resolve at the tail already
|
||||
// handles - so the copy is a refcount it does not need to pay. It keeps the copy for
|
||||
// exactly one thing: holding the twin alive across the nested sync.
|
||||
const SharedPtr<BackendTextureObject> backendObj = backendSlot;
|
||||
|
||||
if (imageBindableStorageRequired) {
|
||||
@@ -1337,6 +1543,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
backendObj->SyncBuiltinSamplerToBackend(textureObject);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
// One re-resolve, and only because a nested GetOrCreate may have GROWN the
|
||||
// vector and moved the element; the entry itself cannot have been erased, since
|
||||
// nothing on this arm erases a live slot. No second Find-or-create, no
|
||||
// put-the-twin-back repair.
|
||||
auto* slot = g_backendTextureObjects.Find(textureObject.get());
|
||||
MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr,
|
||||
"the texture twin resolved at entry is gone after its own sync");
|
||||
if (slot != nullptr && *slot != nullptr) {
|
||||
return *slot;
|
||||
}
|
||||
// Cannot happen - the caller holds the frontend object, so its slot cannot be
|
||||
// reclaimed underneath this call - but the return is a reference, and a null
|
||||
// deref in a release build is a worse way to learn that than a re-created twin.
|
||||
auto& repaired = g_backendTextureObjects.GetOrCreate(textureObject);
|
||||
if (!repaired) {
|
||||
repaired = backendObj;
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
#endif
|
||||
auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get());
|
||||
auto& refreshedBackendObj = refreshedSlot ? *refreshedSlot
|
||||
: g_backendTextureObjects.GetOrCreate(textureObject);
|
||||
@@ -1363,6 +1591,108 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// * everything unit bindings say nothing about: the touched-unit high-water mark,
|
||||
// the frontend context identity, the backend ES context generation, and (for the
|
||||
// resolution memo) the program keys that arbitrate aliased targets.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2: the snapshot stops holding weak_ptrs and holds the frontend objects' LIFETIME IDs.
|
||||
// The weak_ptr was here for one reason - a raw address lies once the allocator recycles
|
||||
// it - and a lifetime id is a monotone per-class counter that is never handed out twice,
|
||||
// so it answers the same question with an integer compare and without pinning a control
|
||||
// block. 0 means "nothing bound", which no live object can collide with (the counters
|
||||
// start at 1). This is not the twin table's {slot, gen}: a bound texture that has never
|
||||
// been synced has no twin and therefore no handle, so a handle-keyed snapshot would read
|
||||
// two never-synced textures as equal. The identity has to exist before the twin does.
|
||||
//
|
||||
// Split by the RUNTIME arm, not by the build, for exactly the reason g_fbSlotCache is:
|
||||
// MOBILEGL_PIPE_PUSH=0 has to reproduce P1's behaviour (ConfigLoader.cpp), and a
|
||||
// legacy-arm run that debounced on lifetime ids would be running P2's mechanism while
|
||||
// the A/B attributed the result to P1. The two answers are equivalent - OwnerEquals on
|
||||
// two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0; a live-versus-expired
|
||||
// control block and two distinct lifetime ids both compare unequal - so keeping the
|
||||
// legacy fields costs that arm nothing but the words, and gives the control back its
|
||||
// fidelity. A build with no legacy arm compiled carries neither the fields nor the
|
||||
// branch.
|
||||
struct UnitBindingsSnapshot {
|
||||
Array<Uint64, (SizeT)TextureTarget::TextureTargetCount> slotObjects{};
|
||||
Uint64 samplerObject = 0;
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// P1's identity, kept verbatim for the legacy arm only.
|
||||
Array<WeakPtr<MG_State::GLState::ITextureObject>, (SizeT)TextureTarget::TextureTargetCount>
|
||||
legacySlotObjects{};
|
||||
WeakPtr<MG_State::GLState::SamplerObject> legacySamplerObject{};
|
||||
#endif
|
||||
};
|
||||
|
||||
static Uint64 LifetimeIdOf(const SharedPtr<MG_State::GLState::ITextureObject>& object) {
|
||||
return object ? object->GetLifetimeId() : 0;
|
||||
}
|
||||
|
||||
static Uint64 LifetimeIdOf(const SharedPtr<MG_State::GLState::SamplerObject>& object) {
|
||||
return object ? object->GetLifetimeId() : 0;
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
#define MGB_UNIT_BINDINGS_HANDLE_ARM (EsprytSlotTablesEnabled())
|
||||
#else
|
||||
#define MGB_UNIT_BINDINGS_HANDLE_ARM (true)
|
||||
#endif
|
||||
|
||||
static void CaptureUnitBindings(Int maxTouchedUnit, Vector<UnitBindingsSnapshot>& out) {
|
||||
const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM;
|
||||
out.resize(static_cast<SizeT>(maxTouchedUnit + 1));
|
||||
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
|
||||
auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit);
|
||||
auto& snapshot = out[static_cast<SizeT>(unit)];
|
||||
const auto& slots = textureUnit.GetAllBindingSlots();
|
||||
for (SizeT i = 0; i < slots.size(); ++i) {
|
||||
if (handleArm) {
|
||||
snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject());
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
else {
|
||||
snapshot.legacySlotObjects[i] = slots[i].GetBoundObject();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (handleArm) {
|
||||
snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject());
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
else {
|
||||
snapshot.legacySamplerObject = textureUnit.GetSamplerObject();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector<UnitBindingsSnapshot>& snapshots) {
|
||||
if (snapshots.size() != static_cast<SizeT>(maxTouchedUnit + 1)) return false;
|
||||
const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM;
|
||||
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
|
||||
auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit);
|
||||
const auto& snapshot = snapshots[static_cast<SizeT>(unit)];
|
||||
const auto& slots = textureUnit.GetAllBindingSlots();
|
||||
for (SizeT i = 0; i < slots.size(); ++i) {
|
||||
if (handleArm) {
|
||||
if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false;
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
else if (!OwnerEquals(snapshot.legacySlotObjects[i], slots[i].GetBoundObject())) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (handleArm) {
|
||||
if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false;
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
else if (!OwnerEquals(snapshot.legacySamplerObject, textureUnit.GetSamplerObject())) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#undef MGB_UNIT_BINDINGS_HANDLE_ARM
|
||||
#else
|
||||
struct UnitBindingsSnapshot {
|
||||
Array<WeakPtr<MG_State::GLState::ITextureObject>, (SizeT)TextureTarget::TextureTargetCount>
|
||||
slotObjects{};
|
||||
@@ -1395,6 +1725,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
static Vector<UnitBindingsSnapshot> g_observedUnitBindings;
|
||||
static Uint64 g_observedUnitBindingsContextId = 0;
|
||||
@@ -1610,7 +1941,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// registry keeps a backend object alive until its frontend texture expires, which an
|
||||
// attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame -
|
||||
// reduces to the key compare and an empty loop.
|
||||
const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw);
|
||||
const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw);
|
||||
const auto& currentFBO = drawSlot.GetBoundObject();
|
||||
if (currentFBO) {
|
||||
const Uint16 fboSlotVersion = drawSlot.GetVersion();
|
||||
@@ -1827,7 +2158,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
auto* textureBuffer =
|
||||
static_cast<MG_State::GLState::TextureObjectBuffer*>(imageBinding.Texture.get());
|
||||
const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
BufferImpl::MarkBufferGpuWritten(bufferObject);
|
||||
#else
|
||||
if (bufferObject) bufferObject->MarkGpuWritten();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1904,7 +2239,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr;
|
||||
|
||||
for (auto& target : fboTargets) {
|
||||
auto& slot = GetFramebufferBindingSlotFast(target);
|
||||
auto& slot = GetFramebufferBindingSlotChecked(target);
|
||||
auto& currentFBO = slot.GetBoundObject();
|
||||
|
||||
// The three memos together say "this target is already synced": which object is
|
||||
@@ -2742,7 +3077,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// only runs later in PrepareForDraw: a program compiled against a stale count
|
||||
// would not be relinked until the draw after the one that needed it.
|
||||
{
|
||||
const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw);
|
||||
const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw);
|
||||
const auto& drawFBO = drawSlot.GetBoundObject();
|
||||
const Uint16 slotVersion = drawSlot.GetVersion();
|
||||
const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0;
|
||||
@@ -2766,16 +3101,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_fragColorBroadcastCount = g_broadcastMemoCount;
|
||||
}
|
||||
|
||||
BackendProgramObjectImpl* twin = g_programTwinLookupMemo.Lookup(currentProgram);
|
||||
if (!twin) {
|
||||
auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get());
|
||||
auto& backendObj =
|
||||
backendProgramSlot ? *backendProgramSlot : g_backendProgramObjects.GetOrCreate(currentProgram);
|
||||
BackendProgramObjectImpl* twin = nullptr;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
auto* slot = g_backendProgramObjects.Find(currentProgram.get());
|
||||
auto& backendObj = slot ? *slot : g_backendProgramObjects.GetOrCreate(currentProgram);
|
||||
if (!backendObj) {
|
||||
backendObj = MakeShared<BackendProgramObjectImpl>();
|
||||
}
|
||||
g_programTwinLookupMemo.Store(currentProgram, backendObj.get());
|
||||
twin = backendObj.get();
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
twin = g_programTwinLookupMemo.Lookup(currentProgram);
|
||||
if (!twin) {
|
||||
auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get());
|
||||
auto& backendObj = backendProgramSlot ? *backendProgramSlot
|
||||
: g_backendProgramObjects.GetOrCreate(currentProgram);
|
||||
if (!backendObj) {
|
||||
backendObj = MakeShared<BackendProgramObjectImpl>();
|
||||
}
|
||||
g_programTwinLookupMemo.Store(currentProgram, backendObj.get());
|
||||
twin = backendObj.get();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// A link-version mismatch means the program was relinked: the backend
|
||||
// shaders and every cache built by CacheResourceLocations (block
|
||||
@@ -2857,7 +3207,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
auto& slot = GetFramebufferBindingSlotFast(target);
|
||||
auto& slot = GetFramebufferBindingSlotChecked(target);
|
||||
// No fast path on the binding slot's version. It is a 16-bit counter that only
|
||||
// ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old
|
||||
// snapshot and any later slot version that happened to land on it - one wrap of the
|
||||
@@ -2871,13 +3221,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// and the twin memo replaces even that with an array probe on the steady path.
|
||||
const auto& currentFBO = slot.GetBoundObject();
|
||||
if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) {
|
||||
FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO);
|
||||
if (!twin) {
|
||||
auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
|
||||
if (backendFBOSlot && *backendFBOSlot) {
|
||||
twin = backendFBOSlot->get();
|
||||
g_fboTwinLookupMemo.Store(currentFBO, twin);
|
||||
FramebufferImpl::BackendFramebufferObject* twin = nullptr;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
// Not "slot": the enclosing scope's `slot` is the framebuffer BINDING slot,
|
||||
// and this one is the twin table's entry.
|
||||
auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
|
||||
if (twinEntry && *twinEntry) {
|
||||
twin = twinEntry->get();
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
twin = g_fboTwinLookupMemo.Lookup(currentFBO);
|
||||
if (!twin) {
|
||||
auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
|
||||
if (backendFBOSlot && *backendFBOSlot) {
|
||||
twin = backendFBOSlot->get();
|
||||
g_fboTwinLookupMemo.Store(currentFBO, twin);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (twin) {
|
||||
twin->Bind(target);
|
||||
@@ -2932,7 +3297,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
auto& slot = GetFramebufferBindingSlotFast(target);
|
||||
auto& slot = GetFramebufferBindingSlotChecked(target);
|
||||
const auto& fbo = slot.GetBoundObject();
|
||||
SyncAndBindFramebufferObject(fbo, target);
|
||||
FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion();
|
||||
@@ -3155,7 +3520,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// pass creates it later in the same draw), and a cached miss would keep skipping the
|
||||
// bind after it appears.
|
||||
struct UnitSamplerLookupMemo {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The {slot, gen} of the frontend sampler this row was resolved for. It replaces the
|
||||
// weak_ptr and its owner compare: a stale row cannot match, because the successor of a
|
||||
// freed sampler is handed the same slot only with a higher Gen.
|
||||
MG_Pipe::MGPipeHandle frontendHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
WeakPtr<MG_State::GLState::SamplerObject> frontend{};
|
||||
#endif
|
||||
SamplerImpl::BackendSamplerObject* backend = nullptr;
|
||||
};
|
||||
static Array<UnitSamplerLookupMemo, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
|
||||
@@ -3164,6 +3537,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend(
|
||||
Int unit, const SharedPtr<MG_State::GLState::SamplerObject>& samplerObject) {
|
||||
auto& memo = g_unitSamplerLookupMemos[static_cast<SizeT>(unit)];
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
SamplerImpl::g_backendSamplerObjects.HandleOf(samplerObject.get());
|
||||
if (memo.backend && !MG_Pipe::MGPipeHandleIsNull(handle) && memo.frontendHandle == handle) {
|
||||
return memo.backend;
|
||||
}
|
||||
auto* slot = SamplerImpl::g_backendSamplerObjects.FindByHandle(handle);
|
||||
if (slot && *slot) {
|
||||
// A MISS is still never cached: the twin may not exist yet when the unit pass
|
||||
// runs, because the program pass creates it later in the same draw.
|
||||
memo.frontendHandle = handle;
|
||||
memo.backend = slot->get();
|
||||
return memo.backend;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) {
|
||||
return memo.backend;
|
||||
}
|
||||
@@ -3173,6 +3565,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
memo.backend = backendSamplerSlot->get();
|
||||
return memo.backend;
|
||||
}
|
||||
#endif
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -4885,14 +5278,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
// The emulated shift has to be in place before PrepareForDraw, because that is what syncs the
|
||||
// VAO; a zero here is what un-shifts the arrays for the next ordinary draw.
|
||||
//
|
||||
// P3a (D-H2): this is the LEGACY arm's carrier. On the handle arm the draw's raw base
|
||||
// instance rides in MGPVertexBuffers::BaseInstance - a ContentHash input, so a base-instance
|
||||
// change that moves no buffer is still emitted rather than suppressed - and the server
|
||||
// decides whether to shift, because emulation ownership is the server's. The scopes below
|
||||
// are therefore compiled only where the pre-handle arm is (a pull build always).
|
||||
inline Uint32 EmulatedFetchBaseInstance(GLuint baseinstance) {
|
||||
return UseNativeBaseInstance() ? 0u : static_cast<Uint32>(baseinstance);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) \
|
||||
const VertexArrayImpl::ScopedFetchBaseInstance name(value)
|
||||
#else
|
||||
#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) ((void)(value))
|
||||
#endif
|
||||
|
||||
void DrawElementsInstancedBaseVertexBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices,
|
||||
GLsizei instancecount, GLint basevertex, GLuint baseinstance) {
|
||||
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
|
||||
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
|
||||
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
|
||||
PrepareForDraw(syncBit);
|
||||
const ScopedRestartIndexSubstitution restart(type, count, indices);
|
||||
if (!restart.DrawIsValid()) return;
|
||||
@@ -4929,7 +5335,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void DrawElementsInstancedBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices,
|
||||
GLsizei instancecount, GLuint baseinstance) {
|
||||
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
|
||||
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
|
||||
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
|
||||
PrepareForDraw(syncBit);
|
||||
const ScopedRestartIndexSubstitution restart(type, count, indices);
|
||||
if (!restart.DrawIsValid()) return;
|
||||
@@ -4981,7 +5387,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void DrawArraysInstancedBaseInstance(GLenum mode, GLint first, GLsizei count, GLsizei instancecount,
|
||||
GLuint baseinstance) {
|
||||
DrawSyncFlags syncBit = DrawSyncBit::Instancing;
|
||||
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
|
||||
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
|
||||
PrepareForDraw(syncBit);
|
||||
SetCurrentBaseInstance(baseinstance);
|
||||
ForEachViewportRoutingPass([&] {
|
||||
@@ -6410,6 +6816,86 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
const GLuint backendTextureId = (*backendTextureSlot)->GetBackendTextureId();
|
||||
|
||||
// The one direct-iteration site over a twin table. The legacy walk reads the map
|
||||
// KEY, i.e. the raw frontend address, and has to test the entry's weak_ptr by hand
|
||||
// before it dares dereference it. ForEachLive hands over a strong reference instead,
|
||||
// so that hazard cannot arise; the body is otherwise identical, which is why it is
|
||||
// lifted into a lambda both arms call.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The push arm walks the twin table through ForEachLive, which hands over a
|
||||
// STRONG reference to the state object; the legacy map walk below it reads the
|
||||
// map key - the raw frontend address - and has to test the entry weak_ptr by
|
||||
// hand first. The body is shared between the two arms through the lambda.
|
||||
const auto detachFrom = [&](MG_State::GLState::FramebufferObject* stateFBO,
|
||||
const SharedPtr<FramebufferImpl::BackendFramebufferObject>& backendFBO) {
|
||||
if (stateFBO == nullptr || !backendFBO || stateFBO->IsDefaultFramebuffer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& attachments = stateFBO->GetAllAttachmentObjects();
|
||||
for (SizeT i = 0; i < attachments.size(); ++i) {
|
||||
const auto& attachmentObject = attachments[i];
|
||||
if (!attachmentObject.IsTexture() || attachmentObject.GetTexture().get() != texture.get()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto frontendType = static_cast<FramebufferAttachmentType>(i);
|
||||
GLenum backendAttachment = GL_NONE;
|
||||
if (frontendType >= FramebufferAttachmentType::Color0 &&
|
||||
frontendType <= FramebufferAttachmentType::Color31) {
|
||||
backendAttachment = backendFBO->GetBackendAttachmentType(frontendType);
|
||||
} else {
|
||||
backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType);
|
||||
}
|
||||
if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(
|
||||
attachmentObject.GetTextureUploadTarget());
|
||||
if (textureTarget == GL_UNKNOWN_MGL) {
|
||||
textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(texture->GetTarget());
|
||||
}
|
||||
|
||||
const GLuint backendFBOId = backendFBO->GetBackendFramebufferId();
|
||||
FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId);
|
||||
if (attachmentObject.IsLayered()) {
|
||||
g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0);
|
||||
} else {
|
||||
g_GLESFuncs.glFramebufferTexture2D(
|
||||
GL_DRAW_FRAMEBUFFER, backendAttachment, textureTarget, 0, 0);
|
||||
}
|
||||
ClearGLErrors();
|
||||
m_detachedAttachments.push_back(
|
||||
{backendFBOId, backendAttachment, textureTarget, backendTextureId,
|
||||
static_cast<GLint>(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()});
|
||||
}
|
||||
};
|
||||
|
||||
// Already inside #if MOBILEGL_PIPE_PUSH, so no second guard here: the arm choice
|
||||
// below is the RUNTIME one.
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
FramebufferImpl::g_backendFramebufferObjects.ForEachLive(
|
||||
[&](const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBO,
|
||||
const SharedPtr<FramebufferImpl::BackendFramebufferObject>& backendFBO) {
|
||||
detachFrom(stateFBO.get(), backendFBO);
|
||||
});
|
||||
return;
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin();
|
||||
it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) {
|
||||
// An entry whose state object died is only waiting for the next collection;
|
||||
// the key is a dangling address, so it must not be dereferenced here.
|
||||
if (it->second.stateRef.expired()) {
|
||||
continue;
|
||||
}
|
||||
detachFrom(it->first, it->second.backend);
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
// Pull build: exactly the pre-P2 walk, so this translation unit generates the
|
||||
// same code it did before P2 (G1).
|
||||
for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin();
|
||||
it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) {
|
||||
auto* stateFBO = it->first;
|
||||
@@ -6460,6 +6946,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static_cast<GLint>(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()});
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
~ScopedDetachedTextureFramebufferAttachments() {
|
||||
@@ -9901,6 +10388,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static Bool InitDisplayAndContext(EGLint surfaceBit, NativeWindowType window = static_cast<NativeWindowType>(0)) {
|
||||
DestroyEGLContext();
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// DIAGNOSE the twin-table arm here, at backend startup, so an operator who set
|
||||
// MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a combination that leaves no
|
||||
// arm at all is told so by name, in the log, before the first draw.
|
||||
//
|
||||
// Diagnose, and deliberately NOT resolve: resolving raises
|
||||
// Fatal{PipeLegacyMemosDisabled}, and this function runs inside eglMakeCurrent, which
|
||||
// the integration harness pre-flights in a FORKED CHILD
|
||||
// (MG_IntegrationTest/Harness/HeadlessGL.cpp). A child that dies on a signal is reported
|
||||
// to the parent as "no usable GPU/display/ICD" and every scenario in the lane is
|
||||
// SKIPPED - so the stop became a green lane that ran nothing, on exactly the two env
|
||||
// vars the D14/D18 A/B is driven with (ROADMAP.md:7). The stop now belongs to the first
|
||||
// twin lookup, which happens in a scenario body where a crash IS a test failure.
|
||||
DiagnoseEsprytSlotArm();
|
||||
#endif
|
||||
|
||||
g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if (g_Display == EGL_NO_DISPLAY) return false;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@
|
||||
#include <MG_State/GLState/TextureState/TextureObject.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
|
||||
#include "SlotTables.h"
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a: the vertex-input payload views the handle arm of the VAO twin consumes.
|
||||
#include <MG_Pipe/MGPipeTypes.h>
|
||||
#endif
|
||||
|
||||
namespace MobileGL::MG_Backend::DirectGLES {
|
||||
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
|
||||
@@ -267,7 +272,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
EndViewportRoutingPasses(passCount);
|
||||
}
|
||||
|
||||
template <typename StateObject, typename BackendObject>
|
||||
// The backend twin table. Two arms live behind this one interface (ARCHITECTURE.md 9.6 -
|
||||
// after Track H the MOBILEGL_PIPE_PUSH bitmap alone is not a valid A/B, because with a bit
|
||||
// clear the backend would still be running the re-keyed code):
|
||||
//
|
||||
// legacy (MOBILEGL_PIPE_LEGACY_MEMOS): UnorderedMap<StateObject*, Entry> keyed on the
|
||||
// frontend heap ADDRESS, with a weak_ptr per entry as the ABA defence, an erase
|
||||
// inside Find, and a garbage sweep as the only death signal. Pre-P2 code verbatim.
|
||||
// handles (MOBILEGL_PIPE_PUSH and kMGPipeSubsystemEsprytSlots): BackendSlotTable, keyed
|
||||
// on MGPipeHandle{Slot, Gen}. See SlotTables.h for what that buys.
|
||||
//
|
||||
// Which arm runs is fixed once per process (EsprytSlotTablesEnabled()): the two arms hold
|
||||
// their twins in different containers, so a mid-run flip would strand every twin already
|
||||
// built. Every call site below this class is arm-agnostic and unchanged.
|
||||
//
|
||||
// The kind is a template parameter ONLY in the push build. G1 requires the pull build's
|
||||
// symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes
|
||||
// every instantiation's mangled name - so in the pull build the parameter, like the arm it
|
||||
// selects, does not exist. The macro below spells that one difference; it is #undef'd
|
||||
// straight after the class, and the twelve declaration and definition sites name the
|
||||
// registry through the TwinRegistry alias instead, which swallows the kind in the pull
|
||||
// build. (An alias template may have a parameter it does not use, and an alias emits no
|
||||
// symbol of its own, so the pull build's mangled names are unchanged.)
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind
|
||||
#else
|
||||
#define MGB_TWIN_KIND_PARAM
|
||||
#endif
|
||||
|
||||
template <typename StateObject, typename BackendObject MGB_TWIN_KIND_PARAM>
|
||||
class StateBackendObjectRegistry {
|
||||
public:
|
||||
|
||||
@@ -286,12 +319,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
using BackendMap = UnorderedMap<StateObject*, Entry>;
|
||||
using iterator = typename BackendMap::iterator;
|
||||
using const_iterator = typename BackendMap::const_iterator;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
using SlotTable = BackendSlotTable<StateObject, BackendObject, kKind>;
|
||||
#endif
|
||||
|
||||
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
|
||||
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
// The slot table arms the teardown sentinel itself, at its own first
|
||||
// insertion (D13; SlotTables.h) - so a table used outside a registry arms
|
||||
// it too, which is right: it is the twin, not the registry, that owns the
|
||||
// driver id a guarded destructor exists for.
|
||||
return m_slotTable.GetOrCreate(stateObj);
|
||||
}
|
||||
#endif
|
||||
// Twin creation is the moment a driver-owned id starts needing a guarded
|
||||
// destructor; cold path, so the once-guard costs nothing per draw.
|
||||
// destructor; cold path, so the once-guard costs nothing per draw. It is armed
|
||||
// here, at the first insertion - a destructor hook on the table itself is wrong
|
||||
// for the reason spelled out above InProcessTeardown().
|
||||
EnsureProcessTeardownSentinel();
|
||||
// Sweep BEFORE the entry reference below exists: the map is open-addressed and an
|
||||
// erase relocates the rest of the probe cluster, so collecting once that reference
|
||||
@@ -324,14 +371,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return entry.backend;
|
||||
}
|
||||
|
||||
// Null when no live state object owns this key. The result points into the map, so
|
||||
// it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry.
|
||||
// Take that literally, including for Find: the map is open-addressed and erases by
|
||||
// shifting the rest of the probe cluster into the hole, so an erase relocates entries
|
||||
// OTHER than the erased one - and Find erases, whenever it lands on a key whose state
|
||||
// object has expired. Callers that need the twin across another registry call must copy
|
||||
// the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves).
|
||||
// Null when no live state object owns this key.
|
||||
//
|
||||
// On the HANDLE arm the result is a stable array element: only a GetOrCreate that grows
|
||||
// the table can move it, and nothing else on the table invalidates it.
|
||||
//
|
||||
// On the LEGACY arm the result points into the map, so it stays valid only until the
|
||||
// next GetOrCreate/Find/CollectGarbage on this registry. Take that literally, including
|
||||
// for Find: the map is open-addressed and erases by shifting the rest of the probe
|
||||
// cluster into the hole, so an erase relocates entries OTHER than the erased one - and
|
||||
// Find erases, whenever it lands on a key whose state object has expired. Callers that
|
||||
// need the twin across another registry call must copy the BackendPtr out (or keep only
|
||||
// the pointee, which is heap-allocated and never moves).
|
||||
BackendPtr* Find(StateObject* stateObj) {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return m_slotTable.Find(stateObj);
|
||||
}
|
||||
#endif
|
||||
const auto entryIt = m_entries.find(stateObj);
|
||||
if (entryIt == m_entries.end()) {
|
||||
return nullptr;
|
||||
@@ -352,16 +409,98 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
iterator end() { return m_entries.end(); }
|
||||
const_iterator end() const { return m_entries.end(); }
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The {slot, gen} this object's twin is keyed on, or the null handle. This is what a
|
||||
// backend memo stores instead of a raw pointer, a GL name or a bare lifetime id.
|
||||
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return m_slotTable.HandleOf(stateObj);
|
||||
}
|
||||
return MG_Pipe::kMGPipeNullHandle;
|
||||
}
|
||||
|
||||
// The twin at a handle, or null when the slot is free or its Gen has moved on. This is
|
||||
// the lookup a backend memo that already holds a handle wants: no lifetime-id probe.
|
||||
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return m_slotTable.FindByHandle(handle);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// P2 step e2. STATIC, because a death notice is about an object and not about a
|
||||
// registry instance: it is answered by EVERY table of this kind that exists - this
|
||||
// registry's own, and any by-value copy of it a fixture or a context reset is holding
|
||||
// (SlotTables.h explains the holder list and why one holder was a leak).
|
||||
//
|
||||
// The legacy arm cannot answer this at all - its key is the frontend heap ADDRESS and
|
||||
// the object is already gone by the time the notice arrives - so there it is a no-op
|
||||
// and the garbage sweep stays its only death signal. That asymmetry is not an
|
||||
// oversight: it is the A/B the compile-time arm exists to make measurable
|
||||
// (ARCHITECTURE.md 9.6), and announced-versus-discovered death is one of the things
|
||||
// being measured.
|
||||
static Bool DestroyByLifetimeId(Uint64 lifetimeId) {
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return SlotTable::OnFrontendObjectDestroyed(lifetimeId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy
|
||||
// begin()/end() handed out the map key, i.e. the raw frontend address - exactly the
|
||||
// identity the backend must stop reading - and handed it out for entries whose state
|
||||
// object had already died, so the one caller had to test stateRef.expired() itself
|
||||
// before dereferencing it. Here the state object arrives as a strong reference.
|
||||
template <typename Fn>
|
||||
void ForEachLive(Fn&& fn) const {
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
m_slotTable.ForEachLive(fn);
|
||||
return;
|
||||
}
|
||||
for (const auto& [stateKey, entry] : m_entries) {
|
||||
(void)stateKey;
|
||||
if (!entry.backend) continue;
|
||||
const StatePtr state = entry.stateRef.lock();
|
||||
if (!state) continue;
|
||||
fn(state, entry.backend);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the
|
||||
// handle arm death is announced by the frontend object's destructor
|
||||
// (MG_State/GLState/StateObjectDeathNotice.h), so there is no garbage to collect on a
|
||||
// tick, the slot table has no collector to forward to, and this is the predicted
|
||||
// branch plus a return - which is how ROADMAP.md:18's "delete the GC" is delivered
|
||||
// without deleting the legacy arm's own collector while that arm is still compiled
|
||||
// beside it.
|
||||
void CollectGarbageIfNeeded() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
++m_gcTick;
|
||||
if (m_gcTick < kGCInterval) {
|
||||
return;
|
||||
}
|
||||
CollectGarbage();
|
||||
m_gcTick = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void CollectGarbageNow() { CollectGarbage(); }
|
||||
// Pre-P2 API, kept for the legacy arm. On the handle arm there is nothing it could
|
||||
// collect: a twin leaves with its object's death notice, and a notice dropped during
|
||||
// process teardown is a deliberate leak (SlotTables.h), not garbage awaiting a call.
|
||||
void CollectGarbageNow() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (EsprytSlotTablesEnabled()) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
CollectGarbage();
|
||||
}
|
||||
|
||||
private:
|
||||
void CollectGarbage() {
|
||||
@@ -395,8 +534,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint32 m_gcTick = 0;
|
||||
Uint32 m_creationTick = 0;
|
||||
Bool m_isCollecting = false;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
BackendSlotTable<StateObject, BackendObject, kKind> m_slotTable;
|
||||
#endif
|
||||
};
|
||||
|
||||
#undef MGB_TWIN_KIND_PARAM
|
||||
|
||||
// One spelling for the twin registry at every declaration and definition site. In the push
|
||||
// build the kind is the registry's third template argument; in the pull build the alias
|
||||
// drops it, so the mangled name is the pre-P2 two-argument one.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
|
||||
using TwinRegistry = StateBackendObjectRegistry<StateObject, BackendObject, kKind>;
|
||||
#else
|
||||
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
|
||||
using TwinRegistry = StateBackendObjectRegistry<StateObject, BackendObject>;
|
||||
#endif
|
||||
|
||||
namespace BufferImpl {
|
||||
const GLenum TempBufferTarget = GL_ARRAY_BUFFER;
|
||||
|
||||
@@ -493,8 +648,135 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// no map, and a respecification then has to retire the id rather than hand it
|
||||
// to glBufferData, which the driver would silently refuse.
|
||||
Bool immutableStorage = false;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a: the client's shadow base as the last content-carrying resource call left
|
||||
// it. The handle-shaped ops carry `shadow + offset` beside their record, so the
|
||||
// base is recovered by subtracting the record's own offset once, here.
|
||||
//
|
||||
// IT IS A RAW POINTER INTO AN ALLOCATION THIS SIDE DOES NOT OWN, so its lifetime
|
||||
// rule is written here and enforced at the three events that end it - a cached
|
||||
// base with no invalidation is a use-after-free waiting for an ordinary call:
|
||||
//
|
||||
// * a content-carrying call (respecify / sub-data / flush-range) REFRESHES it;
|
||||
// * an ORPHANING respecify (HasDefinedContent clear) CLEARS it, because that is
|
||||
// also the call that resizes the shadow - reserve + resize reallocates and
|
||||
// frees the old block - and it brings no replacement base;
|
||||
// * a successful map_persistent CLEARS it, because the client then adopts the
|
||||
// coherent pointer and drops the shadow (PipeResource::AdoptPersistentMap does
|
||||
// clear() + shrink_to_fit()). For such a resource the bytes are persistentPtr.
|
||||
//
|
||||
// Every reader treats null as "no bytes to move". And any path that STILL HOLDS the
|
||||
// frontend object - the ensure path does, because D-N keeps SyncPersistentMappedRange
|
||||
// there for all of P3a - re-reads MappedData() instead of reading this, exactly as
|
||||
// the legacy arm did; this member exists for the drains that have no object, which
|
||||
// in P3a is the readback flush and the fp64 narrowing.
|
||||
const Uint8* hostBytes = nullptr;
|
||||
#endif
|
||||
};
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a (D-A4): the SEVENTH Espryt slot table, and the first one keyed by a handle the
|
||||
// CALL carried rather than one this backend minted off a frontend object's lifetime
|
||||
// id. That is what discharges, for this kind, the debt SlotTables.h records against
|
||||
// itself: GLESBufferResource stops hanging off PipeResource::m_backend and lives here
|
||||
// instead, so the resource table is the server's own and a frontend heap reference is
|
||||
// no longer part of resolving it.
|
||||
//
|
||||
// The StateObject parameter is BufferObject only because the template names one; not
|
||||
// one member that touches it is instantiated on this table (no Find(StateObject*), no
|
||||
// HandleOf, no ForEachLive), and the handle overloads never look at it. Death is
|
||||
// announced by the family's own ResourceDestroy call, not by the shared death notice
|
||||
// (D-L), and the slot is freed by the CLIENT after that call returns.
|
||||
using BackendBufferResourceTable =
|
||||
BackendSlotTable<MG_State::GLState::BufferObject, GLESBufferResource, MG_Pipe::MGPipeKind::Buffer>;
|
||||
extern BackendBufferResourceTable g_backendBufferResources;
|
||||
|
||||
// Resolved once per process and latched, exactly like EsprytSlotTablesEnabled() and
|
||||
// for the same reason: the two arms hold GLESBufferResource in DIFFERENT containers -
|
||||
// the legacy arm in the frontend object's PipeResource::m_backend, the handle arm in
|
||||
// the table above - so an answer that changed mid-run would strand every resource
|
||||
// already built and leak the driver ids they own.
|
||||
Bool ResolveResourceSubsystemArm();
|
||||
// Same shape for the vertex-input family (bit 8), and separate because the two bits are
|
||||
// separately clearable - but NOT independent, and the resolver says so out loud rather
|
||||
// than half-running: bit 8 REQUIRES bit 7, because the vertex-input handle arm resolves
|
||||
// every attribute's driver buffer id out of the resource slot table and only bit 7 puts
|
||||
// twins there. `0x17f` (bit 8 on, bit 7 off) is therefore refused at arm resolution with
|
||||
// a named MGLOG_E and runs the legacy vertex-input arm; `0x0ff` (bit 7 on, bit 8 off) is
|
||||
// a real, supported A/B, because the legacy VAO walk reaches the handle arm through
|
||||
// EnsureBufferResource's own dispatch. Both resolvers also answer
|
||||
// MG_Config::Features.PipeLegacyMemos, so "the bit is clear and the legacy arm was taken
|
||||
// away" is a named verdict instead of a silent legacy run.
|
||||
Bool ResolveVertexInputSubsystemArm();
|
||||
|
||||
// INLINE for the reason SlotTables.h spells out at EsprytSlotTablesEnabled: both are
|
||||
// consulted on the per-draw path (the VAO sync's gate, EnsureBufferResource, every
|
||||
// buffer op), and out-of-line they would be a call through the PLT per consult.
|
||||
inline Bool ResourceSubsystemEnabled() {
|
||||
static const Bool enabled = ResolveResourceSubsystemArm();
|
||||
return enabled;
|
||||
}
|
||||
inline Bool VertexInputSubsystemEnabled() {
|
||||
static const Bool enabled = ResolveVertexInputSubsystemArm();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Resolve-or-create / resolve-only, by the handle the call carried. Neither touches
|
||||
// MGPipeSlots(): the handle ARRIVED already minted by the side that owns minting.
|
||||
GLESBufferResource* GetOrCreateBufferResourceForHandle(MG_Pipe::MGPipeHandle res);
|
||||
GLESBufferResource* FindBufferResourceForHandle(MG_Pipe::MGPipeHandle res);
|
||||
|
||||
// MONOLITH GLUE, and named as such: the handle of a resource this backend is looking
|
||||
// at through a frontend object, resolved through the client allocator's lifetime-id
|
||||
// index. Every caller is a site P3a deliberately does NOT migrate - the SSBO / UBO /
|
||||
// indirect / pack-PBO binding walks are dirty bits 15-17 and P4b's, and the index
|
||||
// host mirror is P8's - so they still arrive holding the object. Under a real split
|
||||
// neither the object nor its lifetime id exists on this side and every one of them
|
||||
// has to receive the handle in a payload instead.
|
||||
MG_Pipe::MGPipeHandle HandleOfBuffer(const MG_State::GLState::BufferObject* bufferObject);
|
||||
|
||||
// The handle arms of the two draw-path entry points below. IsBufferDrawCleanByHandle
|
||||
// asks the applier the same five questions IsBufferDrawClean asks the frontend object,
|
||||
// with identical semantics (D-A4); EnsureBufferResourceForHandle is the ensure path
|
||||
// driven by the applier's descriptor and the shadow base the call carried.
|
||||
//
|
||||
// `frontend` supplies the ONE question the applier's record cannot answer in P3a: an
|
||||
// emulated (non-adopted) persistent map is written through its pointer with no call, so
|
||||
// MGPipeResourceRecord::HasLiveHostWrites - the field that will carry it - is pinned
|
||||
// false and the probe still has to ask the object. It retires with P5. See the long note
|
||||
// at the definition; passing null means "no live map", not "unknown".
|
||||
Bool IsBufferDrawCleanByHandle(MG_Pipe::MGPipeHandle res, const GLESBufferResource* resource,
|
||||
const MG_State::GLState::BufferObject* frontend);
|
||||
GLESBufferResource* EnsureBufferResourceForHandle(
|
||||
const SharedPtr<MG_State::GLState::BufferObject>& bufferObject, MG_Pipe::MGPipeHandle res);
|
||||
|
||||
// P3a (D-D): "the GPU wrote through this resource", announced on the reverse channel
|
||||
// instead of poked into the frontend object. ARCHITECTURE.md calls OnGpuWritten a
|
||||
// NARROWING channel - the client builds its pending set conservatively at each
|
||||
// draw/dispatch emission point and this callback only ever takes entries out of it -
|
||||
// so in P3a, where the client's conservative set is exactly what the three
|
||||
// MarkGpuWritten sites marked, the announced set is the whole resource and the
|
||||
// observable behaviour is identical. P8/P9 narrow it; the channel is what they need.
|
||||
//
|
||||
// The legacy arm keeps calling BufferObject::MarkGpuWritten directly, and the pull
|
||||
// build never sees this function at all (G1).
|
||||
void MarkBufferGpuWritten(const SharedPtr<MG_State::GLState::BufferObject>& bufferObject);
|
||||
|
||||
// The applier's stored extent for this resource, 0 when it has no record. The one
|
||||
// thing outside BufferImpl that needs it is the fp64 narrowing, whose source extent
|
||||
// used to be BufferObject::GetSize().
|
||||
SizeT ResourceWidthForHandle(MG_Pipe::MGPipeHandle res);
|
||||
// The applier's server-owned mutation serial for this resource, 0 when it has no
|
||||
// record. It is what the narrowed-fp64 memo keys its freshness on now that the
|
||||
// frontend change serial is gone from the backend's view.
|
||||
Uint64 ResourceSerialForHandle(MG_Pipe::MGPipeHandle res);
|
||||
// The ES context generation a twin's driver id must carry to be current. Its one
|
||||
// consumer is the draw-clean probe's unit test, which has to build a twin that answers
|
||||
// CLEAN to every question except the one under test - a case that cannot go red for
|
||||
// that question otherwise. Push-only, like the rest of this block.
|
||||
Uint CurrentBufferContextGeneration();
|
||||
#endif
|
||||
|
||||
// Registered as the frontend's BufferBackendOps at backend init and on
|
||||
// every MakeCurrent (the ES context can be destroyed and recreated, e.g.
|
||||
// by the trace replayer's probe context).
|
||||
@@ -697,8 +979,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
struct ResolvedDrawBuffers {
|
||||
struct Entry {
|
||||
MG_State::GLState::BufferObject* frontend = nullptr;
|
||||
// A RAW TWIN POINTER, AND IT MAY DANGLE - the invariant that makes that safe
|
||||
// is stated here rather than left in the two callers (espryt-v3 §8, m8).
|
||||
//
|
||||
// Nothing tells this memo when a twin dies: on the handle arm a
|
||||
// resource_destroy takes the twin out of the slot table (ReleaseByHandle)
|
||||
// while this entry still holds its address, and on the legacy arm the same
|
||||
// is true of the registry's own release. So the rule is: THIS POINTER IS
|
||||
// ONLY EVER DEREFERENCED AFTER THE ENTRY'S IDENTITY HAS BEEN RE-RESOLVED IN
|
||||
// THE SAME PASS - FindByHandle(handle) on the handle arm, the frontend
|
||||
// identity compare on the legacy one - and a miss re-resolves through
|
||||
// EnsureBufferResource rather than trusting what is stored here. Both
|
||||
// consumers do that today; a third one that read `resource` straight out of
|
||||
// a "valid" memo would be reading freed memory, and no compare in this
|
||||
// struct would catch it. The pointer stays raw because the alternative -
|
||||
// owning a reference from a per-draw memo - is what keeps a dead driver
|
||||
// buffer alive, which is the leak class P2's death notice exists to remove.
|
||||
BufferImpl::GLESBufferResource* resource = nullptr;
|
||||
Uint8 attribIndex = 0;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a re-key: the entry's identity on the handle arm. A {slot, gen} cannot
|
||||
// be reproduced by a recycled heap address, so the clean probe compares
|
||||
// this instead of the raw frontend pointer and never has to ask the
|
||||
// allocator for it again mid-draw.
|
||||
MG_Pipe::MGPipeHandle handle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
};
|
||||
Bool valid = false;
|
||||
Uint32 configVersion = 0;
|
||||
@@ -706,6 +1011,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Array<Entry, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> entries;
|
||||
MG_State::GLState::BufferObject* iboFrontend = nullptr;
|
||||
BufferImpl::GLESBufferResource* iboResource = nullptr;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a re-key of the memo's validity key: on the handle arm the frontend VAO's
|
||||
// wrapping configuration version is replaced by the bound vertex-elements CSO
|
||||
// (identity AND its server-owned content serial) plus the vertex-buffer set's
|
||||
// own serial - three monotone Uint64s and a {slot, gen}, no wrap and no
|
||||
// identity patch. The IBO entry keeps its separate key for the same reason it
|
||||
// always had one: the index slot is not part of the configuration (D5).
|
||||
MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
Uint64 elementsSerial = 0;
|
||||
Uint64 buffersSerial = 0;
|
||||
MG_Pipe::MGPipeHandle iboHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
// Buffer-mutation epoch (BufferImpl::CurrentBufferMutationEpoch) at which
|
||||
// the LAST probe pass found every entry / the IBO clean; 0 = not stamped
|
||||
// (epochs start at 1). While a stamp matches the pre-pass epoch read, the
|
||||
@@ -741,15 +1058,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// dropping it. Returns false when the stream cannot be built, in which case the
|
||||
// caller must DISABLE the array - leaving a 64-bit array enabled with no pointer is
|
||||
// what the Adreno driver turns into a SIGSEGV at the next draw.
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
Bool SyncFloat64AttributeAsFloat32(Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib,
|
||||
Uint32 fetchBaseInstance);
|
||||
#endif
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The handle arm of the whole vertex-elements half. Everything it needs arrives in
|
||||
// the applier's records - the bound CSO's two views, the vertex-buffer set, the
|
||||
// index buffer and the resolved fetch base instance - so it takes no argument at
|
||||
// all and touches no frontend type. The legacy arm above it is unchanged and both
|
||||
// compile in every push build (ARCHITECTURE.md 9.6).
|
||||
void SyncToBackendFromApplier();
|
||||
// Same narrowing, same memo, same Adreno disable; the source bytes are the shadow
|
||||
// base the resource call carried and the memo key is the buffer's {slot, gen}.
|
||||
Bool SyncFloat64AttributeAsFloat32ByHandle(Uint attribIndex, const MGPVertexAttribWire& attrib,
|
||||
const MG_Pipe::MGPVertexBuffer& binding,
|
||||
Uint32 fetchBaseInstance);
|
||||
#endif
|
||||
|
||||
// What the converted float32 stream in m_convertedAttributeBufferIds[i] was built
|
||||
// from. A hit skips the CPU conversion and the re-upload; the buffer's change serial
|
||||
// is part of the key, so a glBufferSubData into the source invalidates it.
|
||||
struct ConvertedFloat64Stream {
|
||||
Bool valid = false;
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// The pre-handle pin: a FRONTEND lifetime id, i.e. the key
|
||||
// ARCHITECTURE.md 9.5 lists for deletion as "ConvertedVertexStreamKey's
|
||||
// sourcePin". Kept compiled for the legacy arm (and therefore present in
|
||||
// every pull build, which is what keeps sizeof(this) still).
|
||||
Uint64 sourceLifetimeId = 0;
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// What replaces it: the source buffer's {slot, gen}. It is the SAME identity
|
||||
// the rest of the backend now keys on, it cannot be reproduced by a recycled
|
||||
// frontend address, and it costs the walk no allocator probe - the handle is
|
||||
// already in the vertex-buffer entry that named the source.
|
||||
MG_Pipe::MGPipeHandle sourceHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
// On the handle arm this is the applier's server-owned Serial rather than the
|
||||
// frontend change serial; both answer the same question - "have the source
|
||||
// bytes moved since the conversion" - and neither is trusted for a
|
||||
// persistently mapped buffer, which is written with no call at all.
|
||||
Uint64 sourceChangeSerial = 0;
|
||||
SizeT sourceOffset = 0;
|
||||
SizeT sourceStride = 0;
|
||||
@@ -772,6 +1122,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// version early-out in SyncToBackend must not be trusted while it is set.
|
||||
Bool m_hasConvertedFloat64Attribute = false;
|
||||
Bool m_isInitialized = false;
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// ---- the pre-handle memo set (ARCHITECTURE.md 9.6) -------------------------
|
||||
// Retired by P3a on the handle arm and kept compiled here so the A/B is real: a
|
||||
// cleared subsystem bit runs THESE, not a re-keyed twin wearing their names. A
|
||||
// pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not move
|
||||
// and no symbol resizes (G1).
|
||||
Uint16 m_syncedIndexBufferVersion = 0;
|
||||
// Identity of the buffer the version above was stamped against. Raw and never
|
||||
// dereferenced: the slot version is a wrapping Uint16 (see the ResolvedDrawBuffers
|
||||
@@ -787,6 +1143,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint32 m_syncedConfigVersion = 0;
|
||||
Array<MG_State::GLState::VertexAttributeVersion, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>
|
||||
m_syncedAttributeVersions;
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// ---- what replaces them on the handle arm (D-G4) ---------------------------
|
||||
// The bound vertex-elements CSO this twin last emitted, and the applier's
|
||||
// server-owned content serial for it. Together they replace
|
||||
// m_hasSyncedConfigVersion + m_syncedConfigVersion AND the whole per-attribute
|
||||
// version array: the applier's stored Attributes[] IS what was last pushed, so a
|
||||
// per-attribute compare has nothing left to prove and the walk re-emits.
|
||||
MG_Pipe::MGPipeHandle m_syncedElementsHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
Uint64 m_syncedElementsSerial = 0;
|
||||
Bool m_hasSyncedElements = false;
|
||||
// The vertex-buffer set's own serial. Not in D-G4's table, and it has to be here:
|
||||
// set_vertex_buffers is an independent call carrying the buffer identities, the
|
||||
// offsets and the divisors this twin BAKES into the driver VAO, so a set that
|
||||
// moved while the format did not must still re-emit them.
|
||||
Uint64 m_syncedVertexBuffersSerial = 0;
|
||||
// Replaces m_syncedIndexBufferVersion (a wrapping Uint16) AND
|
||||
// m_syncedIndexBufferObject (the raw identity patch that closed its wrap hole):
|
||||
// one monotone Uint64, no wrap, nothing to patch. This is the Track H re-key
|
||||
// ARCHITECTURE.md 9.5 counts.
|
||||
Uint64 m_syncedIndexSerial = 0;
|
||||
#endif
|
||||
// Byte shift currently baked into the instanced arrays' offsets by the baseInstance
|
||||
// emulation (see SetPendingFetchBaseInstance). It is draw state, not VAO state, so it
|
||||
// is deliberately NOT covered by the config version: the frontend never bumps for it.
|
||||
@@ -800,7 +1178,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint64 m_syncedBufferIdGeneration = 0;
|
||||
};
|
||||
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject>
|
||||
extern TwinRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject, MG_Pipe::MGPipeKind::VertexElementsCso>
|
||||
g_backendVertexArrayObjects;
|
||||
|
||||
// Shadowed glBindVertexArray: every backend VAO bind goes through here so a
|
||||
@@ -816,6 +1194,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// instanced array at element "floor(instance / divisor) + baseInstance", and ES has no
|
||||
// way to say the "+ baseInstance" part - so it is folded into the attribute's own byte
|
||||
// offset (baseInstance * stride) for every divisor'd array, which is exactly equivalent.
|
||||
//
|
||||
// P3a RETIRES THE AMBIENT GLOBAL (D-H2): an ambient process global cannot cross a
|
||||
// pushed boundary, so on the handle arm the draw's RAW base instance rides in
|
||||
// MGPVertexBuffers::BaseInstance and the SERVER decides whether to shift - the answer
|
||||
// lands in MGPipeApplierState::VertexFetchBaseInstance and the VAO sync reads it there.
|
||||
// The three declarations below and the three scopes in DirectGLES.cpp are the legacy
|
||||
// arm's, kept compiled because a cleared subsystem bit has to run a real pre-handle
|
||||
// path and because removing them would delete two symbols from the PULL build (G1).
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// Must be set BEFORE PrepareForDraw so the VAO sync sees it, and cleared after the draw
|
||||
// so the next one refetches from element 0; ScopedFetchBaseInstance does both.
|
||||
void SetPendingFetchBaseInstance(Uint32 baseInstance);
|
||||
@@ -828,6 +1215,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
ScopedFetchBaseInstance(const ScopedFetchBaseInstance&) = delete;
|
||||
ScopedFetchBaseInstance& operator=(const ScopedFetchBaseInstance&) = delete;
|
||||
};
|
||||
#endif
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The server-owned half of the same decision, and the reason the client never
|
||||
// pre-shifts an offset: emulation ownership is the server's (ARCHITECTURE.md 5.7).
|
||||
// True when the driver applies baseInstance to the vertex fetch itself, in which case
|
||||
// the attribute-offset emulation must stay out of the way. Applied to whatever the
|
||||
// applier stored, so the answer is the same whichever side resolved it first.
|
||||
Bool BackendUsesNativeBaseInstance();
|
||||
#endif
|
||||
} // namespace VertexArrayImpl
|
||||
|
||||
namespace TextureImpl {
|
||||
@@ -1121,7 +1518,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
void ActivateTextureUnit(Uint unit);
|
||||
void UnbindTexture(Uint unit, GLenum target);
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::ITextureObject, BackendTextureObject>
|
||||
extern TwinRegistry<MG_State::GLState::ITextureObject, BackendTextureObject, MG_Pipe::MGPipeKind::Texture>
|
||||
g_backendTextureObjects;
|
||||
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
|
||||
@@ -1212,7 +1609,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint64 m_syncedBackendIdGeneration = 0;
|
||||
};
|
||||
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::FramebufferObject, BackendFramebufferObject>
|
||||
extern TwinRegistry<MG_State::GLState::FramebufferObject, BackendFramebufferObject, MG_Pipe::MGPipeKind::Framebuffer>
|
||||
g_backendFramebufferObjects;
|
||||
// True when the read buffer names a fixed-point (norm/snorm) attachment that the
|
||||
// backend actually stores in a floating-point format. GL clamps a read from a
|
||||
@@ -1730,7 +2127,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the
|
||||
// ES context is recreated.
|
||||
extern Uint g_lastUsedBackendProgramId;
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
|
||||
extern TwinRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl, MG_Pipe::MGPipeKind::ShaderCso>
|
||||
g_backendProgramObjects;
|
||||
|
||||
// Points one shader storage block of an ALREADY-LINKED backend program at
|
||||
@@ -1830,7 +2227,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
extern Array<BackendSamplerObject*, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
|
||||
g_boundSamplersCache;
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::SamplerObject, BackendSamplerObject>
|
||||
extern TwinRegistry<MG_State::GLState::SamplerObject, BackendSamplerObject, MG_Pipe::MGPipeKind::SamplerCso>
|
||||
g_backendSamplerObjects;
|
||||
} // namespace SamplerImpl
|
||||
|
||||
@@ -1857,7 +2254,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Int m_cacheSamples = 0;
|
||||
};
|
||||
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::RenderbufferObject, BackendRenderbufferObject>
|
||||
extern TwinRegistry<MG_State::GLState::RenderbufferObject, BackendRenderbufferObject, MG_Pipe::MGPipeKind::Renderbuffer>
|
||||
g_backendRenderbufferObjects;
|
||||
} // namespace RenderbufferImpl
|
||||
} // namespace MobileGL::MG_Backend::DirectGLES
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
// MobileGL - MobileGL/MG_Backend/DirectGLES/SlotTables.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 <Includes.h>
|
||||
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#endif
|
||||
|
||||
// Espryt 0b, the first Track H slice: the DENSE, {slot, gen}-keyed twin table that replaces
|
||||
// StateBackendObjectRegistry's UnorderedMap<StateObject*, Entry>.
|
||||
//
|
||||
// What changes, and why each of them is the point:
|
||||
//
|
||||
// * The KEY stops being a frontend heap address. It is MGPipeHandle{Slot, Gen}, minted by the
|
||||
// client's MGPipeSlotAllocator off the frontend object's GetLifetimeId(). A recycled heap
|
||||
// address cannot reproduce a handle, so the weak_ptr the registry carried per entry purely
|
||||
// to catch that (its Entry::stateRef, used as an IDENTITY test) stops being an identity
|
||||
// mechanism, and OwnerEquals / TwinLookupMemo x3 / UnitSamplerLookupMemo's owner compare all
|
||||
// lose their reason to exist.
|
||||
// * The lookup stops being a hash probe into an open-addressed map and becomes one bounds
|
||||
// check plus one array index, so a returned BackendPtr* is NOT invalidated by the next Find
|
||||
// on the table. That kills the hazard Managers.h documents at length, and with it the
|
||||
// by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it.
|
||||
// * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1,
|
||||
// MG_Remote/Server/PipeObjectTables) be an array rather than an object graph.
|
||||
//
|
||||
// Death is ANNOUNCED, and that is what lets this table have no garbage collector - the
|
||||
// deliverable ROADMAP.md:18 spells "GC" in and the one D13 makes a precondition of the switch-
|
||||
// over. All six re-keyed object classes raise MG_State::GLState::NotifyStateObjectDestroyed()
|
||||
// from their destructor (BufferBackendOps' shape, one entry point for six kinds), the backend
|
||||
// consumes it in Managers.cpp, and OnFrontendObjectDestroyed() below drops the twin in EVERY
|
||||
// table of the kind and returns the slot, at the moment the frontend object's last SharedPtr
|
||||
// goes. So:
|
||||
// * there is NO draw-path tick, NO creation tick and NO sweep of any kind on this arm. The
|
||||
// seven CollectGarbageIfNeeded call sites in DirectGLES.cpp drive the LEGACY registry only;
|
||||
// * a twin, and the driver storage it owns, is freed when the application lets go of the
|
||||
// object rather than up to 64 creations or 1024 draw ticks later. That is what
|
||||
// Managers.h's "dead gigabytes" note asked for.
|
||||
//
|
||||
// EVERY HOLDER OF THE KIND, not one. Two live tables of one kind is a real configuration - the
|
||||
// ScopedDirectGLESTextureBindings fixture keeps a by-value copy of the Texture registry for the
|
||||
// length of a test, and a context reset does the same in reverse - and the slot allocator
|
||||
// erases its lifetimeId -> slot mapping on Free, so a notice delivered to one holder and
|
||||
// resolved again by the next would find nothing to resolve. Every table therefore links itself
|
||||
// into a per-table-type list at construction and out at destruction, and one notice resolves
|
||||
// the handle ONCE, drops the twin in each holder BY HANDLE, and frees the slot once, last. No
|
||||
// holder can be left naming a live entry for a dead object, and there is nothing a sweep could
|
||||
// still find. (The list is per table TYPE; the kind is the type's template parameter, and each
|
||||
// of the six kinds has exactly one table type in this backend. Magma's subsystem-4 table mints
|
||||
// out of its own per-renderer allocator, not MGPipeSlots(), so it is not a holder here.)
|
||||
//
|
||||
// The weak_ptr per entry survives for exactly one reason: ForEachLive() hands the callee a
|
||||
// STRONG reference to the frontend object, which the one direct-iteration site
|
||||
// (ScopedDetachedTextureFramebufferAttachments) needs. It is never an identity test - that is
|
||||
// what Gen is for - and it is never read to decide whether an entry is dead: a destructor that
|
||||
// runs after exit() has begun has its notice dropped by InProcessTeardown(), and that twin is
|
||||
// then a DELIBERATE leak (the process is exiting, the driver reclaims the object, and a twin
|
||||
// destructor must not call into a driver that may already be unloaded), not something to be
|
||||
// collected later.
|
||||
//
|
||||
// P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS
|
||||
// handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId().
|
||||
// MGPipeHandles.h:13-16 says a handle is minted by the CLIENT and never by the server, and
|
||||
// under a real split neither the frontend object nor its lifetime id exists on this side of
|
||||
// the wire. This is monolith glue: the minting and the lifetimeId -> handle resolution both
|
||||
// belong on the client, and the backend should receive the handle in the verb payload. It is
|
||||
// NOT part of "Track H done" and check_include_closure.py does not probe MG_Backend headers,
|
||||
// so nothing catches it automatically.
|
||||
namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
|
||||
// Declared in Managers.h as well; repeated here because this header is included from it
|
||||
// before that declaration, and the table below is the arming site on this arm (D13: "the
|
||||
// arming site moves to the slot table's first insertion").
|
||||
void EnsureProcessTeardownSentinel();
|
||||
|
||||
// What the two knobs add up to. Split out as a PURE function of them so a test can drive
|
||||
// every combination without needing a process per combination.
|
||||
enum class EsprytSlotArmVerdict {
|
||||
Handles, // kMGPipeSubsystemEsprytSlots is set: the {slot, gen} tables run.
|
||||
Legacy, // the bit is clear and the legacy address-keyed registry is reachable.
|
||||
NoArm, // the bit is clear AND MOBILEGL_PIPE_LEGACY_MEMOS=0 made the legacy arm
|
||||
// unreachable, so the operator asked for a configuration with no arm at all.
|
||||
};
|
||||
|
||||
EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled);
|
||||
|
||||
// This process's verdict, read off MG_Config::Features. Latches nothing and stops nothing.
|
||||
EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict();
|
||||
|
||||
// Says, at backend bring-up, that the knobs leave no arm - and does NOT stop.
|
||||
//
|
||||
// The stop cannot live here, and that is the whole point of the split. Backend context
|
||||
// creation runs inside eglMakeCurrent, and the integration harness pre-flights exactly that
|
||||
// sequence in a FORKED CHILD (MG_IntegrationTest/Harness/HeadlessGL.cpp): a child that dies
|
||||
// on a signal is reported as "no usable GPU/display/ICD" and every scenario in the lane is
|
||||
// SKIPPED - i.e. the lane goes green having run nothing, on the very pair of env vars the
|
||||
// D14/D18 A/B is driven with, which is what ROADMAP.md:7 forbids. So bring-up only
|
||||
// DIAGNOSES; the stop is raised by ResolveEsprytSlotTablesArm() at the first twin lookup,
|
||||
// which happens in the test body where the harness reports it as a failure.
|
||||
//
|
||||
// The CALL SITE (InitDisplayAndContext in DirectGLES.cpp) is pinned by
|
||||
// DirectGLESSlotTable.EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs
|
||||
// the real bring-up entry point under the pair in a forked child: edit that site back to
|
||||
// ResolveEsprytSlotTablesArm() and the case fails naming both knobs.
|
||||
void DiagnoseEsprytSlotArm();
|
||||
|
||||
// Reads the config, logs, installs the death-notice consumer, and STOPS when the operator
|
||||
// left no arm at all. Cold: called exactly once per process, from the latch below - i.e. at
|
||||
// the first twin lookup, which is the first moment an arm is actually needed. A process
|
||||
// that never twins anything needs no arm and is not stopped.
|
||||
Bool ResolveEsprytSlotTablesArm();
|
||||
|
||||
// True when this process runs the {slot, gen} arm. Fixed for the life of the process: the
|
||||
// two arms hold their twins in different containers, so flipping mid-run would strand them.
|
||||
//
|
||||
// INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive on the twin tables
|
||||
// consults it, i.e. it is on the per-draw path several times per draw. As an out-of-line
|
||||
// function in Managers.cpp (no LTO in any shipped configuration) that was a call through
|
||||
// the PLT per lookup; here the caller sees a guard-variable load and a perfectly-predicted
|
||||
// branch, and the arm dispatch folds into the caller.
|
||||
inline Bool EsprytSlotTablesEnabled() {
|
||||
static const Bool enabled = ResolveEsprytSlotTablesArm();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
|
||||
class BackendSlotTable {
|
||||
public:
|
||||
using StatePtr = SharedPtr<StateObject>;
|
||||
using StateWeakPtr = std::weak_ptr<StateObject>;
|
||||
using BackendPtr = SharedPtr<BackendObject>;
|
||||
|
||||
// The largest slot index this table will grow to for a handle that ARRIVED in a call's
|
||||
// payload. Slots are dense and allocated per kind, so a million of one kind is already
|
||||
// far past any application's live object count; the cap is here because the alternative
|
||||
// is letting a corrupt 32-bit slot decide a vector resize. See GetOrCreate(MGPipeHandle).
|
||||
static constexpr Uint32 kMaxHandleSlot = 1u << 20;
|
||||
|
||||
struct Entry {
|
||||
BackendPtr backend;
|
||||
// LIVENESS ONLY, and only for ForEachLive(), which locks it so the callee holds a
|
||||
// strong ref. Never compared against another object to decide identity - that is
|
||||
// what Gen is for - never dereferenced for its address, and never read to decide
|
||||
// whether the slot is dead: death is announced, not discovered.
|
||||
StateWeakPtr stateRef;
|
||||
// The generation this entry's twin was built for. An entry whose Gen no longer
|
||||
// matches the allocator's is a twin of the slot's PREVIOUS owner.
|
||||
Uint32 Gen = 0;
|
||||
Bool Live = false;
|
||||
};
|
||||
|
||||
// Every constructor links the table into the per-type holder list and the destructor
|
||||
// unlinks it, so a by-value copy (the ScopedDirectGLESTextureBindings fixture's saved
|
||||
// registry) is a holder for exactly as long as it exists. Copy and move carry the
|
||||
// ENTRIES and the memo; the links are the table's own and are never copied.
|
||||
BackendSlotTable() { LinkHolder(); }
|
||||
BackendSlotTable(const BackendSlotTable& other):
|
||||
m_slots(other.m_slots),
|
||||
m_nullTwin(other.m_nullTwin),
|
||||
m_memoLifetimeId(other.m_memoLifetimeId),
|
||||
m_memoHandle(other.m_memoHandle) {
|
||||
LinkHolder();
|
||||
}
|
||||
BackendSlotTable(BackendSlotTable&& other) noexcept:
|
||||
m_slots(std::move(other.m_slots)),
|
||||
m_nullTwin(std::move(other.m_nullTwin)),
|
||||
m_memoLifetimeId(other.m_memoLifetimeId),
|
||||
m_memoHandle(other.m_memoHandle) {
|
||||
other.m_slots.clear();
|
||||
other.ForgetHandle();
|
||||
LinkHolder();
|
||||
}
|
||||
BackendSlotTable& operator=(const BackendSlotTable& other) {
|
||||
if (this != &other) {
|
||||
m_slots = other.m_slots;
|
||||
m_nullTwin = other.m_nullTwin;
|
||||
m_memoLifetimeId = other.m_memoLifetimeId;
|
||||
m_memoHandle = other.m_memoHandle;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
BackendSlotTable& operator=(BackendSlotTable&& other) noexcept {
|
||||
if (this != &other) {
|
||||
m_slots = std::move(other.m_slots);
|
||||
m_nullTwin = std::move(other.m_nullTwin);
|
||||
m_memoLifetimeId = other.m_memoLifetimeId;
|
||||
m_memoHandle = other.m_memoHandle;
|
||||
other.m_slots.clear();
|
||||
other.ForgetHandle();
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
~BackendSlotTable() { UnlinkHolder(); }
|
||||
|
||||
// Resolve-or-create. The handle comes from the client allocator keyed on the frontend
|
||||
// object's lifetime id, so two calls for the same live object always land on the same
|
||||
// slot, and a successor object at the same heap address never does.
|
||||
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
|
||||
// No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build
|
||||
// must not trap where the release build quietly does the documented thing.
|
||||
if (stateObj == nullptr) {
|
||||
// The registry this replaces inserted a null KEY and handed back that entry's
|
||||
// twin (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on
|
||||
// exactly that tolerance), so a release build never dereferenced null here.
|
||||
// Keep the shape exactly, INCLUDING across calls: the map kept its null-keyed
|
||||
// entry, so a second null call was handed the same twin the first one got.
|
||||
// Resetting here instead would have destroyed it - an arm difference in the one
|
||||
// path that documents relying on this. One per-table parking slot, never live,
|
||||
// never handed a handle, because a null object has no identity and
|
||||
// therefore cannot have a {slot, gen}.
|
||||
return m_nullTwin;
|
||||
}
|
||||
|
||||
// D13: the teardown sentinel is armed by the slot table's first insertion. Twin
|
||||
// creation is the moment a driver-owned id starts needing a guarded destructor;
|
||||
// this is the cold path, so the once-guard costs nothing per draw. On the legacy
|
||||
// arm StateBackendObjectRegistry::GetOrCreate arms it itself.
|
||||
EnsureProcessTeardownSentinel();
|
||||
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId());
|
||||
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
|
||||
"MGPipe slot space of kind %u is exhausted",
|
||||
static_cast<Uint32>(kKind));
|
||||
Entry& entry = EntryAt(handle.Slot);
|
||||
if (entry.Live && entry.Gen != handle.Gen) {
|
||||
// The slot was reclaimed and handed to a new object: the twin at it describes
|
||||
// driver ids the new state object never made.
|
||||
entry.backend.reset();
|
||||
}
|
||||
entry.Gen = handle.Gen;
|
||||
entry.Live = true;
|
||||
entry.stateRef = stateObj;
|
||||
// No creation tick and no sweep here. The registry this replaces needed both,
|
||||
// because nothing told it a texture or a renderbuffer had been DELETED and object
|
||||
// CHURN rather than draw count is what made that urgent. Every one of the six kinds
|
||||
// now announces its own death from its destructor, so a dead twin's slot is already
|
||||
// back before the next creation asks for one.
|
||||
RememberHandle(stateObj->GetLifetimeId(), handle);
|
||||
return entry.backend;
|
||||
}
|
||||
|
||||
// P3a: resolve-or-create BY HANDLE, and it is the shape that discharges the debt this
|
||||
// header records against itself at the top of the file.
|
||||
//
|
||||
// The overload above mints - it calls MGPipeSlots().Acquire off a frontend object's
|
||||
// lifetime id, from inside MG_Backend - which is monolith glue: a handle is minted by
|
||||
// the CLIENT, and under a real split neither the object nor its lifetime id exists on
|
||||
// this side. This overload never touches the allocator at all. The handle ARRIVED, in
|
||||
// the call's payload, already minted by the side that owns minting; all this does is
|
||||
// index the slot, notice a generation that no longer matches (the slot was recycled,
|
||||
// so the twin at it describes driver ids the new resource never made) and hand back
|
||||
// the twin pointer. FindByHandle beside it is the same shape and already existed.
|
||||
//
|
||||
// No StatePtr, therefore no Entry::stateRef: the weak pointer is liveness for
|
||||
// ForEachLive() and a handle-keyed entry has no frontend object to weakly hold. Such
|
||||
// an entry is therefore invisible to ForEachLive, which is correct - the one direct
|
||||
// iteration site walks texture twins, and it is not one of these tables.
|
||||
//
|
||||
// Death stays ANNOUNCED, as it is on the other overload: for a handle-keyed kind the
|
||||
// announcement is the family's own destroy call, not the shared death notice, and the
|
||||
// slot is freed by the CLIENT after that call returns.
|
||||
//
|
||||
// UNUSED AT THE CONTRACT COMMIT, deliberately: it is a member of a class template, so
|
||||
// an uninstantiated one costs nothing anywhere, and the backend package is what gives
|
||||
// it its first caller.
|
||||
BackendPtr& GetOrCreate(MG_Pipe::MGPipeHandle handle) {
|
||||
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
|
||||
"GetOrCreate(handle) named the reserved null handle");
|
||||
if (MG_Pipe::MGPipeHandleIsNull(handle)) return m_nullTwin;
|
||||
|
||||
// A slot index that ARRIVED in a payload indexes a vector this call would RESIZE,
|
||||
// and nothing between the payload and here bounds it: the applier's blob gates sit
|
||||
// in front of the vertex-input family, not in front of the resource family, which
|
||||
// dispatches ops->Create(record.Res, ...) straight through. There is no allocator
|
||||
// constant to check against on this side - the allocator is the client's - so this
|
||||
// is a sanity cap and is documented as one: kMaxHandleSlot entries of one kind is
|
||||
// already orders of magnitude past any real GL object count, while a corrupt 32-bit
|
||||
// slot asks for a four-billion-entry resize.
|
||||
if (handle.Slot >= kMaxHandleSlot) {
|
||||
MOBILEGL_ASSERT(false, "GetOrCreate(handle) named slot %u, past this table's %u bound",
|
||||
handle.Slot, kMaxHandleSlot);
|
||||
return m_nullTwin;
|
||||
}
|
||||
|
||||
// Same arming as the minting overload, and for the same reason: twin creation is
|
||||
// the moment a driver-owned id starts needing a guarded destructor.
|
||||
EnsureProcessTeardownSentinel();
|
||||
|
||||
// THE TWO DIRECTIONS ARE NOT SYMMETRIC HERE, where they are on the minting overload.
|
||||
// There the handle comes straight out of MGPipeSlots().Acquire and can never be
|
||||
// BEHIND the entry, so a bare `!=` only ever means "the slot was recycled forward".
|
||||
// Here the handle arrived in a payload, so `handle.Gen < entry.Gen` is a reachable
|
||||
// input, and adopting it would destroy the INCUMBENT LIVE twin - a driver buffer id,
|
||||
// a persistent map, a pooled store, released by a defaulted destructor that issues
|
||||
// no glDeleteBuffers and no pool enrolment - and then stamp the slot back to the
|
||||
// dead resource's generation, after which the incumbent's own FindByHandle refuses
|
||||
// it and it is silently handed a fresh, empty twin. That is a leak AND a resource
|
||||
// that loses its storage with no diagnostic, i.e. the shape commit d7655247 fixed
|
||||
// and the thing MGPipeHandle::Gen exists to prevent. So: forward is a recycle and
|
||||
// resets the twin, BACKWARD is refused - which is the same answer FindByHandle
|
||||
// below already gives the same input.
|
||||
Entry& entry = EntryAt(handle.Slot);
|
||||
if (entry.Live && entry.Gen > handle.Gen) {
|
||||
MOBILEGL_ASSERT(false,
|
||||
"GetOrCreate(handle) named generation %u at slot %u, which is BEHIND "
|
||||
"the live entry's %u - refusing rather than destroying the incumbent",
|
||||
handle.Gen, handle.Slot, entry.Gen);
|
||||
return m_nullTwin;
|
||||
}
|
||||
if (entry.Live && entry.Gen != handle.Gen) entry.backend.reset();
|
||||
entry.Gen = handle.Gen;
|
||||
entry.Live = true;
|
||||
return entry.backend;
|
||||
}
|
||||
|
||||
// The generation of the LIVE entry at this slot, or 0 when the slot is out of range or
|
||||
// holds no live entry. It exists so a caller can DIAGNOSE - in a release build, where
|
||||
// MOBILEGL_ASSERT is inert - the refusal GetOrCreate(handle) above performs silently.
|
||||
Uint32 LiveGenAt(Uint32 slot) const {
|
||||
if (slot >= m_slots.size()) return 0;
|
||||
const Entry& entry = m_slots[slot];
|
||||
return entry.Live ? entry.Gen : 0;
|
||||
}
|
||||
|
||||
// P3a: the death half of the overload above, for a kind whose announcement is its own
|
||||
// destroy CALL rather than the shared death notice (D-L). Hands the twin OUT rather
|
||||
// than destroying it in place, because the caller may still have to decide what
|
||||
// happens to the driver id it owns - Espryt pools it, deletes it, or parks it on the
|
||||
// deferred-release list when no context is current on this thread - and every one of
|
||||
// those outcomes has to be reached with the entry already retired, so a re-entrant
|
||||
// GetOrCreate from a twin destructor cannot resurrect it.
|
||||
//
|
||||
// The slot itself is NOT freed here: it belongs to the kind, and for a handle-keyed
|
||||
// kind the CLIENT frees it after the destroy call returns (SlotAllocator.h:60 - the
|
||||
// Gen bump rides the next handout, so a double free cannot skip a generation). An
|
||||
// entry whose Gen no longer matches is a twin of the slot's previous owner and is
|
||||
// left alone: the successor's own GetOrCreate resets it.
|
||||
BackendPtr ReleaseByHandle(MG_Pipe::MGPipeHandle handle) {
|
||||
if (MG_Pipe::MGPipeHandleIsNull(handle)) return BackendPtr{};
|
||||
if (m_memoHandle.Slot == handle.Slot) ForgetHandle();
|
||||
if (handle.Slot >= m_slots.size()) return BackendPtr{};
|
||||
Entry& entry = m_slots[handle.Slot];
|
||||
if (!entry.Live || entry.Gen != handle.Gen) return BackendPtr{};
|
||||
BackendPtr dead = std::move(entry.backend);
|
||||
entry.backend.reset();
|
||||
entry.stateRef.reset();
|
||||
entry.Live = false;
|
||||
return dead;
|
||||
}
|
||||
|
||||
// Null when no live twin of this object exists. Unlike the registry's Find this NEVER
|
||||
// mutates the table, so the returned pointer survives any later Find on it; only a
|
||||
// GetOrCreate that grows the vector can move it, and callers that hold one across a
|
||||
// possible insertion still copy the BackendPtr out.
|
||||
BackendPtr* Find(StateObject* stateObj) {
|
||||
if (stateObj == nullptr) return nullptr;
|
||||
return FindByHandle(HandleOf(stateObj));
|
||||
}
|
||||
|
||||
const BackendPtr* Find(StateObject* stateObj) const {
|
||||
return const_cast<BackendSlotTable*>(this)->Find(stateObj);
|
||||
}
|
||||
|
||||
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
|
||||
if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr;
|
||||
if (handle.Slot >= m_slots.size()) return nullptr;
|
||||
Entry& entry = m_slots[handle.Slot];
|
||||
if (!entry.Live || entry.Gen != handle.Gen) return nullptr;
|
||||
return &entry.backend;
|
||||
}
|
||||
|
||||
// The handle this object's twin is keyed on, or the null handle. This is what a backend
|
||||
// memo stores instead of a raw pointer, a GL name or a bare lifetime id.
|
||||
//
|
||||
// A NULL answer is never memoised. The memo is per table and the allocator is per
|
||||
// kind, so with two holders of one kind the OTHER table can be the one that acquires;
|
||||
// a cached "no handle" here would then outlive the twin's creation over there, and
|
||||
// nothing on this table's own acquire path would ever refresh it. A miss costs the
|
||||
// allocator probe it always cost; a hit is refreshed the moment anyone acquires.
|
||||
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
|
||||
if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle;
|
||||
const Uint64 lifetimeId = stateObj->GetLifetimeId();
|
||||
if (lifetimeId == m_memoLifetimeId) return m_memoHandle;
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId);
|
||||
if (!MG_Pipe::MGPipeHandleIsNull(handle)) RememberHandle(lifetimeId, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// P2 step e2's backend half. The frontend object with this lifetime id has just been
|
||||
// DESTROYED: resolve its handle ONCE, drop its twin in EVERY table of this type, and
|
||||
// return the slot to the allocator - in that order, because the allocator forgets the
|
||||
// lifetime id on Free and a holder told second could no longer resolve it.
|
||||
//
|
||||
// The slot is returned whether or not any holder still had a twin at it: the lifetime
|
||||
// id is dead and MG_State never hands one out twice, so nothing can acquire it again,
|
||||
// and a slot minted for it that no table holds (a table reset with `= {}` drops its
|
||||
// entries without freeing) would otherwise stay allocated for the life of the process.
|
||||
//
|
||||
// STATIC, and deliberately so: a notice is about an object, not about a table, and
|
||||
// "which table holds it" is exactly the question that produced the two-holder leak.
|
||||
// Returns whether the object had a slot of this kind, i.e. whether anything was freed;
|
||||
// a second call for the same id answers false because the allocator no longer maps it.
|
||||
static Bool OnFrontendObjectDestroyed(Uint64 lifetimeId) {
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId);
|
||||
if (MG_Pipe::MGPipeHandleIsNull(handle)) return false;
|
||||
for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) {
|
||||
// The successor is read BEFORE the release: ReleaseTwinAt runs the twin's
|
||||
// destructor, which is a driver call, and nothing that outlives it may be a
|
||||
// reference into this holder.
|
||||
BackendSlotTable* const next = holder->m_nextHolder;
|
||||
holder->ReleaseTwinAt(handle);
|
||||
holder = next;
|
||||
}
|
||||
MG_Pipe::MGPipeSlots().Free(kKind, handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
// How many tables of this type exist right now. For the tests that pin the holder
|
||||
// list; nothing on a shipping path asks.
|
||||
static Uint32 HolderCount() {
|
||||
Uint32 count = 0;
|
||||
for (const BackendSlotTable* holder = s_firstHolder; holder != nullptr;
|
||||
holder = holder->m_nextHolder) {
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry.
|
||||
// Replaces the registry's begin()/end(), whose iterator exposed the raw frontend
|
||||
// address as the map key - the one place the backend read an identity it must not have.
|
||||
// The state object is handed over as a STRONG reference, so the callee cannot be handed
|
||||
// a dangling key the way the old iteration could.
|
||||
template <typename Fn>
|
||||
void ForEachLive(Fn&& fn) const {
|
||||
// Index loop and a COPIED twin, not a range-for over references: fn is arbitrary
|
||||
// backend code, and a nested GetOrCreate on this table would resize m_slots and
|
||||
// invalidate both the iterator and any reference into the vector that outlives the
|
||||
// call. The one caller today happens not to insert; that is not a property the
|
||||
// walk should depend on.
|
||||
for (SizeT slot = 0; slot < m_slots.size(); ++slot) {
|
||||
const Entry& entry = m_slots[slot];
|
||||
if (!entry.Live || !entry.backend) continue;
|
||||
const StatePtr state = entry.stateRef.lock();
|
||||
if (!state) continue;
|
||||
const BackendPtr twin = entry.backend;
|
||||
fn(state, twin);
|
||||
}
|
||||
}
|
||||
|
||||
Uint32 LiveCount() const {
|
||||
Uint32 count = 0;
|
||||
for (const Entry& entry : m_slots) {
|
||||
if (entry.Live) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private:
|
||||
// Drop the twin at `handle` if THIS table holds it. Frees nothing: the slot belongs to
|
||||
// the kind, not to the table, and OnFrontendObjectDestroyed returns it once, after
|
||||
// every holder has let go.
|
||||
Bool ReleaseTwinAt(MG_Pipe::MGPipeHandle handle) {
|
||||
// Forget the memo whenever it names this slot, even if this table has no entry
|
||||
// there: a memo can be a handle learned from the allocator for an object another
|
||||
// holder twinned, and it must not survive the slot's next handout.
|
||||
if (m_memoHandle.Slot == handle.Slot) ForgetHandle();
|
||||
if (handle.Slot >= m_slots.size()) return false;
|
||||
// The twin's destructor is a driver call and could, in principle, re-enter
|
||||
// GetOrCreate on this table and resize m_slots. So NOTHING that outlives the
|
||||
// destructor may be a reference into m_slots: the twin is moved out into a local,
|
||||
// the entry is finished with, and only then is the local released.
|
||||
BackendPtr dead;
|
||||
{
|
||||
Entry& entry = m_slots[handle.Slot];
|
||||
if (!entry.Live || entry.Gen != handle.Gen) return false;
|
||||
dead = std::move(entry.backend);
|
||||
entry.backend.reset();
|
||||
entry.stateRef.reset();
|
||||
entry.Live = false;
|
||||
}
|
||||
dead.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Grows the table to hold `slot`. Every caller bounds `slot` first - the minting
|
||||
// overload because the allocator produced it, the handle overload against
|
||||
// kMaxHandleSlot - because this is the one place a client-supplied number decides an
|
||||
// allocation size.
|
||||
Entry& EntryAt(Uint32 slot) {
|
||||
if (slot >= m_slots.size()) m_slots.resize(static_cast<SizeT>(slot) + 1);
|
||||
return m_slots[slot];
|
||||
}
|
||||
|
||||
void RememberHandle(Uint64 lifetimeId, MG_Pipe::MGPipeHandle handle) const {
|
||||
m_memoLifetimeId = lifetimeId;
|
||||
m_memoHandle = handle;
|
||||
}
|
||||
void ForgetHandle() const {
|
||||
m_memoLifetimeId = 0;
|
||||
m_memoHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
}
|
||||
|
||||
// The holder list: intrusive and doubly linked, so registering and unregistering are
|
||||
// two pointer writes with no allocation, and its head is a constant-initialised
|
||||
// static - which is what lets the process-lifetime registry globals in Managers.cpp
|
||||
// link themselves in from their own constructors with no initialisation-order
|
||||
// question to answer. Single-threaded, like every table it links (the tables live and
|
||||
// die on the context thread, as the notice they answer does).
|
||||
void LinkHolder() {
|
||||
m_prevHolder = nullptr;
|
||||
m_nextHolder = s_firstHolder;
|
||||
if (s_firstHolder != nullptr) s_firstHolder->m_prevHolder = this;
|
||||
s_firstHolder = this;
|
||||
}
|
||||
void UnlinkHolder() {
|
||||
if (m_prevHolder != nullptr) {
|
||||
m_prevHolder->m_nextHolder = m_nextHolder;
|
||||
} else {
|
||||
s_firstHolder = m_nextHolder;
|
||||
}
|
||||
if (m_nextHolder != nullptr) m_nextHolder->m_prevHolder = m_prevHolder;
|
||||
m_prevHolder = nullptr;
|
||||
m_nextHolder = nullptr;
|
||||
}
|
||||
|
||||
static inline BackendSlotTable* s_firstHolder = nullptr;
|
||||
BackendSlotTable* m_prevHolder = nullptr;
|
||||
BackendSlotTable* m_nextHolder = nullptr;
|
||||
|
||||
// Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live.
|
||||
Vector<Entry> m_slots;
|
||||
// Handed back by GetOrCreate for a null state object. Never live, never handed a handle.
|
||||
BackendPtr m_nullTwin;
|
||||
|
||||
// ONE-entry resolution memo, lifetimeId -> handle. It exists because without it every
|
||||
// resolution goes through the allocator's ByLifetimeId hash, which the deleted
|
||||
// TwinLookupMemos existed to avoid and which D13 promises to replace with "direct slot
|
||||
// indexing".
|
||||
//
|
||||
// It is one entry and therefore only helps a caller that asks for the SAME object twice
|
||||
// running - ResolveVaoTwin and SyncCurrentProgram do, once per draw each. Two callers
|
||||
// it does NOT help, recorded rather than claimed away: BindCurrentFBO resolves BOTH
|
||||
// targets in a frame, and ResolveUnitSamplerBackend asks for a different sampler per
|
||||
// texture unit, so both thrash a single-entry memo and pay the probe P1 did not (P1 had
|
||||
// a per-unit memo and a direct-mapped 6-slot array there). Making the memo per-unit /
|
||||
// per-target is the fix, and G11 - the device-side gate that would price it - is owed.
|
||||
//
|
||||
// It cannot serve a stale answer, by three independent arguments:
|
||||
// * the key is a lifetime id, which MG_State never hands out twice, so a recycled
|
||||
// heap address cannot hit this memo the way it could hit an address-keyed one;
|
||||
// * a null answer is never stored, so another holder's acquire cannot be hidden by
|
||||
// a "no handle" this table remembered earlier; and
|
||||
// * even a hit for a slot that has since been freed and re-handed is caught, because
|
||||
// the caller resolves the handle through FindByHandle, which compares Gen.
|
||||
// Cleared anyway when a death notice names the memoised slot. 0 is never a live
|
||||
// lifetime id (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss.
|
||||
mutable Uint64 m_memoLifetimeId = 0;
|
||||
mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
};
|
||||
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace MobileGL::MG_Backend::DirectGLES
|
||||
@@ -0,0 +1,498 @@
|
||||
// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.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 <Includes.h>
|
||||
|
||||
#include <Config.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// kMGPipeSubsystem* - the runtime bitmask's named bits - and MGPipeHandle itself. Both are
|
||||
// header-only constant/POD declarations, and both are push-only, so the pull build's include
|
||||
// graph is unchanged (G1).
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
#endif
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the
|
||||
// {slot, gen} mint the re-keyed sites are written against.
|
||||
//
|
||||
// Two switches decide which arm a re-keyed site runs, and they are NOT the same switch:
|
||||
//
|
||||
// MOBILEGL_PIPE_PUSH (compile) - is the pushed state there to be keyed on at all
|
||||
// Features.PipePush (runtime bitmask) - is THIS subsystem migrated in THIS run
|
||||
// MOBILEGL_PIPE_LEGACY_MEMOS (compile) - is the pre-handle arm compiled beside it
|
||||
// Features.PipeLegacyMemos (runtime) - may the pre-handle arm be ENTERED in this run
|
||||
//
|
||||
// ARCHITECTURE.md 9.6's point: once a handle wave lands, a clear MOBILEGL_PIPE_PUSH bit is
|
||||
// only a valid A/B while the legacy arm is still compiled, because with the bit clear the
|
||||
// backend would otherwise still run the re-keyed code. So a clear bit selects the legacy
|
||||
// arm, and a run that has explicitly disabled the legacy arm may not fall into it.
|
||||
//
|
||||
// D14 spends that last sentence at STARTUP, not per draw: "a Track-H subsystem whose bit is
|
||||
// clear is a startup Fatal{PipeLegacyMemosDisabled}". Nothing in the draw path aborts, and
|
||||
// nothing outside Track H consults the legacy-memo lever at all - see
|
||||
// MagmaPipeValidateSubsystemConfiguration below for both halves of that rule.
|
||||
//
|
||||
// The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper
|
||||
// below is behind it, and the pull build's translation units are byte-identical (G1).
|
||||
namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Is `subsystemBit` (MG_Pipe/MGPipe.h's kMGPipeSubsystem*) migrated in this run?
|
||||
inline Bool MagmaPipeSubsystemOn(Uint64 subsystemBit) {
|
||||
return (MG_Config::Features.PipePush & subsystemBit) != 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D14's startup gate
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// Called once from VulkanRenderer::Initialize(), i.e. only when Magma is the backend
|
||||
// that is actually running. It answers exactly one question and it answers it before the
|
||||
// first draw: is there an arm for Magma's Track-H subsystem in this configuration?
|
||||
//
|
||||
// Three deliberate boundaries, each of which the per-draw shape this replaces got wrong:
|
||||
//
|
||||
// * ONLY Magma's own Track-H bit is checked. Espryt's bit 5 is Espryt's business (a
|
||||
// DirectVulkan run does not execute one line of DirectGLES' re-key), so
|
||||
// MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must
|
||||
// not kill an Espryt one.
|
||||
// * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT fatal. It is not a
|
||||
// memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and
|
||||
// a clear bit there simply means the client is not pushing render-state CSOs in this
|
||||
// run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5
|
||||
// and 6 "Track H" and labels bit 0 nothing of the sort.
|
||||
// * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside
|
||||
// GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a
|
||||
// branch nobody needs on the hottest path in the backend.
|
||||
//
|
||||
// [declared deviation from D14, review v2 minor 2] D14's runtime row reads "false: the
|
||||
// legacy arm is never entered", and D14's compile-switch row names ComputePipelineStateHash
|
||||
// as part of the pre-handle arm. Those two together would make MOBILEGL_PIPE_LEGACY_MEMOS=0
|
||||
// with bit 0 CLEAR a contradiction: the pipeline memo has no CSO handle to key on, so it
|
||||
// keys on a state hash, and in a build that compiles the pre-handle arm that hash IS
|
||||
// ComputePipelineStateHash. Magma does not make that fatal - bit 0 is not Track H, and
|
||||
// there is a correct answer (the state hash) where for bits 5/6 there is none - but it no
|
||||
// longer does it SILENTLY: the combination is named once, at startup, right here.
|
||||
inline void MagmaPipeValidateSubsystemConfiguration() {
|
||||
if (!MG_Config::Features.PipeLegacyMemos &&
|
||||
!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) {
|
||||
MGLOG_W("MGPipe: MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState (bit 0 "
|
||||
"of MOBILEGL_PIPE_PUSH) clear - Magma's pipeline memo has no CSO handle to key "
|
||||
"on, so every draw whose pipeline-state version moved runs the pre-handle STATE "
|
||||
"HASH instead. That is not a Track-H subsystem and not fatal, but it is not the "
|
||||
"handle arm either: set bit 0 (MOBILEGL_PIPE_PUSH=0x%llx) if this run was meant "
|
||||
"to measure it.",
|
||||
static_cast<unsigned long long>(MG_Config::Features.PipePush |
|
||||
MG_Pipe::kMGPipeSubsystemRenderState));
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// The pre-handle arm is compiled AND the operator has not forbidden entering it, so a
|
||||
// clear bit is an ordinary, valid A/B: the site takes the legacy arm.
|
||||
if (MG_Config::Features.PipeLegacyMemos) return;
|
||||
#endif
|
||||
if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) return;
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
const char* const why = "this run has MOBILEGL_PIPE_LEGACY_MEMOS=0";
|
||||
#else
|
||||
const char* const why =
|
||||
"this build has cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF, which compiles no such arm";
|
||||
#endif
|
||||
MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} Magma's Track-H subsystem "
|
||||
"(kMGPipeSubsystemMagmaVertexInput, bit 6 of MOBILEGL_PIPE_PUSH) is clear, so the "
|
||||
"vertex-input cache and the VAO draw memo want the pre-handle arm - but %s. Set "
|
||||
"bit 6 (MOBILEGL_PIPE_PUSH=0x%llx, or the default 0x%llx), or allow the legacy arm.",
|
||||
why,
|
||||
static_cast<unsigned long long>(MG_Config::Features.PipePush |
|
||||
MG_Pipe::kMGPipeSubsystemMagmaVertexInput),
|
||||
static_cast<unsigned long long>(MG_Pipe::kMGPipeSubsystemsMigratedAtP2));
|
||||
std::abort();
|
||||
}
|
||||
|
||||
// "Does this Track-H site run the handle arm?" - the ONE question every re-keyed Track-H
|
||||
// site asks, so that they cannot disagree with each other or with the startup gate.
|
||||
inline Bool MagmaPipeTrackHArmIsHandles(Uint64 trackHBit) {
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
return MagmaPipeSubsystemOn(trackHBit);
|
||||
#else
|
||||
// No pre-handle arm exists in this build, and MagmaPipeValidateSubsystemConfiguration
|
||||
// has already made a clear bit a startup Fatal, so the handle arm is the only arm a
|
||||
// running process can be on.
|
||||
(void)trackHBit;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Negative control C (P2 brief D18): MOBILEGL_PIPE_HANDLE_ABA_CONTROL
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// "Is the object-identity half of every vertex-input memo key deliberately defeated in
|
||||
// this run?" - the ONE question the control's sites ask, for the same reason
|
||||
// MagmaPipeTrackHArmIsHandles exists: three sites deciding separately could disagree,
|
||||
// and a control that defeats two of three guards proves nothing.
|
||||
//
|
||||
// WHAT IT DEFEATS, AND WHY IT IS SPELLED AS "REPLACE THE IDENTITY WITH A CONSTANT"
|
||||
// RATHER THAN "USE THE HEAP ADDRESS".
|
||||
//
|
||||
// D18 wrote the control as "hash attr.Buffer.get() instead of GetLifetimeId(), and skip
|
||||
// the vaoLifetimeId compare", on the theory that a deleted object's replacement lands at
|
||||
// the freed heap block and so reproduces the key. Measured, it does not: in
|
||||
// HandleRecycleScenario the GL NAMES come back (glGen* hands the deleted name straight
|
||||
// out) but the C++ heap blocks do not - a VertexArrayObject is 3920 bytes, too large for
|
||||
// glibc's tcache, so its chunk goes to the unsorted bin and is split by the very next
|
||||
// allocation the replacement path makes. Four create/delete cycles in one run produced
|
||||
// four distinct addresses, ~1 MiB apart. With no address reuse there is nothing for
|
||||
// "hash the address" to collide with: the replacement hashes differently, indexes a
|
||||
// different memo slot, and inherits nothing - so the arm asserted stale pixels and saw
|
||||
// fresh ones, which is a FAILING negative control that had stopped controlling anything.
|
||||
//
|
||||
// So the control no longer asks the allocator for the collision; it manufactures it. On
|
||||
// both arms the object identity is replaced by a constant, which is the strongest form of
|
||||
// "the allocator handed the block back" and is deterministic. That covers strictly more
|
||||
// than D18's spelling, and in particular it reaches the arm P2 SHIPS: on the handle arm
|
||||
// the constant defeats the OBJECT IDENTITY THAT SELECTS THE SLOT - the key the handle arm
|
||||
// ships - so the replacement VAO is handed the dead one's memo entry and its content hash.
|
||||
// Defeating only the retired lifetime-id/address guards would leave that key untested,
|
||||
// which is exactly the vacuity this control exists to catch.
|
||||
//
|
||||
// WHAT IT DOES NOT COVER, AND WHY NO REPRODUCER OF THIS SHAPE CAN [fix-aba review v1,
|
||||
// MAJOR 1]. It does NOT exercise the GENERATION half of {slot, gen}:
|
||||
//
|
||||
// * this mint has no death notification - nothing in MG_Backend/DirectVulkan consumes
|
||||
// NotifyStateObjectDestroyed - so a slot returns to the free list only through
|
||||
// OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024, below);
|
||||
// * HandleRecycleScenario issues five frame boundaries, so the free list is empty when
|
||||
// the replacement VAO acquires and it gets a BRAND-NEW slot at Gen 1 (measured:
|
||||
// redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is
|
||||
// decided by the SLOT alone, and deleting the ++Gen below leaves all four arms green;
|
||||
// * a genuine slot REUSE needs >= 1024 idle boundaries after the dead object's last
|
||||
// draw, which necessarily puts the two draws in different frames - and the only memo
|
||||
// that carries a GPU slice rather than a layout, ResolvedVertexBindings, declines
|
||||
// across frames by design. The two requirements are mutually exclusive, so the
|
||||
// generation is out of reach of any same-frame pixel reproducer for this memo.
|
||||
//
|
||||
// The generation is covered where it IS expressible, over this mint and the claim rule
|
||||
// MagmaPipeClaimSlotMemos below: MG_Test/Pipe/MagmaPipeIdentityTest.cpp drives a real
|
||||
// retire -> reuse and asserts that a memo stamped at {slot, gen=N} is not served at
|
||||
// {slot, gen=N+1} with the knob off and IS served with it on. Deleting the ++Gen reds that
|
||||
// suite; it is the only place in the tree where that deletion is caught.
|
||||
//
|
||||
// Everything the control does NOT defeat is as load-bearing as what it does. It never
|
||||
// touches a guard that is not an IDENTITY guard: the resolved-bindings memo's frame
|
||||
// serial, its slice-epoch compares and its host-map check all stay in force, so a green
|
||||
// AbaControl arm still means "a replacement object was handed its dead predecessor's
|
||||
// resolved vertex bindings because the identity halves of the keys were defeated", not
|
||||
// "every safety net was switched off until something broke".
|
||||
//
|
||||
// Off by default (Config.h), set only by the HandleRecycle AbaControl ctest lanes, and
|
||||
// #if MOBILEGL_PIPE_PUSH throughout, so no shipping pull build can even parse it.
|
||||
inline Bool MagmaPipeAbaControlDefeatsIdentity() {
|
||||
return MG_Config::Features.PipeHandleAbaControl;
|
||||
}
|
||||
|
||||
// The single consumer-table entry every VAO collapses onto while the control is on. Slot
|
||||
// 0 is a real, ordinary entry of both tables (MagmaPipeSlotIndex maps the first allocatable
|
||||
// handle onto it), so nothing about the tables changes shape for the control's sake.
|
||||
inline constexpr Uint32 kMagmaPipeAbaControlSlotIndex = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The {slot, gen} mint
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// Maps a frontend object's never-reused lifetime id to a dense {slot, gen}. Three
|
||||
// properties, and the third is the one review v2 got wrong:
|
||||
//
|
||||
// 1. exact identity - Gen moves whenever a slot changes owner, so a stale handle can
|
||||
// never match a live object even if the allocator hands back the same heap address
|
||||
// (the ABA HandleRecycleScenario reproduces);
|
||||
// 2. dense slots - the slot IS an index, so a consumer's per-slot table needs no hash,
|
||||
// no probe and no mix;
|
||||
// 3. NO CAPACITY CLIFF. A live object's handle never changes while the object is being
|
||||
// drawn, whatever the working set size.
|
||||
//
|
||||
// Property 3 is why this is not the fixed 2-way set-associative LRU the previous round
|
||||
// shipped. That structure evicted a LIVE object once the working set passed its capacity,
|
||||
// and every consumer memo keyed on the handle died with it: measured on a verbatim
|
||||
// transcription, 54% of uses lost their handle at 2500 live VAOs against 2048 entries, and
|
||||
// 20% at 1024 live VAOs once the lifetime ids are sparse (an app that creates and destroys
|
||||
// VAOs, which is the Minecraft chunk shape this exists for). Two of the three memos it
|
||||
// fed - the content-hash memo and the resolved-state memo - had NO capacity before this
|
||||
// package: they were unbounded mutable fields on VertexArrayObject. Introducing eviction
|
||||
// there turns one ComputeHash per VAO reconfiguration into one per DRAW, and, once the
|
||||
// buffer table thrashes too, makes the vertex-input content hash a per-draw value that
|
||||
// inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every
|
||||
// draw. That is a worse leak than the one it was introduced to avoid.
|
||||
//
|
||||
// So: grow on demand, and reclaim by AGE instead of by capacity.
|
||||
//
|
||||
// * Acquire hits an UnorderedMap<lifetimeId, slotIndex>, in front of which sits a
|
||||
// one-entry memo. Every re-keyed site in a draw asks about the SAME VAO, so the memo
|
||||
// turns the five-or-six acquisitions a draw makes into one map probe plus five Uint64
|
||||
// compares - less than the address multiply plus two-way probe the pre-handle arm ran.
|
||||
// * OnFrameBoundary retires slots whose object has not been drawn for
|
||||
// kRetireAgeBoundaries boundaries and returns them to a free list, so the table's
|
||||
// footprint tracks the LIVE DRAWN working set, not objects ever created. That is the
|
||||
// property MG_Impl/Pipe/SlotAllocator cannot have here: nothing in P2 can call its
|
||||
// Free (the tracker emits no object-class state, BufferBackendOps::OnDestroy is handed
|
||||
// a BackendBufferResource rather than the BufferObject, and VertexArrayObject has no
|
||||
// death hook at all - adding one is D13's explicit-destroy work, which covers Espryt's
|
||||
// six kinds, not VertexElementsCso), so an allocator here would grow by one SlotState
|
||||
// plus one map node per object EVER created, for the life of the process, on a
|
||||
// platform with an LMK. Age-based reclamation is the stand-in for the death
|
||||
// notification, and it is exactly as ABA-proof, because reuse bumps Gen.
|
||||
// * A retire costs at most one memo recompute if the object is drawn again - the same
|
||||
// price a cache miss costs - and it is charged only to objects that went idle for
|
||||
// ~1024 frames, never to a hot one.
|
||||
//
|
||||
// Memory: one map node plus one 24-byte Entry per live object, i.e. tens of bytes against
|
||||
// the kilobyte a VertexArrayObject or a BufferObject already costs the frontend. There is
|
||||
// no capacity to size off a device measurement because there is no capacity; what the
|
||||
// device run in D.4.2 can still want is the number itself, so the high-water mark is
|
||||
// logged at MGLOG_D on the allocate-a-new-slot branch (once per new object, never on a
|
||||
// draw - ROADMAP.md:7).
|
||||
//
|
||||
// Single-threaded, like the rest of the renderer. Owned per VulkanRenderer (see
|
||||
// MagmaPipeIdentityTables): a process-global would share one table, and one reclamation
|
||||
// clock, across two live contexts.
|
||||
class MagmaPipeIdentityTable {
|
||||
public:
|
||||
explicit MagmaPipeIdentityTable(const char* kindName) : m_kindName(kindName) {}
|
||||
|
||||
// Slots ever minted. A consumer table indexed by MagmaPipeSlotIndex() needs this many
|
||||
// entries; MagmaPipeSlotTable below grows itself, so nobody has to ask.
|
||||
Uint32 Count() const { return static_cast<Uint32>(m_entries.size()); }
|
||||
// Objects currently holding a slot - the live working set this table tracks.
|
||||
Uint32 LiveCount() const { return static_cast<Uint32>(m_index.size()); }
|
||||
|
||||
MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) {
|
||||
// Unreachable: MG_State hands out lifetime ids from 1 precisely so that a
|
||||
// zero-initialised memo slot cannot carry a live object's id. Guarded anyway so
|
||||
// that a zero can never be minted into a slot and then indexed with.
|
||||
if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle;
|
||||
// The one-entry front memo. Cleared by any retire, so it can never serve a slot
|
||||
// that has been handed back to the free list.
|
||||
if (lifetimeId == m_lastLifetimeId) {
|
||||
m_entries[m_lastIndex].LastUse = m_boundary;
|
||||
return m_lastHandle;
|
||||
}
|
||||
Uint32 index = 0;
|
||||
const auto it = m_index.find(lifetimeId);
|
||||
if (it != m_index.end()) {
|
||||
index = it->second;
|
||||
} else {
|
||||
index = ClaimSlot();
|
||||
m_entries[index].LifetimeId = lifetimeId;
|
||||
m_index.emplace(lifetimeId, index);
|
||||
}
|
||||
Entry& entry = m_entries[index];
|
||||
entry.LastUse = m_boundary;
|
||||
m_lastLifetimeId = lifetimeId;
|
||||
m_lastIndex = index;
|
||||
m_lastHandle = MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot,
|
||||
entry.Gen};
|
||||
return m_lastHandle;
|
||||
}
|
||||
|
||||
// Ages the table and returns idle slots to the free list. Same shape and the same
|
||||
// self-gating as VertexInputStateFactory::OnFrameBoundary, which is what the reclaimed
|
||||
// slots' consumers use.
|
||||
void OnFrameBoundary() {
|
||||
++m_boundary;
|
||||
if ((m_boundary % kSweepInterval) != 0) return;
|
||||
SizeT retired = 0;
|
||||
for (auto it = m_index.begin(); it != m_index.end();) {
|
||||
Entry& entry = m_entries[it->second];
|
||||
if ((m_boundary - entry.LastUse) > kRetireAgeBoundaries) {
|
||||
entry.LifetimeId = 0;
|
||||
m_freeSlots.push_back(it->second);
|
||||
it = m_index.erase(it);
|
||||
++retired;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (retired != 0) {
|
||||
// A retired slot's Gen has not moved yet - it moves when the slot is reused -
|
||||
// so a front memo pointing at one would still hand out a handle the consumer
|
||||
// tables would accept. Drop it.
|
||||
m_lastLifetimeId = 0;
|
||||
m_lastHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
MGLOG_D("MagmaPipeIdentityTable(%s): retired %zu idle slots, %u live of %u minted",
|
||||
m_kindName, retired, LiveCount(), Count());
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
// Sweep cadence and retirement age, deliberately the same numbers
|
||||
// VertexInputStateFactory::OnFrameBoundary uses for the entries these slots key: a slot
|
||||
// retired earlier than its cache entry would mint a new handle for an object whose
|
||||
// entry is still live and still correct, which is a pure waste.
|
||||
static constexpr Uint64 kSweepInterval = 256;
|
||||
static constexpr Uint64 kRetireAgeBoundaries = 1024;
|
||||
|
||||
struct Entry {
|
||||
Uint64 LifetimeId = 0;
|
||||
Uint64 LastUse = 0;
|
||||
// Moves ONLY on slot reuse, never on respecify: an object that keeps its slot keeps
|
||||
// its generation, which is what makes a memo survive a reconfiguration.
|
||||
Uint32 Gen = 0;
|
||||
};
|
||||
|
||||
Uint32 ClaimSlot() {
|
||||
while (!m_freeSlots.empty()) {
|
||||
const Uint32 index = m_freeSlots.back();
|
||||
m_freeSlots.pop_back();
|
||||
// MGPipeHandles.h:52-58 defends the Gen wrap only in a debug allocator, and
|
||||
// MOBILEGL_ASSERT is compiled out of every build P2 runs (Defines.h: asserts are
|
||||
// live only at MOBILEGL_LOG_ACTIVE_LEVEL == DEBUG). So the wrap is handled on the
|
||||
// RELEASE path instead of asserted: a slot that has been reused 2^32 times is
|
||||
// permanently retired rather than wrapped, because a wrapped Gen would let a
|
||||
// stale handle match a live object. It costs one slot.
|
||||
if (m_entries[index].Gen == ~Uint32{0}) {
|
||||
MGLOG_W("MagmaPipeIdentityTable(%s): slot %u reached generation 2^32-1 and is "
|
||||
"retired for good; {slot, gen} stays unique",
|
||||
m_kindName, index + MG_Pipe::kMGPipeFirstAllocatableSlot);
|
||||
continue;
|
||||
}
|
||||
++m_entries[index].Gen;
|
||||
return index;
|
||||
}
|
||||
const Uint32 index = static_cast<Uint32>(m_entries.size());
|
||||
m_entries.push_back(Entry{});
|
||||
m_entries[index].Gen = 1;
|
||||
// The high-water mark, at powers of two from 1024 up: at most a handful of lines
|
||||
// for a whole session, emitted from the allocate-a-NEW-slot branch, i.e. once per
|
||||
// object this backend has ever seen and never on a draw (ROADMAP.md:7).
|
||||
//
|
||||
// [narrow, declared deviation from D20's "MGLOG_D for anything non-critical"] This
|
||||
// one is I, not D, because D is compiled out of every build that ships and of every
|
||||
// build P2 measures, and this line IS the measurement review v2's MAJOR 1 asks for:
|
||||
// the live-object high-water mark of minecraft-1.21.4-in-world and
|
||||
// ...-sodium-in-world, which nothing on desktop reaches and no gate here can see.
|
||||
// The structure no longer has a capacity to size off it, so the number is evidence
|
||||
// rather than a tuning input - but D.4.2 should still read it out of the device log,
|
||||
// and it cannot read a line that was compiled away.
|
||||
const SizeT minted = m_entries.size();
|
||||
if (minted >= 1024 && (minted & (minted - 1)) == 0) {
|
||||
MGLOG_I("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live",
|
||||
m_kindName, minted, LiveCount());
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const char* m_kindName = "";
|
||||
Uint64 m_boundary = 0;
|
||||
Vector<Entry> m_entries;
|
||||
Vector<Uint32> m_freeSlots;
|
||||
UnorderedMap<Uint64, Uint32> m_index;
|
||||
// One-entry front memo (see Acquire). m_lastLifetimeId == 0 means "empty": a live
|
||||
// object's lifetime id is never 0.
|
||||
Uint64 m_lastLifetimeId = 0;
|
||||
Uint32 m_lastIndex = 0;
|
||||
MG_Pipe::MGPipeHandle m_lastHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
};
|
||||
|
||||
// The two mints one renderer owns. Per renderer, NOT process-global: two live contexts (or
|
||||
// a context recreation, which destroys and rebuilds the renderer) would otherwise share one
|
||||
// table and one reclamation clock, and both consumer tables are per-instance already.
|
||||
class MagmaPipeIdentityTables {
|
||||
public:
|
||||
// A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array
|
||||
// resolves to, and the only kind in MGPipeKind that names vertex-input state.
|
||||
MG_Pipe::MGPipeHandle HandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
|
||||
return kind == MG_Pipe::MGPipeKind::Buffer ? m_buffers.Acquire(lifetimeId)
|
||||
: m_vaos.Acquire(lifetimeId);
|
||||
}
|
||||
void OnFrameBoundary() {
|
||||
m_vaos.OnFrameBoundary();
|
||||
m_buffers.OnFrameBoundary();
|
||||
}
|
||||
const MagmaPipeIdentityTable& Vaos() const { return m_vaos; }
|
||||
const MagmaPipeIdentityTable& Buffers() const { return m_buffers; }
|
||||
|
||||
private:
|
||||
MagmaPipeIdentityTable m_vaos{"VertexElementsCso"};
|
||||
MagmaPipeIdentityTable m_buffers{"Buffer"};
|
||||
};
|
||||
|
||||
// The table entry a handle names. Every per-slot table Magma keeps is indexed by this.
|
||||
//
|
||||
// A null handle has no slot, and it is unreachable here: both lifetime-id sources start at
|
||||
// 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. The
|
||||
// ternary, not the assertion, is what has effect in a shipped build (Defines.h compiles
|
||||
// MOBILEGL_ASSERT out at INFO), and slot 0 of a consumer table is a real entry that a null
|
||||
// handle can never match, because MGPipeHandleIsNull is also what the consumers compare.
|
||||
inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) {
|
||||
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
|
||||
"a null MGPipeHandle has no slot to index a per-slot table with");
|
||||
return MG_Pipe::MGPipeHandleIsNull(handle)
|
||||
? 0u
|
||||
: handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot;
|
||||
}
|
||||
|
||||
// A grow-on-demand per-slot table whose ENTRY ADDRESSES NEVER MOVE.
|
||||
//
|
||||
// D12.4 asks for a grow-on-demand Vector, and with an unbounded mint that is what a
|
||||
// consumer needs - but a Vector that grows relocates its elements, and the draw path holds
|
||||
// references into these entries across nested calls. Chunks of kChunkEntries are appended
|
||||
// instead: the Vector of owning pointers reallocates, the chunks never do, so an entry
|
||||
// reference is valid for the life of the table. That is the same guarantee the fixed table
|
||||
// it replaces gave, without the fixed capacity.
|
||||
template <typename T, Uint32 kChunkEntries = 256>
|
||||
class MagmaPipeSlotTable {
|
||||
public:
|
||||
T& operator[](Uint32 index) {
|
||||
const Uint32 chunk = index / kChunkEntries;
|
||||
while (m_chunks.size() <= chunk) {
|
||||
m_chunks.push_back(MakeUnique<Chunk>());
|
||||
}
|
||||
return m_chunks[chunk]->Entries[index % kChunkEntries];
|
||||
}
|
||||
SizeT Capacity() const { return m_chunks.size() * kChunkEntries; }
|
||||
|
||||
private:
|
||||
struct Chunk {
|
||||
T Entries[kChunkEntries] = {};
|
||||
};
|
||||
Vector<UniquePtr<Chunk>> m_chunks;
|
||||
};
|
||||
|
||||
// The claim rule every per-slot memo table uses, in one place so that the rule and the
|
||||
// negative control that defeats it cannot drift apart between consumers - and so that the
|
||||
// unit suite which drives a REAL slot reuse (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) tests
|
||||
// this code rather than a copy of it.
|
||||
//
|
||||
// The SLOT picks the entry; the WHOLE handle - Gen included - decides whether the entry is
|
||||
// this object's. A slot the mint recycled for a different object comes back with a moved
|
||||
// Gen, so the compare fails and the entry is cleared rather than inherited. That is the
|
||||
// half HandleRecycleScenario cannot reach (see MagmaPipeAbaControlDefeatsIdentity).
|
||||
//
|
||||
// With negative control C on, every object collapses onto one entry and the entry is handed
|
||||
// back UNCLEARED and UNCLAIMED - at once "the replacement reproduced its predecessor's
|
||||
// slot" and "the slot was reused and Gen did not move".
|
||||
//
|
||||
// `Memos` needs a MG_Pipe::MGPipeHandle member named Owner and a default constructor that
|
||||
// means "empty"; VertexInputStateFactory::VaoBackendMemos is the one production instance.
|
||||
template <typename Memos, Uint32 kChunkEntries>
|
||||
inline Memos& MagmaPipeClaimSlotMemos(MagmaPipeSlotTable<Memos, kChunkEntries>& table,
|
||||
const MG_Pipe::MGPipeHandle& handle) {
|
||||
if (MagmaPipeAbaControlDefeatsIdentity()) {
|
||||
return table[kMagmaPipeAbaControlSlotIndex];
|
||||
}
|
||||
Memos& memos = table[MagmaPipeSlotIndex(handle)];
|
||||
if (!(memos.Owner == handle)) {
|
||||
memos = Memos{};
|
||||
memos.Owner = handle;
|
||||
}
|
||||
return memos;
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
@@ -7,6 +7,7 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "VertexInputStateFactory.h"
|
||||
#include "MagmaPipeArms.h"
|
||||
#include "MG_Util/Converters/MGToStr/DataTypeConverter.h"
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <utility>
|
||||
@@ -45,25 +46,149 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
|
||||
// test's positions) instead of its own.
|
||||
// Zero for client memory (no buffer), which is a distinct identity of its own.
|
||||
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
//
|
||||
// P2 D12.4 / ARCHITECTURE.md 9.5: under the handle arm the identity is the
|
||||
// buffer's {slot, gen} rather than its lifetime id - "lifetimeId -> gen mixed
|
||||
// into every server-side content hash". The two are equally ABA-proof (the
|
||||
// allocator maps one onto the other and bumps Gen only on slot REUSE); what
|
||||
// changes is that the key is now the identity the SERVER will be handed once
|
||||
// buffers travel as handles, instead of a number only the client can mint.
|
||||
Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (attr.Buffer) {
|
||||
// The SAME arm question the other four re-keyed sites ask, through the same
|
||||
// helper: a site that decided for itself could silently key on the pre-handle
|
||||
// identity while its neighbours keyed on the handle.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
m_identity->HandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId());
|
||||
bufferKey = static_cast<Uint64>(handle.Slot) | (static_cast<Uint64>(handle.Gen) << 32);
|
||||
}
|
||||
if (MagmaPipeAbaControlDefeatsIdentity()) {
|
||||
// Negative control C (P2 brief D18), on WHICHEVER arm this run is on - the
|
||||
// pre-handle lifetime id and the handle's {slot, gen} are the same guard
|
||||
// wearing two hats, and a control that defeated only the retired one would
|
||||
// say nothing about the key P2 ships.
|
||||
//
|
||||
// The identity is replaced by a constant rather than by the raw
|
||||
// BufferObject*, because the address is not recycled in practice and so
|
||||
// never collides (see MagmaPipeAbaControlDefeatsIdentity). Zero is what a
|
||||
// key with NO buffer identity in it looks like - the exact defect this
|
||||
// hash was fixed for: "the hash is what TryBindResolvedVertexBindings
|
||||
// accepts as proof that a memoised binding still reads the buffer it was
|
||||
// resolved from", and with the identity gone it accepts a binding resolved
|
||||
// from a different buffer. HandleRecycleScenario.AbaControl then draws a
|
||||
// replacement VAO and gets its dead predecessor's vertex data.
|
||||
bufferKey = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
|
||||
}
|
||||
|
||||
return XXH64_digest(m_hashState);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor(
|
||||
const MG_State::GLState::VertexArrayObject& vao) const {
|
||||
const MG_Pipe::MGPipeHandle handle =
|
||||
m_identity->HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId());
|
||||
// One entry per mintable slot, grown on demand: the mint has no capacity, so neither
|
||||
// does this, and no two live VAOs can share an entry however large the working set is.
|
||||
// There is no probe in front of it because the mint itself is one - a one-entry memo
|
||||
// hit for every acquisition after this draw's first, and a hash probe otherwise.
|
||||
//
|
||||
// The claim rule - the slot picks the entry, the whole handle (Gen included) decides
|
||||
// whose it is - and negative control C's defeat of it are MagmaPipeArms.h's
|
||||
// MagmaPipeClaimSlotMemos, so that the unit suite which drives a REAL slot reuse
|
||||
// (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) exercises this code and not a copy of it.
|
||||
// What the control defeats HERE is the identity that SELECTS the entry: every VAO
|
||||
// collapses onto one, handed back uncleared, so the replacement inherits the dead
|
||||
// VAO's content hash and its resolved-entry pointer. The GENERATION half is the unit
|
||||
// suite's business, for the reason MagmaPipeAbaControlDefeatsIdentity spells out.
|
||||
return MagmaPipeClaimSlotMemos(m_vaoMemos, handle);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao,
|
||||
Uint64& outHash) const {
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
const VaoBackendMemos& memos = MemosFor(vao);
|
||||
if (memos.HashConfigVersion != vao.GetConfigVersion()) return false;
|
||||
outHash = memos.Hash;
|
||||
return true;
|
||||
}
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
return vao.GetBackendHashMemo(outHash);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
VertexInputStateFactory::HashType VertexInputStateFactory::GetOrComputeHash(
|
||||
const MG_State::GLState::VertexArrayObject& vao) const {
|
||||
HashType hash = 0;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.5: the same memo, on the backend's side of the boundary.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
VaoBackendMemos& memos = MemosFor(vao);
|
||||
if (memos.HashConfigVersion == vao.GetConfigVersion()) {
|
||||
return memos.Hash;
|
||||
}
|
||||
hash = ComputeHash(vao);
|
||||
memos.Hash = hash;
|
||||
memos.HashConfigVersion = vao.GetConfigVersion();
|
||||
return hash;
|
||||
}
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
if (!vao.GetBackendHashMemo(hash)) {
|
||||
hash = ComputeHash(vao);
|
||||
vao.SetBackendHashMemo(hash);
|
||||
}
|
||||
#endif
|
||||
return hash;
|
||||
}
|
||||
|
||||
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
|
||||
const MG_State::GLState::VertexArrayObject& vao) {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.5: the same per-draw fast path, but the resolved-entry pointer lives in this
|
||||
// factory's slot-indexed table instead of on the frontend VAO. The eviction epoch
|
||||
// survives the move and is still what stops a stale pointer being dereferenced: the
|
||||
// POINTEE is a cache entry this factory can erase at a frame boundary, and moving the
|
||||
// memo does not change that.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
VaoBackendMemos& memos = MemosFor(vao);
|
||||
if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr &&
|
||||
memos.StateEpoch == m_evictionEpoch) {
|
||||
const auto* memoEntry = static_cast<const BackendVertexInputState*>(memos.State);
|
||||
memoEntry->lastUsedFrameBoundary = m_frameBoundaryCounter;
|
||||
return *memoEntry;
|
||||
}
|
||||
const BackendVertexInputState& resolved =
|
||||
GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
|
||||
// MemosFor is re-taken rather than kept live across GetOrCreateVertexInputState:
|
||||
// the reference is not worth holding across a call that can resize the table.
|
||||
VaoBackendMemos& stamp = MemosFor(vao);
|
||||
stamp.State = &resolved;
|
||||
stamp.StateEpoch = m_evictionEpoch;
|
||||
stamp.StateConfigVersion = vao.GetConfigVersion();
|
||||
// The AUX memo is deliberately NOT stamped here: its two words already live in
|
||||
// VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and its getter has no
|
||||
// live reader anywhere, so the handle arm retires it rather than moving it.
|
||||
return resolved;
|
||||
}
|
||||
#endif
|
||||
#if !MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// Unreachable: with no legacy arm compiled MagmaPipeTrackHArmIsHandles is a compile-
|
||||
// time true, so the handle arm above always returns. Written out rather than left to
|
||||
// fall off the end so the function still has a return on every path a compiler sees.
|
||||
return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
|
||||
#else
|
||||
// Per-draw fast path: the VAO carries a pointer to its resolved entry,
|
||||
// valid while its config version and the cache's eviction epoch both
|
||||
// match - no re-hash, no map lookup.
|
||||
@@ -83,6 +208,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
vao.SetBackendAuxMemo(entry.layoutHash,
|
||||
PackVertexInputAuxMasks(entry.unsupportedAttribMask, entry.attributeLocationMask));
|
||||
return entry;
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
}
|
||||
|
||||
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
|
||||
@@ -316,8 +442,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// Invalidate every VAO's state-pointer memo: the erased node's
|
||||
// address may be reused by a future insert. Advance through the
|
||||
// process-wide source so the value stays unique across factory
|
||||
// instances (see the member comment).
|
||||
// instances (see the member comment). With no legacy arm the memos
|
||||
// live in this factory and die with it, so a per-instance bump is
|
||||
// enough - P2 D12.5.
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
m_evictionEpoch = ++s_evictionEpochSource;
|
||||
#else
|
||||
++m_evictionEpoch;
|
||||
#endif
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
// MG_Pipe::MGPipeHandle for the P2 D12.5 memo table below. A header of constexpr constants,
|
||||
// so the pull build gains nothing from it.
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
|
||||
#include "Config.h"
|
||||
#include "MagmaPipeArms.h"
|
||||
#include "VertexInputStateBuilder.h"
|
||||
#include "MG_State/GLState/VertexArrayState/VertexArrayObject.h"
|
||||
#include <Includes.h>
|
||||
@@ -70,8 +74,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
};
|
||||
};
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The mint is the RENDERER's (MagmaPipeIdentityTables), not a process-global and not
|
||||
// this factory's: VulkanRenderer::LookupVaoDrawMemo has to derive the same {slot, gen}
|
||||
// for the same VAO, and a table that outlived the context it was minted for would share
|
||||
// one reclamation clock across two live contexts (review v2 minor 4).
|
||||
VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice,
|
||||
MagmaPipeIdentityTables& identity):
|
||||
m_config(config), m_physicalDevice(physicalDevice), m_identity(&identity) {}
|
||||
#else
|
||||
VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice):
|
||||
m_config(config), m_physicalDevice(physicalDevice) {}
|
||||
#endif
|
||||
~VertexInputStateFactory() = default;
|
||||
VertexInputStateFactory(const VertexInputStateFactory&) = delete;
|
||||
|
||||
@@ -86,6 +100,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// Memoized ComputeHash: reuses the VAO's cached hash while its config version
|
||||
// is unchanged. Use this on per-draw paths.
|
||||
HashType GetOrComputeHash(const MG_State::GLState::VertexArrayObject& vao) const;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The VAO's content hash IF it has already been memoized, without computing one.
|
||||
// P2 D12.5: the three draw-path readers that used to ask the VAO object this
|
||||
// question ask the factory instead, because that is where the memo lives once the
|
||||
// frontend object stops carrying the backend's state.
|
||||
Bool TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const;
|
||||
#endif
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(
|
||||
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
|
||||
@@ -112,6 +133,44 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
static VkFormat ToFloat32VertexFormat(Int componentCount);
|
||||
Bool SupportsVertexBufferFormat(VkFormat format) const;
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// ---- P2 D12.5: the backend's memos, off the frontend VAO and into the backend ----
|
||||
//
|
||||
// The two facts that used to live as `mutable` fields on VertexArrayObject
|
||||
// (Get/SetBackendHashMemo and Get/SetBackendStateMemo), kept here instead, keyed on
|
||||
// the VAO's {slot, gen} and guarded by exactly the same config version. A frontend
|
||||
// state object holding the backend's raw pointer is what P2 retires: under split the
|
||||
// backend is in another process and its cache entry has no address a client could
|
||||
// store, so the memo has to live on the side that owns the pointee.
|
||||
//
|
||||
// The AUX memo is not carried over: its two words moved into VaoDrawMemo::layoutHash
|
||||
// and layoutAuxMasks long ago and its getter has no live reader anywhere in the tree,
|
||||
// so the handle arm simply stops writing it (D12.5 says delete rather than move).
|
||||
struct VaoBackendMemos {
|
||||
// Whose memos these are. The identity table can recycle a slot for a different
|
||||
// VAO under LRU pressure, and the handle compare - Gen included - is what says
|
||||
// the contents are this object's and not its predecessor's.
|
||||
MG_Pipe::MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle;
|
||||
Uint64 Hash = 0;
|
||||
Uint32 HashConfigVersion = ~0u;
|
||||
const void* State = nullptr;
|
||||
Uint64 StateEpoch = 0;
|
||||
Uint32 StateConfigVersion = ~0u;
|
||||
};
|
||||
// Grow-on-demand (D12.4), one entry per slot the renderer's mint has ever handed
|
||||
// out, and NO CAPACITY: these two memos had none before this package either - they
|
||||
// were unbounded mutable fields on the VertexArrayObject itself - and re-introducing
|
||||
// eviction here is what review v2 rejected. MagmaPipeSlotTable grows in chunks so an
|
||||
// entry reference stays valid across the nested GetOrCreateVertexInputState call.
|
||||
// 48 B per live VAO, reclaimed with the slot when the object goes idle.
|
||||
mutable MagmaPipeSlotTable<VaoBackendMemos> m_vaoMemos;
|
||||
// The renderer's {slot, gen} mint (see the constructor). Never null under push.
|
||||
MagmaPipeIdentityTables* m_identity = nullptr;
|
||||
// The entry belonging to `vao`, claimed (and cleared) if the slot currently holds
|
||||
// someone else's.
|
||||
VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const;
|
||||
#endif
|
||||
|
||||
const VulkanRendererConfig& m_config;
|
||||
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
|
||||
// Values are heap-allocated: UnorderedMap is open-addressing, so INSERT
|
||||
@@ -137,8 +196,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// than anything a predecessor ever stamped, so a dead factory's memo can
|
||||
// never compare equal here - the same never-reused idiom as the lifetime ids.
|
||||
// Single-threaded like the rest of the factory (renderer-thread only).
|
||||
//
|
||||
// P2 D12.5: the process-wide source is the LEGACY arm's need. It exists because the
|
||||
// memos live on the frontend VAOs and therefore outlive the factory. The handle arm's
|
||||
// memo table is owned by this factory and dies with it, so a per-instance counter is
|
||||
// enough there and the epoch shrinks back to what it looks like it should be.
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
static inline Uint64 s_evictionEpochSource = 0;
|
||||
Uint64 m_evictionEpoch = ++s_evictionEpochSource;
|
||||
#else
|
||||
Uint64 m_evictionEpoch = 1;
|
||||
#endif
|
||||
static inline XXH64_state_t* m_hashState = XXH64_createState();
|
||||
};
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -397,6 +397,101 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
};
|
||||
static DynamicStateShadow g_dynamicStateShadow;
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// ---- D12.3: DynamicTailKey's inputs against the P2 chunk table ----
|
||||
//
|
||||
// DynamicTailKey's inventory (declared above, one line per reader) is an exact,
|
||||
// hand-maintained enumeration of what the six Apply* in the tail read. The P2 chunk table
|
||||
// (MG_Pipe/MGPipeRenderStateSpans.h) is an independent, offsetof-derived statement of
|
||||
// which bytes of RenderStateParameters are dynamic state. The two were written for
|
||||
// different reasons, so making them check each other is free evidence: if a later chunk
|
||||
// edit demotes or promotes one of these members, the mismatch is a BUILD BREAK here rather
|
||||
// than a tail that silently stops being re-run when its input moves.
|
||||
//
|
||||
// The brief (P2 D12.3) expects every input to be dynamic; the tree says otherwise for
|
||||
// exactly one, and the tree is right - see the ScissorTestEnabledMask note below.
|
||||
//
|
||||
// These assertions ARE D19's DynamicChunksCoverMagmasDynamicTailKey, in the only file this
|
||||
// package owns. D19 names it as a case in MG_Test/Pipe/RenderStateSpansTest.cpp, which
|
||||
// belongs to package A (C.5). INTEGRATOR: make sure the outcome is not "neither" - if
|
||||
// package A did not land that case, this static_assert block is the whole gate, and if it
|
||||
// did, the two are redundant on purpose and both should stay.
|
||||
namespace {
|
||||
// Is [begin, begin + size) covered entirely by DYNAMIC chunks?
|
||||
constexpr Bool MagmaRenderStateRangeIsDynamic(SizeT begin, SizeT size) {
|
||||
const SizeT end = begin + size;
|
||||
for (SizeT i = 0; i < MG_Pipe::kMGPipeRenderStateChunkCount; ++i) {
|
||||
const SizeT chunkBegin = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i];
|
||||
const SizeT chunkEnd = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i + 1];
|
||||
if (end <= chunkBegin || begin >= chunkEnd) continue; // disjoint
|
||||
if (MG_Pipe::MGPipeRenderStateChunkIsPipeline(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
using MagmaTailRsp = RenderStateParameters;
|
||||
|
||||
#define MAGMA_TAIL_INPUT_IS_DYNAMIC(Member) \
|
||||
static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, Member), \
|
||||
sizeof(MagmaTailRsp::Member)), \
|
||||
"ApplyDynamicDrawStateTail reads " #Member \
|
||||
", which the P2 chunk table no longer calls dynamic state: a change to it would " \
|
||||
"move the pipeline version, not the parameters version, and the tail would stop " \
|
||||
"being re-run for it")
|
||||
|
||||
// Viewports, DepthRanges and ScissorBoxes are asserted over the WHOLE array while the
|
||||
// tail reads only element 0. That is deliberately stricter than the reader needs: the
|
||||
// chunk table has no per-element granularity today, so an array that is dynamic at all
|
||||
// is dynamic entirely, and asserting the whole of it says so. If a later phase ever
|
||||
// splits a per-viewport chunk out, this is a build break by design - narrow the assert
|
||||
// to element 0 then, and say why in the same commit.
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(Viewports); // ApplyGLViewportState: Viewports[0]
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(DepthRanges); // ApplyGLViewportState: DepthRanges[0]
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(BlendColor); // ApplyBlendConstants
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetFactor); // ApplyPolygonOffsetState
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetUnits); // ApplyPolygonOffsetState
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(LineWidth); // ApplyLineWidthState
|
||||
MAGMA_TAIL_INPUT_IS_DYNAMIC(ScissorBoxes); // the scissor rect: ScissorBoxes[0]
|
||||
#undef MAGMA_TAIL_INPUT_IS_DYNAMIC
|
||||
|
||||
// ApplyStencilState reads three of the seven members of each face, and D6 splits
|
||||
// StencilFaceState at sub-member granularity for exactly this reason: Ref, ValueMask
|
||||
// and WriteMask are VK_DYNAMIC_STATE_STENCIL_{REFERENCE,COMPARE_MASK,WRITE_MASK}, while
|
||||
// Func and the three ops are baked into the pipeline. Asserted per member, per face,
|
||||
// because the split runs THROUGH the struct rather than around it.
|
||||
constexpr SizeT kMagmaStencilFace1 = offsetof(MagmaTailRsp, StencilStates) + sizeof(StencilFaceState);
|
||||
#define MAGMA_TAIL_STENCIL_IS_DYNAMIC(Member) \
|
||||
static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, StencilStates) + \
|
||||
offsetof(StencilFaceState, Member), \
|
||||
sizeof(StencilFaceState::Member)), \
|
||||
"ApplyStencilState reads the FRONT face's " #Member " as dynamic state"); \
|
||||
static_assert(MagmaRenderStateRangeIsDynamic(kMagmaStencilFace1 + offsetof(StencilFaceState, Member), \
|
||||
sizeof(StencilFaceState::Member)), \
|
||||
"ApplyStencilState reads the BACK face's " #Member " as dynamic state")
|
||||
|
||||
MAGMA_TAIL_STENCIL_IS_DYNAMIC(Ref);
|
||||
MAGMA_TAIL_STENCIL_IS_DYNAMIC(ValueMask);
|
||||
MAGMA_TAIL_STENCIL_IS_DYNAMIC(WriteMask);
|
||||
#undef MAGMA_TAIL_STENCIL_IS_DYNAMIC
|
||||
|
||||
// THE ONE INPUT THAT IS NOT DYNAMIC, and the brief's D12.3 says it should be.
|
||||
// The tree wins, and it is right: the split's only rule is "a byte is pipeline state
|
||||
// iff a public setter that calls BumpVersions() writes it", and ScissorTestEnabledMask
|
||||
// is written by SetCapability(ScissorTest), which does. It sits in pipeline chunk P6
|
||||
// with the other capability bools. The tail reads it only to decide between the
|
||||
// scissor box and a full-extent rect, and it is HARMLESS there for a reason worth
|
||||
// stating: a pipeline-half write moves the pipeline version, and the pipeline version
|
||||
// moves only together with the parameters version (BumpVersions bumps both), so the
|
||||
// tail's version gate is invalidated by it just the same. A DYNAMIC member promoted
|
||||
// into the pipeline half would break that direction, which is what the asserts above
|
||||
// are for; this one is pinned in the opposite direction so that DEMOTING it - which
|
||||
// would be a real G7 violation - is also a build break.
|
||||
static_assert(!MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, ScissorTestEnabledMask),
|
||||
sizeof(MagmaTailRsp::ScissorTestEnabledMask)),
|
||||
"ScissorTestEnabledMask is written by SetCapability(ScissorTest), which calls "
|
||||
"BumpVersions(), so the chunk table must keep it in the pipeline half");
|
||||
} // namespace
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
|
||||
static void ResetDynamicStateShadow() {
|
||||
g_dynamicStateShadow = {};
|
||||
}
|
||||
@@ -3015,6 +3110,13 @@ void main() {
|
||||
}
|
||||
|
||||
void VulkanRenderer::Initialize() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D14, and it belongs HERE rather than on a draw: "a Track-H subsystem whose bit is
|
||||
// clear is a STARTUP Fatal{PipeLegacyMemosDisabled}". Checks Magma's own bit only, and
|
||||
// only once this backend is the one being brought up, so an Espryt-side bitmask cannot
|
||||
// kill a Magma run and vice versa.
|
||||
MagmaPipeValidateSubsystemConfiguration();
|
||||
#endif
|
||||
CreateInstance();
|
||||
CreateSurface();
|
||||
PickPhysicalDevice();
|
||||
@@ -3194,7 +3296,12 @@ void main() {
|
||||
m_physicalDevice.properties.limits.minUniformBufferOffsetAlignment, m_config.MaxFramesInFlight,
|
||||
maxProgramBindings, kDescriptorSetsPerFrame, m_textureManager.get(), m_samplerManager.get());
|
||||
MOBILEGL_ASSERT(succeeded, "UniformDescriptorBinder initialization failed.");
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
m_vertexInputStateFactory =
|
||||
MakeUnique<VertexInputStateFactory>(m_config, m_physicalDevice.handle, m_pipeIdentity);
|
||||
#else
|
||||
m_vertexInputStateFactory = MakeUnique<VertexInputStateFactory>(m_config, m_physicalDevice.handle);
|
||||
#endif
|
||||
MOBILEGL_ASSERT(m_vertexInputStateFactory != nullptr, "VertexInputStateFactory creation failed.");
|
||||
|
||||
// Prime the first frame so Render() always targets an acquired swapchain image.
|
||||
@@ -3542,6 +3649,76 @@ void main() {
|
||||
if (m_vaoDrawMemoTable.empty()) {
|
||||
m_vaoDrawMemoTable.resize(kVaoDrawMemoSlotCount);
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (MagmaPipeAbaControlDefeatsIdentity()) {
|
||||
// Negative control C (P2 brief D18), ahead of BOTH arms because it defeats the
|
||||
// identity half of both keys at once: the legacy arm's (address, lifetime id) pair
|
||||
// and the handle arm's {slot, gen}. Every VAO lands on one entry and the entry is
|
||||
// handed back without an identity compare and WITHOUT being cleared - which is
|
||||
// exactly what this table would do if a replacement object reproduced its dead
|
||||
// predecessor's address, or reused its slot without the generation moving.
|
||||
//
|
||||
// Nothing else about the entry is relaxed: whether the resolved bindings it holds
|
||||
// are then USED is still decided by TryBindResolvedVertexBindings' frame serial,
|
||||
// content hash, active-attribute mask and slice epochs. That is what keeps the arm
|
||||
// an assertion about identity rather than about the memo as a whole.
|
||||
VaoDrawMemo& aliased = m_vaoDrawMemoTable[kMagmaPipeAbaControlSlotIndex];
|
||||
aliased.vaoKey = vao;
|
||||
aliased.vaoLifetimeId = vao->GetLifetimeId();
|
||||
return &aliased;
|
||||
}
|
||||
// ---- P2 D12.4, the handle arm ----
|
||||
//
|
||||
// The slot PICKS the entry, and the handle DECIDES whether the entry is this VAO's -
|
||||
// the same division of labour the legacy arm below gives the address and the lifetime
|
||||
// id, with two differences that are both improvements:
|
||||
//
|
||||
// * the slot is dense from 1, so below kVaoDrawMemoSlotCount live slots the map is a
|
||||
// bijection and the two-way probe never collides at all, where an address hash
|
||||
// collides by the birthday rule from the first few dozen VAOs;
|
||||
// * the handle is an exact identity - Gen moves whenever a slot changes owner - so
|
||||
// neither a deleted VAO's successor at the same heap address nor a VAO whose slot
|
||||
// was recycled can match a predecessor's entry, even byte-identically configured.
|
||||
// That is what makes the lifetime-id half of the legacy compare unnecessary here.
|
||||
//
|
||||
// The capacity and the victim rule are deliberately the base ref's, unchanged: this is
|
||||
// the one memo of the three that HAD a capacity before P2, and an entry lost to a
|
||||
// collision costs exactly what it cost then (one vertex-binding re-resolve). Above
|
||||
// kVaoDrawMemoSlotCount live VAOs a set of two ways serves four slots, and degrades
|
||||
// from there - never worse than the address-hashed table it replaces, which was already
|
||||
// colliding.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao);
|
||||
const Uint32 index = MagmaPipeSlotIndex(handle) & (kVaoDrawMemoSlotCount - 1u);
|
||||
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
|
||||
if (first.vaoHandle == handle) {
|
||||
return &first;
|
||||
}
|
||||
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
|
||||
if (second.vaoHandle == handle) {
|
||||
return &second;
|
||||
}
|
||||
// Miss: recycle a slot. Prefer an unclaimed one; otherwise evict the entry whose
|
||||
// bindings memo is older (its VAO is the one drawn less recently).
|
||||
VaoDrawMemo* victim = &first;
|
||||
if (!MG_Pipe::MGPipeHandleIsNull(first.vaoHandle) &&
|
||||
(MG_Pipe::MGPipeHandleIsNull(second.vaoHandle) ||
|
||||
second.bindings.frameSerial < first.bindings.frameSerial)) {
|
||||
victim = &second;
|
||||
}
|
||||
victim->vaoHandle = handle;
|
||||
victim->vaoKey = vao;
|
||||
victim->vaoLifetimeId = vao->GetLifetimeId();
|
||||
victim->contentHash = 0;
|
||||
victim->layoutFactsValid = false;
|
||||
// Unmatchable until a resolve completes (same rule as the legacy arm: a bailed-out
|
||||
// resolve must never leave stale contents matchable).
|
||||
victim->bindings.frameSerial = 0;
|
||||
victim->bindings.indexFrameSerial = 0;
|
||||
victim->bindings.indexBuffer = nullptr;
|
||||
return victim;
|
||||
}
|
||||
#endif
|
||||
// Multiplicative mix of the (16-byte-aligned) address; take high bits, they
|
||||
// carry the most entropy of a multiply.
|
||||
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
|
||||
@@ -3551,6 +3728,13 @@ void main() {
|
||||
// its own is recycled, and a slot matched on a recycled address hands the new VAO
|
||||
// the dead one's resolved bindings.
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
// Negative control C has NO consumer here. It is answered once, ahead of both arms, by
|
||||
// the early return above, so a run that reaches this line has the knob off and the
|
||||
// lifetime-id half of the compare is unconditional. A fourth consumer here would be a
|
||||
// second site deciding the same question - what MagmaPipeAbaControlDefeatsIdentity
|
||||
// exists to prevent - and a trap: narrow that early return later and this one would
|
||||
// silently return to D18's retired semantics. If it is ever narrowed, ask the accessor
|
||||
// here rather than re-reading MG_Config::Features.
|
||||
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
|
||||
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
|
||||
return &first;
|
||||
@@ -3619,7 +3803,7 @@ void main() {
|
||||
VaoDrawMemo* slot = nullptr;
|
||||
ResolvedVertexBindings* memo = nullptr;
|
||||
Uint64 vaoContentHash = 0;
|
||||
const Bool vaoHashKnown = vao.GetBackendHashMemo(vaoContentHash);
|
||||
const Bool vaoHashKnown = VaoContentHashIfKnown(vao, vaoContentHash);
|
||||
if (vaoHashKnown) {
|
||||
slot = LookupVaoDrawMemo(&vao);
|
||||
memo = &slot->bindings;
|
||||
@@ -4818,6 +5002,7 @@ void main() {
|
||||
return MGB_CTX->GetRenderStateParameters().SampleMaskValue;
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount,
|
||||
VkSampleCountFlagBits rasterizationSamples) const {
|
||||
// One bulk fetch instead of ~17 per-field accessor calls into MG_State: every
|
||||
@@ -4908,6 +5093,23 @@ void main() {
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
Uint64 VulkanRenderer::ComputePipelineSubsetStateHashFallback() const {
|
||||
// The client's own hash, over the client's own definition of the pipeline subset - the
|
||||
// seven pipeline chunks of the P2 chunk table, which is a strict SUPERSET of what
|
||||
// ComputePipelineStateHash enumerated by hand. The render-pass facts it does not carry
|
||||
// (colorAttachmentCount, the rasterization sample count, and through them the effective
|
||||
// sample mask) are exactly the facts entry.renderPassHash separates, which is why the
|
||||
// CSO handle can key this memo in the first place; this fallback inherits that argument
|
||||
// unchanged.
|
||||
//
|
||||
// Only reached with no render-state CSO bound, and only in a build with no pre-handle
|
||||
// arm to fall back to instead.
|
||||
return MG_Pipe::MGPipeComputePipelineSubsetHash(MGB_CTX->GetRenderStateParameters());
|
||||
}
|
||||
#endif
|
||||
|
||||
// A program that runs a geometry shader AND captures transform feedback. Both halves are
|
||||
// link-time properties, so this is safe to fold into a pipeline keyed on the program hash.
|
||||
@@ -4983,6 +5185,25 @@ void main() {
|
||||
// per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds.
|
||||
// The version only guards recomputing the hash - unchanged version, unchanged bytes.
|
||||
const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.1. Non-null means the client's render-state CSO handle is this draw's state
|
||||
// key and the hash below is not computed at all; null means the pre-handle arm. The
|
||||
// two arms' entries can never match each other: the handle arm stores hash 0 and a
|
||||
// real handle, the legacy arm a real hash and the null handle, and the probe compares
|
||||
// both components.
|
||||
const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso();
|
||||
// Exactly one of the two state keys is live per draw, and the ternary short-circuits,
|
||||
// so a draw on the handle arm neither hashes nor touches the fallback cache.
|
||||
const Uint64 pipelineStateHash =
|
||||
!MG_Pipe::MGPipeHandleIsNull(renderStateCso)
|
||||
? 0
|
||||
: ResolveFallbackPipelineStateHash(renderStateVersion,
|
||||
renderPassEntry.colorAttachmentCount,
|
||||
renderPassEntry.sampleCount);
|
||||
#else
|
||||
// THE PULL BUILD'S TEXT, statement for statement what the base ref has: G1 admits no
|
||||
// resize of this function, and a helper the compiler merely inlines is not the same
|
||||
// instruction schedule.
|
||||
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
|
||||
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount ||
|
||||
m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) {
|
||||
@@ -4994,12 +5215,16 @@ void main() {
|
||||
m_pipelineStateHashValid = true;
|
||||
}
|
||||
const Uint64 pipelineStateHash = m_pipelineStateHash;
|
||||
#endif
|
||||
for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) {
|
||||
const PipelineMemoEntry& entry = m_pipelineMemo[i];
|
||||
if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode &&
|
||||
entry.programHash == programObj.hash && entry.vertexInputHash == vertexLayoutHash &&
|
||||
entry.renderPassHash == renderPassHash &&
|
||||
entry.pipelineStateHash == pipelineStateHash &&
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
entry.renderStateCso == renderStateCso &&
|
||||
#endif
|
||||
entry.primitiveRestartEnable == primitiveRestartEnable &&
|
||||
entry.transformFlags == transformFlags) {
|
||||
if (MG_Util::PipeStats::Enabled()) {
|
||||
@@ -5668,6 +5893,9 @@ void main() {
|
||||
entry.vertexInputHash = vertexLayoutHash;
|
||||
entry.renderPassHash = renderPassHash;
|
||||
entry.pipelineStateHash = pipelineStateHash;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
entry.renderStateCso = renderStateCso;
|
||||
#endif
|
||||
entry.primitiveRestartEnable = primitiveRestartEnable;
|
||||
entry.transformFlags = transformFlags;
|
||||
entry.pipeline = pipeline;
|
||||
@@ -5917,6 +6145,21 @@ void main() {
|
||||
// One compare for the whole tail: see the gate's declaration in
|
||||
// DynamicStateShadow for why (version, extent, default-FBO flag) pins every
|
||||
// input the six Apply* below read.
|
||||
//
|
||||
// P2 D12.3: this read is RE-SOURCED, not re-shaped. Under MOBILEGL_PIPE_PUSH the
|
||||
// accessor no longer walks into GLContext's RenderState - it returns
|
||||
// PipeInputs::m_renderStateParametersVersion, which the applier publishes from
|
||||
// MGPDynamicState::Version (set_dynamic_state) and MGPBindRenderState::Version
|
||||
// (bind_render_state). So the gate now reads what the client PUSHED.
|
||||
//
|
||||
// What it does NOT do, and the P2 brief expects it to, is stop moving on a
|
||||
// pipeline-only change. The tree settles that against the brief: bind_render_state
|
||||
// carries m_version too and the applier publishes it, and it has to - Espryt's
|
||||
// SyncRenderState uses the very same counter as its all-state change detector and G5
|
||||
// forbids touching it, so a bind that rewrote the pipeline half while leaving the
|
||||
// counter still would make Espryt skip re-syncing the blend state it just changed.
|
||||
// The second-level DynamicTailKey compare below is therefore what actually absorbs a
|
||||
// pipeline-only change, exactly as it did before P2: one key build, no vkCmd*.
|
||||
const Uint paramsVersion = MGB_CTX->GetRenderStateParametersVersion();
|
||||
if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion &&
|
||||
shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() &&
|
||||
@@ -6104,9 +6347,23 @@ void main() {
|
||||
// draw of a VAO-cycling stream (Minecraft chunk rendering) through the full
|
||||
// path, re-resolving descriptors and texture layouts nothing invalidated.
|
||||
const auto& vao = *MGB_CTX->GetBoundVertexArray();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.4: the handle replaces the (address, lifetime id) pair here too - one
|
||||
// compare instead of two, and the same identity the VAO draw memo is keyed on, so
|
||||
// the two cannot disagree about whether "the VAO moved". The config version stays:
|
||||
// it answers a different question (did this same object's layout change).
|
||||
const Bool vaoMoved =
|
||||
MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)
|
||||
? (!(ResolveVaoHandle(vao) == snap.vaoHandle) ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion)
|
||||
: (static_cast<const void*>(&vao) != snap.vao ||
|
||||
vao.GetLifetimeId() != snap.vaoLifetimeId ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion);
|
||||
#else
|
||||
const Bool vaoMoved =
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
#endif
|
||||
const auto& drawFbo =
|
||||
MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
|
||||
@@ -6194,7 +6451,7 @@ void main() {
|
||||
Uint64 auxMasks = 0;
|
||||
Bool factsKnown = false;
|
||||
Uint64 contentHash = 0;
|
||||
if (vao.GetBackendHashMemo(contentHash)) {
|
||||
if (VaoContentHashIfKnown(vao, contentHash)) {
|
||||
const VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao);
|
||||
if (vaoMemo->layoutFactsValid && vaoMemo->contentHash == contentHash) {
|
||||
vaoLayoutHash = vaoMemo->layoutHash;
|
||||
@@ -6211,7 +6468,7 @@ void main() {
|
||||
auxMasks = VertexInputStateFactory::PackVertexInputAuxMasks(
|
||||
vertexInputState.unsupportedAttribMask, vertexInputState.attributeLocationMask);
|
||||
Uint64 stampedHash = 0;
|
||||
if (vao.GetBackendHashMemo(stampedHash)) {
|
||||
if (VaoContentHashIfKnown(vao, stampedHash)) {
|
||||
VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao);
|
||||
vaoMemo->contentHash = stampedHash;
|
||||
vaoMemo->layoutHash = vaoLayoutHash;
|
||||
@@ -6332,6 +6589,18 @@ void main() {
|
||||
// what lets a per-draw GL_BLEND toggle alternate between two memo entries
|
||||
// instead of missing forever on a monotonic version. A miss falls through
|
||||
// to the full lookup.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Same arm selector as GetOrCreatePipeline's probe (P2 D12.1); this site is the
|
||||
// fast path's copy of it, and the two must key identically or the fast path would
|
||||
// hand back a pipeline the full path would not have matched.
|
||||
const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso();
|
||||
const Uint64 pipelineStateHash =
|
||||
!MG_Pipe::MGPipeHandleIsNull(renderStateCso)
|
||||
? 0
|
||||
: ResolveFallbackPipelineStateHash(renderStateVersion, snap.renderPassColorCount,
|
||||
snap.renderPassSampleCount);
|
||||
#else
|
||||
// The pull build's text, statement for statement (see GetOrCreatePipeline).
|
||||
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
|
||||
m_pipelineStateHashColorCount != snap.renderPassColorCount ||
|
||||
m_pipelineStateHashSampleCount != snap.renderPassSampleCount) {
|
||||
@@ -6342,6 +6611,8 @@ void main() {
|
||||
m_pipelineStateHashSampleCount = snap.renderPassSampleCount;
|
||||
m_pipelineStateHashValid = true;
|
||||
}
|
||||
const Uint64 pipelineStateHash = m_pipelineStateHash;
|
||||
#endif
|
||||
const auto memoTransformFlags =
|
||||
ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags);
|
||||
for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) {
|
||||
@@ -6349,7 +6620,10 @@ void main() {
|
||||
if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode &&
|
||||
entry.programHash == programObj.hash && entry.vertexInputHash == vaoLayoutHash &&
|
||||
entry.renderPassHash == snap.renderPassHash &&
|
||||
entry.pipelineStateHash == m_pipelineStateHash &&
|
||||
entry.pipelineStateHash == pipelineStateHash &&
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
entry.renderStateCso == renderStateCso &&
|
||||
#endif
|
||||
entry.primitiveRestartEnable == drawPrimitiveRestartEnable &&
|
||||
entry.transformFlags == memoTransformFlags) {
|
||||
pipeline = entry.pipeline;
|
||||
@@ -6383,6 +6657,15 @@ void main() {
|
||||
snap.bindGeneration = bindGeneration;
|
||||
snap.vao = static_cast<const void*>(&vao);
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Guarded by the SUBSYSTEM, not only by the build switch: with bit 6 clear the field
|
||||
// is dead (vaoMoved takes the address/lifetime-id branch), and minting a handle for it
|
||||
// would put this package's cost inside MOBILEGL_PIPE_PUSH=0 - the all-pull control arm
|
||||
// D14 defines as reproducing P1 exactly, and the arm D.4.3's T2 is measured on.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
snap.vaoHandle = ResolveVaoHandle(vao);
|
||||
}
|
||||
#endif
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.vaoLayoutHash = vaoLayoutHash;
|
||||
snap.pipeline = pipeline;
|
||||
@@ -6964,6 +7247,13 @@ void main() {
|
||||
snap.programVersion = program.GetBackendStateVersion();
|
||||
snap.vao = &vao;
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Subsystem-guarded for the same reason as the other stamping site: the field
|
||||
// is dead with bit 6 clear, and MOBILEGL_PIPE_PUSH=0 has to be P1 exactly.
|
||||
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
|
||||
snap.vaoHandle = ResolveVaoHandle(vao);
|
||||
}
|
||||
#endif
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.drawFboLifetimeId = drawFbo->GetLifetimeId();
|
||||
@@ -12524,6 +12814,14 @@ void main() {
|
||||
if (m_vertexInputStateFactory) {
|
||||
m_vertexInputStateFactory->OnFrameBoundary();
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Reclaim {slot, gen} for objects that have not been drawn for a long time, on the same
|
||||
// cadence and the same retirement age as the entries those slots key. This is the
|
||||
// stand-in for the frontend death notification P2 has no hook for, and it is what keeps
|
||||
// the mint's footprint the LIVE working set rather than every object ever created
|
||||
// (review v2 MAJOR 1 / MAJOR 3).
|
||||
m_pipeIdentity.OnFrameBoundary();
|
||||
#endif
|
||||
if (m_samplerManager) {
|
||||
m_samplerManager->OnFrameBoundary();
|
||||
}
|
||||
@@ -12889,6 +13187,9 @@ void main() {
|
||||
InvalidateSetupDrawSnapshots();
|
||||
}
|
||||
m_vertexInputStateFactory->OnFrameBoundary();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
m_pipeIdentity.OnFrameBoundary();
|
||||
#endif
|
||||
m_samplerManager->OnFrameBoundary();
|
||||
auto& frame = m_frameContext.GetCurrent();
|
||||
auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#pragma once
|
||||
#include "Config.h"
|
||||
#include "FrameContext.h"
|
||||
#include "MagmaPipeArms.h"
|
||||
#include "PipelineFactory.h"
|
||||
#include "ProgramFactory.h"
|
||||
#include "SwapchainObject.h"
|
||||
@@ -24,7 +25,13 @@
|
||||
#include "MG_Util/Math/VectorTypes.h"
|
||||
#include <Includes.h>
|
||||
#include <MG_Backend/BackendObject.h>
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The applier's CSO store: MGPipeApplier().BoundRenderStateCso is what the pipeline memo
|
||||
// keys on after P2 (D12.1). Push-only, so the pull build's include graph is unchanged.
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#endif
|
||||
#include <vk_mem_alloc.h>
|
||||
|
||||
#include "../VkIncludes.h"
|
||||
@@ -820,12 +827,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 programHash = 0;
|
||||
Uint64 vertexInputHash = 0;
|
||||
Uint64 renderPassHash = 0;
|
||||
// VALUE hash of the pipeline-relevant fixed-function state (see
|
||||
// ComputePipelineStateHash), not the monotonic pipeline-state version:
|
||||
// the version never repeats, so a per-draw GL_BLEND toggle would miss
|
||||
// all entries forever even though the state alternates between two
|
||||
// values the memo already holds.
|
||||
// The PRE-HANDLE arm's key component (P2 brief D12.1), and 0 in every entry the
|
||||
// handle arm mints. VALUE hash of the pipeline-relevant fixed-function state (see
|
||||
// ComputePipelineStateHash), not the monotonic pipeline-state version: the version
|
||||
// never repeats, so a per-draw GL_BLEND toggle would miss all entries forever even
|
||||
// though the state alternates between two values the memo already holds.
|
||||
Uint64 pipelineStateHash = 0;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The HANDLE arm's key component, and the whole of D12.1: the CLIENT already
|
||||
// hashed the pipeline subset of RenderStateParameters and minted a content-
|
||||
// addressed CSO for it (MG_Pipe/MGPipeRenderStateSpans.h, MG_Impl/Pipe/CsoCache),
|
||||
// so re-hashing the same 396 bytes here was work the boundary had already done.
|
||||
// Two draws share a CSO handle exactly when their pipeline bytes are equal, and
|
||||
// the client's subset is a strict SUPERSET of what ComputePipelineStateHash read,
|
||||
// so the handle discriminates at least as finely as the hash it replaces.
|
||||
//
|
||||
// renderPassHash STAYS beside it and is what keeps this key complete: the CSO
|
||||
// carries GL state only, while colorAttachmentCount and the rasterization sample
|
||||
// count - which ComputePipelineStateHash folded in through its signature and
|
||||
// through ResolveEffectiveSampleMask - are render-pass facts that the render-pass
|
||||
// hash already separates.
|
||||
//
|
||||
// Null in an entry minted by the legacy arm, so entries of the two arms can never
|
||||
// match each other: the compare below tests BOTH components.
|
||||
MG_Pipe::MGPipeHandle renderStateCso = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
ProgramFactory::CompileOptionFlags transformFlags = {};
|
||||
// Baked into the pipeline (PipelineFactory::ComputeHash mixes it), and NOT derivable
|
||||
// from anything else in this key: it depends on whether the draw is indexed and on the
|
||||
@@ -839,19 +865,142 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize];
|
||||
Uint32 m_pipelineMemoCount = 0;
|
||||
Uint32 m_pipelineMemoNext = 0;
|
||||
// Hash of every fixed-function GL state the pipeline payload reads that the
|
||||
// memo key's other fields (mode / program / vertex input / render pass /
|
||||
// transform flags) do not already pin down. Equal hash under an equal rest
|
||||
// of key => byte-identical PipelineCreatePayload. Cached per pipeline-state
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.1's arm selector, and the whole of the pipeline memo's re-key. Returns the
|
||||
// render-state CSO this draw is keyed on, or the null handle when the pre-handle arm
|
||||
// is the one that runs.
|
||||
//
|
||||
// Under the handle arm the memo's state key IS this handle. The client hashed those
|
||||
// 396 pipeline bytes when it minted the CSO (MGPipeComputePipelineSubsetHash), so
|
||||
// recomputing an overlapping hash here was work the boundary had already done; the
|
||||
// client's pipeline subset is a strict SUPERSET of what ComputePipelineStateHash read,
|
||||
// so the handle discriminates at least as finely as the hash it replaces. What the
|
||||
// handle does NOT carry is the render-pass side - colorAttachmentCount and the
|
||||
// rasterization sample count, which ComputePipelineStateHash folded in through its
|
||||
// signature and through ResolveEffectiveSampleMask - and that is exactly why
|
||||
// entry.renderPassHash stays in the key beside it.
|
||||
//
|
||||
// The arm is live only when the render-state subsystem is migrated in this run AND the
|
||||
// client has actually bound a CSO. The second half is not belt and braces: a tree whose
|
||||
// tracker does not emit create/bind_render_state yet has no handle to key on, and
|
||||
// delete_render_state clears the binding (MG_Pipe/PipeApply.cpp), so the null handle is
|
||||
// reachable on any tree. Keying every draw on it would alias every render state onto
|
||||
// one memo entry, so a null handle means "fall back to a state hash" - never an abort,
|
||||
// and never a per-draw consultation of the legacy-memo lever: bit 0 is not a Track-H
|
||||
// subsystem (D14 labels only bits 5 and 6 that), and the lever's Fatal is a STARTUP
|
||||
// one, in MagmaPipeValidateSubsystemConfiguration.
|
||||
//
|
||||
// The fallback is warned ONCE rather than logged at debug, and that is deliberate: a
|
||||
// silent fallback is what makes "the CSO arm never ran" easy to miss. W is compiled in
|
||||
// at every shipped log level.
|
||||
//
|
||||
// The latch is a plain member bool, NOT MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL
|
||||
// (MG_Util/Debug/Log.h) is an UNCONDITIONAL std::atomic_flag::test_and_set - a locked
|
||||
// xchg, executed on every evaluation, not "one static bool test" as an earlier round of
|
||||
// this comment claimed - and this site is on the per-draw pipeline path in the very
|
||||
// configuration that reaches it (no tracker: every draw). ROADMAP.md:7 forbids leaving
|
||||
// instrumentation on a hot path, so the once-ness is one non-atomic, always-predicted
|
||||
// load of a member that is false exactly once. Single-threaded like the rest of the
|
||||
// renderer, and per renderer rather than per process, which is also the right scope: a
|
||||
// second context that never binds a CSO deserves to say so.
|
||||
//
|
||||
// What the absence of this warning from a run's log proves, EXACTLY: that no draw took
|
||||
// the fallback WHILE bit 0 was set. With kMGPipeSubsystemRenderState clear the function
|
||||
// returns before the latch, so absence proves nothing at all - and no draw is keyed on a
|
||||
// handle either. Grep the mask out of the log beside it (review v2 minor 3).
|
||||
//
|
||||
// Push-only by construction: the pull build does not compile this function at all, so
|
||||
// its two callers are statement-for-statement what they were (G1).
|
||||
//
|
||||
// [routed to the integrator, review v2 minor 11] MG_Pipe::MGPipeApplier() is ONE
|
||||
// process-global applier (MG_Pipe/PipeApply.cpp), not the per-context CSO store D2
|
||||
// specifies. In a multi-context process this reads whatever CSO another context last
|
||||
// bound. The defect is package A's and the fix belongs there; Magma is its only P2
|
||||
// consumer, so it is named here rather than left for both reviews to assume the other
|
||||
// caught it.
|
||||
MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const {
|
||||
if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) {
|
||||
return MG_Pipe::kMGPipeNullHandle;
|
||||
}
|
||||
const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso;
|
||||
if (MG_Pipe::MGPipeHandleIsNull(boundCso) && !m_pipelineCsoFallbackWarned) {
|
||||
m_pipelineCsoFallbackWarned = true;
|
||||
MGLOG_W("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is "
|
||||
"bound; the pipeline memo is running on a state hash, not on the CSO "
|
||||
"handle (no tracker on this build, or a draw between "
|
||||
"delete_render_state and the next bind)");
|
||||
}
|
||||
return boundCso;
|
||||
}
|
||||
// Latch for the warning above. Mutable because the resolve is const and the latch is
|
||||
// not part of the renderer's observable state.
|
||||
mutable Bool m_pipelineCsoFallbackWarned = false;
|
||||
// The memo key's STATE-HASH half, for a draw that has no CSO handle to key on: the
|
||||
// pre-handle arm, and the fallback of D12.1's handle arm. Cached on the pipeline-state
|
||||
// version plus the two render-pass facts the hash's inputs depend on, so an unchanged
|
||||
// (version, colorAttachmentCount, sampleCount) proves the bytes are unchanged.
|
||||
//
|
||||
// [deviation from D12.1] The brief deletes this gate and its cached fields outright.
|
||||
// They cannot go while a no-CSO draw is reachable - and it is, on any tree: a draw
|
||||
// between delete_render_state and the next bind has no handle. On a tree whose tracker
|
||||
// binds a CSO these five words are written once and never read again; they retire for
|
||||
// real when the pull path does, at P13.
|
||||
Uint64 ResolveFallbackPipelineStateHash(Uint renderStateVersion, Uint32 colorAttachmentCount,
|
||||
VkSampleCountFlagBits rasterizationSamples) {
|
||||
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
|
||||
m_pipelineStateHashColorCount != colorAttachmentCount ||
|
||||
m_pipelineStateHashSampleCount != rasterizationSamples) {
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
m_pipelineStateHash =
|
||||
ComputePipelineStateHash(colorAttachmentCount, rasterizationSamples);
|
||||
#else
|
||||
m_pipelineStateHash = ComputePipelineSubsetStateHashFallback();
|
||||
#endif
|
||||
m_pipelineStateHashVersion = renderStateVersion;
|
||||
m_pipelineStateHashColorCount = colorAttachmentCount;
|
||||
m_pipelineStateHashSampleCount = rasterizationSamples;
|
||||
m_pipelineStateHashValid = true;
|
||||
}
|
||||
return m_pipelineStateHash;
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// The same answer as ComputePipelineStateHash, computed from the P2 chunk table
|
||||
// instead of from a hand-written field list, for the build that compiles no
|
||||
// pre-handle arm (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF). It is the CLIENT's own
|
||||
// hash function - MGPipeComputePipelineSubsetHash over the 396 pipeline bytes - so a
|
||||
// draw keyed on it and a draw keyed on a CSO handle are keyed on the same equivalence
|
||||
// class of state, and the render-pass facts stay separated by renderPassHash either
|
||||
// way. This is what makes the no-legacy build RUNNABLE rather than a configuration
|
||||
// that aborts on the first draw that arrives without a CSO.
|
||||
Uint64 ComputePipelineSubsetStateHashFallback() const;
|
||||
#endif
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// THE PRE-HANDLE ARM (P2 brief D12.1 / D14). Hash of every fixed-function GL state the
|
||||
// pipeline payload reads that the memo key's other fields (mode / program / vertex
|
||||
// input / render pass / transform flags) do not already pin down. Equal hash under an
|
||||
// equal rest of key => byte-identical PipelineCreatePayload. Cached per pipeline-state
|
||||
// version: the version is monotonic and bumps on every pipeline-state
|
||||
// change, so an unchanged (version, colorAttachmentCount) proves the state
|
||||
// bytes are unchanged and the hash can be reused without re-reading them.
|
||||
//
|
||||
// The handle arm computes none of this: the client hashed the same bytes when it
|
||||
// minted the CSO, so all five cached-hash members below exist only to avoid a
|
||||
// re-hash the handle arm never performs.
|
||||
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount,
|
||||
VkSampleCountFlagBits rasterizationSamples) const;
|
||||
#endif
|
||||
// The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see
|
||||
// the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload
|
||||
// and the pipeline-state memo word so the two cannot disagree.
|
||||
// and the pipeline-state memo word so the two cannot disagree. NOT part of the legacy
|
||||
// arm: it is a PAYLOAD computation that depends on rasterizationSamples, so it survives
|
||||
// the re-key and keeps reading Multisample / SampleMask / SampleMaskValue out of the
|
||||
// working block.
|
||||
Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const;
|
||||
// ResolveFallbackPipelineStateHash's cache. Written once and never read again on a
|
||||
// build whose client binds a render-state CSO; see that function for why it survives
|
||||
// the re-key at all.
|
||||
Uint m_pipelineStateHashVersion = 0;
|
||||
Uint32 m_pipelineStateHashColorCount = 0;
|
||||
// The sample count the cached hash was computed at. A pipeline-state input now depends on
|
||||
@@ -877,7 +1026,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// Drops every memoized pipeline handle. Required at command-buffer
|
||||
// boundaries and whenever any pipeline may have been destroyed. Also drops
|
||||
// the cached pipeline-state hash: the same boundaries can retire the GL
|
||||
// context whose monotonic version the cache is keyed on.
|
||||
// context whose monotonic version the cache is keyed on. The handle arm has no
|
||||
// such cache to drop - a CSO handle is not derived from a monotonic version.
|
||||
void InvalidatePipelineMemo() {
|
||||
m_pipelineMemoCount = 0;
|
||||
m_pipelineMemoNext = 0;
|
||||
@@ -968,6 +1118,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// common shape), and "the VAO did not move" would then skip the layout
|
||||
// re-resolve for a different VAO.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.4: the handle arm's answer to the same question, and one compare rather
|
||||
// than the pair above. Kept BESIDE them rather than replacing them because the
|
||||
// pre-handle arm is still compiled (MOBILEGL_PIPE_LEGACY_MEMOS) and this snapshot
|
||||
// is a value struct, not a wire type.
|
||||
MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
Uint32 vaoConfigVersion = 0;
|
||||
const void* drawFbo = nullptr;
|
||||
// Never-reused lifetime id beside the raw pointer + Uint16 version: a
|
||||
@@ -1242,6 +1399,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// - bindings revalidates per draw exactly as before (frame serial, content
|
||||
// hash, per-binding live buffer pointers and slice epochs).
|
||||
struct alignas(64) VaoDrawMemo {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 D12.4: the handle arm's key, and the ONLY key it needs. {slot, gen} is an
|
||||
// identity, so the pointer-plus-lifetime-id pair below stops being a key here;
|
||||
// the slot also picks the table entry, so the address hash and the two-way probe
|
||||
// go with it. Null in an entry that has never been claimed.
|
||||
MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle;
|
||||
#endif
|
||||
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
|
||||
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
|
||||
// ALONE is not an identity: a deleted VAO's heap address is handed straight
|
||||
@@ -1268,8 +1432,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer
|
||||
// stable for the duration of a draw, which the EBO memo handoff
|
||||
// (m_currentDrawResolvedEntry) relies on.
|
||||
//
|
||||
// [deviation from D12.4, deliberate and narrow] The brief asks for a grow-on-demand
|
||||
// Vector. This one stays FIXED at exactly the capacity and exactly the 2-way victim
|
||||
// rule it has on the base ref, and only its KEY changes (a {slot, gen} handle instead
|
||||
// of a hashed heap address plus a lifetime id). Two reasons, and the second is the
|
||||
// whole of review v2's MAJOR 1:
|
||||
// * a VaoDrawMemo is ~450 B (ResolvedVertexBindings dominates), so growing this
|
||||
// table with the live VAO set is megabytes on a platform with an LMK, where the
|
||||
// other two memos are 48 B and can afford it;
|
||||
// * this is the ONLY one of the three memos that had a capacity before this package.
|
||||
// Losing an entry here costs a vertex-binding re-resolve, exactly what losing it
|
||||
// cost on the base ref, so at any working-set size this table is no worse than what
|
||||
// it replaces - and strictly better below capacity, where the handle is a bijection
|
||||
// with the slot and the two-way probe never collides at all. The other two memos
|
||||
// (VertexInputStateFactory::m_vaoMemos) had NO capacity, so they keep having none.
|
||||
static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two
|
||||
Vector<VaoDrawMemo> m_vaoDrawMemoTable;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The renderer's {slot, gen} mint, shared with its VertexInputStateFactory so both
|
||||
// derive the same handle for the same VAO. Per renderer, never a process-global: a
|
||||
// global would share one table and one reclamation clock across two live contexts and
|
||||
// outlive every one of them (review v2 minor 4).
|
||||
MagmaPipeIdentityTables m_pipeIdentity;
|
||||
// The VAO's {slot, gen}. A one-entry memo hit for every acquisition after a draw's
|
||||
// first, so there is no second memo in front of it here.
|
||||
MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) {
|
||||
return m_pipeIdentity.HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso,
|
||||
vao.GetLifetimeId());
|
||||
}
|
||||
#endif
|
||||
// "Is this VAO's content hash already memoized?", asked of whichever side owns the
|
||||
// memo (P2 D12.5). Force-inlined and defined in the class body so that the PULL
|
||||
// build's three readers keep compiling to the very same two loads they always did -
|
||||
// G1 admits no resize, and an out-of-line call here would be one.
|
||||
[[gnu::always_inline]] inline Bool VaoContentHashIfKnown(
|
||||
const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
return m_vertexInputStateFactory->TryGetMemoizedHash(vao, outHash);
|
||||
#else
|
||||
return vao.GetBackendHashMemo(outHash);
|
||||
#endif
|
||||
}
|
||||
// Finds the slot holding `vao`, or recycles the older of its two candidate
|
||||
// slots into an empty memo keyed on `vao`. Never returns null.
|
||||
VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao);
|
||||
|
||||
@@ -609,6 +609,13 @@ namespace MobileGL::MG_Pipe {
|
||||
// The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp):
|
||||
// the filler's per-field copies and stamps, and the verify snapshot.
|
||||
friend struct MGPipeFillAccess;
|
||||
// The other door, and the one that exists because of what this block IS after P2:
|
||||
// the server's working RenderStateParameters. MG_Pipe/PipeApply.cpp scatters
|
||||
// bind_render_state's and set_dynamic_state's chunks straight into m_renderState,
|
||||
// which is why DirectGLES' SyncRenderState is not one line changed. It deliberately
|
||||
// does NOT stamp the poison generations - a stamp says "the filler published this
|
||||
// for THIS verb", which is the walk's statement, not the applier's.
|
||||
friend struct MGPipeApplyAccess;
|
||||
|
||||
// ---- identity ----
|
||||
const void* m_contextIdentity = nullptr;
|
||||
|
||||
@@ -11,5 +11,51 @@ endif()
|
||||
add_executable(DriverBench DriverBench.c)
|
||||
target_link_libraries(DriverBench PRIVATE dl)
|
||||
|
||||
# WHY EVERY ENTRY HERE CARRIES A PASS_REGULAR_EXPRESSION.
|
||||
#
|
||||
# DriverBench prints one CSV row per case it ran and exits 0 whatever it ran. Before this, a ctest
|
||||
# entry naming a case therefore could not answer the only question it exists to ask: an argument
|
||||
# matching nothing in kBenchCases selected no case, printed only the header row, and still exited
|
||||
# 0. DriverBench.c now refuses an unknown case name (exit 2), which closes it at the source - but
|
||||
# the entry must be able to go red for the reason it exists WITHOUT depending on that check
|
||||
# staying in the binary, so each entry also requires the case's own output row to appear.
|
||||
#
|
||||
# The regex is what a healthy run of that case prints and nothing else does: the case name at the
|
||||
# start of a line, then the frames / ops-per-frame / median-ms / ns-per-op / fps columns
|
||||
# (run_case()). A rename, a drop from kBenchCases, a boot_egl() failure or
|
||||
# a crash part-way through the case all remove that row and turn the entry red.
|
||||
#
|
||||
# Note that a PASS_REGULAR_EXPRESSION makes ctest ignore the process exit code (cmCTestRunTest:
|
||||
# success is `retVal == 0 || !RequiredRegularExpressions.empty()`), which is why the row itself
|
||||
# has to be the evidence rather than a companion to the rc.
|
||||
add_test(NAME DriverBench COMMAND DriverBench draw_tiny)
|
||||
set_tests_properties(DriverBench PROPERTIES LABELS benchmark)
|
||||
# draw_tiny's a/ops scale with $DRIVERBENCH_DRAWS (main()), so only the shape of
|
||||
# the row is pinned here, not the column values.
|
||||
set_tests_properties(DriverBench PROPERTIES
|
||||
LABELS benchmark
|
||||
PASS_REGULAR_EXPRESSION "(^|\n)draw_tiny,[0-9]+,[0-9]+,[0-9.]+,[0-9.]+,[0-9.]+")
|
||||
|
||||
# The Blaze3D blend toggle, as its own entry.
|
||||
#
|
||||
# mc_state_toggle is glEnable(GL_BLEND) / glBlendFuncSeparate / glDrawElements /
|
||||
# glDisable(GL_BLEND) / glDrawElements, 46 times - the measured vanilla-frame rate, and the exact
|
||||
# shape ROADMAP.md writes down as the microbenchmark P2 owes the GO/NO-GO. It is the workload the
|
||||
# whole "push at validate, not in the setter" decision was made for: a per-setter design pays for
|
||||
# every toggle, and a CSO that is minted twice and then reused pays for none of them.
|
||||
#
|
||||
# The case has existed in kBenchCases since P0 and nothing ran it, so nothing noticed if it broke.
|
||||
# Exposing it costs about 1.2 s inside an existing three-minute job, and it means the number the
|
||||
# P2 report quotes comes from a case CI has been executing all along rather than from a code path
|
||||
# whose first run is the day it is measured.
|
||||
#
|
||||
# Like the entry above, this runs against whatever $DRIVERBENCH_EGL_LIB names (the system driver
|
||||
# when unset) - the ctest entry is a "does this case still run" gate, not the measurement. The
|
||||
# measurement is run_driver_bench.sh against each of {native, espryt, magma}.
|
||||
add_test(NAME DriverBenchStateToggle COMMAND DriverBench mc_state_toggle)
|
||||
# The ops-per-frame column is pinned to 46 here, unlike the entry above: the mc_* cases are
|
||||
# excluded from the $DRIVERBENCH_DRAWS scaling on purpose ("the mc_* rates are measured and must
|
||||
# not move, or the numbers stop being comparable", main()), so 46 toggles per frame
|
||||
# is part of what "this case still runs" means. Change the workload and this entry says so.
|
||||
set_tests_properties(DriverBenchStateToggle PROPERTIES
|
||||
LABELS benchmark
|
||||
PASS_REGULAR_EXPRESSION "(^|\n)mc_state_toggle,[0-9]+,46,[0-9.]+,[0-9.]+,[0-9.]+")
|
||||
|
||||
@@ -476,6 +476,28 @@ int main(int argc, char** argv) {
|
||||
if (getenv("DRIVERBENCH_FRAMES")) g_frames = atoi(getenv("DRIVERBENCH_FRAMES"));
|
||||
if (getenv("DRIVERBENCH_SPRITES")) g_mixSprites = atol(getenv("DRIVERBENCH_SPRITES"));
|
||||
|
||||
/* A requested case name that matches nothing used to select nothing, print the header row and
|
||||
* exit 0 - so a caller that names a case (run_driver_bench.sh, and the two ctest entries in
|
||||
* CMakeLists.txt) could not tell "the case ran" from "the case has been renamed or deleted".
|
||||
* Refuse it here, before any GL work, so the refusal reaches a caller that has no display
|
||||
* either, and name what does exist so the fix is obvious. */
|
||||
int unknownCases = 0;
|
||||
for (int j = 1; j < argc; ++j) {
|
||||
int known = 0;
|
||||
for (int i = 0; i < kBenchCaseCount; ++i)
|
||||
if (strcmp(argv[j], kBenchCases[i].name) == 0) known = 1;
|
||||
if (!known) {
|
||||
fprintf(stderr, "DriverBench: no case named '%s'\n", argv[j]);
|
||||
unknownCases = 1;
|
||||
}
|
||||
}
|
||||
if (unknownCases) {
|
||||
fprintf(stderr, "DriverBench: the %d cases in kBenchCases are:\n", kBenchCaseCount);
|
||||
for (int i = 0; i < kBenchCaseCount; ++i)
|
||||
fprintf(stderr, " %s\n", kBenchCases[i].name);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (boot_egl()) return 1;
|
||||
build_resources();
|
||||
|
||||
|
||||
@@ -659,6 +659,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
if (ConditionalRenderDiscardsCommand()) return;
|
||||
MGP_SET_BASE_INSTANCE(baseinstance);
|
||||
MGP_FILL(DrawElementsInstancedBaseVertexBaseInstance);
|
||||
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertexBaseInstance(
|
||||
mode, count, type, indices, instancecount, basevertex, baseinstance);
|
||||
@@ -681,6 +682,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
if (ConditionalRenderDiscardsCommand()) return;
|
||||
MGP_SET_BASE_INSTANCE(baseinstance);
|
||||
MGP_FILL(DrawElementsInstancedBaseInstance);
|
||||
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseInstance(mode, count, type, indices,
|
||||
instancecount, baseinstance);
|
||||
@@ -710,6 +712,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
if (ConditionalRenderDiscardsCommand()) return;
|
||||
MGP_SET_BASE_INSTANCE(baseinstance);
|
||||
MGP_FILL(DrawArraysInstancedBaseInstance);
|
||||
MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstancedBaseInstance(mode, first, count, instancecount,
|
||||
baseinstance);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/CsoCache.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 <Includes.h>
|
||||
|
||||
// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7).
|
||||
//
|
||||
// THE LOOKUP, and the first step is the whole point:
|
||||
// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing,
|
||||
// zero probing, and nothing is emitted unless m_version also moved. That is the steady
|
||||
// state of every frame, and it is why the tracker asks the cache at all only when the
|
||||
// dirty walk says the pipeline version moved.
|
||||
// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP
|
||||
// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an
|
||||
// xxHash; a bare 64-bit equality would let a collision alias two different render
|
||||
// states onto one CSO, which is silent wrong pixels with no gate that can see it.
|
||||
// Mesa's cso_cache memcmps for the same reason. The memcmp only ever runs on a
|
||||
// pipeline-version change, i.e. never in the steady state.
|
||||
// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind.
|
||||
//
|
||||
// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md
|
||||
// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64
|
||||
// and publishes the mint / bind / evict counters that retune reads.
|
||||
//
|
||||
// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime
|
||||
// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records:
|
||||
// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is
|
||||
// precisely "whole-block content addressing" and reproduces the regression
|
||||
// RenderState.h records. It is what separates "push is slower" from "the CSO design is
|
||||
// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops
|
||||
// the switch from rotting.
|
||||
//
|
||||
// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that
|
||||
// would name a new .cpp is package A's and is frozen behind the p2/contract tag.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/MGPipeRenderStateSpans.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_Util/Metrics/PipeStats.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
inline constexpr SizeT kMGPipeCsoCacheCapacity = 64;
|
||||
|
||||
class MGPipeCsoCache {
|
||||
public:
|
||||
struct Counters {
|
||||
Uint64 Mints = 0; // create_render_state emissions
|
||||
// bind_render_state emissions, mint or reuse. Counted in Acquire because Acquire
|
||||
// has exactly ONE caller (PipeFill.cpp's EmitRenderState) and that caller binds
|
||||
// immediately after every call - so "acquisitions" and "binds" are the same
|
||||
// number, and counting it here keeps the count from depending on an emitter
|
||||
// remembering to tick it. mints/binds is the cache's hit rate and it is the
|
||||
// number the CSO content-addressing negative control moves.
|
||||
Uint64 Binds = 0;
|
||||
Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp
|
||||
Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists
|
||||
Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state
|
||||
};
|
||||
|
||||
// The handle for `params`' pipeline subset. Mints and emits create_render_state on a
|
||||
// miss; emits delete_render_state for whatever it evicts to make room. `payloadBytes`
|
||||
// accumulates what went on the wire, for PipeStats::RecordDrawPayloadBytes.
|
||||
MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) {
|
||||
Array<Uint8, kMGPipePipelineChunkBytes> bytes;
|
||||
MGPipeGatherPipelineBytes(params, bytes.data());
|
||||
++m_counters.Binds;
|
||||
|
||||
const Bool contentAddressed =
|
||||
(MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0;
|
||||
if (contentAddressed) {
|
||||
const Uint64 hash = s_hashForTest != nullptr ? s_hashForTest(bytes.data())
|
||||
: MGPipeHashPipelineBytes(bytes.data());
|
||||
for (SizeT i = 0; i < m_entries.size(); ++i) {
|
||||
if (m_entries[i].Hash != hash) continue;
|
||||
if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) {
|
||||
// A 64-bit collision between two DIFFERENT render states. Reusing the
|
||||
// handle here would render one state with the other's pipeline, so the
|
||||
// entry is dropped and the caller mints - correctness first, and the
|
||||
// counter says how often it happened.
|
||||
++m_counters.Collisions;
|
||||
Evict(i);
|
||||
break;
|
||||
}
|
||||
m_entries[i].LastUsed = ++m_clock;
|
||||
++m_counters.Hits;
|
||||
return m_entries[i].Cso;
|
||||
}
|
||||
return Mint(hash, bytes, payloadBytes);
|
||||
}
|
||||
// Content addressing OFF: never probe, always mint. The records still exist, so
|
||||
// the arm differs from the default one in exactly one thing - whether a handle is
|
||||
// reused - which is what makes it a control rather than a different design.
|
||||
return Mint(0, bytes, payloadBytes);
|
||||
}
|
||||
|
||||
// Context teardown, a server reset, a unit test's fixture. Emits nothing: the applier
|
||||
// is reset alongside, and a delete for a record that is about to be dropped anyway
|
||||
// would be a wire message with no reader.
|
||||
void Reset() {
|
||||
for (auto& entry : m_entries) MGPipeSlots().Free(MGPipeKind::RenderStateCso, entry.Cso);
|
||||
m_entries.clear();
|
||||
m_clock = 0;
|
||||
}
|
||||
|
||||
void ResetCounters() { m_counters = Counters{}; }
|
||||
|
||||
SizeT Size() const { return m_entries.size(); }
|
||||
const Counters& GetCounters() const { return m_counters; }
|
||||
|
||||
// TEST SEAM, and it is here because the thing it tests cannot be reached any other
|
||||
// way. A 64-bit collision between two DIFFERENT render states is silent wrong pixels
|
||||
// and it is exactly what the memcmp confirm above exists to stop, so
|
||||
// CsoCacheTest.HashCollisionDoesNotAliasTwoStates has to be able to make one happen.
|
||||
// Null in every real build - one never-taken, perfectly-predicted branch on a path
|
||||
// that runs only when the pipeline version moved, i.e. never in the steady state.
|
||||
using HashForTestFn = Uint64 (*)(const void* pipelineBytes);
|
||||
inline static HashForTestFn s_hashForTest = nullptr;
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
Uint64 Hash = 0;
|
||||
Uint64 LastUsed = 0;
|
||||
MGPipeHandle Cso = kMGPipeNullHandle;
|
||||
Array<Uint8, kMGPipePipelineChunkBytes> Bytes{};
|
||||
};
|
||||
|
||||
MGPipeHandle Mint(Uint64 hash, const Array<Uint8, kMGPipePipelineChunkBytes>& bytes,
|
||||
Uint64& payloadBytes) {
|
||||
if (m_entries.size() >= kMGPipeCsoCacheCapacity) {
|
||||
SizeT victim = 0;
|
||||
for (SizeT i = 1; i < m_entries.size(); ++i) {
|
||||
if (m_entries[i].LastUsed < m_entries[victim].LastUsed) victim = i;
|
||||
}
|
||||
Evict(victim);
|
||||
}
|
||||
|
||||
const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::RenderStateCso);
|
||||
MGPRenderStateDesc desc{};
|
||||
desc.Cso = cso;
|
||||
desc.BaseCso = kMGPipeNullHandle;
|
||||
// A brand-new CSO names every pipeline chunk; the incremental form against a
|
||||
// BaseCso is what the applier's assertion allows and P3 will use once a CSO is
|
||||
// minted from a neighbour rather than from nothing.
|
||||
desc.ChunkMask = kAllPipelineChunks;
|
||||
desc.Blob.Size = kMGPipePipelineChunkBytes;
|
||||
MGPipeApplyCreateRenderState(desc, bytes.data());
|
||||
payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes;
|
||||
|
||||
Entry entry;
|
||||
entry.Hash = hash;
|
||||
entry.LastUsed = ++m_clock;
|
||||
entry.Cso = cso;
|
||||
entry.Bytes = bytes;
|
||||
m_entries.push_back(entry);
|
||||
|
||||
++m_counters.Mints;
|
||||
if (MG_Util::PipeStats::Enabled()) {
|
||||
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoMints, 1);
|
||||
}
|
||||
return cso;
|
||||
}
|
||||
|
||||
void Evict(SizeT index) {
|
||||
MGPHandleOnly handle{};
|
||||
handle.Handle = m_entries[index].Cso;
|
||||
handle.Kind = static_cast<Uint32>(MGPipeKind::RenderStateCso);
|
||||
MGPipeApplyDeleteRenderState(handle);
|
||||
MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso);
|
||||
m_entries[index] = m_entries.back();
|
||||
m_entries.pop_back();
|
||||
++m_counters.Evictions;
|
||||
}
|
||||
|
||||
static constexpr Uint32 kAllPipelineChunks =
|
||||
static_cast<Uint32>((Uint64{1} << kMGPipePipelineChunkCount) - 1);
|
||||
|
||||
Vector<Entry> m_entries;
|
||||
Uint64 m_clock = 0;
|
||||
Counters m_counters;
|
||||
};
|
||||
|
||||
// The monolith's one cache, held beside the tracker. A Vector scan rather than a hash
|
||||
// map on purpose: 64 entries of Uint64 is a handful of cache lines, it is probed only
|
||||
// when the pipeline version moved, and it keeps the eviction order in the same array as
|
||||
// the content - a map would need a second structure to answer "which is oldest".
|
||||
inline MGPipeCsoCache& MGPipeCsoCacheInstance() {
|
||||
static MGPipeCsoCache cache;
|
||||
return cache;
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,21 @@
|
||||
namespace MobileGL::MG_Pipe {
|
||||
struct PipeInputs;
|
||||
|
||||
// PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity,
|
||||
// and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out
|
||||
// of the live GLContext, stamping each with the new serial. In a verify build it then
|
||||
// runs the entry compare against a second snapshot (P1 brief D8).
|
||||
void MGPipeFillForVerb(MGPipeVerb verb);
|
||||
// PipeFill.cpp. THE VALIDATE POINT (ARCHITECTURE.md 5.1, P2 brief D1). In order:
|
||||
// 1. bump the per-verb serial, record the verb and the context identity;
|
||||
// 2. run the tracker's DIRTY WALK for this verb's class (MG_Impl/Pipe/Tracker.h);
|
||||
// 3. EMIT, for each set dirty bit whose subsystem bit is on in the runtime
|
||||
// MOBILEGL_PIPE_PUSH bitmask, the P2 call that carries it;
|
||||
// 4. run the P1 residual fill for every field an emitted call did NOT supply,
|
||||
// stamping each with the new serial exactly as before;
|
||||
// 5. in a verify build, the entry compare against a second snapshot (P1 brief D8) -
|
||||
// which stops being a tautology the moment step 3 supplies a field step 4 skips.
|
||||
//
|
||||
// It was MGPipeFillForVerb through P1, when steps 2 and 3 did not exist. The macro
|
||||
// spelling, the 83 call sites and the verb enum are unchanged: the dispatch is
|
||||
// kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor*
|
||||
// entry points with one call site per verb instead of nine.
|
||||
void MGPipeValidateForVerb(MGPipeVerb verb);
|
||||
|
||||
// Ends the verb in flight without starting another: bumps the serial, so every field the
|
||||
// verb stamped goes stale, and puts the current verb back to "none", so a read made after
|
||||
@@ -33,6 +43,36 @@ namespace MobileGL::MG_Pipe {
|
||||
// stop where it says it stops (MG_Test/ScopedPipeVerb.h).
|
||||
void MGPipeLeaveVerb();
|
||||
|
||||
// PipeFill.cpp. P3a D-H2.1: the DRAW's raw vertex-fetch base instance, which
|
||||
// set_vertex_buffers now carries as an explicit field.
|
||||
//
|
||||
// It replaces an ambient process global the backend read at VAO sync time, which is a
|
||||
// shape that cannot cross a pushed boundary. The client sends the raw value and never a
|
||||
// pre-shifted offset: whether to emulate the fetch shift or let GL_EXT_base_instance do
|
||||
// it is the SERVER's decision. It is also an input to set_vertex_buffers' content hash
|
||||
// and to the tracker's bit-9 shutter, so a draw whose only change is its base instance
|
||||
// still reaches the emitter and still goes out.
|
||||
//
|
||||
// DO NOT CALL IT DIRECTLY FROM A GL ENTRY POINT - use MGP_SET_BASE_INSTANCE below. This
|
||||
// whole declaration block is inside #if MOBILEGL_PIPE_PUSH, so a bare call would not even
|
||||
// compile in a pull build, and the three call sites are in a file that is compiled in
|
||||
// both. The macro is the same shape MGP_FILL already has, for the same reason.
|
||||
//
|
||||
// The validate point consumes and clears it - on both of its exits - and MGPipeLeaveVerb
|
||||
// clears it too, so a plain draw that follows a base-instanced one sees 0 again. The
|
||||
// tracker's Reset() deliberately does NOT clear it (Tracker.h): a make-current happens
|
||||
// BETWEEN the setter and the fill that reads it.
|
||||
//
|
||||
// The three GL entry points that make this call (ID-10's grant) are
|
||||
// MG_Impl/GLImpl/Drawing/GL_Drawing.cpp's DrawElementsInstancedBaseVertexBaseInstance,
|
||||
// DrawElementsInstancedBaseInstance and DrawArraysInstancedBaseInstance - one line each,
|
||||
// immediately above the MGP_FILL, carrying the RAW baseinstance argument.
|
||||
void MGPipeSetPendingBaseInstance(Uint32 baseInstance);
|
||||
// What the next set_vertex_buffers will carry. The unit gate reads it to pin that a
|
||||
// make-current between the setter and the fill does not eat it
|
||||
// (TrackerWalk.ABaseInstanceSurvivesTheFirstWalkOnAFreshContext).
|
||||
Uint32 MGPipePendingBaseInstance();
|
||||
|
||||
// PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never
|
||||
// the value - of `field` at `verb`, so that verb's read of it is
|
||||
// Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The
|
||||
@@ -41,6 +81,28 @@ namespace MobileGL::MG_Pipe {
|
||||
// Fatal{PipeVerifyBadKnob}.
|
||||
void MGPipeSetPoisonOmission(const char* verb, const char* field);
|
||||
|
||||
// PipeFill.cpp. How many times set_vertex_attrib_defaults' applier failed to reproduce
|
||||
// the value the call carried, so the client wrote the mirror itself
|
||||
// (EmitVertexAttribDefaults). It is the ONE observable of that repair: the window it
|
||||
// covers is a verb whose class does not read m_currentVertexAttribute, where reading the
|
||||
// storage to check it would be the poison violation the fill table exists to forbid. So
|
||||
// TrackerShippedEmitter asserts on this counter instead, and the day package A's applier
|
||||
// switches on MGPAttribValue::ValueClass the counter stops moving.
|
||||
//
|
||||
// Not hot-path instrumentation: it is incremented only inside the repair branch, which
|
||||
// runs only when the call actually went out, which is only when an attribute default
|
||||
// moved.
|
||||
Uint64 MGPipeVertexAttribDefaultRepairCount();
|
||||
|
||||
// PipeFill.cpp. The header of the last set_vertex_attrib_defaults that actually went out
|
||||
// - Mask, and Count == 0 for "none ever did", since a call naming no attribute is not
|
||||
// emitted. Two properties of this call have no other observable, because reading
|
||||
// m_currentVertexAttribute back at a verb whose class does not carry it is the poison
|
||||
// violation the fill table exists to forbid: that a FRESH CONTEXT republishes all 32
|
||||
// (the server's mirror still holds the previous context's defaults), and that one moved
|
||||
// attribute publishes exactly one. Eight bytes, written only when a call goes out.
|
||||
MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader();
|
||||
|
||||
#if MOBILEGL_PIPE_VERIFY
|
||||
// PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2):
|
||||
// fills `snapshot` from the live GLContext the old way, for every field in `mask`. This
|
||||
@@ -49,7 +111,14 @@ namespace MobileGL::MG_Pipe {
|
||||
void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask);
|
||||
#endif
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb)
|
||||
#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeValidateForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb)
|
||||
// P3a D-H2.1. One line immediately ABOVE the MGP_FILL of a draw entry point that takes a
|
||||
// baseinstance, carrying the argument RAW. It has to be a macro for MGP_FILL's reason: the
|
||||
// three call sites are compiled in the pull build too, where MGPipeSetPendingBaseInstance is
|
||||
// neither declared nor defined.
|
||||
#define MGP_SET_BASE_INSTANCE(BaseInstance) \
|
||||
::MobileGL::MG_Pipe::MGPipeSetPendingBaseInstance(static_cast<::MobileGL::Uint32>(BaseInstance))
|
||||
#else
|
||||
#define MGP_FILL(Verb) ((void)0)
|
||||
#define MGP_SET_BASE_INSTANCE(BaseInstance) ((void)0)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/ResourceTracker.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 <Includes.h>
|
||||
|
||||
// The CLIENT side of P3a's resource family (brief D-A, D-B, D-C, D-D).
|
||||
//
|
||||
// WHERE IT RUNS, and it is the ONE exception to push-at-validate (ARCHITECTURE.md 5.1):
|
||||
// the seven BufferBackendOps hooks already dispatch at the GL call that causes them, so
|
||||
// their pipe calls are emitted from the same BufferObject dispatchers - not from
|
||||
// MGPipeValidateForVerb. Nothing about buffers moves to validate time in P3a.
|
||||
//
|
||||
// WHAT LIVES HERE
|
||||
// * the sticky BindMask, one constexpr BufferTarget -> bit table with a static_assert
|
||||
// that it covers every enumerator, so a new target cannot be silently unmapped;
|
||||
// * the lifetimeId -> {slot, gen} mint (through MGPipeSlots(), the one allocator) and
|
||||
// the slot -> BufferObject* INVERSE the reverse channel resolves a writeback through;
|
||||
// * the nine MGPipeEmitResource* bodies, declared in MG_Pipe/PipeMutation.h so that
|
||||
// MG_State sees a declaration and never this file (the same layering PipeMutation.h
|
||||
// already has for MGP_NOTE_MUTATION: declare in MG_Pipe, define in MG_Impl);
|
||||
// * the MGPSubData range splitter, because one record's box caps the destination at a
|
||||
// 2^31-1 offset and a 2^32-1 size;
|
||||
// * the map-persistent-roundtrips counting site.
|
||||
//
|
||||
// HEADER-ONLY, for the ownership reason Tracker.h states in full: the root CMakeLists.txt
|
||||
// that would name a new .cpp belongs to the contract package and is frozen behind the tag.
|
||||
// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library.
|
||||
//
|
||||
// NO TIMER, and no per-call record copy on a HOT path. The two observables a unit case
|
||||
// needs - the last emitted descriptor and the per-call counts - are written only by
|
||||
// resource_create and resource_respecify, which run once per glBufferData rather than per
|
||||
// upload; resource_subdata, the hot one, is observed through the pure builders below
|
||||
// instead (MGPipeBuildSubDataRecord / MGPipeForEachSubDataRecordRange), which is also what
|
||||
// lets a test drive the splitter at both of its bounds without a 4 GiB buffer.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
#include <MG_State/GLState/BufferState/BufferState.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Metrics/PipeStats.h>
|
||||
|
||||
#include <Config.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D-A3: BindMask
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// MGPResourceDesc::BindMask's twelve bits, in the order MGPipeTypes.h names them:
|
||||
// VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|
|
||||
// DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY.
|
||||
//
|
||||
// They are spelled HERE rather than in MGPipeTypes.h because that header is the contract
|
||||
// package's and the mask has, so far, exactly one producer: this file. The integrator
|
||||
// moves them beside the field when a second producer appears (P4a's texture family).
|
||||
enum MGPipeBindBit : Uint16 {
|
||||
kMGPipeBindNone = 0,
|
||||
kMGPipeBindVertex = 1u << 0,
|
||||
kMGPipeBindIndex = 1u << 1,
|
||||
kMGPipeBindConstant = 1u << 2,
|
||||
kMGPipeBindShaderBuffer = 1u << 3,
|
||||
kMGPipeBindIndirect = 1u << 4,
|
||||
kMGPipeBindSampler = 1u << 5,
|
||||
kMGPipeBindShaderImage = 1u << 6,
|
||||
kMGPipeBindRenderTarget = 1u << 7,
|
||||
kMGPipeBindDepthStencil = 1u << 8,
|
||||
kMGPipeBindStreamOutput = 1u << 9,
|
||||
kMGPipeBindAtomic = 1u << 10,
|
||||
// THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this
|
||||
// resource's bytes so it can rewrite restart indices and flatten multi-draws
|
||||
// (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently
|
||||
// disables both under split, which is why it is set from the same table as every
|
||||
// other bit rather than from a special case at the emission site.
|
||||
kMGPipeBindElementArray = 1u << 11,
|
||||
};
|
||||
|
||||
// A sentinel the table below returns for an enumerator it does not name. It is NOT a
|
||||
// legal mask value: every enumerator must be listed, including the ones that map to no
|
||||
// bit at all, so that ADDING a BufferTarget is a build break here rather than a bit
|
||||
// that silently stops being published.
|
||||
inline constexpr Uint32 kMGPipeBindUnmapped = 0x10000u;
|
||||
|
||||
// The one table. No `default:` arm on purpose - that is what makes the static_assert
|
||||
// below able to see an unnamed enumerator.
|
||||
constexpr Uint32 MGPipeBindMaskForBufferTarget(BufferTarget target) {
|
||||
switch (target) {
|
||||
case BufferTarget::Vertex:
|
||||
return kMGPipeBindVertex;
|
||||
// GL_ELEMENT_ARRAY_BUFFER is the VAO's element slot: the same bind is both "this
|
||||
// resource is an index buffer" and "the server may need its bytes on its own side".
|
||||
case BufferTarget::Index:
|
||||
return kMGPipeBindIndex | kMGPipeBindElementArray;
|
||||
case BufferTarget::Uniform:
|
||||
return kMGPipeBindConstant;
|
||||
case BufferTarget::ShaderStorage:
|
||||
return kMGPipeBindShaderBuffer;
|
||||
case BufferTarget::DispatchIndirect:
|
||||
case BufferTarget::DrawIndirect:
|
||||
case BufferTarget::Parameter:
|
||||
return kMGPipeBindIndirect;
|
||||
// A texture buffer's backing store is SAMPLED through the texture that names it.
|
||||
case BufferTarget::Texture:
|
||||
return kMGPipeBindSampler;
|
||||
case BufferTarget::TransformFeedback:
|
||||
return kMGPipeBindStreamOutput;
|
||||
case BufferTarget::AtomicCounter:
|
||||
return kMGPipeBindAtomic;
|
||||
// TRANSFER AND QUERY TARGETS, which the bind mask deliberately does not name: none
|
||||
// of them is a pipeline binding, none of them makes the server keep anything, and
|
||||
// a bit set for them would only widen what a split server mirrors. Listed rather
|
||||
// than defaulted, so the completeness assert still sees them.
|
||||
case BufferTarget::CopyRead:
|
||||
case BufferTarget::CopyWrite:
|
||||
case BufferTarget::PixelPack:
|
||||
case BufferTarget::PixelUnpack:
|
||||
case BufferTarget::Query:
|
||||
return kMGPipeBindNone;
|
||||
case BufferTarget::BufferTargetCount:
|
||||
case BufferTarget::Unknown:
|
||||
return kMGPipeBindNone;
|
||||
}
|
||||
return kMGPipeBindUnmapped;
|
||||
}
|
||||
|
||||
constexpr Bool MGPipeEveryBufferTargetIsMapped() {
|
||||
for (SizeT i = 0; i < static_cast<SizeT>(BufferTarget::BufferTargetCount); ++i) {
|
||||
if (MGPipeBindMaskForBufferTarget(static_cast<BufferTarget>(i)) == kMGPipeBindUnmapped) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static_assert(MGPipeEveryBufferTargetIsMapped(),
|
||||
"a BufferTarget enumerator has no MGPResourceDesc::BindMask row: add it to "
|
||||
"MGPipeBindMaskForBufferTarget, including a deliberate kMGPipeBindNone, or the "
|
||||
"resource it is bound to stops publishing that binding (D-A3, P8 expectation 1)");
|
||||
static_assert(MGPipeBindMaskForBufferTarget(BufferTarget::Index) & kMGPipeBindElementArray,
|
||||
"the ELEMENT_ARRAY bit is the index host mirror's switch (ARCHITECTURE.md 10.3)");
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// MGPipeTypes.h documents Target as "Buffer | Tex1D..TexCubeArray | Renderbuffer |
|
||||
// TexBuffer" and StorageKind as "== TextureStorageType", but P3a is buffer-only and the
|
||||
// contract package minted no enum for the first list. Buffer is its leading member and
|
||||
// is therefore 0, which is also what a zero-initialised record already says; the second
|
||||
// is the frontend enum, named rather than open-coded.
|
||||
inline constexpr Uint16 kMGPipeResourceTargetBuffer = 0;
|
||||
inline constexpr Uint8 kMGPipeResourceStorageKindBuffer =
|
||||
static_cast<Uint8>(MobileGL::TextureStorageType::Buffer);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D-A2: the payload builders. Pure, so a unit case can assert field by field.
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// The descriptor for `buffer`. `storageDefined` is false for the create that the
|
||||
// constructor emits - storage is defined lazily by the first respecify and a backend
|
||||
// tolerates a resource that has none - and true for every respecify.
|
||||
inline MGPResourceDesc MGPipeBuildResourceDesc(const MG_State::GLState::BufferObject& buffer,
|
||||
MGPipeHandle handle, Uint16 bindMask,
|
||||
Bool storageDefined) {
|
||||
MGPResourceDesc desc{};
|
||||
desc.Resource = handle;
|
||||
desc.Target = static_cast<Uint8>(kMGPipeResourceTargetBuffer);
|
||||
desc.StorageKind = kMGPipeResourceStorageKindBuffer;
|
||||
desc.BindMask = bindMask;
|
||||
if (storageDefined) {
|
||||
// MGPResourceDesc::Width is a Uint32 and that is the CONTRACT's shape, not this
|
||||
// package's, so a store of 4 GiB or more cannot be declared at all. Truncating it
|
||||
// silently is the one answer that must not happen: the applier's range gate would
|
||||
// then refuse the first legal write past the truncated extent as
|
||||
// Fatal{ProtocolCorruption} and name a corruption that is really a narrowing here.
|
||||
// So it is said out loud, once, in every build - the assertion compiles out at
|
||||
// INFO, which is what all three gate builds are.
|
||||
if (buffer.GetSize() > static_cast<SizeT>(0xFFFFFFFFull)) {
|
||||
MGLOG_E_ONCE("MGPipe: buffer %u declares a store of %llu bytes, which does not fit "
|
||||
"MGPResourceDesc::Width - the descriptor's extent is narrowed and every "
|
||||
"write past 4 GiB will be refused by the applier's range gate",
|
||||
buffer.GetExternalIndex(),
|
||||
static_cast<unsigned long long>(buffer.GetSize()));
|
||||
MOBILEGL_ASSERT(false, "MGPResourceDesc::Width cannot carry this buffer's size");
|
||||
}
|
||||
desc.Width = static_cast<Uint32>(buffer.GetSize());
|
||||
desc.Usage = static_cast<Uint32>(buffer.GetUsage());
|
||||
desc.StorageFlags = static_cast<Uint32>(buffer.GetStorageFlags());
|
||||
desc.Immutable = buffer.IsImmutableStorage() ? 1 : 0;
|
||||
desc.HasDefinedContent = buffer.HasDefinedContent() ? 1 : 0;
|
||||
}
|
||||
// Diagnostics only: a GL name is never an identity, never a memo key and never part
|
||||
// of a content hash (ARCHITECTURE.md 4.2.1).
|
||||
desc.GlNameForDiag = static_cast<Uint32>(buffer.GetExternalIndex());
|
||||
return desc;
|
||||
}
|
||||
|
||||
// The buffer half of MGPSubData: the destination range rides in the box's first
|
||||
// coordinate and first extent, and MGPipeSetSubDataBufferRange is the ONLY spelling of
|
||||
// that convention. Returns false, with the record untouched, when the range does not fit
|
||||
// one record - which is where MGPipeForEachSubDataRecordRange comes in.
|
||||
//
|
||||
// `sourceIsVerbatimLevelShadow` is the record's own question - "are these bytes an
|
||||
// untransformed level shadow?" - and it is a PARAMETER because the answer differs by
|
||||
// caller: resource_subdata hands over the client's own shadow at an offset into it and
|
||||
// says yes; buffer_subdata_resident hands over the application's staging store, or the
|
||||
// locally expanded pattern FillSubData built, and both say no. Nothing reads it on the
|
||||
// buffer path today, which is exactly why it must not be a hard-coded 1 that becomes
|
||||
// wrong the moment something does.
|
||||
//
|
||||
// Blob is FILLED, exactly: Seg is kMGHostSpanSegNone (monolith - the bytes travel beside
|
||||
// the record through the entry point's companion pointer) and Size is the piece's own
|
||||
// byte length, which is what the applier's ONE Blob rule holds a non-zero declaration to
|
||||
// (PipeApply.cpp's SubDataBoxFault: != 0 && != MGPipeSubDataBufferSize is refused).
|
||||
// Leaving it 0 would be legal too; declaring it correctly is the stronger of the two.
|
||||
inline Bool MGPipeBuildSubDataRecord(MGPipeHandle res, Uint64 offset, Uint64 size, MGPSubData& out,
|
||||
Bool sourceIsVerbatimLevelShadow) {
|
||||
out = MGPSubData{};
|
||||
out.Res = res;
|
||||
out.Target = kMGPipeResourceTargetBuffer;
|
||||
out.SourceIsVerbatimLevelShadow = sourceIsVerbatimLevelShadow ? 1 : 0;
|
||||
if (!MGPipeSetSubDataBufferRange(out, offset, size)) return false;
|
||||
out.Blob.Seg = kMGHostSpanSegNone;
|
||||
out.Blob.Size = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE record's destination box caps the offset at 2^31-1 and the size at 2^32-1
|
||||
// (MGPipeTypes.h), so a range beyond either has to be split. The pieces are CONTIGUOUS
|
||||
// and in ASCENDING order, and both properties are load-bearing rather than tidy:
|
||||
// splitting a content write into overlapping or reordered pieces would change what the
|
||||
// backend's queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being
|
||||
// exactly the writes the application made.
|
||||
inline constexpr Uint64 kMGPipeSubDataMaxRecordOffset = 0x7FFFFFFFull;
|
||||
inline constexpr Uint64 kMGPipeSubDataMaxRecordSize = 0xFFFFFFFFull;
|
||||
|
||||
// WITH THE RECORD'S OWN BOUND THE SPLIT IS NOT REACHABLE, and saying so is better than a
|
||||
// loop that reads as if it were: a second piece starts at least 2^32-1 bytes past the
|
||||
// first, which is already past the OFFSET cap, so a range too big for one record is
|
||||
// REFUSED rather than split. The offset cap cannot be split away at all - every piece of
|
||||
// a range that starts past 2^31-1 starts past it too - and a silent truncation is the one
|
||||
// answer that must not happen, so the walk emits nothing and its caller says so once.
|
||||
//
|
||||
// `maxChunk` exists because the record's bound is not the tight one for long: a transport
|
||||
// segment is far smaller (tens of MiB), and that is where this walk starts producing real
|
||||
// splits. It is a parameter now, and exercised at a reachable value by the unit gate, so
|
||||
// that lowering it is one argument rather than a new code path written under pressure.
|
||||
template <class Fn>
|
||||
inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece,
|
||||
Uint64 maxChunk = kMGPipeSubDataMaxRecordSize) {
|
||||
if (offset > kMGPipeSubDataMaxRecordOffset) return false;
|
||||
if (size == 0) return true;
|
||||
if (maxChunk == 0) return false;
|
||||
// Every piece has to be encodable BEFORE any of them is emitted: a half-emitted range
|
||||
// is a partial content write the backend would land as if it were the whole one.
|
||||
const Uint64 chunkCap = maxChunk < kMGPipeSubDataMaxRecordSize ? maxChunk : kMGPipeSubDataMaxRecordSize;
|
||||
for (Uint64 at = offset; at < offset + size; at += chunkCap) {
|
||||
if (at > kMGPipeSubDataMaxRecordOffset) return false;
|
||||
}
|
||||
for (Uint64 at = offset, left = size; left > 0;) {
|
||||
const Uint64 chunk = left > chunkCap ? chunkCap : left;
|
||||
piece(at, chunk);
|
||||
at += chunk;
|
||||
left -= chunk;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The tracker: handles, the inverse, the sticky mask, the reverse channel
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
class MGPipeResourceTracker {
|
||||
public:
|
||||
using BufferObject = MG_State::GLState::BufferObject;
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
|
||||
// The handle for `buffer`, minted on first use. Minting is NOT gated on a backend
|
||||
// having registered MGPipeResourceOps: the handle is CLIENT state and
|
||||
// set_vertex_buffers names it whether or not the resource family is switched on, so
|
||||
// gating it would make the vertex-input subsystem emit null handles whenever the
|
||||
// resource subsystem is off. Only the CALLS are gated (D-A1).
|
||||
MGPipeHandle Acquire(BufferObject& buffer) {
|
||||
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Buffer, buffer.GetLifetimeId());
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1);
|
||||
m_bySlot[slot].Object = &buffer;
|
||||
m_bySlot[slot].Gen = handle.Gen;
|
||||
return handle;
|
||||
}
|
||||
|
||||
// The handle a buffer already has, or the null handle. Never mints - the emission
|
||||
// path calls Acquire, the query paths call this.
|
||||
MGPipeHandle Find(const BufferObject& buffer) const {
|
||||
return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, buffer.GetLifetimeId());
|
||||
}
|
||||
|
||||
// D-D's inverse, and a RAW pointer is exact here: the entry exists only between the
|
||||
// create the constructor emits and the destroy the destructor emits, and a readback
|
||||
// is only ever issued for a live, bound buffer. A WeakPtr would be wrong - the
|
||||
// object does not own itself through a SharedPtr at those two moments. The Gen
|
||||
// compare is what refuses a stale handle rather than resolving it to whatever now
|
||||
// occupies the slot.
|
||||
BufferObject* Resolve(MGPipeHandle handle) const {
|
||||
const SizeT slot = handle.Slot;
|
||||
if (MGPipeHandleIsNull(handle) || slot >= m_bySlot.size()) return nullptr;
|
||||
const Entry& entry = m_bySlot[slot];
|
||||
if (entry.Object == nullptr || entry.Gen != handle.Gen) return nullptr;
|
||||
if (MGPipeSlots().GenOfSlot(MGPipeKind::Buffer, handle.Slot) != handle.Gen) return nullptr;
|
||||
return entry.Object;
|
||||
}
|
||||
|
||||
// Drops the inverse entry and the sticky mask. The CALLER frees the slot afterwards,
|
||||
// in that order (D-L): MGPipeSlotAllocator::Free erases the lifetimeId -> slot
|
||||
// mapping, so anything that has to resolve the handle must do it first.
|
||||
void Retire(MGPipeHandle handle) {
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) return;
|
||||
m_bySlot[slot] = Entry{};
|
||||
}
|
||||
|
||||
// ---- D-L: was resource_create actually EMITTED for this slot? ----
|
||||
//
|
||||
// The create is gated at its call site (BufferObject's constructor) and the destroy
|
||||
// is gated inside MGPipeEmitResourceDestroyAndFree, so the two ask the SAME question
|
||||
// at two different moments. A buffer constructed while a backend's table was
|
||||
// registered and destroyed after UnregisterBufferBackendOps() would take the second
|
||||
// answer, free its slot, and leave the applier's record Live - on a slot the
|
||||
// allocator is about to hand out again, with the backend's twin (a driver buffer id)
|
||||
// still attached to it. So the answer is LATCHED at the create and the destroy uses
|
||||
// the latched one; the two are then a pair by construction rather than by the
|
||||
// registration outliving every buffer.
|
||||
void NotePublished(MGPipeHandle handle) {
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) return;
|
||||
m_bySlot[slot].Published = true;
|
||||
}
|
||||
Bool WasPublished(MGPipeHandle handle) const {
|
||||
const SizeT slot = handle.Slot;
|
||||
return slot < m_bySlot.size() && m_bySlot[slot].Published;
|
||||
}
|
||||
|
||||
// The sticky everBoundAs mask. Sticky exactly as MGPResourceDesc::ImageBindableHint's
|
||||
// everImageBound is: ORed, never cleared, so a buffer that was an element array once
|
||||
// keeps saying so.
|
||||
Uint16 BindMask(MGPipeHandle handle) const {
|
||||
const SizeT slot = handle.Slot;
|
||||
return slot < m_bySlot.size() ? m_bySlot[slot].BindMask : Uint16{0};
|
||||
}
|
||||
|
||||
// OR one target's bit into a handle's sticky mask, without looking at the context at
|
||||
// all. This is what closes the sampling window for the two bits anything keys on:
|
||||
// the vertex-input emitters resolve, at EVERY draw, exactly the attribute buffers and
|
||||
// the element-slot buffer, so any buffer ever DRAWN FROM carries its ARRAY_BUFFER /
|
||||
// ELEMENT_ARRAY bit for the rest of its life whether or not it happened to be bound
|
||||
// at a storage op. It grows the table rather than dropping the note: it is called
|
||||
// from the validate point, which is GL-thread by construction, and a slot outside the
|
||||
// table is a buffer whose mint this process has not seen (a unit fixture's
|
||||
// ResetForTest, in practice).
|
||||
void NoteBoundAs(MGPipeHandle handle, BufferTarget target) {
|
||||
if (MGPipeHandleIsNull(handle)) return;
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1);
|
||||
m_bySlot[slot].BindMask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
|
||||
}
|
||||
|
||||
// Accumulates into the sticky mask every target `buffer` is bound to RIGHT NOW, and
|
||||
// returns the accumulated value.
|
||||
//
|
||||
// [DEVIATION, recorded in client-v2.md] D-A3 asks for the OR at every glBindBuffer /
|
||||
// glBindBufferBase / glBindBufferRange / VAO element-slot bind, and C.1 points at
|
||||
// MG_State/GLState/BufferState/BufferState.{h,cpp} for it - a file this package DOES
|
||||
// own. The brief is wrong about where the entry points are: BufferState only VENDS
|
||||
// BindingSlot<BufferObject>& / BindingSlotRange1D&, and the .Bind() calls are
|
||||
// MG_Impl/GLImpl/Buffer/GL_Buffer.cpp's (BindBuffer_State, BindBufferBase_State,
|
||||
// BindBufferRange_State), which C.5 assigns to no package. So the mask is accumulated
|
||||
// by SAMPLING the frontend's live binding state instead - here, at every create and
|
||||
// respecify, which is where the value is PUBLISHED - and ORed into a per-slot sticky
|
||||
// field that is never cleared.
|
||||
//
|
||||
// WHAT SAMPLING ALONE CANNOT SEE is not "a bind after the last respecify" (which the
|
||||
// specified design misses too) but a TRANSIENT bind: bind an EBO, draw, unbind, then
|
||||
// define it through DSA - the respecify's sample sees no binding at all, and the DSA
|
||||
// idiom makes that the common case rather than a corner (TryAdoptLargeStorage's own
|
||||
// comment names glNamedBufferSubData as what MC 26.3 streams with). That hole is
|
||||
// closed for the two bits anything keys on by NoteBoundAs above, called from
|
||||
// EmitVertexBuffers / EmitIndexBuffer at every draw. What is left unpublished is a
|
||||
// buffer that is bound, never drawn from, and never re-specified afterwards; the
|
||||
// remaining fix is one line in each of GL_Buffer.cpp's three *_State binders, for the
|
||||
// seven bits nothing keys on yet, and it stays handed to whoever owns that file.
|
||||
//
|
||||
// The scan is skipped unless a binding-slot version moved since the last one, which
|
||||
// is one Uint16 load per global target and none per binding point. It is NOT called
|
||||
// from the content emitters, deliberately: it walks the whole context's binding state
|
||||
// and writes the tracker, and one of those emitters (resource_subdata) is on the path
|
||||
// D-A2 preserves as reachable off the render thread. Extra sampling could only widen
|
||||
// a sticky union, but not at the price of a context-wide read from the wrong thread.
|
||||
Uint16 RefreshBindMask(GLContext& ctx, const BufferObject& buffer, MGPipeHandle handle) {
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) return 0;
|
||||
Entry& entry = m_bySlot[slot];
|
||||
const Uint64 epoch = BindEpoch(ctx);
|
||||
if (epoch == m_bindEpoch && entry.BindMaskEpoch == epoch) return entry.BindMask;
|
||||
m_bindEpoch = epoch;
|
||||
entry.BindMaskEpoch = epoch;
|
||||
Uint16 mask = entry.BindMask;
|
||||
for (const auto target : MG_State::GLState::GlobalBufferTargets) {
|
||||
if (ctx.GetBufferBindingSlot(target).GetBoundObject().get() == &buffer) {
|
||||
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
|
||||
}
|
||||
}
|
||||
for (const auto target : MG_State::GLState::BufferBindPointTargets) {
|
||||
const SizeT touched = ctx.GetTouchedBufferBindingPointCount(target);
|
||||
for (SizeT i = 0; i < touched; ++i) {
|
||||
if (ctx.GetBufferBindingPoint(target, static_cast<Uint>(i)).GetBoundObject().get() == &buffer) {
|
||||
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The index slot is the BOUND VAO's, not BufferState's, so it is not in
|
||||
// GlobalBufferTargets and GetBufferBindingSlot(Index) asserts without a VAO.
|
||||
if (const auto& vao = ctx.GetBoundVertexArray()) {
|
||||
if (vao->GetIndexBufferBindingSlot().GetBoundObject().get() == &buffer) {
|
||||
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(BufferTarget::Index));
|
||||
}
|
||||
for (int i = 0; i < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++i) {
|
||||
if (vao->GetAttribute(static_cast<Uint>(i)).Buffer.get() == &buffer) {
|
||||
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(BufferTarget::Vertex));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.BindMask = mask;
|
||||
return mask;
|
||||
}
|
||||
|
||||
// ---- the two observables a unit case reads (see the header comment) ----
|
||||
const MGPResourceDesc& LastDesc() const { return m_lastDesc; }
|
||||
Uint64 CreateCount() const { return m_creates; }
|
||||
Uint64 RespecifyCount() const { return m_respecifies; }
|
||||
Uint64 DestroyCount() const { return m_destroys; }
|
||||
Uint64 MapPersistentCount() const { return m_mapPersistents; }
|
||||
|
||||
void NoteDesc(const MGPResourceDesc& desc, Bool isCreate) {
|
||||
m_lastDesc = desc;
|
||||
if (isCreate) {
|
||||
++m_creates;
|
||||
} else {
|
||||
++m_respecifies;
|
||||
}
|
||||
}
|
||||
void NoteDestroy() { ++m_destroys; }
|
||||
void NoteMapPersistent() { ++m_mapPersistents; }
|
||||
|
||||
// A unit fixture's per-case reset, and the library never calls it. THE RULE, stated
|
||||
// rather than left as an absence, because "nothing resets this" is not a reason:
|
||||
//
|
||||
// A buffer handle and the applier record it names are SHARE-GROUP OBJECT STATE.
|
||||
// A GL object lives in a share group, not in a context, so a make-current changes
|
||||
// neither. The applier's MGPipeApplierReset() is a make-current and deliberately
|
||||
// keeps its Resources / VertexElementsCsos (PipeApply.h says so beside them); the
|
||||
// ONLY things that drop a record are the object's own death signal -
|
||||
// resource_destroy, which ~BufferObject raises through
|
||||
// MGPipeEmitResourceDestroyAndFree, and delete_vertex_elements - and
|
||||
// MGPipeApplierReleaseObjectRecords(), which is the SERVED CONTEXT's teardown and
|
||||
// is deliberately wired to nothing in the monolith (there is one applier behind
|
||||
// every context, so calling it on one context's destruction would drop every other
|
||||
// context's records).
|
||||
//
|
||||
// So this tracker needs no re-publication path on a fresh context and must not have
|
||||
// one: re-emitting resource_create for a record the applier still holds would move
|
||||
// its Serial for nothing. What the client owes instead is the destroy - which
|
||||
// ~BufferObject already emits, in the fixed emit-then-free order (D-L) - and that is
|
||||
// the whole of the client's side of the record lifecycle.
|
||||
//
|
||||
// The vertex-input emitter's latches are the OTHER half and are genuinely per
|
||||
// context: MGPipeVertexInputEmitter::Reset() is called from the FreshlyPrimed arm
|
||||
// because the applier's vertex-input WORKING state (the bound handle, the window, the
|
||||
// fetch shift) IS cleared there. Its vertex-elements RECORDS are not, which is why
|
||||
// the emitter's Reset drops the "already published" latches but no create is lost:
|
||||
// the latch is what says "re-publish", and re-publishing an unchanged configuration
|
||||
// is a bounded over-fire, not a dropped write.
|
||||
void ResetForTest() {
|
||||
m_bySlot.clear();
|
||||
m_bindEpoch = 0;
|
||||
m_lastDesc = MGPResourceDesc{};
|
||||
m_creates = m_respecifies = m_destroys = m_mapPersistents = 0;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
BufferObject* Object = nullptr;
|
||||
Uint32 Gen = 0;
|
||||
Uint16 BindMask = 0;
|
||||
Bool Published = false;
|
||||
Uint64 BindMaskEpoch = 0;
|
||||
};
|
||||
|
||||
// "Has any buffer binding moved since the last scan": the sum of the binding-slot
|
||||
// versions, which BindingSlot bumps only on a real change. A collision costs one
|
||||
// skipped rescan of ONE buffer's mask, and the mask is re-scanned at the next
|
||||
// emission whose epoch differs, so it can delay a bit by one storage op and never
|
||||
// drop one - the same over-fire-is-free / under-fire-is-fatal direction every
|
||||
// shutter in Tracker.h takes.
|
||||
//
|
||||
// IT DOES NOT SEE THE 84x4 INDEXED BINDING POINTS, and that is sound only because
|
||||
// BindBufferBase_State / BindBufferRange_State also bind the GENERIC slot for the
|
||||
// same target (GL_Buffer.cpp:1531 says why), so an indexed bind always moves one of
|
||||
// the versions summed here. If that ever stops being true, the CONSTANT /
|
||||
// SHADER_BUFFER / ATOMIC / STREAM_OUTPUT bits start being missed silently and the
|
||||
// repair is to fold GetTouchedBufferBindingPointCount into the epoch.
|
||||
static Uint64 BindEpoch(GLContext& ctx) {
|
||||
Uint64 epoch = 1;
|
||||
for (const auto target : MG_State::GLState::GlobalBufferTargets) {
|
||||
epoch += ctx.GetBufferBindingSlot(target).GetVersion();
|
||||
epoch *= 3;
|
||||
}
|
||||
if (const auto& vao = ctx.GetBoundVertexArray()) {
|
||||
epoch += vao->GetIndexBufferBindingSlot().GetVersion();
|
||||
epoch = MGPipeMixShutterValue(epoch, vao->GetLifetimeId());
|
||||
epoch = MGPipeMixShutterValue(epoch, vao->GetConfigVersion());
|
||||
}
|
||||
return epoch;
|
||||
}
|
||||
|
||||
// The same mix Tracker.h's composite shutters use. Spelled here rather than
|
||||
// included so this header does not depend on the tracker.
|
||||
static constexpr Uint64 MGPipeMixShutterValue(Uint64 accumulator, Uint64 value) {
|
||||
accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
Vector<Entry> m_bySlot;
|
||||
Uint64 m_bindEpoch = 0;
|
||||
MGPResourceDesc m_lastDesc{};
|
||||
Uint64 m_creates = 0;
|
||||
Uint64 m_respecifies = 0;
|
||||
Uint64 m_destroys = 0;
|
||||
Uint64 m_mapPersistents = 0;
|
||||
};
|
||||
|
||||
// The monolith's one resource tracker, beside the state tracker, the CSO cache and the
|
||||
// set-hash suppressor.
|
||||
inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() {
|
||||
static MGPipeResourceTracker tracker;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D-D: the client's half of the reverse channel
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// The backend produced the bytes of a readback and hands them back through the channel.
|
||||
// The client resolves the handle to its own object and writes the shadow; the epoch bump
|
||||
// stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4:
|
||||
// the reverse channel needs the same ordering guarantee as the forward one).
|
||||
inline void MGPipeClientOnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) {
|
||||
auto* buffer = MGPipeResourceTrackerInstance().Resolve(res);
|
||||
if (buffer == nullptr) {
|
||||
MGLOG_E_ONCE("MGPipe: OnBufferWriteback for a handle {%u,%u} that resolves to no buffer",
|
||||
res.Slot, res.Gen);
|
||||
return;
|
||||
}
|
||||
if (bytes.Seg != kMGHostSpanSegNone) {
|
||||
MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u); P3a is monolith only",
|
||||
bytes.Seg);
|
||||
return;
|
||||
}
|
||||
// Monolith: Seg is kMGHostSpanSegNone and Offset IS the address of the backend's
|
||||
// mapped bytes (MGPipeTypes.h says so in as many words). Under a transport the
|
||||
// segment resolves first, and that is the phase's edit, not this one's.
|
||||
buffer->WritebackFromBackend(
|
||||
DataPtr{reinterpret_cast<void*>(static_cast<std::uintptr_t>(bytes.Offset)),
|
||||
static_cast<SizeT>(bytes.Size)},
|
||||
static_cast<SizeT>(offset));
|
||||
}
|
||||
|
||||
// A draw or dispatch wrote these ranges. ARCHITECTURE.md 7.1 calls this a NARROWING
|
||||
// channel - the client builds a conservative pending set at its own emission points and
|
||||
// the callback only ever removes from it - so P3a's implementation marks exactly what
|
||||
// the three Espryt MarkGpuWritten sites mark today and the observable behaviour is
|
||||
// unchanged. The narrowing itself is P8/P9's.
|
||||
inline void MGPipeClientOnGpuWritten(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges) {
|
||||
// THE SHAPE IS A CONTRACT POINT, not a formality: the announcement is ONE range
|
||||
// covering kMGPipeWholeBuffer, deliberately not ZERO ranges, because zero will mean
|
||||
// "a fully narrowed set - nothing is dirty" at P8/P9. Marking the whole buffer
|
||||
// written for a zero-range announcement would be the narrowing channel run backwards,
|
||||
// so the shape is asserted here rather than assumed.
|
||||
MOBILEGL_ASSERT(rangeCount == 1 && ranges != nullptr,
|
||||
"OnGpuWritten {slot=%u, gen=%u}: P3a announces exactly one whole-buffer range, "
|
||||
"not %u",
|
||||
res.Slot, res.Gen, static_cast<Uint>(rangeCount));
|
||||
(void)ranges;
|
||||
if (rangeCount == 0) return;
|
||||
auto* buffer = MGPipeResourceTrackerInstance().Resolve(res);
|
||||
if (buffer == nullptr) {
|
||||
// Loud, like its sibling above: a backend announcing a write against a handle
|
||||
// this client cannot resolve is a dropped MarkGpuWritten, and a dropped
|
||||
// MarkGpuWritten is a stale shadow read back as if it were current.
|
||||
MGLOG_E_ONCE("MGPipe: OnGpuWritten for a handle {%u,%u} that resolves to no buffer", res.Slot,
|
||||
res.Gen);
|
||||
return;
|
||||
}
|
||||
buffer->MarkGpuWritten();
|
||||
}
|
||||
|
||||
// Installed once, and never over an entry a backend already claimed: these two are the
|
||||
// CLIENT's implementations of a backend -> frontend callback, so the backend installs
|
||||
// the rest of the table and these two answer for it.
|
||||
inline void MGPipeInstallClientResourceCallbacks() {
|
||||
if (gMGPipeCallbacks.OnBufferWriteback == nullptr) {
|
||||
gMGPipeCallbacks.OnBufferWriteback = &MGPipeClientOnBufferWriteback;
|
||||
}
|
||||
if (gMGPipeCallbacks.OnGpuWritten == nullptr) {
|
||||
gMGPipeCallbacks.OnGpuWritten = &MGPipeClientOnGpuWritten;
|
||||
}
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
@@ -0,0 +1,93 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/SetHashSuppressor.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 <Includes.h>
|
||||
|
||||
// Coalescing rule 4 (ARCHITECTURE.md 5.4, P2 brief D11): every kVarTail set_* hashes the
|
||||
// RESOLVED set on the client and does not emit when the hash has not moved.
|
||||
//
|
||||
// This is the carrier for the ~175 lines of debounce that move off the backends in P3b and
|
||||
// P4b - Espryt's UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and
|
||||
// Magma's equivalents all answer "is this set the same set as last time", and every one of
|
||||
// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE
|
||||
// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a
|
||||
// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves
|
||||
// the set they name. P3a wires the second, SetVertexBuffers.
|
||||
//
|
||||
// A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first
|
||||
// bites: the hash has to cover EVERY input the record carries, not only the set. Its
|
||||
// baseInstance is DRAW state and moves without the buffer set moving, so a hash over the
|
||||
// entries alone would suppress a record whose one changed field is the fetch shift and the
|
||||
// server would keep the previous one. MG_Impl/Pipe/VertexInputEmit.h's
|
||||
// MGPipeVertexBufferSetContentHash mixes Start, Count and BaseInstance in for exactly that
|
||||
// reason, and VertexInputEmit's base-instance pair is the test that says so.
|
||||
//
|
||||
// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a
|
||||
// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and
|
||||
// never a missed one.
|
||||
//
|
||||
// Header-only for the same ownership reason as Tracker.h and CsoCache.h: the root
|
||||
// CMakeLists.txt that would name a new .cpp belongs to package A and is frozen behind the
|
||||
// p2/contract tag.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
// One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list).
|
||||
enum class MGPipeSuppressorSlot : Uint32 {
|
||||
SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance
|
||||
SetSamplerViews, // P3b
|
||||
BindSamplerStates, // P3b
|
||||
SetShaderImages, // P4b
|
||||
SetShaderBuffers, // P4b
|
||||
SetStreamOutputTargets, // P4b
|
||||
SetVertexAttribDefaults, // P2 - the one consumer that is wired
|
||||
Count,
|
||||
};
|
||||
|
||||
inline constexpr SizeT kMGPipeSuppressorSlotCount = static_cast<SizeT>(MGPipeSuppressorSlot::Count);
|
||||
|
||||
class MGPipeSetHashSuppressor {
|
||||
public:
|
||||
// True when `contentHash` differs from what this slot last emitted, and LATCHES it.
|
||||
// False means the resolved set has not moved and the call must not go out.
|
||||
Bool ShouldEmit(MGPipeSuppressorSlot slot, Uint64 contentHash) {
|
||||
const Uint64 latched = contentHash == 0 ? 1 : contentHash;
|
||||
const SizeT index = static_cast<SizeT>(slot);
|
||||
if (m_lastEmitted[index] == latched) return false;
|
||||
m_lastEmitted[index] = latched;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A context change or a server reset: what the server has is no longer what this
|
||||
// slot last emitted, so the next resolved set must go out whatever it hashes to.
|
||||
void Invalidate(MGPipeSuppressorSlot slot) { m_lastEmitted[static_cast<SizeT>(slot)] = 0; }
|
||||
|
||||
void InvalidateAll() {
|
||||
for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) m_lastEmitted[i] = 0;
|
||||
}
|
||||
|
||||
// 0 == "never emitted". Exposed for the unit test, which is what pins that the
|
||||
// reserved value really is reserved.
|
||||
Uint64 LastEmitted(MGPipeSuppressorSlot slot) const {
|
||||
return m_lastEmitted[static_cast<SizeT>(slot)];
|
||||
}
|
||||
|
||||
private:
|
||||
Array<Uint64, kMGPipeSuppressorSlotCount> m_lastEmitted{};
|
||||
};
|
||||
|
||||
// The monolith's one suppressor, beside the tracker and the CSO cache.
|
||||
inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() {
|
||||
static MGPipeSetHashSuppressor suppressor;
|
||||
return suppressor;
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.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
|
||||
|
||||
// SlotAllocator.h. Compiled only under MOBILEGL_PIPE_PUSH.
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
namespace {
|
||||
// The ShaderCso band the ordinary allocator must never enter: the top 1/16 of the
|
||||
// ShaderCso slot space is reserved for PROGRAM PIPELINE COMPOSITES, which are minted
|
||||
// client-side out of the stage programs bound to a pipeline object. Reserving a band
|
||||
// rather than a flag keeps the composite resolver's lifetime bookkeeping out of here
|
||||
// (MGPipeHandles.h, ARCHITECTURE.md 5.6.3).
|
||||
Bool SlotIsAllocatable(MGPipeKind kind, Uint32 slot) {
|
||||
if (slot < kMGPipeFirstAllocatableSlot) return false;
|
||||
if (kind != MGPipeKind::ShaderCso) return true;
|
||||
return slot < kMGPipeShaderCsoCompositeSlotBase;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) {
|
||||
const SizeT index = static_cast<SizeT>(kind);
|
||||
MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index);
|
||||
return m_kinds[index < kKindCount ? index : 0];
|
||||
}
|
||||
|
||||
const MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) const {
|
||||
const SizeT index = static_cast<SizeT>(kind);
|
||||
MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index);
|
||||
return m_kinds[index < kKindCount ? index : 0];
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) {
|
||||
KindState& state = StateOf(kind);
|
||||
if (state.Slots.empty()) {
|
||||
// Slot 0 exists so the vector is slot-indexed, and is never handed out.
|
||||
state.Slots.resize(kMGPipeFirstAllocatableSlot);
|
||||
}
|
||||
|
||||
Uint32 slot = 0;
|
||||
Bool reused = false;
|
||||
while (!state.FreeList.empty()) {
|
||||
const Uint32 candidate = state.FreeList.back();
|
||||
state.FreeList.pop_back();
|
||||
if (!SlotIsAllocatable(kind, candidate)) continue;
|
||||
slot = candidate;
|
||||
reused = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!reused) {
|
||||
slot = static_cast<Uint32>(state.Slots.size());
|
||||
MOBILEGL_ASSERT(SlotIsAllocatable(kind, slot),
|
||||
"MGPipe slot space of kind %u is exhausted at slot %u",
|
||||
static_cast<Uint32>(kind), slot);
|
||||
if (!SlotIsAllocatable(kind, slot)) return kMGPipeNullHandle;
|
||||
state.Slots.emplace_back();
|
||||
}
|
||||
|
||||
SlotState& entry = state.Slots[slot];
|
||||
if (entry.EverHandedOut) {
|
||||
// The one place Gen may move. 2^32 recycles of ONE slot is ~50 days of continuous
|
||||
// churn at one recycle per frame at 1000 fps, which is why the bound is asserted
|
||||
// in a debug allocator rather than defended in release.
|
||||
MOBILEGL_ASSERT(entry.Gen != ~Uint32{0},
|
||||
"MGPipe handle generation wrapped on kind %u slot %u; {slot, gen} is "
|
||||
"no longer unique",
|
||||
static_cast<Uint32>(kind), slot);
|
||||
++entry.Gen;
|
||||
}
|
||||
entry.EverHandedOut = true;
|
||||
entry.Live = true;
|
||||
entry.LifetimeId = 0;
|
||||
++state.LiveCount;
|
||||
return MGPipeHandle{slot, entry.Gen};
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::AllocateFor(MGPipeKind kind, Uint64 lifetimeId) {
|
||||
const MGPipeHandle handle = Allocate(kind);
|
||||
if (MGPipeHandleIsNull(handle)) return handle;
|
||||
KindState& state = StateOf(kind);
|
||||
state.Slots[handle.Slot].LifetimeId = lifetimeId;
|
||||
if (lifetimeId != 0) {
|
||||
MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(),
|
||||
"lifetime id %llu already owns a slot of kind %u",
|
||||
static_cast<unsigned long long>(lifetimeId), static_cast<Uint32>(kind));
|
||||
state.ByLifetimeId[lifetimeId] = handle.Slot;
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const {
|
||||
if (lifetimeId == 0) return kMGPipeNullHandle;
|
||||
const KindState& state = StateOf(kind);
|
||||
const auto it = state.ByLifetimeId.find(lifetimeId);
|
||||
if (it == state.ByLifetimeId.end()) return kMGPipeNullHandle;
|
||||
const Uint32 slot = it->second;
|
||||
if (slot >= state.Slots.size() || !state.Slots[slot].Live) return kMGPipeNullHandle;
|
||||
return MGPipeHandle{slot, state.Slots[slot].Gen};
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) {
|
||||
const MGPipeHandle existing = FindByLifetimeId(kind, lifetimeId);
|
||||
if (!MGPipeHandleIsNull(existing)) return existing;
|
||||
return AllocateFor(kind, lifetimeId);
|
||||
}
|
||||
|
||||
void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) {
|
||||
KindState& state = StateOf(kind);
|
||||
if (handle.Slot >= state.Slots.size()) return;
|
||||
SlotState& entry = state.Slots[handle.Slot];
|
||||
// A stale handle must not free the slot its successor now owns - that is the whole
|
||||
// reason the generation is in the key.
|
||||
if (!entry.Live || entry.Gen != handle.Gen) return;
|
||||
if (entry.LifetimeId != 0) {
|
||||
const auto it = state.ByLifetimeId.find(entry.LifetimeId);
|
||||
if (it != state.ByLifetimeId.end() && it->second == handle.Slot) {
|
||||
state.ByLifetimeId.erase(it);
|
||||
}
|
||||
}
|
||||
entry.Live = false;
|
||||
entry.LifetimeId = 0;
|
||||
--state.LiveCount;
|
||||
state.FreeList.push_back(handle.Slot);
|
||||
}
|
||||
|
||||
Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const {
|
||||
const KindState& state = StateOf(kind);
|
||||
if (handle.Slot >= state.Slots.size()) return false;
|
||||
const SlotState& entry = state.Slots[handle.Slot];
|
||||
return entry.Live && entry.Gen == handle.Gen;
|
||||
}
|
||||
|
||||
Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const {
|
||||
const KindState& state = StateOf(kind);
|
||||
if (slot >= state.Slots.size()) return 0;
|
||||
return state.Slots[slot].Gen;
|
||||
}
|
||||
|
||||
Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const {
|
||||
const KindState& state = StateOf(kind);
|
||||
if (slot >= state.Slots.size()) return 0;
|
||||
return state.Slots[slot].LifetimeId;
|
||||
}
|
||||
|
||||
Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const {
|
||||
return static_cast<Uint32>(StateOf(kind).Slots.size());
|
||||
}
|
||||
|
||||
Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; }
|
||||
|
||||
Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const {
|
||||
return static_cast<Uint32>(StateOf(kind).FreeList.size());
|
||||
}
|
||||
|
||||
void MGPipeSlotAllocator::Reset() {
|
||||
for (KindState& state : m_kinds) {
|
||||
state.Slots.clear();
|
||||
state.FreeList.clear();
|
||||
state.ByLifetimeId.clear();
|
||||
state.LiveCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
MGPipeSlotAllocator& MGPipeSlots() {
|
||||
static MGPipeSlotAllocator allocator;
|
||||
return allocator;
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.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 <Includes.h>
|
||||
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
|
||||
// The CLIENT's slot allocator: the thing that mints every MGPipeHandle in the system
|
||||
// (ARCHITECTURE.md 4.2 - no create_* call in the catalogue returns a server-cast handle,
|
||||
// which is what lets the whole catalogue be remoted with zero creation round trips).
|
||||
//
|
||||
// Per kind: a free list plus a high-water mark, so slots stay DENSE and the server's object
|
||||
// table is an array rather than a hash map. It has nothing to do with MG_State's
|
||||
// IndexGenerator - that container's LIFO GL-name reuse is the very problem {slot, gen}
|
||||
// exists to close, and the whole point of the identity is that an ABA on the GL name, on
|
||||
// the heap address or on the lifetime id cannot reproduce a handle.
|
||||
//
|
||||
// Gen increments ONLY when a slot is reused, never on a respecify: a glBufferData on a live
|
||||
// buffer keeps the same {slot, gen}, because the object is the same object. Two generations
|
||||
// exist in the design and they are strictly separate - this is the client's answer to "is
|
||||
// this still the same GL object"; MGGen is the server's epoch for "did I recast my driver
|
||||
// object", and no MGPipe call may require the client to know it.
|
||||
//
|
||||
// The lifetimeId -> slot map is what keeps a GL NAME out of every key (ARCHITECTURE.md 4.2):
|
||||
// the frontend object's lifetime id is the client's own identity for it, so the backend key
|
||||
// is the handle and the frontend key is the lifetime id, and neither is a recyclable name.
|
||||
//
|
||||
// Lives in MG_Impl (the client side, unrestricted) and is compiled only under
|
||||
// MOBILEGL_PIPE_PUSH. It is in the P2 CONTRACT commit rather than in a Track H package
|
||||
// because both Track H slices - Espryt 0b and Magma subsystem 4 - key off it.
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
class MGPipeSlotAllocator {
|
||||
public:
|
||||
static constexpr SizeT kKindCount = static_cast<SizeT>(MGPipeKind::KindCount);
|
||||
|
||||
// A fresh {slot, gen} of this kind, from the free list if one is waiting and from the
|
||||
// high-water mark otherwise. Never returns slot 0 (reserved: null, and the default
|
||||
// framebuffer for kind Framebuffer), and never returns a ShaderCso slot inside the
|
||||
// composite band, which the program-pipeline resolver mints out of separately.
|
||||
MGPipeHandle Allocate(MGPipeKind kind);
|
||||
// Allocate and remember `lifetimeId` as this handle's frontend identity.
|
||||
MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId);
|
||||
// The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap
|
||||
// address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per
|
||||
// object, so the map key is unique for the life of the process.
|
||||
MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const;
|
||||
// FindByLifetimeId, then AllocateFor when it misses. The ordinary client path.
|
||||
MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId);
|
||||
|
||||
// Returns the slot to the free list. The Gen bump happens on the NEXT handout of that
|
||||
// slot, not here, so a handle that is freed twice cannot skip a generation and the
|
||||
// "gen moves only on reuse" contract holds for an object that is never reused.
|
||||
void Free(MGPipeKind kind, MGPipeHandle handle);
|
||||
|
||||
Bool IsLive(MGPipeKind kind, MGPipeHandle handle) const;
|
||||
// 0 for a slot that was never handed out; the generation of the LAST handout
|
||||
// otherwise, live or not.
|
||||
Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const;
|
||||
Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const;
|
||||
// One past the highest slot ever handed out of this kind, i.e. what a server-side
|
||||
// slot-indexed table must be sized to.
|
||||
Uint32 HighWater(MGPipeKind kind) const;
|
||||
Uint32 LiveCount(MGPipeKind kind) const;
|
||||
Uint32 FreeCount(MGPipeKind kind) const;
|
||||
|
||||
// Context teardown / server reset / a unit test's fixture.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
struct SlotState {
|
||||
Uint32 Gen = 0;
|
||||
Bool Live = false;
|
||||
Bool EverHandedOut = false;
|
||||
Uint64 LifetimeId = 0;
|
||||
};
|
||||
|
||||
struct KindState {
|
||||
// Indexed by slot; [0] is the reserved slot and is never live.
|
||||
Vector<SlotState> Slots;
|
||||
Vector<Uint32> FreeList;
|
||||
UnorderedMap<Uint64, Uint32> ByLifetimeId;
|
||||
Uint32 LiveCount = 0;
|
||||
};
|
||||
|
||||
KindState& StateOf(MGPipeKind kind);
|
||||
const KindState& StateOf(MGPipeKind kind) const;
|
||||
|
||||
Array<KindState, kKindCount> m_kinds{};
|
||||
};
|
||||
|
||||
// The monolith's one client allocator. Under split there is one per client context.
|
||||
MGPipeSlotAllocator& MGPipeSlots();
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
@@ -0,0 +1,528 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/Tracker.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 <Includes.h>
|
||||
|
||||
// The frontend state tracker (ARCHITECTURE.md 5.2, P2 brief D4).
|
||||
//
|
||||
// WHERE IT RUNS. Not above MGP_FILL and not in the GL setter: MGPipeValidateForVerb, the
|
||||
// one statement MGP_FILL already expands to before every gBackendFunctionsTable.GL call
|
||||
// (PipeFill.h). Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), so a
|
||||
// setter that pushed would push twice per batch for a state the batch may not even read;
|
||||
// the validate point coalesces the whole bracket into the two draws that observe it
|
||||
// (ARCHITECTURE.md 5.1).
|
||||
//
|
||||
// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2,
|
||||
// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits
|
||||
// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and the
|
||||
// rest are still computed, latched and counted so the per-bit fire rate is a measurement
|
||||
// rather than a plan, with their fields going through the residual fill until P3b/P4a/P4b.
|
||||
//
|
||||
// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit
|
||||
// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the
|
||||
// dangerous direction precisely because the P1 verify comparator cannot see it for
|
||||
// object-class state (it compares those by identity only). So each shutter below is
|
||||
// deliberately coarser than the state it guards - five bits share one buffer aggregate,
|
||||
// the framebuffer bit fires on any attachment write anywhere - and the narrowing is P3's
|
||||
// work, paid for with the fire rates this file publishes.
|
||||
//
|
||||
// NO TIMER LIVES HERE. ROADMAP.md forbids committing hot-path instrumentation; the
|
||||
// absolute ns/draw comes from DriverBench, which times whole frames from outside the
|
||||
// library (P2 brief D17). The only counting is the per-bit fire tally, behind
|
||||
// PipeStats::Enabled() like every other counting site in the tree.
|
||||
//
|
||||
// HEADER-ONLY, and that is an ownership decision rather than a design one: the P2 brief
|
||||
// asks for Tracker.{h,cpp}, but the root CMakeLists.txt that would have to name a new .cpp
|
||||
// belongs to package A and is frozen behind the p2/contract tag. Everything here is
|
||||
// included by exactly one translation unit in the library (MG_Impl/Pipe/PipeFill.cpp) plus
|
||||
// the unit tests, so inline costs nothing. Splitting it back out is one list(APPEND) line.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/MGPipeValueTypes.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Metrics/PipeStats.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
// One bit per row of the ARCHITECTURE.md 5.2 table, hand-written rather than generated:
|
||||
// the list is design, not derived data, and the generator has nothing to derive it from.
|
||||
enum class MGPipeDirty : Uint32 {
|
||||
// ---- value class: P2 emits for these five ----
|
||||
NewRenderState = 0, // RenderState::m_version -> set_dynamic_state
|
||||
NewPipelineState, // RenderState::m_pipelineStateVersion -> create/bind_render_state
|
||||
NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state
|
||||
NewPatchState, // the patch trio, NaN legal -> set_patch_state
|
||||
NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults
|
||||
// ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a; the other three are
|
||||
// still computed and counted, and are emitted from P3b/P4a on ----
|
||||
NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements
|
||||
NewShader, // the current program's link version
|
||||
NewShaderBindings, // image units, block bindings, uniform write set
|
||||
NewGlobalConstants, // the default-uniform-block image
|
||||
// ---- object class. THE FIRST TWO ARE P3a's, not P3b/P4b's: the roadmap puts
|
||||
// set_vertex_buffers and set_index_buffer in the same phase as the vertex-elements
|
||||
// trio, and this comment said otherwise until the commit that wired them. The rest
|
||||
// are still computed and counted only. ----
|
||||
NewVertexBuffers, // -> set_vertex_buffers (P3a)
|
||||
NewIndexBuffer, // -> set_index_buffer (P3a)
|
||||
NewFramebuffer,
|
||||
NewSamplerViews,
|
||||
NewSamplers,
|
||||
NewShaderImages,
|
||||
NewConstBuffers,
|
||||
NewShaderBuffers,
|
||||
NewSoTargets,
|
||||
Count,
|
||||
};
|
||||
|
||||
inline constexpr SizeT kMGPipeDirtyCount = static_cast<SizeT>(MGPipeDirty::Count);
|
||||
static_assert(kMGPipeDirtyCount <= 32, "the dirty mask is a Uint32");
|
||||
|
||||
inline constexpr Uint32 MGPipeDirtyBit(MGPipeDirty bit) {
|
||||
return Uint32{1} << static_cast<Uint32>(bit);
|
||||
}
|
||||
|
||||
// The five P2 emits for. Each phase's constant survives as the next phase's A/B control
|
||||
// and as what a test compares the subsystem map against, so none of them is edited in
|
||||
// place when a later phase takes more bits over.
|
||||
inline constexpr Uint32 kMGPipeDirtyEmittedAtP2 =
|
||||
MGPipeDirtyBit(MGPipeDirty::NewRenderState) | MGPipeDirtyBit(MGPipeDirty::NewPipelineState) |
|
||||
MGPipeDirtyBit(MGPipeDirty::NewPixelPack) | MGPipeDirtyBit(MGPipeDirty::NewPatchState) |
|
||||
MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults);
|
||||
|
||||
// The three P3a adds: the vertex-input family, all on one subsystem.
|
||||
inline constexpr Uint32 kMGPipeDirtyEmittedAtP3a =
|
||||
kMGPipeDirtyEmittedAtP2 | MGPipeDirtyBit(MGPipeDirty::NewVertexElements) |
|
||||
MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers) | MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer);
|
||||
|
||||
inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = {
|
||||
"NEW_RENDER_STATE",
|
||||
"NEW_PIPELINE_STATE",
|
||||
"NEW_PIXEL_PACK",
|
||||
"NEW_PATCH_STATE",
|
||||
"NEW_VERTEX_ATTRIB_DEFAULTS",
|
||||
"NEW_VERTEX_ELEMENTS",
|
||||
"NEW_SHADER",
|
||||
"NEW_SHADER_BINDINGS",
|
||||
"NEW_GLOBAL_CONSTANTS",
|
||||
"NEW_VERTEX_BUFFERS",
|
||||
"NEW_INDEX_BUFFER",
|
||||
"NEW_FRAMEBUFFER",
|
||||
"NEW_SAMPLER_VIEWS",
|
||||
"NEW_SAMPLERS",
|
||||
"NEW_SHADER_IMAGES",
|
||||
"NEW_CONST_BUFFERS",
|
||||
"NEW_SHADER_BUFFERS",
|
||||
"NEW_SO_TARGETS",
|
||||
};
|
||||
|
||||
// Which runtime MOBILEGL_PIPE_PUSH subsystem bit gates a dirty bit's emission. Zero for
|
||||
// a bit P2 does not emit, which is what makes "the bitmask is a true per-subsystem A/B"
|
||||
// literally true rather than approximately.
|
||||
inline constexpr Uint64 MGPipeSubsystemForDirty(MGPipeDirty bit) {
|
||||
switch (bit) {
|
||||
case MGPipeDirty::NewRenderState:
|
||||
case MGPipeDirty::NewPipelineState:
|
||||
return kMGPipeSubsystemRenderState;
|
||||
case MGPipeDirty::NewPixelPack:
|
||||
return kMGPipeSubsystemPixelPack;
|
||||
case MGPipeDirty::NewPatchState:
|
||||
return kMGPipeSubsystemPatchState;
|
||||
case MGPipeDirty::NewVertexAttribDefaults:
|
||||
return kMGPipeSubsystemVertexAttribDefaults;
|
||||
// P3a's three, all one subsystem: create/bind_vertex_elements, set_vertex_buffers
|
||||
// and set_index_buffer are the vertex-input family and an operator switching it off
|
||||
// has to get the whole family's legacy arm, not two thirds of it.
|
||||
// PipeFill.cpp's SubsystemForEmitter carries the pairing static_asserts.
|
||||
case MGPipeDirty::NewVertexElements:
|
||||
case MGPipeDirty::NewVertexBuffers:
|
||||
case MGPipeDirty::NewIndexBuffer:
|
||||
return kMGPipeSubsystemVertexInput;
|
||||
default:
|
||||
// The remaining bits have no call of their own until P3b/P4a/P4b, so there is no
|
||||
// subsystem to switch and the residual fill keeps supplying their fields.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter.
|
||||
// It is a hash, so two different states can in principle collide and cost a MISSED fire.
|
||||
// The five bits P2 emits for are never composed - they are widened counters and byte
|
||||
// compares, neither of which can collide.
|
||||
//
|
||||
// P3a's three ARE composed, so the risk is now real rather than academic, and it is
|
||||
// accepted with its size stated: each mix takes a 64-bit input into a 64-bit
|
||||
// accumulator, so two DIFFERENT vertex configurations collide with probability ~2^-64
|
||||
// per pair, and the inputs are a monotone lifetime id, a monotone configuration version
|
||||
// and a widened slot version - none of which an application can steer. The alternative,
|
||||
// comparing the whole 32-attribute configuration byte for byte on every verb, is the
|
||||
// per-draw cost the shutter exists to avoid. The narrowing that removes the composition
|
||||
// for bit 10 - its own slot version plus the bound object's identity - is what this
|
||||
// phase already did to the one shutter that was composed over an unrelated aggregate.
|
||||
inline constexpr Uint64 MGPipeMixShutter(Uint64 accumulator, Uint64 value) {
|
||||
accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
// A Uint16 counter widened at the TRACKER boundary, never in MG_State
|
||||
// (ARCHITECTURE.md 5.2: MG_State is not changed for this). A decrease is a wrap and adds
|
||||
// 65536. A wrap is harmless locally - one extra re-push, never a missed one - which is
|
||||
// exactly what TrackerTest.WrapAroundRePushesButNeverMisses pins.
|
||||
//
|
||||
// THE ONE CASE IT CANNOT SEE, stated because "never a missed push" is otherwise stronger
|
||||
// than what is true: the wrap test is `now < m_last`, so a counter that advances by
|
||||
// EXACTLY 65536 (or a multiple) between two walks reads as unchanged. That needs 65536
|
||||
// render-state mutations inside one verb boundary, and it is pre-existing in class -
|
||||
// both backends already compare raw Uint16 versions the same way - so P2 records it
|
||||
// rather than widening MG_State's counters, which ARCHITECTURE.md 5.2 rules out.
|
||||
class MGPipeWidenedCounter {
|
||||
public:
|
||||
Uint64 Observe(Uint16 now) {
|
||||
if (m_started && now < m_last) m_high += 0x10000ull;
|
||||
m_started = true;
|
||||
m_last = now;
|
||||
return m_high + now;
|
||||
}
|
||||
void Reset() {
|
||||
m_high = 0;
|
||||
m_last = 0;
|
||||
m_started = false;
|
||||
}
|
||||
|
||||
private:
|
||||
Uint64 m_high = 0;
|
||||
Uint16 m_last = 0;
|
||||
Bool m_started = false;
|
||||
};
|
||||
|
||||
class MGPipeTracker {
|
||||
public:
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
|
||||
// The dirty walk. Compares every shutter against what was last pushed, LATCHES the
|
||||
// new values, counts the fires per verb class, and returns the mask. Latching here
|
||||
// rather than after emission is deliberate: a bit whose subsystem is switched off is
|
||||
// not emitted, but its fields are then still pulled by the residual fill, so the
|
||||
// pushed block is correct either way and a bit can never fire twice for one change.
|
||||
Uint32 Update(GLContext& ctx, MGPipeVerbClass verbClass) {
|
||||
// A different context is a different server: nothing the tracker latched about
|
||||
// the old one says anything about this one, and the first walk on a fresh
|
||||
// context must publish a COMPLETE state rather than an increment.
|
||||
if (m_context != &ctx) {
|
||||
Reset();
|
||||
m_context = &ctx;
|
||||
}
|
||||
const Bool wasPrimed = m_primed;
|
||||
|
||||
Uint64 now[kMGPipeDirtyCount];
|
||||
const RenderStateParameters& render = ctx.GetRenderStateParameters();
|
||||
|
||||
// ---- bits 0..1: the two Uint16 render-state counters, widened HERE ----
|
||||
now[Index(MGPipeDirty::NewRenderState)] =
|
||||
m_renderStateVersion.Observe(static_cast<Uint16>(ctx.GetRenderStateParametersVersion()));
|
||||
now[Index(MGPipeDirty::NewPipelineState)] =
|
||||
m_pipelineStateVersion.Observe(static_cast<Uint16>(ctx.GetPipelineStateVersion()));
|
||||
|
||||
// ---- bit 4 and the value-class bits 5..8 ----
|
||||
now[Index(MGPipeDirty::NewVertexAttribDefaults)] = ctx.GetAnyVertexAttribDefaultGeneration();
|
||||
|
||||
const auto& vao = ctx.GetBoundVertexArray();
|
||||
const Uint64 vaoIdentity =
|
||||
vao ? MGPipeMixShutter(vao->GetLifetimeId(), vao->GetConfigVersion()) : 0;
|
||||
now[Index(MGPipeDirty::NewVertexElements)] = vaoIdentity;
|
||||
|
||||
// Deliberately NOT GetProgramForDraw: that joins a pending link, and the tracker
|
||||
// must not force a compile just to answer "did the shader move". These version
|
||||
// counters are plain members and are exactly what the backends already read
|
||||
// without joining (Core.cpp, the glUseProgram half of join site J1).
|
||||
const auto& program = ctx.GetCurrentProgram();
|
||||
Uint64 shader = 0;
|
||||
Uint64 bindings = 0;
|
||||
Uint64 constants = 0;
|
||||
Uint64 programImages = 0;
|
||||
if (program) {
|
||||
shader = MGPipeMixShutter(program->GetLifetimeId(), program->GetLinkVersion());
|
||||
bindings = MGPipeMixShutter(
|
||||
MGPipeMixShutter(MGPipeMixShutter(program->GetImageUnitVersion(),
|
||||
program->GetBackendStateVersion()),
|
||||
program->GetBlockBindingVersion()),
|
||||
program->GetUniformWriteSetVersion());
|
||||
constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion());
|
||||
programImages = program->GetImageUnitVersion();
|
||||
}
|
||||
now[Index(MGPipeDirty::NewShader)] = shader;
|
||||
now[Index(MGPipeDirty::NewShaderBindings)] = bindings;
|
||||
now[Index(MGPipeDirty::NewGlobalConstants)] = constants;
|
||||
|
||||
// ---- the object-class bits 9..17 ----
|
||||
const Uint64 textureContent = ctx.GetAnyTextureContentGeneration();
|
||||
const Uint64 textureParams = ctx.GetAnyTextureParamsGeneration();
|
||||
const Uint64 buffers = ctx.GetAnyBufferChangeGeneration();
|
||||
|
||||
// Bit 9. The VAO attribute aggregate mixed with the bound VAO's identity is
|
||||
// already exact for the SET - it is bumped by all three Bump*Version functions,
|
||||
// which are the only writers of an attribute's format, buffer or enable state -
|
||||
// and a driver-id re-mint that moves no client counter is caught server-side by
|
||||
// the backend's own id generation.
|
||||
//
|
||||
// THE PENDING BASE INSTANCE IS MIXED IN, and this is a deviation from the design
|
||||
// note that said "keep the shutter" (recorded in client-v1.md): the draw's
|
||||
// baseInstance is now an EXPLICIT field of set_vertex_buffers and a
|
||||
// ContentHash input, and it moves neither the attribute aggregate nor the VAO
|
||||
// identity. Without it here, a draw whose only change is its base instance would
|
||||
// never reach the emitter at all and the server would keep the previous fetch
|
||||
// shift - which is the same silently-wrong-geometry the backend's
|
||||
// baseInstanceDirty flag exists to prevent, one level further out. It fires
|
||||
// extra only on the draws that actually carry one.
|
||||
now[Index(MGPipeDirty::NewVertexBuffers)] = MGPipeMixShutter(
|
||||
MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity), m_pendingBaseInstance);
|
||||
// Bit 10, NARROWED (P3a, D-I). It used to mix the whole buffer-CONTENT aggregate
|
||||
// with the VAO identity and therefore fired on any buffer write anywhere; what
|
||||
// it guards is one binding slot, so it now reads that slot's own version and the
|
||||
// identity of what is bound to it. The version is a WRAPPING Uint16 bumped only
|
||||
// on a real change, so it goes through the widened counter at this boundary; the
|
||||
// bound object's lifetime id joins it because identity is what closes the wrap
|
||||
// hole. The VAO identity stays in the mix because the element slot BELONGS to
|
||||
// the bound VAO - switching VAOs switches slots.
|
||||
Uint64 indexShutter = 0;
|
||||
if (vao) {
|
||||
const auto& indexSlot = vao->GetIndexBufferBindingSlot();
|
||||
const auto& indexObject = indexSlot.GetBoundObject();
|
||||
indexShutter = MGPipeMixShutter(m_indexSlotVersion.Observe(indexSlot.GetVersion()),
|
||||
indexObject ? indexObject->GetLifetimeId() : 0);
|
||||
}
|
||||
now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter);
|
||||
now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter(
|
||||
ctx.GetAnyFramebufferAttachmentGeneration(),
|
||||
m_framebufferBind.Observe(
|
||||
ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion()));
|
||||
now[Index(MGPipeDirty::NewSamplerViews)] =
|
||||
MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration());
|
||||
now[Index(MGPipeDirty::NewSamplers)] =
|
||||
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration());
|
||||
now[Index(MGPipeDirty::NewShaderImages)] =
|
||||
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages);
|
||||
now[Index(MGPipeDirty::NewConstBuffers)] = buffers;
|
||||
now[Index(MGPipeDirty::NewShaderBuffers)] = buffers;
|
||||
now[Index(MGPipeDirty::NewSoTargets)] =
|
||||
MGPipeMixShutter(buffers, ctx.GetTransformFeedbackGeneration());
|
||||
|
||||
Uint32 dirty = 0;
|
||||
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
|
||||
// Bits 2 and 3 are handled below: they are BitwiseEqual shutters, not
|
||||
// counters, so they have no entry in `now`.
|
||||
if (i == Index(MGPipeDirty::NewPixelPack) || i == Index(MGPipeDirty::NewPatchState)) {
|
||||
continue;
|
||||
}
|
||||
if (!m_primed || now[i] != m_lastPushed[i]) dirty |= Uint32{1} << static_cast<Uint32>(i);
|
||||
m_lastPushed[i] = now[i];
|
||||
}
|
||||
|
||||
// ---- bit 2: the PACK half of the pixel store, BitwiseEqual ----
|
||||
const PixelStoreParameters pack = ctx.GetPixelStoreParameters(false);
|
||||
if (!m_primed || std::memcmp(&pack, &m_pack, sizeof(pack)) != 0) {
|
||||
dirty |= MGPipeDirtyBit(MGPipeDirty::NewPixelPack);
|
||||
m_pack = pack;
|
||||
}
|
||||
|
||||
// ---- bit 3: the patch trio, BitwiseEqual, and NaN IS LEGAL ----
|
||||
// A NaN outer level is a legal glPatchParameterfv value and must compare equal to
|
||||
// itself (ARCHITECTURE.md 5.2). Float equality says it is not; memcmp says it is,
|
||||
// which is the whole reason this is a byte compare.
|
||||
PatchTrio patch{};
|
||||
patch.PatchVertices = render.PatchVertices;
|
||||
for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = render.PatchDefaultOuterLevel[i];
|
||||
for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = render.PatchDefaultInnerLevel[i];
|
||||
if (!m_primed || std::memcmp(&patch, &m_patch, sizeof(patch)) != 0) {
|
||||
dirty |= MGPipeDirtyBit(MGPipeDirty::NewPatchState);
|
||||
m_patch = patch;
|
||||
}
|
||||
|
||||
m_primed = true;
|
||||
m_freshlyPrimed = !wasPrimed;
|
||||
m_lastDirty = dirty;
|
||||
|
||||
if (MG_Util::PipeStats::Enabled()) {
|
||||
const SizeT cls = static_cast<SizeT>(verbClass);
|
||||
++m_walks[cls];
|
||||
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
|
||||
if (dirty & (Uint32{1} << static_cast<Uint32>(i))) ++m_fires[i][cls];
|
||||
}
|
||||
}
|
||||
return dirty;
|
||||
}
|
||||
|
||||
// Context teardown, server reset, a unit test's fixture. The next Update returns
|
||||
// every bit set, which is what makes the first verb on a fresh context publish a
|
||||
// complete state rather than an increment. Deliberately does NOT clear the fire
|
||||
// tallies: they are a per-run measurement, not per-context state.
|
||||
//
|
||||
// AND IT DELIBERATELY DOES NOT CLEAR m_pendingBaseInstance. Everything else this
|
||||
// function clears is a LATCH describing what the server was last told; the pending
|
||||
// base instance is THIS CALL'S ARGUMENT, written by the draw entry point one
|
||||
// statement before MGP_FILL and not yet read by anybody. Update() calls Reset() from
|
||||
// inside itself whenever the current GLContext pointer moves, so clearing it here
|
||||
// meant that `eglMakeCurrent(ctxB); glDrawArraysInstancedBaseInstance(..., 7)` put a
|
||||
// BaseInstance of 0 on the wire - one silently mis-shifted instanced draw per context
|
||||
// switch, on the emulation path, with nothing to catch it. The value is cleared by the
|
||||
// verb that consumes it (PipeFill.cpp's step 3, and its no-context early return) and
|
||||
// by MGPipeLeaveVerb, which is where a per-call argument belongs.
|
||||
void Reset() {
|
||||
std::memset(m_lastPushed, 0, sizeof(m_lastPushed));
|
||||
m_renderStateVersion.Reset();
|
||||
m_pipelineStateVersion.Reset();
|
||||
m_framebufferBind.Reset();
|
||||
m_indexSlotVersion.Reset();
|
||||
m_pack = PixelStoreParameters{};
|
||||
m_patch = PatchTrio{};
|
||||
m_staged = RenderStateParameters{};
|
||||
m_stagedAttribs = AttribDefaults{};
|
||||
m_context = nullptr;
|
||||
m_lastDirty = 0;
|
||||
m_primed = false;
|
||||
m_freshlyPrimed = false;
|
||||
}
|
||||
|
||||
void ResetCounters() {
|
||||
std::memset(m_fires, 0, sizeof(m_fires));
|
||||
std::memset(m_walks, 0, sizeof(m_walks));
|
||||
}
|
||||
|
||||
Uint64 FireCount(MGPipeDirty bit, MGPipeVerbClass verbClass) const {
|
||||
return m_fires[Index(bit)][static_cast<SizeT>(verbClass)];
|
||||
}
|
||||
Uint64 FireCount(MGPipeDirty bit) const {
|
||||
Uint64 total = 0;
|
||||
for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_fires[Index(bit)][i];
|
||||
return total;
|
||||
}
|
||||
Uint64 WalkCount(MGPipeVerbClass verbClass) const {
|
||||
return m_walks[static_cast<SizeT>(verbClass)];
|
||||
}
|
||||
Uint64 WalkCount() const {
|
||||
Uint64 total = 0;
|
||||
for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_walks[i];
|
||||
return total;
|
||||
}
|
||||
|
||||
Uint32 LastDirty() const { return m_lastDirty; }
|
||||
Bool Primed() const { return m_primed; }
|
||||
// True when the LAST Update was the first one after a Reset - a fresh context, or a
|
||||
// server reset. The emission step reads it to send a COMPLETE state rather than an
|
||||
// increment against a staging mirror that describes a context that is gone.
|
||||
Bool FreshlyPrimed() const { return m_freshlyPrimed; }
|
||||
|
||||
// "What the server has" (P2 brief D8). set_dynamic_state sends the dynamic chunks
|
||||
// that differ from this, which is the chunk-level suppressor; a chunk that
|
||||
// memcmp-matches is not sent at all.
|
||||
RenderStateParameters& Staged() { return m_staged; }
|
||||
const RenderStateParameters& Staged() const { return m_staged; }
|
||||
|
||||
// The same mirror for the 32 glVertexAttrib* defaults: set_vertex_attrib_defaults
|
||||
// names only the attributes that differ from it, which is the var-tail's own
|
||||
// suppressor underneath D11's set-hash one.
|
||||
using AttribDefaults = Array<MG_State::GLState::CurrentVertexAttributeValue,
|
||||
MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>;
|
||||
AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; }
|
||||
const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; }
|
||||
|
||||
// ---- P3a D-H2: the draw's vertex-FETCH base instance ----
|
||||
//
|
||||
// It lives HERE rather than in a file static because bit 9's shutter has to see it:
|
||||
// an ambient process global cannot cross a pushed boundary, and the value is now an
|
||||
// explicit field of set_vertex_buffers and an input to its content hash, so a draw
|
||||
// whose only change is its base instance has to reach the emitter. Set immediately
|
||||
// before the fill at the three *BaseInstance draw entry points; CONSUMED and cleared
|
||||
// by the validate point once it has been emitted, so a plain draw that follows one
|
||||
// sees 0 again.
|
||||
//
|
||||
// THE CLEAR THAT ACTUALLY RUNS IN PRODUCTION IS THE VALIDATE POINT'S. MGPipeLeaveVerb
|
||||
// clears it too, but no GL entry point calls MGPipeLeaveVerb - only MG_Test's
|
||||
// ScopedPipeVerb and TrackerTest do - so the production guarantee is entirely
|
||||
// PipeFill.cpp's, on BOTH of its exits: the end of step 3, and the no-live-context
|
||||
// early return that skips step 3 altogether. Reset() deliberately does not clear it
|
||||
// (see there): it is this call's argument, not a latch.
|
||||
void SetPendingBaseInstance(Uint32 baseInstance) { m_pendingBaseInstance = baseInstance; }
|
||||
Uint32 PendingBaseInstance() const { return m_pendingBaseInstance; }
|
||||
void ClearPendingBaseInstance() { m_pendingBaseInstance = 0; }
|
||||
|
||||
private:
|
||||
static constexpr SizeT Index(MGPipeDirty bit) { return static_cast<SizeT>(bit); }
|
||||
|
||||
struct PatchTrio {
|
||||
Uint PatchVertices;
|
||||
Float Outer[4];
|
||||
Float Inner[2];
|
||||
};
|
||||
|
||||
Uint64 m_lastPushed[kMGPipeDirtyCount]{};
|
||||
MGPipeWidenedCounter m_renderStateVersion;
|
||||
MGPipeWidenedCounter m_pipelineStateVersion;
|
||||
// The draw framebuffer BINDING slot version, widened for the same reason: a Uint16
|
||||
// that wrapped would let a composite shutter repeat and cost a missed fire.
|
||||
MGPipeWidenedCounter m_framebufferBind;
|
||||
// The BOUND VAO's element-array slot version, widened for the same reason. One
|
||||
// counter over a slot that changes with the bound VAO: a stale high word can only
|
||||
// ADD a fire, never drop one, and the VAO identity in the same mix is what makes a
|
||||
// switch between two VAOs differ whatever their slot versions read.
|
||||
MGPipeWidenedCounter m_indexSlotVersion;
|
||||
Uint32 m_pendingBaseInstance = 0;
|
||||
// Bits 2 and 3 are BitwiseEqual shutters, not counters.
|
||||
PixelStoreParameters m_pack{};
|
||||
PatchTrio m_patch{};
|
||||
|
||||
RenderStateParameters m_staged{};
|
||||
AttribDefaults m_stagedAttribs{};
|
||||
|
||||
const void* m_context = nullptr;
|
||||
Uint32 m_lastDirty = 0;
|
||||
Bool m_primed = false;
|
||||
Bool m_freshlyPrimed = false;
|
||||
|
||||
Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{};
|
||||
Uint64 m_walks[kMGPipeVerbClassCount]{};
|
||||
};
|
||||
|
||||
// ONE attribute default, flattened onto the wire (P2 brief D10). A named function rather
|
||||
// than four lines inside the emitter because this flattening is the whole correctness
|
||||
// question of set_vertex_attrib_defaults: a CurrentVertexAttributeValue is one value in
|
||||
// three views and GLContext converts NUMERICALLY between them, so four words alone are
|
||||
// not the value - glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in
|
||||
// floatValue. MGPAttribValue::ValueClass is what makes the four words readable again, and
|
||||
// TrackerAttribPayload pins that here instead of leaving it to the emitter's shape.
|
||||
inline void MGPipeFillAttribValue(Uint32 location,
|
||||
const MG_State::GLState::CurrentVertexAttributeValue& value,
|
||||
Uint32 writtenClass, MGPAttribValue& out) {
|
||||
out = MGPAttribValue{};
|
||||
out.Location = location;
|
||||
out.ValueClass = static_cast<Uint8>(writtenClass);
|
||||
static_assert(sizeof(out.Data) == sizeof(value.floatValue), "MGPAttribValue::Data is four words");
|
||||
switch (writtenClass) {
|
||||
case MG_State::GLState::kVertexAttribValueClassInt:
|
||||
std::memcpy(out.Data, value.intValue.data(), sizeof(out.Data));
|
||||
break;
|
||||
case MG_State::GLState::kVertexAttribValueClassUint:
|
||||
std::memcpy(out.Data, value.uintValue.data(), sizeof(out.Data));
|
||||
break;
|
||||
default:
|
||||
std::memcpy(out.Data, value.floatValue.data(), sizeof(out.Data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The monolith's one tracker. Under split there is one per client context; the context
|
||||
// identity check inside Update is what makes the single instance safe today.
|
||||
inline MGPipeTracker& MGPipeTrackerInstance() {
|
||||
static MGPipeTracker tracker;
|
||||
return tracker;
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
@@ -0,0 +1,438 @@
|
||||
// MobileGL - MobileGL/MG_Impl/Pipe/VertexInputEmit.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 <Includes.h>
|
||||
|
||||
// The CLIENT side of P3a's vertex-input family (brief D-G, D-H, D-I): the bound VAO's
|
||||
// format as create/bind_vertex_elements, its buffers as set_vertex_buffers with an explicit
|
||||
// baseInstance, and its element binding as set_index_buffer.
|
||||
//
|
||||
// UNLIKE THE RESOURCE FAMILY, these three emit at the VALIDATE POINT, from
|
||||
// MGPipeValidateForVerb's step 3 in the fixed order elements -> buffers -> index. That is
|
||||
// the ordinary rule (ARCHITECTURE.md 5.1); the resource family is the one exception to it.
|
||||
//
|
||||
// THE CSO IS IDENTITY-ADDRESSED, NOT CONTENT-ADDRESSED (D-G1, a recorded deviation from
|
||||
// ARCHITECTURE.md's 1024-entry content-addressed scheme). One handle per frontend
|
||||
// VertexArrayObject, minted off its lifetime id, and create_vertex_elements is RE-ISSUED on
|
||||
// the same handle whenever the configuration moves - legal, because MGPipeHandle::Gen
|
||||
// increments only on slot reuse and never on a respecify. Espryt has no vertex-elements CSO
|
||||
// to share: its twin owns one driver VAO name plus 64 scratch buffer ids, which two frontend
|
||||
// VAOs cannot share, so content addressing would be strictly slower on the only backend this
|
||||
// phase touches. P7 adds the hash-probe-memcmp layer above these same three calls when
|
||||
// Magma's VertexInputStateFactory takes the CSO over.
|
||||
//
|
||||
// WHAT THE UNIT GATE READS. G6 is "the emitted blob + set + index record reproduce exactly
|
||||
// what the backend's VAO twin reads from the frontend today, field by field, for all 32
|
||||
// slots", and G7 is a scripted control that stops the conversion copying ONE field and
|
||||
// expects the suite to go red NAMING it. So the conversion is a pure function per field
|
||||
// (MGPipeBuildVertexAttribWire / MGPipeBuildVertexBindingPointWire) and the staging buffers
|
||||
// the emitter builds into are readable afterwards - the emitter passes m_blob and m_entries
|
||||
// straight to the applier, so "what was emitted" costs no copy at all.
|
||||
//
|
||||
// HEADER-ONLY, for the ownership reason Tracker.h states in full.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Impl/Pipe/ResourceTracker.h>
|
||||
#include <MG_Impl/Pipe/SetHashSuppressor.h>
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Impl/Pipe/Tracker.h>
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Metrics/PipeStats.h>
|
||||
|
||||
#include <xxhash.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D-G2: the wire conversion, one pure function per view
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// EVERY FIELD OF VertexAttribute THE WIRE FORM CARRIES, and nothing else:
|
||||
//
|
||||
// Divisor is deliberately absent - it is resolved per binding point and travels in
|
||||
// MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads
|
||||
// it. Carrying it twice would let a malformed record disagree with itself.
|
||||
// LegacyStride / LegacyPointer are deliberately absent - they are the
|
||||
// glGetVertexAttrib* query answers and nothing but the query path reads them, so
|
||||
// they stay client-side.
|
||||
// Buffer is deliberately absent - identity travels in set_vertex_buffers, which is
|
||||
// what keeps this record stable while the buffers under it change.
|
||||
// Stride is the RESOLVED distance and a surviving 0 is MEANINGFUL: a pointer call's 0
|
||||
// was already resolved to the element size by the frontend, so a 0 here can only
|
||||
// have come from the binding model, where it means every vertex reads the SAME
|
||||
// element. Collapsing it back into the element size is what made
|
||||
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer.
|
||||
// IsLong travels SEPARATELY from Type == Float64: VertexAttribFormat(GL_DOUBLE) reads
|
||||
// doubles and asks for them converted to float, VertexAttribLFormat keeps all 64
|
||||
// bits, and the backend's fp64 narrowing and its Adreno disabled-attribute
|
||||
// workaround both key on telling the two apart.
|
||||
inline MGPVertexAttribWire MGPipeBuildVertexAttribWire(const MG_State::GLState::VertexAttribute& attrib,
|
||||
Uint32 bindingIndex) {
|
||||
// ASSERT RATHER THAN ASSUME, in both directions, because the three narrowing casts
|
||||
// below cross a package boundary: VertexArrayObject is another package's file and its
|
||||
// 32-slot bound is its invariant, not this one's, so a BindingIndex of 256 would wrap
|
||||
// to 0 and silently point every attribute at binding 0, and a negative Stride (the
|
||||
// frontend field is a signed int) would arrive as a ~4 GiB unsigned distance.
|
||||
MOBILEGL_ASSERT(bindingIndex < 256u,
|
||||
"MGPVertexAttribWire::BindingIndex is a Uint8 and cannot carry %u",
|
||||
static_cast<Uint>(bindingIndex));
|
||||
MOBILEGL_ASSERT(attrib.Size >= 0 && attrib.Size <= 255,
|
||||
"MGPVertexAttribWire::Size is a Uint8 and cannot carry %d", attrib.Size);
|
||||
MGPVertexAttribWire wire{};
|
||||
wire.Offset = static_cast<Uint64>(attrib.Offset);
|
||||
wire.Stride = static_cast<Int32>(attrib.Stride);
|
||||
wire.Type = static_cast<Uint32>(attrib.Type);
|
||||
wire.Size = static_cast<Uint8>(attrib.Size);
|
||||
wire.Enabled = attrib.Enabled ? 1 : 0;
|
||||
wire.Normalized = attrib.Normalized ? 1 : 0;
|
||||
wire.IsInteger = attrib.IsInteger ? 1 : 0;
|
||||
wire.IsLong = attrib.IsLong ? 1 : 0;
|
||||
wire.IsBgra = attrib.IsBgra ? 1 : 0;
|
||||
wire.BindingIndex = static_cast<Uint8>(bindingIndex);
|
||||
return wire;
|
||||
}
|
||||
|
||||
// The ARB_vertex_attrib_binding view. Its initial Stride is 16, not 0 (GL 4.6 core table
|
||||
// 23.4), which is why the wire form keeps it signed and copies it verbatim.
|
||||
inline MGPVertexBindingPointWire
|
||||
MGPipeBuildVertexBindingPointWire(const MG_State::GLState::VertexBufferBindingPoint& point) {
|
||||
MGPVertexBindingPointWire wire{};
|
||||
wire.Offset = static_cast<Uint64>(point.Offset);
|
||||
wire.Stride = static_cast<Int32>(point.Stride);
|
||||
wire.Divisor = static_cast<Uint32>(point.Divisor);
|
||||
return wire;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// D-H2.3: the content hash, WITH BaseInstance in it
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// A HARD REQUIREMENT, not a nicety. set_vertex_buffers is suppressed on an unchanged
|
||||
// hash (SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while
|
||||
// the buffer set did not would be suppressed and the server would keep the previous
|
||||
// fetch shift - exactly the bug the backend's baseInstanceDirty flag exists to prevent.
|
||||
inline Uint64 MGPipeVertexBufferSetContentHash(const MGPVertexBuffer* entries, Uint32 start, Uint32 count,
|
||||
Uint32 baseInstance) {
|
||||
Uint64 hash = XXH64(entries, static_cast<SizeT>(count) * sizeof(MGPVertexBuffer), 0);
|
||||
hash = MGPipeMixShutter(hash, start);
|
||||
hash = MGPipeMixShutter(hash, count);
|
||||
hash = MGPipeMixShutter(hash, baseInstance);
|
||||
return hash;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The emitter
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
class MGPipeVertexInputEmitter {
|
||||
public:
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
using VertexArrayObject = MG_State::GLState::VertexArrayObject;
|
||||
static constexpr SizeT kAttribs = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIBS);
|
||||
static constexpr SizeT kBindings = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS);
|
||||
static_assert(kAttribs <= kMGPipeMaxVertexAttribs && kBindings <= kMGPipeMaxVertexAttribs,
|
||||
"both declared counts are bounded by kMGPipeMaxVertexAttribs");
|
||||
|
||||
// create/bind_vertex_elements. D-G3's three arms, verbatim:
|
||||
//
|
||||
// no VAO bound -> bind the null handle (legal, and it means
|
||||
// exactly "no VAO bound")
|
||||
// the bound VAO CHANGED -> (re)create if its configuration moved since
|
||||
// this handle last published one, then bind
|
||||
// the same VAO, configuration MOVED-> create on the SAME handle, and do NOT rebind
|
||||
//
|
||||
// The latch is PER HANDLE, in a slot-indexed table, so ping-ponging between two VAOs
|
||||
// re-binds but never re-creates either. A Uint32 configuration version does not wrap
|
||||
// in any realistic run and is compared directly; the tracker's widened counter is
|
||||
// for the Uint16s and is not needed here.
|
||||
Uint64 EmitVertexElements(GLContext& ctx) {
|
||||
const auto& vao = ctx.GetBoundVertexArray();
|
||||
if (!vao) {
|
||||
if (!MGPipeHandleIsNull(m_boundHandle)) {
|
||||
MGPipeApplyBindVertexElements(HandleOnly(kMGPipeNullHandle));
|
||||
++m_binds;
|
||||
m_boundHandle = kMGPipeNullHandle;
|
||||
m_boundLifetimeId = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
const Uint32 configVersion = vao->GetConfigVersion();
|
||||
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::VertexElementsCso, lifetimeId);
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_latch.size()) m_latch.resize(slot + 1);
|
||||
Latch& latch = m_latch[slot];
|
||||
|
||||
Uint64 bytes = 0;
|
||||
const Bool configMoved = !latch.Published || latch.ConfigVersion != configVersion ||
|
||||
latch.Gen != handle.Gen;
|
||||
if (configMoved) bytes += EmitCreate(*vao, handle, latch, configVersion);
|
||||
if (lifetimeId != m_boundLifetimeId || m_boundHandle != handle) {
|
||||
MGPipeApplyBindVertexElements(HandleOnly(handle));
|
||||
++m_binds;
|
||||
bytes += sizeof(MGPHandleOnly);
|
||||
m_boundHandle = handle;
|
||||
m_boundLifetimeId = lifetimeId;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// set_vertex_buffers. Espryt consumes RESOLVED attributes, so the set is one entry
|
||||
// per attribute slot with BindingIndex == the attribute index; Start is 0 and Count
|
||||
// is the highest ENABLED attribute plus one, which is the 32-slot prefix walk the
|
||||
// dirty bit is specified over.
|
||||
//
|
||||
// A client-memory array is Res == kMGPipeNullHandle, and that is not a hole: it is
|
||||
// exactly how the server learns "this attribute is client-sourced, upload it
|
||||
// yourself". Its store genuinely does not exist at this moment - the client-array
|
||||
// uploader runs after PrepareForDraw, at the draw entry point - and moving that
|
||||
// resolution to the client is P8's.
|
||||
Uint64 EmitVertexBuffers(GLContext& ctx, Uint32 baseInstance) {
|
||||
const auto& vao = ctx.GetBoundVertexArray();
|
||||
Uint32 count = 0;
|
||||
if (vao) {
|
||||
for (SizeT i = 0; i < kAttribs; ++i) {
|
||||
if (vao->GetAttribute(static_cast<Uint>(i)).Enabled) count = static_cast<Uint32>(i) + 1;
|
||||
}
|
||||
for (SizeT i = 0; i < count; ++i) {
|
||||
const auto& attrib = vao->GetAttribute(static_cast<Uint>(i));
|
||||
MGPVertexBuffer& entry = m_entries[i];
|
||||
entry = MGPVertexBuffer{};
|
||||
entry.Res = attrib.Buffer ? MGPipeSlots().Acquire(MGPipeKind::Buffer,
|
||||
attrib.Buffer->GetLifetimeId())
|
||||
: kMGPipeNullHandle;
|
||||
// D-A3's sticky mask, ORed HERE rather than only sampled at a storage op.
|
||||
// This is the bit that survives the DSA idiom: a buffer defined through
|
||||
// glNamedBuffer* may never be bound at any resource emission, but a draw
|
||||
// that fetches from it resolves it right here, on the GL thread, at every
|
||||
// draw. Sticky, so one draw is enough for the rest of its life.
|
||||
MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, BufferTarget::Vertex);
|
||||
// The attribute's own byte offset lives in MGPVertexAttribWire::Offset,
|
||||
// so the entry's is the BINDING's, which the frontend already folded in.
|
||||
entry.Offset = 0;
|
||||
// Signed on the frontend, unsigned on the wire, and a negative one would
|
||||
// arrive as a ~4 GiB fetch distance rather than as an error.
|
||||
MOBILEGL_ASSERT(attrib.Stride >= 0, "a resolved vertex stride is never negative (%d)",
|
||||
attrib.Stride);
|
||||
entry.Stride = static_cast<Uint32>(attrib.Stride);
|
||||
entry.Divisor = static_cast<Uint32>(attrib.Divisor);
|
||||
entry.BindingIndex = static_cast<Uint32>(i);
|
||||
}
|
||||
}
|
||||
|
||||
const Uint64 hash = MGPipeVertexBufferSetContentHash(m_entries.data(), 0, count, baseInstance);
|
||||
if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, hash)) {
|
||||
return 0;
|
||||
}
|
||||
m_lastBuffers = MGPVertexBuffers{};
|
||||
m_lastBuffers.Start = 0;
|
||||
m_lastBuffers.Count = count;
|
||||
// THE DRAW'S RAW value. The client never pre-shifts an offset and never learns
|
||||
// whether the server emulated the shift or let GL_EXT_base_instance do it -
|
||||
// emulation is server-owned.
|
||||
m_lastBuffers.BaseInstance = baseInstance;
|
||||
m_lastBuffers.ContentHash = hash;
|
||||
MGPipeApplySetVertexBuffers(m_lastBuffers, m_entries.data());
|
||||
++m_bufferSets;
|
||||
return sizeof(MGPVertexBuffers) + static_cast<Uint64>(count) * sizeof(MGPVertexBuffer);
|
||||
}
|
||||
|
||||
// set_index_buffer. An INDEPENDENT call, not a subset of the vertex-elements
|
||||
// configuration version (D5) - the index slot is explicitly outside the VAO's
|
||||
// m_configVersion, and the shutter for it is bit 10's, narrowed in Tracker.h.
|
||||
//
|
||||
// Offset and IndexSize are 0 here and the draw verb overrides them: at the validate
|
||||
// point there is no draw to read them from, and the applier stores what it is given.
|
||||
Uint64 EmitIndexBuffer(GLContext& ctx) {
|
||||
const auto& vao = ctx.GetBoundVertexArray();
|
||||
m_lastIndex = MGPIndexBuffer{};
|
||||
if (vao) {
|
||||
if (const auto& bound = vao->GetIndexBufferBindingSlot().GetBoundObject()) {
|
||||
m_lastIndex.Res = MGPipeSlots().Acquire(MGPipeKind::Buffer, bound->GetLifetimeId());
|
||||
// The ELEMENT_ARRAY bit, and it is the one the split path keys on
|
||||
// (kCapNeedsHostIndexBytes -> restart rewriting, multi-draw flattening).
|
||||
// Noted at every draw for RefreshBindMask's reason: an EBO defined through
|
||||
// DSA and unbound before its last respecify would otherwise never publish
|
||||
// it, and getting that bit wrong is invisible in monolith.
|
||||
MGPipeResourceTrackerInstance().NoteBoundAs(m_lastIndex.Res, BufferTarget::Index);
|
||||
}
|
||||
}
|
||||
MGPipeApplySetIndexBuffer(m_lastIndex);
|
||||
++m_indexSets;
|
||||
return sizeof(MGPIndexBuffer);
|
||||
}
|
||||
|
||||
// ---- what a unit case reads. None of it costs a copy: the emitter builds INTO
|
||||
// these and hands the applier the same pointers. ----
|
||||
const Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs>& LastAttributes() const { return m_attributes; }
|
||||
const Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs>& LastBindingPoints() const {
|
||||
return m_bindingPoints;
|
||||
}
|
||||
const MGPVertexElements& LastElements() const { return m_lastElements; }
|
||||
const MGPVertexBuffers& LastVertexBuffers() const { return m_lastBuffers; }
|
||||
const Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs>& LastEntries() const { return m_entries; }
|
||||
const MGPIndexBuffer& LastIndexBuffer() const { return m_lastIndex; }
|
||||
MGPipeHandle BoundHandle() const { return m_boundHandle; }
|
||||
Uint64 CreateCount() const { return m_creates; }
|
||||
Uint64 BindCount() const { return m_binds; }
|
||||
Uint64 VertexBufferSetCount() const { return m_bufferSets; }
|
||||
Uint64 IndexBufferSetCount() const { return m_indexSets; }
|
||||
|
||||
// ---- C-1: "does the applier hold a record for exactly this handle?" ----
|
||||
//
|
||||
// The CSO's death path (MGPipeEmitVertexElementsDestroyAndFree) needs that answer and
|
||||
// MUST NOT GUESS IT FROM THE SLOT. A VertexElementsCso slot can exist with no record
|
||||
// behind it, because a backend that keys its twins on the handle mints the slot itself
|
||||
// (DirectGLES' BackendSlotTable::GetOrCreate -> MGPipeSlots().Acquire) whether or not
|
||||
// bit 8 ever asked this client to emit anything - which is exactly what a
|
||||
// MOBILEGL_PIPE_PUSH=0x7f lane runs. delete_vertex_elements on such a handle is a
|
||||
// REFUSED call, and the applier's resolver asserts on a refusal
|
||||
// (PipeApply.cpp's ResolveVertexElements), i.e. a stop in a verify build.
|
||||
//
|
||||
// Kept OUT of Reset(), unlike the create/bind latch beside it, and for the mirror
|
||||
// image of Reset()'s own reason: "a fresh context is a fresh server" is true of the
|
||||
// per-context half of this table, and object RECORDS are precisely what
|
||||
// MGPipeApplierReset does not clear (PipeApply.h's two halves). This half tracks those
|
||||
// records, so it lives exactly as long as they do.
|
||||
Bool RecordIsPublished(MGPipeHandle handle) const {
|
||||
if (MGPipeHandleIsNull(handle)) return false;
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_latch.size()) return false;
|
||||
const Latch& latch = m_latch[slot];
|
||||
return latch.RecordLive && latch.RecordGen == handle.Gen;
|
||||
}
|
||||
|
||||
// The record named by `handle` is gone from the applier. Also drops the bound-handle
|
||||
// memo when it named it, so the client's idea of BoundVertexElements and the applier's
|
||||
// (which MGPipeApplyDeleteVertexElements just cleared for the same handle) stay in
|
||||
// step rather than diverging until the next bind happens to correct it.
|
||||
void NoteRecordDestroyed(MGPipeHandle handle) {
|
||||
if (MGPipeHandleIsNull(handle)) return;
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot < m_latch.size() && m_latch[slot].RecordGen == handle.Gen) {
|
||||
m_latch[slot] = Latch{};
|
||||
}
|
||||
if (m_boundHandle == handle) {
|
||||
m_boundHandle = kMGPipeNullHandle;
|
||||
m_boundLifetimeId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh context is a fresh server: the applier's records are gone, so every latch
|
||||
// this emitter holds describes objects the server no longer has. Called from the
|
||||
// validate point's FreshlyPrimed arm beside MGPipeApplierReset and the suppressor's
|
||||
// InvalidateAll, for the same reason they are.
|
||||
//
|
||||
// The PER-CONTEXT half only - see RecordIsPublished above for why RecordLive/RecordGen
|
||||
// survive. Re-creating a configuration the applier already holds is a bounded
|
||||
// over-fire (MGPipeApplyCreateVertexElements starts the record over); forgetting that
|
||||
// it holds one at all would leak the record and its slot at the object's death.
|
||||
void Reset() {
|
||||
for (Latch& latch : m_latch) {
|
||||
latch.Published = false;
|
||||
latch.Gen = 0;
|
||||
latch.ConfigVersion = 0;
|
||||
}
|
||||
m_boundHandle = kMGPipeNullHandle;
|
||||
m_boundLifetimeId = 0;
|
||||
}
|
||||
|
||||
void ResetCounters() { m_creates = m_binds = m_bufferSets = m_indexSets = 0; }
|
||||
|
||||
private:
|
||||
struct Latch {
|
||||
// The PER-CONTEXT half: "has this emitter told THIS server about this handle's
|
||||
// configuration". Cleared by Reset() at every make-current.
|
||||
Bool Published = false;
|
||||
Uint32 Gen = 0;
|
||||
Uint32 ConfigVersion = 0;
|
||||
// The RECORD half: "does the applier hold a create_vertex_elements record at this
|
||||
// slot, for this generation". Lives as long as the record does - see
|
||||
// RecordIsPublished.
|
||||
Bool RecordLive = false;
|
||||
Uint32 RecordGen = 0;
|
||||
};
|
||||
|
||||
static MGPHandleOnly HandleOnly(MGPipeHandle handle) {
|
||||
MGPHandleOnly only{};
|
||||
only.Handle = handle;
|
||||
only.Kind = static_cast<Uint32>(MGPipeKind::VertexElementsCso);
|
||||
return only;
|
||||
}
|
||||
|
||||
Uint64 EmitCreate(const VertexArrayObject& vao, MGPipeHandle handle, Latch& latch, Uint32 configVersion) {
|
||||
// ALL 32 OF EACH, deliberately. The record DECLARES both counts and the applier
|
||||
// refuses one whose counts do not describe its own blob, so a self-describing
|
||||
// record is the cheap shape - and G6 is stated over all 32 slots, which a
|
||||
// truncated set could not answer. It rides create_vertex_elements only, i.e.
|
||||
// once per configuration change, never per draw.
|
||||
for (SizeT i = 0; i < kAttribs; ++i) {
|
||||
m_attributes[i] = MGPipeBuildVertexAttribWire(vao.GetAttribute(static_cast<Uint>(i)),
|
||||
vao.GetAttributeBindingIndex(static_cast<Uint>(i)));
|
||||
}
|
||||
for (SizeT i = 0; i < kBindings; ++i) {
|
||||
m_bindingPoints[i] = MGPipeBuildVertexBindingPointWire(vao.GetBindingPoint(static_cast<Uint>(i)));
|
||||
}
|
||||
// Attributes first, then binding points, both ascending and contiguous.
|
||||
constexpr SizeT kAttribBytes = kAttribs * sizeof(MGPVertexAttribWire);
|
||||
constexpr SizeT kBindingBytes = kBindings * sizeof(MGPVertexBindingPointWire);
|
||||
std::memcpy(m_blob.data(), m_attributes.data(), kAttribBytes);
|
||||
std::memcpy(m_blob.data() + kAttribBytes, m_bindingPoints.data(), kBindingBytes);
|
||||
|
||||
m_lastElements = MGPVertexElements{};
|
||||
m_lastElements.Cso = handle;
|
||||
m_lastElements.AttributeCount = static_cast<Uint32>(kAttribs);
|
||||
m_lastElements.BindingPointCount = static_cast<Uint32>(kBindings);
|
||||
m_lastElements.Blob.Seg = kMGHostSpanSegNone;
|
||||
m_lastElements.Blob.Offset = 0;
|
||||
m_lastElements.Blob.Size = kAttribBytes + kBindingBytes;
|
||||
MGPipeApplyCreateVertexElements(m_lastElements, m_blob.data());
|
||||
++m_creates;
|
||||
latch.Published = true;
|
||||
latch.Gen = handle.Gen;
|
||||
latch.ConfigVersion = configVersion;
|
||||
// THE ONE PRODUCER of the record half: a create that reached the applier is the
|
||||
// only thing that makes delete_vertex_elements a legal call for this handle.
|
||||
latch.RecordLive = true;
|
||||
latch.RecordGen = handle.Gen;
|
||||
return sizeof(MGPVertexElements) + kAttribBytes + kBindingBytes;
|
||||
}
|
||||
|
||||
Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs> m_attributes{};
|
||||
Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs> m_bindingPoints{};
|
||||
Array<Uint8, kMGPipeMaxVertexAttribs *(sizeof(MGPVertexAttribWire) + sizeof(MGPVertexBindingPointWire))>
|
||||
m_blob{};
|
||||
Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs> m_entries{};
|
||||
|
||||
MGPVertexElements m_lastElements{};
|
||||
MGPVertexBuffers m_lastBuffers{};
|
||||
MGPIndexBuffer m_lastIndex{};
|
||||
|
||||
Vector<Latch> m_latch;
|
||||
MGPipeHandle m_boundHandle = kMGPipeNullHandle;
|
||||
Uint64 m_boundLifetimeId = 0;
|
||||
|
||||
Uint64 m_creates = 0;
|
||||
Uint64 m_binds = 0;
|
||||
Uint64 m_bufferSets = 0;
|
||||
Uint64 m_indexSets = 0;
|
||||
};
|
||||
|
||||
// The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the
|
||||
// set-hash suppressor and the resource tracker.
|
||||
inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() {
|
||||
static MGPipeVertexInputEmitter emitter;
|
||||
return emitter;
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
@@ -52,6 +52,7 @@ add_executable(MobileGLIntegrationTest
|
||||
Main.cpp
|
||||
Harness/HeadlessGL.cpp
|
||||
Harness/BackendCapsPeek.cpp
|
||||
Harness/PipeSlotPeek.cpp
|
||||
Scenarios/OrientationScenario.cpp
|
||||
Scenarios/CrossFrameBufferScenario.cpp
|
||||
Scenarios/ResidentIndexScenario.cpp
|
||||
@@ -129,6 +130,9 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/DualSourceBlendScenario.cpp
|
||||
Scenarios/PipeVerifyArmingScenario.cpp
|
||||
Scenarios/PoisonOmissionScenario.cpp
|
||||
Scenarios/HandleRecycleScenario.cpp
|
||||
Scenarios/CsoContentAddressingScenario.cpp
|
||||
Scenarios/ResourceSubsystemControlScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MobileGLIntegrationTest PRIVATE
|
||||
@@ -318,6 +322,190 @@ function(mgl_itest_join_environment outVar)
|
||||
set(${outVar} "${joined}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# --- what THIS TREE implements, answered by the build rather than by a person ----------
|
||||
#
|
||||
# Two P2 entries assert something that only EXISTS once another P2 package has landed:
|
||||
# HandleRecycleScenario's Handles arm needs a backend keyed on {slot, gen} (packages C and D),
|
||||
# its AbaControl arm needs a consumer for MOBILEGL_PIPE_HANDLE_ABA_CONTROL (package D), and
|
||||
# CsoContentAddressingScenario needs the client-side tracker that mints CSOs at all (package B).
|
||||
# The gates package is written and merged FIRST, against the P2 contract commit, precisely so
|
||||
# that the AbaControl red is on the record before either backend is touched - so for a while
|
||||
# those entries have nothing to assert.
|
||||
#
|
||||
# The honest report for that is a SKIP naming what is missing, never a deleted registration and
|
||||
# never a green that means "the thing I test does not exist yet". What decides the skip is
|
||||
# THIS block, so that nobody has to remember to remove a hand-written guard:
|
||||
#
|
||||
# * two of the three answers are pure EXISTENCE checks, through file(GLOB CONFIGURE_DEPENDS).
|
||||
# Ninja re-evaluates such a glob before every build and reconfigures only when the RESULT
|
||||
# changes, so these cost nothing until the file appears - and then they arm themselves.
|
||||
# * the third has to read a file's CONTENTS, because package D re-keys inside an existing
|
||||
# source rather than adding one. VertexInputStateFactory.cpp is the one file both of D's
|
||||
# answers live in (ComputeHash's buffer key is what the re-key changes AND what the ABA
|
||||
# knob reverts), it is small, and it is watched by name - so an edit to it reconfigures and
|
||||
# an edit anywhere else in the backend does not.
|
||||
#
|
||||
# Every verdict is printed at configure time: a marker that silently answered "no" for a tree
|
||||
# that does implement the thing would turn a real gate into a permanent skip.
|
||||
set(MGL_ITEST_CAPABILITY_ENV "")
|
||||
|
||||
# Whether the library under test compiled the push arm. Passed in rather than inferred, because
|
||||
# the two CSO counters and the cso[] bracket of the stats line are #if MOBILEGL_PIPE_PUSH: in a
|
||||
# pull build there is no CSO to mint and no channel to read, so the control has nothing to say -
|
||||
# and "nothing to say" must be a SKIP that names the reason, not an assertion failure about a
|
||||
# missing bracket.
|
||||
#
|
||||
# The lanes themselves are registered in BOTH builds even so. `ctest -L integration-gpu` has to
|
||||
# be name-for-name identical between the pull build and the push build (P2 gate G2), and a lane
|
||||
# that exists in only one of them breaks that comparison for every future package - a much worse
|
||||
# outcome than four entries that skip.
|
||||
if (MOBILEGL_PIPE_PUSH)
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_PUSH_BUILD=1")
|
||||
endif()
|
||||
|
||||
# THE THREE MARKERS BELOW ANSWER A QUESTION ABOUT THE SOURCE TREE, so each is only a true
|
||||
# statement about THIS LIBRARY while this build compiles the arm the source implements - and all
|
||||
# three arms are `#if MOBILEGL_PIPE_PUSH`. A pull build has no {slot, gen} key (the slot tables
|
||||
# and the re-keyed memos are push-only) and no Features.PipeHandleAbaControl at all (Config.h
|
||||
# declares the field inside `#if MOBILEGL_PIPE_PUSH` and ConfigLoader parses it in the same arm).
|
||||
# A source-only probe would therefore arm the PULL build's lanes the moment packages C and D
|
||||
# land: the AbaControl lane would go hard red on a gate G2 requires green (the guards it means to
|
||||
# defeat are still in force, so the scenario's "expect the stale pixels" assertion fails), and the
|
||||
# Handles lane would report green against a library that contains no re-key at all - the
|
||||
# "test that cannot fail" this scenario exists to avoid.
|
||||
#
|
||||
# So the whole block sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and
|
||||
# HandleRecycleScenario re-checks that marker before either arm asserts, so a hand-forced
|
||||
# environment cannot arm an arm this build does not have either.
|
||||
#
|
||||
# ALL FOUR MARKERS ARE CONTENT PROBES, AND NONE OF THEM NAMES A FILE. A probe for a filename asks
|
||||
# the wrong question: the owning package chooses its own file layout, so the moment it moves the
|
||||
# code the probe answers "no" forever and the arm skips with a reason that has become false - a
|
||||
# test quietly measuring nothing, which is the one outcome this whole scenario exists to prevent.
|
||||
# The CSO probe was rewritten for exactly that reason once already; the magma probe still read one
|
||||
# hard-coded .cpp, and package D already keeps one of its two Features.PipeHandleAbaControl
|
||||
# consumers in a different file of the same directory (Renderer/VulkanRenderer.cpp), so it was one
|
||||
# refactor away from a permanent AbaControl skip. So all four now ask "does any source in the
|
||||
# directory the owning package owns name this symbol?", which is the thing each arm actually needs.
|
||||
#
|
||||
# Staleness cannot creep in from either side: the GLOB is CONFIGURE_DEPENDS (a file added or
|
||||
# removed re-runs it) and every file it finds is appended to CMAKE_CONFIGURE_DEPENDS (an edit to
|
||||
# one re-runs it).
|
||||
function(mgl_itest_probe_for_symbol outVar directory symbolRegex)
|
||||
file(GLOB_RECURSE mglItestProbeSources CONFIGURE_DEPENDS
|
||||
"${directory}/*.h" "${directory}/*.hpp" "${directory}/*.cpp" "${directory}/*.c")
|
||||
set(mglItestProbeHit "")
|
||||
foreach(mglItestProbeSource IN LISTS mglItestProbeSources)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestProbeSource}")
|
||||
file(STRINGS "${mglItestProbeSource}" mglItestProbeLines REGEX "${symbolRegex}")
|
||||
if (mglItestProbeLines AND NOT mglItestProbeHit)
|
||||
set(mglItestProbeHit "${mglItestProbeSource}")
|
||||
endif()
|
||||
endforeach()
|
||||
set(${outVar} "${mglItestProbeHit}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
if (MOBILEGL_PIPE_PUSH)
|
||||
# DirectGLES' Track H arm, probed by the subsystem bit it is gated on rather than by
|
||||
# SlotTables.h existing: the bit is declared in the contract (MG_Pipe/MGPipe.h:77) and the
|
||||
# backend has to name it to honour MOBILEGL_PIPE_PUSH's default mask, whatever files package C
|
||||
# spreads the slot tables across.
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_ESPRYT_SLOTS
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES" "kMGPipeSubsystemEsprytSlots")
|
||||
if (MGL_ITEST_ESPRYT_SLOTS)
|
||||
message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (${MGL_ITEST_ESPRYT_SLOTS})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no DirectGLES source names kMGPipeSubsystemEsprytSlots - "
|
||||
"HandleRecycle.Handles will SKIP on it")
|
||||
endif()
|
||||
|
||||
# The CSO counters' EMITTER. The tracker package may implement the tracker and the cache
|
||||
# header-only - today it does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so what is
|
||||
# looked for is what the control actually reads: a source emitting the two counters.
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_CSO_EMITTER
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "RenderStateCso(Mints|Binds)")
|
||||
if (MGL_ITEST_CSO_EMITTER)
|
||||
message(STATUS "Integration tests: the CSO counters have an emitter (${MGL_ITEST_CSO_EMITTER})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no MG_Impl/Pipe source emits RenderStateCsoMints/Binds - "
|
||||
"CsoContentAddressing will SKIP")
|
||||
endif()
|
||||
|
||||
# DirectVulkan's Track H arm, and the ABA knob's consumer. Both over the whole backend
|
||||
# directory: the re-key is subsystem 4's bit wherever package D reads it, and the knob has a
|
||||
# consumer if ANY DirectVulkan source reverts a guard on it - today two do, in two files.
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_REKEY
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "kMGPipeSubsystemMagmaVertexInput")
|
||||
if (MGL_ITEST_MAGMA_REKEY)
|
||||
message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen} "
|
||||
"(${MGL_ITEST_MAGMA_REKEY})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no DirectVulkan source names kMGPipeSubsystemMagmaVertexInput - "
|
||||
"HandleRecycle.Handles will SKIP on it")
|
||||
endif()
|
||||
|
||||
# P3a's buffer question, and it is NOT the two above. Bits 5/6 re-keyed each backend's
|
||||
# VERTEX-INPUT memos; a BUFFER only travels as a handle once the resource_* family does
|
||||
# (P3a for DirectGLES, P7 for DirectVulkan), and until then a buffer's backend twin is
|
||||
# still reached from the frontend BufferObject. HandleRecycle's buffer case therefore has
|
||||
# a marker of its own: reading the P2 one would arm its Handles arm on a tree where
|
||||
# nothing about a buffer is keyed on a handle.
|
||||
#
|
||||
# Probed by MGPipeResourceOps - the op table PipeApply.h declares and a backend registers -
|
||||
# over each backend's whole directory, for the reason the block above gives: the owning
|
||||
# package picks its own file layout, and a filename probe would answer "no" forever the
|
||||
# moment it moved the code.
|
||||
foreach(mglItestResourceBackend DirectGLES DirectVulkan)
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_OPS
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/${mglItestResourceBackend}" "MGPipeResourceOps")
|
||||
if (MGL_ITEST_RESOURCE_OPS)
|
||||
message(STATUS "Integration tests: ${mglItestResourceBackend} registers a resource op table "
|
||||
"(${MGL_ITEST_RESOURCE_OPS})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV
|
||||
"MGITEST_HANDLE_REKEY_RESOURCES_${mglItestResourceBackend}=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no ${mglItestResourceBackend} source names "
|
||||
"MGPipeResourceOps - HandleRecycle.Handles' buffer case will SKIP on it")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
# The client-side emitter of P3a's map-persistent-roundtrips counter, which is what
|
||||
# StorageBufferRegrow, LargeArenaAdoption and ResourceSubsystemControl read. Same shape and
|
||||
# same reason as the CSO emitter probe above: the counter is minted in MG_Impl/Pipe (package
|
||||
# B), header-only today, so the question is "does any source there emit it", not "does a
|
||||
# named file exist". Until it does, mpr= is structurally zero and an assertion about it
|
||||
# would be a statement about nothing.
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_EMITTER
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "MapPersistentRoundtrips")
|
||||
if (MGL_ITEST_RESOURCE_EMITTER)
|
||||
message(STATUS "Integration tests: map-persistent-roundtrips has an emitter "
|
||||
"(${MGL_ITEST_RESOURCE_EMITTER})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_RESOURCE_EMITTER_PRESENT=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no MG_Impl/Pipe source emits MapPersistentRoundtrips - "
|
||||
"StorageBufferRegrow's, LargeArenaAdoption's and ResourceSubsystemControl's "
|
||||
"mpr entries will SKIP")
|
||||
endif()
|
||||
|
||||
mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_ABA
|
||||
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "PipeHandleAbaControl")
|
||||
if (MGL_ITEST_MAGMA_ABA)
|
||||
message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer "
|
||||
"(${MGL_ITEST_MAGMA_ABA})")
|
||||
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1")
|
||||
else()
|
||||
message(STATUS "Integration tests: no DirectVulkan source names PipeHandleAbaControl - "
|
||||
"HandleRecycle.AbaControl will SKIP")
|
||||
endif()
|
||||
else()
|
||||
message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl}, "
|
||||
"CsoContentAddressing and ResourceSubsystemControl stay registered (G2) and SKIP: "
|
||||
"every arm they assert is compiled only under MOBILEGL_PIPE_PUSH")
|
||||
endif()
|
||||
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
|
||||
@@ -463,6 +651,20 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
# emulation never runs - the ambient registration would be exercising the un-stripped path
|
||||
# twice and calling it coverage. With the variable set, the blocks really are emitted with no
|
||||
# location and the assertion is about the spelling the device gets.
|
||||
# RESOURCE_LOCK, on this lane and on the three below it, and it is a bug fix rather than a
|
||||
# precaution. Each of these lanes gives a WHOLE SCENARIO one MOBILEGL_LOG_FILE_PATH, and one case
|
||||
# in each reads that log back to prove the pinned emulation actually armed. The library opens the
|
||||
# log fopen(path, "w"), so every process in the lane TRUNCATES it - and under `ctest -j` a sibling
|
||||
# case of the same lane can truncate it while the arming case is reading, which reads back as "the
|
||||
# log carries no arming line" and fails a healthy lane. Measured on this tree: three runs of the
|
||||
# full integration-gpu label at -j 8 produced 4 failures, 0 and 2, always one of these arming
|
||||
# cases, never the same set twice.
|
||||
#
|
||||
# The rule stated above the verify block - a case that reads the log needs a lane whose filter
|
||||
# selects it alone - would fix it by re-filtering, but that would RENAME the arming entries, and
|
||||
# an existing ctest name may never disappear (gate G14). A ctest RESOURCE_LOCK named after the log
|
||||
# is the same guarantee without touching a name: ctest never runs two entries holding the same lock
|
||||
# at once, so the only processes that can truncate a lane's log are ones nobody is reading it for.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.UnlocatedIoBlocks."
|
||||
TEST_FILTER "UnlocatedIoBlockScenario.*"
|
||||
@@ -470,6 +672,7 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
RESOURCE_LOCK unlocated-io-blocks.log
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -586,6 +789,7 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
RESOURCE_LOCK primgen-query-reroute.log
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -621,6 +825,7 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
RESOURCE_LOCK point-size-demotion-gles.log
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -631,6 +836,7 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
RESOURCE_LOCK point-size-demotion-vulkan.log
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -665,6 +871,376 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
# why the arming case has a lane and a log of its own below, and why neither this file nor CI
|
||||
# may read the ambient logs as evidence about the entries that ran before the last one. The
|
||||
# ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture).
|
||||
# --- G8: the handle ABA, three always-on arms -----------------------------------------
|
||||
#
|
||||
# ALWAYS ON, in every build mode, which is deliberate: the Legacy arm asserts today's
|
||||
# lifetimeId + weak_ptr guards and is meaningful in a pull build, and `ctest -R HandleRecycle`
|
||||
# has to name the same entries whichever build directory it is pointed at (P2 brief G8 runs it
|
||||
# against build-verify; D.3 part 1 runs it again as part of the interface-purity gate).
|
||||
#
|
||||
# One lane per arm, and each lane names MGITEST_HANDLE_ARM: the arm is not a property of the
|
||||
# test body, it is the (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS, MOBILEGL_PIPE_HANDLE_ABA_CONTROL)
|
||||
# triple the process was launched with, and the scenario skips in the ambient entries because
|
||||
# none of that is configured there.
|
||||
#
|
||||
# Every list APPENDS the common/Vulkan environment for the reason spelled out above the verify
|
||||
# block: a ctest ENVIRONMENT property REPLACES the job environment for the names it lists, so an
|
||||
# entry naming only its own knobs would lose the EGL vendor and Vulkan ICD pinning.
|
||||
#
|
||||
# The AbaControl arm is DirectVulkan only. The knob defeats the object-identity half of
|
||||
# DirectVulkan's vertex-input memo keys (VertexInputStateFactory::ComputeHash, its per-VAO memo
|
||||
# table, and LookupVaoDrawMemo); it steers nothing on DirectGLES, and a lane that configured it
|
||||
# there would be a permanent skip claiming to be a control.
|
||||
#
|
||||
# SO THE BUFFER ABA's CORRUPTION EVIDENCE IS MAGMA-ONLY, AND THAT IS RECORDED RATHER THAN LEFT
|
||||
# AS AN ABSENCE (gates m10, closed here as a statement rather than as a lane). P3a re-keys the
|
||||
# BUFFER on DirectGLES - resource_* now dispatches by handle - so the obvious next move is a
|
||||
# DirectGLES `.AbaControl` lane over ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents.
|
||||
# It is not a registration-only change and it is therefore not made here: the knob has exactly
|
||||
# one consumer in the tree (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h's
|
||||
# MagmaPipeAbaControlDefeatsIdentity, which is what the MGL_ITEST_MAGMA_ABA probe above looks
|
||||
# for), so a DirectGLES lane would run with the knob inert, fail to reproduce the corruption it
|
||||
# asserts, and go RED in an always-on integration-gpu lane - the exact failure mode the header
|
||||
# of HandleRecycleScenario.cpp records this file already having had once.
|
||||
#
|
||||
# What that costs, stated so the next reader does not have to re-derive it: on DirectGLES the
|
||||
# buffer case's Handles arm proves the re-key does NOT alias, and nothing proves the reproducer
|
||||
# could still see an aliasing that was reintroduced there. The positive control for that is a
|
||||
# Features.PipeHandleAbaControl consumer over Espryt's resource slot table - one `if` in
|
||||
# GetOrCreate / FindByHandle, the way MagmaPipeClaimSlotMemos is Magma's - and it belongs with
|
||||
# whoever next touches that table, not with a lane registration.
|
||||
#
|
||||
# It gets TWO lanes, because there are two arms and the control has to reach the one P2 SHIPS.
|
||||
# `AbaControl` is D18's lane verbatim (MOBILEGL_PIPE_PUSH=0, the pre-handle arm) and defeats the
|
||||
# lifetime-id/address guards; `AbaControlHandles` runs the handle arm (MOBILEGL_PIPE_LEGACY_MEMOS=0,
|
||||
# the default push mask) and defeats the object identity that SELECTS THE SLOT - the key the handle
|
||||
# arm ships. With only the first lane the control says nothing at all about the re-key: the handle
|
||||
# arm is not executed under MOBILEGL_PIPE_PUSH=0, so every guard it would have to defeat is in
|
||||
# another branch.
|
||||
#
|
||||
# NEITHER lane exercises the GENERATION half of {slot, gen}, and no lane of this shape can. Magma's
|
||||
# mint has no death notification and returns a slot only through its age sweep (256/1024 boundaries,
|
||||
# MagmaPipeArms.h), so the five frame boundaries this scenario issues always hand the replacement a
|
||||
# brand-new slot at Gen 1; a real reuse needs >= 1024 idle boundaries, which puts the two draws in
|
||||
# different frames - where the only pixel-visible memo declines by design. The generation is covered
|
||||
# by the unit suite MG_Test/Pipe/MagmaPipeIdentityTest.cpp instead, which drives a real
|
||||
# retire -> reuse; MagmaPipeAbaControlDefeatsIdentity carries the measurement.
|
||||
#
|
||||
# The two PUSH-ONLY knobs of those arms are set only in a push build, and the lane NAMES are
|
||||
# unaffected by that (an ENVIRONMENT property is not part of a test's name, so G2 still sees the
|
||||
# same list in both builds). MOBILEGL_PIPE_LEGACY_MEMOS=0 says "never enter the legacy arm"; in a
|
||||
# pull build the legacy arm is the ONLY arm and every Track-H subsystem bit is clear, which is
|
||||
# precisely D14's startup Fatal{PipeLegacyMemosDisabled} condition - so a lane that set it there
|
||||
# would abort the process before the scenario could report its skip. MOBILEGL_PIPE_HANDLE_ABA_CONTROL
|
||||
# has no field to parse into in a pull build at all (Config.h declares it under #if MOBILEGL_PIPE_PUSH).
|
||||
# A lane that needs an arm pins EVERY knob that selects it. A ctest ENVIRONMENT property overrides
|
||||
# only the variables it names; the rest leak in from the job. The five-part gate's all-pull control
|
||||
# arm runs `MOBILEGL_PIPE_PUSH=0 ctest -L integration-gpu` over the whole label, and without the
|
||||
# explicit bitmask below that leaked PUSH=0 turned this lane's LEGACY_MEMOS=0 into D14's armless
|
||||
# combination: the bring-up aborted, on purpose, and the lane went red for a reason that was never
|
||||
# about handles. The mask is the PHASE default, not a hand-picked bit, so the lane keeps measuring
|
||||
# the shape that ships: it was kMGPipeSubsystemsMigratedAtP2 (0x7f) and is now
|
||||
# kMGPipeSubsystemsMigratedAtP3a (0x1ff, MG_Pipe/MGPipe.h), which adds bit 7 (resources) and bit 8
|
||||
# (vertex input). Pinning it at 0x7f after P3a would leave the Handles arm asserting the P2 shape
|
||||
# while the buffer and vertex-input handles it is supposed to be about stayed switched off - a lane
|
||||
# that still passes and no longer measures the key that ships. Each phase's constant survives as the
|
||||
# NEXT phase's A/B control, which is what ResourceSubsystemControl's Off lane uses 0x7f for.
|
||||
if (MOBILEGL_PIPE_PUSH)
|
||||
set(MGL_ITEST_HANDLES_ARM_KNOBS "MOBILEGL_PIPE_LEGACY_MEMOS=0" "MOBILEGL_PIPE_PUSH=0x1ff")
|
||||
set(MGL_ITEST_ABA_ARM_KNOBS "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1")
|
||||
else()
|
||||
set(MGL_ITEST_HANDLES_ARM_KNOBS "")
|
||||
set(MGL_ITEST_ABA_ARM_KNOBS "")
|
||||
endif()
|
||||
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS}
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS}
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0"
|
||||
${MGL_ITEST_ABA_ARM_KNOBS}
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba"
|
||||
${MGL_ITEST_HANDLES_ARM_KNOBS} ${MGL_ITEST_ABA_ARM_KNOBS}
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.HandleRecycle.Handles."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.HandleRecycle.Handles."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.HandleRecycle.Legacy."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.HandleRecycle.Legacy."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControl."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControlHandles."
|
||||
TEST_FILTER "HandleRecycleScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# --- G12: the CSO content-addressing negative control ---------------------------------
|
||||
#
|
||||
# PUSH BUILDS ONLY, and that is the honest scope rather than a convenience: the two counters the
|
||||
# control reads (CallClass::RenderStateCsoMints / RenderStateCsoBinds) and the `cso[...]` bracket
|
||||
# of the summary line are both `#if MOBILEGL_PIPE_PUSH` (PipeStats.h, PipeStats.cpp), so in a pull
|
||||
# build there is no channel to read and an entry here would be a permanent skip.
|
||||
#
|
||||
# Each arm gets a LOG PATH OF ITS OWN. The library opens its log fopen(path, "w") - every process
|
||||
# in a lane truncates it - and these two cases READ that log, so a shared path would have them
|
||||
# reading a neighbour's bring-up under `ctest -j 4`. Same rule as the arming lane below.
|
||||
#
|
||||
# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the
|
||||
# workload be bracketed by two swaps and read back as a window covering exactly itself.
|
||||
#
|
||||
# Registered in EVERY build, including the pull build where there is no CSO at all, so that
|
||||
# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a
|
||||
# pull build MGITEST_PIPE_PUSH_BUILD is absent and both cases skip saying so.
|
||||
#
|
||||
# THE MASK IS THE PHASE DEFAULT AND THE CONTROL IS BIT 63, and the two must not be confused. The
|
||||
# only thing these four lanes are an A/B about is `no CSO content addressing` (bit 63,
|
||||
# kMGPipeBehaviourNoCsoContentAddressing), which is what separates the On lane from the Off lane.
|
||||
# Every
|
||||
# other bit is the build's shipping mask, so it moves with the phase: it was 0x7f
|
||||
# (kMGPipeSubsystemsMigratedAtP2) and is now 0x1ff (kMGPipeSubsystemsMigratedAtP3a) for the same
|
||||
# reason MGL_ITEST_HANDLES_ARM_KNOBS above moved. Left pinned at 0x7f after P3a these lanes would
|
||||
# keep passing while running on a configuration nothing ships - bits 7 (resources) and 8 (vertex
|
||||
# input) cleared - which is the "still green, no longer measuring the shape that ships" failure
|
||||
# the Handles-arm comment above rejects. The counters they read (csom / csob) are render-state and
|
||||
# are steered by neither bit, so raising the mask is behaviour-preserving for what they assert;
|
||||
# what it buys is that a CSO regression that only shows up with the P3a subsystems on can reach
|
||||
# them. (contract-review-v1.md item 11, closed here for all three lane families.)
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed"
|
||||
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing"
|
||||
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed"
|
||||
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing"
|
||||
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.CsoContentAddressing.On."
|
||||
TEST_FILTER "CsoContentAddressingScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.CsoContentAddressing.Off."
|
||||
TEST_FILTER "CsoContentAddressingScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.CsoContentAddressing.On."
|
||||
TEST_FILTER "CsoContentAddressingScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off."
|
||||
TEST_FILTER "CsoContentAddressingScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# --- G12: the P3a subsystem A/B, and G10's map-persistent-roundtrips lanes ------------
|
||||
#
|
||||
# THE MASKS ARE THE TWO PHASE CONSTANTS, not hand-picked bits: 0x1ff is
|
||||
# kMGPipeSubsystemsMigratedAtP3a (the push build's default) and 0x7f is
|
||||
# kMGPipeSubsystemsMigratedAtP2, which is exactly that default with bits 7 (resources) and 8
|
||||
# (vertex input) cleared. MGPipe.h:79 keeps each phase's constant alive as the next phase's A/B
|
||||
# control for this reason, and a lane that spelled its own bit pattern would stop being the shape
|
||||
# that ships the first time the default moved.
|
||||
#
|
||||
# DirectGLES only. P3a migrates Espryt's buffer and VAO paths; Magma's buffer path is P7 and
|
||||
# registers no MGPipeResourceOps, so a DirectVulkan arm would be measuring the client emitter
|
||||
# against a backend nobody asked to change.
|
||||
#
|
||||
# Each reading entry gets a LOG PATH OF ITS OWN, and its ctest entry selects ONE case: the library
|
||||
# opens the log fopen(path, "w") - every process in a lane truncates it - and these cases READ it.
|
||||
# Two readers in one lane race under `ctest -j`, and the failure looks exactly like "the counter
|
||||
# was never emitted". Same rule as the CSO lanes above and the verify arming lane below.
|
||||
#
|
||||
# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets a
|
||||
# workload be bracketed by two swaps and read back as a window covering exactly itself.
|
||||
#
|
||||
# Registered in EVERY build, including the pull build where none of the counters exists, so that
|
||||
# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a
|
||||
# pull build MGITEST_PIPE_PUSH_BUILD is absent and every one of these cases skips saying so; a
|
||||
# MOBILEGL_PIPE_PUSH value in the environment of a pull library steers nothing (Config.h declares
|
||||
# the field inside the push guard), exactly as the HandleRecycle Legacy lanes already rely on.
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=on"
|
||||
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-on-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=off"
|
||||
"MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-off-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.ResourceSubsystemControl.On."
|
||||
TEST_FILTER "ResourceSubsystemControlScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.ResourceSubsystemControl.Off."
|
||||
TEST_FILTER "ResourceSubsystemControlScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# G10's two counting entries, one per scenario that has a claim about the counter:
|
||||
# StorageBufferRegrow asserts N definitions cost N round trips (never one per draw), and
|
||||
# LargeArenaAdoption asserts one adoption costs exactly one. Each names a single case in its
|
||||
# TEST_FILTER, for the private-log reason above.
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=storage-buffer-regrow"
|
||||
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-storage-buffer-regrow-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=large-arena-adoption"
|
||||
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-large-arena-adoption-DirectGLES.log"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.MapPersistentRoundtrips."
|
||||
TEST_FILTER "StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.MapPersistentRoundtrips."
|
||||
TEST_FILTER "LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# LargeArenaAdoption's three behavioural cases under BOTH arms of the same A/B. This file is where
|
||||
# an adopted store's whole life is exercised - the NULL-data definition that adopts it, an
|
||||
# in-flight SubData, a readback and a GPU write - so if the handle path and the legacy
|
||||
# BufferBackendOps path disagree about any of it, one of these two lanes goes red and names which.
|
||||
# No log path: none of these three cases reads one, and giving them one would only add a file for
|
||||
# four processes to truncate. The fourth case skips in both lanes for exactly that reason, saying
|
||||
# so.
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x1ff"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x7f"
|
||||
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.ResourceSubsystemOn."
|
||||
TEST_FILTER "LargeArenaAdoptionScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT}"
|
||||
)
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES.ResourceSubsystemOff."
|
||||
TEST_FILTER "LargeArenaAdoptionScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
if (MOBILEGL_PIPE_VERIFY)
|
||||
# 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb
|
||||
# boundary and again at every accessor read, which the design budgets at 5-10x.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.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 "PipeSlotPeek.h"
|
||||
|
||||
#if !defined(__ANDROID__)
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#define MGITEST_PIPE_SLOT_PEEK_LIVE 1
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace MGITest {
|
||||
|
||||
#if defined(MGITEST_PIPE_SLOT_PEEK_LIVE)
|
||||
namespace {
|
||||
MobileGL::MG_Pipe::MGPipeKind Translate(PipeSlotKind kind) {
|
||||
switch (kind) {
|
||||
case PipeSlotKind::Buffer: return MobileGL::MG_Pipe::MGPipeKind::Buffer;
|
||||
default: return MobileGL::MG_Pipe::MGPipeKind::VertexElementsCso;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive) {
|
||||
if (outLive == nullptr) return false;
|
||||
*outLive = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().LiveCount(Translate(kind)));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater) {
|
||||
if (outHighWater == nullptr) return false;
|
||||
*outHighWater = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().HighWater(Translate(kind)));
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
bool PeekPipeSlotLiveCount(PipeSlotKind, unsigned*) { return false; }
|
||||
bool PeekPipeSlotHighWater(PipeSlotKind, unsigned*) { return false; }
|
||||
#endif
|
||||
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,44 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.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
|
||||
//
|
||||
// The CLIENT slot allocator's occupancy, read from a scenario.
|
||||
//
|
||||
// It exists for one assertion, P3a's C-1: a frontend object that dies must return its
|
||||
// MGPipeHandle slot WHATEVER BACKEND IS RUNNING. That question has no answer in the GL API -
|
||||
// the leak it rules out is entirely inside the library, and it is invisible in pixels, in GL
|
||||
// names and in `glGetError` - so the only honest observable is the allocator's own live count
|
||||
// and high-water mark. Reading them is what makes the case fail on the backend it actually
|
||||
// failed on (DirectVulkan, which installs no StateObjectDeathOps) rather than only on the one
|
||||
// where a backend-owned free happened to exist.
|
||||
//
|
||||
// A separate translation unit for BackendCapsPeek.h's reason, verbatim: the scenario sources
|
||||
// include the GL headers with prototypes and MobileGL's umbrella header is not meant to meet
|
||||
// them in one file.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace MGITest {
|
||||
|
||||
// Which client-side object kind to ask about. Mirrors MG_Pipe::MGPipeKind for exactly the
|
||||
// kinds a scenario has a reason to count, so that the enum does not travel through this
|
||||
// header and the GL headers together.
|
||||
enum class PipeSlotKind {
|
||||
Buffer,
|
||||
VertexElementsCso,
|
||||
};
|
||||
|
||||
// Live slots of this kind right now, and one past the highest slot ever handed out.
|
||||
// Both return false, touching nothing, where the allocator is out of reach: in a PULL
|
||||
// build there is no allocator at all (it is `#if MOBILEGL_PIPE_PUSH`), and on Android this
|
||||
// module links the shipping libMobileGL.so built -fvisibility=hidden, so no internal symbol
|
||||
// resolves. A caller that gets false must SKIP rather than pass - "could not look" is not
|
||||
// "did not leak".
|
||||
bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive);
|
||||
bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater);
|
||||
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,94 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.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
|
||||
//
|
||||
// Reading ONE PipeStats summary window out of the library's own log, for the scenarios whose
|
||||
// claim is about a counter rather than about pixels.
|
||||
//
|
||||
// WHY THROUGH A LOG FILE AT ALL. MG_Util::PipeStats is internal to the library and this module
|
||||
// cannot link against it (ScenarioFixture.h has the long version: on Android this binary links
|
||||
// the SHIPPING libMobileGL.so, built -fvisibility=hidden). The library's `MGPipe stats:` line is
|
||||
// the only channel, so a lane that wants to read a counter sets MOBILEGL_PIPE_STATS=1,
|
||||
// MOBILEGL_PIPE_STATS_PERIOD=1 - one line per eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of
|
||||
// its OWN.
|
||||
//
|
||||
// THE LOG PATH HAS TO BE PRIVATE TO ONE CTEST ENTRY, and that is not a style rule: the library
|
||||
// opens it fopen(path, "w"), so every process launched in a lane TRUNCATES it. Two entries of one
|
||||
// lane reading the same path race under `ctest -j`, and the shape of the failure is an empty read
|
||||
// that looks exactly like "the counter was never emitted". So a case that reads a window gets a
|
||||
// ctest entry whose TEST_FILTER selects that case alone, with a log path nothing else writes -
|
||||
// the rule PipeVerifyArmingScenario and CsoContentAddressingScenario already follow.
|
||||
//
|
||||
// THE WINDOW IS "SINCE THE PREVIOUS LINE" (PipeStats::FormatWindowLine), so the caller closes the
|
||||
// setup window with a swap, runs the workload, swaps again, and reads the LAST line - which then
|
||||
// covers the workload and nothing else.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
namespace MGITest::PipeStatsWindow {
|
||||
|
||||
// The lane's private log path, or empty when the lane configured none.
|
||||
inline std::string LibraryLogPath() {
|
||||
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
|
||||
return (path != nullptr && *path != '\0') ? std::string(path) : std::string();
|
||||
}
|
||||
|
||||
inline std::string ReadWholeFile(const std::string& path) {
|
||||
if (path.empty()) return {};
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file.good()) return {};
|
||||
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
// The last summary line in the log, verbatim. `found` is false when the library never emitted
|
||||
// one, which is a different failure from "the counter read zero" and has to be reported as
|
||||
// one: it means the stats channel never reached the process, not that the workload did
|
||||
// nothing.
|
||||
struct Window {
|
||||
bool found = false;
|
||||
std::string line;
|
||||
};
|
||||
|
||||
inline Window Last(const std::string& log) {
|
||||
Window window;
|
||||
const std::string marker = "MGPipe stats:";
|
||||
const std::size_t at = log.rfind(marker);
|
||||
if (at == std::string::npos) return window;
|
||||
const std::size_t end = log.find('\n', at);
|
||||
window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at);
|
||||
window.found = true;
|
||||
return window;
|
||||
}
|
||||
|
||||
inline Window LastFromLaneLog() { return Last(ReadWholeFile(LibraryLogPath())); }
|
||||
|
||||
// One counter out of that line, by its short name ("mpr", "draws", "csom"), or -1 when the
|
||||
// line does not carry it. The search includes the SEPARATOR before the name and the `=` after
|
||||
// it, so "draws" cannot match "draws/f=" and "mpr" cannot match a longer name ending in it -
|
||||
// a substring match here would read a neighbouring counter's value and report it as this
|
||||
// one's, which is the one way a counter assertion can be wrong without ever failing.
|
||||
inline long long CounterOrAbsent(const Window& window, const char* shortName) {
|
||||
if (!window.found) return -1;
|
||||
// A counter is preceded either by a space (` mpr=`, ` draws=`) or by its bracket's
|
||||
// opening (`cso[csom=`, `bytes/f[stage-buffer=`); nothing in the line is preceded by
|
||||
// anything else.
|
||||
for (const char* prefix : {" ", "["}) {
|
||||
const std::string key = std::string(prefix) + shortName + "=";
|
||||
const std::size_t at = window.line.find(key);
|
||||
if (at == std::string::npos) continue;
|
||||
return std::strtoll(window.line.c_str() + at + key.size(), nullptr, 10);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace MGITest::PipeStatsWindow
|
||||
@@ -0,0 +1,329 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.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 CSO CONTENT-ADDRESSING NEGATIVE CONTROL (gate G12).
|
||||
//
|
||||
// P2's render-state CSO is content-addressed: the client hashes the 396 pipeline bytes, probes a
|
||||
// 64-entry cache, memcmps a hash hit and reuses the handle. The whole design is measured against
|
||||
// a knob that turns that off - kMGPipeBehaviourNoCsoContentAddressing, bit 63 of the runtime
|
||||
// MOBILEGL_PIPE_PUSH bitmask - so that "push is slower" can be told apart from "the CSO design is
|
||||
// slower" (P2 brief D.4.5). A measurement knob has one characteristic failure mode: it stops
|
||||
// steering anything and every later number is quietly taken against a switch that does nothing.
|
||||
// This file is the entry that cannot let that happen.
|
||||
//
|
||||
// WHAT IT ASSERTS, per arm, and why those are the right shapes:
|
||||
//
|
||||
// content-addressed (MOBILEGL_PIPE_PUSH=0x7f)
|
||||
// A Blaze3D blend toggle - enable / draw / disable / draw, N times, which is the workload
|
||||
// the CsoCache exists for (ARCHITECTURE.md 5.1: the push happens at validate rather than in
|
||||
// the setter precisely because Blaze3D brackets every batch this way) - visits exactly TWO
|
||||
// distinct pipeline subsets. So the mint count must stay small and BOUNDED while the bind
|
||||
// count grows with the draws: csom << csob.
|
||||
//
|
||||
// no content addressing (MOBILEGL_PIPE_PUSH=0x800000000000007f)
|
||||
// Every pipeline-version change mints a fresh CSO and the map is never probed, so mint and
|
||||
// bind must move together: csom == csob. This is the assertion a dead switch fails - with
|
||||
// the bit ignored, this arm would report csom << csob just like the other one.
|
||||
//
|
||||
// both arms
|
||||
// The PIXELS must not move. The quad is drawn with alpha 1.0 through
|
||||
// GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA, so the blended and unblended draws produce the
|
||||
// same colour by construction and the readback is the same image in both arms and after
|
||||
// every toggle. "The counters moved and the picture did not" is the whole claim.
|
||||
//
|
||||
// HOW THE COUNTERS ARE READ. MG_Util::PipeStats is internal to the library and this module cannot
|
||||
// link against it (ScenarioFixture.h explains why: on Android this binary links the SHIPPING
|
||||
// libMobileGL.so, built -fvisibility=hidden). The library's own summary line is the only channel,
|
||||
// so each lane sets MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 - one line per
|
||||
// eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of its OWN. The log path has to be private: the
|
||||
// library opens it fopen(path, "w"), so every process in a lane truncates it, and a whole-file
|
||||
// read in a shared lane races a neighbour's bring-up. That is the same rule, and the same
|
||||
// remedy, as PipeVerifyArmingScenario's arming lane.
|
||||
//
|
||||
// The window a summary line reports is "since the previous line" (PipeStats::FormatWindowLine), so
|
||||
// the workload runs inside ONE frame: a swap before it closes the setup window, and the swap after
|
||||
// it emits a line whose csom / csob cover the toggle loop and nothing else.
|
||||
//
|
||||
// WHY IT CAN SKIP. The counters are minted by the client-side tracker (P2 package B), and this
|
||||
// file is written against the P2 contract commit, before that package lands. Until the tracker
|
||||
// exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob
|
||||
// would be a statement about nothing. The build answers the question rather than a hand-maintained
|
||||
// list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/ for the two
|
||||
// counters' names and passes the answer in as MGITEST_PIPE_TRACKER_PRESENT, with a
|
||||
// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale.
|
||||
// It is a CONTENT probe, not a filename probe, precisely so that the owning package keeps control
|
||||
// of its own file layout - it implements the tracker and the cache header-only today, and a glob
|
||||
// for `Tracker.cpp` would have kept this control skipping forever after that package landed, with
|
||||
// a reason that had become false. When an emitter lands the arms arm themselves; until then the
|
||||
// entries are registered, visible and SKIPPED with the reason - never absent, and never green for
|
||||
// having asserted nothing.
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#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 {
|
||||
|
||||
// Set by the two CsoContentAddressing. ctest entries and by nothing else; a harness
|
||||
// marker, never read by the library. Its absence means an ambient entry, where neither
|
||||
// the stats channel nor a private log path is configured.
|
||||
constexpr const char* kLaneMarker = "MGITEST_CSO_LANE";
|
||||
constexpr const char* kLaneContentAddressed = "content-addressed";
|
||||
constexpr const char* kLaneNoContentAddressing = "no-content-addressing";
|
||||
|
||||
// Toggle pairs per frame. 8 is small enough to keep the frame cheap and large enough that
|
||||
// "mints stay bounded" and "mints track binds" are different numbers by a wide margin.
|
||||
constexpr int kTogglePairs = 8;
|
||||
constexpr int kDrawsPerFrame = kTogglePairs * 2;
|
||||
// The blend toggle visits two distinct pipeline subsets, so two CSOs. The bound is
|
||||
// deliberately a little looser than 2: a future chunk-table change could legitimately
|
||||
// split one of them, and the claim being pinned here is "bounded, not per-draw".
|
||||
constexpr long long kMaxDistinctCsos = 4;
|
||||
|
||||
constexpr const char* kVS = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
||||
)";
|
||||
|
||||
constexpr const char* kFS = R"(#version 330 core
|
||||
out vec4 oColor;
|
||||
void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); }
|
||||
)";
|
||||
|
||||
constexpr int kInset = 2;
|
||||
|
||||
bool BuildMarkerIsSet(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value != nullptr && value[0] == '1' && value[1] == '\0';
|
||||
}
|
||||
|
||||
std::string LaneName() {
|
||||
const char* lane = std::getenv(kLaneMarker);
|
||||
return lane != nullptr ? std::string(lane) : std::string();
|
||||
}
|
||||
|
||||
std::string LibraryLogPath() {
|
||||
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
|
||||
return (path != nullptr && *path != '\0') ? std::string(path) : std::string();
|
||||
}
|
||||
|
||||
std::string ReadWholeFile(const std::string& path) {
|
||||
if (path.empty()) return {};
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file.good()) return {};
|
||||
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
// One window's CSO counters, as the library printed them.
|
||||
struct CsoWindow {
|
||||
bool found = false;
|
||||
long long mints = -1;
|
||||
long long binds = -1;
|
||||
std::string line;
|
||||
};
|
||||
|
||||
// Parses `... cso[csom=<N> csob=<M>] ...` out of the LAST "MGPipe stats:" line in the log.
|
||||
// The last line, because the window a line reports is "since the previous line" and the
|
||||
// caller closes the setup window with a swap before the workload.
|
||||
CsoWindow LastCsoWindow(const std::string& log) {
|
||||
CsoWindow window;
|
||||
const std::string marker = "MGPipe stats:";
|
||||
std::size_t at = log.rfind(marker);
|
||||
if (at == std::string::npos) return window;
|
||||
const std::size_t end = log.find('\n', at);
|
||||
window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at);
|
||||
|
||||
const std::string mintKey = "csom=";
|
||||
const std::string bindKey = "csob=";
|
||||
const std::size_t mintAt = window.line.find(mintKey);
|
||||
const std::size_t bindAt = window.line.find(bindKey);
|
||||
if (mintAt == std::string::npos || bindAt == std::string::npos) return window;
|
||||
window.mints = std::strtoll(window.line.c_str() + mintAt + mintKey.size(), nullptr, 10);
|
||||
window.binds = std::strtoll(window.line.c_str() + bindAt + bindKey.size(), nullptr, 10);
|
||||
window.found = true;
|
||||
return window;
|
||||
}
|
||||
|
||||
class CsoContentAddressingScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
m_lane = LaneName();
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVS, kFS, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
|
||||
const float quad[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f,
|
||||
-1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f};
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
glGenBuffers(1, &m_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
|
||||
RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str());
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
glBindVertexArray(0);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// GTEST_SKIP() returns from the function it is written in, so this cannot report
|
||||
// through a return value; every caller pairs it with `if (IsSkipped()) return;`.
|
||||
void SkipUnlessTheLaneIsAssertableHere() {
|
||||
if (m_lane.empty()) {
|
||||
GTEST_SKIP() << "runs only in its own lane: the two CsoContentAddressing. ctest entries set "
|
||||
"MGITEST_CSO_LANE together with the MOBILEGL_PIPE_PUSH bitmask, "
|
||||
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
|
||||
"MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient "
|
||||
"entries, and the ambient log is shared, so a read here would race.";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
|
||||
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so there is no "
|
||||
"render-state CSO to mint, no cso[] bracket in the summary line and "
|
||||
"nothing for the content-addressing bit to steer. The entry is "
|
||||
"registered here anyway so that `ctest -L integration-gpu` names the "
|
||||
"same tests in the pull build and the push build (gate G2).";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) {
|
||||
GTEST_SKIP() << "the CSO counters have no emitter in this build: no source under "
|
||||
"MobileGL/MG_Impl/Pipe/ names RenderStateCsoMints or "
|
||||
"RenderStateCsoBinds, so nothing mints or binds a render-state CSO "
|
||||
"and csom / csob are structurally zero. P2 package B owns the tracker "
|
||||
"and the CSO cache; this entry arms itself when they land, whatever "
|
||||
"files that package chooses to put them in.";
|
||||
return;
|
||||
}
|
||||
if (LibraryLogPath().empty()) {
|
||||
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's summary "
|
||||
"line is the only channel this module has for reading PipeStats";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// enable / draw / disable / draw, kTogglePairs times, entirely inside one frame.
|
||||
// Returns the readback taken at the end of that frame, before the swap.
|
||||
Image RunBlendToggleFrame() {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glUseProgram(m_program);
|
||||
glBindVertexArray(m_vao);
|
||||
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);
|
||||
for (int i = 0; i < kTogglePairs; ++i) {
|
||||
glEnable(GL_BLEND);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||
glDisable(GL_BLEND);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||
}
|
||||
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
||||
Gl().EndFrame();
|
||||
return image;
|
||||
}
|
||||
|
||||
std::string m_lane;
|
||||
GLuint m_program = 0;
|
||||
GLuint m_vao = 0;
|
||||
GLuint m_vbo = 0;
|
||||
};
|
||||
|
||||
// ONE case per lane, and that is a hard constraint rather than a style choice.
|
||||
//
|
||||
// This case READS the library log, and the log is a per-LANE resource: the library opens it
|
||||
// fopen(path, "w"), so every process in a lane truncates it. A second case in this lane would
|
||||
// therefore race this one under `ctest -j`, and the shape of the failure is a silent, empty
|
||||
// read that looks exactly like "the counters were never emitted". Splitting the plumbing
|
||||
// assertion into its own case would have bought a clearer failure message and paid for it
|
||||
// with a flake in the thing the message is about. The plumbing is asserted first, with its
|
||||
// own message, inside this one process instead.
|
||||
TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) {
|
||||
if (!Ready()) return;
|
||||
SkipUnlessTheLaneIsAssertableHere();
|
||||
if (IsSkipped()) return;
|
||||
|
||||
Gl().EndFrame(); // close the setup window
|
||||
const Image first = RunBlendToggleFrame();
|
||||
const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath()));
|
||||
// The plumbing first, with its own message, so a counter-ratio failure below can never
|
||||
// be confused with "the lane never turned the stats channel on".
|
||||
ASSERT_TRUE(window.found)
|
||||
<< "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath()
|
||||
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting "
|
||||
"here) and the cso[] bracket is unconditional inside that #if, so it cannot be "
|
||||
"missing for a build reason: either MOBILEGL_PIPE_STATS / "
|
||||
"MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or no summary line was "
|
||||
"emitted at all because nothing reached PipeStats::OnPresent.";
|
||||
RecordProperty("cso_line", window.line.c_str());
|
||||
|
||||
// Every draw in the frame changed the pipeline subset, so every draw is a bind. This
|
||||
// is the denominator both arms are read against; without it, "csom == csob" would also
|
||||
// be satisfied by a frame in which neither happened at all.
|
||||
ASSERT_GE(window.binds, static_cast<long long>(kDrawsPerFrame))
|
||||
<< "the toggle frame issued " << kDrawsPerFrame
|
||||
<< " draws whose pipeline subset alternates, so it must have issued at least that many "
|
||||
"render-state binds. It reported: "
|
||||
<< window.line;
|
||||
|
||||
if (m_lane == kLaneContentAddressed) {
|
||||
EXPECT_LE(window.mints, kMaxDistinctCsos)
|
||||
<< "with content addressing on, enable/draw/disable/draw x " << kTogglePairs
|
||||
<< " visits two distinct pipeline subsets and must mint a bounded number of CSOs, then "
|
||||
"reuse them. It reported: "
|
||||
<< window.line;
|
||||
EXPECT_LT(window.mints, window.binds)
|
||||
<< "with content addressing on the cache must be answering binds it did not mint. "
|
||||
<< window.line;
|
||||
} else if (m_lane == kLaneNoContentAddressing) {
|
||||
EXPECT_EQ(window.mints, window.binds)
|
||||
<< "kMGPipeBehaviourNoCsoContentAddressing (bit 63 of MOBILEGL_PIPE_PUSH) must make every "
|
||||
"bind mint a fresh CSO - the map is never probed and no handle is ever reused. Equal "
|
||||
"counters are the only reading that proves the bit STEERED anything: if it were "
|
||||
"ignored, this arm would report the same bounded mint count as the other one. It "
|
||||
"reported: "
|
||||
<< window.line;
|
||||
} else {
|
||||
FAIL() << "unknown " << kLaneMarker << " value '" << m_lane << "'";
|
||||
}
|
||||
|
||||
// ... and the picture is the same in both arms and after every toggle. The quad is
|
||||
// opaque, so the blended and unblended draws agree by construction.
|
||||
EXPECT_TRUE(RegionIsMostly(first, kInset, first.Width() - kInset, kInset, first.Height() - kInset,
|
||||
"green", 0.0, "the blend-toggle frame [" + m_lane + "]"));
|
||||
const Image second = RunBlendToggleFrame();
|
||||
EXPECT_TRUE(second == first)
|
||||
<< "the second toggle frame does not match the first: " << second.ByteDiffCount(first)
|
||||
<< " bytes differ. The CSO path must not change what is drawn.";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,13 +25,31 @@
|
||||
// * 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).
|
||||
//
|
||||
// P3a (gate G10, G12) adds a fourth case and two more lanes, and neither of them
|
||||
// changes what the three above assert:
|
||||
//
|
||||
// * AnAdoptionCostsExactlyOneMapPersistentRoundtrip counts the acquisition.
|
||||
// ARCHITECTURE.md:474 prices the adopted store at one round trip per STORAGE
|
||||
// DEFINITION; `map-persistent-roundtrips` counts every map_persistent
|
||||
// emission, mint or decline (D-B2), so one definition plus a frame of draws
|
||||
// must publish exactly one. It reads the library's summary line, so it needs
|
||||
// a lane with the stats channel and a private log path, and it SKIPS - with
|
||||
// the reason - anywhere else and on any tree that does not emit the counter.
|
||||
// * the three original cases are registered TWICE MORE, with P3a's resource and
|
||||
// vertex-input subsystem bits set and cleared, because this file is where an
|
||||
// adopted store's whole life is exercised: definition, in-flight SubData,
|
||||
// readback and a GPU write. If the handle path and the legacy BufferBackendOps
|
||||
// path disagree about any of it, one of the two arms goes red here.
|
||||
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/PipeStatsWindow.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
@@ -73,6 +91,19 @@ layout(std430, binding = 0) buffer Arena { uint word; };
|
||||
void main() { word = 0xC0FFEEu; }
|
||||
)";
|
||||
|
||||
// Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker,
|
||||
// never read by the library.
|
||||
constexpr const char* kLaneMarker = "MGITEST_MPR_LANE";
|
||||
// Draws issued against the arena inside the counted window. One definition, many draws:
|
||||
// "one per definition" (1) and "one per draw" (kDrawsInTheWindow) have to be different
|
||||
// numbers or the assertion cannot tell them apart.
|
||||
constexpr int kDrawsInTheWindow = 5;
|
||||
|
||||
bool BuildMarkerIsSet(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value != nullptr && value[0] == '1' && value[1] == '\0';
|
||||
}
|
||||
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
@@ -102,6 +133,12 @@ void main() { word = 0xC0FFEEu; }
|
||||
// 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),
|
||||
@@ -173,15 +210,49 @@ void main() { word = 0xC0FFEEu; }
|
||||
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
|
||||
}
|
||||
|
||||
void DrawQuad() {
|
||||
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(m_vao);
|
||||
glBindVertexArray(vao != 0 ? vao : m_vao);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
// GTEST_SKIP() returns from the function it is written in, so this cannot report
|
||||
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
|
||||
void SkipUnlessTheRoundtripCounterIsReadableHere() {
|
||||
if (std::getenv(kLaneMarker) == nullptr) {
|
||||
GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry "
|
||||
"sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, "
|
||||
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
|
||||
"MOBILEGL_LOG_FILE_PATH. The ambient entries and the two subsystem "
|
||||
"arms configure none of that, and their log is shared - a read there "
|
||||
"would race a neighbour's bring-up.";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
|
||||
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so "
|
||||
"CallClass::MapPersistentRoundtrips does not exist and the summary "
|
||||
"line carries no mpr=. The entry stays registered so that "
|
||||
"`ctest -L integration-gpu` names the same tests in both builds (G2).";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
|
||||
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
|
||||
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
|
||||
"emits map_persistent and mpr= is structurally zero. P3a package B "
|
||||
"owns that emitter; this entry arms itself when it lands.";
|
||||
return;
|
||||
}
|
||||
if (PipeStatsWindow::LibraryLogPath().empty()) {
|
||||
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
|
||||
"summary line is the only channel this module has for reading "
|
||||
"PipeStats";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -222,6 +293,97 @@ void main() { word = 0xC0FFEEu; }
|
||||
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) {
|
||||
@@ -275,4 +437,72 @@ void main() { word = 0xC0FFEEu; }
|
||||
<< "the compute write into the adopted arena did not reach the CPU readback";
|
||||
}
|
||||
|
||||
// G10, the per-adoption half: ONE storage definition of an arena costs ONE map_persistent
|
||||
// emission, however many draws read it afterwards.
|
||||
//
|
||||
// The arena SetUp defined is deliberately re-defined inside the counted window rather than
|
||||
// measured from outside it: the window a summary line reports is "since the previous line",
|
||||
// so the definition has to happen between the two swaps that bracket it, and a case that
|
||||
// counted SetUp's definition would be reading a window it did not control.
|
||||
//
|
||||
// ONE reading case per lane, for the reason PipeStatsWindow.h gives: the library truncates the
|
||||
// log per process, so two readers in a lane race under `ctest -j`.
|
||||
TEST_F(LargeArenaAdoptionScenario, AnAdoptionCostsExactlyOneMapPersistentRoundtrip) {
|
||||
if (!Ready() || IsSkipped()) return;
|
||||
SkipUnlessTheRoundtripCounterIsReadableHere();
|
||||
if (IsSkipped()) return;
|
||||
|
||||
Gl().EndFrame(); // close the setup window, SetUp's own definition included
|
||||
|
||||
// One definition of a store past the 16 MiB adoption threshold, taken by RE-SPECIFYING
|
||||
// SetUp's arena while m_vao's attributes are still pointing into it - and the attributes
|
||||
// are deliberately NOT re-declared afterwards, so the draws below can only land if the
|
||||
// backend VAO followed the new store on its own.
|
||||
//
|
||||
// That is the hard shape on purpose. It was routed around in the first cut of this file
|
||||
// because feat/disaggregated did not yet carry `dev`'s d7655247 ("rebind VAOs when an
|
||||
// adopted buffer is respecified - the immediate retire path forgot the buffer-id
|
||||
// generation") and the workload was a hard SIGSEGV inside the vertex fetch on the first
|
||||
// draw after the re-specification. ID-9 merged that fix (feat/disaggregated 5cb826b0) and
|
||||
// requires it to hold in BOTH the legacy and the handle arm of the respecify/retire path,
|
||||
// so this workload counts the path rather than avoiding it: under the
|
||||
// ResourceSubsystemOn./Off. lanes the same body runs on both arms, and a handle arm that
|
||||
// re-implemented the retire without the rebind is a crash here rather than a silent
|
||||
// divergence found on device.
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
|
||||
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(FirstGLError(), 0u) << "re-specifying the arena inside the counted window failed";
|
||||
|
||||
// ... and then a frame's worth of traffic against it, of the shape the arena exists for:
|
||||
// a SubData per draw, every one of which lands in the adopted mapping and none of which
|
||||
// may acquire it again.
|
||||
for (int draw = 0; draw < kDrawsInTheWindow; ++draw) {
|
||||
UploadQuad(0.f, 1.f, 0.f);
|
||||
DrawQuad();
|
||||
}
|
||||
const auto px = CenterPixel();
|
||||
EXPECT_EQ(FirstGLError(), 0u);
|
||||
EXPECT_GT(px[1], 200) << "the draws inside the counted window never landed, so the count below "
|
||||
"would be a number about nothing";
|
||||
|
||||
Gl().EndFrame(); // the swap that emits the window covering exactly the work above
|
||||
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
|
||||
ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
|
||||
<< ": either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not "
|
||||
"reach the process, or nothing reached PipeStats::OnPresent.";
|
||||
RecordProperty("stats_line", window.line.c_str());
|
||||
|
||||
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
|
||||
ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line;
|
||||
EXPECT_EQ(roundtrips, 1)
|
||||
<< "one storage definition of an adopted arena is one map_persistent emission "
|
||||
"(ARCHITECTURE.md:474, D-B2: mint OR decline, both need an answer from the resource "
|
||||
"owner). This window defined the arena once and drew from it "
|
||||
<< kDrawsInTheWindow << " times, so 1 is the whole cost; " << kDrawsInTheWindow
|
||||
<< " would mean the acquisition moved onto the draw path - the ~167 ms/arena hiccup this "
|
||||
"adoption removed, re-introduced - and 0 would mean the emission stopped happening. It "
|
||||
"reported: "
|
||||
<< window.line;
|
||||
}
|
||||
|
||||
} // namespace MGITest
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.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 - THE P3a SUBSYSTEM A/B IS REAL (gate G12).
|
||||
//
|
||||
// P3a migrates two subsystems: kMGPipeSubsystemResources (bit 7, the resource_* family) and
|
||||
// kMGPipeSubsystemVertexInput (bit 8, vertex elements / buffers / index). The push build's default
|
||||
// mask becomes kMGPipeSubsystemsMigratedAtP3a = 0x1ff, and P2's 0x7f survives as the control that
|
||||
// clears exactly those two bits - MGPipe.h:79's rule that every phase's constant keeps meaning what
|
||||
// it meant, so an operator's recorded mask is still readable a phase later.
|
||||
//
|
||||
// That A/B is what every "push vs pull" number in MEASUREMENTS.md is taken against, and it has one
|
||||
// characteristic failure mode: the bits stop steering anything, both arms run the same code, and
|
||||
// every later comparison is quietly taken against a switch that does nothing. This file is the
|
||||
// entry that cannot let that happen.
|
||||
//
|
||||
// WHAT IT ASSERTS, per arm:
|
||||
//
|
||||
// on (MOBILEGL_PIPE_PUSH=0x1ff)
|
||||
// The client emits map_persistent for every definition of a store past
|
||||
// BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the window's
|
||||
// map-persistent-roundtrips (`mpr=`) equals the number of definitions in it - one per
|
||||
// storage definition, mint or decline (D-B2).
|
||||
//
|
||||
// off (MOBILEGL_PIPE_PUSH=0x7f, P2's default = P3a's subsystems cleared)
|
||||
// The frontend dispatch falls through to the legacy BufferBackendOps arm, nothing is emitted
|
||||
// through the resource family, and mpr= must read ZERO. This is the reading a dead switch
|
||||
// fails: with bit 7 ignored, this arm would report the same non-zero count as the other one.
|
||||
//
|
||||
// both arms
|
||||
// THE PIXELS MUST NOT MOVE. The arena is filled with one solid-colour quad and drawn, and
|
||||
// both arms must read back that colour. "The counters moved and the picture did not" is the
|
||||
// whole claim - a switch that changed what is drawn would not be an A/B, it would be a bug.
|
||||
//
|
||||
// WHY IT CAN SKIP. The counter is emitted by the client-side resource tracker (P3a package B), and
|
||||
// this file is written against the P3a contract commit, before that package lands. Until then
|
||||
// nothing emits map_persistent, mpr= is structurally zero in BOTH arms, and an assertion about the
|
||||
// difference would be a statement about nothing. The build answers the question rather than a
|
||||
// hand-maintained list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/
|
||||
// for the counter's name and passes the answer in as MGITEST_PIPE_RESOURCE_EMITTER_PRESENT, with a
|
||||
// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale. It
|
||||
// is a CONTENT probe, not a filename probe, so the owning package keeps control of its own file
|
||||
// layout - P3a's new client files are headers (D-N), and a glob for `ResourceTracker.cpp` would
|
||||
// have kept this control skipping forever with a reason that had become false.
|
||||
//
|
||||
// DirectGLES ONLY, and that is the honest scope: P3a migrates Espryt's buffer and VAO paths.
|
||||
// Magma's buffer path is P7 and registers no MGPipeResourceOps, so a DirectVulkan lane here would
|
||||
// be measuring the client emitter against a backend that has not been asked to change - which is
|
||||
// a real question, but it is P7's, not this control's.
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/PipeStatsWindow.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 {
|
||||
|
||||
// Set by the two ResourceSubsystemControl. ctest entries and by nothing else; a harness
|
||||
// marker, never read by the library. Its absence means an ambient entry, where neither the
|
||||
// stats channel nor a private log path is configured.
|
||||
constexpr const char* kLaneMarker = "MGITEST_RESOURCE_SUBSYSTEM_LANE";
|
||||
constexpr const char* kLaneOn = "on";
|
||||
constexpr const char* kLaneOff = "off";
|
||||
|
||||
// Past BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the store is offered for
|
||||
// adoption at all; the vertex payload sits deep inside it so a clamped or aliased adopted
|
||||
// range would miss it. Same shape as LargeArenaAdoptionScenario, deliberately: this
|
||||
// control's workload has to be one the buffer path really takes.
|
||||
constexpr GLsizeiptr kArenaBytes = GLsizeiptr(20) * 1024 * 1024;
|
||||
constexpr GLintptr kVertexOffset = GLintptr(16) * 1024 * 1024;
|
||||
// Two definitions and several draws each, so "one per definition", "one per draw" and
|
||||
// "none at all" are three different numbers.
|
||||
//
|
||||
// ONE ARENA DEFINED TWICE, not two arenas defined once each: the second definition
|
||||
// RE-SPECIFIES a store whose bytes the VAO's attributes are already pointing into, and
|
||||
// the attributes are not re-declared afterwards. That makes this control also the place
|
||||
// where the respecify/retire path is exercised on BOTH arms of the A/B, which is what
|
||||
// ID-9 asks for: `dev`'s d7655247 ("rebind VAOs when an adopted buffer is respecified -
|
||||
// the immediate retire path forgot the buffer-id generation") arrived in
|
||||
// feat/disaggregated with the 5cb826b0 merge, and the handle arm duplicates that retire
|
||||
// core, so an arm that forgot the rebind must be visible somewhere. Here it is a dead
|
||||
// draw or a fault, not a silent divergence. The first cut of this file routed around the
|
||||
// path because the fix was not yet in this branch's history and the workload reproduced
|
||||
// as a hard SIGSEGV in the vertex fetch; that detour is what ID-9 supersedes.
|
||||
//
|
||||
// The COUNT is unaffected by the change: two storage definitions either way, which is
|
||||
// what ARCHITECTURE.md:474 prices.
|
||||
constexpr int kDefinitionsInTheWindow = 2;
|
||||
constexpr int kDrawsPerDefinition = 3;
|
||||
constexpr int kInset = 2;
|
||||
|
||||
constexpr const char* kVS = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr const char* kFS = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() { oColor = vec4(vColor, 1.0); }
|
||||
)";
|
||||
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
};
|
||||
|
||||
std::vector<Vertex> Quad(float r, float g, float b) {
|
||||
return {
|
||||
{-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b},
|
||||
{-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b},
|
||||
};
|
||||
}
|
||||
|
||||
bool BuildMarkerIsSet(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value != nullptr && value[0] == '1' && value[1] == '\0';
|
||||
}
|
||||
|
||||
std::string LaneName() {
|
||||
const char* lane = std::getenv(kLaneMarker);
|
||||
return lane != nullptr ? std::string(lane) : std::string();
|
||||
}
|
||||
|
||||
class ResourceSubsystemControlScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
m_lane = LaneName();
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVS, kFS, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
|
||||
// The VAO only. The arena is created and defined inside the counted window - the
|
||||
// window a summary line reports is "since the previous line", so a definition
|
||||
// taken in SetUp would be counted in a window this case does not control - and
|
||||
// its attribute pointers are declared only once the store exists, because an
|
||||
// attribute whose offset is 16 MiB into a store that has not been defined yet is
|
||||
// a range no driver has to accept.
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str());
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
glUseProgram(0);
|
||||
glBindVertexArray(0);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
if (m_arena != 0) glDeleteBuffers(1, &m_arena);
|
||||
m_arena = 0;
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// GTEST_SKIP() returns from the function it is written in, so this cannot report
|
||||
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
|
||||
void SkipUnlessTheLaneIsAssertableHere() {
|
||||
if (m_lane.empty()) {
|
||||
GTEST_SKIP() << "runs only in its own lane: the two ResourceSubsystemControl. ctest "
|
||||
"entries set MGITEST_RESOURCE_SUBSYSTEM_LANE together with the "
|
||||
"MOBILEGL_PIPE_PUSH bitmask that arm means, MOBILEGL_PIPE_STATS=1, "
|
||||
"MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH. "
|
||||
"None of that is configured in the ambient entries, and the ambient "
|
||||
"log is shared, so a read here would race.";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
|
||||
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH: there are no "
|
||||
"subsystem bits to clear, CallClass::MapPersistentRoundtrips does "
|
||||
"not exist and the summary line carries no mpr=. The entry is "
|
||||
"registered here anyway so that `ctest -L integration-gpu` names the "
|
||||
"same tests in the pull build and the push build (gate G2).";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
|
||||
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
|
||||
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
|
||||
"emits map_persistent, mpr= is structurally zero in BOTH arms and "
|
||||
"the difference between them is not observable yet. P3a package B "
|
||||
"owns the client-side resource tracker; this control arms itself "
|
||||
"when it lands, whatever file that package puts the emitter in.";
|
||||
return;
|
||||
}
|
||||
if (PipeStatsWindow::LibraryLogPath().empty()) {
|
||||
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
|
||||
"summary line is the only channel this module has for reading "
|
||||
"PipeStats";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ONE storage definition - the NULL-data glBufferData past the adoption threshold,
|
||||
// which is Minecraft's arena-creation idiom and the adoption point - then a few
|
||||
// draws. Entirely inside one frame, so one summary window covers exactly this.
|
||||
//
|
||||
// The attribute pointers are declared ONCE, on the first definition, and never again:
|
||||
// definition 0 creates the store, every later index RE-SPECIFIES it under the live
|
||||
// VAO. Re-declaring them afterwards would re-sync the VAO by hand and hide the thing
|
||||
// the second definition is here to exercise (see kDefinitionsInTheWindow above).
|
||||
void DefineTheArenaAndDrawFromIt(int index, float r, float g, float b) {
|
||||
if (index == 0) glGenBuffers(1, &m_arena);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
|
||||
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
|
||||
const std::vector<Vertex> vertices = Quad(r, g, b);
|
||||
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
|
||||
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
|
||||
if (index == 0) {
|
||||
glBindVertexArray(m_vao);
|
||||
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);
|
||||
}
|
||||
glUseProgram(m_program);
|
||||
for (int draw = 0; draw < kDrawsPerDefinition; ++draw) {
|
||||
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||
}
|
||||
}
|
||||
|
||||
std::string m_lane;
|
||||
GLuint m_program = 0;
|
||||
GLuint m_vao = 0;
|
||||
GLuint m_arena = 0;
|
||||
};
|
||||
|
||||
// ONE case per lane, and it is a constraint rather than a preference: this case READS the
|
||||
// library log, the log is a per-LANE resource (the library opens it fopen(path, "w"), so
|
||||
// every process in a lane truncates it), and a second case here would race this one under
|
||||
// `ctest -j` with a failure indistinguishable from "the counter was never emitted". The
|
||||
// plumbing is asserted first, with its own message, inside this one process.
|
||||
TEST_F(ResourceSubsystemControlScenario, ClearingTheP3aBitsStopsTheEmissionsAndNotThePixels) {
|
||||
if (!Ready()) return;
|
||||
SkipUnlessTheLaneIsAssertableHere();
|
||||
if (IsSkipped()) return;
|
||||
|
||||
BindDefaultFramebuffer();
|
||||
Gl().EndFrame(); // close the setup window: everything below is one window
|
||||
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
for (int definition = 0; definition < kDefinitionsInTheWindow; ++definition) {
|
||||
DefineTheArenaAndDrawFromIt(definition, 0.0f, 1.0f, 0.0f);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
|
||||
<< "arena definition " << definition
|
||||
<< " left a GL error behind (definition 0 creates the store, every later one "
|
||||
"re-specifies it under the live VAO)";
|
||||
}
|
||||
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
||||
Gl().EndFrame(); // the swap that emits the window covering exactly the work above
|
||||
|
||||
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
|
||||
ASSERT_TRUE(window.found)
|
||||
<< "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
|
||||
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting "
|
||||
"here), so either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not reach the "
|
||||
"process, or no summary line was emitted at all because nothing reached "
|
||||
"PipeStats::OnPresent.";
|
||||
RecordProperty("stats_line", window.line.c_str());
|
||||
|
||||
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
|
||||
ASSERT_GE(roundtrips, 0)
|
||||
<< "the summary line carries no mpr= field, so this build's PipeStats has no "
|
||||
"map-persistent-roundtrips counter to read: "
|
||||
<< window.line;
|
||||
|
||||
if (m_lane == kLaneOn) {
|
||||
EXPECT_EQ(roundtrips, static_cast<long long>(kDefinitionsInTheWindow))
|
||||
<< "with bits 7|8 SET the resource family is the path a store definition takes, so "
|
||||
"each of the " << kDefinitionsInTheWindow
|
||||
<< " definitions in this window is one map_persistent emission (mint or decline - "
|
||||
"both need an answer from the resource owner, D-B2). "
|
||||
<< (kDefinitionsInTheWindow * kDrawsPerDefinition)
|
||||
<< " would mean an acquisition per draw, and 0 would mean the emission never "
|
||||
"happened on the arm that is supposed to do it. It reported: "
|
||||
<< window.line;
|
||||
} else if (m_lane == kLaneOff) {
|
||||
EXPECT_EQ(roundtrips, 0)
|
||||
<< "with bits 7|8 CLEARED (MOBILEGL_PIPE_PUSH=0x7f, P2's default) the frontend "
|
||||
"dispatch must fall through to the legacy BufferBackendOps arm and emit nothing "
|
||||
"through the resource family, so mpr= must be zero. A non-zero count here is the "
|
||||
"dead-switch reading: the bits are being ignored, both arms run the same code, "
|
||||
"and every push-vs-pull number taken against this A/B is measuring one arm twice. "
|
||||
"It reported: "
|
||||
<< window.line;
|
||||
} else {
|
||||
FAIL() << "unknown " << kLaneMarker << " value '" << m_lane
|
||||
<< "': the arms are on / off. Reading an unrecognised name as either would make "
|
||||
"this lane assert the other arm's expectation while claiming to test this one.";
|
||||
}
|
||||
|
||||
// ... and the picture is the same whichever arm ran. The arena is drawn with one solid
|
||||
// colour, so both arms must read back exactly that.
|
||||
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
|
||||
image.Height() - kInset, "green", 0.0,
|
||||
"the arena draw [" + m_lane + "]"))
|
||||
<< "the subsystem bits changed what is DRAWN, which is not an A/B - the handle path and "
|
||||
"the legacy path must produce the same pixels from the same arena.";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -19,11 +19,26 @@
|
||||
//
|
||||
// The assertion is deliberately on the WHOLE grown range, so a partial write names the byte the
|
||||
// stale extent stopped at.
|
||||
//
|
||||
// P3a ADDS THE COST OF THAT REGROWTH (gate G10). ARCHITECTURE.md:474 prices a persistently mapped
|
||||
// store at "one round trip per STORAGE DEFINITION, not one per store" - and, emphatically, not one
|
||||
// per draw. `map-persistent-roundtrips` (`mpr=` in the summary line) counts every map_persistent
|
||||
// EMISSION, mint or decline (D-B2), so the claim is directly countable: N definitions of an
|
||||
// adopted store must publish exactly N, whatever the workload does between them. A regression that
|
||||
// re-acquires per dispatch reports N x dispatches, which is the failure this case exists to name;
|
||||
// a regression that stops emitting reports 0.
|
||||
//
|
||||
// The second case therefore respecifies a store LARGE ENOUGH TO BE ADOPTED
|
||||
// (BufferObject::TryAdoptLargeStorage's 16 MiB threshold), several times, with several dispatches
|
||||
// between the definitions, and reads the one window that covers exactly that workload. It skips -
|
||||
// visibly, with the reason - on a tree where nothing emits the counter yet.
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/PipeStatsWindow.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
@@ -50,6 +65,31 @@ void main() {
|
||||
constexpr int kSmallElements = 6; // 24 bytes - the first iteration's size
|
||||
constexpr int kLargeElements = 24; // 96 bytes - what the second iteration grows to
|
||||
|
||||
// The G10 case's sizes. Every one of them is past BufferObject::TryAdoptLargeStorage's
|
||||
// 16 MiB threshold, because a store below it is never offered for adoption at all and the
|
||||
// window would then be asserting that nothing happened. They GROW, which is the scenario's
|
||||
// subject: each glBufferData is a new storage definition, so each is one acquisition.
|
||||
constexpr int kAdoptedDefinitions = 3;
|
||||
constexpr int kAdoptedBaseElements = 5 * 1024 * 1024; // 20 MiB of uint
|
||||
constexpr int kAdoptedGrowthElements = 1024 * 1024; // + 4 MiB per definition
|
||||
// Enough dispatches per definition that "one per definition" and "one per dispatch" are
|
||||
// different numbers by a wide margin (3 vs 12), and few enough to stay cheap.
|
||||
constexpr int kDispatchesPerDefinition = 4;
|
||||
// Only the first elements are dispatched over: the point of the large store is the
|
||||
// ADOPTION, not the compute cost.
|
||||
constexpr int kDispatchedElements = 6;
|
||||
|
||||
// Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker,
|
||||
// never read by the library. Its absence means an ambient entry, where neither the stats
|
||||
// channel nor a private log path is configured - and where the shared log makes a read
|
||||
// race a neighbour's bring-up.
|
||||
constexpr const char* kLaneMarker = "MGITEST_MPR_LANE";
|
||||
|
||||
bool BuildMarkerIsSet(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value != nullptr && value[0] == '1' && value[1] == '\0';
|
||||
}
|
||||
|
||||
class StorageBufferRegrowScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
@@ -114,6 +154,56 @@ void main() {
|
||||
return values;
|
||||
}
|
||||
|
||||
// The Minecraft arena idiom, and the adoption point: a NULL-data definition of a
|
||||
// store past the threshold. No host-side vector, so a 28 MiB definition costs
|
||||
// nothing on this side of the API.
|
||||
void DefineAdoptedStore(int elements) {
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
|
||||
glBufferData(GL_SHADER_STORAGE_BUFFER,
|
||||
static_cast<GLsizeiptr>(static_cast<GLsizeiptr>(elements) *
|
||||
static_cast<GLsizeiptr>(sizeof(unsigned int))),
|
||||
nullptr, GL_DYNAMIC_DRAW);
|
||||
}
|
||||
|
||||
// GTEST_SKIP() returns from the function it is written in, so this cannot report
|
||||
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
|
||||
void SkipUnlessTheRoundtripCounterIsReadableHere() {
|
||||
if (std::getenv(kLaneMarker) == nullptr) {
|
||||
GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry "
|
||||
"sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, "
|
||||
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
|
||||
"MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient "
|
||||
"entries, and the ambient log is shared, so a read here would race a "
|
||||
"neighbour's bring-up.";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
|
||||
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so "
|
||||
"CallClass::MapPersistentRoundtrips does not exist (PipeStats.h "
|
||||
"declares it inside the push guard, because growing the enum in a "
|
||||
"pull build resizes the counter arrays and the name table - a G1 "
|
||||
"break for a counter that could never leave zero) and the summary "
|
||||
"line carries no mpr=. The entry is registered here anyway so that "
|
||||
"`ctest -L integration-gpu` names the same tests in the pull build "
|
||||
"and the push build (gate G2).";
|
||||
return;
|
||||
}
|
||||
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
|
||||
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
|
||||
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
|
||||
"emits map_persistent and mpr= is structurally zero. P3a package B "
|
||||
"owns the client-side resource tracker; this entry arms itself when "
|
||||
"it lands, whatever file that package puts the emitter in.";
|
||||
return;
|
||||
}
|
||||
if (PipeStatsWindow::LibraryLogPath().empty()) {
|
||||
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
|
||||
"summary line is the only channel this module has for reading "
|
||||
"PipeStats";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned int m_program = 0;
|
||||
GLuint m_buffer = 0;
|
||||
std::string m_buildLog;
|
||||
@@ -153,4 +243,68 @@ void main() {
|
||||
|
||||
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
|
||||
}
|
||||
|
||||
// G10. N storage definitions of an adopted store cost N map-persistent round trips - not one
|
||||
// per draw, and not zero.
|
||||
//
|
||||
// ONE case in this lane, and that is a constraint rather than a preference: it READS the
|
||||
// library log, the log is a per-lane resource (the library opens it fopen(path, "w"), so every
|
||||
// process in a lane truncates it), and a second reading entry in the same lane would race this
|
||||
// one under `ctest -j` with a failure that looks exactly like "the counter was never emitted".
|
||||
// The plumbing is therefore asserted first, with its own message, inside this one process.
|
||||
TEST_F(StorageBufferRegrowScenario, NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw) {
|
||||
if (!Ready()) return;
|
||||
SkipUnlessTheRoundtripCounterIsReadableHere();
|
||||
if (IsSkipped()) return;
|
||||
|
||||
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer);
|
||||
Gl().EndFrame(); // close the setup window: everything below is one window
|
||||
|
||||
for (int definition = 0; definition < kAdoptedDefinitions; ++definition) {
|
||||
DefineAdoptedStore(kAdoptedBaseElements + definition * kAdoptedGrowthElements);
|
||||
ASSERT_EQ(FirstGLError(), 0u) << "definition " << definition << " of the adopted store failed";
|
||||
for (int dispatch = 0; dispatch < kDispatchesPerDefinition; ++dispatch) {
|
||||
glUseProgram(m_program);
|
||||
glDispatchCompute(static_cast<GLuint>(kDispatchedElements), 1, 1);
|
||||
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
// The store is still the one the last definition made, and it still works: a counter
|
||||
// assertion over a workload that silently stopped functioning would be measuring nothing.
|
||||
std::vector<unsigned int> values(static_cast<std::size_t>(kDispatchedElements), 0xDEADBEEFu);
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
|
||||
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
|
||||
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
|
||||
EXPECT_EQ(FirstGLError(), 0u);
|
||||
for (int i = 0; i < kDispatchedElements; ++i) {
|
||||
EXPECT_EQ(values[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
|
||||
<< "the adopted store's own dispatch did not write element " << i;
|
||||
}
|
||||
|
||||
Gl().EndFrame(); // the swap that emits the window covering exactly the loop above
|
||||
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
|
||||
ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
|
||||
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD "
|
||||
"before getting here), so either MOBILEGL_PIPE_STATS / "
|
||||
"MOBILEGL_PIPE_STATS_PERIOD did not reach the process or no summary "
|
||||
"line was emitted at all because nothing reached PipeStats::OnPresent.";
|
||||
RecordProperty("stats_line", window.line.c_str());
|
||||
|
||||
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
|
||||
ASSERT_GE(roundtrips, 0)
|
||||
<< "the summary line carries no mpr= field, so this build's PipeStats has no "
|
||||
"map-persistent-roundtrips counter to read: " << window.line;
|
||||
EXPECT_EQ(roundtrips, static_cast<long long>(kAdoptedDefinitions))
|
||||
<< "an adopted store costs ONE map_persistent per STORAGE DEFINITION "
|
||||
"(ARCHITECTURE.md:474). This window defined the store " << kAdoptedDefinitions
|
||||
<< " times and dispatched " << kDispatchesPerDefinition << " times against each of them, so "
|
||||
<< kAdoptedDefinitions << " is the whole cost. "
|
||||
<< (kAdoptedDefinitions * kDispatchesPerDefinition)
|
||||
<< " would mean an acquisition per DRAW - the regression this counter exists to catch - and 0 "
|
||||
"would mean nothing emitted map_persistent at all. It reported: "
|
||||
<< window.line;
|
||||
|
||||
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
|
||||
}
|
||||
} // namespace MGITest
|
||||
|
||||
@@ -34,11 +34,39 @@
|
||||
/* dead: no backend reads it since D21; kept for inventory row 594 */ \
|
||||
X(GetBoundTransformFeedbackName, SetStreamOutputTargets) \
|
||||
X(GetBoundVertexArray, BindVertexElements) \
|
||||
/* Polymorphic over BufferTarget: its rows split across set_vertex_buffers, */ \
|
||||
/* set_index_buffer, set_indirect_buffers and set_shader_buffers when the */ \
|
||||
/* inventory is re-vendored carrying the target argument (deferred out of P1: */ \
|
||||
/* the extractor lives in MobileGL-CS). Named for the plan's explicit */ \
|
||||
/* replacement of the DrawIndirect/Parameter pair. */ \
|
||||
/* Polymorphic over BufferTarget, and P3a SPLITS it - not by re-vendoring the */ \
|
||||
/* inventory (the extractor lives in MobileGL-CS and still does not carry the */ \
|
||||
/* target argument), but by supplying the target from the EMISSION SITE, which */ \
|
||||
/* knows it exactly. The split, target by target, in BufferTarget's OWN */ \
|
||||
/* spelling (BufferObject.h:15-33) so the list cross-checks mechanically: */ \
|
||||
/* Vertex, and the per-attribute buffer of a VAO -> set_vertex_buffers */ \
|
||||
/* Index -> set_index_buffer */ \
|
||||
/* DrawIndirect, Parameter -> set_indirect_buffers */ \
|
||||
/* Uniform, ShaderStorage, AtomicCounter, */ \
|
||||
/* TransformFeedback -> set_shader_buffers */ \
|
||||
/* CopyRead, CopyWrite, PixelPack, PixelUnpack, */ \
|
||||
/* Texture -> still pulled: the */ \
|
||||
/* transfer and pixel-store targets have no call of their own yet; */ \
|
||||
/* DispatchIndirect -> still pulled, and */ \
|
||||
/* it is the one a reader most plausibly assumes set_indirect_buffers */ \
|
||||
/* covers: BOTH backends read it (SyncBoundBuffer at every */ \
|
||||
/* glDispatchComputeIndirect) and NO call carries it - MGPIndirectBuffers */ \
|
||||
/* is the DrawIndirect + Parameter pair only - so the field stays the */ \
|
||||
/* fill loop's for this target; */ \
|
||||
/* Query -> still pulled: no */ \
|
||||
/* backend reads it at all and no call names it. */ \
|
||||
/* That is 15 of 15; a target missing from this list is a target a later */ \
|
||||
/* phase would retire the pull for on the strength of a split that never */ \
|
||||
/* covered it. */ \
|
||||
/* THE ROW STAYS ONE ROW, and that is structural rather than a shortcut: this */ \
|
||||
/* list IS the MGPipeInputField enum and the PipeInputs field set, and the */ \
|
||||
/* field is ONE array (m_bufferBindingSlot[kBufferTargetCount]) that a second */ \
|
||||
/* row of the same name could only duplicate. So the split lives here and in */ \
|
||||
/* the emitters, and the row keeps naming set_indirect_buffers for the plan's */ \
|
||||
/* explicit replacement of the DrawIndirect/Parameter pair. It is deliberately */ \
|
||||
/* NOT in the EMITTED list below: seven targets above are still pulled, and a */ \
|
||||
/* row there says "the whole field is supplied", which for this field would be */ \
|
||||
/* the same half-truth GetPixelStoreParameters is kept out for. */ \
|
||||
X(GetBufferBindingSlot, SetIndirectBuffers) \
|
||||
X(GetBufferBindingPoint, SetShaderBuffers) \
|
||||
X(GetBufferBindingPointCount, SetShaderBuffers) \
|
||||
@@ -71,9 +99,10 @@
|
||||
X(GetProgramForDispatch, SetDispatchProgram) \
|
||||
X(GetProgramForDraw, SetDrawProgram) \
|
||||
X(GetProgramObject, CreateShaderState) \
|
||||
/* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \
|
||||
/* state; recorded here so the G7 chunk table has to answer for it before it */ \
|
||||
/* freezes (section 10.3-5). */ \
|
||||
/* ANSWERED by P2: it is in the pipeline half. SetProvokingVertexMode calls */ \
|
||||
/* BumpVersions(), and the chunk table's rule is exactly that, so it rides */ \
|
||||
/* pipeline chunk P4 - a strict superset of what ComputePipelineStateHash used */ \
|
||||
/* to hash (MGPipeRenderStateSpans.cpp records the provenance). */ \
|
||||
X(GetProvokingVertexMode, CreateRenderState) \
|
||||
X(GetRenderStateParameters, CreateRenderState) \
|
||||
X(GetRenderStateParametersVersion, BindRenderState) \
|
||||
@@ -128,4 +157,78 @@
|
||||
X(handle-ify (wire handle), kStructuralHandle) \
|
||||
X(Buffer ops delta, ResourceRespecify)
|
||||
|
||||
// X(Accessor, PipeCall) - the EMITTED list (P2 brief D5): which P2 call now SUPPLIES this
|
||||
// PipeInputs field, so the per-verb residual fill loop no longer has to pull it out of
|
||||
// GLContext. gen_pipe.py turns it into kMGPipeFieldEmittedBy[] (generated/PipeFilled.inc);
|
||||
// a field with no row here keeps going through the fill loop, which is what makes the
|
||||
// MOBILEGL_PIPE_PUSH bitmask a true per-subsystem A/B rather than an all-or-nothing switch.
|
||||
//
|
||||
// Every name must be an accessor in MGP_COVERAGE_ACCESSOR_LIST and every call must be a
|
||||
// real call in PipeCalls.def; gen_pipe.py refuses anything else.
|
||||
//
|
||||
// The one row whose call differs from the accessor list's is GetPrimitiveRestartIndex:
|
||||
// coverage maps it onto draw_vbo because that is where a backend reads it, but the VALUE
|
||||
// travels in dynamic chunk D6, so set_dynamic_state is what supplies it.
|
||||
//
|
||||
// GetPixelStoreParameters is DELIBERATELY ABSENT, and the reason is the shape of the field
|
||||
// rather than of the call. The field is PipeInputs::m_pixelStore[2] - pack AND unpack - and
|
||||
// set_pixel_pack_state carries the PACK half only, deliberately and permanently (D10,
|
||||
// ARCHITECTURE.md 4.6 D5: nothing on the far side of the boundary reads unpack state). A row
|
||||
// here says "this field is supplied, the fill loop may skip it", which would be a half-truth:
|
||||
// the moment the render-state bitmask has its bit set, the unpack half would be written by
|
||||
// nothing while its poison stamp said it was published, so neither the poison nor the verify
|
||||
// comparator could see it. Until the field is split, the whole of it keeps going through the
|
||||
// fill loop and the pack half is simply written twice.
|
||||
//
|
||||
// P3a ADDS ONE ROW, GetBoundVertexArray -> BindVertexElements, and it is the vertex-input
|
||||
// family's only candidate: GetBufferBindingSlot is polymorphic over a target set P3a covers
|
||||
// only part of (see its comment above) and GetCurrentVertexAttribute has been here since P2.
|
||||
// The row is INERT until the vertex-input subsystem is wired - MG_Impl/Pipe/PipeFill.cpp's
|
||||
// kMGPipeWiredSubsystems does not carry that bit at the contract commit, because the emitters
|
||||
// beside it are still stubs - which is exactly the guard that lets a row land before the call
|
||||
// that carries it exists.
|
||||
//
|
||||
// A NOTE FOR THE COMMIT THAT WIRES IT, because it is not visible from this file: the field is
|
||||
// a shared pointer to the frontend VAO, and the vertex-input calls supply the CONFIGURATION
|
||||
// (the applier's MGPipeVertexElementsRecord), not the object. So the row is shape-only in the
|
||||
// same sense GetCurrentVertexAttribute's is, and it stays so until the backend's twin
|
||||
// resolution reads the applier's BoundVertexElements instead of the object - at which point
|
||||
// PipeFill.cpp's EmittedCallSuppliesTheWholeField arm is where that is decided, deliberately
|
||||
// rather than silently by this row's presence.
|
||||
#define MGP_COVERAGE_EMITTED_LIST(X) \
|
||||
X(GetBlendColor, SetDynamicState) \
|
||||
X(GetBlendEquationIndexed, CreateRenderState) \
|
||||
X(GetBlendFuncIndexed, CreateRenderState) \
|
||||
X(GetBoundVertexArray, BindVertexElements) \
|
||||
X(GetClampReadColor, SetDynamicState) \
|
||||
X(GetClearColor, SetDynamicState) \
|
||||
X(GetClearDepth, SetDynamicState) \
|
||||
X(GetClearStencil, SetDynamicState) \
|
||||
X(GetColorMaskIndexed, CreateRenderState) \
|
||||
X(GetCullFaceMode, CreateRenderState) \
|
||||
X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \
|
||||
X(GetDepthFunc, CreateRenderState) \
|
||||
X(GetDepthMask, CreateRenderState) \
|
||||
X(GetDepthRangeIndexed, SetDynamicState) \
|
||||
X(GetLineWidth, SetDynamicState) \
|
||||
X(GetLogicOp, CreateRenderState) \
|
||||
X(GetMinSampleShadingValue, CreateRenderState) \
|
||||
X(GetPatchDefaultInnerLevel, SetPatchState) \
|
||||
X(GetPatchDefaultOuterLevel, SetPatchState) \
|
||||
X(GetPatchVertices, SetPatchState) \
|
||||
X(GetPipelineStateVersion, BindRenderState) \
|
||||
X(GetPolygonModeFront, CreateRenderState) \
|
||||
X(GetPolygonOffsetFactor, SetDynamicState) \
|
||||
X(GetPolygonOffsetUnits, SetDynamicState) \
|
||||
X(GetPrimitiveRestartIndex, SetDynamicState) \
|
||||
X(GetProvokingVertexMode, CreateRenderState) \
|
||||
X(GetRenderStateParameters, CreateRenderState) \
|
||||
X(GetRenderStateParametersVersion, BindRenderState) \
|
||||
X(GetScissorBox, SetDynamicState) \
|
||||
X(GetStencilState, CreateRenderState) \
|
||||
X(GetViewport, SetDynamicState) \
|
||||
X(GetViewportIndexed, SetDynamicState) \
|
||||
X(IsCapabilityEnabled, CreateRenderState) \
|
||||
X(IsCapabilityEnabledIndexed, CreateRenderState)
|
||||
|
||||
// clang-format on
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
// MobileGL - MobileGL/MG_Pipe/DirtySurface.def
|
||||
// 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
|
||||
|
||||
// The dirty-surface mapping (ARCHITECTURE.md 5.2 corollary 4, P2 brief D16).
|
||||
//
|
||||
// MGPipe replaces "the backend rediscovers what changed" with "the frontend says what
|
||||
// changed", which only works if EVERY frontend mutation a backend can observe has an answer
|
||||
// to "what publishes this". The failure mode is silent and one-directional: a mutation that
|
||||
// forgets to publish renders stale, and no purity gate can see it.
|
||||
//
|
||||
// So the surface is enumerated MECHANICALLY. scripts/gen_pipe_dirty_surface.py scans
|
||||
// MG_Impl/GLImpl AND MG_State/GLState for every pGLContext-> mutator call and every
|
||||
// MGP_NOTE_MUTATION site, and, with --check, fails if a scanned mutator has no row here or a
|
||||
// row here names a mutator the scan no longer finds. Both directions, so a deleted mutator
|
||||
// cannot leave a stale row behind either.
|
||||
//
|
||||
// THE SECOND ROOT AND THE SECOND MECHANISM ARE P3a's, and they were not cosmetic: reading
|
||||
// MG_State/GLState found BumpSamplingResolutionGeneration, a mutator with no row at all
|
||||
// whose every caller lives in that directory, and reading MGP_NOTE_MUTATION brought
|
||||
// NoteUnitTouched - which no `pGLContext->` pattern can see, because every texture and
|
||||
// sampler bind routes through it rather than calling it through the context - inside the
|
||||
// gate. A notice site carries a FIELD name, so the mutator a row names is the ENCLOSING
|
||||
// function.
|
||||
//
|
||||
// ANSWERS. A row lists EVERY publisher that fires on EVERY path through that mutator,
|
||||
// and only those; several are joined with '|'. A publisher that fires on some paths but
|
||||
// not all must not appear, because a shutter built from this file would then UNDER-fire,
|
||||
// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. The one row that
|
||||
// carries a bit which fires on only some paths says so in its answer - kPulledPartialShutter
|
||||
// joined with that bit - because the alternative, dropping the bit, tells a reader of this
|
||||
// file that a bit P2 already emits a call for has no shutter at all.
|
||||
//
|
||||
// "EVERY PATH" MEANS EVERY PATH THAT MUTATES. A setter that returns early because the value
|
||||
// did not change publishes nothing and needs to publish nothing - there is no mutation to
|
||||
// carry - so a redundant-write guard (SetColorMask's `if (changed) BumpVersions();`, the
|
||||
// BitwiseEqual guards on the patch levels) does not make its publisher conditional in the
|
||||
// sense this rule cares about. A publisher reached on only SOME of the paths that DO mutate
|
||||
// - SetCapability's ClipDistance arms, SetStencilFunc's reference-only call - is the thing
|
||||
// that must not be named.
|
||||
//
|
||||
// EVERY BIT ANSWER IN THIS FILE IS DERIVED AND CHECKED, in two families and one
|
||||
// direction. The RenderState family (45 rows) is checked both ways against RenderState.cpp,
|
||||
// below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that
|
||||
// bit: gen_pipe_dirty_surface.py resolves what the shutter READS and what each mutator
|
||||
// transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop it reads out of
|
||||
// MGPipeNoteAggregate's own switch, and through the function-like macros of MG_State, which
|
||||
// it EXPANDS - sixteen of RenderState.cpp's writes exist only after the preprocessor has
|
||||
// pasted them together) to the SAME two-level token, MEM:<member> plus FIELD:<member>.<leaf>,
|
||||
// and fails a row that names a bit whose shutter its mutator moves on no path at all. That
|
||||
// half is one-directional on purpose - "it does write something the shutter reads" cannot
|
||||
// prove it does so on EVERY path - so it catches under-firing and not over-claiming.
|
||||
//
|
||||
// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that twice:
|
||||
// its write analysis first recorded a write through a member's field as the field alone
|
||||
// and never the member, so --check printed, as a fact about RenderState.cpp, that
|
||||
// SetPixelStoreParam "writes nothing NEW_PIXEL_PACK's shutter reads" about a setter whose
|
||||
// whole body is sixteen writes to exactly that member; then, once it read the member, it
|
||||
// still could not see a write through a REFERENCE (SetBlendEquation's `for (auto&
|
||||
// blendState : m_parameters.BlendStates)`) and said the same false thing about seven more
|
||||
// setters - while its reader side resolved `render.PatchVertices` to the WHOLE of
|
||||
// m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and
|
||||
// a row saying glClearColor publishes the patch state was green. So now: a reference or
|
||||
// pointer bound to a member-rooted lvalue is followed, and its writes are credited to the
|
||||
// member and the field it was bound to; a write whose root the analysis cannot place (a
|
||||
// reference parameter, a call result, a member without the m_ prefix, a token it could not
|
||||
// expand) TAINTS the function, and every answer that depends on a tainted function is
|
||||
// UNDECIDED - printed with its reason, never a verdict; a writer supports a bit only when
|
||||
// the two sides share a member AND, both resolved to fields, their field sets intersect (a
|
||||
// whole-member write or read is every field); a member in common with no field information
|
||||
// on one side is COARSE, reported and never counted. --check counts only the supported
|
||||
// answers as derived, prints the COARSE and UNDECIDED tallies, and FAILS on an UNDECIDED
|
||||
// row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST at the bottom of this file marks it - a
|
||||
// mark that outlives its reason is a red gate too. What it still cannot claim: a shutter
|
||||
// member written outside MG_State/GLState + MG_Impl/Pipe is undecided in the absence
|
||||
// direction, a call is resolved by NAME to every body of that name, and a FIELD token is
|
||||
// not scoped to a type - all three only widen what a mutator is credited with, and the
|
||||
// second is also how a taint spreads.
|
||||
//
|
||||
// The prose answers (kImmediate, kExplicitDestroy, kUnpublishedDestroy, kNoBackendRead,
|
||||
// kPulledEveryVerb, kPulledPartialShutter, kReverseChannel) are statements no derivation
|
||||
// checks - except the bits a kPulledPartialShutter row names, which are checked like any
|
||||
// other bit answer. --check prints how many rows carry a prose answer, so "all mapped" can
|
||||
// never be read as "all verified".
|
||||
//
|
||||
// For the RenderState family that answer is not a matter of taste and it is CHECKED
|
||||
// rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and
|
||||
// derives, per setter, which of NEW_RENDER_STATE / NEW_PIPELINE_STATE moves on every
|
||||
// path - BumpVersions() moves both, a bare ++m_version moves only NEW_RENDER_STATE, and
|
||||
// a setter with both kinds of path therefore always-fires only NEW_RENDER_STATE - and
|
||||
// --check fails when a row disagrees, in either direction. That check exists because
|
||||
// this file got exactly two rows wrong: SetCapability, whose ClipDistance0..7 arms move
|
||||
// only m_version, and SetStencilFunc, whose pipeline bump is conditional on Func moving.
|
||||
// Both named NEW_PIPELINE_STATE, which does not fire for glEnable(GL_CLIP_DISTANCE0) or
|
||||
// for a reference-only glStencilFunc.
|
||||
//
|
||||
// NEW_* a MGPipeDirty bit (MG_Impl/Pipe/Tracker.h). The tracker's shutter for
|
||||
// that bit moves when this mutator runs, so the next verb publishes it.
|
||||
// kImmediate the mutating function also reaches the backend in the same body, so the
|
||||
// mutation is published inline and needs no shutter at all.
|
||||
// kReverseChannel not state: a write INTO the frontend from the backend's side.
|
||||
// kNoBackendRead no backend read point observes this state at all.
|
||||
// kExplicitDestroy published by the delete_* / resource_destroy call the Track H slice
|
||||
// emits when the object's last reference drops - an object's DEATH,
|
||||
// which no generation shutters because there is no longer an object
|
||||
// to carry one. Only for a kind that HAS an identity on the wire to
|
||||
// destroy: the resources and CSOs of PipeCalls.def, which is what P2
|
||||
// brief D13 scopes Espryt 0b's explicit destroy to.
|
||||
// kUnpublishedDestroy
|
||||
// the same event for a kind NOTHING publishes: a program, a program
|
||||
// pipeline and a shader have no per-object handle on the wire at all
|
||||
// in P2 - resource_destroy and the delete_* family name resources and
|
||||
// CSOs - so their DirectGLES twins are still reclaimed by the
|
||||
// backend's own registry teardown and no frontend call says they
|
||||
// died. Recorded as a hole rather than dressed up as a mechanism that
|
||||
// exists; naming kExplicitDestroy here would be the same defect the
|
||||
// RenderState derivation above exists to stop, one class down in
|
||||
// stakes. (D13's prose says 'six kinds' while the Core.cpp ranges it
|
||||
// cites also cover MarkProgram/MarkShaderForDeletion; the tree
|
||||
// decides, and the tree has no wire object for those three.)
|
||||
// kPulledEveryVerb no shutter exists at all - no MGPipeDirty bit moves on any path through
|
||||
// this mutator - and none is needed yet: the PipeInputs field it writes is
|
||||
// in its verb class's may-read mask, so the residual fill copies it at
|
||||
// EVERY verb of that class. A shutter here is a P3/P4 optimisation, not a
|
||||
// correctness gap.
|
||||
// kPulledPartialShutter
|
||||
// the same pull, but a bit DOES move - on some of the paths that mutate,
|
||||
// not all of them - so this row must never be read as "no shutter exists".
|
||||
// The bits that move are named after the '|', which is the one place this
|
||||
// file joins a prose answer with a bit, and the reason is exactly that a
|
||||
// P3a shutter builder has to be able to tell "no bit covers this" from "a
|
||||
// bit covers half of it". The named bits are checked the same way every
|
||||
// other bit answer is - a dead one is a red gate - but they are NOT a
|
||||
// licence to narrow: what holds on every mutating path is the pull.
|
||||
// Which rows need this answer is a human judgement and stays one: the
|
||||
// derivation's "it does move that shutter" direction over-approximates
|
||||
// (a call name resolves to every body of that name, a write inside an
|
||||
// `if` counts), so it can refute a named bit but cannot find the rows
|
||||
// that should have named one.
|
||||
//
|
||||
// KNOWN BLIND SPOTS OF THE SCANNER, recorded here rather than left implicit
|
||||
// (gen_pipe_dirty_surface.py's own notes plus its scan roots):
|
||||
// 1. it matches braced function bodies textually, so a mutator inside a LAMBDA is
|
||||
// attributed to the enclosing function;
|
||||
// 2. a mutation published through a HELPER the entry point calls reads as deferred here.
|
||||
// 3. CLOSED AT P3a. The scan root was MG_Impl/GLImpl only, so the four MGP_NOTE_MUTATION
|
||||
// sites in MG_State/GLState/TextureState/TextureState.h were outside it entirely. The
|
||||
// root is now both directories and the notice is a recognised publish mechanism; what
|
||||
// that found is the two rows marked "P3a" below.
|
||||
// The gate is therefore a COMPLETENESS gate over what the scanner does see. The semantic
|
||||
// proof stays the MOBILEGL_PIPE_VERIFY lane, which is blind to none of them.
|
||||
//
|
||||
// clang-format off
|
||||
|
||||
// X(Mutator, Answer)
|
||||
#define MGP_DIRTY_SURFACE_LIST(X) \
|
||||
/* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \
|
||||
X(RecordError, kReverseChannel) \
|
||||
/* ---- immediate publish points: the same body reaches the backend ---- */ \
|
||||
X(SetActiveTextureUnit, kImmediate) \
|
||||
X(BeginTransformFeedback, kImmediate) \
|
||||
X(EndTransformFeedback, kImmediate) \
|
||||
X(SetTransformFeedbackPaused, kImmediate) \
|
||||
X(MarkTransformFeedbackObjectForDeletion, kImmediate) \
|
||||
/* ---- the render state. Derived from RenderState.cpp and gated by --check: */ \
|
||||
/* a setter that calls BumpVersions() on every path publishes BOTH counters; */ \
|
||||
/* one that also has a bare ++m_version path publishes only NEW_RENDER_STATE. */ \
|
||||
X(SetBlendEquation, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetBlendEquationIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetBlendFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetBlendFuncIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
/* SetCapability's ClipDistance0..7 arms write ClipDistanceEnabledMask (dynamic */ \
|
||||
/* chunk D7) and deliberately do NOT BumpVersions, so NEW_PIPELINE_STATE does */ \
|
||||
/* not fire at all for glEnable(GL_CLIP_DISTANCE0): set_dynamic_state publishes */ \
|
||||
/* it, and NEW_RENDER_STATE is the only answer that holds on every arm. */ \
|
||||
X(SetCapability, NEW_RENDER_STATE) \
|
||||
X(SetCapabilityIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetColorMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetColorMaskIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetCullFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetDepthFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetDepthMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetFrontFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetLogicOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetMinSampleShadingValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetPolygonMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetProvokingVertexMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetSampleCoverage, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetSampleMaskValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
/* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask (dynamic */ \
|
||||
/* D3/D4), and ++m_pipelineStateVersion is CONDITIONAL on Func moving - which is */ \
|
||||
/* what keeps a glStencilFunc that moves only the reference from evicting a */ \
|
||||
/* cached pipeline, and is why only NEW_RENDER_STATE fires on every call. */ \
|
||||
/* SetStencilOp is wholly pipeline, SetStencilMask wholly dynamic. */ \
|
||||
X(SetStencilFunc, NEW_RENDER_STATE) \
|
||||
X(SetStencilOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetStencilMask, NEW_RENDER_STATE) \
|
||||
X(SetBlendColor, NEW_RENDER_STATE) \
|
||||
X(SetClampReadColor, NEW_RENDER_STATE) \
|
||||
X(SetClearColor, NEW_RENDER_STATE) \
|
||||
X(SetClearDepth, NEW_RENDER_STATE) \
|
||||
X(SetClearStencil, NEW_RENDER_STATE) \
|
||||
X(SetClipControl, NEW_RENDER_STATE) \
|
||||
X(SetDepthRange, NEW_RENDER_STATE) \
|
||||
X(SetDepthRangeIndexed, NEW_RENDER_STATE) \
|
||||
X(SetHint, NEW_RENDER_STATE) \
|
||||
X(SetLineWidth, NEW_RENDER_STATE) \
|
||||
X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \
|
||||
X(SetPointSize, NEW_RENDER_STATE) \
|
||||
X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \
|
||||
X(SetPolygonOffset, NEW_RENDER_STATE) \
|
||||
X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \
|
||||
X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \
|
||||
X(SetScissorBox, NEW_RENDER_STATE) \
|
||||
X(SetScissorBoxIndexed, NEW_RENDER_STATE) \
|
||||
X(SetViewport, NEW_RENDER_STATE) \
|
||||
X(SetViewportIndexed, NEW_RENDER_STATE) \
|
||||
/* ---- the other value-class bits ---- */ \
|
||||
/* kPulledPartialShutter, NOT kPulledEveryVerb, and NOT a bare NEW_PIXEL_PACK: */ \
|
||||
/* RenderState::SetPixelStoreParam writes BOTH halves - eight Pack arms and eight */ \
|
||||
/* Unpack arms - while the tracker's bit 2 is a byte compare of the PACK half alone */ \
|
||||
/* (Tracker.h), because set_pixel_pack_state deliberately has no unpack counterpart */ \
|
||||
/* (ARCHITECTURE.md 4.6). So glPixelStorei(GL_PACK_ALIGNMENT, 8) DOES move bit 2 and */ \
|
||||
/* glPixelStorei(GL_UNPACK_ALIGNMENT, 8) moves nothing at all, and a shutter narrowed */ \
|
||||
/* to bit 2 would under-fire for eight of the sixteen arms. What is true on every path */ \
|
||||
/* is the pull: GetPixelStoreParameters is one of the two Coverage.def rows an emitted */ \
|
||||
/* call does not supply completely (PipeFill.cpp), so the residual fill copies both */ \
|
||||
/* halves at every verb of the class. The bit is named anyway because P2 already EMITS */ \
|
||||
/* set_pixel_pack_state off it: a row that said "no shutter exists" about the only */ \
|
||||
/* mutator behind a shipped call would be a false answer to the one question D16 hands */ \
|
||||
/* P3a. Splitting this setter into a pack half and an unpack half is what would let the */ \
|
||||
/* pack half answer NEW_PIXEL_PACK outright; that is P3's move, not P2's. */ \
|
||||
X(SetPixelStoreParam, kPulledPartialShutter|NEW_PIXEL_PACK) \
|
||||
X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
/* Also an immediate publish point, but it has a real bit and the bit is */ \
|
||||
/* the more useful answer: set_patch_state carries it whatever the caller */ \
|
||||
/* does next. */ \
|
||||
X(SetPatchVertices, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
|
||||
X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \
|
||||
X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \
|
||||
X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \
|
||||
/* ---- object class ---- */ \
|
||||
X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \
|
||||
/* P3a, FOUND BY THE WIDENED SCAN ROOT and not by anything before it: every caller of */ \
|
||||
/* this one is inside MG_State/GLState (SamplerObject::BumpVersion for any sampler */ \
|
||||
/* parameter, TextureObjectBase's shape bump), which the scan did not read, so it had */ \
|
||||
/* no row at all while its sibling above did. NEW_SAMPLERS is the bit whose shutter */ \
|
||||
/* mixes the texture-params aggregate with exactly this generation, and the bump is */ \
|
||||
/* unconditional on every path that reaches it - the early-outs are in the setters */ \
|
||||
/* above it, which is the redundant-write guard this file's rule explicitly allows. */ \
|
||||
X(BumpSamplingResolutionGeneration, NEW_SAMPLERS) \
|
||||
/* P3a, and the reason the scan now reads MGP_NOTE_MUTATION as a publish mechanism of */ \
|
||||
/* its own: this function is not a pGLContext-> mutator at all - every texture and */ \
|
||||
/* sampler bind entry point routes THROUGH it - and it moves two pushed PipeInputs */ \
|
||||
/* fields with two different answers. */ \
|
||||
/* GetTextureBindGeneration moves only on the bindingChanged arm, so NEW_SAMPLER_ */ \
|
||||
/* VIEWS fires on SOME of the paths that mutate and not all: a redundant re-bind */ \
|
||||
/* of the object a unit already holds advances the high-water mark alone. That is */ \
|
||||
/* precisely what kPulledPartialShutter is for. */ \
|
||||
/* GetMaxTouchedTextureUnit has no shutter at all; it is in its verb class's may-read */ \
|
||||
/* mask and the residual fill copies it at every verb. */ \
|
||||
/* Both are ALSO published inline by MGP_NOTE_MUTATION when the write happens inside a */ \
|
||||
/* verb already in flight - a backend binding its own synthesised fallback texture - */ \
|
||||
/* which is the window no shutter and no pull can cover, and the reason those sites */ \
|
||||
/* had to come inside this gate rather than stay a recorded blind spot. */ \
|
||||
X(NoteUnitTouched, kPulledPartialShutter|NEW_SAMPLER_VIEWS) \
|
||||
/* NOT NEW_SO_TARGETS, and this one was false on EVERY path: GLContext::SetNamed */ \
|
||||
/* TransformFeedbackBinding either binds a BufferState binding point (index == the */ \
|
||||
/* bound XFB object) or writes a saved-bindings entry, and NEW_SO_TARGETS mixes the */ \
|
||||
/* buffer-CONTENT aggregate with the transform-feedback generation - the first moves */ \
|
||||
/* only at BufferObject.cpp's content sites, the second only in BeginTransformFeedback. */ \
|
||||
/* A binding moves neither. It reaches the backend the same way every other buffer */ \
|
||||
/* binding point does, through GetBufferBindingPoint in the verb class's may-read mask, */ \
|
||||
/* so the honest answer is the pull. Narrowing it is P3b's, when it takes the subsystem */ \
|
||||
/* over and the binding points get a generation of their own. */ \
|
||||
X(SetNamedTransformFeedbackBinding, kPulledEveryVerb) \
|
||||
/* ---- an object's death: no generation, because there is no longer an object */ \
|
||||
/* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \
|
||||
/* that have a handle on the wire; programs, program pipelines and shaders have */ \
|
||||
/* none in P2, so nothing publishes theirs - kUnpublishedDestroy, a known hole. */ \
|
||||
/* TWO OF THESE ROWS STOPPED BEING ASPIRATIONAL AT P3a, and the call that makes */ \
|
||||
/* each true is named rather than implied: */ \
|
||||
/* MarkBufferObjectForDeletion -> resource_destroy (PipeCalls.def), emitted */ \
|
||||
/* from ~BufferObject the moment the last reference drops - which is the */ \
|
||||
/* glDelete* that only marks the name, followed by whatever unbind actually */ \
|
||||
/* releases it - and followed IN THAT ORDER by the client freeing the slot. */ \
|
||||
/* MarkVertexArrayForDeletion -> delete_vertex_elements, published through the */ \
|
||||
/* death notice ~VertexArrayObject already raises for the VertexElementsCso */ \
|
||||
/* kind; the CSO handle is minted per frontend VAO off its lifetime id. */ \
|
||||
/* THE PUBLISHER IS THE BACKEND'S (Managers.cpp's OnFrontendStateObject- */ \
|
||||
/* Destroyed consumer, package espryt), not the client's: the client mints */ \
|
||||
/* the CSO handle and emits create/bind, and the free rides with that */ \
|
||||
/* consumer. Until it lands the row states the design, not the tree. */ \
|
||||
X(MarkBufferObjectForDeletion, kExplicitDestroy) \
|
||||
X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \
|
||||
X(MarkProgramForDeletion, kUnpublishedDestroy) \
|
||||
X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \
|
||||
X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \
|
||||
X(MarkSamplerObjectForDeletion, kExplicitDestroy) \
|
||||
X(MarkShaderForDeletion, kUnpublishedDestroy) \
|
||||
X(MarkTextureObjectForDeletion, kExplicitDestroy) \
|
||||
X(MarkVertexArrayForDeletion, kExplicitDestroy) \
|
||||
/* ---- no backend read point observes these at all ---- */ \
|
||||
/* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \
|
||||
/* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \
|
||||
/* reader under MG_Backend and no Coverage.def row. */ \
|
||||
X(BeginConditionalRender, kNoBackendRead) \
|
||||
X(EndConditionalRender, kNoBackendRead) \
|
||||
/* ---- pulled at every verb of the class, so the next verb publishes them */ \
|
||||
/* unconditionally. The transform-feedback accounting counters reach the */ \
|
||||
/* backend through GetTransformFeedbackCapturedVertices and friends, which */ \
|
||||
/* are in the kDraw and kXfbSpan may-read masks. */ \
|
||||
X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \
|
||||
X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \
|
||||
X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \
|
||||
X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \
|
||||
X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \
|
||||
X(AddTransformFeedbackPrimitives, kPulledEveryVerb)
|
||||
|
||||
// X(Mutator, Bit) - the (row, bit) pairs above whose derivation is KNOWN to come out
|
||||
// UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here
|
||||
// is marked derived: --check fails when the derivation cannot decide it, and fails again
|
||||
// when a mark here names a pair the derivation now decides, so this list can neither hide a
|
||||
// row nor outlive its reason. Empty today: every bit answer above is supported at field
|
||||
// level. The ten mutators that reach a tainted body (--check prints the count) all carry a
|
||||
// prose answer, which no derivation checks.
|
||||
#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X)
|
||||
|
||||
// clang-format on
|
||||
@@ -26,6 +26,50 @@
|
||||
// Fatal{UnmigratedPipeInput, "Field@Verb"} found there is fixed by adding the (class, field)
|
||||
// row, never by marking the field sticky.
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// THE VERDICT ON THE EIGHT STATICALLY OVER-APPROXIMATED ROWS (P2 brief C.1, MEASUREMENTS.md
|
||||
// section 4). Every one of them is KEPT, and the reason is the same in all three groups: the
|
||||
// row is not a guess, it names a concrete backend path, and the only evidence that could
|
||||
// retire it is DYNAMIC - a corpus that never reaches the path proves nothing, because a row
|
||||
// removed on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build.
|
||||
//
|
||||
// kReadback + IsTransformFeedbackActive / IsTransformFeedbackPaused
|
||||
// KEPT. The depth/stencil read emulation draws (ScopedEmulationDrawState, DirectGLES.cpp)
|
||||
// and pauses an active capture around its own draw, so a glReadPixels of a depth or
|
||||
// stencil attachment reads the transform-feedback state exactly as a draw does. Reached
|
||||
// only when the emulation is armed, which is a driver-shaped decision, so no desktop
|
||||
// corpus can decide it.
|
||||
//
|
||||
// kTextureOp + IsCapabilityEnabled, kDispatch + IsCapabilityEnabled
|
||||
// KEPT. Magma's GenerateMipmap materialises a texture's queued clear before it blits and
|
||||
// PrepareStorageImageTextures does the same for every storage image a dispatch writes;
|
||||
// both go through VkClearManager::PreCompensateSrgbClearColor, which reads
|
||||
// GL_FRAMEBUFFER_SRGB. The P2 contract gave that capability real storage for the first
|
||||
// time, so this row went from reading a compile-time constant to reading real state -
|
||||
// which is the opposite of a row that could be dropped.
|
||||
//
|
||||
// kBlitOrCopy / kTextureOp + the shader blit's viewport and vertex/buffer bindings
|
||||
// (GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode, GetBufferBindingPoint)
|
||||
// KEPT. TryBlitToDefaultFramebufferWithShader is a real draw of a backend-owned helper
|
||||
// program: ApplyGLViewportState -> ComputeGLViewport reads viewport 0 and its depth range,
|
||||
// GetOrCreateBlitPipeline -> SelectProvokingVertexMode reads the provoking vertex, and
|
||||
// BindProgramUniformBuffers' block resolvers read the frontend binding points. It is taken
|
||||
// when a blit's destination is the default framebuffer and the driver cannot do it
|
||||
// natively - again a driver-shaped decision.
|
||||
//
|
||||
// What WOULD retire a row: the poison build already answers "was this field read at this
|
||||
// verb" exactly (MOBILEGL_PIPE_POISON_OMIT withholds one field's stamp for one verb and a
|
||||
// read of it aborts naming the pair). Turning that into a retirement gate means running the
|
||||
// omission across the full CTS caselist on both devices, not the desktop corpus.
|
||||
//
|
||||
// P3a STATUS: still not done, and deliberately not done here. P3a is one of the five
|
||||
// architecture boundaries that owe a full gl44to46 caselist run on both devices, so the
|
||||
// omission sweep rides that run rather than duplicating it - the verdict lands with the
|
||||
// caselist result at the phase exit, off the critical path, and every row above stays in
|
||||
// place until it does. A row retired on desktop evidence would be retired on evidence that
|
||||
// cannot support it, which is the reason this item exists rather than a scheduling excuse.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
//
|
||||
// gen_pipe.py's block regexes end at a blank line: keep the empty line after each macro.
|
||||
//
|
||||
// clang-format off
|
||||
|
||||
@@ -55,10 +55,44 @@ namespace MobileGL::MG_Pipe {
|
||||
};
|
||||
|
||||
// The pipeline/dynamic split of RenderStateParameters, defined exactly once (section
|
||||
// 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes;
|
||||
// MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is
|
||||
// when the chunk table can be filled with real offsets.
|
||||
struct MGPipeRenderStateSpans;
|
||||
// 4.5.2): MG_Pipe/MGPipeRenderStateSpans.{h,cpp}, which landed with P2 and computes
|
||||
// every chunk boundary with offsetof. Include that header to use it; what stays here
|
||||
// is the generated member list at the bottom of this file, which is what the chunk
|
||||
// table was derived from.
|
||||
|
||||
// ---- MOBILEGL_PIPE_PUSH's runtime bitmask (Config.h Features.PipePush) ----
|
||||
//
|
||||
// One bit per SUBSYSTEM, so an A/B is per subsystem rather than all-or-nothing, and
|
||||
// bit 63 for the one BEHAVIOUR the design has to be measured against. Bits are
|
||||
// allocated in ROADMAP order and never reused: an operator's recorded 0x7f has to keep
|
||||
// meaning what it meant.
|
||||
//
|
||||
// A clear subsystem bit means "keep pulling", which after P2 is only a valid control
|
||||
// while MOBILEGL_PIPE_LEGACY_MEMOS compiles the pre-handle arm beside it.
|
||||
inline constexpr Uint64 kMGPipeSubsystemRenderState = 1ull << 0;
|
||||
inline constexpr Uint64 kMGPipeSubsystemPixelPack = 1ull << 1;
|
||||
inline constexpr Uint64 kMGPipeSubsystemPatchState = 1ull << 2;
|
||||
inline constexpr Uint64 kMGPipeSubsystemVertexAttribDefaults = 1ull << 3;
|
||||
inline constexpr Uint64 kMGPipeSubsystemResidualValues = 1ull << 4;
|
||||
inline constexpr Uint64 kMGPipeSubsystemEsprytSlots = 1ull << 5; // Track H, Espryt 0b
|
||||
inline constexpr Uint64 kMGPipeSubsystemMagmaVertexInput = 1ull << 6; // Track H, Magma subsystem 4
|
||||
// P3a's two. Resources is the seven BufferBackendOps hooks turned into the handle-shaped
|
||||
// resource_* family; VertexInput is vertex elements, vertex buffers and the index buffer.
|
||||
// They are separate bits because they are separate A/Bs: a buffer path that regressed and
|
||||
// a vertex path that regressed are different findings, and clearing one must not disarm
|
||||
// the other.
|
||||
inline constexpr Uint64 kMGPipeSubsystemResources = 1ull << 7;
|
||||
inline constexpr Uint64 kMGPipeSubsystemVertexInput = 1ull << 8;
|
||||
// bits 9..62 reserved for the later phases, allocated in ROADMAP order.
|
||||
// NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of CSOs, so
|
||||
// every pipeline-version change mints a fresh CSO and the map is never probed. This is
|
||||
// the negative control the whole CSO design is measured against (ROADMAP.md P2).
|
||||
inline constexpr Uint64 kMGPipeBehaviourNoCsoContentAddressing = 1ull << 63;
|
||||
// The default of a push build with the knob unset (ConfigLoader.cpp). Each phase's
|
||||
// constant STAYS, because it is the A/B control for the phase after it: P3a's
|
||||
// "everything P2 had and nothing of mine" arm is spelled MOBILEGL_PIPE_PUSH=0x7f.
|
||||
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP2 = 0x7full; // bits 0..6
|
||||
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP3a = 0x1ffull; // bits 0..8
|
||||
|
||||
// The catalogue itself. Only macros, so it is safe to expand inside the namespace, and
|
||||
// consumers (the unit test, later the transport) get MGP_CALL_LIST from this header.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.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
|
||||
|
||||
// The definitions behind MGPipeRenderStateSpans.h and behind the two arrays
|
||||
// generated/PipeSpanTable.inc has declared since P0. Compiled ONLY under
|
||||
// MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it to SOURCE_FILES there), which is how the
|
||||
// pull build gains no symbol from the split - a declaration emits nothing.
|
||||
//
|
||||
// PROVENANCE OF THE PIPELINE HALF. It began as the enumeration
|
||||
// VulkanRenderer::ComputePipelineStateHash carried above itself, which was the contract
|
||||
// that function had without being able to say so; it moves here because this file is now
|
||||
// that contract. Verbatim, from VulkanRenderer.cpp at feat/disaggregated@48268068:
|
||||
//
|
||||
// Value hash over every fixed-function GL state the pipeline payload reads that
|
||||
// the memo key's other fields (mode, program hash, vertex-input hash, render-pass
|
||||
// hash, transform flags) do not already pin down. Enumerated against the payload
|
||||
// build in GetOrCreatePipeline - any new GL-state read there must be added here:
|
||||
// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides
|
||||
// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest,
|
||||
// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask
|
||||
// - patch vertices, polygon mode, cull face mode, depth func, logic op,
|
||||
// min sample shading, the glSampleMaski word
|
||||
// - front/back stencil ops + compare funcs (ref/mask are dynamic state)
|
||||
// - per draw buffer up to the render pass's colour span: indexed blend enable,
|
||||
// blend factors/equations, indexed colour write mask (broadcast from index 0
|
||||
// when the device lacks independentBlend - the same read the payload does)
|
||||
// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are
|
||||
// pinned by the render-pass hash key, exactly as the version-keyed memo relied on.
|
||||
//
|
||||
// P2's pipeline half is a strict SUPERSET of that list. It adds SampleCoverageValue,
|
||||
// SampleCoverageInvert, FrontFaceModeSetting, ProvokingVertexModeSetting,
|
||||
// ScissorTestEnabledMask, PolygonModeBack, the eleven capability bools the hash never read
|
||||
// (DebugOutput, DebugOutputSynchronous, Dither, LineSmooth, PolygonOffsetLine,
|
||||
// PolygonOffsetPoint, PolygonSmooth, SampleAlphaToCoverage, SampleAlphaToOne, SampleCoverage,
|
||||
// ProgramPointSize) and the three capabilities P2 gave storage to (FramebufferSrgb,
|
||||
// DepthClamp, TextureCubeMapSeamless). All of them are written by a setter that calls
|
||||
// BumpVersions(), so under the header's rule they are pipeline. The alternative - demoting
|
||||
// those setters to ++m_version - would change MG_State semantics in the PULL build for the
|
||||
// sake of the push path. Growing the subset costs nothing measurable: the hash runs only
|
||||
// when m_pipelineStateVersion moves, which is exactly when Magma recomputed
|
||||
// ComputePipelineStateHash before.
|
||||
//
|
||||
// The render-pass facts are deliberately NOT here. ComputePipelineStateHash's signature is
|
||||
// (colorAttachmentCount, rasterizationSamples) and it folds ResolveEffectiveSampleMask, so
|
||||
// it was never a pure function of RenderStateParameters; a CSO handle cannot replace it on
|
||||
// its own and Magma keeps renderPassHash as a separate memo-key component.
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/MGPipeRenderStateSpans.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
namespace {
|
||||
// Half-local chunk index -> global chunk index. The halves alternate, so this is
|
||||
// arithmetic rather than a table.
|
||||
constexpr SizeT GlobalPipelineChunk(SizeT halfIndex) { return halfIndex * 2 + 1; }
|
||||
constexpr SizeT GlobalDynamicChunk(SizeT halfIndex) { return halfIndex * 2; }
|
||||
|
||||
const Uint8* BytesOf(const RenderStateParameters& params) {
|
||||
return reinterpret_cast<const Uint8*>(¶ms);
|
||||
}
|
||||
Uint8* BytesOf(RenderStateParameters& params) { return reinterpret_cast<Uint8*>(¶ms); }
|
||||
|
||||
SizeT BlobBytes(Uint32 chunkMask, SizeT halfCount, SizeT (*toGlobal)(SizeT)) {
|
||||
SizeT total = 0;
|
||||
for (SizeT i = 0; i < halfCount; ++i) {
|
||||
if ((chunkMask & (1u << i)) == 0) continue;
|
||||
total += MGPipeRenderStateChunkAt(toGlobal(i)).Length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void Gather(const RenderStateParameters& params, Uint32 chunkMask, void* dst, SizeT halfCount,
|
||||
SizeT (*toGlobal)(SizeT)) {
|
||||
Uint8* out = static_cast<Uint8*>(dst);
|
||||
const Uint8* src = BytesOf(params);
|
||||
for (SizeT i = 0; i < halfCount; ++i) {
|
||||
if ((chunkMask & (1u << i)) == 0) continue;
|
||||
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
|
||||
std::memcpy(out, src + chunk.Offset, chunk.Length);
|
||||
out += chunk.Length;
|
||||
}
|
||||
}
|
||||
|
||||
void Scatter(const void* src, Uint32 chunkMask, RenderStateParameters& dst, SizeT halfCount,
|
||||
SizeT (*toGlobal)(SizeT)) {
|
||||
const Uint8* in = static_cast<const Uint8*>(src);
|
||||
Uint8* out = BytesOf(dst);
|
||||
for (SizeT i = 0; i < halfCount; ++i) {
|
||||
if ((chunkMask & (1u << i)) == 0) continue;
|
||||
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
|
||||
std::memcpy(out + chunk.Offset, in, chunk.Length);
|
||||
in += chunk.Length;
|
||||
}
|
||||
}
|
||||
|
||||
Uint32 ChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b,
|
||||
SizeT halfCount, SizeT (*toGlobal)(SizeT)) {
|
||||
const Uint8* left = BytesOf(a);
|
||||
const Uint8* right = BytesOf(b);
|
||||
Uint32 mask = 0;
|
||||
for (SizeT i = 0; i < halfCount; ++i) {
|
||||
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
|
||||
if (std::memcmp(left + chunk.Offset, right + chunk.Offset, chunk.Length) != 0) {
|
||||
mask |= 1u << i;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
constexpr Uint32 AllChunks(SizeT halfCount) {
|
||||
return halfCount >= 32 ? ~Uint32{0} : static_cast<Uint32>((Uint64{1} << halfCount) - 1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The two arrays generated/PipeSpanTable.inc declares. Every entry is
|
||||
// MGPipeRenderStateChunkAt(), so a boundary can only be written once.
|
||||
const MGPStateChunk kMGPipePipelineChunks[kMGPipePipelineChunkCount] = {
|
||||
MGPipeRenderStateChunkAt(GlobalPipelineChunk(0)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(1)),
|
||||
MGPipeRenderStateChunkAt(GlobalPipelineChunk(2)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(3)),
|
||||
MGPipeRenderStateChunkAt(GlobalPipelineChunk(4)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(5)),
|
||||
MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)),
|
||||
};
|
||||
static_assert(sizeof(kMGPipePipelineChunks) / sizeof(kMGPipePipelineChunks[0]) == kMGPipePipelineChunkCount,
|
||||
"kMGPipePipelineChunks lost an entry");
|
||||
|
||||
const MGPStateChunk kMGPipeDynamicChunks[kMGPipeDynamicChunkCount] = {
|
||||
MGPipeRenderStateChunkAt(GlobalDynamicChunk(0)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(1)),
|
||||
MGPipeRenderStateChunkAt(GlobalDynamicChunk(2)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(3)),
|
||||
MGPipeRenderStateChunkAt(GlobalDynamicChunk(4)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(5)),
|
||||
MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)),
|
||||
};
|
||||
static_assert(sizeof(kMGPipeDynamicChunks) / sizeof(kMGPipeDynamicChunks[0]) == kMGPipeDynamicChunkCount,
|
||||
"kMGPipeDynamicChunks lost an entry");
|
||||
|
||||
void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst) {
|
||||
Gather(params, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount,
|
||||
GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst) {
|
||||
Scatter(src, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount,
|
||||
GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask) {
|
||||
return BlobBytes(chunkMask, kMGPipePipelineChunkCount, GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) {
|
||||
Gather(params, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) {
|
||||
Scatter(src, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask) {
|
||||
return BlobBytes(chunkMask, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
|
||||
}
|
||||
|
||||
void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) {
|
||||
Gather(params, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
|
||||
}
|
||||
|
||||
void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) {
|
||||
Scatter(src, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
|
||||
}
|
||||
|
||||
Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) {
|
||||
return ChunksThatMoved(a, b, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
|
||||
}
|
||||
|
||||
Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) {
|
||||
return ChunksThatMoved(a, b, kMGPipePipelineChunkCount, GlobalPipelineChunk);
|
||||
}
|
||||
|
||||
Uint64 MGPipeHashPipelineBytes(const void* bytes) {
|
||||
return static_cast<Uint64>(
|
||||
XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableSeed));
|
||||
}
|
||||
|
||||
Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params) {
|
||||
// 396 bytes on the stack. A streaming XXH64_state_t would allocate; gathering first
|
||||
// is also what CsoCache wants, because the same bytes are what a hash hit memcmps
|
||||
// against before the handle is reused.
|
||||
Uint8 gathered[kMGPipePipelineChunkBytes];
|
||||
MGPipeGatherPipelineBytes(params, gathered);
|
||||
return MGPipeHashPipelineBytes(gathered);
|
||||
}
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
@@ -0,0 +1,277 @@
|
||||
// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.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 <Includes.h>
|
||||
|
||||
#include "MGPipeTypes.h"
|
||||
#include "MGPipeValueTypes.h"
|
||||
|
||||
// G7: the pipeline/dynamic split of RenderStateParameters, written in EXACTLY ONE PLACE
|
||||
// (ARCHITECTURE.md 5.3, D-B1).
|
||||
//
|
||||
// The rule that decides the split, and it is the only rule:
|
||||
//
|
||||
// A byte of RenderStateParameters is in the PIPELINE half if and only if some public
|
||||
// RenderState setter that calls BumpVersions() writes it. Every other byte is in the
|
||||
// DYNAMIC half. There is no third set.
|
||||
//
|
||||
// That makes the G7 invariant - the pipeline-subset hash moves IF AND ONLY IF
|
||||
// m_pipelineStateVersion moves - true by CONSTRUCTION rather than by inspection, and it is
|
||||
// what MG_Test/Pipe/RenderStateSpansTest.cpp walks every setter to confirm.
|
||||
//
|
||||
// The chunks alternate: chunk 0 is dynamic, chunk 1 is pipeline, and so on, so the whole
|
||||
// table is 16 BOUNDARIES rather than 15 hand-written ranges. Every boundary is an offsetof
|
||||
// or a sizeof - never a literal - because a python guess at a layout it cannot see is
|
||||
// exactly the drift the setter-consistency test exists to catch. 8 dynamic chunks + 7
|
||||
// pipeline chunks = 15, and both counts fit the Uint32 ChunkMask of MGPRenderStateDesc and
|
||||
// MGPDynamicState with room to spare.
|
||||
//
|
||||
// Note the two splits are ORTHOGONAL and coexist (ARCHITECTURE.md 5.3): DirectGLES'
|
||||
// head [0, 312) / blend [312, 536) / tail [536, 1168) spans cut ACROSS this table, and
|
||||
// nothing about them changes. StencilFaceState is deliberately NOT reordered - reordering
|
||||
// would move Espryt's shadow bytes for no gain.
|
||||
namespace MobileGL::MG_Pipe {
|
||||
|
||||
namespace MGPipeRenderStateChunkDetail {
|
||||
using RSP = RenderStateParameters;
|
||||
using SFS = StencilFaceState;
|
||||
|
||||
inline constexpr SizeT kStencilFace0 = offsetof(RSP, StencilStates);
|
||||
inline constexpr SizeT kStencilFace1 = kStencilFace0 + sizeof(SFS);
|
||||
// The pipeline half of one stencil face is [Func, Ref) + [FailOp, end); the dynamic
|
||||
// half is [Ref, FailOp) - Ref and ValueMask are VK_DYNAMIC_STATE_STENCIL_REFERENCE /
|
||||
// _COMPARE_MASK and WriteMask is _WRITE_MASK, which is why glStencilFunc changing only
|
||||
// the reference must not evict a cached pipeline (RenderState.cpp SetStencilFunc).
|
||||
inline constexpr SizeT kFaceDynamicBegin = offsetof(SFS, Ref);
|
||||
inline constexpr SizeT kFaceDynamicEnd = offsetof(SFS, FailOp);
|
||||
} // namespace MGPipeRenderStateChunkDetail
|
||||
|
||||
// 15 chunks, 16 boundaries, strictly ascending, [0, sizeof(RenderStateParameters)).
|
||||
inline constexpr SizeT kMGPipeRenderStateChunkCount = 15;
|
||||
|
||||
inline constexpr Array<SizeT, kMGPipeRenderStateChunkCount + 1> kMGPipeRenderStateChunkBoundaries = {
|
||||
// D0 dynamic: Viewports[16], LineWidth, PointSize
|
||||
SizeT{0},
|
||||
// P0 pipeline: PatchVertices, PatchDefaultOuterLevel, PatchDefaultInnerLevel
|
||||
offsetof(RenderStateParameters, PatchVertices),
|
||||
// D1 dynamic: PolygonOffsetFactor/Units/Clamp, ClipOrigin, ClipDepthMode
|
||||
offsetof(RenderStateParameters, PolygonOffsetFactor),
|
||||
// P1 pipeline: BlendStates[8], LogicOp, DepthTestEnabled, DepthFunc, DepthMask,
|
||||
// ColorMasks[8], FramebufferSrgbEnabled, DepthClampEnabled,
|
||||
// TextureCubeMapSeamlessEnabled
|
||||
offsetof(RenderStateParameters, BlendStates),
|
||||
// D2 dynamic: ClearColor, ClearDepth, ClearStencil, BlendColor, DepthRanges[16]
|
||||
offsetof(RenderStateParameters, ClearColor),
|
||||
// P2 pipeline: SampleCoverageValue, SampleCoverageInvert, SampleMaskValue,
|
||||
// MinSampleShadingValue, StencilStates[0].Func
|
||||
offsetof(RenderStateParameters, SampleCoverageValue),
|
||||
// D3 dynamic: StencilStates[0].{Ref, ValueMask, WriteMask}
|
||||
MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin,
|
||||
// P3 pipeline: StencilStates[0].{FailOp, PassDepthFailOp, PassDepthPassOp},
|
||||
// StencilStates[1].Func
|
||||
MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd,
|
||||
// D4 dynamic: StencilStates[1].{Ref, ValueMask, WriteMask}
|
||||
MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin,
|
||||
// P4 pipeline: StencilStates[1].{FailOp, PassDepthFailOp, PassDepthPassOp},
|
||||
// CullFaceEnabled, CullFaceModeSetting, FrontFaceModeSetting,
|
||||
// ProvokingVertexModeSetting
|
||||
MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd,
|
||||
// D5 dynamic: the four hints, PointFadeThresholdSize, PointSpriteCoordOrigin,
|
||||
// ClampReadColor
|
||||
offsetof(RenderStateParameters, LineSmoothHint),
|
||||
// P5 pipeline: PolygonModeFront, PolygonModeBack
|
||||
offsetof(RenderStateParameters, PolygonModeFront),
|
||||
// D6 dynamic: PrimitiveRestartIndex
|
||||
offsetof(RenderStateParameters, PrimitiveRestartIndex),
|
||||
// P6 pipeline: the 20 capability bools ColorLogicOpEnabled..ProgramPointSizeEnabled,
|
||||
// ScissorTestEnabledMask
|
||||
offsetof(RenderStateParameters, ColorLogicOpEnabled),
|
||||
// D7 dynamic: ScissorBoxes[16], ScissorBoxWrittenMask, ClipDistanceEnabledMask
|
||||
offsetof(RenderStateParameters, ScissorBoxes),
|
||||
sizeof(RenderStateParameters),
|
||||
};
|
||||
|
||||
// Chunk 0 is dynamic and they alternate, which is not a coincidence: every boundary above
|
||||
// is a transition between a run of BumpVersions()-written members and a run of
|
||||
// ++m_version-only members, so two adjacent chunks of the same half would mean a boundary
|
||||
// that separates nothing.
|
||||
constexpr Bool MGPipeRenderStateChunkIsPipeline(SizeT index) { return (index % 2) == 1; }
|
||||
|
||||
constexpr MGPStateChunk MGPipeRenderStateChunkAt(SizeT index) {
|
||||
return MGPStateChunk{static_cast<Uint16>(kMGPipeRenderStateChunkBoundaries[index]),
|
||||
static_cast<Uint16>(kMGPipeRenderStateChunkBoundaries[index + 1] -
|
||||
kMGPipeRenderStateChunkBoundaries[index])};
|
||||
}
|
||||
|
||||
namespace MGPipeRenderStateChunkDetail {
|
||||
constexpr SizeT CountHalf(Bool pipeline) {
|
||||
SizeT count = 0;
|
||||
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
|
||||
if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
constexpr SizeT BytesOfHalf(Bool pipeline) {
|
||||
SizeT bytes = 0;
|
||||
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
|
||||
if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) {
|
||||
bytes += MGPipeRenderStateChunkAt(i).Length;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
} // namespace MGPipeRenderStateChunkDetail
|
||||
|
||||
inline constexpr SizeT kMGPipePipelineChunkCount = MGPipeRenderStateChunkDetail::CountHalf(true);
|
||||
inline constexpr SizeT kMGPipeDynamicChunkCount = MGPipeRenderStateChunkDetail::CountHalf(false);
|
||||
// The CSO's content-addressed identity is exactly this many bytes; CsoCache stores them
|
||||
// per entry and memcmps them on a hash hit.
|
||||
inline constexpr SizeT kMGPipePipelineChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(true);
|
||||
inline constexpr SizeT kMGPipeDynamicChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(false);
|
||||
|
||||
// Bumped by hand when something about the table changes that its BYTES do not show -
|
||||
// the halves' membership, the meaning of a chunk, the gather order.
|
||||
inline constexpr Uint64 kMGPipeRenderStateChunkTableVersion = 1;
|
||||
|
||||
// What actually seeds MGPipeComputePipelineSubsetHash. The version above is a promise a
|
||||
// reader has to keep; this is the part that keeps itself. Folding the boundary table into
|
||||
// the seed means a moved boundary invalidates every persisted key whether or not anyone
|
||||
// remembered to bump the version - and it does so WITHOUT a static_assert on the
|
||||
// boundaries, which would turn G7's negative control (which moves a boundary on purpose
|
||||
// and must still compile) into a build break.
|
||||
namespace MGPipeRenderStateChunkDetail {
|
||||
constexpr Uint64 BoundaryChecksum() {
|
||||
Uint64 hash = 0xcbf29ce484222325ull; // FNV-1a, 64-bit
|
||||
for (SizeT i = 0; i <= kMGPipeRenderStateChunkCount; ++i) {
|
||||
hash = (hash ^ static_cast<Uint64>(kMGPipeRenderStateChunkBoundaries[i])) * 0x100000001b3ull;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
} // namespace MGPipeRenderStateChunkDetail
|
||||
inline constexpr Uint64 kMGPipeRenderStateChunkTableSeed =
|
||||
kMGPipeRenderStateChunkTableVersion ^ MGPipeRenderStateChunkDetail::BoundaryChecksum();
|
||||
|
||||
// ---- the trip wires. A mistake in the table is a build break, here. ----
|
||||
static_assert(kMGPipeRenderStateChunkBoundaries[0] == 0,
|
||||
"the chunk table must start at byte 0 of RenderStateParameters");
|
||||
static_assert(kMGPipeRenderStateChunkBoundaries[kMGPipeRenderStateChunkCount] ==
|
||||
sizeof(RenderStateParameters),
|
||||
"the chunk table must cover RenderStateParameters to its last byte");
|
||||
static_assert(kMGPipePipelineChunkCount == 7);
|
||||
static_assert(kMGPipeDynamicChunkCount == 8);
|
||||
static_assert(kMGPipePipelineChunkCount + kMGPipeDynamicChunkCount == kMGPipeRenderStateChunkCount);
|
||||
static_assert(kMGPipePipelineChunkBytes + kMGPipeDynamicChunkBytes == sizeof(RenderStateParameters),
|
||||
"the two halves must partition the block exactly - no gap, no overlap");
|
||||
static_assert(kMGPipeRenderStateChunkCount <= 32,
|
||||
"a chunk index has to fit the Uint32 ChunkMask of MGPRenderStateDesc/MGPDynamicState");
|
||||
|
||||
// Sorted, non-overlapping and complete: because every chunk is [b[i], b[i+1]) the only
|
||||
// way to violate that is a non-ascending boundary, so this is the whole check.
|
||||
constexpr Bool MGPipeRenderStateChunkBoundariesAscend() {
|
||||
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
|
||||
if (!(kMGPipeRenderStateChunkBoundaries[i] < kMGPipeRenderStateChunkBoundaries[i + 1])) {
|
||||
return false;
|
||||
}
|
||||
if (kMGPipeRenderStateChunkBoundaries[i + 1] > 0xffffu) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static_assert(MGPipeRenderStateChunkBoundariesAscend(),
|
||||
"the chunk boundaries must strictly ascend and fit MGPStateChunk's Uint16 fields");
|
||||
|
||||
// The measured sizes. They are DERIVED above; these two assertions only pin what the P2
|
||||
// brief and MEASUREMENTS.md quote, so a table change that moves them is loud.
|
||||
static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes");
|
||||
static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes");
|
||||
|
||||
// ---- global chunk bits, so nothing downstream hand-maintains a second table ----
|
||||
|
||||
// The GLOBAL chunk indices (bit i is chunk i of the 15) whose byte range overlaps
|
||||
// [offset, offset + size). It falls straight out of the boundary table, which is the
|
||||
// whole point: the applier scopes its derivation by the chunks a scatter actually moved
|
||||
// (D5/D8), and a hand-written member -> chunk mapping is exactly the second table that
|
||||
// would go stale the first time a boundary moves.
|
||||
constexpr Uint32 MGPipeRenderStateChunkBitsCovering(SizeT offset, SizeT size) {
|
||||
Uint32 bits = 0;
|
||||
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
|
||||
const SizeT begin = kMGPipeRenderStateChunkBoundaries[i];
|
||||
const SizeT end = kMGPipeRenderStateChunkBoundaries[i + 1];
|
||||
if (offset < end && begin < offset + size) bits |= Uint32{1} << i;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
// The wire masks are HALF-LOCAL (bit i of MGPRenderStateDesc::ChunkMask is pipeline chunk
|
||||
// i); these widen them to the global indices the boundary table is written in. The
|
||||
// halves alternate with chunk 0 dynamic, so the two conversions are arithmetic.
|
||||
constexpr Uint32 MGPipeGlobalChunkBitsOfPipelineMask(Uint32 pipelineMask) {
|
||||
Uint32 bits = 0;
|
||||
for (SizeT i = 0; i < kMGPipePipelineChunkCount; ++i) {
|
||||
if (((pipelineMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2 + 1);
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
constexpr Uint32 MGPipeGlobalChunkBitsOfDynamicMask(Uint32 dynamicMask) {
|
||||
Uint32 bits = 0;
|
||||
for (SizeT i = 0; i < kMGPipeDynamicChunkCount; ++i) {
|
||||
if (((dynamicMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2);
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
inline constexpr Uint32 kMGPipeAllGlobalChunks =
|
||||
static_cast<Uint32>((Uint64{1} << kMGPipeRenderStateChunkCount) - 1);
|
||||
|
||||
// The two conversions must agree with MGPipeRenderStateChunkIsPipeline, and together they
|
||||
// must cover the table exactly - a widening that dropped or doubled a chunk would make
|
||||
// the applier's scoping silently wrong rather than loud.
|
||||
namespace MGPipeRenderStateChunkDetail {
|
||||
inline constexpr Uint32 kAllPipelineHalfBits =
|
||||
static_cast<Uint32>((Uint64{1} << kMGPipePipelineChunkCount) - 1);
|
||||
inline constexpr Uint32 kAllDynamicHalfBits =
|
||||
static_cast<Uint32>((Uint64{1} << kMGPipeDynamicChunkCount) - 1);
|
||||
inline constexpr Uint32 kWidenedPipeline = MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineHalfBits);
|
||||
inline constexpr Uint32 kWidenedDynamic = MGPipeGlobalChunkBitsOfDynamicMask(kAllDynamicHalfBits);
|
||||
} // namespace MGPipeRenderStateChunkDetail
|
||||
static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline &
|
||||
MGPipeRenderStateChunkDetail::kWidenedDynamic) == 0,
|
||||
"the two half-local -> global widenings must not overlap");
|
||||
static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline |
|
||||
MGPipeRenderStateChunkDetail::kWidenedDynamic) == kMGPipeAllGlobalChunks,
|
||||
"the two half-local -> global widenings must cover the whole chunk table");
|
||||
static_assert(MGPipeRenderStateChunkBitsCovering(0, sizeof(RenderStateParameters)) == kMGPipeAllGlobalChunks,
|
||||
"every chunk must be covered by the whole block");
|
||||
|
||||
// ---- the operations everything else is written against ----
|
||||
|
||||
// The 396 pipeline bytes of `params`, in ascending chunk order, into `dst`.
|
||||
void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst);
|
||||
// The inverse: `src` is kMGPipePipelineChunkBytes bytes in the same order.
|
||||
void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst);
|
||||
// Incremental create_render_state: only the pipeline chunks named by `chunkMask` (bit i
|
||||
// is pipeline chunk i, 0-based within the pipeline half), concatenated ascending.
|
||||
SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask);
|
||||
void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst);
|
||||
void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst);
|
||||
|
||||
// set_dynamic_state: bit i of `chunkMask` is dynamic chunk i, 0-based within the dynamic
|
||||
// half; the blob is those chunks concatenated in ascending order.
|
||||
SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask);
|
||||
void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst);
|
||||
void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst);
|
||||
// Which dynamic chunks differ between two blocks - the chunk-level suppressor's answer.
|
||||
Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b);
|
||||
// Which pipeline chunks differ - the incremental-create mask against a base CSO.
|
||||
Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b);
|
||||
|
||||
// XXH64 over the seven pipeline chunks in ascending order, seeded with the table version.
|
||||
// Runs ONLY when m_pipelineStateVersion moved, i.e. never in the steady state.
|
||||
Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params);
|
||||
// The same hash over already-gathered bytes (CsoCache holds them, so it does not re-gather).
|
||||
Uint64 MGPipeHashPipelineBytes(const void* bytes);
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
@@ -231,8 +231,13 @@ namespace MobileGL::MG_Pipe {
|
||||
|
||||
// The half of the render state that must NOT mint a CSO: viewport, scissor, depth
|
||||
// range, blend colour, line width, polygon offset, stencil ref/write mask, clear
|
||||
// values, sample coverage, hints and the point-size family. This is what keeps
|
||||
// glViewport from evicting Magma's pipeline memo (D-B1).
|
||||
// values, hints, the point-size family and the primitive-restart index. This is what
|
||||
// keeps glViewport from evicting Magma's pipeline memo (D-B1).
|
||||
//
|
||||
// SAMPLE COVERAGE IS NOT IN IT, and this comment used to say it was. P2's rule is that
|
||||
// a byte is pipeline state if and only if a public RenderState setter that calls
|
||||
// BumpVersions() writes it, and SetSampleCoverage does - so SampleCoverageValue and
|
||||
// SampleCoverageInvert are in pipeline chunk P2 (MGPipeRenderStateSpans.h).
|
||||
struct MGPDynamicState {
|
||||
Uint32 ChunkMask;
|
||||
Uint16 Version;
|
||||
@@ -245,11 +250,29 @@ namespace MobileGL::MG_Pipe {
|
||||
// VertexAttribute[32] AND the binding points, because a pointer-call stride of 0 means
|
||||
// "element size" while a binding-model stride of 0 means "every vertex reads the same
|
||||
// element" (section 4.5.3). IsLong and Type == Float64 are carried separately.
|
||||
//
|
||||
// THE BLOB RULE - ONE RULE FOR EVERY RECORD IN THIS HEADER THAT CARRIES AN MGPBlobRef,
|
||||
// and MGPSubData below is the other one. `Blob.Size` is the record's own statement of how
|
||||
// many bytes its blob holds, and the applier holds the record to that statement WHENEVER
|
||||
// THE RECORD MAKES IT: a non-zero Blob.Size that disagrees with the byte length the
|
||||
// record's other fields describe is Fatal{ProtocolCorruption} and the call is refused. A
|
||||
// ZERO Blob.Size means "this record does not declare its blob", which is what a monolith
|
||||
// emission is - the bytes travel beside the record through the apply entry point's
|
||||
// companion `const void*` and no MGPBlobRef is filled - and it is not a fault. Either way
|
||||
// the bytes read are bounded by the record's OTHER fields (the two counts here, the
|
||||
// destination range there), so the length is a cross-check and never the safety property.
|
||||
// A transport that fills these in gets a real gate on the first record it truncates; a
|
||||
// client that leaves them zero gets no verify-build abort for a field it never used.
|
||||
struct MGPVertexElements {
|
||||
MGPipeHandle Cso;
|
||||
Uint32 AttributeCount;
|
||||
Uint32 BindingPointCount;
|
||||
MGPBlobRef Blob; // VertexAttribute[] followed by VertexBufferBindingPoint[]
|
||||
// MGPVertexAttribWire[AttributeCount] followed by
|
||||
// MGPVertexBindingPointWire[BindingPointCount] - the WIRE views of MGPipeValueTypes.h,
|
||||
// not VertexAttribute / VertexBufferBindingPoint, which cannot travel at all (each
|
||||
// holds a SharedPtr<BufferObject>). The layout is stated once at
|
||||
// MGPipeValueTypes.h's P3a block and unpacked once in PipeApply.cpp.
|
||||
MGPBlobRef Blob;
|
||||
};
|
||||
MGP_ASSERT_POD(MGPVertexElements, 40);
|
||||
|
||||
@@ -348,6 +371,13 @@ namespace MobileGL::MG_Pipe {
|
||||
};
|
||||
MGP_ASSERT_POD(MGPFramebufferState, 304);
|
||||
|
||||
// GL_MAX_VERTEX_ATTRIBS as MobileGL advertises it, on the MG_Pipe side of the boundary.
|
||||
// It bounds the two declared counts of MGPVertexElements, the applier's two per-CSO
|
||||
// arrays and the vertex-buffer set. It MUST equal VertexArrayObject::MAX_VERTEX_ATTRIBS;
|
||||
// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that sees both and carries the
|
||||
// static_assert, because this header may not include a frontend one.
|
||||
inline constexpr Uint32 kMGPipeMaxVertexAttribs = 32;
|
||||
|
||||
struct MGPVertexBuffer {
|
||||
MGPipeHandle Res;
|
||||
Uint64 Offset;
|
||||
@@ -361,9 +391,25 @@ namespace MobileGL::MG_Pipe {
|
||||
// Var-tail header: MGPVertexBuffer[Count] follows.
|
||||
struct MGPVertexBuffers {
|
||||
Uint32 Start, Count;
|
||||
// The vertex-FETCH base instance these offsets are valid for (P3a, D-H1). It is DRAW
|
||||
// state, not VAO state, and it is NOT the same thing as MGPDrawInfo::StartInstance:
|
||||
// that one is the GL draw's baseInstance and feeds gl_BaseInstance, this one is the
|
||||
// shift the fetch address of an instanced array needs when the device has no native
|
||||
// base-instance support. The server decides whether to emulate it or let
|
||||
// GL_EXT_base_instance do the work - emulation is server-owned - so the client sends
|
||||
// the draw's raw value and never a pre-shifted offset.
|
||||
//
|
||||
// IT IS A ContentHash INPUT, and that is a requirement rather than a nicety:
|
||||
// set_vertex_buffers is suppressed on an unchanged hash (MG_Impl/Pipe/
|
||||
// SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while
|
||||
// the buffer set did not would be suppressed and the server would keep the previous
|
||||
// shift. It rides ONE PER EMITTED SET rather than per entry: per entry the shift is
|
||||
// redundant and lets a malformed record disagree with itself.
|
||||
Uint32 BaseInstance;
|
||||
Uint32 Pad0;
|
||||
Uint64 ContentHash;
|
||||
};
|
||||
MGP_ASSERT_POD(MGPVertexBuffers, 16);
|
||||
MGP_ASSERT_POD(MGPVertexBuffers, 24);
|
||||
|
||||
// An independent call, NOT a subset of the VAO configuration version (D5).
|
||||
struct MGPIndexBuffer {
|
||||
@@ -514,14 +560,17 @@ namespace MobileGL::MG_Pipe {
|
||||
// to it because both sides are the same translation unit. G3 emits the offsetof
|
||||
// assertions; under split the block is serialized field-wise rather than memcpy'd.
|
||||
struct ResidualValueBlock {
|
||||
RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land
|
||||
PixelStoreParameters Pack; // until set_pixel_pack_state lands
|
||||
// The 35 CapabilityInput bits, packed in enum order. P2 retired everything else:
|
||||
// RenderStateParameters to create/bind_render_state + set_dynamic_state, Pack to
|
||||
// set_pixel_pack_state, and the patch quintet to set_patch_state.
|
||||
//
|
||||
// What is left is deliberately REDUNDANT. Every one of the 35 capabilities is
|
||||
// answerable from the assembled working block now that P2 gave FramebufferSrgb,
|
||||
// DepthClamp and TextureCubeMapSeamless real storage - which is the point: the
|
||||
// applier compares the two answers bit by bit, so the day a later call takes a
|
||||
// capability over and forgets to carry it, the block says so on the next draw
|
||||
// (Fatal{PipeResidualDiverged, "<Cap>"}, MG_Pipe/PipeApply.cpp).
|
||||
Uint64 CapabilityBits;
|
||||
Uint32 PatchVertices;
|
||||
Uint32 Pad0;
|
||||
Float PatchOuter[4];
|
||||
Float PatchInner[2];
|
||||
Uint32 Pad1[2];
|
||||
};
|
||||
static_assert(std::is_trivially_copyable_v<ResidualValueBlock>);
|
||||
// The retirement ratchet. This number only ever goes DOWN: every stage that lands a real
|
||||
@@ -530,10 +579,12 @@ namespace MobileGL::MG_Pipe {
|
||||
// gone. Shrinking the block without lowering the number, or growing it at all, is a build
|
||||
// break - which is the point.
|
||||
//
|
||||
// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and
|
||||
// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no
|
||||
// SizeT.
|
||||
#define MGL_RESIDUAL_BLOCK_SIZE 1248
|
||||
// Stable across the ABIs MobileGL ships on: the one member is a fixed-width scalar.
|
||||
//
|
||||
// P2: 1248 -> 8. RenderStateParameters (1168) retired to create/bind_render_state and
|
||||
// set_dynamic_state, PixelStoreParameters (28) to set_pixel_pack_state, and the patch
|
||||
// quintet (52 with its padding) to set_patch_state.
|
||||
#define MGL_RESIDUAL_BLOCK_SIZE 8
|
||||
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,
|
||||
"the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field "
|
||||
"retired, and do not raise it");
|
||||
@@ -568,8 +619,16 @@ namespace MobileGL::MG_Pipe {
|
||||
//
|
||||
// THE BUFFER HALF. With Target == Buffer there is no level and no box, so the destination
|
||||
// byte range rides in the box's first coordinate and first extent: UnionBox.X is the byte
|
||||
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0, and
|
||||
// Blob holds exactly Size source bytes. That caps ONE record at a 2^31-1 offset and a
|
||||
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0.
|
||||
//
|
||||
// THE BLOB RULE, THE SAME ONE MGPVertexElements ABOVE CARRIES: `Blob.Size` is the record's
|
||||
// own statement of how many bytes its blob holds and the applier holds the record to that
|
||||
// statement whenever the record makes it - a non-zero Blob.Size that is not exactly the
|
||||
// record's own byte size (MGPipeSubDataBufferSize below) is Fatal{ProtocolCorruption} and
|
||||
// the write is refused. A zero Blob.Size means "this record does not declare its blob",
|
||||
// which is what a monolith emission is: the bytes travel beside the record through
|
||||
// MGPipeApplyResourceSubData's companion `const void*`. The destination range is what
|
||||
// bounds the write in both cases. That caps ONE record at a 2^31-1 offset and a
|
||||
// 2^32-1 size; a range beyond either is split by the emitter - the same rule, and at
|
||||
// SEG_STAGE's 32 MiB the far tighter one, that the ring's half-capacity bound already
|
||||
// imposes on it. MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size below are
|
||||
@@ -606,6 +665,22 @@ namespace MobileGL::MG_Pipe {
|
||||
}
|
||||
inline Uint64 MGPipeSubDataBufferSize(const MGPSubData& record) { return record.UnionBox.W; }
|
||||
|
||||
// P3a, D-A5: the per-record half of resource_respecify's kNeedsAck.
|
||||
//
|
||||
// Flags are a PER-CALL static property and resource_respecify serves BOTH glBufferData
|
||||
// and glBufferStorage. A bare kNeedsAck on the call would acknowledge every glBufferData
|
||||
// in a world upload; only glBufferStorage is a real synchronous allocation and only it is
|
||||
// allowed a synchronous ack. So kNeedsAck on the call means "records of this call MAY
|
||||
// require an acknowledgement" and THIS predicate decides per record. In monolith the ack
|
||||
// is ((void)0) - the applier is one function call away - and the transport wires the
|
||||
// doorbell to this predicate when it lands.
|
||||
//
|
||||
// Immutable is exactly the right discriminator: it is set iff the store came from a
|
||||
// glBufferStorage* entry point, which is the definition of the allowed case.
|
||||
inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) {
|
||||
return desc.Immutable != 0;
|
||||
}
|
||||
|
||||
// The forward terminator for a server-initiated texture pull (section 7.1). May carry
|
||||
// zero regions - that is how a pull that needs nothing is answered.
|
||||
struct MGPSubDataComplete {
|
||||
|
||||
@@ -289,6 +289,21 @@ namespace MobileGL {
|
||||
// Every entry is initialized to all-true in RenderState's constructor.
|
||||
Array<BoolVec4, kMGMaxDrawBuffers> ColorMasks;
|
||||
|
||||
// GL_FRAMEBUFFER_SRGB / GL_DEPTH_CLAMP / GL_TEXTURE_CUBE_MAP_SEAMLESS. Until P2 these
|
||||
// three fell to SetCapability's "not supported currently" arm - glEnable was swallowed
|
||||
// and IsCapabilityEnabled answered a compile-time false, so DirectGLES' sRGB block and
|
||||
// the DirectVulkan read points consumed a constant while glIsEnabled lied about it.
|
||||
// Placed HERE, in the three alignment bytes between ColorMasks (32 bytes, align 1) and
|
||||
// ClearColor (align 4), so sizeof(RenderStateParameters) stays 1168 and no existing
|
||||
// offset moves: the Espryt span constants and the P2 chunk table both depend on that.
|
||||
// All three are PIPELINE state (their setters call BumpVersions): FramebufferSrgb is
|
||||
// what ARCHITECTURE.md 5.3 asks for, DepthClamp is
|
||||
// VkPipelineRasterizationStateCreateInfo::depthClampEnable, and TextureCubeMapSeamless
|
||||
// changes sampler interpretation.
|
||||
Bool FramebufferSrgbEnabled = false;
|
||||
Bool DepthClampEnabled = false;
|
||||
Bool TextureCubeMapSeamlessEnabled = false;
|
||||
|
||||
// Clear State
|
||||
FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
Float ClearDepth = 1.0f;
|
||||
@@ -529,6 +544,61 @@ namespace MobileGL {
|
||||
};
|
||||
} // namespace MG_State::GLState
|
||||
|
||||
// ---- P3a: the WIRE forms of the two views above (ARCHITECTURE.md section on vertex
|
||||
// elements; brief D-G2). Neither VertexAttribute nor VertexBufferBindingPoint can travel
|
||||
// as itself: both hold a SharedPtr<BufferObject>, and a payload never contains a pointer.
|
||||
// They live here rather than in MGPipeTypes.h so the structs they mirror are one screen
|
||||
// away and a member added above has its wire twin in view; MGPipeTypes.h includes this
|
||||
// header, so MG_Pipe sees them unqualified like every other value type.
|
||||
//
|
||||
// Both ride the create_vertex_elements BLOB, in ascending index order, attributes first:
|
||||
// MGPVertexAttribWire[AttributeCount] then MGPVertexBindingPointWire[BindingPointCount],
|
||||
// each count <= VertexArrayObject::MAX_VERTEX_ATTRIBS (32). The applier refuses a record
|
||||
// whose declared counts do not match the blob's declared size.
|
||||
|
||||
// The resolved flat attribute view. Buffer identity does NOT travel here - it travels in
|
||||
// set_vertex_buffers, which is what keeps this record stable while buffers change under
|
||||
// it. Stride is the RESOLVED distance and a surviving 0 can only have come from the
|
||||
// binding model (see VertexAttribute::Stride above); collapsing it back into the element
|
||||
// size is what made KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the
|
||||
// buffer. Divisor is deliberately ABSENT: it is resolved per binding point and travels in
|
||||
// MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads it.
|
||||
// LegacyStride / LegacyPointer are likewise absent - they are the glGetVertexAttrib*
|
||||
// query answers and stay client-side, because nothing but the query path reads them.
|
||||
struct MGPVertexAttribWire {
|
||||
Uint64 Offset; // 0
|
||||
Int32 Stride; // 8
|
||||
Uint32 Type; // 12 DataType
|
||||
Uint8 Size; // 16 1..4; GL_BGRA keeps 4
|
||||
Uint8 Enabled; // 17
|
||||
Uint8 Normalized; // 18
|
||||
Uint8 IsInteger; // 19
|
||||
// CARRIED SEPARATELY from Type == Float64, and it has to be: VertexAttribFormat(
|
||||
// GL_DOUBLE) also reads doubles from memory but asks for them converted to float,
|
||||
// while VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its
|
||||
// Adreno disabled-attribute workaround both key on telling the two apart.
|
||||
Uint8 IsLong; // 20
|
||||
Uint8 IsBgra; // 21
|
||||
Uint8 BindingIndex; // 22 which MGPVertexBuffer entry feeds it (< MAX_VERTEX_ATTRIBS)
|
||||
Uint8 Pad0; // 23
|
||||
};
|
||||
|
||||
// The ARB_vertex_attrib_binding view. Buffer identity is again in set_vertex_buffers.
|
||||
//
|
||||
// WHY IT TRAVELS AT ALL, since no backend has ever read a binding point (the frontend
|
||||
// resolves them eagerly into the flat view above, and grep finds zero backend reads of
|
||||
// VertexBufferBindingPoint / GetAttributeBindingIndex / GetAttributeRelativeOffset): the
|
||||
// record DECLARES BindingPointCount, PipeFields.def names it, and a record whose declared
|
||||
// counts do not describe its own blob is a shape the applier's bounds gate would have to
|
||||
// police forever. Carrying both views keeps the record self-describing, and the cost is
|
||||
// paid once per configuration change rather than per draw - the blob rides only on
|
||||
// create_vertex_elements.
|
||||
struct MGPVertexBindingPointWire {
|
||||
Uint64 Offset; // 0
|
||||
Int32 Stride; // 8 GL 4.6 core table 23.4: the INITIAL value is 16, not 0
|
||||
Uint32 Divisor; // 12
|
||||
};
|
||||
|
||||
// ---- trip wires (P0.5). Sizes are what every ABI MobileGL ships on produces: every
|
||||
// member is a fixed-width scalar, an enum of one, or an array of those - no pointer, no
|
||||
// SizeT - except the vertex types, which carry SharedPtr<BufferObject> by design and are
|
||||
@@ -545,5 +615,16 @@ namespace MobileGL {
|
||||
static_assert(std::is_trivially_copyable_v<SamplerParameters> && sizeof(SamplerParameters) == 100);
|
||||
static_assert(std::is_trivially_copyable_v<MG_State::GLState::VertexAttributeVersion> &&
|
||||
sizeof(MG_State::GLState::VertexAttributeVersion) == 6);
|
||||
// The two P3a wire views. Unlike the structs they mirror these ARE flat PODs with
|
||||
// explicit padding, so the trip wire is the same one every MGPipe payload carries: the
|
||||
// blob they ride in is memcpy'd, and a field silently changing width is a protocol break
|
||||
// no test would otherwise see. (MGP_ASSERT_POD is MGPipeTypes.h's and that header
|
||||
// includes this one, so the assertions are spelled out here instead.)
|
||||
static_assert(std::is_trivially_copyable_v<MGPVertexAttribWire> &&
|
||||
sizeof(MGPVertexAttribWire) == 24);
|
||||
static_assert(std::is_standard_layout_v<MGPVertexAttribWire>);
|
||||
static_assert(std::is_trivially_copyable_v<MGPVertexBindingPointWire> &&
|
||||
sizeof(MGPVertexBindingPointWire) == 16);
|
||||
static_assert(std::is_standard_layout_v<MGPVertexBindingPointWire>);
|
||||
} // namespace MobileGL
|
||||
#endif // MOBILEGL_MG_PIPE_VALUE_TYPES_H
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
||||
// MobileGL - MobileGL/MG_Pipe/PipeApply.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 <Includes.h>
|
||||
|
||||
#include "MGPipeRenderStateSpans.h"
|
||||
#include "MGPipeTypes.h"
|
||||
|
||||
// The in-process applier: the SERVER half of the calls P2 emits. Under split this file is
|
||||
// MG_Remote/Server/PipeApplier (ARCHITECTURE.md 8.3); in the monolith it writes
|
||||
// MG_Backend/MGPipe/PipeInputs' gPipeInputs directly, so a call and its effect are one
|
||||
// function call apart and nothing is serialised.
|
||||
//
|
||||
// THE SERVER'S PER-CONTEXT WORKING BLOCK *IS* PipeInputs::m_renderState. bind_render_state
|
||||
// and set_dynamic_state scatter their chunks straight into it, which is why DirectGLES'
|
||||
// SyncRenderState is not one line changed (ROADMAP.md P2, G5): the block Espryt binds by
|
||||
// const reference is the assembled block. It is also what makes the MOBILEGL_PIPE_VERIFY
|
||||
// comparator a real oracle instead of a tautology - the compare-at-read now proves
|
||||
// "assembled == live", field by field, at every backend read.
|
||||
//
|
||||
// This header FORWARD-DECLARES PipeInputs rather than including it: the applier's callers
|
||||
// (MG_Impl/Pipe) already have it, and MG_Pipe sits below MG_Backend.
|
||||
//
|
||||
// Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt), so the pull build gains no symbol.
|
||||
namespace MobileGL::MG_Pipe {
|
||||
struct PipeInputs;
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The CSO store
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// One record per live render-state CSO, indexed by MGPipeHandle::Slot. It keeps the 396
|
||||
// pipeline bytes because an incremental create_render_state names only the chunks that
|
||||
// moved against a BaseCso - the rest has to come from somewhere, and that somewhere is
|
||||
// the record the client is naming.
|
||||
struct MGPipeRenderStateCsoRecord {
|
||||
Uint32 Gen = 0;
|
||||
Bool Live = false;
|
||||
Array<Uint8, kMGPipePipelineChunkBytes> PipelineBytes{};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// P3a: the handle-shaped resource op table (D-A1)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// The SECOND backend op table, beside BufferBackendOps. Registered by the active backend
|
||||
// at bring-up and cleared at shutdown, exactly as that one is; a null table means "this
|
||||
// backend has not taken the resource family over", and the frontend then dispatches the
|
||||
// old way, which is what lets the client half land on its own and what keeps a backend
|
||||
// whose buffer path is a later phase untouched.
|
||||
//
|
||||
// NO FRONTEND TYPE APPEARS HERE, and that is the whole point of the conversion: every
|
||||
// hook it replaces took a frontend heap reference and four of them read that object's
|
||||
// shadow bytes. A resource is an MGPipeHandle plus a payload record plus, where the call
|
||||
// carries content, a companion `const void*`.
|
||||
//
|
||||
// THE COMPANION POINTER IS NOT A NEW IDEA - MGPipeApplyCreateRenderState already carries
|
||||
// a blob beside its POD for the same reason: in monolith a blob needs no MGPBlobRef and
|
||||
// the pointer is the client's own shadow base, so the call is zero-copy and behaviour is
|
||||
// unchanged. How those bytes cross under a real transport is that phase's problem and
|
||||
// that phase's flag edit; resource_respecify deliberately does NOT carry kHasBlob here,
|
||||
// because a kHasBlob record must own an MGPBlobRef member and MGPResourceDesc has none.
|
||||
//
|
||||
// SubDataResident MAY BE NULL and stays nullable on purpose: one backend deliberately
|
||||
// does not implement it (kOptional in the catalogue), the frontend checks it exactly as
|
||||
// it checks the op table it replaces, and giving that backend a real implementation is a
|
||||
// behaviour change that belongs in its own change, not in this migration.
|
||||
struct MGPipeResourceOps {
|
||||
void (*Create)(MGPipeHandle res, const MGPResourceDesc& desc);
|
||||
void (*Respecify)(MGPipeHandle res, const MGPResourceDesc& desc, const void* initialBytes);
|
||||
void (*SubData)(MGPipeHandle res, const MGPSubData& record, const void* bytes);
|
||||
// kOptional: may be null. `bytes` is the application's staging store and is valid for
|
||||
// the duration of the call only.
|
||||
void (*SubDataResident)(MGPipeHandle res, const MGPSubData& record, const void* bytes);
|
||||
void (*FlushRange)(MGPipeHandle res, const MGPFlushRange& record, const void* bytes);
|
||||
void (*Readback)(MGPipeHandle res, const MGPReadback& record);
|
||||
void (*Destroy)(MGPipeHandle res);
|
||||
void* (*MapPersistent)(MGPipeHandle res, Uint64 size, const void* seedBytes);
|
||||
void (*UnmapPersistent)(MGPipeHandle res);
|
||||
};
|
||||
|
||||
// Install / read the table. A null argument uninstalls, which is what a backend does at
|
||||
// context teardown and what every build that has not migrated the family sits at.
|
||||
void MGPipeSetResourceOps(const MGPipeResourceOps* ops);
|
||||
const MGPipeResourceOps* MGPipeGetResourceOps();
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// P3a: the applier's own records (D-G4)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// THE SLOT A CLIENT MAY NAME IS BOUNDED, and the bound lives here rather than at the
|
||||
// client's allocator because the two tables below are grown BY the slot index. An array a
|
||||
// handle indexes is the right shape for a dense slot space (MGPipeHandles.h) and the price
|
||||
// of that shape is that one corrupt Uint32 in a payload otherwise arrives at an allocator
|
||||
// as a four-billion-entry request from inside the bounds gate's own commit. A slot at or
|
||||
// above these is Fatal{ProtocolCorruption} - the same verdict as any other record that
|
||||
// would make the server act outside its own storage - and never a resize.
|
||||
//
|
||||
// The two numbers differ because the two records do: a resource record is descriptor-sized
|
||||
// and a vertex-elements record carries both unpacked views at ~1.3 KB, so one bound would
|
||||
// mean two very different worst cases. Both are far above what a GL application has live
|
||||
// at once, and NEITHER IS EVER ALLOCATED BY BEING NAMED: the tables grow to the client's
|
||||
// own dense high-water mark and no further, so the bound costs nothing until a record is
|
||||
// already corrupt. Package C bounds handle.Slot the same way before
|
||||
// BackendSlotTable::EntryAt, which resizes on a client-supplied index too.
|
||||
inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20;
|
||||
inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16;
|
||||
|
||||
// One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the
|
||||
// reserved null handle and is never live.
|
||||
struct MGPipeResourceRecord {
|
||||
Uint32 Gen = 0;
|
||||
Bool Live = false;
|
||||
// The last create/respecify, verbatim. The backend reads its Width / Usage /
|
||||
// StorageFlags / HasDefinedContent instead of asking the frontend object.
|
||||
MGPResourceDesc Desc{};
|
||||
// SERVER-OWNED, monotone, and it never crosses the line: an MGGen-class counter, ++ on
|
||||
// every mutation this applier applies (respecify, sub-data, flush range, resident
|
||||
// sub-data). It is what replaces the frontend change serial the backend used to
|
||||
// mirror, and no MGPipe call may require the client to provide or know one.
|
||||
Uint64 Serial = 0;
|
||||
// ALWAYS FALSE IN P3a, AND WRITTEN BY NOBODY. It exists so the phase that pushes
|
||||
// persistent-mapped host writes can set it with zero new record kinds; a verify build
|
||||
// pins that it is false, so that phase cannot land a silent semantic change under it.
|
||||
Bool HasLiveHostWrites = false;
|
||||
};
|
||||
|
||||
// The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus
|
||||
// the serial the backend's per-VAO twin compares against instead of a wrapping Uint16
|
||||
// configuration version plus an identity patch.
|
||||
//
|
||||
// The 32 is GL's MAX_VERTEX_ATTRIBS as MobileGL advertises it (kMGPipeMaxVertexAttribs,
|
||||
// MGPipeTypes.h), which is also the bound the record's two declared counts are checked
|
||||
// against before the blob is unpacked.
|
||||
struct MGPipeVertexElementsRecord {
|
||||
Uint32 Gen = 0;
|
||||
Bool Live = false;
|
||||
Uint32 AttributeCount = 0;
|
||||
Uint32 BindingPointCount = 0;
|
||||
Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs> Attributes{};
|
||||
Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs> BindingPoints{};
|
||||
// Server-owned MGGen, ++ on every create_vertex_elements applied to this handle -
|
||||
// including a RE-create on the same handle, which is how a configuration change
|
||||
// travels (the handle is minted per frontend VAO and Gen moves only on slot reuse).
|
||||
Uint64 ContentSerial = 0;
|
||||
};
|
||||
|
||||
struct MGPipeApplierState {
|
||||
// Indexed by slot; slot 0 is the reserved null handle and is never live
|
||||
// (MGPipeHandles.h kMGPipeFirstAllocatableSlot).
|
||||
Vector<MGPipeRenderStateCsoRecord> RenderStateCsos;
|
||||
// The last bind, so a rebind of the same handle can be answered without a scatter.
|
||||
MGPipeHandle BoundRenderStateCso = kMGPipeNullHandle;
|
||||
// The residual block as last received. Compared against the assembled state on every
|
||||
// set_residual_value_state; a disagreement is the D9 trip wire.
|
||||
ResidualValueBlock Residual{};
|
||||
Bool HasResidual = false;
|
||||
|
||||
// The GLOBAL chunk bits (MGPipeRenderStateSpans.h's numbering) this applier has
|
||||
// itself scattered into the working block since the last reset - its own ledger of
|
||||
// which bytes of PipeInputs::m_renderState are the APPLIER'S rather than the per-verb
|
||||
// fill loop's. Both trip wires arm off it, and that is the whole of their contract:
|
||||
//
|
||||
// - with the render-state subsystem OFF (MOBILEGL_PIPE_PUSH bit 0 clear - the
|
||||
// per-subsystem A/B of D14) nothing is ever scattered, the ledger stays empty and
|
||||
// the wires say nothing. The working block is then the fill loop's, published per
|
||||
// VERB CLASS (MG_Pipe/FillPoints.def), so at a kDispatch or kTextureOp verb - the
|
||||
// two classes that publish IsCapabilityEnabled but NOT GetRenderStateParameters -
|
||||
// it still holds the previous draw's bytes and is an oracle for nothing;
|
||||
// - with it ON the applier is the block's only writer (D5 takes an emitted field
|
||||
// out of the fill loop), so the bytes it has scattered are current at every verb
|
||||
// of every class and comparing against them is honest.
|
||||
//
|
||||
// set_patch_state's own write to the working block deliberately does NOT enter the
|
||||
// ledger: that is the OTHER carrier, and a wire comparing against bytes it had just
|
||||
// written itself would be a tautology.
|
||||
Uint32 ScatteredChunkBits = 0;
|
||||
|
||||
// What the two trip wires last did. A wire nothing can observe is a gate that cannot
|
||||
// go red for the reason it exists (ROADMAP.md), and only a poison or verify build
|
||||
// aborts: the shipped push build counts and logs, so these counters are how a unit
|
||||
// case sees the wire fire in EVERY build rather than in one.
|
||||
Uint32 ResidualCapabilitiesCompared = 0; // of the 35, at the last set_residual_value_state
|
||||
Uint32 ResidualDivergences = 0; // cumulative
|
||||
Uint32 PatchCarrierComparisons = 0; // cumulative, armed set_patch_state calls only
|
||||
Uint32 PatchCarrierDivergences = 0; // cumulative
|
||||
|
||||
// ---- P3a (D-G4). ----
|
||||
//
|
||||
// THE TWO HALVES BELOW HAVE DIFFERENT LIVES, and MGPipeApplierReset is where the
|
||||
// difference is spent: the OBJECT RECORDS describe GL objects and outlive a
|
||||
// make-current; the WORKING STATE describes what the next draw fetches with and does
|
||||
// not. Reading the whole block as "per context" is what dropped a shared buffer's
|
||||
// record at every context switch and made the write that followed it disappear.
|
||||
|
||||
// ---- object records: indexed by MGPipeHandle::Slot of kind Buffer /
|
||||
// VertexElementsCso, and NOT part of the working state.
|
||||
//
|
||||
// A GL object lives in a SHARE GROUP, not in a context: a buffer created before a
|
||||
// make-current is the same buffer, with the same storage, after it, and its record is
|
||||
// the only thing the backend has left to read that storage's extent and mutation
|
||||
// serial out of (D-A4 re-keys IsBufferDrawClean onto exactly those two). Dropping the
|
||||
// records at a make-current would therefore make every subsequent glBufferSubData on a
|
||||
// pre-existing buffer resolve to nothing and be refused - a lost write, in a build
|
||||
// where the refusal's assertion has compiled out.
|
||||
//
|
||||
// They are cleared by the object's OWN death signal - resource_destroy,
|
||||
// delete_vertex_elements, which is what D-L makes the buffer's death crossing - and by
|
||||
// MGPipeApplierReleaseObjectRecords when the served context and its applier go away.
|
||||
// Nothing else.
|
||||
Vector<MGPipeResourceRecord> Resources;
|
||||
Vector<MGPipeVertexElementsRecord> VertexElementsCsos;
|
||||
|
||||
// Every call this applier REFUSED because it named a record this applier does not
|
||||
// have: an unknown slot, a slot that is not live, or a generation that has moved on
|
||||
// under it. The refusal is a defined no-op - nothing stored, nothing dispatched, no
|
||||
// serial moved - for the reason written beside kResourceRefusalNote in PipeApply.cpp,
|
||||
// but A NO-OP NOBODY CAN SEE IS A DROPPED CALL NOBODY CAN SEE: MOBILEGL_ASSERT compiles
|
||||
// out at INFO, which is what all three gate builds and every shipped build are, so
|
||||
// these two are how a unit case - and an operator reading a log - observe it in EVERY
|
||||
// build. Per context, like the four render-state wire counters above.
|
||||
Uint64 RefusedResourceCalls = 0;
|
||||
Uint64 RefusedVertexInputCalls = 0;
|
||||
|
||||
// ---- working state: what the next draw fetches with. All of it is per context and
|
||||
// all of it is cleared by MGPipeApplierReset, EXCEPT the two serials, which only ever
|
||||
// advance (see there).
|
||||
|
||||
// The last bind_vertex_elements. Null is legal and means "no VAO bound".
|
||||
MGPipeHandle BoundVertexElements = kMGPipeNullHandle;
|
||||
|
||||
// The last set_vertex_buffers, as received: the entries, the window they describe,
|
||||
// and the fetch base instance they are valid for.
|
||||
Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs> VertexBuffers{};
|
||||
Uint32 VertexBufferStart = 0;
|
||||
Uint32 VertexBufferCount = 0;
|
||||
// The RAW value the client sent (MGPVertexBuffers::BaseInstance). It is NOT a resolved
|
||||
// shift: whether the fetch shift has to be emulated at all is a backend capability - a
|
||||
// device with native base-instance support shifts nothing - and emulation is
|
||||
// server-owned, so the backend arm turns this into a per-attribute byte shift out of
|
||||
// each attribute's own stride and divisor. This header sits below MG_Backend and may
|
||||
// not ask that question. The client never pre-shifts an offset and never learns the
|
||||
// answer.
|
||||
Uint32 VertexFetchBaseInstance = 0;
|
||||
// Server-owned MGGen, ++ on every applied set_vertex_buffers. It is what retires the
|
||||
// backend twin's wrapping-Uint16-plus-identity patches - which means IT MUST NEVER
|
||||
// HAND OUT A VALUE TWICE. A reset ADVANCES it (the cleared window is itself a change
|
||||
// the twin has to hear about) and never returns it to 0: a counter that restarts walks
|
||||
// back through every value it has already stamped into a twin that outlived the
|
||||
// switch, and the identity patch that used to close that hole is exactly what D-G4
|
||||
// deletes on the twin's side.
|
||||
Uint64 VertexBuffersSerial = 0;
|
||||
|
||||
// The last set_index_buffer. Independent of the vertex-elements configuration by
|
||||
// design (D5): the index slot is not part of a VAO's configuration version.
|
||||
MGPIndexBuffer IndexBuffer{};
|
||||
// Advanced, never zeroed, for VertexBuffersSerial's reason.
|
||||
Uint64 IndexBufferSerial = 0;
|
||||
|
||||
// Every map_persistent EMISSION, i.e. every acquisition attempt - mint OR decline -
|
||||
// because every one of them needs an answer from the resource owner. In monolith the
|
||||
// answer is free; under a transport it is a real round trip. The number is therefore
|
||||
// the same in both modes and is "one per storage definition", which is what makes it
|
||||
// assertable today instead of a counter that can only ever read zero. The counter an
|
||||
// operator greps is PipeStats' map-persistent-roundtrips (mpr); this member is the
|
||||
// applier-side observable a unit case reads without a stats window.
|
||||
Uint64 MapPersistentRoundtrips = 0;
|
||||
};
|
||||
|
||||
// The monolith's single applier. Under split there is one per served context.
|
||||
MGPipeApplierState& MGPipeApplier();
|
||||
|
||||
// A MAKE-CURRENT, NOT A TEARDOWN - and the distinction is the whole of this function's
|
||||
// contract. It runs on every change of the current GLContext (MGPipeTracker::Update resets
|
||||
// the tracker whenever the context pointer moves, and the emitter calls this from the
|
||||
// first walk that follows), including a make-current BACK to a context that is still alive
|
||||
// and whose objects are all still there.
|
||||
//
|
||||
// So it drops what a returning context may not inherit - the render-state CSOs (whose
|
||||
// client-side cache is dropped on the line above it, so both sides start over together),
|
||||
// the residual mirror, and the vertex-input WORKING state - and it ADVANCES the two global
|
||||
// vertex-input serials rather than zeroing them. It does NOT drop the resource or
|
||||
// vertex-elements records: those describe share-group objects that the switch does not
|
||||
// destroy, and dropping them is a dropped write on the far side of it.
|
||||
void MGPipeApplierReset();
|
||||
|
||||
// THE OTHER SCOPE: the served context is going away and its applier with it, so the object
|
||||
// records go too. Under split that is one applier per served context and this is its
|
||||
// teardown. In the monolith there is ONE applier behind every context, so this is
|
||||
// deliberately wired to NOTHING: a record is cleared by its object's own death signal
|
||||
// (resource_destroy, delete_vertex_elements) and the process's exit clears the rest.
|
||||
// Calling it on one context's destruction in a monolith would drop every other context's
|
||||
// records, which is the C1 hole in its other direction.
|
||||
void MGPipeApplierReleaseObjectRecords();
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The seven apply entry points (ARCHITECTURE.md 5.3, ROADMAP.md P2)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// create_render_state. `chunkBytes` is the pipeline chunks named by desc.ChunkMask,
|
||||
// concatenated in ascending chunk order (MGPipeGatherPipelineChunks' output). A
|
||||
// brand-new CSO must name every chunk; an incremental one starts from desc.BaseCso.
|
||||
void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes);
|
||||
// bind_render_state: 12 bytes, no blob, no hashing. Scatters the record's seven pipeline
|
||||
// chunks into the working block and publishes both versions.
|
||||
void MGPipeApplyBindRenderState(const MGPBindRenderState& bind);
|
||||
// delete_render_state: frees the slot. The client's allocator owns the Gen bump on
|
||||
// REUSE; the record only stops being live here. CsoCache's LRU eviction emits this.
|
||||
void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle);
|
||||
// set_dynamic_state: the dynamic chunks named by dyn.ChunkMask, concatenated ascending.
|
||||
void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes);
|
||||
// set_pixel_pack_state. PACK only, deliberately (MGPipeTypes.h, ARCHITECTURE.md 4.6 D5).
|
||||
void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack);
|
||||
// set_patch_state. The trio also travels in pipeline chunk P0, and the applier asserts
|
||||
// under verify that the two carriers agree - the redundancy is a trip wire, not waste.
|
||||
void MGPipeApplySetPatchState(const MGPPatchState& patch);
|
||||
// set_vertex_attrib_defaults: `tail` is hdr.Count MGPAttribValues for the attributes
|
||||
// named by hdr.Mask, in ascending location order.
|
||||
void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, const MGPAttribValue* tail);
|
||||
// set_residual_value_state: what has no call of its own. Since P2 that is one Uint64 of
|
||||
// capability bits, and every one of them is ALSO answerable from the assembled working
|
||||
// block - which is the point. A disagreement is Fatal{PipeResidualDiverged, "<Cap>"}.
|
||||
void MGPipeApplySetResidualValueState(const ResidualValueBlock& block);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// P3a: the nine resource entry points (D-A1, D-A2)
|
||||
// ---------------------------------------------------------------------------------
|
||||
//
|
||||
// These are the ONE exception to push-at-validate: they are applied at the GL call that
|
||||
// causes them, from the same dispatchers that call the old op table today, because that
|
||||
// is already where those hooks run. Nothing about buffers moves to validate time here.
|
||||
//
|
||||
// The `bytes` companion of the three content-carrying calls is the client's shadow base,
|
||||
// never a copy (see MGPipeResourceOps). A null is a real answer wherever the payload says
|
||||
// the content is undefined.
|
||||
//
|
||||
// AT THE CONTRACT COMMIT EVERY BODY BELOW IS A STUB. The signatures are what the client,
|
||||
// the backend and the gates compile against, and the records above are what they write
|
||||
// into; the bodies land in the two commits that follow this one on the same branch.
|
||||
|
||||
// resource_create: mints the record and marks the slot Live. Emitted from the buffer
|
||||
// object's CONSTRUCTOR, so a resource exists before anything can name it; storage is
|
||||
// defined lazily by the first respecify and a backend tolerates a resource with none.
|
||||
void MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
|
||||
// resource_respecify: replaces the stored descriptor and bumps Serial. `initialBytes` is
|
||||
// the shadow when desc.HasDefinedContent, else null. kNeedsAck on the call,
|
||||
// MGPipeResourceRespecifyNeedsAck(desc) per record - only an immutable store acks.
|
||||
void MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes);
|
||||
// resource_subdata, buffer half: the destination range rides in the record's box through
|
||||
// MGPipeSetSubDataBufferRange, and a false from that helper is where the EMITTER split.
|
||||
// The applier stores nothing per record - contents are the backend's - and bumps Serial.
|
||||
void MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes);
|
||||
// buffer_subdata_resident: same shape; `bytes` is the application's staging store and is
|
||||
// valid for the duration of the call only. The op-table entry may be null.
|
||||
void MGPipeApplyBufferSubDataResident(const MGPSubData& record, const void* bytes);
|
||||
// resource_flush_range: record.AccessFlags are the application's REAL mapping flags, not
|
||||
// a normalised subset - the backend reads them per call to choose its upload shape.
|
||||
void MGPipeApplyResourceFlushRange(const MGPFlushRange& record, const void* bytes);
|
||||
// resource_readback: whole-buffer by contract. The answer travels back through the
|
||||
// reverse channel, and the writeback happens BEFORE the mutation epoch bumps, never
|
||||
// after - the ordering is a correctness rule, not a preference.
|
||||
void MGPipeApplyResourceReadback(const MGPReadback& record);
|
||||
// resource_destroy: clears Live and drops the record, then the backend frees its twin.
|
||||
// The CLIENT frees the slot afterwards, in that order, because the allocator forgets the
|
||||
// lifetime id on free and a notice resolved twice finds nothing the second time.
|
||||
void MGPipeApplyResourceDestroy(const MGPHandleOnly& handle);
|
||||
// map_persistent: bumps MapPersistentRoundtrips and asks the backend. Returns the
|
||||
// coherent host pointer the resource owner donated, or null for a DECLINE - which is a
|
||||
// real answer and the reason the call is kOptional as well as kReplySlot. `seedBytes` is
|
||||
// the shadow, still live at this point, for the backends that seed the new store from it.
|
||||
void* MGPipeApplyMapPersistent(const MGPHandleOnly& handle, Uint64 size, const void* seedBytes);
|
||||
// unmap_persistent: the donation ends. Never emitted by P3a's own paths; the call exists
|
||||
// so the pair is complete and the transport has both halves.
|
||||
void MGPipeApplyUnmapPersistent(const MGPHandleOnly& handle);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// P3a: the five vertex-input entry points (D-G, D-H, D-I)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// create_vertex_elements. `blobBytes` is MGPVertexAttribWire[desc.AttributeCount]
|
||||
// immediately followed by MGPVertexBindingPointWire[desc.BindingPointCount], both in
|
||||
// ascending index order. The applier REFUSES a record whose declared counts do not
|
||||
// describe its own blob, and both counts are bounded by kMGPipeMaxVertexAttribs.
|
||||
// Re-issuing on the same handle is how a configuration change travels; it bumps
|
||||
// ContentSerial and does not rebind.
|
||||
void MGPipeApplyCreateVertexElements(const MGPVertexElements& desc, const void* blobBytes);
|
||||
// bind_vertex_elements. The null handle is legal and means "no VAO bound".
|
||||
void MGPipeApplyBindVertexElements(const MGPHandleOnly& handle);
|
||||
// delete_vertex_elements: emitted from ONE place, the frontend object's death notice.
|
||||
void MGPipeApplyDeleteVertexElements(const MGPHandleOnly& handle);
|
||||
// set_vertex_buffers: `tail` is hdr.Count MGPVertexBuffer entries starting at hdr.Start.
|
||||
// hdr.BaseInstance is the DRAW's raw base instance and is stored, unresolved, in
|
||||
// VertexFetchBaseInstance - the decision whether to emulate the fetch shift is the
|
||||
// backend's, for the reason written beside that member. Bumps VertexBuffersSerial.
|
||||
void MGPipeApplySetVertexBuffers(const MGPVertexBuffers& hdr, const MGPVertexBuffer* tail);
|
||||
// set_index_buffer: an independent call, NOT a subset of the vertex-elements
|
||||
// configuration. Bumps IndexBufferSerial.
|
||||
void MGPipeApplySetIndexBuffer(const MGPIndexBuffer& record);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The derivation step (ARCHITECTURE.md 5.3, P2 brief D5)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
// Recomputes every PipeInputs field that is a pure function of the working
|
||||
// RenderStateParameters, instead of pulling it out of GLContext a second time.
|
||||
//
|
||||
// The oracle is the one P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each of
|
||||
// these from the live context at every backend read, so a transcription error is caught
|
||||
// on the first draw that reads it - on the retrace and integration-verify LANES, which is
|
||||
// where the comparator arms (MG_Config::Features.PipeVerify). A unit-test process never
|
||||
// runs the config loader, so the unit oracle is a different one:
|
||||
// RenderStateSpansTest.DerivationMatchesTheFrontendGetters walks every setter and
|
||||
// compares all 29 derived values against the frontend getters they were transcribed from.
|
||||
void MGPipeDeriveRenderStateFields(PipeInputs& inputs);
|
||||
|
||||
// The same derivation, SCOPED to the chunks a scatter actually moved (bit i is global
|
||||
// chunk i - MGPipeGlobalChunkBitsOf{Pipeline,Dynamic}Mask widens a wire mask to it). This
|
||||
// is what the applier calls, and it is why a per-frame glViewport - the D8 case whose
|
||||
// whole point is that it sends dynamic chunk D0 alone - does not pay for the 8-wide blend
|
||||
// loop, the 16-wide depth-range loop or the 35-arm capability switch. Every guard's chunk
|
||||
// set is computed from the boundary table with MGPipeRenderStateChunkBitsCovering, so a
|
||||
// boundary move cannot leave one stale, and
|
||||
// RenderStateSpansTest.IncrementalChunksKeepEveryDerivedFieldInStep drives the scoped
|
||||
// path against the frontend getters family by family.
|
||||
void MGPipeDeriveRenderStateFieldsForChunks(PipeInputs& inputs, Uint32 globalChunkBits);
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
@@ -16,6 +16,12 @@
|
||||
// kScreen lands in struct MGPipeScreen, every other class in struct
|
||||
// MGPipeContext (plan section 4.3).
|
||||
// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional
|
||||
// kNeedsAck on a call means records of this call MAY require an ack; a
|
||||
// per-record predicate decides. resource_respecify carries it for
|
||||
// glBufferStorage - a real synchronous allocation, and the only entry point
|
||||
// allowed a synchronous ack - and MGPipeResourceRespecifyNeedsAck(desc)
|
||||
// (MGPipeTypes.h) is what says so, which is why the same call still carries
|
||||
// every glBufferData without acknowledging one.
|
||||
//
|
||||
// RECORD NUMBERING NEVER CHURNS. Entries that are not implemented yet still occupy their
|
||||
// line (plan section 11, P0: "the complete call catalogue, placeholders included"). A new
|
||||
@@ -73,7 +79,7 @@
|
||||
/* ---- screen: caps, resources, persistent map, fences (plan 4.4.1) ---- */ \
|
||||
X(GetCaps, MGPCaps, kScreen, kReplySlot) \
|
||||
X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \
|
||||
X(ResourceRespecify, MGPResourceDesc, kScreen, kNone) \
|
||||
X(ResourceRespecify, MGPResourceDesc, kScreen, kNeedsAck) \
|
||||
X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \
|
||||
X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \
|
||||
X(UnmapPersistent, MGPHandleOnly, kScreen, kOptional) \
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
F(Res) F(Offset) F(Stride) F(Divisor) F(BindingIndex)
|
||||
|
||||
#define MGP_FIELDS_MGPVertexBuffers(F) \
|
||||
F(Start) F(Count) F(ContentHash)
|
||||
F(Start) F(Count) F(BaseInstance) F(ContentHash)
|
||||
|
||||
#define MGP_FIELDS_MGPIndexBuffer(F) \
|
||||
F(Res) F(Offset) F(IndexSize)
|
||||
@@ -144,8 +144,11 @@
|
||||
#define MGP_FIELDS_MGPPatchState(F) \
|
||||
F(Vertices) F(Outer) F(Inner)
|
||||
|
||||
// P2 ratcheted this block from six rows to one: RenderStateParameters retired to
|
||||
// create/bind_render_state + set_dynamic_state, Pack to set_pixel_pack_state and the
|
||||
// patch trio to set_patch_state. What is left is the redundant capability trip wire.
|
||||
#define MGP_FIELDS_ResidualValueBlock(F) \
|
||||
F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner)
|
||||
F(CapabilityBits)
|
||||
|
||||
#define MGP_FIELDS_MGPResidualValueState(F) \
|
||||
F(Version) F(Blob)
|
||||
@@ -231,7 +234,8 @@
|
||||
F(Viewports) F(LineWidth) F(PointSize) F(PatchVertices) F(PatchDefaultOuterLevel) \
|
||||
F(PatchDefaultInnerLevel) F(PolygonOffsetFactor) F(PolygonOffsetUnits) F(PolygonOffsetClamp) \
|
||||
F(ClipOrigin) F(ClipDepthMode) F(BlendStates) F(LogicOp) F(DepthTestEnabled) F(DepthFunc) \
|
||||
F(DepthMask) F(ColorMasks) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \
|
||||
F(DepthMask) F(ColorMasks) F(FramebufferSrgbEnabled) F(DepthClampEnabled) \
|
||||
F(TextureCubeMapSeamlessEnabled) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \
|
||||
F(DepthRanges) F(SampleCoverageValue) F(SampleCoverageInvert) F(SampleMaskValue) \
|
||||
F(MinSampleShadingValue) F(StencilStates) F(CullFaceEnabled) F(CullFaceModeSetting) \
|
||||
F(FrontFaceModeSetting) F(ProvokingVertexModeSetting) F(LineSmoothHint) F(PolygonSmoothHint) \
|
||||
@@ -291,6 +295,21 @@
|
||||
#define MGP_FIELDS_MGHostSpan(F) \
|
||||
F(Ptr) F(Seg) F(Size) F(Offset)
|
||||
|
||||
// P3a's two vertex wire views (MGPipeValueTypes.h). They are not call payloads either: they
|
||||
// are the ELEMENTS of create_vertex_elements' blob, and the comparator has to see into them
|
||||
// for the same reason it sees into the value structs - a blob compared with memcmp would
|
||||
// false-differ on MGPVertexAttribWire::Pad0. Divisor is deliberately not in the attribute
|
||||
// list (it travels in MGPVertexBuffer) and the two Legacy* query answers are deliberately not
|
||||
// on the wire at all; both absences are argued in MGPipeValueTypes.h and both are enforced
|
||||
// here by gen_pipe.py's "every list names exactly its struct's direct members" rule.
|
||||
|
||||
#define MGP_FIELDS_MGPVertexAttribWire(F) \
|
||||
F(Offset) F(Stride) F(Type) F(Size) F(Enabled) F(Normalized) F(IsInteger) F(IsLong) F(IsBgra) \
|
||||
F(BindingIndex)
|
||||
|
||||
#define MGP_FIELDS_MGPVertexBindingPointWire(F) \
|
||||
F(Offset) F(Stride) F(Divisor)
|
||||
|
||||
// Every payload above, in the order the comparator is generated. Keep in sync with the
|
||||
// macros; gen_pipe.py reads THIS list to know what to emit.
|
||||
#define MGP_VERIFY_PAYLOAD_LIST(P) \
|
||||
@@ -308,6 +327,7 @@
|
||||
P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \
|
||||
P(MGPSurfaceInfo) \
|
||||
P(RenderStateParameters) P(PixelStoreParameters) P(PerBufferBlendState) P(StencilFaceState) \
|
||||
P(DynamicBackendParameters) P(MGHostSpan)
|
||||
P(DynamicBackendParameters) P(MGHostSpan) \
|
||||
P(MGPVertexAttribWire) P(MGPVertexBindingPointWire)
|
||||
|
||||
// clang-format on
|
||||
|
||||
@@ -33,10 +33,131 @@ namespace MobileGL::MG_Pipe {
|
||||
// A no-op unless a context is live, a verb has been filled, and `field` is in that verb
|
||||
// class's may-read mask; a forwarded (sticky) field has no storage and is never copied.
|
||||
void MGPipeNoteFrontendMutation(MGPipeInputField field);
|
||||
|
||||
// ---- the aggregate generations (P2 brief D4, ARCHITECTURE.md 5.2) ----
|
||||
//
|
||||
// MGP_NOTE_MUTATION answers "a backend moved a frontend value INSIDE its own verb".
|
||||
// MGP_NOTE_AGGREGATE answers a different question, which is why it is a second macro
|
||||
// and not an overload: "did ANY object of this class move since the last time the
|
||||
// tracker looked", collapsed onto one monotonic Uint64 per class so a per-verb dirty
|
||||
// walk is a handful of compares rather than a scan over 32 attributes, 16 attachments,
|
||||
// 32 texture units and 84 binding points.
|
||||
//
|
||||
// The counters are members of the owning MG_State container, all guarded by
|
||||
// MOBILEGL_PIPE_PUSH so the pull build's state objects do not change size (G1). The
|
||||
// bump points sit on OBJECTS, which have no back-pointer to their state, so the macro
|
||||
// goes through a free function that finds the live GLContext - the same shape, and for
|
||||
// the same reason, as MGP_NOTE_MUTATION (MG_Impl/Pipe/PipeFill.cpp). It costs a global
|
||||
// load on a path that has just written object state.
|
||||
//
|
||||
// Monotonic and never reset: the tracker widens and compares, it never subtracts.
|
||||
// Over-firing is free (one extra re-push); under-firing renders stale, which is why
|
||||
// every counter here is deliberately COARSER than the state it guards.
|
||||
enum class MGPipeAggregate : Uint32 {
|
||||
// VertexArrayState: any VAO attribute format / buffer / enable moved.
|
||||
VaoAttribute = 0,
|
||||
// FramebufferState: any FBO attachment or default-geometry write, or a bind.
|
||||
FramebufferAttachment,
|
||||
// TextureState: any texture object CONTENT moved (an upload, a dirty region).
|
||||
TextureContent,
|
||||
// TextureState: any texture object or sampler object PARAMETER moved.
|
||||
TextureParams,
|
||||
// BufferState: any buffer object contents moved.
|
||||
BufferChange,
|
||||
// GLContext: a glVertexAttrib* default value moved. Not one of D4 five: the bit it
|
||||
// shutters (NEW_VERTEX_ATTRIB_DEFAULTS) is specified there as a ContentHash over
|
||||
// all 32 CurrentVertexAttributeValues, and hashing 768 bytes on EVERY draw does not
|
||||
// fit inside the T1 ceiling. The hash still decides whether to EMIT (D11 set-hash
|
||||
// suppressor); this decides whether to hash at all.
|
||||
VertexAttribDefault,
|
||||
Count,
|
||||
};
|
||||
|
||||
// MG_Impl/Pipe/PipeFill.cpp. A no-op unless a context is live.
|
||||
void MGPipeNoteAggregate(MGPipeAggregate aggregate);
|
||||
|
||||
// ---- P3a: the resource family's emission points (brief D-A1) ----
|
||||
//
|
||||
// The seven BufferBackendOps hooks already dispatch at the GL call that causes them
|
||||
// (ARCHITECTURE.md 5.1 names them as the ONE exception to push-at-validate), so their
|
||||
// pipe calls are emitted from the same BufferObject dispatchers rather than from the
|
||||
// validate point. That puts the emission inside MG_State, which is why these are
|
||||
// DECLARED here beside the two notices and DEFINED in MG_Impl/Pipe/PipeFill.cpp: this
|
||||
// header is the one MG_State already includes for exactly this, and the closure gate
|
||||
// (check_include_closure.py's mutation-header probe) keeps it a declaration - reaching
|
||||
// MG_Impl/Pipe/ResourceTracker.h from BufferObject.cpp would pull the client's tracker
|
||||
// into the state machine that calls it.
|
||||
//
|
||||
// The forward declaration is the whole coupling: none of these needs the definition of
|
||||
// BufferObject, and this header must not gain it.
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
class BufferObject;
|
||||
}
|
||||
|
||||
namespace MobileGL::MG_Pipe {
|
||||
// (Features.PipePush & kMGPipeSubsystemResources) != 0 && MGPipeGetResourceOps() != nullptr.
|
||||
//
|
||||
// BOTH HALVES MATTER. The bit is the operator's per-subsystem A/B; the table is "has a
|
||||
// backend taken this family over at all". Until one has, every dispatch below falls
|
||||
// through to the BufferBackendOps table it replaces and the tree behaves exactly as it
|
||||
// did - which is what lets the client half land on its own.
|
||||
Bool MGPipeResourceSubsystemEnabled();
|
||||
// The nullable member, asked the way the frontend asks g_bufferBackendOps->ResidentSubData
|
||||
// today: one backend deliberately does not implement it and the caller has a different
|
||||
// path when it is absent (BufferObject::FillSubData).
|
||||
Bool MGPipeResourceOpsHaveSubDataResident();
|
||||
|
||||
// Minted from the constructor and released from the destructor, both unconditionally in
|
||||
// a push build: a handle is CLIENT state and set_vertex_buffers names it whether or not
|
||||
// the resource family is switched on. The CALLS are what the predicate above gates.
|
||||
void MGPipeMintResourceHandle(MG_State::GLState::BufferObject& buffer);
|
||||
// In this order, and it is not negotiable (D-L): the destroy resolves the handle, and
|
||||
// MGPipeSlotAllocator::Free erases the lifetimeId -> slot mapping it resolves through.
|
||||
//
|
||||
// RETURNS whether resource_destroy was emitted, which is the LATCH taken at this buffer's
|
||||
// create and not a second reading of MGPipeResourceSubsystemEnabled(). The destructor
|
||||
// needs that answer to decide whether the legacy OnDestroy still owes a call: asking the
|
||||
// predicate twice pairs a create emitted under one registration with a destroy gated on
|
||||
// another, and either direction leaks - a live applier record on a slot about to be
|
||||
// re-handed-out, or a backend object nobody releases.
|
||||
Bool MGPipeEmitResourceDestroyAndFree(MG_State::GLState::BufferObject& buffer);
|
||||
|
||||
// THE VERTEX-ELEMENTS CSO's DEATH, and it is BACKEND-NEUTRAL - which is the whole point.
|
||||
// Before this, the only thing that ever returned a VertexElementsCso slot was DirectGLES'
|
||||
// StateObjectDeathOps table; under any backend that installs none - DirectVulkan/Magma,
|
||||
// which keeps its own age-reclaimed identity table on purpose - every VAO ever created
|
||||
// held its slot and its ~1.3 KB applier record for the life of the process, on the shipped
|
||||
// 0x1ff mask, and past 65536 slots every create_vertex_elements became a permanent
|
||||
// Fatal{ProtocolCorruption}. The client mints the slot, so the client is where the death
|
||||
// has to be spoken from.
|
||||
//
|
||||
// Takes the lifetime id and not the object for StateObjectDeathNotice.h's reason: the last
|
||||
// SharedPtr has already dropped by the time this runs, and the lifetime id is what the
|
||||
// slot allocator resolves the handle from. Returns whether delete_vertex_elements went
|
||||
// out, i.e. whether the applier actually held a record - see the definition for why that
|
||||
// is asked rather than assumed.
|
||||
Bool MGPipeEmitVertexElementsDestroyAndFree(Uint64 lifetimeId);
|
||||
|
||||
void MGPipeEmitResourceCreate(MG_State::GLState::BufferObject& buffer);
|
||||
void MGPipeEmitResourceRespecify(MG_State::GLState::BufferObject& buffer);
|
||||
void MGPipeEmitResourceSubData(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size);
|
||||
void MGPipeEmitBufferSubDataResident(MG_State::GLState::BufferObject& buffer, SizeT offset,
|
||||
const void* bytes, SizeT size);
|
||||
void MGPipeEmitResourceFlushRange(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size,
|
||||
Uint32 accessFlags);
|
||||
void MGPipeEmitResourceReadback(MG_State::GLState::BufferObject& buffer);
|
||||
// Returns the coherent host pointer the resource owner donated, or null for a DECLINE -
|
||||
// which is a real answer. Every call, mint or decline, is one map-persistent roundtrip.
|
||||
void* MGPipeEmitMapPersistent(MG_State::GLState::BufferObject& buffer);
|
||||
} // namespace MobileGL::MG_Pipe
|
||||
#define MGP_NOTE_MUTATION(Field) \
|
||||
::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field)
|
||||
#define MGP_NOTE_AGGREGATE(Aggregate) \
|
||||
::MobileGL::MG_Pipe::MGPipeNoteAggregate(::MobileGL::MG_Pipe::MGPipeAggregate::Aggregate)
|
||||
#else
|
||||
#define MGP_NOTE_MUTATION(Field) ((void)0)
|
||||
#define MGP_NOTE_AGGREGATE(Aggregate) ((void)0)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -299,6 +299,97 @@ inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] =
|
||||
"SetStreamOutputTargets",
|
||||
};
|
||||
|
||||
// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no
|
||||
// longer pulls it out of GLContext. kNone means the field is still pulled - which
|
||||
// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single
|
||||
// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST.
|
||||
enum class MGPipeFieldEmitter : Uint8 {
|
||||
kNone = 0,
|
||||
BindRenderState,
|
||||
BindVertexElements,
|
||||
CreateRenderState,
|
||||
SetDynamicState,
|
||||
SetPatchState,
|
||||
SetVertexAttribDefaults,
|
||||
};
|
||||
|
||||
inline constexpr const char* kMGPipeFieldEmitterNames[] = {
|
||||
"kNone",
|
||||
"BindRenderState",
|
||||
"BindVertexElements",
|
||||
"CreateRenderState",
|
||||
"SetDynamicState",
|
||||
"SetPatchState",
|
||||
"SetVertexAttribDefaults",
|
||||
};
|
||||
|
||||
inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = {
|
||||
MGPipeFieldEmitter::kNone, // GetActiveTextureUnit
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetBlendColor
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetBlendEquationIndexed
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetBlendFuncIndexed
|
||||
MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackName
|
||||
MGPipeFieldEmitter::BindVertexElements, // GetBoundVertexArray
|
||||
MGPipeFieldEmitter::kNone, // GetBufferBindingSlot
|
||||
MGPipeFieldEmitter::kNone, // GetBufferBindingPoint
|
||||
MGPipeFieldEmitter::kNone, // GetBufferBindingPointCount
|
||||
MGPipeFieldEmitter::kNone, // GetTouchedBufferBindingPointCount
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetClampReadColor
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetClearColor
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetClearDepth
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetClearStencil
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetColorMaskIndexed
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetCullFaceMode
|
||||
MGPipeFieldEmitter::SetVertexAttribDefaults, // GetCurrentVertexAttribute
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetDepthMask
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed
|
||||
MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot
|
||||
MGPipeFieldEmitter::kNone, // GetImageTextureBinding
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetLineWidth
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetLogicOp
|
||||
MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue
|
||||
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel
|
||||
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel
|
||||
MGPipeFieldEmitter::SetPatchState, // GetPatchVertices
|
||||
MGPipeFieldEmitter::BindRenderState, // GetPipelineStateVersion
|
||||
MGPipeFieldEmitter::kNone, // GetPixelStoreParameters
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetPolygonModeFront
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex
|
||||
MGPipeFieldEmitter::kNone, // GetProgramForDispatch
|
||||
MGPipeFieldEmitter::kNone, // GetProgramForDraw
|
||||
MGPipeFieldEmitter::kNone, // GetProgramObject
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters
|
||||
MGPipeFieldEmitter::BindRenderState, // GetRenderStateParametersVersion
|
||||
MGPipeFieldEmitter::kNone, // GetSamplingResolutionGeneration
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetScissorBox
|
||||
MGPipeFieldEmitter::CreateRenderState, // GetStencilState
|
||||
MGPipeFieldEmitter::kNone, // GetTextureBindGeneration
|
||||
MGPipeFieldEmitter::kNone, // GetTextureContextId
|
||||
MGPipeFieldEmitter::kNone, // GetTextureObject
|
||||
MGPipeFieldEmitter::kNone, // GetTextureUnitObject
|
||||
MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices
|
||||
MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration
|
||||
MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter
|
||||
MGPipeFieldEmitter::kNone, // GetTransformFeedbackProgram
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetViewport
|
||||
MGPipeFieldEmitter::SetDynamicState, // GetViewportIndexed
|
||||
MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabled
|
||||
MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabledIndexed
|
||||
MGPipeFieldEmitter::kNone, // IsTransformFeedbackActive
|
||||
MGPipeFieldEmitter::kNone, // IsTransformFeedbackPaused
|
||||
MGPipeFieldEmitter::kNone, // InvalidateCompileEnv
|
||||
MGPipeFieldEmitter::kNone, // ValidateProgramName
|
||||
MGPipeFieldEmitter::kNone, // RecordError
|
||||
MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId
|
||||
MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan
|
||||
};
|
||||
inline constexpr SizeT kMGPipeEmittedFieldCount = 34;
|
||||
|
||||
struct MGPipeFilledState {
|
||||
Uint64 CurrentVerbSerial;
|
||||
Uint64 FilledGen[kMGPipeInputFieldCount];
|
||||
|
||||
@@ -14,54 +14,80 @@
|
||||
|
||||
// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own
|
||||
// completeness trip wire: MG_Test walks every public RenderState setter and asserts that
|
||||
// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test
|
||||
// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from
|
||||
// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are
|
||||
// derived from a list that was reviewed rather than invented.
|
||||
// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves
|
||||
// (MG_Test/Pipe/RenderStateSpansTest.cpp).
|
||||
//
|
||||
// Deliberately absent, and each absence is a question P2 has to answer before the chunk
|
||||
// table freezes:
|
||||
// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability
|
||||
// falls to "not supported currently" and IsCapabilityEnabled returns false), so six
|
||||
// backend read points are constant false today. Pipeline state or dead capability?
|
||||
// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today.
|
||||
// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and
|
||||
// are handled elsewhere in the payload path rather than in the memo word.
|
||||
// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides
|
||||
// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that
|
||||
// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction
|
||||
// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24
|
||||
// members VulkanRenderer::ComputePipelineStateHash used to hash.
|
||||
//
|
||||
// The three questions P0 left open are ANSWERED here, and the answers are in this list:
|
||||
// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all -
|
||||
// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a
|
||||
// compile-time false. P2 gave all three real storage in the three padding bytes between
|
||||
// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state.
|
||||
// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline.
|
||||
// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is
|
||||
// ++m_version only), so they are dynamic, in chunk D1.
|
||||
//
|
||||
// The complement of this list is the DYNAMIC subset - the half whose whole purpose is that
|
||||
// glViewport must not mint a new CSO.
|
||||
|
||||
inline constexpr const char* const kMGPipePipelineStateMembers[] = {
|
||||
"CullFaceEnabled",
|
||||
"DepthTestEnabled",
|
||||
"PolygonOffsetFillEnabled",
|
||||
"RasterizerDiscardEnabled",
|
||||
"ColorLogicOpEnabled",
|
||||
"StencilTestEnabled",
|
||||
"PrimitiveRestartEnabled",
|
||||
"PrimitiveRestartFixedIndexEnabled",
|
||||
"DepthMask",
|
||||
"SampleShadingEnabled",
|
||||
"MultisampleEnabled",
|
||||
"SampleMaskEnabled",
|
||||
"SampleMaskValue",
|
||||
"MinSampleShadingValue",
|
||||
"PatchVertices",
|
||||
"PatchDefaultOuterLevel",
|
||||
"PatchDefaultInnerLevel",
|
||||
"PolygonModeFront",
|
||||
"CullFaceModeSetting",
|
||||
"DepthFunc",
|
||||
"LogicOp",
|
||||
"StencilStates",
|
||||
"BlendStates",
|
||||
"LogicOp",
|
||||
"DepthTestEnabled",
|
||||
"DepthFunc",
|
||||
"DepthMask",
|
||||
"ColorMasks",
|
||||
"FramebufferSrgbEnabled",
|
||||
"DepthClampEnabled",
|
||||
"TextureCubeMapSeamlessEnabled",
|
||||
"SampleCoverageValue",
|
||||
"SampleCoverageInvert",
|
||||
"SampleMaskValue",
|
||||
"MinSampleShadingValue",
|
||||
"StencilStates",
|
||||
"CullFaceEnabled",
|
||||
"CullFaceModeSetting",
|
||||
"FrontFaceModeSetting",
|
||||
"ProvokingVertexModeSetting",
|
||||
"PolygonModeFront",
|
||||
"PolygonModeBack",
|
||||
"ColorLogicOpEnabled",
|
||||
"DebugOutputEnabled",
|
||||
"DebugOutputSynchronousEnabled",
|
||||
"DitherEnabled",
|
||||
"LineSmoothEnabled",
|
||||
"MultisampleEnabled",
|
||||
"PolygonOffsetFillEnabled",
|
||||
"PolygonOffsetLineEnabled",
|
||||
"PolygonOffsetPointEnabled",
|
||||
"PolygonSmoothEnabled",
|
||||
"PrimitiveRestartEnabled",
|
||||
"PrimitiveRestartFixedIndexEnabled",
|
||||
"RasterizerDiscardEnabled",
|
||||
"SampleAlphaToCoverageEnabled",
|
||||
"SampleAlphaToOneEnabled",
|
||||
"SampleCoverageEnabled",
|
||||
"SampleMaskEnabled",
|
||||
"SampleShadingEnabled",
|
||||
"StencilTestEnabled",
|
||||
"ProgramPointSizeEnabled",
|
||||
"ScissorTestEnabledMask",
|
||||
};
|
||||
inline constexpr SizeT kMGPipePipelineStateMemberCount = 24;
|
||||
inline constexpr SizeT kMGPipePipelineStateMemberCount = 44;
|
||||
static_assert(kMGPipePipelineStateMemberCount ==
|
||||
sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));
|
||||
|
||||
// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets
|
||||
// in C++ with offsetof rather than guessing them in python.
|
||||
// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every
|
||||
// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline
|
||||
// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves
|
||||
// partition [0, sizeof(RenderStateParameters)) exactly - asserted there.
|
||||
extern const MGPStateChunk kMGPipePipelineChunks[];
|
||||
extern const MGPStateChunk kMGPipeDynamicChunks[];
|
||||
|
||||
@@ -113,6 +113,8 @@ inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState
|
||||
inline Bool MGPipeVerify(const StencilFaceState& a, const StencilFaceState& b, const char** outField);
|
||||
inline Bool MGPipeVerify(const DynamicBackendParameters& a, const DynamicBackendParameters& b, const char** outField);
|
||||
inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char** outField);
|
||||
inline Bool MGPipeVerify(const MGPVertexAttribWire& a, const MGPVertexAttribWire& b, const char** outField);
|
||||
inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBindingPointWire& b, const char** outField);
|
||||
|
||||
template <>
|
||||
struct MGPipeHasFieldVerifier<MGPBlobRef> : std::true_type {};
|
||||
@@ -252,6 +254,10 @@ template <>
|
||||
struct MGPipeHasFieldVerifier<DynamicBackendParameters> : std::true_type {};
|
||||
template <>
|
||||
struct MGPipeHasFieldVerifier<MGHostSpan> : std::true_type {};
|
||||
template <>
|
||||
struct MGPipeHasFieldVerifier<MGPVertexAttribWire> : std::true_type {};
|
||||
template <>
|
||||
struct MGPipeHasFieldVerifier<MGPVertexBindingPointWire> : std::true_type {};
|
||||
|
||||
template <class T>
|
||||
inline Bool MGPipeFieldEqual(const T& a, const T& b) {
|
||||
@@ -643,6 +649,16 @@ inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char**
|
||||
return true;
|
||||
}
|
||||
|
||||
inline Bool MGPipeVerify(const MGPVertexAttribWire& a, const MGPVertexAttribWire& b, const char** outField) {
|
||||
MGP_FIELDS_MGPVertexAttribWire(MGP_VERIFY_FIELD)
|
||||
return true;
|
||||
}
|
||||
|
||||
inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBindingPointWire& b, const char** outField) {
|
||||
MGP_FIELDS_MGPVertexBindingPointWire(MGP_VERIFY_FIELD)
|
||||
return true;
|
||||
}
|
||||
|
||||
#undef MGP_VERIFY_FIELD
|
||||
|
||||
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 69;
|
||||
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 71;
|
||||
|
||||
@@ -929,3 +929,11 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size,
|
||||
}
|
||||
|
||||
#undef MGP_WIRE_CHECK_BOUNDS
|
||||
|
||||
// The ResidualValueBlock layout, from PipeFields.def's
|
||||
// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering
|
||||
// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point.
|
||||
static_assert(offsetof(ResidualValueBlock, CapabilityBits) == 0,
|
||||
"the residual block's first member must sit at offset 0");
|
||||
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,
|
||||
"the residual ratchet only ever goes down");
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <Config.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
namespace {
|
||||
@@ -33,9 +34,31 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
BufferObject::BufferObject(Uint externalIndex)
|
||||
: m_externalIndex(externalIndex), m_size(0), m_usage(BufferUsage::StaticDraw), m_isMapped(false),
|
||||
m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} {}
|
||||
m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a D-A2: a resource EXISTS before anything can name it, so resource_create is
|
||||
// emitted from the constructor and carries no storage - the store is defined lazily
|
||||
// by the first respecify and every backend already tolerates a resource with none.
|
||||
// The handle itself is minted whatever the subsystem bitmask says, because
|
||||
// set_vertex_buffers names this buffer by handle out of a different subsystem.
|
||||
MG_Pipe::MGPipeMintResourceHandle(*this);
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) MG_Pipe::MGPipeEmitResourceCreate(*this);
|
||||
#endif
|
||||
}
|
||||
|
||||
BufferObject::~BufferObject() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P3a D-L: the buffer's death crosses as resource_destroy, which is the catalogue
|
||||
// call for it - no seventh NotifyStateObjectDestroyed raiser is added, because that
|
||||
// header exists for kinds that have no such call. The emit-then-free ORDER is fixed
|
||||
// inside the helper and is not negotiable.
|
||||
// The answer is the helper's LATCH - "was resource_create emitted for this buffer" -
|
||||
// not a second reading of MGPipeResourceSubsystemEnabled(): a buffer constructed
|
||||
// while a backend's table was registered and destroyed after it was unregistered has
|
||||
// a pipe record to drop and no legacy backend object, and one constructed the other
|
||||
// way round has the opposite, so the create's answer is the only one that pairs.
|
||||
if (MG_Pipe::MGPipeEmitResourceDestroyAndFree(*this)) return;
|
||||
#endif
|
||||
if (m_resource.Backend() && g_bufferBackendOps && g_bufferBackendOps->OnDestroy) {
|
||||
g_bufferBackendOps->OnDestroy(m_resource.ReleaseBackend());
|
||||
}
|
||||
@@ -43,6 +66,13 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
void BufferObject::NotifyRespecify() {
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
MG_Pipe::MGPipeEmitResourceRespecify(*this);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (g_bufferBackendOps && g_bufferBackendOps->Respecify) {
|
||||
g_bufferBackendOps->Respecify(*this);
|
||||
}
|
||||
@@ -50,8 +80,15 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
void BufferObject::NotifySubData(SizeT offset, SizeT size) {
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
if (size == 0) return;
|
||||
m_hasDefinedContent = true;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
MG_Pipe::MGPipeEmitResourceSubData(*this, offset, size);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (g_bufferBackendOps && g_bufferBackendOps->SubData) {
|
||||
g_bufferBackendOps->SubData(*this, offset, size);
|
||||
}
|
||||
@@ -59,8 +96,19 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
void BufferObject::NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess) {
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
if (range.start >= range.end) return;
|
||||
m_hasDefinedContent = true;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
static_assert(sizeof(appAccess.GetRaw()) <= sizeof(Uint32),
|
||||
"MGPFlushRange::AccessFlags is a Uint32 and carries the application's "
|
||||
"real Flags<BufferMappingAccessBit>, unnormalised");
|
||||
MG_Pipe::MGPipeEmitResourceFlushRange(*this, range.start, range.end - range.start,
|
||||
static_cast<Uint32>(appAccess.GetRaw()));
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (g_bufferBackendOps && g_bufferBackendOps->FlushMappedRange) {
|
||||
g_bufferBackendOps->FlushMappedRange(*this, range, appAccess);
|
||||
}
|
||||
@@ -73,6 +121,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
// undefined store to "has content" - that would cost the next orphaning
|
||||
// respecification a full-size upload of bytes the application never wrote.
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
return;
|
||||
}
|
||||
m_hasDefinedContent = true;
|
||||
@@ -80,6 +129,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
// The write already landed in coherent GPU memory; the backend has no separate
|
||||
// copy to sync. Only bump the serial so cached transient slices invalidate.
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
return;
|
||||
}
|
||||
NotifySubData(offset, size);
|
||||
@@ -183,6 +233,12 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (m_size < kLargeBufferAdoptBytes) return;
|
||||
if (m_resource.IsGpuResident()) return;
|
||||
if (m_isMapped) return;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
if (void* base = MG_Pipe::MGPipeEmitMapPersistent(*this)) m_resource.AdoptPersistentMap(base);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return;
|
||||
if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) {
|
||||
m_resource.AdoptPersistentMap(base);
|
||||
@@ -302,6 +358,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
data.size, m_size);
|
||||
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
}
|
||||
|
||||
void BufferObject::MarkGpuWritten() {
|
||||
@@ -314,6 +371,17 @@ namespace MobileGL::MG_State::GLState {
|
||||
// Cleared unconditionally: without a readback op the shadow can never catch up,
|
||||
// and retrying on every subsequent read would only repeat the same no-op.
|
||||
m_gpuWritePending = false;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
// The answer comes back through the reverse channel's OnBufferWriteback, which
|
||||
// resolves this handle to this object and writes the shadow before the server
|
||||
// bumps its mutation epoch. In monolith the whole sequence is synchronous inside
|
||||
// the applier, so the caller sees the reconciled shadow on return exactly as it
|
||||
// does today.
|
||||
MG_Pipe::MGPipeEmitResourceReadback(*this);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->ReadbackFromGpu == nullptr) {
|
||||
return;
|
||||
}
|
||||
@@ -362,10 +430,35 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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 MOBILEGL_PIPE_PUSH
|
||||
// buffer_subdata_resident stays NULLABLE and stays asymmetric: one backend
|
||||
// deliberately does not implement it, and the frontend checks the pipe table exactly
|
||||
// as it checks the op table it replaces, so a backend without it keeps the legacy
|
||||
// ordered in-place host write below.
|
||||
if (bytes.size > 0 && MG_Pipe::MGPipeResourceSubsystemEnabled() &&
|
||||
MG_Pipe::MGPipeResourceOpsHaveSubDataResident()) {
|
||||
MG_Pipe::MGPipeEmitBufferSubDataResident(*this, offset, bytes.data, bytes.size);
|
||||
m_hasDefinedContent = true;
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
m_gpuWritePending = true;
|
||||
return;
|
||||
}
|
||||
// No resident op: the write lands in place below, after retiring the GPU writes this
|
||||
// store is known to be waiting on - which is the same answer, and the same code, a
|
||||
// backend with a null ResidentSubData gets today.
|
||||
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
SyncGpuWrites();
|
||||
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
|
||||
NotifyContentWrite(offset, bytes.size);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
|
||||
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
|
||||
m_hasDefinedContent = true;
|
||||
++m_changeSerial;
|
||||
MGP_NOTE_AGGREGATE(BufferChange);
|
||||
m_gpuWritePending = true;
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +487,16 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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) {
|
||||
if (m_resource.IsGpuResident() &&
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The same question, asked of whichever table owns the family in this build.
|
||||
(MG_Pipe::MGPipeResourceSubsystemEnabled()
|
||||
? MG_Pipe::MGPipeResourceOpsHaveSubDataResident()
|
||||
: (g_bufferBackendOps && g_bufferBackendOps->ResidentSubData))
|
||||
#else
|
||||
g_bufferBackendOps && g_bufferBackendOps->ResidentSubData
|
||||
#endif
|
||||
) {
|
||||
Vector<Uint8> expanded(size);
|
||||
if (pattern.size == 1) {
|
||||
Memset(expanded.data(), *static_cast<const Uint8*>(pattern.data), size);
|
||||
@@ -496,6 +598,14 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (m_isMapped) {
|
||||
return false;
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this);
|
||||
if (pushedBase == nullptr) return false;
|
||||
m_resource.AdoptPersistentMap(pushedBase);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
|
||||
return false;
|
||||
}
|
||||
@@ -540,6 +650,16 @@ namespace MobileGL::MG_State::GLState {
|
||||
// returning; AdoptPersistentMap then releases the shadow. Falls back to the
|
||||
// shadow when the backend declines (returns null). Only attempted once - the
|
||||
// storage is immutable and outlives unmap/remap.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) &&
|
||||
!(access & BufferMappingAccessBit::FlushExplicit) &&
|
||||
MG_Pipe::MGPipeResourceSubsystemEnabled()) {
|
||||
if (void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this)) {
|
||||
m_resource.AdoptPersistentMap(pushedBase);
|
||||
}
|
||||
return m_resource.Bytes() + range.start;
|
||||
}
|
||||
#endif
|
||||
if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) &&
|
||||
!(access & BufferMappingAccessBit::FlushExplicit) && g_bufferBackendOps &&
|
||||
g_bufferBackendOps->AcquirePersistentMap) {
|
||||
|
||||
@@ -64,7 +64,19 @@ namespace MobileGL::MG_State::GLState {
|
||||
Bool ValidateName(Uint index) const;
|
||||
Bool ValidateBufferObject(Uint index) const;
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 brief D4: "did the contents of ANY buffer object move". One counter for every
|
||||
// BufferObject ++m_changeSerial site, which is what NEW_VERTEX_BUFFERS /
|
||||
// NEW_INDEX_BUFFER / NEW_CONST_BUFFERS / NEW_SHADER_BUFFERS / NEW_SO_TARGETS all
|
||||
// shutter on in P2 - five bits over one aggregate until P3b splits them.
|
||||
void NoteBufferChanged() { ++m_anyBufferChangeGeneration; }
|
||||
Uint64 GetAnyBufferChangeGeneration() const { return m_anyBufferChangeGeneration; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Uint64 m_anyBufferChangeGeneration = 0;
|
||||
#endif
|
||||
UnorderedMap<Uint, SharedPtr<BufferObject>> m_bufferObjects;
|
||||
IndexGenerator<Uint> m_indexGenerator;
|
||||
Array<BindingSlot<BufferObject>, GlobalBufferTargets.size()> m_bindingSlots;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <Config.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL::MG_State {
|
||||
void Init() {
|
||||
@@ -213,6 +214,12 @@ namespace MobileGL::MG_State {
|
||||
current.intValue[component] = static_cast<Int32>(value[component]);
|
||||
current.uintValue[component] = static_cast<Uint32>(value[component]);
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// The two views above are CONVERSIONS, not bit copies, so which one was written
|
||||
// is part of the value; set_vertex_attrib_defaults carries it.
|
||||
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassFloat;
|
||||
#endif
|
||||
MGP_NOTE_AGGREGATE(VertexAttribDefault);
|
||||
}
|
||||
|
||||
void GLContext::SetCurrentVertexAttributeInt(Uint index, const Array<Int32, 4>& value) {
|
||||
@@ -227,6 +234,10 @@ namespace MobileGL::MG_State {
|
||||
current.floatValue[component] = static_cast<Float>(value[component]);
|
||||
current.uintValue[component] = static_cast<Uint32>(value[component]);
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassInt;
|
||||
#endif
|
||||
MGP_NOTE_AGGREGATE(VertexAttribDefault);
|
||||
}
|
||||
|
||||
void GLContext::SetCurrentVertexAttributeUint(Uint index, const Array<Uint32, 4>& value) {
|
||||
@@ -241,6 +252,10 @@ namespace MobileGL::MG_State {
|
||||
current.floatValue[component] = static_cast<Float>(value[component]);
|
||||
current.intValue[component] = static_cast<Int32>(value[component]);
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassUint;
|
||||
#endif
|
||||
MGP_NOTE_AGGREGATE(VertexAttribDefault);
|
||||
}
|
||||
|
||||
const CurrentVertexAttributeValue& GLContext::GetCurrentVertexAttribute(Uint index) const {
|
||||
|
||||
@@ -30,10 +30,26 @@ namespace MobileGL {
|
||||
void Init();
|
||||
|
||||
namespace GLState {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// MGPAttribValue::ValueClass' encoding (MG_Pipe/MGPipeTypes.h documents the order
|
||||
// "Float | Int | Uint | Double"). It lives here rather than in MG_Pipe because the
|
||||
// FRONTEND is the only thing that knows which of the three views below a value was
|
||||
// written through - the other two are numeric conversions of it - and MG_Pipe has
|
||||
// no enum for the field yet. If package A introduces one, this becomes its alias.
|
||||
inline constexpr Uint32 kVertexAttribValueClassFloat = 0;
|
||||
inline constexpr Uint32 kVertexAttribValueClassInt = 1;
|
||||
inline constexpr Uint32 kVertexAttribValueClassUint = 2;
|
||||
#endif
|
||||
|
||||
struct CurrentVertexAttributeValue {
|
||||
Array<Float, 4> floatValue{0.f, 0.f, 0.f, 1.f};
|
||||
Array<Int32, 4> intValue{0, 0, 0, 1};
|
||||
Array<Uint32, 4> uintValue{0u, 0u, 0u, 1u};
|
||||
// Three scalar arrays and NOTHING ELSE. MG_Backend/MGPipe/PipeInputs.cpp
|
||||
// compares this storage with one memcmp and asserts that size, so a fourth
|
||||
// member here is a build break in a file P2 package B does not own. The
|
||||
// written-class discriminator set_vertex_attrib_defaults needs therefore
|
||||
// lives beside the array on GLContext, not inside the value.
|
||||
};
|
||||
|
||||
// Which of the three views above a shader input of a given GLSL type consumes.
|
||||
@@ -198,6 +214,64 @@ namespace MobileGL {
|
||||
Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; }
|
||||
const SharedPtr<ProgramPipelineObject>& GetBoundProgramPipeline() const;
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// ---- the aggregate generations (P2 brief D4) ----
|
||||
//
|
||||
// The bump points sit on OBJECTS - a VertexArrayObject, a TextureObject, a
|
||||
// BufferObject - which have no back-pointer to the state container that owns
|
||||
// them, so MGP_NOTE_AGGREGATE goes through MGPipeNoteAggregate, which finds
|
||||
// the live context and lands here. This facade is the whole reason the
|
||||
// objects need no back-pointer, and it is push-only so the pull build's
|
||||
// GLContext is byte-identical (G1).
|
||||
void NoteVaoAttributeChanged() { m_vertexArrayState.NoteAttributeChanged(); }
|
||||
Uint64 GetAnyVaoAttributeGeneration() const {
|
||||
return m_vertexArrayState.GetAnyAttributeGeneration();
|
||||
}
|
||||
void NoteFramebufferAttachmentChanged() { m_framebufferState.NoteAttachmentChanged(); }
|
||||
Uint64 GetAnyFramebufferAttachmentGeneration() const {
|
||||
return m_framebufferState.GetAnyAttachmentGeneration();
|
||||
}
|
||||
void NoteTextureContentChanged() { m_textureState.NoteTextureContentChanged(); }
|
||||
Uint64 GetAnyTextureContentGeneration() const {
|
||||
return m_textureState.GetAnyTextureContentGeneration();
|
||||
}
|
||||
void NoteTextureParamsChanged() { m_textureState.NoteTextureParamsChanged(); }
|
||||
Uint64 GetAnyTextureParamsGeneration() const {
|
||||
return m_textureState.GetAnyTextureParamsGeneration();
|
||||
}
|
||||
void NoteBufferChanged() { m_bufferState.NoteBufferChanged(); }
|
||||
Uint64 GetAnyBufferChangeGeneration() const {
|
||||
return m_bufferState.GetAnyBufferChangeGeneration();
|
||||
}
|
||||
// The sixth aggregate lives here rather than on a state container because
|
||||
// the values it guards do too (m_currentVertexAttributes).
|
||||
void NoteVertexAttribDefaultChanged() { ++m_anyVertexAttribDefaultGeneration; }
|
||||
Uint64 GetAnyVertexAttribDefaultGeneration() const {
|
||||
return m_anyVertexAttribDefaultGeneration;
|
||||
}
|
||||
|
||||
// Which of the three views of m_currentVertexAttributes[index] the last
|
||||
// glVertexAttrib* write filled DIRECTLY. The other two are NUMERIC
|
||||
// conversions of it (SetCurrentVertexAttribute* below), not bit copies, so
|
||||
// four words on a wire are not the value unless the class travels with them:
|
||||
// glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in
|
||||
// floatValue. set_vertex_attrib_defaults carries this as MGPAttribValue's
|
||||
// ValueClass so the applier can redo the conversion instead of memcpying one
|
||||
// view into all three.
|
||||
//
|
||||
// It is kept BESIDE the array rather than inside CurrentVertexAttributeValue
|
||||
// because that struct is mirrored into PipeInputs and compared there by a
|
||||
// memcmp whose size assertion (MG_Backend/MGPipe/PipeInputs.cpp) is a file
|
||||
// this package does not own - and because it need not be mirrored: the class
|
||||
// only decides how to REBUILD the three views, so two writes that leave the
|
||||
// three views identical rebuild identically whichever class they carried.
|
||||
Uint32 GetCurrentVertexAttributeClass(Uint index) const {
|
||||
return index < m_currentVertexAttributeClasses.size()
|
||||
? m_currentVertexAttributeClasses[index]
|
||||
: kVertexAttribValueClassFloat;
|
||||
}
|
||||
#endif
|
||||
|
||||
// RenderState
|
||||
Uint GetRenderStateParametersVersion() const;
|
||||
// Only the pipeline-relevant subset - see RenderState::m_pipelineStateVersion.
|
||||
@@ -515,6 +589,11 @@ namespace MobileGL {
|
||||
Bool m_transformFeedbackPaused = false;
|
||||
GLenum m_transformFeedbackPrimitiveMode = GL_POINTS;
|
||||
SharedPtr<ProgramObject> m_transformFeedbackProgram;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Uint64 m_anyVertexAttribDefaultGeneration = 0;
|
||||
// Parallel to m_currentVertexAttributes; see GetCurrentVertexAttributeClass.
|
||||
Array<Uint32, VertexArrayObject::MAX_VERTEX_ATTRIBS> m_currentVertexAttributeClasses{};
|
||||
#endif
|
||||
Uint64 m_transformFeedbackGeneration = 0;
|
||||
// Source of the per-span ids above; never rolls back with an object switch.
|
||||
Uint64 m_transformFeedbackNextGeneration = 0;
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "FramebufferObject.h"
|
||||
#include "MG_State/GLState/StateObjectDeathNotice.h"
|
||||
#include "MG_Util/Types.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
|
||||
@@ -21,6 +23,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
return s_nextFramebufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
FramebufferObject::~FramebufferObject() {
|
||||
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
|
||||
// garbage sweep. This is the last SharedPtr to this object dropping - not the
|
||||
// glDelete* that only marks the name and leaves a still-bound object very much
|
||||
// alive - so it is the exact moment the backend's twin, and the driver storage
|
||||
// that twin owns, stop being reachable. The notice carries the lifetime id
|
||||
// because the object no longer exists to be passed, and because the lifetime id
|
||||
// is what the client slot allocator resolves the handle from. No-op unless a
|
||||
// backend registered the ops (a pull build declares none at all).
|
||||
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Framebuffer, m_lifetimeId);
|
||||
}
|
||||
#endif
|
||||
|
||||
// FramebufferAttachmentObject
|
||||
FramebufferAttachmentObject::FramebufferAttachmentObject(
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget textureUploadTarget, Int level,
|
||||
@@ -190,6 +206,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (m_readBuffer == buf) return;
|
||||
m_readBuffer = buf;
|
||||
++m_objectVersion;
|
||||
MGP_NOTE_AGGREGATE(FramebufferAttachment);
|
||||
}
|
||||
|
||||
Uint FramebufferObject::GetExternalIndex() const {
|
||||
@@ -201,6 +218,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (member == value) return; \
|
||||
member = value; \
|
||||
++m_objectVersion; \
|
||||
MGP_NOTE_AGGREGATE(FramebufferAttachment); \
|
||||
}
|
||||
|
||||
MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER(DefaultWidth, m_defaultWidth, Int)
|
||||
@@ -213,5 +231,6 @@ namespace MobileGL::MG_State::GLState {
|
||||
void FramebufferObject::BumpAttachmentVersion(FramebufferAttachmentType type) {
|
||||
++m_attachmentVersions[static_cast<SizeT>(type)];
|
||||
++m_objectVersion;
|
||||
MGP_NOTE_AGGREGATE(FramebufferAttachment);
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
|
||||
@@ -115,6 +115,12 @@ namespace MobileGL {
|
||||
Array<Uint16, static_cast<SizeT>(FramebufferAttachmentType::FramebufferAttachmentTypeCount)>;
|
||||
|
||||
FramebufferObject(Uint externalIndex);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2. Out of line, and declared only where there is a notice to raise:
|
||||
// in a pull build this class keeps its implicit destructor, which is what keeps
|
||||
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
|
||||
~FramebufferObject();
|
||||
#endif
|
||||
|
||||
void AttachTexture(FramebufferAttachmentType type, const SharedPtr<ITextureObject>& texture,
|
||||
TextureUploadTarget textureUploadTarget = TextureUploadTarget::Unknown, int level = 0,
|
||||
|
||||
@@ -25,7 +25,19 @@ namespace MobileGL::MG_State::GLState {
|
||||
Bool ValidateName(Uint index) const;
|
||||
Bool ValidateFramebufferObject(Uint index) const;
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 brief D4: "did the attachment set or the default geometry of ANY framebuffer
|
||||
// move". It does NOT cover a BIND - a bind writes a BindingSlot, not the object - so
|
||||
// MGPipeTracker pairs this counter with the bound draw framebuffer identity, which
|
||||
// is one extra load and keeps the bump points on the object where they belong.
|
||||
void NoteAttachmentChanged() { ++m_anyAttachmentGeneration; }
|
||||
Uint64 GetAnyAttachmentGeneration() const { return m_anyAttachmentGeneration; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Uint64 m_anyAttachmentGeneration = 0;
|
||||
#endif
|
||||
UnorderedMap<Uint, SharedPtr<FramebufferObject>> m_framebufferObjects;
|
||||
IndexGenerator<Uint> m_indexGenerator;
|
||||
Array<BindingSlot<FramebufferObject>, static_cast<SizeT>(FramebufferTarget::FramebufferTargetCount)>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_State/GLState/StateObjectDeathNotice.h>
|
||||
|
||||
const char* kDefaultFragmentShaderSource = R"(#version 460 core
|
||||
layout(location = 0) out vec4 FragColor;
|
||||
@@ -28,7 +29,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
return s_nextProgramLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
ProgramObject::~ProgramObject() { CancelLink(); }
|
||||
ProgramObject::~ProgramObject() {
|
||||
CancelLink();
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
|
||||
// garbage sweep. This is the last SharedPtr to this object dropping - not
|
||||
// glDeleteProgram, which only marks the name and leaves a still-bound object very much
|
||||
// alive - so it is the exact moment the backend's twin, and the driver storage that
|
||||
// twin owns, stop being reachable. The notice carries the lifetime id because the
|
||||
// object no longer exists to be passed, and because the lifetime id is what the client
|
||||
// slot allocator resolves the handle from. No-op unless a backend registered the ops
|
||||
// (a pull build declares none at all).
|
||||
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::ShaderCso, m_lifetimeId);
|
||||
#endif
|
||||
}
|
||||
|
||||
// EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for
|
||||
// why: ~1200 call sites, no LTO). Only its blocking half lives here.
|
||||
|
||||
@@ -317,9 +317,11 @@ namespace MobileGL {
|
||||
SET_CAPABILITY(ColorLogicOp, enabled);
|
||||
SET_CAPABILITY(DebugOutput, enabled);
|
||||
SET_CAPABILITY(DebugOutputSynchronous, enabled);
|
||||
SET_CAPABILITY(DepthClamp, enabled);
|
||||
SET_CAPABILITY(DepthTest, enabled);
|
||||
SET_CAPABILITY(CullFace, enabled);
|
||||
SET_CAPABILITY(Dither, enabled);
|
||||
SET_CAPABILITY(FramebufferSrgb, enabled);
|
||||
SET_CAPABILITY(LineSmooth, enabled);
|
||||
SET_CAPABILITY(Multisample, enabled);
|
||||
SET_CAPABILITY(PolygonOffsetFill, enabled);
|
||||
@@ -335,6 +337,7 @@ namespace MobileGL {
|
||||
SET_CAPABILITY(SampleMask, enabled);
|
||||
SET_CAPABILITY(SampleShading, enabled);
|
||||
SET_CAPABILITY(StencilTest, enabled);
|
||||
SET_CAPABILITY(TextureCubeMapSeamless, enabled);
|
||||
SET_CAPABILITY(ProgramPointSize, enabled);
|
||||
case CapabilityInput::Blend: {
|
||||
Bool stateChanged = false;
|
||||
@@ -378,7 +381,9 @@ namespace MobileGL {
|
||||
++m_version;
|
||||
break;
|
||||
}
|
||||
default: // not supported currently
|
||||
// Every CapabilityInput now has storage; the arm is a backstop for a value
|
||||
// outside the enum, not a silent swallow of a real glEnable.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
#undef SET_CAPABILITY
|
||||
@@ -392,9 +397,11 @@ namespace MobileGL {
|
||||
RETURN_CAPABILITY(ColorLogicOp);
|
||||
RETURN_CAPABILITY(DebugOutput);
|
||||
RETURN_CAPABILITY(DebugOutputSynchronous);
|
||||
RETURN_CAPABILITY(DepthClamp);
|
||||
RETURN_CAPABILITY(DepthTest);
|
||||
RETURN_CAPABILITY(CullFace);
|
||||
RETURN_CAPABILITY(Dither);
|
||||
RETURN_CAPABILITY(FramebufferSrgb);
|
||||
RETURN_CAPABILITY(LineSmooth);
|
||||
RETURN_CAPABILITY(Multisample);
|
||||
RETURN_CAPABILITY(PolygonOffsetFill);
|
||||
@@ -410,6 +417,7 @@ namespace MobileGL {
|
||||
RETURN_CAPABILITY(SampleMask);
|
||||
RETURN_CAPABILITY(SampleShading);
|
||||
RETURN_CAPABILITY(StencilTest);
|
||||
RETURN_CAPABILITY(TextureCubeMapSeamless);
|
||||
RETURN_CAPABILITY(ProgramPointSize);
|
||||
case CapabilityInput::Blend:
|
||||
return m_parameters.BlendStates[0].Enabled;
|
||||
|
||||
@@ -169,11 +169,16 @@ namespace MobileGL {
|
||||
// not evict a cached pipeline. Keeping one counter for both made a glViewport call
|
||||
// knock the next draw off the pipeline memo AND the draw fast path.
|
||||
Uint16 m_pipelineStateVersion = 0;
|
||||
RenderStateParameters m_parameters;
|
||||
// Value-initialised, PADDING INCLUDED. The MGPipe CSO key is a byte-range
|
||||
// hash over RenderStateParameters and the residual block's trip wire is a
|
||||
// byte-level compare, so indeterminate padding would make a CSO handle
|
||||
// reproducible only within one context and would make the trip wire
|
||||
// meaningless. Costs one .text resize of this constructor.
|
||||
RenderStateParameters m_parameters{};
|
||||
|
||||
// Pixel Store
|
||||
PixelStoreParameters m_pixelStorePackParameters;
|
||||
PixelStoreParameters m_pixelStoreUnpackParameters;
|
||||
PixelStoreParameters m_pixelStorePackParameters{};
|
||||
PixelStoreParameters m_pixelStoreUnpackParameters{};
|
||||
};
|
||||
} // namespace GLState
|
||||
} // namespace MG_State
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "RenderbufferObject.h"
|
||||
#include <MG_Util/Metrics/TextureMetrics.h>
|
||||
#include <MG_State/GLState/StateObjectDeathNotice.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
@@ -26,6 +27,20 @@ namespace MobileGL {
|
||||
|
||||
RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
RenderbufferObject::~RenderbufferObject() {
|
||||
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
|
||||
// garbage sweep. This is the last SharedPtr to this object dropping - not
|
||||
// glDeleteRenderbuffers, which only marks the name and leaves a still-bound object very much
|
||||
// alive - so it is the exact moment the backend's twin, and the driver storage that
|
||||
// twin owns, stop being reachable. The notice carries the lifetime id because the
|
||||
// object no longer exists to be passed, and because the lifetime id is what the client
|
||||
// slot allocator resolves the handle from. No-op unless a backend registered the ops
|
||||
// (a pull build declares none at all).
|
||||
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Renderbuffer, m_lifetimeId);
|
||||
}
|
||||
#endif
|
||||
|
||||
Uint RenderbufferObject::GetExternalIndex() const {
|
||||
return m_externalIndex;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ namespace MobileGL {
|
||||
using TargetEnum = RenderbufferTarget;
|
||||
|
||||
RenderbufferObject(Uint externalIndex);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2. Out of line, and declared only where there is a notice to raise:
|
||||
// in a pull build this class stays trivially destructible, which is what keeps
|
||||
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
|
||||
~RenderbufferObject();
|
||||
#endif
|
||||
|
||||
Uint GetExternalIndex() const;
|
||||
void SetInternalFormat(TextureInternalFormat format);
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
#include "SamplerObject.h"
|
||||
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_State/GLState/StateObjectDeathNotice.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
@@ -24,6 +26,20 @@ namespace MobileGL {
|
||||
SamplerObject::SamplerObject(Uint externalIndex)
|
||||
: m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
SamplerObject::~SamplerObject() {
|
||||
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
|
||||
// garbage sweep. This is the last SharedPtr to this object dropping - not the
|
||||
// glDelete* that only marks the name and leaves a still-bound object very much
|
||||
// alive - so it is the exact moment the backend's twin, and the driver storage
|
||||
// that twin owns, stop being reachable. The notice carries the lifetime id
|
||||
// because the object no longer exists to be passed, and because the lifetime id
|
||||
// is what the client slot allocator resolves the handle from. No-op unless a
|
||||
// backend registered the ops (a pull build declares none at all).
|
||||
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::SamplerCso, m_lifetimeId);
|
||||
}
|
||||
#endif
|
||||
|
||||
void SamplerObject::BumpVersion() {
|
||||
++m_version;
|
||||
// Every setter early-outs on an unchanged value, so this only runs on a real
|
||||
@@ -32,6 +48,9 @@ namespace MobileGL {
|
||||
// bindings must never miss an invalidation, and over-invalidating on a wrap-mode
|
||||
// write costs one re-resolve.
|
||||
if (pGLContext) pGLContext->BumpSamplingResolutionGeneration();
|
||||
// Every sampler parameter is a texture PARAMETER as far as the dirty walk is
|
||||
// concerned, and BumpVersion is the one choke point every setter reaches.
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
void SamplerObject::SetWrapS(SamplerWrapMode mode) {
|
||||
|
||||
@@ -16,6 +16,12 @@ namespace MobileGL {
|
||||
class SamplerObject {
|
||||
public:
|
||||
SamplerObject(Uint externalIndex);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2. Out of line, and declared only where there is a notice to raise:
|
||||
// in a pull build this class keeps its implicit destructor, which is what keeps
|
||||
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
|
||||
~SamplerObject();
|
||||
#endif
|
||||
|
||||
void SetWrapS(SamplerWrapMode mode);
|
||||
void SetWrapT(SamplerWrapMode mode);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/StateObjectDeathNotice.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 <Includes.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
|
||||
// P2 step e2, the frontend half: TELL the backend that a state object died, instead of
|
||||
// leaving it to discover the death in a garbage sweep.
|
||||
//
|
||||
// Today the only kind that announces its own death is Buffer, through BufferBackendOps
|
||||
// (BufferState/BufferObject.h) - a frontend-declared ops table that the backend fills in at
|
||||
// context bring-up. This is the same shape for the other six kinds, with two differences that
|
||||
// follow from what the notice is for:
|
||||
//
|
||||
// * it carries {kind, lifetimeId} and NOT the object, because by the time the last
|
||||
// SharedPtr has dropped there is no object left to pass, and the lifetime id is exactly
|
||||
// the key the client slot allocator resolves a handle from (ARCHITECTURE.md 4.2);
|
||||
// * it is one entry point for every kind rather than one ops table per kind, because the
|
||||
// backend's answer is the same for all six: free the slot, drop the twin.
|
||||
//
|
||||
// It exists only under MOBILEGL_PIPE_PUSH. A pull build has no slot allocator, no handle and
|
||||
// nothing that could consume the notice, and G1 requires its symbol set to be byte-for-byte
|
||||
// the pre-P2 one - so in that build this header declares nothing at all and the call sites
|
||||
// compile to nothing.
|
||||
//
|
||||
// The pointer is written once, at backend bring-up, and read from state-object destructors.
|
||||
// It is deliberately a plain pointer and not an atomic: the destructors and the bring-up run
|
||||
// on the context thread, exactly as BufferBackendOps' g_bufferBackendOps does.
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
|
||||
struct StateObjectDeathOps {
|
||||
// The last SharedPtr to the frontend object with this lifetime id has dropped.
|
||||
// Called from the object's destructor, so the object must NOT be touched.
|
||||
void (*OnDestroyed)(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) = nullptr;
|
||||
};
|
||||
|
||||
inline const StateObjectDeathOps* g_stateObjectDeathOps = nullptr;
|
||||
|
||||
inline void SetStateObjectDeathOps(const StateObjectDeathOps* ops) {
|
||||
g_stateObjectDeathOps = ops;
|
||||
}
|
||||
|
||||
inline const StateObjectDeathOps* GetStateObjectDeathOps() {
|
||||
return g_stateObjectDeathOps;
|
||||
}
|
||||
|
||||
inline void NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
|
||||
const StateObjectDeathOps* ops = g_stateObjectDeathOps;
|
||||
if (ops == nullptr || ops->OnDestroyed == nullptr) return;
|
||||
ops->OnDestroyed(kind, lifetimeId);
|
||||
}
|
||||
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
#include "TextureObject.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/StateObjectDeathNotice.h"
|
||||
#include "MG_Util/Types.h"
|
||||
#include <MG_Util/Metrics/TextureMetrics.h>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
@@ -25,6 +27,20 @@ namespace MobileGL {
|
||||
return s_nextTextureLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
TextureObjectBase::~TextureObjectBase() {
|
||||
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
|
||||
// garbage sweep. This is the last SharedPtr to this object dropping - not the
|
||||
// glDelete* that only marks the name and leaves a still-bound object very much
|
||||
// alive - so it is the exact moment the backend's twin, and the driver storage
|
||||
// that twin owns, stop being reachable. The notice carries the lifetime id
|
||||
// because the object no longer exists to be passed, and because the lifetime id
|
||||
// is what the client slot allocator resolves the handle from. No-op unless a
|
||||
// backend registered the ops (a pull build declares none at all).
|
||||
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Texture, m_lifetimeId);
|
||||
}
|
||||
#endif
|
||||
|
||||
void TextureObjectBase::BumpShapeVersion() {
|
||||
++m_shapeVersion;
|
||||
// Shape is what mipmap-completeness is computed from, and completeness decides
|
||||
@@ -99,6 +115,7 @@ namespace MobileGL {
|
||||
m_internalFormat = format;
|
||||
BumpShapeVersion();
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
Uint TextureObjectBase::GetExternalIndex() const {
|
||||
@@ -124,6 +141,7 @@ namespace MobileGL {
|
||||
|
||||
m_sampler->SetBorderColor(color);
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
const IntVec4& TextureObjectBase::GetBorderColorI() const {
|
||||
@@ -138,6 +156,7 @@ namespace MobileGL {
|
||||
|
||||
m_sampler->SetBorderColorI(color);
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
const UintVec4& TextureObjectBase::GetBorderColorUI() const {
|
||||
@@ -152,6 +171,7 @@ namespace MobileGL {
|
||||
|
||||
m_sampler->SetBorderColorUI(color);
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
BorderColorForm TextureObjectBase::GetBorderColorForm() const {
|
||||
@@ -201,6 +221,7 @@ namespace MobileGL {
|
||||
break;
|
||||
}
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
void TextureObjectBase::SetSwizzleParamRGBA(const Vec4<TextureSwizzleParam>& values) {
|
||||
@@ -208,6 +229,7 @@ namespace MobileGL {
|
||||
|
||||
m_swizzleParams = values;
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
const UintVec2& TextureObjectBase::GetLevelRange() const {
|
||||
@@ -225,6 +247,7 @@ namespace MobileGL {
|
||||
m_levelRange.y() = m_levelRange.x();
|
||||
}
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
BumpShapeVersion();
|
||||
}
|
||||
|
||||
@@ -236,6 +259,7 @@ namespace MobileGL {
|
||||
|
||||
m_levelRange.y() = maxLevel;
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
BumpShapeVersion();
|
||||
}
|
||||
|
||||
@@ -256,6 +280,7 @@ namespace MobileGL {
|
||||
m_levelRange.y() = std::min(std::max(m_levelRange.y(), m_levelRange.x()), m_immutableLevels - 1);
|
||||
}
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
Uint16 TextureObjectBase::GetTextureParamsVersion() const {
|
||||
@@ -283,6 +308,7 @@ namespace MobileGL {
|
||||
|
||||
void TextureObjectBase::BumpContentVersion() {
|
||||
++m_contentVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureContent);
|
||||
}
|
||||
|
||||
Int TextureObjectBase::GetSamples() const {
|
||||
@@ -292,6 +318,7 @@ namespace MobileGL {
|
||||
void TextureObjectBase::SetSamples(Int samples) {
|
||||
m_samples = samples;
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
Bool TextureObjectBase::HasFixedSampleLocations() const {
|
||||
@@ -301,6 +328,7 @@ namespace MobileGL {
|
||||
void TextureObjectBase::SetFixedSampleLocations(Bool fixedSampleLocations) {
|
||||
m_fixedSampleLocations = fixedSampleLocations;
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
Uint64 TextureObjectBase::GetLifetimeId() const {
|
||||
@@ -352,6 +380,7 @@ namespace MobileGL {
|
||||
Bool dirty) {
|
||||
if (dirty) {
|
||||
++m_contentVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureContent);
|
||||
}
|
||||
m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty);
|
||||
}
|
||||
@@ -363,6 +392,7 @@ namespace MobileGL {
|
||||
void TextureObjectWithOneMipmap::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
IntVec3 offset, IntVec3 size) {
|
||||
++m_contentVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureContent);
|
||||
m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset,
|
||||
size);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "../SamplerState/SamplerObject.h"
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/Math/VectorTypes.h>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Texture objects are always SharedPtr-owned (TextureState creates every instance via
|
||||
@@ -116,7 +117,15 @@ namespace MobileGL::MG_State::GLState {
|
||||
class TextureObjectBase : public ITextureObject {
|
||||
public:
|
||||
TextureObjectBase(TextureTarget target, Uint externalIndex);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2. Out of line, and declared only where there is a notice to raise: in a
|
||||
// pull build this stays the implicit `= default` the pre-P2 tree had, which is what
|
||||
// keeps the pull build's symbol set byte-for-byte the pre-P2 one (G1). Declared on the
|
||||
// BASE, so every concrete texture class - 2D, 3D, cube, buffer, view - announces once.
|
||||
virtual ~TextureObjectBase();
|
||||
#else
|
||||
virtual ~TextureObjectBase() = default;
|
||||
#endif
|
||||
|
||||
TextureInternalFormat GetFormat() const override;
|
||||
TextureTarget GetTarget() const override;
|
||||
@@ -180,6 +189,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (m_depthStencilTextureMode == mode) return;
|
||||
m_depthStencilTextureMode = mode;
|
||||
++m_textureParamsVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureParams);
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "TextureObject2DCube.h"
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
@@ -49,6 +50,7 @@ namespace MobileGL {
|
||||
void TextureObject2DCube::MarkStorageDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel, bool dirty) {
|
||||
if (dirty) {
|
||||
++m_contentVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureContent);
|
||||
}
|
||||
m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty);
|
||||
}
|
||||
@@ -60,6 +62,7 @@ namespace MobileGL {
|
||||
void TextureObject2DCube::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
IntVec3 offset, IntVec3 size) {
|
||||
++m_contentVersion;
|
||||
MGP_NOTE_AGGREGATE(TextureContent);
|
||||
m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset,
|
||||
size);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,22 @@ namespace MobileGL::MG_State::GLState {
|
||||
// again (the unit tests do exactly that between cases).
|
||||
Uint64 GetContextId() const { return m_contextId; }
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 brief D4, the two texture aggregates. CONTENT is an upload or a dirty region;
|
||||
// PARAMS is a glTexParameter or a glSamplerParameter. They are separate because
|
||||
// NEW_SAMPLER_VIEWS and NEW_SAMPLERS are separate dirty bits and a Minecraft frame
|
||||
// moves them at wildly different rates.
|
||||
void NoteTextureContentChanged() { ++m_anyTextureContentGeneration; }
|
||||
Uint64 GetAnyTextureContentGeneration() const { return m_anyTextureContentGeneration; }
|
||||
void NoteTextureParamsChanged() { ++m_anyTextureParamsGeneration; }
|
||||
Uint64 GetAnyTextureParamsGeneration() const { return m_anyTextureParamsGeneration; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Uint64 m_anyTextureContentGeneration = 0;
|
||||
Uint64 m_anyTextureParamsGeneration = 0;
|
||||
#endif
|
||||
static Uint64 AllocateContextId();
|
||||
|
||||
const Uint64 m_contextId;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "VertexArrayObject.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
|
||||
@@ -37,6 +38,29 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
VertexArrayObject::~VertexArrayObject() {
|
||||
// P2 step e2 / P3a C-1: ANNOUNCE the death instead of leaving the backend to discover
|
||||
// it in a garbage sweep, and RETURN THE CLIENT'S OWN SLOT while doing it. This is the
|
||||
// last SharedPtr to this object dropping - not the glDelete* that only marks the name
|
||||
// and leaves a still-bound object very much alive - so it is the exact moment the
|
||||
// backend's twin, the driver storage that twin owns, the applier's vertex-elements
|
||||
// record and the {slot, gen} that names all three stop being reachable.
|
||||
//
|
||||
// ALL FOUR OF THOSE GO THROUGH ONE HELPER, and it is the client's rather than a
|
||||
// backend's: the handle is minted by MGPipeVertexInputEmitter on every backend, so a
|
||||
// death path that only exists inside a backend's death-ops table is no path at all
|
||||
// under a backend that installs none - which is what DirectVulkan/Magma does on
|
||||
// purpose, and what made every VAO leak a slot and a ~1.3 KB applier record for the
|
||||
// life of the process on the shipped mask. The helper emits delete_vertex_elements,
|
||||
// raises the notice (a no-op unless a backend registered the ops) and frees the slot,
|
||||
// in that fixed order; MG_Pipe/PipeMutation.h and its definition say why each position
|
||||
// is where it is. The buffer's death has exactly this shape one file over
|
||||
// (BufferObject.cpp -> MGPipeEmitResourceDestroyAndFree).
|
||||
MG_Pipe::MGPipeEmitVertexElementsDestroyAndFree(m_lifetimeId);
|
||||
}
|
||||
#endif
|
||||
|
||||
void VertexArrayObject::EnableAttribute(Uint index) {
|
||||
if (index >= MAX_VERTEX_ATTRIBS) return;
|
||||
|
||||
@@ -297,18 +321,21 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (index >= MAX_VERTEX_ATTRIBS) return;
|
||||
++m_attributeVersions[index].FormatVersion;
|
||||
++m_configVersion;
|
||||
MGP_NOTE_AGGREGATE(VaoAttribute);
|
||||
}
|
||||
|
||||
void VertexArrayObject::BumpAttributeBufferVersion(Uint index) {
|
||||
if (index >= MAX_VERTEX_ATTRIBS) return;
|
||||
++m_attributeVersions[index].BufferVersion;
|
||||
++m_configVersion;
|
||||
MGP_NOTE_AGGREGATE(VaoAttribute);
|
||||
}
|
||||
|
||||
void VertexArrayObject::BumpAttributeSwitchVersion(Uint index) {
|
||||
if (index >= MAX_VERTEX_ATTRIBS) return;
|
||||
++m_attributeVersions[index].SwitchVersion;
|
||||
++m_configVersion;
|
||||
MGP_NOTE_AGGREGATE(VaoAttribute);
|
||||
}
|
||||
|
||||
const VertexAttributeVersion& VertexArrayObject::GetAttributeVersion(Uint index) const {
|
||||
|
||||
@@ -25,6 +25,12 @@ namespace MobileGL {
|
||||
static constexpr int MAX_VERTEX_ATTRIB_BINDINGS = 32;
|
||||
|
||||
VertexArrayObject(Uint externIndex);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 step e2. Out of line, and declared only where there is a notice to raise:
|
||||
// in a pull build this class keeps its implicit destructor, which is what keeps
|
||||
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
|
||||
~VertexArrayObject();
|
||||
#endif
|
||||
|
||||
void EnableAttribute(Uint index);
|
||||
void DisableAttribute(Uint index);
|
||||
@@ -100,6 +106,22 @@ namespace MobileGL {
|
||||
// "any vertex-input state changed" with one compare.
|
||||
Uint32 GetConfigVersion() const { return m_configVersion; }
|
||||
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// ---- THE BACKEND'S THREE MEMOS ON THE FRONTEND OBJECT ----
|
||||
//
|
||||
// P2 D12.5 (ARCHITECTURE.md 9.5) retires all three: a frontend state object
|
||||
// must not hold the backend's raw pointers, and under split it cannot - the
|
||||
// backend is in another process and its cache entry has no address the client
|
||||
// could store. Magma's handle arm keeps the same three facts in a slot-indexed
|
||||
// table it owns itself (VertexInputStateFactory::VaoBackendMemos), keyed on the
|
||||
// VAO's {slot, gen} and validated by the same config version, so nothing is
|
||||
// recomputed more often than it was.
|
||||
//
|
||||
// They stay compiled under MOBILEGL_PIPE_LEGACY_MEMOS - which a PULL build
|
||||
// forces ON - because that is the arm the pre-handle A/B runs, and because G1
|
||||
// admits no change to the pull build. They are deleted outright with the pull
|
||||
// path at P13.
|
||||
//
|
||||
// Backend-owned content-hash memo, valid while the config version matches
|
||||
// (same idea as ProgramObject's hash memo — avoids re-hashing all
|
||||
// attributes on every draw).
|
||||
@@ -148,6 +170,7 @@ namespace MobileGL {
|
||||
m_backendAuxMemo1 = aux1;
|
||||
m_backendAuxMemoVersion = m_configVersion;
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
|
||||
private:
|
||||
void BumpAttributeFormatVersion(Uint index);
|
||||
@@ -187,6 +210,10 @@ namespace MobileGL {
|
||||
Array<Bool, MAX_VERTEX_ATTRIBS> m_attributeUsesBindingModel = {};
|
||||
|
||||
Uint32 m_configVersion = 0;
|
||||
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
// The storage behind the three accessors above; retired with them (D12.5).
|
||||
// A pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not
|
||||
// move there and G1 sees no change.
|
||||
mutable Uint64 m_backendHashMemo = 0;
|
||||
mutable Uint32 m_backendHashMemoVersion = ~0u;
|
||||
mutable const void* m_backendStateMemo = nullptr;
|
||||
@@ -195,6 +222,7 @@ namespace MobileGL {
|
||||
mutable Uint64 m_backendAuxMemo0 = 0;
|
||||
mutable Uint64 m_backendAuxMemo1 = 0;
|
||||
mutable Uint32 m_backendAuxMemoVersion = ~0u;
|
||||
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
|
||||
};
|
||||
} // namespace GLState
|
||||
} // namespace MG_State
|
||||
|
||||
@@ -28,7 +28,20 @@ namespace MobileGL {
|
||||
const SharedPtr<VertexArrayObject>& GetBoundVertexArray();
|
||||
Vector<SharedPtr<VertexArrayObject>>& GetAllVertexArrays();
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2 brief D4: "did the attribute configuration of ANY vertex array move".
|
||||
// Bumped from every VertexArrayObject::BumpAttribute*Version through
|
||||
// MGP_NOTE_AGGREGATE(VaoAttribute), which is coarser than the per-object
|
||||
// m_configVersion on purpose - the tracker wants one compare, and an extra
|
||||
// re-push costs a push while a missed one renders stale.
|
||||
void NoteAttributeChanged() { ++m_anyVaoAttributeGeneration; }
|
||||
Uint64 GetAnyAttributeGeneration() const { return m_anyVaoAttributeGeneration; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
Uint64 m_anyVaoAttributeGeneration = 0;
|
||||
#endif
|
||||
// "Nothing bound" (an out-of-range or never-created name was bound). Distinct from
|
||||
// being bound to a live slot so that a slot filled AFTER such a bind does not
|
||||
// retroactively become the bound VAO.
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
#include "Init.h"
|
||||
#include <Config.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#endif
|
||||
|
||||
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
|
||||
#include <MG_Impl/GetProcAddress.h>
|
||||
@@ -1647,11 +1650,28 @@ namespace {
|
||||
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
|
||||
};
|
||||
|
||||
// These cases are BufferBackendOps dispatch tests. Since P3a a push build also installs the
|
||||
// handle-shaped MGPipeResourceOps table at backend bring-up, and the frontend's push arms route
|
||||
// every buffer mutation there first; the fixture therefore scopes BOTH tables, the way
|
||||
// ResourceEmitTest's ApplierGuard scopes the applier, so the mock installed here is the arm
|
||||
// that actually runs. The pipe-side dispatch has its own coverage in ResourceEmitTest.
|
||||
struct ScopedBackendOps {
|
||||
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
|
||||
MG_State::GLState::SetBufferBackendOps(ops);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
m_savedResourceOps = MG_Pipe::MGPipeGetResourceOps();
|
||||
MG_Pipe::MGPipeSetResourceOps(nullptr);
|
||||
#endif
|
||||
}
|
||||
~ScopedBackendOps() { MG_State::GLState::SetBufferBackendOps(nullptr); }
|
||||
~ScopedBackendOps() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
MG_Pipe::MGPipeSetResourceOps(m_savedResourceOps);
|
||||
#endif
|
||||
MG_State::GLState::SetBufferBackendOps(nullptr);
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
const MG_Pipe::MGPipeResourceOps* m_savedResourceOps = nullptr;
|
||||
#endif
|
||||
};
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -53,6 +53,127 @@ if (MSVC)
|
||||
target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
|
||||
|
||||
# The four P2 suites. Their targets and this registration are the CONTRACT commit's; their
|
||||
# CONTENTS belong to the packages named in each file's header, so no package after A has to
|
||||
# come back to this file.
|
||||
#
|
||||
# RenderStateSpansTest links gtest, not gtest_main, for PipeInputsTest's reason: its applier
|
||||
# trip-wire cases read the wire's line back out of a log file, so the suite needs its own
|
||||
# main() to point MOBILEGL_LOG_FILE_PATH at one before anything logs. The other three need no
|
||||
# main() of their own.
|
||||
foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest)
|
||||
add_executable(${pipeTest} ${pipeTest}.cpp)
|
||||
|
||||
target_include_directories(${pipeTest} PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/MobileGL/MG_Pipe
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(${pipeTest} PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
add_executable(
|
||||
RenderStateSpansTest
|
||||
RenderStateSpansTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(RenderStateSpansTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/MobileGL/MG_Pipe
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(RenderStateSpansTest PRIVATE
|
||||
GTest::gtest
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(RenderStateSpansTest PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
|
||||
# Magma's own {slot, gen} mint and the claim rule its per-slot memo tables use
|
||||
# (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h, which is header-only). It sits here rather
|
||||
# than under Backend/DirectVulkan because it needs no device, no GLFW and no Vulkan loader - and
|
||||
# because that directory is registered only when ENABLE_INTEGRATION_TESTS is on, while this
|
||||
# suite is the only place in the tree where a deleted `++Gen` is caught. See the file header.
|
||||
add_executable(
|
||||
MagmaPipeIdentityTest
|
||||
MagmaPipeIdentityTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MagmaPipeIdentityTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/MobileGL/MG_Pipe
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(MagmaPipeIdentityTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(MagmaPipeIdentityTest PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
|
||||
# P3a's two suites. Their targets and this registration are the CONTRACT commit's, for the
|
||||
# same reason the four P2 suites' are: their CONTENTS belong to two later packages each, and
|
||||
# neither of them should have to come back to this file to add a case.
|
||||
#
|
||||
# They link gtest rather than gtest_main and carry their own main(), like PipeInputsTest and
|
||||
# RenderStateSpansTest: the applier's bounds and protocol trip wires report through a log line
|
||||
# in a shipped push build and std::abort() in a poison or verify one, so a case that drives
|
||||
# one reads the line back out of a file the process names before anything logs. Deciding that
|
||||
# HERE is what keeps the later packages out of this file.
|
||||
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
|
||||
add_executable(${pipeTest} ${pipeTest}.cpp)
|
||||
|
||||
target_include_directories(${pipeTest} PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/MobileGL/MG_Pipe
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(${pipeTest} PRIVATE
|
||||
GTest::gtest
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
|
||||
gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
endforeach()
|
||||
gtest_discover_tests(MagmaPipeIdentityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(RenderStateSpansTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest)
|
||||
gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// MobileGL - MobileGL/MG_Test/Pipe/CsoCacheTest.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
|
||||
|
||||
// The render-state CSO cache (P2 brief D7). Owned by P2 package B (p2/tracker); the file and
|
||||
// its CMake registration are the contract commit's.
|
||||
//
|
||||
// Needs the push sources, so every case is a visible SKIP in a pull build rather than a
|
||||
// vanishing test.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/CsoCache.h>
|
||||
#include <MG_Impl/Pipe/SetHashSuppressor.h>
|
||||
#include <MG_Pipe/MGPipeRenderStateSpans.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Pipe;
|
||||
|
||||
namespace {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A
|
||||
// push-only case therefore cannot be ABSENT from a pull build; it has to be there and
|
||||
// SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list
|
||||
// declares exactly the suite.name pairs the push build gets from the real cases below, so
|
||||
// a case added on one side and forgotten on the other shows up as a ctest-name diff
|
||||
// rather than as a test that silently is not there.
|
||||
#define MGL_CSO_CACHE_TEST_LIST(X) \
|
||||
X(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) \
|
||||
X(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) \
|
||||
X(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) \
|
||||
X(CsoCacheTest, ContentAddressingOffMintsEveryTime) \
|
||||
X(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) \
|
||||
X(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) \
|
||||
X(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) \
|
||||
X(SetHashSuppressorTest, SlotsAreIndependent) \
|
||||
X(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_CSO_CACHE_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
class CsoCacheTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_savedPush = MG_Config::Features.PipePush;
|
||||
MGPipeCsoCache::s_hashForTest = nullptr;
|
||||
MGPipeApplierReset();
|
||||
}
|
||||
void TearDown() override {
|
||||
MG_Config::Features.PipePush = m_savedPush;
|
||||
MGPipeCsoCache::s_hashForTest = nullptr;
|
||||
MGPipeApplierReset();
|
||||
}
|
||||
|
||||
// A render state that differs from every other `seed` in a PIPELINE byte, so each one
|
||||
// is a genuinely different CSO. SampleMaskValue is in pipeline chunk P2.
|
||||
static RenderStateParameters PipelineState(Uint32 seed) {
|
||||
RenderStateParameters params{};
|
||||
params.SampleMaskValue = seed;
|
||||
return params;
|
||||
}
|
||||
|
||||
Uint64 m_savedPush = 0;
|
||||
};
|
||||
|
||||
TEST_F(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) {
|
||||
MGPipeCsoCache cache;
|
||||
Uint64 bytes = 0;
|
||||
const RenderStateParameters params = PipelineState(7);
|
||||
const MGPipeHandle first = cache.Acquire(params, bytes);
|
||||
for (int i = 0; i < 16; ++i) EXPECT_TRUE(cache.Acquire(params, bytes) == first);
|
||||
EXPECT_EQ(cache.GetCounters().Mints, 1u);
|
||||
EXPECT_EQ(cache.GetCounters().Hits, 16u);
|
||||
EXPECT_EQ(cache.Size(), 1u);
|
||||
cache.Reset();
|
||||
}
|
||||
|
||||
TEST_F(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) {
|
||||
MGPipeCsoCache cache;
|
||||
Uint64 bytes = 0;
|
||||
Vector<MGPipeHandle> handles;
|
||||
for (Uint32 i = 0; i < kMGPipeCsoCacheCapacity; ++i) {
|
||||
handles.push_back(cache.Acquire(PipelineState(i), bytes));
|
||||
}
|
||||
EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity);
|
||||
EXPECT_EQ(cache.GetCounters().Evictions, 0u);
|
||||
// Touch entry 0 so it is no longer the oldest; entry 1 becomes the victim.
|
||||
EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]);
|
||||
|
||||
const MGPipeHandle overflow = cache.Acquire(PipelineState(kMGPipeCsoCacheCapacity), bytes);
|
||||
EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity);
|
||||
EXPECT_EQ(cache.GetCounters().Evictions, 1u);
|
||||
EXPECT_EQ(cache.GetCounters().Mints, kMGPipeCsoCacheCapacity + 1);
|
||||
EXPECT_FALSE(overflow == handles[1]);
|
||||
// The one that was touched survived; the evicted one has to be minted again.
|
||||
EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]);
|
||||
const MGPipeHandle reborn = cache.Acquire(PipelineState(1), bytes);
|
||||
EXPECT_FALSE(reborn == handles[1]);
|
||||
EXPECT_EQ(cache.GetCounters().Evictions, 2u);
|
||||
cache.Reset();
|
||||
}
|
||||
|
||||
// A 64-bit hash collision between two different render states would alias them onto one
|
||||
// CSO, which is silent wrong pixels with no gate that can see it. The memcmp confirm is
|
||||
// what stops it, and this is what proves the memcmp is doing something.
|
||||
TEST_F(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) {
|
||||
MGPipeCsoCache::s_hashForTest = [](const void*) -> Uint64 { return 0x1234'5678'9abc'def0ull; };
|
||||
MGPipeCsoCache cache;
|
||||
Uint64 bytes = 0;
|
||||
const MGPipeHandle a = cache.Acquire(PipelineState(1), bytes);
|
||||
const MGPipeHandle b = cache.Acquire(PipelineState(2), bytes);
|
||||
EXPECT_FALSE(a == b) << "two different render states were aliased onto one CSO";
|
||||
EXPECT_EQ(cache.GetCounters().Collisions, 1u);
|
||||
EXPECT_EQ(cache.GetCounters().Mints, 2u);
|
||||
EXPECT_EQ(cache.GetCounters().Hits, 0u);
|
||||
cache.Reset();
|
||||
}
|
||||
|
||||
// The negative control the whole CSO design is measured against (ROADMAP.md P2). It turns
|
||||
// off the PROBE and the handle reuse, not the records - otherwise it would measure a
|
||||
// different design rather than this one without content addressing.
|
||||
TEST_F(CsoCacheTest, ContentAddressingOffMintsEveryTime) {
|
||||
MG_Config::Features.PipePush |= kMGPipeBehaviourNoCsoContentAddressing;
|
||||
MGPipeCsoCache cache;
|
||||
Uint64 bytes = 0;
|
||||
const RenderStateParameters params = PipelineState(3);
|
||||
const MGPipeHandle first = cache.Acquire(params, bytes);
|
||||
const MGPipeHandle second = cache.Acquire(params, bytes);
|
||||
const MGPipeHandle third = cache.Acquire(params, bytes);
|
||||
EXPECT_FALSE(first == second);
|
||||
EXPECT_FALSE(second == third);
|
||||
EXPECT_EQ(cache.GetCounters().Mints, 3u);
|
||||
EXPECT_EQ(cache.GetCounters().Hits, 0u);
|
||||
cache.Reset();
|
||||
}
|
||||
|
||||
TEST_F(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) {
|
||||
MGPipeCsoCache cache;
|
||||
Uint64 bytes = 0;
|
||||
cache.Acquire(PipelineState(11), bytes);
|
||||
// A mint puts the descriptor and the whole pipeline half on the wire.
|
||||
EXPECT_EQ(bytes, sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes);
|
||||
const Uint64 afterMint = bytes;
|
||||
cache.Acquire(PipelineState(11), bytes);
|
||||
// A hit puts NOTHING on the wire: the 12-byte bind is the caller's, not the cache's.
|
||||
EXPECT_EQ(bytes, afterMint);
|
||||
cache.Reset();
|
||||
}
|
||||
|
||||
// ---- the set-hash suppressor (D11) ----
|
||||
|
||||
TEST(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) {
|
||||
MGPipeSetHashSuppressor suppressor;
|
||||
for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) {
|
||||
const auto slot = static_cast<MGPipeSuppressorSlot>(i);
|
||||
EXPECT_EQ(suppressor.LastEmitted(slot), 0u) << "slot " << i << " did not start at 0";
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " suppressed its first set";
|
||||
EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " re-emitted an unmoved set";
|
||||
}
|
||||
}
|
||||
|
||||
TEST(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) {
|
||||
MGPipeSetHashSuppressor suppressor;
|
||||
const auto slot = MGPipeSuppressorSlot::SetVertexAttribDefaults;
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(slot, 0));
|
||||
EXPECT_EQ(suppressor.LastEmitted(slot), 1u) << "a computed 0 must not read as never emitted";
|
||||
EXPECT_FALSE(suppressor.ShouldEmit(slot, 0));
|
||||
}
|
||||
|
||||
TEST(SetHashSuppressorTest, SlotsAreIndependent) {
|
||||
MGPipeSetHashSuppressor suppressor;
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42));
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetSamplerViews, 42));
|
||||
EXPECT_FALSE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42));
|
||||
}
|
||||
|
||||
TEST(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo) {
|
||||
MGPipeSetHashSuppressor suppressor;
|
||||
const auto slot = MGPipeSuppressorSlot::SetShaderImages;
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
|
||||
EXPECT_FALSE(suppressor.ShouldEmit(slot, 99));
|
||||
suppressor.Invalidate(slot);
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
|
||||
suppressor.InvalidateAll();
|
||||
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
@@ -0,0 +1,217 @@
|
||||
// MobileGL - MobileGL/MG_Test/Pipe/MagmaPipeIdentityTest.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
|
||||
|
||||
// Magma's {slot, gen} mint (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h) and the claim
|
||||
// rule its per-slot memo tables use, at the one point HandleRecycleScenario cannot reach: a
|
||||
// REAL SLOT REUSE.
|
||||
//
|
||||
// WHY THIS SUITE EXISTS (fix-aba review v1, MAJOR 1). The AbaControl integration lanes defeat
|
||||
// the object identity that SELECTS the slot, and that is the whole of what a same-frame pixel
|
||||
// reproducer can defeat:
|
||||
//
|
||||
// * the mint has no death notification, so a slot returns to the free list only through
|
||||
// OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024);
|
||||
// * HandleRecycleScenario issues five frame boundaries, so its replacement VAO gets a
|
||||
// BRAND-NEW slot at Gen 1 and the generation never participates in a compare;
|
||||
// * a real reuse needs >= 1024 idle boundaries, which puts the two draws in different frames
|
||||
// - and ResolvedVertexBindings, the only memo carrying a GPU slice rather than a layout,
|
||||
// declines across frames by design.
|
||||
//
|
||||
// So deleting the `++m_entries[index].Gen` in MagmaPipeIdentityTable::ClaimSlot leaves every
|
||||
// arm of HandleRecycleScenario green. It reds AnIdleSlotIsRetiredAndReusedWithANewGeneration
|
||||
// and AReusedSlotDoesNotServeItsPredecessorsMemo below, which is the whole point of the file.
|
||||
//
|
||||
// The suite lives beside SlotAllocatorTest because it asserts the same identity contract that
|
||||
// file asserts for the client allocator - "Gen moves on REUSE and never on respecify" - for the
|
||||
// second mint in the tree, the one Magma keeps because nothing in P2 can call the client
|
||||
// allocator's Free (MagmaPipeArms.h says why). It needs no GL context, no driver and no Vulkan
|
||||
// loader: MagmaPipeArms.h is header-only.
|
||||
//
|
||||
// Push-only, like everything it tests, so every case is a visible SKIP in a pull build rather
|
||||
// than a vanishing test (G2 name parity).
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#include <MG_Pipe/MGPipeHandles.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
|
||||
namespace {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// The push build's case list, declared once so a case added on one side and forgotten on
|
||||
// the other shows up as a ctest-name diff rather than as a test that silently is not there
|
||||
// (the shape CsoCacheTest.cpp established).
|
||||
#define MGL_MAGMA_PIPE_IDENTITY_TEST_LIST(X) \
|
||||
X(MagmaPipeIdentityTest, AnIdleSlotIsRetiredAndReusedWithANewGeneration) \
|
||||
X(MagmaPipeIdentityTest, AReusedSlotDoesNotServeItsPredecessorsMemo) \
|
||||
X(MagmaPipeIdentityTest, TheAbaControlKnobServesTheStaleMemoAcrossAReusedSlot) \
|
||||
X(MagmaPipeIdentityTest, ALiveObjectKeepsItsSlotItsGenerationAndItsMemo)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_MAGMA_PIPE_IDENTITY_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
using namespace MobileGL::MG_Backend::DirectVulkan;
|
||||
using MG_Pipe::MGPipeHandle;
|
||||
|
||||
// What VertexInputStateFactory::VaoBackendMemos is, reduced to the two fields the claim
|
||||
// rule needs: the Owner it compares, and one payload word standing in for the memo's
|
||||
// contents (there, the content hash and the resolved-entry pointer). The RULE is production
|
||||
// code - MagmaPipeClaimSlotMemos - not a copy of it.
|
||||
struct TestMemos {
|
||||
MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle;
|
||||
Uint64 Payload = 0;
|
||||
};
|
||||
|
||||
// MagmaPipeIdentityTable's sweep cadence and retirement age are private, so the number of
|
||||
// boundaries needed to retire an object last used at boundary 0 is spelled out here: the
|
||||
// sweep runs when (boundary % 256) == 0 and retires entries idle for more than 1024
|
||||
// boundaries, so the first sweep that can retire it is boundary 1280. Every case that
|
||||
// depends on this ASSERTs the retire actually happened, so a change to either constant
|
||||
// fails loudly instead of silently turning these cases into "two unrelated objects".
|
||||
constexpr int kBoundariesToRetireAnObjectIdleSinceTheStart = 1280;
|
||||
|
||||
class MagmaPipeIdentityTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_savedKnob = MG_Config::Features.PipeHandleAbaControl;
|
||||
MG_Config::Features.PipeHandleAbaControl = false;
|
||||
}
|
||||
void TearDown() override { MG_Config::Features.PipeHandleAbaControl = m_savedKnob; }
|
||||
|
||||
// Ages the mint far enough for the sweep to retire everything that has been idle since
|
||||
// the start, while touching `keepAliveLifetimeId` on every boundary so that IT is never
|
||||
// retired. The keep-alive is what makes the reuse non-degenerate: it holds the first
|
||||
// allocatable slot, so the slot under test is not the one negative control C aliases
|
||||
// everything onto (kMagmaPipeAbaControlSlotIndex).
|
||||
static void AgeUntilTheSweepRetiresTheIdleSlots(MagmaPipeIdentityTable& mint,
|
||||
Uint64 keepAliveLifetimeId) {
|
||||
for (int i = 0; i < kBoundariesToRetireAnObjectIdleSinceTheStart; ++i) {
|
||||
mint.Acquire(keepAliveLifetimeId);
|
||||
mint.OnFrameBoundary();
|
||||
}
|
||||
}
|
||||
|
||||
Bool m_savedKnob = false;
|
||||
};
|
||||
|
||||
// The precondition every case below rests on, asserted on its own so that a failure here
|
||||
// reads as "the mint stopped reusing slots" rather than as a memo bug.
|
||||
TEST_F(MagmaPipeIdentityTest, AnIdleSlotIsRetiredAndReusedWithANewGeneration) {
|
||||
MagmaPipeIdentityTable mint("VertexElementsCso");
|
||||
const MGPipeHandle keepAlive = mint.Acquire(1);
|
||||
const MGPipeHandle first = mint.Acquire(2);
|
||||
ASSERT_EQ(keepAlive.Slot, MG_Pipe::kMGPipeFirstAllocatableSlot);
|
||||
ASSERT_NE(first.Slot, keepAlive.Slot);
|
||||
ASSERT_EQ(first.Gen, 1u) << "a slot's first handout is generation 1";
|
||||
ASSERT_EQ(mint.LiveCount(), 2u);
|
||||
|
||||
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
|
||||
ASSERT_EQ(mint.LiveCount(), 1u)
|
||||
<< "the idle slot was not retired, so nothing in this file is a slot REUSE";
|
||||
|
||||
// The step HandleRecycleScenario cannot take. With an empty free list this would be a
|
||||
// brand-new slot at Gen 1 and the generation would never participate in any compare -
|
||||
// which is exactly what the scenario measures (redVao slot=2 gen=1, greenVao slot=3
|
||||
// gen=1) and why it cannot catch a deleted ++Gen.
|
||||
const MGPipeHandle second = mint.Acquire(3);
|
||||
EXPECT_EQ(second.Slot, first.Slot) << "a retired slot must come back before the high-water mark";
|
||||
EXPECT_EQ(second.Gen, first.Gen + 1u) << "a slot that changes owner must change generation";
|
||||
EXPECT_FALSE(second == first);
|
||||
EXPECT_EQ(mint.Count(), 2u) << "the reuse must not mint a third slot";
|
||||
}
|
||||
|
||||
// THE CASE THE ++Gen IS LOAD-BEARING FOR. Knob off: the replacement gets the predecessor's
|
||||
// SLOT, so the slot cannot be what separates them - only the generation can.
|
||||
TEST_F(MagmaPipeIdentityTest, AReusedSlotDoesNotServeItsPredecessorsMemo) {
|
||||
MagmaPipeIdentityTable mint("VertexElementsCso");
|
||||
MagmaPipeSlotTable<TestMemos> memos;
|
||||
|
||||
mint.Acquire(1); // the keep-alive, so the slot under test is not slot index 0
|
||||
const MGPipeHandle first = mint.Acquire(2);
|
||||
MagmaPipeClaimSlotMemos(memos, first).Payload = 0xDEADull;
|
||||
ASSERT_TRUE(MagmaPipeClaimSlotMemos(memos, first).Owner == first);
|
||||
ASSERT_EQ(MagmaPipeClaimSlotMemos(memos, first).Payload, 0xDEADull);
|
||||
|
||||
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
|
||||
const MGPipeHandle second = mint.Acquire(3);
|
||||
ASSERT_EQ(second.Slot, first.Slot) << "not a slot reuse, so this case would prove nothing";
|
||||
// EXPECT, not ASSERT: with the generation frozen the memo assertion below is exactly what
|
||||
// goes red, and a reader of the failure should see both halves rather than stop here.
|
||||
EXPECT_NE(second.Gen, first.Gen);
|
||||
|
||||
const TestMemos& served = MagmaPipeClaimSlotMemos(memos, second);
|
||||
EXPECT_TRUE(served.Owner == second) << "the entry was not claimed for its new owner";
|
||||
EXPECT_EQ(served.Payload, 0ull)
|
||||
<< "the replacement inherited the dead object's memo out of the SAME slot: the "
|
||||
"generation is the only thing that separates {slot, gen=N} from {slot, gen=N+1}, "
|
||||
"and it did not";
|
||||
}
|
||||
|
||||
// The same shape with negative control C on, which is what makes the case above a control
|
||||
// rather than a tautology: with the knob on the memo IS served across the generation.
|
||||
//
|
||||
// The knob is set before the first claim, as a process-wide knob is in a real run: what it
|
||||
// defeats is the identity that selects the entry, so a run that stamps with it off and reads
|
||||
// with it on would be reading a different entry, not an aliased one.
|
||||
TEST_F(MagmaPipeIdentityTest, TheAbaControlKnobServesTheStaleMemoAcrossAReusedSlot) {
|
||||
MG_Config::Features.PipeHandleAbaControl = true;
|
||||
|
||||
MagmaPipeIdentityTable mint("VertexElementsCso");
|
||||
MagmaPipeSlotTable<TestMemos> memos;
|
||||
|
||||
mint.Acquire(1);
|
||||
const MGPipeHandle first = mint.Acquire(2);
|
||||
TestMemos& stamped = MagmaPipeClaimSlotMemos(memos, first);
|
||||
stamped.Payload = 0xDEADull;
|
||||
|
||||
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
|
||||
const MGPipeHandle second = mint.Acquire(3);
|
||||
ASSERT_EQ(second.Slot, first.Slot);
|
||||
EXPECT_NE(second.Gen, first.Gen);
|
||||
|
||||
const TestMemos& served = MagmaPipeClaimSlotMemos(memos, second);
|
||||
EXPECT_EQ(&served, &stamped) << "the control must collapse every object onto one entry";
|
||||
EXPECT_EQ(served.Payload, 0xDEADull)
|
||||
<< "negative control C is not defeating the claim rule any more: the replacement was "
|
||||
"NOT handed its predecessor's memo, so the AbaControl lanes assert nothing";
|
||||
EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(served.Owner))
|
||||
<< "the control hands the entry back UNCLEARED and UNCLAIMED - it never learns whose "
|
||||
"it is, which is what 'replace the identity with a constant' means";
|
||||
}
|
||||
|
||||
// The other half of the {slot, gen} contract, and the reason a deleted ++Gen cannot be
|
||||
// 'fixed' by bumping Gen on every acquisition: a live object keeps its handle across
|
||||
// sweeps, so its memo survives a reconfiguration instead of being recomputed per draw.
|
||||
TEST_F(MagmaPipeIdentityTest, ALiveObjectKeepsItsSlotItsGenerationAndItsMemo) {
|
||||
MagmaPipeIdentityTable mint("VertexElementsCso");
|
||||
MagmaPipeSlotTable<TestMemos> memos;
|
||||
|
||||
const MGPipeHandle handle = mint.Acquire(2);
|
||||
MagmaPipeClaimSlotMemos(memos, handle).Payload = 0xBEEFull;
|
||||
|
||||
// Past two sweeps (256 and 512), drawn on every boundary.
|
||||
for (int i = 0; i < 700; ++i) {
|
||||
mint.Acquire(2);
|
||||
mint.OnFrameBoundary();
|
||||
}
|
||||
const MGPipeHandle again = mint.Acquire(2);
|
||||
EXPECT_TRUE(again == handle) << "a live object's handle moved under the age sweep";
|
||||
EXPECT_EQ(MagmaPipeClaimSlotMemos(memos, again).Payload, 0xBEEFull)
|
||||
<< "a live object's memo was cleared without its slot changing owner";
|
||||
EXPECT_EQ(mint.Count(), 1u);
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
@@ -110,8 +110,13 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) {
|
||||
TEST(PipeCatalogue, ResidualBlockSizeIsPinned) {
|
||||
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE);
|
||||
EXPECT_EQ(sizeof(ResidualValueBlock), static_cast<SizeT>(MGL_RESIDUAL_BLOCK_SIZE));
|
||||
// It carries the whole of both value structs today; that is what the later stages eat.
|
||||
EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters));
|
||||
// P2 ate 1240 of the 1248: RenderStateParameters retired to create/bind_render_state and
|
||||
// set_dynamic_state, PixelStoreParameters to set_pixel_pack_state, the patch quintet to
|
||||
// set_patch_state. What is left is one Uint64 of capability bits, and it is redundant on
|
||||
// purpose - the applier's trip wire compares it against the assembled block.
|
||||
EXPECT_EQ(sizeof(ResidualValueBlock), 8u);
|
||||
EXPECT_LT(sizeof(ResidualValueBlock), sizeof(RenderStateParameters));
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u);
|
||||
}
|
||||
|
||||
// P0.5 moved the value structs into MG_Pipe/MGPipeValueTypes.h. These are the runtime twins
|
||||
@@ -138,13 +143,18 @@ TEST(PipeCatalogue, ValueTypeLayoutsArePinned) {
|
||||
EXPECT_EQ(kMGMaxDrawBuffers, 8u);
|
||||
}
|
||||
|
||||
// The move did not alter the carrier: the residual block is still the render-state struct,
|
||||
// then the pack struct, then the 8-aligned capability word, at the offsets it had before.
|
||||
// P2 ATE THE TWO VALUE STRUCTS AND THE PATCH TAIL the name still remembers, and the name
|
||||
// stays because a removed test name is a gate failure of its own (G14, additions only).
|
||||
// What it now pins is the other half of the same statement: the carrier is one capability
|
||||
// word, at offset 0, and the members it used to carry are gone rather than merely moved -
|
||||
// which is exactly what "MGL_RESIDUAL_BLOCK_SIZE only ever goes down" has to mean.
|
||||
TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) {
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, RenderState), 0u);
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, Pack), sizeof(RenderStateParameters));
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 1200u);
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, PatchVertices), 1208u);
|
||||
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u);
|
||||
EXPECT_EQ(sizeof(ResidualValueBlock), sizeof(Uint64));
|
||||
// The three carriers that took the retired members over.
|
||||
EXPECT_EQ(sizeof(MGPPixelPackState), sizeof(PixelStoreParameters));
|
||||
EXPECT_EQ(sizeof(MGPPatchState), 40u);
|
||||
EXPECT_EQ(sizeof(MGPBindRenderState), 12u);
|
||||
}
|
||||
|
||||
// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes.
|
||||
@@ -307,33 +317,52 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) {
|
||||
EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f));
|
||||
EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f));
|
||||
|
||||
// The residual carrier is one field since P2, so the nested-struct case it used to
|
||||
// demonstrate is demonstrated on RenderStateParameters directly - which is where it
|
||||
// actually matters now that the block travels as create/bind_render_state chunks.
|
||||
ResidualValueBlock left{};
|
||||
ResidualValueBlock right{};
|
||||
const char* field = nullptr;
|
||||
EXPECT_TRUE(MGPipeVerify(left, right, &field));
|
||||
right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor;
|
||||
right.CapabilityBits = 1ull << static_cast<Uint64>(CapabilityInput::FramebufferSrgb);
|
||||
EXPECT_FALSE(MGPipeVerify(left, right, &field));
|
||||
EXPECT_STREQ(field, "RenderState");
|
||||
EXPECT_STREQ(field, "CapabilityBits");
|
||||
|
||||
RenderStateParameters leftState{};
|
||||
RenderStateParameters rightState{};
|
||||
const char* inner = nullptr;
|
||||
EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner));
|
||||
EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner));
|
||||
rightState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor;
|
||||
EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner));
|
||||
EXPECT_STREQ(inner, "BlendStates");
|
||||
// A NaN patch level in the render state equals itself too.
|
||||
right = left;
|
||||
left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
|
||||
right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
|
||||
EXPECT_TRUE(MGPipeVerify(left, right, &field));
|
||||
// P2's three new capability bools are members like any other, so the comparator names
|
||||
// them rather than folding them into a neighbour's padding.
|
||||
rightState = leftState;
|
||||
rightState.FramebufferSrgbEnabled = true;
|
||||
EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner));
|
||||
EXPECT_STREQ(inner, "FramebufferSrgbEnabled");
|
||||
// A NaN patch level equals itself too.
|
||||
rightState = leftState;
|
||||
leftState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
|
||||
rightState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
|
||||
EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner));
|
||||
}
|
||||
|
||||
// The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and
|
||||
// the struct that used to memcmp is compared member by member.
|
||||
// the struct that used to memcmp is compared member by member. P3a added the two vertex wire
|
||||
// views as a seventh and eighth non-payload entry (63 + 8), for the same reason: they are the
|
||||
// elements of create_vertex_elements' blob, and a memcmp over that blob would false-differ on
|
||||
// MGPVertexAttribWire::Pad0.
|
||||
TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
|
||||
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 69u);
|
||||
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 71u);
|
||||
static_assert(MGPipeHasFieldVerifier<RenderStateParameters>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<PixelStoreParameters>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<PerBufferBlendState>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<StencilFaceState>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<DynamicBackendParameters>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<MGHostSpan>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<MGPVertexAttribWire>::value);
|
||||
static_assert(MGPipeHasFieldVerifier<MGPVertexBindingPointWire>::value);
|
||||
PixelStoreParameters p{};
|
||||
PixelStoreParameters q{};
|
||||
const char* field = nullptr;
|
||||
@@ -352,9 +381,16 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
|
||||
|
||||
// G7 pins the member list the pipeline/dynamic split is derived from.
|
||||
TEST(PipeCatalogue, PipelineSubsetMembersArePinned) {
|
||||
EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u);
|
||||
EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled");
|
||||
EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks");
|
||||
// 44 as of P2, in DECLARATION order. It grew from the 24 members
|
||||
// ComputePipelineStateHash used to hash because the chunk table's rule is "a byte is
|
||||
// pipeline state iff a setter that calls BumpVersions() writes it", and that is a strict
|
||||
// superset: sample coverage, front face, provoking vertex, the scissor-test mask, the
|
||||
// back polygon mode, eleven capability bools the hash never read, and the three
|
||||
// capabilities P2 gave storage to.
|
||||
EXPECT_EQ(kMGPipePipelineStateMemberCount, 44u);
|
||||
EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices");
|
||||
EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1],
|
||||
"ScissorTestEnabledMask");
|
||||
}
|
||||
|
||||
// The reverse channel is exactly ten callbacks (section 7.1).
|
||||
@@ -437,3 +473,113 @@ TEST(PipeCatalogue, SubDataBufferRangeRidesInTheUnionBox) {
|
||||
EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull);
|
||||
EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull);
|
||||
}
|
||||
|
||||
// P3a, D-H1: set_vertex_buffers carries the vertex-FETCH base instance explicitly, one per
|
||||
// emitted set rather than one per entry, and the header grew 16 -> 24 bytes to hold it.
|
||||
//
|
||||
// The size is the cheap half. The half a compiler cannot catch is the PipeFields.def row:
|
||||
// MGPVertexBuffers still HAS a ContentHash and still asserts its size whether or not the
|
||||
// field list names BaseInstance, and a comparator blind to the field would let a
|
||||
// baseInstance-only divergence through under MOBILEGL_PIPE_VERIFY - which is the one gate
|
||||
// that would otherwise have seen the suppression bug the ContentHash rule exists to prevent.
|
||||
// So the field list is pinned the only way it can be: by making the comparator name it.
|
||||
TEST(PipeCatalogue, VertexBufferSetCarriesAnExplicitBaseInstance) {
|
||||
static_assert(sizeof(MGPVertexBuffers) == 24);
|
||||
static_assert(sizeof(MGPVertexBuffer) == 32); // the per-entry struct did NOT change
|
||||
EXPECT_EQ(sizeof(MGPVertexBuffers), 24u);
|
||||
|
||||
MGPVertexBuffers a{};
|
||||
MGPVertexBuffers b{};
|
||||
const char* field = nullptr;
|
||||
EXPECT_TRUE(MGPipeVerify(a, b, &field));
|
||||
b.Pad0 = 0x5A; // padding is not a field
|
||||
EXPECT_TRUE(MGPipeVerify(a, b, &field));
|
||||
b.Pad0 = 0;
|
||||
b.BaseInstance = 7;
|
||||
EXPECT_FALSE(MGPipeVerify(a, b, &field));
|
||||
EXPECT_STREQ(field, "BaseInstance");
|
||||
|
||||
// The set still carries no fetch shift per entry: an entry that disagreed with its own
|
||||
// header is a shape the applier would have to police, and MGPVertexBuffer's Pad0 stays
|
||||
// padding rather than becoming a second copy of the same number.
|
||||
MGPVertexBuffer left{};
|
||||
MGPVertexBuffer right{};
|
||||
right.Pad0 = 0x5A;
|
||||
EXPECT_TRUE(MGPipeVerify(left, right, &field));
|
||||
}
|
||||
|
||||
// P3a, D-G2: the two vertex wire views. They are what create_vertex_elements' blob is made
|
||||
// of, so their sizes are the blob's stride and the applier's bounds arithmetic; and IsLong is
|
||||
// carried SEPARATELY from Type, because a GL_DOUBLE format converted to float and a long
|
||||
// format that keeps all 64 bits are different requests that a backend has to tell apart.
|
||||
TEST(PipeCatalogue, VertexWireViewsAreFlatAndCarryIsLongSeparately) {
|
||||
static_assert(sizeof(MGPVertexAttribWire) == 24);
|
||||
static_assert(sizeof(MGPVertexBindingPointWire) == 16);
|
||||
EXPECT_EQ(sizeof(MGPVertexAttribWire), 24u);
|
||||
EXPECT_EQ(sizeof(MGPVertexBindingPointWire), 16u);
|
||||
|
||||
MGPVertexAttribWire a{};
|
||||
MGPVertexAttribWire b{};
|
||||
const char* field = nullptr;
|
||||
EXPECT_TRUE(MGPipeVerify(a, b, &field));
|
||||
b.Pad0 = 0x5A;
|
||||
EXPECT_TRUE(MGPipeVerify(a, b, &field));
|
||||
b.Pad0 = 0;
|
||||
// Type unchanged, IsLong moved: a comparator that folded the two would miss this.
|
||||
b.IsLong = 1;
|
||||
EXPECT_FALSE(MGPipeVerify(a, b, &field));
|
||||
EXPECT_STREQ(field, "IsLong");
|
||||
|
||||
MGPVertexBindingPointWire p{};
|
||||
MGPVertexBindingPointWire q{};
|
||||
EXPECT_TRUE(MGPipeVerify(p, q, &field));
|
||||
q.Divisor = 2;
|
||||
EXPECT_FALSE(MGPipeVerify(p, q, &field));
|
||||
EXPECT_STREQ(field, "Divisor");
|
||||
}
|
||||
|
||||
// P3a, D-A5: the tree's FIRST kNeedsAck, and the reason it is not a bare flag.
|
||||
//
|
||||
// Flags are a PER-CALL static property and resource_respecify serves both glBufferData and
|
||||
// glBufferStorage. A bare kNeedsAck on the call would acknowledge every glBufferData in a
|
||||
// world upload - a round trip per chunk store the moment a transport is under it. So the flag
|
||||
// declares that records of this call MAY need one and MGPipeResourceRespecifyNeedsAck decides
|
||||
// per record: only an immutable store, which is a real synchronous allocation.
|
||||
//
|
||||
// This is the negative control for a future flag that over-acks: in monolith the ack is a
|
||||
// no-op, so the mistake cannot be shipped from here, and the phase where it would bite
|
||||
// inherits this pin rather than the guess.
|
||||
TEST(PipeCatalogue, ResourceRespecifyAcksOnlyImmutableStorage) {
|
||||
Uint32 flags = 0;
|
||||
#define MGP_FLAGS_OF_RESOURCE_RESPECIFY(Name, Payload, Class, Flags) \
|
||||
if (std::strcmp(#Name, "ResourceRespecify") == 0) flags = static_cast<Uint32>(Flags);
|
||||
MGP_CALL_LIST(MGP_FLAGS_OF_RESOURCE_RESPECIFY)
|
||||
#undef MGP_FLAGS_OF_RESOURCE_RESPECIFY
|
||||
EXPECT_EQ(flags & static_cast<Uint32>(kNeedsAck), static_cast<Uint32>(kNeedsAck));
|
||||
// And it is the ONLY call that carries it: a second one would be a second decision, and
|
||||
// this predicate answers for exactly one call.
|
||||
Uint32 ackingCalls = 0;
|
||||
#define MGP_COUNT_ACKING_CALLS(Name, Payload, Class, Flags) \
|
||||
if ((static_cast<Uint32>(Flags) & static_cast<Uint32>(kNeedsAck)) != 0) ++ackingCalls;
|
||||
MGP_CALL_LIST(MGP_COUNT_ACKING_CALLS)
|
||||
#undef MGP_COUNT_ACKING_CALLS
|
||||
EXPECT_EQ(ackingCalls, 1u);
|
||||
|
||||
// glBufferStorage: an immutable store, and the one entry point allowed a synchronous ack.
|
||||
MGPResourceDesc immutable{};
|
||||
immutable.Immutable = 1;
|
||||
EXPECT_TRUE(MGPipeResourceRespecifyNeedsAck(immutable));
|
||||
|
||||
// glBufferData through the same call: never acknowledged, whatever else the descriptor
|
||||
// says. The usage hint and a defined initial content are the two things a "well it looks
|
||||
// synchronous" reading would key on, so both are set here on purpose.
|
||||
MGPResourceDesc mutableStore{};
|
||||
mutableStore.Immutable = 0;
|
||||
mutableStore.Usage = 0x88E4; // GL_STATIC_DRAW, i.e. the most "final-looking" hint there is
|
||||
mutableStore.HasDefinedContent = 1;
|
||||
mutableStore.Width = 64u * 1024u;
|
||||
EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(mutableStore));
|
||||
|
||||
// And the opcode did not move: a flag-word edit is not a catalogue edit.
|
||||
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::ResourceRespecify), 3);
|
||||
}
|
||||
|
||||
@@ -190,12 +190,12 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
|
||||
#if !MOBILEGL_PIPE_POISON
|
||||
GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)";
|
||||
#else
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit));
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject));
|
||||
|
||||
MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit");
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject));
|
||||
EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit));
|
||||
// The value was still copied: only the stamp is withheld.
|
||||
@@ -212,7 +212,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
|
||||
}
|
||||
}
|
||||
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit));
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetBoundVertexArray));
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetRenderStateParameters));
|
||||
@@ -220,7 +220,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::RecordError));
|
||||
|
||||
// And the omission is scoped to its verb: a different verb of the same class keeps it.
|
||||
MGPipeFillForVerb(MGPipeVerb::BindImageTexture);
|
||||
MGPipeValidateForVerb(MGPipeVerb::BindImageTexture);
|
||||
EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit));
|
||||
#endif
|
||||
}
|
||||
@@ -237,9 +237,9 @@ TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) {
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
const ChildResult r = RunInChild([] {
|
||||
MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit");
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
(void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
(void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort
|
||||
(void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal
|
||||
::_exit(3); // reached only if the poison failed
|
||||
@@ -258,9 +258,9 @@ TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) {
|
||||
#else
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
const ChildResult r = RunInChild([] {
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
(void)gPipeInputs.GetRenderStateParameters();
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
(void)gPipeInputs.GetTextureUnitObject(0);
|
||||
(void)gPipeInputs.GetActiveTextureUnit();
|
||||
});
|
||||
@@ -309,10 +309,10 @@ TEST_F(PipeInputsTest, PoisonOmitKnobArmsTheOmission) {
|
||||
GTEST_SKIP() << "no fork() on this platform";
|
||||
#else
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipePoisonOmit = kOmissionKnob;
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
(void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort
|
||||
(void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal
|
||||
::_exit(3);
|
||||
@@ -334,7 +334,7 @@ TEST_F(PipeInputsTest, BadPoisonOmitKnobIsFatalNamingTheKnob) {
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipePoisonOmit = "NoSuchVerb:GetActiveTextureUnit";
|
||||
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
::_exit(3);
|
||||
});
|
||||
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
|
||||
@@ -354,7 +354,7 @@ TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) {
|
||||
GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)";
|
||||
#else
|
||||
const Uint64 serialBefore = gPipeInputs.FilledState().CurrentVerbSerial;
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial;
|
||||
EXPECT_EQ(serial, serialBefore + 1);
|
||||
const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast<SizeT>(MGPipeVerbClass::kDraw)];
|
||||
@@ -392,11 +392,11 @@ TEST_F(PipeInputsTest, MutatedFieldIsNamedAtRead) {
|
||||
GTEST_SKIP() << "no fork() on this platform";
|
||||
#else
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
MGPipeFillForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here
|
||||
MGPipeValidateForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here
|
||||
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; // the child's DrawArrays fill
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipeVerify = true;
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
const Float boundary = gPipeInputs.GetLineWidth(); // boundary == live: completes
|
||||
(void)gPipeInputs.GetRenderStateParameters();
|
||||
MG_State::pGLContext->SetLineWidth(boundary + 1.0f);
|
||||
@@ -426,12 +426,12 @@ TEST_F(PipeInputsTest, VerifyCorruptKnobNamesTheFieldAtEntry) {
|
||||
GTEST_SKIP() << "no fork() on this platform";
|
||||
#else
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
MGPipeFillForVerb(MGPipeVerb::Clear);
|
||||
MGPipeValidateForVerb(MGPipeVerb::Clear);
|
||||
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1;
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipeVerify = true;
|
||||
MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters";
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
::_exit(3);
|
||||
});
|
||||
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
|
||||
@@ -455,7 +455,7 @@ TEST_F(PipeInputsTest, BadVerifyCorruptKnobIsFatalNamingTheKnob) {
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipeVerify = true;
|
||||
MG_Config::Features.PipeVerifyCorrupt = "NoSuchField";
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
::_exit(3);
|
||||
});
|
||||
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
|
||||
@@ -479,14 +479,14 @@ TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) {
|
||||
GTEST_SKIP() << "no fork() on this platform";
|
||||
#else
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
MGPipeFillForVerb(MGPipeVerb::Clear);
|
||||
MGPipeValidateForVerb(MGPipeVerb::Clear);
|
||||
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1;
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipeVerify = true;
|
||||
MG_Config::Features.PipeVerifyFatal = false;
|
||||
MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters";
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawElements);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawElements);
|
||||
std::exit(0);
|
||||
});
|
||||
ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log;
|
||||
@@ -511,7 +511,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) {
|
||||
#else
|
||||
for (SizeT v = 0; v < kMGPipeVerbCount; ++v) {
|
||||
const auto verb = static_cast<MGPipeVerb>(v);
|
||||
MGPipeFillForVerb(verb);
|
||||
MGPipeValidateForVerb(verb);
|
||||
const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast<SizeT>(kMGPipeVerbClass[v])];
|
||||
for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) {
|
||||
const auto field = static_cast<MGPipeInputField>(f);
|
||||
@@ -532,7 +532,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) {
|
||||
// Without the notice the two reads below differ and the pushed value is the stale one.
|
||||
TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbRefreshesThePushedField) {
|
||||
auto& ctx = *MG_State::pGLContext;
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
ASSERT_EQ(gPipeInputs.GetSamplingResolutionGeneration(), ctx.GetSamplingResolutionGeneration());
|
||||
ASSERT_EQ(gPipeInputs.GetTextureBindGeneration(), ctx.GetTextureBindGeneration());
|
||||
ASSERT_EQ(gPipeInputs.GetMaxTouchedTextureUnit(), ctx.GetMaxTouchedTextureUnit());
|
||||
@@ -566,7 +566,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) {
|
||||
#else
|
||||
auto& ctx = *MG_State::pGLContext;
|
||||
MGPipeSetPoisonOmission("DrawArrays", "GetSamplingResolutionGeneration");
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration));
|
||||
ctx.BumpSamplingResolutionGeneration();
|
||||
EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration))
|
||||
@@ -575,7 +575,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) {
|
||||
// FenceSync is a kQuery verb: its mask holds no texture field at all, so the notice must
|
||||
// leave the generation unfilled and a read of it Fatal{UnmigratedPipeInput}.
|
||||
MGPipeSetPoisonOmission(nullptr, nullptr);
|
||||
MGPipeFillForVerb(MGPipeVerb::FenceSync);
|
||||
MGPipeValidateForVerb(MGPipeVerb::FenceSync);
|
||||
ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration));
|
||||
ctx.BumpSamplingResolutionGeneration();
|
||||
EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration))
|
||||
@@ -598,7 +598,7 @@ TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbDoesNotDivergeAtRead) {
|
||||
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
|
||||
const ChildResult r = RunInChild([] {
|
||||
MG_Config::Features.PipeVerify = true;
|
||||
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
|
||||
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
|
||||
(void)gPipeInputs.GetSamplingResolutionGeneration(); // boundary == live: completes
|
||||
auto& ctx = *MG_State::pGLContext;
|
||||
const Uint64 before = ctx.GetSamplingResolutionGeneration();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
// MobileGL - MobileGL/MG_Test/Pipe/SlotAllocatorTest.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
|
||||
|
||||
// The client slot allocator's IDENTITY CONTRACT (P2 brief C.0 c3). Everything Track H keys
|
||||
// off - Espryt's six slot tables and Magma's VaoDrawMemo - is only as sound as these five
|
||||
// statements, and each of them is the answer to a bug the {slot, gen} pair exists to close:
|
||||
//
|
||||
// GenMovesOnlyOnSlotReuse - a respecify must NOT move the generation, or every
|
||||
// glBufferData would invalidate every memo; a REUSE
|
||||
// must, or a stale handle would address its successor.
|
||||
// FreedSlotComesBackBeforeHighWater - slots stay DENSE, which is what lets the server's
|
||||
// object table be an array rather than a hash map.
|
||||
// SlotZeroIsNeverHandedOut - {0, 0} is the null handle for every kind and {0, 1}
|
||||
// is the default framebuffer.
|
||||
// LifetimeIdSurvivesARecycledAddress - the frontend key is the lifetime id, never a heap
|
||||
// address and never a GL name, so an ABA on either
|
||||
// cannot reproduce a handle. This is the ABA
|
||||
// HandleRecycleScenario reproduces end to end.
|
||||
// CompositeShaderBandIsNeverHandedOut- the top 1/16 of the ShaderCso slot space belongs to
|
||||
// the program-pipeline composite resolver.
|
||||
//
|
||||
// Needs the push sources (SlotAllocator.cpp is compiled only under MOBILEGL_PIPE_PUSH), so
|
||||
// every case is a visible SKIP in a pull build rather than a vanishing test, and the five
|
||||
// names are the same five in every build.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Pipe;
|
||||
|
||||
namespace {
|
||||
// Slot 0 is reserved for every kind - null, and the default framebuffer for kind
|
||||
// Framebuffer - so the first allocatable slot is 1 in every build, pull included.
|
||||
TEST(SlotAllocator, ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre) {
|
||||
EXPECT_EQ(kMGPipeFirstAllocatableSlot, 1u);
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle));
|
||||
EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer));
|
||||
EXPECT_EQ(kMGPipeDefaultFramebuffer.Slot, 0u);
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, GenMovesOnlyOnSlotReuse) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
|
||||
#else
|
||||
MGPipeSlotAllocator allocator;
|
||||
|
||||
const MGPipeHandle first = allocator.Allocate(MGPipeKind::Buffer);
|
||||
const MGPipeHandle second = allocator.Allocate(MGPipeKind::Buffer);
|
||||
EXPECT_EQ(first.Gen, 0u) << "a slot's FIRST handout is generation 0";
|
||||
EXPECT_EQ(second.Gen, 0u);
|
||||
EXPECT_NE(first.Slot, second.Slot);
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, first));
|
||||
|
||||
// A respecify is not an event here at all: nothing in the allocator's interface can
|
||||
// move a live handle's generation, which is the contract "gen increments only when a
|
||||
// slot is REUSED, never on a respecify" stated as an absence.
|
||||
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
|
||||
|
||||
allocator.Free(MGPipeKind::Buffer, first);
|
||||
EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first));
|
||||
// The bump happens on the NEXT handout, not on the free, so a slot that is freed and
|
||||
// never reused keeps its generation - and a double free cannot skip one.
|
||||
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
|
||||
allocator.Free(MGPipeKind::Buffer, first);
|
||||
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
|
||||
|
||||
const MGPipeHandle reused = allocator.Allocate(MGPipeKind::Buffer);
|
||||
EXPECT_EQ(reused.Slot, first.Slot) << "the free list did not hand the slot back";
|
||||
EXPECT_NE(reused.Gen, first.Gen) << "a REUSED slot must carry a new generation";
|
||||
EXPECT_EQ(reused.Gen, first.Gen + 1);
|
||||
|
||||
// THE POINT OF THE GENERATION: the dead handle is not the live one, it does not
|
||||
// validate, and it cannot free the slot its successor now owns.
|
||||
EXPECT_FALSE(first == reused);
|
||||
EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first));
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused));
|
||||
allocator.Free(MGPipeKind::Buffer, first);
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused)) << "a stale handle freed a live slot";
|
||||
|
||||
// Kinds are independent slot spaces: a Buffer slot 1 and a Texture slot 1 are
|
||||
// different objects, and freeing one must not touch the other.
|
||||
const MGPipeHandle texture = allocator.Allocate(MGPipeKind::Texture);
|
||||
EXPECT_EQ(texture.Slot, kMGPipeFirstAllocatableSlot);
|
||||
EXPECT_EQ(texture.Gen, 0u);
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Texture, texture));
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused));
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, FreedSlotComesBackBeforeHighWaterGrows) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
|
||||
#else
|
||||
MGPipeSlotAllocator allocator;
|
||||
|
||||
MGPipeHandle handles[8];
|
||||
for (MGPipeHandle& handle : handles) handle = allocator.Allocate(MGPipeKind::Framebuffer);
|
||||
const Uint32 highWater = allocator.HighWater(MGPipeKind::Framebuffer);
|
||||
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 8u);
|
||||
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u);
|
||||
|
||||
allocator.Free(MGPipeKind::Framebuffer, handles[2]);
|
||||
allocator.Free(MGPipeKind::Framebuffer, handles[5]);
|
||||
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 6u);
|
||||
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 2u);
|
||||
|
||||
// Density is the whole reason the server's object table can be an array: the two
|
||||
// freed slots have to come back before a ninth is minted.
|
||||
const MGPipeHandle a = allocator.Allocate(MGPipeKind::Framebuffer);
|
||||
const MGPipeHandle b = allocator.Allocate(MGPipeKind::Framebuffer);
|
||||
EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), highWater) << "the high-water mark grew with two "
|
||||
"slots waiting on the free list";
|
||||
EXPECT_TRUE((a.Slot == handles[2].Slot && b.Slot == handles[5].Slot) ||
|
||||
(a.Slot == handles[5].Slot && b.Slot == handles[2].Slot))
|
||||
<< "the reused slots are not the two that were freed";
|
||||
|
||||
// Only now does the mark move.
|
||||
const MGPipeHandle fresh = allocator.Allocate(MGPipeKind::Framebuffer);
|
||||
EXPECT_GT(allocator.HighWater(MGPipeKind::Framebuffer), highWater);
|
||||
EXPECT_EQ(fresh.Gen, 0u) << "a slot handed out for the FIRST time is generation 0";
|
||||
|
||||
allocator.Reset();
|
||||
EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), 0u);
|
||||
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 0u);
|
||||
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u);
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, SlotZeroIsNeverHandedOut) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
|
||||
#else
|
||||
MGPipeSlotAllocator allocator;
|
||||
for (SizeT kindIndex = 1; kindIndex < MGPipeSlotAllocator::kKindCount; ++kindIndex) {
|
||||
const MGPipeKind kind = static_cast<MGPipeKind>(kindIndex);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const MGPipeHandle handle = allocator.Allocate(kind);
|
||||
EXPECT_GE(handle.Slot, kMGPipeFirstAllocatableSlot)
|
||||
<< "kind " << kindIndex << " handed out the reserved slot";
|
||||
EXPECT_FALSE(MGPipeHandleIsNull(handle));
|
||||
// {0, 1} is the default framebuffer and must never be minted either.
|
||||
EXPECT_FALSE(handle == kMGPipeDefaultFramebuffer);
|
||||
allocator.Free(kind, handle);
|
||||
}
|
||||
}
|
||||
// Freeing a slot never puts 0 on the free list, so a churned kind still starts at 1.
|
||||
const MGPipeHandle again = allocator.Allocate(MGPipeKind::Buffer);
|
||||
EXPECT_EQ(again.Slot, kMGPipeFirstAllocatableSlot);
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, LifetimeIdSurvivesARecycledAddress) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
|
||||
#else
|
||||
using MG_State::GLState::VertexArrayObject;
|
||||
|
||||
// The allocation must actually happen: C++ permits eliding a new/delete pair, and an
|
||||
// elided one would let two objects share an address for reasons that have nothing to
|
||||
// do with the allocator. Publishing every pointer through a volatile sink keeps the
|
||||
// pairs (MG_Test/State/ObjectLifetimeIdTest.cpp's trick, and the same one
|
||||
// HandleRecycleScenario uses to reproduce the ABA through public GL).
|
||||
static void* volatile addressSink = nullptr;
|
||||
|
||||
MGPipeSlotAllocator allocator;
|
||||
std::unordered_map<std::uintptr_t, MGPipeHandle> handleAtAddress;
|
||||
int reuseCount = 0;
|
||||
|
||||
for (int attempt = 0; attempt < 64; ++attempt) {
|
||||
auto object = std::make_unique<VertexArrayObject>(0u);
|
||||
addressSink = object.get();
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(object.get());
|
||||
const Uint64 lifetimeId = object->GetLifetimeId();
|
||||
|
||||
// Acquire is the ordinary client path: find by lifetime id, allocate on a miss.
|
||||
const MGPipeHandle handle = allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId);
|
||||
EXPECT_FALSE(MGPipeHandleIsNull(handle));
|
||||
// Asking again with the same live object must answer the SAME handle - that is
|
||||
// what makes the map an identity rather than a counter.
|
||||
EXPECT_TRUE(allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId) == handle);
|
||||
EXPECT_EQ(allocator.LifetimeIdOfSlot(MGPipeKind::VertexElementsCso, handle.Slot), lifetimeId);
|
||||
|
||||
const auto previous = handleAtAddress.find(address);
|
||||
if (previous != handleAtAddress.end()) {
|
||||
++reuseCount;
|
||||
// THE ABA. The heap handed the same address back, and the handle must still
|
||||
// be a different one - either a different slot, or the same slot with a new
|
||||
// generation. If this ever held, an address-keyed memo would serve the dead
|
||||
// object's entry to the live one, which is the bug Track H removes.
|
||||
EXPECT_FALSE(previous->second == handle)
|
||||
<< "a recycled heap address reproduced handle {slot=" << handle.Slot << ", gen=" << handle.Gen
|
||||
<< "}";
|
||||
}
|
||||
handleAtAddress[address] = handle;
|
||||
|
||||
// The object dies; the client's death notification frees the slot.
|
||||
allocator.Free(MGPipeKind::VertexElementsCso, handle);
|
||||
EXPECT_FALSE(allocator.IsLive(MGPipeKind::VertexElementsCso, handle));
|
||||
// And the lifetime id stops resolving, so a late lookup cannot resurrect it.
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeId(MGPipeKind::VertexElementsCso, lifetimeId)));
|
||||
}
|
||||
|
||||
// Whether the heap repeats an address is the machine's business, not the allocator's,
|
||||
// so the count is RECORDED and the case does not depend on it: the arm below proves
|
||||
// the same property without waiting for luck, and it proves a STRICTLY STRONGER form
|
||||
// of it. Acquire never sees an address at all (SlotAllocator.h) - it sees a lifetime
|
||||
// id - so the sharpest possible ABA is not "the same address came back" but "the same
|
||||
// LIFETIME ID came back", which is the key the map is actually built on. MG_State
|
||||
// never reissues one, so this can only be built by hand; if even that cannot
|
||||
// reproduce a handle, no recycled address can either.
|
||||
RecordProperty("address_reuses_observed", reuseCount);
|
||||
|
||||
MGPipeSlotAllocator sharp;
|
||||
const Uint64 repeatedLifetimeId = 0x5eed'0000'0000'0001ull;
|
||||
const MGPipeHandle first = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId);
|
||||
EXPECT_FALSE(MGPipeHandleIsNull(first));
|
||||
sharp.Free(MGPipeKind::VertexElementsCso, first);
|
||||
const MGPipeHandle second = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId);
|
||||
EXPECT_FALSE(second == first)
|
||||
<< "re-acquiring the SAME lifetime id after a free reproduced handle {slot=" << first.Slot
|
||||
<< ", gen=" << first.Gen << "}; an address-keyed or name-keyed memo would then serve the dead "
|
||||
"object's entry to the live one";
|
||||
EXPECT_EQ(second.Slot, first.Slot) << "the freed slot was not the one handed back";
|
||||
EXPECT_EQ(second.Gen, first.Gen + 1) << "a reused slot must carry a new generation";
|
||||
// And the dead handle stays dead, which is what makes the ABA detectable rather than
|
||||
// merely unlikely.
|
||||
EXPECT_FALSE(sharp.IsLive(MGPipeKind::VertexElementsCso, first));
|
||||
EXPECT_TRUE(sharp.IsLive(MGPipeKind::VertexElementsCso, second));
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, CompositeShaderBandIsNeverHandedOut) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
|
||||
#else
|
||||
MGPipeSlotAllocator allocator;
|
||||
// Ordinary programs walk the low slots and never enter the band.
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const MGPipeHandle handle = allocator.Allocate(MGPipeKind::ShaderCso);
|
||||
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(handle.Slot));
|
||||
}
|
||||
|
||||
// Every other kind is unaffected: the band is a ShaderCso rule, not a global one.
|
||||
{
|
||||
MGPipeSlotAllocator plain;
|
||||
for (Uint32 i = 0; i < 4; ++i) {
|
||||
const MGPipeHandle handle = plain.Allocate(MGPipeKind::Buffer);
|
||||
EXPECT_EQ(handle.Slot, kMGPipeFirstAllocatableSlot + i);
|
||||
}
|
||||
}
|
||||
|
||||
// ONLY THE EXHAUSTION ARM needs the DEBUG skip, and it is placed here so the two arms
|
||||
// above run in every build. Walking the ShaderCso slot space up to the band is what
|
||||
// proves the band is held back, and reaching the band's edge trips the allocator's
|
||||
// own "slot space is exhausted" MOBILEGL_ASSERT - which is live, and correctly so, in
|
||||
// a DEBUG build. The claim is checked in the INFO builds every gate runs.
|
||||
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
|
||||
GTEST_SKIP() << "asserts are live in a DEBUG build and only the exhaustion arm trips one on purpose";
|
||||
#else
|
||||
// Walk the whole space up to the band. The last handout below the base must be the
|
||||
// slot immediately under it, and the next call must refuse rather than step in - a
|
||||
// composite handle minted by the ordinary allocator would collide with one the
|
||||
// program-pipeline resolver mints for a different object entirely.
|
||||
MGPipeHandle last = kMGPipeNullHandle;
|
||||
while (allocator.HighWater(MGPipeKind::ShaderCso) < kMGPipeShaderCsoCompositeSlotBase) {
|
||||
last = allocator.Allocate(MGPipeKind::ShaderCso);
|
||||
ASSERT_FALSE(MGPipeIsCompositeShaderSlot(last.Slot))
|
||||
<< "the ordinary allocator entered the composite band at slot " << last.Slot;
|
||||
}
|
||||
EXPECT_EQ(last.Slot, kMGPipeShaderCsoCompositeSlotBase - 1);
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(allocator.Allocate(MGPipeKind::ShaderCso)))
|
||||
<< "the allocator handed out a composite-band slot instead of refusing";
|
||||
#endif // the DEBUG guard on the exhaustion arm
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
}
|
||||
} // namespace
|
||||
@@ -0,0 +1,917 @@
|
||||
// MobileGL - MobileGL/MG_Test/Pipe/TrackerTest.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
|
||||
|
||||
// The frontend state tracker: the dirty walk, the aggregate generations, the per-bit fire
|
||||
// counters (P2 brief D4). Owned by P2 package B (p2/tracker); the file and its CMake
|
||||
// registration are the contract commit's.
|
||||
//
|
||||
// Needs the push sources, so every case is a visible SKIP in a pull build rather than a
|
||||
// vanishing test - the shape PipeInputsTest.cpp established.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/CsoCache.h>
|
||||
#include <MG_Impl/Pipe/PipeFill.h>
|
||||
#include <MG_Impl/Pipe/SetHashSuppressor.h>
|
||||
#include <MG_Impl/Pipe/Tracker.h>
|
||||
#include <MG_Impl/Pipe/VertexInputEmit.h>
|
||||
#include <MG_Pipe/MGPipeRenderStateSpans.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Metrics/PipeStats.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Pipe;
|
||||
|
||||
namespace {
|
||||
// The subsystem bitmask the tracker dispatches on is allocated in every build.
|
||||
TEST(Tracker, SubsystemBitsDoNotOverlapTheBehaviourBit) {
|
||||
EXPECT_EQ(kMGPipeSubsystemsMigratedAtP2 & kMGPipeBehaviourNoCsoContentAddressing, 0ull);
|
||||
}
|
||||
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A
|
||||
// push-only case therefore cannot be ABSENT from a pull build; it has to be there and
|
||||
// SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list
|
||||
// declares exactly the suite.name pairs the push build gets from the real cases below, so
|
||||
// a case added on one side and forgotten on the other shows up as a ctest-name diff
|
||||
// rather than as a test that silently is not there.
|
||||
#define MGL_TRACKER_TEST_LIST(X) \
|
||||
X(TrackerAggregates, EveryAggregateStartsAtZero) \
|
||||
X(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) \
|
||||
X(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) \
|
||||
X(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) \
|
||||
X(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) \
|
||||
X(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) \
|
||||
X(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) \
|
||||
X(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) \
|
||||
X(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) \
|
||||
X(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) \
|
||||
X(TrackerWalk, EveryBitHasAName) \
|
||||
X(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) \
|
||||
X(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) \
|
||||
X(TrackerWalk, SteadyStateEmitsNothing) \
|
||||
X(TrackerWalk, BlendToggleReusesTwoCsos) \
|
||||
X(TrackerWalk, ViewportDoesNotMintACso) \
|
||||
X(TrackerWalk, WrapAroundRePushesButNeverMisses) \
|
||||
X(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) \
|
||||
X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \
|
||||
X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \
|
||||
X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) \
|
||||
X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \
|
||||
X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \
|
||||
X(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) \
|
||||
X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \
|
||||
X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \
|
||||
X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \
|
||||
X(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) \
|
||||
X(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) \
|
||||
X(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) \
|
||||
X(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) \
|
||||
X(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) \
|
||||
X(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) \
|
||||
X(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) \
|
||||
X(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) \
|
||||
X(TrackerShippedEmitter, ABaseInstancedDrawAfterAMakeCurrentPublishesItsOwnBaseInstance)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_TRACKER_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
|
||||
using MG_State::GLState::TextureObjectBase;
|
||||
using MobileGL::TextureTarget;
|
||||
|
||||
constexpr SizeT kAggregateCount = static_cast<SizeT>(MGPipeAggregate::Count);
|
||||
|
||||
// A live frontend context for the bump points to find, restored on the way out so the
|
||||
// cases stay independent (PipeInputsTest's idiom).
|
||||
class TrackerAggregates : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_previous = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
}
|
||||
void TearDown() override { MG_State::pGLContext = Move(m_previous); }
|
||||
|
||||
static GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
|
||||
struct Snapshot {
|
||||
Uint64 Values[kAggregateCount];
|
||||
Uint64 operator[](MGPipeAggregate a) const { return Values[static_cast<SizeT>(a)]; }
|
||||
};
|
||||
|
||||
static Snapshot Snap() {
|
||||
GLContext& c = Ctx();
|
||||
Snapshot s{};
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::VaoAttribute)] = c.GetAnyVaoAttributeGeneration();
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::FramebufferAttachment)] =
|
||||
c.GetAnyFramebufferAttachmentGeneration();
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::TextureContent)] = c.GetAnyTextureContentGeneration();
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::TextureParams)] = c.GetAnyTextureParamsGeneration();
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::BufferChange)] = c.GetAnyBufferChangeGeneration();
|
||||
s.Values[static_cast<SizeT>(MGPipeAggregate::VertexAttribDefault)] =
|
||||
c.GetAnyVertexAttribDefaultGeneration();
|
||||
return s;
|
||||
}
|
||||
|
||||
// The whole contract of an aggregate generation in one assertion: the bump point
|
||||
// moved ITS counter and moved NO other. The second half is what stops a bump point
|
||||
// being wired to the wrong aggregate, which would over-fire one dirty bit and
|
||||
// under-fire another - and under-firing is the direction that renders stale.
|
||||
static void ExpectOnly(MGPipeAggregate moved, const Snapshot& before, const Snapshot& after) {
|
||||
for (SizeT i = 0; i < kAggregateCount; ++i) {
|
||||
const auto which = static_cast<MGPipeAggregate>(i);
|
||||
if (which == moved) {
|
||||
EXPECT_GT(after.Values[i], before.Values[i]) << "aggregate " << i << " did not move";
|
||||
} else {
|
||||
EXPECT_EQ(after.Values[i], before.Values[i]) << "aggregate " << i << " moved and must not";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UniquePtr<GLContext> m_previous;
|
||||
};
|
||||
|
||||
TEST_F(TrackerAggregates, EveryAggregateStartsAtZero) {
|
||||
const Snapshot s = Snap();
|
||||
for (SizeT i = 0; i < kAggregateCount; ++i) EXPECT_EQ(s.Values[i], 0ull);
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) {
|
||||
const auto& vao = Ctx().CreateVertexArrayObject(1);
|
||||
ASSERT_TRUE(vao != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
vao->EnableAttribute(3);
|
||||
ExpectOnly(MGPipeAggregate::VaoAttribute, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) {
|
||||
const auto& fbo = Ctx().CreateFramebufferObject(1);
|
||||
ASSERT_TRUE(fbo != nullptr);
|
||||
fbo->SetReadBuffer(FramebufferAttachmentType::Color0);
|
||||
const Snapshot before = Snap();
|
||||
fbo->SetReadBuffer(FramebufferAttachmentType::Color1);
|
||||
ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) {
|
||||
const auto& fbo = Ctx().CreateFramebufferObject(2);
|
||||
ASSERT_TRUE(fbo != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
// The five MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER bodies are one macro, so the
|
||||
// bump statement inside it has to carry its own line continuation or the macro
|
||||
// silently swallows the next line. This case is what says it did not.
|
||||
fbo->SetDefaultWidth(64);
|
||||
ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) {
|
||||
const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D);
|
||||
ASSERT_TRUE(tex != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
static_cast<TextureObjectBase*>(tex.get())->BumpContentVersion();
|
||||
ExpectOnly(MGPipeAggregate::TextureContent, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) {
|
||||
const auto& tex = Ctx().CreateTextureObject(2, TextureTarget::Texture2D);
|
||||
ASSERT_TRUE(tex != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
tex->SetMaxLevel(4);
|
||||
ExpectOnly(MGPipeAggregate::TextureParams, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) {
|
||||
const auto& sampler = Ctx().CreateSamplerObject(1);
|
||||
ASSERT_TRUE(sampler != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
sampler->SetWrapS(MobileGL::SamplerWrapMode::ClampToEdge);
|
||||
ExpectOnly(MGPipeAggregate::TextureParams, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) {
|
||||
const auto& buffer = Ctx().CreateBufferObject(1);
|
||||
ASSERT_TRUE(buffer != nullptr);
|
||||
const Snapshot before = Snap();
|
||||
buffer->Respecify(64, nullptr);
|
||||
ExpectOnly(MGPipeAggregate::BufferChange, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) {
|
||||
const Snapshot before = Snap();
|
||||
Ctx().SetCurrentVertexAttributeFloat(2, Array<Float, 4>{1.0f, 2.0f, 3.0f, 4.0f});
|
||||
ExpectOnly(MGPipeAggregate::VertexAttribDefault, before, Snap());
|
||||
}
|
||||
|
||||
TEST_F(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) {
|
||||
UniquePtr<GLContext> held = Move(MG_State::pGLContext);
|
||||
MGP_NOTE_AGGREGATE(BufferChange); // must not dereference a null context
|
||||
MG_State::pGLContext = Move(held);
|
||||
SUCCEED();
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7)
|
||||
// ===================================================================================
|
||||
//
|
||||
// These drive the tracker and the cache DIRECTLY rather than through
|
||||
// MGPipeValidateForVerb. That is deliberate: MGPipeValidateForVerb reaches the library's
|
||||
// one process-wide tracker, and a unit test that asserts on a shared singleton is a test
|
||||
// that fails when ctest runs the suite in parallel. The emission logic these reproduce is
|
||||
// three lines long and is the same three lines the validate point runs.
|
||||
// Resetting the applier without resetting the process-wide cache and tracker would leave
|
||||
// the next bind_render_state naming a CSO the applier no longer has - it asserts and
|
||||
// returns, leaving m_renderState unwritten. The three are one state, so they are reset
|
||||
// together, here and in TrackerShippedEmitter.
|
||||
// P3a adds two more pieces to that one state. MGPipeApplierReset is a MAKE-CURRENT and
|
||||
// deliberately keeps the object records now, so a fixture that means "this applier is
|
||||
// going away" has to say the other verb as well (PipeApply.h); and the vertex-input
|
||||
// emitter's latches say "this handle has already published this configuration" about an
|
||||
// applier that is about to be empty, so a bind would be suppressed against a record that
|
||||
// is no longer there.
|
||||
void ResetTheServerSideSingletons() {
|
||||
MGPipeApplierReset();
|
||||
MGPipeApplierReleaseObjectRecords();
|
||||
MGPipeCsoCacheInstance().Reset();
|
||||
MGPipeCsoCacheInstance().ResetCounters();
|
||||
MGPipeTrackerInstance().Reset();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
MGPipeVertexInputEmitterInstance().Reset();
|
||||
MGPipeVertexInputEmitterInstance().ResetCounters();
|
||||
}
|
||||
|
||||
class TrackerWalk : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_previous = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
m_savedPush = MG_Config::Features.PipePush;
|
||||
ResetTheServerSideSingletons();
|
||||
}
|
||||
void TearDown() override {
|
||||
MG_Config::Features.PipePush = m_savedPush;
|
||||
ResetTheServerSideSingletons();
|
||||
MG_State::pGLContext = Move(m_previous);
|
||||
}
|
||||
|
||||
static GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
|
||||
// What MGPipeValidateForVerb's step 3 does, minus the PipeStats plumbing: acquire a
|
||||
// CSO when the pipeline version moved, and compute the dynamic chunk mask when
|
||||
// m_version moved.
|
||||
Uint32 Walk(MGPipeVerbClass verbClass = MGPipeVerbClass::kDraw) {
|
||||
const Uint32 dirty = m_tracker.Update(Ctx(), verbClass);
|
||||
const RenderStateParameters& live = Ctx().GetRenderStateParameters();
|
||||
m_lastDynamicMask = 0;
|
||||
if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) {
|
||||
m_lastCso = m_cache.Acquire(live, m_payloadBytes);
|
||||
++m_binds;
|
||||
}
|
||||
if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) {
|
||||
m_lastDynamicMask = m_tracker.FreshlyPrimed()
|
||||
? ~0u
|
||||
: MGPipeDynamicChunksThatMoved(live, m_tracker.Staged());
|
||||
}
|
||||
if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) |
|
||||
MGPipeDirtyBit(MGPipeDirty::NewRenderState))) {
|
||||
m_tracker.Staged() = live;
|
||||
}
|
||||
return dirty;
|
||||
}
|
||||
|
||||
MGPipeTracker m_tracker;
|
||||
MGPipeCsoCache m_cache;
|
||||
MGPipeHandle m_lastCso = kMGPipeNullHandle;
|
||||
Uint32 m_lastDynamicMask = 0;
|
||||
Uint64 m_payloadBytes = 0;
|
||||
Uint64 m_binds = 0;
|
||||
Uint64 m_savedPush = 0;
|
||||
UniquePtr<GLContext> m_previous;
|
||||
};
|
||||
|
||||
TEST_F(TrackerWalk, EveryBitHasAName) {
|
||||
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
|
||||
ASSERT_NE(kMGPipeDirtyNames[i], nullptr);
|
||||
EXPECT_EQ(std::string(kMGPipeDirtyNames[i]).rfind("NEW_", 0), 0u);
|
||||
}
|
||||
}
|
||||
|
||||
// The five P2 emits for each name their own subsystem; the rest name none, which is what
|
||||
// makes MOBILEGL_PIPE_PUSH a per-subsystem A/B instead of one switch.
|
||||
// THE NAME IS P2's AND IT STAYS. A test name is never removed (only added), so this case
|
||||
// keeps the name it was born with and follows the phase constant instead of a literal
|
||||
// five: what it has always asserted is "a bit names a subsystem if and only if this build
|
||||
// emits a call for it", which is the property the emission gate and the residual-fill
|
||||
// skip both rest on. P3a took the vertex-input family over, so the set it compares
|
||||
// against is now kMGPipeDirtyEmittedAtP3a - and a bit that gained an arm without gaining
|
||||
// an emitter, or the reverse, still fails here.
|
||||
TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) {
|
||||
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
|
||||
const auto bit = static_cast<MGPipeDirty>(i);
|
||||
const Bool emitted = (kMGPipeDirtyEmittedAtP3a & MGPipeDirtyBit(bit)) != 0;
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i];
|
||||
}
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState);
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack);
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState);
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults),
|
||||
kMGPipeSubsystemVertexAttribDefaults);
|
||||
// P3a's three, one subsystem: an operator who clears bit 8 gets the whole legacy
|
||||
// vertex-input arm rather than two thirds of it.
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements), kMGPipeSubsystemVertexInput);
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers), kMGPipeSubsystemVertexInput);
|
||||
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer), kMGPipeSubsystemVertexInput);
|
||||
}
|
||||
|
||||
TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) {
|
||||
const Uint32 dirty = Walk();
|
||||
EXPECT_TRUE(m_tracker.FreshlyPrimed());
|
||||
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
|
||||
EXPECT_NE(dirty & (Uint32{1} << static_cast<Uint32>(i)), 0u)
|
||||
<< kMGPipeDirtyNames[i] << " did not fire on a fresh context";
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of a validate-point tracker: two identical draws in a row cost two
|
||||
// Uint16 compares and emit nothing at all.
|
||||
TEST_F(TrackerWalk, SteadyStateEmitsNothing) {
|
||||
Walk();
|
||||
const Uint64 mintsAfterFirst = m_cache.GetCounters().Mints;
|
||||
const Uint64 bindsAfterFirst = m_binds;
|
||||
for (int i = 0; i < 8; ++i) EXPECT_EQ(Walk(), 0u) << "walk " << i << " fired with nothing moved";
|
||||
EXPECT_EQ(m_cache.GetCounters().Mints, mintsAfterFirst);
|
||||
EXPECT_EQ(m_binds, bindsAfterFirst);
|
||||
}
|
||||
|
||||
// The Blaze3D shape ARCHITECTURE.md 5.1 names as the reason push happens at validate and
|
||||
// not in the setter: enable / draw / disable / draw forever mints exactly TWO CSOs and
|
||||
// reuses them for every toggle after that.
|
||||
TEST_F(TrackerWalk, BlendToggleReusesTwoCsos) {
|
||||
constexpr int kToggles = 32;
|
||||
Walk(); // prime
|
||||
// The priming walk already minted and cached the blend-DISABLED state, so the cache
|
||||
// starts empty here or the count below would be one short of the shape it describes.
|
||||
m_cache.Reset();
|
||||
m_cache.ResetCounters();
|
||||
m_binds = 0;
|
||||
for (int i = 0; i < kToggles; ++i) {
|
||||
Ctx().SetCapability(CapabilityInput::Blend, true);
|
||||
Walk();
|
||||
Ctx().SetCapability(CapabilityInput::Blend, false);
|
||||
Walk();
|
||||
}
|
||||
EXPECT_EQ(m_cache.GetCounters().Mints, 2u)
|
||||
<< "a two-state ping-pong must mint two CSOs and then never mint again";
|
||||
EXPECT_EQ(m_binds, static_cast<Uint64>(2 * kToggles));
|
||||
EXPECT_EQ(m_cache.GetCounters().Hits, static_cast<Uint64>(2 * kToggles - 2));
|
||||
EXPECT_EQ(m_cache.Size(), 2u);
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// The regression RenderState.h records: a glViewport must not evict a cached pipeline.
|
||||
// It mints nothing and its payload is one dynamic chunk - D0, the viewports - and not the
|
||||
// other seven.
|
||||
TEST_F(TrackerWalk, ViewportDoesNotMintACso) {
|
||||
Walk(); // prime
|
||||
m_cache.ResetCounters();
|
||||
for (Int i = 1; i <= 16; ++i) {
|
||||
Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i));
|
||||
const Uint32 dirty = Walk();
|
||||
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u);
|
||||
EXPECT_EQ(dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u)
|
||||
<< "glViewport moved the PIPELINE version";
|
||||
EXPECT_EQ(m_lastDynamicMask, 1u) << "glViewport sent something other than chunk D0";
|
||||
}
|
||||
EXPECT_EQ(m_cache.GetCounters().Mints, 0u);
|
||||
EXPECT_EQ(m_binds, 1u) << "only the priming walk may bind";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// RenderState's two shutters are Uint16 and the tracker widens them in its OWN state,
|
||||
// never in MG_State. A wrap must cost an extra re-push at worst and never a missed one.
|
||||
TEST_F(TrackerWalk, WrapAroundRePushesButNeverMisses) {
|
||||
Walk(); // prime
|
||||
constexpr int kMoves = 70000; // past 65535 with room to spare
|
||||
Uint64 fired = 0;
|
||||
for (int i = 0; i < kMoves; ++i) {
|
||||
// Never the default 1.0f: a setter that early-outs on an unchanged value would
|
||||
// not move m_version, and the first iteration would then be a false miss.
|
||||
Ctx().SetLineWidth((i & 1) ? 2.0f : 3.0f);
|
||||
if (Walk() & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) ++fired;
|
||||
}
|
||||
EXPECT_EQ(fired, static_cast<Uint64>(kMoves))
|
||||
<< "a Uint16 wrap swallowed a render-state change";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// The one direction the P1 verify comparator cannot see: it compares object-class fields
|
||||
// by IDENTITY only, so a bound texture whose CONTENT moved looks unchanged to it.
|
||||
// ARCHITECTURE.md 13.2 names under-firing as the dangerous direction, and this is the
|
||||
// first test of it.
|
||||
TEST_F(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) {
|
||||
const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D);
|
||||
ASSERT_TRUE(tex != nullptr);
|
||||
Walk(); // prime
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u);
|
||||
|
||||
static_cast<TextureObjectBase*>(tex.get())->BumpContentVersion();
|
||||
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
|
||||
<< "a bound texture's content moved and NEW_SAMPLER_VIEWS did not fire";
|
||||
// and it settles again, so the bit is a shutter and not a stuck flag
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u);
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// A NaN outer level is a legal glPatchParameterfv value and has to compare equal to
|
||||
// itself, which float equality does not do and a byte compare does.
|
||||
TEST_F(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) {
|
||||
Walk(); // prime
|
||||
Ctx().SetPatchDefaultOuterLevel(
|
||||
FloatVec4(std::numeric_limits<Float>::quiet_NaN(), 1.0f, 1.0f, 1.0f));
|
||||
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u);
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u)
|
||||
<< "a NaN patch level re-fired against itself";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
TEST_F(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) {
|
||||
Walk(); // prime
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
|
||||
Ctx().SetPixelStoreParam(PixelStoreParam::PackAlignment, 8);
|
||||
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
|
||||
// The UNPACK half has no carrier at all, so it must not move the pack shutter.
|
||||
Ctx().SetPixelStoreParam(PixelStoreParam::UnpackAlignment, 8);
|
||||
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u)
|
||||
<< "an unpack write moved the PACK shutter";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
TEST_F(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) {
|
||||
// PipeStats is off in a unit-test process, which is the state the ROADMAP rule about
|
||||
// hot-path instrumentation cares about: the walk must cost nothing extra there.
|
||||
ASSERT_FALSE(MG_Util::PipeStats::Enabled());
|
||||
Walk();
|
||||
Ctx().SetLineWidth(3.0f);
|
||||
Walk();
|
||||
EXPECT_EQ(m_tracker.WalkCount(), 0u);
|
||||
EXPECT_EQ(m_tracker.FireCount(MGPipeDirty::NewRenderState), 0u);
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// P3a D-I: bit 10's narrowed shutter
|
||||
// ===================================================================================
|
||||
//
|
||||
// NEW_INDEX_BUFFER used to be MixShutter(the whole buffer-CONTENT aggregate, the VAO
|
||||
// identity), so it fired on any buffer write anywhere - a glBufferSubData into a texture
|
||||
// upload staging buffer re-published the index binding. It now reads the bound VAO's own
|
||||
// element-slot version and the identity of whatever is bound to it.
|
||||
//
|
||||
// THIS IS THE ONE CASE THE OLD SHUTTER COULD NOT PASS, which is why it is here rather
|
||||
// than in the narrowing commit's prose.
|
||||
TEST_F(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) {
|
||||
const SharedPtr<MG_State::GLState::BufferObject> indices = Ctx().CreateBufferObject(1);
|
||||
indices->Respecify(64, nullptr);
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(indices);
|
||||
Walk();
|
||||
Walk();
|
||||
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "the steady state must be quiet before the interesting half of this case";
|
||||
|
||||
// An entirely unrelated buffer's contents move. Nothing about the element binding
|
||||
// changed, so the bit must stay down.
|
||||
const SharedPtr<MG_State::GLState::BufferObject> unrelated = Ctx().CreateBufferObject(2);
|
||||
unrelated->Respecify(4096, nullptr);
|
||||
Array<Uint8, 16> bytes{};
|
||||
unrelated->UploadSubData(DataPtr{bytes.data(), bytes.size()}, 0);
|
||||
ASSERT_NE(Ctx().GetAnyBufferChangeGeneration(), 0u) << "the buffer aggregate did move";
|
||||
Walk();
|
||||
EXPECT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "NEW_INDEX_BUFFER fired on a write to a buffer that is not the element binding";
|
||||
|
||||
// And the control, on the same tracker: the binding itself moving DOES fire it, so
|
||||
// the quiet above is a narrowing and not a dead bit.
|
||||
const SharedPtr<MG_State::GLState::BufferObject> other = Ctx().CreateBufferObject(3);
|
||||
other->Respecify(64, nullptr);
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(other);
|
||||
Walk();
|
||||
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "NEW_INDEX_BUFFER did not fire when the element binding changed";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// The slot version is a WRAPPING Uint16 that BindingSlot bumps only on a real change, so
|
||||
// it is widened at this boundary - and the bound object's lifetime id joins it because
|
||||
// identity is what closes the wrap hole. 65536 binds later the version reads the same
|
||||
// number it did at the start; if that number were the whole shutter, a binding that had
|
||||
// moved onto a DIFFERENT buffer would read as unchanged and the draw would fetch indices
|
||||
// from the previous one.
|
||||
TEST_F(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) {
|
||||
const SharedPtr<MG_State::GLState::BufferObject> objects[3] = {
|
||||
Ctx().CreateBufferObject(1), Ctx().CreateBufferObject(2), Ctx().CreateBufferObject(3)};
|
||||
for (const auto& object : objects) object->Respecify(64, nullptr);
|
||||
auto& slot = Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot();
|
||||
|
||||
slot.Bind(objects[0]);
|
||||
Walk();
|
||||
Walk();
|
||||
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u);
|
||||
const Uint16 versionAtStart = slot.GetVersion();
|
||||
|
||||
// Drive the Uint16 all the way round WITHOUT the tracker looking, which is exactly
|
||||
// the window a wrap needs: every bind between two walks is invisible to it.
|
||||
//
|
||||
// THREE buffers, not two, and that is the whole construction: BindingSlot bumps its
|
||||
// version only on a real change, so strictly alternating between two objects makes
|
||||
// the version and the bound object share a parity - 65536 changes always land back on
|
||||
// the object they started from, and the wrap is unobservable. Cycling three lands on
|
||||
// objects[65536 % 3] == objects[1] at exactly the same raw version.
|
||||
for (Uint32 i = 1; i <= 65536u; ++i) slot.Bind(objects[i % 3]);
|
||||
ASSERT_EQ(slot.GetVersion(), versionAtStart) << "the version did not come back round";
|
||||
ASSERT_EQ(slot.GetBoundObject(), objects[1]) << "the binding did not land on a different buffer";
|
||||
|
||||
Walk();
|
||||
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "the slot version wrapped onto a DIFFERENT buffer and the bit stayed down - the "
|
||||
"identity half of the shutter is what has to close that hole";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// THE PENDING BASE INSTANCE IS THIS CALL'S ARGUMENT, NOT A LATCH, and Update() is where
|
||||
// the difference bites: it calls Reset() from inside itself whenever the current
|
||||
// GLContext pointer moves, and the draw entry point wrote the value one statement EARLIER
|
||||
// (D-H2.1 puts MGP_SET_BASE_INSTANCE immediately above MGP_FILL, and Update is inside
|
||||
// MGP_FILL). So `eglMakeCurrent(ctxB); glDrawArraysInstancedBaseInstance(..., 7)` used to
|
||||
// put a BaseInstance of 0 on the wire - one silently mis-shifted instanced draw per
|
||||
// context switch, on the emulation path, invisible to a single-context retrace corpus and
|
||||
// to every case that drives the emitter directly.
|
||||
//
|
||||
// A FRESH TRACKER IS EXACTLY THAT SWITCH: m_context starts null, so the first Walk() takes
|
||||
// the same `m_context != &ctx` branch a make-current does.
|
||||
TEST_F(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) {
|
||||
m_tracker.SetPendingBaseInstance(7);
|
||||
const Uint32 dirty = Walk();
|
||||
EXPECT_EQ(m_tracker.PendingBaseInstance(), 7u)
|
||||
<< "the walk that follows a make-current cleared the base instance the same call set";
|
||||
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers), 0u)
|
||||
<< "NEW_VERTEX_BUFFERS did not fire on the first walk of a fresh context";
|
||||
|
||||
// And the control, so the assertion above is about Reset() and not about a value that
|
||||
// is never cleared at all: the verb that consumes it clears it, and the next walk on
|
||||
// the SAME context then sees 0.
|
||||
m_tracker.ClearPendingBaseInstance();
|
||||
Walk();
|
||||
EXPECT_EQ(m_tracker.PendingBaseInstance(), 0u);
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// set_vertex_attrib_defaults' payload (P2 brief D10)
|
||||
// ===================================================================================
|
||||
//
|
||||
// A CurrentVertexAttributeValue is ONE value in three views and GLContext converts
|
||||
// numerically between them, so four words on the wire are not the value unless the class
|
||||
// travels with them. These pin exactly that, because nothing else can: the emission
|
||||
// happens at step 3 and the residual fill re-pulls the field at step 4, so at a kDraw
|
||||
// verb a wrong payload is overwritten before any comparator or backend read sees it -
|
||||
// which is how a hard-coded ValueClass of 0 survived a green verify lane.
|
||||
class TrackerAttribPayload : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_previous = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
}
|
||||
void TearDown() override { MG_State::pGLContext = Move(m_previous); }
|
||||
|
||||
static GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
|
||||
static MGPAttribValue PayloadFor(Uint location) {
|
||||
MGPAttribValue value{};
|
||||
MGPipeFillAttribValue(static_cast<Uint32>(location), Ctx().GetCurrentVertexAttribute(location),
|
||||
Ctx().GetCurrentVertexAttributeClass(location), value);
|
||||
return value;
|
||||
}
|
||||
|
||||
static Uint32 Word(Float value) {
|
||||
Uint32 bits = 0;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
return bits;
|
||||
}
|
||||
|
||||
UniquePtr<GLContext> m_previous;
|
||||
};
|
||||
|
||||
TEST_F(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) {
|
||||
Ctx().SetCurrentVertexAttributeFloat(3, Array<Float, 4>{1.5f, -2.5f, 3.0f, 4.0f});
|
||||
const MGPAttribValue value = PayloadFor(3);
|
||||
EXPECT_EQ(value.Location, 3u);
|
||||
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
|
||||
EXPECT_EQ(value.Data[0], Word(1.5f));
|
||||
EXPECT_EQ(value.Data[1], Word(-2.5f));
|
||||
// The defect this exists to stop: 1.5f's int VIEW is 1, and a carrier that sent the
|
||||
// float bits while calling them class 0 for every attribute would be sending
|
||||
// 0x3FC00000 where the frontend holds 1.
|
||||
EXPECT_NE(value.Data[0], static_cast<Uint32>(Ctx().GetCurrentVertexAttribute(3).intValue[0]));
|
||||
}
|
||||
|
||||
TEST_F(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) {
|
||||
Ctx().SetCurrentVertexAttributeInt(5, Array<Int32, 4>{7, -9, 11, 13});
|
||||
const MGPAttribValue value = PayloadFor(5);
|
||||
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
|
||||
EXPECT_EQ(static_cast<Int32>(value.Data[0]), 7);
|
||||
EXPECT_EQ(static_cast<Int32>(value.Data[1]), -9);
|
||||
// and NOT the float view the frontend converted it into
|
||||
EXPECT_NE(value.Data[0], Word(7.0f));
|
||||
}
|
||||
|
||||
TEST_F(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) {
|
||||
Ctx().SetCurrentVertexAttributeUint(6, Array<Uint32, 4>{4000000000u, 2u, 3u, 4u});
|
||||
const MGPAttribValue value = PayloadFor(6);
|
||||
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassUint);
|
||||
EXPECT_EQ(value.Data[0], 4000000000u);
|
||||
EXPECT_NE(value.Data[0], Word(4000000000.0f));
|
||||
}
|
||||
|
||||
// The class is PER ATTRIBUTE and it is the last writer's, not the context's - a payload
|
||||
// that took one attribute's class for all 32 would be the same defect as a hard-coded 0.
|
||||
TEST_F(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) {
|
||||
Ctx().SetCurrentVertexAttributeFloat(1, Array<Float, 4>{1.0f, 2.0f, 3.0f, 4.0f});
|
||||
Ctx().SetCurrentVertexAttributeInt(2, Array<Int32, 4>{1, 2, 3, 4});
|
||||
EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
|
||||
EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
|
||||
// Same numbers, different classes, so the same four words mean different things:
|
||||
// 1.0f is 0x3F800000 and the integer 1 is 0x00000001.
|
||||
EXPECT_NE(PayloadFor(1).Data[0], PayloadFor(2).Data[0]);
|
||||
// An attribute nobody wrote answers Float, which is what the GL default (0,0,0,1) is.
|
||||
EXPECT_EQ(PayloadFor(7).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
|
||||
// and a later write of the other class moves the class of THAT attribute only
|
||||
Ctx().SetCurrentVertexAttributeUint(1, Array<Uint32, 4>{1u, 2u, 3u, 4u});
|
||||
EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassUint);
|
||||
EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// The SHIPPED emitter, driven through MGPipeValidateForVerb itself
|
||||
// ===================================================================================
|
||||
//
|
||||
// TrackerWalk above reproduces step 3 against a local tracker and cache, which cannot
|
||||
// fail on a defect in the validate point itself (a bit gated on the wrong subsystem, an
|
||||
// emission dropped). These drive the real entry point and read the real singletons back.
|
||||
// Safe because ctest runs one gtest case per process and the fixture resets all three
|
||||
// pieces of server-side state on both sides of every case.
|
||||
class TrackerShippedEmitter : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_previous = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
m_savedPush = MG_Config::Features.PipePush;
|
||||
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2;
|
||||
ResetTheServerSideSingletons();
|
||||
}
|
||||
void TearDown() override {
|
||||
MGPipeLeaveVerb();
|
||||
MG_Config::Features.PipePush = m_savedPush;
|
||||
ResetTheServerSideSingletons();
|
||||
MG_State::pGLContext = Move(m_previous);
|
||||
}
|
||||
|
||||
static GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
static void Draw() { MGPipeValidateForVerb(MGPipeVerb::DrawArrays); }
|
||||
static const MGPipeCsoCache::Counters& Cso() { return MGPipeCsoCacheInstance().GetCounters(); }
|
||||
|
||||
Uint64 m_savedPush = 0;
|
||||
UniquePtr<GLContext> m_previous;
|
||||
};
|
||||
|
||||
TEST_F(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) {
|
||||
constexpr int kToggles = 16;
|
||||
Draw(); // prime: a fresh context resets the cache inside the emitter and mints once
|
||||
MGPipeCsoCacheInstance().ResetCounters();
|
||||
for (int i = 0; i < kToggles; ++i) {
|
||||
Ctx().SetCapability(CapabilityInput::Blend, true);
|
||||
Draw();
|
||||
Ctx().SetCapability(CapabilityInput::Blend, false);
|
||||
Draw();
|
||||
}
|
||||
EXPECT_EQ(Cso().Mints, 1u) << "the blend-disabled state was already cached by the priming draw";
|
||||
EXPECT_EQ(Cso().Binds, static_cast<Uint64>(2 * kToggles));
|
||||
EXPECT_EQ(Cso().Hits, static_cast<Uint64>(2 * kToggles - 1));
|
||||
EXPECT_EQ(MGPipeCsoCacheInstance().Size(), 2u);
|
||||
}
|
||||
|
||||
TEST_F(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) {
|
||||
Draw();
|
||||
// The positive half, so "nothing was emitted" cannot pass because nothing is wired:
|
||||
// the first draw on a fresh context mints and binds exactly one CSO.
|
||||
ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all";
|
||||
ASSERT_EQ(Cso().Binds, 1u);
|
||||
MGPipeCsoCacheInstance().ResetCounters();
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
Draw();
|
||||
EXPECT_EQ(MGPipeTrackerInstance().LastDirty(), 0u) << "walk " << i << " fired with nothing moved";
|
||||
}
|
||||
EXPECT_EQ(Cso().Mints, 0u);
|
||||
EXPECT_EQ(Cso().Binds, 0u) << "a steady-state draw bound a render-state CSO";
|
||||
}
|
||||
|
||||
// The window the repair covers: a glVertexAttrib* write followed by a verb whose class
|
||||
// does NOT read m_currentVertexAttribute. The call still goes out (the dirty bit and the
|
||||
// subsystem bit are all step 3 looks at), and today's applier writes four words into all
|
||||
// three views because it ignores ValueClass, so nothing in step 4 puts the converted
|
||||
// value back and the client repairs the mirror itself.
|
||||
//
|
||||
// THE ASSERTION IS THE INVARIANT, NOT THE DEFECT. "repairs == before + 1" would pin
|
||||
// today's applier and go red the day package A teaches
|
||||
// MGPipeApplySetVertexAttribDefaults to switch on MGPAttribValue::ValueClass - which is
|
||||
// the hand-off this package declares as blocking, and which is supposed to need no edit
|
||||
// here. What must hold either way is that the call went out naming exactly the attribute
|
||||
// that moved, and that the mirror ends up right by at most one repair: zero repairs once
|
||||
// the applier reproduces the value, one until then.
|
||||
TEST_F(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) {
|
||||
Draw();
|
||||
const Uint64 before = MGPipeVertexAttribDefaultRepairCount();
|
||||
// 1.5f is the point: its int view is 1 and its bit pattern is 0x3FC00000, so the two
|
||||
// cannot be the same four words whichever view the carrier picks.
|
||||
Ctx().SetCurrentVertexAttributeFloat(0, Array<Float, 4>{1.5f, 2.5f, 3.5f, 4.5f});
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader();
|
||||
ASSERT_EQ(header.Count, 1u) << "the moved attribute default did not go out at all";
|
||||
EXPECT_EQ(header.Mask, 1u) << "the call named an attribute that did not move";
|
||||
const Uint64 repairs = MGPipeVertexAttribDefaultRepairCount() - before;
|
||||
EXPECT_LE(repairs, 1u) << "one call cannot need two repairs";
|
||||
// and the repair is not free-running: a second identical walk moves nothing, so it
|
||||
// neither re-emits nor re-repairs.
|
||||
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
|
||||
EXPECT_EQ(MGPipeVertexAttribDefaultRepairCount() - before, repairs);
|
||||
}
|
||||
|
||||
// MAJOR 1 of round 2's review, pinned. glEnable(GL_CLIP_DISTANCE0) is one of the 35
|
||||
// capabilities the residual block carries AND one of the eight whose SetCapability arm
|
||||
// deliberately does not BumpVersions(), so it moves m_version alone. An arming condition
|
||||
// that reads the PIPELINE version - which is what this emitter used - never re-arms for
|
||||
// those eight, and nothing can see it downstream: a block that is not emitted cannot
|
||||
// diverge, so the trip wire is simply disarmed.
|
||||
TEST_F(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) {
|
||||
Draw();
|
||||
ASSERT_TRUE(MGPipeApplier().HasResidual) << "the priming draw sent no residual block";
|
||||
// Poison the server's copy so a re-emission is the only thing that can restore it.
|
||||
MGPipeApplier().Residual = ResidualValueBlock{};
|
||||
MGPipeApplier().HasResidual = false;
|
||||
|
||||
Ctx().SetCapability(CapabilityInput::ClipDistance0, true);
|
||||
// The premise: this moved the render-state counter and NOT the pipeline one.
|
||||
const Uint16 pipelineBefore = static_cast<Uint16>(Ctx().GetPipelineStateVersion());
|
||||
Draw();
|
||||
ASSERT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u)
|
||||
<< "the premise is gone: a clip-distance enable now moves the pipeline version";
|
||||
EXPECT_EQ(static_cast<Uint16>(Ctx().GetPipelineStateVersion()), pipelineBefore);
|
||||
|
||||
ASSERT_TRUE(MGPipeApplier().HasResidual)
|
||||
<< "a capability change that moves only m_version never re-armed the residual block";
|
||||
const Uint64 bit = Uint64{1} << static_cast<SizeT>(CapabilityInput::ClipDistance0);
|
||||
EXPECT_NE(MGPipeApplier().Residual.CapabilityBits & bit, 0ull)
|
||||
<< "the re-emitted block does not carry the capability that moved";
|
||||
}
|
||||
|
||||
// MAJOR 3 of round 2's review, pinned. A fresh context resets the tracker's staging
|
||||
// mirror to the GL defaults, which are exactly what a fresh GLContext holds - so the
|
||||
// per-attribute diff is empty on the one walk that must publish everything, while the
|
||||
// applier's mirror still holds the PREVIOUS context's defaults.
|
||||
TEST_F(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) {
|
||||
// The fixture's context is itself fresh, so the priming draw is the first half of the
|
||||
// same statement: a fresh context publishes the COMPLETE set, not a difference.
|
||||
Draw();
|
||||
ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 32u)
|
||||
<< "the first walk on a fresh context published an increment, not a complete state";
|
||||
Ctx().SetCurrentVertexAttributeFloat(3, Array<Float, 4>{9.f, 8.f, 7.f, 6.f});
|
||||
Draw();
|
||||
ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 1u)
|
||||
<< "a steady context published more than the one attribute that moved";
|
||||
|
||||
// A different context, whose 32 defaults are the value-initialised {0,0,0,1} the
|
||||
// tracker's own reset produces - so a diff against the staging mirror finds nothing.
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
Draw();
|
||||
const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader();
|
||||
EXPECT_EQ(header.Count, 32u)
|
||||
<< "a fresh context published " << header.Count
|
||||
<< " attribute defaults; the server's mirror still holds the previous context's";
|
||||
EXPECT_EQ(header.Mask, 0xFFFFFFFFu);
|
||||
}
|
||||
|
||||
// Minor 4 of round 2's review. The fresh-context reset of the applier and the CSO cache
|
||||
// used to sit inside EmitRenderState, i.e. behind bit 0 of MOBILEGL_PIPE_PUSH, so the
|
||||
// per-subsystem A/B D14 invites gave a fresh context a never-reset applier holding the
|
||||
// previous context's CSO records while every suppressor slot WAS invalidated.
|
||||
TEST_F(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) {
|
||||
Draw();
|
||||
ASSERT_FALSE(MGPipeApplier().RenderStateCsos.empty())
|
||||
<< "the priming draw created no CSO record to leak into the next context";
|
||||
|
||||
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2 & ~kMGPipeSubsystemRenderState;
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
Draw();
|
||||
EXPECT_TRUE(MGPipeApplier().RenderStateCsos.empty())
|
||||
<< "a fresh context kept the previous context's CSO records because the reset was "
|
||||
"behind the render-state subsystem bit";
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundRenderStateCso));
|
||||
}
|
||||
|
||||
TEST_F(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) {
|
||||
Draw();
|
||||
ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all";
|
||||
MGPipeCsoCacheInstance().ResetCounters();
|
||||
for (Int i = 1; i <= 8; ++i) {
|
||||
Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i));
|
||||
Draw();
|
||||
EXPECT_NE(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u);
|
||||
EXPECT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u);
|
||||
}
|
||||
EXPECT_EQ(Cso().Mints, 0u);
|
||||
EXPECT_EQ(Cso().Binds, 0u) << "glViewport reached the CSO cache";
|
||||
}
|
||||
|
||||
// B-C1's scenario END TO END, through the shipped entry point and read back off the real
|
||||
// applier: the setter runs, THEN a make-current happens inside the same fill (the fresh
|
||||
// GLContext below is the switch), and what set_vertex_buffers carries has to be the 7 the
|
||||
// draw entry point passed - not the 0 the tracker's context Reset used to leave behind.
|
||||
//
|
||||
// It also pins the two halves D-H2.3 makes one property: the applier's raw
|
||||
// VertexFetchBaseInstance, and the ContentHash the suppressor keys on - which has to mix
|
||||
// BaseInstance in, or the SECOND draw at a different base instance over the same buffer
|
||||
// set would be suppressed as unchanged and the server would keep the first one's shift.
|
||||
TEST_F(TrackerShippedEmitter, ABaseInstancedDrawAfterAMakeCurrentPublishesItsOwnBaseInstance) {
|
||||
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2 | kMGPipeSubsystemResources |
|
||||
kMGPipeSubsystemVertexInput;
|
||||
Draw(); // prime this context, so the switch below is a real make-current
|
||||
ASSERT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u);
|
||||
|
||||
// The make-current, then the entry point's one line, then the fill - in that order,
|
||||
// which is the order GL_Drawing.cpp has.
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
const SharedPtr<MG_State::GLState::BufferObject> vertices = Ctx().CreateBufferObject(1);
|
||||
vertices->Respecify(4096, nullptr);
|
||||
Ctx().GetBoundVertexArray()->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false, false, -1);
|
||||
Ctx().GetBoundVertexArray()->BindAttributeBuffer(0, vertices);
|
||||
Ctx().GetBoundVertexArray()->EnableAttribute(0);
|
||||
|
||||
MGPipeSetPendingBaseInstance(7);
|
||||
ASSERT_EQ(MGPipePendingBaseInstance(), 7u) << "the setter did not take";
|
||||
Draw();
|
||||
|
||||
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 7u)
|
||||
<< "the make-current between the setter and the fill ate the base instance";
|
||||
EXPECT_EQ(MGPipeApplier().VertexBufferCount, 1u);
|
||||
const Uint64 hashAtSeven = MGPipeVertexInputEmitterInstance().LastVertexBuffers().ContentHash;
|
||||
EXPECT_EQ(hashAtSeven,
|
||||
MGPipeVertexBufferSetContentHash(MGPipeVertexInputEmitterInstance().LastEntries().data(), 0,
|
||||
1, 7))
|
||||
<< "the emitted set's ContentHash does not include the base instance it went out with";
|
||||
|
||||
// CONSUMED by the verb that carried it: the next plain draw sees 0 again, and the
|
||||
// clear that makes that true is the validate point's, not MGPipeLeaveVerb's (no GL
|
||||
// entry point calls that one).
|
||||
EXPECT_EQ(MGPipePendingBaseInstance(), 0u);
|
||||
const Uint64 setsAtSeven = MGPipeVertexInputEmitterInstance().VertexBufferSetCount();
|
||||
Draw();
|
||||
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u)
|
||||
<< "a plain draw after a base-instanced one kept the previous fetch shift";
|
||||
EXPECT_GT(MGPipeVertexInputEmitterInstance().VertexBufferSetCount(), setsAtSeven)
|
||||
<< "the buffer set was suppressed on a base-instance-only change - the hash or the "
|
||||
"bit-9 shutter is missing it";
|
||||
EXPECT_NE(MGPipeVertexInputEmitterInstance().LastVertexBuffers().ContentHash, hashAtSeven);
|
||||
|
||||
// THE OTHER EXIT. MGPipeValidateForVerb returns early when there is no live context,
|
||||
// which skips step 3 and therefore skips step 3's clear - and since Reset() no longer
|
||||
// clears it either, that exit is the only path left on which a base instance could
|
||||
// stand into the next verb. A draw with no context is a no-op; its argument must not
|
||||
// outlive it.
|
||||
MGPipeSetPendingBaseInstance(11);
|
||||
UniquePtr<GLContext> parked = Move(MG_State::pGLContext);
|
||||
Draw();
|
||||
EXPECT_EQ(MGPipePendingBaseInstance(), 0u)
|
||||
<< "the no-live-context exit left the draw's base instance standing for the next verb";
|
||||
MG_State::pGLContext = Move(parked);
|
||||
}
|
||||
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
@@ -0,0 +1,635 @@
|
||||
// MobileGL - MobileGL/MG_Test/Pipe/VertexInputEmitTest.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
|
||||
|
||||
// P3a's vertex-input family: create/bind/delete_vertex_elements, set_vertex_buffers and
|
||||
// set_index_buffer, on both sides of the call.
|
||||
//
|
||||
// THIS SUITE IS A NAMED GATE. The phase's G6 is "for every VAO configuration the emitted
|
||||
// MGPVertexElements blob + MGPVertexBuffers set + MGPIndexBuffer reproduce exactly the values
|
||||
// the backend's VAO twin reads from the frontend today, field by field, for all 32 attribute
|
||||
// slots", and it is spelled `ctest -R 'VertexInputEmit\.'`; G7 is its negative control, a
|
||||
// script that stops the wire conversion copying ONE field and expects this suite to go red
|
||||
// NAMING that field. So a case here must fail by field name, never by a bare count, or the
|
||||
// control cannot answer.
|
||||
//
|
||||
// THE SUITE IS `VertexInputEmit`, not `VertexInputEmitTest`: the file is XTest.cpp and the
|
||||
// suite is X, this directory's convention (RenderStateSpansTest.cpp -> RenderStateSpans), and
|
||||
// it is what both gates grep for.
|
||||
//
|
||||
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT - the
|
||||
// client package writes the conversion cases, the base-instance suppression pair and the
|
||||
// create/bind ping-pong pair into this file without touching MG_Test/Pipe/CMakeLists.txt.
|
||||
//
|
||||
// IT HAS ITS OWN main() for the same reason ResourceEmitTest does: the applier refuses a
|
||||
// vertex-elements record whose declared counts do not describe its own blob, and that verdict
|
||||
// is a log line in a shipped push build and std::abort() in a poison or verify one.
|
||||
//
|
||||
// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N`
|
||||
// stays name-for-name identical between the pull and the push trees.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <algorithm>
|
||||
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/SetHashSuppressor.h>
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Impl/Pipe/VertexInputEmit.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_Pipe/PipeMutation.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_State/GLState/StateObjectDeathNotice.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Pipe;
|
||||
|
||||
namespace {
|
||||
String g_logPath;
|
||||
|
||||
int ProcessId() {
|
||||
#if defined(_WIN32)
|
||||
return _getpid();
|
||||
#else
|
||||
return static_cast<int>(getpid());
|
||||
#endif
|
||||
}
|
||||
|
||||
// A MAKE-CURRENT CLEARS THE WORKING STATE AND ADVANCES THE SERIALS, and for this family
|
||||
// the difference between those two verbs is the whole of the rule. The backend's VAO twin
|
||||
// decides "have I already synced this?" by comparing its own memo against the serials, and
|
||||
// the twin does NOT die with a make-current - it is destroyed with the context, and D-G4
|
||||
// deletes the wrapping-version-plus-identity patch that used to cover the gap. So there
|
||||
// are three things a reset could do to a serial whose state it has just cleared and only
|
||||
// one of them is right: carrying the count over lets a twin read clean over a cleared
|
||||
// window immediately; RESTARTING AT 0 walks the counter back up through every value it has
|
||||
// already stamped into a surviving twin, which is worse because it is silent and reliable;
|
||||
// advancing announces the clearing and can never hand out a stamped value again.
|
||||
//
|
||||
// So: the bound handle is null rather than "whatever was bound", the window is empty
|
||||
// rather than 32 stale entries, the fetch shift is 0 rather than the last draw's - and the
|
||||
// two serials have MOVED FORWARD. The applier's OBJECT records are a different scope
|
||||
// entirely and are deliberately not touched here; ResourceEmit's
|
||||
// TheObjectRecordsSurviveAMakeCurrentAndOnlyTheWorkingStateIsReset is where that is driven
|
||||
// with live records in the table.
|
||||
TEST(VertexInputEmit, AResetApplierCarriesNoVertexInputStateOver) {
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
|
||||
#else
|
||||
MGPipeApplierState& applier = MGPipeApplier();
|
||||
applier.BoundVertexElements = MGPipeHandle{7, 3};
|
||||
applier.VertexBufferStart = 1;
|
||||
applier.VertexBufferCount = 5;
|
||||
applier.VertexFetchBaseInstance = 9;
|
||||
applier.VertexBuffersSerial = 42;
|
||||
applier.IndexBufferSerial = 43;
|
||||
applier.MapPersistentRoundtrips = 44;
|
||||
|
||||
MGPipeApplierReset();
|
||||
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements));
|
||||
EXPECT_EQ(MGPipeApplier().VertexBufferStart, 0u);
|
||||
EXPECT_EQ(MGPipeApplier().VertexBufferCount, 0u);
|
||||
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u);
|
||||
EXPECT_EQ(MGPipeApplier().MapPersistentRoundtrips, 0u);
|
||||
// MOVED FORWARD, not zeroed. 43 and 44 are the successors of the 42 and 43 above, and
|
||||
// the property that matters is the strict inequality: no value this counter has
|
||||
// already handed to a twin may ever come back.
|
||||
EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, 43u);
|
||||
EXPECT_EQ(MGPipeApplier().IndexBufferSerial, 44u);
|
||||
EXPECT_GT(MGPipeApplier().VertexBuffersSerial, 42u);
|
||||
EXPECT_GT(MGPipeApplier().IndexBufferSerial, 43u);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// G2 requires the pull and push ctest name sets to be identical, name for name, so a
|
||||
// push-only case is present and SKIPS rather than being absent.
|
||||
#define MGL_VERTEX_INPUT_EMIT_TEST_LIST(X) \
|
||||
X(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) \
|
||||
X(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) \
|
||||
X(VertexInputEmit, IsLongAndFloat64TravelSeparately) \
|
||||
X(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) \
|
||||
X(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) \
|
||||
X(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) \
|
||||
X(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) \
|
||||
X(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) \
|
||||
X(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_VERTEX_INPUT_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
using MG_State::GLState::BufferObject;
|
||||
using MG_State::GLState::VertexArrayObject;
|
||||
|
||||
// The emitters are driven DIRECTLY rather than through MGPipeValidateForVerb, and that
|
||||
// is the point: G6 is a statement about the conversion, and a case that went through the
|
||||
// validate point would also be testing the tracker's shutters, which have their own
|
||||
// suite. What is asserted is what the emitter handed the applier - on this tree the
|
||||
// applier's entry points are stubs, so the emitter's own staging buffers ARE the
|
||||
// emitted record, at no copy.
|
||||
//
|
||||
// AN RAII SCOPE RATHER THAN A gtest FIXTURE: both gates grep `ctest -R
|
||||
// 'VertexInputEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and gtest
|
||||
// refuses to mix TEST and TEST_F under one suite name - so a fixture would rename every
|
||||
// case out of the gate's reach.
|
||||
struct EmitterScope {
|
||||
EmitterScope() {
|
||||
m_previousContext = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
MGPipeVertexInputEmitterInstance().Reset();
|
||||
MGPipeVertexInputEmitterInstance().ResetCounters();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
}
|
||||
~EmitterScope() {
|
||||
MG_State::pGLContext.reset();
|
||||
MG_State::pGLContext = Move(m_previousContext);
|
||||
MGPipeVertexInputEmitterInstance().Reset();
|
||||
MGPipeVertexInputEmitterInstance().ResetCounters();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
}
|
||||
EmitterScope(const EmitterScope&) = delete;
|
||||
EmitterScope& operator=(const EmitterScope&) = delete;
|
||||
|
||||
UniquePtr<GLContext> m_previousContext;
|
||||
};
|
||||
|
||||
GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
MGPipeVertexInputEmitter& Emitter() { return MGPipeVertexInputEmitterInstance(); }
|
||||
|
||||
const SharedPtr<VertexArrayObject>& MakeVao(Uint name) {
|
||||
Ctx().CreateVertexArrayObject(name);
|
||||
Ctx().BindVertexArray(name);
|
||||
return Ctx().GetBoundVertexArray();
|
||||
}
|
||||
|
||||
// ============================ G6 ============================
|
||||
//
|
||||
// "For every VAO configuration the emitted MGPVertexElements blob reproduces EXACTLY the
|
||||
// values the backend's VAO twin reads from the frontend today, field by field, for all 32
|
||||
// attribute slots."
|
||||
//
|
||||
// The oracle is the frontend attribute itself, read back through the same getter the twin
|
||||
// uses, so this cannot drift into asserting what the emitter happens to do. Every field is
|
||||
// its own EXPECT naming that field, which is what G7's scripted control needs: it stops
|
||||
// the conversion copying ONE member and expects this case to go red NAMING it.
|
||||
//
|
||||
// All three configuration families are driven, because they resolve differently and a
|
||||
// conversion that works for one is not evidence about the others: the legacy pointer
|
||||
// entry points (which resolve a 0 stride to the element size before it ever reaches the
|
||||
// wire), the ARB_vertex_attrib_binding entry points (where a 0 stride means the opposite
|
||||
// and must survive), and the enable/disable switch.
|
||||
TEST(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(4096, nullptr);
|
||||
|
||||
constexpr int kAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS;
|
||||
const DataType kTypes[] = {DataType::Float32, DataType::Int16, DataType::Uint8,
|
||||
DataType::Int32, DataType::Float64, DataType::Uint2101010Rev};
|
||||
for (int i = 0; i < kAttribs; ++i) {
|
||||
const auto index = static_cast<Uint>(i);
|
||||
const DataType type = kTypes[i % 6];
|
||||
const int size = 1 + (i % 4);
|
||||
const Bool normalized = (i % 3) == 0;
|
||||
const Bool isInteger = (i % 5) == 0;
|
||||
if (i < 12) {
|
||||
// The legacy pointer family: a raw stride, an effective stride and a pointer
|
||||
// offset, all three distinct so a conversion that took the wrong one fails.
|
||||
vao->SetAttributeFormat(index, size, type, normalized, 16 + i, static_cast<SizeT>(64 + i * 4),
|
||||
isInteger, false, 32 + i);
|
||||
vao->MirrorPointerIntoBinding(index, buffer, static_cast<SizeT>(64 + i * 4), 32 + i);
|
||||
vao->BindAttributeBuffer(index, buffer);
|
||||
vao->SetAttributeDivisor(index, static_cast<Uint>(i % 3));
|
||||
} else if (i < 24) {
|
||||
// The binding-model family, with the attribute deliberately fed by a DIFFERENT
|
||||
// binding index than its own - which is the one thing MGPVertexAttribWire::
|
||||
// BindingIndex exists to carry and the one an identity mapping would hide.
|
||||
const Uint binding = static_cast<Uint>((i + 5) % kAttribs);
|
||||
vao->SetAttributeFormatSeparate(index, size, type, normalized, isInteger,
|
||||
static_cast<Uint>(8 * (i % 4)), false, type == DataType::Float64);
|
||||
vao->SetAttributeBinding(index, binding);
|
||||
vao->SetBindingBuffer(binding, buffer, static_cast<SizeT>(128 + i), 48 + i);
|
||||
vao->SetBindingDivisor(binding, static_cast<Uint>(i % 2));
|
||||
} else {
|
||||
// GL_BGRA keeps size 4 and is its own flag; the disabled tail proves Enabled
|
||||
// travels rather than being implied by "has a format".
|
||||
vao->SetAttributeFormat(index, 4, DataType::Uint8, true, 0, static_cast<SizeT>(i), false, true, -1);
|
||||
}
|
||||
if ((i % 2) == 0) {
|
||||
vao->EnableAttribute(index);
|
||||
} else {
|
||||
vao->DisableAttribute(index);
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "a fresh VAO must publish a create";
|
||||
ASSERT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().LastElements().AttributeCount, static_cast<Uint32>(kAttribs));
|
||||
EXPECT_EQ(Emitter().LastElements().BindingPointCount,
|
||||
static_cast<Uint32>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS));
|
||||
EXPECT_EQ(Emitter().LastElements().Blob.Size,
|
||||
static_cast<Uint64>(kAttribs) * sizeof(MGPVertexAttribWire) +
|
||||
static_cast<Uint64>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS) *
|
||||
sizeof(MGPVertexBindingPointWire))
|
||||
<< "the declared counts must describe the blob's declared size, or the applier refuses it";
|
||||
|
||||
for (int i = 0; i < kAttribs; ++i) {
|
||||
const auto index = static_cast<Uint>(i);
|
||||
const auto& attrib = vao->GetAttribute(index);
|
||||
const MGPVertexAttribWire& wire = Emitter().LastAttributes()[static_cast<SizeT>(i)];
|
||||
SCOPED_TRACE(::testing::Message() << "attribute " << i);
|
||||
EXPECT_EQ(wire.Offset, static_cast<Uint64>(attrib.Offset));
|
||||
EXPECT_EQ(wire.Stride, static_cast<Int32>(attrib.Stride));
|
||||
EXPECT_EQ(wire.Type, static_cast<Uint32>(attrib.Type));
|
||||
EXPECT_EQ(wire.Size, static_cast<Uint8>(attrib.Size));
|
||||
EXPECT_EQ(wire.Enabled, attrib.Enabled ? 1 : 0);
|
||||
EXPECT_EQ(wire.Normalized, attrib.Normalized ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsInteger, attrib.IsInteger ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsLong, attrib.IsLong ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsBgra, attrib.IsBgra ? 1 : 0);
|
||||
EXPECT_EQ(wire.BindingIndex, static_cast<Uint8>(vao->GetAttributeBindingIndex(index)));
|
||||
EXPECT_EQ(wire.Pad0, 0u) << "padding must stay padding";
|
||||
}
|
||||
|
||||
for (int b = 0; b < VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS; ++b) {
|
||||
const auto& point = vao->GetBindingPoint(static_cast<Uint>(b));
|
||||
const MGPVertexBindingPointWire& wire = Emitter().LastBindingPoints()[static_cast<SizeT>(b)];
|
||||
SCOPED_TRACE(::testing::Message() << "binding point " << b);
|
||||
EXPECT_EQ(wire.Offset, static_cast<Uint64>(point.Offset));
|
||||
EXPECT_EQ(wire.Stride, static_cast<Int32>(point.Stride));
|
||||
EXPECT_EQ(wire.Divisor, static_cast<Uint32>(point.Divisor));
|
||||
}
|
||||
|
||||
// The divisor is NOT in the attribute view - it is resolved per binding point and
|
||||
// travels in MGPVertexBuffer::Divisor, which is where the backend reads it. Asserted
|
||||
// here rather than left to a reader of the struct, because carrying it twice is
|
||||
// exactly how a malformed record comes to disagree with itself.
|
||||
Emitter().EmitVertexBuffers(Ctx(), 0);
|
||||
for (Uint32 i = 0; i < Emitter().LastVertexBuffers().Count; ++i) {
|
||||
const auto& attrib = vao->GetAttribute(i);
|
||||
const MGPVertexBuffer& entry = Emitter().LastEntries()[i];
|
||||
SCOPED_TRACE(::testing::Message() << "vertex buffer entry " << i);
|
||||
EXPECT_EQ(entry.Divisor, static_cast<Uint32>(attrib.Divisor));
|
||||
EXPECT_EQ(entry.Stride, static_cast<Uint32>(attrib.Stride));
|
||||
EXPECT_EQ(entry.BindingIndex, i);
|
||||
EXPECT_EQ(entry.Offset, 0u) << "the attribute's own byte offset lives in the wire attribute";
|
||||
}
|
||||
}
|
||||
|
||||
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8: a pointer call's stride 0 means
|
||||
// "tightly packed" and the frontend already resolved it to the element size, so a zero
|
||||
// that reaches the wire can only have come from the binding model - where it means every
|
||||
// vertex reads the SAME element and the fetch address never advances. Collapsing it back
|
||||
// into the element size is what made those two cases read past the buffer.
|
||||
TEST(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
|
||||
vao->SetAttributeFormatSeparate(0, 4, DataType::Float32, false, false, 0);
|
||||
vao->SetAttributeBinding(0, 0);
|
||||
vao->SetBindingBuffer(0, buffer, 0, 0); // the binding model's zero
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
// The control, on the SAME emission: a pointer-style zero was already resolved to the
|
||||
// tightly packed element size by the GL entry point (which is what the effective
|
||||
// stride argument carries), so it must NOT reach the wire as a zero. The raw argument
|
||||
// stays 0 and is reported verbatim by glGetVertexAttribiv - which is exactly why the
|
||||
// two are stored apart and only the resolved one travels.
|
||||
vao->SetAttributeFormat(1, 4, DataType::Float32, false, 0, 0, false, false, 16);
|
||||
vao->MirrorPointerIntoBinding(1, buffer, 0, 16);
|
||||
vao->BindAttributeBuffer(1, buffer);
|
||||
vao->EnableAttribute(1);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
ASSERT_EQ(vao->GetAttribute(0).Stride, 0) << "the frontend itself no longer resolves this to zero";
|
||||
EXPECT_EQ(Emitter().LastAttributes()[0].Stride, 0)
|
||||
<< "a binding-model stride of 0 was collapsed into the element size";
|
||||
EXPECT_EQ(Emitter().LastBindingPoints()[0].Stride, 0);
|
||||
EXPECT_NE(Emitter().LastAttributes()[1].Stride, 0) << "a resolved pointer stride reached the wire as 0";
|
||||
EXPECT_EQ(Emitter().LastAttributes()[1].Stride, static_cast<Int32>(vao->GetAttribute(1).Stride));
|
||||
EXPECT_EQ(vao->GetAttribute(1).LegacyStride, 0) << "the raw query answer is not the resolved one";
|
||||
|
||||
Emitter().EmitVertexBuffers(Ctx(), 0);
|
||||
EXPECT_EQ(Emitter().LastEntries()[0].Stride, 0u) << "and the set has to agree with the format";
|
||||
}
|
||||
|
||||
// VertexAttribFormat(GL_DOUBLE) reads doubles from memory and asks for them CONVERTED to
|
||||
// float; VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its
|
||||
// Adreno disabled-attribute workaround both key on telling the two apart, so IsLong may
|
||||
// never be inferred from Type == Float64.
|
||||
TEST(VertexInputEmit, IsLongAndFloat64TravelSeparately) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
// Attribute 0: GL_DOUBLE, converted to float. Attribute 1: the same type, kept long.
|
||||
vao->SetAttributeFormatSeparate(0, 4, DataType::Float64, false, false, 0, false, false);
|
||||
vao->SetAttributeFormatSeparate(1, 4, DataType::Float64, false, false, 0, false, true);
|
||||
// Attribute 2: NOT a double, and not long either - so "IsLong implies Float64" is
|
||||
// asserted in both directions.
|
||||
vao->SetAttributeFormatSeparate(2, 4, DataType::Float32, false, false, 0, false, false);
|
||||
vao->EnableAttribute(0);
|
||||
vao->EnableAttribute(1);
|
||||
vao->EnableAttribute(2);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
const auto& wires = Emitter().LastAttributes();
|
||||
EXPECT_EQ(wires[0].Type, static_cast<Uint32>(DataType::Float64));
|
||||
EXPECT_EQ(wires[0].IsLong, 0) << "a converted double must not travel as long";
|
||||
EXPECT_EQ(wires[1].Type, static_cast<Uint32>(DataType::Float64));
|
||||
EXPECT_EQ(wires[1].IsLong, 1) << "an L-format double lost its long flag";
|
||||
EXPECT_EQ(wires[2].Type, static_cast<Uint32>(DataType::Float32));
|
||||
EXPECT_EQ(wires[2].IsLong, 0);
|
||||
// And the frontend agrees, so this is not the emitter asserting its own answer.
|
||||
EXPECT_EQ(vao->GetAttribute(0).IsLong, false);
|
||||
EXPECT_EQ(vao->GetAttribute(1).IsLong, true);
|
||||
}
|
||||
|
||||
// D-H2.3, THE SUPPRESSOR TRAP. set_vertex_buffers is suppressed on an unchanged content
|
||||
// hash. The base instance is DRAW state and moves without the buffer set moving, so a
|
||||
// hash that did not include it would suppress the one record whose changed field is the
|
||||
// fetch shift, and the server would keep the previous one - silently wrong geometry on
|
||||
// instanced draws, and no desktop SSIM case need exercise it.
|
||||
TEST(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
vao->BindAttributeBuffer(0, buffer);
|
||||
vao->SetAttributeDivisor(0, 1);
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u) << "the first set always goes out";
|
||||
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
const Uint64 firstHash = Emitter().LastVertexBuffers().ContentHash;
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
|
||||
|
||||
// NOTHING about the buffer set changed; only the draw's base instance.
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 7), 0u)
|
||||
<< "a base-instance-only change was suppressed - it is not in the content hash";
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 7u)
|
||||
<< "the RAW value the draw carried, never a pre-shifted offset";
|
||||
EXPECT_NE(Emitter().LastVertexBuffers().ContentHash, firstHash);
|
||||
|
||||
// And back to zero is a change too - which is what makes a plain draw after a
|
||||
// base-instanced one undo the shift.
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().ContentHash, firstHash)
|
||||
<< "the hash is a function of the set and the base instance, so it has to come back";
|
||||
}
|
||||
|
||||
// The counterpart, and the reason the suppressor exists at all: an unchanged set with an
|
||||
// unchanged base instance is not a record worth sending, and the slot must say so.
|
||||
TEST(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
vao->BindAttributeBuffer(0, buffer);
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
|
||||
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
const Uint64 latched =
|
||||
MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers);
|
||||
EXPECT_NE(latched, 0u) << "0 is reserved for 'never emitted'";
|
||||
|
||||
EXPECT_EQ(Emitter().EmitVertexBuffers(Ctx(), 3), 0u) << "an unchanged set went out again";
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
EXPECT_EQ(MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers),
|
||||
latched);
|
||||
|
||||
// A real change to the SET still goes out with the same base instance, so the
|
||||
// suppression above is not simply "this slot is stuck".
|
||||
vao->SetAttributeDivisor(0, 4);
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
|
||||
}
|
||||
|
||||
// D-G3's per-handle latch. create_vertex_elements is re-issued on the SAME handle when a
|
||||
// configuration moves, and the latch is stored per handle rather than globally so that
|
||||
// rebinding cannot look like a configuration change.
|
||||
TEST(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> a = MakeVao(1);
|
||||
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
a->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 1u);
|
||||
|
||||
// Same VAO, same configuration: nothing at all.
|
||||
EXPECT_EQ(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 1u);
|
||||
|
||||
// Away and back. The bind is re-emitted because the server's bound handle moved; the
|
||||
// create is not, because this handle already published this configuration.
|
||||
MakeVao(2);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 2u);
|
||||
|
||||
Ctx().BindVertexArray(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u) << "a rebind re-created a configuration that had not moved";
|
||||
EXPECT_EQ(Emitter().BindCount(), 3u);
|
||||
|
||||
// A configuration change on the BOUND VAO re-creates on the same handle and does NOT
|
||||
// rebind: the server's bound handle did not move.
|
||||
const MGPipeHandle bound = Emitter().BoundHandle();
|
||||
a->SetAttributeFormat(1, 2, DataType::Int16, true, 8, 4, true);
|
||||
a->EnableAttribute(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 3u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 3u) << "a re-create must not rebind";
|
||||
EXPECT_EQ(Emitter().LastElements().Cso, bound) << "and it must land on the SAME handle";
|
||||
}
|
||||
|
||||
// The latch is per handle, so alternating between two VAOs re-binds and never re-creates.
|
||||
// A global latch would re-create both on every swap - strictly more work than the tree
|
||||
// does today, which is the trade D-G1's identity-addressed CSO exists to avoid.
|
||||
TEST(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> a = MakeVao(1);
|
||||
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
a->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
|
||||
const SharedPtr<VertexArrayObject> b = MakeVao(2);
|
||||
b->SetAttributeFormat(0, 2, DataType::Int16, true, 8, 4, true);
|
||||
b->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
ASSERT_EQ(Emitter().CreateCount(), 2u);
|
||||
const MGPipeHandle handleB = Emitter().BoundHandle();
|
||||
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
Ctx().BindVertexArray(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
Ctx().BindVertexArray(2);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
}
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u) << "ping-ponging re-created a VAO's configuration";
|
||||
EXPECT_EQ(Emitter().BindCount(), 2u + 16u);
|
||||
EXPECT_EQ(Emitter().BoundHandle(), handleB) << "the two VAOs swapped handles";
|
||||
|
||||
// Unbinding entirely publishes the null handle, once.
|
||||
Ctx().BindVertexArray(0);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "the default VAO is a VAO and has a handle";
|
||||
EXPECT_EQ(Emitter().CreateCount(), 3u);
|
||||
}
|
||||
|
||||
// ============================ C-1: the death path ============================
|
||||
//
|
||||
// THE LEAK THIS RULES OUT, and why it is a client case rather than a backend one. Every
|
||||
// validate point with a VAO bound calls MGPipeSlots().Acquire(VertexElementsCso, ...) and
|
||||
// MGPipeApplyCreateVertexElements, and the applier's record is an Array<...,32> pair -
|
||||
// about 1.3 KB per slot. Until C-1 the ONLY thing that ever returned one was DirectGLES'
|
||||
// StateObjectDeathOps table, so under any backend that installs none - which is what
|
||||
// DirectVulkan/Magma deliberately does, MagmaPipeArms.h says why - a VAO's slot and its
|
||||
// record were held for the life of the process, on the shipped 0x1ff mask. Sodium and
|
||||
// Create churn a VAO per chunk section; 10 k of them is 13 MB of records plus 10 k
|
||||
// SlotState entries plus 10 k map nodes, monotonic, on a platform with an LMK, and past
|
||||
// kMGPipeMaxVertexElementsSlots every create_vertex_elements becomes a permanent
|
||||
// Fatal{ProtocolCorruption}.
|
||||
//
|
||||
// This binary installs NO StateObjectDeathOps at all, which is exactly the shape of the
|
||||
// backend the leak was invisible under. Before C-1 the two EXPECTs on LiveCount below read
|
||||
// `live + kChurn` and `HighWater` grew by kChurn; after it, both come back.
|
||||
TEST(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) {
|
||||
EmitterScope scope;
|
||||
ASSERT_EQ(MG_State::GLState::GetStateObjectDeathOps(), nullptr)
|
||||
<< "this case is the NO-death-ops backend; with a consumer installed it would be "
|
||||
"measuring Espryt's free instead of the client's";
|
||||
|
||||
auto& slots = MGPipeSlots();
|
||||
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
|
||||
const Uint32 highWaterBefore = slots.HighWater(MGPipeKind::VertexElementsCso);
|
||||
|
||||
// Every round is one VAO, configured, drawn with (which is what mints the slot AND
|
||||
// publishes the record), then deleted. The names are reused, exactly as a chunk
|
||||
// renderer's are - MG_State hands out a fresh lifetime id per object anyway, so a
|
||||
// recycled NAME must not be what returns the slot.
|
||||
constexpr int kChurn = 64;
|
||||
Uint32 peakLive = 0;
|
||||
for (int round = 0; round < kChurn; ++round) {
|
||||
// Through the name allocator, so MarkVertexArrayForDeletion recognises the name and
|
||||
// actually drops the slot's reference - and so the recycled NAME is part of the
|
||||
// shape, exactly as a chunk renderer's is. The lifetime id is fresh every round
|
||||
// whatever the name does, which is what the free has to key on.
|
||||
Vector<Uint> names;
|
||||
Ctx().GenVertexArrayNames(1, names);
|
||||
ASSERT_EQ(names.size(), 1u) << "round " << round;
|
||||
const Uint name = names[0];
|
||||
Ctx().CreateVertexArrayObject(name);
|
||||
Ctx().BindVertexArray(name);
|
||||
const SharedPtr<VertexArrayObject> vao = Ctx().GetBoundVertexArray();
|
||||
ASSERT_TRUE(vao) << "round " << round;
|
||||
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
vao->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "round " << round;
|
||||
peakLive = std::max(peakLive, slots.LiveCount(MGPipeKind::VertexElementsCso));
|
||||
// Unbind first: a still-bound VAO goes on living, which is the whole reason the
|
||||
// death path hangs off the destructor and not off glDeleteVertexArrays.
|
||||
Ctx().BindVertexArray(0);
|
||||
Ctx().MarkVertexArrayForDeletion(name);
|
||||
}
|
||||
|
||||
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore)
|
||||
<< kChurn << " vertex arrays were created and destroyed and the client kept their "
|
||||
"CSO slots; under a backend that installs no death notice consumer "
|
||||
"that is one SlotState, one map node and a ~1.3 KB applier record per "
|
||||
"VAO, for the life of the process";
|
||||
// The default VAO plus one recycled slot, i.e. the churn recycles instead of growing.
|
||||
EXPECT_LE(slots.HighWater(MGPipeKind::VertexElementsCso) - highWaterBefore, 3u)
|
||||
<< "the CSO slot space grew with the churn instead of being recycled";
|
||||
EXPECT_LE(peakLive, liveBefore + 2u) << "more than one churned VAO was live at once";
|
||||
EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u)
|
||||
<< "a death path emitted delete_vertex_elements for a record the applier never had";
|
||||
}
|
||||
|
||||
// Espryt's death notice frees the same slot the client's death path frees, and after C-1
|
||||
// both run. The allocator's Free is what makes that safe - it refuses a slot that is not
|
||||
// live at that generation, and the Gen bump rides the NEXT handout rather than the free -
|
||||
// so a second release cannot skip a generation, cannot double-push the free list, and
|
||||
// cannot take a slot away from the successor that has meanwhile been given it.
|
||||
TEST(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless) {
|
||||
EmitterScope scope;
|
||||
auto& slots = MGPipeSlots();
|
||||
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
|
||||
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(11);
|
||||
vao->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
const MGPipeHandle handle = Emitter().BoundHandle();
|
||||
ASSERT_FALSE(MGPipeHandleIsNull(handle));
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
|
||||
// The notice's half, by hand and FIRST - the order Espryt's consumer runs in.
|
||||
slots.Free(MGPipeKind::VertexElementsCso, handle);
|
||||
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
|
||||
|
||||
// ...and then the client's, which must find nothing and say so rather than corrupt the
|
||||
// free list or emit a delete for a record it has already forgotten.
|
||||
EXPECT_FALSE(MGPipeEmitVertexElementsDestroyAndFree(lifetimeId))
|
||||
<< "a second release resolved a handle the first one retired";
|
||||
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
|
||||
|
||||
// The successor takes the recycled slot with a MOVED generation, which is the property
|
||||
// a double free would have broken.
|
||||
const MGPipeHandle successor = slots.Acquire(MGPipeKind::VertexElementsCso, lifetimeId + 1);
|
||||
EXPECT_EQ(successor.Slot, handle.Slot);
|
||||
EXPECT_NE(successor.Gen, handle.Gen);
|
||||
slots.Free(MGPipeKind::VertexElementsCso, successor);
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Before anything logs: the logger reads this variable once, on its first write, and
|
||||
// caches the handle. The name carries this process's pid, and the file is removed on the
|
||||
// way out.
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path path =
|
||||
fs::temp_directory_path() / ("mobilegl-vertexinputemit-test-" + std::to_string(ProcessId()) + ".log");
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
g_logPath = path.string();
|
||||
#if defined(_WIN32)
|
||||
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
|
||||
#else
|
||||
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
|
||||
#endif
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
const int rc = RUN_ALL_TESTS();
|
||||
fs::remove(path, ec);
|
||||
return rc;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ namespace MobileGL::MG_Test {
|
||||
explicit ScopedPipeVerb([[maybe_unused]] MG_Pipe::MGPipeVerb verb)
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
: m_verb(verb) {
|
||||
MG_Pipe::MGPipeFillForVerb(m_verb);
|
||||
MG_Pipe::MGPipeValidateForVerb(m_verb);
|
||||
}
|
||||
#else
|
||||
{
|
||||
@@ -60,7 +60,7 @@ namespace MobileGL::MG_Test {
|
||||
// entry point's MGP_FILL would.
|
||||
void Renew() {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
MG_Pipe::MGPipeFillForVerb(m_verb);
|
||||
MG_Pipe::MGPipeValidateForVerb(m_verb);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
|
||||
add_executable(
|
||||
DriverBugProbesTest
|
||||
DriverBugProbesTest.cpp
|
||||
PersistentBufferOrderingProbeTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(DriverBugProbesTest PRIVATE
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -151,6 +151,16 @@ namespace {
|
||||
}
|
||||
EXPECT_NE(line.find("gates["), String::npos) << line;
|
||||
EXPECT_NE(line.find("tex[emit="), String::npos) << line;
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2's two render-state CSO counters ride the same line, short-named. Push-only:
|
||||
// the pull build has no CSO to mint and must stay symbol-identical.
|
||||
EXPECT_NE(line.find("cso[csom="), String::npos) << line;
|
||||
EXPECT_NE(line.find("csob="), String::npos) << line;
|
||||
// P3a's persistent-map acquisition attempts ride the same bracket. It is the counter
|
||||
// the storage-regrow gate reads, so its short name is pinned where an operator's
|
||||
// grep would break.
|
||||
EXPECT_NE(line.find("mpr="), String::npos) << line;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Per-frame fields carry two decimals for the same reason acc/draw does: they are small
|
||||
@@ -267,6 +277,11 @@ namespace {
|
||||
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd");
|
||||
EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push");
|
||||
EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block");
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints");
|
||||
EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds");
|
||||
EXPECT_STREQ(PS::NameOf(PS::CallClass::MapPersistentRoundtrips), "map-persistent-roundtrips");
|
||||
#endif
|
||||
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state");
|
||||
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list");
|
||||
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch");
|
||||
|
||||
@@ -173,6 +173,9 @@ namespace MobileGL::MG_Util::PipeStats {
|
||||
const char* const kCallClassNames[kCallClassCount] = {
|
||||
"draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect",
|
||||
"tex-upload-jobs",
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
"render-state-cso-mints", "render-state-cso-binds", "map-persistent-roundtrips",
|
||||
#endif
|
||||
};
|
||||
const char* const kGateNames[kGateCount] = {
|
||||
"espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch",
|
||||
@@ -416,6 +419,17 @@ namespace MobileGL::MG_Util::PipeStats {
|
||||
line += " box=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadBoxEmissions)]);
|
||||
line += " rect=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadRectEmissions)]);
|
||||
line += " jobs=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadJobs)]);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Push-only, like the two counters themselves: in a pull build there is no CSO to
|
||||
// mint, and a "csom=0 csob=0" that can never be anything else is noise on the one
|
||||
// line an operator greps.
|
||||
line += "] cso[csom=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoMints)]);
|
||||
line += " csob=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoBinds)]);
|
||||
// P3a's persistent-map acquisition attempts, on the same bracket and for the same
|
||||
// reason: it is push-only, and a window with an unexpected mpr= is the one number
|
||||
// that says an adoption is happening per draw rather than per storage definition.
|
||||
line += " mpr=" + std::to_string(calls[static_cast<Uint32>(CallClass::MapPersistentRoundtrips)]);
|
||||
#endif
|
||||
line += "] gates[";
|
||||
for (Uint32 i = 0; i < kGateCount; ++i) {
|
||||
if (i != 0) {
|
||||
|
||||
@@ -98,6 +98,33 @@ namespace MobileGL::MG_Util::PipeStats {
|
||||
// Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list
|
||||
// emission.
|
||||
TextureUploadJobs,
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P2's two, and they are PUSH-ONLY on purpose: a render-state CSO exists only in a
|
||||
// push build, and the pull build has to stay symbol-identical (G1) - growing this
|
||||
// enum there would resize the counter arrays, the name table and FormatWindowLine
|
||||
// for a pair of counters that could never leave zero.
|
||||
//
|
||||
// Render-state CSOs MINTED: a pipeline-subset hash that missed the CsoCache and had
|
||||
// to be created. The Blaze3D blend toggle is the shape this exists to answer for -
|
||||
// enable/draw/disable/draw forever must mint 2 and then never mint again - and it is
|
||||
// half of what a P13 retune of the 64-entry capacity reads.
|
||||
RenderStateCsoMints,
|
||||
// Render-state CSOs BOUND: one per bind_render_state, mint or reuse. mints/binds is
|
||||
// the cache's hit rate, and it is the number the CSO content-addressing negative
|
||||
// control moves.
|
||||
RenderStateCsoBinds,
|
||||
// P3a's, and push-only for the same reason as the two above.
|
||||
//
|
||||
// EVERY map_persistent EMISSION, i.e. every acquisition ATTEMPT - a mint or a decline
|
||||
// - because every one of them needs an answer from the resource owner. Counted that
|
||||
// way on purpose: "round trips actually taken" is 0 by construction in a monolith and
|
||||
// could never go red, which is not a counter, it is a decoration. Counted as attempts
|
||||
// the number is identical in both modes, it is exactly "one per storage definition",
|
||||
// and a regression that acquires per DRAW instead of per definition shows up on the
|
||||
// first window. Counted at the client emitter, behind the usual Enabled() predicate;
|
||||
// no timer anywhere.
|
||||
MapPersistentRoundtrips,
|
||||
#endif
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, ©Read);
|
||||
gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, ©Write);
|
||||
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
|
||||
@@ -62,7 +62,7 @@ val releaseSigningReady = signingStoreFile.exists()
|
||||
val debuggableRelease = (findProperty("mobilegl.debuggableRelease") ?: "false").toString().toBoolean()
|
||||
|
||||
val mobileGlVersionMajor = 26
|
||||
val mobileGlVersionMinor = 8
|
||||
val mobileGlVersionMinor = 9
|
||||
val mobileGlGitShortHash = runGit("rev-parse", "--short=7", "HEAD") ?: "nogit"
|
||||
val mobileGlMonthlyRevision = runGit(
|
||||
"rev-list",
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
#include <utility>
|
||||
#include <dlfcn.h>
|
||||
|
||||
// CLOCK_THREAD_CPUTIME_ID is POSIX and present on Linux and on every Android API this replays
|
||||
// on; the guard exists so the desktop CLI still builds where it is not, and so that "no CPU
|
||||
// series" is a compile-time fact rather than a silently-zero column.
|
||||
//
|
||||
// <time.h>, not <ctime>: clock_gettime, CLOCK_THREAD_CPUTIME_ID and struct timespec are POSIX
|
||||
// names, and only <time.h> is required to put them at global scope - <ctime> guarantees the C++
|
||||
// subset in namespace std and leaves the rest to the implementation. glibc and bionic both happen
|
||||
// to provide them either way; this file is built for both by two different toolchains, so it asks
|
||||
// for the header that actually promises what it uses.
|
||||
#if defined(__unix__) || defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__)
|
||||
#include <time.h>
|
||||
#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 1
|
||||
#else
|
||||
#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 0
|
||||
#endif
|
||||
|
||||
namespace mobilegl_trace {
|
||||
namespace benchmark {
|
||||
namespace {
|
||||
@@ -21,6 +37,23 @@ GlFinishFn gGlFinish = nullptr;
|
||||
Clock::time_point gStart;
|
||||
Clock::time_point gLastBoundary;
|
||||
std::vector<double> gFrameMs;
|
||||
std::vector<double> gFrameCpuMs;
|
||||
double gLastBoundaryCpuMs = 0.0;
|
||||
|
||||
// Milliseconds of CPU time this thread has consumed, or -1 where the clock does not exist.
|
||||
// Negative once means negative always, so End() reports an EMPTY cpu series rather than a
|
||||
// column of zeroes.
|
||||
double ThreadCpuMs() {
|
||||
#if MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK
|
||||
struct timespec now;
|
||||
if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now) != 0) {
|
||||
return -1.0;
|
||||
}
|
||||
return static_cast<double>(now.tv_sec) * 1000.0 + static_cast<double>(now.tv_nsec) / 1e6;
|
||||
#else
|
||||
return -1.0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Same resolution order the glws layers use for MobileGL's entry points: the replay driver
|
||||
// already dlopen()ed the library with RTLD_GLOBAL before retrace started, so RTLD_NOLOAD
|
||||
@@ -49,11 +82,19 @@ GlFinishFn ResolveGlFinish() {
|
||||
void Begin(bool finishEachFrame) {
|
||||
gFrameMs.clear();
|
||||
gFrameMs.reserve(kFrameReserve);
|
||||
gFrameCpuMs.clear();
|
||||
gFrameCpuMs.reserve(kFrameReserve);
|
||||
gFinishEachFrame = finishEachFrame;
|
||||
gResolvedGlFinish = false;
|
||||
gGlFinish = nullptr;
|
||||
// Wall baseline FIRST, CPU baseline second - the same order OnFrameBoundary reads them in,
|
||||
// and for the same reason. Frame 0's CPU interval then sits strictly inside its wall interval,
|
||||
// so whatever this function costs between the two readings lands in the wall number where it
|
||||
// can be seen, instead of inflating the CPU number where it cannot. Taken the other way round
|
||||
// (as this was), frame 0 alone reported a CPU delta biased upward against its own wall delta.
|
||||
gStart = Clock::now();
|
||||
gLastBoundary = gStart;
|
||||
gLastBoundaryCpuMs = ThreadCpuMs();
|
||||
gEnabled = true;
|
||||
}
|
||||
|
||||
@@ -72,8 +113,16 @@ void OnFrameBoundary() {
|
||||
gGlFinish();
|
||||
}
|
||||
}
|
||||
// The CPU reading is taken FIRST and the wall reading second, so the wall delta contains the
|
||||
// cost of the extra syscall rather than the CPU delta hiding inside it: an inflated wall
|
||||
// number is visible, a deflated CPU number is not.
|
||||
const double cpuNow = ThreadCpuMs();
|
||||
const Clock::time_point now = Clock::now();
|
||||
gFrameMs.push_back(std::chrono::duration<double, std::milli>(now - gLastBoundary).count());
|
||||
if (cpuNow >= 0.0 && gLastBoundaryCpuMs >= 0.0) {
|
||||
gFrameCpuMs.push_back(cpuNow - gLastBoundaryCpuMs);
|
||||
}
|
||||
gLastBoundaryCpuMs = cpuNow;
|
||||
gLastBoundary = now;
|
||||
}
|
||||
|
||||
@@ -87,6 +136,13 @@ Report End() {
|
||||
report.totalSeconds = std::chrono::duration<double>(Clock::now() - gStart).count();
|
||||
report.frameMs = std::move(gFrameMs);
|
||||
gFrameMs.clear();
|
||||
// Only hand back a CPU series that lines up frame-for-frame with the wall series. A short
|
||||
// one would be a clock that started failing mid-run, and silently re-indexing it against
|
||||
// frameMs would put frame N's wall time next to frame N+k's CPU time.
|
||||
if (gFrameCpuMs.size() == report.frameMs.size()) {
|
||||
report.frameCpuMs = std::move(gFrameCpuMs);
|
||||
}
|
||||
gFrameCpuMs.clear();
|
||||
return report;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,23 @@ namespace benchmark {
|
||||
//
|
||||
// Retrace runs --singlethread, so all of this is deliberately plain globals: Begin(),
|
||||
// OnFrameBoundary() and End() are only ever reached from the one retrace thread.
|
||||
//
|
||||
// That single-threadedness is also what makes the SECOND series below sound. Beside the wall
|
||||
// clock, every frame boundary reads CLOCK_THREAD_CPUTIME_ID - the CPU time consumed by THIS
|
||||
// thread - and because the retrace loop is the only thread that ever gets here, that number is
|
||||
// the client-side CPU cost of the frame and nothing else. It is the metric the disaggregation
|
||||
// GO/NO-GO hangs on (ROADMAP.md: per-thread CPU p50/p99, not wall time), and before P2 the tree
|
||||
// had no first-party collector for it at all: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no
|
||||
// /proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/. Collecting it
|
||||
// here costs one extra clock_gettime per frame, needs no root, no profiler, no debuggable build
|
||||
// and no sampling, and - unlike a timer inside the library - commits no instrumentation to a hot
|
||||
// path.
|
||||
//
|
||||
// Wall time and CPU time answer different questions and both are kept: with --benchmark-no-finish
|
||||
// the wall series still contains everything the thread WAITED for (driver submit, the compositor,
|
||||
// a fence), while the CPU series contains only what it EXECUTED. A change that moves work off the
|
||||
// retrace thread shows up as the two series diverging, which is exactly the confusion a single
|
||||
// number invites.
|
||||
|
||||
// Arms timing for the retrace that is about to run.
|
||||
//
|
||||
@@ -32,6 +49,12 @@ void OnFrameBoundary();
|
||||
struct Report {
|
||||
// Wall time of every completed frame, in milliseconds.
|
||||
std::vector<double> frameMs;
|
||||
// Thread CPU time of every completed frame, in milliseconds, in the SAME ORDER and with the
|
||||
// same length as frameMs - the two are pushed together at one frame boundary, so index i is
|
||||
// one frame in both. Empty when the platform has no CLOCK_THREAD_CPUTIME_ID, which is the
|
||||
// one honest reading of "this run collected no CPU series"; a vector of zeroes would be
|
||||
// indistinguishable from a frame that genuinely burned no CPU.
|
||||
std::vector<double> frameCpuMs;
|
||||
// Begin() to End(), in seconds. Covers trace parsing and the leading partial frame too,
|
||||
// which is why it is reported next to the per-frame statistics rather than derived from
|
||||
// them.
|
||||
|
||||
@@ -827,6 +827,42 @@ std::string BenchmarkResultPath(const Request& request) {
|
||||
: request.benchmarkResultPath;
|
||||
}
|
||||
|
||||
// mean / median / nearest-rank p95 over the trailing `tail` entries of one per-frame series.
|
||||
//
|
||||
// Split out of SummarizeBenchmark rather than duplicated, because the wall series and the CPU
|
||||
// series have to be reduced IDENTICALLY or the delta between them stops meaning anything: the same
|
||||
// tail window, the same median rule for an even count, the same nearest-rank p95 (so the reported
|
||||
// value is always an observed frame, never an interpolation).
|
||||
struct SeriesSummary {
|
||||
double meanMs = -1.0;
|
||||
double medianMs = -1.0;
|
||||
double p95Ms = -1.0;
|
||||
};
|
||||
|
||||
SeriesSummary SummarizeSeries(const std::vector<double>& series, std::size_t tail) {
|
||||
SeriesSummary summary;
|
||||
if (series.empty() || tail == 0 || tail > series.size()) {
|
||||
return summary;
|
||||
}
|
||||
std::vector<double> window(series.end() - static_cast<std::ptrdiff_t>(tail), series.end());
|
||||
double sum = 0.0;
|
||||
for (double value : window) {
|
||||
sum += value;
|
||||
}
|
||||
summary.meanMs = sum / static_cast<double>(tail);
|
||||
|
||||
std::sort(window.begin(), window.end());
|
||||
summary.medianMs =
|
||||
(tail % 2 == 1) ? window[tail / 2] : 0.5 * (window[tail / 2 - 1] + window[tail / 2]);
|
||||
// Nearest-rank p95, so the reported value is always an observed frame time.
|
||||
std::size_t rank = static_cast<std::size_t>(std::ceil(0.95 * static_cast<double>(tail)));
|
||||
if (rank == 0) {
|
||||
rank = 1;
|
||||
}
|
||||
summary.p95Ms = window[rank - 1];
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Folds the recorded frame times into the headline numbers. Everything but totalSeconds and
|
||||
// the frame count is computed over the trailing benchmarkTailFrames frames only.
|
||||
void SummarizeBenchmark(const Request& request, const benchmark::Report& report, Result& result) {
|
||||
@@ -843,25 +879,20 @@ void SummarizeBenchmark(const Request& request, const benchmark::Report& report,
|
||||
std::min(static_cast<std::size_t>(requestedTail), report.frameMs.size());
|
||||
result.benchmarkTailFrames = static_cast<int>(tail);
|
||||
|
||||
std::vector<double> window(report.frameMs.end() - static_cast<std::ptrdiff_t>(tail),
|
||||
report.frameMs.end());
|
||||
double sum = 0.0;
|
||||
for (double frameMs : window) {
|
||||
sum += frameMs;
|
||||
}
|
||||
result.benchmarkMeanMs = sum / static_cast<double>(tail);
|
||||
|
||||
std::sort(window.begin(), window.end());
|
||||
result.benchmarkMedianMs = (tail % 2 == 1)
|
||||
? window[tail / 2]
|
||||
: 0.5 * (window[tail / 2 - 1] + window[tail / 2]);
|
||||
// Nearest-rank p95, so the reported value is always an observed frame time.
|
||||
std::size_t rank = static_cast<std::size_t>(std::ceil(0.95 * static_cast<double>(tail)));
|
||||
if (rank == 0) {
|
||||
rank = 1;
|
||||
}
|
||||
result.benchmarkP95Ms = window[rank - 1];
|
||||
const SeriesSummary wall = SummarizeSeries(report.frameMs, tail);
|
||||
result.benchmarkMeanMs = wall.meanMs;
|
||||
result.benchmarkMedianMs = wall.medianMs;
|
||||
result.benchmarkP95Ms = wall.p95Ms;
|
||||
result.benchmarkFps = result.benchmarkMeanMs > 0.0 ? 1000.0 / result.benchmarkMeanMs : -1.0;
|
||||
|
||||
// The CPU series is the same length as the wall series or it is empty (trace_benchmark.cpp
|
||||
// refuses to hand back a partial one), so the same tail window applies unchanged. When it is
|
||||
// empty the three CPU fields stay at -1, which is what the platform having no per-thread CPU
|
||||
// clock looks like - and is not the same reading as a genuine 0.0.
|
||||
const SeriesSummary cpu = SummarizeSeries(report.frameCpuMs, tail);
|
||||
result.benchmarkMeanCpuMs = cpu.meanMs;
|
||||
result.benchmarkMedianCpuMs = cpu.medianMs;
|
||||
result.benchmarkP95CpuMs = cpu.p95Ms;
|
||||
}
|
||||
|
||||
bool WriteBenchmarkJson(const Request& request,
|
||||
@@ -886,6 +917,9 @@ bool WriteBenchmarkJson(const Request& request,
|
||||
file << " \"medianFrameMs\": " << result.benchmarkMedianMs << ",\n";
|
||||
file << " \"p95FrameMs\": " << result.benchmarkP95Ms << ",\n";
|
||||
file << " \"fps\": " << result.benchmarkFps << ",\n";
|
||||
file << " \"meanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n";
|
||||
file << " \"medianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n";
|
||||
file << " \"p95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n";
|
||||
file << " \"frameTimesMs\": [";
|
||||
for (std::size_t i = 0; i < report.frameMs.size(); ++i) {
|
||||
if (i > 0) {
|
||||
@@ -893,6 +927,18 @@ bool WriteBenchmarkJson(const Request& request,
|
||||
}
|
||||
file << report.frameMs[i];
|
||||
}
|
||||
file << "],\n";
|
||||
// The WHOLE per-frame CPU array, beside the whole per-frame wall array. This is what makes
|
||||
// p99 - and any other percentile a later question wants - a host-side computation over an
|
||||
// artefact that already exists, instead of a device change. Empty when this platform has no
|
||||
// per-thread CPU clock; an empty array and an array of zeroes are different claims.
|
||||
file << " \"frameCpuTimesMs\": [";
|
||||
for (std::size_t i = 0; i < report.frameCpuMs.size(); ++i) {
|
||||
if (i > 0) {
|
||||
file << ", ";
|
||||
}
|
||||
file << report.frameCpuMs[i];
|
||||
}
|
||||
file << "]\n";
|
||||
file << "}\n";
|
||||
return static_cast<bool>(file);
|
||||
@@ -965,6 +1011,9 @@ bool WriteResultJson(const Request& request, const Result& result) {
|
||||
file << " \"benchmarkMeanFrameMs\": " << result.benchmarkMeanMs << ",\n";
|
||||
file << " \"benchmarkMedianFrameMs\": " << result.benchmarkMedianMs << ",\n";
|
||||
file << " \"benchmarkP95FrameMs\": " << result.benchmarkP95Ms << ",\n";
|
||||
file << " \"benchmarkMeanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n";
|
||||
file << " \"benchmarkMedianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n";
|
||||
file << " \"benchmarkP95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n";
|
||||
file << " \"benchmarkFps\": " << result.benchmarkFps << "\n";
|
||||
} else {
|
||||
file << "\n";
|
||||
@@ -1045,6 +1094,9 @@ Result RunTraceReplay(const Request& request) {
|
||||
<< ", meanMs=" << result.benchmarkMeanMs
|
||||
<< ", medianMs=" << result.benchmarkMedianMs
|
||||
<< ", p95Ms=" << result.benchmarkP95Ms
|
||||
<< ", meanCpuMs=" << result.benchmarkMeanCpuMs
|
||||
<< ", medianCpuMs=" << result.benchmarkMedianCpuMs
|
||||
<< ", p95CpuMs=" << result.benchmarkP95CpuMs
|
||||
<< ", fps=" << result.benchmarkFps
|
||||
<< ", benchmarkResultPath=" << result.benchmarkResultPath;
|
||||
result.message = message.str();
|
||||
|
||||
@@ -90,6 +90,17 @@ struct Result {
|
||||
double benchmarkMedianMs = -1.0;
|
||||
double benchmarkP95Ms = -1.0;
|
||||
double benchmarkFps = -1.0;
|
||||
// The same three statistics over the retrace thread's CPU time instead of wall time, and the
|
||||
// reason the CPU series is collected at all: the disaggregation GO/NO-GO is a per-thread CPU
|
||||
// question, not a frame-rate one. Left at -1 when the platform has no per-thread CPU clock,
|
||||
// which is distinguishable from a real 0.0.
|
||||
//
|
||||
// Only mean/median/p95 stop here. p99 - which is half of what the paired A/B publishes - is
|
||||
// computed HOST-SIDE from the full frameCpuTimesMs[] array in benchmark.json, so asking for a
|
||||
// different percentile later needs no device change and no reflash.
|
||||
double benchmarkMeanCpuMs = -1.0;
|
||||
double benchmarkMedianCpuMs = -1.0;
|
||||
double benchmarkP95CpuMs = -1.0;
|
||||
};
|
||||
|
||||
Result RunTraceReplay(const Request& request);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user