Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
d275a06
[WS1][CUDA][MiniMax-H3] adaln_row_gather: fused six-way AdaLN row gather
fusheng-ji Oct 7, 2026
1f7253c
[WS1][MiniMax-H3] adaln_row_gather: B200 evidence report, chain repla…
fusheng-ji Oct 7, 2026
010c240
Merge inherited RFC 420 review fixes into PR #486
fusheng-ji Oct 7, 2026
2bbef22
fix(h3): validate native gather indices and replay stage dependencies
fusheng-ji Oct 7, 2026
d113891
ci: enable CodeRabbit automatic reviews for test-h3
fusheng-ji Oct 7, 2026
5c79216
ci: inherit existing CodeRabbit review settings
fusheng-ji Oct 7, 2026
e880a10
test(h3): assert adaln_row_gather batch invariance per logical row
fusheng-ji Oct 8, 2026
1438c4c
Merge inherited RFC 420 review fixes into PR #486
fusheng-ji Oct 8, 2026
604c134
Merge the H3 prior-art runner into PR #486
fusheng-ji Oct 8, 2026
7083548
Merge the H3 prior-art runner fix into PR #486
fusheng-ji Oct 8, 2026
c06b1e0
Merge the H3 prior-art figure fix into PR #486
fusheng-ji Oct 8, 2026
213d6ef
Merge the H3 prior-art figure fix into PR #486
fusheng-ji Oct 8, 2026
9bf0605
docs(h3): adaln_row_gather vs existing implementations (B200, 6c900ae)
fusheng-ji Oct 8, 2026
3908e8f
Merge the H3 prior-art evidence into PR #486
fusheng-ji Oct 8, 2026
ef45a37
Merge the H3 prior-art figure layout fix into PR #486
fusheng-ji Oct 8, 2026
96b0475
Merge test-h3 refactor and migrate H3 kernels, tools, and tests
fusheng-ji Oct 10, 2026
4f9da3d
Merge migrated H3 dependency PR #485
fusheng-ji Oct 10, 2026
78b8942
docs(h3): point row-gather validation to migrated tests
fusheng-ji Oct 10, 2026
9243718
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
b52916a
test(h3): skip the CUDA chain backward e2e when the H3 kernels are no…
fusheng-ji Oct 13, 2026
5fef115
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
62f8009
test(h3): fix the flaky FP32 table-gradient check in adaln_modulation
fusheng-ji Oct 13, 2026
7aeb6ba
evidence(h3): adaln_row_gather direct bitwise comparison on B200
fusheng-ji Oct 13, 2026
5783276
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
a07f1ed
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
60aa286
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
61d23ad
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
a00939e
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
ecb30e2
evidence(h3): regenerate adaln_row_gather report and chain replay aft…
fusheng-ji Oct 13, 2026
a6c8545
fix(h3): seed the row-gather op-backward check in the evidence report
fusheng-ji Oct 13, 2026
cbbfaed
evidence(h3): regenerate the row-gather report with the seeded op-bac…
fusheng-ji Oct 13, 2026
169af74
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 13, 2026
2553881
fix(h3): seed the row-gather timing inputs in the evidence report
fusheng-ji Oct 13, 2026
e26e369
docs: move the measurement outputs out of the pull request
fusheng-ji Oct 19, 2026
1a8e7b2
docs: point commit hashes at the rewritten history
fusheng-ji Oct 23, 2026
372544c
Merge test-h3 into feat/h3-adaln-row-gather
fusheng-ji Oct 25, 2026
343fa40
fix(h3): use one timing harness across the stacked PRs
fusheng-ji Oct 26, 2026
5160b8b
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 26, 2026
10d2671
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 26, 2026
e08e5e8
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 26, 2026
9ed5d9c
fix(h3): reject indices on another device than temb, pre-SM80 devices…
fusheng-ji Oct 26, 2026
223a706
Merge feat/h3 parent (#485) into #486
fusheng-ji Oct 26, 2026
4a1cee4
Merge parent (#485) into #486
fusheng-ji Oct 26, 2026
42455bc
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 26, 2026
f2f51f3
fix(h3): state the position contract of the gather backward and the n…
fusheng-ji Oct 26, 2026
2ef5b80
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 26, 2026
c416a10
fix(h3): check the row-gather tile metadata in one pre-pass, time the…
fusheng-ji Oct 26, 2026
86d6f11
docs(h3): row-gather backward timings measured without the forward pass
fusheng-ji Oct 26, 2026
3493f03
Merge feat/h3-adaln-projection-3mod into feat/h3-adaln-row-gather
fusheng-ji Oct 27, 2026
65e8620
fix(h3): check that the gather segments cut the tile list exactly, an…
fusheng-ji Oct 27, 2026
b5113d1
Merge #485 round-5 fixes into #486
fusheng-ji Oct 27, 2026
e366ece
fix(h3): require the segment tiles to partition the positions in order
fusheng-ji Oct 27, 2026
585c651
Merge branch 'test-h3' into feat/h3-adaln-row-gather
maxiaosong1124 Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build_tools/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
H3_BITWISE_SOURCES = (
"csrc/cuda/h3/timestep_sinusoid.cu",
"csrc/cuda/h3/det_linear.cu",
"csrc/cuda/h3/adaln_row_gather.cu",
)
H3_KERNELS_DEFINE = "-DKERNEL_ALIGN_WITH_H3_KERNELS"
# nvcc options that relax FP32 math. They can also arrive through the environment, where PyTorch's
Expand Down
15 changes: 15 additions & 0 deletions csrc/bindings/ops.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,13 @@ torch::Tensor h3_det_linear_backward_input(torch::Tensor grad, torch::Tensor wei
std::vector<torch::Tensor> h3_det_linear_backward_weight(torch::Tensor grad, torch::Tensor x,
c10::ScalarType w_dtype,
bool with_bias);
torch::Tensor h3_adaln_row_gather_forward(torch::Tensor rows, torch::Tensor timestep_indices,
torch::Tensor token_tags, int64_t chunks,
int64_t modality_num);
torch::Tensor h3_adaln_row_gather_backward(torch::Tensor grad, torch::Tensor sorted_pos,
torch::Tensor tile_begin, torch::Tensor tile_end,
torch::Tensor seg_first_tile,
c10::ScalarType out_dtype);
#endif

// PyBind11 Module Registration
Expand Down Expand Up @@ -789,5 +796,13 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("h3_det_linear_backward_weight", &h3_det_linear_backward_weight,
"Deterministic dW/dbias as ascending-row FP32 folds",
py::arg("grad"), py::arg("x"), py::arg("w_dtype"), py::arg("with_bias") = true);
m.def("h3_adaln_row_gather_forward", &h3_adaln_row_gather_forward,
"Fused six-way AdaLN row gather by timestep_index * 3 + token_tag (pure copy)",
py::arg("rows"), py::arg("timestep_indices"), py::arg("token_tags"),
py::arg("chunks") = 6, py::arg("modality_num") = 3);
m.def("h3_adaln_row_gather_backward", &h3_adaln_row_gather_backward,
"Deterministic segmented sum (sorted tiles folded in order) for the row gather",
py::arg("grad"), py::arg("sorted_pos"), py::arg("tile_begin"), py::arg("tile_end"),
py::arg("seg_first_tile"), py::arg("out_dtype"));
#endif
}
264 changes: 264 additions & 0 deletions csrc/cuda/h3/adaln_row_gather.cu
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 RL-Kernel Contributors
//
// MiniMax-H3 AdaLN row gather (RFC #420 `adaln_row_gather`).
//
// rows: (3T, C * H) modulation rows, row r = timestep * 3 + modality and
// column block c = shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp,
// gate_mlp. For every packed-sequence position s:
//
// r[s] = timestep_indices[s] * modality_num + token_tags[s]
// out[c, s, :] = rows[r[s], c * H : (c + 1) * H]
//
// Forward is a pure copy: bitwise equal to C index_select calls, one launch,
// independent of S, packing order and repeats.
//
// Backward d_rows[r, j] = sum_{s : r[s] = r} grad[c(j), s, h(j)] is the one
// cross-row reduction. Positions are visited in a fixed order: the caller
// sorts them by (r, s) with a stable sort and cuts every segment into tiles of
// at most kTile positions. Each tile is an ascending FP32 chain from 0; the
// tiles of a segment are then left-folded in ascending order and cast once.
// No atomics: the result is a function of (r[s], grad) only.

#include <torch/extension.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>

#include <cstdint>

#include "tile_metadata.cuh"

namespace {

__device__ __forceinline__ float to_float(float v) { return v; }
__device__ __forceinline__ float to_float(__nv_bfloat16 v) { return __bfloat162float(v); }
__device__ __forceinline__ float to_float(__half v) { return __half2float(v); }
__device__ __forceinline__ float to_float(double v) { return static_cast<float>(v); }

template <typename T>
__device__ __forceinline__ T from_float(float v);
template <>
__device__ __forceinline__ float from_float<float>(float v) { return v; }
template <>
__device__ __forceinline__ __nv_bfloat16 from_float<__nv_bfloat16>(float v) {
return __float2bfloat16(v);
}
template <>
__device__ __forceinline__ __half from_float<__half>(float v) { return __float2half(v); }
template <>
__device__ __forceinline__ double from_float<double>(float v) { return v; }

template <typename T>
struct CudaType {
using type = T;
};
template <>
struct CudaType<at::BFloat16> {
using type = __nv_bfloat16;
};
template <>
struct CudaType<at::Half> {
using type = __half;
};

template <typename idx_t>
__device__ __forceinline__ int64_t adaln_row(const idx_t* ti, const idx_t* tags, int64_t s,
int64_t modality_num, int64_t num_timesteps) {
const int64_t timestep = static_cast<int64_t>(ti[s]);
const int64_t tag = static_cast<int64_t>(tags[s]);
// Check each semantic index before multiplication, including values that overflow int64.
CUDA_KERNEL_ASSERT(timestep >= 0 && timestep < num_timesteps);
CUDA_KERNEL_ASSERT(tag >= 0 && tag < modality_num);
return timestep * modality_num + tag;
}

// One block per (s, c); 16-byte copies when every row start is 16-byte aligned.
template <typename T, typename idx_t, bool kVector>
__global__ void adaln_row_gather_kernel(const T* __restrict__ rows, int64_t row_stride,
const idx_t* __restrict__ ti,
const idx_t* __restrict__ tags, T* __restrict__ out,
int64_t seq, int64_t hidden, int64_t chunks,
int64_t modality_num, int64_t num_timesteps) {
for (int64_t s = blockIdx.x; s < seq; s += gridDim.x) {
const int64_t c = blockIdx.y;
const int64_t r = adaln_row(ti, tags, s, modality_num, num_timesteps);
const T* src = rows + r * row_stride + c * hidden;
T* dst = out + (c * seq + s) * hidden;
if constexpr (kVector) {
constexpr int kVec = 16 / sizeof(T);
const uint4* src4 = reinterpret_cast<const uint4*>(src);
uint4* dst4 = reinterpret_cast<uint4*>(dst);
for (int64_t i = threadIdx.x; i < hidden / kVec; i += blockDim.x) dst4[i] = src4[i];
Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · high
The vector path iterates i < hidden / kVec, which truncates when hidden is not an exact multiple of kVec; the remaining hidden % kVec columns of every (s, c) block are never copied. Meanwhile the host vector predicate only tests (hidden * elem) % 16 == 0 — that checks total bytes, not element count — so a configuration like hidden*elem a multiple of 16 but hidden % (16/sizeof(T)) != 0 selects the vector path and silently leaves garbage (uninitialized torch::empty) in the tail, rather than falling back to the scalar path. Trigger: chunks not dividing rows.size(1) such that hidden is byte-16-aligned but not element-vector-aligned. Make the predicate require hidden % kVec == 0 (and rows.stride(0) % kVec == 0) so the scalar tail path is used otherwise.

Suggestion:

Suggested change
constexpr int kVec = 16 / sizeof(T);
const uint4* src4 = reinterpret_cast<const uint4*>(src);
uint4* dst4 = reinterpret_cast<uint4*>(dst);
for (int64_t i = threadIdx.x; i < hidden / kVec; i += blockDim.x) dst4[i] = src4[i];
constexpr int kVec = 16 / sizeof(T);
const uint4* src4 = reinterpret_cast<const uint4*>(src);
uint4* dst4 = reinterpret_cast<uint4*>(dst);
for (int64_t i = threadIdx.x; i < hidden / kVec; i += blockDim.x) dst4[i] = src4[i];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not an issue. The vector path is chosen only when (hidden * elem) % 16 == 0; with elem 2 or 4 that is exactly hidden % kVec == 0 (kVec = 16 / elem), so there is no element tail, and the scalar loop is used otherwise.

} else {
for (int64_t i = threadIdx.x; i < hidden; i += blockDim.x) dst[i] = src[i];
}
}
}

// partial[tile, j] = sum over sorted positions p in tile (ascending) of grad[c, s_p, h]
template <typename g_t>
__global__ void adaln_row_gather_partial_kernel(const g_t* __restrict__ grad,
const int64_t* __restrict__ sorted_pos,
const int64_t* __restrict__ tile_begin,
const int64_t* __restrict__ tile_end,
float* __restrict__ partial, int64_t seq,
int64_t hidden, int64_t width) {
const int64_t tile = blockIdx.x;
const int64_t j = static_cast<int64_t>(blockIdx.y) * blockDim.x + threadIdx.x;
if (j >= width) return;
const int64_t c = j / hidden;
const int64_t h = j - c * hidden;
const g_t* g = grad + c * seq * hidden + h;
const int64_t begin = tile_begin[tile];
const int64_t end = tile_end[tile];
float acc = 0.0f;
for (int64_t p = begin; p < end; ++p) {
acc += to_float(g[sorted_pos[p] * hidden]);
}
partial[tile * width + j] = acc;
}

template <typename out_t>
__global__ void adaln_row_gather_fold_kernel(const float* __restrict__ partial,
const int64_t* __restrict__ seg_first_tile,
out_t* __restrict__ out, int64_t width) {
const int64_t r = blockIdx.x;
const int64_t j = static_cast<int64_t>(blockIdx.y) * blockDim.x + threadIdx.x;
if (j >= width) return;
float acc = 0.0f;
for (int64_t tile = seg_first_tile[r]; tile < seg_first_tile[r + 1]; ++tile) {
acc += partial[tile * width + j];
}
out[r * width + j] = from_float<out_t>(acc);
Comment on lines +127 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · high
OOB / stale reads when tiles > 0 but the fold reads a tile that was never computed. partial is filled only by the partial kernel, which is launched with grid.x = tiles — every tile index in [0, tiles) is written, so that part is fine — but num_rows is derived from seg_first_tile.numel() - 1 while the only consistency check is tile_begin.numel() == tile_end.numel(). A metadata set where seg_first_tile is not the cumsum of per-row tile counts (for example seg_first_tile[r+1] > tiles, an unsorted seg_first_tile, or a numel that does not match the caller's num_rows) indexes partial out of bounds inside the fold kernel. With CUDA_KERNEL_ASSERT not used here, this is a silent OOB device read. Add host-side validation before launch, e.g. TORCH_CHECK(num_rows >= 0 && seg_first_tile[0] == 0 && seg_first_tile[num_rows] == tiles, ...) plus a check that tile_begin/tile_end lie in [0, seq], or document/enforce the exact caller contract (_segment_tiles).

Suggestion:

Suggested change
const int64_t r = blockIdx.x;
const int64_t j = static_cast<int64_t>(blockIdx.y) * blockDim.x + threadIdx.x;
if (j >= width) return;
float acc = 0.0f;
for (int64_t tile = seg_first_tile[r]; tile < seg_first_tile[r + 1]; ++tile) {
acc += partial[tile * width + j];
}
out[r * width + j] = from_float<out_t>(acc);
const int64_t r = blockIdx.x;
const int64_t j = static_cast<int64_t>(blockIdx.y) * blockDim.x + threadIdx.x;
if (j >= width) return;
float acc = 0.0f;
for (int64_t tile = seg_first_tile[r]; tile < seg_first_tile[r + 1]; ++tile) {
acc += partial[tile * width + j];
}
out[r * width + j] = from_float<out_t>(acc);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed in the kernels. The tile metadata is not a public input: it is built by _segment_tiles from the same index tensor (int64 sorted_pos, tile_begin, tile_end, seg_first_tile, non-decreasing by construction, seg_first_tile[R] == tiles) and the native entry points check dtype, rank, contiguity, device, sizes and, since 0f67234, a non-empty seg_first_tile. Validating the values on the host would need a device-to-host read of every tensor on each backward, and the backward tests compare the result with the golden. A caller that builds metadata by hand is outside the contract.

}

void check_index(const torch::Tensor& t, const char* name, int64_t seq,
const torch::Tensor& like) {
TORCH_CHECK(t.is_cuda() && t.device() == like.device(), name, " must be on ", like.device());
TORCH_CHECK(t.dim() == 1 && t.size(0) == seq, name, " must be (", seq, ",)");
TORCH_CHECK(t.scalar_type() == at::kLong || t.scalar_type() == at::kInt, name,
" must be int64 or int32");
TORCH_CHECK(t.is_contiguous(), name, " must be contiguous");
}

} // namespace

// rows (R, C * H) with unit column stride -> out (C, S, H) contiguous.
torch::Tensor h3_adaln_row_gather_forward(torch::Tensor rows, torch::Tensor timestep_indices,
torch::Tensor token_tags, int64_t chunks,
int64_t modality_num) {
TORCH_CHECK(rows.is_cuda() && rows.dim() == 2 && rows.stride(1) == 1,
"rows must be a 2-D CUDA tensor with unit column stride");
Comment on lines +152 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · high
The forward checks stride(1) == 1 but never that stride(0) > 0 (or that stride(0) >= rows.size(1)). row_stride = rows.stride(0) is multiplied by the index for both the scalar and the vector (uint4) paths, and the vector-alignment test only checks (stride(0) * elem) % 16 == 0. A zero/negative row stride on a view (or a stride smaller than the row width) therefore produces overlapping or out-of-bounds reads, and negative strides pass the % 16 alignment gate. Add TORCH_CHECK(rows.stride(0) > 0 && rows.stride(0) >= rows.size(1), ...) (or require rows.is_contiguous()); the Python wrapper only calls .contiguous() when stride(1) != 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed. rows is a tensor view: strides are never negative, the largest row offset is (R - 1) * stride(0) + size(1) within the view's storage, and an overlapping or zero stride only re-reads in-bounds elements. The vector path additionally requires stride(0) * elem % 16 == 0.

TORCH_CHECK(chunks > 0 && rows.size(1) % chunks == 0, "rows width ", rows.size(1),
" is not a multiple of ", chunks);
TORCH_CHECK(modality_num > 0 && rows.size(0) % modality_num == 0, "rows count ",
rows.size(0), " is not a multiple of ", modality_num);
TORCH_CHECK(chunks <= 65535, "chunks=", chunks, " exceeds the grid.y limit of 65535");
const int64_t num_timesteps = rows.size(0) / modality_num;
const int64_t seq = timestep_indices.numel();
TORCH_CHECK(seq > 0, "the packed sequence must not be empty");
check_index(timestep_indices, "timestep_indices", seq, rows);
check_index(token_tags, "token_tags", seq, rows);
TORCH_CHECK(timestep_indices.scalar_type() == token_tags.scalar_type(),
"timestep_indices and token_tags must share an integer dtype");
const int64_t hidden = rows.size(1) / chunks;
const c10::cuda::CUDAGuard device_guard(rows.device());
auto out = torch::empty({chunks, seq, hidden}, rows.options());
auto stream = at::cuda::getCurrentCUDAStream();
const dim3 grid(static_cast<unsigned>(std::min<int64_t>(seq, 1 << 20)),
static_cast<unsigned>(chunks));
const int threads = 128;
const int64_t elem = rows.element_size();
const bool vector = (hidden * elem) % 16 == 0 && (rows.stride(0) * elem) % 16 == 0 &&
reinterpret_cast<uintptr_t>(rows.data_ptr()) % 16 == 0;
Comment on lines +174 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · high
The vectorized path reinterprets src = rows + r*row_stride + c*hidden and dst = out + (c*seq+s)*hidden as uint4*, but the vector flag only proves that hidden and row_stride are 16-byte multiples and that the base pointer is 16-byte aligned. dst is not checked at all, and src relies on c*hidden/r*row_stride being 16-byte multiples, which is only guaranteed when the total rows.size(1) = chunks*hidden is also aligned in a particular way. Concretely, with rows = table[::2] (the test_strided_rows_view case) row_stride*elem is 16-byte aligned but rows.data_ptr() is not checked for the c*hidden offset combination, and any rows view whose stride(0) is aligned while rows.size(1) is not a multiple of 16/elem (e.g. odd hidden accepted by the API when chunks*hidden happens to be a multiple) makes c*hidden misalign src for intermediate c. A misaligned uint4 load/store is undefined behaviour on CUDA and can silently return garbage or fault. Please derive vector from every offset that is actually dereferenced, e.g. require hidden*elem % 16 == 0 && row_stride*elem % 16 == 0 && (chunks*hidden)*elem % 16 == 0 plus alignment of both rows and out base pointers (and verify hidden % (16/sizeof(T)) == 0 so the hidden / kVec loop has no dropped tail).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed. Every address the vector path dereferences is a multiple of 16 bytes:

  • src = rows + r*row_stride + c*hidden: rows.data_ptr() is checked 16-byte aligned, row_stride*elem % 16 == 0 is checked, and hidden*elem % 16 == 0 is checked, so c*hidden*elem is a multiple of 16 for every chunk c. rows.size(1) = chunks*hidden does not enter the offset, so its alignment does not matter.
  • dst = out + (c*seq+s)*hidden: the offset is a multiple of hidden*elem, hence of 16, and out comes from torch::empty in the same call (allocator alignment is at least 512 bytes), so it needs no separate check.
  • hidden*elem % 16 == 0 means hidden % (16/sizeof(T)) == 0, so hidden / kVec drops no tail.
    The strided rows = table[::2] case is covered by test_strided_rows_view; a view whose stride(0) or base pointer is misaligned takes the scalar path.


AT_DISPATCH_FLOATING_TYPES_AND2(
at::kHalf, at::kBFloat16, rows.scalar_type(), "h3_adaln_row_gather_forward", [&] {
using T = typename CudaType<scalar_t>::type;
const T* src = reinterpret_cast<const T*>(rows.data_ptr<scalar_t>());
T* dst = reinterpret_cast<T*>(out.data_ptr<scalar_t>());
auto launch = [&](auto idx_tag) {
using idx_t = decltype(idx_tag);
const idx_t* ti = timestep_indices.data_ptr<idx_t>();
const idx_t* tags = token_tags.data_ptr<idx_t>();
if (vector) {
adaln_row_gather_kernel<T, idx_t, true><<<grid, threads, 0, stream>>>(
src, rows.stride(0), ti, tags, dst, seq, hidden, chunks, modality_num,
num_timesteps);
} else {
adaln_row_gather_kernel<T, idx_t, false><<<grid, threads, 0, stream>>>(
src, rows.stride(0), ti, tags, dst, seq, hidden, chunks, modality_num,
num_timesteps);
}
};
if (timestep_indices.scalar_type() == at::kLong) {
launch(int64_t{0});
} else {
launch(int32_t{0});
}
});
C10_CUDA_KERNEL_LAUNCH_CHECK();
return out;
}

// grad (C, S, H) contiguous; sorted_pos (S,) positions sorted stably by row;
// tiles [tile_begin, tile_end) in sorted order; seg_first_tile (R + 1,).
torch::Tensor h3_adaln_row_gather_backward(torch::Tensor grad, torch::Tensor sorted_pos,
torch::Tensor tile_begin, torch::Tensor tile_end,
torch::Tensor seg_first_tile,
c10::ScalarType out_dtype) {
TORCH_CHECK(grad.is_cuda() && grad.dim() == 3 && grad.is_contiguous(),
"grad must be a contiguous (C, S, H) CUDA tensor");
for (const auto* t : {&sorted_pos, &tile_begin, &tile_end, &seg_first_tile}) {
TORCH_CHECK(t->is_cuda() && t->dim() == 1 && t->is_contiguous() &&
t->scalar_type() == at::kLong,
"tile metadata must be contiguous 1-D int64 CUDA tensors");
}
TORCH_CHECK(tile_begin.numel() == tile_end.numel(), "tile_begin/tile_end length mismatch");
const int64_t chunks = grad.size(0);
const int64_t seq = grad.size(1);
const int64_t hidden = grad.size(2);
const int64_t width = chunks * hidden;
TORCH_CHECK(seg_first_tile.numel() >= 1, "seg_first_tile must hold R + 1 entries");
const int64_t num_rows = seg_first_tile.numel() - 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security · high
num_rows is derived from seg_first_tile.numel() - 1, and the fold kernel then reads seg_first_tile[r + 1] plus partial[tile * width + j] for tile < seg_first_tile[r+1]. None of seg_first_tile (monotonicity, seg_first_tile[num_rows] == tiles, length >= 1), sorted_pos entries (< seq) or tile_begin/tile_end (within [0, seq]) are validated on the host. A malformed Python-side metadata tuple (e.g. an empty seg_first_tile giving num_rows == 0, a non-monotone prefix, or an out-of-range position) reads out of bounds of partial/grad; CUDA_KERNEL_ASSERT is not compiled into release builds, so this is a silent OOB rather than an error. Please add host-side TORCH_CHECKs (non-monotone seg_first_tile, seg_first_tile.back() == tiles, tile_end <= seq, tile_begin <= tile_end, and positions in range) so the release path fails closed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed in the kernels. The tile metadata is not a public input: it is built by _segment_tiles from the same index tensor (int64 sorted_pos, tile_begin, tile_end, seg_first_tile, non-decreasing by construction, seg_first_tile[R] == tiles) and the native entry points check dtype, rank, contiguity, device, sizes and, since 0f67234, a non-empty seg_first_tile. Validating the values on the host would need a device-to-host read of every tensor on each backward, and the backward tests compare the result with the golden. A caller that builds metadata by hand is outside the contract.

const int64_t tiles = tile_begin.numel();
TORCH_CHECK(sorted_pos.numel() == seq, "sorted_pos must have S entries");
const c10::cuda::CUDAGuard device_guard(grad.device());
auto out = torch::empty({num_rows, width}, grad.options().dtype(out_dtype));
if (num_rows == 0) return out; // a zero-sized grid is not a valid launch
auto partial = torch::empty({std::max<int64_t>(tiles, 1), width},
grad.options().dtype(at::kFloat));
auto stream = at::cuda::getCurrentCUDAStream();
const int threads = 256;
const int64_t col_blocks64 = (width + threads - 1) / threads;
TORCH_CHECK(col_blocks64 <= 65535, "width ", width, " needs ", col_blocks64,
" column blocks, more than the grid.y limit of 65535");
const unsigned col_blocks = static_cast<unsigned>(col_blocks64);
h3_tiles::check_tile_metadata(stream, sorted_pos.data_ptr<int64_t>(), seq,
tile_begin.data_ptr<int64_t>(), tile_end.data_ptr<int64_t>(), tiles,
seg_first_tile.data_ptr<int64_t>(), num_rows);
if (tiles > 0) {
AT_DISPATCH_FLOATING_TYPES_AND2(
at::kHalf, at::kBFloat16, grad.scalar_type(), "h3_adaln_row_gather_partial", [&] {
using G = typename CudaType<scalar_t>::type;
adaln_row_gather_partial_kernel<G>
<<<dim3(static_cast<unsigned>(tiles), col_blocks), threads, 0, stream>>>(
reinterpret_cast<const G*>(grad.data_ptr<scalar_t>()),
sorted_pos.data_ptr<int64_t>(), tile_begin.data_ptr<int64_t>(),
tile_end.data_ptr<int64_t>(), partial.data_ptr<float>(), seq, hidden, width);
});
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
AT_DISPATCH_FLOATING_TYPES_AND2(
at::kHalf, at::kBFloat16, out_dtype, "h3_adaln_row_gather_fold", [&] {
using O = typename CudaType<scalar_t>::type;
adaln_row_gather_fold_kernel<O>
<<<dim3(static_cast<unsigned>(num_rows), col_blocks), threads, 0, stream>>>(
partial.data_ptr<float>(), seg_first_tile.data_ptr<int64_t>(),
reinterpret_cast<O*>(out.data_ptr<scalar_t>()), width);
});
C10_CUDA_KERNEL_LAUNCH_CHECK();
return out;
}
82 changes: 82 additions & 0 deletions csrc/cuda/h3/tile_metadata.cuh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 RL-Kernel Contributors
//
// Segment-tile metadata shared by the H3 backward kernels that sum the rows of a gradient into the
// rows of a small table (adaln_row_gather, rmsnorm modulation, gate residual).
//
// The caller sorts the S positions by table row (`sorted_pos`), cuts every row's run into tiles
// `[tile_begin, tile_end)` over `sorted_pos`, and gives the first tile of each row in
// `seg_first_tile` (R + 1 entries). The partial kernels read gradients through `sorted_pos`, and the
// fold kernels read the partial rows of a segment's tiles, so a stale or short list would read out
// of bounds.
//
// check_tile_metadata() verifies the list on the device before any of that happens: indices in
// range, the tiles an exact in-order partition of the positions, and the segments an exact cut of
// the tile list. It is a separate
// pass on purpose: an assert in or before the loops of the partial and fold kernels changes how the
// compiler schedules their loads and slowed them by 6-12% (and by several times inside the loop),
// while this pass costs one small launch (about 4 us).
//
// The checks are CUDA_KERNEL_ASSERTs. Unlike assert(), that macro is not removed by NDEBUG on CUDA
// (c10 expands it to a plain `if (!cond) __assert_fail(...)`; only Android, Apple and FreeBSD
// compile it out, and ROCm aborts), so they stay in the release build this extension ships as
// well; tests/models/minimax_h3/test_h3_assert_in_release_build.py compiles with -DNDEBUG -O3 to
// keep that true for the torch it runs against.
#pragma once

#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>

#include <algorithm>
#include <cstdint>

namespace h3_tiles {

static __global__ void check_tile_metadata_kernel(const int64_t* __restrict__ sorted_pos,
int64_t positions,
const int64_t* __restrict__ tile_begin,
const int64_t* __restrict__ tile_end,
int64_t tiles,
const int64_t* __restrict__ seg_first_tile,
int64_t segments) {
const int64_t first_thread = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x;
const int64_t stride = static_cast<int64_t>(gridDim.x) * blockDim.x;
for (int64_t i = first_thread; i < positions; i += stride) {
CUDA_KERNEL_ASSERT(sorted_pos[i] >= 0 && sorted_pos[i] < positions);
}
for (int64_t i = first_thread; i < tiles; i += stride) {
CUDA_KERNEL_ASSERT(tile_begin[i] >= 0 && tile_begin[i] <= tile_end[i] &&
tile_end[i] <= positions);
// The tiles partition [0, positions) in order: each starts where the one before it ends, the
// first at 0 and the last at `positions`. A gap would drop positions from every row's sum and
// an overlap would count them twice, both without leaving the bounds checked above.
CUDA_KERNEL_ASSERT(tile_begin[i] == (i == 0 ? int64_t{0} : tile_end[i - 1]));
CUDA_KERNEL_ASSERT(i != tiles - 1 || tile_end[i] == positions);
}
for (int64_t i = first_thread; i <= segments; i += stride) {
CUDA_KERNEL_ASSERT(seg_first_tile[i] >= 0 && seg_first_tile[i] <= tiles);
CUDA_KERNEL_ASSERT(i == segments || seg_first_tile[i] <= seg_first_tile[i + 1]);
// The segments cut the tile list exactly. A tile that no segment owns would be missing from
// every row's sum, silently.
CUDA_KERNEL_ASSERT(i != 0 || seg_first_tile[0] == 0);
CUDA_KERNEL_ASSERT(i != segments || seg_first_tile[segments] == tiles);
}
Comment on lines +47 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security · high
CUDA_KERNEL_ASSERT is the CUDA runtime assertion macro and expands to nothing when NDEBUG is defined (the usual configuration for release PyTorch extension builds). In that build this validation kernel becomes a no-op, so the only check of tile_begin/tile_end/seg_first_tile disappears. h3_adaln_row_gather_backward then reads partial[tile * width + j] for tile indices taken straight from the unvalidated seg_first_tile in the fold kernel, and out-of-range sorted_pos[p] rows in the partial kernel — an out-of-bounds device read rather than a clean error. Since check_tile_metadata exists specifically because in-kernel asserts were too costly (per the header comment), the metadata must be validated with something that survives release builds. Either use a device-side error path that always executes (e.g. printf + __trap() on violation, as done for the MMA arch guard) or add a host-side bound (seg_first_tile.back() <= tiles, tile_end[i] <= seq, etc.) in the backward wrapper before launch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed: the premise does not hold for this build. CUDA_KERNEL_ASSERT is not assert(). In the torch this extension is built against (2.13, torch/headeronly/macros/Macros.h) it expands to if (C10_UNLIKELY(!(cond))) __assert_fail(...), and the header says it "checks the assertion even when NDEBUG is defined"; it is a no-op only on Android, Apple and FreeBSD, and on ROCm it becomes abort() (the H3 sources are not built for ROCm). TORCH_DISABLE_GPU_ASSERTS is not defined anywhere in the build, and the nvcc line of the H3 sources is -O3 with no -G. Checked on the build rather than only on the header: test_h3_assert_in_release_build.py (65e8620) compiles a one-kernel program with -O3 -DNDEBUG against these headers and requires device-side assert triggered for a bad value and a clean run for a good one, and the tests that run the built extension on bad metadata and bad indices in a child process (test_native_bounds_assertions, test_the_backward_stops_on_metadata..., test_gate_row_outside_the_table_asserts_in_the_kernel) require the same stop. The host-side half of the suggestion is already covered where it can be: the entry point checks the tensors' dtype, dimensions and lengths on the host, and the pre-pass is the bound check on the contents. 65e8620 also makes it require that the segments cut the tile list exactly (seg_first_tile[0] == 0, seg_first_tile[R] == tiles), so a tile that no row owns can no longer drop out of a sum silently; two more corrupted-metadata cases run in the child-process test.

}

// `positions` entries in sorted_pos (each in [0, positions)), `tiles` tiles inside sorted_pos, and
// `segments` row segments (seg_first_tile has segments + 1 entries).
inline void check_tile_metadata(cudaStream_t stream, const int64_t* sorted_pos, int64_t positions,
const int64_t* tile_begin, const int64_t* tile_end, int64_t tiles,
const int64_t* seg_first_tile, int64_t segments) {
// No tiles over a non-empty sequence would leave every row's sum at zero.
TORCH_CHECK(tiles > 0 || positions == 0, "the tile list is empty but there are ", positions,
" positions to sum");
// At least one block, so an empty list (segments + 1 == 0) is a no-op and not an invalid launch.
const int64_t items = std::max({positions, tiles, segments + 1, int64_t{1}});
const unsigned blocks = static_cast<unsigned>(std::min<int64_t>((items + 255) / 256, 1024));
check_tile_metadata_kernel<<<blocks, 256, 0, stream>>>(sorted_pos, positions, tile_begin,
tile_end, tiles, seg_first_tile, segments);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}

} // namespace h3_tiles
Loading
Loading