Skip to content

Codegen and Python Perf optimizations - #2447

Draft
ThrudPrimrose wants to merge 59 commits into
mainfrom
rm-redundant-getattr-hasattr
Draft

Codegen and Python Perf optimizations#2447
ThrudPrimrose wants to merge 59 commits into
mainfrom
rm-redundant-getattr-hasattr

Conversation

@ThrudPrimrose

@ThrudPrimrose ThrudPrimrose commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Three pieces. The first two are behavior-preserving; the third is a bug fix.

Extra

In many places subsets are copied via str(symbolic_expr) and there are many risky and wrong uses of other str(symbolic_expr) in many places. I am also fixing those in this PR.

1. Hot-path attribute overhead (65f48c1aa)

  • Property.__get__ is the hottest read in the stack (>200k calls in a single validate_sdfg) and rebuilt "_" + attr_name on every access. make_properties now precomputes it.
  • validate_state tested in_gpu with hasattr on a dict, which is always False — so the device-level scope walk was recomputed for every state and the value the caller threaded down was ignored.
  • The validate_undefs config lookup ran per memlet; hoisted out of the loop.
  • NestedDict.__getitem__/__contains__ split every name on .; unqualified names now take a direct dict hit.

2. Two codegen scope passes (79674add6)

symbols_defined_at replays a chain that is invariant across the whole SDFG for every node — SDFG symbols, every array's free symbols, interstate edges — when only the final scope step depends on the node. Symbols are defined going down (map parameters and dynamic-range connectors, consume PE index, loop iterator), so symbol_scopes computes each scope's table once by inheriting its parent's. That is also what correctness wants: new_symbols receives the accumulated table because it types a map range against the symbols already in scope.

determine_allocation_lifetime scans per descriptor, so each scan re-walks the SDFG. The worst is loop-invariant in a stronger sense: cfg.used_symbols() parses conditions through sympy/ast and does not depend on the descriptor at all — only the name in test does — so it ran D x |CFGs| times to answer |CFGs| questions. allocation_scopes builds only dictionaries; every decision stays in framecode.

Two details that matter for correctness: the allocation tables preserve sdfg.states() order, not topological order, because that is the order the replaced scans used and it decides which state emits an allocation; and the two distinct predicates are kept apart (n.data == name for the State branch, node.root_data == name for the Scope branch).

defined_at falls back to symbols_defined_at on a miss, because _generate_ConsumeEntry still rewrites dataflow during codegen despite the documented freeze in preprocess.

3. symbols_defined_at includes enclosing LoopRegion loop variables (a806c7ae1)

Backport of 3657ef490. The walk never went up the control-flow hierarchy, so an enclosing LoopRegion's loop variable could be reported undefined for a node in the body, tripping propagate_memlets_nested_sdfg's widening fallback (arr[*, jk-1, *] -> arr[*, 0:klev, *]) and blocking LoopToMap.

Property.__get__ is the hottest read in the stack (>200k calls in a single
validate_sdfg) and probed hasattr(self, 'attr_name') then rebuilt "_" +
attr_name on every access. make_properties now precomputes private_name once,
and class-level defaults replace the probe.

validate_state tested hasattr(context, 'in_gpu'), but context is a dict, so the
probe was always False: the device-level scope walk was recomputed for every
state and the value the caller threaded down was ignored. The validate_undefs
config lookup also ran per memlet; hoisted out of the loop.

validate_sdfg guarded the transient stride/total_size checks with two hasattr
probes. Only descriptors that are really allocated have those attributes, so
test for the allocated kinds positively; DistributedDescriptor (ProcessGrid/
SubArray/RedistrArray) describes an MPI communicator and has neither. The
positive form also fails safe: an unknown Data subclass is skipped rather than
raising AttributeError out of validation.

NestedDict.__getitem__/__contains__ split the key on '.' for every
sdfg.arrays[...] lookup; an unqualified name now takes a direct dict hit.
…canning

Two hot spots in code generation re-derive per node or per descriptor what is
invariant across the whole SDFG.

symbols_defined_at replays the entire chain per node -- SDFG symbols, every
array's free symbols, the interstate edges, then the scope entries down to the
node. Only the last step depends on the node, so each call redoes an O(#arrays)
sympy walk. Symbols are defined going down (map parameters and dynamic-range
connectors, consume PE index), so one top-down pass computes each scope's table
once by inheriting its parent's. That is also what correctness wants:
new_symbols receives the accumulated table because it types a map range against
the symbols already in scope.

determine_allocation_lifetime scans per descriptor, so each re-walks the SDFG:
which states hold the container, whether it appears in an interstate edge or a
block condition, and every state's scope dictionary. The condition scan is the
worst -- cfg.used_symbols() parses through sympy/ast and does not depend on the
descriptor at all, only the `name in` test does, so it ran D*|CFGs| times to
answer |CFGs| questions.

Both modules build only dictionaries; every decision stays in framecode. The
allocation tables preserve sdfg.states() order, not topological order, because
that is what the replaced scans used and it decides which state emits an
allocation. The two distinct predicates are kept apart: `n.data == name` for the
State branch, `node.root_data == name` for the Scope branch.

defined_at falls back to symbols_defined_at on a miss, since _generate_ConsumeEntry
still rewrites dataflow during codegen despite the documented freeze.

Verified by hashing generated code for six programs (nested maps, scope
transient, nested SDFG, gemm, LoopRegion body, multi-state transients) against
unmodified main: all six identical.

tests/perf/ adds a benchmark that times simplify and generate_code over the
npbench/polybench corpus plus loop unrolling on cloudsc, and a script that turns
two CSVs into markdown speedup tables and a plot.
…ables

Backport of 3657ef4 from the extended branch.

symbols_defined_at folded in global symbols, inter-state-edge assignments and
dataflow-scope (Map/Consume) symbols, but never walked up the control-flow
hierarchy, so the loop variable of an enclosing LoopRegion could be reported as
undefined for a node in the loop body. That false "undefined" tripped
propagate_memlets_nested_sdfg's widening fallback and replaced a nested-SDFG
connector dim indexed by the loop variable with the whole array
(arr[*, jk-1, *] -> arr[*, 0:klev, *]), hiding per-iteration uniqueness and
blocking LoopToMap.

Walk parent_graph up the CFG, collect each enclosing LoopRegion, and fold in its
loop variable via LoopRegion.new_symbols, outermost first and before the
scope-symbol walk. symbol_scopes mirrors the same step so the two stay
equivalent.

Caveat on verification: on this branch I could not construct a graph where the
loop variable is actually missing without the walk -- it is reported as defined
either way, so some other route already supplies it here. The observable
difference in the tests is key ORDER. The scenario the original fix targets is
ICON-scale (nested SDFG plus memlet propagation plus LoopToMap) and is not
reproduced by the unit tests added here.
The cloudsc workload timed loop unrolling and codegen but not the initial
simplify. Each stage now feeds the next -- simplify on the freshly built graph,
unroll on the simplified one, codegen on the unrolled one -- which is the order
a real compile runs them in. Prefers build_cloudsc_sdfg(), the maintained entry
point, over calling to_sdfg on the raw program.
Builds a worktree per ref, links dace/external/{cub,moodycamel} into it (git
worktree does not populate submodules, and without them everything that
compiles C++ fails), runs the benchmark in each, then emits the markdown tables
and plot.
@ThrudPrimrose ThrudPrimrose changed the title Rm redundant getattr/hasattr Codegen and Python Perf optimizations Jul 20, 2026
ThrudPrimrose and others added 24 commits July 20, 2026 20:16
Kernel corpora sit at tests/corpus/<name> on extended and tests/<name> upstream,
and only the former are Python packages. Hardcoding one layout (and using a
dotted module name) discovered zero kernels here and wrote an empty CSV that
looked like a clean run. Search both roots, load by file path, key kernels by
file stem so a base/new pair across layouts lines up, and exit non-zero rather
than writing an empty CSV.

cloudsc exists only where tests/corpus/cloudsc does; say so explicitly instead
of dropping three workloads without comment.
The kernel corpus and the benchmark scripts are inputs, not the thing under
test. Letting them vary per branch compares two different benchmarks, and a base
ref that predates tests/perf cannot run at all. The driver now checks
tests/corpus and tests/perf out of <harness-ref> into every worktree, so both
arms measure the identical workload with identical code.

The benchmark reads a single tests/corpus root, loads files by path rather than
by dotted module name (the corpus dirs are not packages everywhere), and exits
non-zero rather than writing an empty CSV that would look like a clean run.
cloudsc unavailability warns and continues instead of discarding the
npbench/polybench results already collected.
…stance

symbols_defined_at special-cased LoopRegion to pick up the loop iterator, but
AbstractControlFlowRegion.new_symbols is already the hook for 'what does this
region bind', returning {} for regions that bind nothing. Walking every
enclosing region and folding in new_symbols drops the isinstance test, treats
control-flow scope exactly like the dataflow scope resolved just below it, and
lets any future region type bind symbols without touching this function.

Dispatch is also stricter than the test it replaces: LoopRegion.new_symbols
additionally requires an init statement and skips a loop variable that appears
in its own init RHS.

symbol_scopes mirrors the same walk. Generated code is byte-identical to
unmodified main across the six-program corpus.
SymbolScopes and AllocationScopes were plain functions under dace/codegen, but
they are exactly what ppl.Pass models: pure analysis over the whole SDFG
returning dictionaries keyed by cfg_id. determine_allocation_lifetime already
calls StateReachability().apply_pass right next to them, so the neighbours were
already passes and these two were the odd ones out.

As passes they declare Modifies.Nothing and a real should_reapply, which
replaces an informal "valid while the SDFG is frozen" docstring with the
invalidation contract the pipeline already understands. They also become
reusable outside codegen -- validate_state calls symbols_defined_at per edge,
which is the same quadratic this removes -- and their results can be shared
through pipeline_results rather than recomputed per consumer.

AllocationScopes no longer needs the frame codegen's memoized free_symbols
helper: reading isedge.data.free_symbols directly gives the same set and drops
the dependency on the caller.

Generated code is byte-identical to unmodified main across the six-program
corpus.
Codegen constructed StateReachability, SymbolScopes and AllocationScopes
separately. StateReachability depends_on ControlFlowBlockReachability, so
running them apart recomputes that dependency per consumer; a Pipeline resolves
depends_on once and shares every result through pipeline_results.

CodegenAnalysisPipeline names the group, matching GPUCodegenPreprocessPipeline
next door. All three declare Modifies.Nothing, so it is safe on a frozen SDFG
and the results stay valid while nothing mutates.

Generated code is byte-identical to unmodified main across the six-program
corpus.
…ces pass

determine_allocation_lifetime still opened with an inline walk collecting
shared transients and, per container, every state that uses it -- through an
access node, through a code node naming it as a free symbol with no memlet, or
through a surrounding interstate edge. That is pure analysis producing
cfg_id-keyed dictionaries, i.e. the same shape as the passes beside it, so it
belongs in the pipeline rather than in the routine that consumes it.

AccessInstances joins CodegenAnalysisPipeline, leaving determine_allocation_lifetime
with decision logic only: 33 lines of gathering become 8 lines of lookups. The
block-topological order is preserved and called out in the docstring -- the
consumer takes the FIRST and LAST entry per container to choose which state
declares and which frees a transient, so reordering would move allocations.

symbols_and_constants stays in framecode -- it is memoized on the code generator,
not derivable from the SDFG alone.

Generated code is byte-identical to unmodified main across the six-program corpus.
Runs code generation on the same cloudsc SDFG under the old analysis path and
the new one, reporting the three stages that feed it: initial simplify, loop
unroll, then codegen on the unrolled graph.

Both arms take tests/corpus and tests/perf from <harness-ref>, so they run the
identical workload with identical measurement code -- which is also what makes
an upstream base ref runnable, since neither directory exists there.

--cloudsc-only skips the kernel corpus for a focused run. SBATCH headers are
inert outside SLURM, so the script is submittable or directly runnable.
tests/corpus lives on extended; tests/perf lives on the branch under test. A
single harness-ref forced both from one place, so pointing it at extended failed
with a bare 'pathspec tests/perf did not match any file(s)'. Each checkout is
now preceded by a cat-file existence test naming the missing ref and path.

generate_data_for_cloudsc imports tests.corpus.cloudsc.cloudsc absolutely, which
needs the repo root on sys.path, while 'cloudsc.*' resolves against
tests/corpus; only one was inserted.
rm -rf deletes the directory but leaves git's worktree registration, so a second
run failed with 'missing but already registered worktree'. Prune before adding
and force the add, which is what a rerun after any failure hits.
…one clone

run_perf_ab.sh runs the npbench/polybench kernel sweep and the cloudsc stages in
one pass, old analysis path vs new, replacing the two near-identical scripts it
supersedes. submit_perf_ab.sh sbatches it on one exclusive normal node with a 2h
limit.

Also fixes two things that only bite at cloudsc scale (2687 arrays, 5890
states): timed() held every rep's deepcopy alive at once (gigabytes), now one at
a time outside the timer; and the ~10 min frontend build, identical for every
ref, is cached via DACE_BENCH_SDFG_CACHE so the second arm loads it.
submit_perf_ab.sh is the sbatch entry point: #SBATCH account g34, partition
normal, 1 exclusive node, 2h. It spack-loads llvm@22.1.5 and gcc@16.1.0 (sourcing
spack setup-env first if the function is not defined in the batch shell), makes
perf_ab/ for the --output path, then execs run_perf_ab.sh (a plain worker, no
SLURM headers).

sbatch tests/perf/submit_perf_ab.sh [args...]
The class docstrings ran to multi-paragraph rationales; the codebase uses a
single line (cf. AccessSets, FindAccessStates). Trimmed each to one line, kept
only the load-bearing notes -- the ported start-state bug, the load-bearing
block order, and why the loop-variable walk exists -- as short inline comments.
Three reuse fixes from an audit of what the passes reimplemented:

* sdfg_symbols/state_symbols were verbatim copies of symbols_defined_at's body.
  Extracted as sdfg_scope_symbols/enclosing_region_symbols in state.py;
  symbols_defined_at and SymbolScopes now call the same functions, so the two
  cannot drift.
* AllocationScopes.scope_dicts re-cached what SDFGState.scope_dict() already
  caches on the state. Dropped the table; the consumer calls state.scope_dict()
  directly (self-caching, frozen SDFG -> identical).
* AccessInstances' inline `free_symbols & array_names` on interstate edges is
  exactly InterstateEdge.used_arrays(sdfg.arrays); use it and drop the
  intermediate set.

symbols_defined_at behavior preserved (LoopRegion loop variable still visible),
generated code byte-identical to unmodified main across the six-program corpus.
393 -> 318 call sites across sdfg, codegen, libraries, transformation and the
frontends. Each removal was checked against the declaring class: guards for
attributes every subclass defines are dropped, kind discrimination becomes
isinstance, and lazy per-instance caches become class-level defaults.

Reflection that has no static equivalent is kept: serialization and property
machinery, visit_<TypeName> dispatch, attribute lookup by a runtime string,
duck-typing on sympy/numpy/ast/user objects, ONNX schema-driven attributes,
and markers other passes attach at runtime.

Three defects surfaced on the way:

- condition_fusion assigned the containing SDFG into NestedSDFG.sdfg, i.e.
  over the nested graph itself. Restricted to ControlFlowBlock;
  set_nested_sdfg_parent_references already covers the nested case.
- validate_state tested a **kwargs dict with hasattr, which is always False,
  so the in_gpu value validate_sdfg computes was discarded and recomputed for
  every state. is_in_scope ignores the state argument when the node is None,
  so the threaded value is the same one; it is now reused.
- _layernorm_axis guarded node.axis with hasattr and then dereferenced it in
  the else branch. Defaults to ONNX's -1, deduplicated into one helper.

cpu.py's ArrayStreamView guard looks dead but is not: sdfg/utils.py attaches
the attribute at runtime. Its comment now says so.
``used_symbols``, ``used_symbols_within_scope`` and ``ReferenceToView`` all called
``new_symbols(...).keys()``, discarding the types. Producing them costs two
``infer_expr_type`` calls per map parameter, and each of those runs a sympy
preorder traversal, a pycode print and an ``ast.parse`` -- 13% of code generation
on the npbench and polybench corpus.

Add ``new_symbol_names``, the names-only counterpart of ``new_symbols``, and
resolve it by dispatch on the node. Also fold the four copies of the dynamic
scope input expression into ``EntryNode.dynamic_input_connectors``.

Generated code is byte-identical on the corpus, and the names agree with
``new_symbols().keys()`` node for node over 1675 scope entries.
``typeclass.__init__`` read ``compiler.default_data_types`` for every type it
wrapped, even though only ``int``, ``float`` and ``complex`` consult it. That
made it the single hottest configuration lookup in code generation -- 27886 of
the 31113 ``Config.get`` calls over the npbench and polybench corpus, each one
probing the environment.

Read it behind the branch that needs it, and drive the three identical
width tables from one mapping instead of repeating the dispatch per type.
…scalar

``dtypes`` rebinds ``bool`` to a typeclass so that ``dace.bool`` names a DaCe
type. ``typeclass.__init__`` resolves its globals at call time, so the
``wrapped_type is bool`` branch compared the argument against that typeclass
rather than the builtin and could never be taken: ``typeclass(bool).type`` stayed
``builtins.bool`` while ``int``, ``float`` and ``complex`` all normalized to
their numpy counterparts.

Name the builtin explicitly so the branch applies. Beyond the inconsistency,
this repairs the hash contract: ``typeclass(bool)`` and ``typeclass(numpy.bool_)``
compared equal but hashed differently, so both could occupy one dictionary.
Every other observable -- ctype, size, ``to_string``, ``as_ctypes``,
``as_numpy_dtype``, ``to_json`` -- is unchanged, and generated code is
byte-identical on the npbench and polybench corpus.
Dropping the ``hasattr(desc, 'strides')`` guard made ``validate_sdfg`` reach for
``strides`` and ``total_size`` on every transient descriptor. A
``DistributedDescriptor`` -- ProcessGrid, SubArray, RedistrArray -- describes an
MPI communicator and has neither, so validating any SDFG holding a process grid
raised ``AttributeError``. That is what test_process_grid_bcast hit.

Test for the allocated kinds positively instead of probing for the attribute, so
an unknown Data subclass is skipped rather than crashing.
Left behind once the getattr call it supported was replaced; ruff fails on it.
The names-only path is only safe while it agrees with ``new_symbols``, so assert
that over frontend-built maps, nested maps and an expanded reduction, before and
after simplify, plus a dynamic scope input and a Consume PE index.

Also cover the typeclass rules the same change relies on: Python's scalars follow
``compiler.default_data_types``, ``bool`` normalizes like the rest, and equal
typeclasses hash alike.
Takes the repo-wide sweep from #2449 so this branch carries it plus the code
generation work on top. The only conflict was the wording of one comment in
validate_state; both sides had reached the same code.
Ported back from extended, where this side won the merge. `analysis_results['AllocationScopes']`
and friends are silent on a rename: the pass class moves, the literal keeps compiling, and the
lookup KeyErrors at codegen time instead of failing at import. Keying on the class's own __name__
ties the lookup to the class it names, so a rename is caught by the import.
ThrudPrimrose and others added 26 commits July 27, 2026 14:00
test_allocated_kinds_are_exactly_the_ones_with_strides asserted that a Data subclass
has strides/total_size if and only if it is in validate_sdfg's allocation-checked
allowlist. That held when DistributedDescriptor had neither attribute, but 9c37398
("Address review: empty descriptor properties") gave it both as constant stubs -- []
and 0, i.e. a declaration that a communicator allocates nothing -- and did not update
the test, so the tripwire fired on the one subtree that provably needs no check.

Having the attributes is not what should exempt it; the stubs being constant is. So
the subtree is exempted, and what makes the exemption sound is pinned instead: the
stub values, and that no subclass overrides either property. A DistributedDescriptor
that grows a real buffer still trips the check.
…ght instances

SymPy's global @cacheit LRUs key on _hashable_content. dace.symbolic.symbol kept dtype as a plain
attribute and left it out, so symbol('i', int32) and symbol('i', int64) aliased in those caches and a
cached entry handed back an expression rebuilt around the foreign symbol -- the order-dependent
'Mod(symbol($i, dtype=dace.int64), 3i16)' round-trip failure. TypedConstant already includes its dtype
for the same reason.

With dtype in the identity, propagation could no longer mint scope parameters from bare names: a map
over 0:N gets an int64 iterator (result_type_of over the range bounds) while pystr_to_symbolic('i')
mints int32, so no Wild-exclude, match or membership test hit and the iteration variable leaked into
the propagated subset. Parameters now resolve to the instances the analysed subsets themselves carry,
and defined variables -- which are only ever membership-tested -- stay bare names, which is what
comparing freshly minted symbols amounted to before.
The dtype-aliasing bug only showed up as a round-trip mismatch in a test that ran after an unrelated one
had built the same expression around an int64 symbol, so it was invisible under the fixed collection
order every workflow uses. Two additions:

- Two tests that assert the invariant directly, without depending on order: a same-name symbol of
  another dtype is a different symbol, and SymPy's global caches never substitute one for the other.
- A workflow that feeds pytest a seeded permutation of the test files, with --dist loadfile so each
  worker still runs many files in one process, which is where the cache carry-over happens. The seed
  comes from the commit SHA, so the order varies across commits but a failure always reproduces, and
  it is echoed as a GitHub annotation on both the run and the failure.
SymPy orders expressions by comparing _hashable_content tuples element-wise, and typeclasses define
equality but no ordering, so a raw typeclass in there raised TypeError from Basic.compare. ctype is
what typeclass equality is defined on anyway.
Symbol identity now carries the dtype, and DaCe routinely holds the same name at two widths on the two
sides of a nested SDFG boundary, or mints a scope parameter from a bare name and compares it against an
expression that already holds one. Neither is a statement about the value:

- shape/stride equality in both inliners goes through symbolic.same_value, which ignores dtypes,
- Memlet.get_stride and SubgraphFusion take the parameter instance the subset carries, so the shifted
  and unshifted expressions cancel again,
- the vectorizers ask whether the parameter *name* occurs, which is what they always meant.
A list never equals a tuple, and the inliners rely on that: an outer descriptor keeps its strides in a
list where the inner one has a tuple, which blocks inlining. Only the symbol dtype may be ignored.
Retyping to the default dtype dropped assumptions such as nonnegative, so two symbols that differ in
their assumptions could compare equal. Substituting a plain SymPy symbol carrying the same assumptions
leaves the dtype as the only thing ignored.
Symbol identity now includes the dtype, so a name parsed on its own is no
longer the instance an expression carries: WCR conflict detection stopped
matching and every reduction went atomic. An UndefinedSymbol reaching the
defined-variable set by name also let affine propagation run on a range that
has no value, which never terminates.
Same regression as the propagation path, unobserved only because no test
drives an undefined range through the underapproximation.
…arguments

The scan stepped one hop out of the exit node. A thread-block map inserted
inside a GPU_Device map adds a second exit whose edge still carries the inner
Memlet, so the array being written was never named and the kernel referenced a
parameter it did not have.
…ames

A symbol's identity includes its dtype, so the keys ``replace_dict`` builds from bare names are not the
instances a memlet volume carries. Intersecting the two sets of instances comes out empty exactly when there
is something to replace, and the volume keeps symbols the surrounding SDFG no longer defines -- which the
frontend then reports as missing on the nested SDFG it has just built. The two subset guards above already
compare names; do the same here.

The symbol mapping of a nested SDFG also went through ``str`` on its way to ``subs``, which re-minted every
symbol in it at the default dtype. Hand the value over as it is.
…-copy

The view memlet's subsets are parsed from a string, so the symbols in them carry the default dtype while the
write range carries the declared one. Identity includes the dtype, so the two spellings of the same range
compared as unrelated, ``intersects`` could not decide, and a staging transient went in to be safe. The
redundant-array cleanup then collapsed that chain with the subsets in swapped roles, and
``p[:, -1] = p[:, -2]`` copied backwards.

Put both sides on one instance per symbol name first. Nothing that reaches the SDFG changes: keeping the
subsets themselves instead of their string spelling was tried and reverted, because the round trip also
canonicalizes the expressions, and the passes that pattern-match a memlet subset depend on that.

The two indirection memlets move off the deprecated string constructor while here.
Both re-parse what they have just printed, which loses the dtype every symbol carries.
``pystr_to_symbolic`` passes a symbolic expression through untouched, so the argument can be handed over
as it is.
These pieces are spliced into the emitted C++, so they need the C++ spelling of an expression rather than
whatever ``str`` happens to print. Drops a ternary whose two branches were identical while there.
An exact comparison followed by a comparison of the two string spellings was standing in for a comparison
that ignores how a symbol is spelled. ``same_value`` is that comparison, so six copies of the pair -- and
the TODO each one carried -- come out.
``free_symbols`` already yields names, so re-minting them only produced instances that compare unequal to
the loop variable once the dtype is part of identity, and the check silently stopped firing.
``loop_variable`` is a plain string, so ``in`` let an ``i`` claim the bounds of the nearest enclosing ``idx``
loop and size the stored buffer from the wrong range.
The two tests added alongside it pin the same invariant without depending on collection order, which is the
cheaper guard and needs no runner of its own.
@ThrudPrimrose
ThrudPrimrose force-pushed the rm-redundant-getattr-hasattr branch from adf8459 to 42a2d1e Compare August 4, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant