Skip to content
KernelIndex
Search⌘K

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
NVIDIA B200
48.7ms
#147 of 286
2026-07-12

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-memoryextern __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