/* Kernels for attention forward pass. If you do not have CUDNN, you can remove ENABLE_CUDNN to run the other kernels See the README for cuDNN install instructions Compile example with cuDNN: nvcc -I/PATH/TO/cudnn-frontend/include -DENABLE_CUDNN -O3 --use_fast_math --lcublas -lcublasLt -lcudnn attention_forward.cu -o attention_forward Compile example without cuDNN: nvcc -O3 --use_fast_math -lcublas -lcublasLt attention_forward.cu -o attention_forward version 1 is naive port from CPU code to kernel, parallelize over batch, time, heads only ./attention_forward 1 version 2 is a naive implementation of flash attention, taken, adapted from https://github.com/tspeterkim/flash-attention-minimal and with help from https://github.com/leloykun/flash-hyperbolic-attention-minimal sadly, this flash attention version seems about 3X slower than the naive version ./attention_forward 2 version 3 is a cuBLAS + softmax version, similar to the PyTorch implementation cuBLAS is used both to calculate the QK^T and the final weighted sum the softmax is calculated using a custom, efficient kernel as well this turns out to be ~20X faster than (1) nice ./attention_forward 3 version 4 is a further optimized kernel that fuses the scale operation, uses a directly autoregressive softmax, and uses the online softmax algorithm. ./attention_forward 4 version 5 is a FP16 version of kernel 4 ./attention_forward 5 version 6 is kernel 5 skipping (un)permute (unrealistic but useful comparison point) version 10 is using cuDNN Flash Attention using FP16 or BF16, see: https://github.com/NVIDIA/cudnn-frontend/blob/main/docs/operations/Attention.md ./attention_forward 10 version 11 is kernel 10 skipping FP16/FP32 conversions (full FP16/BF16 network) ./attention_forward 11 */ //#define ENABLE_CUDNN // can be enabled via nvcc "-DENABLE_CUDNN" #include #include #include #include #include #include #include #include #include #define ENABLE_BF16 #include "common.h" // ---------------------------------------------------------------------------- // CUDA & cuDNN setup static bool first_run_validation = true; // always run e.g. permute on 1st run #ifdef ENABLE_CUDNN #include namespace fe = cudnn_frontend; #if CUBLAS_LOWP == CUDA_R_16BF #define CUDNN_16BIT fe::DataType_t::BFLOAT16 #else #define CUDNN_16BIT fe::DataType_t::HALF #endif static cudnnHandle_t cudnn_handle; static size_t cudnn_workspace_size = 0; // dynamically allocated as needed (up to 256MiB!) static void* cudnn_workspace = NULL; #define checkCudaErr(err) assert((int)err == 0); #define checkCudnnErr(err) assert((int)err == 0); #endif // ENABLE_CUDNN // ---------------------------------------------------------------------------- // CPU code reference void attention_forward_cpu(float* out, float* preatt, float* att, const float* inp, int B, int T, int C, int NH) { // input is (B, T, 3C) Q,K,V // preatt, att are (B, NH, T, T) // output 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++) { const 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++) { const 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]; } val *= scale; if (val > maxval) { maxval = val; } preatt_bth[t2] = val; } // pad with -INFINITY outside of autoregressive region for debugging comparisons for (int t2 = t+1; t2 < T; t2++) { preatt_bth[t2] = -INFINITY; } // pass 2: calculate the exp and keep track of sum float expsum = 0.0f; for (int t2 = 0; t2 <= t; t2++) { float expv = expf(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++) { const 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]; } } } } } } // ---------------------------------------------------------------------------- // GPU kernels __global__ void attention_query_key_kernel1(float* preatt, const float* inp, int B, int T, int C, int NH) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int total_threads = B * NH * T * T; if (idx < total_threads) { int t2 = idx % T; int t = (idx / T) % T; if (t2 > t) { // autoregressive mask preatt[idx] = -INFINITY; return; } int h = (idx / (T * T)) % NH; int b = idx / (NH * T * T); int C3 = C*3; int hs = C / NH; // head size const float* query_t = inp + b * T * C3 + t * C3 + h * hs; const 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]; } val *= 1.0 / sqrtf(hs); preatt[idx] = val; } } __global__ void attention_softmax_kernel1(float* att, const float* preatt, int B, int T, int NH) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int total_threads = B * T * NH; if (idx < total_threads) { int h = idx % NH; int t = (idx / NH) % T; int b = idx / (NH * T); const 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; // find maxval float maxval = -FLT_MAX; for (int t2 = 0; t2 <= t; t2++) { if (preatt_bth[t2] > maxval) { maxval = preatt_bth[t2]; } } // calculate the exp and keep track of sum float expsum = 0.0f; for (int t2 = 0; t2 <= t; t2++) { float expv = expf(preatt_bth[t2] - maxval); expsum += expv; att_bth[t2] = expv; } float expsum_inv = expsum == 0.0f ? 0.0f : 1.0f / expsum; // 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; } } } } // warp-level reduction for finding the maximum value __device__ float warpReduceMax(float val) { for (int offset = 16; offset > 0; offset /= 2) { val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset)); } return val; } __global__ void softmax_forward_kernel4(float* out, const float* inp, int N, int C) { // out is (N, C) just like inp. Each row of inp will get softmaxed. // same as kernel3, but can handle any block size (multiple of 32) // each row of C elements is handled by block_size threads // furthermore, each block_size threads get executed in warps of 32 threads // special reduction operations warpReduceMax/warpReduceSum are used for intra-warp reductions // shared memory is used for inter-warp reduction extern __shared__ float shared[]; int idx = blockIdx.x; int tid = threadIdx.x; int warpId = threadIdx.x / 32; // warp index within a block int laneId = threadIdx.x % 32; // thread index within a warp // the number of warps per block. recall that blockDim.x is block_size int warpsPerBlock = blockDim.x / 32; // shared[] must be allocated to have 2 * warpsPerBlock elements // first half for max values, the second half for sum values float* maxvals = shared; float* sumvals = &shared[warpsPerBlock]; // one row of inp, i.e. inp[idx, :] of shape (C,) const float* x = inp + idx * C; // first, thread coarsening by directly accessing global memory in series float maxval = -INFINITY; for (int i = tid; i < C; i += blockDim.x) { maxval = fmaxf(maxval, x[i]); } // now within-warp reductions for maxval maxval = warpReduceMax(maxval); // the 0th thread of each warp writes the maxval of that warp to shared memory if (laneId == 0) maxvals[warpId] = maxval; __syncthreads(); // now the 0th thread reduces the maxvals in shared memory, i.e. across warps if (tid == 0) { float val = maxvals[tid]; for (int i = 1; i < warpsPerBlock; i++) { val = fmaxf(val, maxvals[i]); } // store the final max in the first position maxvals[0] = val; } __syncthreads(); // broadcast the max to all threads float offset = maxvals[0]; // compute expf and write the result to global memory for (int i = tid; i < C; i += blockDim.x) { // subtract max for numerical stability out[idx * C + i] = expf(x[i] - offset); } // okay now we calculated exp(x - max(x)) // step 2: sum all the values and divide by the sum // thread coarsening for sum x = out + idx * C; float sumval = 0.0f; for (int i = tid; i < C; i += blockDim.x) { sumval += x[i]; } // within-warp reduction for sumval sumval = warpReduceSum(sumval); // write sumval to shared memory if (laneId == 0) sumvals[warpId] = sumval; __syncthreads(); // inter-thread reduction of sum if (tid == 0) { float val = sumvals[tid]; for (int i = 1; i < warpsPerBlock; ++i) { val += sumvals[i]; } sumvals[0] = val; } __syncthreads(); // broadcast the sum to all threads float sum = sumvals[0]; // divide the whole row by the sum for (int i = tid; i < C; i += blockDim.x) { out[idx * C + i] = x[i] / sum; } } __device__ float& vec_at(float4& vec, int index) { return reinterpret_cast(&vec)[index]; } __device__ float vec_at(const float4& vec, int index) { return reinterpret_cast(&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(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{}); sumval *= expf(inv_temperature * (maxval - global_maxval)); float sum = cg::reduce(warp, sumval, cg::plus{}); 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); } } __global__ void attention_value_kernel1(float* out, const float* att, const float* inp, int B, int T, int C, int NH) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int total_threads = B * T * NH; if (idx < total_threads) { int h = idx % NH; int t = (idx / NH) % T; int b = idx / (NH * T); int C3 = C*3; int hs = C / NH; // head size float* out_bth = out + b * T * C + t * C + h * hs; const float* att_bth = att + b*NH*T*T + h*T*T + t*T; for (int i = 0; i < hs; i++) { out_bth[i] = 0.0f; } for (int t2 = 0; t2 <= t; t2++) { const 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]; } } } } __global__ void attention_forward_kernel2( const float* Q, const float* K, const float* V, const int N, const int d, const int Tc, const int Tr, const int Bc, const int Br, const float softmax_scale, float* l, float* m, float* O ) { int tx = threadIdx.x; int bx = blockIdx.x; int by = blockIdx.y; // batch and head index // Offset into Q,K,V,O,l,m - different for each batch and head int qkv_offset = (bx * gridDim.y * N * d) + (by * N * d); // gridDim.y = nh int lm_offset = (bx * gridDim.y * N) + (by * N); // offset for l and m // Define SRAM for Q,K,V,S extern __shared__ float sram[]; int tile_size = Bc * d; // size of Qi, Kj, Vj float* Qi = sram; float* Kj = &sram[tile_size]; float* Vj = &sram[tile_size * 2]; float* S = &sram[tile_size * 3]; for (int j = 0; j < Tc; j++) { // Load Kj, Vj to SRAM for (int x = 0; x < d; x++) { Kj[(tx * d) + x] = K[qkv_offset + (tile_size * j) + (tx * d) + x]; Vj[(tx * d) + x] = V[qkv_offset + (tile_size * j) + (tx * d) + x]; } __syncthreads(); // such that the inner loop can use the correct Kj, Vj for (int i = 0; i < Tr; i++) { // if past the end of the sequence, break if (i * Br + tx >= N) { break; } // Load Qi to SRAM, l and m to registers for (int x = 0; x < d; x++) { Qi[(tx * d) + x] = Q[qkv_offset + (tile_size * i) + (tx * d) + x]; } float row_m_prev = m[lm_offset + (Br * i) + tx]; float row_l_prev = l[lm_offset + (Br * i) + tx]; // S = QK^T, row_m = rowmax(S) // S[tx][y] = Sum_{x = 0}^{d-1} {Qi[tx][x] * Kj[y][x]} // row_m = Max_{y = 0}^{Bc-1} S[tx][y] // with causal masking float row_m = -INFINITY; for (int y = 0; y < Bc; y++) { if (j * Bc + y >= N) { break; } float sum = 0; for (int x = 0; x < d; x++) { sum += Qi[(tx * d) + x] * Kj[(y * d) + x]; } sum *= softmax_scale; if (i * Br + tx < j * Bc + y) sum = -INFINITY; S[(Bc * tx) + y] = sum; if (sum > row_m) row_m = sum; } // implement softmax with causal masking // P = exp(S - row_m), row_l = rowsum(P) // P[tx][y] = exp(S[tx][y] - row_m) float row_l = 0; for (int y = 0; y < Bc; y++) { if (j * Bc + y >= N) { break; } if (i * Br + tx < j * Bc + y) S[(Bc * tx) + y] = 0; else S[(Bc * tx) + y] = __expf(S[(Bc * tx) + y] - row_m); row_l += S[(Bc * tx) + y]; } // Compute new m and l float row_m_new = max(row_m_prev, row_m); float row_l_new = (__expf(row_m_prev - row_m_new) * row_l_prev) + (__expf(row_m - row_m_new) * row_l); // Write O, l, m to HBM for (int x = 0; x < d; x++) { float pv = 0; // Pij * Vj for (int y = 0; y < Bc; y++) { if (j * Bc + y >= N) { break; } pv += S[(Bc * tx) + y] * Vj[(y * d) + x]; } O[qkv_offset + (tile_size * i) + (tx * d) + x] = (1 / row_l_new) \ * ((row_l_prev * __expf(row_m_prev - row_m_new) * O[qkv_offset + (tile_size * i) + (tx * d) + x]) \ + (__expf(row_m - row_m_new) * pv)); } m[lm_offset + (Br * i) + tx] = row_m_new; l[lm_offset + (Br * i) + tx] = row_l_new; } __syncthreads(); // otherwise, thread can use the wrong Kj, Vj in inner loop } } __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 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 scale_kernel(float* inp, float scale, int B, int NH, int T) { // scales the pre-softmax attention scores by scale // and sets the autoregressive locations to -INFINITY int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < B * NH * T * T) { int rest = idx % (NH * T * T); rest = rest % (T * T); int t2 = rest / T; int t = rest % T; if (t > t2) { inp[idx] = -INFINITY; } else { inp[idx] *= scale; } } } // direct translation of the CPU kernel. Each warp handles ont (b, h, t) combination. // The important changes compared to the CPU version: // - each inner loop is handled by a warp // - don't write non-autoregressive parts // - reordered the last loops so that we can do all writing in the outer loop. __global__ void attention_forward_fused1(float* out, float* preatt, float* att, const float* inp, int B, int T, int C, int NH) { // input is (B, T, 3C) Q,K,V // preatt, att are (B, NH, T, T) // output is (B, T, C) int C3 = C*3; int hs = C / NH; // head size float scale = 1.0 / sqrtf(hs); namespace cg = cooperative_groups; cg::thread_block block = cg::this_thread_block(); cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block); int t = blockIdx.x * warp.meta_group_size() + warp.meta_group_rank(); int h = blockIdx.y; int b = blockIdx.z; if(t >= T) return; const 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 = -INFINITY; for (int t2 = 0; t2 <= t; t2++) { const 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 = warp.thread_rank(); i < hs; i += warp.size()) { val += query_t[i] * key_t2[i]; } val = cg::reduce(warp, val, cg::plus{}); val *= scale; maxval = max(maxval, val); if(warp.thread_rank() == 0) { preatt_bth[t2] = val; } } // pass 2: calculate the exp and keep track of sum float expsum = 0.0f; for (int t2 = warp.thread_rank(); t2 <= t; t2 += warp.size()) { float expv = expf(preatt_bth[t2] - maxval); expsum += expv; } expsum = cg::reduce(warp, expsum, cg::plus{}); float expsum_inv = expsum == 0.0f ? 0.0f : 1.0f / expsum; // pass 3: normalize to get the softmax is combined with the next loop to reduce memory round-trips for (int t2 = warp.thread_rank(); t2 <= t; t2 += warp.size()) { att_bth[t2] = expf(preatt_bth[t2] - maxval) * expsum_inv; } // pass 4: accumulate weighted values into the output of attention float* out_bth = out + b * T * C + t * C + h * hs; for (int i = warp.thread_rank(); i < hs; i += warp.size()) { float o = 0.f; for (int t2 = 0; t2 <= t; t2++) { const 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]; o += att_btht2 * value_t2[i]; } out_bth[i] = o; } } // ---------------------------------------------------------------------------- // kernel launcher void attention_forward1(float* out, float* preatt, float* att, const float* inp, int B, int T, int C, int NH, const int block_size) { // attention calculation int total_threads = B * NH * T * T; int num_blocks = ceil_div(total_threads, block_size); attention_query_key_kernel1<<>>(preatt, inp, B, T, C, NH); // softmax and value accumulation total_threads = B * T * NH; num_blocks = ceil_div(total_threads, block_size); attention_softmax_kernel1<<>>(att, preatt, B, T, NH); attention_value_kernel1<<>>(out, att, inp, B, T, C, NH); } void attention_forward2(float* out, const float* inp, int B, int T, int C, int NH, const int block_size) { // TODO there should be no mallocs inside any of these functions! // not fixing this because we don't intend to use attention_forward2, // it seems to be way too slow as is // these are hardcoded to 32 for now const int Bc = 32; const int Br = 32; // renaming these to be consistent with the kernel // const int B = B; const int nh = NH; const int N = T; const int d = C / NH; // more const int Tc = ceil((float) N / Bc); const int Tr = ceil((float) N / Br); const float softmax_scale = 1.0 / sqrt(d); // create some temporary memory float* l; float* m; cudaCheck(cudaMalloc(&l, B * nh * N * sizeof(float))); cudaCheck(cudaMalloc(&m, B * nh * N * sizeof(float))); cudaCheck(cudaMemset(l, 0, B * nh * N * sizeof(float))); cudaCheck(cudaMemset(m, -10000.0f, B * nh * N * sizeof(float))); // calculate SRAM size needed per block, ensure we have enough shared memory int col_tile_size = Bc * d; // size of Kj, Vj int row_tile_size = Br * d; // size of Qi const int sram_size = (2 * col_tile_size * sizeof(float)) // SRAM size for Kj, Vj + (row_tile_size * sizeof(float)) // SRAM size for Qi + (Bc * Br * sizeof(float)); // SRAM size for S int max_sram_size; cudaDeviceGetAttribute(&max_sram_size, cudaDevAttrMaxSharedMemoryPerBlock, 0); if (sram_size > max_sram_size) { printf("Max shared memory: %d, requested shared memory: %d \n", max_sram_size, sram_size); printf("SRAM size exceeds maximum shared memory per block\n"); printf("Try decreasing col_tile_size or row_tile_size further\n"); exit(1); } // grid and block dims dim3 grid_dim(B, nh); // batch_size x num_heads dim3 block_dim(Br); // Br threads per block // 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) // so we have to permute the tensor using a kernel with block_size float *q, *k, *v; cudaCheck(cudaMalloc(&q, B * T * C * sizeof(float))); cudaCheck(cudaMalloc(&k, B * T * C * sizeof(float))); cudaCheck(cudaMalloc(&v, B * T * C * sizeof(float))); int total_threads = B * N * nh * d; int num_blocks = ceil_div(total_threads, block_size); permute_kernel<<>>(q, k, v, inp, B, N, nh, d); // now actually call the flash attention kernel attention_forward_kernel2<<>>( q, k, v, N, d, Tc, Tr, Bc, Br, softmax_scale, l, m, out ); // out has shape (B, nh, N, d) but we need to unpermute it to (B, N, nh, d) unpermute_kernel<<>>(out, q, B, N, nh, d); cudaCheck(cudaMemcpy(out, q, B * T * C * sizeof(float), cudaMemcpyDeviceToDevice)); // free memory cudaCheck(cudaFree(l)); cudaCheck(cudaFree(m)); cudaCheck(cudaFree(q)); cudaCheck(cudaFree(k)); cudaCheck(cudaFree(v)); } void attention_forward3(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<<>>(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.0f / sqrtf(HS); total_threads = B * NH * T * T; num_blocks = ceil_div(total_threads, block_size); scale_kernel<<>>(preatt, scale, B, NH, T); // softmax. preatt is (B, NH, T, T) but we view it as (B * NH * T, T) and use the softmax kernel int softmax_block_size = 256; int grid_size = B * NH * T; size_t shared_mem_size = 2 * softmax_block_size / 32 * sizeof(float); softmax_forward_kernel4<<>>(att, preatt, B * NH * T, T); // new approach: first cuBLAS another batched matmul // y = 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<<>>(vaccum, out, B, T, NH, HS); } void attention_forward4(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<<>>(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<<>>(att, scale, preatt, B * NH, T); // new approach: first cuBLAS another batched matmul // y = 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<<>>(vaccum, out, B, T, NH, HS); } __global__ void softmax_forward_kernel5_lowp(floatX* out, float inv_temperature, const floatX* 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 floatX* 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; // Same thing but without float4, one at a time for (int i = warp.thread_rank(); i < pos_by_4; i += warp.size()) { float old_maxval = maxval; for(int k = 0; k < 4; ++k) { maxval = fmaxf(maxval, (float)x[4*i + k]); } sumval *= expf(inv_temperature * (old_maxval - maxval)); for(int k = 0; k < 4; ++k) { sumval += expf(inv_temperature * ((float)x[4*i + k] - maxval)); } } if(4*pos_by_4 + warp.thread_rank() <= own_pos) { float old_maxval = maxval; maxval = fmaxf(maxval, (float)x[4*pos_by_4 + warp.thread_rank()]); sumval *= expf(inv_temperature * (old_maxval - maxval)); sumval += expf(inv_temperature * ((float)x[4*pos_by_4 + warp.thread_rank()] - maxval)); } float global_maxval = cg::reduce(warp, maxval, cg::greater{}); sumval *= expf(inv_temperature * (maxval - global_maxval)); float sum = cg::reduce(warp, sumval, cg::plus{}); 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 * ((float)__ldcs(x + i) - global_maxval)); __stcs(out + idx * T + i, (floatX)(ev * norm)); } } __global__ void permute_kernel_lowp(floatX* q, floatX* k, floatX* 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] = (floatX)inp[inp_idx]; k[idx] = (floatX)inp[inp_idx + NH * d]; v[idx] = (floatX)inp[inp_idx + 2 * (NH * d)]; } } __global__ void unpermute_kernel_lowp(const floatX* 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] = (float)inp[idx]; } } void attention_forward5(float* out, floatX* vaccum, floatX* qkvr, floatX* preatt, floatX* att, const float* inp, int B, int T, int C, int NH, const int block_size, bool skip_permute=false) { // FP16 version of kernel 4 (with permute/unpermute doing FP32<->FP16) // That permute can be skipped on perf runs to analyse its performance impact // inp is (B, T, 3C) QKV // preatt, att are (B, NH, T, T) // output is (B, T, C) // permute and separate inp from (B, T, 3, NH, HS) to 3X (B, NH, T, HS) int HS = C / NH; // head size floatX *q = qkvr + 0 * B * T * C; floatX *k = qkvr + 1 * B * T * C; floatX* v = qkvr + 2 * B * T * C; int total_threads = B * NH * T * HS; int num_blocks = ceil_div(total_threads, block_size); if (!skip_permute || first_run_validation) { permute_kernel_lowp<<>>(q, k, v, inp, B, T, NH, HS); } // IMPORTANT: alpha/beta are FP32 for CUBLAS_COMPUTE_32F even if FP16 inputs/outputs // But need FP16 scale for CUBLAS_COMPUTE_16F (no errors otherwise, just garbage results *sigh*) const float alpha = 1.0f; const float beta = 0.0f; const floatX alpha_lowp = (floatX)alpha; const floatX beta_lowp = (floatX)beta; void* alpha_ptr = CUBLAS_LOWP_COMPUTE == CUBLAS_COMPUTE_16F ? (void*)&alpha_lowp : (void*)α void* beta_ptr = CUBLAS_LOWP_COMPUTE == CUBLAS_COMPUTE_16F ? (void*)&beta_lowp : (void*)β // batched matrix multiply with cuBLAS cublasCheck(cublasGemmStridedBatchedEx(cublas_handle, CUBLAS_OP_T, CUBLAS_OP_N, T, T, HS, alpha_ptr, k, CUBLAS_LOWP, HS, T * HS, q, CUBLAS_LOWP, HS, T * HS, beta_ptr, preatt, CUBLAS_LOWP, T, T * T, B * NH, CUBLAS_LOWP_COMPUTE, CUBLAS_GEMM_DEFAULT)); // multiply all elements of preatt elementwise by scale float scale = 1.0f / sqrtf(HS); int softmax_block_size = 256; int grid_size = ceil_div(B * NH * T * 32, softmax_block_size); softmax_forward_kernel5_lowp<<>>(att, scale, preatt, B * NH, T); // new approach: first cuBLAS another batched matmul // y = att @ v # (B, nh, T, T) @ (B, nh, T, hs) -> (B, nh, T, hs) cublasCheck(cublasGemmStridedBatchedEx(cublas_handle, CUBLAS_OP_N, CUBLAS_OP_N, HS, T, T, alpha_ptr, v, CUBLAS_LOWP, HS, T * HS, att, CUBLAS_LOWP, T, T * T, beta_ptr, vaccum, CUBLAS_LOWP, HS, T * HS, B * NH, CUBLAS_LOWP_COMPUTE, CUBLAS_GEMM_DEFAULT)); // 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); if(!skip_permute || first_run_validation) { unpermute_kernel_lowp<<>>(vaccum, out, B, T, NH, HS); } } #ifdef ENABLE_CUDNN using graph_tensors_fwd = std::tuple, std::shared_ptr, // Q, std::shared_ptr, // K, std::shared_ptr, // V, std::shared_ptr, // Attn_scale, std::shared_ptr, // O std::shared_ptr>; // Stats // Need a cache because graph->build_operation_graph() is slow but everything else seems fast using cache_type_fwd = std::unordered_map; // Loosely based on cuDNN frontend samples functions and massively simplified template auto lookup_cache_or_build_graph_fwd(Args... args) { static cache_type_fwd user_maintained_cache_fwd; auto [B, H, T, HS, is_inference_only] = std::make_tuple(args...); auto graph = std::make_shared(); graph->set_io_data_type(CUDNN_16BIT) .set_intermediate_data_type(fe::DataType_t::FLOAT) .set_compute_data_type(fe::DataType_t::FLOAT); // QKV is (B, T, 3, NH, HS) which cuDNN can handle directly without an external permute auto Q = graph->tensor(fe::graph::Tensor_attributes() .set_name("Q") .set_dim({B, H, T, HS}) .set_stride({3 * H * HS * T, HS, 3 * H * HS, 1})); auto K = graph->tensor(fe::graph::Tensor_attributes() .set_name("K") .set_dim({B, H, T, HS}) .set_stride({3 * H * HS * T, HS, 3 * H * HS, 1})); auto V = graph->tensor(fe::graph::Tensor_attributes() .set_name("V") .set_dim({B, H, T, HS}) .set_stride({3 * H * HS * T, HS, 3 * H * HS, 1})); auto attn_scale = graph->tensor(fe::graph::Tensor_attributes() .set_name("attn_scale") .set_dim({1, 1, 1, 1}) .set_stride({1, 1, 1, 1}) .set_is_pass_by_value(true) .set_data_type(fe::DataType_t::FLOAT)); auto sdpa_options = fe::graph::SDPA_attributes().set_name("flash_attention"); sdpa_options.set_is_inference(is_inference_only); sdpa_options.set_attn_scale(attn_scale); sdpa_options.set_causal_mask(true); // Create the graph operation and get the output tensors back auto [O, stats] = graph->sdpa(Q, K, V, sdpa_options); // Output is (B, T, NH, HS) BF16/FP16 and stats for backward pass is (B, NH, T) FP32 O->set_output(true).set_dim({B, H, T, HS}).set_stride({H * HS * T, HS, H * HS, 1}); assert(stats == nullptr || is_inference_only == false); if (is_inference_only == false) { stats->set_output(true).set_data_type(fe::DataType_t::FLOAT) .set_dim({B, H, T, 1}) .set_stride({H * T, T, 1, 1}); } assert(graph->validate().is_good()); auto key = graph->key(); auto it = user_maintained_cache_fwd.find(key); if (it != user_maintained_cache_fwd.end()) { return it->second; } // Build the operation graph and execution part (this is the VERY SLOW PART) assert(graph->build_operation_graph(cudnn_handle).is_good()); auto plans = graph->create_execution_plans({fe::HeurMode_t::A}); assert(graph->check_support(cudnn_handle).is_good()); assert(graph->build_plans(cudnn_handle).is_good()); auto tuple = std::make_tuple(graph, Q, K, V, attn_scale, O, stats); user_maintained_cache_fwd.insert({key, tuple}); return tuple; } // Used on first run only so we can validate against the CPU results __global__ void fp32_to_lowp_kernel(floatX* out, const float* inp) { int idx = blockIdx.x * blockDim.x + threadIdx.x; out[idx] = (floatX)inp[idx]; } __global__ void lowp_to_fp32_kernel(const floatX* inp, float *out) { int idx = blockIdx.x * blockDim.x + threadIdx.x; out[idx] = (float)inp[idx]; } void attention_forward_cudnn(floatX* out, // output: (B, T, NH, HS) float* stats, // output for backward pass: (B, NH, T) floatX* inp, // input: (B, T, 3, NH, HS) QKV float* in_fp32, // fp32 input float* out_fp32, // fp32 output for validation int B, int T, int C, int NH) { static bool first_run_validation = true; int HS = C / NH; // number of features per head bool is_inference_only = (stats == nullptr); // Convert from FP32 to FP16/BF16 on 1st run to get correct results const int block_size = 64; // smallest full occupancy block size on modern GPUs if (first_run_validation) { int total_threads = B * T * C * 3; assert(total_threads % block_size == 0); int num_blocks = total_threads / block_size; fp32_to_lowp_kernel<<>>(inp, in_fp32); } // Get graph and tensors from cache (or generate it on first use) auto [graph, Q, K, V, attn_scale, O, softmax_stats] = lookup_cache_or_build_graph_fwd(B, NH, T, HS, is_inference_only); // Prepare all the tensor pointers for executing the graph void* devPtrQ = inp; void* devPtrK = (inp + C); void* devPtrV = (inp + 2 * C); float attn_scale_cpu = 1.0 / sqrtf(HS); void* devPtrO = out; // Build variant pack std::unordered_map, void*> variant_pack = { {Q, devPtrQ}, {K, devPtrK}, {V, devPtrV}, {attn_scale, &attn_scale_cpu}, {O, devPtrO}}; // Add the stats tensor unless we are only doing inference (only needed for backward pass) if (is_inference_only == false) { variant_pack[softmax_stats] = stats; } // Reallocate the workspace if the required size is greater than the current workspace // By default, cuDNN uses up to 256MiB of workspace, so we don't want to just allocate the maximum if (graph->get_workspace_size() > cudnn_workspace_size) { if (cudnn_workspace_size > 0) { cudaCheck(cudaFree(cudnn_workspace)); } cudnn_workspace_size = graph->get_workspace_size(); cudaCheck(cudaMalloc(&cudnn_workspace, cudnn_workspace_size)); } // Execute graph assert(graph->execute(cudnn_handle, variant_pack, cudnn_workspace).is_good()); cudaCheck(cudaGetLastError()); // Optionally convert back from FP16/BF16 to FP32 if (first_run_validation) { int total_threads = B * T * C; assert(total_threads % block_size == 0); int num_blocks = total_threads / block_size; lowp_to_fp32_kernel<<>>(out, out_fp32); } cudaCheck(cudaGetLastError()); first_run_validation = false; } #endif // ENABLE_CUDNN // kernel version dispatch void attention_forward(int kernel_num, float* out, float* stats, float* vaccum, float* qkvr, float* preatt, float* att, float* inp, int B, int T, int C, int NH, const int block_size) { switch (kernel_num) { case 1: attention_forward1(out, preatt, att, inp, B, T, C, NH, block_size); break; case 2: attention_forward2(out, inp, B, T, C, NH, block_size); break; case 3: attention_forward3(out, vaccum, qkvr, preatt, att, inp, B, T, C, NH, block_size); break; case 4: attention_forward4(out, vaccum, qkvr, preatt, att, inp, B, T, C, NH, block_size); break; case 5: attention_forward5(out, (floatX*)vaccum, (floatX*)qkvr, (floatX*)preatt, (floatX*)att, inp, B, T, C, NH, block_size, false); break; case 6: // skip permutes for perf passes (to analyse perf as if in/out were truly 16-bit) attention_forward5(out, (floatX*)vaccum, (floatX*)qkvr, (floatX*)preatt, (floatX*)att, inp, B, T, C, NH, block_size, true); break; #ifdef ENABLE_CUDNN case 10: // note: validation only cares about out, which is out_fp32 of the function // inp is hackily converted to FP16 into qkvr only on the first run // similarly, vaccum is converted to FP32 into out only on the first run attention_forward_cudnn((floatX*)vaccum, stats, (floatX*)qkvr, inp, out, B, T, C, NH); break; #endif default: printf("Invalid kernel number\n"); exit(1); } } // ---------------------------------------------------------------------------- int main(int argc, char **argv) { setup_main(); int B = 8; int T = 1024; int C = 768; int NH = 12; int deviceIdx = 0; cudaCheck(cudaSetDevice(deviceIdx)); cudaDeviceProp deviceProp; cudaGetDeviceProperties(&deviceProp, deviceIdx); // setup cuBLAS (and cuDNN if needed) cublasCreate(&cublas_handle); int enable_tf32 = deviceProp.major >= 8 ? 1 : 0; printf("enable_tf32: %d\n", enable_tf32); cublasMath_t cublas_math_mode = enable_tf32 ? CUBLAS_TF32_TENSOR_OP_MATH : CUBLAS_DEFAULT_MATH; cublasCheck(cublasSetMathMode(cublas_handle, cublas_math_mode)); #ifdef ENABLE_CUDNN checkCudnnErr(cudnnCreate(&cudnn_handle)); #endif // create host memory of random numbers float* out = (float*)malloc(B * T * C * sizeof(float)); float* preatt = (float*)malloc(B * NH * T * T * sizeof(float)); float* att = (float*)malloc(B * NH * T * T * sizeof(float)); //float* inp = make_random_float(B * T * 3 * C, 10.0f); float* inp = make_random_float(B * T * 3 * C); // move to GPU float* d_out; float* d_stats; // for cuDNN float* d_vaccum; float* d_qkvr; float* d_preatt; float* d_att; float* d_inp; cudaCheck(cudaMalloc(&d_out, B * T * C * sizeof(float))); cudaCheck(cudaMalloc(&d_stats, B * NH * T * sizeof(float))); cudaCheck(cudaMalloc(&d_vaccum, B * T * 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_inp, B * T * 3 * C * sizeof(float))); cudaCheck(cudaMemcpy(d_inp, inp, B * T * 3 * C * sizeof(float), cudaMemcpyHostToDevice)); // 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); int block_sizes[] = {32, 64, 128, 256, 512}; // Lower accuracy requirements for FP16 (1e-4f also too much for TF32 on kernels 3 & 4) float accuracy_threshold = (kernel_num <= 4) ? 1e-3f : 1e-2f; // first check the correctness of the kernel attention_forward_cpu(out, preatt, att, inp, B, T, C, NH); for (int j = 0; j < sizeof(block_sizes) / sizeof(int); j++) { int block_size = block_sizes[j]; printf("Checking block size %d.\n", block_size); attention_forward(kernel_num, d_out, d_stats, d_vaccum, d_qkvr, d_preatt, d_att, d_inp, B, T, C, NH, block_size); // all kernels should produce the correct output out // todo - make accuracy threshold dynamic and depend on FP16 vs FP32? validate_result(d_out, out, "out", B * T * C, accuracy_threshold); // but as for preatt and att, things get a bit more complicated: if (kernel_num != 2 && kernel_num < 5) { // kernel 2 (knowingly) fails att/preatt because it uses a different algorithm // that estimates the softmax online and never materializes preatt/att validate_result(d_att, att, "att", B * NH * T * T, accuracy_threshold); } if (kernel_num != 2 && kernel_num < 4) { // kernel 4 (knowingly) fails preatt because it fuses the scale normalization // into the softmax, so preatt is off by 1.0f / sqrt(HS) // but att and out (checked below) should match. validate_result(d_preatt, preatt, "preatt", B * NH * T * T, accuracy_threshold); } } printf("All results match. Starting benchmarks.\n\n"); first_run_validation = false; // benchmark speed of the kernel for (int j = 0; j < sizeof(block_sizes) / sizeof(int); j++) { int block_size = block_sizes[j]; int repeat_times = 100; float elapsed_time = benchmark_kernel(repeat_times, attention_forward, kernel_num, d_out, d_stats, d_vaccum, d_qkvr, d_preatt, d_att, d_inp, B, T, C, NH, block_size); printf("block_size %4d | time %f ms\n", block_size, elapsed_time); } // free memory free(out); free(preatt); free(att); free(inp); cudaCheck(cudaFree(d_out)); cudaCheck(cudaFree(d_vaccum)); cudaCheck(cudaFree(d_qkvr)); cudaCheck(cudaFree(d_preatt)); cudaCheck(cudaFree(d_att)); cudaCheck(cudaFree(d_inp)); cudaCheck(cudaFree(d_stats)); cublasDestroy(cublas_handle); #ifdef ENABLE_CUDNN cudnnDestroy(cudnn_handle); if (cudnn_workspace_size > 0) { cudaCheck(cudaFree(cudnn_workspace)); } #endif return 0; }