[Only for CI] Extensions - #2475
Draft
ThrudPrimrose wants to merge 4032 commits into
Draft
Conversation
…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>
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.