rtx-backend-cuda: transpose of tall tensors failed to launch, and the generic permute was an identity copy
CI / Build CPU-Only (Explicit) (push) Failing after 7s
Documentation / Build User Guide (push) Successful in 7s
CI / Format Check (push) Failing after 1m5s
CI / Build (ubuntu-latest) (push) Failing after 6m42s
Documentation / Build API Documentation (push) Failing after 6s
CI / Clippy Check (push) Failing after 7m18s
Performance Benchmarks / Run Benchmarks (push) Successful in 7m21s
CI / CI Success (push) Failing after 0s
CI / Build (macos-latest) (push) Failing after 9s
CI / Test (macos-latest) (push) Skipped
CI / Test (ubuntu-latest) (push) Skipped
CI / Python Bindings (maturin) (macos-latest) (push) Skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Skipped
CI / WASM Build + Size Check (push) Skipped
CI / Distributed Training Tests (push) Skipped
CI / Build CPU-Only (Explicit) (push) Failing after 7s
Documentation / Build User Guide (push) Successful in 7s
CI / Format Check (push) Failing after 1m5s
CI / Build (ubuntu-latest) (push) Failing after 6m42s
Documentation / Build API Documentation (push) Failing after 6s
CI / Clippy Check (push) Failing after 7m18s
Performance Benchmarks / Run Benchmarks (push) Successful in 7m21s
CI / CI Success (push) Failing after 0s
CI / Build (macos-latest) (push) Failing after 9s
CI / Test (macos-latest) (push) Skipped
CI / Test (ubuntu-latest) (push) Skipped
CI / Python Bindings (maturin) (macos-latest) (push) Skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Skipped
CI / WASM Build + Size Check (push) Skipped
CI / Distributed Training Tests (push) Skipped
Found by running dg-gnn's HetGAT training on an RTX 5060 Ti: the first backward
pass panicked in transpose_2d with CUDA_ERROR_INVALID_VALUE. The tensor was a
[4,211,136 x 1] gradient. The tiled kernel maps rows onto grid.y, and CUDA caps
grid.y at 65,535 blocks, so anything past ~2.1M rows was rejected at launch.
Inference never hit it — the forward pass has no transposes.
Three fixes, each tested on the GPU:
- A row or column vector transposes to itself in memory ([N,1] and [1,N] are
the same N floats). swap_dims now shares the buffer and swaps the shape; no
kernel. This is the case that actually failed.
- transpose_2d_gpu hands matrices with rows/32 > 65,535 to the 1-D generic
permute, whose grid.x allows 2^31-1 blocks.
- transpose_generic_gpu passed the INPUT's strides (swapped) as the kernel's
output_strides — but the kernel DECODES each flat output index with those,
so they must be the contiguous strides of the output shape. For a 2-D input
that was [1, cols], which decodes every index to itself: the "permute" was
a plain copy. Verified against the original code — a [2,3,4] swap_dims(0,2)
returned b[1][0][0] = 12.0 where 1.0 is correct. Every higher-D dim swap
went through this path; nothing had checked it numerically.
Also stamps kernel outputs with the contiguous strides of their new shape
rather than the input's strides swapped. Nothing outside ops/shape.rs reads
strides today, so that was latent, but is_contiguous() now tells the truth.
rtx-backend-cuda --features cuda: 55 + 16 passed, 0 failed (was 51 + 16; four
new tests, one of which fails on the original code for each bug above).
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33534ee688
commit
4f7b350d96
@@ -66,6 +66,20 @@ pub fn swap_dims<const D: usize>(
|
||||
|
||||
// For 2D tensors swapping dims 0 and 1, use optimized transpose kernel
|
||||
if D == 2 && dim1 == 0 && dim2 == 1 {
|
||||
// A row or column vector transposes to itself in memory: [N,1] and
|
||||
// [1,N] are the same N contiguous floats. Share the buffer and swap
|
||||
// the shape — no kernel. This is not just an optimisation: a [4.2M,1]
|
||||
// gradient reaching the tiled kernel needs grid.y = 131,598, and CUDA
|
||||
// caps grid.y at 65,535, so the launch failed with INVALID_VALUE.
|
||||
if tensor.shape[0] == 1 || tensor.shape[1] == 1 {
|
||||
return CudaTensorPrimitive {
|
||||
data: tensor.data.clone(),
|
||||
shape: new_shape,
|
||||
strides: CudaTensorPrimitive::<D>::compute_strides(&new_shape),
|
||||
device: tensor.device.clone(),
|
||||
offset: tensor.offset,
|
||||
};
|
||||
}
|
||||
return transpose_2d_gpu(tensor);
|
||||
}
|
||||
|
||||
@@ -73,6 +87,9 @@ pub fn swap_dims<const D: usize>(
|
||||
transpose_generic_gpu(tensor, dim1, dim2, new_shape, new_strides)
|
||||
}
|
||||
|
||||
/// Hardware limit on `gridDim.y` (and `.z`); `gridDim.x` allows 2^31-1.
|
||||
const MAX_GRID_Y: usize = 65_535;
|
||||
|
||||
/// Optimized 2D matrix transpose using shared memory tiled kernel.
|
||||
fn transpose_2d_gpu<const D: usize>(tensor: &CudaTensorPrimitive<D>) -> CudaTensorPrimitive<D> {
|
||||
let device = &tensor.device;
|
||||
@@ -80,6 +97,16 @@ fn transpose_2d_gpu<const D: usize>(tensor: &CudaTensorPrimitive<D>) -> CudaTens
|
||||
let cols = tensor.shape[1];
|
||||
let numel = tensor.numel();
|
||||
|
||||
// The tiled kernel maps rows onto grid.y. Past 65,535 * 32 rows the launch
|
||||
// is rejected outright, so hand tall matrices to the 1-D generic permute.
|
||||
if rows.div_ceil(32) > MAX_GRID_Y {
|
||||
let mut new_shape = tensor.shape;
|
||||
let mut new_strides = tensor.strides;
|
||||
new_shape.swap(0, 1);
|
||||
new_strides.swap(0, 1);
|
||||
return transpose_generic_gpu(tensor, 0, 1, new_shape, new_strides);
|
||||
}
|
||||
|
||||
// Allocate output
|
||||
let output = device
|
||||
.stream()
|
||||
@@ -111,16 +138,18 @@ fn transpose_2d_gpu<const D: usize>(tensor: &CudaTensorPrimitive<D>) -> CudaTens
|
||||
.expect("Failed to launch transpose_2d kernel");
|
||||
}
|
||||
|
||||
// Swapped shape and strides for 2D
|
||||
// The kernel wrote a fresh buffer that is contiguous in the transposed
|
||||
// shape, so its strides are the contiguous ones for that shape — NOT the
|
||||
// input's strides swapped, which would describe a view of the *original*
|
||||
// buffer. Nothing outside this module reads strides today, so the old
|
||||
// stamp was latent rather than live; keep it honest anyway.
|
||||
let mut new_shape = tensor.shape;
|
||||
let mut new_strides = tensor.strides;
|
||||
new_shape.swap(0, 1);
|
||||
new_strides.swap(0, 1);
|
||||
|
||||
CudaTensorPrimitive {
|
||||
data: Arc::new(output),
|
||||
shape: new_shape,
|
||||
strides: new_strides,
|
||||
strides: CudaTensorPrimitive::<D>::compute_strides(&new_shape),
|
||||
device: device.clone(),
|
||||
offset: 0,
|
||||
}
|
||||
@@ -148,13 +177,23 @@ fn transpose_generic_gpu<const D: usize>(
|
||||
let mut permuted_input_strides = tensor.strides;
|
||||
permuted_input_strides.swap(dim1, dim2);
|
||||
|
||||
// The kernel DECODES each flat output index with `output_strides` — so they
|
||||
// must be the contiguous strides of the output shape, which is what the
|
||||
// kernel writes. `new_strides` (the input's strides swapped) was being
|
||||
// passed here instead; for a contiguous 2-D input that is [1, cols], which
|
||||
// decodes every index to itself and turns the transpose into a plain copy.
|
||||
// Nothing exercised this path with a numeric check until the tall-matrix
|
||||
// fallback did.
|
||||
let _ = new_strides;
|
||||
let out_strides = CudaTensorPrimitive::<D>::compute_strides(&new_shape);
|
||||
|
||||
// Upload strides and shape to GPU
|
||||
let stream = device.stream();
|
||||
let input_strides_gpu = stream
|
||||
.clone_htod(&permuted_input_strides[..])
|
||||
.expect("Failed to upload input strides");
|
||||
let output_strides_gpu = stream
|
||||
.clone_htod(&new_strides[..])
|
||||
.clone_htod(&out_strides[..])
|
||||
.expect("Failed to upload output strides");
|
||||
let output_shape_gpu = stream
|
||||
.clone_htod(&new_shape[..])
|
||||
@@ -197,7 +236,7 @@ fn transpose_generic_gpu<const D: usize>(
|
||||
CudaTensorPrimitive {
|
||||
data: Arc::new(output),
|
||||
shape: new_shape,
|
||||
strides: new_strides,
|
||||
strides: out_strides,
|
||||
device: device.clone(),
|
||||
offset: 0,
|
||||
}
|
||||
@@ -225,6 +264,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// grid.y is capped at 65,535 blocks of 32 rows. A column vector past that
|
||||
/// used to fail the launch with CUDA_ERROR_INVALID_VALUE; it is a reshape.
|
||||
#[test]
|
||||
fn transpose_of_a_tall_column_vector_is_a_reshape_not_a_launch() {
|
||||
if let Ok(device) = CudaDevice::new(0) {
|
||||
let n = 4_211_136; // the exact size that failed in a dg-gnn backward pass
|
||||
let host: Vec<f32> = (0..n).map(|i| i as f32).collect();
|
||||
let a = creation::from_data(&host, [n, 1], &device);
|
||||
let at = transpose(&a);
|
||||
assert_eq!(at.shape, [1, n]);
|
||||
assert!(at.is_contiguous());
|
||||
let back = dev_ops::copy_to_host(&at);
|
||||
assert_eq!(back[0], 0.0);
|
||||
assert_eq!(back[n - 1], (n - 1) as f32);
|
||||
assert_eq!(back[123_456], 123_456.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall matrix with more than one column cannot be a reshape and must
|
||||
/// take the 1-D generic path once rows/32 exceeds the grid.y limit.
|
||||
#[test]
|
||||
fn transpose_of_a_tall_matrix_falls_back_past_the_grid_limit() {
|
||||
if let Ok(device) = CudaDevice::new(0) {
|
||||
let rows = MAX_GRID_Y * 32 + 32; // one tile past the cap
|
||||
let cols = 3;
|
||||
let host: Vec<f32> = (0..rows * cols).map(|i| i as f32).collect();
|
||||
let a = creation::from_data(&host, [rows, cols], &device);
|
||||
let at = transpose(&a);
|
||||
assert_eq!(at.shape, [cols, rows]);
|
||||
assert!(at.is_contiguous());
|
||||
let back = dev_ops::copy_to_host(&at);
|
||||
// at[c][r] == a[r][c] == r*cols + c
|
||||
for (r, c) in [(0, 0), (1, 2), (rows - 1, 0), (rows - 1, 2), (1_000_000, 1)] {
|
||||
assert_eq!(back[c * rows + r], (r * cols + c) as f32, "at[{c}][{r}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiled kernel's output is contiguous in the transposed shape; the
|
||||
/// strides must say so rather than echo the input's strides swapped.
|
||||
#[test]
|
||||
fn tiled_transpose_output_is_stamped_contiguous() {
|
||||
if let Ok(device) = CudaDevice::new(0) {
|
||||
let a = creation::from_data(&[1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3], &device);
|
||||
let at = transpose(&a);
|
||||
assert_eq!(at.shape, [3, 2]);
|
||||
assert_eq!(at.strides, [2, 1]);
|
||||
assert!(at.is_contiguous());
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic permute decoded output indices with the wrong strides and
|
||||
/// degenerated into a copy. A 3-D dim swap goes through it unconditionally.
|
||||
#[test]
|
||||
fn generic_permute_actually_permutes() {
|
||||
if let Ok(device) = CudaDevice::new(0) {
|
||||
// a[i][j][k] = 100*i + 10*j + k, shape [2,3,4]
|
||||
let host: Vec<f32> = (0..2 * 3 * 4)
|
||||
.map(|n| { let (i, j, k) = (n / 12, (n / 4) % 3, n % 4); (100 * i + 10 * j + k) as f32 })
|
||||
.collect();
|
||||
let a = creation::from_data(&host, [2, 3, 4], &device);
|
||||
let b = swap_dims(&a, 0, 2); // b[k][j][i] == a[i][j][k], shape [4,3,2]
|
||||
assert_eq!(b.shape, [4, 3, 2]);
|
||||
assert!(b.is_contiguous());
|
||||
let back = dev_ops::copy_to_host(&b);
|
||||
for i in 0..2 { for j in 0..3 { for k in 0..4 {
|
||||
assert_eq!(back[k * 6 + j * 2 + i], (100 * i + 10 * j + k) as f32, "b[{k}][{j}][{i}]");
|
||||
}}}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reshape() {
|
||||
if let Ok(device) = CudaDevice::new(0) {
|
||||
|
||||
Reference in New Issue
Block a user