병렬 컴퓨팅의 한 중요한 개념은 작업을 효율적으로 분산 처리하기 위한 구조를 이해하는 것이다. 여기에는 NVIDIA의 CUDA(Compute Unified Device Architecture)와 같은 GPU 프로그래밍 모델이 자주 사용된다. CUDA는 작업을 병렬 방식으로 분산 처리하기 위해 스레드 블록과 그리드의 개념을 도입했다.

스레드 블록

스레드 블록(Thread Block)은 CUDA 프로그래밍 모델에서 기본적인 연산 단위이다. 하나의 커널이 여러 스레드 블록으로 나뉘며, 각 블록 내에는 다수의 스레드가 존재한다. 이 스레드들은 물리적으로 같은 Streaming Multiprocessor(SM) 내에서 실행된다.

스레드 블록의 주요 특징은 다음과 같다:

스레드 블록의 크기는 다음과 같은 형태로 지정할 수 있다:

dim3 dimBlock(16, 16);

여기서 \texttt{dim3}는 3차원 크기를 지정하는 CUDA 내의 자료형이다.

그리드(Grid) 구성

그리드(Grid)는 다수의 스레드 블록으로 구성된 상위 구조이다. 커널 실행 시, 여러 개의 스레드 블록이 그리드 내에서 동작하며, 이는 전체 입력 데이터에 대한 병렬 처리를 가능하게 한다.

그리드의 주요 특징은 다음과 같다:

그리드의 크기는 다음과 같은 형태로 지정할 수 있다:

dim3 dimGrid((N + dimBlock.x - 1) / dimBlock.x, (M + dimBlock.y - 1) / dimBlock.y);

여기서 \texttt{dimGrid}도 \texttt{dim3} 자료형으로 지정되는 3차원 벡터이다. 예를 들어, 2차원 배열을 처리하는 경우 \texttt{N}과 \texttt{M}은 해당 배열의 크기를 의미한다.

예제: 행렬 곱셈

스레드 블록과 그리드 구성을 이해하기 위해 행렬 곱셈의 예제를 살펴보자. 두 개의 행렬 \mathbf{A}\mathbf{B}를 곱하여 행렬 \mathbf{C}를 계산하는 작업을 병렬 처리하는 방식이다.

행렬 곱셈은 다음과 같이 표현된다:

\mathbf{C}[i][j] = \sum_{k} \mathbf{A}[i][k] \cdot \mathbf{B}[k][j]

각 행렬 요소를 계산하기 위해 스레드 블록과 그리드를 어떻게 활용할 수 있는지를 여기서 기술한다. 다음은 CUDA 커널 코드의 예제이다:

__global__ void MatrixMultiplyKernel(float *A, float *B, float *C, int N) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    float Cvalue = 0;

    if(row < N && col < N) {
        for (int e = 0; e < N; ++e)
            Cvalue += A[row * N + e] * B[e * N + col];
        C[row * N + col] = Cvalue;
    }
}

CUDA는 이 함수를 수천 개의 병렬 스레드에서 실행하며, 각 스레드는 자신의 인덱스를 기반으로 \mathbf{C}의 특정 요소를 계산한다.

메모리 관리와 최적화

전역 메모리(Global Memory)

전역 메모리는 모든 스레드에서 접근 가능하며, 대규모 데이터 집합을 저장하는 공간이다. 하지만 전역 메모리는 접근 시간이 길어, 이를 효율적으로 사용하는 것이 중요하다. 전역 메모리는 다음과 같은 방식으로 선언 및 사용된다:

__global__ void kernelFunction(float *d_array) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    d_array[idx] = ...;
}

이 코드에서 d_array는 전역 메모리의 배열이다.

공유 메모리(Shared Memory)

공유 메모리는 특정 스레드 블록 내에서만 접근 가능한 메모리 공간이다. 접근 속도가 빠르기 때문에 스레드 간 데이터 공유에 유리하다. 간단한 예제를 통해 공유 메모리를 사용하는 방법을 살펴보자:

__global__ void kernelFunction(float *d_array) {
    __shared__ float sharedArray[BLOCK_SIZE];

    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    sharedArray[threadIdx.x] = d_array[idx];
    __syncthreads();

    ... // sharedArray를 이용한 연산
}

이 코드에서 sharedArray는 공유 메모리로 선언된 배열이다. 이 배열은 같은 블록 내의 모든 스레드가 공유하여 사용할 수 있다.

콘스턴트 메모리(Constant Memory)

콘스턴트 메모리는 읽기 전용 메모리로, 작은 크기의 데이터(최대 64KB)를 빠르게 접근할 수 있는 특징을 가진다. 주로 모든 스레드가 동일하게 접근해야 하는 상수 데이터를 저장하는 데 사용된다. 예를 들어, 다음과 같이 상수 메모리를 선언할 수 있다:

__constant__ float constArray[SIZE];

최적화 기법

최적화를 통해 CUDA 프로그램의 성능을 크게 향상시킬 수 있다. 몇 가지 주요 최적화 기법은 다음과 같다:

메모리 코어렌시(Memory Coalescing)

메모리 코어렌시는 연속된 메모리 접근 패턴을 갖도록 하여 전역 메모리 접근을 최적화하는 방법이다. 예를 들어, 다음과 같은 방식으로 메모리 코어렌시를 달성할 수 있다:

int idx = threadIdx.x + blockIdx.x * blockDim.x;
int stride = blockDim.x * gridDim.x;
for (int i = idx; i < N; i += stride) {
    d_array[idx] = ...;
}
은닉 및 동기화

GPU는 많은 스레드를 동시에 실행하며, 이는 메모리 접근 지연을 감추는 데 유리하다. 또한, 스레드 간 동기화는 성능을 저하시키지 않도록 주의해야 한다. 예를 들어, 중요한 데이터 공유 시 필요한 동기화는 최소화해야 한다.

__syncthreads();  // 필요할 때만 사용

성능 분석 도구

CUDA에는 NVidia Visual Profiler와 같은 성능 분석 도구가 있어서 병목 현상을 식별하고 최적화할 수 있다. 이러한 도구를 사용해 커널의 실행 시간을 분석하고, 메모리 접근 패턴을 확인하여 최적화를 진행할 수 있다.