23 KiB
Decision log — TensileLite characterization
The durable record of why the characterization suite looks the way it does: which modules are deliberately left below the coverage bar, which behaviors are pinned as latent bugs rather than "fixed" in a characterization test, which mutants are accepted as equivalent, and the few places this departed from the add-only rule. A reviewer of any future TensileLite change should be able to read this file and understand the intent behind the tests next to it.
One entry per non-trivial decision: the choice made, why, and why the alternatives were rejected. Routine "wrote tests, hit the bar, committed" steps are not logged — only genuine forks.
See README.md (this directory) for the per-module protocol and how to run the
suite. Additional background is tracked under AIHPBLAS-3871.
D0 — Scope of "every remaining module"
Decision: Characterize the pure / table / IO / config / toolchain-helper Python surface; exclude the codegen/asm/GPU modules (KernelWriter*, Components/, Asm, GenerateSummations, verify_stinky*, ClientWriter) and defer Solution.py slice-3b (derivation config matrix). Why: the excluded set (~38k stmts) emits GPU assembly / drives the full build; it is not unit-characterizable without a GPU + toolchain pipeline (rated ★ lowest fit in the original MODULE MAP). Snapshotting their structure would be brittle and low-value. Alternatives rejected: (a) attempt the kernel writers with heavy mocking — rejected: the mocks would assert our own scaffolding, not real behaviour, and would break on every codegen change; (b) include them as 0%-and-documented stubs — rejected: adds empty dirs with no value. These are listed OUT of scope in the plan, not silently skipped.
D1 — Per-module coverage measurement (suite-alone) vs full -m unit each time
Decision: Measure each module with a fast suite-alone --cov run; run
the full -m unit no-regression gate once per batch (and capture a fresh
baseline) rather than once per module.
Why: the full suite is ~110s; doing it per module across ~44 modules would
add hours with no extra signal (a new add-only test dir cannot reduce another
module's coverage). Per-batch is enough to catch any accidental import-time
regression.
Alternative rejected: full run per module — rejected on cost; the add-only
constraint makes per-module regression practically impossible.
D2 — Trivial-module doc overhead
Decision: For small modules that hit ~100% cleanly, write a single compact
target.md (before→after + any resistance inline) and skip separate
resistance.md/recommendations.md; commit one atomic commit per module.
Why: keeps the per-module-commit requirement without ceremony that adds no
information for a trivial 100% module.
Alternative rejected: the full 5-file deliverable per module (as used for the
large standalone targets) — rejected as disproportionate for 9-60 stmt modules.
D3 — Testing Component find/match without polluting the global registry
Decision: Define private isolated _CharBase/_CharNest* Component
subclasses in the test module to drive matches/findAll/find/versions
(single-match, >1-match RuntimeError, nested-abstract recursion) deterministically;
test LocalRead._getLdsReadMemToken/_emitLdsRead by calling them unbound
with stub self/writer/module (no subclass needed).
Why: ComponentMeta auto-registers every subclass into its base's
implementations. Production searches always start at a real subtype
(Component.<RealSubtype>.find), never at Component.findAll, so private impls
parented at Component/_CharBase never appear in a real search — the mutation
is additive and inert. The unbound-call trick covers the codegen LocalRead
helpers without registering a concrete LocalRead (which would join the real
LocalRead search set).
Alternatives rejected: (a) exercise the real registered components with a
fake writer — rejected: match results are nondeterministic across environments
and the >1-match error can't be forced reliably; (b) register a concrete
LocalRead subclass — rejected: it would pollute the real LocalRead
implementations set used by the kernel writers. Also note the
from .Components import * at the end of Component.py shadows the module-level
LocalRead name, so the real class is reached via the Component.LocalRead
attribute.
D4 — Common/Parallel.py: accept <95% (fork/process-pool paths)
Decision: Characterize the pure helpers + single-threaded + n_jobs=1
in-process paths of Parallel.py (→ ~81% line) and document the rest as
resistance, accepting this module below the 95% bar.
Why: the uncovered lines are the real parallel-execution paths —
ProcessingPool (multiprocessing.Pool), ParallelMapReturnAsGenerator
(ProcessPoolExecutor), the joblib generator-return branch, and the Windows-only
os.name=="nt" branch. These fork/spawn OS processes; exercising them in a unit
test is flaky (pickling, fork-in-pytest, CI nondeterminism, slow) and tests the
OS scheduler more than our code. joblib n_jobs=1 and multiprocessing.dummy
(threads) ARE covered because they run in-process.
Alternatives rejected: (a) run real multiprocessing.Pool(2) /
ProcessPoolExecutor with module-level picklable funcs — covers the lines but
is flaky and slow; rejected (same rationale as excluding the codegen surface);
(b) deep-monkeypatch multiprocessing — would assert our mocks, not real
behaviour; rejected. Net: Parallel.py is an honest <95% module, like the
out-of-scope codegen set.
D5 — recurring submodule-shadowing gotcha
Observation (not a fork, but recorded): several Tensile packages re-export
a class that shadows a same-named submodule attribute, so
import Tensile.X.Foo as F binds the class, not the module. Hit for
SolutionStructs.Solution, Component (LocalRead), and Common.Parallel
(joblib Parallel). Standard fix applied everywhere:
F = importlib.import_module("Tensile.X.Foo").
D6 — KernelHelperNaming.py: cover the naming half, accept <95%
Decision: Characterize the pure naming/orchestration surface
(KernelHelperEnum, kernelObjectNameCallables, the five *Names functions)
and document the init* object-construction functions (L110-240) as
out-of-scope codegen — accepting the module at ~34% line.
Why: the init* functions construct KernelWriter{BetaOnly,Conversion, ActivationEnumHeader,ActivationFunction,Reduction} instances — the GPU
code-emit classes excluded by D0. They are ~half the module and are not
unit-characterizable without the full kernel-writer machinery.
Alternatives rejected: (a) construct the KernelWriter* objects — pulls the
out-of-scope codegen surface into the unit tests; rejected; (b) drop the module
entirely — rejected: the *Names functions encode the real kernel-naming
contract and are worth pinning. Net: a partial module like Parallel.
D9 — Configuration.py: operators/ProjectConfig covered; AST evaluator deferred
Decision: Cover the Parameter operator surface, ReadWriteTransformDict,
and ProjectConfig (sections/dotted-get/defaults/constraints); document (a)
the reflected-operator isinstance(lhs, Parameter) branches as DEAD and (b) the
ExpressionEvaluator AST walker + CallableParameter/createBinaryOp as a
deferred expression-machinery slice. Accept Configuration <95% combined.
Why (a): Python only dispatches __radd__/__rlt__/... when the LEFT
operand is not a Parameter, so inside those methods lhs is never a
Parameter — that branch is unreachable via real operators (the reflected
comparison dunders aren't auto-called at all; Python uses the opposite
operator). They are pinned by explicit calls where meaningful, else dead.
Why (b): ExpressionEvaluator.evaluate is a ~70-line ast node walker;
exhaustive coverage needs an AST-node matrix (BinOp/BoolOp/Compare/Name/Num/…)
— a focused slice, disproportionate to this sweep's per-module budget.
Alternatives rejected: force the dead reflected branches via __radd__
internals — impossible without a Parameter left operand; build the full AST
matrix now — deferred as Configuration-slice-2. Net: a partial module.
D10 — Contractions.py: predicate/serialization matrix deferred (~86%)
Decision: Cover the index value classes + ProblemType (indexNames/
operationIdentifier/placeholderStr/predicates) + SizeMapping/
InternalArgsSupport/ProblemPredicate.CompoundPredicates from the one vendored
gfx942-HSS fixture; accept ~86% combined and document the rest.
Why: the remaining branches are ProblemPredicate.FromOriginalKeyPair/
CompoundPredicates + Solution/SizeMapping.FromOriginalState arms that fire
only for other problem configurations (sparse, activation, bias variants,
batched, double/complex dtypes, GSU algorithms, ...). Exercising them needs a
MATRIX of varied logic fixtures; only one is vendored, and hand-authoring
derived-solution states that match the exact serialized format is brittle.
Alternatives rejected: vendor many more logic YAMLs — large/out of proportion
and add-only-risky; synthesize derived states by hand — fragile (must match the
full post-derivation key set). Net: a partial like the Solution.py slices; a
"Contractions matrix" slice could finish it given more fixtures.
D11 — BenchmarkStructs.py: BenchmarkProcess builder deferred
Decision: Cover the pure helpers (getDefaultsForMissingParameters,
separateParameters, checkCDBufferAndStrides), the fork-permutation cartesian
product (constructForkPermutations/constructLazyForkPermutations), and
BenchmarkStep; document BenchmarkProcess (the config->benchmark-steps
integration builder, L83-235) as needing full benchmark configs.
Why: BenchmarkProcess.__init__/getConfigParameters/
convertParametersToSteps consume a complete benchmark config (problemType +
problemSizeGroup with BenchmarkCommonParameters/ForkParameters/ProblemSizes/...)
and build ProblemType/ProblemSizes/steps — an integration path better covered by
an end-to-end benchmark-config fixture than hand-built dicts.
Alternatives rejected: hand-author a full benchmark config — large/brittle;
out of proportion to the per-module budget. Net: a partial; an integration
fixture would finish it.
D12 — TensileBenchmarkCluster: pin the --results-only constraint crash rather than asserting clean workflow steps
ADR: adr/0001-pin-results-only-boolop-crash.md — the per-decision record (with defect link) for this pinned bug.
Context: While characterizing TensileBenchmarkCluster, the --results-only
flag (alone) raises AssertionError: Constraint evaluation failed: RunDeployStep or RunBenchmarkStep or RunResultsStep during construction.
Root cause (real latent bug): ExpressionEvaluator's BoolOp handler
(Configuration.py:651-652) only evaluates node.values[0] and
node.values[1], ignoring values[2:]. Python parses a or b or c as a single
BoolOp(Or, values=[a,b,c]), so the constraint collapses to a or b. With
--results-only only the third operand (RunResultsStep) is True, so the
constraint evaluates False or False → fails. --deploy-only, --run-only,
and --run-and-results-only happen to leave one of the first two operands True,
so they survive.
Decision: Pin the actual behavior — a test asserting --results-only raises
AssertionError — instead of asserting the (intended-but-unreachable) workflow
tuple (False, False, True).
Why: Characterization tests must encode what the code does today, not what
it should do. Flagging this as a real bug (3+ operand boolean constraints whose
truth depends on the 3rd+ operand are mis-evaluated) is more valuable than a
green test that hides it. ADD-ONLY constraint forbids fixing Configuration.py
here.
Rejected alternatives:
- Assert the clean tuple — would fail (construction raises) and misrepresent behavior.
- Skip the flag entirely — loses the documentation of a real, user-facing bug.
- Fix the BoolOp evaluator — out of scope (ADD-ONLY) and belongs in a separate change with its own regression coverage.
Residual coverage: 192 stmts, 1 miss (line 120, the bare-except swallow
when a task subdir already exists) → 99.51%. Line 120 is a defensive
already-exists guard not worth a dedicated fixture.
D13 — Activation.py: pin the pure config/type/numeric layer only; asm codegen is out of scope
Context: Activation.py is ~1037 statements. After pinning the pure surface, line coverage is 34.1% (up from 16.8%). The remaining ~660 lines are rocisa assembly codegen: the getXModule emitters (getExp/getGelu/getSigmoid/getTanh/ getDGelu/getSilu/getSwish/...), CombineInstructions/FuseInstruction and their iter helpers, replaceInst/removeOldInst, ConvertCoeffToHex/HolderToGpr/ createVgprIdxList, and ActivationInline.
Decision: Characterize only the pure layer + the asm entry-points that run cleanly with dummy vgprs. Do NOT attempt to drive the full asm codegen.
What is pinned (48 tests): ActivationAvailable, ActivationTypeRegister. typeAvailable, the full ActivationType API (construct/passActivation/ getAdditionalArgNum/arg-strings/fitSupported/getEnumIndex/getEnumStrList/ state/repr/str/eq/lt/toEnum), actCacheInfo.isSame, getMagic/getMagicStr/ HexToStr/addSpace, and ActivationModule defaults/setters/counters/vgprPrefix + the working getModule paths (abs/relu/none/clippedrelu/leakyrelu/clamp/drelu) and getAllGprUsage for a single type.
Why: (a) The codegen/asm/GPU layer is explicitly excluded from this
characterization effort's scope. (b) In this environment most emitters raise
immediately — NameError: 'SelectBit'/'VMaxF16' (half paths for sigmoid/exp/
gelu/tanh/silu/swish/clamp) and KeyError: 'TransOpWait' (single paths for
gelu/sigmoid/exp/tanh/silu/swish/dgelu/geluscaling). These are missing-symbol /
ISA-map-dependent codegen paths that cannot be exercised without the full
KernelWriter/ISA context, so they can be neither run nor meaningfully pinned
here. Verifying emitted assembly would require exactly the codegen harness the
scope excludes.
Rejected alternatives:
- Smoke-call every getModule type — most raise (see above); would just assert the raises, which pins environment breakage, not behavior.
- Build a full rocisa register/ISA context and snapshot emitted asm — that is codegen characterization, out of scope and high-maintenance.
Result: 1037 stmts, 683 missed → 34.1% line. Documented ceiling.
D14 — TensileLibLogicToYaml: pin the formGroups("None") crash on the skipMI / MI-disabled path
Context: formForkParams(sol, skipMI=True) (or any solution with
EnableMatrixInstruction falsy) sets temp = "None" (a string) and then calls
forkData.append(formGroups(temp)). formGroups does temp.items(), which on a
str raises AttributeError. So the entire skipMI / MI-disabled code path is
currently broken, and TensileLibLogicToYaml(..., skipMI=True) crashes too.
Decision: Pin the crash (assert AttributeError) instead of asserting a
"None"-sentinel Group, and drive the orchestrator / fork tests through the
MI-enabled (skipMI=False + EnableMatrixInstruction=True) path which works.
Why: Characterization records present behavior; this is a real, user-facing
bug (the --skipMI CLI flag is unusable). ADD-ONLY forbids fixing
formGroups/formForkParams.
Rejected alternatives:
- Assert a "None" group is produced — fails; misrepresents behavior.
- Skip the path — loses documentation of a real bug on a public CLI flag.
Residual: 199 stmts, 4 missed → 98% line. Misses are two yaml-representer callbacks (representNone/flowSeq, registered but not invoked by these tests) and two orchestrator RuntimeError guards (empty solutionIndex / missing solution).
D15 — TensileClientConfig: dead code, REMOVED (final)
Final verdict (2026-06-03, with the user): TensileClientConfig is dead
code and has been removed. The earlier two readings in this entry were both
wrong on the conclusion; this records the corrected reasoning and the outcome.
What was removed:
Tensile/TensileClientConfig.py(the module)Tensile/bin/TensileClientConfig(the launcher)- the
"TensileClientConfig"entry incmake/tensilelite_auto_build.cmakeVALID_BINS
Why it is dead (evidence):
- No in-tree caller. Following
invoke/ the build / QuickTune / the tuning docs, the client-config writing done during tuning goes throughClientWriter.writeClientConfig/writeClientConfigIni(driven bybin/Tensile→Tensile.py→BenchmarkProblems.py). Nothing calls the standaloneTensileClientConfig.main()/bin/TensileClientConfig. The two share the "ClientConfig" name but are different code paths — the source of the earlier "it's used in tuning" confusion. - Not shipped.
MANIFEST.inpackages onlybin/Tensileandbin/TensileCreateLibrary;[project.scripts]registers onlyTensile. - Unimportable anyway.
TensileClientConfig.py:29still didfrom .Common import ... assignGlobalParameters, restoreDefaultGlobalParameters, the pre-refactor flat path. AfterTensile.Commonbecame a package those funcs live inCommon/GlobalParameters.pyand are not re-exported byCommon/__init__.py(which only star-imports Constants/Parallel/Types/ Utilities), so the import raisedImportError. (Sibling entrypoints —Tensile.py,GenerateSummations,TensileUpdateLibrary,TensileRetuneLibrary— were migrated to.Common.GlobalParameters; this one was missed.) A second latent break existed too::176calledassignGlobalParameters(globalParams)with one arg against the current two-arg(config, isaInfoMap)signature.
Validation: full -m unit (Tensile/Tests/unit, in tensilelite-char:repro)
= 2466 passed / 201 skipped both before and after the removal — no
regression. This is a real source deletion (departs from the ADD-ONLY rule of
the characterization pass) committed separately as a cleanup, at the user's
explicit direction.
History of this entry (do not repeat):
- v1 — "dead module, skip; assert nothing." WRONG reasoning (called it dead only because the import failed, without checking callers/packaging).
- v2 — "live tuning entrypoint, broken by refactor, restore it (~2 lines)." Also
WRONG: there is no caller and it is not shipped, so there was nothing live to
restore. The
writeClientConfig*path (which is live) was conflated with it. - v3 (this) — dead code, verified by caller/packaging/import analysis, removed with a green suite on both sides.
Not touched: shared/tensile/Tensile/TensileClientConfig.py — a separate
vendored full-Tensile tree (different ClientWriter signatures), out of scope.
Mutation testing — accepted equivalents & # pragma: no mutate
A mutant counts as killed only if the suite passes clean, fails on the mutant,
and reverts cleanly. A survivor is accepted (marked
# pragma: no mutate, or recorded here as equivalent) only with a one-line
justification. The mutation config lives in [tool.mutmut] in
pyproject.toml; see the Mutation testing section of README.md for how to
run it.
M0 — pilot slice (report-only). The first slice mutates five files only:
Common/Utilities.py, and the four TensileLogic/Valid{ChipId,MatrixInstruction, WorkGroup,WorkGroupMappingXCC}.py. It is intentionally narrow so the workflow
(triage → kill → re-certify) is proven before widening to the critical modules.
Survivors on covered lines are killed with focused test_mut_*_char.py; only the
genuinely-unkillable ones below are accepted.
M1 — accepted # pragma: no mutate (display-only string mutations). Three
lines in Common/Utilities.py carry the pragma because the mutant only alters a
user-facing string with no observable control-flow or return-value effect, so no
characterization assertion can distinguish mutant from original:
:219—sys.stdout.write("\b" + self.chars[...]), the progress-spinner animation frame (cosmetic terminal output).:362—print("ERROR: Can't have a negative register value"), a diagnostic message string.:367—print("ERROR: Divide by 0"), a diagnostic message string.
M2 — accepted # pragma: no mutate (expanded mutation run). These
equivalent source forms are fenced so mutmut does not keep reporting them:
Tensile.Common.ValidParameters.checkSpaceFillAlgoIsValid— therange(0, maxOrderID + 1)membership check carries# pragma: no mutatebecauserange(0, n)andrange(n)produce the same values; the explicit lower bound documents the valid OrderID interval.Tensile.Common.ValidParameters.checkSpaceFillAlgoWGMIsValid— therange(0, 256)membership check carries# pragma: no mutatebecauserange(0, n)andrange(n)produce the same values; the explicit lower bound documents the half-open GridDim interval[0, 256).
M3 — accepted equivalent mutants (expanded mutation run). These survivors are behaviorally equivalent on the specific public surface under test:
Tensile.TensileLogic.ValidWorkGroupMappingXCC.x__cu_count_from_path__mutmut_9— changingcutoCUinside the regex literal is equivalent because the search usesre.IGNORECASE.
Two former survivors are intentionally no longer accepted equivalents:
Tensile.TensileLogic.ValidWorkGroupMappingXCC.x__validateWorkGroupMappingXCC__mutmut_14
is avoided by making the missing-key / -1 sentinel branch explicit before
reading the fixed WorkGroupMappingXCC value, and
Tensile.Common.Utilities.xǁSpinnyThingǁincrement__mutmut_1 is killable because
SpinnyThing.increment now uses its value parameter to advance by caller
selected steps.
M4 — widened mutation slice. The only_mutate set in [tool.mutmut] was
extended past the original five files to add Common/DataType.py,
Common/Types.py, and Common/ValidParameters.py, with the matching
characterization directories (DataType, CommonTypes, ValidParameters)
added to pytest_add_cli_args_test_selection. Source-path mapping for the
widened slice (recorded here because the shorthand names differ from the file
paths): DataType → Tensile/Common/DataType.py; CommonTypes →
Tensile/Common/Types.py; ValidParameters → Tensile/Common/ValidParameters.py
(there is no Tensile/SolutionStructs/ValidParameters.py).
D16 — BufferLoad/BufferStore promoted to Required Parameters
Context kernel basename hash changes across all archs; assembly verified unchanged/correct; no err or kernel-count changes."
D17 — StreamKWorkStealing added to the required (min-naming) parameter set
Decision: Promote StreamKWorkStealing to the required (min-naming) parameter set in
Common/RequiredParameters.py and accept the regenerated _codegen / SolutionClass /
ValidParameters goldens.
Why: without it, two solutions differing only in StreamKWorkStealing would collide on the
same kernel identity name/hash.
Verification: only basename hashes + the SKWS0 name token + the roster/valid-values entry
change (num_keys 334→335); no err, instruction-count, or emitted-assembly changes.