Files
karpathy--llm.c/dev/cuda/attention_backward.cu
2026-07-13 12:37:59 +08:00

1155 lines
48 KiB
Plaintext

/*
Kernels for attention backward pass.
Compile example:
nvcc -O3 --use_fast_math -lcublas -lcublasLt attention_backward.cu -o attention_backward
version 1 is a naive first version
OMP_NUM_THREADS=32 ./attention_backward 1
version 2 much ensures better load-balancing by having independent threads for each batch and attention head
OMP_NUM_THREADS=32 ./attention_backward 2
version 3 uses a full warp to calculate each result (instead of a thread), which enables coalesced memory access
OMP_NUM_THREADS=32 ./attention_backward 3
version 4 improves data reuse in registers by doing 8 values of t3 in one warp.
OMP_NUM_THREADS=32 ./attention_backward 4
version 5 reduces the amount of non-fp32 instructions needed by avoiding ifs
OMP_NUM_THREADS=32 ./attention_backward 5
*/
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <float.h>
#include <cublas_v2.h>
#include <cuda_runtime.h>
#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h>
#include <cooperative_groups/scan.h>
#include "common.h"
// ----------------------------------------------------------------------------
// CPU code reference
/*
NOTE:
This version of attention_forward is modified to be consistent with the
attention_forward GPU kernel in the following way small but important way:
- preatt is only QUERY @ KEY, without the scale
- the scale instead moved and fused into the softmax
- the full preatt matrix is materialized, even the parts that get masked out
- this doesn't actually change anything due to masking, but it lets us
easily compare to the GPU version, which also does the full, dense sgemm
In this way we'll be able to make sure that preatt and att agree CPU vs GPU
*/
void attention_forward_cpu(float* out, float* preatt, float* att,
float* inp,
int B, int T, int C, int NH) {
// input is (B, T, 3C) holding the query, key, value (Q, K, V) vectors
// preatt, att are (B, NH, T, T). NH = number of heads, T = sequence length
// that holds the pre-attention and post-attention scores (used in backward)
// output is (B, T, C)
// attention is the only layer that mixes information across time
// every other operation is applied at every (b,t) position independently
// (and of course, no layer mixes information across batch)
int C3 = C*3;
int hs = C / NH; // head size
float scale = 1.0 / sqrtf(hs);
#pragma omp parallel for collapse(3)
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
for (int h = 0; h < NH; h++) {
float* query_t = inp + b * T * C3 + t * C3 + h * hs;
float* preatt_bth = preatt + b*NH*T*T + h*T*T + t*T;
float* att_bth = att + b*NH*T*T + h*T*T + t*T;
// pass 1: calculate query dot key and maxval
float maxval = -FLT_MAX;
for (int t2 = 0; t2 < T; t2++) { // used to be t2 <= t
float* key_t2 = inp + b * T * C3 + t2 * C3 + h * hs + C; // +C because it's key
// (query_t) dot (key_t2)
float val = 0.0f;
for (int i = 0; i < hs; i++) {
val += query_t[i] * key_t2[i];
}
if (val > maxval) {
maxval = val;
}
preatt_bth[t2] = val;
}
// pass 2: calculate the exp and keep track of sum
// maxval is being calculated and subtracted only for numerical stability
float expsum = 0.0f;
for (int t2 = 0; t2 <= t; t2++) {
float expv = expf(scale * (preatt_bth[t2] - maxval));
expsum += expv;
att_bth[t2] = expv;
}
float expsum_inv = expsum == 0.0f ? 0.0f : 1.0f / expsum;
// pass 3: normalize to get the softmax
for (int t2 = 0; t2 < T; t2++) {
if (t2 <= t) {
att_bth[t2] *= expsum_inv;
} else {
// causal attention mask. not strictly necessary to set to zero here
// only doing this explicitly for debugging and checking to PyTorch
att_bth[t2] = 0.0f;
}
}
// pass 4: accumulate weighted values into the output of attention
float* out_bth = out + b * T * C + t * C + h * hs;
for (int i = 0; i < hs; i++) { out_bth[i] = 0.0f; }
for (int t2 = 0; t2 <= t; t2++) {
float* value_t2 = inp + b * T * C3 + t2 * C3 + h * hs + C*2; // +C*2 because it's value
float att_btht2 = att_bth[t2];
for (int i = 0; i < hs; i++) {
out_bth[i] += att_btht2 * value_t2[i];
}
}
}
}
}
}
// NOTE: Also contains the re-shuffling of the exact position of "scale"
// and when it is applied (after preatt, not "during" preatt)
// also, full matrices are materialized, even the parts that get masked out
void attention_backward_cpu(float* dinp, float* dpreatt, float* datt,
float* dout, float* inp, float* att,
int B, int T, int C, int NH) {
// inp/dinp are (B, T, 3C) Q,K,V
// att/datt/dpreatt are (B, NH, T, T)
// dout is (B, T, C)
int C3 = C*3;
int hs = C / NH; // head size
float scale = 1.0 / sqrtf(hs);
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
for (int h = 0; h < NH; h++) {
float* att_bth = att + b*NH*T*T + h*T*T + t*T;
float* datt_bth = datt + b*NH*T*T + h*T*T + t*T;
float* dpreatt_bth = dpreatt + b*NH*T*T + h*T*T + t*T;
float* dquery_t = dinp + b * T * C3 + t * C3 + h * hs;
float* query_t = inp + b * T * C3 + t * C3 + h * hs;
// backward pass 4, through the value accumulation
float* dout_bth = dout + b * T * C + t * C + h * hs;
for (int t2 = 0; t2 < T; t2++) { // ADJUSTED! this was t2 <= t (see note on function)
float* value_t2 = inp + b * T * C3 + t2 * C3 + h * hs + C*2; // +C*2 because it's value
float* dvalue_t2 = dinp + b * T * C3 + t2 * C3 + h * hs + C*2;
for (int i = 0; i < hs; i++) {
// in the forward pass this was:
// out_bth[i] += att_bth[t2] * value_t2[i];
// so now we have:
datt_bth[t2] += value_t2[i] * dout_bth[i];
dvalue_t2[i] += att_bth[t2] * dout_bth[i];
}
}
// backward pass 2 & 3, the softmax
// note that softmax (like e.g. tanh) doesn't need the input (preatt) to backward
for (int t2 = 0; t2 <= t; t2++) {
for (int t3 = 0; t3 <= t; t3++) {
float indicator = t2 == t3 ? 1.0f : 0.0f;
float local_derivative = att_bth[t2] * (indicator - att_bth[t3]);
dpreatt_bth[t3] += scale * local_derivative * datt_bth[t2];
}
}
// backward pass 1, the query @ key matmul
for (int t2 = 0; t2 <= t; t2++) {
float* key_t2 = inp + b * T * C3 + t2 * C3 + h * hs + C; // +C because it's key
float* dkey_t2 = dinp + b * T * C3 + t2 * C3 + h * hs + C; // +C because it's key
for (int i = 0; i < hs; i++) {
// in the forward pass this was:
// preatt_bth[t2] += query_t[i] * key_t2[i]
// so now we have:
dquery_t[i] += key_t2[i] * dpreatt_bth[t2];
dkey_t2[i] += query_t[i] * dpreatt_bth[t2];
}
}
}
}
}
}
// ----------------------------------------------------------------------------
// GPU kernels
// the forward pass that is the sequence [permute, sgemm, softmax, sgemm, unpermute]
__global__ void permute_kernel(float* q, float* k, float* v,
const float* inp,
int B, int N, int NH, int d) {
// okay so now, this kernel wants Q,K,V to all be of shape (B, NH, N, d)
// but instead, we have a single tensor QKV (inp) of shape (B, N, 3, NH, d)
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// Q[b][nh_][n][d_] = inp[b][n][0][nh_][d_]
if (idx < B * NH * N * d) {
int b = idx / (NH * N * d);
int rest = idx % (NH * N * d);
int nh_ = rest / (N * d);
rest = rest % (N * d);
int n = rest / d;
int d_ = rest % d;
int inp_idx = (b * N * 3 * NH * d) + (n * 3 * NH * d) + (0 * NH * d) + (nh_ * d) + d_;
q[idx] = inp[inp_idx];
k[idx] = inp[inp_idx + NH * d];
v[idx] = inp[inp_idx + 2 * (NH * d)];
}
}
__global__ void permute_kernel_backward(float* dinp,
const float* dq, const float* dk, const float* dv,
int B, int N, int NH, int d) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < B * NH * N * d) {
int b = idx / (NH * N * d);
int rest = idx % (NH * N * d);
int nh_ = rest / (N * d);
rest = rest % (N * d);
int n = rest / d;
int d_ = rest % d;
int inp_idx = (b * N * 3 * NH * d) + (n * 3 * NH * d) + (0 * NH * d) + (nh_ * d) + d_;
dinp[inp_idx] += dq[idx];
dinp[inp_idx + NH * d] += dk[idx];
dinp[inp_idx + 2 * (NH * d)] += dv[idx];
}
}
__global__ void unpermute_kernel(const float* inp, float *out, int B, int N, int NH, int d) {
// out has shape (B, nh, N, d) but we need to unpermute it to (B, N, nh, d)
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// out[b][n][nh_][d_] <- inp[b][nh_][n][d_]
if (idx < B * NH * N * d) {
int b = idx / (NH * N * d);
int rest = idx % (NH * N * d);
int nh_ = rest / (N * d);
rest = rest % (N * d);
int n = rest / d;
int d_ = rest % d;
int other_idx = (b * NH * N * d) + (n * NH * d) + (nh_ * d) + d_;
out[other_idx] = inp[idx];
}
}
__global__ void unpermute_kernel_backward(float* dinp, const float *dout, int B, int N, int NH, int d) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < B * NH * N * d) {
int b = idx / (NH * N * d);
int rest = idx % (NH * N * d);
int nh_ = rest / (N * d);
rest = rest % (N * d);
int n = rest / d;
int d_ = rest % d;
int other_idx = (b * NH * N * d) + (n * NH * d) + (nh_ * d) + d_;
dinp[idx] += dout[other_idx];
}
}
__device__ float& vec_at(float4& vec, int index) {
return reinterpret_cast<float*>(&vec)[index];
}
__device__ float vec_at(const float4& vec, int index) {
return reinterpret_cast<const float*>(&vec)[index];
}
__global__ void softmax_forward_kernel5(float* out, float inv_temperature, const float* inp, int N, int T) {
// inp, out shape: (N, T, T), where N = B * NH
// fuses the multiplication by scale inside attention
// directly autoregressive, so we only compute the lower triangular part
// uses the online softmax algorithm
assert(T % 4 == 0);
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
int idx = blockIdx.x * warp.meta_group_size() + warp.meta_group_rank();
if(idx >= N * T) {
return;
}
int own_pos = idx % T;
int pos_by_4 = own_pos / 4;
// one row of inp, i.e. inp[idx, :] of shape (T,)
const float* x = inp + idx * T;
// not INF, so we don't get NaNs accidentally when subtracting two values.
float maxval = -FLT_MAX;
float sumval = 0.0f;
const float4* x_vec = reinterpret_cast<const float4*>(x);
for (int i = warp.thread_rank(); i < pos_by_4; i += warp.size()) {
float4 v = x_vec[i];
float old_maxval = maxval;
for(int k = 0; k < 4; ++k) {
maxval = fmaxf(maxval, vec_at(v, k));
}
sumval *= expf(inv_temperature * (old_maxval - maxval));
for(int k = 0; k < 4; ++k) {
sumval += expf(inv_temperature * (vec_at(v, k) - maxval));
}
}
if(4*pos_by_4 + warp.thread_rank() <= own_pos) {
float old_maxval = maxval;
maxval = fmaxf(maxval, x[4*pos_by_4 + warp.thread_rank()]);
sumval *= expf(inv_temperature * (old_maxval - maxval));
sumval += expf(inv_temperature * (x[4*pos_by_4 + warp.thread_rank()] - maxval));
}
float global_maxval = cg::reduce(warp, maxval, cg::greater<float>{});
sumval *= expf(inv_temperature * (maxval - global_maxval));
float sum = cg::reduce(warp, sumval, cg::plus<float>{});
float norm = 1.f / sum;
// divide the whole row by the sum
for (int i = warp.thread_rank(); i <= own_pos; i += warp.size()) {
// recalculation is faster than doing the round-trip through memory.
float ev = expf(inv_temperature * (__ldcs(x + i) - global_maxval));
__stcs(out + idx * T + i, ev * norm);
}
}
// naive kernel to backward through an autoregressive softmax, just to get correctness
__global__ void softmax_autoregressive_backward_kernel1(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, int NH) {
// dpreatt, datt, att are all (B, NH, T, T)
int t3 = blockIdx.x * blockDim.x + threadIdx.x;
if (t3 < T) {
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
for (int b = 0; b < B; b++) {
for (int h = 0; h < NH; h++) {
for (int t = t3; t < T; t++) {
const float* att_bth = att + b*NH*T*T + h*T*T + t*T;
const float* datt_bth = datt + b*NH*T*T + h*T*T + t*T;
float* dpreatt_bth = dpreatt + b*NH*T*T + h*T*T + t*T;
float accum = 0.0f;
for (int t2 = 0; t2 <= t; t2++) {
float indicator = t2 == t3 ? 1.0f : 0.0f;
float local_derivative = att_bth[t2] * (indicator - att_bth[t3]);
accum += scale * local_derivative * datt_bth[t2];
}
dpreatt_bth[t3] = accum;
}
}
}
}
}
// parallelize across t,b,h
__global__ void softmax_autoregressive_backward_kernel2(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, int NH) {
int t3 = blockIdx.x * blockDim.x + threadIdx.x;
int idx = blockIdx.y * T * T;
if (t3 >= T) { return; }
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
for (int t = t3; t < T; t++) {
float result = 0.0;
const float* att_bth = att + idx + t*T;
const float* datt_bth = datt + idx + t*T;
float* dpreatt_bth = dpreatt + idx + t*T;
for (int t2 = 0; t2 <= t; t2++) {
float indicator = t2 == t3 ? 1.0f : 0.0f;
float local_derivative = att_bth[t2] * (indicator - att_bth[t3]);
result += scale * local_derivative * datt_bth[t2];
}
dpreatt_bth[t3] = result;
}
}
// parallelize across t,b,h
__global__ void softmax_autoregressive_backward_kernel3(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, int NH) {
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
int t3 = blockIdx.x * warp.meta_group_size() + warp.meta_group_rank();
int idx = blockIdx.y * T * T;
if (t3 >= T) { return; }
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
for (int t = t3; t < T; t++) {
float result = 0.0;
const float* att_bth = att + idx + t*T;
const float* datt_bth = datt + idx + t*T;
float* dpreatt_bth = dpreatt + idx + t*T;
const float att_at_t3 = att_bth[t3];
for (int t2 = warp.thread_rank(); t2 <= t; t2 += warp.size()) {
float indicator = t2 == t3 ? 1.0f : 0.0f;
float local_derivative = att_bth[t2] * (indicator - att_at_t3);
result += local_derivative * datt_bth[t2];
}
result = cg::reduce(warp, result, cg::plus<float>());
if(warp.thread_rank() == 0) {
dpreatt_bth[t3] = scale * result;
}
}
}
__global__ void softmax_autoregressive_backward_kernel4(float* __restrict__ dpreatt, const float* __restrict__ datt,
const float* __restrict__ att,
int B, int T, int C, int NH) {
constexpr int UNROLL = 8;
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
int t3 = UNROLL * (blockIdx.x * warp.meta_group_size() + warp.meta_group_rank());
int idx = blockIdx.y * T * T;
if (t3 >= T) { return; }
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
// the innermost loop combines different values of t2 with different values of t.
// by handling [t3, t3 + UNROLL) in one thread, we get much better memory reuse:
// any t3/t-dependent value can be loaded once before the t2 loop.
// within the t2 loop, we can combine each loaded value with each of the UNROLL
// pre-loaded values, thus cutting memory ready by a factor of ~UNROLL.
// one iteration of this loop has to handle the cases
// this may lead to some invalid indices; therefore, we have several
// early-outs in the iteration over k below.
for (int t = t3; t < T; t++) {
float result[UNROLL] = {};
const float* att_bth = att + idx + t * T;
const float* datt_bth = datt + idx + t * T;
float* dpreatt_bth = dpreatt + idx + t * T;
float att_at_t3[UNROLL];
for(int k = 0; k < UNROLL; ++k) {
if (t < t3 + k) continue;
att_at_t3[k] = att_bth[t3 + k];
}
for (int t2 = warp.thread_rank(); t2 <= t; t2 += warp.size()) {
float att_t2 = att_bth[t2];
float datt_t2 = datt_bth[t2];
for(int k = 0; k < UNROLL; ++k) {
if (t < t3 + k) continue;
float indicator = t2 == (t3 + k) ? 1.0f : 0.0f;
float local_derivative = att_t2 * (indicator - att_at_t3[k]);
result[k] += local_derivative * datt_t2;
}
}
for(int k = 0; k < UNROLL; ++k) {
result[k] = cg::reduce(warp, result[k], cg::plus<float>());
}
if (warp.thread_rank() < UNROLL) {
dpreatt_bth[t3 + warp.thread_rank()] = scale * result[warp.thread_rank()];
}
}
}
__global__ void softmax_autoregressive_backward_kernel5(float* __restrict__ dpreatt, const float* __restrict__ datt,
const float* __restrict__ att,
int B, int T, int C, int NH) {
constexpr int UNROLL = 8;
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
int t3 = UNROLL * (blockIdx.x * warp.meta_group_size() + warp.meta_group_rank());
int idx = blockIdx.y * T * T;
if (t3 >= T) { return; }
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
for (int t = t3; t < T; t++) {
float result[UNROLL] = {};
const float* att_bth = att + idx + t * T;
const float* datt_bth = datt + idx + t * T;
float* dpreatt_bth = dpreatt + idx + t * T;
float att_at_t3[UNROLL];
for(int k = 0; k < UNROLL; ++k) {
// if t < t3+k, we're out of bounds.
// in that case, we don't care what we read, because later on,
// we won't write the corresponding result. So just clip to
// make sure this is a valid (in-bounds) memory access.
att_at_t3[k] = att_bth[min(t, t3 + k)];
}
// the code below is actually just a for loop; except,
// we have to do something special in one iteration in
// the middle, and an if turned out to have significant
// performance impact.
// so we split the loop in three parts. Ugly, but effective.
// the beginning/end loop does the same thing, so we write the code
// just once in a lambda. In this step, we're guaranteed that
// indicator == 0
auto loop_step = [&](int t2){
float p = att_bth[t2] * datt_bth[t2];
for (int k = 0; k < UNROLL; ++k) {
result[k] -= p * att_at_t3[k];
}
};
// Now the actual loop.
{
// declare the loop iterator. Needs to be kept across the
// three different parts, so it's not a local variable in
// the for loop.
int t2 = warp.thread_rank();
// first part, as long as t2 < t3, indicator == 0
for (; t2 < t3; t2 += warp.size()) {
loop_step(t2);
}
// because k <= warp.size() (==32), the event that t3+k == t2
// has to happen at this particular step.
static_assert(UNROLL <= 32, "UNROLL is too large, this won't produce correct results.");
if (t2 <= t) {
float att_t2 = att_bth[t2];
float datt_t2 = datt_bth[t2];
float p = att_t2 * datt_t2;
for (int k = 0; k < UNROLL; ++k) {
float indicator = t2 == (t3 + k) ? 1.0f : 0.0f;
result[k] += p * (indicator - att_at_t3[k]);
}
t2 += warp.size();
}
// rest of the loop, indicator == 0 again
for (; t2 <= t; t2 += warp.size()) {
loop_step(t2);
}
}
for(int k = 0; k < UNROLL; ++k) {
result[k] = cg::reduce(warp, result[k], cg::plus<float>());
}
// when storing, we need to check that this is actually a valid result.
// here, warp.thread_rank() corresponds to `k` in the previous loops.
if (warp.thread_rank() < UNROLL && t >= t3 + warp.thread_rank()) {
dpreatt_bth[t3 + warp.thread_rank()] = scale * result[warp.thread_rank()];
}
}
}
// I want `BlockSize` to be statically known to the compiler, thus we get a template here.
// This kernel takes a step back, and looks at the original CPU code again. We have some simple outer loops
// That are independent, (b, t, h), and then the inner loops over (t2, t3) where we're combining elements -- this is
// where we can reuse data and be more efficient
// => handle b, t, h through block indices; each block does all the work for the (t2, t3) loop cooperatively.
// Now we have two nested loops, and in the inner instruction, we combine indexing from both => this calls for
// loop tiling, and lifting some of the memory ops out of the loop.
// We're in luck here; if we tile so that t3 is the outer loop, we can get a sinlge write op per result, AND also cache
// the t2-indexed part of the computation, which is the problematic one because it contains a multiplication that now we
// do not have to repeat over and over.
// => do an outer t3 loop where each thread gets one t3 index. Then, do an outer t2 loop in steps of BlockSize, and
// prepare BlockSize many elements for the inner loop. Here, each thread calculates one element and stores it in shmem.
// Then, in the inner t2 loop, each thread reads *all* the elements previously stored and does its computations.
// This way, we do 3*BlockSize loads, but BlockSize^2 computation steps => This kernel is now entirely compute bound.
// To fix up the compute issues, as above, we replace ifs in memory reading with min, and also split the inner loop
// into a large region where we don't have to calculate the indicator, and a small, costly region where we do.
template<int BlockSize>
__global__ void __launch_bounds__(BlockSize) softmax_autoregressive_backward_kernel6(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, int NH) {
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
__shared__ float att_bth_s[BlockSize];
int idx = blockIdx.y;
int t = blockIdx.x;
att += idx * T * T;
datt += idx * T * T;
dpreatt += idx * T * T;
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
const float* att_bth = att + t * T;
const float* datt_bth = datt + t * T;
float* dpreatt_bth = dpreatt + t * T;
int block_steps = ceil_div(t+1, BlockSize);
// very important: This loop condition needs to be the same for all threads.
// even if a thread later on is not going to do any work, it needs to participate in the
// data loading process!
for (int t3f = 0; t3f < block_steps; ++t3f) {
int t3 = t3f * BlockSize + block.thread_rank();
float acc = 0.f;
float at3 = att_bth[t3];
for (int t2b = 0; t2b <= t; t2b += BlockSize) {
int end = min(t + 1 - t2b, BlockSize);
block.sync();
{
int t2i = block.thread_rank();
int t2 = min(t, t2b + t2i);
att_bth_s[t2i] = att_bth[t2] * datt_bth[t2];
}
block.sync();
if(t3f * BlockSize == t2b) {
for (int t2i = 0; t2i < end; t2i++) {
int t2 = t2b + t2i;
float indicator = t2 == t3 ? 1.0f : 0.0f;
acc += att_bth_s[t2i] * (indicator - at3);
}
} else {
for (int t2i = 0; t2i < end; t2i++) {
acc += att_bth_s[t2i] * (0.f - at3);
}
}
}
dpreatt_bth[t3] = scale * acc;
}
}
// Actually disentangling the loops and simplifying the resulting math gives us this pretty nice kernel.
template<int BlockSize>
__global__ void softmax_autoregressive_backward_kernel7(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, float scale) {
namespace cg = cooperative_groups;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
__shared__ float block_acc[32];
int idx = blockIdx.y;
int t = blockIdx.x;
att += idx * T * T;
datt += idx * T * T;
dpreatt += idx * T * T;
const float* att_bth = att + t * T;
const float* datt_bth = datt + t * T;
float* dpreatt_bth = dpreatt + t * T;
if(warp.meta_group_rank() == 0) {
block_acc[warp.thread_rank()] = 0;
}
float local_sum = 0;
for(int t2 = block.thread_rank(); t2 <= t; t2 += BlockSize) {
local_sum += att_bth[t2] * datt_bth[t2];
}
block_acc[warp.meta_group_rank()] = cg::reduce(warp, local_sum, cg::plus<float>{});
block.sync();
local_sum = cg::reduce(warp, block_acc[warp.thread_rank()], cg::plus<float>{});
for (int t3 = block.thread_rank(); t3 <= t; t3 += BlockSize) {
float acc = att_bth[t3] * (datt_bth[t3] - local_sum);
dpreatt_bth[t3] = scale * acc;
}
}
// The slightly less pretty version of kernel 7. Adding in all the dirty tricks that can give us a few more percent
// - streaming memory access instructions
// - reordering blocks to prevent tail effect
// - multiple values of T per block
template<int BlockSize>
__global__ void softmax_autoregressive_backward_kernel8(float* dpreatt, const float* datt, const float* att,
int B, int T, int C, float scale) {
namespace cg = cooperative_groups;
constexpr int T_per_block = 4;
cg::thread_block block = cg::this_thread_block();
cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block);
__shared__ float block_acc[32];
int idx = blockIdx.y;
// go through blocks in reverse order, so the slowest block starts first
int t0 = T - 1 - T_per_block*blockIdx.x;
att += idx * T * T;
datt += idx * T * T;
dpreatt += idx * T * T;
if (warp.meta_group_rank() == 0) {
block_acc[warp.thread_rank()] = 0;
}
for(int to = 0; to < T_per_block; ++to) {
int t = t0 - to;
if(t < 0) return;
const float* att_bth = att + t * T;
const float* datt_bth = datt + t * T;
float* dpreatt_bth = dpreatt + t * T;
float local_sum = 0;
for (int t2 = block.thread_rank(); t2 <= t; t2 += BlockSize) {
local_sum += att_bth[t2] * datt_bth[t2];
}
block_acc[warp.meta_group_rank()] = cg::reduce(warp, local_sum, cg::plus<float>{});
block.sync();
local_sum = cg::reduce(warp, block_acc[warp.thread_rank()], cg::plus<float>{});
for (int t3 = block.thread_rank(); t3 <= t; t3 += BlockSize) {
// don't touch the cache. Some parts will still be here from the previous loop, and
// we want to exploit those.
float acc = __ldcs(att_bth + t3) * (__ldcs(datt_bth + t3) - local_sum);
__stcs(dpreatt_bth + t3, scale * acc);
}
}
}
// ----------------------------------------------------------------------------
// kernel launchers
// attention forward pass kernel
void attention_forward(float* out, float* vaccum, float* qkvr, float* preatt, float* att,
const float* inp,
int B, int T, int C, int NH,
const int block_size) {
// inp is (B, T, 3C) QKV
// preatt, att are (B, NH, T, T)
// output is (B, T, C)
int HS = C / NH; // head size
// permute and separate inp from (B, T, 3, NH, HS) to 3X (B, NH, T, HS)
float *q, *k, *v;
q = qkvr + 0 * B * T * C;
k = qkvr + 1 * B * T * C;
v = qkvr + 2 * B * T * C;
int total_threads = B * NH * T * HS;
int num_blocks = ceil_div(total_threads, block_size);
permute_kernel<<<num_blocks, block_size>>>(q, k, v, inp, B, T, NH, HS);
// batched matrix multiply with cuBLAS
const float alpha = 1.0f;
const float beta = 0.0f;
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_T, CUBLAS_OP_N,
T, T, HS,
&alpha,
k, HS, T * HS,
q, HS, T * HS,
&beta,
preatt, T, T * T,
B * NH));
// multiply all elements of preatt elementwise by scale
float scale = 1.0 / sqrtf(HS);
int softmax_block_size = 256;
int grid_size = ceil_div(B * NH * T * 32, softmax_block_size);
softmax_forward_kernel5<<<grid_size, softmax_block_size>>>(att, scale, preatt, B * NH, T);
// new approach: first cuBLAS another batched matmul
// vaccum = att @ v # (B, nh, T, T) @ (B, nh, T, hs) -> (B, nh, T, hs)
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_N, CUBLAS_OP_N,
HS, T, T,
&alpha,
v, HS, T * HS,
att, T, T * T,
&beta,
vaccum, HS, T * HS,
B * NH));
// now unpermute
// y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side
num_blocks = ceil_div(B * T * C, block_size);
unpermute_kernel<<<num_blocks, block_size>>>(vaccum, out, B, T, NH, HS);
}
void launch_softmax_1(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int num_blocks = ceil_div(T, block_size);
softmax_autoregressive_backward_kernel1<<<dim3(num_blocks, B*NH), block_size>>>(dpreatt, datt, att, B, T, C, NH);
}
void launch_softmax_2(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int num_blocks = ceil_div(T, block_size);
softmax_autoregressive_backward_kernel2<<<dim3(num_blocks, B*NH), block_size>>>(dpreatt, datt, att, B, T, C, NH);
}
void launch_softmax_3(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int num_blocks = ceil_div(32*T, block_size);
softmax_autoregressive_backward_kernel3<<<dim3(num_blocks, B*NH), block_size>>>(dpreatt, datt, att, B, T, C, NH);
}
void launch_softmax_4(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int num_blocks = ceil_div(32/8*T, block_size);
softmax_autoregressive_backward_kernel4<<<dim3(num_blocks, B*NH), block_size>>>(dpreatt, datt, att, B, T, C, NH);
}
void launch_softmax_5(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int num_blocks = ceil_div(32/8*T, block_size);
softmax_autoregressive_backward_kernel5<<<dim3(num_blocks, B*NH), block_size>>>(dpreatt, datt, att, B, T, C, NH);
}
template<class Launcher>
void dispatch_launch(Launcher&& launch, int block_size) {
switch(block_size) {
case 32:
return launch(std::integral_constant<int, 32>{});
case 64:
return launch(std::integral_constant<int, 64>{});
case 128:
return launch(std::integral_constant<int, 128>{});
case 256:
return launch(std::integral_constant<int, 256>{});
case 512:
return launch(std::integral_constant<int, 512>{});
case 1024:
return launch(std::integral_constant<int, 1024>{});
default:
assert(false && "Invalid block size");
}
}
void launch_softmax_6(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
auto launch = [&](auto int_const) {
softmax_autoregressive_backward_kernel6<int_const.value><<<dim3(T, B * NH), int_const.value>>>(dpreatt, datt, att, B, T, C, NH);
};
dispatch_launch(launch, block_size);
}
void launch_softmax_7(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
auto launch = [&](auto int_const) {
constexpr int block_size = int_const.value;
softmax_autoregressive_backward_kernel7<block_size><<<dim3(T, B * NH), block_size>>>
(dpreatt, datt, att, B, T, C, scale);
};
dispatch_launch(launch, block_size);
}
void launch_softmax_8(float* dpreatt, float* datt, const float* att, int B, int T, int C, int NH, int block_size) {
int hs = C / NH; // head size
float scale = 1.0f / sqrtf(hs);
auto launch = [&](auto int_const) {
constexpr int block_size = int_const.value;
softmax_autoregressive_backward_kernel8<block_size><<<dim3(T / 4, B * NH), block_size>>>
(dpreatt, datt, att, B, T, C, scale);
};
dispatch_launch(launch, block_size);
}
// the sequence of transformations in this compound op is:
// inp (B,T,3C) -> qkvr (B,T,3C) -> preatt (B,NH,T,T) -> att (B,NH,T,T) -> vaccum (B,T,C) -> out (B,T,C)
template<class SoftmaxKernel>
void attention_backward1(float* dinp, float* dqkvr, float* dpreatt, float* datt, float* dvaccum,
const float* dout,
const float* inp, const float* qkvr, const float* preatt, const float* att, const float* vaccum,
int B, int T, int C, int NH,
SoftmaxKernel softmax_autoregressive_backward,
const int block_size) {
int HS = C / NH; // head size
const float alpha = 1.0f;
const float beta = 1.0f; // note beta = 1.0f so that we accumulate gradients (+=)
// unpack convenience pointers into q, k, v
const float *q, *k, *v;
q = qkvr + 0 * B * T * C;
k = qkvr + 1 * B * T * C;
v = qkvr + 2 * B * T * C;
float *dq, *dk, *dv;
dq = dqkvr + 0 * B * T * C;
dk = dqkvr + 1 * B * T * C;
dv = dqkvr + 2 * B * T * C;
// backward through the unpermute operation
int num_blocks = ceil_div(B * T * C, block_size);
unpermute_kernel_backward<<<num_blocks, block_size>>>(dvaccum, dout, B, T, NH, HS);
cudaCheck(cudaGetLastError());
// backward into datt
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_T, CUBLAS_OP_N,
T, T, HS,
&alpha,
v, HS, T * HS,
dvaccum, HS, T * HS,
&beta,
datt, T, T * T,
B * NH));
// backward into dv
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_N, CUBLAS_OP_T,
HS, T, T,
&alpha,
dvaccum, HS, T * HS,
att, T, T * T,
&beta,
dv, HS, T * HS,
B * NH));
// backward into preatt
softmax_autoregressive_backward(dpreatt, datt, att, B, T, C, NH, block_size);
cudaCheck(cudaGetLastError());
// backward into q
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_N, CUBLAS_OP_N,
HS, T, T,
&alpha,
k, HS, T * HS,
dpreatt, T, T * T,
&beta,
dq, HS, T * HS,
B * NH));
// backward into k
cublasCheck(cublasSgemmStridedBatched(cublas_handle,
CUBLAS_OP_N, CUBLAS_OP_T,
HS, T, T,
&alpha,
q, HS, T * HS,
dpreatt, T, T * T,
&beta,
dk, HS, T * HS,
B * NH));
// backward into inp
num_blocks = ceil_div(B * NH * T * HS, block_size);
permute_kernel_backward<<<num_blocks, block_size>>>(dinp, dq, dk, dv, B, T, NH, HS);
cudaCheck(cudaGetLastError());
}
// kernel version dispatch
void attention_backward(int kernel_num,
float* dinp, float* dqkvr, float* dpreatt, float* datt, float* dvaccum,
const float* dout,
const float* inp, const float* qkvr, const float* preatt, const float* att, const float* vaccum,
int B, int T, int C, int NH,
const int block_size) {
switch (kernel_num) {
case 1:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_1, block_size);
break;
case 2:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_2, block_size);
break;
case 3:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_3, block_size);
break;
case 4:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_4, block_size);
break;
case 5:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_5, block_size);
break;
case 6:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_6, block_size);
break;
case 7:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_7, block_size);
break;
case 8:
attention_backward1(dinp, dqkvr, dpreatt, datt, dvaccum, dout, inp, qkvr, preatt, att, vaccum, B, T, C, NH,
launch_softmax_8, block_size);
break;
default:
printf("Invalid kernel number\n");
exit(1);
}
}
// ----------------------------------------------------------------------------
int main(int argc, char **argv) {
setup_main();
// hyperparameters
int B = 4;
int T = 1024;
int C = 768;
int NH = 12;
// read kernel_num from command line
int kernel_num = 1;
if (argc > 1) {
kernel_num = atoi(argv[1]);
}
printf("Using kernel %d\n", kernel_num);
// create the host memory for the forward pass
float* inp = make_random_float(B * T * 3 * C);
float* qkvr = (float*)malloc(B * T * 3 * C * sizeof(float));
float* preatt = (float*)malloc(B * NH * T * T * sizeof(float));
float* att = (float*)malloc(B * NH * T * T * sizeof(float));
float* vaccum = (float*)malloc(B * T * C * sizeof(float));
float* out = (float*)malloc(B * T * C * sizeof(float));
// execute the forward pass on the CPU
attention_forward_cpu(out, preatt, att, inp, B, T, C, NH);
// create device memory for the forward pass
float *d_inp, *d_qkvr, *d_preatt, *d_att, *d_vaccum, *d_out;
cudaCheck(cudaMalloc(&d_inp, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_qkvr, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_preatt, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMalloc(&d_att, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMalloc(&d_vaccum, B * T * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_out, B * T * C * sizeof(float)));
// copy over the input
cudaCheck(cudaMemcpy(d_inp, inp, B * T * 3 * C * sizeof(float), cudaMemcpyHostToDevice));
// execute the forward pass on the GPU
const int block_size = 256;
attention_forward(d_out, d_vaccum, d_qkvr, d_preatt, d_att, d_inp, B, T, C, NH, block_size);
// check that preatt, att, and out match between the CPU and GPU versions
printf("Checking the forward pass CPU <-> GPU...\n");
printf("[preatt]\n"); validate_result(d_preatt, preatt, "preatt", B * T * C, 5e-3f);
printf("[att]\n"); validate_result(d_att, att, "att", B * T * C, 1e-3f);
printf("[out]\n"); validate_result(d_out, out, "out", B * T * C, 1e-3f);
// set up the memory for the backward pass
float* dout = make_random_float(B * T * C); // the gradients on the output
float* dinp = make_zeros_float(B * T * 3 * C); // zeros for all else, to += into
float* dpreatt = make_zeros_float(B * NH * T * T);
float* datt = make_zeros_float(B * NH * T * T);
// call backward() on the CPU to get our reference gradients
attention_backward_cpu(dinp, dpreatt, datt, dout, inp, att, B, T, C, NH);
// create device memory for the backward pass
float *d_dinp, *d_dqkvr, *d_dpreatt, *d_datt, *d_dvaccum, *d_dout;
cudaCheck(cudaMalloc(&d_dinp, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_dqkvr, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_dpreatt, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMalloc(&d_datt, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMalloc(&d_dvaccum, B * T * C * sizeof(float)));
cudaCheck(cudaMalloc(&d_dout, B * T * C * sizeof(float)));
// copy over the dout gradients that starts the backprop chain
cudaCheck(cudaMemcpy(d_dout, dout, B * T * C * sizeof(float), cudaMemcpyHostToDevice));
// memset all the other memory to zeros, to += into
cudaCheck(cudaMemset(d_dinp, 0, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMemset(d_dqkvr, 0, B * T * 3 * C * sizeof(float)));
cudaCheck(cudaMemset(d_dpreatt, 0, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMemset(d_datt, 0, B * NH * T * T * sizeof(float)));
cudaCheck(cudaMemset(d_dvaccum, 0, B * T * C * sizeof(float)));
// call backward() on the GPU
attention_backward(kernel_num, d_dinp, d_dqkvr, d_dpreatt, d_datt, d_dvaccum,
d_dout, d_inp, d_qkvr, d_preatt, d_att, d_vaccum,
B, T, C, NH, block_size);
// check that the gradients match between the CPU and GPU versions
// note that we will only check the correctness at [att, preatt, inp]
// the gradients at qkvr and vaccum will remain unchecked, but are
// assumed to be correct if the other gradients are correct
printf("Checking the backward pass CPU <-> GPU...\n");
printf("[datt]\n"); validate_result(d_datt, datt, "datt", B * NH * T * T, 5e-3f);
printf("[dpreatt]\n"); validate_result(d_dpreatt, dpreatt, "dpreatt", B * NH * T * T, 1e-3f);
printf("[dinp]\n"); validate_result(d_dinp, dinp, "dinp", B * T * 3 * C, 1e-3f);
// also let's manually step through the gradients here
float* h_dinp = (float*)malloc(B * T * 3 * C * sizeof(float));
cudaCheck(cudaMemcpy(h_dinp, d_dinp, B * T * 3 * C * sizeof(float), cudaMemcpyDeviceToHost));
int num_match = 0;
int num_no_match = 0;
int num_zero_grad = 0;
int HS = C / NH;
for (int i = 0; i < B * T * 3 * C; i++) {
// the dimensions of inp are (B, T, 3, NH, HS)
// where B = batch, T = time, 3 = qkv, NH = num heads, HS = head size
// unpack the individual b,t,qkvix,h,c indices
int ix = i;
int c = ix % HS;
ix /= HS;
int h = ix % NH;
ix /= NH;
int qkvix = ix % 3;
ix /= 3;
int t = ix % T;
ix /= T;
int b = ix;
float diff = fabs(dinp[i] - h_dinp[i]);
// attempt to index at random
if (b == 1 && t == 5 && c == 23 && h == 2) {
printf("ix %5d [b=%4d, t=%4d, qkv=%4d, nh=%4d, hs=%4d]: ref: %f gpu: %f\n", i, b, t, qkvix, h, c, dinp[i], h_dinp[i]);
}
if (diff > 1e-4f) {
num_no_match++;
} else {
num_match++;
}
if (dinp[i] == 0.0f) {
num_zero_grad++;
}
}
printf("Number of matching gradients: %d (%.2f%% of total)\n", num_match, 100*(float)num_match / (B * T * 3 * C));
printf("Number of non-matching gradients: %d (%.2f%% of total)\n", num_no_match, 100*(float)num_no_match / (B * T * 3 * C));
printf("Number of gradients that are exactly zero: %d (%.2f%% of total)\n", num_zero_grad, 100*(float)num_zero_grad / (B * T * 3 * C));
// final verdict
printf("All results match. Starting benchmarks.\n\n");
// benchmark speed of the kernel
int block_sizes[] = {32, 64, 128, 256, 512, 1024};
for (int j = 0; j < sizeof(block_sizes) / sizeof(int); j++) {
int block_size = block_sizes[j];
int repeat_times = 10;
float elapsed_time = benchmark_kernel(repeat_times, attention_backward,
kernel_num, d_dinp, d_dqkvr, d_dpreatt, d_datt, d_dvaccum,
d_dout, d_inp, d_qkvr, d_preatt, d_att, d_vaccum,
B, T, C, NH, block_size);
printf("block_size %4d | time %f ms\n", block_size, elapsed_time);
}
// free memory
free(inp);
free(qkvr);
free(preatt);
free(att);
free(vaccum);
free(out);
free(dout);
free(dinp);
free(dpreatt);
free(datt);
free(h_dinp);
cudaCheck(cudaFree(d_inp));
cudaCheck(cudaFree(d_qkvr));
cudaCheck(cudaFree(d_preatt));
cudaCheck(cudaFree(d_att));
cudaCheck(cudaFree(d_vaccum));
cudaCheck(cudaFree(d_out));
cudaCheck(cudaFree(d_dinp));
cudaCheck(cudaFree(d_dqkvr));
cudaCheck(cudaFree(d_dpreatt));
cudaCheck(cudaFree(d_datt));
cudaCheck(cudaFree(d_dvaccum));
cudaCheck(cudaFree(d_dout));
cublasDestroy(cublas_handle);
return 0;
}