Skip to content

[WIP] New CPU codegen, new GPU codegen and GPU offloading pass (combined) - #2620

Draft
ThrudPrimrose wants to merge 1017 commits into
mainfrom
main-ngc-offload
Draft

ThrudPrimrose wants to merge 1017 commits into
mainfrom
main-ngc-offload

Conversation

@ThrudPrimrose

Copy link
Copy Markdown
Collaborator

Combines the readable CPU code generator, the new GPU code generator (#2259) and the GPU offloading pass on top of main, so all three run through CI together.
It also replaces the const-init marking with a small pass that promotes literal-only transients to SDFG constants.

🤖 Generated with Claude Code

FinnHeckman and others added 30 commits September 1, 2026 02:13
…L_models group

The exported graph needs a pure And expansion to lower; eager attention keeps the SDPA mask guards out of the graph.
…k factory

transformers builds the mask from inputs_embeds.shape[1], which the TorchScript tracer turns into a 0-dim tensor. A 4D mask is returned as-is, so the tracer never reaches that code.
Deleting dace/transformation/interstate/gpu_transform_sdfg.py left about a
hundred references to it behind, so the branch did not import: SDFG.apply_gpu_
transformations still imported the deleted module (through a `dace.dace.` path
that would not have resolved either), and GPUTransformMap, the sparse csrmm /
csrmv expansions, eight test modules, a codegen sample and three doc pages named
it directly.

apply_gpu_transformations now runs OffloadToAccelerator and honours its own
simplify / validate contract. sequential_innermaps, register_transients,
permissive, host_maps and host_data were that transformation's parameters and go
with it -- the pass derives what stays on the host rather than taking it as an
input. Callers that passed them are updated.

tests/host_map_host_data_test.py tested nothing but the two removed host
parameters and goes with them. gpu_transform_test.py loses the two cases that
asserted the old transformation's own output shape rather than a general
property: its toplevel_trans lifetimes, and its copy elision for a fully-written
array.

The doc entry moves off the interstate page, which no longer holds that module,
and onto the passes page under its real module path.
Five separate reasons the pass could not have run as merged.

The package had no __init__.py, at either level, so neither the module nor a
wheel built from it was importable. Phase 1 and phase 6 each built an f-string
with a nested same-quote expression, which is PEP 701 and therefore Python 3.12
and up; the project declares >= 3.10, where both are syntax errors. Phase 3
imported the length-one array <-> scalar conversion from a private copy of it
under passes/vectorization/ -- a directory that was also not a package -- rather
than the version merged to main in #2532; the copy is deleted and the merged
module, a superset of it, is used instead.

Every set in the package is now an OrderedSet. The one the review asked about
drives which partitions phase 4 wraps, and the rest reach node insertion the
same way, so a plain set makes codegen depend on hash order.

The tests were named *_unittests.py, which pytest.ini's python_files does not
match, so CI collected none of the fifteen. They also carried gpu_offload and
current markers that were never registered -- pytest_configure only takes effect
in a conftest -- and five of them called sdfg.view(), which opens a browser.
They are renamed, marked gpu, and no longer draw graphs.

MAX_ITERATIONS and VERBOSE were mutable class attributes; they are now
max_iterations and verbose Properties on the Pass, with the bound raised well
past what any SDFG needs and a docstring for why the loop terminates. Also
dropped: an unused GPU_CPU_Unified enum pair, a committed npbench.db, a stray
whitespace-only edit to validation.py, the debug edits to auto_optimize.py that
had disabled expand_library_nodes, and a dead match arm that named a CLOSE node
"open".
Renaming the test file so pytest collects it turned up two failures and one
crash, none of which had ever run in CI.

Both failures were one bug in the single-element copy optimization. Moving an
access node through a map entry leaves ``other_subset`` describing a node that is
no longer on the edge: validation then reads it against the surviving node's
descriptor and reports a dimension mismatch, or -- when that node is a tasklet,
which has no descriptor -- crashes reaching for ``.data`` on it. The subset
describes the second container of a container-to-container copy, so it is
dropped wherever the rewire puts a scope node or a tasklet on one end.

The crash was a nested SDFG node in a state. The copy analysis dispatched on
ControlFlowRegion and had no case for one, so it raised by name. A nested SDFG is
a graph of its own and is now classified by its contents: one holding no
GPU-scheduled node runs on the host whole, which is what a sequential scan in a
loop region is. Classifying it by the state around it made the state hybrid and
handed its body to the size-1 wrapper, which is one kernel launch per scan step.

The size-1 wrapper also carried three defects fixed on the extended branch. An
empty memlet orders and carries no data, so a connector on it is invalid and
infer_connector_types raised KeyError; empty boundary edges now cross the wrapper
connector-free, and the connector numbering follows a list rather than a set.
Ordering edges for dangling roots cannot be the else-branch of the rewiring,
since rewiring one boundary edge does not make the other roots any less dangling.
And a partition is a dataflow component while a map scope spans one, so a
partition holding half a scope is closed under its scopes, or left alone where
closing would reach across a kernel.

Three gaps behind the schedule assignment go with them: a Stream has no subset to
collect and is invisible to the analysis rather than an error, an expansion now
declares whether it runs inside a kernel, and a transient live only inside one
kernel becomes a Register instead of a host allocation the dispatcher would have
to answer with an illegal copy.

Schedule assignment is now a single recursive walk: GPU_Device at a host level,
Sequential below one, and a nested SDFG at a host level is a host level of its
own.
The offloading makes every top-level map a kernel. That is wrong for a map whose
purpose is to LAUNCH work rather than do it -- ICON's shape, an nblks map over
nproma/nlev bodies -- and it cannot work at all for a map holding a callback.

host_maps takes None to name nothing, True to derive them structurally, or a list
of the maps themselves, each given as a label or as the MapEntry node. It is a
constructor argument rather than a Property: a MapEntry cannot round-trip through
JSON, and a caller passing node objects is driving the pass in process anyway. A
named map is final except where the lowering could not be emitted -- an inner
extent naming the outer map's own parameter reaches the launch configuration,
where that parameter is not in scope.

Automatic detection is structural only: a scope that launches rather than
computes. Deciding a map by comparing how many threads each lowering would launch
is a heuristic and is deliberately left out.

Callbacks are the case that needs no naming at all. Neither kind can be
offloaded -- a Python callback needs the interpreter, and a GPU callback is
itself a launch, so a kernel cannot issue one -- so a map around one stays on the
host whatever host_maps says, and a callback bounds a partition rather than being
swept into a size-1 map. This derives what host_data=['__pystate'] and
exclude_tasklets were pinning by hand against the transformation this pass
replaced, which is why neither parameter comes back.

The same rule already covered a map around a device-wide library node: a cuBLAS
call is issued by host code, so that answer does not wait for automatic
detection either.

Nine tests, on the ICON zekinh gather kernel the shape is named for. The control
asserts the outer map IS offloaded when nothing is named, so the pinning tests
cannot pass by asserting the default; the negative control asserts a parent map
that does its own work is not auto-detected, so detection cannot pass by calling
every outer map a host map. A frontend-generated callback and a hand-built map
around one cover both routes, and the scan case pins that a loop-carried
accumulation gains no map at all.
… with dropout

torch.onnx.export restores the module's original training flag when it finishes. A freshly constructed wrapper defaults to training mode, so the restore switched the wrapped BertModel back on and the reference outputs, computed after the export, were sampled with dropout active.
…entions

Three things, all in the offloading package.

A map enclosing a device-wide library node was kept on the host unconditionally,
on the grounds that a vendor call is issued by host code. That is an opinion
about scheduling, and applying it whatever the caller asked for moved the kernel
one level inwards wherever a library node sat under a map: npbench spmv came out
with its outer map sequential and the gather map inside a nested SDFG promoted to
a kernel, which then read host memory ('Illegal copy! (from x to indirection)'),
and trmm failed to compile. Both pass again with the rule gone. host_maps is now
what it says: a list the caller gives, automatic structural detection, or
nothing. The ExpandTransformation.runs_inside_kernel flag existed only to serve
that rule and goes with it, which leaves dace/transformation/transformation.py
and dace/libraries/standard/nodes/reduce.py identical to main again.

A program returning several values names them __return_0, __return_1, ... The
device twin tested for equality with "__return", so only the single-return case
was moved out of the reserved namespace; the rest became __return_0_gpu, which
still starts with __return and the runtime refuses as a transient. The host twin
next to it already used startswith -- the asymmetry was the bug. bicg,
gramschmidt and ludcmp pass again.

The rest is convention. The phase modules were named offl_phaseN_<what>, an
abbreviation and a numbering with gaps (no 5, a 7b) in a directory called
offload_to_accelerator_phases, where every sibling package under passes/ uses
plain descriptive module names; they are now phases/<what>.py, and the ordering
stays where it is load-bearing, in apply_pass. Every public function carries
parameter and return annotations. A map parameter becomes a SYMBOL, so
get_new_map_identifiers now checks the name against every namespace one can live
in -- symbol tables up the parent chain, descriptors, and every map in the SDFG
-- rather than one state's parameters, since reusing a name that is already a
symbol elsewhere gives one string two sets of assumptions. Extent comparison
reads names through symbolic rather than str() on sympy objects. Also gone: two
plain sets whose iteration order reaches codegen, a hasattr, two direct function
imports, and a duplicated copyright header.
tests/npbench runs these kernels end to end on the device, but every one of them
is marked gpu, so on a machine without one the offloading gets no corpus coverage
at all -- which is how two defects reached the branch and were only found by
reading a failure on hardware.

This runs the same pipeline those tests run, auto_optimize for the GPU, and stops
before the compiler. validate and generate_code need neither a device nor a
toolchain, take about a second a kernel, and still catch the whole placement
family: a descriptor moved to the device that host code still reads fails
validate with the container named, and a name minted into a namespace the runtime
reserves fails at code generation.

Running the whole pipeline rather than the pass alone is the point. auto_optimize
picks library implementations before it offloads, and a raw parsed SDFG has none:
offloading one directly leaves a host LAPACKE call under a GPU schedule with its
status scalar in device memory, which is a graph the pipeline never produces.
Asserting on it reported a library bug that cannot happen, and nearly bought a
fix for it. Going through auto_optimize also means the cuBLAS and cuSolverDn
lowerings are the ones under test, rather than the reference expansions.

A kernel whose own test_gpu is skipped is skipped here too, with upstream's
reason: mandelbrot2 is issue #1139 and lenet raises std::runtime_error, and
reporting either here would be reporting somebody else's open bug as this pass's.
Reading the marker off the module keeps the two in step when one is re-enabled.

33 kernels, 5 skipped.
A view owns no storage: it is an alias, and it belongs wherever the container it
aliases ends up. The pass did not treat it that way, and the two halves of the
mistake hid each other.

ArrayView, ContainerView and ContainerArray all derive from Array, so is_array
answered True for every one of them and the view branch sitting below it was
unreachable. An alias was therefore placed as though it owned a buffer, which is
how a view came to be renamed into a device twin of itself -- C_0 aliasing
C_0_host aliasing C_0, a cycle the code generator follows until it runs out of
stack. Meanwhile a view that was NOT placed kept whatever storage it was declared
with, so npbench correlation handed cuBLAS three Default views of GPU_Global
arrays and the call was emitted into the host translation unit, where the stream
it wants does not exist.

Both are fixed together, because fixing either alone breaks the other: is_array
now recognises only real buffers, and a view takes its origin's storage as the
LAST step of copy insertion -- only then does every container carry the storage
the placement gave it, and doing it earlier copies a host storage that has not
been updated yet. A view inside a kernel keeps Register, which the code generator
requires, and a view chain that reaches no access node is left alone instead of
recursing on None and asking the state for the edges of a node it does not hold.

Structures and container arrays have no single buffer to place -- one is a record
of descriptors, the other an array of them -- so they are skipped by name like a
Stream rather than falling through to the raise. A length-one view is excluded
from the scalar conversion for the same reason ConvertLengthOneArraysToScalars
exempts one: a Scalar cannot carry the views alias edge.
OffloadToAccelerator takes host_maps -- the maps that keep a host schedule, so
that the maps under them become the kernels -- but apply_gpu_transformations did
not forward it, and that method is how everything outside this package offloads.
A caller could not reach the feature at all, which is the same gap the old
transformation's own host_maps parameter used to cover.

The parameter defaults to naming nothing, so nothing changes for a caller that
does not pass it. Two tests: one asserts the method and the pass reach the same
schedules for the same named map, so the plumbing cannot rot without saying so,
and one asserts the default still offloads the outer map, so the first cannot
pass by asserting the behaviour the pass produces anyway.
host_maps defaulted to None, which read as "unset" rather than as an answer. The
default is now False -- run no host-map detection at all -- and None and [] mean
the same thing, so a caller that computes the list and finds it empty gets "name
none" instead of falling into a different branch. True is what asks for the
built-in heuristics, and a list names the maps outright.

Nothing changes for a caller that passes nothing: all three spellings already
produced no host maps, and the default is the same answer under a name that says
so. A test pins the four spellings against each other, so the day one of them
drifts into meaning something else it says so.

A map holding a callback is still kept on the host whatever this says: a Python
callback needs the interpreter and a GPU callback is itself a launch, so a kernel
cannot issue either, and offloading one produces code that cannot run rather than
code that runs slower.
Two ways an SDFG stopped surviving a serialization round trip, both found by running the
GPU pipeline over the npbench kernels with `testing.serialization` on.

A memlet parsed from a string mints its symbols with the default dtype and no assumptions,
while a map parameter carries the frontend's -- int64 and nonnegative. SymPy compares
assumptions, so the two `k` atoms stay distinct and offsetting one subset by the other
leaves `k - k` in the range instead of `0`. A symbol in DaCe is its name, so both denote
one value; `Range.offset` and `offset_new` now collapse such a pair, guarded by a name
count so the substitution only runs when a name really is duplicated (vadv).

The GPUAuto reduce expansion reshaped its `_in` descriptor by assigning `shape` alone.
`offset` is rank-dependent, so it stayed at the source array's rank and the descriptor
failed its own `validate` with "Offset must be the same size as shape"; deserialization
then answered with an unregistered placeholder instead of an Array (softmax). It goes
through `set_shape`, which recomputes what the rank decides.
`compiler.cuda.max_concurrent_streams` went back to 0. Changing the shipped default to -1
puts every SDFG on the default stream, offloaded by this pass or not, which is not this
pass's call to make: it dropped the cross-stream event synchronization and the host wait
before a device-to-host copy's reader, taking four codegen tests with it.

Copy placement only propagated forwards, so an array first used on the device late in the
program was copied there between two device states -- a host state in the middle of a run
of kernels, which is what GPUPersistentKernel then had to swallow and could not
("MappedTasklet expansion cannot cross the CPU/GPU boundary"). A state that never touches
the array cannot care which side it is on, so the copy now moves above it, and only when
every successor wants the array on the device, which leaves a copy inside the one branch
that needs it.

`OffloadToAccelerator(verbose=True)` raised a TypeError instead of setting the property;
both Properties are now named parameters, as the other passes spell it.
The offset corrections in vertical map fusion bring together two mints of one name -- a
memlet parsed from a string carries the default dtype and no assumptions, a map parameter
carries the frontend's -- and SymPy compares assumptions, so the subtraction leaves `k - k`
standing where it means `0`. That reaches code generation and stops the SDFG from surviving
a serialization round trip, because only one of the two atoms is written with its dtype.

Folding it in `Range.offset` fixed it for every caller at once, but that is a change to the
subset arithmetic every transformation shares, and it does not belong to this PR. The fold
now runs where the two mints actually meet. The general question -- how a name comes to
carry two different symbols at all -- is left to its own change.
… copy

The fold now lives in `Range.offset` and `Range.compose`, where the two mints of one name
actually meet, so map fusion needs no copy of it and `compose` is covered too -- the other
place a composed bound kept `i + (M - i - 1)`. This is the same change as the bugfix PR
against main; it sits here so the corpus test is green before that lands.
ThrudPrimrose and others added 30 commits October 2, 2026 12:31
…te names

The walk moves into PowerRelaxer, the sign facts of an SDFG become a frozen SignFacts that
is passed down instead of saved and restored on the pass, and the provers are public
functions that the tests call directly. Behaviour is unchanged.
# Conflicts:
#	dace/transformation/passes/gpu_specialization/split_state_by_gpu_class.py
#	tests/gpu_specialization/auto_single_stream_test.py
#	tests/gpu_specialization/gpu_stream_test.py
#	tests/gpu_specialization/monolithic_single_stream_test.py
#	tests/gpu_specialization/npbench_gpu_correctness_test.py
A size held in a scalar (nt = Nt + 1; np.empty(nt)) is a data descriptor, but an extent has
to be a symbol. The frontend now reads each such scalar into its own __sym_ symbol on an
interstate edge and substitutes it into the shape, leaving the descriptor in place so the
program can still read or reassign it. A fresh symbol per shape keeps two arrays sized from
one reassigned name from collapsing onto a single value, and keeps a later index on the same
name from rebinding the extent. A size-1 array is read through a subscript, since assigning
it by name would take its pointer.

np.zeros, np.ones and np.full go through the same promotion as np.empty and dace.define_local,
so they no longer reject a computed size.
A transient sized by a symbol that an interstate edge assigns is allocated at the dominator of
its accesses. When that dominator only dominates them and its only outgoing edge carries the
assignment and enters a control flow region, the allocation was emitted at the start of the
dominator, before the symbol was assigned, and the array got an undefined size. The block the
edge leads to now allocates the data, and a control flow region allocates on entry what it is
the scope of.

The deallocation at a postdominating region was never emitted, so the array leaked. A region
now deallocates on exit what it is the scope of.
…claration

determine_allocation_lifetime only saw access nodes, so a transient named as a free name in a
tasklet's code, with no connector, memlet or access node, did not count as a use. Its
declaration then landed in the brace scope of the one state with a real write, and the sibling
state reading it from code did not compile. Such uses now count, through a stand-in access node
that is never made for a view, whose allocation reads the edges of its node.
…mbined branch

The merge of the slimmed GPU codegen branch dropped both, and the offloaded cholesky2
kernel then wrote device-resident _info from the host.
…perator new, not an alignment attribute

new double DACE_ALIGN(64)[N] names an over-aligned element type, which GCC 16 rejects for a constant bound
("alignment of array elements is greater than element size"). Defer to the base heap_alloc_stmt/heap_free_stmt,
which emit new (std::align_val_t(n)) T[count] at the descriptor's alignment and the matching aligned delete, and
fuse constant-extent allocations like runtime ones.

(cherry picked from commit 89266363be90b413e79e86067c40fc77e1e748d6)
The tile library nodes of dace.libraries.tileops, their per-ISA lowerings and runtime headers, and the dace.tile.add and
dace.tile.masked_copy calls of a @dace.program, with the tests of the nodes and the calls. The vectorization passes that
emit the nodes are not part of this commit.

The nodes need the ONE broadcast marker and shapes_equal of dace.symbolic, DACE_UNROLL and c_mod of the runtime
headers, and the library registration of a node that subclasses a node of another library.
# Conflicts:
#	dace/libraries/standard/nodes/reduce.py
…nt' into ngc-2620

# Conflicts:
#	dace/codegen/targets/framecode.py
# Conflicts:
#	dace/codegen/targets/cpp.py
#	dace/codegen/targets/framecode.py
#	dace/symbolic.py

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.

3 participants