Files
MobileGL/scripts/ci/spawn_lane_parity.py
T
swung0x48 1de8f02ebe [MG_IntegrationTest, docs] (P7): the whole integration binary runs under Magma split, and it is one red
Tier 2 replays the split-arm registrations - ~100 scenarios, the set Espryt's disaggregation work
has carried since P5 - and it measured green on Magma across all three transports. That is a real
result but it is NOT exit gate 1. The gate says the INTEGRATION BINARY is green under Magma split,
and the set that names is the one the monolith DirectVulkan. arm runs: the whole suite minus
MGL_ITEST_MONOLITH_MECHANISM_FILTER. 510 cases against tier 2's 102, and the readback matrices,
the same-texture copy shapes and the odd-offset uniform ranges are in it and nowhere else in the
disaggregated registry.

integration-magma-full-{split,spawn,tcp}, in no gating step, private log per entry through a list
of its own. THE RESULT: 453/0/57 on inproc, 453/0/57 on spawn, 454/1/57 on tcp - and not one of
the 1530 entries reaches any of the tree's fifteen @P7 named refusals. The single red is
IterationRPProgram203Scenario.FixedCompleteInputProducesFixedCompleteGoldenOutput on the TCP arm:
one texel of 262656, texel 0, which is the case's own exposure value
(IterationRPProgram203Scenario.cpp:371, actual half bits (0x357a, 0x4b65) against golden
(0x3a74, 0x4821)). Deterministic 5/5 on tcp; 5/5 GREEN on inproc, on spawn and on the monolith
control. spawn and tcp differ only in MOBILEGL_IPC_DATA, so the suspicion is the STREAM data path
and not "two processes"; the GLES tcp lane is green in the same run, so it is not the fixture.
Not fixed here - it is what the census is for.

TWO ENVIRONMENT DIFFERENCES FROM THE GATED TIERS, both measured rather than assumed:

  * NO MGITEST_SPLIT_LANE=1. That marker arms ScenarioFixture's destructor assertion - a case in
    an armed split lane must have moved the client encoder's record ordinal - which is right for
    a curated lane and wrong for a whole-binary census. With it set, the first run produced 13
    reds, all `EmitSeq was 20 at SetUp and is 20 now`: AdvertisedLimitsScenario (8),
    SpirvShaderBinaryScenario (4) and ProgramPipelineScenario.AReservedName - a limits query is
    answered from the client's CapsMirror and a shader-binary error surface never reaches a verb.
    Those 13 would red on a DirectGLES census too. What it gives up is stated in the comment: this
    tier does not prove its records crossed, and the tcp topology proof is inert in it.
  * DualBlockScenario.* filtered out. Its green REQUIRES MOBILEGL_IPC_ROLE_SPLIT_STATE=1 and a
    census must not set that knob; without it the case is red by name, which is its documented
    negative-control behaviour. The monolith arm skips it for the same class of reason.

CtWireScenario gets MOBILEGL_TEST_SERVER_COUNTERS=1 in this tier - a switch that makes a counter
readable, not an oracle that asserts - because without it the spawn arm's ApplierReset case reds
on "server control-record telemetry missing", which says nothing about Magma. The strict-arming,
pmap and persistent-map-arm markers stay out.

The inproc-only exclusion in spawn_lane_parity.py is now PER TIER: in the gated tier the two
scenarios are registered on the inproc arm alone, in the census they are on every arm and simply
skip, so excluding them there would manufacture a spurious "extra".

Measured after the change: every pre-existing lane unchanged and green (magma-split 73,
spawn 52, tcp 54, all-* 102/102/104, GLES 186/102/104, buffers 19, runahead 14, caches 7), the
dual-block census 257 green / 2 skipped / 0 red with an empty ratchet, both new CI steps green,
parity RC=0 across all three tiers, G14 4155 -> 6095 with removed=0.

The note now carries the grouped red list, the 57 named skips by reason, the four pieces of
evidence that the tcp red is a defect and not plumbing, and six findings - the first being that
wave 2's clusters cannot be scheduled from this lane's red list, because on lavapipe there is
essentially none.
2026-09-22 14:24:19 -04:00

188 lines
10 KiB
Python

#!/usr/bin/env python3
"""Exit gate 9.2: integration-spawn's case-name set equals integration-split's.
WHY A GATE AND NOT A COMMENT. The two lanes are registered from one macro, so today they agree
by construction - but a macro is one edit away from a second hand-written block, and a spawn lane
covering LESS than the split lane goes green while proving less. Nothing about a smaller green
lane looks wrong. This asserts the sets rather than the counts, because a count can agree while
the membership does not.
ARM PREFIXES ARE STRIPPED BEFORE COMPARING. The lanes deliberately differ in name -
`DirectGLES.Split.X` against `DirectGLES.Spawn.X` - and the sub-lane tails (`F1.`, `Ct.`) are
part of neither the case nor the arm, so both come off.
ONE SANCTIONED ASYMMETRY, LISTED HERE AND NOWHERE ELSE. `integration-split` also labels the
PUSH-MONOLITH arm (the CMakeLists explains why: the label means "the set the disaggregation gate
runs", not "runs inproc"), and that arm's scenario is monolith-only by construction. It cannot
have a spawn counterpart, so it is named as an exception rather than allowed to widen the
tolerance for everything else.
P7 PACKAGE L ADDS THE SAME QUESTION FOR MAGMA, IN TWO TIERS, AND THE NORMALISATION IS
DIFFERENT ON PURPOSE. The Espryt half above strips the sub-lane tails `F1.` and `Ct.` because
they name neither the case nor the arm. The Magma half must NOT: `RunAhead.Credit1.` and
`RunAhead.Credit3.` are two entries of the same case, differing in the credit the lane pins, and
a normaliser that dropped the tail would fold them together and then report an equal-sized set
that had silently lost one. So there the arm - and only the arm, the SECOND dotted segment - is
what comes off.
"""
import argparse
import re
import subprocess
import sys
# Registered only on the push-monolith arm, which has no spawn counterpart by construction.
MONOLITH_ONLY = {"MonolithAttachmentClearScenario"}
CASE = re.compile(r"DirectGLES\.[A-Za-z0-9]+\.(?:F1\.|Ct\.)?([A-Za-z0-9_]+Scenario\.[A-Za-z0-9_]+)")
# `<Backend>.<Arm>.<everything else>` -> `<Backend>.<everything else>`. The arm is the only
# segment two arms of one lane are allowed to differ in.
ARM = re.compile(r"^(DirectGLES|DirectVulkan)\.(Split|Spawn|Tcp|TcpDevice)\.(.+)$")
TEST_LINE = re.compile(r"^\s*Test\s+#\d+:\s*(\S+)\s*$", re.MULTILINE)
# `ctest -N -L <tcp label>` lists the FIXTURE entries too, because every tcp entry declares
# FIXTURES_REQUIRED mobilegl-tcp and ctest pulls the setup/cleanup in with them. They are the
# supervisor, not cases, and they exist on exactly one arm by construction. Named here so that
# any OTHER unparseable name is still an error rather than a silent drop.
FIXTURE_ENTRIES = {"TcpServer.Start", "TcpServer.Stop"}
# THE MAGMA TIER'S SANCTIONED ASYMMETRIES, LISTED HERE AND NOWHERE ELSE - the same discipline
# MONOLITH_ONLY above gets. Both are inproc-only BY CONSTRUCTION, not by omission:
# MagmaRunAheadScenario arms its hold through ServerLoopInstance().SetBeforeRetireHookForTesting,
# a hook on the CALLING process's server loop. With the server in another
# process the hold never activates and the wait times out.
# MagmaWireCacheScenario SetUp asserts runtime.transportName == "inproc" outright: the lane reads
# the server's own view/attachment identity out of this process.
# A cross-process hold and a cross-process cache peek are server-side knobs neither exists yet;
# until they do, naming the two scenarios here keeps the tolerance from widening for anything else.
MAGMA_INPROC_ONLY = ("MagmaRunAheadScenario.", "MagmaWireCacheScenario.")
def lane_names(build_dir, label):
"""Every ctest entry name under an ANCHORED label (`ctest -L` is a regex, not a name)."""
out = subprocess.run(["ctest", "-N", "-L", "^" + label + "$"], cwd=build_dir,
capture_output=True, text=True, check=True).stdout
return {m.group(1) for m in TEST_LINE.finditer(out)}
def arm_keys(names):
return {f"{m.group(1)}.{m.group(3)}" for m in (ARM.match(n) for n in names) if m}
def lane_cases(build_dir, label):
out = subprocess.run(["ctest", "-N", "-L", "^" + label + "$"], cwd=build_dir,
capture_output=True, text=True, check=True).stdout
return {m.group(1) for m in CASE.finditer(out)}
def compare_arms(build_dir, tier, labels, inproc_only=()):
"""The three arms of one tier must name the same set after the arm segment comes off.
Returns True on failure, the way main() below counts them."""
sets = {}
for arm, label in labels.items():
names = lane_names(build_dir, label) - FIXTURE_ENTRIES
sets[arm] = arm_keys(names)
print(f"{tier}: {label} has {len(names)} case entrie(s), "
f"{len(sets[arm])} after normalisation")
if len(names) != len(sets[arm]):
print(f"::error::{label}: {len(names) - len(sets[arm])} entry name(s) do not parse "
f"as <Backend>.<Arm>.<case>, so this comparison silently drops them: "
+ ", ".join(sorted(n for n in names if not ARM.match(n))), file=sys.stderr)
return True
if not any(sets.values()):
print(f"::note::{tier}: no entries under {sorted(labels.values())} - nothing to compare")
return False
failed = False
reference = "split"
comparable = {k for k in sets[reference]
if not any(only in k for only in inproc_only)}
if len(comparable) != len(sets[reference]):
print(f"{tier}: {len(sets[reference]) - len(comparable)} inproc-only entrie(s) excluded "
f"from the comparison by name ({', '.join(inproc_only)})")
for arm, keys in sets.items():
if arm == reference:
continue
missing, extra = sorted(comparable - keys), sorted(keys - comparable)
if missing or extra:
print(f"::error::{tier}: the {arm} arm does not match the {reference} arm: "
f"missing={missing}, extra={extra}. Every arm of a tier is emitted from one "
f"loop over the same case list, so a difference here is a registration bug, "
f"not a coverage decision.", file=sys.stderr)
failed = True
return failed
def main():
parser = argparse.ArgumentParser()
parser.add_argument("build_dir")
parser.add_argument("--require-device", action="store_true",
help="Also require the configured integration-tcp-device lane")
args = parser.parse_args()
split = lane_cases(args.build_dir, "integration-split")
spawn = lane_cases(args.build_dir, "integration-spawn")
if not split or not spawn:
print(f"::error::a lane is EMPTY (split={len(split)}, spawn={len(spawn)}). An empty set "
f"compares equal to nothing and would pass this gate silently, which is the one "
f"way it could stop meaning anything.", file=sys.stderr)
return 1
split_comparable = {c for c in split if c.split(".", 1)[0] not in MONOLITH_ONLY}
missing = sorted(split_comparable - spawn)
extra = sorted(spawn - split_comparable)
print(f"spawn-lane parity: split {len(split)} ({len(split_comparable)} comparable), "
f"spawn {len(spawn)}")
if missing:
print("::error::cases in integration-split with no integration-spawn counterpart: " +
", ".join(missing) + ". Exit gate 9.2 requires the sets to be equal - a spawn lane "
"that covers less goes green while proving less. Register through "
"mgl_itest_register_split_arms, which emits both arms from one filter.",
file=sys.stderr)
if extra:
print("::error::cases in integration-spawn that integration-split does not run: " +
", ".join(extra) + ". The split arm is the control; a case only the spawn arm runs "
"has no baseline to be compared against.", file=sys.stderr)
failed = bool(missing or extra)
labels = ["integration-tcp"]
if args.require_device:
labels.append("integration-tcp-device")
for label in labels:
cases = lane_cases(args.build_dir, label)
missing, extra = sorted(spawn - cases), sorted(cases - spawn)
print(f"{label} parity: spawn {len(spawn)}, tcp {len(cases)}")
if not cases or missing or extra:
print(f"::error::{label}: missing={missing}, extra={extra}", file=sys.stderr)
failed = True
# P7 package L, exit gate 1's half of the same question: the Magma arms.
# THE EXCLUSION IS PER TIER, not global. In the GATED tier the two scenarios are registered
# on the inproc arm ALONE (the CMakeLists says why), so they are absent from the other arms
# and have to come off the reference set. In the FULL-SUITE census they are registered on
# every arm - it is a whole-binary filter, and there they simply SKIP for want of their
# lane marker - so excluding them there would turn a present-on-all-arms case into a
# spurious "extra".
failed |= compare_arms(args.build_dir, "magma gated tier",
{"split": "integration-magma-split",
"spawn": "integration-magma-spawn",
"tcp": "integration-magma-tcp"},
inproc_only=MAGMA_INPROC_ONLY)
failed |= compare_arms(args.build_dir, "magma informational tier",
{"split": "integration-magma-all-split",
"spawn": "integration-magma-all-spawn",
"tcp": "integration-magma-all-tcp"})
# Tier 3, the whole-suite census. It is expected to be RED and is in no gating step - but
# its three arms must still name the same set, because the census's whole value is that a
# case red on spawn and green on inproc is a two-process fact, and that comparison needs
# both arms to have run the same case.
failed |= compare_arms(args.build_dir, "magma full-suite tier",
{"split": "integration-magma-full-split",
"spawn": "integration-magma-full-spawn",
"tcp": "integration-magma-full-tcp"})
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())