perf: tune bf16 gemv and add opt-in fused swiglu

- deepen common-shape BF16 GEMV tuning with warp-row tiling for LLaMA/Qwen2/GPT-NeoX/OPT decode projections
- add fused BF16 up/gate SwiGLU CUDA primitive with ASTRAI_SWIGLU=0/1/auto dispatch
- keep the unfused linear backend as the default path; auto enables no shape until per-architecture checkpoint gates pass
- fall back to the linear/torch chain when kernels are absent, on CPU, in training, or outside supported M/K/dtype shapes
- add gemv/swiglu benchmark scripts, dispatch and parity tests, and kernel documentation

Benchmark: NVIDIA L20 (sm_89), CUDA 12.8, PyTorch 2.11.0+cu128, idle GPU. AstrAI 1B config (24 layers, hidden 1536, vocab 100000), BF16, prompt 128, 32 greedy decode tokens, CUDA graphs enabled, A/B in separate interleaved processes (3 rounds, 8 trials each, medians). Default vs ASTRAI_SWIGLU=1 per generate call: batch 1 134.8->129.1 ms (+4.44%), batch 2 136.2->130.9 ms (+4.06%), batch 4 145.5->140.3 ms (+3.66%). Greedy output identical at batch 1, differs at batch 2/4, so auto stays unfused by default; kernelless fallback verified bit-identical greedy.
This commit is contained in:
0z5a
2026-09-03 04:26:53 +08:00
parent 88c06db096
commit d4a292b36b
20 changed files with 2008 additions and 37 deletions
+4
View File
@@ -27,6 +27,7 @@ from astrai.extension.backend import (
attn_backend,
get_backend,
linear,
swiglu,
)
from astrai.extension.dispatch import (
Axes,
@@ -51,6 +52,7 @@ from astrai.extension.ops import (
attn_paged_decode,
attn_prefill,
bf16_gemv,
bf16_swiglu,
)
__all__ = [
@@ -65,10 +67,12 @@ __all__ = [
"attn_backend",
"get_backend",
"linear",
"swiglu",
"attn_decode",
"attn_paged_decode",
"attn_prefill",
"bf16_gemv",
"bf16_swiglu",
"is_available",
"KERNEL_NAMES",
"apply_rotary_emb",
+2
View File
@@ -13,6 +13,7 @@ from astrai.extension.backend.attention import (
)
from astrai.extension.backend.linear import linear
from astrai.extension.backend.rotary import apply_rotary_emb
from astrai.extension.backend.swiglu import swiglu
__all__ = [
"ATTN_BACKEND",
@@ -26,4 +27,5 @@ __all__ = [
"attn_backend",
"get_backend",
"linear",
"swiglu",
]
+61 -9
View File
@@ -1,9 +1,9 @@
"""Inference-only dispatch for AstrAI linear layers.
The CUDA GEMV path is deliberately narrow: automatic selection is enabled
only for single-row BF16 shapes measured to beat ``F.linear`` on a supported
architecture. Every training, prefill, unsupported-layout, and unmeasured
call falls back to PyTorch.
only for small decode batches and BF16 shapes measured to beat ``F.linear``
on a supported architecture. Every training, prefill, unsupported-layout,
and unmeasured call falls back to PyTorch.
"""
import logging
@@ -30,20 +30,72 @@ from astrai.extension.ops.gemv import bf16_gemv
logger = logging.getLogger(__name__)
# Shape keys are (N, K) for Y[M, N] = X[M, K] @ W[N, K].T. A band is
# automatic only after both the per-shape >=5% and end-to-end decode >=3%
# gates pass and checkpoint greedy output remains stable. M=1 and M=8 remain
# empty on SM89; the safe M=2/4 bands improve real-engine throughput by
# 11.8-14.0%.
# automatic only after both the per-shape >=5% and projection-chain/engine
# >=3% gates pass and output argmax remains stable. M=1 is limited to OPT 1.3B;
# M=8 remains empty because at least one projection in each measured family
# misses the per-shape gate even when its aggregate chain result is positive.
_COMMON_TRANSFORMER_SM89_SHAPES = frozenset(
{
(1024, 4096), # LLaMA 3 8B K/V
(4096, 4096), # LLaMA 2/3 7B/8B Q/O
(11008, 4096), # LLaMA 2 7B gate/up
(4096, 11008), # LLaMA 2 7B down
(14336, 4096), # LLaMA 3 8B gate/up
(4096, 14336), # LLaMA 3 8B down
(5120, 5120), # LLaMA 2 13B Q/K/V/O
(13824, 5120), # LLaMA 2 13B gate/up
(5120, 13824), # LLaMA 2 13B down
(16384, 4096), # GPT-NeoX MLP up
(4096, 16384), # GPT-NeoX MLP down
}
)
_COMMON_TRANSFORMER_SM89_M4_SHAPES = _COMMON_TRANSFORMER_SM89_SHAPES - {
(4096, 4096),
(11008, 4096),
(4096, 11008),
}
_QWEN2_7B_SM89_SHAPES = frozenset(
{
(512, 3584), # K/V
(3584, 3584), # Q/O
(18944, 3584), # gate/up
(3584, 18944), # down
}
)
_LLAMA3_70B_SM89_SHAPES = frozenset(
{
(1024, 8192), # K/V
(8192, 8192), # Q/O
(28672, 8192), # gate/up
(8192, 28672), # down
}
)
_OPT_1_3B_SM89_SHAPES = frozenset(
{
(2048, 2048), # Q/K/V/O
(8192, 2048), # MLP up
(2048, 8192), # MLP down
}
)
_AUTO_GEMV_SHAPES: dict[tuple[int, int], dict[int, frozenset[tuple[int, int]]]] = {
(8, 9): {
2: frozenset(
1: _OPT_1_3B_SM89_SHAPES,
2: _COMMON_TRANSFORMER_SM89_SHAPES
| _QWEN2_7B_SM89_SHAPES
| _LLAMA3_70B_SM89_SHAPES
| _OPT_1_3B_SM89_SHAPES
| frozenset(
{
(256, 1536),
(1536, 1536),
(100000, 1536),
}
),
4: frozenset({(256, 1536), (1536, 1536)}),
4: _COMMON_TRANSFORMER_SM89_M4_SHAPES
| _QWEN2_7B_SM89_SHAPES
| _LLAMA3_70B_SM89_SHAPES
| frozenset({(256, 1536), (1536, 1536)}),
}
}
_AUTO_GEMV_M = frozenset(
+109
View File
@@ -0,0 +1,109 @@
"""Inference-only fused SwiGLU selection for dense MLP layers."""
import logging
import os
from functools import cache
import torch
import torch.nn.functional as F
from torch import Tensor
from astrai.extension.backend.linear import linear
from astrai.extension.loader import is_available
from astrai.extension.ops.swiglu import bf16_swiglu
logger = logging.getLogger(__name__)
# Shape keys are (N, K) for the paired up/gate projections. Automatic entries
# are populated only after the primitive, MLP chain, and greedy checkpoint
# gates pass on that architecture.
_AUTO_SWIGLU_SHAPES: dict[tuple[int, int], dict[int, frozenset[tuple[int, int]]]] = {}
_AUTO_SWIGLU_M = frozenset(
m for architecture in _AUTO_SWIGLU_SHAPES.values() for m in architecture
)
_VALID_MODES = {"0", "1", "auto"}
_WARNED_MODES: set[str] = set()
def _swiglu_mode() -> str:
mode = os.environ.get("ASTRAI_SWIGLU", "auto").strip().lower()
if mode in _VALID_MODES:
return mode
if mode not in _WARNED_MODES:
_WARNED_MODES.add(mode)
logger.warning(
"ASTRAI_SWIGLU=%r is invalid; expected 0, 1, or auto; using auto",
mode,
)
return "auto"
def _unfused_swiglu(x: Tensor, up_weight: Tensor, gate_weight: Tensor) -> Tensor:
# Keep the existing linear backend in the fallback chain. This preserves
# any independently qualified GEMV shapes instead of making the fusion
# decision suppress linear-level optimizations.
return linear(x, up_weight) * F.silu(linear(x, gate_weight))
def _fused_swiglu(x: Tensor, up_weight: Tensor, gate_weight: Tensor) -> Tensor:
return bf16_swiglu(x.detach(), up_weight.detach(), gate_weight.detach())
@cache
def _device_capability(device_index: int) -> tuple[int, int]:
return torch.cuda.get_device_capability(device_index)
def _swiglu_capable(x: Tensor, up_weight: Tensor, gate_weight: Tensor) -> bool:
return not (
torch.is_grad_enabled()
or not x.is_cuda
or x.dtype != torch.bfloat16
or up_weight.dtype != torch.bfloat16
or gate_weight.dtype != torch.bfloat16
or x.ndim not in (1, 2)
or up_weight.ndim != 2
or gate_weight.ndim != 2
or (x.ndim == 2 and not 1 <= x.shape[0] <= 8)
or up_weight.shape != gate_weight.shape
or x.shape[-1] != up_weight.shape[1]
or x.shape[-1] % 8 != 0
or x.device != up_weight.device
or x.device != gate_weight.device
or not x.is_contiguous()
or not up_weight.is_contiguous()
or not gate_weight.is_contiguous()
or not is_available("bf16_swiglu")
)
def _auto_swiglu_shape(x: Tensor, up_weight: Tensor) -> bool:
capability = _device_capability(x.get_device())
m = 1 if x.ndim == 1 else x.shape[0]
return (up_weight.shape[0], up_weight.shape[1]) in _AUTO_SWIGLU_SHAPES.get(
capability, {}
).get(m, ())
def swiglu(x: Tensor, up_weight: Tensor, gate_weight: Tensor) -> Tensor:
"""Apply the dense-MLP SwiGLU projection with a safe torch fallback.
``ASTRAI_SWIGLU=0`` keeps the unfused linear-backend chain, ``1`` forces
the fused primitive for supported inputs, and ``auto`` uses only
architecture/shape bands backed by benchmark and checkpoint evidence.
"""
mode = _swiglu_mode()
if mode == "0" or (mode == "auto" and not _AUTO_SWIGLU_SHAPES):
return _unfused_swiglu(x, up_weight, gate_weight)
if mode == "auto":
m = 1 if x.ndim == 1 else (x.shape[0] if x.ndim == 2 else None)
if m not in _AUTO_SWIGLU_M:
return _unfused_swiglu(x, up_weight, gate_weight)
if _swiglu_capable(x, up_weight, gate_weight) and (
mode == "1" or _auto_swiglu_shape(x, up_weight)
):
return _fused_swiglu(x, up_weight, gate_weight)
return _unfused_swiglu(x, up_weight, gate_weight)
__all__ = ["swiglu"]
+2
View File
@@ -9,6 +9,7 @@ from astrai.extension.ops.attention import (
)
from astrai.extension.ops.gemv import bf16_gemv
from astrai.extension.ops.rotary import rotary_emb
from astrai.extension.ops.swiglu import bf16_swiglu
__all__ = [
"TensorLayout",
@@ -17,5 +18,6 @@ __all__ = [
"attn_paged_prefill",
"attn_prefill",
"bf16_gemv",
"bf16_swiglu",
"rotary_emb",
]
+22
View File
@@ -0,0 +1,22 @@
"""Stateless wrapper for the directly callable fused BF16 SwiGLU primitive."""
import torch
from astrai.extension.loader import get_module
def bf16_swiglu(
x: torch.Tensor,
up_weight: torch.Tensor,
gate_weight: torch.Tensor,
) -> torch.Tensor:
"""Compute ``linear(x, up) * silu(linear(x, gate))`` for M in [1, 8].
Inputs must be contiguous BF16 CUDA tensors. Both weights use row-major
``[N, K]`` storage with identical shapes, and K must be divisible by 8.
The primitive is inference-only and performs no fallback.
"""
return get_module("bf16_swiglu").bf16_swiglu(x, up_weight, gate_weight)
__all__ = ["bf16_swiglu"]
+2 -1
View File
@@ -5,6 +5,7 @@ import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
from astrai.extension.backend.swiglu import swiglu
from astrai.factory import BaseFactory
from astrai.model.components.linear import Linear
@@ -38,7 +39,7 @@ class MLP(nn.Module):
self.down = Linear(dim_ffn, dim, init_std=down_init_std)
def forward(self, x: Tensor) -> FFNOutput:
gated = self.up(x) * F.silu(self.gate(x))
gated = swiglu(x, self.up.weight, self.gate.weight)
out = self.down(gated)
return {"hidden_states": out, "aux_loss": None, "router_stats": None}
+2
View File
@@ -62,6 +62,7 @@ set(KERNEL_NAMES
attn_paged_decode
attn_paged_prefill
bf16_gemv
bf16_swiglu
rotary_emb
)
set(KERNEL_SRCS
@@ -70,6 +71,7 @@ set(KERNEL_SRCS
attention/paged_decode.cu
attention/paged_prefill.cu
gemv/bf16_gemv.cu
gemv/bf16_swiglu.cu
rotary_emb.cu
)
+141 -5
View File
@@ -12,7 +12,9 @@
namespace {
constexpr int kThreads = 256;
constexpr int kHalfCtaThreads = 128;
constexpr int kWarpSize = 32;
constexpr int kWarpTiledThreads = 128;
__device__ __forceinline__ float warp_sum(float value) {
#pragma unroll
@@ -22,7 +24,7 @@ __device__ __forceinline__ float warp_sum(float value) {
return value;
}
template <int Rows>
template <int Rows, int Threads>
__global__ void bf16_gemv_kernel(
const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ weight,
@@ -36,7 +38,7 @@ __global__ void bf16_gemv_kernel(
const int warp = threadIdx.x / kWarpSize;
float sums[Rows] = {};
__shared__ float warp_sums[Rows][kThreads / kWarpSize];
__shared__ float warp_sums[Rows][Threads / kWarpSize];
// Weight row: scalar head/tail around a 16-byte-aligned uint4 middle so
// any K is accepted while keeping 128-bit weight loads, which dominate
// bandwidth on decode shapes. x pairs with scalar loads: it is a tiny
@@ -129,7 +131,6 @@ __global__ void bf16_gemv_kernel(
}
}
#pragma unroll
for (int row = 0; row < Rows; ++row) {
sums[row] = warp_sum(sums[row]);
@@ -146,7 +147,7 @@ __global__ void bf16_gemv_kernel(
#pragma unroll
for (int row = 0; row < Rows; ++row) {
float sum =
lane < (kThreads / kWarpSize) ? warp_sums[row][lane] : 0.0f;
lane < (Threads / kWarpSize) ? warp_sums[row][lane] : 0.0f;
sum = warp_sum(sum);
if (lane == 0) {
if (bias != nullptr) {
@@ -158,6 +159,120 @@ __global__ void bf16_gemv_kernel(
}
}
template <int Rows>
__global__ void bf16_gemv_aligned_warp_tiled_kernel(
const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ weight,
const __nv_bfloat16* __restrict__ bias,
__nv_bfloat16* __restrict__ output,
int n,
int k
) {
constexpr int kWarpsPerBlock = kWarpTiledThreads / kWarpSize;
const int lane = threadIdx.x & (kWarpSize - 1);
const int warp = threadIdx.x / kWarpSize;
const int output_index = blockIdx.x * kWarpsPerBlock + warp;
if (output_index >= n) {
return;
}
// The launcher selects this path only when each row is 16-byte aligned.
// Four independent output rows per CTA remove the block-wide reduction
// barrier and improve occupancy for the medium LLaMA projection bands.
const int vectors = k / 8;
const auto* x4 = reinterpret_cast<const uint4*>(x);
const auto* w4 = reinterpret_cast<const uint4*>(weight) +
static_cast<int64_t>(output_index) * vectors;
float sums[Rows] = {};
for (int vector = lane; vector < vectors; vector += kWarpSize) {
const uint4 wv_raw = w4[vector];
const auto* wv = reinterpret_cast<const __nv_bfloat162*>(&wv_raw);
#pragma unroll
for (int row = 0; row < Rows; ++row) {
const uint4 xv_raw =
x4[static_cast<int64_t>(row) * vectors + vector];
const auto* xv = reinterpret_cast<const __nv_bfloat162*>(&xv_raw);
#pragma unroll
for (int pair = 0; pair < 4; ++pair) {
sums[row] = fmaf(
__bfloat162float(__low2bfloat16(xv[pair])),
__bfloat162float(__low2bfloat16(wv[pair])),
sums[row]
);
sums[row] = fmaf(
__bfloat162float(__high2bfloat16(xv[pair])),
__bfloat162float(__high2bfloat16(wv[pair])),
sums[row]
);
}
}
}
#pragma unroll
for (int row = 0; row < Rows; ++row) {
sums[row] = warp_sum(sums[row]);
if (lane == 0) {
if (bias != nullptr) {
sums[row] += __bfloat162float(bias[output_index]);
}
output[row * n + output_index] = __float2bfloat16_rn(sums[row]);
}
}
}
template <int Rows>
constexpr bool use_warp_tiled_kernel(int n, int k) {
// These bands are intentionally narrow and are validated by the common
// transformer benchmark. The 256-thread cooperative kernel remains the
// fallback for arbitrary K, larger projections, and M=2 (where the
// single-warp reduction regresses the current vectorized kernel).
if constexpr (Rows == 4) {
return (n == 1024 && k == 4096) ||
(n == 4096 && k == 4096) ||
(n == 11008 && k == 4096) ||
(n == 4096 && k == 11008);
}
return false;
}
template <int Rows>
constexpr bool use_half_cta_kernel(int n, int k) {
// A 128-thread CTA reduces synchronization and scheduling overhead for
// selected medium decode projections. Keep the selector exact: long-K
// and bandwidth-saturated shapes regress, and the winning bands differ
// materially with the number of reused input rows.
if constexpr (Rows == 1) {
return n == 8192 && k == 2048;
}
if constexpr (Rows == 2) {
return (n == 4096 && k == 4096) ||
(n == 11008 && k == 4096) ||
(n == 3584 && k == 3584) ||
(n == 2048 && k == 2048) ||
(n == 8192 && k == 2048);
}
if constexpr (Rows == 4) {
return (n == 5120 && k == 5120) ||
(n == 3584 && k == 3584) ||
(n == 2048 && k == 2048) ||
(n == 8192 && k == 2048);
}
if constexpr (Rows == 8) {
return (n == 4096 && k == 4096) ||
(n == 11008 && k == 4096) ||
(n == 4096 && k == 11008) ||
(n == 1024 && k == 4096) ||
(n == 5120 && k == 5120) ||
(n == 512 && k == 3584) ||
(n == 3584 && k == 3584) ||
(n == 1024 && k == 8192) ||
(n == 2048 && k == 2048) ||
(n == 8192 && k == 2048) ||
(n == 2048 && k == 8192);
}
return false;
}
template <int Rows>
void launch_bf16_gemv(
const __nv_bfloat16* x,
@@ -168,7 +283,28 @@ void launch_bf16_gemv(
int k,
cudaStream_t stream
) {
bf16_gemv_kernel<Rows><<<n, kThreads, 0, stream>>>(
const bool aligned_rows = k % 8 == 0 &&
(reinterpret_cast<uintptr_t>(x) & 15u) == 0u &&
(reinterpret_cast<uintptr_t>(weight) & 15u) == 0u;
if constexpr (Rows == 4) {
if (aligned_rows && use_warp_tiled_kernel<Rows>(n, k)) {
constexpr int kWarpsPerBlock = kWarpTiledThreads / kWarpSize;
const int blocks = (n + kWarpsPerBlock - 1) / kWarpsPerBlock;
bf16_gemv_aligned_warp_tiled_kernel<Rows>
<<<blocks, kWarpTiledThreads, 0, stream>>>(
x, weight, bias, output, n, k
);
return;
}
}
if (aligned_rows && use_half_cta_kernel<Rows>(n, k)) {
bf16_gemv_kernel<Rows, kHalfCtaThreads>
<<<n, kHalfCtaThreads, 0, stream>>>(
x, weight, bias, output, n, k
);
return;
}
bf16_gemv_kernel<Rows, kThreads><<<n, kThreads, 0, stream>>>(
x, weight, bias, output, n, k
);
}
+368
View File
@@ -0,0 +1,368 @@
// Fused small-M BF16 SwiGLU primitive for decode-time dense MLP layers.
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include <torch/extension.h>
#include <cstdint>
#include <limits>
namespace {
constexpr int kThreads = 256;
constexpr int kWarpSize = 32;
constexpr int kWarps = kThreads / kWarpSize;
__device__ __forceinline__ float warp_sum(float value) {
#pragma unroll
for (int offset = kWarpSize / 2; offset > 0; offset >>= 1) {
value += __shfl_down_sync(0xffffffff, value, offset);
}
return value;
}
__device__ __forceinline__ float round_bf16(float value) {
return __bfloat162float(__float2bfloat16_rn(value));
}
template <int Rows>
__global__ void bf16_swiglu_kernel(
const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ up_weight,
const __nv_bfloat16* __restrict__ gate_weight,
__nv_bfloat16* __restrict__ output,
int n,
int k
) {
const int output_index = blockIdx.x;
const int lane = threadIdx.x & (kWarpSize - 1);
const int warp = threadIdx.x / kWarpSize;
const int vector_count = k / 8;
float up_sums[Rows] = {};
float gate_sums[Rows] = {};
__shared__ float up_warp_sums[Rows][kWarps];
__shared__ float gate_warp_sums[Rows][kWarps];
const auto* x4 = reinterpret_cast<const uint4*>(x);
const auto* up4 = reinterpret_cast<const uint4*>(
up_weight + static_cast<int64_t>(output_index) * k
);
const auto* gate4 = reinterpret_cast<const uint4*>(
gate_weight + static_cast<int64_t>(output_index) * k
);
// Read each pair of up/gate weight chunks once per CTA, then reuse it for
// every active decode row. The fused epilogue removes two [M, N]
// intermediates and the standalone SiLU and multiply launches.
for (int vector_index = threadIdx.x;
vector_index < vector_count;
vector_index += blockDim.x) {
const uint4 up_raw = up4[vector_index];
const uint4 gate_raw = gate4[vector_index];
const auto* up_values =
reinterpret_cast<const __nv_bfloat162*>(&up_raw);
const auto* gate_values =
reinterpret_cast<const __nv_bfloat162*>(&gate_raw);
#pragma unroll
for (int row = 0; row < Rows; ++row) {
const uint4 x_raw =
x4[static_cast<int64_t>(row) * vector_count + vector_index];
const auto* x_values =
reinterpret_cast<const __nv_bfloat162*>(&x_raw);
#pragma unroll
for (int pair = 0; pair < 4; ++pair) {
const float2 xv = __bfloat1622float2(x_values[pair]);
const float2 uv = __bfloat1622float2(up_values[pair]);
const float2 gv = __bfloat1622float2(gate_values[pair]);
up_sums[row] = fmaf(xv.x, uv.x, up_sums[row]);
up_sums[row] = fmaf(xv.y, uv.y, up_sums[row]);
gate_sums[row] = fmaf(xv.x, gv.x, gate_sums[row]);
gate_sums[row] = fmaf(xv.y, gv.y, gate_sums[row]);
}
}
}
#pragma unroll
for (int row = 0; row < Rows; ++row) {
up_sums[row] = warp_sum(up_sums[row]);
gate_sums[row] = warp_sum(gate_sums[row]);
}
if (lane == 0) {
#pragma unroll
for (int row = 0; row < Rows; ++row) {
up_warp_sums[row][warp] = up_sums[row];
gate_warp_sums[row][warp] = gate_sums[row];
}
}
__syncthreads();
if (warp == 0) {
#pragma unroll
for (int row = 0; row < Rows; ++row) {
float up = lane < kWarps ? up_warp_sums[row][lane] : 0.0f;
float gate = lane < kWarps ? gate_warp_sums[row][lane] : 0.0f;
up = warp_sum(up);
gate = warp_sum(gate);
if (lane == 0) {
// Match the public composition's BF16 rounding boundaries:
// BF16 linear outputs, BF16 SiLU output, then BF16 multiply.
up = round_bf16(up);
gate = round_bf16(gate);
const float silu = round_bf16(gate / (1.0f + expf(-gate)));
output[static_cast<int64_t>(row) * n + output_index] =
__float2bfloat16_rn(up * silu);
}
}
}
}
template <int Rows>
void launch_bf16_swiglu(
const __nv_bfloat16* x,
const __nv_bfloat16* up_weight,
const __nv_bfloat16* gate_weight,
__nv_bfloat16* output,
int n,
int k,
cudaStream_t stream
) {
bf16_swiglu_kernel<Rows><<<n, kThreads, 0, stream>>>(
x, up_weight, gate_weight, output, n, k
);
}
template <int Rows>
__global__ void bf16_swiglu_warp_rows_kernel(
const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ up_weight,
const __nv_bfloat16* __restrict__ gate_weight,
__nv_bfloat16* __restrict__ output,
int n,
int k
) {
const int output_index = blockIdx.x;
const int row = threadIdx.x / kWarpSize;
const int lane = threadIdx.x & (kWarpSize - 1);
const int vector_count = k / 8;
float up_sum = 0.0f;
float gate_sum = 0.0f;
const auto* x4 = reinterpret_cast<const uint4*>(
x + static_cast<int64_t>(row) * k
);
const auto* up4 = reinterpret_cast<const uint4*>(
up_weight + static_cast<int64_t>(output_index) * k
);
const auto* gate4 = reinterpret_cast<const uint4*>(
gate_weight + static_cast<int64_t>(output_index) * k
);
// A warp owns one decode row. Same-address weight reads from sibling
// warps are served through the read-only/L1 path, while each row avoids
// CTA-wide shared-memory reductions and synchronization.
for (int vector_index = lane;
vector_index < vector_count;
vector_index += kWarpSize) {
const uint4 x_raw = x4[vector_index];
const uint4 up_raw = up4[vector_index];
const uint4 gate_raw = gate4[vector_index];
const auto* x_values = reinterpret_cast<const __nv_bfloat162*>(&x_raw);
const auto* up_values =
reinterpret_cast<const __nv_bfloat162*>(&up_raw);
const auto* gate_values =
reinterpret_cast<const __nv_bfloat162*>(&gate_raw);
#pragma unroll
for (int pair = 0; pair < 4; ++pair) {
const float2 xv = __bfloat1622float2(x_values[pair]);
const float2 uv = __bfloat1622float2(up_values[pair]);
const float2 gv = __bfloat1622float2(gate_values[pair]);
up_sum = fmaf(xv.x, uv.x, up_sum);
up_sum = fmaf(xv.y, uv.y, up_sum);
gate_sum = fmaf(xv.x, gv.x, gate_sum);
gate_sum = fmaf(xv.y, gv.y, gate_sum);
}
}
up_sum = warp_sum(up_sum);
gate_sum = warp_sum(gate_sum);
if (lane == 0) {
up_sum = round_bf16(up_sum);
gate_sum = round_bf16(gate_sum);
const float silu =
round_bf16(gate_sum / (1.0f + expf(-gate_sum)));
output[static_cast<int64_t>(row) * n + output_index] =
__float2bfloat16_rn(up_sum * silu);
}
}
template <int Rows>
void launch_bf16_swiglu_warp_rows(
const __nv_bfloat16* x,
const __nv_bfloat16* up_weight,
const __nv_bfloat16* gate_weight,
__nv_bfloat16* output,
int n,
int k,
cudaStream_t stream
) {
bf16_swiglu_warp_rows_kernel<Rows><<<n, Rows * kWarpSize, 0, stream>>>(
x, up_weight, gate_weight, output, n, k
);
}
torch::Tensor bf16_swiglu(
torch::Tensor x,
torch::Tensor up_weight,
torch::Tensor gate_weight
) {
TORCH_CHECK(
x.is_cuda() && up_weight.is_cuda() && gate_weight.is_cuda(),
"x, up_weight, and gate_weight must be CUDA tensors"
);
TORCH_CHECK(
x.device() == up_weight.device() && x.device() == gate_weight.device(),
"x and weights must share a device"
);
TORCH_CHECK(
x.scalar_type() == torch::kBFloat16 &&
up_weight.scalar_type() == torch::kBFloat16 &&
gate_weight.scalar_type() == torch::kBFloat16,
"x and weights must be bf16"
);
TORCH_CHECK(
x.dim() == 1 || x.dim() == 2,
"x must have shape [K] or [M, K]"
);
TORCH_CHECK(
up_weight.dim() == 2 && gate_weight.dim() == 2,
"weights must have shape [N, K]"
);
TORCH_CHECK(
x.is_contiguous() && up_weight.is_contiguous() &&
gate_weight.is_contiguous(),
"x and weights must be contiguous"
);
TORCH_CHECK(
!x.requires_grad() && !up_weight.requires_grad() &&
!gate_weight.requires_grad(),
"bf16_swiglu is inference-only and does not support autograd"
);
const int64_t m = x.dim() == 1 ? 1 : x.size(0);
const int64_t k = x.size(-1);
const int64_t n = up_weight.size(0);
TORCH_CHECK(m >= 1 && m <= 8, "M must be in [1, 8]");
TORCH_CHECK(
gate_weight.sizes() == up_weight.sizes(),
"up_weight and gate_weight must have identical shapes"
);
TORCH_CHECK(up_weight.size(1) == k, "weight K must match x K");
TORCH_CHECK(k > 0 && n > 0, "N and K must be positive");
TORCH_CHECK(k % 8 == 0, "K must be divisible by 8");
TORCH_CHECK(
k <= std::numeric_limits<int>::max() &&
n <= std::numeric_limits<int>::max(),
"N or K exceeds the CUDA launcher limit"
);
const at::cuda::OptionalCUDAGuard guard(x.device());
const auto* properties = at::cuda::getDeviceProperties(x.device().index());
TORCH_CHECK(
properties->major >= 8,
"bf16_swiglu requires compute capability 8.0+"
);
auto stream = at::cuda::getCurrentCUDAStream();
auto output = x.dim() == 1 ? torch::empty({n}, x.options())
: torch::empty({m, n}, x.options());
const auto* x_ptr =
reinterpret_cast<const __nv_bfloat16*>(x.data_ptr());
const auto* up_ptr =
reinterpret_cast<const __nv_bfloat16*>(up_weight.data_ptr());
const auto* gate_ptr =
reinterpret_cast<const __nv_bfloat16*>(gate_weight.data_ptr());
auto* output_ptr =
reinterpret_cast<__nv_bfloat16*>(output.data_ptr());
const int n_int = static_cast<int>(n);
const int k_int = static_cast<int>(k);
const bool use_warp_rows = n_int == 6912 && k_int == 1536;
switch (m) {
case 1:
launch_bf16_swiglu<1>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
break;
case 2:
if (use_warp_rows) {
launch_bf16_swiglu_warp_rows<2>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
} else {
launch_bf16_swiglu<2>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
}
break;
case 3:
launch_bf16_swiglu<3>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
break;
case 4:
if (use_warp_rows) {
launch_bf16_swiglu_warp_rows<4>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
} else {
launch_bf16_swiglu<4>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
}
break;
case 5:
launch_bf16_swiglu<5>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
break;
case 6:
launch_bf16_swiglu<6>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
break;
case 7:
launch_bf16_swiglu<7>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
break;
case 8:
if (use_warp_rows) {
launch_bf16_swiglu_warp_rows<8>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
} else {
launch_bf16_swiglu<8>(
x_ptr, up_ptr, gate_ptr, output_ptr, n_int, k_int, stream.stream()
);
}
break;
}
C10_CUDA_CHECK(cudaGetLastError());
return output;
}
} // namespace
PYBIND11_MODULE(TORCH_EXTENSION_NAME, module) {
module.def(
"bf16_swiglu",
&bf16_swiglu,
py::arg("x"),
py::arg("up_weight"),
py::arg("gate_weight"),
"M in [1, 8] fused BF16 up/gate projection and SwiGLU"
);
}
+105 -18
View File
@@ -1,9 +1,9 @@
# CUDA Kernels
AstrAI includes optional custom CUDA kernels for attention, rotary embedding,
BF16 GEMV, and FP8 GEMM. These are built when `nvcc` is available and CUDA is
detected. BF16 GEMV is directly callable and can be selected by the guarded
model linear dispatcher described below.
BF16 GEMV/SwiGLU, and FP8 GEMM. These are built when `nvcc` is available and
CUDA is detected. BF16 GEMV and SwiGLU are directly callable and can be
selected by guarded model dispatchers described below.
## Overview
@@ -15,35 +15,96 @@ model linear dispatcher described below.
| `attn_paged_prefill` | `attention/paged_prefill.cu` | Paged KV cache prefill attention (ragged batch) |
| `rotary_emb` | `rotary_emb.cu` | Fused rotary embedding (cos/sin lookup + rotation) |
| `bf16_gemv` | `gemv/bf16_gemv.cu` | M=1..8 BF16 linear with FP32 accumulation (sm_80+) |
| `bf16_swiglu` | `gemv/bf16_swiglu.cu` | Fused M=1..8 BF16 up/gate projections and SwiGLU epilogue (sm_80+) |
| `fp8_ops` | `fp8/ops.cu` | FP8 quantization + tensor-core GEMM (sm_89+) |
### BF16 GEMV primitive
`astrai.extension.bf16_gemv(x, weight, bias=None)` accepts a contiguous BF16
input shaped `[K]` or `[M, K]`, with `M` in `[1, 8]` and any positive `K`, and
row-major weights `[N, K]`. One CTA reduces each output row and computes all M
results together, reusing the weight row across tokens. The weight stream uses
128-bit vectorized loads anchored at each row's first 16-byte-aligned address
with scalar head/tail sweeps for unaligned remainders, so arbitrary `K` and
storage offsets stay correct; x loads are vectorized when every row base is
16-byte aligned (always true for K % 8 == 0 with allocator-aligned tensors)
and scalar otherwise. Accumulation is FP32; the optional BF16 bias is fused
before the BF16 store. The launcher uses the current CUDA stream, is CUDA
Graph capture-safe, and requires sm_80 or newer.
row-major weights `[N, K]`. The general path assigns one 256-thread CTA to an
output row and computes all M results together, reusing the weight row across
tokens. For measured aligned M=4 medium projections, a 128-thread CTA instead
assigns one output to each of four warps. That removes the CTA-wide reduction
barrier and exposes four neighboring outputs without changing accumulation.
The weight stream uses 128-bit vectorized loads anchored at each row's first
16-byte-aligned address with scalar head/tail sweeps for unaligned remainders,
so arbitrary `K` and storage offsets stay correct. The warp-tiled path is used
only when both tensors and every row are 16-byte aligned; all other calls keep
the general arbitrary-K path. Accumulation is FP32; the optional BF16 bias is
fused before the BF16 store. The launcher uses the current CUDA stream, is
CUDA Graph capture-safe, and requires sm_80 or newer.
Model `Linear` calls route through the lightweight linear backend. Set
`ASTRAI_GEMV=0` for an unconditional `F.linear` fallback, `1` to force the
kernel for any supported M in [1, 8], or `auto` (the default) to select only
architecture/shape bands that pass both the per-shape and end-to-end gates.
M=1 has no automatic SM89 band because isolated winners did not reach the 3%
whole-graph gate. Measured SM89 small-M bands are enabled as follows:
Measured SM89 small-M bands are enabled as follows:
| M | Automatic `(N, K)` bands | Engine throughput |
| M | Automatic `(N, K)` bands | Validated gain |
|---:|---|---:|
| 2 | `(256,1536)`, `(1536,1536)`, `(100000,1536)` | +14.0% |
| 4 | `(256,1536)`, `(1536,1536)` | +11.8% |
| 1 | OPT-1.3B Q/K/V/O and MLP | +4.54% OPT projection chain |
| 2 | AstrAI `(256,1536)`, `(1536,1536)`, `(100000,1536)` plus all common shapes below | +14.0% on AstrAI 1B; +5.66% to +25.20% common chains |
| 4 | AstrAI `(256,1536)`, `(1536,1536)` plus gated common shapes below | +11.8% on AstrAI 1B; +5.67% to +7.71% common chains |
| 8 | none | at least one projection in every measured family missed the per-shape gate |
These A→B→B→A results use the real `InferenceEngine`, including scheduler,
The common set covers LLaMA 2 7B Q/O, gate/up, and down; LLaMA 3 8B K/V,
gate/up, and down; LLaMA 2 13B Q/K/V/O, gate/up, and down; and GPT-NeoX MLP
up/down. In `(N,K)` form it is `(1024,4096)`, `(4096,4096)`,
`(11008,4096)`, `(4096,11008)`, `(14336,4096)`, `(4096,14336)`,
`(5120,5120)`, `(13824,5120)`, `(5120,13824)`, `(16384,4096)`, and
`(4096,16384)`. M=2 enables all eleven. M=4 excludes the three LLaMA 2 7B
bands `(4096,4096)`, `(11008,4096)`, and `(4096,11008)` because their combined
projection chain reached only +1.89%, below the 3% automatic-dispatch gate.
The extended common set adds Qwen2-7B `(512,3584)`, `(3584,3584)`,
`(18944,3584)`, and `(3584,18944)`; LLaMA 3 70B `(1024,8192)`,
`(8192,8192)`, `(28672,8192)`, and `(8192,28672)`; and OPT-1.3B
`(2048,2048)`, `(8192,2048)`, and `(2048,8192)`. Qwen2 and LLaMA 3 70B are
enabled at M=2/4. OPT-1.3B is enabled at M=1/2. Other rows retain their
previous policy or fall back to PyTorch.
Inside the primitive, a templated cooperative kernel uses either 256 threads
or a shape-gated 128-thread CTA. The smaller CTA is enabled only where an
interleaved direct-module comparison against the original 256-thread kernel
cleared 5%: OPT up at M=1; selected LLaMA 2 7B, Qwen2, and OPT projections at
M=2; LLaMA 2 13B Q/O, Qwen2 Q/O, and selected OPT projections at M=4; and
selected LLaMA 2, Qwen2, LLaMA 3 KV, and OPT projections at M=8. Confirmed
direct-kernel gains range from +5.37% to +48.54%. Long-K and saturated shapes
keep the 256-thread fallback. This internal selector is separate from model
automatic dispatch, whose Python/wrapper overhead is included in the gates
above.
On NVIDIA L20 (SM89), the common-shape microbenchmark reports +5.37% to
+114.39% for M=2 and +5.38% to +115.26% for M=4 versus `F.linear`. The paired
main-versus-warp-tiling run used identical interleaved settings; for the four
M=4 selected shapes, candidate latency changed from 0.016292 to 0.016108 ms
for `(4096,4096)`, 0.037939 to 0.028539 ms for `(11008,4096)`, 0.043407 to
0.039803 ms for `(4096,11008)`, and 0.006697 to 0.006390 ms for
`(1024,4096)`.
The dependent projection-chain gate, which includes Python dispatch and
rotates through distinct weights instead of repeatedly warming one matrix,
measured:
| Synthetic chain | M=2 | M=4 | Row argmax parity |
|---|---:|---:|---|
| LLaMA 2 7B | +8.49% | fallback (M=4 bands excluded) | exact |
| LLaMA 3 8B | +8.50% | +6.44% | exact |
| LLaMA 2 13B | +5.66% | +5.67% | exact |
| GPT-NeoX 20B | +6.95% | +5.93% | exact |
| Qwen2 7B | +7.48% | +7.48% | exact |
| LLaMA 3 70B | +7.77% | +7.69% | exact |
| OPT 1.3B | +25.20% | fallback (M=4 up projection regresses) | exact |
OPT 1.3B M=1 is +4.54%. Qwen2 and LLaMA 3 70B M=1, and all three new
families at M=8, remain exact PyTorch fallbacks.
These are synthetic projection-chain measurements, not whole-model throughput
claims. Reproduce them with `scripts/tools/benchmark_gemv_common.py`.
The AstrAI 1B A→B→B→A results use the real `InferenceEngine`, including scheduler,
sampling, and CUDA Graph. M=8 stays on PyTorch because its remaining
greedy-stable winners missed the 3% end-to-end gate. Long-K MLP-down bands are
also excluded because their valid BF16 error changed a checkpoint greedy
@@ -55,6 +116,30 @@ remain on PyTorch. Use mode `1` only for explicit A/B runs outside this table.
The primitive remains directly callable and deliberately has no internal
`F.linear` fallback. The model-level backend owns fallback and dispatch policy.
### BF16 SwiGLU primitive
`astrai.extension.bf16_swiglu(x, up_weight, gate_weight)` fuses the two dense
MLP projections with `up * silu(gate)` into one CUDA launch for contiguous
BF16 inputs with `M` in `[1, 8]` and K divisible by 8. It preserves the BF16
rounding boundaries of the two projection outputs, SiLU output, and final
product while accumulating dot products in FP32.
The kernel contains two output-row tilings. A CTA-reuse path reads each up/gate
weight chunk once and applies it to all M rows. The native AstrAI 1B shape
`(N,K)=(6912,1536)` uses one warp per decode row for M=2/4/8; on L20 this
removes the shared reductions and barrier and reduces M=4 CUDA-Graph latency
from 0.0324 ms to 0.0181 ms. Wider LLaMA/GPT-NeoX matrices keep CTA reuse,
because duplicating their weight reads across row warps regressed 1.3-4.2%.
Dense `MLP` modules route through the SwiGLU backend. `ASTRAI_SWIGLU=0` keeps
the unfused linear backend, and `1` explicitly forces the fused primitive.
`auto` is the default but currently has no enabled bands: although direct
errors are small (maximum absolute error at most 2.4e-4 in the L20 matrix),
the different FP32 reduction order changed greedy checkpoint output for
M=1/2/4. Automatic dispatch therefore remains numerically identical to the
existing path. See [the benchmark protocol](./swiglu_benchmark.md) for raw
operator, engine, and checkpoint evidence.
Additionally, optimized `.cuh` variants with tensor-core MMA (Matrix Multiply-Accumulate) exist:
| Variant | File | Optimization |
@@ -245,10 +330,12 @@ astrai/extension/
│ ├── attention.py # Stateless attention kernel wrappers
│ ├── rotary.py # Stateless rotary kernel wrapper
│ ├── gemv.py # Stateless BF16 GEMV primitive
│ ├── swiglu.py # Stateless fused BF16 SwiGLU primitive
│ └── fp8.py # Stateless FP8 primitives (custom_op)
├── fp8.py # FP8 strategy layer (fp8_autocast, recipes)
└── backend/
├── attention.py # Backend selection, KV cache I/O, and fallback
├── swiglu.py # Inference-only fused/unfused SwiGLU policy
└── rotary.py # Per-call CUDA/torch rotary dispatch
```
+19
View File
@@ -20,3 +20,22 @@ change the decode batch sizes. Compare each GPU architecture only with its own
baseline; do not use absolute A100-versus-L20 numbers as a dispatch criterion.
Keep the raw JSON as the source of truth and generate tables with
`--markdown-output` rather than transcribing measurements by hand.
For direct A/B coverage of the custom kernel and guarded dispatcher across
traditional LLaMA and GPT-NeoX decode shapes, use:
```bash
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=. python scripts/tools/benchmark_gemv_common.py \
--suite all --family traditional --m 2 4 \
--output results/gemv_common.json
```
The kernel suite compares the directly callable primitive with `F.linear`.
Use repeatable `--shape-label` and `--chain-label` filters for a focused run.
The synthetic-chain suite alternates `ASTRAI_GEMV=0` and `auto`, includes
dependent MLP work and Python dispatch, and rotates through distinct weights.
Pass `--candidate-mode 1` to characterize a family before adding it to the
automatic shape table; the checked-in final evidence always uses `auto`.
It is deliberately not labeled a whole-model throughput benchmark. Both
suites report median/p90 CUDA-event latency plus maximum absolute error,
relative L2 error, and row-wise argmax parity.
+58
View File
@@ -0,0 +1,58 @@
# Fused SwiGLU benchmark
`scripts/tools/benchmark_swiglu.py` compares the directly callable fused BF16
SwiGLU primitive with both `F.linear` and the existing two-GEMV chain. It covers
the native AstrAI 1B MLP plus LLaMA 2 7B/13B, LLaMA 3 8B, and GPT-NeoX 20B
up/gate shapes at M=1/2/4/8 in eager and CUDA Graph modes.
```bash
CUDA_VISIBLE_DEVICES=0 python scripts/tools/benchmark_swiglu.py \
--output results/swiglu.json \
--markdown-output results/swiglu.md \
--m-values 1,2,4,8 --mode both \
--warmup 20 --iterations 100 --trials 10
```
Each trial uses A-B-C-C-B-A ordering to balance clock, cache, and temperature
drift. The generated JSON records every timing sample, p50/p90/p99, CUDA launch
count, maximum/mean absolute error, and cosine similarity.
## L20 findings
Hardware was one NVIDIA L20 (sm_89), PyTorch 2.11.0+cu128, CUDA 12.8. The
existing GPU5 inference service remained resident (15.4 GiB) but idle at the
sampling boundaries; no process or container was stopped.
For AstrAI 1B `(N,K)=(6912,1536)`, CUDA Graph medians were:
| M | torch (ms) | GEMV chain (ms) | fused (ms) | vs best unfused |
|---:|---:|---:|---:|---:|
| 1 | 0.02564 | 0.02298 | 0.01375 | +67.13% |
| 2 | 0.02484 | 0.02628 | 0.01416 | +75.40% |
| 4 | 0.02507 | 0.03839 | 0.01806 | +38.82% |
| 8 | 0.02563 | 0.07007 | 0.03339 | -23.24% |
The wide traditional shapes are weight-bandwidth dominated. CTA reuse keeps
the fused primitive within roughly -1.2% to +0.9% of the best unfused chain,
so none is eligible for automatic selection. This negative crossover is kept
in the raw evidence rather than hidden by a favorable subset.
The real 24-layer AstrAI checkpoint was then run through `InferenceEngine`,
including scheduler, sampling, and CUDA Graph. A-B-B-A medians were:
| Batch | unfused (ms/step) | forced fused (ms/step) | throughput gain |
|---:|---:|---:|---:|
| 1 | 4.125 | 3.925 | +5.10% |
| 2 | 4.245 | 4.055 | +4.69% |
| 4 | 4.475 | 4.305 | +3.95% |
## Dispatch decision
Direct correctness stayed close (`max_abs <= 2.4e-4`, cosine approximately
1.0), but deterministic greedy generations changed at M=1, M=2, and M=4.
For that reason no SM89 shape is enabled in `auto`. The default path stays on
the existing unfused linear backend, including any independently qualified
GEMV dispatch. `ASTRAI_SWIGLU=1` remains an explicit benchmark/experimentation
switch for callers that accept normal BF16 reduction-order variation. A future
automatic band must repeat both the performance and checkpoint-output gates.
+450
View File
@@ -0,0 +1,450 @@
"""Benchmark the BF16 GEMV primitive and guarded linear dispatcher.
The kernel suite covers AstrAI's native projections plus common LLaMA and
GPT-NeoX matrix shapes. The chain suite is a synthetic projection/MLP chain;
it measures dispatcher overhead and dependent MLP work, but is deliberately
not presented as a whole-model throughput benchmark.
"""
import argparse
import gc
import json
import math
import os
import statistics
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import torch
import torch.nn.functional as F
from astrai.extension import bf16_gemv, is_available, linear
@dataclass(frozen=True)
class Shape:
label: str
n: int
k: int
@dataclass(frozen=True)
class Chain:
label: str
hidden: int
kv: int
intermediate: int
fused_qkv: bool = False
gated_mlp: bool = True
@dataclass(frozen=True)
class Timing:
median_ms: float
p90_ms: float
ASTRAI_SHAPES = (
Shape("astrai_qkv", 256, 1536),
Shape("astrai_square", 1536, 1536),
Shape("astrai_up_gate", 6912, 1536),
Shape("astrai_down", 1536, 6912),
Shape("astrai_lm_head", 100000, 1536),
)
TRADITIONAL_SHAPES = (
Shape("llama2_7b_qo", 4096, 4096),
Shape("llama2_7b_up_gate", 11008, 4096),
Shape("llama2_7b_down", 4096, 11008),
Shape("llama3_8b_kv", 1024, 4096),
Shape("llama3_8b_up_gate", 14336, 4096),
Shape("llama3_8b_down", 4096, 14336),
Shape("llama2_13b_qo", 5120, 5120),
Shape("llama2_13b_up_gate", 13824, 5120),
Shape("llama2_13b_down", 5120, 13824),
Shape("gpt_neox_up", 16384, 4096),
Shape("gpt_neox_down", 4096, 16384),
Shape("qwen2_7b_kv", 512, 3584),
Shape("qwen2_7b_qo", 3584, 3584),
Shape("qwen2_7b_up_gate", 18944, 3584),
Shape("qwen2_7b_down", 3584, 18944),
Shape("llama3_70b_kv", 1024, 8192),
Shape("llama3_70b_qo", 8192, 8192),
Shape("llama3_70b_up_gate", 28672, 8192),
Shape("llama3_70b_down", 8192, 28672),
Shape("opt_1_3b_qkvo", 2048, 2048),
Shape("opt_1_3b_up", 8192, 2048),
Shape("opt_1_3b_down", 2048, 8192),
)
CHAINS = (
Chain("llama2_7b", 4096, 4096, 11008),
Chain("llama3_8b", 4096, 1024, 14336),
Chain("llama2_13b", 5120, 5120, 13824),
Chain("gpt_neox_20b", 4096, 4096, 16384, fused_qkv=True),
Chain("qwen2_7b", 3584, 512, 18944),
Chain("llama3_70b", 8192, 1024, 28672),
Chain("opt_1_3b", 2048, 2048, 8192, gated_mlp=False),
)
def _elapsed_ms(fn: Callable[[], torch.Tensor], inner: int) -> float:
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(inner):
fn()
end.record()
end.synchronize()
return start.elapsed_time(end) / inner
def _timing(values: list[float]) -> Timing:
ordered = sorted(values)
p90_index = max(0, math.ceil(0.9 * len(ordered)) - 1)
return Timing(statistics.median(ordered), ordered[p90_index])
def _measure_pair(
baseline: Callable[[], torch.Tensor],
candidate: Callable[[], torch.Tensor],
*,
warmup: int,
samples: int,
inner: int,
prepare_baseline: Callable[[], None] = lambda: None,
prepare_candidate: Callable[[], None] = lambda: None,
) -> tuple[Timing, Timing]:
cases = (
("baseline", prepare_baseline, baseline),
("candidate", prepare_candidate, candidate),
)
for iteration in range(warmup):
_, prepare, fn = cases[iteration % 2]
prepare()
fn()
torch.cuda.synchronize()
values: dict[str, list[float]] = {"baseline": [], "candidate": []}
for sample in range(samples):
order = cases if sample % 2 == 0 else tuple(reversed(cases))
for label, prepare, fn in order:
prepare()
values[label].append(_elapsed_ms(fn, inner))
return _timing(values["baseline"]), _timing(values["candidate"])
def _print_header() -> None:
print(
"suite,label,m,n,k,torch_median_ms,torch_p90_ms,"
"candidate_median_ms,candidate_p90_ms,speedup_pct,"
"max_abs,relative_l2,argmax_equal"
)
def _print_result(
suite: str,
label: str,
m: int,
n: int,
k: int,
baseline: Timing,
candidate: Timing,
reference: torch.Tensor,
actual: torch.Tensor,
) -> dict[str, object]:
difference = actual.float() - reference.float()
max_abs = difference.abs().max().item()
relative_l2 = difference.norm().item() / max(reference.float().norm().item(), 1e-12)
argmax_equal = torch.equal(actual.argmax(dim=-1), reference.argmax(dim=-1))
speedup = (baseline.median_ms / candidate.median_ms - 1.0) * 100.0
result: dict[str, object] = {
"suite": suite,
"label": label,
"m": m,
"n": n,
"k": k,
"torch_median_ms": baseline.median_ms,
"torch_p90_ms": baseline.p90_ms,
"candidate_median_ms": candidate.median_ms,
"candidate_p90_ms": candidate.p90_ms,
"speedup_pct": speedup,
"max_abs": max_abs,
"relative_l2": relative_l2,
"argmax_equal": argmax_equal,
}
print(
f"{suite},{label},{m},{n},{k},"
f"{baseline.median_ms:.6f},{baseline.p90_ms:.6f},"
f"{candidate.median_ms:.6f},{candidate.p90_ms:.6f},"
f"{speedup:+.2f},{max_abs:.6f},{relative_l2:.8f},"
f"{str(argmax_equal).lower()}",
flush=True,
)
return result
def _weight(n: int, k: int, device: torch.device, std: float) -> torch.Tensor:
weight = torch.empty((n, k), device=device, dtype=torch.bfloat16)
weight.normal_(mean=0.0, std=std)
return weight.requires_grad_(True)
def _kernel_functions(
x: torch.Tensor, weight: torch.Tensor
) -> tuple[Callable[[], torch.Tensor], Callable[[], torch.Tensor]]:
def baseline() -> torch.Tensor:
return F.linear(x, weight)
def candidate() -> torch.Tensor:
return bf16_gemv(x, weight.detach())
return baseline, candidate
def benchmark_kernels(
args: argparse.Namespace, device: torch.device
) -> list[dict[str, object]]:
if args.family == "astrai":
shapes = ASTRAI_SHAPES
elif args.family == "traditional":
shapes = TRADITIONAL_SHAPES
else:
shapes = ASTRAI_SHAPES + TRADITIONAL_SHAPES
if args.shape_label:
requested = set(args.shape_label)
shapes = tuple(shape for shape in shapes if shape.label in requested)
missing = requested - {shape.label for shape in shapes}
if missing:
raise ValueError(f"unknown shape labels: {', '.join(sorted(missing))}")
results: list[dict[str, object]] = []
for shape in shapes:
weight = _weight(shape.n, shape.k, device, args.weight_std)
for m in args.m:
x = torch.randn((m, shape.k), device=device, dtype=torch.bfloat16)
baseline_fn, candidate_fn = _kernel_functions(x, weight)
with torch.inference_mode():
reference = baseline_fn()
actual = candidate_fn()
baseline, candidate = _measure_pair(
baseline_fn,
candidate_fn,
warmup=args.warmup,
samples=args.samples,
inner=args.inner,
)
results.append(
_print_result(
"kernel",
shape.label,
m,
shape.n,
shape.k,
baseline,
candidate,
reference,
actual,
)
)
del baseline_fn, candidate_fn, x, reference, actual
del weight
gc.collect()
torch.cuda.empty_cache()
return results
def _set_mode(mode: str) -> None:
os.environ["ASTRAI_GEMV"] = mode
def _chain_weights(
spec: Chain, device: torch.device, std: float
) -> dict[str, torch.Tensor]:
weights = {
"o": _weight(spec.hidden, spec.hidden, device, std),
"up": _weight(spec.intermediate, spec.hidden, device, std),
"down": _weight(spec.hidden, spec.intermediate, device, std),
}
if spec.fused_qkv:
weights["qkv"] = _weight(3 * spec.hidden, spec.hidden, device, std)
else:
weights.update(
{
"q": _weight(spec.hidden, spec.hidden, device, std),
"k": _weight(spec.kv, spec.hidden, device, std),
"v": _weight(spec.kv, spec.hidden, device, std),
}
)
if spec.gated_mlp:
weights["gate"] = _weight(spec.intermediate, spec.hidden, device, std)
return weights
def _chain_fn(
x: torch.Tensor, weights: dict[str, torch.Tensor], spec: Chain
) -> Callable[[], torch.Tensor]:
def run() -> torch.Tensor:
output_projection = linear(x, weights["o"])
up = linear(x, weights["up"])
if spec.fused_qkv:
attention_projection = linear(x, weights["qkv"])[..., : x.shape[-1]]
hidden = F.gelu(up)
else:
attention_projection = linear(x, weights["q"])
linear(x, weights["k"])
linear(x, weights["v"])
if spec.gated_mlp:
gate = linear(x, weights["gate"])
hidden = F.silu(gate) * up
else:
hidden = F.gelu(up)
down = linear(hidden, weights["down"])
return attention_projection + output_projection + down
return run
def benchmark_chains(
args: argparse.Namespace, device: torch.device
) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
chains = CHAINS
if args.chain_label:
requested = set(args.chain_label)
chains = tuple(chain for chain in chains if chain.label in requested)
missing = requested - {chain.label for chain in chains}
if missing:
raise ValueError(f"unknown chain labels: {', '.join(sorted(missing))}")
for spec in chains:
weights = _chain_weights(spec, device, args.weight_std)
for m in args.m:
x = torch.randn((m, spec.hidden), device=device, dtype=torch.bfloat16)
run = _chain_fn(x, weights, spec)
with torch.inference_mode():
_set_mode("0")
reference = run()
_set_mode(args.candidate_mode)
actual = run()
baseline, candidate = _measure_pair(
run,
run,
warmup=args.warmup,
samples=args.samples,
inner=args.chain_inner,
prepare_baseline=lambda: _set_mode("0"),
prepare_candidate=lambda: _set_mode(args.candidate_mode),
)
results.append(
_print_result(
"synthetic_chain",
spec.label,
m,
spec.hidden,
spec.intermediate,
baseline,
candidate,
reference,
actual,
)
)
del x, reference, actual
del weights
gc.collect()
torch.cuda.empty_cache()
return results
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--suite", choices=("kernel", "chain", "all"), default="all")
parser.add_argument(
"--family", choices=("astrai", "traditional", "all"), default="all"
)
parser.add_argument(
"--m", type=int, nargs="+", choices=(1, 2, 4, 8), default=(1, 2, 4, 8)
)
parser.add_argument(
"--shape-label",
action="append",
help="limit the kernel suite to one or more named shape labels",
)
parser.add_argument(
"--chain-label",
action="append",
help="limit the chain suite to one or more named model families",
)
parser.add_argument("--device", type=int, default=0)
parser.add_argument("--warmup", type=int, default=20)
parser.add_argument("--samples", type=int, default=9)
parser.add_argument("--inner", type=int, default=100)
parser.add_argument("--chain-inner", type=int, default=20)
parser.add_argument(
"--candidate-mode",
choices=("auto", "1"),
default="auto",
help="dispatcher mode for the candidate side of the chain suite",
)
parser.add_argument("--weight-std", type=float, default=0.02)
parser.add_argument("--seed", type=int, default=20260902)
parser.add_argument(
"--output",
type=Path,
help="optional JSON output; stdout always retains the compact CSV table",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not torch.cuda.is_available() or not is_available("bf16_gemv"):
raise RuntimeError("benchmark requires CUDA and the built bf16_gemv extension")
if args.warmup < 0 or args.samples < 1 or args.inner < 1 or args.chain_inner < 1:
raise ValueError("warmup must be non-negative and sample/inner counts positive")
torch.cuda.set_device(args.device)
device = torch.device("cuda", args.device)
torch.manual_seed(args.seed)
torch.cuda.manual_seed_all(args.seed)
properties = torch.cuda.get_device_properties(device)
print(
f"# device={properties.name}, capability={properties.major}.{properties.minor}, "
f"seed={args.seed}, weight_std={args.weight_std}"
)
_print_header()
results: list[dict[str, object]] = []
if args.suite in ("kernel", "all"):
results.extend(benchmark_kernels(args, device))
if args.suite in ("chain", "all"):
results.extend(benchmark_chains(args, device))
if args.output is not None:
payload = {
"environment": {
"device": properties.name,
"capability": f"{properties.major}.{properties.minor}",
"torch": torch.__version__,
"cuda": torch.version.cuda,
},
"parameters": {
"suite": args.suite,
"family": args.family,
"m": args.m,
"shape_labels": args.shape_label,
"chain_labels": args.chain_label,
"candidate_mode": args.candidate_mode,
"seed": args.seed,
"weight_std": args.weight_std,
"warmup": args.warmup,
"samples": args.samples,
"inner": args.inner,
"chain_inner": args.chain_inner,
},
"results": results,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, indent=2) + "\n")
if __name__ == "__main__":
main()
+325
View File
@@ -0,0 +1,325 @@
"""Benchmark fused BF16 SwiGLU against torch and unfused GEMV chains."""
from __future__ import annotations
import json
import math
import statistics
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
import click
import torch
import torch.nn.functional as F
from astrai.extension import bf16_gemv, bf16_swiglu, is_available
@dataclass(frozen=True)
class SwiGLUShape:
name: str
n: int
k: int
DEFAULT_SHAPES = (
SwiGLUShape("astrai_1b", 6912, 1536),
SwiGLUShape("llama2_7b", 11008, 4096),
SwiGLUShape("llama3_8b", 14336, 4096),
SwiGLUShape("llama2_13b", 13824, 5120),
SwiGLUShape("gpt_neox_20b", 16384, 6144),
)
def parse_positive_ints(value: str) -> tuple[int, ...]:
try:
values = tuple(dict.fromkeys(int(item.strip()) for item in value.split(",")))
except ValueError as exc:
raise click.BadParameter("expected comma-separated integers") from exc
if not values or any(item <= 0 for item in values):
raise click.BadParameter("values must be positive integers")
return values
def parse_shape(value: str) -> SwiGLUShape:
parts = value.split(":")
if len(parts) != 3 or not parts[0]:
raise click.BadParameter("shape must use NAME:N:K")
try:
n, k = (int(item) for item in parts[1:])
except ValueError as exc:
raise click.BadParameter("N and K must be integers") from exc
if n <= 0 or k <= 0 or k % 8:
raise click.BadParameter("N must be positive and K positive/divisible by 8")
return SwiGLUShape(parts[0], n, k)
def percentile(values: Iterable[float], quantile: float) -> float:
ordered = sorted(values)
rank = (len(ordered) - 1) * quantile
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return ordered[lower]
fraction = rank - lower
return ordered[lower] * (1 - fraction) + ordered[upper] * fraction
def summarize(values: list[float]) -> dict[str, float]:
return {
"median_ms": statistics.median(values),
"p90_ms": percentile(values, 0.90),
"p99_ms": percentile(values, 0.99),
"min_ms": min(values),
"max_ms": max(values),
}
def time_operation(operation: Callable[[], torch.Tensor], iterations: int) -> float:
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(iterations):
operation()
end.record()
end.synchronize()
return start.elapsed_time(end) / iterations
def count_cuda_kernels(
operation: Callable[[], torch.Tensor], repeats: int = 5
) -> float:
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA,
],
acc_events=True,
) as profile:
for _ in range(repeats):
operation()
torch.cuda.synchronize()
device_type = torch.autograd.DeviceType.CUDA
events = [event for event in profile.events() if event.device_type == device_type]
return len(events) / repeats
def capture(operation: Callable[[], torch.Tensor]):
for _ in range(3):
operation()
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
output = operation()
def replay() -> torch.Tensor:
graph.replay()
return output
return replay
def make_operations(x, up_weight, gate_weight, mode: str):
operations: dict[str, Callable[[], torch.Tensor]] = {
"torch": lambda: F.linear(x, up_weight) * F.silu(F.linear(x, gate_weight)),
"gemv_chain": lambda: (
bf16_gemv(x, up_weight) * F.silu(bf16_gemv(x, gate_weight))
),
"fused": lambda: bf16_swiglu(x, up_weight, gate_weight),
}
if mode == "graph":
operations = {name: capture(op) for name, op in operations.items()}
return operations
def benchmark_case(
shape: SwiGLUShape,
m: int,
mode: str,
*,
warmup: int,
iterations: int,
trials: int,
) -> list[dict[str, object]]:
x = torch.randn((m, shape.k), device="cuda", dtype=torch.bfloat16) * 0.1
scale = shape.k**-0.5
up_weight = (
torch.randn((shape.n, shape.k), device="cuda", dtype=torch.bfloat16) * scale
)
gate_weight = (
torch.randn((shape.n, shape.k), device="cuda", dtype=torch.bfloat16) * scale
)
operations = make_operations(x, up_weight, gate_weight, mode)
for operation in operations.values():
for _ in range(warmup):
operation()
torch.cuda.synchronize()
samples = {name: [] for name in operations}
forward_order = tuple(operations)
# A-B-C-C-B-A order balances cache, clock, and temperature drift.
for _ in range(trials):
for name in (*forward_order, *reversed(forward_order)):
samples[name].append(time_operation(operations[name], iterations))
with torch.no_grad():
expected = operations["torch"]().clone()
actual = operations["fused"]().clone()
difference = (actual.float() - expected.float()).abs()
max_abs_error = float(difference.max())
mean_abs_error = float(difference.mean())
cosine_similarity = float(
F.cosine_similarity(actual.float().flatten(), expected.float().flatten(), dim=0)
)
results = []
for name, operation in operations.items():
result: dict[str, object] = {
"shape": shape.name,
"m": m,
"n": shape.n,
"k": shape.k,
"mode": mode,
"implementation": name,
"cuda_kernel_launches_per_call": count_cuda_kernels(operation),
**summarize(samples[name]),
}
if name == "fused":
result.update(
max_abs_error=max_abs_error,
mean_abs_error=mean_abs_error,
cosine_similarity=cosine_similarity,
)
results.append(result)
return results
def device_metadata() -> dict[str, object]:
props = torch.cuda.get_device_properties(0)
return {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"gpu_name": props.name,
"compute_capability": f"{props.major}.{props.minor}",
"total_memory_bytes": props.total_memory,
"torch_version": torch.__version__,
"cuda_version": torch.version.cuda,
"dtype": "bfloat16",
}
def render_markdown(payload: dict[str, object]) -> str:
metadata = payload["metadata"]
results = payload["results"]
assert isinstance(metadata, dict)
assert isinstance(results, list)
by_case = {
(item["shape"], item["m"], item["mode"], item["implementation"]): item
for item in results
}
cases = sorted({(item["shape"], item["m"], item["mode"]) for item in results})
lines = [
"# Fused SwiGLU benchmark",
"",
f"- GPU: {metadata['gpu_name']}",
f"- Compute capability: {metadata['compute_capability']}",
f"- PyTorch / CUDA: {metadata['torch_version']} / {metadata['cuda_version']}",
"",
"| Shape | M | Mode | torch ms | GEMV chain ms | fused ms | "
"vs best unfused | fused kernels | max abs | cosine |",
"|---|---:|---|---:|---:|---:|---:|---:|---:|---:|",
]
for shape, m, mode in cases:
torch_item = by_case[(shape, m, mode, "torch")]
gemv_item = by_case[(shape, m, mode, "gemv_chain")]
fused_item = by_case[(shape, m, mode, "fused")]
best = min(torch_item["median_ms"], gemv_item["median_ms"])
improvement = (best / fused_item["median_ms"] - 1) * 100
lines.append(
f"| {shape} | {m} | {mode} | {torch_item['median_ms']:.5f} | "
f"{gemv_item['median_ms']:.5f} | {fused_item['median_ms']:.5f} | "
f"{improvement:+.2f}% | "
f"{fused_item['cuda_kernel_launches_per_call']:.1f} | "
f"{fused_item['max_abs_error']:.5f} | "
f"{fused_item['cosine_similarity']:.8f} |"
)
lines.append("")
return "\n".join(lines)
@click.command(help=__doc__)
@click.option("--output", type=click.Path(path_type=Path), required=True)
@click.option("--markdown-output", type=click.Path(path_type=Path))
@click.option("--m-values", default="1,2,4,8", show_default=True)
@click.option("--shape", "shape_values", multiple=True, help="Repeat NAME:N:K.")
@click.option("--mode", type=click.Choice(("eager", "graph", "both")), default="both")
@click.option("--warmup", type=click.IntRange(min=1), default=10, show_default=True)
@click.option(
"--iterations", type=click.IntRange(min=1), default=100, show_default=True
)
@click.option("--trials", type=click.IntRange(min=1), default=10, show_default=True)
@click.option("--seed", type=int, default=0, show_default=True)
def benchmark_command(
output: Path,
markdown_output: Path | None,
m_values: str,
shape_values: tuple[str, ...],
mode: str,
warmup: int,
iterations: int,
trials: int,
seed: int,
) -> None:
if not torch.cuda.is_available():
raise click.ClickException("CUDA is required")
if not is_available("bf16_gemv") or not is_available("bf16_swiglu"):
raise click.ClickException("built bf16_gemv and bf16_swiglu are required")
shapes = tuple(parse_shape(value) for value in shape_values) or DEFAULT_SHAPES
m_values_parsed = parse_positive_ints(m_values)
if any(m > 8 for m in m_values_parsed):
raise click.BadParameter("fused primitive supports M up to 8")
modes = ("eager", "graph") if mode == "both" else (mode,)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
results = []
with torch.inference_mode():
for shape in shapes:
for m in m_values_parsed:
for current_mode in modes:
click.echo(
f"{shape.name}: M={m} N={shape.n} K={shape.k} {current_mode}"
)
results.extend(
benchmark_case(
shape,
m,
current_mode,
warmup=warmup,
iterations=iterations,
trials=trials,
)
)
torch.cuda.empty_cache()
payload: dict[str, object] = {
"metadata": device_metadata(),
"settings": {
"warmup": warmup,
"iterations": iterations,
"trials": trials,
"seed": seed,
"order": "A-B-C-C-B-A",
},
"results": results,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(payload, indent=2) + "\n")
if markdown_output is not None:
markdown_output.parent.mkdir(parents=True, exist_ok=True)
markdown_output.write_text(render_markdown(payload))
if __name__ == "__main__":
benchmark_command()
+1
View File
@@ -122,6 +122,7 @@ class _CMakeBuildExt(_build_ext):
"attn_paged_decode",
"attn_paged_prefill",
"bf16_gemv",
"bf16_swiglu",
"rotary_emb",
)
missing = [name for name in required if not any(lib_dir.glob(f"{name}.*.so"))]
+60
View File
@@ -43,6 +43,66 @@ def test_bf16_gemv_matches_small_decode_batches(m, n, k):
torch.testing.assert_close(actual, expected, rtol=0.02, atol=0.5)
@skip_no_gemv
@pytest.mark.parametrize("m", [2, 4])
@pytest.mark.parametrize(
"n,k",
[
(1024, 4096),
(4096, 4096),
(11008, 4096),
(4096, 11008),
(14336, 4096),
(4096, 14336),
(5120, 5120),
(13824, 5120),
(5120, 13824),
(16384, 4096),
(4096, 16384),
(512, 3584),
(3584, 3584),
(18944, 3584),
(3584, 18944),
(1024, 8192),
(8192, 8192),
(28672, 8192),
(8192, 28672),
(2048, 2048),
(8192, 2048),
(2048, 8192),
],
)
def test_bf16_gemv_matches_common_transformer_shapes(m, n, k):
torch.manual_seed(2026 + m + n + k)
x = torch.randn(m, k, device="cuda", dtype=torch.bfloat16)
weight = torch.empty(n, k, device="cuda", dtype=torch.bfloat16)
weight.normal_(mean=0.0, std=0.02)
actual = bf16_gemv(x, weight)
expected = F.linear(x, weight)
torch.testing.assert_close(actual, expected, rtol=0.02, atol=0.25)
@skip_no_gemv
@pytest.mark.parametrize(
"m,n,k",
[
(1, 8192, 2048),
(8, 4096, 11008),
(8, 512, 3584),
(8, 1024, 8192),
(8, 2048, 8192),
],
)
def test_bf16_gemv_matches_half_cta_edge_bands(m, n, k):
torch.manual_seed(2026 + m + n + k)
x = torch.randn(m, k, device="cuda", dtype=torch.bfloat16)
weight = torch.empty(n, k, device="cuda", dtype=torch.bfloat16)
weight.normal_(mean=0.0, std=0.02)
actual = bf16_gemv(x, weight)
expected = F.linear(x, weight)
torch.testing.assert_close(actual, expected, rtol=0.02, atol=0.25)
@skip_no_gemv
def test_bf16_gemv_preserves_singleton_batch_and_fuses_bias():
torch.manual_seed(23)
+84 -4
View File
@@ -6,6 +6,7 @@ import torch.nn.functional as F
from astrai.extension import explain, is_available, linear, op_backend
from astrai.extension.backend import linear as public_linear
from astrai.extension.backend.linear import _AUTO_GEMV_SHAPES
from astrai.model.components.linear import Linear
GEMV_AVAILABLE = (
@@ -23,6 +24,45 @@ def test_linear_backend_is_public():
assert linear is public_linear
def test_sm89_common_shape_policy_keeps_only_validated_families_enabled():
common = {
(1024, 4096),
(4096, 4096),
(11008, 4096),
(4096, 11008),
(14336, 4096),
(4096, 14336),
(5120, 5120),
(13824, 5120),
(5120, 13824),
(16384, 4096),
(4096, 16384),
}
subthreshold_m4 = {(4096, 4096), (11008, 4096), (4096, 11008)}
qwen2_7b = {
(512, 3584),
(3584, 3584),
(18944, 3584),
(3584, 18944),
}
llama3_70b = {
(1024, 8192),
(8192, 8192),
(28672, 8192),
(8192, 28672),
}
opt_1_3b = {(2048, 2048), (8192, 2048), (2048, 8192)}
policy = _AUTO_GEMV_SHAPES[(8, 9)]
assert policy[1] == opt_1_3b
assert common <= policy[2]
assert qwen2_7b | llama3_70b | opt_1_3b <= policy[2]
assert common - subthreshold_m4 <= policy[4]
assert qwen2_7b | llama3_70b <= policy[4]
assert subthreshold_m4.isdisjoint(policy[4])
assert opt_1_3b.isdisjoint(policy[4])
assert 8 not in policy
def test_model_linear_routes_through_backend(monkeypatch):
sentinel = torch.randn(2, 4)
@@ -93,7 +133,7 @@ def test_mode_one_dispatches_supported_small_batches(monkeypatch, m):
@skip_no_gemv
def test_auto_m1_falls_back_until_end_to_end_gate_passes(monkeypatch):
def test_auto_unmeasured_m1_falls_back(monkeypatch):
monkeypatch.setenv("ASTRAI_GEMV", "auto")
x = torch.randn(1, 1536, device="cuda", dtype=torch.bfloat16)
winning = torch.randn(
@@ -109,10 +149,38 @@ def test_auto_m1_falls_back_until_end_to_end_gate_passes(monkeypatch):
@skip_no_gemv
def test_auto_selects_measured_sm89_small_batch_winner(monkeypatch):
@pytest.mark.parametrize(
"m,n,k",
[
(4, 256, 1536),
(2, 1024, 4096),
(2, 11008, 4096),
(2, 4096, 11008),
(2, 14336, 4096),
(4, 4096, 14336),
(2, 5120, 5120),
(4, 13824, 5120),
(2, 5120, 13824),
(4, 16384, 4096),
(2, 4096, 16384),
(2, 512, 3584),
(4, 3584, 3584),
(2, 18944, 3584),
(4, 3584, 18944),
(2, 1024, 8192),
(4, 8192, 8192),
(2, 28672, 8192),
(4, 8192, 28672),
(1, 2048, 2048),
(2, 8192, 2048),
(1, 2048, 8192),
],
)
def test_auto_selects_measured_sm89_small_batch_winner(monkeypatch, m, n, k):
monkeypatch.setenv("ASTRAI_GEMV", "auto")
x = torch.randn(4, 1536, device="cuda", dtype=torch.bfloat16)
weight = torch.randn(256, 1536, device="cuda", dtype=torch.bfloat16)
x = torch.randn(m, k, device="cuda", dtype=torch.bfloat16)
weight = torch.empty(n, k, device="cuda", dtype=torch.bfloat16)
weight.normal_(mean=0.0, std=0.02)
with torch.no_grad():
trace = explain("linear", x, weight)
if torch.cuda.get_device_capability() == (8, 9):
@@ -133,6 +201,18 @@ def test_auto_selects_measured_sm89_small_batch_winner(monkeypatch):
(4, 100000, 1536), # LM head misses the 5% M=4 gate
(4, 1536, 6912), # long-K accumulation changed checkpoint greedy output
(8, 256, 1536), # remaining M=8 winners miss the 3% end-to-end gate
(1, 4096, 4096), # isolated M=1 winner misses the projection-chain gate
(8, 1024, 4096), # isolated M=8 winner misses the projection-chain gate
(4, 12288, 4096), # GPT-NeoX fused QKV was not measured as a winner
(4, 4096, 4096), # LLaMA 2 7B M=4 chain misses the 3% gate
(4, 11008, 4096),
(4, 4096, 11008),
(1, 3584, 3584), # Qwen2 M=1 chain misses the 3% gate
(8, 3584, 3584), # Qwen2 Q/O misses the M=8 per-shape gate
(1, 8192, 8192), # LLaMA 3 70B M=1 projections miss the per-shape gate
(8, 1024, 8192), # LLaMA 3 70B K/V loses at wrapper level for M=8
(4, 8192, 2048), # OPT up loses at wrapper level for M=4
(8, 2048, 2048), # OPT M=8 chain and Q/K/V/O both regress
],
)
def test_auto_rejects_measured_small_batch_losers(monkeypatch, m, n, k):
+99
View File
@@ -0,0 +1,99 @@
import pytest
import torch
import torch.nn.functional as F
from astrai.extension import bf16_swiglu, is_available
SWIGLU_AVAILABLE = (
torch.cuda.is_available()
and is_available("bf16_swiglu")
and torch.cuda.get_device_capability() >= (8, 0)
)
skip_no_swiglu = pytest.mark.skipif(
not SWIGLU_AVAILABLE,
reason="BF16 SwiGLU requires a built kernel and compute capability 8.0+",
)
def reference_swiglu(x, up_weight, gate_weight):
return F.linear(x, up_weight) * F.silu(F.linear(x, gate_weight))
@skip_no_swiglu
@pytest.mark.parametrize("m", [1, 2, 4, 8])
@pytest.mark.parametrize("n,k", [(6912, 1536), (4096, 4096), (11008, 4096)])
def test_bf16_swiglu_matches_common_dense_mlp_shapes(m, n, k):
torch.manual_seed(37 + m)
x = torch.randn(m, k, device="cuda", dtype=torch.bfloat16) * 0.1
up_weight = torch.randn(n, k, device="cuda", dtype=torch.bfloat16) * (k**-0.5)
gate_weight = torch.randn(n, k, device="cuda", dtype=torch.bfloat16) * (k**-0.5)
actual = bf16_swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
assert actual.shape == (m, n)
torch.testing.assert_close(actual, expected, rtol=0.03, atol=0.01)
@skip_no_swiglu
def test_bf16_swiglu_preserves_vector_shape():
x = torch.randn(1536, device="cuda", dtype=torch.bfloat16) * 0.1
up_weight = torch.randn(256, 1536, device="cuda", dtype=torch.bfloat16) * 0.02
gate_weight = torch.randn_like(up_weight) * 0.02
actual = bf16_swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
assert actual.shape == (256,)
torch.testing.assert_close(actual, expected, rtol=0.03, atol=0.01)
@skip_no_swiglu
def test_bf16_swiglu_uses_current_stream_and_cuda_graph():
torch.manual_seed(43)
x = torch.randn(4, 1536, device="cuda", dtype=torch.bfloat16) * 0.1
up_weight = torch.randn(6912, 1536, device="cuda", dtype=torch.bfloat16) * 0.02
gate_weight = torch.randn_like(up_weight) * 0.02
stream = torch.cuda.Stream()
with torch.cuda.stream(stream):
for _ in range(3):
bf16_swiglu(x, up_weight, gate_weight)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
actual = bf16_swiglu(x, up_weight, gate_weight)
x.copy_(torch.randn_like(x) * 0.1)
graph.replay()
stream.synchronize()
expected = reference_swiglu(x, up_weight, gate_weight)
torch.testing.assert_close(actual, expected, rtol=0.03, atol=0.01)
@skip_no_swiglu
@pytest.mark.parametrize(
"make_args,error",
[
(
lambda: (
torch.randn(9, 16, device="cuda", dtype=torch.bfloat16),
torch.randn(8, 16, device="cuda", dtype=torch.bfloat16),
torch.randn(8, 16, device="cuda", dtype=torch.bfloat16),
),
"M must",
),
(
lambda: (
torch.randn(2, 15, device="cuda", dtype=torch.bfloat16),
torch.randn(8, 15, device="cuda", dtype=torch.bfloat16),
torch.randn(8, 15, device="cuda", dtype=torch.bfloat16),
),
"divisible by 8",
),
(
lambda: (
torch.randn(2, 16, device="cuda", dtype=torch.bfloat16),
torch.randn(8, 16, device="cuda", dtype=torch.bfloat16),
torch.randn(7, 16, device="cuda", dtype=torch.bfloat16),
),
"identical shapes",
),
],
)
def test_bf16_swiglu_rejects_unsupported_inputs(make_args, error):
with pytest.raises(RuntimeError, match=error):
bf16_swiglu(*make_args())
+94
View File
@@ -0,0 +1,94 @@
import logging
import pytest
import torch
import torch.nn.functional as F
from astrai.extension import is_available, swiglu
from astrai.model.components.mlp import MLP
SWIGLU_AVAILABLE = (
torch.cuda.is_available()
and is_available("bf16_swiglu")
and torch.cuda.get_device_capability() >= (8, 0)
)
skip_no_swiglu = pytest.mark.skipif(
not SWIGLU_AVAILABLE,
reason="BF16 SwiGLU requires a built kernel and compute capability 8.0+",
)
def reference_swiglu(x, up_weight, gate_weight):
return F.linear(x, up_weight) * F.silu(F.linear(x, gate_weight))
def test_cpu_and_training_calls_fall_back_with_gradients(monkeypatch):
monkeypatch.setenv("ASTRAI_SWIGLU", "1")
x = torch.randn(2, 8, requires_grad=True)
up_weight = torch.randn(4, 8, requires_grad=True)
gate_weight = torch.randn(4, 8, requires_grad=True)
actual = swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
torch.testing.assert_close(actual, expected)
actual.sum().backward()
assert x.grad is not None
assert up_weight.grad is not None
assert gate_weight.grad is not None
def test_invalid_mode_warns_and_uses_auto(monkeypatch, caplog):
monkeypatch.setenv("ASTRAI_SWIGLU", "invalid-test-mode")
with caplog.at_level(logging.WARNING):
actual = swiglu(torch.randn(2, 8), torch.randn(4, 8), torch.randn(4, 8))
assert actual.shape == (2, 4)
assert "using auto" in caplog.text
def test_mlp_routes_through_swiglu_backend(monkeypatch):
sentinel = torch.randn(2, 4)
def fake_swiglu(x, up_weight, gate_weight):
assert x.shape == (2, 3)
assert up_weight.shape == gate_weight.shape == (4, 3)
return sentinel
monkeypatch.setattr("astrai.model.components.mlp.swiglu", fake_swiglu)
layer = MLP(3, 4)
output = layer(torch.randn(2, 3))
assert output["hidden_states"].shape == (2, 3)
@skip_no_swiglu
def test_mode_zero_disables_fused_kernel(monkeypatch):
monkeypatch.setenv("ASTRAI_SWIGLU", "0")
x = torch.randn(4, 1536, device="cuda", dtype=torch.bfloat16) * 0.1
up_weight = torch.randn(6912, 1536, device="cuda", dtype=torch.bfloat16) * 0.02
gate_weight = torch.randn_like(up_weight) * 0.02
with torch.no_grad():
actual = swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
torch.testing.assert_close(actual, expected)
@skip_no_swiglu
def test_mode_one_forces_supported_shape(monkeypatch):
monkeypatch.setenv("ASTRAI_SWIGLU", "1")
x = torch.randn(4, 1536, device="cuda", dtype=torch.bfloat16) * 0.1
up_weight = torch.randn(6912, 1536, device="cuda", dtype=torch.bfloat16) * 0.02
gate_weight = torch.randn_like(up_weight) * 0.02
with torch.no_grad():
actual = swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
torch.testing.assert_close(actual, expected, rtol=0.03, atol=0.01)
@skip_no_swiglu
def test_auto_falls_back_until_shape_is_qualified(monkeypatch):
monkeypatch.setenv("ASTRAI_SWIGLU", "auto")
x = torch.randn(4, 1536, device="cuda", dtype=torch.bfloat16)
up_weight = torch.randn(6912, 1536, device="cuda", dtype=torch.bfloat16)
gate_weight = torch.randn_like(up_weight)
with torch.no_grad():
actual = swiglu(x, up_weight, gate_weight)
expected = reference_swiglu(x, up_weight, gate_weight)
torch.testing.assert_close(actual, expected)