
목차
GPU Occupancy는 GPU의 성능을 최대한 활용하기 위한 핵심 지표입니다. 이 글에서는 GPU Occupancy를 높이기 위한 다양한 최적화 기법들을 심층적으로 분석합니다. 최신 정보를 기반으로 Occupancy 극대화를 위한 전략과 실제 코드 예시, 그리고 주의사항까지 꼼꼼하게 다루어, 독자 여러분이 GPU 연산 능력을 최대한 끌어올릴 수 있도록 돕겠습니다.
Occupancy란 무엇인가
GPU Occupancy는 GPU가 동시에 실행할 수 있는 워프(warp)의 비율을 나타내는 지표입니다. 워프는 GPU의 최소 실행 단위이며, Occupancy가 높을수록 GPU의 연산 자원을 더 효율적으로 활용할 수 있습니다. 높은 Occupancy는 일반적으로 더 높은 성능으로 이어지지만, 항상 그런 것은 아닙니다. Occupancy는 이론적인 최대치를 나타낼 뿐이며, 실제 성능은 메모리 접근 패턴, 연산 복잡도, 그리고 워프 스케줄링 등의 다른 요인에 의해서도 영향을 받습니다.
Occupancy는 다음과 같은 공식으로 계산됩니다.
Occupancy = (Active Warps per Multiprocessor) / (Maximum Warps per Multiprocessor)
예를 들어, 멀티프로세서당 최대 워프 수가 64이고, 활성 워프 수가 32라면 Occupancy는 50%가 됩니다.
레지스터 사용량 줄이기
각 스레드가 사용하는 레지스터의 수는 Occupancy에 직접적인 영향을 미칩니다. 레지스터 사용량이 많아지면 GPU가 동시에 실행할 수 있는 워프 수가 줄어들어 Occupancy가 감소합니다. 따라서, 불필요한 레지스터 사용을 줄이는 것이 중요합니다.
다음은 레지스터 사용량을 줄이는 몇 가지 방법입니다.
- 변수 재사용: 더 이상 사용하지 않는 변수를 재사용하여 레지스터 할당을 최소화합니다.
- 데이터 타입 최적화: 필요 이상의 정밀도를 가진 데이터 타입을 사용하지 않도록 합니다. 예를 들어, float 대신 half를 사용하는 것을 고려할 수 있습니다.
- 컴파일러 최적화 활용: 컴파일러 최적화 옵션을 활용하여 불필요한 레지스터 사용을 줄입니다. CUDA 컴파일러(nvcc)는 다양한 최적화 옵션을 제공합니다.
다음은 CUDA 코드 예시입니다.
__global__ void kernel(float* input, float* output, int size) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < size) {
float temp = input[idx] * 2.0f;
output[idx] = temp + 1.0f;
}
}
위 코드에서 temp 변수를 재사용하여 레지스터 사용량을 줄일 수 있습니다.
Shared Memory 활용 극대화
Shared Memory는 각 스레드 블록 내의 스레드들이 공유할 수 있는 온칩(on-chip) 메모리입니다. Global Memory에 비해 접근 속도가 훨씬 빠르기 때문에, 자주 사용되는 데이터는 Shared Memory에 저장하여 성능을 향상시킬 수 있습니다. 하지만, Shared Memory의 용량은 제한적이므로 효율적인 활용이 중요합니다.
다음은 Shared Memory를 활용하는 몇 가지 방법입니다.
- Tile 기반 연산: 데이터를 작은 타일(tile)로 나누어 Shared Memory에 로드하고, 타일 내의 스레드들이 데이터를 공유하며 연산을 수행합니다.
- 데이터 재사용: Shared Memory에 로드된 데이터를 여러 번 재사용하여 Global Memory 접근 횟수를 줄입니다.
- 뱅크 충돌 최소화: Shared Memory는 뱅크(bank)라는 작은 단위로 나뉘어져 있으며, 동시에 같은 뱅크에 접근하는 스레드가 많을 경우 뱅크 충돌이 발생하여 성능이 저하될 수 있습니다. 뱅크 충돌을 최소화하기 위해 메모리 접근 패턴을 신중하게 설계해야 합니다.
다음은 CUDA 코드 예시입니다.
__global__ void matrix_multiplication(float* A, float* B, float* C, int width) {
__shared__ float tileA[TILE_WIDTH][TILE_WIDTH];
__shared__ float tileB[TILE_WIDTH][TILE_WIDTH];
int row = blockIdx.y * TILE_WIDTH + threadIdx.y;
int col = blockIdx.x * TILE_WIDTH + threadIdx.x;
float sum = 0.0f;
for (int k = 0; k < width / TILE_WIDTH; ++k) {
tileA[threadIdx.y][threadIdx.x] = A[row * width + k * TILE_WIDTH + threadIdx.x];
tileB[threadIdx.y][threadIdx.x] = B[(k * TILE_WIDTH + threadIdx.y) * width + col];
__syncthreads();
for (int i = 0; i < TILE_WIDTH; ++i) {
sum += tileA[threadIdx.y][i] * tileB[i][threadIdx.x];
}
__syncthreads();
}
C[row * width + col] = sum;
}
위 코드는 행렬 곱셈을 수행하는 CUDA 커널입니다. Shared Memory를 사용하여 행렬 데이터를 타일 단위로 로드하고, 스레드들이 데이터를 공유하며 연산을 수행합니다.
워프 발산(Warp Divergence) 줄이기
워프는 32개의 스레드로 구성되어 있으며, 워프 내의 모든 스레드는 기본적으로 동일한 명령어를 실행합니다. 하지만, 조건문 등으로 인해 워프 내의 스레드들이 서로 다른 분기(branch)를 실행하게 되면 워프 발산이 발생합니다. 워프 발산이 발생하면 GPU는 각 분기를 순차적으로 실행해야 하므로 성능이 저하됩니다.
다음은 워프 발산을 줄이는 몇 가지 방법입니다.
- 조건문 최소화: 가능하면 조건문 사용을 줄이거나, 워프 내의 모든 스레드가 동일한 분기를 실행하도록 코드를 재구성합니다.
- 데이터 정렬: 데이터를 정렬하여 워프 내의 스레드들이 유사한 연산을 수행하도록 합니다.
- Predicate 활용: Predicate를 사용하여 특정 스레드의 연산을 비활성화합니다.
다음은 CUDA 코드 예시입니다.
__global__ void kernel(float* input, float* output, int size) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < size) {
if (input[idx] > 0.0f) {
output[idx] = input[idx] * 2.0f;
} else {
output[idx] = input[idx] / 2.0f;
}
}
}
위 코드에서 조건문으로 인해 워프 발산이 발생할 수 있습니다. 워프 발산을 줄이기 위해 다음과 같이 코드를 변경할 수 있습니다.
__global__ void kernel(float* input, float* output, int size) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < size) {
float value = input[idx];
float result = (value > 0.0f) ? value * 2.0f : value / 2.0f;
output[idx] = result;
}
}
적절한 Block Size 선택
Block Size는 각 스레드 블록에 포함된 스레드의 수를 결정합니다. 적절한 Block Size를 선택하는 것은 Occupancy와 성능에 큰 영향을 미칩니다. Block Size가 너무 작으면 GPU의 연산 자원을 충분히 활용하지 못하고, 너무 크면 레지스터 사용량이 증가하여 Occupancy가 감소할 수 있습니다.
적절한 Block Size는 GPU 아키텍처와 커널의 특성에 따라 다르지만, 일반적으로 128 ~ 256 사이의 값을 사용하는 것이 좋습니다. 다양한 Block Size를 시도하여 최적의 값을 찾는 것이 중요합니다.
CUDA Occupancy Calculator를 사용하여 적절한 Block Size를 예측할 수 있습니다. CUDA Occupancy Calculator는 GPU 아키텍처, 레지스터 사용량, Shared Memory 사용량 등을 고려하여 최적의 Block Size를 제안합니다.
CUDA Profiler 활용 및 분석
CUDA Profiler는 CUDA 프로그램의 성능을 분석하고 최적화하는 데 유용한 도구입니다. CUDA Profiler를 사용하면 커널 실행 시간, 메모리 접근 패턴, 워프 발산 등의 정보를 얻을 수 있습니다.
CUDA Profiler를 사용하여 성능 병목 지점을 식별하고, 위에서 설명한 최적화 기법들을 적용하여 성능을 향상시킬 수 있습니다. 예를 들어, CUDA Profiler를 사용하여 워프 발산이 많이 발생하는 부분을 찾고, 해당 부분을 개선하여 성능을 높일 수 있습니다.
NVIDIA Nsight Systems, Nsight Compute 등의 툴을 활용하여 더욱 심도있는 분석이 가능합니다. 이러한 툴은 GPU의 동작을 시각적으로 보여주어, 성능 문제를 더욱 쉽게 파악하고 해결할 수 있도록 도와줍니다.