Skip to content

[New GPU Codegen Prep] Add MoveArrayOutOfKernel - #2535

Open
ThrudPrimrose wants to merge 86 commits into
mainfrom
gpu-prep/hoist-kernel-transients
Open

ThrudPrimrose wants to merge 86 commits into
mainfrom
gpu-prep/hoist-kernel-transients

Conversation

@ThrudPrimrose

Copy link
Copy Markdown
Collaborator

Hoists a transient GPU_Global array declared inside a GPU_Device map out of the kernel, giving it one slice per map iteration, and demotes one small enough to per-thread registers instead; GPUs cannot allocate device-global memory inside a kernel, so this exists for backward compatibility. The lift builds its memlet paths with add_memlet_path and finds a free name with utils.find_new_name rather than open-coding either.

ThrudPrimrose and others added 13 commits July 3, 2026 16:14
DaCe's symbolic C++ printer emits dace::math::pow (libm, double) for a
non-constant exponent, which is illegal where a C++ integer is required -- an
array size, a subscript, or a loop bound. For example the radix decomposition
R**(K-i-1) used as a stride in stockham_fft fails to compile. dace::math::ipow
(repeated multiply, exact integer) is the correct lowering iff the exponent is
a non-negative integer (its C++ exponent is unsigned, so a negative one is
catastrophic).

- Add an ipow SymPy Function (dace/symbolic.py) lowering to dace::math::ipow;
  registered so it round-trips through parsing and serialization.
- Add the RelaxIntegerPowers pass rewriting Pow -> ipow for non-negative
  integer exponents: constants, integer-valued float literals, and symbolic
  exponents proven >= 0 by interval analysis over the enclosing loop / map
  iterator ranges. A scoped recursive descent binds each iterator (and each
  SDFG's declared symbol signs) as it enters loops, maps and nested SDFGs and
  drops them on exit; the proof reuses equalize_symbol and the assuming/ask
  idiom. Wired last in SIMPLIFY_PASSES.
- Teach infer_symbols_from_datadescriptor to read ipow as Pow when solving a
  shape for its symbols (ipow is opaque to the solver otherwise).
- Unskip the stockham_fft CPU test; add tests/passes/relax_integer_powers_test.py.
_SerializedSymbolicParser._functions (the serialize_symbolic/
deserialize_symbolic path used for SDFG properties like an Array's
total_size) lacked ipow, so a serialized 64*ipow(P, 2) deserialized to an
opaque Function('ipow') that never folds; int(evaluate(total_size, {P: n}))
then raised "Cannot convert symbols to int" (mpi subarrays_test BlockGather,
whose return shape is 8*P x 8*P). Register the real class so it round-trips
and folds.
…ove pass pre-codegen

Root cause of the stockham_fft heap corruption: ``dace::math::ipow`` seeded its
accumulator at ``a`` and looped ``b - 1`` times, so ``ipow(a, 0)`` returned ``a``
instead of ``1``. Every zero-exponent size/stride/bound was corrupted (e.g.
``ipow(R, 0)`` -> ``R``), doubling the innermost tile-transpose loop and writing one
element past the output array. Seed at ``1`` and loop ``b`` times so the base case is
correct; pass the scalar exponent (and base) by value.

Also close the gaps that left an integer-context power as a ``double`` ``dace::math::pow``:
- RelaxIntegerPowers now visits loop bounds/conditions, interstate-edge conditions +
  assignments, and nested-SDFG symbol mappings -- not just descriptors and memlet
  subsets. The scope analysis already proved these exponents non-negative; they were
  simply never offered to it.
- cppunparse qualifies an ``ipow`` call as ``dace::math::ipow`` (the symbolic printer
  already does for descriptor expressions; loop bounds/interstate edges unparse through
  the AST path instead).
- Move RelaxIntegerPowers out of SIMPLIFY_PASSES to a pre-codegen pass. A size is just a
  more complex expression that should not perturb simplification; keeping powers as
  ``Pow`` through simplify also lets SymPy fold ``R**i * R**(K-i-1) -> R**(K-1)`` before
  the opaque ``ipow`` freezes the form.
The single ipow template seeded the accumulator with T(1), but vector value
types (half4/half8, and the CPU vector types) have no scalar constructor, so
T(1) fails to compile -- regressing the CUDA half-vector gelu path that raises
a value to a constant integer power (a**3 -> ipow(a, 3)).

Split into two SFINAE overloads keyed on is_constructible<T, int>: scalar types
seed at T(1) (keeping the a**0 == 1 base case), vector types seed at a and loop
from 1. Vector ipow is only ever emitted with a compile-time exponent >= 1 (the
constant-power path writes a literal 1 for exponent 0), so the base case only
matters for scalars.
…-case

The cppunparse/interstate path (loop bounds, interstate-edge assignments) emitted ipow
through a bespoke t.func.id == 'ipow' branch that hand-qualified it to dace::math::ipow.
Every other sympy/DaCe builtin on that path (min, max, int_ceil, Min, Max, Abs) instead
resolves by being defined at global scope in the runtime headers, so the default _Call
emits the bare name. Give ipow the same treatment: a global-scope ipow wrapper in math.h
forwarding to dace::math::ipow, and drop the special-case branch.
…onent

dace::math::pow had integer overloads for `int` and `unsigned int` only, so int64 symbols
fell to the generic std::pow and came back `double`. stockham_fft's `R ** K` then reached
codegen as a floating-point value in two places gcc rejects outright: an OpenMP loop bound
("invalid controlling predicate") and a pointer offset in the MatMul expansion's WCR
("invalid operands of types 'dace::complex128*' and 'double'"). A constrained template now
covers every integral width, and the generic overload excludes both-integral so the two
cannot be ambiguous.

Addresses the review on the ipow overloads. The global wrapper takes an arbitrary `U` and
forwards it to an `unsigned int` parameter, so a signed -1 arrives as 4294967295 and the
loop spins instead of returning; it now answers 0, which is what pow(int, int) has always
done for a negative exponent. The guard is limited to arithmetic T because pi.h defines
ipow for the typeless_pi types, which have no T(0).

The 3 sve and 1 argument_signature failures in tests/codegen are pre-existing on this
branch: they fail identically with this commit's math.h reverted.
Hoists a transient GPU_Global array declared inside a GPU_Device map out of
the kernel, giving it one slice per map iteration, and demotes one small
enough to per-thread registers instead. GPUs cannot allocate device-global
memory inside a kernel, so this exists for backward compatibility.

The lift builds its memlet paths with add_memlet_path and finds a free name
with utils.find_new_name rather than open-coding either, and the node-to-state
lookup is built once for the whole pass.
@ThrudPrimrose ThrudPrimrose changed the title [WIP] Add MoveArrayOutOfKernel [New GPU Codegen Prep] Add MoveArrayOutOfKernel Aug 26, 2026
…r redesign

The pass now walks each owning SDFG's transient once, computes the GPU levels
its accesses sit under, and prefixes every memlet from that one fact. Ported
from extended, each with a reproducer:
- index a lifted slice from the map's first iteration, not the raw param;
- order the prepended indices like the prepended dimensions (outermost first);
- carry one slice per iteration out through the map exits, not the whole buffer;
- translate a lifted descriptor through the nested symbol_mapping (simultaneous);
- reshape and rewrite only the defining SDFG and its ancestors, never the
  nests below it that receive the buffer through a connector;
- rewrite subscripts that interstate edges, loops and branches read;
- bind the kernel params the new index names into every nest on the way down,
  and never into a nest that assigns that name itself (refused instead).
Also drops the node-to-state cache, a class-level mutable default shared across
instances, and refuses a transient shared by two kernels or read by control
flow under per-thread maps instead of lifting it wrongly.
…ls, control flow reading a per-thread buffer
…ansient in place when a nest would misread its index
…im redundant tests

enclosing_maps walks helpers.get_parent_map. prefix_control_flow hands its subscript
prefixer to rewrite_code_slots, which also rewrites NestedSDFG symbol_mapping values, so a
nest bound to an element of a lifted buffer now reads it with the kernel index (new test).
Merge duplicate tile_extent, layout and non-transient accumulator tests.
@ThrudPrimrose
ThrudPrimrose marked this pull request as ready for review September 28, 2026 16:33
ThrudPrimrose and others added 6 commits September 28, 2026 22:08
…lyses

Both passes spent most of their time outside their dataflow analyses. Output is
unchanged (identical SDFG JSON on all benchmarks):
- ConstantPropagation: 17.3s -> 4.1s on a test SDFG with ~900 nested SDFGs and
  ~10k symbols, 53s -> 4.1s on a 4000-block CFG.
- SymbolPropagation: 11.7s -> 0.5s on the same test SDFG, 57s -> 6.1s on a
  4000-block CFG, and time no longer grows with loop nesting depth.

Shared:
- Add cfg.blockorder_reverse_postorder, a linear block order for fixed-point
  analyses, used by both passes instead of blockorder_topological_sort, whose
  dominator and branch analyses are quadratic in the length of a region.
- Add Node.new_symbol_names so used_symbols no longer infers the types of map
  parameters only to discard them.
- replace_properties_dict substitutes into symbolic properties only when a
  replaced symbol occurs, and _internal_replace filters replacements by name
  without invoking the SymPy printer.

ConstantPropagation:
- Process nested SDFGs before removing unused symbols from their parent and
  memoize their used symbols (nodes.memoize_nested_used_symbols), so each
  nested SDFG is traversed once instead of once per enclosing SDFG.
- Skip empty descriptor replacements and memoize free-symbol and parsing
  queries on assignment values.

SymbolPropagation:
- Dead assignment elimination skips SDFGs without assignments, sweeps each
  SDFG to its own fixed point, and assembles the symbols read from blocks,
  meta code and edges instead of recomputing free symbols per region.
- Revisit a region after its contents so that its successors see final values
  in the same sweep.
- _update_syms computes each free-symbol set once and skips regions whose meta
  code and outgoing edges read no candidate.
- Memoize free-symbol and parsing queries on values, and precompute the view
  and opaque scalar names per SDFG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a test SDFG with ~4.4k states, ~8.5k control flow regions and ~4.7k data containers, code generation ran out of
memory (over 24 GB) in the reachability analysis; past that, it was on track to take ~50 minutes, dominated by
per-array and per-tasklet rescans of the whole SDFG. It now takes 54 s with a peak of 1.4 GB, and loading takes 13 s
instead of 25 s. Generated code and serialized SDFGs are unchanged on all tested SDFGs.

- ControlFlowBlockReachability / StateReachability: return read-only set views backed by per-region bitset
  transitive closures instead of materialized reachable sets (quadratic memory in deeply nested control flow).
- Allocation lifetime: compute per-SDFG control-flow symbols and access nodes once instead of per array.
- InterstateEdge.new_symbols / LoopRegion.new_symbols: skip edges without assignments and only look up the data
  containers named in the expressions instead of building a dictionary of all of them.
- Code generation resolves the symbols of each state once (SymbolResolver) and the struct types once per SDFG;
  tasklets without struct initializers skip StructInitializer.
- ControlFlowRegion.cfg_id: cache the position in the CFG list.
- Validation: check connector names against symbols/constants/arrays without enumerating all nested data names.
- Deserialization: reset the CFG list once per SDFG instead of per added region; cache the version comparison
  of the symbolic deserializer.
- Serialization: cache the default assumptions of symbols per type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt list

- The final clean-up of the frame code appended every kept line to one string (and truncated it to drop a goto
  followed by its label), which copies the code generated so far for each line: quadratic in the code size. Lines
  are now collected in a list and joined once, labels are looked up in the goto counter instead of the list of all
  gotos, and the regular expressions are compiled once.
- The top-level interstate variable declarations reuse the argument list computed on construction (the one the
  function signature and the targets use) instead of traversing the whole SDFG again with ``SDFG.arglist()``.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ``read_set |= rs.keys()`` does not update the set in place (``set.__ior__`` does not accept dictionary views, so
  ``dict_keys.__ror__`` builds a new set), which made collecting the data containers of all states quadratic in their
  number. The sets are now updated in place.
- The state-level read/write analysis deep-copied its result, although ``SDFG.read_and_write_sets`` and
  ``SDFGState.read_and_write_sets`` only use the container names. Both now use the uncopied
  ``_read_and_write_subsets``; ``_read_and_write_sets`` still returns a copy.

Both are used by ``SDFG.used_symbols``, which code generation calls on the whole SDFG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ``blockorder_topological_sort``: in a region where no block has more than one outgoing edge, the traversal only
  follows single edges and never consults the parent tree, branch merges or loop exits, so their computation
  (immediate dominators, transitive closure of the dominator tree, dominance frontiers) is skipped.
- ``block_parent_tree``: branch merges are only computed when loops are respected (the only place they are used), and
  reuse the dominators computed there instead of computing them a second time.
- ``ControlFlowRaising``: regions with a single sink and no branches have no conditionals to lift, so the dominator
  analysis is skipped for them (the start block is still queried, as the analysis would).

Code generation calls these once per control flow region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThrudPrimrose and others added 26 commits October 3, 2026 10:13
networkx 3.6+ omits the start block from immediate_dominators, so block_parent_tree and
blockorder_topological_sort raised KeyError on the entry block during codegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is get_parent_map repeated to the outermost scope, so it belongs next to
it rather than in the pass.
Remove the yapf pre-commit hook, .style.yapf and the yapf pin in the linting group.
Add the ruff-format hook next to ruff-check. line-length and quote-style carry over the yapf settings; notebooks stay unformatted as before.
ruff honours fmt: off/on at statement level only, so the parametrize decorator uses a trailing fmt: skip.
# Conflicts:
#	dace/autodiff/library/library.py
#	dace/codegen/codeobject.py
#	dace/codegen/targets/cpu.py
#	dace/data/core.py
#	dace/data/distributed.py
#	dace/frontend/python/replacements/array_manipulation.py
#	dace/frontend/python/replacements/filtering.py
#	dace/libraries/blas/blas_helpers.py
#	dace/libraries/blas/nodes/batched_matmul.py
#	dace/libraries/blas/nodes/dot.py
#	dace/libraries/blas/nodes/einsum.py
#	dace/libraries/blas/nodes/gemm.py
#	dace/libraries/blas/nodes/gemv.py
#	dace/libraries/blas/nodes/ger.py
#	dace/libraries/blas/nodes/matmul.py
#	dace/libraries/fft/nodes/fft.py
#	dace/libraries/linalg/nodes/tensordot.py
#	dace/libraries/mpi/nodes/allreduce.py
#	dace/libraries/mpi/nodes/redistribute.py
#	dace/libraries/mpi/nodes/reduce.py
#	dace/libraries/onnx/nodes/onnx_op.py
#	dace/libraries/onnx/nodes/onnx_op_registry.py
#	dace/libraries/onnx/schema.py
#	dace/libraries/pblas/nodes/pgemv.py
#	dace/libraries/sparse/nodes/csrmm.py
#	dace/libraries/sparse/nodes/csrmv.py
#	dace/libraries/standard/helper.py
#	dace/libraries/standard/nodes/copy/node.py
#	dace/libraries/standard/nodes/fill/node.py
#	dace/libraries/standard/nodes/reduce.py
#	dace/libraries/stencil/stencil.py
#	dace/memlet.py
#	dace/properties.py
#	dace/sdfg/nodes.py
#	dace/sdfg/sdfg.py
#	dace/sdfg/state.py
#	dace/transformation/dataflow/copy_to_device.py
#	dace/transformation/dataflow/copy_to_map.py
#	dace/transformation/dataflow/local_storage.py
#	dace/transformation/dataflow/map_expansion.py
#	dace/transformation/dataflow/mapreduce.py
#	dace/transformation/dataflow/reduce_expansion.py
#	dace/transformation/dataflow/stream_transient.py
#	dace/transformation/dataflow/streaming_memory.py
#	dace/transformation/dataflow/strip_mining.py
#	dace/transformation/dataflow/tasklet_fusion.py
#	dace/transformation/dataflow/tiling.py
#	dace/transformation/dataflow/vectorization.py
#	dace/transformation/dataflow/warp_tiling.py
#	dace/transformation/estimator/enumeration/enumerator.py
#	dace/transformation/estimator/enumeration/greedy_enumerator.py
#	dace/transformation/estimator/enumeration/scoring_enumerator.py
#	dace/transformation/helpers.py
#	dace/transformation/interstate/gpu_transform_sdfg.py
#	dace/transformation/interstate/loop_peeling.py
#	dace/transformation/interstate/loop_unroll.py
#	dace/transformation/interstate/sdfg_nesting.py
#	dace/transformation/pass_pipeline.py
#	dace/transformation/passes/analysis/analysis.py
#	dace/transformation/passes/constant_propagation.py
#	dace/transformation/passes/dead_dataflow_elimination.py
#	dace/transformation/passes/full_map_fusion.py
#	dace/transformation/passes/fusion_inline.py
#	dace/transformation/passes/length_one_array_scalar_conversion.py
#	dace/transformation/passes/loop_local_memory_reduction.py
#	dace/transformation/passes/pattern_matching.py
#	dace/transformation/passes/prune_symbols.py
#	dace/transformation/passes/scalar_promotion.py
#	dace/transformation/passes/scalar_to_symbol.py
#	dace/transformation/passes/simplification/control_flow_raising.py
#	dace/transformation/passes/simplify.py
#	dace/transformation/subgraph/composite.py
#	dace/transformation/subgraph/expansion.py
#	dace/transformation/subgraph/stencil_tiling.py
#	dace/transformation/subgraph/subgraph_fusion.py
#	dace/transformation/transformation.py
#	tests/codegen/aligned_allocation_test.py
#	tests/codegen/allocation_after_size_assignment_test.py
#	tests/codegen/allocation_lifetime_test.py
#	tests/codegen/cpp_test.py
#	tests/codegen/external_memory_test.py
#	tests/library/copy_node_test.py
#	tests/numpy/searching_test.py
#	tests/passes/symbol_propagation_test.py
#	tests/transformations/nest_subgraph_test.py
ThrudPrimrose added a commit that referenced this pull request Oct 13, 2026
…NestedGPUDeviceMapLowering (#2534)

MoveArrayOutOfKernel keeps constant-size per-thread arrays of up to register_limit (16) elements in
registers and moves larger and symbolically-sized ones out of the kernel; the GPU codegen preprocess
pipeline keeps calling both passes. Branch code that iterated states() as a list or called
all_states() follows main's generator states().
ThrudPrimrose added a commit that referenced this pull request Oct 13, 2026
…NestedGPUDeviceMapLowering (#2534)

MoveArrayOutOfKernel keeps constant-size per-thread arrays of up to register_limit (16) elements in
registers and moves larger and symbolically-sized ones out of the kernel; the GPU codegen preprocess
pipeline keeps calling both passes.
… up to register_limit and moves the rest

Binds the kernel parameters before reshaping the nests below the owner.
@ThrudPrimrose
ThrudPrimrose force-pushed the gpu-prep/hoist-kernel-transients branch from 747ace5 to 568468f Compare October 13, 2026 16:37

This branch has not been deployed

No deployments
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.

2 participants