Skip to content

Fix an out-of-bounds read in the fused window kernels, generalise to non-square shapes, port to ROCm - #389

Open
Francesco Brigante (francescobrigante) wants to merge 34 commits into
microsoft:mainfrom
francescobrigante:feat/window-kernels-nonsquare-rocm
Open

Fix an out-of-bounds read in the fused window kernels, generalise to non-square shapes, port to ROCm#389
Francesco Brigante (francescobrigante) wants to merge 34 commits into
microsoft:mainfrom
francescobrigante:feat/window-kernels-nonsquare-rocm

Conversation

@francescobrigante

@francescobrigante Francesco Brigante (francescobrigante) commented Aug 29, 2026

Copy link
Copy Markdown

Summary

window_merge_and_roll_forward_cuda_kernel uses nH as the stride between
window rows. The stride is nW. On any feature map with H != W the kernel
reads the wrong element, and when nH > nW it reads past the end of the
tensor — an out-of-bounds read on every forward pass, silent, with
fused_window_process=True and a stock img_size=(256, 128).

This PR fixes that one-token bug, then generalises the four kernels to
non-square windows and per-axis shifts, adds bfloat16 support, replaces
deprecated ATen APIs, launches on the current stream, validates preconditions,
and adds a test suite — including one that checks the index arithmetic with no
GPU at all
, so it runs in CI.

It also ports the kernels to ROCm. The port uncovered a second defect: __ldg
has no HIP overload for c10::Half, and half has been in the dispatch from the
start, so the fused window process has never compiled on AMD. With that
fixed, everything is validated on an MI300X as well as on an RTX 3080.

Everything is under kernels/window_process/. The single exception is a CI
workflow. The Python API, models/swin_transformer.py and setup.py are
unchanged.


1. The bug

Upstream, swin_window_process_kernel.cu:

