Skip to content

Add Beverin inference image and multi-role cluster example - #15

Draft
LorenzoPaleari wants to merge 125 commits into
clusterfrom
agent/inference-cluster-example
Draft

Add Beverin inference image and multi-role cluster example#15
LorenzoPaleari wants to merge 125 commits into
clusterfrom
agent/inference-cluster-example

Conversation

@LorenzoPaleari

Copy link
Copy Markdown
Collaborator

Summary

  • merge the current main branch into cluster without container conflicts
  • reorganize the archived Beverin vLLM build under containers/cluster/ce-images/inference
  • promote the minimal ROCm 7.2.3, PyTorch 2.11, and vLLM 0.23.0 build chain with a dedicated build guide
  • add a configurable Beverin Slurm example for distributed vLLM, parallel agent nodes, and judge nodes
  • implement judge health and web-search routes, with explicit pass skeletons for score/submit/bench/verify
  • add deterministic problem sharding and multi-agent concurrency scaffolding
  • document configuration, topology, lifecycle, APIs, logs, security, troubleshooting, and current limitations
  • permit byte-identical renames of already tracked large files while retaining the 500 KiB guard for new or modified blobs

Why

The cluster branch contained generic AMD/NVIDIA Container Engine images and an archived working vLLM build, but did not expose a maintainable inference build path or an end-to-end multi-role Slurm example. This change makes the known-good inference inputs directly usable and supplies the orchestration skeleton needed for a Beverin deployment.

Impact

Operators can configure inference, agent, and judge node counts from a trusted environment file, submit one Slurm allocation, and inspect per-job service and agent logs. Web search is functional. Benchmark grading and remote task assignment remain intentionally unimplemented and return explicit errors rather than simulating results.

The history includes the requested clean synchronization from current main into cluster, so the PR contains the corresponding upstream changes in addition to the container work.

Validation

  • git diff --check
  • Bash syntax checks for beverin.sbatch and run_cluster.sh
  • Python compilation checks for agent_driver.py and judge_service.py
  • focused problem-loading and judge-routing checks
  • simulated distributed vLLM head/worker argument checks
  • large-file guard regression checks, including rejection of unrelated oversized files

A real Beverin allocation, CE image build, distributed vLLM startup, and GPU execution were not available in this environment and remain to be validated on-cluster.

guanLeTea and others added 30 commits August 2, 2026 14:05
Skill bodies cost 1169 lines in EVERY prompt and 1081 of them -- 92% -- were four
instrument manuals: perf, nsys, rocprof, opt-reports. A box has at most one GPU
vendor, so most of that is a manual for hardware the reader does not have, carried
on every task including the ones that never profile.

So an instrument skill's BODY is now gated on `prompt.profiling_guidance`, and the
`profile_first` strategy turns it on by itself -- needing a second knob to get the
manual for the tools that strategy exists to use would be a trap nobody finds.
Measured on a gemm task: 1373 lines to 284, a saving of 1089.

The INDEX line is deliberately not gated. It is the only thing telling an agent the
page exists, and gating it too would make the capability undiscoverable rather than
merely absent. That asymmetry is what the first new test pins.

INSTRUMENT_SKILLS lists both variants of each instrument. A `-judge` page is the
same manual with one section swapped, so it costs the same tokens and gates for the
same reason; omitting the five would have inlined ~1900 unconditional lines the day
they ship.

Also: the comment on GENERAL_SKILL claimed the other skills were "read on demand"
and that the prompt "indexes the rest instead of inlining everything". The template
has always inlined every body. The claim is true now.
Every pointer PARAMETER in every C/C++ reference now carries restrict: 784 of them
across 245 .cpp files, plus the translator, which has always emitted it. The three
hand-written kernels that were missing it are fixed.

The gate is parse-based, and that is the whole point. `grep restrict <file>` is the
obvious check and it is wrong twice: it passes a file that qualifies one parameter
of six, and it FAILS all 173 *_reference.c files forever, because those have no
buffer pointer parameters at all -- TSVC keeps its arrays as file-scope globals and
PolyBench passes them through POLYBENCH_2D(...) declarator macros. Their complete
pointer inventory is 141 `struct args_t *func_args`, 32 `char **argv` and 23 scalar
out-params of an untimed init_array.

An agent chasing that grep to green has exactly one move available: hoist the
globals into restrict-qualified parameters. That deletes the benchmark. s242
(a[i] = a[i-1] + ...) and s1113 (a[i] = a[len/2-i] + b[i]) exist to test whether a
compiler DETECTS the dependence, and a non-aliasing promise answers the question
for it. The docstring says so, so the next reader does not rediscover it.

Four supporting tests, because a parse-based gate fails silently: a regex that
breaks on a multi-line signature reports zero offenders out of zero parameters and
looks exactly like a clean tree. One hand-rolled scan here found 24 parameters
where there are 784. So the scanner's own yield is asserted (> 500), the multi-line
signature that broke it is pinned, an unqualified parameter is proven to fail, and
the constructor member-initialiser that reads as a pointer (`nnr_(size_t(n1) * n2)`
is multiplication) is proven not to.

Separately: DaCe GPU variants are pinned to one stream. Concurrent streams overlap
kernels, and every profiling question assumes they do not -- a per-kernel counter
bracket needs a synchronised region to bracket, and an nsys timeline attributes a
gap to the wrong launch when the next kernel is already running elsewhere.
Level 3 is whole networks -- ResNet, DenseNet, VGG, AlexNet, GoogLeNet, LSTM and GRU
in four variants each, Mamba2, minGPT causal attention -- built out of the level 1
primitives already in the corpus. Each lands as the usual pair: a manifest and a
buffer-out NumPy kernel.

Two agents did the porting and both were killed by process death before reporting,
so their LEARNED notes are lost; the ports themselves were complete on disk and are
what is committed here. 8 of the 50 remain: four EfficientNet variants, RegNet, and
three vision transformers.

Unverified, and the reason is worth recording: neither porter had
docs/canonical_numpy_form.md, which is the BINDING spec these files have to satisfy
and has a CI gate behind it. They copied neighbouring ports instead, so they may
satisfy CNF by imitation, but nothing has checked. An audit against the three
invariants -- static shape at declaration, explicit indexing, declare-then-fill --
is the next step before these are trusted.
Each instrument gets one page, and each page a -judge twin that is the same text with
only `## How it runs` swapped -- byte-identical elsewhere, enforced by a test that is
proven to fire three ways (reword a shared line 250 lines from the seam; rename the
swap heading; copy the execution section across so the twin never says who runs it).

The pages were reviewed against upstream vendor documentation first: 64 errors found
across six pages, every one re-verified before the fix, none rejected. That caught
things like PAPI's `:stat=` defaulting to `avg` rather than `sum` -- so a bare
`cuda:::dram__bytes_read` is bytes PER DRAM PARTITION, low by the instance count with
nothing in the output saying so.

Then they were FIELD TESTED: a fresh agent given one page, forbidden the siblings, and
a task whose answer was known by construction. Three of four failed. Every failure was
a page that is factually correct in every claim and still routes the reader wrong:

* optimization-hints told the reader tolerance settles reduction reassociation. The
  harness verifies BITWISE. The tester's fastest variant, 3.13x, built by following the
  page, graded `correct: true, verified: false` -- score zero.
* papi-cpu summed idle threads' barrier spin into the total: 21.9x and 4.5x over truth
  in two independent tests, both printing its own `armed N threads, counted N` healthy
  line. Its guard covered armed < used; the failure is the other direction. A guard
  that checks one direction of a two-directional error is worse than none.
* nsys omitted `--force-overwrite=true` from the stats line, so every rerun silently
  returned the PREVIOUS run's CSVs and exited 0 -- breaking the only loop an agent runs.

ncu's four metric tables became a reading -> action table, with every threshold read
out of the shipped `sections/*.py` rules rather than remembered, and the same values
confirmed in both installed versions. Its ordering discovery matters as much as nsys's
total_ns rule: `Memory Throughput` is the MAXIMUM over its constituents, so 85% there
with DRAM at 30% means L1 or L2 is saturated and DRAM work buys nothing.

Fixtures under fixtures/ carry ground truth by construction. The CPU one documents a
trap worth keeping: `a[i] = c[i] > 0 ? x : y` is not branch-bound at -O3 -march=native,
because gcc if-converts it to vcmpgtpd plus masked ops -- measured misprediction 88x
BELOW the threshold the phase existed to trip. A branch the compiler can flatten is not
a branch.

Not shipped into hpcagent_bench/skills/ yet: the five -judge pages describe an
/instrument route the service does not have, and papi-gpu's numbers have never been
observed because the driver profiling gate is on.
… .so one by experiment

The design shipped with ten unanswered questions. Nine are now decided in the doc.
The tenth was worth measuring rather than deciding, and the measurement changed the
answer.

CAN a judge count an arbitrary UNINSTRUMENTED .so it dlopens and calls? Yes -- so
requiring agents to ship instrumented libraries is unnecessary. But by PAPI_attach,
which binds counters to TIDs, not by pre-arming the OpenMP pool, which binds them to
thread NUMBERS. Four-way agreement against perf stat as truth: 1.489e9 instructions
truth, 1.476e9 attach, 1.486e9 register, 1.472e9 instrumented. Counting perturbs
nothing measurable: 0.0562 s uncounted against 0.0560 s attached.

The value is in how it BREAKS, because four of the five ways are silent:

* raw pthread_create workers report 0.2% of truth -- 3.0M instructions for 1.53e9
  executed, every PAPI return PAPI_OK. papi.py's `appeared` guard does not fire: the
  threads are created AND joined inside the call, so thread_ids() before and after
  are identical.
* nested parallelism reports exactly 24.8% -- two armed outer threads times a quarter
  of each inner team. Entirely plausible as a magnitude.
* a cross-runtime .so (judge libgomp, agent libomp) reports 13.3%. And two OpenMP
  runtimes in one process fight over affinity: the judge's OMP_PROC_BIND=close
  confined libomp's workers to one core and made the parallel kernel slower than
  serial.
* OMP_WAIT_POLICY=active inflates cycles 4.01x by counting barrier spin. Outside-in
  is not wrong here -- it matches perf stat -- it is counting spin as kernel work.
  This is LLVM libomp's default, so it fires on real submissions.
* register mode only: PAPI_stop from a non-owning thread returns PAPI_OK with k*2^47
  garbage, and IPC comes out ~1.000 for those slots, so an IPC sanity check misses it.

The check that catches all of them is sampling /proc/self/task DURING the call rather
than before and after: unarmed_tids was 0 for every correct case and 6-17 for every
wrong one, including the pthread case the before/after guard cannot see.
papi-gpu told the reader to arm the event set once and take a PAPI_read delta per
region, with two cudaDeviceSynchronize calls presented as the thing that made the
delta the kernel's. It does not work. The cuda component flushes the counter
ASYNCHRONOUSLY and a device synchronise does not flush it, so the delta between two
reads is whatever happened to be flushed in between -- which has no relationship to
what ran in between.

An EMPTY bracket -- two reads with nothing at all between them -- reported 374 MB of
DRAM reads. That is the whole bug in one line.

Measured against four kernels of known compulsory traffic, 25 regions each:

                          truth/rep   start/stop    read-delta
  streams b,c into a       128 MiB    134.26 MB      128.4 MB
  touches 64 KB x64          64 KB      77.9 KB       93.5 MB   <- 1300x
  reads a, 64 FMA, writes    64 MiB     67.08 MB      111.1 MB
  reads a and c, divergent  128 MiB    134.27 MB      126.0 MB

PAPI_start/PAPI_stop per region lands on the compulsory traffic to within 0.1% on
every row. The read-delta is wrong on every row, and the true 2100x spread across
those four kernels arrives as 1.2x -- it does not add noise, it flattens the ranking
you are profiling to find. Same result on the SM side: a 7168x instruction-count
ratio, exactly matching 512 warps x 11 against 524288 x 77, reported as 1.66x.

The page argued against start/stop on the grounds that re-arming CUPTI per region is
instrumentation cost inside the region. The cost is real -- 2.37 s against 1.22 s over
20 regions -- but the page also says, correctly, that a counted run's wall clock
belongs to no comparison. It traded correctness for a property it tells you to ignore.

The syncs turn out to be redundant too: removing both changed the answer by 0.008%,
because PAPI_stop synchronises to collect. They were a line that looked load-bearing.

gpu_papi_init now arms and disarms around NOTHING at startup and REFUSES to run if
that reads back non-zero. It surfaces the permission gate early, and it is the one
self-test that catches a counter accumulating device-wide instead of attributing --
the failure mode that produces confident, plausible, wrong numbers on every region at
once with no error anywhere.

The GPU fixture had the same class of defect from the other direction: 6 MB of buffers
against 24 MB of L2, so k_stream was entirely cache-resident and never memory-bound.
dram__bytes_read correctly reported ~0 and read as a broken counter. Now 96 MB, 4x L2,
verified landing on each kernel's algorithmic minimum. Costs 0.44 s for 20 reps.

Also adds the AMD side, standalone and judge variants of each:

  rocprofv3        the dispatch trace -- which kernel, which copy, which gap
  rocprof-compute  kernel-level analysis -- SOL, memory chart, which pipe
  papi-gpu-amd     the rocp_sdk component, same start/stop discipline

There is no AMD GPU on this box and all three say so at the top. What ports is the
METHOD, not the numbers: PAPI's own rocp_sdk README documents the same lagged-flush
behaviour and recommends adding delays between the kernel returning and PAPI_stop,
which is a race you cannot see losing. The empty-bracket self-test is what makes that
checkable on hardware the page could not be tested on.

Two AMD traps that return silent zeros are on the page for the same reason:
AQLPROFILE_READ_API=0 is required on ROCm >= 6.2.0, and PAPI_library_init must run
BEFORE any HIP call -- the opposite of the CUDA rule, where the component needs a live
context first. Same library, opposite order, each silent when wrong.
Seven jobs upload their coverage data. Every one of them names it `.coverage`, and
the download step passed `merge-multiple: true`, which flattens all seven into a
single directory -- so seven artifacts raced for one path. Six were discarded and the
winner became the published project total.

Two consecutive GREEN runs show it plainly: both logged `Found 7 artifact(s)` and then
`Combined 1 file`, one reporting 59.96% and the next 13.44%. Same repo, same code; the
swing is entirely which job happened to land last. The number in the summary has not
been a total.

Run 30809753679 lost the race harder: two extractions interleaved rather than cleanly
overwriting, leaving a torn SQLite file, and combine died with

  Couldn't use data file '.../HPCAgent-Bench/.coverage': database disk image is malformed

against the REPO ROOT path, which is why this reads as a destination problem and is
not one. Coverage 7.15.3 combines via `ATTACH DATABASE` and reports the error on the
main connection, so the message names the wrong file; the malformed file is
`coverage-data/.coverage`. Every individual artifact passes `PRAGMA integrity_check`.
No job produced a corrupt file -- the merge did.

The coverage config was never the problem: `parallel = true`, `relative_files` and
`concurrency = ["multiprocessing", "thread"]` are all already set, which is exactly
why the per-job files are clean under xdist.

Dropping `merge-multiple` gives each artifact its own subdirectory, so the seven files
no longer share a name. `coverage combine` takes explicit paths of any basename and
content-hash-dedups, so same-name-different-directory is fine.

The second half matters more than the first: a partial combine prints a perfectly
plausible percentage and stays green, which is how this survived every green run. The
step now fails unless combine consumed every file it was handed, and a repo gate
pins all three properties -- proven to fire by breaking each one in turn.
`--cache-control` defaults to `all`, which invalidates L1 and L2 before every replay
pass so that pass 3 sees what pass 1 saw. The cost is that the kernel is measured
COLD, which is not how it runs, and that is invisible until the working set fits in
cache -- at which point it owns the headline number.

Same kernel, same binary, 6 MB of buffers against this part's 24 MB of L2:

  --cache-control all  (default)   dram__bytes_read 4.20 MB   DRAM Throughput 90.04%
  --cache-control none             dram__bytes_read 2.05 MB   DRAM Throughput  0.14%

A 640x swing in the number that decides whether you are memory-bound, from a flag
nobody sets. At a 96 MB working set the two agree (94.53% against 94.33%), because
then the data genuinely does not fit and the flush changes nothing.

This also reconciles ncu against an in-situ counter, which is how it was found: PAPI's
cuda component does not touch the caches, so on that same kernel it reported near-zero
DRAM traffic while ncu reported 90% of peak. Neither is broken -- they answer
cold-start against steady-state, and which one you want depends on whether the kernel
runs once or a thousand times. A timestep loop is the second case and is exactly where
the default misleads.

Both of the page's ordering claims were also tested with the gate open, since the
metric half was written while it was shut. `Waves Per SM` < 1.0 killing the occupancy
chapter is CONFIRMED and is the sharpest rule on the page: the launch-bound kernel
reads 0.01 waves/SM with 16.71% warps active, so the occupancy chapter argues for
tuning occupancy and the ordering rule correctly overrules it with "widen the grid".

The Memory-Throughput-is-a-maximum claim could NOT be tested here -- after the fixture
resize every kernel is DRAM-limited, so Memory Throughput and DRAM Throughput are
equal on all four. The claim stays, unverified, and now says so.

Also files the ablation, tagging and speed-up-plot backlog.
The restrict gate landed in 1389108 ahead of the one file that violates it:
lavamd_reference.cpp declares 12 bare pointer parameters at :47 and :90, so
test_reference_source_form.py has been failing on main ever since. The twelve
__restrict__ qualifiers were already written and simply never committed.

The rest is provenance, and it is the part worth reading. Four reference kernels carry
OpenMP directives that do NOT match their upstream, and until now nothing said so --
which leaves the next reader unable to tell an adaptation from a transcription error.
Each file now states which it is and why:

- lavamd: upstream kernel_cpu.c:112-117 carries four private(...) clauses. The adapted
  directive drops ALL of them and the correct list is EMPTY -- upstream declares those
  eighteen variables at function scope, whereas this extraction declares each at its
  point of use inside the outer loop body, so C++ scoping already makes them private.
  A copied private() list would have been wrong here in a way that still compiles.
- xsbench: a reimplementation of the unionized-grid lookup, so the directive was
  re-derived from the loop in this file rather than copied.
- cp2k_density_matrix_trs4: upstream carries NO OpenMP directive at all -- every matrix
  operation is a library call -- so there was nothing to copy and the directives are
  adapted from scratch.
- cp2k_grid_integrate: hand-written Fortran, not a transcription of the C.
- velocity_tendencies: adapted, not copied verbatim.

Upstream text is quoted verbatim in each header, tabs included, so a reader can check
the adaptation rather than trust it.
Phase 2c runs `hpcagent_bench/benchmarks/`. Every file it measures sits inside the
`[tool.coverage.run] omit` pattern, so instrumenting it produces no report data
whatsoever. It has been pure cost, and the cost is large.

`omit` stops LINE tracing, not the per-call dispatch. sys.settrace fires on every call
event even for a file coverage will never record, and this phase is call-dominated:
profiling the one cloudsc branch test shows 4.4M function calls in 23.7 s, so it pays
that dispatch four million times to discard the result. Measured here, 8.28 s bare
against >120 s instrumented. In CI the identical 745 tests went 183.57 s -> 736 s when
coverage landed, which pushed the heaviest test past --timeout=600 and has made mpi
Phase 2c red for three consecutive runs. Not a flake and not new -- it dates to the
commit that turned coverage on.

COVERAGE_CORE=sysmon looked like the fix and is not one. coverage 7.13.5 refuses sysmon
whenever `branch = true` on Python < 3.14 -- "sys.monitoring can't measure branches in
this version", since BRANCH_RIGHT/BRANCH_LEFT arrive in 3.14 -- and refuses it again
for `concurrency=`. It then warns and silently falls back to the C tracer. Setting it
would have changed the log and not the runtime; confirmed by reading core.py's
selection logic and by env.PYBEHAVIOR.branch_right_left being False here.

So the phase clears PYTEST_ADDOPTS. That is the whole fix for this job, and it loses
nothing, because there was nothing to lose.

Phase 5's numba/jax sweep is a different case and gets a different answer. It drives
real library code, so its coverage IS signal and cannot be switched off. Its budget was
set against a 200-port kernelbench subtrack and an uninstrumented run; the subtrack is
now 239 and coverage is on. It took 26:01 when last green and reached 93% before the
runner killed it at 35:00, so the budget goes to 55. That is a correction for work
added on purpose -- the per-test --timeout=600 is still what catches a hang.

Two gates pin it, both proven to fire by breaking them: Phase 2c stays uninstrumented,
and the omit pattern that makes that safe stays in place. The second matters more than
it looks -- narrowing the omit would quietly make this the one phase where a real
library path goes unmeasured, and nothing else would notice.
… ceiling

Two failures from 0619ec4, which ported 39 KernelBench level3 networks and grew the
kernelbench subtrack 200 -> 239 without landing anything that depends on either number.

The resolver globbed ("level1", "level2") only, so all 39 new ports fell into res.skips
and three tests ERRORED at collection on the `resolved` fixture. level3 IS vendored --
third_party/KernelBench at 423217d9 holds level1 (100), level2 (100), level3 (50),
level4 (20) -- and uses the same <index>_<Name>.py convention, so the existing
UPSTREAM_INDEX regex and kernelbench_key fold apply unchanged. Checked before trusting
that: grouping all three levels by key produces no new collisions, so adding level3
cannot perturb any level1/level2 resolution. The "no upstream model #1 for key"
phrasing is the duplicate-name index, not a level-specific numbering.

level4 stays out deliberately -- it holds HuggingFace model+batch+seq configs
(16_gpt2_bs1_seq1023.py) and nothing was ported from it. That is now a named constant
with the reason attached rather than a tuple literal.

All 39 resolve 1:1 (alexnet -> level3/5_AlexNet.py, lenet5 -> level3/4_LeNet5.py, ...),
239 copies, 0 skips. The hardcoded 200 in test_kernelbench_references.py becomes a named
PORT_COUNT = 239; the assertion still fails loudly if the subtrack grows again without
the resolver learning where those sources live, which is the property it was protecting.

Second: ml/shallow_wide_mlp resolved to 16.032 GB against a 16 GB XL ceiling. The
interesting part is that shrinking the batch CANNOT fix it -- the three weight matrices
alone are 32768*16384 + 32768*32768 + 16384*32768 = 2^31 elements = exactly 16.000 GiB,
the ceiling to the byte. Even batch_size 1 leaves it 917,504 B over on the biases. A
weight dimension had to move.

output_size 16384 -> 8192 at XL only. The width is what "shallow wide" means and both
hidden layers stay at 32768; the depth stays at upstream's [32768, 32768] so the
provenance link the first half of this commit just established is not broken; batch and
input keep upstream's get_inputs() shape. XL_BYTE_CEILING is untouched and no skip was
added -- the ceiling is a real memory budget with other kernels tuned right up to it.

XL footprint 16.032 -> 14.024 GiB. L already had output_size 8192, so L and XL are now
equal there: flat rather than growing, which 76 other corpus kernels already do, and no
ladder violation. The manifest carries the 2^31 arithmetic as a comment so the next
reader does not re-derive it.
Four tests hardcoded the subtrack size as a bare 200. Porting 39 level3 networks moved
the real number to 239 and every one of them broke, which is how five CI jobs came to
fail with a NUMBER as their first decisive line rather than a defect:

  tests/test_e2e_numerical.py:67        UNGATED_COUNT = 200
  tests/test_kernelbench_references.py  expected 200 kernelbench ports, found 239
  tests/test_levels.py:99               assert ... == 200
  tests/test_kernelbench_translation.py assert len(kernelbench_stems()) == 200

They all guard different consequences of the corpus growing -- provenance resolution,
the level selector, the translation ratchet, the numerical sweep's exclusion set -- so
none of them is redundant. What was redundant was the literal. A ratchet needing an
update in four places is a ratchet that will be wrong in at least one, so the number now
lives in tests/corpus_counts.py and the four import it.

UNGATED_COUNT deserves its own note, because raising an exclusion count is the one edit
here that could be a weakening. It is not: the exclusion is defined by
`spec.subtrack in UNGATED_SUBTRACKS`, and that predicate did not change. No kernel that
was gated became ungated -- the SUBTRACK grew, and the count is a pin on the subtrack's
size. Deriving it from the shared constant is what keeps that honest: the two can no
longer disagree, so this stays a size pin and never becomes somewhere to park a failing
kernel. The comment says so, since its predecessor explicitly asked for a reason.
The claim was that COVERAGE_CORE=sysmon silently falls back to the C tracer, argued
from coverage's core.py selection logic. Measuring it: the same cloudsc test that runs
in 8.28 s bare ran 1500 s under sysmon and was KILLED without finishing -- byte for
byte the behaviour of the unset run. So >181x, and that is a floor rather than a
figure.

The earlier '>120 s' was the point at which the first attempt was interrupted, not a
completion. Replacing it with the number that was actually observed.
INSTRUMENT_SKILLS gates the instrument bodies out of prompts that did not ask for
them -- worth 1373 -> 284 lines when it landed, because four manuals were 1081 of 1169.
It is a hand-written list of names that must stay in sync with a directory, which is the
same shape as the four test files that each hardcoded the corpus size until the corpus
grew. It drifts, silently, in the direction of paying for it.

So the check is derived from what the gate actually protects: token cost. Any page long
enough to be a manual (>= 100 body lines) must be classified. The threshold separates
cleanly -- the strategy skills are all ~22 lines, the manuals 104-480, and nothing sits
near the boundary. Drafts are checked too, since a draft graduates by one `mv` and the
failure has to arrive BEFORE the page is in every prompt rather than after.

Adding the six AMD pages was the reason to write it; the gate then immediately found
three more nobody had classified -- optimization-hints (104), pytorch-to-numpy (138) and
static-analysis (141), about 380 lines that would have entered every prompt on
graduation.

Being big is not the same as being an instrument, though, so the fix is not one list but
a forced choice between two:

- static-analysis is gated. It is a compile-time tool with the same shape as opt-reports:
  run it, read the report.
- optimization-hints is ALWAYS inlined. It is not an instrument at all -- it is the ORDER
  of operations, and gating it behind a profiling knob would hide the sequencing from
  precisely the agent least likely to ask for it. Its worst measured failure routed a
  reader to a zero score and involved no tool.
- pytorch-to-numpy is always inlined only because it is PARKED and is a porting skill;
  listed explicitly so the size gate cannot quietly absorb it into the instrument set
  while whether it ships at all is still open.

A second gate refuses membership in both sets, since that means nobody decided and the
behaviour would depend on which check ran first. Both proven to fire by breaking them.
Two kernelbench ports were refused outright:

  NotImplementedError: slice step 'stride' must be a compile-time integer;
  a symbolic step is read as 1 and the stride is lost

efficientnet_mb_conv (step `stride`) and resnet_basic_block (step `conv_stride`). A
parse sweep of all 246 benchmarks/ml kernels found exactly those two -- the initial
read of the CI log suggested 19 files and 70 slices, but most of those slices are
inside helpers the inliner absorbs, and they were never the problem.

Nor is the cause helper specialization, which is where the investigation started. In
both kernels the helper IS inlined, so the guard fires at the KERNEL BODY site
(frontend.py:534), not the surviving-helper sites. The step is not a call-site literal:
it is a kernel PARAMETER from the manifest's init.scalars (stride: 2, conv_stride: 1)
that is also an ABI argument.

_FoldStructuralUses already exists for exactly this class of name -- a runtime argument
keeps its name everywhere it can be evaluated at runtime, and folds only where nothing
else can be emitted. It covered a structural call's AXIS slot and not a slice's STEP
slot, which is the same kind of slot, refused by the sibling guard for the same reason.
So this adds visit_Slice, folding node.step ONLY. Bounds keep their name and still reach
the ABI: a bound is an ordinary integer expression, and the trip count comes from the
target's extent.

Placement is the delicate part. The fold runs after inlining (so a helper's ::stride has
already become the body's ::conv_stride), after _FoldConstantSymbols, and BEFORE
desugar_tuples and both structural guards -- which respects the ordering the existing
comments require, since the tuple fold must precede the guards and a literal can only
help it. The rebound-name rule was extracted to _rebound_names and is now shared by both
folds; _FoldConstantSymbols' behaviour is byte-identical.

The guard itself is untouched, message intact. Three of the seven new tests are
REFUSALS, pinning that it did not get weaker: a name absent from the manifest, a name
reachable as an EXTENT (which the harness may scale), and a name the body REBINDS.

The different-strides case is real and is proven on a real port: efficientnet_mb_conv
uses two conv helpers with different strides in one kernel and parses to steps {1, 2}
across 6 slices -- the 1x1 convs keep 1, the depthwise conv takes the manifest's 2. A
numerical test pins it too, since a collapse would read 1 3 5 7 instead of 1 4 7 10.

test_abi_corpus_agreement goes 3 failed / 2 passed -> 5 passed. That is the decisive
one: KNOWN_NON_LOWERING is {} and ratcheted in both directions, so all 578 kernels lower
and every emitted signature matches its binding.

The ports themselves are not wrong. canonical_numpy_form.md allows slices over declared
axes and says nothing against a step; an init.scalars knob used as a step is an ordinary
structural constant. The translator was the limitation.

Known and deliberately not fixed: on the surviving-helper path, _build_helper_kirs keeps
only the FIRST call site, so a second call to an array-returning non-inlinable helper is
left un-rewritten. Reproduced -- it is a COMPILE ERROR in all three native backends, not
a wrong answer, and no corpus kernel reaches it. Fixing it is one KernelIR per distinct
constant tuple, a feature-sized change to a function the whole corpus flows through, for
a construct with zero consumers.
…abled

DaCe's `compiler.command_cache` records the first build of a shape with `ninja -t
compdb` and replays those commands for later SDFGs, skipping CMake entirely. It has
been ON by default on spcl/dace@extended the whole time. It has also been doing nothing
in CI, because DaCe picks its generator with `shutil.which('ninja')` and only replays
when it picked Ninja (codegen/compiler.py:560, :644) -- and .github/actions/setup never
installed ninja.

So the config read True, CMake fell back to Make, and every SDFG paid a full configure.
Nothing reported it. A missing build cache is not an error, it is a slow build, which
reads as "CI is sluggish today" rather than as a defect -- the same shape as a guard
that only checks one direction.

ninja-build and ccache now install in the shared setup action rather than in whichever
job noticed first. ccache is there for the same silent-failure reason: DaCe knows
nothing about it, so it helps only via a compiler launcher or a PATH shim, and neither
can point at a package that is not installed.

On the framework side, pin_build_caching() joins pin_cpp_standard() and
pin_single_stream() at the top of optimize(). Same argument as the C++ standard: a
user's ~/.dace.conf must not be able to change what a graded baseline costs to build.
It pins build_mode=cmake (native writes per-object .o.cmd files, so there is no
compile_commands.json and therefore no command cache at all), configure_cache and
command_cache -- and warns when ninja is absent, since that is the one input the config
cannot describe.

ccache is offered through CMAKE_{C,CXX,CUDA}_COMPILER_LAUNCHER rather than by hoping
/usr/lib/ccache sorts first on PATH. CMake reads those from the environment, so it
covers the build DaCe is about to run without touching DaCe -- which is out of scope
here anyway.

Three gates, each proven to fire by breaking it: the pins survive a hostile conf (every
one is set to the WRONG value first, so a no-op function fails), ccache reaches CMake
without depending on PATH order, and CI installs both tools.
A submission's build is write-heavy and entirely disposable, so a memory filesystem is
the right medium for it. It is the wrong medium everywhere the build shares memory with
the kernel being timed, which is most places -- and that objection is not new here:
harness/recording.py already REFUSES a results DB on a memory filesystem, because "on a
compute node the DB would compete with the run for RAM".

So the sandbox opts IN rather than defaulting on. HPCAGENT_BENCH_SANDBOX_DIR names a
directory outright; otherwise /dev/shm is used only under CI. A workstation keeps the
ordinary temp dir, which also keeps the standing rule that builds belong on disk rather
than in the tmpfs /tmp already is.

The second rule matters as much as the first: a tmpfs that runs out does not degrade,
it fails the build with ENOSPC, and that failure is then attributed to the SUBMISSION
rather than to the host. Below 512 MB free the sandbox declines and falls back. Checked
per call rather than once at import -- free space is a property of the moment and
several sandboxes can be live at once.

Also files npbench PR#47 (0-init) to the backlog. It converts np.empty/np.empty_like to
zeros across 256 sites, because a kernel that writes an `empty` buffer only PARTIALLY
leaves stale memory in its output and is therefore read-nondeterministic. That lands
harder here than upstream: this repo grades BITWISE, so a stale byte is not a tolerance
question, it is a failed verification that reads as a flake. Filed to audit rather than
port, since CNF's declare-then-fill invariant may already cover most of it.
The column's own docstring said it: `pluto` built `<base>_fp{64,32}.cpp` -- the same
sources as `llvm`, with the same clang++ -- and never invoked polycc. Meanwhile
polycc_report described a transformation that nothing compiled. Every pluto-vs-llvm
number in the results DB is llvm-vs-llvm.

polycc is source-to-source ONLY (the one compiler-adjacent call in the script is
clang-format, to indent its own output), so making this real is a BUILD PATH, not a flag
preset. hpcagent_bench/pluto_transform.py is now the single place the invocation is
spelled, and the report's args are defined as an EXTENSION of the build's
(POLYCC_REPORT_ARGS = POLYCC_ARGS + --debug) so the two are structurally incapable of
describing different transforms again.

Two things had to change that look cosmetic and are not, both measured:

(1) The column takes the C driver, not clang++. polycc's output is C and only C: rank>=2
    arrays arrive as VLA parameters (`const double A[restrict NI][NK]`), and neither
    variably-modified types nor the `restrict` KEYWORD exist in C++; polycc also prepends
    its own `#define min(x,y)`, which detonates inside libstdc++. Verified on a real
    kernel: gramschmidt's transformed output compiles clean under clang -std=c17 and does
    not compile at all under clang++ -std=c++20.

(2) The OpenMP spelling had to change. clang ACCEPTS -fopenmp=libgomp and generates no
    OpenMP for the pragma AT ALL. Measured on clang 21.1.8 with nm -u, one
    `#pragma omp parallel for` loop:

      -fopenmp=libgomp            GOMP=0 kmpc=0   <- pragma dropped, loop is SERIAL
      -fopenmp                    GOMP=0 kmpc=3
      -fopenmp=libgomp -fopenmp   GOMP=0 kmpc=0   <- the `=<lib>` form wins in EITHER
      -fopenmp -fopenmp=libgomp   GOMP=0 kmpc=0      order, so appending cannot rescue it

    clang implements OpenMP only against its own libomp. Since `polycc --parallel` PUTS
    the pragma in the source, the shared clang baseline would have timed a serial binary
    under a parallel label -- the same lie this column was rebuilt to stop telling, one
    layer down. CPU_BASELINE_CLANG_PLUTO is written as a substitution on
    CPU_BASELINE_CLANG so the two cannot drift in any flag except that one, and
    flags.pluto_capability gates the column on the object actually referencing a runtime.

    The other clang columns keep libgomp deliberately: their sources carry no OpenMP
    pragma (0 of 45 emitted *_fp64.cpp), so the spelling cannot change their codegen, and
    test_fork_openmp_safety.py pins libgomp as the runtime whose fork() behaviour the
    isolation layer is tested against.

Argument order is the third trap. polycc's signature is symbols, then arrays, then
scalars -- a VLA parameter's extents are themselves parameters and C requires them
declared first -- while every other native column uses the canonical ABI order. The
translator already writes that order as <base>_pluto_binding.json, so call_args reads it
rather than re-deriving it. When it is missing the column DECLINES: a positional ctypes
call cannot detect a permuted argument list, it just returns numbers.

Non-affine scops still decline through NotSupportedByFramework rather than falling back
to the untransformed source. polycc may silently MISCOMPILE a non-affine scop rather
than reject it, so "exited 0" is not evidence the transform was sound, and a silent
fallback here would be the original bug with a better hiding place.

End-to-end on gramschmidt: 3 omp pragmas emitted, compiles as C, and the object carries
3 OpenMP runtime symbols. spmm_csr declines (polycc rc=1).

Also files the skill-page review (all five pages DO NOT SHIP, 20 surviving findings) and
two backlog items: npbench 0-init, and unit tests proving the kernelbench NumPy ports
agree with the PyTorch models they were translated from.
…mula

All five failed an 11-agent review (find, then adversarially re-check). The AMD pages
failed hardest, and the root cause is worth recording because it is not "I was careless":

  The ROCm counters page gives DEFINITIONS AND UNITS ONLY -- every formula on it reads
  "NOT SHOWN". A web-fetch summariser returned a confident "Derived Metrics with
  Formulas" markdown table anyway, and the page repeated it. Three of four derived
  formulas were therefore invented by a model and shipped as vendor documentation.

The honest fence those pages carried ("no AMD GPU on this box, nothing here was
executed") did not help at all, because a reader cannot tell a fenced-but-correct claim
from a fenced-and-fabricated one. A fence is not a source.

So the formulas now come from ROCm's own counter_defs.yaml, fetched and PARSED rather
than summarised, and the page cites that file rather than the prose page:

  VALUBusy        100*reduce(SQ_ACTIVE_INST_VALU,sum)/CU_NUM/reduce(GRBM_GUI_ACTIVE,max)
  SALUBusy        100*reduce(SQ_INST_CYCLES_SALU,sum)/CU_NUM/reduce(GRBM_GUI_ACTIVE,max)
  MemUnitStalled  100*TCP_TCP_TA_DATA_STALL_CYCLES_max/reduce(GRBM_GUI_ACTIVE,max)/SE_NUM
  VALUUtilization 100*reduce(SQ_THREAD_CYCLES_VALU,sum)/(reduce(SQ_ACTIVE_INST_VALU,sum)*MAX_WAVE_SIZE)
  LDSBankConflict 100*reduce(SQ_LDS_BANK_CONFLICT,sum)/reduce(GRBM_GUI_ACTIVE,max)/CU_NUM
  L2CacheHit      100*reduce(TCC_HIT,sum)/(reduce(TCC_HIT,sum)+reduce(TCC_MISS,sum))

None of those denominators is SQ_BUSY_CU_CYCLES, which is what the page had used for
three of them. L2CacheHit was the one I had right. They are also ARCHITECTURE-SPECIFIC:
on gfx10 LDSBankConflict is SQC_LDS_BANK_CONFLICT/SQC_LDS_IDX_ACTIVE and L2CacheHit
counts GL2C_* instead of TCC_*, so the page now says to ask the tool by name.

Other corrections, each grounded in an upstream quote:

- `--pmc` twice does NOT give two passes. The option is nargs="*" with no append, so the
  second occurrence silently DISCARDS the first. Multi-pass is a two-row input file. The
  page had been teaching silent data loss.
- VALUUtilization (rocprofv3) and `VALU Utilization` (rocprof-compute) are near-identical
  spellings for opposite quantities -- lane occupancy vs how busy the VALU was.
  rocprof-compute's divergence metric is `VALU Active Threads`, in work-items.
- Units differ BETWEEN THE TOOLS: rocprof-compute's gfx942 L2 panel declares Read BW as
  `unit: (Bytes + $normUnit)`, while FetchSize is documented in KILOBYTES. The page had
  carried the KB rule, called it "the unit trap", and then applied it to the byte tool --
  creating the 1024x error it warned about.
- Occupancy: 8 wavefront slots per SIMD and 32 per CU on CDNA, so 2048 work-items fill a
  CU, not 256. The old text also asserted a factor that works out to 1.
- GPUBusy is a PERCENTAGE (100*GRBM_GUI_ACTIVE/GRBM_COUNT), so dividing by it inverts the
  normalisation. The normaliser is GRBM_GUI_ACTIVE.
- Percentages are no longer accumulated across regions; only counts are.
- rocp_sdk enumerates under `rocp_sdk:::`, not `rocm:::` -- every shell example was wrong.
- AQLPROFILE_READ_API=0 is intercept-mode-only, not an unconditional export.
- Copies DO carry a byte volume (`bytes` in the buffer-tracing record); the page had said
  you could not get it from the trace.
- v1 rocprof DOES print launch geometry; LDS column is LDS_Block_Size; the pmc CSV is
  pid-prefixed; *_domain_stats.csv was missing from the reports table.
- Replay is not the only distortion: dispatches are serialized across HIP streams, and
  replay breaks MPI outright (repeated MPI_Init/MPI_Finalize).

papi-gpu: three events its own steps 3 and 5 CONSUME were never armed -- all three
verified to resolve here first. "within 0.1% on every row" was arithmetically false; row
2 is +18.9% (77.9 KB against 64 KB), which is launch overhead at a scale where it stops
being negligible. A leftover claim that the syncs are the measurement is gone.

ncu: the gate section was stale -- this box now reads RmProfilingAdminOnly: 0, so the
page was routing readers off a working profiler onto cuobjdump, which it calls incapable
of costing anything. Deleted, along with the blanket UNVERIFIED fence. --cache-control
none now carries its precondition (valid only on a single-pass collection; --set basic is
8 passes). Step 3's prose contradicted the page's own action table and the table was the
one matching NVIDIA. The occupancy-gap row now sends you to re-read Waves Per SM first,
since inside 1 <= Waves Per SM < 5 NVIDIA attributes the gap to the tail.

Findings and their upstream quotes: docs/BACKLOG_skill_page_review_20260803.md.
Also backlogs porting the remaining 31 KernelBench kernels.
…t wrong

The three AMD pages were written against no hardware. This box turns out to HAVE an
AMD GPU -- a Radeon 780M (gfx1103, RDNA3 integrated) already exposing /dev/kfd, with
ROCm 7.2.4 and rocprofiler-sdk 1.1.0 in Ubuntu's own archive. So the rocprofv3 page has
now been executed rather than researched, against a real HIP fixture.

Three claims confirmed, and three corrected -- including two that were THEMSELVES
corrections made from documentation earlier today. Source-verified and hardware-verified
are different states, and this is the evidence:

  CONFIRMED  the LDS column is LDS_Block_Size, not Group_Segment_Size
  CONFIRMED  *_domain_stats.csv exists and the reports table had omitted it
  CONFIRMED  the output layout is FLAT on this version, not <hostname>/<pid>/

  WRONG      "copies carry a bytes field, ask for csv json". The CSV emitter has NO size
             field at all: Kind, Direction, Stream_Id, Source_Agent_Id,
             Destination_Agent_Id, Correlation_Id, Start_Timestamp, End_Timestamp. The
             buffer-tracing record does define `bytes`, which is what the doc search
             found, but it does not reach --output-format csv. The page now says check
             your emitter before believing any page, including itself.
  WRONG      "no register count". The trace carries VGPR_Count, Accum_VGPR_Count and
             SGPR_Count per dispatch -- the numbers that turn "occupancy is low" into a
             cause. Both the draft AND the SHIPPED rocprof skill said otherwise.
  MISSING    kernel_stats also carries StdDev, which nothing surfaced.

The shipped skill's correction hit a gate that turned out to be right to resist: it pins
the skill to columns the repo's READER really parses, and the reader matches
Group_Segment_Size. So on current ROCm the reader finds no LDS column at all, silently,
because a missing optional column reads as null. That is a live product bug, not a doc
error -- the page now names both spellings and says which one the code matches, and the
reader/fixture fix is backlogged rather than smuggled in here.

One operational finding worth as much as any column: rocprofv3 REQUIRES
hsa-amd-aqlprofile and does not depend on it. Without the package the run dies with

  ./gpu_phases: error while loading shared libraries: libhsa-amd-aqlprofile64.so.1

prefixed with the CHILD's name, not the profiler's, because the library is injected into
the profiled process. The binary links and runs fine standalone, so this reads as a bug
in your own program and is not one.

Ubuntu also ships two ROCm stacks that do not interoperate: /usr/bin/hipcc links against
/usr/lib/rocm/llvm and fails with undefined symbol __hipUnregisterFatBinary, while
/opt/rocm/bin/amdclang++ has no device bitcode. The build that works crosses them --
Ubuntu's clang for device code, /opt/rocm for the runtime.

Not verified here and still marked so: anything CDNA-specific. An iGPU has no HBM and no
Infinity Fabric, and the MI300 counter expressions are a different architecture's.
NVIDIA needs no sample: install the CUDA Toolkit and nsys/ncu/CUPTI are there. AMD is
six separate traps, every one of which cost time today and none of which is obvious:
Ubuntu already packages ROCm (so amdgpu-install's version-pinned URL is a dead end),
the tools land in /opt/rocm/bin off PATH, rocprofv3 needs hsa-amd-aqlprofile and does
not depend on it, rocprof-compute pins astunparse==1.6.2 against a PEP-668 system
python, two ROCm toolchains coexist without interoperating, and an unsupported gfx
target needs HSA_OVERRIDE_GFX_VERSION.

Written down so the sample is a script plus a preflight check rather than prose.
The pmc-twice test could not confirm the pass-splitting claim on hardware:
rocprofiler-sdk 1.1.0 reports no counter metrics for the agent at all.

  rocprofiler_iterate_agent_supported_counters failed for agent 1 (gfx1103)
    :: Agent HW architecture is not supported, no counter metrics found.
  terminate called after throwing an instance of 'std::out_of_range'
    what():  unordered_map::at
  [rocprofv3_error_signal_handler] rocprofv3 caught signal 6

The unsupported-agent line is only a WARNING, so the run continues and then
aborts on the empty counter map, and hangs a further 10s+ in queue sync. The
observable is SIGABRT plus a hang in a program that runs clean unprofiled --
same trap as the missing aqlprofile library, and it reads as the user's kernel
faulting.

Page now says to run --pmc under a timeout, and that trace support on a part
implies nothing about counter support on it. The fence marks every --pmc
SEMANTIC claim (pass splitting, budget, cross-pass ratios) as source-read
rather than run, since they could not be exercised here.
Checked every flag the AMD pages name against rocprofv3 --help on ROCm 7.2.4.
All 7 resolve; no page invents an option. Two other things did not survive.

The counter budget was described backwards. The page said an over-long --pmc
list makes the kernel replay until every counter is collected. That is ncu's
behaviour and rocprof v1's; rocprofv3 says the opposite in its own help --
"job will fail if entire set of counters cannot be collected in single pass".
So the cost is the run, not the wall-clock, and splitting passes is the user's
job rather than the tool's. Page now says so and points at the input file.

rocprof-compute is installed by the distro ROCm packages and still cannot run:
it pins astunparse==1.6.2 against an installed 1.6.3 and misses 11 packages.
Every subcommand including --help prints the dependency errors and exits 0, so
a wrapper checking the return code concludes success and finds no output. The
pin is exact and the installed version is newer, so upgrading cannot fix it --
the page now says to build a venv from the shipped requirements.txt, and to
confirm --help prints usage before assuming the tool is present.

The --pmc repeat-overwrite claim was re-checked against the argparse
definition and holds: nargs="*", no append action, so the second occurrence
replaces the first.
The framework looked for `<base>_pluto_binding.json`. The emitter has only ever written
`<base>_fpNN_pluto_binding.json`, one per precision, so the file was never found and every
kernel declined with "no binding" -- with the binding sitting in the same directory. CI
reported this as a pluto column that ran nothing; locally, where polycc exists, the same
kernels declined silently.

Only the ORDER now comes from that file. Shape, dtype and which arguments are output
pointers come from the manifest-derived binding every other native column allocates
against. That is not tidiness: the pluto binding is emitted per precision and call_args has
no way to say which precision is running, so reading a dtype out of it would be reading
fp64's declaration during an fp32 run half the time. `_ArgView` is gone with it -- it
existed to adapt those per-precision dicts, and defaulted shape to `()` and dtype to None,
which allocates a 0-d float64 scalar and hands the kernel a pointer to 8 bytes for a buffer
it indexes.

`run_polycc` now returns the argv it ran alongside the result. The transformation report
echoed a command rebuilt from a second `shutil.which`, so it could print something that was
never executed. It also deletes its own partial output on failure: polycc writes as it
goes, and a truncated translation unit whose mtime is newer than the scop's is exactly the
"fresh enough, reuse it" condition transformed_sources tests -- the next build compiled half
a kernel and timed it.

`_ensure_built` reuses a cached .so only while it is newer than every source that composes
it. An existence check alone made the artifact unfalsifiable: the name says which framework
built it and nothing about which sources it compiled, so a tree holding a
lib<short>_pluto.so from before this column compiled polycc's output would be returned,
timed, and recorded as a Pluto number while being a clang one.

test_wrap_kernel_matches_numpy drops pluto and gains a replacement at the right layer. That
test calls the built symbol positionally in canonical ABI order; pluto's scop declares its
size symbols FIRST, because a VLA parameter's extents must be declared before use in C:

  C SIG    : void tsvc_2_s212_fp64(double *a, double *b, const double *c, const double *d, int64_t LEN_1D)
  PLUTO SIG: void tsvc_2_s212_fp64(int64_t LEN_1D, double *a, double *b, const double *c, const double *d)

Measured: passing the first order to the second segfaults immediately. CI never saw it
because polycc is absent there. The new test pins that the framework finds the binding and
that polycc's order differs from canonical -- the two halves that were broken.
…ways had

The AMD kernel-trace reader matched `Group_Segment_Size`. rocprofiler-sdk 1.1.0 emits
`LDS_Block_Size`. The symptom was not an absent field, which is what the backlog entry
claimed and what would have been tolerable: `column()` returns "" for an unmatched prefix
and `number("")` is 0.0, so a 16 KB workgroup came back as `shared_memory: 0.0,
shared_memory_unit: "B"`. That is a measurement. It says the LDS budget is free, and an
agent sizes a tile against a budget it has already spent.

Both spellings are matched now, and a trace carrying neither reports null -- which is what
the module's own "Absent is not zero" doctrine promised and the code did not deliver.
Fixtures carry both generations plus a no-LDS trace, so neither direction can regress.

`registers_per_thread` was documented as unavailable on this vendor, in the payload note
shipped with every AMD profile, in two docstrings and in the agent-facing skill page. The
trace carries `VGPR_Count`; it is now the row's register count. `SGPR_Count` stays out: the
scalar file is per wavefront and has no NVIDIA counterpart, so it has no field in a schema
whose whole point is being vendor-independent, and averaging it into one that means
something else would be worse than omitting it.

The shipped skill page contradicted itself on this in three places, saying the trace does
carry VGPR/SGPR in one section and "not in the trace at all" two sections down. One prompt
fragment, both answers. Also corrects the kernel-stats schema there (it was missing `Name`
and `StdDev` and in the wrong order) and restores the LDS unit and the allocation-granule
caveat, which a page that teaches LDS occupancy arithmetic cannot do without.

Adds the aqlprofile trap to the gates table. `rocprofv3` requires `hsa-amd-aqlprofile` and
does not depend on it; missing, the traced run dies with `libhsa-amd-aqlprofile64.so.1`
prefixed with the CHILD's name, so the one refusal that misattributes itself to the
submission had no named cause and no fix.
HSA_OVERRIDE_GFX_VERSION is the standard escape hatch for an unsupported ROCm target, and
the page presented "gfx1103 has no counters" as a hardware fact without ever applying it --
so the headline measured claim could have been an unset environment variable. It is not.
Measured: with HSA_OVERRIDE_GFX_VERSION=11.0.0 exported and the kernel built
--offload-arch=gfx1100, the application runs clean and `--pmc` produces the same
std::out_of_range abort, with the warning still naming gfx1103. The override is a ROCr/HIP
lie about the ISA; rocprofiler reads the real hardware ID when it enumerates counters.

Also measured on that run: `timeout`'s SIGTERM at the deadline is caught by rocprofv3's own
signal handler, logged as "caught signal 15", and the process keeps running. It needs
SIGKILL. The page said to run `--pmc` under a `timeout`; it now says `timeout -k`, because
the bare form does not terminate it.

The rocprof-compute banner asserted "No command below was executed and no number below was
observed" six lines above a paragraph reporting an executed --help and an observed exit
code. On a page whose entire value is letting a reader tell measured from documented, a
banner the next paragraph refutes trains them to discount both. Scoped to what is actually
true: no profile was collected. Its venv remedy also dropped --system-site-packages, which
is the form that works -- rocprof-compute imports the distro's ROCm Python modules, so an
isolated venv satisfies every pinned pip requirement and then fails on those instead.

The copies section deleted the only lead to byte volume while telling the reader to compute
achieved bandwidth. The CSV finding is right and stays; the emitters that may still carry
`bytes` are named again rather than gestured at.

Backlog: the install sample omitted hsa-amd-aqlprofile from the apt line that calls itself
"the whole thing", two bullets above declaring it REQUIRED -- scripted as written it
installs the broken configuration. The clang bitcode path is derived from
--print-resource-dir instead of hardcoding major version 20. The "verbatim" measured header
is one physical line again, since a wrapped copy pasted into a fixture makes csv.DictReader
read lines 2 and 3 as data. Item 11 records what landed and keeps StdDev as the open half.
The setup action exported DACE_compiler_build_mode=native for speed. A DACE_* environment
variable outranks Config.set, so dace_framework.BUILD_CACHE_PINS asked for `cmake` on every
job and got `native` -- which skips CMake, writes per-object .o.cmd files, produces no
compile_commands.json, and therefore makes compiler.command_cache inert while it still
reports True. Exactly the "config that reads enabled and does nothing" failure the pin's
own docstring warns about for ninja.

Two mutually exclusive optimizations, each claiming to be the fast one, and the env var was
winning silently. The pin is the framework's stated design and lives in library code with a
test; the export is a CI-only override whose comment claims the opposite. The export goes.

tests/test_dace_flavors.py::test_the_build_cache_pins_are_applied_and_survive_a_hostile_conf
was the visible symptom (assert 'native' == 'cmake') and now fails if this inverts again.
…does not share

The test demanded `libgomp` in PLUTO_PAR on Linux. PLUTO_PAR was changed to a bare
-fopenmp on purpose: measured, `clang -fopenmp=libgomp` accepts the flag, parses the pragma
and emits no OpenMP call at all, and pluto is the ONE clang column whose sources carry
`#pragma omp parallel for` -- so the runtime pin that is inert everywhere else silently
serialises exactly this column under a parallel label.

The other clang columns keep libgomp and are still asserted. The pluto leg is now pinned to
the spelling that emits OpenMP, in both PLUTO_PAR and the baseline it substitutes into.
…code

`coverage combine ... | tee combine.log` discarded combine's status, since a run: block gets
`bash -e` with no pipefail. A torn or malformed coverage database exited 0, and the only
check that then fired was the file-count one -- which reports a partial merge and sends the
reader after the wrong cause entirely, on the exact defect whose diagnosis this workflow
already spends four paragraphs untangling. set -o pipefail.

test_ci_installs_the_tools_that_fail_silently_when_absent searched the whole action.yml for
"ccache", and the same file carries a comment block explaining why ccache is there. Drop
the package and the test stays green on the comment. It now reads the apt-get install lines
(joining the backslash continuation the package list wraps with).

test_ccache_is_offered_to_cmake saved two launcher variables and pin_build_caching sets
three, leaking CMAKE_CUDA_COMPILER_LAUNCHER into every later test in the worker -- silently
routing a later nvcc through ccache. Test-order dependence, and the pollution direction is
toward passing.
ThrudPrimrose and others added 29 commits August 5, 2026 18:03
Three translator items, and the first of them was mis-diagnosed in the backlog.

A kernel names its own dimensions off a parameter -- `batch, channels, h, w =
x.shape`, folded by tuple-desugar to `batch = batch_size` -- while `init.shapes`
keeps the symbol. String `==` on those tokens then declines a contraction whose
extents match, because `batch` and `batch_size` are the same extent spelled two
ways. `dims_agree` walks a cheap ladder instead: literal equality, then
alias-substituted equality, then sympy (imported lazily, kept off the common
path). It fails CLOSED -- an unresolvable comparison answers False, because a
wrong True contracts over two different extents and a wrong False only declines
a matmul.

The backlog claimed one cause. Instrumenting every decline found two: the second
was `relu_self_attention`'s Call operand, which never reached
`_matmul_result_shape` at all, so no amount of alias resolution would have
helped. `_materialise_call_operands` spills it first, skipping rank-1 so the
scalar dot path is untouched. The corpus ratchet `KNOWN_NON_LOWERING` is now
EMPTY (was 5), and all five kernels verify numerically.

A full float sum/mean now accumulates its innermost axis in blocks of 128 --
numpy's own pairwise cutoff -- rather than one serial chain. Measured through
the op oracle, n = 2**22, emitted float32, gcc -O2 (which does not reassociate
on its own), against numpy's pairwise sum:

    one accumulator   |d| = 1.09e+02   (5.2e-05 relative)
    blocked, 128      |d| = 4.00e+00   (1.9e-06 relative)

Deliberately scoped: the FULL reduction only, and only its innermost axis, which
is the one long dependence chain. Outer axes keep plain loops so an emitted nest
still looks like a nest to the parallelism and isopar recognisers. Integer sums
are untouched -- integer addition is exact and associative, so blocking it is
pure code growth.

The test passes `dtypes=` explicitly. Without it `run_op` emits float64, whose
naive error is ~1e-11, and the test goes green whatever the accumulation does;
the first version of this test had no teeth and looked fine.

Finally, `_assert_ok` accepted a run in which every backend reported
"unsupported", so a case could go green having graded nothing. Skips stay
accepted -- a backend that cannot lower an op should not fail the op's test --
but at least one of c/cpp/fortran must now have reported ok, and the failure
names what each backend actually did.
GitHub throttles anonymous CI egress with 403, not 429, so a container build
that happened to be rate-limited read as "the repository is gone" while the
repository was public and fine.

Two changes. The ref is pinned to a commit rather than tracking a branch, which
needs `git init` + `fetch <sha>` + `checkout FETCH_HEAD`, since `--branch` takes
a branch or a tag and never a SHA. And the fetch retries four times with
doubling backoff, naming the dependency and the `git ls-remote` diagnostic when
it finally gives up, instead of failing on the first 403.

This raises the odds, it does not remove the dependency: only vendoring would.
`HPTT_REF`, `HPTT_REPO` and `HPTT_CLONE_TRIES` override each part.
…ing it

libstdc++ picks its parallel-algorithm backend per translation unit from
`__has_include(<tbb/tbb.h>)`. A runner without the TBB headers keeps compiling,
keeps linking, keeps returning right answers, and quietly runs sequentially --
nothing in the flags, the exit code or the output says so. The repo already
engineered against exactly this for Polly; isopar had no equivalent.

`languages.isopar_capability()` compiles one `std::execution::par_unseq` call at
the flags the harness really builds C++ with and reads `nm` for a TBB runtime
call, returning the same three-way AutoparVerdict every other column uses.
Measured here: ok, runtime_calls=12. It lives in `languages` rather than beside
`flags.polly_capability` because only that module can name the cpp block's
compiler and its `-std=`, and `stdpar_link_flags` -- which must agree with it --
is directly above.

`flags.probe_autopar` grew `runtime_pattern` and `suffix` rather than forking
into a second probe: the evidence is the same (compile, then nm) and only what
counts as a runtime call differs.

Worth stating plainly, because the backlog entry implied otherwise: cpp_isopar
is NOT a scored column today. It has no FRAMEWORK_LANG entry, and its only
consumers are correctness oracles, where a serial backend is slow rather than
wrong. So the exposure today is zero and this keeps it zero -- the probe is
registered in preflight.AUTOPAR_PROBES, so the column cannot be added ungated.

The negative case is real rather than monkeypatched.
`-D_GLIBCXX_USE_TBB_PAR_BACKEND=0` is what a runner without libtbb-dev compiles,
since libstdc++ defines that macro AS the `__has_include`. Same source, same
exit code, right answers -- 12 TBB references down to 0, object 22088 B down to
1256 B. Both halves are asserted in ONE test so it cannot pass by measuring
nothing, and a second test pins that the header question (which decides -ltbb)
and the nm evidence never disagree.

Also: `test_gate_is_a_no_op_for_ungated_frameworks` still listed pluto after it
joined AUTOPAR_GATED, so it asserted the opposite of what the tree does and
passed or failed according to whether the host's clang honours an OpenMP pragma.
…n NaN

The GPU skill pages tell an agent that a float-atomic reduction will not pass
scoring, and a test pins that the pages SAY so. Nothing had ever made it true:
the gpu CI job is disabled for want of a runner, so no float-atomic submission
had ever met `_determinism_check` on this repo.

It needs no device. The gate is host Python over two output dicts, and "two runs
of a float-atomic reduction" is fully characterised by what those dicts hold --
values equal to the last ulp, unequal in their bits. Built with `nextafter`
rather than a re-summation in another order, because numpy's pairwise sum may
reassociate to identical bits and the fixture would then silently test nothing.

The tests are paired so none can pass vacuously: the ulp-apart pair is rejected
under bitwise=True and ACCEPTED under bitwise=False, which is what shows the
rejection is the bitwise leg's work rather than a fixture whose numbers are
simply far apart. A reproducing run is accepted; a run that reproduces a WRONG
answer is still rejected; and the default is pinned off inspect.signature rather
than off a call site's line number.

Pinning it surfaced a real defect. `np.array_equal` reports NaN != NaN, so a
kernel whose output legitimately holds NaN -- a masked cell, a log of zero --
failed the gate as NONDETERMINISTIC even though the second run was a bit-for-bit
copy of the first, with nothing the agent could do about it. Reproducibility and
validity are different questions: whether that NaN belongs there is the oracle
leg's, and `compare_arrays` was already NaN/+-Inf-aware, so the two legs had
disagreed on what NaN means. Now `equal_nan=True`, with a test pinning that this
did not become "NaN matches anything".
ubuntu-latest ships LLVM 18, whose Fortran driver is still spelled `flang-new`,
while the harness is developed and measured against 21 -- flags.POLLY_PAR's
Polly findings and the clang OpenMP-spelling measurements behind flags.PLUTO_PAR
are both 21 numbers. A CI-only major nobody develops against is exactly how a
count comes out 0 on a dev box and nonzero on a runner with nothing in the log
to say the toolchains differed.

CI now installs clang-21, flang-21 and libomp-21-dev from apt.llvm.org, with the
key fetch retried. The unversioned clang/clang++/flang are symlinked into
/usr/local/bin, and that is not cosmetic: `resolve_compiler`'s
highest-`<name>-<major>` fallback does not help here, because
`flags.polly_capability` probes bare `shutil.which("clang")` and compilers.yaml
names the drivers unversioned, so a distro clang left on the box would keep
winning and the LLVM columns would silently stay on 18. toolset.yaml's discovery
list gains clang-22/21 so prefer-latest stays true.

`verify_toolchain` now checks clang++ as well as clang. They ship in one apt
package, but a driver is required here because a TEST requires it:
test_warnings_ratchet skips its ENTIRE -Wall -Wextra count when any one of
gcc/g++/clang/clang++/gfortran is missing, so verifying only clang would let the
C++ half of that ratchet go silently unmeasured -- the exact failure this script
exists to make loud.
Six entries close and one is corrected in place. C claimed a single cause and
had two -- the second never reached the shape comparison at all. E's headline no
longer holds: run 31001878513's integration job passed the ratchet at count 0,
and not vacuously, since the test asserts 20+ real builds. D's entry implied a
scored cpp_isopar column to protect; there is none, so the work is to keep the
exposure at zero rather than to close one. I closes only its determinism half --
the null-workspace protocol is still unexercised, and "the GPU pages are green"
is still not evidence the GPU track works.
…n real hardware

Two tracks, both measured on this box rather than read off a docstring.

The generated *_dace.py are AUTOGENERATED from the numpy reference by
numpyto_c.dace_emit, so a frontend gap is not something a kernel author routes
around -- it lands in the generator's output. Parsing 336 of them (to_sdfg,
never a C++ build) gives 311 clean and 25 refused across 18 classes, and 8 of
those 25 are INTERNAL CRASHES rather than clean refusals: KeyError: npwx,
KeyError: pyobject, a bodyless NotImplementedError, _numpy_empty() missing a
required argument. An unsupported construct should name itself; those are bugs
in the refusal, separate from whether the feature is ever supported.

The sweep STALLED at 336/576 on vexx_k_dace.py -- stuck, not slow. A kernel that
hangs the frontend parse is its own finding, and it means 25 is a floor rather
than a total.

Root-caused the two known gaps rather than describing them. The bitwise NameError
is ONE bare eval with no globals dict (replacements/utils.py:236) silently
borrowing that module's namespace, which contains no symbolic head name at all.
Shifts survive the identical path only because left_shift.eval FOLDS to a literal
when both operands are numbers, while bitwise_and and friends are bodyless and do
not -- so the string stays bitwise_and(1, 3) and the eval raises. The
__-prefixed variants are half-wired: produced by the parser, absent from
symbolic.py:3299's binop map, so codegen emits __left_shift(N, 1) into C++.
Patched to the BARE classes, shifts work end to end including inside a map range.
And nqueens shows the reversed form (1 << N) needs covering too.

For comprehensions there is no desugarer at all -- four sites mention them and
none rewrites anything, one of them commented out. They vanish today only as a
side effect of constant folding, so only a compile-time-evaluable comprehension
survives. The _DISALLOWED_STMTS entry stays: it is the assertion that
preprocessing already handled the node.

Both GPU lanes pass here (RTX 4050, cc 8.9, nvcc 13.1), including
agentbench-gpu.yml, which is workflow_dispatch + self-hosted and had therefore
never executed anywhere: 48 passed for agent_bench, 7 for frameworks. Zero skips,
which is what makes it evidence -- _has_gpu() gates the tvm/triton cases. This
does NOT close the GPU item: the null-workspace protocol is still unexercised,
and the leg that would catch an all-zero result is the oracle, not determinism,
since all-zeros reproduces perfectly.

Also root-caused: cegterg's 17 skips are one missing apt package (libfftw3-dev;
blas/lapack are fine), and the numerical oracle still calls polycc directly at
numerical_oracle.py:1109 instead of through run_polycc, so it inherits the
ambient environment and misses the aarch64 pet shim -- the Pluto merge did not
fix that.

Nothing is applied: the dace tree is shared, currently dirty with another
session's work, and this session files DaCe bugs rather than fixing them.
The merge-landing doc carried ten resolved entries with their full original
text and verification prose; that is git history, not backlog. A-J are now one
line of root cause each, and the two items that did not close -- the
null-workspace protocol and the HPTT clone dependency -- are the only entries
with argument behind them.

BACKLOG_ci_reds_20260804.md is deleted: all four causes closed. Its one
surviving ask (a run summary that says failed vs timed out vs cancelled vs
could-not-fetch) moves to the merge-landing doc, and the durable facts -- a 403
from a runner is throttling, preemption reads as failure -- are carried in the
method notes rather than in a doc titled after four fixed reds.

Ablations doc: levels 1-3 of KernelBench are ported, so item 10 is now the
level4 decision alone; the expression-Tuple emit gap it referenced is fixed;
item 11 keeps its two open pieces and drops the fixed schema narrative.
Backlog notes are a working session's state, not the project's. They read as
project documentation to anyone else, they rot the moment the work lands, and
they carry per-session context nobody can verify from the tree.

Removes the four docs/BACKLOG_*.md and docs/skills_draft/BACKLOG.md; the open
work they held is tracked outside the repo. Real documentation stays: the
DESIGN_* docs, the guides, canonical_numpy_form.md.

Also drops the "BACKLOG item 5" cross-reference from plot_speedup.py's module
docstring -- the rule it states (one reader, never mix two run tags) is the
part that mattered and it stays. .gitignore keeps BACKLOG*.md out.
…egterg

CI has installed libfftw3-dev since the 4-runner split, but nothing checked it.
tests/ports/cegterg builds its C++ reference against <fftw3.h> and skips cleanly
when the probe fails, so a runner provisioned without the package reports 25
passed, 17 skipped -- green, and indistinguishable from a suite that ran.

Checked as a pkg-config module rather than a -lfftw3 link row because the header
is the half that goes missing: libfftw3-double3 arrives as a transitive
dependency of half a desktop, which makes the link resolve while fftw3.h is
absent -- exactly the state of the box this was found on, where a link-only
probe would have reported present.
…machine_learning

hpc -> scientific_computing, foundation -> loop_level_reasoning, ml -> machine_learning,
everywhere: the benchmarks/<track>/ directories, all 631 manifests, the selectors, the HF
config slugs, the Harbor adapter, the sbatch submission scripts and the docs.

Hard rename, no aliases -- an old selector is an error naming the new token rather than a
translation layer nobody would ever delete. The `foundation:` manifest block and
`spec.foundation` follow the track they are named after, so there is no leftover spelling.

Two classes of site a word-boundary substitution CANNOT reach, both found by tests rather
than by grep:

  * a token glued to a word character -- `submit_foundation_alps`, `foundation_tsvc_2_s212`
    (the HF config slug), `foundation_blk`. `\b` treats `_` as a word char.
  * a token inside a glob -- `glob("*.y*ml")` in tests/test_ci_coverage.py became
    `glob("*.y*machine_learning")`, which matched nothing, so the loop body never ran and
    the test PASSED. A vacuous glob is invisible to every gate except one that counts its
    own matches.

Prose is decided by hand: HPC also names the field, ML also names the corpus in translator
comments, and `foundation-model` is an LLM. Only the spellings where the word IS the track
were rewritten; "HPC login node" stayed.
…ch results

The five DO-NOT-SHIP pages and the language pages, each defect checked against the tool
that has to make it true:

  * papi-gpu-amd: the metric layer was REGENERATED. Five derived-metric formulas were
    fabricated (SQ_BUSY_CU_CYCLES as the denominator for VALUBusy / SALUBusy /
    LDSBankConflict); the counter names resolve, so wrong numbers came back silently.
    Deleted -- rocprofv3 computes derived metrics by name. Also: the event prefix is
    rocp_sdk, not rocm; GPUBusy is a percentage, so dividing by it inverts the
    normalisation; percentages are no longer summed across regions; the empty-bracket
    self-test now fails on probe == 0, which is the silent zero the page exists to prevent.
  * rocprofv3: `--pmc` twice does NOT mean two passes -- the launcher joins one survivor and
    discards the rest, silently. Two rows in an input file is the documented form.
  * rocprof-compute: VALUUtilization was inverted, and the imported KILOBYTES rule was itself
    unsourced -- Read BW is Gbps. The real trap is dimensional: FetchSize is a KiB volume,
    Read BW is a rate.
  * ncu: the gate section routed a reader off a WORKING profiler onto cuobjdump, which the
    page itself calls incapable of costing anything. One line pointing at
    /proc/driver/nvidia/params replaces it.
  * lang-c told the reader to use [[nodiscard]] under -std=c17, where the page's own line 119
    says the syntax is unavailable. lang-fortran gated on `flang-new`, renamed to `flang` at
    LLVM 20, so the gate never ran; its -fanalyzer gate is C-only and always scored green.
    lang-hip's ASan gate passed having instrumented nothing when the runtime was absent
    (-print-file-name echoes the name back). lang-python counted a pre-commit run that
    produced no output. opt-reports named the wrong directory for two capture kinds.
    static-analysis analysed at c++17/c11 while the harness builds c++23/c17.

The nsys and profiling pages absorb the draft additions; the draft nsys page stays until its
-judge twin can move with it, since the variant-2 test pairs them inside the draft tree.

An honest "nothing here was executed" fence is not a substitute for a source: a reader cannot
tell a fenced-but-correct claim from a fenced-and-fabricated one. Every retained claim now
carries an upstream quote and a URL; anything unsourceable was deleted.
The CI red on 81174cc was tests/test_papi_gpu.py seeing 'counted run
failed (TIMEOUT)' where the child died of SIGSEGV: a 5 s rep_timeout
lost a scheduling race on a loaded runner, and terminate() relabeled
the crash. Two-level fix: forked.py now classifies a child that
already died of its own signal as that signal even when the deadline
check fired first, and the two forked-child tests get a
SCHEDULING_PATIENCE_S = 30 budget that names what it actually pays
for. test_dace_flavors only exercises config pins THIS DaCe declares.
POST /profile now dispatches on a 'tool' field -- linuxperf, papi, nsys,
rocprofv3, none -- and POST /instrument is gone: it was the same build,
the same data and the same run with a different instrument attached, so
it was a parameter wearing a route's clothes. tool='none' is that
capability (the judge attaches nothing and hands back the agent's own
stdout) and tool='papi' finally reaches count_submission, which counts
without perf and so is the only measurement left where
perf_event_paranoid forbids sampling. A tool the language cannot use is
a 400 that names the one that serves it, checked before anything builds.

/score and /submit were two names for one handler. They now differ by
the thing that matters: /score grades the PUBLIC seed only and is never
recorded, /submit adds the held-out second seed and settles the run. An
agent iterating against /score cannot overfit inputs it cannot see.
/oracle stays as an alias for /submit -- it has the hidden seed on
today, so aliasing it to the public-only route would have quietly
weakened the anti-overfit gate for every page that calls it.

A prebuilt library is now read from the shared mount and only from
there. Its path in the agent's container means nothing in the judge's,
and the judge dlopen()s what that field names -- so an absolute path
outside the mount was an arbitrary object of the agent's choosing. The
mount's installed libraries are listed in /task, and a link that failed
on a library nobody installed now says which name, instead of leaving
it in the linker's output.
The route change lands in the docs the agent actually reads: the
contract doc gains the /score vs /submit split and the /profile tool
table, the tool prompts say iterate with score and finalize with
submit, and the counters page names tool='papi' as what to reach for
when perf_event_paranoid forbids sampling.

Five GPU judge pages lose a capability rather than keep a promise the
service does not make: every host tool is a 400 for a cuda/hip
submission, so the device tracer is the judge's only instrument there
and the instrumented artifact stays the agent's to run. An
'instrumented_ns' field that never existed is gone with them.

Two frontmatter descriptions carried an unquoted colon, which is a YAML
mapping and broke the pages' own gate.
Both land in AUTOGENERATED *_dace.py, so neither is something a kernel
author can route around -- the generator emits them and the kernel
silently has no DaCe column.

A chained comparison hits a bodyless NotImplementedError in
visit_Compare: 48 conv/pool kernels die on 'if 0 <= oy < oh'. Split into
its links, but only when every repeated operand is a Name or a Constant
-- the split evaluates the middle operand twice, which for a call is a
duplicated side effect and for a subscript a second memlet, and a
refusal is recoverable where a miscompile is not.

reshape's -1 means 'infer from the size' to numpy and a negative
dimension to dace: 47 kernels broadcast a bias with reshape(1, -1, 1,
1). Spell the extent out from the operand's shape, and leave the call
alone when it cannot be computed rather than guessing an extent into
the SDFG.
195 of 576 generated programs do not parse, and nothing measured it:
*_dace.py is emitted from the numpy reference, so a construct the
frontend refuses lands in the generator's output and the kernel silently
has no DaCe column. The failure is invisible because the DaCe column
simply is not there to be red.

A ratchet, not a pass/fail: REFUSED carries the known set with each
cause, a new refusal fails the gate, and a kernel that starts parsing
fails it too, so the list can only shrink. Parse only -- no C++ compiler
runs -- so the whole corpus fits a runner with nothing but python.

One subprocess per kernel, because the two interesting failures are the
ones an in-process loop cannot survive to report: cloudsc wedges the
parse, and DaCe's parse state is process-global. Measured 4-wide, 24
kernels came back as crashes that all parse fine alone -- so the sweep
runs at the parallelism its numbers survive.

The runner checks out submodules it does not need yet: the torch
agreement phase belongs in this job and its test is not written.
The confinement landed one layer too deep: Sandbox.build serves the
in-process callers too -- the optimizers and framework runners build a
.so in a temp dir in THIS process and hand back its path, which is not a
claim anyone needs to check. Two of their tests went red, correctly.

The path is only untrusted when it arrived over HTTP, so
_submission_from_body resolves it and the request faults with a 400
instead of the build failing later.
The numpy reference is the correctness oracle for every backend, so a port
that quietly computes a different function grades every submission against
the wrong answer and nothing ever goes red -- the DaCe, C, C++ and Fortran
columns all agree with each other and all of them are wrong. 250 kernels
were ported from KernelBench and nothing compared them to their originals:
collect_reference_sources.py resolves provenance and the collected model is
never imported.

215 of the 250 run against their upstream model at preset S on CPU. The
other 35 cannot be lined up mechanically and are pinned in UNALIGNED with
the cause, a ratchet like the frontend gate: a port that stops agreeing
fails, and a pinned port that becomes comparable fails too, so the list can
only shrink. Nothing is skipped -- a skip and a pass look identical in a
summary.

Three of the pinned causes are defects the comparison found and cannot
grade around: four manifests give GroupNorm 4 channels and 8 or 16 groups,
so the upstream model cannot be built at the size the harness runs;
conv2d_avg_pool_sigmoid_sum's manifest pools by 2 where the model pools by
4; regnet's port and model disagree on num_classes. They are named in the
map rather than fixed here, because each one changes a manifest the corpus
already measured.

A loss returning (1,) against a torch 0-d scalar is the same number in two
spellings, so the comparison reshapes rather than calling it a mismatch;
that alone moved five ports from disagree to agree.
A function's frees are emitted after its body, so a data-dependent early
return jumped straight over all of them. Only helpers can return at all --
the kernel is void and its returns are dropped -- which is why no
kernel-level test ever saw it, and it is the worst shape the bug could
take: the caller is a benchmark loop, so the helper leaks its workspace
once per element per rep. Measured on the audit's own example, 32 reps over
8 elements leaked 256 allocations; a run long enough to measure is a run
long enough to exhaust the box.

Every exit now releases what is live at that point, innermost branch first,
and a scalar return latches its value into a temporary before any free runs
-- the returned expression is usually a read of the buffer being released.
A body that ends in a return no longer emits the closing frees at all,
since nothing can reach them.

Under AddressSanitizer rather than by reading the emitted text: with the
frees stripped back out, LeakSanitizer fails the run and names the 256
allocations, so the gate is known to be able to fail.
The agreement phase peaked at 6.8 GB, which no GitHub runner has. It is not
a leak -- three ports account for all of it, and the largest is 3.1 GB on
its own.

The cause is that a manifest names SCALARS, so it can never bind an
upstream __init__ parameter that is a LIST of layer sizes. Falling back to
upstream's value built shallow_wide_mlp at [32768, 32768] rather than the
manifest's hidden1=24, hidden2=20 -- and the port was going to be reported
unalignable anyway, so the whole allocation bought nothing. A sequence the
manifest cannot name is now that refusal, stated before the model is built
rather than after.

The rest is ordinary care with peaks: the model is only rebuilt when a
submodule scalar actually changes an argument (it was always built twice,
with both alive at once), the torch side is dropped as soon as its outputs
and parameters are numpy, and torch runs single-threaded.

6815 MB -> 1832 MB, same 252 results.
Freeing on every exit is only right where the value survives the free. A
scalar return latches into a temporary first, and an array return copies
into the out-param before anything is released -- both safe. The gap is a
helper that returns a heap local BY VALUE: the previous commit would have
emitted free(t) immediately before returning t.

That shape is already ill-typed C (a pointer returned from a double-typed
function) and only arises when a helper's array return was misclassified as
scalar -- the array path rewrites returns into an out-param and never gets
here. So neither answer is emittable: freeing hands back a dangling
pointer, not freeing leaks. It now names the buffer and refuses, which is
what an unsupported construct owes its caller.

Verified the exits that DO fall through still free: a void kernel frees its
locals at the end, and a helper whose branches all return frees on each of
them.
The reallocating free was emitted only where a SECOND marker made the
reallocation visible in the emitted text. A marker whose one occurrence
sits inside a loop is emitted once and runs once per iteration, so every
iteration but the last overwrote a pointer nothing had freed.

dbcsr is the shape: A is allocated inside the a_pos loop and freed once
after it, B and __mm1 inside the nested b_pos loop. At preset XL that loop
runs 640k times with ~160k buffers live at up to 8 KB each -- order 1.3 GB
lost per call, and the kernel is called once per measurement rep. gmres
does the same with __spv3 once per restart iteration.

Freeing before every deferred allocation covers both shapes and is less
code than tracking whether a realloc was visible: the declaration
NULL-initialises the name and free(NULL) is a no-op, so the first pass
through the loop is safe.
numpy's two indexing rules change rank differently, and a length-1 slice is
where they visibly differ: a[0:N, 0] drops the axis and is (N,), while
a[0:N, 0:1] keeps it and is (N, 1). A kept length-1 axis BROADCASTS --
every position along it reads the same source element.

The scalarizer mapped every RHS slice axis onto the destination's iteration
variable, so a[:, 0:1] came out as a[i][j]: a whole row where one column
belongs. out[:, :] = a[:, 0:1] + b returned wrong numbers in C, C++ and
Fortran, with no diagnostic anywhere -- and it could not be caught by
cross-backend agreement, because all three share this lowering and agreed
with each other. Only the numpy oracle disagreed.

Reading such an axis at its slice start is correct whichever extent the
destination has: where the destination is also length 1, the iteration
variable only ever takes that one value.

Covered against numpy for both rules, since the emitted C was plausible
enough to read as correct. The symbolic k:k+1 form is recognised as well as
the constant one.
n, c, h, w = x.shape is what the helper inliner emits, and it is the
single biggest reason a generated dace program is refused. Each unpacked
name reaches the frontend as an ordinary body local, so it mints a fresh
opaque symbol per use and the buffer sized from them cannot be written
from x: "could not broadcast [batch_size, 3, 224, 224] into
[__sym___inl6_n_0, __sym___inl6_c_in_0, ...]". 137 generated programs
carry that spelling, against 132 IndexError refusals measured.

Split into n = x.shape[0], the passes that already exist resolve every
one: _ShapeToSymbol for a declared array, _inline_transient_shape_scalars
for a transient. No new inlining logic, and the subscript spelling was
always the supported one.

A swap must go through temporaries. a, b = b, a lowered in source order
assigns a = b and then reads the NEW a -- a wrong answer rather than a
refusal, so every source is latched first whenever the right-hand side
reads any name the left-hand side binds.
Ranks of one job compile different SDFGs into the same .dacecache and the
same precompiled-header cache, and neither is written atomically. Racing
there does not merely fail: a rank can load the .so another rank is
halfway through writing, so the run validates WRONG. The reported
symptoms -- library load errors, FileExistsError, crashes, and job
timeouts where one rank waits on a build another rank is rewriting -- are
all that one race.

Suffixing both roots with the rank removes the sharing instead of trying
to lock it: no coordination, no lock file to leak when a rank is killed,
and a crashed rank leaves only its own directory behind. Four launcher
variables are read because Open MPI, MPICH/PMI, Slurm and MVAPICH each
publish the rank under a different name, and missing one silently returns
the whole job to a shared folder.

The header cache is partitioned through DACE_BUILD_CACHE_DIR, the knob
DaCe reads. Its default is already RAM-backed (/dev/shm, falling back to
~/.cache), so this only partitions what is in memory already; the cost is
one ~110 MB header per rank rather than per node, still bounded by the
existing LRU budget. A run with no launcher variable is left on DaCe's
own defaults -- it has nothing to race with, and suffixing anyway would
give every plain run a cold cache.
DaCe has no runtime .shape: an array's extents ARE symbols, so a shape
read has to be resolved before the frontend sees it. _ShapeToSymbol did
that for declared arguments only, and a read on a TRANSIENT survived --
(h.shape[3] + 2 - kw) // 1 + 1. That is not merely unresolved: it makes
the enclosing size expression non-symbolic, and _plan_size_promotion was
all-or-nothing, so ONE such read denied every size scalar in the kernel
its symbol. The whole conv family refused on that.

Three parts, each measured on alexnet/vgg16/resnet18/lenet5/mobilenet_v1/
squeezenet:

ResolveShapeReads rewrites every remaining .shape[k]. It is flow
sensitive, because h is rebound per layer and its extents change with it,
and deliberately conservative: an extent guessed wrong is a miscompile,
not a refusal, so it infers only through an alias, an allocation, a
reshape, a transpose and an elementwise result whose operands agree, and
never through @, whose result shape is neither operand's.

Promotion drops the names it cannot promote, transitively, instead of
abandoning the kernel. The closure follows every name in a candidate's
right-hand side including positions that are not sizes: np.full's dtype
argument dragged an array-valued name in, and that one name cost every
size scalar its symbol.

Compound reshape extents are hoisted to a name, because DaCe names the
container it builds after the shape EXPRESSION and then wants a symbol of
that same name. Collected from reshape only -- an allocation takes a
compound extent happily -- but substituted in EVERY shape, or the
allocation and the reshape name different symbols and DaCe cannot see
they are the same extent.
Co-authored-by: Lorenzo Paleari <lorenzo.paleari@inf.ethz.ch>
@LorenzoPaleari
LorenzoPaleari force-pushed the agent/inference-cluster-example branch from 881851a to 136c6d8 Compare August 6, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants