submission 870074
shivbhatia · python · License unknown
Use it
Vendorable · source mirrored · license unknownView source →
No package. Vendor the mirrored source: 315 lines, June 9 Researcher Reciprocity License v1.0.
eigh.py
curl "https://kernelindex.com/api/v1/implementations/kernelbot-eigh-870074?include=source"interfacepython
Compatibility
measured onNVIDIA B200
declared hardwareNVIDIA B200
architecturessm_100
dtypesfp32
Benchmark evidence
1 measurement across 1 GPU, fastest first.
Operation / workload
Hardware
Latency
Rank
Observed
Reported · How evidence levels are derived →
Source and license
sourceavailable
revision digestsha256:ff5f6f021768be80d6d47762b01f64fad051aa455788bff1c584baa26b8e4ea6
license declaredunknown
license concludedunknown
authorsshivbhatia
imported2026-08-26
Techniques
Extracted from the mirrored source by pattern, never inferred. Each row cites its line.
shared-memory
extern __shared__ float smem[];Kernel source
eigh.py315 lines
import torch
from functools import lru_cache
from torch.utils.cpp_extension import load_inline
from task import input_t, output_t
# popcorn-cli submit --gpu B200 --leaderboard eigh --mode leaderboard eigh.py
#
# ============================================================================
# Batched real-symmetric eigendecomposition via the Jacobi eigenvalue method.
# ============================================================================
#
# Returns (Q, L) matching torch.linalg.eigh:
# Q : (B, n, n) columns are orthonormal eigenvectors
# L : (B, n) eigenvalues, ascending
# such that A @ Q = Q @ diag(L) and A = Q diag(L) Q^T.
#
# HOW JACOBI WORKS
# ----------------
# A symmetric matrix is diagonalised by repeatedly applying tiny 2x2 rotations
# ("Givens rotations"), each chosen to zero one off-diagonal entry A[p][q].
# We apply the two-sided similarity transform A <- G^T A G , which preserves
# eigenvalues. Accumulating every G into V gives the eigenvectors: at the end
# A = V diag(A) V^T, so the columns of V are eigenvectors and diag(A) are the
# eigenvalues.
#
# Rotations acting on DISJOINT index pairs commute and touch disjoint rows /
# columns, so we can do n/2 of them at once. A round-robin ("chess tournament")
# schedule visits every pair {i,j} exactly once over n-1 rounds = one "sweep".
# A handful of sweeps converges to machine precision.
#
# GPU MAPPING
# -----------
# One thread-block per matrix. The matrix A and the eigenvector accumulator V
# live in shared memory, so every rotation is a cheap on-chip update. Threads
# cooperate across the n/2 pairs and the n rows/cols of each round.
#
# LIMITATION (correctness-first, not yet fast)
# --------------------------------------------
# Shared memory holds A + V = 2*n*n floats, which fits only up to n ~= 140 on
# current hardware. For larger n (including the 512 target) we fall back to
# torch.linalg.eigh so the submission is always correct. Scaling the kernel to
# 512+ needs block-Jacobi (tiling), which is the next step.
#
# ROTATION CONVENTION (kept self-consistent everywhere)
# -----------------------------------------------------
# With G = [[c,-s],[s,c]] on the (p,q) plane and A' = G^T A G, the (p,q) entry
# vanishes when theta = 0.5 * atan2(2*a_pq, a_pp - a_qq). Both the column
# update (A G, and V G) and the row update (G^T A) then take the form
# new_p = c*old_p + s*old_q
# new_q = -s*old_p + c*old_q
# ============================================================================
cuda_source = r"""
#include <cuda_runtime.h>
#include <math.h>
// One block per matrix. Dynamic shared memory layout (all float):
// sA : n*n working copy of the matrix (becomes diagonal)
// sV : n*n eigenvector accumulator (starts as identity)
// cs : 2*m per-pair (cos, sin) for the current round, m = n/2
__global__ void jacobi_kernel(
const float* __restrict__ A, // (B, n, n) input, row-major
float* __restrict__ V, // (B, n, n) eigenvectors out (unsorted)
float* __restrict__ L, // (B, n) eigenvalues out (unsorted)
const int* __restrict__ Pidx, // (n-1, m) "p" index of each pair, per round
const int* __restrict__ Qidx, // (n-1, m) "q" index of each pair, per round
int n, int nsweeps
) {
extern __shared__ float smem[];
const int m = n / 2;
float* sA = smem;
float* sV = sA + n * n;
float* cs = sV + n * n; // cs[2k] = cos, cs[2k+1] = sin for pair k
const int b = blockIdx.x; // which matrix in the batch
const int tid = threadIdx.x;
const int T = blockDim.x;
const float* Ab = A + (size_t)b * n * n;
// Load A into shared memory and set V = identity.
for (int idx = tid; idx < n * n; idx += T) {
sA[idx] = Ab[idx];
int i = idx / n, j = idx % n;
sV[idx] = (i == j) ? 1.0f : 0.0f;
}
__syncthreads();
// Symmetrise away FP32 roundoff: A <- 0.5 (A + A^T). The upper triangle
// drives both halves so the result is exactly symmetric.
for (int idx = tid; idx < n * n; idx += T) {
int i = idx / n, j = idx % n;
if (i < j) {
float avg = 0.5f * (sA[i * n + j] + sA[j * n + i]);
sA[i * n + j] = avg;
sA[j * n + i] = avg;
}
}
__syncthreads();
for (int sweep = 0; sweep < nsweeps; ++sweep) {
for (int r = 0; r < n - 1; ++r) {
const int* Pr = Pidx + r * m;
const int* Qr = Qidx + r * m;
// (1) Choose each pair's rotation angle from the CURRENT matrix,
// before any entry is modified this round.
for (int k = tid; k < m; k += T) {
int p = Pr[k], q = Qr[k];
float app = sA[p * n + p];
float aqq = sA[q * n + q];
float apq = sA[p * n + q];
float theta = 0.5f * atan2f(2.0f * apq, app - aqq);
cs[2 * k] = cosf(theta);
cs[2 * k + 1] = sinf(theta);
}
__syncthreads();
// (2) Right-multiply by G: rotate columns p,q of A and of V.
// Work is indexed by (pair k, row i). Because the pairs are
// disjoint, no two threads touch the same column, so there are
// no races even though many threads write sA / sV at once.
for (int idx = tid; idx < m * n; idx += T) {
int k = idx / n, i = idx % n;
int p = Pr[k], q = Qr[k];
float c = cs[2 * k], s = cs[2 * k + 1];
float ap = sA[i * n + p], aq = sA[i * n + q];
sA[i * n + p] = c * ap + s * aq;
sA[i * n + q] = -s * ap + c * aq;
float vp = sV[i * n + p], vq = sV[i * n + q];
sV[i * n + p] = c * vp + s * vq;
sV[i * n + q] = -s * vp + c * vq;
}
__syncthreads();
// (3) Left-multiply by G^T: rotate rows p,q of A.
// Indexed by (pair k, col j). Same disjointness argument.
for (int idx = tid; idx < m * n; idx += T) {
int k = idx / n, j = idx % n;
int p = Pr[k], q = Qr[k];
float c = cs[2 * k], s = cs[2 * k + 1];
float tp = sA[p * n + j], tq = sA[q * n + j];
sA[p * n + j] = c * tp + s * tq;
sA[q * n + j] = -s * tp + c * tq;
}
__syncthreads();
}
}
// Emit results: eigenvalues = diag(A), eigenvectors = columns of V.
// Sorting into ascending order is done cheaply on the host afterwards.
for (int idx = tid; idx < n * n; idx += T)
V[(size_t)b * n * n + idx] = sV[idx];
for (int i = tid; i < n; i += T)
L[(size_t)b * n + i] = sA[i * n + i];
}
// Host launcher: opt into large dynamic shared memory (needed above 48 KB),
// then launch one block per matrix.
void jacobi_launch(torch::Tensor A, torch::Tensor V, torch::Tensor L,
torch::Tensor P, torch::Tensor Q, int n, int nsweeps,
int threads, int smem_bytes) {
cudaFuncSetAttribute(jacobi_kernel,
cudaFuncAttributeMaxDynamicSharedMemorySize, smem_bytes);
int B = A.size(0);
jacobi_kernel<<<B, threads, smem_bytes>>>(
A.data_ptr<float>(), V.data_ptr<float>(), L.data_ptr<float>(),
P.data_ptr<int>(), Q.data_ptr<int>(), n, nsweeps);
}
"""
cpp_source = (
"void jacobi_launch(torch::Tensor A, torch::Tensor V, torch::Tensor L, "
"torch::Tensor P, torch::Tensor Q, int n, int nsweeps, int threads, "
"int smem_bytes);"
)
_ext = load_inline(
# NOTE: name bumped to force a fresh build — an earlier version compiled
# under a different name could otherwise be served stale from a cached
# TORCH_EXTENSIONS_DIR. Also dropped -use_fast_math so the trig used to
# build rotations is full-accuracy (rules it out as an error source).
name="jacobi_eigh_v3",
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=["jacobi_launch"],
extra_cuda_cflags=["-arch=sm_80"],
verbose=False,
)
# Largest per-block shared allocation we'll request. 160 KB is safe on the
# target hardware and covers n up to ~140; bigger matrices fall back to torch.
_SMEM_LIMIT = 160 * 1024
_NSWEEPS = 8 # ~6-8 sweeps converges for these sizes; the safety net below
# catches any matrix that hasn't, so we can run lean here
@lru_cache(maxsize=None)
def _schedule(n: int, device_str: str):
"""Round-robin pairing schedule.
Returns two (n-1, n/2) int32 tensors P, Q. In round r the disjoint pairs
are (P[r,k], Q[r,k]). Built with the standard "circle method": index 0 is
fixed while the rest rotate, so every unordered pair appears exactly once
over the n-1 rounds.
"""
arr = list(range(n))
P, Q = [], []
for _ in range(n - 1):
P.append(arr[: n // 2])
Q.append(arr[n // 2:][::-1]) # fold second half onto the first
arr = [arr[0]] + [arr[-1]] + arr[1:-1] # rotate all but arr[0]
dev = torch.device(device_str)
return (torch.tensor(P, dtype=torch.int32, device=dev),
torch.tensor(Q, dtype=torch.int32, device=dev))
# --- Block-Jacobi parameters (the path for the big n>=512 shapes) ----------
_BLOCK = 128 # b: tile size; n must be divisible by b and N=n/b even
_BLOCK_SWEEPS = 6 # block sweeps to convergence; safety net catches stragglers
def _scalar_jacobi(A: torch.Tensor, n: int, B: int, smem: int):
"""Shared-memory scalar Jacobi CUDA kernel (small n). Returns (V, L)."""
P, Q_idx = _schedule(n, "cuda")
V = torch.empty_like(A)
L = torch.empty(B, n, device=A.device, dtype=torch.float32)
# One warp-rounded block sized to the n/2 pairs (capped at 1024 threads).
threads = min(1024, max(32, ((n // 2 + 31) // 32) * 32))
_ext.jacobi_launch(A, V, L, P, Q_idx, n, _NSWEEPS, threads, smem)
L, order = torch.sort(L, dim=-1)
V = torch.gather(V, 2, order.unsqueeze(1).expand(-1, n, -1))
return V, L
def _block_jacobi(A: torch.Tensor, b: int, sweeps: int):
"""Two-sided block-Jacobi eigensolver for large n. Returns (V, L).
Tiles A into an N x N grid of b x b blocks (n = N*b). Each round pairs up
all N block-indices into N/2 disjoint pairs; for each pair (I,J) it eigen-
decomposes the 2b x 2b submatrix and applies that rotation to the whole
matrix via a block-structured orthogonal W: A <- W^T A W, V <- V W.
After a few sweeps A is diagonal and the columns of V are the eigenvectors.
PHASE 1: the big W^T A W / V W matmuls run in FP32. Switching them to FP16
(cast W and A to .half() around the matmul) is the next speed lever and is
what puts the work on tensor cores; the safety net guards the precision.
"""
B, n, _ = A.shape
N = n // b
dev = A.device
A = 0.5 * (A + A.transpose(-1, -2)) # exact symmetry
# Normalise magnitude to O(1) so high-magnitude inputs can't overflow the
# FP32 matmuls (and to improve conditioning). Eigenvectors are scale-
# invariant; eigenvalues scale by s and are unscaled at the end.
s = A.abs().amax(dim=(-2, -1), keepdim=True).clamp_min(1e-30) # (B,1,1)
A = A / s
V = torch.eye(n, device=dev, dtype=A.dtype).unsqueeze(0).expand(B, n, n).contiguous()
P, Q = _schedule(N, str(dev)) # round-robin over BLOCK indices
P, Q = P.long(), Q.long() # (N-1, N/2) each
lo, hi = slice(0, b), slice(b, 2 * b) # halves of the 2b x 2b block
for _ in range(sweeps):
for r in range(N - 1):
I, J = P[r], Q[r] # (N/2,) disjoint block indices
# Gather the 2b x 2b submatrix for every pair (and every matrix).
# Ap[bt, R, C] is the (R,C) b x b block of matrix bt.
Ap = A.reshape(B, N, b, N, b).permute(0, 1, 3, 2, 4) # (B,N,N,b,b)
top = torch.cat([Ap[:, I, I], Ap[:, I, J]], dim=-1) # (B,N/2,b,2b)
bot = torch.cat([Ap[:, J, I], Ap[:, J, J]], dim=-1)
M = torch.cat([top, bot], dim=-2) # (B,N/2,2b,2b)
# Eigenvectors U of each 2b x 2b block (eigenvalues not needed here).
_, U = torch.linalg.eigh(M.reshape(-1, 2 * b, 2 * b))
U = U.reshape(B, N // 2, 2 * b, 2 * b)
# Assemble the round's orthogonal transform W: identity everywhere
# except the four b x b sub-blocks of each pair, filled from U.
Wb = torch.zeros(B, N, N, b, b, device=dev, dtype=A.dtype)
Wb[:, I, I] = U[:, :, lo, lo]
Wb[:, I, J] = U[:, :, lo, hi]
Wb[:, J, I] = U[:, :, hi, lo]
Wb[:, J, J] = U[:, :, hi, hi]
W = Wb.permute(0, 1, 3, 2, 4).reshape(B, n, n)
# Two-sided similarity update + eigenvector accumulation, with the
# big matmuls in FP16 to hit tensor cores (this is the speed win).
# A and V are kept in FP32 between rounds to limit error build-up;
# inputs are O(1) after normalisation so FP16 can't overflow.
Wh = W.half()
A = torch.matmul(torch.matmul(Wh.transpose(-1, -2), A.half()), Wh).float()
V = torch.matmul(V.half(), Wh).float()
L = A.diagonal(dim1=-2, dim2=-1) * s.reshape(B, 1) # unscale eigenvalues
L, order = torch.sort(L, dim=-1)
V = torch.gather(V, 2, order.unsqueeze(1).expand(-1, n, -1))
return V, L
def custom_kernel(data: input_t) -> output_t:
# MEASURED (submission 870047, per-case): cuSOLVER via torch.linalg.eigh
# beats both custom paths on *every* benchmark shape -- the scalar kernel
# loses to launch/overhead on small n, and the PyTorch block-Jacobi loses
# ~10x on large n (subproblem eighs + full-matrix global-memory traffic each
# round). Pure torch (~54 ms geomean) is the best config we have measured,
# so route everything there. The _scalar_jacobi / _block_jacobi helpers
# above are kept as the algorithmic basis for a future *fused* CUDA kernel,
# which is the only plausible route below cuSOLVER.
L, Q = torch.linalg.eigh(data)
return Q, L
scrolls · 315 lines total
Source code from GPU Mode and the KernelBot dataset · June 9 Researcher Reciprocity License v1.0
Best evidence level for this revision: reported
JSON