Split View: GPU 커널을 직접 손본다는 것 — 전치 커널 하나를 5배 빠르게 만들기까지
GPU 커널을 직접 손본다는 것 — 전치 커널 하나를 5배 빠르게 만들기까지
- 들어가며 — 프로파일러가 가리킨 커널이 라이브러리 안에 없을 때
- 커널이 무엇이고, 왜 고치게 되는가
- 실행 모델 — 스레드, 워프, 블록, 그리드
- 메모리 계층 — 코얼레싱과 뱅크 충돌
- 점유율과 루프라인 — 무엇을 보고 무엇을 무시할 것인가
- 실습 — 전치 커널을 네 단계로 고치기
- Nsight Compute로 무엇을 보는가
- 마치며 — 커널을 고친다는 것은 데이터가 움직이는 순서를 고치는 일이다
- 참고 자료
들어가며 — 프로파일러가 가리킨 커널이 라이브러리 안에 없을 때
학습 스텝 하나가 420ms인데 그중 170ms가 이름 모를 커널 하나에 들어가 있는 상황을 만나면, 선택지가 갑자기 줄어듭니다. cuBLAS 호출이면 손댈 것이 없고, PyTorch 연산자면 다른 연산자로 바꿔 보면 됩니다. 그런데 그 커널이 우리 모델의 특이한 마스킹 로직을 위해 누군가 급하게 짜 넣은 것이라면, 고칠 사람은 우리밖에 없습니다.
이 글은 그 지점에서 시작합니다. GPU 커널을 "직접 손본다"는 말이 실제로 어떤 작업인지, 무엇을 알아야 하고 무엇을 재야 하는지를 다룹니다. 결론부터 말하면 커널 최적화의 8할은 연산을 줄이는 일이 아니라 메모리를 옮기는 순서를 바꾸는 일입니다. 이 글의 실습에서 우리가 고칠 커널도 산술 연산이 단 한 개도 없는 커널입니다. 그런데도 5배 이상 빨라집니다.
기준 환경은 CUDA Toolkit 13.3 Update 1, Nsight Compute 2026.2 계열입니다. 개념은 세대를 타지 않지만 도구의 플래그 이름은 바뀌므로, 명령줄은 자기 버전의 Nsight Compute CLI 문서와 대조해 보시기 바랍니다.
커널이 무엇이고, 왜 고치게 되는가
커널은 GPU에서 실행되는 함수 하나입니다. CPU 함수와 다른 점은 호출 한 번에 수만 개의 인스턴스가 동시에 시작된다는 것입니다. 이 인스턴스 하나하나가 스레드이고, 스레드는 자기가 몇 번째인지를 내장 변수로 알아내 그에 해당하는 데이터 조각만 처리합니다.
커널을 직접 고칠 만한 상황은 실무에서 생각보다 좁습니다. 순서대로 점검하면 이렇습니다.
| 상황 | 먼저 해야 할 일 | 커널을 고칠 이유가 되는가 |
|---|---|---|
| 표준 GEMM, 컨볼루션이 느림 | cuBLAS, cuDNN 버전과 데이터 타입, 텐서 코어 경로 확인 | 거의 아니다 |
| 여러 작은 연산자가 줄지어 실행됨 | torch.compile로 퓨전 유도 | 대개 아니다 |
| 어텐션 변형이 필요함 | FlashAttention 계열 라이브러리에 해당 변형이 있는지 확인 | 없으면 그렇다 |
| 커널 하나가 이론 대역폭의 20퍼센트만 씀 | Nsight Compute로 원인 확인 | 그렇다 |
| 우리 도메인 특유의 인덱싱, 마스킹, 희소 패턴 | 대체할 라이브러리 없음 | 그렇다 |
핵심은 마지막 두 줄입니다. 라이브러리로 대체할 수 있으면 대체하는 쪽이 언제나 낫습니다. 라이브러리 커널은 우리가 쓸 수 있는 시간보다 훨씬 많은 튜닝이 들어가 있고, 새 아키텍처가 나오면 우리 대신 갱신됩니다. 직접 쓴 커널은 우리가 평생 관리해야 합니다.
실행 모델 — 스레드, 워프, 블록, 그리드
커널을 고치려면 하드웨어가 스레드를 어떻게 묶는지 알아야 합니다. 계층은 네 단계입니다.
그리드(grid) 커널 호출 한 번의 전체 스레드 집합
└ 블록(block) 같은 SM에 배치되고, 셰어드 메모리와 __syncthreads()를 공유
└ 워프(warp) 32개 스레드. 스케줄링과 명령어 발행의 실제 단위
└ 스레드 자기 레지스터를 가진 실행 흐름 하나
여기서 실무적으로 가장 중요한 사실은 워프가 진짜 단위라는 것입니다. 프로그래머는 스레드 단위로 코드를 쓰지만 하드웨어는 32개를 한 덩어리로 발행합니다. 여기서 두 가지 결과가 따라 나옵니다.
첫째, 분기 발산입니다. 한 워프 안의 스레드들이 서로 다른 분기를 타면 하드웨어는 양쪽 경로를 순차로 실행하면서 해당 없는 스레드를 비활성화합니다. 워프 안이 갈라지는 조건문은 실행 시간을 더합니다. 워프 경계에 맞춰 갈라지는 조건문은 공짜입니다.
둘째, 메모리 접근도 워프 단위로 합쳐집니다. 이것이 코얼레싱이고, 다음 절의 주제입니다.
블록 크기를 정할 때는 32의 배수로 두는 것이 기본입니다. 블록 크기가 33이면 하드웨어는 워프 두 개를 발행하고 그중 31개 레인이 놀게 됩니다.
메모리 계층 — 코얼레싱과 뱅크 충돌
GPU 메모리는 층으로 되어 있고, 층마다 지연과 대역폭이 자릿수 단위로 다릅니다.
| 계층 | 범위 | 대략적 성격 |
|---|---|---|
| 레지스터 | 스레드 하나 | 가장 빠름. 스레드당 개수가 점유율을 좌우 |
| 셰어드 메모리 | 블록 하나 | SM 내부 SRAM. 프로그래머가 직접 관리하는 캐시 |
| L1 / 텍스처 캐시 | SM 하나 | 셰어드 메모리와 물리적으로 같은 저장소를 나눠 씀 |
| L2 캐시 | GPU 전체 | 모든 SM이 공유. HBM 앞의 마지막 방어선 |
| HBM (글로벌 메모리) | GPU 전체 | 용량은 크지만 지연이 수백 사이클 |
코얼레싱
글로벌 메모리 접근은 32바이트 단위 트랜잭션으로 처리됩니다. 한 워프의 32개 스레드가 연속된 float 32개, 즉 128바이트를 읽으면 트랜잭션 네 번으로 끝납니다. 반대로 같은 32개 스레드가 4096 간격으로 떨어진 float 32개를 읽으면 트랜잭션이 32번 필요하고, 그때마다 32바이트를 가져와 4바이트만 쓰고 버립니다. 대역폭의 8분의 1만 쓰는 셈입니다.
이것이 커널 성능 차이의 가장 큰 단일 원인입니다. 실습에서 정확히 이 8배 낭비를 보게 됩니다.
뱅크 충돌
셰어드 메모리는 32개 뱅크로 인터리브되어 있습니다. 4바이트 워드 기준으로 주소를 4로 나눈 몫을 32로 나눈 나머지가 뱅크 번호입니다. 한 워프의 스레드들이 서로 다른 뱅크를 건드리면 한 사이클에 처리되고, 같은 뱅크의 서로 다른 주소를 건드리면 그 수만큼 직렬화됩니다.
전형적인 사고는 정사각 셰어드 배열의 열 방향 접근입니다. tile[32][32]에서 tile[i][0]을 i에 대해 훑으면 모든 원소가 뱅크 0에 걸립니다. 32-way 충돌입니다. 해법은 배열을 한 칸 넓히는 것입니다. tile[32][33]으로 선언하면 행마다 시작 뱅크가 한 칸씩 밀려서 열 접근이 32개 뱅크에 고르게 퍼집니다. 셰어드 메모리를 32 * 4바이트, 즉 블록당 128바이트 더 쓰고 직렬화를 없애는 거래입니다.
점유율과 루프라인 — 무엇을 보고 무엇을 무시할 것인가
점유율은 목표가 아니라 증상이다
점유율은 SM이 상주시킬 수 있는 최대 워프 수 대비 실제 상주 워프 수입니다. 초심자에게 흔한 오해는 이것을 최대화해야 할 목표로 삼는 것입니다. 그렇지 않습니다.
점유율이 하는 일은 하나뿐입니다. 지연을 가리는 것입니다. 어떤 워프가 HBM 응답을 기다리는 동안 다른 워프를 실행하면 SM이 놀지 않습니다. 그러니 점유율은 "지연을 가릴 워프가 충분한가"라는 질문의 대리 지표이지, 그 자체로 성능이 아닙니다.
낮은 점유율이 오히려 빠른 경우가 두 가지 있습니다.
첫째, 스레드당 레지스터를 많이 써서 명령어 수준 병렬성을 확보한 경우입니다. 스레드 하나가 독립적인 로드 네 개를 동시에 띄워 놓으면, 워프 수가 4분의 1이어도 진행 중인 메모리 요청의 총량은 같습니다. Vasily Volkov의 Better Performance at Lower Occupancy가 이 논지를 정면으로 다룹니다. 2010년 자료지만 논지는 지금도 유효합니다.
둘째, 커널이 이미 대역폭 상한에 붙어 있는 경우입니다. 메모리 파이프가 포화 상태면 워프를 더 넣어도 넣을 자리가 없습니다.
그래서 실무 규칙은 이렇습니다. 점유율은 낮을 때만 봅니다. 25퍼센트 아래로 떨어졌고 커널이 지연에 묶여 있다면 레지스터 사용량이나 셰어드 메모리 할당을 의심합니다. 60퍼센트가 나오는데 커널이 느리다면 점유율은 범인이 아니므로 다른 곳을 봐야 합니다.
루프라인 — 대부분의 커널은 대역폭에 묶여 있다
어디를 볼지 정하는 도구가 루프라인입니다. 축은 산술 강도, 즉 옮긴 바이트 하나당 연산 횟수입니다.
달성 성능(FLOP/s)
^
| ______________ 연산 상한
| /
| / 기울기 = 메모리 대역폭
| /
+--------+-------------------> 산술 강도 (FLOP/Byte)
전환점
전환점 = (연산 상한 FLOP/s) / (메모리 대역폭 Byte/s)
전환점은 하드웨어 특성입니다. 최신 데이터센터 GPU에서 이 값은 수십에서 수백 FLOP/Byte 범위입니다. 그런데 우리가 실제로 쓰는 커널들의 산술 강도를 계산해 보면 대부분 한 자릿수입니다.
| 연산 | 대략적 산술 강도 | 위치 |
|---|---|---|
| 원소별 덧셈 | 1 FLOP / 12 Byte | 극단적 메모리 바운드 |
| 활성화 함수 | 수 FLOP / 8 Byte | 메모리 바운드 |
| 행렬 전치 | 0 FLOP / 8 Byte | 순수 메모리 |
| LayerNorm | 열 자릿수 FLOP / Byte | 메모리 바운드 |
| GEMM (큰 행렬) | 타일 크기에 비례해 수백까지 | 연산 바운드 |
| LLM 디코드 단계 | 배치가 작으면 2 미만 | 메모리 바운드 |
읽는 법은 단순합니다. 산술 강도가 전환점보다 훨씬 작으면 그 커널의 성능 상한은 이미 정해져 있습니다. 연산 명령어를 아무리 줄여도 소용없고, 유일하게 의미 있는 개선은 옮기는 바이트를 줄이거나 옮기는 방식을 고치는 것입니다.
그래서 커널 최적화의 첫 질문은 언제나 이것입니다. "이 커널의 이론 최소 트래픽은 몇 바이트이고, 지금 실제로 몇 바이트를 옮기고 있는가."
실습 — 전치 커널을 네 단계로 고치기
이제 실제로 고쳐 봅니다. 대상은 4096 x 4096 float 행렬 전치입니다. 연산이 0회이므로 메모리 이야기만 남고, 그래서 교보재로 이상적입니다.
이론 최소 트래픽은 명확합니다. 한 번 읽고 한 번 쓰므로 2 * 4096 * 4096 * 4바이트, 약 134MB입니다. 이보다 적게 옮길 방법은 없습니다. 따라서 성능의 상한은 "전치하지 않고 그냥 복사만 하는 커널"이며, 이것을 먼저 재서 기준선으로 삼습니다.
전체 코드
// transpose.cu
// 빌드: nvcc -O3 -arch=sm_80 transpose.cu -o transpose
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>
static const int TILE = 32;
static const int BLOCK_ROWS = 8; // 블록당 32x8 = 256 스레드
static const int N = 4096;
#define CHECK(x) do { cudaError_t e_ = (x); if (e_ != cudaSuccess) { \
printf("CUDA error: %s (line %d)\n", cudaGetErrorString(e_), __LINE__); \
exit(1); } } while (0)
// 0단계. 상한선: 전치하지 않고 복사만 한다.
__global__ void copyKernel(float *out, const float *in) {
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = in[(y + j) * N + x];
}
// 1단계. naive: 읽기는 코얼레싱되지만 쓰기가 N 간격 스트라이드다.
__global__ void transposeNaive(float *out, const float *in) {
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[x * N + (y + j)] = in[(y + j) * N + x];
}
// 2단계. 셰어드 메모리 타일: 전치를 SRAM 안에서 끝내고
// 글로벌 읽기와 쓰기를 둘 다 코얼레싱시킨다.
__global__ void transposeShared(float *out, const float *in) {
__shared__ float tile[TILE][TILE];
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = in[(y + j) * N + x];
__syncthreads();
// 블록 좌표를 바꿔 끼워서, 쓰기도 연속 주소가 되게 만든다.
x = blockIdx.y * TILE + threadIdx.x;
y = blockIdx.x * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = tile[threadIdx.x][threadIdx.y + j];
}
// 3단계. 패딩 한 칸으로 셰어드 메모리 뱅크 충돌을 없앤다.
__global__ void transposePadded(float *out, const float *in) {
__shared__ float tile[TILE][TILE + 1]; // 유일한 차이
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = in[(y + j) * N + x];
__syncthreads();
x = blockIdx.y * TILE + threadIdx.x;
y = blockIdx.x * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = tile[threadIdx.x][threadIdx.y + j];
}
typedef void (*Kern)(float *, const float *);
static void bench(const char *name, Kern k, float *d_out, const float *d_in,
const float *h_ref, float *h_out, bool checkTranspose) {
dim3 grid(N / TILE, N / TILE), block(TILE, BLOCK_ROWS);
const int WARMUP = 5, ITERS = 50;
const double bytes = 2.0 * N * N * sizeof(float);
for (int i = 0; i < WARMUP; i++) k<<<grid, block>>>(d_out, d_in);
CHECK(cudaDeviceSynchronize());
cudaEvent_t t0, t1;
CHECK(cudaEventCreate(&t0));
CHECK(cudaEventCreate(&t1));
CHECK(cudaEventRecord(t0));
for (int i = 0; i < ITERS; i++) k<<<grid, block>>>(d_out, d_in);
CHECK(cudaEventRecord(t1));
CHECK(cudaEventSynchronize(t1));
float ms = 0.f;
CHECK(cudaEventElapsedTime(&ms, t0, t1));
double perIter = ms / ITERS;
double gbs = bytes / (perIter * 1.0e-3) / 1.0e9;
// 정확성 검증 없는 성능 수치는 의미가 없다.
CHECK(cudaMemcpy(h_out, d_out, (size_t)N * N * sizeof(float),
cudaMemcpyDeviceToHost));
long bad = 0;
for (long r = 0; r < N && bad == 0; r++)
for (long c = 0; c < N; c++) {
float want = checkTranspose ? h_ref[c * N + r] : h_ref[r * N + c];
if (h_out[r * N + c] != want) { bad++; break; }
}
printf("%-18s %8.3f ms %8.1f GB/s %s\n", name, perIter, gbs,
bad ? "FAIL" : "ok");
CHECK(cudaEventDestroy(t0));
CHECK(cudaEventDestroy(t1));
}
int main() {
size_t bytes = (size_t)N * N * sizeof(float);
float *h_in = (float *)malloc(bytes), *h_out = (float *)malloc(bytes);
for (long i = 0; i < (long)N * N; i++) h_in[i] = (float)(i % 1000);
float *d_in, *d_out;
CHECK(cudaMalloc(&d_in, bytes));
CHECK(cudaMalloc(&d_out, bytes));
CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));
cudaDeviceProp p;
CHECK(cudaGetDeviceProperties(&p, 0));
printf("%s peak HBM = %.1f GB/s\n\n", p.name,
2.0 * p.memoryClockRate * (p.memoryBusWidth / 8) / 1.0e6);
bench("copy (upper bound)", copyKernel, d_out, d_in, h_in, h_out, false);
bench("naive", transposeNaive, d_out, d_in, h_in, h_out, true);
bench("shared tile", transposeShared, d_out, d_in, h_in, h_out, true);
bench("shared + padding", transposePadded, d_out, d_in, h_in, h_out, true);
cudaFree(d_in); cudaFree(d_out); free(h_in); free(h_out);
return 0;
}
측정 방법에서 중요한 것
수치를 믿으려면 하니스가 먼저 정직해야 합니다. 위 코드가 지키는 규칙은 다섯 가지입니다.
- 웜업을 버립니다. 첫 호출에는 컨텍스트 생성과 모듈 로딩이 섞여 들어갑니다.
- 반복 측정 후 평균을 냅니다. 커널 하나가 1ms 수준이면 클럭 변동만으로 10퍼센트가 흔들립니다.
- CPU 타이머가 아니라 cudaEvent를 씁니다. 커널 실행은 비동기라 CPU 시간은 발행 시간만 잽니다.
- 정확성을 검증합니다. 잘못된 인덱싱은 대개 더 빠릅니다. 검증 없는 GB/s는 숫자 놀이입니다.
- 시간이 아니라 유효 대역폭으로 환산합니다. 절대 시간은 크기와 장비에 따라 달라지지만, 이론 대역폭 대비 몇 퍼센트인지는 어디서나 비교 가능합니다.
유효 대역폭 공식은 간단합니다. 옮겨야 하는 최소 바이트를 실제 걸린 시간으로 나눕니다. 여기서 "옮겨야 하는 최소"라는 점이 중요합니다. 낭비해서 실제로 오간 바이트가 아니라, 알고리즘상 필요한 바이트를 씁니다. 그래야 낭비가 숫자에 드러납니다.
결과의 형태
아래는 A100 80GB(sm_80)급 장비에서 이 하니스를 돌렸을 때 나오는 전형적인 형태입니다. 절대값은 장비, 드라이버, 클럭 상태에 따라 크게 달라지므로 그대로 인용하지 마시고, 위 코드를 자기 GPU에서 직접 돌려 자기 기준선을 만드시기 바랍니다. 의미가 있는 것은 단계 사이의 상대 비율입니다.
| 단계 | 유효 대역폭 | 복사 대비 | 병목 |
|---|---|---|---|
| copy (상한선) | 기준값 100 | 100퍼센트 | 없음. HBM 포화 |
| naive | 약 18 | 약 18퍼센트 | 쓰기 비코얼레싱. 트랜잭션당 4바이트만 사용 |
| shared tile | 약 63 | 약 63퍼센트 | 셰어드 메모리 32-way 뱅크 충돌 |
| shared + padding | 약 93 | 약 93퍼센트 | 사실상 없음. 타일 경계 효과만 남음 |
읽어야 할 것은 세 가지입니다.
첫째, naive가 상한의 5분의 1 수준이라는 점입니다. 연산은 0회이고 옮기는 데이터의 양도 동일한데 5배가 느립니다. 차이는 순서뿐입니다. 쓰기가 N 간격으로 흩어져서, 32바이트 트랜잭션마다 4바이트만 쓰고 28바이트를 버립니다. 8배 낭비가 다른 효과와 섞여 5배 차이로 나타납니다.
둘째, 셰어드 타일이 큰 폭을 회복하지만 끝까지 가지 못한다는 점입니다. 글로벌 접근은 양쪽 다 고쳤는데 병목이 SRAM 안으로 옮겨 갔습니다. tile[threadIdx.x][threadIdx.y + j]는 열 방향 접근이고, 32x32 정사각 배열에서 열 접근은 전부 같은 뱅크입니다.
셋째, 마지막 단계의 코드 차이가 배열 선언 한 글자라는 점입니다. [TILE]을 [TILE + 1]로 바꾼 것이 전부입니다. 커널 최적화가 종종 이런 모양입니다. 알고리즘이 아니라 데이터 배치를 한 칸 옮기는 일입니다.
흔한 실패 방식
이 실습에서 실제로 자주 밟는 지뢰들입니다.
__syncthreads()누락. 셰어드 타일을 채우고 읽기 전에 동기화가 없으면 결과가 비결정적으로 틀립니다. 작은 입력에서는 우연히 맞는 경우가 많아 더 위험합니다.__syncthreads()를 분기 안에 넣기. 블록 안 일부 스레드만 도달하는 위치에 두면 정의되지 않은 동작입니다.- 인덱스 계산에서 블록 좌표를 바꿔 끼우지 않기. 셰어드 타일만 넣고 출력 인덱스를 그대로 두면 쓰기가 다시 스트라이드가 되어 2단계의 이득이 사라집니다. 결과는 맞는데 빨라지지 않는, 가장 알아채기 어려운 형태입니다.
-O3없이 재기. 호스트 코드 최적화가 빠지면 검증 루프가 측정 시간을 지배해 결론이 뒤집힙니다.- N이 너무 작음. 커널 실행 오버헤드가 수 마이크로초라, 총 시간이 수십 마이크로초면 오버헤드를 재게 됩니다.
Nsight Compute로 무엇을 보는가
수치가 나빴을 때 원인을 말해 주는 것이 프로파일러입니다. 명령줄부터 보겠습니다.
# 전체 섹션 수집. 커널 하나에 수백 ms가 걸리므로 대상을 좁혀야 한다.
ncu --set full \
--kernel-name regex:transpose \
--launch-skip 5 --launch-count 1 \
-o transpose_report \
./transpose
# 원인 특정용 지표만 콕 집어 뽑기 (훨씬 빠름)
ncu --metrics \
sm__throughput.avg.pct_of_peak_sustained_elapsed,\
gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed,\
l1tex__data_bank_conflicts_pipe_lsu_mem_shared.sum,\
l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio \
--kernel-name regex:transpose --launch-count 1 ./transpose
# GUI로 열기
ncu-ui transpose_report.ncu-rep
--launch-skip으로 웜업 실행을 건너뛰는 것이 중요합니다. 프로파일러는 첫 실행의 캐시 콜드 상태를 그대로 보여 주기 때문에, 건너뛰지 않으면 정상 상태가 아닌 것을 분석하게 됩니다.
리포트를 열면 섹션이 여럿인데, 실무에서 순서는 정해져 있습니다.
1. Speed of Light. 연산 처리량과 메모리 처리량을 하드웨어 이론치 대비 퍼센트로 보여 줍니다. 여기서 방향이 정해집니다. 메모리가 80퍼센트 이상이면 대역폭에 붙은 것이고, 둘 다 30퍼센트 미만이면 지연이나 점유율 문제입니다. 우리 전치 커널의 naive 버전은 이 화면에서 둘 다 낮게 나옵니다. 파이프가 포화된 것이 아니라 낭비하고 있기 때문입니다.
2. Memory Workload Analysis. 여기가 핵심입니다. 요청 하나당 몇 개의 섹터를 가져왔는지 보여 주는데, 완벽히 코얼레싱된 32스레드 float 로드는 요청당 4섹터입니다. naive 커널의 쓰기는 요청당 32섹터가 나옵니다. 8배 낭비가 이 한 줄에 그대로 찍힙니다. 이 지표 하나가 "코얼레싱 문제인가"를 즉답합니다.
3. Shared Memory 관련 지표. 뱅크 충돌 횟수가 나옵니다. 2단계 커널은 여기가 크게 뜨고, 3단계는 0에 가깝게 떨어집니다. 패딩이 실제로 들었는지 확인하는 곳입니다.
4. Warp State Statistics. 워프가 멈춰 있던 이유를 종류별로 보여 줍니다. Stall Long Scoreboard가 압도적이면 글로벌 메모리 응답 대기이고, Stall MIO Throttle이면 셰어드 메모리나 특수 함수 유닛 쪽 혼잡입니다. 원인이 메모리인지 명령어인지 갈라 줍니다.
5. Occupancy. 마지막에 봅니다. 앞의 네 개가 깨끗한데 여전히 느릴 때, 그때 상주 워프가 모자란지 확인합니다. 이 순서를 지키지 않고 점유율부터 보면 대개 엉뚱한 방향으로 최적화하게 됩니다.
Nsight Compute에는 루프라인 섹션도 있어서 우리 커널이 비탈에 있는지 평지에 있는지를 그림으로 보여 줍니다. 전치 커널은 산술 강도가 0이라 가장 왼쪽 끝에 찍히고, 그것만으로 "연산 최적화는 할 것이 없다"는 결론이 납니다.
이 작업을 언제 그만둘 것인가
전치 커널을 상한의 93퍼센트까지 올렸다면 남은 7퍼센트를 쫓을 이유는 거의 없습니다. 판단 기준을 정해 두는 편이 낫습니다.
- 이론 최소 트래픽 대비 90퍼센트를 넘겼다면 그만둡니다. 메모리 바운드 커널에서 그 위는 타일 경계와 TLB 효과라 노력 대비 회수가 급격히 나빠집니다.
- 전체 실행 시간에서 이 커널의 비중을 다시 잽니다. 170ms짜리를 40ms로 줄였으면 이제 다른 곳이 병목입니다. 암달의 법칙은 커널 최적화에서도 그대로 작동합니다.
- 유지 비용을 계산합니다. 손으로 쓴 커널은 새 아키텍처가 나올 때마다 재검증 대상입니다. 라이브러리 대비 20퍼센트 빠른 커널이 2년 뒤에는 30퍼센트 느려져 있을 수 있습니다.
- 한 단계 위 층에서 해결되는지 먼저 봅니다. 다음 글에서 다룰 Triton으로 같은 커널을 20줄에 짜서 비슷한 성능이 나온다면, CUDA C++ 버전을 유지할 이유가 줄어듭니다.
마치며 — 커널을 고친다는 것은 데이터가 움직이는 순서를 고치는 일이다
이 글의 실습에는 부동소수점 연산이 한 번도 등장하지 않았습니다. 그런데도 첫 버전과 마지막 버전 사이에 5배 차이가 났습니다. 바뀐 것은 같은 데이터를 어떤 순서로 읽고 어디에 잠깐 세워 두었다가 어떤 순서로 쓰는가, 그것뿐입니다.
이 사실이 GPU 커널 작업의 성격을 규정합니다. 연산량을 줄이는 알고리즘적 개선은 대개 라이브러리가 이미 해 두었거나 우리 문제에서는 바꿀 수 없습니다. 우리에게 남는 지렛대는 메모리 계층 안에서의 데이터 배치와 이동 순서이고, 다행히 그쪽이 훨씬 큰 지렛대입니다.
작업 순서를 한 줄로 정리하면 이렇습니다. 이론 최소 트래픽을 계산하고, 하니스로 지금 몇 퍼센트를 쓰고 있는지 재고, Nsight Compute로 낭비의 위치를 특정하고, 배치를 고치고, 다시 잽니다. 감으로 고치고 빨라졌다고 말하는 것은 이 순서에서 측정 두 번을 빼먹은 것이며, 그렇게 얻은 결론은 다음 장비에서 뒤집힙니다.
참고 자료
- CUDA C++ Programming Guide: https://docs.nvidia.com/cuda/cuda-c-programming-guide/
- CUDA C++ Best Practices Guide (코얼레싱, 뱅크 충돌 절 포함): https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/
- An Efficient Matrix Transpose in CUDA C/C++ (이 실습의 원전): https://developer.nvidia.com/blog/efficient-matrix-transpose-cuda-cc/
- Nsight Compute CLI 문서: https://docs.nvidia.com/nsight-compute/NsightComputeCli/index.html
- Nsight Compute 커널 프로파일링 가이드(섹션 설명): https://docs.nvidia.com/nsight-compute/ProfilingGuide/index.html
- Volkov, Better Performance at Lower Occupancy (GTC 2010): https://www.nvidia.com/content/gtc-2010/pdfs/2238_gtc2010.pdf
- Williams et al., Roofline: An Insightful Visual Performance Model: https://dl.acm.org/doi/10.1145/1498765.1498785
What It Really Means to Hand-Tune a GPU Kernel — Making One Transpose Kernel 5x Faster
- Introduction — When the Kernel the Profiler Points At Is Not in Any Library
- What a Kernel Is, and Why You End Up Modifying One
- The Execution Model — Threads, Warps, Blocks, Grids
- The Memory Hierarchy — Coalescing and Bank Conflicts
- Occupancy and the Roofline — What to Look At and What to Ignore
- Hands-On — Fixing a Transpose Kernel in Four Stages
- What Nsight Compute Shows You
- Closing — Fixing a Kernel Means Fixing the Order Data Moves In
- References
Introduction — When the Kernel the Profiler Points At Is Not in Any Library
You hit a training step that takes 420ms, and 170ms of it sits inside one kernel with no recognizable name — and suddenly your options shrink. If it's a cuBLAS call, there's nothing to touch. If it's a PyTorch operator, you can try swapping in another operator. But if that kernel is something someone hastily wrote to handle your model's unusual masking logic, you're the only one who can fix it.
This post starts from that point. It covers what "hand-tuning" a GPU kernel actually involves — what you need to know, and what you need to measure. The short version: eight-tenths of kernel optimization is not about cutting compute, it's about changing the order in which memory moves. The kernel we fix in this post's hands-on section doesn't contain a single arithmetic operation. And yet it still gets more than 5x faster.
The reference environment is CUDA Toolkit 13.3 Update 1 and the Nsight Compute 2026.2 line. The concepts don't age with hardware generations, but tool flag names do change, so check your command lines against your own version's Nsight Compute CLI documentation.
What a Kernel Is, and Why You End Up Modifying One
A kernel is a single function that runs on the GPU. The difference from a CPU function is that one call launches tens of thousands of instances simultaneously. Each instance is a thread, and a thread figures out its own index from a built-in variable and processes only the data slice that corresponds to it.
Situations where hand-modifying a kernel is actually worthwhile are narrower in practice than you'd think. Checked in order, it looks like this.
| Situation | What to do first | Is it a reason to modify the kernel? |
|---|---|---|
| Standard GEMM or convolution is slow | Check cuBLAS/cuDNN version, data type, tensor-core path | Almost never |
| Several small operators run back to back | Coax fusion out of torch.compile | Usually not |
| You need an attention variant | Check whether a FlashAttention-family library already has that variant | If not, yes |
| One kernel uses only 20 percent of theoretical bandwidth | Find the cause with Nsight Compute | Yes |
| Indexing, masking, or sparsity patterns unique to your domain | No library to substitute | Yes |
The key is the last two rows. If a library can replace it, replacing it is always better. Library kernels carry far more tuning than you could ever spend the time on, and they get updated for you when a new architecture ships. A kernel you wrote by hand is one you have to maintain for the rest of its life.
The Execution Model — Threads, Warps, Blocks, Grids
To modify a kernel you need to know how the hardware groups threads. There are four levels of hierarchy.
Grid The entire set of threads for one kernel launch
└ Block Placed together on the same SM, shares shared memory and __syncthreads()
└ Warp 32 threads. The real unit of scheduling and instruction issue
└ Thread A single execution flow with its own registers
The single most practically important fact here is that the warp is the real unit. Programmers write code per-thread, but the hardware issues 32 of them as one bundle. Two consequences follow from this.
First, branch divergence. If threads within one warp take different branches, the hardware executes both paths sequentially, disabling whichever threads don't apply to the current path. A conditional that splits inside a warp adds to execution time. A conditional that splits cleanly at warp boundaries is free.
Second, memory access is also coalesced at the warp level. That's coalescing, the subject of the next section.
The default when choosing a block size is to make it a multiple of 32. If the block size is 33, the hardware issues two warps and 31 lanes of the second one sit idle.
The Memory Hierarchy — Coalescing and Bank Conflicts
GPU memory comes in layers, and latency and bandwidth differ by orders of magnitude between layers.
| Layer | Scope | Rough character |
|---|---|---|
| Registers | One thread | Fastest. Count per thread governs occupancy |
| Shared memory | One block | SRAM inside the SM. A programmer-managed cache |
| L1 / texture cache | One SM | Physically shares the same storage as shared memory |
| L2 cache | Whole GPU | Shared by every SM. The last line of defense before HBM |
| HBM (global memory) | Whole GPU | Large capacity, but latency runs into the hundreds of cycles |
Coalescing
Global memory accesses are handled as 32-byte transactions. If the 32 threads of one warp read 32 consecutive floats — 128 bytes — it finishes in four transactions. Conversely, if the same 32 threads read 32 floats spaced 4096 apart, it needs 32 transactions, and each one fetches 32 bytes just to use 4 and discard the rest. That's using only one-eighth of the bandwidth.
This is the single largest cause of kernel performance differences. In the hands-on section you'll see exactly this 8x waste.
Bank conflicts
Shared memory is interleaved across 32 banks. On a 4-byte-word basis, the bank number is the address divided by 4, then taken modulo 32. If the threads of one warp touch different banks, it's handled in one cycle; if they touch different addresses within the same bank, accesses get serialized by that many.
The classic accident is column-wise access into a square shared array. If you sweep tile[i][0] over i in a tile[32][32] array, every element lands on bank 0 — a 32-way conflict. The fix is to widen the array by one column. Declaring tile[32][33] shifts each row's starting bank by one, so column access spreads evenly across all 32 banks. It's a trade: 32 * 4 bytes, i.e. 128 extra bytes per block of shared memory, in exchange for removing the serialization.
Occupancy and the Roofline — What to Look At and What to Ignore
Occupancy is a symptom, not a goal
Occupancy is the number of warps actually resident on an SM divided by the maximum it can host. A common mistake for beginners is treating this as a target to maximize. It isn't.
Occupancy does exactly one job: hiding latency. If one warp is waiting on an HBM response and another warp runs in the meantime, the SM never sits idle. So occupancy is a proxy for the question "do we have enough warps to hide latency" — it is not performance in itself.
There are two cases where lower occupancy is actually faster.
First, when a thread uses many registers to extract instruction-level parallelism. If a single thread has four independent loads in flight at once, the total volume of in-flight memory requests stays the same even with a quarter as many warps. Vasily Volkov's Better Performance at Lower Occupancy addresses this thesis head-on. It's from 2010, but the argument still holds today.
Second, when the kernel is already pinned to the bandwidth ceiling. If the memory pipe is saturated, adding more warps has nowhere to go.
So the practical rule is: check occupancy only when it's low. If it's dropped below 25 percent and the kernel is latency-bound, suspect register usage or shared-memory allocation. If it reads 60 percent and the kernel is still slow, occupancy isn't the culprit — look elsewhere.
Roofline — most kernels are bandwidth-bound
The tool for deciding where to look is the roofline. Its axis is arithmetic intensity — operations performed per byte moved.
Achieved performance (FLOP/s)
^
| ______________ compute ceiling
| /
| / slope = memory bandwidth
| /
+--------+-------------------> arithmetic intensity (FLOP/Byte)
ridge point
ridge point = (compute ceiling FLOP/s) / (memory bandwidth Byte/s)
The ridge point is a hardware property. On modern datacenter GPUs this value falls somewhere in the tens to hundreds of FLOP/Byte. But when you compute the arithmetic intensity of the kernels we actually use, most of them are single digits.
| Operation | Rough arithmetic intensity | Where it lands |
|---|---|---|
| Elementwise addition | 1 FLOP / 12 Byte | Extremely memory-bound |
| Activation function | a few FLOP / 8 Byte | Memory-bound |
| Matrix transpose | 0 FLOP / 8 Byte | Pure memory |
| LayerNorm | low single-digit FLOP / Byte | Memory-bound |
| GEMM (large matrices) | up to hundreds, proportional to tile size | Compute-bound |
| LLM decode step | under 2 when batch is small | Memory-bound |
Reading it is simple. If arithmetic intensity sits far below the ridge point, that kernel's performance ceiling is already fixed. Cutting compute instructions further does nothing; the only improvement that matters is reducing the bytes moved, or fixing how they move.
So the first question in kernel optimization is always: "what is this kernel's theoretical minimum traffic, and how many bytes is it actually moving right now?"
Hands-On — Fixing a Transpose Kernel in Four Stages
Now let's actually fix one. The target is a 4096 x 4096 float matrix transpose. Compute count is zero, so only the memory story remains — which makes it an ideal teaching example.
The theoretical minimum traffic is clear. One read and one write means 2 * 4096 * 4096 * 4 bytes, about 134MB. There's no way to move less than that. So the performance ceiling is "a kernel that just copies without transposing," and we measure that first as our baseline.
Full code
// transpose.cu
// build: nvcc -O3 -arch=sm_80 transpose.cu -o transpose
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>
static const int TILE = 32;
static const int BLOCK_ROWS = 8; // 32x8 = 256 threads per block
static const int N = 4096;
#define CHECK(x) do { cudaError_t e_ = (x); if (e_ != cudaSuccess) { \
printf("CUDA error: %s (line %d)\n", cudaGetErrorString(e_), __LINE__); \
exit(1); } } while (0)
// Stage 0. Upper bound: copy only, no transpose.
__global__ void copyKernel(float *out, const float *in) {
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = in[(y + j) * N + x];
}
// Stage 1. naive: the read is coalesced, but the write strides by N.
__global__ void transposeNaive(float *out, const float *in) {
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[x * N + (y + j)] = in[(y + j) * N + x];
}
// Stage 2. shared-memory tile: finish the transpose inside SRAM,
// so both the global read and the global write are coalesced.
__global__ void transposeShared(float *out, const float *in) {
__shared__ float tile[TILE][TILE];
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = in[(y + j) * N + x];
__syncthreads();
// Swap the block coordinates so the write also lands on consecutive addresses.
x = blockIdx.y * TILE + threadIdx.x;
y = blockIdx.x * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = tile[threadIdx.x][threadIdx.y + j];
}
// Stage 3. remove the shared-memory bank conflict with one column of padding.
__global__ void transposePadded(float *out, const float *in) {
__shared__ float tile[TILE][TILE + 1]; // the only difference
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = in[(y + j) * N + x];
__syncthreads();
x = blockIdx.y * TILE + threadIdx.x;
y = blockIdx.x * TILE + threadIdx.y;
for (int j = 0; j < TILE; j += BLOCK_ROWS)
out[(y + j) * N + x] = tile[threadIdx.x][threadIdx.y + j];
}
typedef void (*Kern)(float *, const float *);
static void bench(const char *name, Kern k, float *d_out, const float *d_in,
const float *h_ref, float *h_out, bool checkTranspose) {
dim3 grid(N / TILE, N / TILE), block(TILE, BLOCK_ROWS);
const int WARMUP = 5, ITERS = 50;
const double bytes = 2.0 * N * N * sizeof(float);
for (int i = 0; i < WARMUP; i++) k<<<grid, block>>>(d_out, d_in);
CHECK(cudaDeviceSynchronize());
cudaEvent_t t0, t1;
CHECK(cudaEventCreate(&t0));
CHECK(cudaEventCreate(&t1));
CHECK(cudaEventRecord(t0));
for (int i = 0; i < ITERS; i++) k<<<grid, block>>>(d_out, d_in);
CHECK(cudaEventRecord(t1));
CHECK(cudaEventSynchronize(t1));
float ms = 0.f;
CHECK(cudaEventElapsedTime(&ms, t0, t1));
double perIter = ms / ITERS;
double gbs = bytes / (perIter * 1.0e-3) / 1.0e9;
// A performance number with no correctness check is meaningless.
CHECK(cudaMemcpy(h_out, d_out, (size_t)N * N * sizeof(float),
cudaMemcpyDeviceToHost));
long bad = 0;
for (long r = 0; r < N && bad == 0; r++)
for (long c = 0; c < N; c++) {
float want = checkTranspose ? h_ref[c * N + r] : h_ref[r * N + c];
if (h_out[r * N + c] != want) { bad++; break; }
}
printf("%-18s %8.3f ms %8.1f GB/s %s\n", name, perIter, gbs,
bad ? "FAIL" : "ok");
CHECK(cudaEventDestroy(t0));
CHECK(cudaEventDestroy(t1));
}
int main() {
size_t bytes = (size_t)N * N * sizeof(float);
float *h_in = (float *)malloc(bytes), *h_out = (float *)malloc(bytes);
for (long i = 0; i < (long)N * N; i++) h_in[i] = (float)(i % 1000);
float *d_in, *d_out;
CHECK(cudaMalloc(&d_in, bytes));
CHECK(cudaMalloc(&d_out, bytes));
CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));
cudaDeviceProp p;
CHECK(cudaGetDeviceProperties(&p, 0));
printf("%s peak HBM = %.1f GB/s\n\n", p.name,
2.0 * p.memoryClockRate * (p.memoryBusWidth / 8) / 1.0e6);
bench("copy (upper bound)", copyKernel, d_out, d_in, h_in, h_out, false);
bench("naive", transposeNaive, d_out, d_in, h_in, h_out, true);
bench("shared tile", transposeShared, d_out, d_in, h_in, h_out, true);
bench("shared + padding", transposePadded, d_out, d_in, h_in, h_out, true);
cudaFree(d_in); cudaFree(d_out); free(h_in); free(h_out);
return 0;
}
What matters about the measurement method
For the numbers to be trustworthy, the harness has to be honest first. The code above follows five rules.
- Discard the warmup. The first call has context creation and module loading mixed into it.
- Average over repeated measurements. If a single kernel runs around 1ms, clock variation alone can swing the result by 10 percent.
- Use cudaEvent, not a CPU timer. Kernel launches are asynchronous, so a CPU timer only measures how long it took to submit.
- Verify correctness. A broken index is usually faster. A GB/s number with no verification is just a number game.
- Convert to effective bandwidth, not raw time. Absolute time depends on size and hardware, but the percentage of theoretical bandwidth is comparable anywhere.
The effective-bandwidth formula is simple: divide the minimum bytes that must move by the time it actually took. The "must" is the important word here — use the bytes the algorithm actually requires, not however many bytes were wasted in transit. That's what makes the waste show up in the number.
The shape of the results
Below is the typical shape you get running this harness on an A100 80GB (sm_80)-class machine. Absolute values vary a lot by device, driver, and clock state, so don't quote these numbers as-is — run the code above on your own GPU and build your own baseline. What matters is the relative ratio between stages.
| Stage | Effective bandwidth | Vs. copy | Bottleneck |
|---|---|---|---|
| copy (ceiling) | baseline 100 | 100 percent | None. HBM saturated |
| naive | about 18 | about 18 percent | Non-coalesced write. Only 4 bytes used per transaction |
| shared tile | about 63 | about 63 percent | 32-way shared-memory bank conflict |
| shared + padding | about 93 | about 93 percent | Essentially none. Only tile-boundary effects remain |
Three things are worth reading here.
First, naive sits at about a fifth of the ceiling. Zero compute, the same amount of data moved, and yet it's 5x slower. The only difference is order. The write is scattered at N-wide strides, so out of every 32-byte transaction only 4 bytes get used and 28 are discarded. An 8x waste shows up mixed with other effects as a 5x difference.
Second, the shared tile recovers most of the gap but doesn't get all the way there. Both global accesses are now fixed, but the bottleneck moved inside SRAM. tile[threadIdx.x][threadIdx.y + j] is a column-wise access, and in a 32x32 square array every column access lands on the same bank.
Third, the code difference in the final stage is one character in an array declaration. Changing [TILE] to [TILE + 1] is the whole fix. Kernel optimization often looks exactly like this — not an algorithmic change, just shifting the data layout by one slot.
Common failure modes
Landmines actually stepped on often in this exercise.
- Missing
__syncthreads(). Without a sync between filling the shared tile and reading it back, results go wrong non-deterministically. Small inputs happen to come out right often enough to be more dangerous. - Putting
__syncthreads()inside a branch. Placed where only some threads in a block reach it, this is undefined behavior. - Forgetting to swap block coordinates in the index math. If you add the shared tile but leave the output index alone, the write becomes strided again and stage 2's gain disappears. The result is correct but not faster — the hardest form of this bug to notice.
- Measuring without
-O3. Without host-code optimization, the verification loop dominates the measured time and flips the conclusion. - N too small. Kernel launch overhead is a few microseconds; if the total time is tens of microseconds, you're measuring overhead.
What Nsight Compute Shows You
The profiler is what tells you the cause when a number comes back bad. Let's start with the command line.
# Collect every section. One kernel can take hundreds of ms, so narrow the target.
ncu --set full \
--kernel-name regex:transpose \
--launch-skip 5 --launch-count 1 \
-o transpose_report \
./transpose
# Pull only the metrics you need to pin down the cause (much faster)
ncu --metrics \
sm__throughput.avg.pct_of_peak_sustained_elapsed,\
gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed,\
l1tex__data_bank_conflicts_pipe_lsu_mem_shared.sum,\
l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio \
--kernel-name regex:transpose --launch-count 1 ./transpose
# Open in the GUI
ncu-ui transpose_report.ncu-rep
Skipping the warmup runs with --launch-skip matters. The profiler shows you the cold-cache state of the first run exactly as it is, so without skipping you end up analyzing something that isn't steady state.
Opening the report gives you several sections, and in practice there's a fixed order to go through them.
1. Speed of Light. Shows compute throughput and memory throughput as a percentage of hardware theoretical maximum. This is where direction gets decided. If memory is above 80 percent, you're pinned to bandwidth; if both are under 30 percent, it's a latency or occupancy problem. Our naive transpose kernel comes back low on both in this view — not because the pipe is saturated, but because it's wasting.
2. Memory Workload Analysis. This is the crux. It shows how many sectors were fetched per request; a perfectly coalesced 32-thread float load is 4 sectors per request. The naive kernel's write comes back at 32 sectors per request. The 8x waste shows up in this one line exactly as it is. This single metric answers "is it a coalescing problem" directly.
3. Shared Memory metrics. Shows the bank-conflict count. Stage 2's kernel spikes here, and stage 3 drops it to near zero. This is where you confirm the padding actually worked.
4. Warp State Statistics. Shows, by category, the reasons warps were stalled. If Stall Long Scoreboard dominates, that's waiting on a global memory response; if it's Stall MIO Throttle, that's congestion on shared memory or special-function units. It splits the cause into memory versus instructions.
5. Occupancy. Look at this last. When the first four are clean and it's still slow, that's when you check whether resident warps are too few. Skip this order and look at occupancy first, and you usually end up optimizing in the wrong direction.
Nsight Compute also has a roofline section, which shows in a chart whether our kernel sits on the slope or on the flat part. The transpose kernel has zero arithmetic intensity, so it plots at the far left edge — and that alone tells you "there is no compute to optimize here."
When to stop this work
Once you've pushed the transpose kernel to 93 percent of the ceiling, there's little reason to chase the remaining 7 percent. It's worth having a stopping rule set in advance.
- Stop once you've crossed 90 percent of theoretical minimum traffic. In a memory-bound kernel, anything above that is tile-boundary and TLB effects, where the return on effort drops off sharply.
- Re-measure this kernel's share of total runtime. If you took it from 170ms to 40ms, the bottleneck is now somewhere else. Amdahl's law holds just as much for kernel optimization.
- Factor in maintenance cost. A hand-written kernel is up for revalidation every time a new architecture ships. A kernel that's 20 percent faster than the library today can be 30 percent slower two years from now.
- Check first whether it's solved one layer up. If the next post's Triton version of the same kernel gets written in 20 lines and lands at similar performance, that's much less reason to keep maintaining the CUDA C++ version.
Closing — Fixing a Kernel Means Fixing the Order Data Moves In
Not a single floating-point operation appeared in this post's hands-on section. And yet there was a 5x gap between the first version and the last. What changed was only this: in what order the same data was read, where it briefly sat, and in what order it was written back.
That fact defines the character of GPU kernel work. Algorithmic improvements that cut compute are usually already done by a library, or simply not available to change in our problem. What's left as a lever is data placement and movement order within the memory hierarchy — and fortunately, that's the much bigger lever.
The work order boils down to one line: compute the theoretical minimum traffic, measure with a harness what percentage you're currently using, pinpoint the location of the waste with Nsight Compute, fix the layout, and measure again. Fixing by feel and calling it faster skips two measurements out of this sequence, and a conclusion reached that way flips on the next machine.
References
- CUDA C++ Programming Guide: https://docs.nvidia.com/cuda/cuda-c-programming-guide/
- CUDA C++ Best Practices Guide (includes coalescing and bank-conflict sections): https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/
- An Efficient Matrix Transpose in CUDA C/C++ (the origin of this hands-on exercise): https://developer.nvidia.com/blog/efficient-matrix-transpose-cuda-cc/
- Nsight Compute CLI documentation: https://docs.nvidia.com/nsight-compute/NsightComputeCli/index.html
- Nsight Compute kernel profiling guide (section descriptions): https://docs.nvidia.com/nsight-compute/ProfilingGuide/index.html
- Volkov, Better Performance at Lower Occupancy (GTC 2010): https://www.nvidia.com/content/gtc-2010/pdfs/2238_gtc2010.pdf
- Williams et al., Roofline: An Insightful Visual Performance Model: https://dl.acm.org/doi/10.1145/1498765.1498785