perf: rebuild decode gemm dispatch around shape-driven tile configs
- split-K removed entirely: tiled kernel walks K in one pass, no partials/semas workspace, no memset, single launch per call
- skinny GEMM (M<=8) dispatch table replaces the hand-written switch
- shape-driven four-family table replaces plan_gemm: wide-N (n>=4096) default {16,64,64,3,128} with BM=32 at M>16; narrow-N deep-K rings {16,32,256,2,64} while the grid fits one wave, {16,32,128,2,64} past it
- narrow-N is K-serial: widening the grid measurably does nothing (BN 64->32 ties, doubled m_tiles tie, kv at 4 blocks ties q/o at 24); deeper K chunks win until 72KB smem forces one CTA per SM and past one wave the 2-wave quantization loses to BK=128
- launch-check macros in common/launch.cuh; smem opt-in for the 72KB/60KB rings
- rename kernels/bf16_*.cu to gemm.cu/swiglu.cu; module names unchanged
- Python gate: lm_head (N>32768) falls back to cuBLAS, band narrows to M<=32
- drop the stale per-op benchmark narratives; fold the live numbers into cuda_kernels.md
Benchmark: NVIDIA L20 (sm_89, 92 SMs), CUDA 12.8, bf16, L2-thrash weight rotation, per-call medians at M=16: q/o 9.5us, kv 8.6us, gate/up 33.3us, down 33.7us (down -29% vs prior default). End-to-end 1B decode (gen 128, 3 trials, tokens/s vs cuBLAS): B=1 260 vs 252, B=8 1660 vs 1446, B=16 2464 vs 2437, B=32 3620 vs 3690. Prior split-K dispatch measured B=16 2243 / B=32 3393.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# CUDA Kernels
|
||||
|
||||
AstrAI includes optional custom CUDA kernels for attention, rotary embedding,
|
||||
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
|
||||
BF16 GEMM/SwiGLU, and FP8 GEMM. These are built when `nvcc` is available and
|
||||
CUDA is detected. BF16 GEMM and SwiGLU are directly callable and can be
|
||||
selected by guarded model dispatchers described below.
|
||||
|
||||
## Overview
|
||||
@@ -14,49 +14,55 @@ selected by guarded model dispatchers described below.
|
||||
| `attn_paged_decode` | `attention/paged_decode.cu` | Paged KV cache decode attention |
|
||||
| `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` | `bf16_gemv.cu` | M=1..8 BF16 linear with FP32 accumulation (sm_80+) |
|
||||
| `bf16_swiglu` | `bf16_swiglu.cu` | Fused M=1..8 BF16 up/gate projections and SwiGLU epilogue (sm_80+) |
|
||||
| `bf16_gemm` | `gemm.cu` | M=1..64 BF16 linear, skinny GEMM path for M<=8 and tensor-core tiled path above (sm_80+) |
|
||||
| `bf16_swiglu` | `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
|
||||
### BF16 GEMM 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 computes an output row for all M tokens
|
||||
together, reusing the weight row across tokens. CTA size is 256 threads,
|
||||
except for small weight matrices (`N*K <= 12 MiB`) at `M=8`, where a
|
||||
128-thread CTA measured 5-9% faster on L20. Variant selection is otherwise
|
||||
intentionally shape-free: under HBM-streaming conditions (weights rotated
|
||||
through L2, as in real decode) the kernel is bandwidth-bound and block-size
|
||||
choice measures within noise, so earlier per-shape variant tables were
|
||||
removed along with the warp-tiled kernel.
|
||||
`astrai.extension.bf16_gemm(x, weight, bias=None)` accepts a contiguous BF16
|
||||
input shaped `[K]` or `[M, K]`, with `M` in `[1, 64]`, and row-major weights
|
||||
`[N, K]`. One entry point selects the internal path by M:
|
||||
|
||||
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. 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.
|
||||
- `M` in `[1, 8]` — register-resident GEMV kernel, any positive `K`: one
|
||||
flat 128-thread CTA per weight row computes the output column for all M
|
||||
tokens together. Under HBM-streaming conditions (weights rotated through
|
||||
L2, as in real decode) the kernel is bandwidth-bound; on L20 the flat
|
||||
128-thread CTA measured within ~1% of a per-shape tuned 128/256/512 mix at
|
||||
M=1 and M=8 and at most ~2% below it at M=2-4, while dodging the register
|
||||
cliff the larger CTAs hit on the 307MB lm_head at M>=7 (+21% DRAM
|
||||
throughput vs 256t). Simplicity was chosen over the last ~2%.
|
||||
- `M` in `(8, 64]` — CUTLASS-style tiled kernel (`tiled_gemm_kernel`,
|
||||
fully parameterized by template parameters BM/BN/BK/stages/threads):
|
||||
multistage cp.async staging with XOR-swizzled 16B chunks, no split-K —
|
||||
every block walks the whole K range in one pass and the grid fills the
|
||||
SMs along N. Dispatch is shape-driven: wide N (n >= 4096, grid already
|
||||
full) uses the bandwidth-optimal default (BN=64, BK=64, 3 stages; BM
|
||||
widens to 32 at M>16); narrow N is K-serial, where widening the grid
|
||||
measurably does nothing and deeper K chunks win (BN=32, BK=256 while
|
||||
the grid fits one wave, BK=128 past it). Requires `K % 8 == 0` and
|
||||
16-byte-aligned tensors.
|
||||
|
||||
Both paths accumulate in FP32, fuse the optional BF16 bias before the BF16
|
||||
store, use the current CUDA stream, are CUDA Graph capture-safe, and require
|
||||
sm_80 or newer. The GEMV path anchors 128-bit weight loads 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.
|
||||
|
||||
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). Automatic
|
||||
dispatch is keyed on the decode batch size alone: the kernel streams each
|
||||
weight exactly once, so once a batch size is profitable it is profitable
|
||||
across projection shapes. On compute capability 8.0+, `auto` selects the
|
||||
kernel for `M` in [2, 4], where every measured model family beat the cuBLAS
|
||||
small-M path at the HBM bandwidth floor (AstrAI 1B chain +11.8% to +14.0%,
|
||||
common LLaMA/Qwen/OPT chains +5.66% to +25.20%). `M=1` keeps cuBLAS, whose
|
||||
GEMV path is already at the floor, and `M >= 5` approaches the cuBLAS
|
||||
tensor-core crossover (M=8 regressed at wrapper level in every measured
|
||||
family). Out-of-band, training, prefill-sized, or unsupported calls fall
|
||||
back to PyTorch.
|
||||
`ASTRAI_GEMM=0` for an unconditional `F.linear` fallback, `1` to force the
|
||||
kernel for any capable M (1-64, with K%8==0 and alignment for M>8), or
|
||||
`auto` (the default). On compute capability 8.0+, `auto` routes all capable
|
||||
decode batches M in [1, 64] through the kernel. The GEMV path (M≤8, any K)
|
||||
measured universal wins at the HBM bandwidth floor (AstrAI 1B end-to-end
|
||||
+8.8% at M=1 rising to +17% at M=8). The tiled path (M 9-64) measured on
|
||||
L20 across five shape families wins or ties four (q/o, kv, gate/up, lm_head)
|
||||
and regresses one (down-projection M>16: +25% latency; narrow-N long-K where
|
||||
cuBLAS is strong). Out-of-band, training, prefill-sized, or unsupported
|
||||
calls fall back to PyTorch.
|
||||
|
||||
Inside the primitive, a templated cooperative kernel uses 256 threads,
|
||||
except at the largest decode batch where a 128-thread CTA wins 5-9% on
|
||||
small weight matrices: `M=8` with 16-byte-aligned inputs, `K % 8 == 0`,
|
||||
and `N*K <= 12 MiB` selects the smaller CTA. This internal selector is
|
||||
separate from model automatic dispatch, whose Python/wrapper overhead is
|
||||
included in the gates above.
|
||||
The measurements below date from the original `[2, 4]` automatic band; the
|
||||
band has since widened to `{1, ..., 8}` with the flat 128-thread kernel:
|
||||
|
||||
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
|
||||
@@ -84,7 +90,7 @@ 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 `csrc/bench/benchmark_gemv_common.py`.
|
||||
claims. Reproduce them with `csrc/bench/benchmark_gemm_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
|
||||
@@ -121,8 +127,7 @@ the unfused linear backend, and `1` explicitly forces the fused primitive.
|
||||
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.
|
||||
existing path.
|
||||
|
||||
Additionally, optimized `.cuh` variants with tensor-core MMA (Matrix Multiply-Accumulate) exist:
|
||||
|
||||
@@ -313,7 +318,7 @@ astrai/extension/
|
||||
├── ops/
|
||||
│ ├── attention.py # Stateless attention kernel wrappers
|
||||
│ ├── rotary.py # Stateless rotary kernel wrapper
|
||||
│ ├── gemv.py # Stateless BF16 GEMV primitive
|
||||
│ ├── gemm.py # Stateless BF16 GEMM primitive
|
||||
│ ├── swiglu.py # Stateless fused BF16 SwiGLU primitive
|
||||
│ └── fp8.py # Stateless FP8 primitives (custom_op)
|
||||
├── fp8.py # FP8 strategy layer (fp8_autocast, recipes)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Decode linear shape benchmark
|
||||
|
||||
`csrc/bench/benchmark_gemv.py` records the `F.linear` baseline used to decide
|
||||
whether a BF16 GEMV or small-M kernel should enter automatic inference dispatch.
|
||||
It does not change model execution or select a custom kernel.
|
||||
|
||||
The default matrix covers the AstrAI 1B q/k/v/out projections, MLP up/gate/down,
|
||||
and LM head for `M=1,2,4,8,16,32`. Each shape runs in eager and CUDA Graph replay
|
||||
modes. Results include device-event latency samples, p50/p90/p99, estimated
|
||||
effective IO bandwidth, and CUDA kernel launches per call.
|
||||
|
||||
```bash
|
||||
CUDA_VISIBLE_DEVICES=0 python csrc/bench/benchmark_gemv.py \
|
||||
--output results/decode_linear.json \
|
||||
--markdown-output results/decode_linear.md
|
||||
```
|
||||
|
||||
Use `--shape NAME:N:K` repeatedly to override the preset and `--m-values` to
|
||||
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 csrc/bench/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.
|
||||
Automatic dispatch is keyed on the decode batch size alone (`M` in `[2, 4]` on
|
||||
compute capability 8.0+); use `--candidate-mode 1` to characterize a family
|
||||
before widening that band. 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.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Fused SwiGLU benchmark
|
||||
|
||||
`csrc/bench/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 csrc/bench/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.
|
||||
|
||||
## HBM re-measurement and kernel simplification
|
||||
|
||||
The operator numbers above are L2-resident: the AstrAI pair is 40.5 MB,
|
||||
smaller than the 96 MB L2, so a tight timing loop re-reads warm weights
|
||||
(13.75 us implies ~3.1 TB/s, far above the 864 GB/s spec). Real decode rotates
|
||||
~1 GB of per-layer weights through L2 every step, so every call is cold.
|
||||
|
||||
Re-measuring with rotated weight copies (>= 240 MB working set) on the same
|
||||
L20 showed:
|
||||
|
||||
- The fused CTA-reuse kernel sits at the dual-stream cold-read floor
|
||||
(702 vs 699 GB/s at (6912,1536); 369 vs 370 GB/s at (11008,4096)). Wide
|
||||
LLaMA matrices cap at ~370-400 GB/s regardless of kernel, even for a
|
||||
pure-read loop, so the old per-variant gaps there were noise.
|
||||
- The `(6912,1536)` warp-per-row variant (formerly M=2/4/8) is 2-6% slower
|
||||
than CTA reuse at M=2/4 under cold weights and no longer wins at M=8 once
|
||||
the CTA drops to 128 threads. It and its dispatch table were deleted.
|
||||
- New rule: 256 threads for M in [1, 7], 128 threads for M=8. End-to-end
|
||||
through the built module at (6912,1536): 738-752 GB/s for M in [1, 4] and
|
||||
702 GB/s at M=8 (+6% over the removed warp path).
|
||||
|
||||
The M=8 CUDA-Graph regression reported above (`-23.24%`) does not survive the
|
||||
cold-weight regime: cuBLAS reaches L2 bandwidth in the warm loop while both
|
||||
fused paths converge to the same HBM floor.
|
||||
Reference in New Issue
Block a user