Skip to content

[Only for CI] Extensions - #2475

Draft
ThrudPrimrose wants to merge 4032 commits into
mainfrom
extended
Draft

[Only for CI] Extensions#2475
ThrudPrimrose wants to merge 4032 commits into
mainfrom
extended

Conversation

@ThrudPrimrose

Copy link
Copy Markdown
Collaborator

No description provided.

@ThrudPrimrose
ThrudPrimrose changed the base branch from ci-fix to main August 3, 2026 11:37
ThrudPrimrose and others added 10 commits August 6, 2026 20:48
…ng it

g++ 16.1 on aarch64 refuses to parallelize at -ftree-parallelize-loops=72 when Graphite is in the
same command line, the same suppression measured earlier on g++ 15.2 at x86: parloops emits no
GOMP_parallel at all above roughly 33 threads, so the arm silently becomes plain -O3. The probe
caught it and refused to launch, which is correct -- publishing -O3 timings under an autopar label
is a fabricated column -- but it left the job with nothing to do, and the previous fixed cap of 32
was wrong in the other direction: the cliff moves with the compiler build, so no constant is right.

register_arms now probes descending widths and takes the largest that actually emits a parallel
region, printing which one it picked and why. The width is a compile-time heuristic, not the
runtime width -- the emitted region still runs on OMP_NUM_THREADS threads -- so lowering it costs
nothing measurable, and the arm is only ever registered on proof that it parallelizes. The old
equality guard is gone with it: it asserted a width the compiler had already been observed to
reject. Verified at OMP_NUM_THREADS=72, where the search settles on 32 and all seven arms register.

Both toolchains are spack-provided and a batch script inherits no module environment, so the job
now sources spack and loads gcc@16.1.0 and llvm@22.1.7 before resolving anything. Without that the
arms silently measure whatever /usr/bin holds rather than the compilers under test.
The connector Memlet of a NestedSDFG producer is a claim, and frontends widen it
conservatively -- CloudSC's selection sort writes IORDER(JL,JM) behind a
data-dependent index, so the Fortran frontend hands the whole 0:klon, 0:5 array
to the connector. In shared mode MapFusionVertical privatizes the intermediate
into a per-iteration buffer, but the producer only defines one element of it, so
the consumer read uninitialized memory (iorder stayed at its -999 fill, giving a
wild llindex1[klon*(iorder-1)] write) and the whole garbage buffer was copied
back over the global array, transposed. The existing coverage test cannot see
this: it compares the widened connector Memlets, which trivially cover each
other, while the real per-iteration write is only visible inside the nest.

Check instead that a whole-array claim is actually fulfilled: translate the
nest's inner writes through its symbol mapping and require one of them to cover
the intermediate. Subsets that reference anything but outer symbols and Map
parameters -- a data container used as an index -- are undecidable and refuse the
fusion. Partial claims keep the existing machinery.

Ported from the FaCe branch. The regression test pins its two nests with
no_inline: generate_code inlines host nests, and inlining this pattern drops the
idx_at descriptor while leaving idx_at in the subset, so the SDFG validates but
arglist() raises KeyError -- a separate InlineSDFG bug that would otherwise mask
the shape under test.
find_fast_library returned only the vendor BLAS and then fell straight through
to 'pure', so anything that was not a BLAS node -- a tensor transpose or
contraction, a threaded Reduce/ArgReduce, an OpenMP-5 Scan -- lowered to the
serial expansion under auto_optimize while canonicalize took the fast form.
The two pipelines are compared column against column, so that difference showed
up in the figure as a pipeline result when it was really a priority-list result.

Mirror canonicalize_fast_library_priority: HPTT when HPTT_ROOT is set, then
TTGT (transpose+GEMM, no external dependency), then OpenMP and CPU ahead of
pure. apply_cpu_library_parallelism still has the last word on the
scope-dependent node types, so a node nested inside a parallel map keeps its
sequential expansion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…udget

Two measurement bugs, both of which made the DaCe columns report a number that
was not the thing the label claimed.

BLAS lowering. dace ships library.blas.default_implementation=pure while lapack
and linalg already default to OpenBLAS, so every gemv/gemm in the four DaCe arms
expanded to naive nested maps. The figure's reference is numpy, and numpy is
OpenBLAS, so the kernels that ARE one BLAS call lost for that reason alone --
measured at the paper shapes: atax 503ms vs numpy 17.8ms (28x), covariance
1504ms vs 35.3ms (43x). Pin blas/lapack/linalg per arm inside toolchain_env,
which already spans the transform, so expand_library_nodes sees the setting.
The serialize arms keep pure deliberately: they exist to measure what parloops
and Polly do to sequential affine loops, and an OpenBLAS call is neither affine
nor sequential -- lowering them would make all three report the same OpenBLAS
number and would silently destroy seq-cpp, whose whole meaning is one thread.
register_arms refuses to launch when an arm asks for OpenBLAS and none resolves,
and the resolved library plus its threading flavor go into every arm's evidence
string, so a timing can always be read back to the kernel that produced it.

Rep count. A flat 50 reps does not survive a corpus spanning ~50us to ~20s per
call. Measured on the last sweep: heat_3d took 243 minutes and jacobi_2d 139,
together 6.4h of a 10h job, while the other 79 kernels shared the rest -- 50
reps x 7 arms on a 20s stencil is ~2 wall-clock hours for one kernel, and reps
6..50 only re-confirm what rep 5 already had, since run-to-run spread on one
long steady-state nest is a fraction of a percent. Size the count against a
20s budget with a floor of 5, so cheap kernels keep the full 50 and the
expensive tail stops dominating; drop the second warmup once a kernel is sized
down, since warmup is priced in full calls too. The real count is recorded per
entry and preferred by the CSV, because the record-level value is now only a
ceiling. Each arm also prints its own wall clock: a kernel writes its JSON only
after every arm finishes, so an expensive one was previously indistinguishable
from a hang and left nothing behind when the job hit its time limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reasons the table was not comparing like with like.

Thread width. -floop-nest-optimize is the isl loop-nest scheduler, but on
g++ 16.1 / Neoverse V2 it also suppresses parloops above width 40, and that
width is the arm's REAL runtime thread count -- parloops emits it as a literal
num_threads (mov w2, N; bl GOMP_parallel, verified by disassembly), so the
region does not widen to OMP_NUM_THREADS. Keeping the flag meant this one arm
ran 40 threads against the other five at 72 and its column was understated by
the width ratio. Every arm running the same width is the stronger property, so
drop the scheduler: with -fgraphite-identity alone the probe parallelizes at the
full 72 and Graphite still reports its SCoP. This column now honestly means
"gcc auto-parallelization, Graphite code generation, no isl rescheduling", which
the docstring says outright; CANON_PERF_GCC_AUTOPAR_FLAGS puts it back for
anyone who wants the scheduler and accepts the narrower arm.

Stencil tsteps. The paper rows were inconsistent across the six stencils --
adi and seidel_2d at MEDIUM, fdtd_2d and heat_3d at LARGE, jacobi_2d at
EXTRALARGE, and jacobi_1d (tsteps=4000, N=32000) at no polybench class at all,
~32x beyond its own EXTRALARGE. The two at the top dominated the sweep: heat_3d
243min and jacobi_2d 139min out of a 10h job. For a stencil tsteps is a pure
outer repetition count -- it multiplies total time without changing the per-step
working set, the access pattern, or any relative speedup the figure reports --
so level all six at 100, the value adi and seidel_2d already used. Spatial sizes
are untouched, so the working sets stay exactly as they were.

The six stencil results already on disk were measured at the old tsteps and must
be re-measured with --force; every other kernel is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resume invalidation. has_current_arms checked only that every registered arm was
present, so a record stayed "current" when the KERNEL changed under it. Editing
a paper_sizes row leaves all seven arms in place and every number measured at
the old shape, so the sweep would skip it forever and the table would mix shapes
without saying so -- which is exactly what levelling the stencil tsteps would
have caused. Compare the recorded shape against the one the corpus declares
today, so a size edit invalidates its own results with no --force to remember.
expected_paper_shape reads the kernel module's paper_sizes constant and
allocates nothing, because it runs once per kernel inside the resume check;
suites whose shapes only exist after make_inputs return None and are left alone,
since deriving them would cost more than the measurement being skipped. Verified
against the results on disk: 84 skip, and the 4 stencils whose tsteps changed
redo themselves while adi and seidel_2d (already at 100) do not.

Corpus split. --suite now takes a comma-separated set, and the job wrapper grows
--suites with two named groups: array (poly+np, 54 kernels, divides by numpy)
and loops (tsvc+tsvc25, 223 kernels, divides by seq-cpp). The halves cost very
different amounts and answer different questions, so running them as separate
jobs lets each have a time limit and a results directory that fit -- and stops a
slow polybench sweep from eating the wall clock the tsvc kernels needed. The
perf facet still gets ONE pooled invocation over the whole selection so the
shard stays balanced (14/14/13/13 and 56/56/56/55 on 4 ranks); sharding each
corpus separately would hand every rank a slice of every corpus and defeat the
split. selected_suites normalises to SUITES order, because the shard is
positional and a spec that reordered the pool would break resumption against
results an earlier spelling produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-kernel alarm fires on whichever arm happens to be running when it
expires, leaving that arm with an error and no timing -- but the arm LABEL is
still in the record, and presence was all has_current_arms tested. So a
transient timeout was baked in permanently: the record looked complete, every
later sweep skipped it, and the column stayed empty in the figure with nothing
saying why. Ten kernels on disk are in exactly that state, heat_3d among them.

Treat a timed-out arm as not-current so it is retried. Other arm errors are
deliberately NOT retried: a kernel that fails to build or miscompares under an
arm fails the same way every sweep, and retrying would burn the budget forever.
A timeout is a property of the budget instead of the kernel, which is what makes
it worth another attempt.

Raise the cap to 1800s in the submit script for the same reason: 600s is a
fraction too tight for the heaviest stencils even after the tsteps levelling --
heat_3d measured 680s for its seven arms -- so the default was cutting off
kernels that were progressing normally. Still a cap, so a hung kernel cannot eat
the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The kernel alarm is a single shot, and the per-arm ``except`` absorbs the
_Timeout it raises -- so the bound was spent on whichever arm happened to be
running when it fired, and every LATER arm of that kernel then ran with no
timeout at all. heat_3d showed both halves of the failure: llvm-autopar took the
timeout, and seq-cpp afterwards was unbounded.

Re-arm per arm, clamped to the kernel's remaining time so this can only tighten
the caller's deadline, never extend it, and restore the kernel bound after the
loop so the last arm's alarm cannot fire while the record is being written.
CANON_PERF_ARM_TIMEOUT defaults to a third of the kernel budget: enough for a
normal seven-arm kernel, still short enough to catch one that has run away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
libgomp and libomp co-resident in one process cost ~34x, and the harness measured
all seven arms in one interpreter with the gcc arms first -- so every clang arm
ran with both runtimes loaded and the LLVM columns read 50-150x slow for a reason
that had nothing to do with LLVM (jacobi_1d: 2.53ms gcc vs 370.99ms llvm).

