[CUDA & Modern C++로 GPU 프로그래밍 시작하기] #3: Host & Device 메모리 관리와 벡터 덧셈 맛보기

이제 우리는 Host(CPU)와 Device(GPU) 코드 구조를 이해하고, 간단한 커널 호출 예제를 통해 GPU 코드가 어떻게 동작하는지 살펴봤습니다. 이번 글에서는 실제로 데이터를 GPU로 보내고, 연산한 뒤 다시 결과를 가져오는 과정을 좀 더 구체적으로 다뤄볼 예정입니다. 즉, Host <-> Device 메모리 전송(cudaMemcpy)메모리 할당(cudaMalloc) 방법을 배우고, 이를 활용한 간단한 벡터 덧셈 예제를 통해 GPU 병렬 연산의 기본을 체험해보겠습니다.

etc-image-0

Host와 Device 메모리 관리의 기본

앞서 말했듯 Host와 Device는 별개의 메모리 공간을 사용합니다. 이는 마치 서로 다른 섬에 사는 두 존재가 다리를 통해 상자를 옮기는 상황과 비슷합니다. 여기서 상자는 데이터, 다리는 cudaMemcpy 같은 API라고 할 수 있습니다.

  • Host 메모리 할당: 그냥 new, malloc, std::vector 등을 통해 CPU 메모리에 변수를 확보하면 됩니다. 이건 우리가 익숙하게 해왔던 부분이죠.
  • Device 메모리 할당: GPU 메모리에 공간을 확보하기 위해 cudaMalloc 함수를 사용합니다.
    float* d_arr;
    cudaMalloc(&d_arr, size_in_bytes);
    
  • 데이터 복사(cudaMemcpy): Host 메모리에 있는 데이터를 Device 메모리로 복사하거나, 반대로 Device 결과를 Host로 가져올 때 사용합니다.
    cudaMemcpy(d_arr, h_arr, size_in_bytes, cudaMemcpyHostToDevice);  // H->D
    cudaMemcpy(h_arr, d_arr, size_in_bytes, cudaMemcpyDeviceToHost);  // D->H
    

이렇게 Host와 Device 메모리를 분리해서 생각하면, 데이터 이동 과정이 명확해집니다.

커널 호출과 연계하기

지난 글에서 보았던 커널 런칭 문법을 다시 떠올려봅시다.

kernel_function<<<블록수, 스레드수_per_블록>>>(...);

호출하기 전에 이미 GPU 메모리에 연산 대상 데이터가 있어야 GPU가 작업을 효율적으로 수행할 수 있습니다. 반대로, 연산이 끝난 뒤에는 결과를 Host로 가져와야 유용하게 활용할 수 있습니다.

이 흐름을 단계별로 정리하면 다음과 같습니다.

  1. Host에서 연산 대상 데이터 준비 (예: std::vector<float> 등)
  2. Device 메모리에 공간 할당(cudaMalloc)
  3. Host → Device 데이터 복사(cudaMemcpy)
  4. GPU 커널 런칭(<<<...>>>)
  5. GPU 연산 완료 대기(cudaDeviceSynchronize())
  6. Device → Host 결과 복사(cudaMemcpy)
  7. Device 메모리 해제(cudaFree)

이제 이 과정을 벡터 덧셈 예제로 구체화해봅시다.

벡터 덧셈 예제: CPU vs GPU

우리가 C++에서 벡터 덧셈을 할 때는 단순히 반복문을 돌며 요소-wise로 더해주면 됩니다. 예를 들어:

for (int i = 0; i < N; ++i) {
    c[i] = a[i] + b[i];
}

이 연산을 GPU에서 한다면, 모든 요소를 독립적으로 처리할 수 있으므로 수천 개의 스레드가 동시에 덧셈을 수행할 수 있습니다. 각각의 스레드가 벡터의 일부를 담당하게 되는 것이죠.

GPU 커널 코드 예제

__global__ void vector_add_kernel(const float* a, const float* b, float* c, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x; 
    if (idx < N) {
        c[idx] = a[idx] + b[idx];
    }
}
  • blockIdx.x, threadIdx.x는 CUDA에서 제공하는 내장 변수로, 각 스레드가 자신만의 인덱스를 계산하는 데 사용됩니다.
  • if (idx < N)는 전체 스레드 수가 N보다 크더라도 인덱스 범위를 벗어나지 않도록 안전장치를 두는 것입니다.

Host 코드에서의 흐름

#include <cstdio>
#include <vector>
#include <cstdlib>

// 벡터 덧셈 커널
__global__ void vector_add_kernel(const float* a, const float* b, float* c, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x; 
    if (idx < N) {
        c[idx] = a[idx] + b[idx];
    }
}

int main() {
    int N = 100000;
    size_t size = N * sizeof(float);

    // Host 메모리에 벡터 준비
    std::vector<float> h_a(N), h_b(N), h_c(N);
    for (int i = 0; i < N; ++i) {
        h_a[i] = static_cast<float>(rand()) / RAND_MAX;
        h_b[i] = static_cast<float>(rand()) / RAND_MAX;
    }

    // Device 메모리 할당
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, size);
    cudaMalloc(&d_b, size);
    cudaMalloc(&d_c, size);

    // Host -> Device 복사
    cudaMemcpy(d_a, h_a.data(), size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b.data(), size, cudaMemcpyHostToDevice);

    // 커널 런칭: 스레드와 블록 설정
    int threadsPerBlock = 256;
    int blocks = (N + threadsPerBlock - 1) / threadsPerBlock;
    vector_add_kernel<<<blocks, threadsPerBlock>>>(d_a, d_b, d_c, N);

    // GPU 연산 완료 대기
    cudaDeviceSynchronize();

    // Device -> Host 복사 (결과 가져오기)
    cudaMemcpy(h_c.data(), d_c, size, cudaMemcpyDeviceToHost);

    // 결과 확인(간단히 첫 몇 개 출력)
    for (int i = 0; i < 5; ++i) {
        printf("c[%d] = %f\n", i, h_c[i]);
    }

    // 메모리 해제
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);

    return 0;
}

여기서 핵심 포인트는 다음과 같습니다.

  • threadsPerBlock와 blocks로 스레드 개수를 조정해 전체 벡터를 커버.
  • cudaMalloc, cudaMemcpy, cudaFree로 Host-Device 간 데이터 흐름 명확하게 관리.
  • GPU 커널 내부에서 인덱스를 계산해 벡터 요소에 접근.

이 과정을 거치면 대량의 데이터를 짧은 시간에 병렬로 처리할 수 있게 됩니다.

Before vs After: 병렬 연산 준비 완료

이전까지는 단순히 “GPU에서 한 번 출력해보는 정도”였다면, 이제는 데이터 이동과 병렬 연산을 통해 Host-Device 협력 구조가 보입니다.

Before:

  • GPU 코드 실행 여부만 확인 (“Hello, GPU!” 출력)
  • 데이터 이동 개념이 불명확함

After:

  • Host에서 데이터 준비 → Device로 전송 → GPU 병렬 연산 → 결과 회수 과정 확립
  • 실제 연산 예제(벡터 덧셈)로 GPU 병렬성 체감
  • 이후 스레드, 블록, 그리드 최적화, 메모리 계층 구조 이해 등으로 확장 가능한 발판 마련

다음 글 예고

다음 글(#4)에서는 스레드와 블록, 그리고 그리드 개념을 좀 더 깊게 파헤칩니다. 벡터 덧셈 예제를 통해 우리가 설정한 블록과 스레드 개수의 의미를 명확히 알고, 이를 다양한 문제에 적용하는 방법을 알아보겠습니다.

유용한 링크 & 리소스

반응형