/* Kernels for fused forward/backward classifier part This fuses softmax, crossentropy, and logit gradients into a single pass, so we don't have to write unnecessary (B, T, V) tensors. Such an operation is only possible if `dloss` can be known beforehand, which doesn't seem like much of a restriction: In pretraining, it is just a constant 1/batch_size tensor, for fine-tuning we might zero out the input prompt, but that is known in advance. Compile example: nvcc -O3 --use_fast_math classifier_fused.cu -o classifier_fused ./classifier_fused 1 ./classifier_fused 2 ./classifier_fused 3 */ #include #include #include #include #include #include #include "common.h" // ---------------------------------------------------------------------------- // CPU code reference void softmax_forward_cpu(float* out, const float* inp, int N, int C) { // inp is (N, C) // out is (N, C), each row of inp will get softmaxed for (int i = 0; i < N; i++) { const float* inp_row = inp + i * C; float* out_row = out + i * C; float maxval = -INFINITY; for (int j = 0; j < C; j++) { if (inp_row[j] > maxval) { maxval = inp_row[j]; } } double sum = 0.0; for (int j = 0; j < C; j++) { out_row[j] = expf(inp_row[j] - maxval); sum += out_row[j]; } for (int j = 0; j < C; j++) { out_row[j] /= sum; } } } void crossentropy_forward_cpu(float* losses, const float* probs, const int* targets, int B, int T, int V) { // output: losses is (B,T) of the individual losses at each position // input: probs are (B,T,V) of the probabilities // input: targets is (B,T) of integers giving the correct index in logits for (int b = 0; b < B; b++) { for (int t = 0; t < T; t++) { // loss = -log(probs[target]) const float* probs_bt = probs + b * T * V + t * V; int ix = targets[b * T + t]; losses[b * T + t] = -logf(probs_bt[ix]); } } } void crossentropy_softmax_backward_cpu(float* dlogits, const float* dlosses, const float* probs, const int* targets, int B, int T, int V) { // backwards through both softmax and crossentropy for (int b = 0; b < B; b++) { for (int t = 0; t < T; t++) { float* dlogits_bt = dlogits + b * T * V + t * V; const float* probs_bt = probs + b * T * V + t * V; float dloss = dlosses[b * T + t]; int ix = targets[b * T + t]; for (int i = 0; i < V; i++) { float p = probs_bt[i]; float indicator = i == ix ? 1.0f : 0.0f; dlogits_bt[i] = (p - indicator) * dloss; } } } } // ---------------------------------------------------------------------------- // GPU kernels struct SoftmaxParams { float Scale; float Offset; }; namespace cg = cooperative_groups; __device__ SoftmaxParams prepare_softmax(cg::thread_block_tile<32>& warp, int idx, const float* inp, int V, int P) { // this warp (of 32) threads processes one row of inp, i.e. inp[idx, :] of shape (V,) // note that inp is actually (B * T, P) but we only use the first V elements // this function tehen calculates: // 1) the max value to subtract for numerical stability and // 2) the sum normalization factor const float* x = inp + idx * P; // thread coarsening loop, where the 32 threads serially process all V elements // thread_rank() is in [0, 31], warp.size() is 32 float maxval = -INFINITY; float sumval = 0.0f; for (int i = warp.thread_rank(); i < V; i += warp.size()) { float v = x[i]; float old_maxval = maxval; // online softmax recurrence from "Online normalizer calculation for softmax" paper maxval = fmaxf(maxval, v); sumval *= expf((old_maxval - maxval)); sumval += expf(v - maxval); } // warp-level reduction to get the maxval across the 32 threads float global_maxval = cg::reduce(warp, maxval, cg::greater{}); // all 32 threads do a final shift of the sum considering the global max in this row sumval *= expf((maxval - global_maxval)); // warp-level reduction to get the sumval across the 32 threads float global_sumval = cg::reduce(warp, sumval, cg::plus{}); // the final normalization factor float norm = 1.0f / global_sumval; return SoftmaxParams{norm, global_maxval}; } __global__ void fused_classifier_kernel1(float* dlogits, float* losses, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P) { namespace cg = cooperative_groups; cg::thread_block block = cg::this_thread_block(); cg::thread_block_tile<32> warp = cg::tiled_partition<32>(block); // example: B = 4, T = 1024, block_size = 128 => we'd have grid_size = 1024 // each block of 4 warps is in charge of 4 rows of the input, one warp per row // meta_group_size is the number of warps per block (e.g. 4) // meta_group_rank is the index of the warp in the block (e.g. 0, 1, 2, 3) int idx = blockIdx.x * warp.meta_group_size() + warp.meta_group_rank(); if (idx >= B * T) { // there are B * T rows in the input return; } int b = idx / T; int t = idx % T; // calculate the offset (maxval) and scale (sumval) for the softmax SoftmaxParams sp = prepare_softmax(warp, idx, logits, V, P); // in each row (handled by one warp), thread 0 calculates the loss // calculate the probability needed for the loss and update losses if(warp.thread_rank() == 0) { int ix = targets[b * T + t]; float prob = expf(logits[idx * P + ix] - sp.Offset) * sp.Scale; losses[b * T + t] = -logf(prob); } // finally all threads calculate the gradients // prob is only materialized here temporarily and in registers, never // as a full tensor that gets written to global memory for (int i = warp.thread_rank(); i < V; i += warp.size()) { float prob = expf(logits[idx * P + i] - sp.Offset) * sp.Scale; float* dlogits_bt = dlogits + b * T * P + t * P; float dloss = dlosses[b * T + t]; int ix = targets[b * T + t]; float indicator = i == ix ? 1.0f : 0.0f; dlogits_bt[i] = (prob - indicator) * dloss; } } __device__ float vec_at(const float4& vec, int index) { return reinterpret_cast(&vec)[index]; } __device__ SoftmaxParams prepare_softmax_blockwide(cg::thread_block_tile<32>& warp, int idx, const float* inp, int V, int P) { // one row of inp, i.e. inp[idx, :] of shape (V,) // float4 to get 128-bit loads and memory level parallelism const float4* x_vec4 = reinterpret_cast(inp + idx * P); float thread_maxval = -INFINITY; float thread_sumval = 0.0f; // do the loop in reverse to maximise probability of L2 cache hits // so even small L2s get some hits on the 2nd read of the same thread for (int i = (V+3)/4 + threadIdx.x - blockDim.x; i >= 0; i -= blockDim.x) { float4 v4 = x_vec4[i]; #pragma unroll for(int k = 0; k < 4; k++) { if (i*4+k >= V) { // bounds checking against real V continue; } float old_maxval = thread_maxval; thread_maxval = fmaxf(thread_maxval, vec_at(v4, k)); thread_sumval *= expf((old_maxval - thread_maxval)); thread_sumval += expf(vec_at(v4, k) - thread_maxval); } } // two reductions of up to 1024 threads: // 1) inside warp (shuffle), 2) cross-warp (shared memory), 3) inside warp (shuffle) // this results in much cleaner assembly than a multi-warp cg::reduce __shared__ float shared_maxval[32]; __shared__ float shared_sumval[32]; int num_warps = blockDim.x / 32; int warp_id = threadIdx.x / 32; int lane_id = threadIdx.x % 32; // reduce maxval within each warp float warp_maxval = cg::reduce(warp, thread_maxval, cg::greater{}); // thread 0 in each warp writes to shared memory if (lane_id == 0) { shared_maxval[warp_id] = warp_maxval; } __syncthreads(); // each thread now loads the maxval across previous warps // if the thread is "out of range" of data, use -FLT_MAX as the maxval warp_maxval = (lane_id < num_warps) ? shared_maxval[lane_id] : -FLT_MAX; // now reduce the maxval among the warp threads float block_maxval = cg::reduce(warp, warp_maxval, cg::greater{}); // each thread uses maxval to scale sumval to avoid numerical instability / overflow thread_sumval *= expf(thread_maxval - block_maxval); // (warp-level) reduce sumval, thread 0 in each warp saves result in shared memory float warp_sumval = cg::reduce(warp, thread_sumval, cg::plus{}); if (lane_id == 0) { shared_sumval[warp_id] = warp_sumval; } __syncthreads(); // same strategy, now reduce sumval across warps warp_sumval = (lane_id < num_warps) ? shared_sumval[lane_id] : 0.0f; float block_sumval = cg::reduce(warp, warp_sumval, cg::plus{}); // return the softmax parameters return SoftmaxParams{1.f / block_sumval, block_maxval}; } // Fused forward and backward pass for classifier including softmax, and logit gradients // Writes to both probs (only for debugging) and dlogits (only for training) are optional // N.B.: We may want to reuse the logits memory for dlogits, so they should *not* be __restrict__! __global__ void fused_classifier_kernel2(float* dlogits, float* losses, float* probs, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P) { 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; int ix = targets[idx]; // softmax (reading B * T * V, same logits read again below, hopefully still in cache) SoftmaxParams sp = prepare_softmax_blockwide(warp, idx, logits, V, P); // calculate the probability needed for the loss and update (single-threaded) if(threadIdx.x == 0) { float prob = expf(logits[idx * P + ix] - sp.Offset) * sp.Scale; losses[idx] = -logf(prob); } // very sensible default for dlosses is 1/(B*T), which is the uniform loss float dloss = dlosses != NULL ? dlosses[idx] : 1.0f / (B*T); // calculate the gradients directly, saves bandwidth from probs during training // but also supports writing probs for inference-only and debugging const float4* logits_vec4 = reinterpret_cast(logits + idx * P); for (int i = threadIdx.x; i < (V+3)/4; i += blockDim.x) { // this is the 2nd read of logits after the one in prepare_softmax2 // this data will never be needed again, so we reduce cache persistence float4 v4 = __ldcs(&logits_vec4[i]); #pragma unroll for(int k = 0; k < 4; ++k) { int element = i*4 + k; float prob = expf(vec_at(v4, k) - sp.Offset) * sp.Scale; prob = (element < V) ? prob : 0.0f; // bounds checking against real V // this kernel is DRAM limited so cost of inner branch is ~zero if (probs != NULL) { probs[idx * P + element] = prob; } if (dlogits != NULL) { float indicator = element == ix ? 1.0f : 0.0f; dlogits[idx * P + element] = (prob - indicator) * dloss; } } } } __device__ SoftmaxParams prepare_softmax_blockwide_nofloat4(cg::thread_block_tile<32>& warp, int idx, const float* inp, int V, int P) { // same but not float4 // one row of inp, i.e. inp[idx, :] of shape (V,) const float* x = inp + idx * P; float thread_maxval = -INFINITY; float thread_sumval = 0.0f; // do the loop in reverse to maximise probability of L2 cache hits // so even small L2s get some hits on the 2nd read of the same thread for (int i = V + threadIdx.x - blockDim.x; i >= 0; i -= blockDim.x) { float v = x[i]; float old_maxval = thread_maxval; thread_maxval = fmaxf(thread_maxval, v); thread_sumval *= expf((old_maxval - thread_maxval)); thread_sumval += expf(v - thread_maxval); } // two reductions of up to 1024 threads: // 1) inside warp (shuffle), 2) cross-warp (shared memory), 3) inside warp (shuffle) // this results in much cleaner assembly than a multi-warp cg::reduce __shared__ float shared_maxval[32]; __shared__ float shared_sumval[32]; int num_warps = blockDim.x / 32; int warp_id = threadIdx.x / 32; int lane_id = threadIdx.x % 32; // reduce maxval within each warp float warp_maxval = cg::reduce(warp, thread_maxval, cg::greater{}); // thread 0 in each warp writes to shared memory if (lane_id == 0) { shared_maxval[warp_id] = warp_maxval; } __syncthreads(); // each thread now loads the maxval across previous warps // if the thread is "out of range" of data, use -FLT_MAX as the maxval warp_maxval = (lane_id < num_warps) ? shared_maxval[lane_id] : -FLT_MAX; // now reduce the maxval among the warp threads float block_maxval = cg::reduce(warp, warp_maxval, cg::greater{}); // each thread uses maxval to scale sumval to avoid numerical instability / overflow thread_sumval *= expf(thread_maxval - block_maxval); // (warp-level) reduce sumval, thread 0 in each warp saves result in shared memory float warp_sumval = cg::reduce(warp, thread_sumval, cg::plus{}); if (lane_id == 0) { shared_sumval[warp_id] = warp_sumval; } __syncthreads(); // same strategy, now reduce sumval across warps warp_sumval = (lane_id < num_warps) ? shared_sumval[lane_id] : 0.0f; float block_sumval = cg::reduce(warp, warp_sumval, cg::plus{}); // return the softmax parameters return SoftmaxParams{1.f / block_sumval, block_maxval}; } // same as 2 but not using float4 __global__ void fused_classifier_kernel3(float* dlogits, float* losses, float* probs, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P) { 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; int ix = targets[idx]; // softmax (reading B * T * V, same logits read again below, hopefully still in cache) SoftmaxParams sp = prepare_softmax_blockwide_nofloat4(warp, idx, logits, V, P); // calculate the probability needed for the loss and update (single-threaded) if(threadIdx.x == 0) { float prob = expf(logits[idx * P + ix] - sp.Offset) * sp.Scale; losses[idx] = -logf(prob); } // very sensible default for dlosses is 1/(B*T), which is the uniform loss float dloss = dlosses != NULL ? dlosses[idx] : 1.0f / (B*T); // calculate the gradients directly, saves bandwidth from probs during training // but also supports writing probs for inference-only and debugging const float* logits_vec = logits + idx * P; for (int i = threadIdx.x; i < V; i += blockDim.x) { // this is the 2nd read of logits after the one in prepare_softmax2 // this data will never be needed again, so we reduce cache persistence float v = __ldcs(&logits_vec[i]); float prob = expf(v - sp.Offset) * sp.Scale; if (probs != NULL) { probs[idx * P + i] = prob; } if (dlogits != NULL) { float indicator = (i == ix) ? 1.0f : 0.0f; dlogits[idx * P + i] = (prob - indicator) * dloss; } } } // ---------------------------------------------------------------------------- // kernel launcher void fused_classifier1(float* dlogits, float* losses, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P, int block_size) { const int N = B * T; // total number of rows in the input // how many rows of the input can each block of threads process? // e.g. in block_size=128, 4 rows get handled by 4 warps (of 32 threads each) const int rows_per_block = block_size / 32; const int grid_size = N / rows_per_block; // total number of blocks needed fused_classifier_kernel1<<>>(dlogits, losses, logits, dlosses, targets, B, T, V, P); cudaCheck(cudaGetLastError()); } void fused_classifier2(float* dlogits, float* losses, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P, int block_size) { const int N = B * T; const int grid_size = N; fused_classifier_kernel2<<>>(dlogits, losses, NULL, logits, dlosses, targets, B, T, V, P); cudaCheck(cudaGetLastError()); } void fused_classifier3(float* dlogits, float* losses, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P, int block_size) { const int N = B * T; const int grid_size = N; fused_classifier_kernel3<<>>(dlogits, losses, NULL, logits, dlosses, targets, B, T, V, P); cudaCheck(cudaGetLastError()); } void fused_classifier(int kernel_num, float* dlogits, float* losses, const float* logits, const float* dlosses, const int* targets, int B, int T, int V, int P, int block_size) { switch (kernel_num) { case 1: fused_classifier1(dlogits, losses, logits, dlosses, targets, B, T, V, P, block_size); break; case 2: fused_classifier2(dlogits, losses, logits, dlosses, targets, B, T, V, P, block_size); break; case 3: fused_classifier3(dlogits, losses, logits, dlosses, targets, B, T, V, P, block_size); break; default: printf("Invalid kernel number\n"); exit(1); } } // ---------------------------------------------------------------------------- int main(int argc, char **argv) { srand(0); int B = 8; // batch size int T = 1024; // sequence length int V = 50257; // vocab size int P = (V + 63) & ~63; // padded vocab size, up to nearest multiple of 64 int deviceIdx = 0; cudaCheck(cudaSetDevice(deviceIdx)); // create host memory of random numbers float* logits = make_random_float_01(B * T * V); float* probs = (float*)malloc(B * T * V * sizeof(float)); float* dlogits = (float*)malloc(B * T * V * sizeof(float)); float* losses = (float*)malloc(B * T * sizeof(float)); float* dlosses = make_random_float(B * T); int* targets = make_random_int(B * T, V); // make the input less uniformly random: Otherwise, all probabilities will be basically zero, // and the tests are not actually meaningful. int* outliers = make_random_int(B * T * 3, V); for(int k = 0; k < 3; ++k) { for(int j = 0; j < B * T; ++j) { logits[j * V + outliers[j*3 + k]] *= 20; } } // move to GPU int *d_targets; float *d_logits, *d_losses; float *d_dlogits, *d_dlosses, *d_dlogits_no_pad; cudaCheck(cudaMalloc(&d_dlogits, B * T * P * sizeof(float))); cudaCheck(cudaMalloc(&d_logits, B * T * P * sizeof(float))); cudaCheck(cudaMalloc(&d_dlogits_no_pad, B * T * V * sizeof(float))); cudaCheck(cudaMalloc(&d_targets, B * T * sizeof(int))); cudaCheck(cudaMalloc(&d_losses, B * T * sizeof(float))); cudaCheck(cudaMalloc(&d_dlosses, B * T * sizeof(float))); // move to GPU cudaCheck(cudaMemset(d_logits, 0xff, B * T * P * sizeof(float))); cudaCheck(cudaMemcpy2D(d_logits, P * sizeof(float), logits, V * sizeof(float), V * sizeof(float), B * T, cudaMemcpyHostToDevice)); cudaCheck(cudaMemcpy(d_dlosses, dlosses, B * T * sizeof(float), cudaMemcpyHostToDevice)); cudaCheck(cudaMemcpy(d_targets, targets, B * T * sizeof(int), 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); // define block sizes we'll use in correctness and timing int block_sizes[] = {32, 64, 128, 256, 512, 1024}; // first check the correctness of the kernel softmax_forward_cpu(probs, logits, B * T, V); crossentropy_forward_cpu(losses, probs, targets, B, T, V); crossentropy_softmax_backward_cpu(dlogits, dlosses, probs, targets, B, T, V); // time the kernel at different block sizes 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); fused_classifier(kernel_num, d_dlogits, d_losses, d_logits, d_dlosses, d_targets, B, T, V, P, block_size); validate_result(d_losses, losses, "losses", B * T, 1e-4f); // undo the padding before we can check for correctness cudaCheck(cudaMemcpy2D(d_dlogits_no_pad, V * sizeof(float), d_dlogits, P * sizeof(float), V * sizeof(float), B * T, cudaMemcpyDeviceToDevice)); validate_result(d_dlogits_no_pad, dlogits, "dlogits", B * T * V, 1e-4f); } printf("All results match. Starting benchmarks.\n\n"); for (int j = 0; j < sizeof(block_sizes) / sizeof(int); j++) { int block_size = block_sizes[j]; int repeat_times = 1000; float elapsed_time = benchmark_kernel(repeat_times, fused_classifier, kernel_num, d_dlogits, d_losses, d_logits, d_dlosses, d_targets, B, T, V, P, block_size); printf("block_size %4d | time %f ms\n", block_size, elapsed_time); } // free memory free(logits); free(probs); free(dlogits); free(losses); free(dlosses); free(targets); cudaCheck(cudaFree(d_dlogits)); cudaCheck(cudaFree(d_losses)); cudaCheck(cudaFree(d_logits)); cudaCheck(cudaFree(d_dlosses)); cudaCheck(cudaFree(d_targets)); return 0; }