Measured with a standalone microbenchmark of DaCe's emitted jacobi_1d body at
OMP_NUM_THREADS=72: clang alone 19.15ms, clang with libgomp also loaded 660.17ms,
gcc with libomp also loaded 664.43ms. Symmetric, so it belongs to the pairing and
not to either compiler -- both runtimes build an OMP_NUM_THREADS-sized pool and
spin against each other.

Ruled out by measurement first, and all negative: codegen quality (no -fopenmp,
gcc 1.59ms vs clang 1.99ms), fork/join cost alone (~2.6x, not 50x), the emitted
per-iteration len-1 stack arrays (clang was faster with them), the flags
(CMAKE_CXX_FLAGS is byte-identical across the two arms), and every affinity and
spin knob. KMP_BLOCKTIME=0 recovers 652ms -> 72ms but is still 3.8x off the clean
19ms, so it is a mitigation and not the fix.

Group the arms by the runtime their compiler links and give each group a SPAWNED
child -- spawned, not forked, since a fork inherits the parent's already-mapped
runtime, which is the thing being kept apart. The dataset is rebuilt per child
because a ctx holds live numpy arrays; that costs one extra allocation and
reference per kernel, far below the penalty it removes. A crashed child falls
back to in-process measurement and says so, rather than silently dropping a whole
family of columns.

Not a complete fix, and the hole is documented at _ISOLATE_ARMS: the spack
OpenBLAS is threads=openmp built with gcc, so a clang arm on a kernel with BLAS
nodes still meets libgomp inside its own process. numpy is clear -- it ships its
own bundled libscipy_openblas64 -- so stencils, where this was found, are fully
isolated.

MEASUREMENT_EPOCH makes this self-invalidating: every record written before the
isolation carries contaminated LLVM columns, so it is re-measured rather than
skipped, however complete it looks.

Also keeps two generated-code samples as a reference for what the two pipelines
emit: auto_optimize's jacobi_1d carries four per-iteration len-1 stack arrays
(map-fusion transients that are genuinely Array(shape=[1])), canonicalize's
carries none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At LEN_1D=32000 a 72-thread parallel-for hands each thread 444 elements, which
is far below the fork/join break-even -- so both DaCe pipelines came out SLOWER
than plain sequential C++ on tsvc (autoopt 0.703x, canon 0.678x against a
seq-cpp denominator of 1.000x) and the columns measured OpenMP overhead rather
than either pipeline. Raise every extent to put 7k-12k elements on each thread
at 72:

  LEN_1D  32000 -> 589824   (72 * 8192)      8192 / thread
  LEN_2D    256 -> 768      (768^2)          8192 / thread
  LEN_3D     64 -> 96       (96^3)          12288 / thread
  LEN_R7  28672 -> 516096   (7 * 73728)      7168 / thread, still a multiple of 7

s176 is capped back to 32000, and is the only kernel that needs it: it is
quadratic in LEN_1D in both loops, so at the raised extent the KERNEL alone
would be ~8.7e10 flops -- bigger than any polybench gemm -- and would dominate
the tsvc sweep, with its scalar-Python oracle worse still. The cap only ever
lowers an extent and only applies to a 1d-regime kernel, so regime_sizes'
LEN_1D >= LEN_2D + 8 invariant cannot be broken by it.

Measured dataset-construction cost at the new sizes: 0.0-0.3s per kernel, and
s176 unchanged at 59s under its cap, so nothing else in the corpus regressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ThrudPrimrose ThrudPrimrose added the no-ci Do not run any CI or actions for this PR label Aug 7, 2026
ThrudPrimrose and others added 18 commits August 7, 2026 11:50
A paper-size kernel is meant to be a realistic unit of work, not a batch job.
One call past the budget is a sizing bug in the corpus rather than a slow
machine, so surface it instead of letting the sweep absorb it: heat_3d and
jacobi_2d between them ate 6.4h of a 10h run before their tsteps were levelled,
and nothing in the output said which kernel was responsible.

Enforced from the probe call _time_all_reps already makes, so it costs nothing
extra, and the kernel is still REPORTED -- reps=1 plus an over_budget flag and
the budget it broke, both carried into the result file -- rather than dropped.
A silent hole in the table would hide exactly the thing this exists to find, and
the flag stops a one-sample timing being read as a best-of-N.

Default 60s; CANON_PERF_MAX_CALL_MS=180000 for a 3-minute budget.

Measured against the current stencil shapes, none of which trips it: jacobi_2d
4.87s and heat_3d 2.66s for the slowest honest arm (seq-cpp), with every other
stencil below those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three minutes leaves room for the heaviest honest arm -- seq-cpp on jacobi_2d
measures 4.87s, so the margin is ~37x -- while still catching a kernel whose
paper shape has run away. CANON_PERF_MAX_CALL_MS lowers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s generator

Affinity. The per-runtime isolation regressed every libgomp arm 20x --
autoopt-gcc 2.10ms -> 41.08ms, gcc-autopar 6.35ms -> 128.24ms -- while
sequential and libomp arms were untouched. Cause: loading the spack OpenBLAS
(threads=openmp, so libgomp) under OMP_PROC_BIND=close binds the calling thread
and collapses the PROCESS affinity mask, measured as 288 cpus before the ctypes
load and 1 after. In-process that is harmless, since libgomp still binds its team
across the places it captured at init, but a spawned child INHERITS the shrunken
mask and its own libgomp then sees a single place, so all 72 threads land on one
core. Capture the CPU set at import -- before any module-level code can load a
runtime, which is why it sits above register_arms -- and restore it in each child
before the dataset is built, since CS.make would otherwise re-shrink the mask
this is undoing.

Thread default. OMP_NUM_THREADS defaulted to 4, so a bare run timed 4 threads and
reported it under the same arm labels as a batch run. Default to 72, one node's
288 cores over the 4 ranks the submit script requests.

Codegen pairing. Each pipeline is now measured on the generator it is shipped
against: auto_optimize on legacy, canonicalize on experimental_readable. The
serialize arms stay on experimental_readable for a separate, load-bearing reason
-- they depend on its __restrict__ qualifiers, without which neither parloops nor
Polly can disambiguate the memory, so dropping them to legacy would silently
halve those columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ound

Two findings, one fix and one retreat.

Restore per arm rather than once at child start: CS.make loads OpenBLAS right
after, which re-collapses the mask, so a restore placed before it is simply
undone. restore_affinity() is now called immediately before each arm is built
and timed, and tolerates a cgroup refusing to widen.

That is still not enough, so isolation is now OFF by default. Measured on
jacobi_1d: isolation fixes the llvm columns outright (autoopt-llvm 59.39ms ->
0.845ms, canon-llvm 60.17ms -> 0.830ms, a 70x recovery that makes them the
fastest arms), but breaks the gcc ones by the same mechanism reversed. A spawned
child loads OpenBLAS, libgomp binds the master thread and collapses the mask to
one core, and the child's libgomp has already fixed its place list before
anything can widen it again -- so every libgomp arm runs on a single core
(autoopt-gcc 1.94ms -> 39.6ms). Reproduced standalone: a child with OpenBLAS
loaded takes 40.76ms against 3.62ms for one without, and restoring the mask
helps only a freshly spawned process (8.07ms), never the process that already
initialised libgomp.

Neither setting therefore gives a wholly valid table: OFF has honest gcc columns
and co-residency-inflated llvm ones, ON has honest llvm columns and one-core gcc
ones. OFF is the default because it is the state every earlier measurement was
taken in, and a silent 20x regression across the four gcc arms is worse than the
llvm inflation, which is at least now understood and documented. The real fix is
to stop the collapse -- an OpenBLAS not linked against libgomp, or keeping
libgomp from binding the master before its place list is built -- not to widen
the mask afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dace-simplify+llvm-autopar column was measuring sequential code. Built through DaCe exactly as
the arm does, the shared objects for tsvc s000/s111/s112/s1111 contained zero fork calls, and Polly
says why: "No profitable polyhedral optimization found". Its cost model declines a single 1-D loop,
and tsvc is overwhelmingly 1-D loops, so the arm sat at 1.22x over seq-cpp -- parity, which is
exactly what timing an unparallelized loop looks like.

-polly-process-unprofitable + -polly-parallel-force fix that, and they also make the arm the
symmetric counterpart of the gcc one: dace-simplify+gcc-autopar has carried -floop-parallelize-all
-- precisely "parallelize regardless of profitability" -- from the start, so without them one table
held its two auto-parallelizers to different standards. Measured on a 16-kernel debug sweep:
1.222x -> 7.365x pooled, against 7.872x for gcc. The four DaCe arms stay at 11-13x, so the change
lands only where it was aimed.

Separately, -polly-omp-backend=LLVM. Polly defaults to GNU and emits GOMP_parallel* -- libgomp --
while the same command line links -fopenmp, i.e. libomp. Both runtimes then spin up a pool and burn
cores against each other (~34x on this box, symmetric). This arm is blas=pure, so Polly's own
backend was the only thing pulling libgomp in. It is worth fixing on its own terms even though it
did not move the number: the arm was still sequential, so there was nothing to slow down.

The probe now proves the runtime it ended up on via its own _LLVM_AUTOPAR_MARKER rather than
sharing the gcc arm's GOMP_parallel. A marker both runtimes matched would check nothing, and this
one fails loudly if Polly ever reverts to the GNU backend instead of silently re-creating the
conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each auto-parallelizer now appears twice: forced, and on its compiler's own cost model
(`...-autopar-default`). The gap between them is a result rather than a knob to pick, and measured
within one run over 16 tsvc+tsvc25 kernels it is entirely one-sided:

    gcc-autopar    8.088x forced   8.058x default   (0.4% -- nothing)
    llvm-autopar   7.572x forced   1.209x default   (6.3x)

Forcing decides the LLVM column outright and does nothing for gcc, because Polly declines flat 1-D
loops structurally -- unforced it emits no parallel region at all on most of tsvc and the column
measures sequential code -- while gcc's parloops already accepts them. Reporting only the forced
pair would hide that the LLVM number is a policy artefact and the gcc number is not; reporting only
the default pair would show Polly timing unparallelized loops with no sign it can do better.

The `-default` flag strings are derived by REMOVING the forcing knob from the forced string rather
than spelled out separately, so the pair differs in exactly one flag and a
CANON_PERF_*_AUTOPAR_FLAGS override cannot silently change one half of a paired comparison.

_PROBE_SRC gains a flat 1-D loop next to the nest, and that is a prerequisite for the default arms,
not a nicety. Left to their own cost models the two compilers accept OPPOSITE shapes: gcc
parallelizes flat loops and declines the constant-bound nest, Polly the reverse. With the nest
alone, gcc-autopar-default would have sent resolve_autopar_hint down the width ladder hunting a
width that parallelizes it, found none, and killed an arm that handles the real corpus fine.

Read any probe line narrowly regardless: it proves a pass CAN parallelize something, never that it
did anything to a measured kernel. A green probe next to a sequential column is exactly what hid
the unforced Polly arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It holds cluster job logs and two LIVE `git worktree` checkouts (wt-base, wt-new, both still
registered), so a `git add -A` would commit nested checkouts into the tree. Same reasoning, and the
same block, as the other local perf-job trees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag constants had grown 15-25 line rationales over 3-line definitions -- 7.8:1 comment-to-code
on _GCC_AUTOPAR_FLAGS and _LLVM_AUTOPAR_FLAGS, 4.4:1 on _PROBE_SRC. The evidence in them was real
but the source file is the wrong container: it buries the code and the tables go stale silently.

Measured A/B numbers, rejected hypotheses and flag archaeology now live in the commits that
established them (20ae06d, 77676ae), and each constant keeps one line naming the reason and
pointing there. Net -75 lines, no behaviour change: all nine arms still probe green at width 72.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fix

Symbolic comparisons survive any AST nesting (ast.Constant carrier); computed
subscript results unwrap unless the source is a literal display; np.empty gets
NumPy's float64 default; .copy() on a sliced array materializes a real transient;
NestedCall states join the control-flow region being parsed, not the SDFG root.
…iate guard

covers() rank mismatches fail closed, a covering write must dominate every sink
of the root region, and a missing destination subset is no longer full-write
evidence. Ports the ordering-edge dedup test from d-face.
…nstead of three

The tag array becomes a real transient sized by the scattered array's domain
symbol (Persistent lifetime, allocated in dace_init), deleting the runtime max
sweep and the per-call heap allocation. Out-of-domain index values are skipped
in both passes, closing an OOB on strided scatters.
…guards

Two shapes only: parallel entries use reduction(inscan) with strided scans
walking the index space in one region per scan; sequential contexts get a naked
loop. Shape selection is static via libnode_is_sequential (resolves scope across
nested SDFGs); omp_in_parallel guards are gone. Serial-order asserts on scan
results move to 1e-12 tolerance per the sanctioned association-order policy.
…directly

Lifting the stride==1 restriction on the direct-write path drops the third
full-length pass and the second full-size transient of every strided scan.
…tive parity

The CPU OpenMP expansion lowers to dace::reduce::OP / seq::OP calls (supported
dtypes: builtins, complex via declare reduction, the half/bfloat16/float8
family); the bespoke pragma emission is gone. Selection is static from the
node's scope, closing the nested-in-parallel-map hole; runtime nesting guards
are deleted. Measured at parity with a native reduction-clause loop on g++ and
clang++ after removing the bare if-clause that devectorized combined constructs.
Symbol identity folds dtype into sympy hashing, so a declared int64 loop
variable never matched the default-typed instance in parsed indices; axes were
silently classified independent and lifted unwidened. Branched min/max now
passes the real loop stride, fixing a strided fold over unvisited elements.
Tiles the nest, skews the tile schedule, keeps the tile interior row-major and
unit-stride: one parallel region with a barrier per tile diagonal instead of a
fork per element diagonal. Tile-order legality is checked beyond magnitude
clamping (a steeper tau can reverse tile order); dependences exceeding the tile
fall back to the untiled skew.
…ing moves to BreakAntiDependence

The loop path outlines the whole LoopRegion, splits per output group (coalesced
to carried plus free), and inlines the clones back. The forward-reads snapshot
machinery returns to BreakAntiDependence behind a forward_reads property; the
break_anti_dependence knob is removed. nest_sdfg_subgraph now detects
region-local transients and name-read symbols correctly.
… after WCR-map lifting

The cleanup helper is state-machine only (StateFusionExtended, EmptyState-
Elimination, DeadStateElimination); PruneConnectors+InlineSDFG run as a fixpoint
at every site past the first LoopToMap; InlineMultistateSDFG runs exactly once;
SinkStateIntoLoop is gone. NormalizeWCR reruns at the end of reduction_to_wcr_map
because LoopToMap outlining traps WCRs inside loop bodies after the earlier run.
BreakAntiDependence is wired as its own prep stage.
ThrudPrimrose and others added 30 commits August 12, 2026 14:56
Follow-up to #2492, which stopped a foreign GPU error from being charged to the
next DaCe program. Two things on the same code path were still assuming device 0.

The memory-pool setup passed a literal 0 as the ordinal. That is valid whenever
any device exists, so DACE_GPU_CHECK never fires on it -- it silently applies the
release threshold to device 0's pool while the allocations below run on whichever
device the host thread is actually on. Under CUDA_VISIBLE_DEVICES those are not
the same physical GPU.

And nothing had selected a device at all. Which device a host thread is on is
per-thread state, so __dace_init_cuda now reads it, range-checks the ordinal
against the count it already queried, and selects it before use. That gives the
pool query a real ordinal to name, and it forces the context to exist at a point
where a placement failure is still attributable to what caused it -- instead of
surfacing later out of whichever checked call happens to run first. In a
CUB-reducing module that call is the temp-storage size query, which is where this
started: a pending 'invalid argument (1)' reaches CUB's size query, which reads
the current device through cudaGetDevice, gets the stale error back, concludes
there is no current device, and returns cudaErrorInvalidDevice -- so 1 surfaces
as 'invalid device ordinal (101)' out of an initializer that did nothing wrong.

Adds the GPU test that #2492's source-level coverage could not give: poison the
runtime's per-thread error slot on purpose, with the same cudaErrorInvalidValue
seen in a reproducing CI round, then run a CUB reduction. Poisoned through ctypes
rather than cupy, because cupy calls cudaGetLastError() when it raises and clears
the slot the test needs left dirty; generated modules link libcudart dynamically,
so it is the same instance and the same slot. Verified both directions: it fails
with 'invalid device ordinal' against a tree without the drain, and passes with
it.

Verified on a GH200: all six tests in gpu_error_drain_test.py, 18 GPU tests
across reduce_test CUDA (device), all six cuda_mempool_test cases -- the pooled
path is what exercises the new ordinal -- gpu_worker_pinning_test and
wcr_cudatest, plus the CPU codegen suite. Against the full 'gpu and not mpi'
marker the ordinal failure reproduced in 2 of 10 CI-equivalent rounds before, and
has not reappeared since.
An empty memlet is an ordering edge: it moves no value. Three places in the backward pass read
one as if it carried data.

The subgraph search walked them, so the reverse BFS from the outputs collected dataflow that
feeds no gradient. With two backward passes in one program and simplification on, the fused
state puts the first pass's generated backward on such an edge, the second pass differentiates
it, and the data forwarding then collides on a map connector the second-order gradient already
took. The one ordering edge the search must still follow is the one tying a node without data
inputs to its enclosing scope -- drop that and a map body loses its entry.

Reversing a node then read the connector name off the ordering edge, so the gradient name list
grew a None that invert_map_connector choked on, and connecting the gradients looked the
descriptor up by the edge's data, raising KeyError(None).

The traversal also moves off a plain set, which left the differentiated subgraph's node order,
and with it the reversal order, up to the hash seed.
The DaCe runtime headers the generated extension includes are C++20 (std::bit_cast in types.h),
but the flag list left the standard to torch's extension builder, which still defaults to C++17
on current releases. Every torch dispatcher test failed to compile against a recent torch.
`from mpi4py import MPI` calls MPI_Init on import. Test modules and corpus
drivers export MPI4PY_RC_INITIALIZE=0 to stop an unlaunched parse from
bootstrapping a singleton MPI job, whose bring-up stalls on a wedged transport
and hangs collection. The switch is process-global and one-way: nothing
initializes MPI afterwards, so the next communicator call aborts the whole job
with "MPI_Comm_rank() was called before MPI_INIT" and no Python traceback.

That is how the MPI CI job died. `mpirun -n 2 pytest -m "mpi and not gpu"`
collects the whole tree, and collection imports the 57 modules that export the
switch -- before anything imports mpi4py.MPI. The first test to touch
COMM_WORLD, tests/library/mpi/mpi4py_test.py, then aborted both ranks with an
empty report. Running that file alone passed, which is why it read as a
heisenbug.

ensure_mpi_initialized runs once at import and calls MPI_Init when a rank
variable says a launcher started this process: there the switch cannot be what
the caller meant, and the launcher has already prepared the job. Without one
the switch is obeyed, since a singleton bring-up is exactly what it guards
against. mpi4py.rc is not the state to test -- the environment switch is read
inside the MPI submodule as it loads and never mirrored back, so rc.initialize
reads True while MPI is down; only MPI_Initialized knows. mpi4py finalizes only
what it initialized itself, so the Init made here registers its own Finalize.

Split MPI_RANK_VARS out of LAUNCHER_RANK_VARS: srun sets SLURM_PROCID for steps
that run no MPI at all, which is enough to name a build folder but not enough
to call MPI_Init on.
The 2-rank ring was racy by construction. Isend wrote req[0:1] into one access
node and Irecv wrote req[1:2] into a SECOND access node over the same container,
while the Waitall read req[0:2] from the first. Two access nodes are two nodes:
nothing ordered the Irecv against the Waitall, so codegen was free to emit
Isend, Waitall, Irecv -- and did. The Waitall then dereferenced an MPI_Request
the Irecv had not posted yet, segfaulting both ranks inside
ompi_request_check_same_instance.

One access node carries both request writes, which is what puts the Irecv
before the Waitall. Nothing else changes: same nodes, same memlets, same
MpiPackUnpack assertion.

The test had never run: mpi4py_test.py sorts ahead of it and aborted the whole
job first, so heterogeneous CI never reached this file.
Two independent misses left dace::math::pow (double) in integer positions --
an array size, a subscript, and the OpenMP map bound GCC rejected with
"invalid controlling predicate" on stockham_fft.

- _rebuildable only accepted the EVALUATING rebuild. DaCe mints packed shapes
  and strides unevaluated, and the evaluating constructor canonicalizes their
  argument order, so the guard read a reordering as a value change and skipped
  the whole product -- every power inside R*R**K or R**i*R**(K-i-1) stayed pow.
  _rebuild now tries the unevaluated build too and rewrites the node the same
  way it was built, so the pack survives.

- _visit_sdfg replaced the fact set with the nested SDFG's own. A nested SDFG
  an expansion mints declares symbol dtypes only; the sign registry stays on the
  SDFG the frontend built, so K lost 'positive' on the way in and R**K declined
  for want of a provable sign. The facts now travel the symbol mapping, like the
  iterator ranges already do.
Sourcing setvars.sh puts Intel's OpenMP runtime on the load path beside the
libgomp the generated kernels are built against. With both loaded, the outlined
parallel loop computes its static partition from a different team size than the
one that created the team and silently skips iterations -- the mechanism behind
merge_node_test::test_v2_all_array_2d failing on CI (deterministically, 12/48
elements) since the loop2x runner gained oneAPI, while passing everywhere the
package is absent.
The copy is what orders this write against another write to the same region of
out_array -- the value lands on out_array only after in_array's producer
finished. Folding it away makes both writes direct siblings out of one scope,
and siblings in a scope have no relative order, so codegen may replay the
superseded value last. In vadv's k=0 body that emitted

    ccol[i,j,0] = tmp * divided;   // final
    ccol[i,j,0] = tmp;             // superseded, wins

The existing race guard only inspected OTHER access nodes of the container and
only their reads, so a second write landing on out_array itself was invisible.
Provably disjoint regions and equal WCRs (accumulation commutes) still fold.
Structural asserts, not just numerics: the small case happens to codegen in the
right order even when the copy is folded away, so only checking the value would
pass on the broken build. Asserts the match is refused and that no map exit ends
up with two unordered writes to the same region.
DaCe had two half-implemented ideas about which GPU to run on. Allocations and
kernel launches always used the calling thread's current device. Library handles
used whatever node.location['gpu'] said, in five byte-identical copies of the same
parsing block -- the only readers of that property in the codebase.

The combination cannot work. Nothing enables peer access, so a cuBLAS handle on
device 1 operated on pointers cudaMalloc'd on device 0: an illegal access, or a
silently wrong answer where unified addressing hid it. Worse, those helpers call
cudaSetDevice and never restore, so one placed node moved the calling thread for
every kernel launched afterwards -- kernels that assume the current device.

So make the model explicit instead of maintaining the ambiguity. __dace_init_cuda
selects the device once, records it in the GPU context, and nothing changes it
again; the memory pool and every library handle read that ordinal back. The
device is settable at init through the new compiler.cuda.device entry, defaulting
to -1, which means "whatever device this process is already on" -- the one rank
per GPU under CUDA_VISIBLE_DEVICES arrangement. An explicit ordinal is for a
single process choosing among several visible GPUs; it is compiled in, so a build
shared between ranks would send them all to one GPU, and the entry says so.

location['gpu'] is now rejected at expansion, naming the node, the ordinal, and
the supported alternative. Silently ignoring it would have been the worst of the
three options: the same wrong results as today, with nothing to notice. Nothing
in the tree sets it.

Multiple GPUs are still reachable the way they already were, and the way the
tests and CI already do it: multiple processes.

Verified on GH200s: 24 GPU tests, including one that pins compiler.cuda.device to
1 and confirms through cudaGetDevice that the program ran there, and one that
asserts cudaSetDevice appears exactly once in generated code -- the invariant the
whole change rests on. Plus cuBLAS, cuTENSOR and cuSolverDn library tests for the
five rewritten environments, and the CPU codegen suite (179 passed, 1 skipped,
1 pre-existing SVE failure unrelated to GPUs).
Rationale that belongs in a commit message had been left in the source: a
nine-line comment on one variable, docstrings restating whole arguments. Net 93
lines out, no behavior change. Comments from #2492 are left alone.

The error message lost a clause, so the test matching on it was updated with it.
Diff against main drops from 587 added lines to 405.

Comments: rationale that belongs in commit messages was left in the source, both
mine and #2492's -- a 16-line block above __dace_gpu_drain_error, a nine-line one
above a single variable, docstrings restating whole arguments.

Tests: the two GPU init files are merged, since both exercise __dace_init_cuda
and each carried its own copy of the same SDFG helper. Dropped as non-orthogonal:
the separate device-selection assert (the merged init test covers the same
emission), the empty-location and message-content tests (folded into the
parametrized environment test), and a cuBLAS gemm end-to-end that library tests
already cover.
Removed comment about JIT fallback in CI configuration.
…e differentiating

A DaCe symbol folds its dtype into its sympy identity, so the frontend's subset can carry
``j:int64`` while ``LoopToMap`` reparses the loop variable into ``j:int32``. ``_affine_coeffs``
differentiated w.r.t. the latter, found no occurrence of it, and answered ``a == 0`` -- the index
read as a loop-INVARIANT constant. ``u[1:N-1, j+1]`` and ``u[1:N-1, j]`` then looked like the
constants 1 and 0, whose difference is nonzero, so ``_dim_provably_disjoint`` certified a genuine
loop-carried recurrence as provably disjoint and the loop was parallelized.

adi and deriche miscompiled silently (wrong numbers, no diagnostic) on all four of their slice
sweeps, forward and backward alike. The 1D form ``a[i] = a[i+1]`` was refused all along: its
subsets are built from one symbol instance, so nothing mismatched -- the 2D slice is what routes
the index through a second instance.

``_align_itersym`` already exists for exactly this hazard; ``_affine_coeffs`` now goes through it.
Polybench CPU: 29/31 -> 31/31 passing, and the only LoopToMap applications lost are adi's 4 and
deriche's 4, every one of them a real dependence.
… as a scalar

LowerInterstateConditionalAssignmentsToTasklets demoted every free symbol a
condition_symbol_to_scalar tasklet reads. Two kinds must not be demoted.

An SDFG argument (a shape symbol such as N) has no definition inside the SDFG to
rewrite into a scalar assignment, so demote_symbol_to_scalar raised outright:
"Scalar to symbol demotion only works if the resulting scalar would be transient
or an input scalar name is provided". polybench nussinov hit this in the
canonicalize+vectorize corpus whenever WavefrontSkew was a no-op (no islpy), which
is the CI configuration.

A symbol the graph itself evaluates -- a loop variable that also indexes a memlet --
survived the first guard but broke the emitted C++: table[_loop_it_0] with
_loop_it_0 an fp64 container is "invalid types 'int* __restrict__[double]'".

The two preconditions now live next to demote_symbol_to_scalar as
symbol_demotes_to_transient_scalar (which the function itself uses for its raise)
and symbol_carries_graph_structure, and the pass skips a symbol failing either.
Both are uniform across lanes, so the condition stays valid with them left symbols.
…ed tile arm

640c9e8 made branched_masked_tail the resolved GPU K=1 default, so the else-arm
is a MASKED tile body instead of a scalar lane loop and the multiply is a tile op
in BOTH arms. That commit's predecessor comment said landing the flip meant moving
the tile-op counts with it; the counts never moved, so this test still encoded the
superseded branched_tail shape and had been red since.

Only the count moves, 1 -> 2, and it stays an exact structural count with the
reason recorded inline. The rest of the test is untouched: the fp64-leak check and
the nvcc compile still assert exactly what they did, and both already hold -- the
constant is broadcast, not re-materialized, which a third TileBinop would reveal.
…up band

The terminal ('end', SimplifyPass()) ran the full simplify fixpoint over an
already-canonical SDFG, whose shape its sub-passes were never written against
-- canonicalize emits far more ordering edges and WCR than the frontend does.
Replace it with the passes that stage actually needed: the pipeline's own
_inline_single_state + _structural_cleanup band (the same tidying every other
phase boundary uses), then a fixpoint of DeadDataflowElimination and
ArrayElimination for the dead transients the earlier phases strand.

Measured over the 76 tsvc_2_5 kernels: map and loop counts identical on all
76, so no LoopToMap lift is lost; leftover dead dataflow drops from 8 kernels
to 3. The three remaining are known and unrelated to this band -- an empty
conditional arm that is a region rather than a state, a multi-state loop body
the single-state inliner cannot flatten, and one unmerged duplicate access
node.
…to General Tests

Reverts the single cpu-ci.yml collapse. One workflow per original group again --
Code Quality, General Tests, Heterogeneous Tests, Machine Learning and Autodiff
Tests, NASA/NOAA pyFV3 -- with the GPU workflows untouched.

The eight extended-only CPU suites (canonicalization, vectorization, corpus,
loop2x-libnodes, cpu-codegen, extensions, graph-backend, cloudsc-e2e) become
plain jobs inside General Tests instead of separate groups. Each keeps its own
strategy and runs once; none inherits the General Tests python-version x
simplify matrix, so there is no cross product. Job ids and display names are
verbatim from the source files, except graph-backend's, which was 'test' and
collided with the General Tests job of that name.

MKL_THREADING_LAYER=GNU is carried over to both steps that source oneAPI.
The main merge brought main's index-based add_node/remove_node while this branch
still serialized and resolved _start_block as a block object, so to_json called
node_id() on an int and raised NodeNotFoundError on every SDFG with an explicit
start block. Take main's representation throughout -- annotation, to_json,
from_json, the getter and the setter -- so the field has one meaning again.
…nto extended

# Conflicts:
#	dace/runtime/include/dace/cuda/cudacommon.cuh
Salvaged from the symbol-dtype-cache-alias worktree, where it was written as a
strict xfail against main's behaviour. It passes on this branch, which folds the
dtype into the symbol's sympy identity, so two same-name symbols of different
dtypes no longer share one cache key and the deserializer gets back what it
asked for.
``_start_block`` holds an INDEX into the node list. Two readers still compared it to a block:

* ``state_fusion.is_start_block`` -- ``graph._start_block is block`` is always False against an
  integer, so a fusion that removed a region's entry reported "was not the entry" and skipped the
  re-pin. When the region has a second source block nothing derives the entry, and the region is
  left with no answer at all (``start_block`` raises).
* ``SDFG.from_json`` -- stored the block object while ``to_json`` wrote the index, so every read of
  a pinned entry on a deserialized SDFG resolved a block through ``self.node()``.

The state_fusion test that pinned the old identity representation now reads the pin through the
node list, which is what the field means.
…onicalize

Both were things the terminal ``SimplifyPass`` used to do on the way out.

``PruneEmptyConditionalBranches`` closes the case the state-level cleanup cannot see: an empty
conditional ARM is a ControlFlowRegion, not a state, so ``DeadStateElimination`` walks past it.
``ConditionFusion`` merges two adjacent guards into one ConditionalBlock whose branches are their
cross product, and the combination that does no work is an empty arm -- without the prune the
collapsed nest carries a dead fourth branch and the guarded scan split keeps an empty ``else``.

``OptionalArrayInference`` recomputes the derived ``optional`` annotation. Without it canonicalize
emits descriptors carrying none, and re-canonicalizing that output annotates them at the leading
``clean`` simplify, so the pipeline is not a fixed point.

Also lands the recipe's ordering invariants as tests: a pass inserted in the wrong place stops
matching silently, and the value-preservation corpus stays green while it does.
The branch's error checking and the pooled CUB workspace are kept together rather than one over
the other. The reduce function still takes its temp storage from the per-stream ``ReduceTag`` pool
-- a per-call cudaMalloc/cudaFree costs a device-wide synchronization on every invocation -- but
every step of it now reports its status instead of dropping it: CUB reads a null workspace as
"only report the size", so a failed size query or a failed allocation silently leaves the output
untouched. ``get_scratch`` returns nullptr and the error code on a failed allocation (and clears
the entry, since cudaMalloc leaves the pointer unspecified), the reduce function returns the first
failing status, and the caller wraps the call in DACE_GPU_CHECK.

Elsewhere extended already carried the branch's intent in a stronger form and keeps it:
``gpu_stream_expr``, first-error recording with the context guard, the cross-stream event record
guarded on the edge actually HAVING an event (event 0 belongs to another edge), the host-side wait
when the destination has no stream at all, and the used-arrays walk naming the destination by the
node the memlet path lands on.
The representation cases come from wt-nosimplify2, restated against the index form the branch
settled on: the pin is an index, ``remove_node`` re-resolves it by identity around a removal, and
both JSON directions agree on it. The ambiguous-region cases are the only ones that read the pin
at all -- a single-source graph answers from ``source_nodes()`` whatever the field holds.

The band test gains the empty-arm prune in ``_TERMINAL_BAND`` and lets the A/B helper skip the
terminal ``OptionalArrayInference``, which sits later, beside the symbol cleanups.
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