Skip to content

Build AITER from source at a pinned tag and prebuild every attention variant - #375

Merged
demandal25 merged 42 commits into
amd-integrationfrom
aiter-source-build
Sep 15, 2026
Merged

demandal25 merged 42 commits into
amd-integrationfrom
aiter-source-build

Conversation

@demandal25

@demandal25 demandal25 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

The image now builds AITER from source at a pinned tag and prebuilds every attention variant, so a container starts with them present instead of compiling them inside the serving process. A fresh container's first pass over the four attention families drops from 949 s to 134 s. The pin moves to v0.1.21.post2, which is an ABI migration rather than a version bump: 0.1.21 moved rmsnorm and the cos/sin-cache rope from at::Tensor to the POD aiter_tensor_t.

What changed

The prebuild

  • docker/prebuild_aiter_attention.py — new, stdlib-only driver that composes and builds all 44 attention variants with no GPU. It reaches AITER's builder through the flat sys.path import aiter/setup.py itself uses; import aiter runs arch detection and a triton import that both need a device.
  • docker/Dockerfile.rocm — clones and installs AITER from source, asserts the result, then runs the driver. AITER_SOURCE=0 restores a wheel install as an unblock.
  • tests/rocm/test_prebuild_aiter_attention.py — new. Three parity checks: the driver's filenames against aiter_variants.py, its CK receipts and filter tokens against the installed aiter/ops/mha.py, and every name token aiter_loader.cc can compose against the set the driver actually builds.

The ABI move

  • csrc/rocm/norm_aiter.cu, csrc/rocm/rope_aiter.cu — migrated to the POD tensor API via the existing aiter_tensor_compat.h, matching the three shims that migrated at 0.1.16.
  • tests/rocm/test_aiter_stream_affinity.py — new, covers a gap the migration exposed (below).
  • flashinfer/rocm/aiter_utils.py — floor to 0.1.21, and the import probe no longer hard-requires aiter_meta.

Supporting fixes

  • flashinfer/jit/rocm/aiter_source.pycompose_cache_tag() now keys both AITER caches by arch, AITER version and ROCm, from one place.
  • docs/rocm/backends.md, CLAUDE.md, CONTRIBUTING.md, README.md, the tutorial notebook — the source build is now the supported install; the previous text said the opposite.

Architecture / design notes

Why the artifacts go in AITER's jit/ directory rather than FlashInfer's store. PR #363 added a variant store that aiter_loader.cc reads at dlopen. That cannot help mha_batch_prefill: its bootstrap doubles as the page-size probe and runs before the loader, so AITER compiles regardless of what is in our store. Putting the same file where AITER itself looks makes the bootstrap find it built — measured at 101.4 s cold against 0.2 s prebuilt. #363's store is untouched and remains the fallback for wheel users.

Why source rather than a wheel. No amd-aiter wheel targets ROCm 10 at this revision — the index publishes 0.1.21.post2 only as +rocm7.14.0 and +rocm7.2.3. A source build compiles against the ROCm actually in the image.

Why 44 variants and not 40. fp8 is reachable, and batch-prefill only. prefill.py routes every fp8 query through the native paged kernel because the flat-gather route runs mha_varlen_fwd, which has no fp8 arm, and the in-tree fa2 kernel rejects 8-bit types at compile time. All three AITER mha families carry an fp8 arm upstream, but kFp8Bf16 is constructed at exactly one site in this tree. fp8 adds 4 names rather than 8 because it has no LSE arm — AITER ships no LSE instance of the fp8 kernel at any page size, and prefill.py:3334 raises on the combination. aiter_variants.py stays at 40: it serves the store route, which has no fp8 arm.

Why the version move and the shim migration are one commit. The POD overloads do not exist at 0.1.20 (rmsnorm_quant.h:37 declares void rmsnorm(torch::Tensor&, ...), and aiter_tensor_t appears in none of the norm or rope headers), so either ordering leaves a commit whose tests fail at dlopen. The bisect split the plan wanted is preserved elsewhere: one commit proves the mechanism at 0.1.20, the next moves the revision.

One build per variant, not per file. aiter_variants.builds() says 32 builds cover 40 files because the bootstraps loop return_lse in Python. Driving build_module directly, one call yields one .so; all 16 varlen builds took 65–67 s with none short-circuiting.

Benchmark results

Cold start, fresh container from the built image, gfx942. Both arms pay the same FlashInfer JIT cost, so the delta is AITER's.

family prebuilt not prebuilt saved
mha_fwd (single prefill) 52.5 s 485.9 s 433 s
mha_varlen_fwd (soft cap) 41.6 s 156.7 s 115 s
mha_varlen_fwd (ragged) 39.7 s 177.5 s 138 s
mha_batch_prefill (paged) 0.4 s 129.3 s 129 s
total 134.2 s 949.4 s 815 s (7.1x)

Steady state is unchanged, and now measured rather than asserted. Single prefill on gfx950 (q=256, GQA 16/4, HD=64, causal, fp16), 4d322ab0 + the old wheel image against 6d80af63 + the new source image. bench_aiter_prefill.py is byte-identical between the two trees.

kv before (aiter 0.1.20, wheel) after (0.1.21.post2, source + prebuilt) ratio
512 5.85 TFLOPS 5.89 TFLOPS 1.01x
1024 13.58 13.85 1.02x
2048 26.72 27.41 1.03x
3072 41.15 41.41 1.01x
4096 49.82 49.82 1.00x
8192 46.76 46.75 1.00x
mean 1.01x

Native paged batch prefill at page_size=1024, kv=1024: 62.98 TFLOPS before, 62.36 after (0.99x). Both figures are inside run-to-run noise, which is the expected result — same sources, same compiler, same kernels.

Image build cost, 32 cores at --jobs 2: 40 variants in 28 min uncontended, plus 80 s for the clone and install; the 8 fp8 variants add ~66 s each. Artifacts are 224 MB. AITER's intermediate jit/build/ staging was another 603 MB; the driver now deletes it on success, verified by removing it and confirming plan() builds nothing.

The final set is 47 artifacts: 44 variants plus 3 whole modules — built end to end and verified after merge. From the built image: total 47 / variants 44 / fp8 4 / whole modules 3, jit dir mode 755, AITER's build/ tree absent, and every CK variant above the instance threshold (all populated). The earlier 49-artifact build described below predates the fp8 LSE drop and is left as the record of what it measured at the time.

End-to-end image build, gfx950 node: 49/49 present, which at that commit was 48 variants plus module_fmha_v3_fwd. Two things moved afterwards, neither re-run as a full image build: the fp8 LSE arm was dropped (48 → 44 variants, below), and the whole-module set grew to three. module_aiter_core (28 s) and module_mla_asm (15 s) were each verified to build through the same driver path individually. The installed tree stays read-only (0755); a container started as an unrelated uid takes AITER's copy-to-~/.aiter fallback, measured at 226 MB in 97 ms, and sees every artifact. An earlier revision made the tree world-writable to skip that copy — reverted, because it put a writable directory on Python's import path for 97 ms of saving.

Why those two were added. PREBUILD_KERNELS=0 ships none of AITER's own modules. Running tests/rocm/test_mla_aiter.py in the built image took the jit dir from 49 artifacts to 51: AITER compiled module_mla_asm and module_aiter_core in-process. The second is on the attention path too, so both sat in front of a first request.

Correctness control. A driver-built varlen variant against the one AITER builds lazily: byte-identical size (3,123,528), 99 non-__hip_cuid_* symbols identical. Every prebuilt variant carries both architectures — offload bundle ids gfx942,gfx950 on the bf16 and fp8 batch-prefill artifacts alike.

An artifact can link with no kernel in it, and _check now catches that. A CK --filter that matches no instances still compiles and still produces a non-empty .so. The fp8 LSE arm was exactly this: 518,440 bytes against 5,437,120 for the same variant's working arm, and 20 ck_tile markers against 8,325. Those four are no longer built, and _check rejects any CK variant carrying fewer than 200 markers.

Soft-cap defect re-measured at 0.1.21.post2 over non-square shapes with the guard disarmed: gfx950 wrong on all 12 capped rows (9 at 1.19–4.11 absolute, 3 returning NaN) where fa2 is 0.0001–0.0011; gfx942 clean on all 12. The gate is unchanged; only its evidence string moves.

Pre-existing defect found while benchmarking, not introduced here

AITER's native paged batch prefill faults on gfx950 past kv=1024 at page_size=1024: Memory access fault by GPU node-1 ... FmhaBatchPrefillWithPagedKVCacheKernel. It reproduces identically on both arms — 4d322ab0 with aiter 0.1.20 and 6d80af63 with 0.1.21.post2 — so it is not a regression from the version move, and it is why the paged half of the benchmark above reports a single row. Recorded here rather than fixed: it is an AITER kernel fault and out of scope for this PR.

Test plan

  • tests/rocm/ full suite, gfx942 (MI300X): 29,983 passed, 3,722 skipped, 0 failed of 33,705
  • tests/rocm/ full suite, gfx950 (MI350X): 29,927 passed, 3,778 skipped, 0 failed of 33,705
  • Both suites run against a pin-run.sh detached worktree, verified un-drifted afterwards
  • All 69 rmsnorm/rope AITER tests, which fail at dlopen on 0.1.21.post2 before this branch
  • Stream-affinity tests A/B'd on gfx950: guards stripped → 2 failed / 3 passed; restored → 5 passed
  • Parity tests A/B'd: reverting the fp8 dtype set fails with aiter_loader.cc can emit ['fp8bf16', 'pertensor'], never built; reverting the --only fix fails with 8/9 present
  • All 8 fp8 variants build clean, ~66 s each, both architectures in the fatbin
  • fp8 paged prefill dispatches through a driver-built artifact and matches a bf16 reference (rel=0.0505); new tests/rocm/test_fp8_paged_prefill_aiter.py, 2 passed on gfx950
  • Image built end to end at the final state: 47/47 present — 44 variants (4 fp8) plus 3 whole modules, read-only 0755 jit dir, no leftover build/ tree, no hollow artifacts
  • Earlier end-to-end build at 49/49, before the fp8 LSE arm was dropped
  • MLA cold-build regression measured in the image (49 → 51 artifacts) and closed by prebuilding both
  • Image built end to end at both AITER_SOURCE=1 and AITER_SOURCE=0
  • AITER_SOURCE=0 with a sub-floor wheel fails the build loudly rather than shipping a silently-degraded image
  • Foreign-uid container (uid 12345, read-only 0755 tree) resolves to ~/.aiter/jit in 1.51 s with all artifacts visible
  • docker build --check clean on the final Dockerfile (only pre-existing UndefinedVar warnings, lines 140-141)
  • Soft-cap sweep, gfx942 and gfx950
  • /code-review xhigh on the changelist
  • pre-commit run on every changed file

demandal25 and others added 26 commits September 14, 2026 07:58
The benchmark skill told you how to time a kernel but not which candidates to
time, so "AMD ships an optimized library for this" has been read as a reason to
route to it. The record here says that is a coin flip: AITER prefill beats fa2
by 3.67x on gfx942 and 4.51x on gfx950, while the in-tree HIP kernel beats AITER
on rmsnorm/fused_add_rmsnorm by 1.6-1.8x and on append_paged_kv_cache at
3.62 TB/s against 2.86.

Adds a section saying to enumerate the ROCm libraries that already implement an
op -- AITER, CK/CK-Tile, hipBLASLt, rocBLAS, MIOpen, Triton -- benchmark each
against the in-tree kernel, and route auto to the winner per arch, recording it
as a Capability row with an evidence string. Also names the three things that
make the measurement wrong more often than the stopwatch does: a library can win
on time and still fail the contract (CK-Tile layernorm2d reads fp32 gamma/beta
as the input dtype), a library may dispatch internally so "AITER" is not one
number, and gfx942 and gfx950 have diverged.

Also records a measurement the paged-prefill bullet needed. It already warned
that the advertised native page sizes are a hint and that plan() degrades to
flat-gather. On amd-aiter 0.1.21.post2 all three advertised sizes fail the probe
with "no matching kernel found", on both gfx942 and gfx950:

    advertised native page sizes: [128, 256, 1024]
      page_size=  128  available=False
      page_size=  256  available=False
      page_size= 1024  available=False

So mha_batch_prefill is currently unreachable and the paged path should be
benchmarked as flat-gather.

Co-Authored-By: Claude <noreply@anthropic.com>
The `aiter_libs` cache tag is `<arch>__aiter-<version>`. ROCm was never a
component because the pinned wheel's version string carried it:

    amd-aiter 0.1.20+rocm10.1.0a20260819.3135022

A source-built AITER has no local version segment — `pip show amd-aiter` on a
`v0.1.21.post2` clone reports a bare `0.1.21.post2` — so that accidental key
disappears and two ROCm toolchains on one box share a cache directory. These
are CK-tile objects linked into the shims by `-L`/`-rpath`, and a mismatched
one loads rather than failing, so the failure mode is a wrong kernel, not an
error.

`_rocm_version()` already existed in aiter_variants for the #363 store tag;
move it down to aiter_source, which aiter_variants already imports from, and
use it in both. Its docstring claimed the aiter_libs cache "never leaves the
box that built it" as the reason ROCm was omitted there — true, and beside the
point, since a box's toolchain can change under a persistent cache.

The composed tag now gets the path-component guard the arch already had:
`torch.version.hip` is not this module's value to trust, and the arch check
alone would let a separator through in the ROCm field.

Co-Authored-By: Claude <noreply@anthropic.com>
`_aiter_csrc_include_dir` resolved only through the `aiter_meta` package. An
editable AITER install has no `aiter_meta` at all: `setup.py develop` takes an
`is_develop_mode()` branch that sets `packages = ["aiter"]`, so every shim
needing a C++ header — rope, rmsnorm, activation, fused MoE, batch decode —
failed with "is the aiter source package installed?" against an install that
plainly was.

Fall back to AITER's own `AITER_CSRC_DIR`. It derives from `AITER_META_DIR`,
which falls back to the repo root when `aiter_meta` has no `csrc/`, so in a
develop layout it names the clone's `csrc/` and in any layout it honours an
`AITER_META_DIR` override. Confirmed on the 0.1.21.post2 non-editable install
that both routes resolve to the same directory:

    AITER_CSRC_DIR = .../site-packages/aiter_meta/csrc
    same dir as aiter_meta/csrc/include = True

aiter_meta stays first: it needs no `import aiter`, and importing aiter runs
arch detection against a live device. The fallback pays that cost only where
the alternative was an exception.

Co-Authored-By: Claude <noreply@anthropic.com>
Both the module docstring and backends.md claimed prebuilding "cannot be a
`docker build` step". That conflates the driver with the machinery it calls.

`prebuild_aiter_variants` needs a GPU because it produces a variant by calling
the op, which launches a kernel. AITER's builder does not: `aiter/setup.py:212`
reaches it with

    sys.path.insert(0, ".../aiter/"); from jit import core

a flat import that never runs `aiter/__init__.py` — which is what needs a
device, for arch detection and a triton import that fails with "0 active
drivers ([]). There should only be one." Measured: a GPU-less container builds
`mha_fwd_bf16_nbias_mask_nlse_ndropout_nqscale` this way and `nm -D` finds the
expected `aiter::mha_fwd` symbol.

The claim matters because it rules out an image-build prebuild that is in fact
available, so correct it before anything is designed around it.

Co-Authored-By: Claude <noreply@anthropic.com>
A GPU-free driver that builds the attention variant .so at image-build time,
plus the two parity tests that keep it honest. No Dockerfile change yet, so
nothing in this commit runs in CI; commit 5 wires it up.

Why a second driver rather than reusing prebuild_aiter_variants: that one emits
a variant by *calling the op*, which needs a device. AITER's builder does not —
aiter/setup.py:212 reaches it by a flat `sys.path` import of `jit.core` that
never runs aiter/__init__.py, the part needing arch detection and triton.

Artifacts land in AITER's own jit/ dir rather than FlashInfer's store, which is
what makes mha_batch_prefill worth building at all. Its bootstrap doubles as the
page-size probe and runs before the loader, so only a hit AITER itself can see
spares the compile. Measured on gfx942:

    arm=warm   plan() =   0.2s    .so built during plan: 0
    arm=cold   plan() = 101.4s    .so built during plan: 1

Default set is 36 of 40: everything but mha_fwd's four LSE variants, the most
expensive builds in the set at 803 s each two-arch. LSE on *single* prefill
serves cascade/merge, and Blaze-O1's LUM3 — the consumer that pins this repo as
a submodule — records its footprint as "fp16, head_dim 64, batch mode, causal,
no soft-cap/bias/LSE". Batch mode is mha_fwd, so LUM3's exact file
(mha_fwd_fp16_nbias_mask_nlse_ndropout_nqscale.so) is in the default set and a
test asserts it. `--all` builds 40.

Verified end to end, GPU_ARCHS="gfx942;gfx950", against the lazily built
artifact as control — varlen is the control because its recipe comes from
AITER's own helper:

    lazy   : 3123528 bytes  168 symbols
    driver : 3123528 bytes  168 symbols
    99 non-cuid symbols identical; the 69 that differ are all __hip_cuid_*
    both carry amdgcn-amd-amdhsa--gfx942 and --gfx950

Two traps this cost, both now guarded by tests:

  - get_args_of_build("mha_fwd") *warns and returns empty args* instead of
    raising; the real key is "module_mha_fwd". The empty args dropped every
    include path and surfaced minutes later as "fmha_fwd.hpp file not found".
    _args_of_build now asserts srcs and includes are non-empty.
  - compose_mha_fwd_variant_suffix_and_filter is misnamed: it always emits
    _logits and _skip tokens, which is the *varlen* shape. Its only callers are
    varlen. mha_fwd composes inline with receipt 100 against varlen's 200, and
    spells its mask filter "_m*" where batch_prefill spells it "_mask*".

Child build output goes to a file, never a pipe: hipcc is verbose enough to fill
a pipe buffer, and a child blocked on a full pipe never exits.

All six parity assertions A/B'd by breaking the driver one way at a time:

    caught  mha_fwd mask filter -> _mask*
    caught  batch_prefill dtype filter gains a _
    caught  mha_fwd receipt 100 -> 200
    caught  batch_prefill loses its sink token
    caught  varlen token renamed
    caught  mha_fwd lse arm no longer skipped

Co-Authored-By: Claude <noreply@anthropic.com>
…ants

Wires up the driver from the previous commit. Deliberately still at v0.1.20 --
the ABI the image already runs -- so that a failure here is the *mechanism* and
a failure after the v0.1.21.post2 bump is the *revision*. Do not collapse the
two commits; the bisect depends on the split.

Source rather than wheel because it is the only way to compile AITER against
the ROCm in this image. No +rocm10.0 build of amd-aiter is published, so the
pinned wheel is a +rocm10.1.0a retarget of the same revision. AITER_SOURCE=0
restores that wheel install verbatim for an unblock.

Three things a reader cannot see by reading, so they are checked rather than
asserted in prose (tmp validator, not shipped):

  - The clone is created and deleted in ONE RUN. Splitting them leaves it in
    the earlier layer, where a later `rm -rf` cannot reach it -- CK alone is
    over a GB.
  - The driver COPY sits after the install, so editing the variant list re-runs
    only the prebuild.
  - Every AITER_* the new layers reference is declared with ARG in this stage.

Non-editable install on purpose: `setup.py develop` takes an is_develop_mode()
branch setting packages = ["aiter"] with no aiter_meta, and aiter_meta/csrc/include
is what every C++ shim compiles against. The non-editable path runs
prepare_packaging(), which copies csrc and the CK tree into aiter_meta -- which
is also what makes deleting the clone safe.

PREBUILD_KERNELS=0 because 1 rmtree's the jit dir and does
`from aiter.aot.flydsl.common import run_aot`, a real `import aiter`. Losing its
prebuilt modules costs nothing measurable: FlashInfer links its own
lib<module>.so built from the same sources, and of AITER's 111 prebuilt modules
only module_mla_asm is ever loaded (11.5 s, 0.2 MB).

The post-install check is a hard assertion, not a print. A shallow clone that
lost its tag makes setuptools_scm invent a .dev version, which falls under
aiter_utils.AITER_MIN_VERSION and silently degrades every AITER path to fa2 --
a working image that is quietly several times slower on prefill.

GPU_ARCHS must be explicit: there is no device here for AITER's arch detection,
and both architectures go in one fat binary.

Validated: 12 RUN instructions parse under `sh -n`, the embedded Python heredoc
parses, and all five invariants A/B'd by breaking each one and confirming the
check fires. This buildx has neither --check nor --call, and a real image build
is ~2h, so the build-time number lands in the PR description rather than here.

Co-Authored-By: Claude <noreply@anthropic.com>
The plan split this into three commits -- shim migration, then AITER_REF, then
the floor raise -- on the claim that the POD overloads already existed at
0.1.20 and the migration could land first. They do not:

    0.1.20  rmsnorm_quant.h:37  void rmsnorm(torch::Tensor& out, ...)
    0.1.20  grep aiter_tensor_t {rmsnorm,rmsnorm_quant,rope}.h  ->  no matches
    0.1.21  aiter::rmsnorm(aiter_tensor_t&, aiter_tensor_t&, aiter_tensor_t&,
                           double, bool)

Either order leaves a commit whose rmsnorm/rope backend="aiter" tests fail at
dlopen with `undefined symbol: _ZN5aiter7rmsnormERN2at6TensorES2_S2_db`. They
are one atomic change. The floor raise joins them because a 0.1.20 user would
otherwise clear the gate and then fail to load.

The 5/6 bisect split the plan wanted is intact: commit 5 proved the *mechanism*
at 0.1.20, this moves the *revision*.

  - norm_aiter.cu and rope_aiter.cu now declare the POD signatures and convert
    through csrc/rocm/aiter_tensor_compat.h, matching activation/page/fused_moe,
    which migrated at 0.1.16 and already pass. Named lvalues at the call sites:
    the out parameters are non-const references, so a to_aiter() temporary would
    not bind.
  - AITER_MIN_VERSION 0.1.20 -> 0.1.21, _AITER_LAST_VALIDATED -> 0.1.21.post2
    (no +rocm segment; a source build has none), the vendored header pins, and
    kAbiPinNote, which now names the source build rather than a dead pip line.

Not touched: arch_caps.py evidence strings stay at 0.1.20 because they assert a
measurement that has not been repeated -- honestly stale beats quietly wrong.
The soft-cap re-measurement is the next commit.

Two plan items turned out to be non-issues, checked rather than assumed:
_AITER_SOFTCAP_DEFECT_THROUGH appears only in message text, and
test_single_prefill_kernels.py:92's skip is already driven by
arch_caps.aiter_softcap_defect_arch, so neither stops firing at 0.1.21.post2.

A/B by construction: all 69 rmsnorm/rope AITER tests failed at dlopen on
0.1.21.post2 before this change and pass after, measured on gfx942. The version
floor table gains 0.1.20 -> False and 0.1.21.dev0 -> False.

Co-Authored-By: Claude <noreply@anthropic.com>
The set was trimmed on an estimate that turned out 2-5x too slow. Measured in a
real image build at --jobs 2 on 32 cores:

    family                  n  measured s  census est   ratio
    mha_fwd                 4         162         803    5.0x
    mha_varlen_fwd         16          65         140    2.2x
    mha_batch_prefill      16          68         206    3.0x

    36 variant(s) in 23 min at --jobs 2
    36/36 present in .../aiter/jit

The census figures came from Step 0's S0.7, which ran two concurrent builds at
MAX_JOBS=16 each on purpose. The driver sets no such cap, so ninja uses the whole
box (-j 307) and every family is far cheaper than the plan assumed.

That dissolves the trade-off the trim was making. Dropping mha_fwd's four LSE
variants saves ~5 min of CI, once, and costs a multi-minute in-process stall to
whoever first calls single_prefill_with_kv_cache(..., return_lse=True). The whole
40 lands at roughly 26 min. `--only` still narrows by family for local work.

Also removes --all, which now has nothing to select.

Co-Authored-By: Claude <noreply@anthropic.com>
The gate is unchanged -- gfx950 defective, gfx942 clean -- but it was recorded
against 0.1.20 and the version move made that evidence stale. Re-measured on
both boards, guard disabled so the kernel actually runs, non-square shapes at
head_dim=128 causal, fp16, vs an fp32 reference.

gfx950 (MI350X, slurm 67930310), amd-aiter 0.1.21.post2, ROCm 10.0:

      qo     kv    cap  |aiter-ref|   |fa2-ref|
       1   1024     30       1.1930      0.0001
       1   1024     50       2.6929      0.0001
      17   1024     30          NaN      0.0001
      37   4096     50       2.6767      0.0000
     128    512     50       3.3845      0.0003
     256   1024     50       3.4392      0.0002
    1024   1024     50       4.1128      0.0010
    12 cap>0 rows, 12 wrong (9 by magnitude, 3 returning NaN)

gfx942 (MI300X), same version and sweep: 12 cap>0 rows, 0 wrong, worst 0.0003.
cap=0 is clean on both boards, which is why the guard must stay narrow rather
than becoming a KnownBad row over the whole op.

Three things this sweep had to get right, each of which produced a wrong answer
first:

  - The guard has to be disabled. With it on, every gfx950 capped row raises
    FlashInfer's own ValueError and the sweep reports "not reproduced" -- a
    clean result meaning "never ran". The script now exits 2 as INCONCLUSIVE
    when no capped row executes, so that cannot read as a pass again.
  - NaN output is a wrong answer, not a skipped row. Scoring it as "raised"
    understated the defect by three rows; nothing threw in either run.
  - qo_len > kv_len is dropped: causal with more queries than keys is ill-posed
    and *both* backends fail, which says nothing about AITER.

_AITER_SOFTCAP_DEFECT_THROUGH moves to 0.1.21.post2. It is message text only;
the gate is arch_caps.aiter_softcap_defect_arch, which this leaves alone.

Co-Authored-By: Claude <noreply@anthropic.com>
The docs told users to pip-install a wheel and warned that a source build was
untested. The image now does the opposite, so every one of those passages is
not merely stale but actively wrong.

Rewritten rather than renumbered, because the reasons changed too:

  - The wheel recipe becomes the tagged source build, with the three details
    that actually bite: build by tag (a lost tag makes setuptools_scm invent a
    .dev version that falls under the floor and silently degrades every AITER
    path to fa2), non-editable (develop ships no aiter_meta), and
    PREBUILD_KERNELS=0 (=1 needs a GPU, and its modules are no loss).
  - The cp312 rationale is gone. It held because every 0.1.20 wheel was cp312
    only; a source build follows whichever interpreter is present, so the
    interpreter is now a property of the base image.
  - The "+rocm10.1.0a is the nearest retarget" apology is replaced by the
    headline correctness win: a source build compiles against the ROCm in the
    image rather than a retarget of another one.
  - CLAUDE.md's "a source build tracks master, treat as untested here" is
    reversed. Its neighbouring "That gap is a trap" sentence lost its
    antecedent with that paragraph, so it is reworded to stand alone -- the
    advice to read the installed tree rather than a checkout is still right.
  - CONTRIBUTING.md documents AITER_REF, AITER_PREBUILD_JOBS and AITER_SOURCE=0
    in place of the wheel args.

Left alone deliberately: backends.md:435's "measured exact on amd-aiter 0.1.20"
is a record of when a measurement happened, not a pin.

The notebook edit is a two-line text change; cell and output counts are
unchanged (17 and 17) after the JSON round-trip.

Co-Authored-By: Claude <noreply@anthropic.com>
Self-review (/code-review xhigh) before push. One finding was a regression this
branch introduced; the rest are its consequences.

**The stream guard.** csrc/rocm/aiter_tensor_compat.h says it outright: "The POD
entry points launch on aiter::getCurrentHIPStream(), which defaults to nullptr
and is otherwise set only by AITER's Python layer." All three shims that
migrated at 0.1.16 install a StreamGuard; the two this branch migrated did not:

    activation_aiter.cu      1
    fused_moe_aiter.cu       1
    page_aiter.cu            1
    norm_aiter.cu            0   <- added here
    rope_aiter.cu            0   <- added here

Under `with torch.cuda.stream(s)` -- which is how vLLM and SGLang drive a rank --
the kernel went to the null stream while `at::empty_like` and the `copy_` back
ran on `s`: a silent race, and an abort under graph capture. The at::Tensor
entry points picked the torch stream up themselves, so nothing caught it.

**AITER_SOURCE=0 installed a wheel below the floor this branch raised.** The
build stayed green and every AITER path silently stopped routing -- the exact
failure the surrounding assertions exist to prevent. The assertion block now
rejects any resolved version under AITER_MIN_VERSION, wheel arm included.

**The csrc-include fallback was unreachable.** `_aiter_importable()` still did a
hard `import aiter_meta`, so on the editable layout the fallback exists to serve,
routing gave up long before the resolver ran. It now probes through
`_aiter_csrc_include_dir()` instead, which is the thing that actually has to work.

Smaller, same review: `AITER_MIN_VERSION`'s comment still argued for 0.1.20;
kAbiPinNote had lost its actionable command and now names backends.md; the
benchmark-kernel skill still stated the old floor on the line that was edited
for this pin; the tutorial still promised native paged page sizes that fail the
probe at 0.1.21.post2; `_run_jobs` leaked children on the way out, which matters
because a zombie-held AITER build lock is never treated as stale; two driver
docstrings carried measurements that belong in a commit message.

Dropped `test_the_blaze_lum3_variant_is_in_the_default_set`: the proper noun it
cites appears nowhere in-tree, and `test_nothing_is_trimmed_from_the_built_set`
already covers the assertion.

128 tests pass after the shim rebuild the StreamGuard forces.

Co-Authored-By: Claude <noreply@anthropic.com>
The StreamGuard regression fixed in the previous commit passed all 69 norm and
rope AITER tests while it was live. Every one of them runs on the default
stream, where the null stream AITER falls back to and the caller's stream are
the same stream, so the race cannot appear. A reviewer found it by reading.

These tests run the same ops inside `with torch.cuda.stream(s)` with real work
queued ahead, which is how vLLM and SGLang drive a rank.

A/B, guards stripped from both shims and the shim cache cleared:

    ..F.F     2 failed, 3 passed
    FAILED test_rmsnorm_aiter_is_correct_on_a_side_stream[4096]
    FAILED test_rope_aiter_is_correct_on_a_side_stream

Restored, 5 passed. Only the larger shapes lose the race -- hence hidden=4096
in the parametrize and the matmul ballast, without which nothing fails. A race
test catches a subset by nature; two of five is enough to stop this returning
silently, which is the whole problem with it.

Co-Authored-By: Claude <noreply@anthropic.com>
Three defects found while verifying the image, each by running it rather than
reading it.

**603 MB of dead weight shipped.** The 40 variants are 224 MB; AITER's
jit/build/ staging -- CK blob sources and objects, ~15 MB per variant -- was
another 603 MB, three quarters of the directory. Proven removable rather than
assumed: with build/ deleted, a paged prefill plan() built 0 artifacts, because
AITER decides "already built" from the .so. The driver now removes each tree on
success and keeps it on failure, where it is the only diagnostic.

**The floor check compared strings.** `Version(got).base_version <
Version(FLOOR).base_version` is lexicographic, so it would have admitted 0.1.9
against a 0.1.10 floor. Comparing Version objects is both correct and simpler;
PEP 440 already puts 0.1.20+rocm10.1.0a below 0.1.21 and 0.1.21.dev0 below
0.1.21, which is what the floor wants.

**The wheel fallback could not build at all.** With the floor enforced,
AITER_SOURCE=0 correctly refused its 0.1.20 pin -- leaving a documented escape
hatch that always fails. The index does publish 0.1.21.post2, but only
+rocm7.14.0 and +rocm7.2.3: no ROCm 10 build exists at this revision, which is
the clearest statement yet of why the default path builds from source. The arm
now pins the 7.14 retarget and CONTRIBUTING says plainly that it is an unblock.

Also regenerates tmp/Dockerfile.c5probe from docker/Dockerfile.rocm instead of
hand-copying it. The hand-copied version silently lacked the floor assertion, so
the first AITER_SOURCE=0 run "passed" a check the real file would have failed --
a test that proved nothing, which is worse than no test.

Cold-start A/B on the built image, fresh container, same FlashInfer JIT cost in
both arms so the delta is AITER's:

    family                        prebuilt    cold     saved
    mha_fwd (single prefill)         52.5s   485.9s    433s
    mha_varlen_fwd (soft cap)        41.6s   156.7s    115s
    mha_varlen_fwd (ragged)          39.7s   177.5s    138s
    mha_batch_prefill (paged)         0.4s   129.3s    129s
    total                           134.2s   949.4s    815s  (7.1x)

Co-Authored-By: Claude <noreply@anthropic.com>
Rebasing onto amd-integration picked up #365, which routes single prefill to
AITER's asm arm on CDNA4 and dlopens module_fmha_v3_fwd.so. Its own failure
hint says why that is safe:

    module_fmha_v3_fwd.so ships prebuilt in the amd-aiter wheel, so unlike the
    mha_fwd variants there is no JIT build to trigger. Its absence means the
    wheel is incomplete or the JIT dir points elsewhere.

This branch replaces the wheel with a PREBUILD_KERNELS=0 source install, which
ships no prebuilt modules at all. Measured on the image built from this branch:

    module_fmha_v3_fwd.so present: NO
    module_* present: 0

So the two changes are individually correct and jointly broken: #365's default
CDNA4 prefill route would throw at dlopen in the new image, with a message
blaming an incomplete wheel that is not being used.

The driver now builds whole modules as well as variants. module_fmha_v3_fwd
takes 96 s GPU-free at GPU_ARCHS="gfx942;gfx950" and needs no filter, because
AITER resolves the asm kernel from its own config table per call rather than
from filename axes.

The durable part is the parity test: it greps aiter_loader.cc for every
"module_*.so" it opens and fails when the driver's set does not cover them.
A/B'd by emptying LOADER_MODULES -- caught. Without it the next module added to
the loader repeats this silently, since nothing else connects the two files.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous commit taught the driver to build module_fmha_v3_fwd but its own
--check still enumerated only the 40 variants, so the image-build gate would
pass with the asm module missing -- the exact hole that commit set out to close.
Visible in that build's own output: the job runner counted [41/41] while the
check printed 40/40.

The edit that was meant to do this silently matched nothing, because ruff-format
had rewrapped the function between writing and applying it, and unlike the other
edits in that batch this one asserted no anchor.

Verified against the image built from the previous commit:

    520896 bytes .../aiter/jit/module_fmha_v3_fwd.so
    mha_*.so: 40
    amdgcn-amd-amdhsa--gfx942 amdgcn-amd-amdhsa--gfx950

The artifact is there and carries both code objects; only the count was wrong.

Co-Authored-By: Claude <noreply@anthropic.com>
The full suite on a box whose AITER is 0.1.20 -- below the floor this branch
raises -- failed six tests that pass on the image it ships. Both causes are the
tests trusting whatever AITER the host happens to have.

**The new stream tests guarded on `is_aiter_supported`**, which answers for the
architecture alone. On a sub-floor box they ran anyway and died inside
`require_aiter` rather than skipping. They now use `requires_aiter`, the helper
every other AITER test in tests/rocm uses, which is why those skipped cleanly on
the same run.

**`test_a_missing_aiter_package_is_its_own_reason` stubbed importability but not
presence.** `_auto_select_prefill_backend` reports the ABI-floor reason when a
sub-floor AITER *is* installed and the not-installed reason otherwise, so with
0.1.20 present the test asserted on the wrong branch:

    assert 'aiter package not installed' in 'amd-aiter 0.1.20+rocm10.1.0a...
    is below the 0.1.21 ABI floor (the vendored struct layouts do not match
    older releases)'

It now pins absence as well, which is what its name claims. `TestRequireAiterRuntime`
directly below already does this, for the stated reason that which branch raises
"does not depend on the amd-aiter the host happens to have installed".

Verified both ways:

    aiter 0.1.21.post2   43 passed
    aiter 0.1.20         sssss...........s.......ss.................  exit 0

Co-Authored-By: Claude <noreply@anthropic.com>
The driver enumerated bf16 and fp16 only, so an fp8 deployment got nothing
from this branch: every fp8 paged prefill still compiled AITER inside the
serving process, which is the stall the prebuild exists to remove.

fp8 is reachable and batch-prefill only. prefill.py routes every fp8 query
through the native paged kernel because the flat-gather route runs
mha_varlen_fwd, which has no fp8 arm, and the in-tree fa2 kernel rejects
8-bit types at compile time. All three AITER mha families carry an fp8 arm
upstream, but kFp8Bf16 is constructed at exactly one site in this tree
(batch_prefill_paged_aiter.cu:110), so the reachable set is 8 names, not 24:
mha_batch_prefill_fp8bf16_{logits,nlogits}_nbias_{mask,nmask}_{lse,nlse}
_ndropout_pertensor_nsink.so. All 8 build clean, ~66 s each.

The old parity test could not have caught this: it compared the driver's
table against flashinfer's, and both omit fp8. The replacement reads the
token literals out of aiter_loader.cc instead. A/B'd by reverting the dtype
set -- it fails with "aiter_loader.cc can emit ['fp8bf16', 'pertensor'],
never built".

flashinfer's own table stays at 40. It serves the store route, which has no
fp8 arm; its docstrings claimed to enumerate everything the loader can ask
for, which was already false and is now corrected.

Co-Authored-By: Claude <noreply@anthropic.com>
… readable

Three defects found by review, each verified by reproducing it.

--only built the family it was asked for and then failed its own check on
the whole-module set it deliberately skips:

    $ prebuild_aiter_attention.py --only mha_fwd
    8/9 present in .../aiter/jit
    missing or empty:
      module_fmha_v3_fwd.so        # exit 1 on a healthy tree

_check now takes the same `extra` set the invocation builds.

build_module was called directly, which skips the HIP_CLANG_PATH export that
AITER's own compile_ops wraps the build in. optCompilerConfig.json sets it
from MHA_HIP_CLANG_PATH for module_mha_fwd and module_mha_varlen_fwd, so with
that variable set the prebuilt .so would come from a different clang than the
lazy build -- falsifying the byte-identical claim. Unset in this image, so no
artifact shipped so far is affected.

The chmod is the one with teeth. AITER's get_user_jit_dir() copies the whole
jit tree into $HOME when its own directory is unwritable, and the tree is
owned by uid 1003. Any container run with --user of another uid therefore
sees none of the prebuilt artifacts -- and dies outright where HOME is not
writable, which is not a fallback but a crash:

    PermissionError: [Errno 13] Permission denied: '/nonexistent'
      in get_user_jit_dir() -> shutil.copytree(this_dir, home_jit_dir)

Verified fixed: the same foreign uid now resolves to the installed jit dir.
--user is the documented run mode, so this would have hit normal use.

Co-Authored-By: Claude <noreply@anthropic.com>
… comment cap

Moving _rocm_version into aiter_source let _aiter_cache_tag gain the ROCm
component that variant_store_dir already had, which left the tag shape and
its path-safety guard written out twice in two modules. The next change to
either could land in one and not the other, and the two cache directories
would then disagree about staleness. Both now call compose_cache_tag().

The rest is the repo's own comment cap, which this branch had been ignoring:
the driver's module docstring ran 27 lines and carried a measurement, and two
more docstrings ran long. Measurements belong in these commit messages.

Also drops a stale claim in test_aiter_version_gate.py, which still said the
vendored structs follow the 0.1.20 layout after the floor moved to 0.1.21.

No behaviour change: the driver diff is docstrings only, and the tag helper
composes the same string from the same inputs.

Co-Authored-By: Claude <noreply@anthropic.com>
…ng guard

A/B on gfx950, guards stripped from norm_aiter.cu and rope_aiter.cu and the
shim rebuilt from a cold cache:

    guards in source: 0   -> 2 failed, 3 passed
      FAILED test_rmsnorm_aiter_is_correct_on_a_side_stream[4096]
              NaN, 1048576 / 1048576 mismatched
      FAILED test_rope_aiter_is_correct_on_a_side_stream
              518956 / 524288 mismatched, 13.5 absolute
    guards in source: 3   -> 5 passed

So the suite does catch the regression, but not through the case whose
docstring claimed it: fused_add_rmsnorm passes either way, because its
buffers are cloned on the default stream before the call and so never meet
the stream-ordered allocator hazard the other two hit. Left as coverage of
the in-place path with the claim corrected rather than strengthened -- a race
that reproduces sometimes is worse than two that reproduce every run.

Co-Authored-By: Claude <noreply@anthropic.com>
Nothing in tests/rocm exercised fp8 attention, which left the eight fp8
variants the previous commit added with no end-to-end check at all. That
matters more than the usual coverage argument: the driver's `_check` only
proves the .so exists and is non-empty, so a CK --filter that matched zero
instances would still link, still pass the build, and fail only at dispatch.

Measured on gfx950 with the driver-built artifacts in place -- AITER loads
the file our composed name asks for and the kernel runs:

    [aiter] import [mha_batch_prefill_fp8bf16_nlogits_nbias_mask_nlse
                    _ndropout_pertensor_nsink] under .../aiter/jit/
    fp8 aiter ok, out=(128, 8, 128) torch.bfloat16
    mean abs err 0.0031   rel 0.0505   max 0.0293

The tolerance is deliberately loose. This asserts the kernel dispatched and
did not return garbage or NaN, not that fp8 quantisation is accurate; a tight
bound here would fail on the quantisation error rather than on a defect.

The second case pins the refusal: fa2 has no fp8 kernel, and without an
explicit raise the caller gets a ninja compiler log from a static_assert.

Co-Authored-By: Claude <noreply@anthropic.com>
…ong job first

Three from review, all in the prebuild path.

_run_jobs killed its in-flight children on the way out but never reaped them.
AITER treats a build baton as stale only when kill(pid, 0) fails, which a
zombie child of a living parent answers successfully -- so an interrupted run
left the next build for that md_name blocking forever, indistinguishable from
a slow compile. It also leaked the mkstemp log of every killed job.

The chmod recomputed AITER's jit directory from the package path while the
driver wrote to core.get_user_jit_dir(), which honours AITER_JIT_DIR and falls
back to $HOME. The two agree in this image and could silently stop agreeing,
chmod-ing an empty directory while reporting success -- reintroducing exactly
the foreign-uid failure the chmod exists to prevent. The driver now prints the
directory it resolved (--print-jit-dir) and the Dockerfile chmods that.

a+rwX rather than a+w: the intent is that any uid can write into the tree, and
X grants directory traversal without marking regular files executable.

module_fmha_v3_fwd is the longest single job -- it compiles the whole asm FAV3
source set rather than one filtered CK instance group -- and was queued last,
so at --jobs 2 it ran alone on the tail with a core idle. Queued first it
overlaps the variants.

--list and the closing summary now count the whole-module builds too; before,
progress said [49/49] while the summary said 48 and --list named neither.

Co-Authored-By: Claude <noreply@anthropic.com>
The benchmark skill's page-size bullet stated {128, 256, 1024} and called
{1, 16, 1024} "wrong". It has it backwards: _aiter_native_page_sizes() returns
frozenset({1, 16, 1024}) for any AITER >= 0.1.10. The bullet also called the
{16, 1024} arm unreachable at the 0.1.21 floor, when it is the
PackageNotFoundError fallback and reachable whenever metadata is missing.

Its measured claim was stale too -- "all three advertised sizes fail the probe
... mha_batch_prefill is unreachable". On 0.1.21.post2 the native paged kernel
runs: 62 TFLOPS at page_size=1024, kv=1024 on gfx950. What is true, and now
recorded, is that the sweep has to stop there; beyond kv=1024 it faults inside
FmhaBatchPrefillWithPagedKVCacheKernel on 0.1.20 and 0.1.21.post2 alike.

Also adds importorskip to the two aiter_meta probes. They were the only
aiter-dependent tests in the tree without a guard, and would error rather than
skip on precisely the layouts the fallback they cover was written for.

Co-Authored-By: Claude <noreply@anthropic.com>
a+rwX on the whole tree left every compiled kernel -rwxrwxrwx in the shipped
image, so any uid inside the container could replace a .so the process later
dlopens. AITER only needs to *create* files in that directory -- its
get_user_jit_dir() check is os.access(dir, W_OK) -- so the write bit belongs on
the directories alone.

Measured in the image after the change: directory 777, artifact 755.

Co-Authored-By: Claude <noreply@anthropic.com>
… cold

PREBUILD_KERNELS=0 ships none of AITER's own modules, and LOADER_MODULES
covered only module_fmha_v3_fwd -- the one that throws at dlopen. The two that
merely stall were missed, so the Dockerfile comment calling the lost prebuilts
"no loss" was wrong on its own terms: it already noted aiter.mla loads one.

Measured in the built image, running tests/rocm/test_mla_aiter.py against a
49-artifact jit dir: it came back with 51. The two AITER built in-process were
module_mla_asm and module_aiter_core, the latter also built by the attention
path, so both sit in front of a first request rather than off to one side.

Both build GPU-free through the same driver path as the variants:

    ok   module_aiter_core  28s
    ok   module_mla_asm     15s

43 s of image build to take that cost out of every container start.

Co-Authored-By: Claude <noreply@anthropic.com>
… stale claims

The previous commit narrowed the chmod to directories and claimed that stopped
any uid swapping a compiled kernel. It did not: unlink is governed by the
directory's write bit, not the file's mode, so a+w on the directory let any uid
delete a .so and write its own in its place. Directories now go a+wt -- the
sticky bit leaves removal to the owner -- which is what the comment claimed.

GPU_ARCHS was a command prefix on the prebuild, so the --print-jit-dir call on
the next line ran without it. It resolves the same path either way here, but a
chmod aimed at a different directory than the one just populated is the exact
silent failure this line exists to prevent; now exported for both.

Three claims contradicted by this branch's own measurements:

- The tutorial said the native paged probe "fails for every advertised size on
  both architectures, so this path always gathers". It serves page_size=1024,
  and 16 for fp8 -- which the new fp8 test depends on, since an fp8 query that
  lands on flat-gather raises rather than degrading.
- refresh_aiter_jitspec's docstring still named the two-component cache tag
  that this branch replaced with a three-component one.
- MLA's ImportError still said "Install a wheel", now the unsupported path.

Also trims the soft-cap comment to its conclusion; the per-row numbers are in
fa4383e's message, which is where a measurement belongs.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 14, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The world-writable installed JIT directory creates a code-loading risk, and the installation recipe and parity test need correction.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Pins AITER to v0.1.21.post2, builds it from source, prebuilds attention artifacts, and migrates affected ROCm shims to the POD tensor ABI.

Changes:

  • Adds GPU-free prebuilding for 48 attention variants and three AITER modules.
  • Migrates RMSNorm and RoPE shims to aiter_tensor_t.
  • Updates cache keys, validation, tests, and installation documentation.
File summaries
File Description
.claude/skills/benchmark-kernel/SKILL.md Updates AITER benchmarking guidance.
CLAUDE.md Documents the pinned source build.
CONTRIBUTING.md Documents new Docker build arguments.
README.md Updates the supported AITER version.
csrc/rocm/aiter_loader.cc Updates ABI pin diagnostics.
csrc/rocm/norm_aiter.cu Migrates norm calls to the POD ABI.
csrc/rocm/rope_aiter.cu Migrates RoPE calls to the POD ABI.
docker/Dockerfile.rocm Builds and precompiles AITER.
docker/prebuild_aiter_attention.py Adds the prebuild driver.
docs/rocm/backends.md Documents source installation.
examples/amd_flashinfer_rocm_tutorial.ipynb Updates tutorial requirements.
flashinfer/jit/rocm/aiter_source.py Unifies cache tags and header discovery.
flashinfer/jit/rocm/aiter_variants.py Uses the shared cache tag.
flashinfer/rocm/aiter_utils.py Raises the ABI floor and adjusts probes.
flashinfer/rocm/arch_caps.py Updates soft-cap evidence.
flashinfer/rocm/mla.py Updates installation guidance.
flashinfer/rocm/prebuild_aiter_variants.py Clarifies GPU requirements.
flashinfer/rocm/prefill.py Updates validated and defective versions.
include/flashinfer/rocm/attention/aiter/mha_batch_prefill_args.h Updates ABI provenance.
include/flashinfer/rocm/attention/aiter/mha_fwd_args.h Updates ABI provenance.
tests/rocm/test_aiter_source_probes.py Tests cache and header fallback behavior.
tests/rocm/test_aiter_stream_affinity.py Tests non-default stream correctness.
tests/rocm/test_aiter_version_gate.py Tests the new version floor.
tests/rocm/test_fp8_paged_prefill_aiter.py Tests FP8 paged prefill.
tests/rocm/test_prebuild_aiter_attention.py Tests prebuild parity and coverage.
tests/rocm/test_prefill_decode_dispatch.py Stabilizes missing-package testing.
Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 3
  • Review effort level: Balanced

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

Comment thread docker/Dockerfile.rocm Outdated
Comment thread tests/rocm/test_prebuild_aiter_attention.py Outdated
Comment thread docs/rocm/backends.md Outdated
…sites too

Removing ENV HOME in d56e274 put back the ordering hazard a4daaa5 had
fixed: the prebuild resolved AITER's jit directory under whatever HOME the base
image left. Rather than manage HOME, assert the outcome. The driver already
prints the directory it resolved, so the build now fails loudly if the
artifacts land anywhere but the installed tree -- which is the only place a
runtime container will look for them. Nothing is made writable to achieve it.

The version-assertion heredoc is the one BuildKit-only instruction in the file,
so it now declares the frontend instead of relying on the daemon default.

_loader_name_tokens() scanned only between dtype_token and build_so_name, which
excludes the prefix/infix/suffix literals the three call sites pass in -- the
very place _nsink lives. A/B, dropping _nsink from the driver:

    AssertionError: aiter_loader.cc can emit ['nsink'], never built

The old range could not see that at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 15, 2026 03:24
@demandal25

Copy link
Copy Markdown
Collaborator Author

Review loop closed at a27cf11

Two Copilot rounds, 7 findings, all dispositioned in-thread. Closing here — the recurring item is settled and what remains are declines with evidence. Later rounds re-raising these should be read against this comment.

The recurring item: a writable directory in the image. Raised three times, in three different places. Settled by not widening anything: the installed aiter/jit stays 0755, no chmod, no 1777 HOME. The fallback it was avoiding costs 226 MB in 97 ms and finds every artifact, and --user is already omitted for AITER work because it makes that directory unwritable. a27cf1167 pins the destination by assertion instead, so a build whose artifacts land anywhere but the installed tree fails loudly.

Declined, with what settles each:

  • Cache key uses torch.version.hip, not the build toolchain. Correct, and out of scope: this PR added the ROCm component. Before it the tag was <arch>__aiter-<ver> and a toolchain change collided outright. Keying off hipcc is a refinement of the fix, not a defect in it.
  • GPU_ARCHS differs between build (gfx942;gfx950) and runtime (single arch), so the prebuilds may be ignored. Measured, not argued: running tests/rocm/test_mla_aiter.py in the built image took the jit dir from 49 artifacts to 51 — AITER built the two it lacked and accepted all 49 under the narrower runtime value. Cold start is 134 s against 949 s for the same reason.
  • _aiter_importable() now depends on the AITER header tree. Pre-existing and loosened here: the probe previously required import aiter_meta outright, where it now accepts either that or AITER_CSRC_DIR.
  • fp8 is only prebuilt for batch-prefill. kFp8Bf16 is constructed at exactly one site in the tree (csrc/rocm/batch_prefill_paged_aiter.cu:110); the other two families never form an fp8 key.
  • Partial rotary may be broken by the POD migration. tests/rocm/test_rope_aiter.py:32 parametrizes (128, 64, 8, 2) for exactly that case, and all 69 rmsnorm/rope AITER tests pass on gfx942 and gfx950.

Deferred, named rather than carried silently: the evidence= strings on the non-soft-cap rows in arch_caps.py still cite aiter 0.1.20. Only the soft-cap row was re-measured at 0.1.21.post2. Re-measuring the full capability table is its own piece of work.

What this needs next is a human reader.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The driver builds unreachable FP8 LSE artifacts and can oversubscribe compiler resources during parallel image builds.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

docker/prebuild_aiter_attention.py:188

  • The fp8 branch also iterates has_lse=True, producing four artifacts that cannot be requested. flashinfer/rocm/prefill.py:3334-3341 rejects every fp8 call with LSE before dispatch because AITER has no fp8 LSE instance. These builds therefore add several minutes to every image build while yielding unusable .so files; restrict fp8 to has_lse=False and update the expected variant count.
    docker/prebuild_aiter_attention.py:405
  • --jobs starts multiple AITER builds, but each child independently lets AITER choose about 80% of all host CPUs (and computes its memory limit from the same free-memory snapshot). With the default --jobs 2, this can run roughly twice the intended compiler workload and exceed the host's memory budget during docker build. Pass each child a bounded per-process MAX_JOBS (or safely coordinate AITER's PREBUILD_THREAD_NUM) so total Ninja parallelism is divided across these processes.
  • Files reviewed: 26/26 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

demandal25 and others added 2 commits September 15, 2026 00:21
…ls in it

Both suppressed comments from the third review, and the first proves the second.

fp8 was enumerated over both LSE arms. It has none: prefill.py:3334 raises
"fp8 prefill cannot produce LSE: AITER builds no LSE instance of the fp8 kernel
at any page size", and prefill.py:1019 already excluded it for the store route.
The driver did not, so four of the eight fp8 builds asked CK for an instance set
that does not exist. They compiled, linked, and passed --check:

    518,440  ..._logits_nbias_mask_lse_...pertensor_nsink.so    <- all four LSE
    518,440  ..._logits_nbias_nmask_lse_...
  5,130,416  ..._nlogits_nbias_nmask_nlse_...                   <- all four non-LSE
  5,437,120  ..._logits_nbias_mask_nlse_...

Ten times smaller, because they are a dispatcher with nothing behind it. 44
variants now, not 48.

That is the failure mode three reviews called speculative, so _check now looks
for it: an artifact carrying fewer than 200 `ck_tile` markers has no instances.
Measured on the two arms of the same variant -- 20 markers against 8,325 --
which is what sets the threshold. Applied to the CK variants only; the
LOADER_MODULES are built -DENABLE_CK=0 and legitimately have none.

Also bounds each child's ninja parallelism. AITER computes MAX_JOBS from ~80% of
*all* host CPUs and its own free-memory snapshot, per process, so --jobs N
oversubscribed the box N-fold; the children now divide one budget.

Co-Authored-By: Claude <noreply@anthropic.com>
…our others

The memory fault this branch found while benchmarking was recorded only in the
benchmark skill, but _aiter_paged_route_page_sizes sends bf16/fp16 to exactly
the page size that faults, so the note now sits beside that return. Not gated:
it reproduces on 0.1.20 as well, and the failing kv boundary has not been
bounded -- gating on a guess would disable a path that works below it.

_child_env deferred to a pre-set MAX_JOBS, which CLAUDE.md documents as the way
to limit a build, so the documented knob defeated the split it was added for.
It now divides that budget: MAX_JOBS=4 with --jobs 2 gives each child 2.

_aiter_importable no longer probes for AITER's header tree. It answers "can
AITER run?", and a shim that is already built or AOT-cached needs no headers;
the build path raises with the real cause when they are what is missing. Raised
in four separate reviews, each time correctly.

The Dockerfile now resolves and checks the jit destination before the 35-minute
prebuild rather than after it, _has_ck_instances reads in 1 MB chunks instead of
pulling a whole multi-MB artifact into memory, and compose_cache_tag rejects the
same character class its test asserts on.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 15, 2026 04:34
@demandal25

Copy link
Copy Markdown
Collaborator Author

Suppressed comments, review 3 (2) — both accepted, fixed in e441b65

These have no thread, so recording them here. Both were right, and the first one proves the second.

prebuild_aiter_attention.py:188 — the fp8 LSE arm is unreachable. Correct, and the tree already said so: prefill.py:3334 raises "fp8 prefill cannot produce LSE: AITER builds no LSE instance of the fp8 kernel at any page size", and prefill.py:1019 already excluded it for the store route. The driver did not. Those four builds do not just waste time — they produce artifacts with no kernel in them:

    518,440  ..._logits_nbias_mask_lse_...pertensor_nsink.so     <- all four LSE
    518,440  ..._logits_nbias_nmask_lse_...
  5,130,416  ..._nlogits_nbias_nmask_nlse_...                    <- all four non-LSE
  5,437,120  ..._logits_nbias_mask_nlse_...

44 variants now, not 48.

This also settles a claim declined three times. That a CK --filter matching nothing still links and passes _check was called speculative in earlier rounds, including by me. It was not: the same variant measures 20 ck_tile markers with no instances against 8,325 with them. _check now rejects any CK variant under 200 markers, validated against those two real artifacts. LOADER_MODULES are exempt — they are built -DENABLE_CK=0 and legitimately have none.

prebuild_aiter_attention.py:405--jobs oversubscribes. Correct. aiter/jit/core.py:631 sets max_num_jobs_cores = max(1, os.cpu_count() * 0.8) per process, plus a memory limit from its own snapshot, so --jobs N ran N times the intended compiler load. Children now divide one budget. The first version of the fix deferred to a pre-set MAX_JOBS, which is the knob CLAUDE.md documents for limiting a build — so it divides that too: MAX_JOBS=4 with --jobs 2 gives each child 2.

One thing I got wrong earlier and have corrected in the description: I explained the small fp8 artifact as "its CK instance set is smaller". It was an empty shell.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The standalone source-install guidance omits a required AITER module prebuild and can leave default ASM prefill failing at load time.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread csrc/rocm/aiter_loader.cc
Comment thread docs/rocm/backends.md
Comment thread tests/rocm/test_fp8_paged_prefill_aiter.py Outdated
demandal25 and others added 2 commits September 15, 2026 00:53
… have

Making the source build the supported install left a gap the wheel did not
have. PREBUILD_KERNELS=0 ships no AITER modules, and aiter_loader.cc dlopens
module_fmha_v3_fwd directly -- there is no lazy JIT behind it, unlike the
mha_fwd variants. The image is fine because the prebuild driver builds it; the
standalone recipe in docs/rocm/backends.md stopped short, so a large enough
single prefill fails at dlopen.

The loader's hint still read "ships prebuilt in the amd-aiter wheel ... its
absence means the wheel is incomplete", which sends a source user to reinstall
the thing that cannot fix it. It now names the driver and the
FLASHINFER_AITER_ASM_PREFILL=0 escape, which is read at
include/flashinfer/rocm/attention/aiter/single_prefill.cuh:100.

The fp8 test's docstring also still said eight variants and described --check as
presence-only; both moved in e441b65.

Co-Authored-By: Claude <noreply@anthropic.com>
…ctually work

tests/rocm/test_fp8_paged_prefill.py already exists with 20 cases, including
test_fp8_paged_prefill_matches_fp32_reference (a tighter reference than the bf16
one added here), test_every_routed_fp8_page_size_is_numerically_right across
pages 1/16/1024, test_fp8_rejects_return_lse, and
test_single_prefill_fp8_raises_instead_of_a_ninja_log. Both cases added in
7733443 have a direct equivalent there, so the new file was pure duplication
on a compile-bound suite, and its docstring's claim that nothing else exercised
fp8 paged prefill was simply wrong. Removed.

The standalone prebuild recipe added in 714d451 omitted GPU_ARCHS, which the
driver never sets -- the flat `from jit import core` import skips the
aiter/__init__.py that would populate it, so every build asserts. Measured
first-hand earlier in this branch: eight consecutive `FAIL` lines with the
variable unset. Both the doc and the driver's usage line now set it.

--jobs took int() at parser-construction time, so a non-numeric
AITER_PREBUILD_JOBS crashed before --help could run; it is now validated in
main() and reports the variable by name. _check runs even when a build failed,
so one bad variant no longer hides the hollow artifacts among the other 43.