int input_offset =
    (blockIdx.z * nH * nW + (blockIdx.y - shift_size + H) % H / window_size * nH + ...
//                                                                          ^^^^ nW

Windows are laid out row-major: window (wrow, wcol) of batch element b sits
at flat index b * nH * nW + wrow * nW + wcol. Advancing one window row
skips nW windows, not nH. The other three kernels already use nW; this one
is the odd one out.

No non-square window is needed to trigger it. With a single scalar
window_size, nH = H / window_size and nW = W / window_size, so nH != nW
whenever H != W. img_size is documented as int | tuple(int) and goes
through to_2tuple, so a non-square feature map reaches the kernels through a
supported public argument. Every released Swin checkpoint is trained on square
images, which is the only reason this has never surfaced.

Square 8×8 window, shift 4, B=2, C=1, only H×W varying — reproducible on
a CPU with reference.py:

H×W nH, nW numel max index reached outcome
16×16 2, 2 512 511 correct
16×32 2, 4 1024 895 in bounds, 50% of elements wrong
32×16 4, 2 1024 1407 out of bounds, 75% wrong
64×16 8, 2 2048 4735 out of bounds, 87.5% wrong

nH > nW walks off the end of the tensor. nH < nW stays inside it and
corrupts silently — the worse of the two.

A stock configuration reaches it:

SwinTransformer(img_size=(256, 128), window_size=8, depths=[2, 2, 2],
                fused_window_process=True)

  layers.0.blocks.1   res=64×32   shift=4   nH,nW = 8,4   <- out of bounds
  layers.1.blocks.1   res=32×16   shift=4   nH,nW = 4,2   <- out of bounds

Measured on an RTX 3080, reverting only the one-token fix on top of this branch:
torch.equal(eager, fused)False, peak logit error 3.2e-2;
compute-sanitizerInvalid __global__ read, 41 008 errors. With the fix:
torch.equal True, error exactly 0, memcheck + initcheck + synccheck report
0 errors.

The pre-fix kernel is bit-exact on every square configuration, including the
56×56 w7 case in the existing unit_test.py — which is why the existing suite
passes over it.

One thing that is not a bug. Upstream computes the intra-window offset as
(blockIdx.y - shift + H) % window_size without reducing % H first. This
branch writes the explicit form, but the two are equivalent: they differ by
a multiple of H, and H is a multiple of window_h whenever nH is exact,
which the launcher already requires. TestIntraWindowModuloIsEquivalent asserts
it. The change is for readability and is not claimed as a fix.


2. Generalisation and cleanup

Beyond the fix, the kernels now accept non-square windows (window_h,
window_w) and per-axis shifts (shift_h, shift_w) instead of two
scalars. The Python arity is unchanged — int works exactly as before, an
(h, w) pair is new:

windows = WindowProcess.apply(x, B, H, W, C, -shift_size, window_size)   # unchanged
windows = WindowProcess.apply(x, B, H, W, C, (-2, -8), (4, 16))          # new

TestScalarWindowStillWorks asserts the int path and the pair it expands to are
bit-identical, so models/swin_transformer.py keeps working untouched.

Other changes in the range:

  • bfloat16 added to the dispatch — upstream dispatches float, double and
    half, so a bf16 tensor needed an fp32 round trip to use the fused path.
  • Deprecated ATen APIs replaced (input.type()scalar_type(),
    .data<T>().data_ptr<T>(), output from input.options() instead of
    hardcoded kCUDA + kFloat32). The pre-fix sources fail to compile on
    torch 2.8+
    : the AT_DISPATCH_* overload taking DeprecatedTypeProperties
    is removed, and T* data() no longer exists on TensorBase.
  • Current stream (at::cuda::getCurrentCUDAStream()) instead of the legacy
    default stream, which serialises against any user stream and cannot be captured
    in a CUDA graph. C10_CUDA_KERNEL_LAUNCH_CHECK() added.
  • Preconditions TORCH_CHECKed before launch: tiling divisibility, element
    count, int32 offset overflow, and the grid.z/grid.y limit of 65 535.
  • Incidental test-suite fixes: backward() returned 8 values for 7 forward
    inputs; unit_test.py never checked a gradient (both "backward" tests
    re-compared the forward outputs); test_forward_backward_speed asserted
    fused < eager on time.time() with no warm-up or synchronisation — moved to
    benchmark.py, which reports rather than asserts.

3. Correctness

The kernels perform no arithmetic on tensor values. Each is a pure gather
whose entire logic is the computation of input_offset from blockIdx. Two
consequences:

Parity is asserted with torch.equal, exact, on every dtype including
float16 and bfloat16 — any deviation is an indexing error, not a rounding one.

The index math is testable with no GPU. reference.py transcribes the four
input_offset computations to vectorised PyTorch — the same arithmetic inside
the domain the launcher enforces, not an approximation — so test_index_math.py
runs on a CPU runner in CI. It asserts that each kernel equals the composition
of the eager ops it fuses, that K1/K2 and K3/K4 are inverse permutations, and —
with a legacy_row_stride=True flag — that the upstream map is correct on
square grids, wrong on nH < nW, out of bounds on nH > nW, and invisible when
nH == 1. Every case runs at four shifts, including the negative one: the model
calls the partition path with -shift_size, and this suite used to pass only
positive shifts, leaving that sign untested. unit_test.py never had the gap —
it negates at the call site — so the fix was needed here and only here.

The regression guarantee for the compiled kernel rests on unit_test.py, on a
GPU: 4 kernels × 4 dtypes × 15 shapes × 3 shifts, plus CUDA-graph capture, a
side-stream run, a non-contiguous incoming gradient, the int-vs-pair
compatibility path and six precondition tests — 15 test methods total. The full
matrix has run on the MI300X; two of the 15 shapes postdate the RTX 3080 run,
and §6 records exactly what each device swept.

test_model_parity.py runs a whole SwinTransformer — square, tall
(256,128), wide (128,256) — and requires the logits and the gradients to
be identical with and without fused_window_process.


4. Performance

benchmark.py, fused against torch.roll + window_partition, forward and
backward, batch 192, 200 iterations after 10 warm-up. Selected rows from a
24-row matrix — 4 configurations × 2 directions × 3 dtypes — which
benchmark.py prints in full and the README samples.

RTX 3080, torch 2.8.0+cu129 / CUDA 12.9:

config op dtype eager ms fused ms speedup eager MiB fused MiB
stage 1 56×56 w7 partition float32 7.137 2.398 2.98× 1543.5 882.0
stage 1 56×56 w7 merge bfloat16 5.150 1.998 2.58× 661.5 441.0
stage 2 28×28 w7 partition bfloat16 2.783 0.726 3.84× 392.0 224.0
stage 2 28×28 w7 merge float32 3.907 1.222 3.20× 661.5 441.0
non-square 32×16 w8 partition float32 1.218 0.417 2.92× 252.0 144.0
non-square window 16×64 w4×16 merge float32 2.402 0.801 3.00× 432.0 288.0

2.51–3.84× on fwd+bwd: that is the min and max over all 24 measured rows, of
which the six above are a sample —
with peak memory dropping proportionally. The path is
memory bound: the gain comes from eliminating the second materialisation that the
eager path pays (torch.roll writes a copy, window_partition calls
.contiguous() for another).

AMD Instinct MI300X, torch 2.12.0+rocm7.14.0: 1.03–2.38×, memory identical
to CUDA. The smaller ratio reflects the faster eager baseline on this hardware
(stage 1 float32: 7.137 ms on the 3080 vs 0.911 ms on the MI300X), which
compresses both paths toward a floor. The block-width thresholds in
best_block_dim() were measured on CDNA at swin-tiny's C and confirmed to
match the NVIDIA-tuned choice; full numbers are in the README.


5. ROCm / HIP port

Why no new kernel code was written

The four kernels use no NVIDIA-specific primitives — no shared memory, no
__syncthreads(), no warp-level intrinsics, no atomics. Each kernel is a pure
gather: read one element, write it to a permuted location. PyTorch's
CUDAExtension runs hipify automatically when torch.version.hip is set, and
_get_rocm_arch_flags() derives --offload-arch from PYTORCH_ROCM_ARCH,
so the build command is:

PYTORCH_ROCM_ARCH=gfx942 python setup.py install     # gfx90a for MI210

setup.py is untouched — and deliberately so: passing an arch through
extra_compile_args would make CUDAExtension skip its own detection, pinning
the build to one GPU.

The __ldg defect

Despite the clean translation, the kernels did not compile on AMD — for all
four kernels, instantiated for c10::Half:

error: no matching function for call to '__ldg'

The symbol __ldg hipifies correctly (HIP defines it), but not for every
type
. float, double and c10::BFloat16 have HIP overloads; c10::Half
does not. The cause is in PyTorch's own headers, which do not guard the two
alike: Half.h guards the overload on
(__CUDA_ARCH__ >= 350) || (__clang__ && __CUDA__), which no hipcc build
satisfies, while BFloat16.h guards on __CUDACC__ || __HIPCC__ and degrades
to *ptr under ROCm. Since half has been
in the dispatch from the start, the fused window process has never been
buildable on AMD
.

The fix is a macro:

#if defined(__HIP_PLATFORM_AMD__)
#define SWIN_WP_LDG(ptr) (*(ptr))
#else
#define SWIN_WP_LDG(ptr) __ldg(ptr)
#endif

On NVIDIA this expands to __ldg(ptr), identical to the original code, so the
CUDA translation unit is unchanged. On AMD, it degrades to a plain dereference —
__ldg is an advisory cache hint on both platforms, so the AMD branch loses the
hint and nothing else.

Hipify verification in CI

hipify_check.py runs the real hipify with no GPU and verifies that every
launch names a stream getter (upstream's 0 hipifies to the HIP null stream;
this branch's getCurrentCUDAStream() hipifies to the current one). Different
torch versions rewrite the getter differently — 2.8 and 2.10 rewrite it to
at::hip::getCurrentHIPStreamMasqueradingAsCUDA(), 2.12 and 2.13 leave the
at::cuda:: spelling — so the check accepts either, and the two CI legs pin
2.8 and 2.13 precisely so that both behaviours stay covered. The MI300X build
ran on 2.12, so the kept spelling is not only accepted by the checker but has
compiled under hipcc and produced bit-exact results on hardware.

Note on ROCm 7.14 images: The PyTorch wheels ship without the SDK headers
that torch's own headers #include when compiling device code (rocThrust,
hipBLAS, etc.), so no PyTorch HIP extension compiles out of the box — this
one included, and so would a file whose only content is #include <ATen/ATen.h>. One dev metapackage fixes it and no source changes; the README
has the exact commands, plus the two plausible-looking variants that break the
build in unrelated places.


6. Validation status

check where result
index math (test_index_math.py) CPU, and CI on py3.9 / py3.11 10/10
CUDA → HIP translation (hipify_check.py) CPU, CI, and MI300X complete; the two CI legs pin torch 2.8 and 2.13, which hipify treats differently, and the check passes on both — as it does on 2.12 in the ROCm container itself
CUDA build RTX 3080 · torch 2.8.0+cu129 · CUDA 12.9 builds, no source change
kernel parity (unit_test.py) RTX 3080 15/15 test methods, bit-exact; the parity ones swept 4 kernels × {f64, f32, f16, bf16} × 13 shapes × 3 shifts — two more shapes, and one changed test body, were added afterwards and have run on the MI300X only
model parity (test_model_parity.py) RTX 3080 4/4 — logits and gradients identical, square and non-square
bug reproduction RTX 3080 pre-fix: img_size=(256,128) gives different logits, compute-sanitizer reports OOB reads; post-fix: bit-identical to eager
compute-sanitizer memcheck + initcheck + synccheck RTX 3080 0 errors — all 4 kernels, fwd + bwd, over the 13 shapes that ran
ROCm build MI300X (gfx942) · torch 2.12.0+rocm7.14.0 · HIP 7.14 builds from PYTORCH_ROCM_ARCH, setup.py unchanged
kernel parity (unit_test.py) MI300X 15/15 test methods, bit-exact; the parity ones swept the full matrix — 4 kernels × 4 dtypes × all 15 shapes × 3 shifts. CUDA-graph capture included, via hipGraph
model parity (test_model_parity.py) MI300X 4/4

CI on the branch head, both legs green:
https://github.com/francescobrigante/Swin-Transformer/actions/runs/33199033099
(full history:
https://github.com/francescobrigante/Swin-Transformer/actions/workflows/window-process.yml).
The GPU rows are reported rather than linkable: both cards are rented, and the
RTX 3080 was released once its rows were recorded, which is why the two shapes
added afterwards carry an MI300X result and no NVIDIA one.

A note on gradient reproducibility. test_gradients_are_identical initially
used torch.equal and failed on the MI300X. Investigation showed this is
platform non-determinism, not a kernel defect: running the eager path (no fused
kernel) twice produces max abs diff = 1.863e-09 on 1 of 63 gradient tensors,
and the fused-vs-eager diff is the same 1.863e-09. The test now measures the
platform's own reproducibility first, and holds each gradient to that bound.
On CUDA — deterministic in every run so far — every gradient takes the strict
torch.equal branch. Bit-exactness of the kernels themselves is unaffected and
still asserted per kernel in unit_test.py, 15/15 on both vendors.


7. Review guide

The commits are ordered so each one is separately readable, and git bisect is
meaningful across the whole range.

Start with two. afd246b adds reference.py and test_index_math.py
the bug provable without a GPU; cf5e949 is the fix itself, one token, isolated.
Then 568ab74 (per-axis window and shift), 2eca45b (TORCH_CHECK
preconditions), aadeb8d (hipify_check.py and the ROCm findings) and
215e151 (the __ldg macro, so the kernels compile under HIP).

The rest of the range covers the deprecated ATen APIs, the current stream,
bfloat16, the test suite, the MI300X run, the README and CI workflow, and
documentation corrections. setup.py is deliberately untouched.

The four kernels in swin_window_process_kernel.cu perform no arithmetic on
tensor values -- each is a pure gather whose entire logic lives in the
computation of input_offset from blockIdx. Transcribing that offset
computation to vectorised PyTorch reproduces the kernels exactly on any
device, so their correctness becomes testable without a GPU or a compiled
extension.

reference.py holds the transcription; test_index_math.py pins each kernel to
the composition of PyTorch ops it replaces, checks that the backward kernels
invert their forward, and documents two properties of the current CUDA source:

  - window_merge_and_roll_forward uses `* nH` as the stride between window
    rows, but windows are laid out row-major as b*nH*nW + wrow*nW + wcol, so
    the stride is nW. The two agree exactly when nH == nW, which is why no
    model in this repository has ever hit it. On non-square feature maps it is
    an out-of-bounds read when nH > nW and silent corruption when nH < nW.

  - the intra-window modulo omits `% H` / `% W`. This one is genuinely a no-op:
    the two forms differ by a multiple of H, and H is a multiple of window_h
    whenever `nH = H / window_h` is exact, which the launcher already requires.
Windows are laid out row-major as `b * nH * nW + wrow * nW + wcol`, so the
stride between consecutive window rows is nW, not nH. The other three kernels
already use nW; this one did not.

nH and nW are equal for every model in this repository, because they are all
trained on square images, which is why this has never been observed. On a
non-square feature map the miscomputed offset is:

  - out of bounds when nH > nW. For B=2, H=16, W=8, window=4 the kernel reads
    index 351 from a 256-element tensor: an illegal memory access.
  - in bounds but wrong when nH < nW, silently corrupting half the elements.

Covered by TestUpstreamRowStrideBug in test_index_math.py.
Splits `shift_size` and `window_size` into `shift_h`/`shift_w` and
`window_h`/`window_w` throughout the kernels and their launchers, so the fused
path is no longer restricted to square windows on square feature maps. Grids
become dim3(window_w, window_h, ...) -- x indexes columns, y indexes rows --
and nH/nW are derived per axis.

The Python signature is deliberately unchanged: WindowProcess still takes
seven arguments, and `shift_size`/`window_size` now accept either an int
(isotropic, the previous behaviour) or a (h, w) pair. Both call sites in
models/swin_transformer.py keep working untouched.

unit_test.py re-declared WindowProcess and WindowProcessReverse instead of
importing them, so it called the extension directly and would not have been
updated by the change above -- it would have passed seven arguments to a
nine-argument function. It now imports the two Function classes, which both
fixes that and removes the duplication that allowed the two copies to drift.

Two incidental fixes in window_process.py, both visible in the diff:

  - backward() now calls .contiguous() on the incoming gradient. The C++ side
    asserts contiguity via CHECK_INPUT, and an upstream op can hand us a
    non-contiguous gradient.
  - backward() returned eight values for seven forward inputs. Now seven.

window_merge_and_roll_forward also gains the explicit `% H` / `% W` before the
intra-window modulo, so it mirrors the structure of its backward counterpart.
This is a readability change, not a fix: the two forms are equal whenever
H % window_h == 0, which the launcher already requires. Asserted by
TestIntraWindowModuloIsEquivalent in test_index_math.py.
All four are marked C10_DEPRECATED_MESSAGE in torch 2.7 and still compile, so
this is a warning cleanup rather than a build fix -- except for the allocation
change, which is a real one.

  Tensor::type()          -> Tensor::scalar_type()   (inside AT_DISPATCH)
  Tensor::data<T>()       -> Tensor::data_ptr<T>()
  x.type().is_cuda()      -> x.is_cuda()             (CHECK_CUDA)

Output tensors were allocated by branching on kFloat16 and hardcoding
torch::kFloat32 otherwise, on torch::kCUDA with no device index, with
requires_grad(true). Deriving them from the input's options() instead:

  - preserves the input dtype rather than silently widening anything that is
    not fp16 to fp32, which is what unblocks the bfloat16 support added next;
  - keeps the output on the input's device instead of the ambient current
    device;
  - drops requires_grad(true), which has no meaning on a tensor created inside
    an autograd.Function forward -- autograd builds the graph from the Function
    itself.

torch/extension.h is dropped from the .cu: it was only needed for
torch::empty/torch::dtype and pulls pybind11 through nvcc for no reason.
ATen/Dispatch.h is now included explicitly rather than relied on transitively
through ATen/ATen.h.
The kernels were launched with an implicit stream argument of 0, i.e. the
default stream, while the surrounding PyTorch ops run on whatever stream is
current. These usually coincide, but not always, and when they diverge there
is no ordering guarantee between the kernel and the ops around it:

  - DDP overlaps the backward pass with NCCL all-reduce on side streams;
  - user code under `with torch.cuda.stream(s)`;
  - CUDA graph capture, which rejects the default stream outright, so
    torch.compile(mode="reduce-overhead") cannot capture a model using the
    fused path.

Also adds C10_CUDA_KERNEL_LAUNCH_CHECK() after each launch. Without it a
failed launch -- an invalid configuration, or a grid.z above 65535 -- stays
silent until the next synchronisation point, which reports it with an
unrelated stack trace.
AT_DISPATCH_FLOATING_TYPES_AND_HALF covers float64, float32 and float16.
bfloat16 is now the default autocast dtype for transformer training on Ampere
and later, so the fused path was unreachable for it: a caller had to cast to
float32, run the kernel, and cast back.

That round trip costs two allocations and two full passes over the tensor --
exactly the two passes the fused kernel exists to eliminate, since it replaces
a torch.roll (one copy) plus a window_partition (permute + contiguous, another
copy). The workaround therefore cancelled the optimisation.

Switching to AT_DISPATCH_FLOATING_TYPES_AND2(Half, BFloat16, ...) makes the
kernels dispatch on bfloat16 directly. Nothing in them needs adapting: they
perform no arithmetic on the values, only gathers, so any trivially copyable
scalar type works. This is only reachable because the previous commit made the
output tensor inherit the input dtype instead of widening to float32.
The launchers derive nH = H / window_h and nW = W / window_w with integer
division and never verify that the division is exact. Passing a grid that the
window does not tile produces no error: the kernels read the wrong elements, or
past the end of the tensor. The same holds for a shift larger than the window,
for a shape that disagrees with the tensor handed in, and for the two hardware
limits the launch configuration can exceed.

check_window_args() now enforces, on the host side before the launch:

  - H % window_h == 0 and W % window_w == 0
  - |shift| < window size, per axis
  - tensor.numel() == B * H * W * C. The window layout and the spatial layout
    hold the same number of elements, since nH * window_h == H and
    nW * window_w == W, so a single check covers all four entry points.
  - B * H * W * C fits in int32. Kernel offsets are computed in int, as they
    are upstream; this branch keeps that and reports the limit rather than
    wrapping around silently.
  - B * nH * nW <= 65535 and H <= 65535, the CUDA grid.z and grid.y limits.
The existing unit_test.py had two problems beyond the duplication already
removed in the non-square commit.

The two backward tests never checked a gradient. Both call .backward() on the
eager and fused outputs and then assert torch.equal(expected, fused_output),
which compares the forward results again; input1.grad and input2.grad are
never read. The backward kernels were effectively untested.  They are now
compared directly.

test_forward_backward_speed asserted that the fused path is faster than eager,
timed with time.time() and no warmup. A wall-clock assertion in a unit test is
flaky by construction, so it moves to benchmark.py, which uses CUDA events,
warms up, and reports peak memory alongside the timings.

Coverage is now the product of:

  dtype   float64, float32, float16, bfloat16 (when supported)
  shape   square; nH > nW; nH < nW; non-square window, both orientations;
          a single window
  shift   0 (W-MSA) and window // 2 (SW-MSA)

for forward, backward, and a forward/reverse round trip -- plus a check that an
int shift/window still matches the (h, w) pair it expands to, which is the path
models/swin_transformer.py uses, and that each precondition added earlier
raises rather than corrupting memory.

Parity is asserted with torch.equal on every dtype, including float16 and
bfloat16. That is not too strict: the kernels are exact permutations and
perform no arithmetic, so any deviation is an indexing error rather than a
rounding one. For the same reason gradcheck adds nothing here, even though
float64 does dispatch.

Importing the extension is guarded so the file stays collectible on machines
without a GPU, where every test skips.
Five paths were exercised by nothing.

best_block_dim() picks 64, 128 or 256 threads per block from C, but every
shape in the suite used C in {1, 32, 64, 96}, all below the first threshold of
384. Only the 64-thread branch ran. Shapes with C = 512 and C = 1024 now reach
the other two, and C = 1, 64 and 1024 make the strided
`for (i = threadIdx.x; i < C; i += blockDim.x)` loop run in its partial, exact
and multi-pass forms.

The grid.z guard, B * nH * nW <= 65535, is now tested with a batch that trips
it. It costs a 4.5M element tensor because the shape is chosen so the window
count comes from B alone.

The .contiguous() call added to backward() had no test. An upstream op can hand
the backward a non-contiguous gradient, and the C++ side rejects it.

The current-stream fix had no test either. A race against the default stream is
timing dependent, so this uses CUDA graph capture instead: capture runs on a
non-default stream and rejects any launch on the legacy default stream, so the
graph fails to capture unless the fix is present. That is deterministic. A
second test runs the kernel inside an explicit side stream and checks parity.

test_model_parity.py adds the end-to-end statement: a SwinTransformer produces
bit-identical logits and bit-identical parameter gradients whether or not the
fused kernels are enabled. Both runs flip the flag on the same model instance,
so the weights are the same by construction rather than by seeding.
setup.py needs no change. torch.utils.cpp_extension.CUDAExtension hipifies its
own sources when torch.version.hip is set (cpp_extension.py, the IS_HIP_EXTENSION
branch of CUDAExtension), and derives --offload-arch from PYTORCH_ROCM_ARCH or
from the arch list the installed torch was built for. Hardcoding an arch in
extra_compile_args would be worse than leaving it alone: _get_rocm_arch_flags()
skips its own detection entirely once it sees an offload-arch flag from the
user, so pinning gfx942 for MI300X would break the build on every other AMD GPU.

What ROCm did need is in the current-stream commit earlier in this branch.
Without it there is no stream symbol for hipify to map, and the kernels launch
on the HIP null stream -- the same class of bug as on CUDA, with the same
consequences under DDP and graph capture.

What was missing entirely is any way to tell whether the translation is
complete. hipify_check.py fills that in, and deliberately requires no GPU:
torch.utils.hipify is pure Python, so this runs on a CPU-only CI runner, or on
a laptop. It translates the sources out of place, fails if any CUDA-specific
symbol survives, lists the symbols left alone because HIP implements them
natively, and asserts that every hipLaunchKernelGGL call carries an explicit
stream rather than falling back to the null stream.

Verified locally:

  cuda_runtime.h                 -> hip/hip_runtime.h
  cuda_fp16.h                    -> hip/hip_fp16.h
  ATen/cuda/CUDAContext.h        -> ATen/hip/HIPContext.h
  c10/cuda/CUDAException.h       -> c10/hip/HIPException.h
  at::cuda::getCurrentCUDAStream -> at::hip::getCurrentHIPStreamMasqueradingAsCUDA
  C10_CUDA_KERNEL_LAUNCH_CHECK   -> C10_HIP_KERNEL_LAUNCH_CHECK
  kernel<<<...>>>                -> hipLaunchKernelGGL(...)

swin_window_process.cpp translates to itself: it contains nothing device
specific. __ldg, __global__, dim3, the blockIdx/threadIdx family and the
AT_DISPATCH macros are all native to HIP and pass through unchanged.

best_block_dim() now documents why the port is this cheap: the kernels use no
shared memory, no __syncthreads() and no warp-level primitives, so CDNA's
64-wide wavefront against NVIDIA's 32-wide warp changes occupancy and nothing
else. All three block widths are already multiples of 64, so neither platform
schedules a partial wave. The thresholds were tuned on NVIDIA and are not
measured on CDNA; -DSWIN_WP_BLOCK_DIM=N overrides them for tuning.

Not validated on AMD hardware. The translation is verified, the build path is
the standard PyTorch one, and the arithmetic is platform independent -- but no
MI300X has run this.
README.md covers what the kernels replace, how to build on CUDA and on ROCm,
the int/pair form of the shift and window arguments, the constraints now
enforced by TORCH_CHECK, and which tests need a GPU and which do not.

The CI workflow is the first thing in this repository outside
kernels/window_process/. It is a deliberate exception: there is no CI here at
all, and the whole point of reference.py and hipify_check.py is that the parts
they verify need no GPU. Without a workflow that stays a property nobody
observes. The job runs on a CPU runner and covers:

  - the index arithmetic of all four kernels, via reference.py
  - the CUDA to HIP translation, via hipify_check.py, including that every
    launch carries an explicit stream
  - that the two GPU test files import and skip cleanly rather than erroring
    when there is no device and no compiled extension

The parity tests against the compiled extension are not run here; they need a
CUDA device.
The row stride bug needs nH != nW, and that follows from H != W alone: with a
single square window_size, nH = H / window_size and nW = W / window_size. A
non-square window is not required, and img_size is documented as
`int | tuple(int)` and passed through to_2tuple, so a non-square feature map
reaches the blocks through a supported public argument.

SwinTransformer(img_size=(256, 128), window_size=8, depths=[2, 2, 2]) puts two
shifted blocks at 64x32 and 32x16, giving nH/nW of 8/4 and 4/2. Both have
nH > nW, which is the regime where the miscomputed offset leaves the tensor
rather than merely pointing at the wrong element, so the merge kernel reads out
of bounds twice per forward pass.

The new test asserts that the configuration really does produce nH != nW in a
shifted block before comparing the two paths, so it cannot quietly stop testing
what it claims to test.
leaf() did tensor.clone().detach().requires_grad_(rg).cuda(): moving a
tensor that already requires grad produces a non-leaf, so .grad is never
populated on it. Every *_backward parity assertion first checks
assertIsNotNone(a.grad) on the eager side and so failed on every
dtype/shape/shift -- the compiled backward kernels K2/K4 were never
actually compared. Order .cuda() before requires_grad_ so the result is
a leaf. With this, unit_test.py exercises backward for the first time
(the upstream file never checked a gradient at all).
…mage

- unit_test.py / test_index_math.py: shapes with nH,nW = 3,2 and 3,5 --
  coprime and both > 1, where a wrong row/window stride cannot alias back
  to a correct offset the way 4,2 and 2,4 can.
- both files: a third shift regime (1, 2) with shift_h != shift_w, which
  only the per-axis signature added on this branch can express.
- test_model_parity.py: split the non-square model test into a tall
  (nH > nW, out of bounds before the fix) and a wide (nH < nW, silent
  corruption before the fix) case.
nH != nW arises whenever H/window_h != W/window_w, so a square feature
map with a rectangular window (48x48, window 6x16 -> nH=8, nW=3) reaches
the same regime as a non-square map. Add it to both the index-math and
the compiled-kernel shape lists so all four image/window combinations
are exercised explicitly.
Add the benchmark table (RTX 3080, torch 2.8.0+cu129, CUDA 12.9) and a
Validation section: what ran where, the CUDA parity results, and the
ROCm path stated honestly as statically verified only. Include the
upstream-vs-branch hipified launch diff from torch.utils.hipify.
torch >= 2.9 hipify no longer rewrites at::cuda::getCurrentCUDAStream or
C10_CUDA_KERNEL_LAUNCH_CHECK (they resolve through the CUDA-compat
headers) and wraps the hipLaunchKernelGGL call across lines. The old
check hard-failed on both. Now: CUDA-only includes must still translate,
every launch must still carry a real stream (matched across newlines,
either at::cuda:: or at::hip:: spelling), and the compat shims are only
reported. The null-stream launch is still caught.

Also install numpy in CI so torch stops warning, and note the version
split in the README.
The Args block still said "float32/float16", which predates the
AT_DISPATCH_FLOATING_TYPES_AND2 change: float64 and bfloat16 dispatch
too, and bfloat16 is the reason the fused path no longer needs an fp32
round trip. State all four on both classes. Docstrings only, no
behaviour change.
Building on an MI300X (gfx942, torch 2.12.0+rocm7.14.0) fails on all four
kernels with "no matching function for call to '__ldg'", instantiated for
c10::Half. Probed one type at a time: float, double and c10::BFloat16 all
have a HIP overload, c10::Half does not. Half has been in the dispatch
since upstream, so the fused window process has never been buildable on
AMD -- not untested, uncompilable.

SWIN_WP_LDG is a macro and not an inline function so that the NVIDIA
branch expands to exactly the __ldg(ptr) token sequence that was there
before: the CUDA translation unit is unchanged, and the parity results
measured on an RTX 3080 still describe the code that ships. The hint is
advisory on both platforms, so the AMD branch loses nothing but the
request.

Also drop the "port to AMD unchanged" claim from the block-width comment,
which this commit makes untrue.
test_gradients_are_identical compared a whole-model backward with
torch.equal and failed on an MI300X. Running the eager path twice, with
no fused kernel involved, shows why: 62 of 63 gradients are bit-identical
and one moves by 1.9e-9, because the backward of a GEMM may pick a
different reduction split per run. Measured on the same machine:

  eager vs eager   identical 62/63   max abs diff 1.863e-09
  fused vs fused   identical 62/63   max abs diff 2.794e-09
  eager vs fused   identical 62/63   max abs diff 1.863e-09

The fused path is exactly as far from eager as eager is from itself, on
gradients whose scale is 0.25.

So measure the noise first and hold each gradient to what that licenses:
bit-exactness wherever eager reproduces itself, and no further than eager
is from itself elsewhere. Where the backward is deterministic -- CUDA, in
every run so far -- every gradient takes the first branch and this is the
strict comparison it replaces. A guard keeps the test from going vacuous
if a platform reproduced nothing.

Bit-exactness of the kernels themselves is unaffected: unit_test.py
asserts it with torch.equal per kernel, and passes 15/15 on the MI300X.
The ROCm path is no longer static-only. Build, kernel parity, model
parity and benchmark all ran on an AMD Instinct MI300X (gfx942, torch
2.12.0+rocm7.14.0): 15/15 and 4/4, bit-exact, CUDA-graph capture included
through hipGraph.

Adds the MI300X benchmark table next to the RTX 3080 one. The speedup is
1.03-2.38x against 2.5-3.8x on the 3080, and the honest reading is that
the eager baseline moved: stage 1 takes 7.137 ms on the 3080 and 0.911 ms
here, so both paths compress toward a floor. The fused rows also stop
scaling with dtype, so at these sizes the kernel is not bandwidth bound
on CDNA.

Replaces the "tuned on NVIDIA, not measured on CDNA" caveat with the
measurement: rebuilding with -DSWIN_WP_BLOCK_DIM shows 64 beating 256
beating 1024, and 1024 making the fused path slower than eager. The
heuristic transfers.

Also records what the static check could not see -- __ldg translates and
still does not compile for c10::Half -- and says so in hipify_check.py's
own output, since a symbol-level check cannot see an overload set.
Every file under kernels/window_process/ carries the NVIDIA header, as the
whole subtree was contributed by NVIDIA and the repository puts a provenance
block on every source file.

Three of the new files are original work rather than derived from it, so they
now carry a "Written by" line as well, the same way models/swin_transformer.py
and the rest of the repository do:

  test_index_math.py    GPU-free tests of the kernels' index arithmetic
  test_model_parity.py  eager vs fused parity through a real SwinTransformer
  hipify_check.py       CUDA -> HIP translation check with no AMD hardware

benchmark.py and reference.py deliberately do not get the line: both were split
out of the upstream unit_test.py, which held the reference implementations and a
timing test alongside the correctness ones, so that code is NVIDIA's.
The README had grown into a record of the work that produced it: a validation
matrix naming specific machines, the ROCm session's findings written as a
narrative, and the full 24-row benchmark matrix for each vendor. That belongs in
the pull request, which is where it also lives. A README stays in the tree long
after the change is merged, and a reader landing in this directory needs to know
what the kernels are, how to build them, what they refuse to do, and how to
check them -- not what happened on one afternoon in August.

Cut from 277 lines to 207:

- The validation matrix becomes one paragraph: both cards, the two test files
  and their results, and the compute-sanitizer run, with the explicit note that
  ROCm has no equivalent.
- The benchmark tables keep the partition direction and the two swin-tiny
  stages, with the merge direction bounded (within 6% on the RTX 3080, 1% on the
  MI300X) and the non-square configurations given as a band. Every number still
  measured; benchmark.py prints the full matrix for anyone who wants it.
- The ROCm material is now "Building on AMD", written as what a builder needs
  before starting: PYTORCH_ROCM_ARCH, the --offload-arch trap, the __ldg
  overload gap, and the missing SDK headers in AMD's own images.

Adds a line the file did not have: the two GPU-free checks run in CI.
Comments and docstrings only. The one change to a .cu file is inside //, so
nothing is recompiled; test_index_math and hipify_check are green after it.

hipify_check.py carried most of the weight and most of the prose:

- The explanatory header was a comment block, so __doc__ was None while main()
  passed description=__doc__ to argparse -- `--help` printed no description at
  all. It is now a real module docstring, and argparse takes its summary line.
- The three constant comments described what the symbols are. They now say what
  the check does with them: a survivor in MUST_BE_TRANSLATED is a failure, a
  rewrite in KNOWN_PORTABLE is a failure, and CUDA_COMPAT_SHIMS never fails.
  Each comment now matches the branch it sits above.
- check_launch_form's docstring ran three claims into one sentence. Split: what
  the bug is, what varies in the translated spelling, what is rejected.
- The success caveat was four print continuations with newlines inside the
  string literals. It is a named constant, printed as one line.
- report() had no docstring; open() without a context manager appeared three
  times and is now a two-line read() helper.

swin_window_process_kernel.cu: the note above best_block_dim still said the
thresholds "have not been measured on CDNA". They have been, on an MI300X, and
the heuristic transferred -- 64 beats 256 beats 1024, and at 1024 the fused path
loses to eager at stage 1. The comment said the opposite of the measurement.

reference.py: the header said "no GPU" three times in two sentences.
A second readability pass, after reading the branch as a reviewer would.

hipify_check.py: the comments assumed the reader already knew ROCm. The module
docstring now says what hipify is before using its name, and the three constant
comments name the concrete thing each list holds -- CUDA header files that do
not exist on an AMD machine, PyTorch symbols that work under either spelling,
names both languages share -- instead of the word "symbols" three times. The
first of them read "Includes a HIP build does not provide", where "Includes" was
a noun and read as a verb. SYMBOL_LEVEL_CAVEAT had also been introduced between
the KNOWN_PORTABLE comment and the list it describes; it now follows them both.

benchmark.py: main() passed description=__doc__ to argparse while the header was
a comment block, so __doc__ was None and --help printed no description at all --
the same defect just fixed in hipify_check.py. The header is now a docstring.
The claim that "the expected ceiling is roughly 2x" is gone with it: the
measured speedups are 2.5-3.8x on an RTX 3080, so the file predicted a ceiling
its own README exceeds. It now says what the fused path saves and leaves the
number to the measurement. --iters and --forward-only gained help text.

test_index_math.py: _make_spatial and _make_windows seeded the RNG and then
built their tensors with torch.arange. Nothing in the file is random, so the
seeds did nothing.

test_model_parity.py: NOISE_HEADROOM = 8 was justified as "a few multiples",
which explains the shape of the bound but not the number. It now carries the
measurement it came from -- 1.9e-9 of noise against a gradient scale of 0.254,
so 8x of it is still seven orders of magnitude below the signal.

Two places disagreed with the code and are now aligned:

- README said every constraint is enforced by TORCH_CHECK. Device and
  contiguity go through CHECK_INPUT, and the channel-last layout is the shape
  contract rather than a separate check.
- The CI workflow header said the GPU parity tests "are not run here", while the
  workflow runs both files. It runs them to check they skip instead of erroring,
  which is what the step is named and what both files now say.
An external review of the branch found several statements that the code
itself contradicts. Each one here is either corrected or dropped, and the
two test gaps behind them are closed.

Claims that were wrong:

- hipify_check.py said torch >= 2.9 stops rewriting the CUDA-compat
  spellings. It does not follow the version order at all: torch 2.8 and
  2.10 rewrite them, 2.13 does not, which the two CI legs show directly.
  The check never depended on this, so the prediction is gone rather
  than re-dated, and the "kept" branch no longer explains a mechanism it
  cannot observe.
- Its docstring listed two failure modes where the code has three, and
  said everything hipify leaves untouched is reported -- the opposite of
  how KNOWN_PORTABLE is judged three lines below.
- check_launch_form claimed to reject only a literal 0. It rejects any
  launch that does not name a current-stream getter.
- The constraint table implied every precondition guarded a silent wrong
  read. |shift| >= window computes correctly upstream (verified against
  reference.py); that check is the model's own documented contract made
  explicit, not a bug guard. The grid bounds caught an async failure,
  not a bad read, and dtype and layout were already reported.
- The README credited CI with running all three GPU test files on every
  push. It runs two of them, behind a paths filter; benchmark.py exits
  non-zero without a device.
- best_block_dim's thresholds were said to be measured on CDNA. Both
  swin-tiny stages have C below the first threshold, so only the width
  the heuristic picks there was measured; 384 and 1024 remain untested
  on AMD.
- test_index_math.py documented an invocation that fails: there is no
  package, and reference.py is imported top-level.
- The __ldg comment credited CUDA with the overloads. PyTorch supplies
  the Half and BFloat16 ones, and guards them differently -- which is
  why only Half breaks under hipcc, and is checkable in any torch
  checkout with no AMD GPU.

Coverage:

- nH == 1 was in neither shape list. The legacy row stride is
  src_y / window_h * nH, and src_y / window_h < nH, so at nH == 1 the
  wrong stride is never multiplied by anything and the bug is invisible
  -- while its transpose, nW == 1, is the out-of-bounds case. Both
  orientations are now in SHAPES, the nH < nW test skips the degenerate
  one explicitly, and test_legacy_is_invisible_when_nH_is_one asserts
  the equality rather than leaving it unstated. Also adds a square grid
  with a non-square window, which the file lacked.
- unit_test.py promised a partial trailing pass of the strided channel
  loop that no C produced: 512/128 and 1024/256 are exact. C = 100 on a
  64-wide block covers it.

The kernel and C++ diffs here are comments only; no compiled file
changes behaviour. A note above the four input_offset computations now
says that reference.py transcribes them by hand and that no CPU test
can bind the two.
Replacing "torch >= 2.9 leaves them alone" with "the behaviour does not
follow the version order" traded a wrong claim for an unsupported one:
2.8 and 2.10 rewriting while 2.13 does not is a single change of
behaviour in version order, and is consistent with a boundary anywhere
in (2.10, 2.13]. What the evidence ruled out was the boundary at 2.9,
not the existence of one. Both files now state the three observations
and stop there.

The README also named the two torch versions the CI legs install, but
the workflow pins neither: py3.11 will move on, and the README outlives
the pin. It now says the legs install different versions, which the
Python support window makes true by construction. The PR body keeps the
numbers, being a snapshot of one moment.
…te missed

Three gaps, each one a case where a test could pass while the thing it
names is broken.

- test_index_math.py only ever passed positive shifts. The model passes
  -shift_size into the partition path, so the sign that actually reaches
  the kernels was the one no test used. _shifts() now returns the
  negative form as well.

- unit_test.py had no nH == 1 shape. That is the orientation where the
  upstream row-stride bug is invisible: the legacy term is
  src_y / window_h * nH, src_y / window_h runs over [0, nH), so at
  nH == 1 it is always 0 and the wrong stride multiplies nothing. A
  suite that reproduces the bug on nH > nW and nH < nW but never on
  nH == 1 leaves the reader without the reason H != W is not on its own
  sufficient. test_index_math.py asserts that invisibility; unit_test.py
  now covers the same shape against the compiled kernels.

- test_non_contiguous_gradient built its gradient with an 8x8 window,
  which makes the transpose shape-invariant: the test would pass with
  the two window axes swapped anywhere along the path. It now uses a
  4x16 window, where the transpose is observable. The C % blockDim row
  moves to a non-square window for the same reason, rather than adding a
  sixteenth shape to cover it separately.

The comment above the `blocks > 0` assertion in test_model_parity.py
said the count was of shifted blocks. set_fused counts every block
carrying the flag. The assertion is a guard against the model exposing
no flag at all -- in which case the comparison below proves nothing --
and now says so.
__ldg was in KNOWN_PORTABLE, so the check required the token to still be
there after hipify. It is portable as a symbol -- HIP defines it, hipify
leaves it alone -- but not for every type, which is exactly the caveat
the script prints on success. And since the kernels now read through
SWIN_WP_LDG, the only __ldg left in the source sits in the #else branch
of that macro, which hipcc never compiles. The assertion was therefore
about dead code: it would have kept passing had the AMD branch been
wrong, and it contradicted the caveat three lines below.

The macro is reported instead, so the reader sees which arm the build
takes rather than a claim about a token that is not in it.

Also records a fourth hipify observation. The MI300X container runs
torch 2.12, which leaves the at::cuda spelling in place -- so the
boundary sits between 2.10 and 2.12, not between 2.10 and 2.13. More to
the point, 2.12 is the version that produced the ROCm build, so the kept
spelling is not merely accepted by this check: it has compiled under
hipcc and run bit-exact on hardware.
Both files said the kernels compute a larger roll correctly for
|shift| >= window, with no upper bound. There is one.

The operand of the modulo is `... + blockIdx.y - shift_h + H`, and
blockIdx.y is unsigned, so the whole expression is unsigned. While
|shift| <= H the `+ H` keeps it non-negative and the arithmetic is the
one reference.py transcribes. Past that it does not go negative -- it
wraps modulo 2^32, and `% H` still yields an in-range index. The failure
is a silently wrong read, not an out-of-bounds one, which is the
opposite of what an unbounded claim invites a reader to assume.

reference.py gains the same caveat from its own side: Python's % floors
where the kernel wraps, so the transcription is exact inside the
supported domain and more forgiving outside it. The TORCH_CHECKs admit a
narrower range than either, so no accepted input reaches the divergence.
Same facts, a third shorter. The __ldg block spent four lines on where
float and double come from before reaching the point, and split the
"why a macro" rationale into its own paragraph; both fold into the
argument they support. The block-width note repeated the wavefront
reasoning twice. The reference.py warning loses a clause without losing
the warning.

One correction rather than a trim: the Half overload guard is now cited
in full, `(__CUDA_ARCH__ >= 350) || (__clang__ && __CUDA__)`. The
shortened form here was not what the header says.
The two legs exist to cover both hipify behaviours: one version that
rewrites the stream getter and one that leaves it. Neither was pinned,
so both legs resolved whatever pip offered for their Python version --
which happens to differ today and will not always. The moment they
converge, one of the two behaviours stops being tested and nothing
fails, because hipify_check.py accepts either by design.

Pinned to 2.8.0 and 2.13.0, the two versions whose behaviour is actually
recorded, with the reason in a comment so a future bump keeps the
property rather than the numbers.
The documented recipe was reconstructed from a container that already
had the packages. Run against a fresh one it fails three times over,
each failure far from its cause:

- `export CPLUS_INCLUDE_PATH=...:/usr/include` puts /usr/include ahead
  of the compiler's own directories, which defeats libstdc++'s
  #include_next. The build dies on `stdlib.h: No such file or directory`
  before reaching a single ROCm header.
- Ubuntu's librocthrust-dev and librocprim-dev are ROCm 5.7. They
  install a 5.7 HIP into /usr/include that shadows the image's 7.14
  headers, producing a wall of errors inside amd_warp_sync_functions.h.
- The amdrocm-*7.14 packages are not on repo.radeon.com but on
  repo.amd.com/rocm/packages-multi-arch/ubuntu2404, and the four listed
  are subsumed by one metapackage, amdrocm-core-dev7.14.

A fourth step was missing entirely: the image ships libamdhip64.so.7
with no development symlink, so the link resolves -lamdhip64 only with
LIBRARY_PATH set. Compilation succeeded without it and the link failed,
which is why it went unnoticed.

The corrected recipe was validated end to end in a container created
from scratch: build clean, unit_test.py 15/15 and test_model_parity.py
4/4 on the MI300X. No signing key is fetched over the network because
repo.amd.com publishes none that resolves -- the documented path reuses
the keyring the Developer Cloud host already carries, which is what was
actually executed.

Validation and portability sections updated with that run: all 15 shapes
now execute on the MI300X, two of them still only there; and torch 2.12,
the version the ROCm build used, keeps the at::cuda stream spelling, so
the kept form is the one that compiled under hipcc rather than merely
the one the checker tolerates.
Three claims that a reader can catch without running anything.

hipify_check.py printed "nothing device specific in this file" for
swin_window_process.cpp. That file has CHECK_CUDA and is_cuda() in it.
The check never looked for those -- it reports on three fixed watch
lists and that line is what it prints when none of them matched -- but
it is the first line of output for the only file in the PR that anyone
executes, and it overstates what was established. It now says so
literally: no symbol from the watch lists appears in this file.

The corrected apt recipe depends on a keyring it does not install. On
the Developer Cloud image the host carries it, which is where the recipe
was validated and why the gap was invisible; anywhere else apt-get
update dies with NO_PUBKEY -- a fourth failure far from its cause, which
is the exact category this recipe exists to remove. repo.amd.com serves
no key over HTTP and the repo.radeon.com key signs a different
repository (verified: NO_PUBKEY FA296B056C5BB456), so the honest form is
to state the assumption rather than invent a fetch. The build command
also came before the two exports that make it work; it now points
forward to them.

The validation line used "15" twice in one sentence for two different
things: 15 test methods and 15 entries in the shape list. Both are true
and the coincidence is not meaningful, so each is now named.
@francescobrigante

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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.

1 participant