Skip to content

Fix symbol-promotion and squeezing behavior in Frontend - #2443

Draft
ThrudPrimrose wants to merge 24 commits into
mainfrom
fix-frontend-symbol-promotion-squeezing
Draft

Fix symbol-promotion and squeezing behavior in Frontend#2443
ThrudPrimrose wants to merge 24 commits into
mainfrom
fix-frontend-symbol-promotion-squeezing

Conversation

@ThrudPrimrose

Copy link
Copy Markdown
Collaborator

Two DaCe Python-frontend fixes: promoting a size scalar used as an array shape to a symbol (add_datadesc), so a frontend-materialized Nt+1 size no longer collides with add_symbol (FileExistsError); and correctly transposing a unit-dim 2D array ((N,1)/(1,N)) in _transpose by squeezing the unit axis into a stride-safe copy, instead of the library node rejecting it as "not a matrix". Carries only the frontend edits plus two regression tests that expose both bugs.

@ThrudPrimrose
ThrudPrimrose force-pushed the fix-frontend-symbol-promotion-squeezing branch from dc986a0 to 0cd34c9 Compare July 18, 2026 14:26
@ThrudPrimrose
ThrudPrimrose force-pushed the fix-frontend-symbol-promotion-squeezing branch from 0cd34c9 to e620b5c Compare July 18, 2026 14:33
@ThrudPrimrose
ThrudPrimrose marked this pull request as ready for review July 20, 2026 13:57
@ThrudPrimrose ThrudPrimrose reopened this Jul 20, 2026
@ThrudPrimrose
ThrudPrimrose marked this pull request as draft July 20, 2026 15:12
ThrudPrimrose and others added 12 commits July 20, 2026 17:27
A subsets.Range entry of size 1 is ambiguous: a rank-reducing index into a larger
dimension and a genuine extent-1 dimension are both stored as (0, 0, 1). Range.squeeze()
drops both, so a full read of a (NQ, 1, NP) view looks identical to a 2D slice.

np.reshape(x, (NQ, 1, NP)) builds exactly that. SpecializeMatMul dispatched on the
squeezed sizes and saw a matrix, picking Gemm, whose validate re-read the unsqueezed
subset and raised "matrix-matrix product only supported on matrices". npbench's doitgen
hits this at every size, on both the simplified and the auto-optimized pipeline.

The descriptor is the authority: an entry of size 1 is rank-reducing only where the
descriptor's own extent is larger. Under that rule the reshaped operand stays 3D and
dispatches to BatchedMatMul, which is what numpy does, while an index into a larger
dimension still squeezes to Gemm. BatchedMatMul.validate went through the same shared
helper so both nodes agree on operand rank.

Regressed in afd0efe, which removed the squeezing from the validate methods but left
SpecializeMatMul dispatching on squeezed sizes. That commit also dropped the (NQ, 1, NP)
reshape from the in-tree doitgen test, which is why CI stayed green.
numpy distinguishes `a[0]` from `a[0:1]`: an integer index removes its dimension from the
result rank, a slice never does, not even at extent 1. `Range` stored both as `(i, i, 1)`, so
the distinction was unrecoverable, and consumers needing the rank of an access had to guess by
squeezing every extent-1 dimension. That guess is wrong whenever a sliced dimension happens to
have extent 1, which is how `np.reshape(x, (NQ, 1, NP)) @ C4` ends up dispatched as a matrix
product whose validator then rejects it as not a matrix.

`Range` now carries a per-dimension `index_dims` flag, with `rank()`, `rank_dims()` and
`is_index_dim()` derived from it, and the frontend records it for mixed subscripts. `squeeze()`
deliberately keeps its meaning -- it is `np.squeeze`, dropping every extent-1 dimension -- and
now maintains the flags rather than discarding them; `unsqueeze()` inserts slices, matching
`np.expand_dims`. Because only a degenerate range can be an index, `is_index_dim` cross-checks
the flag against the range instead of trusting it, so widening a dimension through `__setitem__`
or a direct `ranges` assignment cannot leave it looking indexed.

The flags survive JSON but not the string form, which still renders a degenerate range as `i`.
That is deliberate: subset strings are re-parsed as symbolic expressions in places that reject
slice syntax (`scalar_to_symbol` embeds them in interstate assignments) and are used as
dictionary keys against the original text (`sdfg_to_tree` de-aliasing), so the rendering cannot
change. Generated code is byte-identical and existing SDFGs load at the rank they were saved
with, since a missing `indexed` entry means slice.

Also reverts the MatMul operand change from 638022d, which redefined what `_get_matmul_operands`
reports as operand size for every BLAS node. It made a 1x1 contraction look like a matrix while
its descriptor stayed a scalar, so cblas_dgemm was handed a `double` where it wanted a `double*`
(the CI failure in tests/numpy/einsum_test.py::test_opteinsum, reproducible under
DACE_optimizer_autooptimize=1). It also broke Gemv's pure expansion and silently dropped alpha,
beta and summation WCR by rerouting products to BatchedMatMul, which honours none of them.

doitgen is still unfixed; doitgen_repro.py drives it and is temporary.
`SpecializeMatMul` selects GEMM by matching on the squeezed operand sizes, so
`np.reshape(x, (NQ, 1, NP)) @ C4` is routed there as an `(NQ, NP) @ (NP, NP)` product. Since
afd0efe, `Gemm.validate`, `ExpandGemmPure` and `_get_codegen_gemm_opts` re-read the raw subset
instead, see rank 3, and reject the operand the dispatcher had just accepted with
"matrix-matrix product only supported on matrices". npbench's doitgen fails at every size, on both
the simplified and the auto-optimized pipeline.

The fix is for GEMM to read the same tuple entries the dispatcher matched on. This does not
redefine what `_get_matmul_operands` reports -- gemv, ger, dot and the vendor and FPGA expansions
continue to read indices 4 and 5 exactly as before -- so it is confined to the GEMM path.

Collapsing the unit dimension is exact, not a convenience: it is the row count of an `NQ`-long
contiguous batch, so the product really is a single GEMM. Keeping it on GEMM also preserves alpha,
beta and a summation WCR on `_c`, none of which BatchedMatMul honours; a unit-batch product with
alpha now computes the right answer where it previously raised.

afd0efe also weakened the in-tree benchmark, replacing the `(NQ, 1, NP)` reshape with
`(NQ, NP)` so it stopped exercising the broken path, which is why CI stayed green. That line is
restored, and it fails without this change.
A size computed in the program (`nt = Nt + 1` then `np.empty(nt)`) is a scalar
data descriptor, but an array extent has to be a symbol. The previous approach
ran a whole-SDFG ScalarToSymbolPromotion mid-parse, which deleted the scalar.
Any later read or reassignment of the size then hit a hard KeyError, and the
pass could disturb unrelated scalars in the half-built SDFG.

Reuse the frontend's own promotion instead: `promote_scalar_to_symbol` mints a
`__sym_<name>` symbol assigned from the scalar on an interstate edge and leaves
the descriptor in place, exactly as `_promote` already does for subscripts. The
shape is rewritten by substituting the symbols in, rather than mutating the
SDFG. The promoted symbol is registered in the visitor's globals so nested
scopes can resolve it as a free symbol of their scope arrays.

The test now executes the programs and checks numerics for the reuse and
reassign cases, which were the KeyError crashes, instead of asserting that the
descriptor was destroyed.
…trix view

Follow-ups to tracking integer-index dimensions in Range:

- __setitem__ kept eager bookkeeping that unpacked every dimension as a triple,
  but a degenerate dimension may legally hold the bare index expression instead
  (add_indirection_subgraph assigns one, and dim_to_string has always rendered
  it). That raised "cannot unpack non-iterable Symbol object" during frontend
  indirection lowering. Revert __setitem__ to its plain form: is_index_dim
  already cross-checks the range at read time, and now accepts the bare form.

- map_dim_shuffle permuted ranges and tile_sizes by hand, leaving the flags
  misattributed to the wrong dimensions; use Range.reorder, which is exactly
  this permutation. Two redundant_array sites rebuilt a Range from bare lists
  and dropped the flags; carry them through (popped dimensions return as slices,
  matching unsqueeze).

- The GEMM matrix-view rule ("raw subset if 2D, else squeezed") lived in two
  places on two different inputs. Collect it into _matrix_subset_size beside
  _matrix_operand in matmul.py; validate, expansion and codegen share it.
The index_dims flag added to Range to distinguish an integer index from a
unit slice (numpy rank reduction) has no production consumer: rank(),
rank_dims() and is_index_dim() are read only by their own test. The GEMM fix
this PR needs dispatches on unit-dim squeezability, which is the opposite
question -- a reshape to (NQ, 1, NP) is all slices (rank 3) yet must be one
2D GEMM -- so it uses squeeze(), never the flag. The flag is also not
maintained through Memlet.from_memlet, bounding_box_union or propagation
(all rebuild a Range from bare tuples and reset it), so rank() is unreliable
after one propagation pass regardless.

Revert subsets.py, memlet_parser.py, map_dim_shuffle.py and redundant_array.py
to their pre-feature state and drop the flag's test. The GEMM matrix-view fix
(_matrix_subset_size / _matrix_operand in matmul.py) is independent -- it reads
only subset.size()/squeeze() -- and stays.
Shape promotion reused one __sym_<name> symbol per size scalar and re-assigned
it on every use. Two arrays sized from the same reassigned variable
(m = 64; a = np.empty(m); m = 2; b = np.empty(m)) then shared one symbol whose
value was overwritten, so a read as length 2 instead of 64 -- a silent
miscompile. A shape scalar used again as an index after the reassignment
(a[m]) hit the same collision through the subscript-promotion path and read
out of bounds.

Mint a distinct symbol for each shape (promote_scalar_to_symbol grows a `fresh`
flag), so every array records the size it was created with, and give fresh
symbols a suffix so they can never land on the bare __sym_<name> the index path
reuses. Iterating the size names in sorted order keeps the promotion states
deterministic.

ExpandGemmPBLAS still read the raw operand size while validate and the other
expansions were moved to the matrix view, so a (NQ, 1, NP) operand validate
accepts would be mis-sized there; read it through _matrix_operand too.
Shorten the docstrings and inline comments this PR added to DaCe's Sphinx
conventions, and replace two hand-rolled helpers with existing utilities:
find_new_name for the fresh promotion-symbol suffix, and symbolic.symlist
for a shape's free symbols. Read the GEMM C operand through _matrix_operand
like A and B rather than indexing the raw operand tuple.
Routing every subscript promotion through promote_scalar_to_symbol published
its __sym_ name in ProgramVisitor.globals, which the original _promote never
did. The leaked names shadowed name resolution and broke 25 frontend tests
with KeyError: '__sym_...'. Only shape promotions need globals visibility,
so gate the write on fresh.
A transient sized by a symbol that a control flow region's incoming edge
assigns had no legal allocation point. simplify() moves the assignment onto
an edge leaving the closest dominating state, and loops and conditionals are
excluded as dominators, so the allocation was emitted in that state, ahead of
the assignment. The array was then allocated at size zero and the first write
corrupted the heap.

Bracket every region with an empty state, so the assignment lands on the
leading state's incoming edge and the allocation follows it. The trailing
state also gives region-scoped deallocations somewhere to go; they were
dropped before, leaking the array.
unordered_arglist followed an exit-node connector only one hop, so when the
data leaves through several nested scopes the inner memlet names a local
transient and the outermost array never enters the argument list. GPU codegen
tiles device maps into nested scopes, so the kernel referenced an array and a
stride symbol its signature never declared.

Follow each path to its last edge, mirroring the read side.
ThrudPrimrose added a commit that referenced this pull request Jul 21, 2026
Cherry-picked from PR #2443. A size computed in the program (nt = Nt + 1;
np.empty(nt)) is a size-1 descriptor, but an extent must be a symbol. The previous
version promoted the descriptor itself with ScalarToSymbolPromotion, so two arrays
sized from the same reassigned name collapsed onto one value and the second array
got the wrong extent.

promote_size_scalars_in_shape now rewrites the shape instead of mutating the SDFG,
reading each size into its own fresh symbol on an interstate edge and leaving the
descriptor in place so it can still be read or reassigned. The subscript-promotion
path in ProgramVisitor is factored into promote_scalar_to_symbol and shares it.

Adds PR #2443's test file: 5 of its 8 cases fail on the previous implementation.
Review of the promotion and squeezing work turned up four defects.

A size used as an extent is admitted when its descriptor holds one element, but
the interstate edge assigned the container by name. For an array that assigns
its pointer, so np.empty(nt) with nt: int64[1] failed to compile. Read those
through nt[0]; a scalar still reads by name.

The rule for an operand's matrix view - the raw subset when it is already 2D,
the squeezed one otherwise - lived in matmul alone. Transpose squeezed
unconditionally, so it rejected an (N, 1) column as not a matrix, and its
operand helpers indexed size[1] after squeezing and would raise IndexError in
every expansion. Move the rule to blas_helpers.matrix_view, which also returns
the dimensions it kept so sizes and strides cannot disagree, and use it from
both libraries. The frontend no longer needs to route unit-dimension
transposition around the library node.

numpy.zeros, ones and full build their transient on their own path and so
rejected a computed size that numpy.empty accepted. Promotion opens a state to
carry the symbol assignment, so the fill has to be placed after it rather than
in the state the replacement was handed.

_get_matmul_operands left res_out unbound, crashing instead of reporting a
missing _c connector.
np.zeros/ones/full built their transient directly, so a size held in a
scalar was rejected while np.empty accepted it. Route them through
promote_size_scalars_in_shape, which now reports whether it promoted:
only then does the fill have to move to the state the promotion opened.

The two tests asserting the rejection now assert the result.
RegionBoundaryStates wrapped every control flow region in two empty
states. On a three-loop program with a branch that is 8 regions and
takes the state count from 5 to 15, none of which was needed. The
largest CloudSC SDFG has 778 regions and was paying 1556 empty states.

The two sides answer different questions, so they are decided
separately:

- A leading state is where an allocation goes. It is needed when the
  region's incoming edge assigns a symbol that a transient's size reads.
- A trailing state is where the matching deallocation goes. It is needed
  when the region ends its scope, whatever sized it: with no state after
  the last region, the free has nowhere to be emitted and is silently
  dropped. Bracketing only the sized regions leaked a transient that
  outlives the region assigning its size.

Sizes are collected across the whole SDFG tree rather than per SDFG: a
nested transient can be sized by a symbol an enclosing region assigns
and passes down through symbol_mapping, and reading only the owning
SDFG's arrays leaves that region unbracketed -- the malloc(0) bug again
one level down.

CloudSC now takes 79 states rather than 1556, in 39 ms.

Tests pin all three: the state count of a program needing nothing, a
conditional whose incoming edge carries the size, and the free that the
first cut dropped.
ThrudPrimrose added a commit that referenced this pull request Jul 21, 2026
Extended only carried the frontend half of the fix. The rest is brought
over from #2443, whose CI is green on all 15 jobs:

- promote_scalar_to_symbol emitted `__sym_nt_0 = nt` for a size-1 array,
  which assigns its pointer. A size-1 array is a valid extent, so the
  assignment reads the subscript instead.
- np.zeros/ones/full built their transient directly, so a size computed
  in the program was rejected while np.empty accepted it. They route
  through promote_size_scalars_in_shape, which now reports whether it
  promoted: only then does the fill move to the state that opened.
- matrix_view() shares one rule for reading an operand as a matrix --
  the raw subset when already 2D, the squeezed one otherwise. Squeezing
  unconditionally rejects a genuine (N, 1) column; not squeezing rejects
  an (NQ, 1, NP) reshape. MatMul had the rule inline, Gemm and Transpose
  lacked it, and Transpose indexed size[1] in every expansion, so an
  (N, 1) operand raised IndexError rather than failing validation.
- _get_matmul_operands left res_out unbound, raising UnboundLocalError
  instead of the intended ValueError when _c is missing.
- RegionBoundaryStates is wired into codegen after ControlFlowRaising.

state.py is deliberately not taken: extended already fixes the same
nested-scope kernel-signature bug through the memlet tree root. The PR's
assertion on the generated signature is taken and passes against it.
ThrudPrimrose and others added 3 commits July 30, 2026 10:06
Aligned allocations (#2438) changed the emitted line from `new double` to
`new (std::align_val_t(64)) double`, and the free from `delete[] b` to
`::operator delete[](b, std::align_val_t(64))`, so the two searches that find those lines stopped
matching once this branch merged main.

Both assertions are unchanged: the size symbol is still assigned before the allocation, and the array
is still freed. Only the patterns used to find the lines are now spelling-agnostic, as the codegen
tests on main already do.
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