Two stale comments: aiter_source's docstring still taught the at::Tensor forward
declaration, which is the trap this branch removes, and the tutorial promised
page_size=1024 without the gfx950 fault that prefill.py and the benchmark skill
both record.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 15, 2026 05:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The standalone recipe currently loses the FlashInfer working directory, and several installation paths or messages remain inconsistent with the source-build workflow.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 25/25 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread docker/prebuild_aiter_attention.py
Comment thread flashinfer/rocm/prefill.py
Comment thread docs/rocm/backends.md Outdated
Comment thread examples/amd_flashinfer_rocm_tutorial.ipynb
demandal25 and others added 2 commits September 15, 2026 02:12
…source sweep

_hip_clang_path claimed to mirror AITER's compile_ops and did not: core.py:1776
guards the export with `os.path.exists(hip_clang_path)`, so an inherited but
stale MHA_HIP_CLANG_PATH is ignored by the lazy build and was being turned into
an invalid compiler path by the prebuild. Same guard now.

The standalone recipe ran `cd aiter && pip install .` unparenthesised, so the
prebuild command in the next block resolved inside the AITER clone, where the
FlashInfer script does not exist. Wrapped in a subshell and said why.

Two more messages that still sent users to a wheel after this branch made the
source build the supported install: prefill.py's _require_aiter_runtime, on the
explicit-AITER path, and the tutorial's missing-package cell. mla.py's copy was
already corrected; these are the rest of the set.

Co-Authored-By: Claude <noreply@anthropic.com>
…smaller ones

_aiter_csrc_include_dir's AITER_CSRC_DIR fallback was a third `import aiter`
site that skipped _ensure_aiter_gpu_archs(). The other two call it first because
AITER freezes its architecture at import, so reaching this one with GPU_ARCHS
unset could pin the wrong arch for the rest of the process.

_child_env sized its budget from os.cpu_count(), which reports the host's cores
inside a cgroup-limited `docker build`; sched_getaffinity reports what the
builder may actually use.

AITER_SOURCE was compared literally to "1", so `--build-arg AITER_SOURCE=true`
took the wheel branch and installed a ROCm 7.14 retarget into a ROCm 10 image
without complaint. Now rejects anything that is not 0 or 1.

The cache tag is checked against a positive character class rather than five
denied spellings; a Popen that raises now unlinks its own log, which nothing
else would have, since only started children reach `running`; the driver removes
AITER's shared build/ root, not just the per-module subdirectory, so it stops
shipping in the layer; and _loader_name_tokens asserts start < end, without
which reordering the two functions in aiter_loader.cc would empty the slice and
silently retire the parity check.

Also drops test_fused_add_rmsnorm_aiter_is_correct_on_a_side_stream. Its own
docstring recorded that it passes with the guard removed, so it banked coverage
it did not have; the two cases that do detect it are A/B'd and stay.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 15, 2026 06:30
@demandal25

Copy link
Copy Markdown
Collaborator Author

Loop closed again at e2fdd10 — five bot rounds, 16 findings, all dispositioned

Every thread is resolved and both suppressed comments answered. The two things still open are declines with measurements behind them, restated here so a later round re-raising them can be read against this.

Declined: gate the gfx950 paged fault. Raised three times. It is real and it is recorded in prefill.py:93 beside the route that selects it, in the benchmark skill, and now in the tutorial. It is not gated because it reproduces identically on aiter 0.1.20, so it is not this PR's regression, and because the failing kv boundary has not been bounded — only that kv=1024 works and the next swept shape faults. Gating on a guess would disable a path that works below the boundary, and for fp8 there is no second route to fall back to. Bounding it is a measurement, and it belongs with whoever fixes the kernel.

Declined: _MIN_CK_MARKERS = 200 is a magic threshold. Fair in principle. Measured on both arms of the same variant: 20 markers in the instance-less build, 1,701 and 8,325 in populated ones. 200 sits an order of magnitude above the hollow case and an order below the smallest populated one, and it is checked only against CK variants — LOADER_MODULES build -DENABLE_CK=0 and are exempt. A symbol-level count would key on the thing itself; that is a better detector, not a defect in this one.

Declined, repeatedly and on the same evidence: that _aiter_importable should re-acquire a header-tree probe (it answers "can AITER run?", and a built or AOT-cached shim needs no headers), and that the driver's variant table should be merged with aiter_variants.py (a structural change to a table two tests now hold in step, in a PR this size).

One I got wrong and want on the record. I added tests/rocm/test_fp8_paged_prefill_aiter.py justifying it as "nothing else here exercised it". tests/rocm/test_fp8_paged_prefill.py already had 20 cases covering both of mine, including a tighter fp32 reference and test_fp8_rejects_return_lse. I had truncated a grep with head and missed the file. It is deleted in 724b9e7.

What this needs next is a human reader.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The shim headers bypass available compile-time ABI validation, and the reported artifact counts conflict with the implementation.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

csrc/rocm/norm_aiter.cu:26

  • The pinned 0.1.21.post2 csrc/include/rmsnorm_quant.h is POD-only and directly declares these signatures; it no longer includes torch/extension.h. Keeping a handwritten declaration defeats the compile-time ABI check required by flashinfer/jit/rocm/aiter_source.py:8-13, so a future parameter change can again compile and fail only at dlopen. Include the real header instead.
    csrc/rocm/rope_aiter.cu:27
  • The pinned 0.1.21.post2 csrc/include/rope.h now contains only the POD declarations and does not include torch/extension.h. This forward declaration therefore bypasses the compile-time ABI validation prescribed by flashinfer/jit/rocm/aiter_source.py:8-13; include the real header so later signature drift is caught during compilation.
    docker/prebuild_aiter_attention.py:182
  • The implementation enumerates 44 variant files (four fp8) and three whole modules, so an unrestricted run produces 47 artifacts. The PR description's final image/test sections instead claim 48 variants, eight fp8 variants, and 51 artifacts. Please reconcile those validation results with the final artifact set so the stated end-to-end coverage is accurate.
  • Files reviewed: 25/25 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…aring them

The forward declarations existed because rmsnorm_quant.h and rope.h pulled in
<torch/extension.h> and so pybind11, which clashes with -DPy_LIMITED_API. At
0.1.21.post2 that is no longer true -- both are POD-only:

    rmsnorm_quant.h   1 include (aiter_tensor.h),  0 torch/pybind refs, 18 aiter_tensor_t
    rope.h            3 includes (POD + cstdint),  0 torch/pybind refs, 82 aiter_tensor_t

So the reason for hand-writing the signatures is gone, and keeping them defeats
the compile-time ABI check this module's own docstring prescribes -- the next
signature change would again compile clean and fail at dlopen on the mangled
name, which is the failure this branch exists to remove.

Verified on a cold JIT cache so both shims really recompile: 73 passed across
test_rmsnorm_aiter, test_rope_aiter and test_aiter_stream_affinity.

Co-Authored-By: Claude <noreply@anthropic.com>
@demandal25

Copy link
Copy Markdown
Collaborator Author

Suppressed comments, review 6 (3) — all three accepted, fixed in 644d725

norm_aiter.cu:26 / rope_aiter.cu:27 — include the real headers. Right, and it is the best catch of the loop. The forward declarations existed because those headers pulled <torch/extension.h> and so pybind11, which clashes with -DPy_LIMITED_API. At 0.1.21.post2 that is no longer true:

rmsnorm_quant.h   1 include (aiter_tensor.h),  0 torch/pybind refs, 18 aiter_tensor_t
rope.h            3 includes (POD + cstdint),  0 torch/pybind refs, 82 aiter_tensor_t

So the declarations were defeating the compile-time ABI check for no remaining reason — the next signature drift would have compiled clean and failed at dlopen, which is the exact failure this branch exists to remove. Both now #include the real header. Verified on a cold JIT cache so the shims genuinely recompile: 73 passed across test_rmsnorm_aiter, test_rope_aiter and test_aiter_stream_affinity.

prebuild_aiter_attention.py:182 — the description contradicted the implementation. Correct: it still claimed 48 variants, eight fp8 and 51 artifacts after the fp8 LSE arm was dropped. The final set is 47 — 44 variants plus 3 whole modules. The description now states that, and reports the end-to-end image build as the 49-artifact run it actually was rather than restating it at the new count; the two whole modules added since were verified individually, not in a full image build. That gap is now named in the description instead of papered over.

Copilot AI review requested due to automatic review settings September 15, 2026 07:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The hardware-specific ABI migration and final multi-artifact image composition warrant human validation, especially since the final artifact set was not rebuilt end to end.

Review details
  • Files reviewed: 25/25 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@demandal25
demandal25 merged commit d4a18b5 into amd-integration Sep 15, 2026
3 checks passed
@demandal25
demandal25 deleted the aiter-source-build branch September 15, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants