Skip to content

feat(rocke) : Ask the compiler which intrinsics each GPU can actually run, and commit the answer - #12269

Merged
CongMa13 merged 22 commits into
developfrom
users/congma/ck/rocke-arch-domain-table
Oct 5, 2026
Merged

CongMa13 merged 22 commits into
developfrom
users/congma/ck/rocke-arch-domain-table

Conversation

@CongMa13

@CongMa13 CongMa13 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

feat(rocke): ask the compiler which intrinsics each GPU can actually run, and commit the answer

Branch: users/congma/ck/rocke-arch-domain-table
Base: develop

ISSUE ID : AICK-2272


Summary — For human reviewers

The short version

rocke has two gates on what it emits. Byte-identity proves the two engines emit the
same bytes; the golden proves those bytes do not change. Neither asks the AMDGPU
toolchain whether the IR is legal
, so an illegal construct emitted stably and by
both engines is green in both.

And when rocke emits a declare for an llvm.amdgcn.* intrinsic, it decides what
to declare based on the LLVM version it is building against. It never asks the other
half of the question: can the GPU we are compiling for actually run this? That
question is asked nowhere at build time — the target architecture picks an ISA
backend and never reaches the declaration table at all.

This PR closes both gaps, in that order.

First it adds a gate that pushes every kernel in the representative corpus through
clang to a linked binary, which is the only oracle that catches an intrinsic
that does not exist. That gate immediately found three dead declarations in the
table, two of them with signatures LLVM would have rejected, and they are deleted.

Then it answers the architecture question by measurement: probe the toolchain once
for every (intrinsic, target) pair in the table, and commit the result as a data
file — one per LLVM vintage, three of them, every cell machine-measured. Nothing
reads those files yet. That is deliberate: a table nobody consumes cannot break
anything, and it surfaces the defects that justify the rest of the work.

Why a link, and not a verify

This is the part that looks like it should work and does not. A declare for an
intrinsic that does not exist passes opt -passes=verify, and passes clang -S
too.
The backend assumes the unknown llvm.* name is an ordinary function defined
somewhere else and emits a GOT-relative call to it. Nothing objects until the
linker cannot find the symbol.

flowchart LR
    A[emit IR] --> B[opt -passes=verify]
    B -->|accepts| C[clang -S]
    C -->|accepts| D[link]
    D -->|undefined symbol| E((caught only here))
    style E fill:#fdd
Loading

A check that stops at compile is blind to the entire fictional-intrinsic bug class,
and the declaration table had several. The same fact is why the architecture probes
later in this PR also run through to a link rather than to an object.

Why the architecture table has to be measured rather than written down

The table is roughly 167 intrinsics across 7 wired targets — about 1,200 cells per
vintage. Nobody can fill that in by hand and be right. An earlier attempt did fill
in a small part of it by hand, and when those rows were re-checked on a second LLVM
version, two of five did not survive. That is the argument for generating it, in
miniature.

Generating it once is also not enough. A generated file that nothing checks drifts
away from its generator
, and a stale availability table is worse than no table at
all, because people trust it. So the gate ships with the generator, not after it.
The gate has already earned its place several times over: every defect in the list
below was found because something re-ran the generator and compared.

The parts that are easy to get wrong

Most of the work here is not the probing. It is making the probe tell the truth. The
same error keeps reappearing in different disguises, and it is always the same shape
— the probe failed for a reason that has nothing to do with the target, and the
failure got recorded as a fact about the target.

flowchart TD
    P[probe fails] --> W{whose fault?}
    W -->|target cannot lower it| A[arch_absent — a real answer]
    W -->|our module was malformed| E[probe_error — fix the generator]
    W -->|compiler crashed or hung| N[no data — stay silent]
    W -->|clang cannot target this| U[no data — stay silent]
    A --> D[the only status allowed<br/>to mean 'no']
    style D fill:#dfd
    style E fill:#fdd
Loading

Seven things had to be right:

The oracle has to be a link — for the reason above.

The LLVM-version question has to be asked first. Not for speed — it is the only
stage that can answer a key whose code generation crashes. An unrecognised llvm.*
name taking a metadata operand gets lowered as an ordinary call, and taking the
alignment of a metadata argument segfaults the backend. Asking "does this name
exist" with a text round-trip avoids the crash entirely.

Probes must be compiled at -O0. At -O3 the optimiser can delete the very
call being asked about. A module with nothing left links happily, and the probe
records "available" for an intrinsic the target does not have. This is not
hypothetical: one cross-lane intrinsic is the identity when its input is uniform, so
the optimiser folded it away, the object contained no such instruction, and the link
succeeded. Nine keys are foldable this way.

A hang is not an answer either. One buffer intrinsic never terminates under the
oldest vintage at -O0, on any targetable arch. With no timeout the sweep simply
waited — and the column recorded ok for all six cells, because it had been blessed
before -O0 became mandatory and at -O3 the call was being deleted. Six cells
claimed an intrinsic was available on hardware where the compiler cannot even finish
compiling it. Timeouts now have their own status, and columns now record how the
question was asked, so a column measured the wrong way can be told from one measured
right. That provenance is the check that would have caught it.

A non-answer must never be recorded as a "no". Seven statuses, not two. "This
target cannot lower it" is a real negative. "This clang cannot target this
architecture", "asking killed the compiler", "asking hung the compiler", and "our
probe module was malformed" are absence of data, and collapsing them into the
negative is the main way this becomes worse than having no table.

An operand we chose badly is our fault, not the target's. The probe passed the
literal 0 for every constant-required operand. Where 0 is not a legal value, the
backend reports "Cannot select" — which is exactly what a genuinely unsupported
target reports.
One load-to-LDS intrinsic takes a transfer size in bytes, and 0 is
not one, so every column said "no target can lower this" about an intrinsic that has
a builder entry point, a lowering handler in both engines, and a production instance
whose own comments record the resulting instructions. Three of those seven cells
were wrong, on a live path. There is no diagnostic that says "your operand value was
out of range", so the only way to tell the two apart is to ask again with a
different value — and the winning value is now recorded in the cell, because
otherwise a first-try answer and a fourth-try answer look identical.

Transient failures must not be committed as facts. Regenerating one column gave
a different answer roughly one run in three: under parallel execution the probes
fork enough linkers to occasionally hit the host's process limit, and that abort was
being recorded as a permanent compiler crash. The root cause was sizing the worker
pool from the host's CPU count — which on a scheduler-pinned node reported 384
against an affinity of 2. A real result is stable under retry and a resource failure
is not, so a serial second pass separates them without anyone having to guess; and
the pool is now sized from this process's actual share. The same fix is lifted into
a shared helper, because the byte-identity gate was asking ninja for 384 compile
jobs on the same two cores.

The newest vintage taught us words we did not know

The third column was the one with no machine-checked data behind it, for no better
reason than nobody having a vehicle to run it in. Its first run came back with 27
cells of "the generator is broken" — and both causes were the same thing from
different angles: that LLVM words two diagnostics differently, and the classifier
had only been taught the older wordings.

One is a target saying outright that an intrinsic is not supported on this
subtarget, where the older vintages only imply it by failing to select. Twelve cells
had a clear answer and it was being thrown away.

The other is the operand defect above, caught one stage earlier — in the verifier,
before codegen — and it is the only one of the three that says what would have been
acceptable
. The candidate list the sweep tries now matches the domain that
diagnostic enumerates, rather than the one we had inferred.

The result worth having: after all three columns were regenerated with what the
newest vintage taught us, the two older columns changed in nothing but the
generator stamp.
The fix was in what we could read, not in what we measure.

What this found

Three dead declarations, deleted. None had a call site in either engine, so
nothing could ever emit them — table weight that outlived its caller. Two were also
simply wrong: declared with a return type and argument count LLVM does not accept.
No kernel had hit the mismatch because no lowering handler existed. Deleting them
means whoever wires the op up writes a correct declaration rather than inheriting a
broken one.

One that looked dead and is not, deliberately kept. The working notes said a
packed-f16 atomic declaration was dead too. It is not — both engines need it. LLVM
auto-upgrades the declaration away, which is why it still compiles. Making rocke
emit the modern form directly is an emission change needing a golden re-bless, so it
is recorded as a follow-up rather than folded in.

Two corpus cases that do not compile at all, found by the new link gate, on an
attention path at one target. They land in an allowlist with the defect named.

And from the measurement alone, with nothing consuming the table yet:
intrinsics whose names exist in no shipped LLVM; intrinsics whose names exist but
which no wired target can lower; one case where the compiler segfaults rather than
reporting that a target cannot select an instruction; and one where it hangs
indefinitely. Fixes for these are separate changes — this PR is the measurement,
deliberately kept apart from acting on it.

Honesty about coverage

Two different limits, and they are not the same.

The link gate can only validate the host's own LLVM vintage. Modules lower at
any vintage, but there is no local compiler for the others; those report
UNVALIDATED, never green. With no LLVM tools at all the gate self-skips loudly.

The architecture table can only be measured on the LLVM the machine has. All
three vintages now have a complete, machine-measured column, which was not true when
this branch started — one column lagged the declaration table and reported itself as
incomplete rather than pretending. That machinery stays, because it will be true
again the next time an intrinsic is wired: a key that is in the artifact but gone
from the table is a hard failure anywhere, because anyone can fix it; a key that has
never been measured fails only on a host running that LLVM, and is a named skip
elsewhere that says which toolchain is needed.

No column is ever reported as green on a machine that could not measure it.

One thing the drift gate deliberately forgives: the exact compiler build. Two
independent builds of the same vintage reproducing a column cell for cell is not
staleness — it is better evidence than a byte match, because it shows the column
describes the vintage rather than one machine. Nothing else is forgiven, including
the rest of the toolchain record, because those fields say how the question was
asked rather than who answered it, and that is exactly where a wrong measurement
hides.

How this was tested

Regeneration is byte-identical run to run, and all three columns were regenerated
against compiler builds different from the ones that blessed them. The drift gate
was verified in both directions: green on a clean tree; red on a fabricated cell, a
dropped key, a changed probe method with identical cells, and an unparseable column.
Its structural half runs in a fraction of a second with no toolchain present, which
is why this is a test rather than a nightly job.

For the declaration deletions: the golden IR anchors and the declaration-table
parity check both green, and the link gate green with no new failures. Note that the
commit message for that change records "the byte-identity gate could not run on this
host -- no cmake"; the gate does run, in containers, and has since been green across
this branch and the one stacked on it. The claim in that message is stale, not the
result.

No emitted bytes change. The only emitter-adjacent edit is the removal of three
declarations that nothing could reach.

Reviewing this

16 commits. The first two are the link gate and the dead-declaration deletion it
justified. The next two build the probe generator and the multi-column schema. The
remaining twelve are each a defect in the measurement found by re-running it, which
is the intended reading order — the commit messages are where the evidence for each
lives.

14 files. Three are the data columns and account for ~17,000 of the ~20,100 added
lines; the two engine-side files are deletions only.


Summary — For AI reviewers

Change surface

tools/gen_arch_domain.py                             +1005   the arch probe generator
tools/check_ir_validity.py                            +379   the emitted-IR link gate
tools/_hostcaps.py                                      +65   shared CPU-share helper
tools/check_byte_identity.py                           8 +-   second caller of the above
tests/core/test_arch_domain_artifact.py               +487   the drift gate
tests/run_all.py                                       +16   wires both gates in
TESTING.md                                             +40   documents both gates
.../arch/data/intrinsic_arch_domain.llvm20.json      +5841   committed column
.../arch/data/intrinsic_arch_domain.llvm22.json      +5703   committed column
.../arch/data/intrinsic_arch_domain.llvm23.json      +5650   committed column
python/rocke/core/lower_llvm.py                        -19   three declares removed
cpp/core/lower_llvm/data.cpp                            -7   the same three, C++ side
dsl_docs/development/arch_axis_proposal.md             +464   design record
dsl_docs/development/multi_toolchain_strategy.md       +438   design record

The two engine files change by deletion only, symmetrically, and every deleted
key has zero _need() call sites in either engine — so no reachable path changes
and no emitted byte can move. Nothing reads the arch-domain artifact. Byte-identity
and the golden are untouched by construction; confirm the symmetry, because a
one-sided deletion is exactly what byte-identity exists to catch.

The gaps being closed

check proves blind to
byte-identity the two engines emit the same bytes whether those bytes are legal
golden IR anchors the bytes do not change whether they were ever right
check_ir_validity.py (new) every corpus module compiles and links only the host's vintage; others report UNVALIDATED
the arch-domain artifact (new) which (intrinsic, target) pairs lower, per vintage nothing consumes it yet — that is the next change

Availability factors as:

available(key, arch, flavor) ≈ arch_domain(key, arch) ∧ exists(key, flavor)

rocke resolves declarations on the flavor axis only. The arch axis is asked
nowhere. This PR measures the missing factor.

Note the factorization is not clean, and the code depends on that: arch_domain is
undefined wherever the name is absent, so the product only holds because the
flavor axis is evaluated first and short-circuits. A schema storing one arch set per
key with no flavor dimension would have to invent those cells. Check that no code
path evaluates the arch axis for a key the flavor axis already rejected.

Properties to verify

Each is falsifiable against the code.

  1. Both oracles are a link, not a verify and not a codegen. A declare for a
    nonexistent intrinsic passes both opt -passes=verify and clang -S; the
    backend lowers it to a GOT-relative external call. Confirm neither the corpus
    gate nor the arch probe treats codegen success as an answer.

  2. Every compile runs in a subprocess. A backend failure is a
    report_fatal_error, which terminates the process rather than raising — compiling
    in-process means one bad module kills the run and emits no report. The isolation
    is a correctness requirement, not a performance choice. Confirm a killed child is
    classified as a crash rather than retried into success.

  3. Tool resolution prefers the ROCm install over PATH. A ROCm ships clang and
    opt under its own tree without putting them on PATH, and the tool that is on
    PATH may be a different vintage than the comgr the modules were lowered against.
    Confirm PATH is checked last.

  4. Two stages, flavor first, and the ordering is load-bearing. Stage one is an
    opt -S round-trip: LLVM resolves a recognised llvm.* declare on parse
    (attaching attribute groups, remangling overloads, or letting AutoUpgrade consume
    it entirely), while an unrecognised name round-trips verbatim. It must run first
    because it is the only stage that can answer a key whose codegen crashes — an
    unknown llvm.* with a metadata operand is lowered through
    TargetLowering::LowerCallTo, and taking a metadata argument's alignment faults.
    Confirm no path reaches stage two for a name_absent key.

  5. -O0 is mandatory for the probes. At -O3 the IR pipeline can delete the
    probed call, leaving a module that links with nothing selected — a false ok.
    Nine keys are foldable this way. Confirm the optimisation level cannot be raised
    by configuration or inherited from the environment, and that it is recorded in the
    artifact rather than only applied.

  6. Every probe has a timeout, and a timeout is not a negative. One key does not
    terminate at -O0 on any targetable arch. toolchain_timeout must join
    target_unsupported, toolchain_crash and probe_error as no-data. Confirm the
    serial retry re-runs a timeout on the same budget — the retry exists to remove
    contention, not to grant more time — and that no path lets an eventually-finishing
    probe upgrade a timeout into ok.

  7. Seven statuses, and only one of them is a negative. ok, arch_absent,
    name_absent, target_unsupported, toolchain_crash, toolchain_timeout,
    probe_error. Only arch_absent means "this target cannot lower it". Verify no
    aggregation, serialization, or summary path collapses a no-data status into a
    negative. The asymmetry is the reason: a false arch_absent breaks a working
    build loudly and safely; a false ok silently admits a kernel that cannot run.

  8. probe_error must be absent from every committed column, not merely valid.
    It means our own probe module was malformed — a generator defect, not a
    measurement. Confirm the gate asserts absence rather than membership in the status
    enum. All three committed columns currently hold zero.

  9. Probe operands are real SSA values, never poison. poison is a valid
    constant of every first-class type and therefore looks like the generic choice,
    but it makes the probe lie: a poison i32 sends one buffer intrinsic into a
    legalisation failure on a target where concrete integers lower cleanly — a false
    arch_absent.

  10. The literal-operand rescue is scoped, and its verdict is final. Some operands
    must be immediates even where the declaration carries no immarg; one vintage
    fails to legalise a variable operand while newer ones reject it in the verifier,
    so all those diagnostics trigger the retry, and several literal values are tried
    because a value out of range for one operand kind is in range for another. Two
    constraints to check: the rescue must be scoped to those diagnostics (literals
    could otherwise constant-fold an unsupported intrinsic away and manufacture an
    ok), and once every integer operand is a constant a surviving legalisation
    failure is the target's answer, recorded as arch_absent rather than falling back
    to the original failure. The verifier's immarg complaint is deliberately
    excluded from that final-answer rule — surviving the sweep there means the
    non-immediate operand is not an integer, which is our module's fault.

  11. The immediate-value sweep fires on arch_absent and on probe_error, and
    records its winner.
    An illegal value in a constant-required operand produces
    the same "Cannot select" as an unsupported target, and no diagnostic
    distinguishes them, so the only discriminator is asking again with a different
    value. Confirm: the sweep is built by comparing module text against the base
    probe, so a declare with no integer constant operand produces no variants and
    costs nothing; the winning value lands in the cell so a first-try ok and a
    later-candidate ok are distinguishable; and a cell that started as
    probe_error takes the first real classification the sweep produced rather
    than keeping its original evidence — committing probe_error there would report
    the generator broken for a target that simply cannot lower the call.

  12. Serial retry separates resource failures from real ones. A real result is
    stable under retry; a host process-limit abort is not. Confirm the retry covers
    the crash, timeout and probe_error statuses, and that a crash reproducing
    serially is still recorded as a crash.

  13. Worker count comes from this process's share, not the host's.
    os.cpu_count() reports the machine; on a scheduler-pinned node that read 384
    against an affinity of 2, which is what produced the process-limit aborts in the
    first place. _hostcaps takes the narrowest of affinity, a cgroup v2 then v1 CFS
    quota, and the host count. Confirm a fractional quota share rounds up to one
    whole worker — rounding down is a hang, not a slow build — that the quota path
    takes its root as a parameter so it is testable on a host imposing none, and that
    the extra min(8, ...) cap stays at the probe call site, since it is about
    clang's own process fan-out and does not apply to the build caller.

  14. Toolchain identity is recorded, cross-checked, and partially forgiven. The
    artifact records the clang identity, the resolved flavor, the generator version,
    the probe cflags and the probe timeout; the generator refuses to run when the
    recorded flavor and the compiler disagree. The drift check compares parsed
    documents
    and forgives a difference in the clang build alone — two builds of one
    vintage agreeing cell for cell is evidence, not staleness. Confirm nothing else
    in the toolchain record is forgiven: those fields say how the question was asked,
    and a generator or cflags or timeout change means the committed answers came from
    a method no longer in use, which demands a re-bless even when every cell agrees.

  15. Crash evidence is host-independent. (core dumped) is a property of the
    host's core-dump settings, not of the fault, so it is normalised away before
    being committed. Without this the drift gate reports drift between two machines
    observing the identical crash. Relatedly, probes run from the scratch directory,
    because probing is expected to crash clang and a core file must not land in the
    worktree.

  16. The gates split by who can act on the failure. For the artifact: a stale key
    — in the artifact, gone from the declaration table — needs no toolchain to fix
    and is a hard failure on every column from any host; a key that has never been
    measured fails only for the host's own vintage and is a named skip elsewhere,
    naming both the keys and the toolchain required. For the link gate: only the
    host's vintage can go green, the rest report UNVALIDATED. Verify no aggregate
    exit path turns a skip or an UNVALIDATED column into a pass, and equally that one
    newly wired intrinsic cannot red the build on every machine until all toolchains
    have been run.

  17. KNOWN_BAD only shrinks. Same convention and same rule as KNOWN_VIOLATIONS
    in test_library_layering.py. An entry that starts compiling is itself reported
    as a failure, so a fix cannot leave dead weight behind. It lands with two
    entries.

  18. The three deleted declarations are unreachable, and the fourth was kept for a
    reason.
    Confirm each deleted key has zero _need() call sites in both engines,
    and that the packed-f16 atomic was not deleted — both engines need it, and LLVM
    auto-upgrading the declaration away is why it still compiles. Its replacement is
    an emission change requiring a golden re-bless, correctly deferred. The
    now-handler-less op names stay registered because they live in index-parallel
    arrays the serializer keys off; removing them is not a no-op.

  19. Regeneration is byte-identical, which is what makes the check a usable gate
    rather than a source of noise. Confirm nothing non-deterministic reaches the
    serialized output — ordering, timestamps, paths, or parallelism-dependent
    iteration.

Repo rules that apply

  • Byte-identity. The only engine-side edit is a symmetric deletion in both
    engines of keys with no call sites; confirm the symmetry and that no reachable
    path changed.
  • No file under platform/ may hardcode an absolute path or escape the tree; anchor
    on __file__ or rocke.assets. tests/run_all.py enforces this and both new
    tools must satisfy it.
  • Cross-platform Python only: tempfile, os.cpu_count(), pathlib,
    shutil.which. No /tmp, nproc, or sudo. The generator spawns parallel
    subprocesses and writes intermediate modules — check temp-dir handling and the
    worker-count derivation.
  • torch is optional; numpy is the only hard dependency.
  • Emitter code must never be run through ruff check --fix. lower_llvm.py is
    emitter code and its edit here is a hand-written deletion.
  • Source change implies test change: _hostcaps carries the tests it was lifted
    with, now covering both callers; the artifact gate is new with the generator; the
    link gate is itself a test wired into run_all.py.

Known-weak points, stated so review can focus

  • The leading-underscore module under tools/. _hostcaps.py is the only
    non-CLI module there; the naming is the only thing marking that distinction.
  • One target is unprobeable on the oldest vintage by construction, recorded as
    target_unsupported rather than as a negative.
  • The two design documents were untracked until this branch. An uncommitted
    document cannot be the record, which is most of why stale claims in them went
    unnoticed. They now keep the earlier hand-run probe table, but labelled as the
    argument that motivated the generator rather than as data. Check that labelling
    survived, and that no numeric claim in either document lacks a generated artifact
    behind it.
  • A stale claim in one commit message. The deletion commit says byte-identity
    could not run on this host for want of cmake. It does run, in containers, and is
    green. The message is wrong; the result is not.
  • The drift gate's regeneration half needs a toolchain; its structural half does
    not.
    Only the former can catch a wrong measurement rather than a missing one,
    so on hosts without LLVM the effective coverage is materially weaker than a green
    result suggests.
  • The classifier is a list of diagnostic wordings. Three vintages have each
    contributed some; a fourth will contribute more, and the failure mode is
    probe_error cells rather than wrong answers — loud, but it means a new toolchain
    cannot be measured without a code change.

Scope calibration

A finding of the form "the emitter still does not consult this table" is out of
scope and expected — consumption is deliberately a later change, so that the table
can be validated against real kernels before it can block anyone. A finding that the
intrinsics shown to be missing or unlowerable are not fixed here is likewise out
of scope: this PR is the measurement, and acting on it is separate work. A finding
that the two allowlisted corpus cases still fail is out of scope — they are named,
owned, and the allowlist is the mechanism that keeps them visible.

What review cannot establish from the diff

Whether the measurements are correct depends on the toolchain that produced them.
A static review can confirm the oracles are links, the ordering short-circuits, the
optimisation level is pinned, the statuses do not collapse, and the gates fail in
both directions. It cannot confirm that any particular cell reflects what the GPU
can really run.


Test plan

  • Three consecutive regenerations byte-identical, and identical to the committed
    columns
  • All three columns regenerated against a compiler build different from the one
    that blessed them, agreeing cell for cell
  • Drift gate green on a clean tree
  • Drift gate red on: a fabricated cell, a dropped key, a changed probe method
    with identical cells, an unparseable column
  • Drift gate not red on a differing compiler build alone
  • python -m pytest tests/core/test_arch_domain_artifact.py -q
  • Emitted-IR link gate green, with the two allowlisted cases and no new failures
  • Golden IR anchors green and declaration-table parity green after the three
    deletions
  • byte-identity green in containers (the deletion commit's message claiming
    otherwise is stale)
  • pre-commit run --from-ref origin/develop --to-ref HEAD from the repo root —
    not yet run; per-commit hooks passed on each commit
  • Link gate green at the other two vintages — cannot run on one host; they
    report UNVALIDATED by design, never green

@therock-pr-bot

therock-pr-bot Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
📝 PR Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ✅ Pass —
🔎 pre-commit ✅ Pass —
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —
🤖 therock-pr-bot ✅ Pass —

🎉 All checks passed! This PR is ready for review.

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

@therock-pr-bot

therock-pr-bot Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🎉 All checks passed! This PR is ready for review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds toolchain-probed intrinsic availability artifacts and gates for LLVM/GPU compatibility, while removing invalid declarations from both Python and C++ tables.

Changes:

  • Adds arch-domain generation, validation, and committed LLVM columns.
  • Adds emitted-IR compile/link validity checking.
  • Documents the multi-toolchain and architecture-axis strategy.
File summaries
File Description
dnn-providers/hip-kernel-provider/rocke/TESTING.md Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/tools/check_ir_validity.py Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/tests/run_all.py Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/tests/core/test_arch_domain_artifact.py Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/lower_llvm.py Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/dsl_docs/development/multi_toolchain_strategy.md Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/dsl_docs/development/arch_axis_proposal.md Updated as part of this pull request.
dnn-providers/hip-kernel-provider/rocke/platform/cpp/core/lower_llvm/data.cpp Updated as part of this pull request.
Review details

Suppressed comments (6)

dnn-providers/hip-kernel-provider/rocke/platform/dsl_docs/development/arch_axis_proposal.md:380

  • This status table says KNOWN_BAD remains empty, but the new validity gate defines two committed KNOWN_BAD entries in platform/tools/check_ir_validity.py:81-90. The document therefore misstates the current state of the gating policy; either update this row to acknowledge the allowlist or change the gate and its acceptance criteria consistently.
| `KNOWN_BAD` | stays empty; arch-unavailable keys become build errors, not allowlist entries |

dnn-providers/hip-kernel-provider/rocke/platform/dsl_docs/development/arch_axis_proposal.md:305

  • The stated sweep size is inconsistent with the committed data: the llvm22 artifact has 1,169 cells over seven wired arches, which is 167 keys, not 148. Please update this design record or remove the fixed count so it cannot drift again.
Cost, measured: 148 keys × 7 arches on `llvm22`, ~15 s wall across cores. Cheap
enough to run in CI, not just on demand — which is why the gate is a test rather
than a nightly.

dnn-providers/hip-kernel-provider/rocke/platform/tests/run_all.py:192

  • run_all.py --only is documented as a byte-identity family filter, but this forwards the same substring to check_ir_validity.py, which matches case IDs instead. A valid filter such as --only mfma can match the identity gate while matching no corpus cases, causing the new gate's FATAL: selection matched no cases exit and making the documented partial runner fail unexpectedly. Use a separate filter contract or do not forward this option to the IR gate.
        if args.only:
            ir_gate += ["--only", args.only]

dnn-providers/hip-kernel-provider/rocke/platform/tools/check_ir_validity.py:278

  • The tool lookup deliberately falls back to PATH, but this gate never checks the selected clang's identity against the native flavor used by current_flavor()/comgr. With ROCKE_LLVM_BIN or PATH pointing at another LLVM, the gate can compile with a different intrinsic set and report a result for the wrong toolchain; the generator already rejects this mismatch, so apply the same provenance check here or fail closed.
    clang = _llvm_tool("clang")
    opt = None if args.no_verify else _llvm_tool("opt")

dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py:634

  • If every literal retry returns the verifier's immarg operand has non-immediate parameter diagnostic, settled remains None and this falls back to the original arch_absent result. That records a probe/module defect as a target negative, contrary to the comment above and to the six-status contract; preserve the last retry result (typically probe_error) when no settled result exists.
            if settled is None and last and last[0] == STATUS_ARCH_ABSENT:
                settled = last
            status, evidence = settled or (status, evidence)

dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py:411

  • The generator documentation says there are 149 keys, but the committed llvm22 column has 1,169 (key, arch) cells across seven wired arches, i.e. 167 keys. This is exactly the kind of fixed-count drift the PR says the artifact should prevent; update the statement or describe the count dynamically.
    reported ok for a target where the instruction does not exist. Nine of the
    149 keys are foldable this way. -O0 keeps the call alive to ISel, which is
  • Files reviewed: 9/11 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@CongMa13
CongMa13 marked this pull request as ready for review September 17, 2026 20:24
@CongMa13
CongMa13 requested review from a team as code owners September 17, 2026 20:24
@tenpercent

Copy link
Copy Markdown
Contributor

Review of head 8c3a6544a45ba7696aceae40d921eafc42c1d89f: REQUEST CHANGES — three confirmed findings.

  1. [P1 / high] The new test breaks installed pytest collection.
    test_arch_domain_artifact.py:49–58 imports gen_arch_domain from an adjacent tools/ directory. CMake installs this test but does not install the generator. Reproducing the installed layout produces ModuleNotFoundError: No module named 'gen_arch_domain' during collection, interrupting the pytest suite. The _DATA path also assumes the source-only python/rocke layout. Install the required tools and resolve data through the installed package, or explicitly exclude this source-only test from installed execution.

  2. [P2 / medium] The default validity gate fails on targets the host compiler cannot support.
    check_ir_validity.py:314–327 classifies every compile rejection as an emission failure. With ROCm 7.1's LLVM 20, --only gemm --arch gfx1250 reports two new failures for invalid target ID 'gfx1250' and exits 1. These cases are also selected by the newly enabled default run_all.py gate. Treat unsupported targets as explicitly unvalidated, as the arch-domain generator already does, while continuing to validate supported targets.

  3. [P2 / medium] Missing opt silently bypasses the required name check.
    gen_arch_domain.py:706–708 substitutes (True, "") when opt is unavailable. With tool discovery adjusted to simulate that environment and the real LLVM 20 clang, av.load.b128.p0 is recorded as toolchain_crash, and the generator writes the artifact with exit status 0. With opt present, the same declaration correctly resolves to name_absent. Require a successful name-check stage before generating an authoritative artifact; otherwise report the run as unvalidated.

Validation: 9 structural/CPU tests passed, including 2,352 subtests, and 11 AMDGPU lowering tests passed. A focused LLVM 20 compile/link probe also passed. Reviewed the GitHub diff and isolated exact-head sources. Full regeneration, C++ byte-identity builds, and GPU numerical tests were not run.

Assisted-by: codex / gpt-6-astra / medium

@CongMa13
CongMa13 added this pull request to stack #12442 September 22, 2026 20:24

@cgmillette cgmillette left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #12269 — AICK-2272 · head b2966e72

Verdict: ⚠️ REQUEST CHANGES — engine change is deletion-only (no codegen risk), but the drift gate can't execute in provider CI (N1).

# Location Finding Sev
N1 CMakeLists.txt + test_arch_domain_artifact.py test staged in rocke_pytest but import gen_arch_domain (tools/ not installed) + checkout-relative _DATA → errors/0 columns in provider artifact MED
N2 check_ir_validity._compile omits -nogpulib the probe generator deems required (foreign-ROCm device bitcode) LOW

Legend — Sev: MED/LOW. Net-new only (existing reviewer comments excluded).

CongMa13 and others added 10 commits September 29, 2026 15:58
…s case)

Byte-identity proves the two engines emit the same bytes; the golden proves
those bytes do not change. Neither asks the AMDGPU toolchain whether the IR is
legal, so an illegal construct emitted stably and by both engines is green in
both. This gate asks that question: lower every case in the representative
corpus and push each module through clang to a linked hsaco.

Three design points, all load-bearing:

- The oracle is a LINK, not a verify and not a codegen. A declare for an
  intrinsic that does not exist passes `opt -passes=verify` AND passes
  `clang -S` -- the backend silently treats the unknown llvm.* name as an
  ordinary external function and emits a GOT-relative call to it. The undefined
  symbol surfaces only at link. Stopping earlier is blind to the whole
  fictional-intrinsic bug class (the decl table has several).

- Every compile runs in a subprocess. A backend failure is a report_fatal_error,
  not an exception: compiling in-process via comgr means one bad module kills
  the run and emits no report. The isolation is a correctness requirement, not a
  performance choice, and it makes the per-module diagnostic free.

- Only the host's own LLVM flavor can be validated. Modules lower at any flavor
  but there is no local compiler for the others; those report UNVALIDATED, never
  green. With no LLVM tools at all the gate self-skips loudly (--strict makes
  that a failure). Tools are located via the ROCm install rocke already resolves,
  with PATH checked last -- a ROCm ships clang/opt under <root>/llvm/bin without
  putting them on PATH, and the tool that is on PATH may be a different vintage
  than the comgr we lower against.

Reuses rocke_ir_parity_harness.cases() as the corpus (128 cases, 14 families,
7 arches), so there is no second corpus to maintain and it grows with the
golden. 27 s for the full sweep.

Known failures live in a KNOWN_BAD allowlist, same convention and same rule as
KNOWN_VIOLATIONS in test_library_layering.py: it only shrinks. An entry that
starts compiling is itself reported as a failure, so a fix cannot leave dead
weight behind. It lands with two entries -- attention/gfx942/3d_{bf16,fp16}
_d128_b64 hit an LLVM 23 backend fatal error ("Do not know how to expand this
operator's operand"), found by this sweep. The other 126 cases compile and link.

Wired into run_all.py after the byte-identity gate (--no-ir-validity opts out)
and documented as TESTING.md section 4.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit af980fb)
All three have zero _need() call sites in either engine, so nothing can ever
emit them -- they are table weight that outlived its caller, and two of the
three are also wrong.

  global.atomic.fadd.v2bf16          no such intrinsic in shipping ROCm; the
                                     pk-bf16 atomic was already rewritten to a
                                     generic atomicrmw fadd <2 x bfloat> with
                                     the fine/remote-memory metadata, and its
                                     docstring says so. The entry is what that
                                     fix left behind.
  amdgcn.cvt.scalef32.pk.fp8.f32     declared i32 f(i32, <2 x float>, float,
  amdgcn.cvt.scalef32.pk.bf8.f32     i1); LLVM wants a <2 x i16> return and 5
                                     arguments. No lowering handler exists in
                                     either engine, so no kernel ever hit the
                                     mismatch. Deleting them means whoever
                                     wires the op up writes a correct declare
                                     rather than inheriting a broken one.

Deliberately NOT deleted, against the working notes:

  global.atomic.fadd.v2f16           the notes claimed this was dead too. It is
                                     not -- _op_memref_global_atomic_add_pk_f16
                                     needs it, as does lower_llvm/mem.cpp. LLVM
                                     auto-upgrades the declare away, which is
                                     why it still compiles; making rocke emit
                                     the atomicrmw directly is an emission
                                     change needing a golden re-bless, so it is
                                     recorded as a follow-up, not folded in.

  arith.cvt_scalef32_pk_{fp8,bf8}_f32  the now-handler-less op names stay
                                     registered; they live in index-parallel
                                     arrays the serializer keys off.

Zero emitted-byte change by construction, and confirmed: golden IR anchors green
(839 passed, 154 skipped, 1711 subtests), emitted-IR validity gate GREEN at
126 OK / 2 KNOWN_BAD / 0 new failures, decl-table parity green at 169 subtests
(172 minus the three deleted). The byte-identity gate could not run on this host
-- no cmake -- but the parity check covers exactly the table edited here, and
nothing reachable changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bcd2cf6)
…toolchain

rocke resolves an intrinsic `declare` on one axis only -- the LLVM flavor. The
target arch picks an ISA backend and never reaches the decl table, so "is this
intrinsic available on this GPU" is asked nowhere at build time. S1 of
dsl_docs/development/arch_axis_proposal.md measures that missing axis instead of
hand-maintaining it, and lands the result as data. Nothing consumes the artifact
yet; that is deliberate -- it cannot break anything, and it surfaces the defects
that justify the rest.

Two stages, because the two axes have different oracles:

  * flavor, arch-free, once per key: `opt -S` resolves a recognised `llvm.*`
    declare on parse (attaching attributes, remangling overloads) and leaves an
    unrecognised name verbatim as an ordinary external function.
  * arch, once per (key, arch) that survived: compile AND LINK a probe module.
    A link, because a declare for a nonexistent intrinsic is accepted by both
    `opt -passes=verify` and `clang -S`; a subprocess, because an unsupported
    intrinsic reaches report_fatal_error.

Answering the flavor axis first is not just a speedup. It is the only stage that
can answer a key whose codegen crashes: an unknown `llvm.*` name with a metadata
operand is lowered as an ordinary call, and taking the alignment of a metadata
argument segfaults the backend.

Probe synthesis passes real SSA values, never poison. Poison is a valid constant
of every first-class type and so looks like the generic choice, but it makes the
probe lie -- a poison i32 operand sends raw.ptr.buffer.load.lds into a
legalisation failure on gfx942, where concrete integers lower cleanly. That one
diagnostic also gets a literal-operand re-probe, since some operands must be
immediates without the declare saying `immarg`; scoped to that diagnostic
because literals could otherwise constant-fold an unsupported intrinsic away and
manufacture an `ok`.

Non-answers are never recorded as negatives. `target_unsupported` (this clang
cannot target this arch), `toolchain_crash` (asking killed the compiler) and
`probe_error` (our module was malformed) are distinct from `arch_absent`.
A result is only meaningful against the toolchain that produced it, so the
artifact records the clang identity and the resolved flavor, and the generator
refuses to run when those two disagree.

Measured on ROCm 7.1 / LLVM 20 (flavor llvm20), 148 keys x 7 wired arches:
ok=487 arch_absent=206 name_absent=224 target_unsupported=116 toolchain_crash=3,
with zero unclassified cells. Regeneration is byte-identical, so `--check` is a
no-op gate. gfx1250 is unprobeable on this vintage by construction; every other
flavor is unvalidated here for the same reason -- one host can only ever
validate its own column.

Findings already visible in the data, all pending separate fixes: 32 declares
name-absent in llvm20 (including ds.read.tr16.b128, which no arch and no flavor
resolves), 15 whose name exists but which no wired arch can lower, and
amdgcn.permlane64, a GFX11+ instruction whose declare segfaults instruction
selection on every wave64 target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 9b88bad)
Re-validating the generator on llvm22 found three real defects.

One artifact per flavor. A host can only ever measure its own LLVM, so a
single shared file meant whichever flavor ran last silently overwrote the
others and --check failed on every machine whose toolchain differed from
the one that blessed the file -- which is exactly what happened when this
host moved from ROCm 7.1 to 7.2. The file is now named after the flavor,
and --check on a flavor with no committed column reports UNVALIDATED and
exits 0 rather than failing.

Widen the literal-operand rescue. llvm20 only ever complained that it
could not legalise a variable operand; llvm22's verifier rejects it up
front with "immarg operand has non-immediate parameter". Both diagnostics
now trigger the rescue, and it tries several literal values because a
value out of range for one operand kind is in range for another.

Take the literal sweep's answer as final. The rescue used to fall back to
the original variable-operand failure when no literal variant produced a
clean answer, which left four raw.ptr.buffer.load.lds cells unclassified.
With no variable integers left there is nothing our probe can still fix,
so a surviving legalisation failure is the target speaking: those cells
are arch_absent, matching the llvm20 column where the same key is CDNA-only.
The verifier's immarg complaint is deliberately excluded from that rule --
surviving the sweep means the non-immediate operand is not an integer,
which really is our module's fault.

llvm22 column: ok=627 arch_absent=275 name_absent=126 toolchain_crash=8,
zero unclassified, regeneration byte-identical. The eight crashes are
LLVM 22 segfaulting on cvt.scalef32.pk.f32.{bf8,fp8} for RDNA targets;
keeping them a distinct status rather than folding them into arch_absent
is what let permlane64 -- which crashed llvm20 everywhere and is ok on
llvm22 -- avoid being recorded as a wrong arch answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 9d2d067)
…g them

Regenerating the llvm20 column on ROCm 7.1 produced a different table on
every run: llvm.fabs.f32 on gfx11-generic came back "Aborted" roughly one
run in three and was recorded as a permanent toolchain_crash. It is not --
under -j the probes fork enough linkers to occasionally hit the process
limit, and the abort is the host running out of resources, not the
compiler failing on the intrinsic.

The serial second pass already existed for exactly this reason and already
carried the right principle in its comment -- a real result is stable under
retry, a resource failure is not -- it just only applied it to probe_error.
Extend it to toolchain_crash. A genuine crash reproduces serially
(permlane64 on the wave64 targets still does, all three of them), so the
retry separates the two without having to guess which is which.

Three consecutive runs are now byte-identical, and identical to the
committed llvm20 column: ok=487 arch_absent=206 name_absent=224
target_unsupported=116 toolchain_crash=3. That closes the open question
from the previous commit -- the llvm20 column was blessed by the older
generator, and regenerating it with the current one changes nothing, so
the widened literal rescue and the final-answer rule affect llvm22 only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 95d0d01)
The probe compiled at -O3, which let the IR pipeline delete the very call
it was asking about. A module with nothing left to select links happily,
so the probe reported ok for an intrinsic the target does not have.

llvm.amdgcn.permlane64 on gfx942 is the case that found it. A kernel
argument is wave-uniform, permlane64 of a uniform value is the identity,
and LLVM 22's InstCombine folds it away; the object held no permlane64 at
all and the link succeeded. The instruction is GFX11-only and LLVM in fact
crashes on it for GFX9 -- llc, which runs no IR pipeline, segfaults in
SIInstrInfo::getInstSizeInBytes. LLVM 20 lacked the fold, so the same cell
was correctly recorded as toolchain_crash there; the llvm22 column
disagreeing with the llvm20 column is what prompted the check.

Nine of the 149 keys are foldable this way -- the cross-lane and uniform
ones (permlane64, ds.bpermute, readfirstlane, readlane) plus a few others.
Only three cells actually changed, all permlane64 on the wave64 CDNA
targets, ok -> toolchain_crash, which now matches llvm20 exactly. The
other six kept their verdicts and two of them report a clearer diagnostic
at -O0 ("Cannot select" instead of a legalisation failure).

A false ok is the dangerous direction here: it silently admits a kernel
that links but cannot run, where a false arch_absent merely breaks a
working build loudly.

llvm22: ok=624 arch_absent=275 name_absent=126 toolchain_crash=11,
regeneration byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1818746)
The arch-domain columns are generated but nothing checked them, and a
generated artifact that is not gated drifts from its generator. A stale
availability table is worse than none, because it is trusted.

Two halves, because they fail for different reasons and are available in
different places. The structural half needs no LLVM, no GPU and no built
C++ engine: it checks each committed column against the decl table it
claims to describe. That is the realistic drift -- add or rename an
intrinsic key and the artifact does not grow a row, it just quietly has
no opinion on the one key nobody has measured, while still parsing and
still looking authoritative. The regeneration half re-probes the
toolchain and requires this host's column back byte-identical; it is the
only check that can catch a wrong measurement rather than a missing one.
A full sweep is 148 keys x 7 targets in ~15 s, so it is a test rather
than a nightly.

A host can only ever measure its own LLVM, so a flavor with no matching
toolchain skips and never fails -- "we did not get an answer" must not be
recorded as "the answer is no", which is the same rule the artifact
itself follows in separating arch_absent from target_unsupported and
toolchain_crash.

probe_error is asserted absent rather than merely valid: it is the one
status that means our own probe module was malformed, so it is a defect
in the generator and must never survive into a committed column.

Verified both directions: green on a clean tree, red on a fabricated
cell (regeneration half) and red on a dropped key (structural half, in
0.19 s with no toolchain).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d6144e8)
…o stories

The two arch-axis documents presented a 15-cell hand-run probe table as
data and described a schema that is not what shipped. Both were written
on an llvm23 host, and llvm23 is the one flavor with no generated column
behind it -- so nothing in them could be reproduced end to end.

Two of the five hand-probed rows did not survive a second flavor. On
llvm20 the ds.load.tr16.b128.* rows are name_absent rather than "Cannot
select": the name does not exist in LLVM 20 at all, and ds.read.tr16.b64
on gfx1250 is target_unsupported because LLVM 20 cannot target it. That
matters for the factorization argument those rows were quoted to support
-- arch_domain is not independent of flavor, it is undefined wherever the
name is absent, and the factorization holds only because the flavor axis
is evaluated first and short-circuits. A table storing one arch set per
key with no flavor column would have had to invent those cells.

Corrected in both documents, with the hand table kept and labelled as the
argument that motivated the generator rather than as the data. Also
recorded the three ways the shipped schema departs from the proposed one
(one file per flavor instead of an unvalidated marker in a shared file;
no wildcard row; evidence on every non-ok cell), the two no-data statuses
the three-outcome taxonomy had no bucket for, the -O0 requirement without
which the optimiser deletes the call and the probe reports ok for a
target that cannot run it, and the serial retry that keeps a resource
failure from being committed as a compiler crash.

The new epic document is the delivery breakdown: five stories, motivation
first, each ending with something that was not true before.

These four documents were untracked. An uncommitted document cannot be
the record, which is most of why the stale claims went unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 50386f9)
…asure

Rebasing the table onto current develop surfaced a design flaw in the gate
added alongside it. Requiring every committed column to cover every decl key
means one newly wired intrinsic reds the build on every machine until someone
has run all three toolchains -- recording "we did not get an answer" as "the
answer is no", which is the one thing the six-status design exists to prevent.

Split the check by who can act on it. A stale key -- present in the artifact,
gone from the decl table -- needs no toolchain to fix, so it stays a hard
failure on every column from any host. An unmeasured key can only be answered
on a host running that flavor's LLVM, so it fails for this host's flavor and is
a named skip elsewhere, naming the keys and the toolchain needed.

Re-measure the llvm22 column against develop's decl table, which has grown
19 keys since the branch point: 167 keys, arch_absent=332 name_absent=147
ok=649 toolchain_crash=41. The llvm20 column is now those 19 keys short and
reports as a skip until a ROCm 7.1 host runs the generator.
The decl table has grown from 148 keys to 167 since the llvm20 column was
blessed, so the errand outstanding on Story 1 is no longer just the nine
`-O0`-sensitive keys -- it is one generator run on a ROCm 7.1 host that fixes
both. State the key count as a moving number rather than a constant, since
quoting it as fixed is what let the gap go unnoticed.
…s `ok`

`llvm.amdgcn.raw.ptr.buffer.load.async.lds` does not terminate under the
llvm20 clang at `-O0` on any of the six targetable arches. The sweep had no
timeout, so the probe was simply waited on; the committed llvm20 column
nonetheless recorded `ok` for all six, because that column predates `-O0`
becoming mandatory and at `-O3` the IR pipeline deletes the call being
probed. Six cells therefore claimed an intrinsic is available on hardware
where the toolchain cannot even finish compiling it -- a false `ok`, the
error direction that silently admits a kernel that cannot run.

Three changes, one per link in that chain:

* A per-probe timeout with its own status. `toolchain_timeout` joins
  `target_unsupported` and `toolchain_crash` as "no data" -- never
  `arch_absent`, which is a claim the target cannot lower the intrinsic.
  A timeout retries on the same budget during the serial pass: the retry
  exists to remove contention, not to grant more time.

* Provenance in every column. `clang` says which compiler answered, not
  which question it was asked, and those differ -- the same build reports
  `ok` or hangs for the same key depending on the optimisation level. A
  column now carries `generator`, `probe_cflags` and `probe_timeout_s`, and
  a test asserts them, so a column measured the wrong way can be told from
  one measured right. That is the check that would have caught this.

* Host-independent crash evidence. ` (core dumped)` is a property of the
  host's core-dump settings, not of the crash, so committed evidence
  differed between machines observing the identical fault and the drift
  gate reported drift where there was none. Normalised away.

Both columns regenerated. llvm20: the six cells above are the only status
change across the 148 pre-existing keys. llvm22: zero cell changes, across
both the generator change and a toolchain bump, so it needed only the new
provenance fields.

Two fixes to how the sweep runs, both found while regenerating:

* Size the worker pool by this process's share, not the host's. The cap was
  `min(8, os.cpu_count())`, and `os.cpu_count()` reports the machine -- on a
  scheduler-pinned host that read 384 against an affinity of 2, so the sweep
  ran 8 workers, each spawning clang and ld.lld, against two cores. That is
  the load the serial-retry pass exists to undo. Now the minimum of affinity,
  a cgroup CPU quota (v2 then v1) and `os.cpu_count()`. The quota path takes
  its root as a parameter because no host here imposes a quota, and an
  untested fallback is not a fallback.

* Run probes from the scratch directory. Probing is *expected* to crash
  clang, and a crash on a host with core dumps enabled dropped a
  multi-megabyte `core` into the worktree the sweep was run from.
… the host's

`check_byte_identity.py` passed `os.cpu_count()` to `cmake --build -j`. That
reports the machine, which on a scheduler-pinned node here is 384 against an
affinity of 2 -- so the gate every engine change has to pass was asking ninja
for 384 compile jobs it had two cores to run.

The arch-domain generator had already grown the fix. Lift it into a shared
`tools/_hostcaps.py` rather than copy it: affinity, then a cgroup v2/v1 CFS
quota, then the host count, narrowest wins. A quota is a rate, so a fractional
share rounds up to one whole worker -- rounding down would be a hang rather
than a slow build.

The helper keeps its existing tests, which now cover both callers. Its
docstring drops the probe-specific rationale, which stays at the generator's
call site where the RLIMIT_NPROC argument for the extra `min(8, ...)` cap
lives; that cap is about clang's own process fan-out and does not apply here.

Leading underscore because every other module under `tools/` is a CLI entry
point and this one is not.
…alone

`--check` compared the committed column byte for byte against a fresh run.
`toolchain.clang` records the exact ROCm build that measured it, so the check
red on every host whose patch level differed from the one that blessed the
artifact -- which is most of them, for a reason nobody can act on. It was
already red here: the llvm22 column was blessed by roc-7.2.4 and this host
runs roc-7.2.0, with all 1169 cells in agreement.

Compare the parsed documents instead, via `_drift`, and forgive a difference
in `toolchain.clang` alone. Two independent builds of the same flavor
reproducing a column cell for cell is not staleness; it is better evidence
than a byte match, because it shows the column describes the flavor rather
than one machine.

Nothing else is forgiven, including the rest of `toolchain`. Those fields say
how the question was asked rather than who answered it, and they are exactly
where a wrong measurement hides -- the same clang reports `ok` or hangs for
the same key depending on whether `probe_cflags` carries `-O0`, which is the
defect that field exists to expose. A `generator` or `probe_timeout_s` change
means the committed answers came from a method we no longer use, so those
still demand a re-bless even when every cell happens to agree.

Drift now also reports which cells moved instead of only that something did.

Tests pin both directions: a different build is not drift; a changed status, a
changed probe method with identical cells, and an unparseable column all are.
…ruction

The arch-domain probe passed the literal 0 for every `immarg`. Where 0 is
not a legal value of that operand, ISel has no pattern and reports
`Cannot select` -- which is also what a genuinely unsupported target
reports. The generator could not tell the two apart and recorded the
target as incapable.

`llvm.amdgcn.global.load.lds` is the case. Its third parameter is the
per-lane transfer size in bytes; 0 is not one. Both committed columns
said `arch_absent` on all seven targets for an intrinsic with a builder
entry point, a lowering handler in both engines, and a production
instance whose own comments record the resulting instructions in the
emitted ISA. Three of those seven cells were wrong, on a live path.

That is the dangerous direction. A false `arch_absent` breaks a build
that works; the permissive failure only costs a diagnostic the link
would have produced anyway. It is also the direction the first consumer
of this table will promote into a hard error.

The existing literal rescue cannot reach it. `literal_ints` substitutes
into non-`immarg` integer arguments -- an operand that is already an
immediate has nothing to substitute -- and `_wants_literals` triggers
only on the two "that operand had to be an immediate" diagnostics, which
an illegal *value* does not produce. There is no diagnostic that says
"your operand value was out of range", so the only discriminator is to
ask again with a different value.

So: on `arch_absent`, and only there, re-probe with each of 1/2/4/16 in
every `immarg` and take the first that links. Any legal value wins,
because the question the table answers is whether the target can lower
the intrinsic, not whether it can lower it with the operand we happened
to pick. The sweep is built by comparing module text against the base
probe, so a declare with no integer `immarg` produces no variants and
costs nothing -- the common case is still one probe.

The winning value lands in the cell as `probe_imm`. Without it a
first-try `ok` and a fourth-candidate `ok` are indistinguishable, and
re-deriving that by hand is precisely what nobody did for two columns.

Both columns regenerated, on ROCm 7.1.1 and 7.2.0 respectively. Three
cells move in each -- gfx90a/gfx942/gfx950 on that one key, at size 1 --
and the four RDNA targets still say `arch_absent` after the full sweep,
which is the correct answer and matches the builder's own documented
size domain. Everything else in both columns reproduced against a
compiler build different from the one that blessed it.

Generator bumped to 3: the probe semantics changed, so an older column
must not be mistaken for one measured this way.

Tests pin both directions. A sweep that only manufactures more `ok`
cells has broken the table the other way round, and that is the failure
nobody notices.

No emitted bytes change -- tools, tests and data only. Byte-identity
GREEN at llvm20 and llvm22, 40 families each.
`llvm23` was the one flavor with no machine-checked column behind it. A
ROCm 10.0 container has the toolchain, and the generator needs clang and
`opt`, not a GPU, so there was no reason for the gap other than nobody
having a vehicle.

The first run came back with 27 `probe_error` cells -- the status that
means the generator is broken, not the toolchain. Both causes were the
same thing seen from different angles: llvm23 words two diagnostics
differently, and we had taught the classifier only the older wordings.

`intrinsic not supported on subtarget` is a target saying outright what
llvm20 and llvm22 only imply by failing to select. Twelve cells across
`permlane16`, `permlanex16` and `mov.dpp8` had a clear answer and it was
being thrown away. Now classified `arch_absent`, like the three failure
shapes that mean the same thing.

`invalid data size for load-to-LDS intrinsic; must be 1, 2, 4, 12, or 16`
is the `immarg` defect, caught one stage earlier: llvm23 checks the
transfer size in the verifier, before codegen. It is a complaint about
our module rather than about the target, so it routes to the operand
rescue. It is also the only diagnostic of the three that says what would
have been acceptable, so the sweep's candidate list now matches the
domain llvm23 enumerates rather than the one we inferred -- 12 added.

Two consequences for the sweep. It now runs on `probe_error` as well as
`arch_absent`: "our module was malformed" is exactly what it repairs. And
when no candidate links, a cell that started as `probe_error` takes the
first real classification the sweep produced instead of keeping its
original evidence. The four RDNA targets are the case -- the default
probe was malformed, but a legal size fails there too, so the honest
answer is `arch_absent`, and committing `probe_error` would have reported
the generator broken for a target that simply cannot lower the call.

The llvm23 column: 722 `ok`, 374 `arch_absent`, 28 `name_absent`, 45
`toolchain_crash`, no `probe_error`. `global.load.lds` resolves the same
way it does on the other two vintages -- CDNA at size 1, RDNA not at any
size.

All three columns regenerated at generator 4. llvm20 and llvm22 differ
only in that number: neither had a `probe_error` cell, and the added
candidate never wins ahead of 1, so nothing llvm23 taught us changes what
the older vintages say. That is the result worth having -- the fix is in
what we can read, not in what we measure.

Tests pin both new wordings and the candidate list. Two design docs
stopped claiming llvm23 has no column.

No emitted bytes change. Byte-identity GREEN at llvm20 and llvm22, 40
families each.
@CongMa13
CongMa13 force-pushed the users/congma/ck/rocke-arch-domain-table branch from b2966e7 to e148bb1 Compare September 29, 2026 21:58
@tenpercent

Copy link
Copy Markdown
Contributor

note: when you stack PRs it affects mergeability

Review of #12269 found the same defect in several places: a gate that
could not actually ask its question reported a verdict anyway. Every
fix below draws the one line the arch-domain design already draws --
"we could not ask" is never recorded as "the answer is no".

Generator (tools/gen_arch_domain.py):

* A missing `opt` no longer substitutes "the name exists" for the
  flavor-axis check. Every key would have been recorded as present
  without being checked, so the run now exits 1 having written nothing.
  No complete LLVM install ships clang without `opt`, so the pair means
  a partial toolchain rather than a probing host with less to offer.
* Any cell still `probe_error` after the serial retry fails the run with
  its diagnostic printed, rather than being committed. This covers
  signal-killed clang and every future unrecognised wording, without
  misfiling an OOM kill -- a property of the host -- as a crash of the
  compiler.

Validity gate (tools/check_ir_validity.py):

* Probe at -O0. At -O3 the optimizer can delete the construct under test
  and leave a linkable module, which is the false `ok` this gate exists
  to prevent. Pinned to the generator's flag by test.
* Pass -nogpulib, so the answer depends on the module under test rather
  than on whichever ROCm's device bitcode clang happened to find.
* A target this clang cannot build (`invalid target ID`) is UNVALIDATED,
  not an emission failure. rocke wires arches ahead of the toolchains
  that can build for them, and this gate now runs by default.
* A clang whose version disagrees with the flavor the corpus was lowered
  at is UNVALIDATED. The former `flavor != native` test compared two
  rocke-side values and ran before clang was even resolved, so the
  binary was never asked what it was.
* ROCKE_LLVM_BIN is authoritative when set. Falling through on a miss let
  clang come from the named directory while `opt` came from the installed
  ROCm -- two vintages recorded as one toolchain.

Runner (tests/run_all.py):

* New `--only-ir`. One substring was forwarded to two tools with
  different matching contracts, and they read an empty selection in
  opposite directions -- run_diff runs zero families and reports green,
  check_ir_validity calls it FATAL -- so an ordinary typo produced one
  vacuous green and one red.

Artifact and packaging:

* Regenerated all three columns on their matching ROCm images. Each had
  drifted from the decl table: 3 stale keys and 2 unmeasured ones. All
  three now match exactly at 166 keys.
* test_arch_domain_artifact.py resolves its data through the installed
  package and is excluded from the installed test tree. It imports the
  generator out of tools/, which is not installed, and an unresolvable
  import there is a collection error that aborts the whole session.

clang_identity/flavor_of_clang are shared via tools/_llvm_identity.py:
the two tools act on a mismatch differently on purpose, but must not
disagree about what a mismatch is.

Gates: artifact test 27 passed / 3548 subtests on llvm20, llvm22 and
llvm23; `--check` up to date on all three; check_ir_validity GREEN on
llvm20 (8 unvalidated) and llvm22 (4 unvalidated). It is RED on llvm23
for 3 gfx950 attention cases that fail only at -O0 there and pass at
-O0 on both other flavors; the rejected construct is backend-selected
MIR, not IR rocke emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CongMa13

Copy link
Copy Markdown
Contributor Author

Thanks all — every comment on this PR is addressed below, grouped by reviewer. Fixed 14, rebutted 1, left open with reasoning 1, plus 2 acknowledgements. All changes are in 69b8eba.

Verification for every claim below: the byte-identity gate and the emitted-IR validity gate were run at all three LLVM flavors (llvm20, llvm22, llvm23), and all three arch-domain columns were regenerated from scratch.


@cgmillette

N1 — the artifact test breaks an installed tree. Fixed both ways you suggested. _DATA now resolves through importlib.resources.files("rocke.core.arch") so it reads the installed package rather than the source tree, and the test itself is excluded from the installed tree, beside the existing test_wavescope_sidecar.py exclusion which is there for exactly this reason. It stays a source-tree gate because it imports the generator, deliberately — that is what keeps it in step with the generator about what "the decl table for a flavor" means. Verified against a mock install: 893 tests collect cleanly; restoring the file reproduces the aborting ModuleNotFoundError.

N2 — -nogpulib. Fixed. Verified it was a no-op first: identical verdicts across all 154 corpus cases with and without it, so this buys independence from the device-library install without moving any result.


@tenpercent

P1 — packaging / installed-tree failure. Same fix as N1 above.

On the JSON data files specifically: the existing rules already cover them, and that is now verified rather than assumed. pyproject.toml ships rocke = ["**/*.json"] for the wheel, and the install(DIRECTORY python/rocke ...) rule excludes only __pycache__/*.pyc, so both paths pick the new columns up. Confirmed by staging a mock install replicating the CMake exclusions and checking the JSON is present in the installed package. The real problem you hit was the test, not the data.

P2 — the default validity gate fails on targets the host compiler cannot support. Fixed. invalid target ID is now classified as unvalidated: excluded from the failure count, excluded from the pass count, and unable to mark a KNOWN_BAD entry stale. Supported targets continue to be validated normally. --strict still reds these, for the CI case where "we could not ask" should block.

Two notes. First, on the current tip your repro is not reachable as written — an llvm23-gated instance raises FATAL before the compile stage ever runs; that is fixed in this same round, and only then does this finding become observable again. Second, the llvm20 gate is now GREEN with 8 unvalidated cases — 4 for an untargetable arch, 4 requiring a newer LLVM — which is the shape this finding asked for.

P2 — missing opt silently recorded as success. Fixed, taking the "or fail" branch. The (True, "") fallback is gone; a missing opt now exits 1 having written nothing. Failure rather than an unvalidated run because no complete LLVM install ships clang without opt — checked across all three flavors' toolchains — so the combination means the environment is pointed at a partial install, not at a host with less to offer.

Your repro is what made the severity clear: a name_absent key recorded as toolchain_crash, with the process exiting 0. That also surfaced an adjacent hazard, now closed: ROCKE_LLVM_BIN used to be a first guess that fell through on a miss, so a prefix holding only a clang would silently resolve opt from a different install — and the flavor axis and the arch axis could then be measured by two different LLVM vintages and committed as one toolchain. It is now authoritative. Pinned by a test asserting the run writes nothing and returns 1.

On stacking this PR. Understood and noted for how this lands.


Copilot

check_ir_validity.py:181 — -O3 hides emission defects. Fixed. COMPILE_CFLAGS = ("-O0", "-nogpulib"), and _compile_argv() was extracted so a test can pin this gate and the arch-domain generator to the same flag.

This found more than it claimed. No verdicts changed at llvm20 or llvm22, but at llvm23 three gfx950 attention cases fail at -O0 and pass at both -O1 and -O3, while the same modules pass at -O0 on the other two flavors. The rejected construct is backend-selected MIR ($vgpr3 = V_MOV_B32_e32 $vgpr10_vgpr11), not IR rocke emits, so it reads as an llvm23 fast-regalloc issue rather than an emission defect. Tracking separately — flagged here because this comment is what surfaced it.

gen_arch_domain.py:761 — missing opt. Fixed; see @tenpercent's P2 above, same change.

gen_arch_domain.py:446 — signal termination classified as probe_error. Fixed, but not as suggested — the guarantee is there, the classification is not.

Clang installs its own SIGSEGV handler and prints the bug-report banner, so an ordinary crash already classifies as toolchain_crash through the text match. The genuinely uncovered case is SIGKILL — an OOM kill — which leaves empty stderr and a negative return code. Mapping every signal death to toolchain_crash would record "this machine ran out of memory" as "this toolchain crashes on this intrinsic": a property of the host committed as a fact about the compiler.

So instead, any cell still probe_error after the serial retry now fails the run, prints its diagnostic, and writes nothing. That covers signal death and every future unrecognised wording without the misattribution. All three full sweeps clear it — no column was refused.

test_arch_domain_artifact.py:160 — skipTest aborts the loop. I don't think this one holds; no change made.

skipTest does raise immediately, but it is raised inside with self.subTest(...), and unittest's subtest executor catches SkipTest (and AssertionError), records it against that subtest, and does not re-raise. Later columns are still checked. Confirmed deliberately, then again by accident: a rename mid-review broke this exact test, and all three flavor subtests reported independent failures rather than the run stopping at the first.

It is non-obvious enough to have misled a careful reader once, so the loop now carries a comment explaining why the skip does not abort it. Happy to reopen if you are seeing different behaviour on another unittest version.

multi_toolchain_strategy.md:360, arch_axis_proposal.md:305, gen_arch_domain.py:411 — stale cell count. Fixed in all three, taking the second option: the number is replaced by a description — every decl key × every wired arch, with the committed columns as the count of record — so it cannot drift again.

One correction to the finding: the count at the time was 166, not 167. The committed columns had drifted from the decl table in both directions — 3 stale keys and 2 unmeasured ones — which is precisely the failure the artifact gate exists to catch. All three columns are regenerated and now match the decl table exactly.

arch_axis_proposal.md:380 — KNOWN_BAD documented as empty. Fixed, and it was two places: the status table and the acceptance criterion both claimed empty. Both now record the 2 entries, and the criterion asks for no new failures beyond them.

run_all.py:192 — --only forwarded to two tools with different matching semantics. Fixed, taking your first option: run_all.py now has a separate --only-ir, and --only is no longer forwarded to the IR gate.

Investigating it turned up a sharper version than stated. mfma matches none of the 17 byte-identity families either, and checking all 17 against the corpus, every one has at least one case — so the path as described is not currently reachable. The reachable defect is that the two tools read an empty selection in opposite directions: run_diff filters to zero families, runs nothing, and reports green, while the IR gate calls it FATAL and exits 1. An ordinary typo therefore produced one vacuous green and one red. That asymmetry is also why the fix is a separate flag rather than silencing the FATAL — silencing it would make a typo'd partial run come back fully green having executed nothing.

check_ir_validity.py:278 — the flavor check never interrogates the compiler. Fixed. Worth stating what the existing check actually was: flavor != native compares two rocke-side values and ran before clang was even resolved, so the binary was never asked what it was. A complete LLVM 20 install on an llvm22 host passed every check and reported GREEN under a header reading flavor : llvm22.

The gate now prints clang --version unconditionally and compares the banner's major against the flavor the corpus was lowered at. On a mismatch it reports UNVALIDATED, deliberately rather than refusing: the generator writes a committed artifact where a mis-attributed run is a permanent false record, while this gate writes nothing — and since this PR turns it on by default, failing closed would newly red every host whose PATH clang disagrees with its comgr. --strict is the opt-in for CI. An unrecognised banner reads as unknown, never as mismatch, so a vendor rewording its version line cannot red the world.

The two helpers moved to a shared tools/_llvm_identity.py so the two tools can act differently on a mismatch without disagreeing about what a mismatch is, with a test asserting they hold the same function object.

gen_arch_domain.py:634 — immarg fallback records a probe defect as a target negative. Not changed, and I would welcome a second opinion.

The path exists exactly as described. But after regenerating all three columns, zero cells carry an immarg / non-immediate diagnostic at any status — the _IMMARG_PROBE_VALUES sweep resolves them before the fallback is reached. I held off because the change would ship untested, and because preserving probe_error now feeds the refusal gate added above, which converts a silently-wrong cell into a hard stop on the entire column. If a future vintage makes it reachable, that refusal fails loudly with the diagnostic printed rather than quietly recording arch_absent. Happy to make it a one-liner if you would rather close it now.

@cgmillette cgmillette left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #12269 — measure per-GPU intrinsic legality + commit it · Verdict: 🟢 high-quality, 🔴 blocked on conflicts
(link gate already caught 3 dead declares + 2 broken corpus cases). ~16 prior findings addressed — not duplicated.
AICK-2272.

# Sev Location Net-new finding
1 🔴 PR state mergeable_state: dirty — conflicts; base of stacked #12441, mergeability coupled
2 🟡 drift gate / columns Structural gate catches missing/stale keys but not wrong cells; only a host with that exact LLVM vintage can re-measure → ~17k committed "facts" are trust-based in normal CI
3 🟡 arch-domain artifact 17k data lines + drift gate are inert until consumer #12441 (stacked, unmerged) lands; only the link gate delivers value now
4 🟢 gen_arch_domain.py classifier Diagnostic-wording list → each new LLVM vintage needs a code change or the refusal gate hard-fails that column (recurring tax)
5 🟢 immarg fallback (left-open item) Concur with author's deferral: zero cells reach it; post-retry refusal gate backstops — not a blocker

Legend: 🔴 blocking · 🟠 should-fix · 🟡 minor-nit · 🟢 info/confirmed.

@cgmillette
cgmillette requested a review from tenpercent October 2, 2026 16:47
CongMa13 and others added 5 commits October 3, 2026 17:45
Conflicts, both resolved by keeping both sides:

- rocke/platform/CMakeLists.txt: the installed-tests EXCLUDE list keeps
  develop's test_lds_analyzer.py and this branch's
  test_arch_domain_artifact.py, and the comment above it names both.
- rocke/platform/tests/run_all.py: keep this branch's emitted-IR validity
  gate (--no-ir-validity, --only-ir) alongside develop's native-parity and
  ctest rework (--config); the usage header lists both sets of flags.

Not a textual conflict, so not resolved here: develop added the xf32 MFMA
decls (mfma.f32.16x16x8.xf32, mfma.f32.32x32x4.xf32) to both engines, and
the committed intrinsic_arch_domain.<flavor>.json columns do not cover
them yet, so test_arch_domain_artifact.py fails on a host that can measure
its own flavor until the artifact is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
develop added mfma.f32.16x16x8.xf32 and mfma.f32.32x32x4.xf32 to both
engines' decl tables. The committed intrinsic_arch_domain.<flavor>.json
columns predate them, so test_arch_domain_artifact.py failed on any host
that can measure its own flavor ("has no answer for 2 decl key(s)").

Regenerated each column with tools/gen_arch_domain.py on the toolchain
that column is stamped with:

  llvm20  ROCm 7.1.1   clang 20.0.0git roc-7.1.1 25444
  llvm22  ROCm 7.2.0   clang 22.0.0git roc-7.2.0 26014
  llvm23  ROCm 10.0.0  clang 23.0.0git llvm-project 8f497e0992fb

Every regeneration reproduced every existing row unchanged, including
the toolchain_crash and toolchain_timeout rows, so the diff is purely
additive: two rows and two canonical entries per column. Both keys
measure ok on gfx942 only (and target_unsupported on gfx1250 for llvm20,
whose clang cannot target it), which agrees with the emitter's
gfx942-only guard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tests/tools/test_run_all.py, which came from develop, drives
run_all.main() against a fake subprocess.run and isolates the native
pytest/ctest plumbing with --no-guard --no-gate. This branch added a
default-on emitted-IR validity gate to run_all.py, so after the merge
that step ran inside every one of those tests. Ten of them failed on
the merged tree; all of them passed on develop, and the file did not
exist on the branch:

  - the gate's Python child carries no env, so the tests' kw["env"]
    lookup over every sys.executable child raised KeyError (4 tests);
  - the fake's return code leaked into main()'s status, so ctest being
    correctly skipped still returned 8 (1 test);
  - a fake that raises was hit before ctest discovery (1 test);
  - an assertion that no Python child ran after a native-setup failure
    saw the gate's child (4 tests).

Opt each argv out of the step with --no-ir-validity, as they already do
for the guard and the byte-identity gate, and say so in the module
docstring so the next default-on step does not recreate the trap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… glue

The gfx942 tiled attention kernel names grew a "fnuz" part on the Python
side in #12304 (dispatcher refactor), but the C++ glue that reimplements
spec.kernel_name() was never updated, so for any spec with a
kv_storage_dtype the two engines named the same kernel differently --
Python emitted ..._kvfp8e4m3_fnuz_..., C++ emitted ..._kvfp8e4m3_....
That is a one-sided emission change, which the byte-identity invariant
exists to catch: the gate has been reporting 70 GREEN + 1 DRIFT on
gfx942_attention_tiled_3d ever since. The 2D builder carries the same
token and the same omission; it is latent only because no 2D config in
the corpus sets kv_storage_dtype, so fix it in the same change rather
than leave a known trap behind.

Python is the source of truth here, so the C++ side moves. Both engines
emit "fnuz" under the *same* condition as the kv part -- presence of a
kv_storage_dtype, not a test of the fp8 format -- so the tag is attached
to the kv branch rather than computed separately; a comment on each site
records that this is deliberate mirroring, not an inferred format check.

Verified in the three ck_ub24.04 images (one toolchain per flavor):
byte-identity is now GREEN 71/71 at llvm20, llvm22 and llvm23 (was 70 +
1 DRIFT at every flavor). Emitted-IR validity stays GREEN, and the
platform pytest failure set is byte-identical to develop's own
(8 failed / 1683 passed / 340 skipped; all 8 are environmental -- the
images ship no ml_dtypes or pybind11).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…cke-arch-domain-table

# Conflicts:
#	dnn-providers/hip-kernel-provider/rocke/TESTING.md
#	dnn-providers/hip-kernel-provider/rocke/platform/CMakeLists.txt
@CongMa13
CongMa13 merged commit dec6abd into develop Oct 5, 2026
173 of 175 checks passed
@CongMa13
CongMa13 deleted the users/congma/ck/rocke-arch-domain-table branch October 5, 2026 20:50
CongMa13 added a commit that referenced this pull request Oct 6, 2026
develop carries #12269, the squash of the arch-domain table branch this lane
is stacked on. A plain merge sees the table's files as added independently on
both sides, which is ten conflicts, six of them add/add. Merged with
origin/users/congma/ck/rocke-arch-domain-table as the common base for that
stacked content instead, which leaves two real conflicts:

- TESTING.md: keep develop's reworded "Two entrypoints with different
  environments and selection" opener and this lane's "arch domain" step in the
  developer runner's chain.
- platform/tests/test_rocke.py: keep develop's backend-aware rejection
  assertions (#12816, #12718) on test_av_b128_rejects_lds_pointer and
  test_s_prefetch_inst_rejects_lds_pointer, with this lane's explicit
  arch="gfx950" on the lowering call.

develop also gained tests in files this lane never touched, so git cannot see
them as conflicts, but they assume things this lane changed:

- Callers that rely on the default target this lane removes raise "lowering
  requires an explicit gfx target" once merged. Name the target they were
  verified against, gfx950, which was the old default:
  - platform/tests/core/test_tf32.py: eight _lower_kernel_to_llvm_python calls
    and eight rocke_engine.lower_serialized_ir calls. Several sit inside
    pytest.raises(match=...) blocks, so left alone they fail on the wrong
    error; the ones behind importorskip("rocke_engine") only surface where the
    engine is built.
  - library/tests/test_attention_builds.py:
    TestAttentionDenseGfx950CodegenKnobs._lower.
- platform/tests/tools/test_run_all.py drives run_all.py with every other step
  switched off. This lane's arch-domain gate is a new default-on step, so it
  ran under the test's fake subprocess.run and returned a child with no env.
  Add --no-arch-domain beside --no-ir-validity in the four argv lists, as the
  file's docstring asks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@assistant-librarian

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

CongMa13 added a commit that referenced this pull request Oct 17, 2026
…12441)

ISSUE ID : AICK-2273

# summary

## What this branch is about

rocke's emitter had no way to know whether an intrinsic it declares
actually
exists on the architecture it is lowering for. An unknown
`llvm.amdgcn.*`
declare is not a parse error, not a verifier error, and not a `clang -S`
error —
it survives every check rocke ran and fails much later, in the backend,
as
`Cannot select`. Worse, the same name can be present at one LLVM flavor
and
absent at another, and present on one arch and absent on its neighbour.

#12269 put the measurement on develop: a generator that probes the
toolchain, an
emitted-IR validity gate that compiles *and links* every corpus case,
and three
committed columns of answers (one per LLVM flavor). Nothing read them.
This
branch is their first consumer, plus the fixes that building a consumer
forced:

1. **A reader and a warning lane** — the lowerer asks the table whenever
a kernel
   demands an intrinsic, on both backends.
2. **Removal of the implicit `gfx950` target default** everywhere it had
spread:
both engines, the per-family bindings and the C wrappers, so every
lowering
names the target it is actually for. This is what lets the lane mean
anything.
3. **A corrected table and sturdier gates**: reading the columns as a
consumer
showed that one of them was wrong, and that the generator and the corpus
gates
   each measured less than they reported.

Plus a long series of methodology fixes, all of which draw one line:

> **A non-answer must never be recorded as a negative.**
> "We could not ask" is not "the answer is no".

The lane is warn-only. Making it an error is a separate change
(AICK-2274).

The commit list includes commits that #12269 already merged by squash;
the diff
against `develop` is what this PR changes.

---

## 1. The warning lane

**Reader** `python/rocke/core/arch/domain.py` — validates the envelope
and raises
on a malformed or wrong-schema column rather than defaulting every field
(which
would have presented a corrupt artifact as a complete absence of data
and
switched the lane off in silence — the one failure the lane exists to
prevent). A
column that exists but cannot be read raises too; only a *missing* file
means
"no data".

**What it reads** —
`python/rocke/core/arch/data/intrinsic_arch_domain.<flavor>.json`,
schema `rocke.intrinsic_arch_domain/v1`, **171 keys × 7 wired arches =
1197 cells
per column**:

| Flavor | `ok` | `name_absent` | `arch_absent` | no-data |
|---|---|---|---|---|
| llvm20 | 535 | 294 | 194 | 174 |
| llvm22 | 669 | 147 | 340 | 41 |
| llvm23 | 740 | 21 | 391 | 45 |

One column per flavor because **a host can only measure its own flavor**
— flavor
is a runtime choice (`ROCKE_LLVM_FLAVOR`), so the generator can never
produce all
three in one run, and a gate must skip the flavors its host cannot
measure rather
than mark them failing.

The lowerer's declaration table has 27 more keys than the columns: the
26
declarations develop added for data prefetch, workgroup clusters and TDM
(#12857),
and `raw.ptr.buffer.load.f16` (#12710), have not been measured yet. An
unmeasured
key is no data, so the lane stays silent for those intrinsics until the
columns
are regenerated. That is a measurement on each of the three toolchains,
and it is
left to a follow-up.

**Status vocabulary** splits answers from non-answers:

- Answers: `ok`, `name_absent`, `arch_absent`
- No data: `target_unsupported`, `toolchain_crash`, `toolchain_timeout`,
`probe_error`

Only `arch_absent` is negative (`is_negative`). Everything else is
silence.
`name_absent` is silent on purpose: it is a flavor-axis fact that holds
for every
target, and the validity gate already reports a reachable one with the
compiler's own diagnostic.

**Warning lane** — `_Lowerer._need()` consults the table and emits
`ArchDomainWarning`. The default backend is the C++ engine, which
returns
finished IR without the Python lowerer running, so the same check also
runs over
its emitted `declare` lines; the declare → key mapping is exact because
both
engines emit the table's declares verbatim.

Controlled by `ROCKE_ARCH_DOMAIN`: `warn` (default) / `off`; any other
value
**raises**, because every way of getting this variable wrong is somebody
trying
to switch the lane off, and silently defaulting to `warn` hands them the
opposite of what they asked for. The value is read in
`lower_kernel_via_backend`,
which every lowering passes through (`lower_kernel_to_llvm`,
`compile_kernel` and
the per-family wrappers), so it raises for every kernel, entry point and
backend
rather than only for a kernel that happens to demand an intrinsic. The
LLVM
flavor is likewise resolved once per lowering and handed to each stage.
`error` mode is planned (AICK-2274).

**Gate** `tools/check_arch_domain.py` — wired into `run_all.py`. It
lowers the
194-case corpus at all three committed flavors (pure Python; it needs no
toolchain, so it rules on every flavor on any host) and compares the
warnings
with `EXPECTED_WARNINGS`, which only ever shrinks. The list holds nine
entries:
three intrinsics (`ds.read.tr16.b64`, `mfma.f32.16x16x32.bf16`,
`mfma.f32.16x16x32.f16`) on gfx942 at each flavor. All nine are one
defect in the
parity harness rather than in a kernel: it builds the gfx950 3D
attention kernel
for two gfx942 cases. The validity gate lists the same two cases as
`KNOWN_BAD`.
Fixing the harness is left to the change that makes the lane binding,
which needs
the list empty.

Each entry names the corpus cases allowed to demand it, so the list
cannot widen
in silence: a case an entry does not name is unexpected even when its
intrinsic is
already allowlisted, and an entry whose case stops demanding it, or no
longer
exists, is stale. A case that did not run (`--only`, `--arch`,
`--flavor`, or an
instance that declines the flavor) is judged neither way. The comparison
is a pure
function with a test per rule.

## 2. No implicit target default

Two commits, in order: first a pure refactor making all 158 implicit
lowering
call sites name their arch explicitly; then removal of the default
itself from
both engines. Omitting the target now raises.

This is what lets the warning lane mean anything — a kernel lowered at a
default
arch is a kernel whose arch nobody chose. The default had also spread
beyond the
lowerer, and the same series removes it from those places: the
per-family pybind
entry points (one shared `require_arch` replaces their per-family "usual
target"
fallbacks), the Python wrappers in `core/backend.py` that mirrored them,
and the
scalar-attention C wrappers below.

## 3. A corrected table and sturdier gates

- **Generator 4 → 6, and one column corrected.** The generator built its
probes
from rocke's hand-written declaration table, which records `immarg` only
where
an author happened to add it, and passed the unmarked operands as
variables
where the compiler demands constants. llvm22 and llvm23 reject that in
the
verifier, so the generator's rescue recorded the true answer; llvm20 has
no such
check, died at ISel with `Cannot select` — which is also what a genuine
negative
looks like — and recorded `arch_absent`. Probes are now built from the
declare
`opt` resolves, so the signature comes from the LLVM being measured. 33
llvm20
cells move: 27 `arch_absent` → `ok` and 6 `arch_absent` →
`toolchain_crash` (a
crash is no data, so the lane stays silent). llvm22 and llvm23 move no
status.
Two more ways to bank a non-answer as an answer are closed: a failed
name probe
(`opt` crashed or timed out) was recorded as `name_absent` for every
arch, and a
  partial `--only`/`--arch` sweep could be filed as a whole column.
- **One meaning of "this flavor refuses the instance".** The arch-domain
gate
counted four gfx1250 instances that refuse an older LLVM as lowering
errors —
eight failures on every run of a clean tree — while the validity gate
already
  treated them as unmeasurable. Both now import one predicate from
  `tools/_flavor_gate.py`, and a test asserts they hold the same object.
- **Provenance.** The validity gate no longer trusts an `opt` from
another
install: an older `opt` rejecting a construct its parser predates reads
as "rocke
emitted invalid IR" when the IR is fine, so that lane is skipped on a
mismatch.
  The generator's identity check covers `opt` as well as `clang`.
- **The arch-domain gate lowers through the native lowerer.** Through
the
dispatching entry point, any host with the C++ engine built would lower
every
case without consulting the table, harvest nothing, and call every
allowlist
  entry stale.

## 4. What testing this turned up

**The scalar-attention C wrappers.** The three public `_lower_to_llvm`
wrappers
in `attention_unified_entry.cpp` are the only lowering entries whose
signature
carries no target, and nothing in the tree called them. They passed
`NULL`, which
the lowerer now rejects, so all three had turned into `ROCKE_ERR_VALUE`.
They now
name `gfx950` explicitly — a fixed target, not a fallback, and the
header says so;
a caller who wants another target builds with the `_new` entry and
lowers through
`rocke_lower_kernel_to_llvm_ex`.

**A crash in the C++ scalar reduce builder.** Testing the wrappers found
that
`rocke_build_unified_attention_reduce_scalar` died with SIGSEGV on every
input:
its loops read three tensor descriptors that nothing had built. It now
builds
them, in the order the Python builder does, and emits the kernel the
Python
builder emits (byte-identical). A new ctest lowers each wrapper and
requires text
identical to lowering the same kernel through the target-taking entry.

**Bindings that ignored their target.** A sweep over every per-family
binding
that takes a `(spec, arch)` found six that discarded `arch`
(layernorm2d,
elementwise and the scalar attention family's serialize/verify entry
points), two
fused-MoE e2e entry points that hard-coded gfx950 into the spec they
built, and a
ninth that kept a pybind default. All now call `require_arch` first. The
sweep is
a test now: an extension-gated check reads every `(spec, arch)` binding
off the
module and requires each to reject an empty target, so a binding added
later is
held to the same rule.

**Bindings that arrived with develop.** Three stream-K wgrad host-plan
bindings
(`conv_wgrad_is_valid_spec`, `conv_wgrad_streamk_default_pool` and
`conv_wgrad_streamk_host`, from #13151) resolved their target with
`arch_or_default`, the helper this branch removed, and declared `arch`
with a
gfx950 default. `rocke_engine.cpp` stopped compiling, so the pybind
extension
could not be built; CI builds no extension, so nothing turned red. They
now take
the target like every other binding, and the sweep covers the two that
take a
spec (pybind prints `arch: str = 'gfx950'` for a defaulted argument,
which is why
the sweep could not see a default before). One decision to look at:
develop's stream-K tests pinned
"without an arch both engines default to gfx950" for the host planner.
The Python
planning helpers keep their default; the C++ binding now refuses an
empty
target, and the test asserts that.

The same thing happened a second time with the gfx950 block-scaled GEMM
bindings
that arrived with #13450 (`gfx950_scaled_serialize_ir`,
`gfx950_scaled_verify` and
`gfx950_scaled_lower_llvm`: four uses of `arch_or_default`), and
`rocke_engine.cpp`
again stopped compiling without anything turning red. They call
`require_arch`
first now. Because the extension-gated sweep cannot run where no
extension is
built, a second test reads `cpp/bindings` and fails on either way a
binding guesses
its target: the removed `arch_or_default` helper, or a gfx target as the
default of
an `arch` argument. It lists every offending line, and it fails on the
previous
merge without the fix. It reads the source, so it runs from a source
checkout and
skips in an installed layout.

## 5. Review

Every review thread has a fix, one commit per concern:

- **Tracker IDs out of code** (Rule 1): four places cited IDs; each now
names the
defect. The allowlist's reason string was also wrong about the defect —
it
blamed two declaration-table items, but the nine warnings come from the
parity
  harness — and now says so.
- **Trust boundaries** (Rule 4): a malformed or wrong-schema column, an
unreadable
column and an unknown `ROCKE_ARCH_DOMAIN` value each raise, instead of
reading
  as "no data" or defaulting to `warn`.
- **One flavor per lowering**: with no flavor given, the C++ path and
the
arch-domain post-pass each resolved their own, and compiler discovery is
retryable, so the IR could be checked against the wrong flavor's column.
It is
  now resolved once in the dispatcher, including for both engines under
  `ROCKE_BACKEND=both`.
- **Tests lower at the arch they are about**: three substitutions of an
explicit
`arch=` had put the wrong arch in, and `test_legacy_equals_gfx950`,
which
compared a call with itself, is replaced by
`test_omitting_the_target_raises`.
- **`develop` merged four times** since the first review (111 commits,
then 99,
29 and 14). Only the first conflicted textually (four binding
translation units
and `test_compiler_detection.py`). The trouble in all four was semantic:
develop's new code was written against the default target this PR
removes, so
it called the lowerer, or a binding, without naming one. Each occurrence
is
fixed here: the compiler-detection tests (three of the calls sit inside
a
subprocess script string, and they had turned the gfx94X CI shards red)
and a
  binding-selection probe; the flag-attribute test that turned the
hipkernelprovider jobs red (twelve subtests), a library gate test that
CI does
not run, and the FP64 elementwise and reduce tests; and the stream-K and
block-scaled GEMM bindings in section 4. A scan of the Python and C++
that the
  merges brought in finds no other call that names no target.
- **Formatting after the merge**: this branch's parenthesized `with`
statements
make black treat `test_rocke.py` as Python 3.9+, so develop's three
multi-line
`with a, b, c:` blocks in that file now need reformatting. Each version
of the
file is black-clean alone; the merge is not, and the pre-commit job
checks every
  file a PR changes.
- **The allowlist names its cases** (see the gate in section 1): it was
keyed by
intrinsic, target and flavor alone, so a second corpus case demanding an
allowlisted intrinsic left the gate green, and fixing one of the two
cases that
  demand it could not make the entry stale.
- **Wording**: the status vocabulary in `TESTING.md` lists
`probe_error`,
durations are out of the generator's comments, and the documentation
examples
  name the target.

---

## Verification

Run in the three toolchain images (one per LLVM flavor) against an
export of the
branch tip; no GPU. The pybind extension is built in each image and put
on
`PYTHONPATH`, so the tests that need it run (CI builds no extension and
skips
them).

| Check | llvm20 | llvm22 | llvm23 |
|---|---|---|---|
| byte identity, every family | GREEN | GREEN | GREEN |
| ctest | 20/20 | 20/20 | 20/20 |
| emitted-IR validity (194 cases) | GREEN (2 documented `KNOWN_BAD`, 8
unvalidated) | GREEN (2 documented `KNOWN_BAD`, 4 unvalidated) | GREEN
(2 documented `KNOWN_BAD`) |
| arch-domain corpus gate (194 cases, 3 flavors) | GREEN (exactly the 9
documented warnings) | same | same |
| platform pytest, default and under `ROCKE_BACKEND=both` | 2685 passed,
59 failed (both runs) | 2689 passed, 59 failed (both runs) | 2690
passed, 59 failed (both runs) |
| library pytest | 2598 passed, 1 failed | 2599 passed, 1 failed | 2599
passed, 1 failed |

Every failure is also a failure of `develop` itself, checked on an
export of its
tip (59 in the platform suite at every flavor, one in the library
suite). The
failing set is the same at the three flavors:

- 56 tests import `ml_dtypes`, which none of the images has: six in
  `core/test_gfx1250_fp4_scaled_wmma.py` and fifty in
  `instances/test_gfx950_mxfp_examples.py` (new with #13450);
- two tests in `core/test_arch_domain_artifact.py` (every declaration is
covered;
regenerating the host's column is a no-op) fail because of the 27
unmeasured
declarations in section 1. That file is excluded from the installed test
tree,
  so CI does not run it;
-
`tools/test_run_all.py::test_existing_engine_archive_is_reused_by_pytest`
fails
whenever pytest runs under `tests/run_all.py`, which exports the
variable the
  test expects to be unset;
- `library/tests/test_kda_hostpack.py::test_runner_geometry_without_gpu`
builds a
stub kernel without the `cluster_dims` attribute that #12857 made the
manifest
  code read.

The key #13450 added to the three columns
(`mfma.scale.f32.32x32x64.f8f6f4.v8i32.v8i32`) was re-measured in each
image with
this branch's generator: every cell, status and evidence, matches the
committed one.

A note on the llvm20 run. At llvm20, `raw.ptr.buffer.load.async.lds`
runs past the
60 s probe budget on six targets (the committed column records
`toolchain_timeout`
for them), and the generator's timeout kills the `clang` driver but not
the
`clang -cc1` it started, so each regeneration (including the
regeneration test
above) leaves twelve runaway compiler processes behind. In a
two-CPU container they starve the rest of the suite: an earlier full run
timed out

`test_binding_compiler_selection.py::test_resolver_failure_releases_binding_builders`
(valgrind, a fixed 120 s limit), which passes in about 15 s alone, on
this branch
and on `develop`. The numbers above are from a run that killed the
leftover probes
as they appeared. The leak is in the generator already on `develop`, not
in this
PR, and is left to a follow-up.

Not run: a GPU, Windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants