AEGLOSLAB 엔비디아 기술 스택

제2부 CUDA 프로그래밍

2.2 첫 CUDA 커널 II — 인덱싱·경계검사·검증

2.2.1 이 장에서 배우는 것 / 선수 지식

2.1에서 명시적 메모리로 첫 SAXPY를 빌드·실행했다. 이 장은 그 커널을 견고하고 정확하게 만드는 부분 — 경계 검사, grid-stride loop, 통합 메모리, 그리고 정확성 검증·디버깅 — 을 손에 익힌다.

배우는 것:

  • 경계 검사 if (i < n)가 왜 선택이 아니라 필수인지.
  • cudaMallocManaged(통합 메모리)로 코드를 단순화하되 동기화 의무가 남는 이유.
  • grid-stride loop로 임의 크기 배열을 어떤 런치 구성에도 견고하게 처리.
  • 명시적 vs 통합 메모리의 트레이드오프와 정확성 검증 규율.

선수 지식: 2.1(빌드·실행·호스트/디바이스 흐름·오류 검사). 실행 구성 설계는 2.3, 메모리 모델 상세는 2.4~2.5.

2.2.2 왜 경계 검사가 필요한가

블록당 스레드 수는 보통 32의 배수(워프 크기 32)로 고른다 — 256이 흔하다. 그런데 배열 길이 N이 256으로 나눠떨어지란 법이 없다. 그러면 마지막 블록의 일부 스레드는 배열 범위 밖을 가리킨다. 이 스레드들이 동기화 없이 쓰기를 하면 메모리 오버런이다. 그래서 커널의 필수 줄:

if (i < N) { /* 실제 작업 */ }
n = 10, block = 4 → grid = 3, 총 12 스레드 (전역 인덱스 i) 0 1 2 3 block 0 4 5 6 7 block 1 8 9 10 11 block 2

주황색 칸 i = 10, 11은 배열 범위 밖이라 if (i < n) 가드가 제외한다 — 올림 나눗셈으로 항상 grid*block ≥ n이기 때문이다.

(n+block-1)/block 올림 나눗셈은 항상 grid*block >= n이라 i >= n인 스레드가 존재한다. if (i < n) 가드를 빠뜨리면 그 스레드들이 배열 밖을 쓴다(메모리 오염·크래시·불법 접근). 디버그 빌드에선 실패하지 않고 릴리스에서만 깨지기도 한다 — 그래서 compute-sanitizer로 확인하는 습관이 중요하다(2.14).

2.2.3 grid-stride loop — 임의 크기를 견고하게

2.1의 "원소당 스레드 한 개" 버전은 매우 크거나 그리드 한도를 넘는 N에서 깨질 수 있고, 스레드 수를 데이터에 묶어버린다. grid-stride loop는 고정된 수의 스레드가 보폭(stride)만큼 건너뛰며 배열 전체를 순회하게 한다 — 어떤 N에도, 어떤 런치 구성에도 견고하고, 한 스레드가 여러 원소를 처리해 런치 오버헤드도 줄인다.

__global__ void saxpy_gs(int n, float a, const float* x, float* y) {
  int idx    = blockIdx.x * blockDim.x + threadIdx.x;
  int stride = blockDim.x * gridDim.x;            // 그리드 전체 스레드 수
  for (int i = idx; i < n; i += stride) {         // 보폭만큼 건너뛰며 전부 처리
    y[i] = a * x[i] + y[i];
  }
}
스레드 (stride=8, N=20)처리하는 i
thread 00, 8, 16
thread 11, 9, 17
……
thread 44, 12 (i=20는 n에 걸려 중단)

이제 그리드를 데이터에 정확히 맞출 필요가 없다 — SM 수에 비례한 고정 그리드로 런치해도 정확하다. 이 패턴을 기본기로 삼는다. 루프 안에서도 i < n 검사(for 조건)가 경계를 지킨다. 그리드 크기 설계·점유율 API는 2.3.

2.2.4 통합 메모리 — 코드 단순화

통합 메모리(cudaMallocManaged)는 호스트·디바이스가 같은 포인터로 접근하는 단일 할당을 만든다. 드라이버가 페이지를 필요한 쪽으로 자동 마이그레이션한다. cudaMemcpy가 사라져 코드가 짧아진다 — 첫 학습·프로토타이핑에 좋다.

#include <cstdio>
#include <cmath>
#define CUDA_CHECK(call) do { cudaError_t e__=(call); if(e__!=cudaSuccess){ \
  fprintf(stderr,"CUDA error %s:%d: %s\n",__FILE__,__LINE__, \
          cudaGetErrorString(e__)); exit(1);} } while(0)

__global__ void saxpy(int n, float a, const float* x, float* y) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) y[i] = a * x[i] + y[i];
}

int main(void) {
  const int N = 1 << 20; const float a = 2.0f;
  float *x, *y;                                  // 호스트·디바이스 공용 포인터
  CUDA_CHECK(cudaMallocManaged(&x, N * sizeof(float)));
  CUDA_CHECK(cudaMallocManaged(&y, N * sizeof(float)));
  for (int i = 0; i < N; ++i) { x[i] = 1.0f; y[i] = 2.0f; }  // 호스트가 직접 씀

  int block = 256, grid = (N + block - 1) / block;
  saxpy<<<grid, block>>>(N, a, x, y);
  CUDA_CHECK(cudaGetLastError());
  CUDA_CHECK(cudaDeviceSynchronize());           // 호스트가 읽기 전 반드시 필요

  float max_err = 0.0f;
  for (int i = 0; i < N; ++i) max_err = fmaxf(max_err, fabsf(y[i]-4.0f));
  printf("max error = %f  ->  %s\n", max_err, max_err==0.0f?"PASS":"FAIL");

  CUDA_CHECK(cudaFree(x));  CUDA_CHECK(cudaFree(y));
  return 0;
}

차이의 핵심: 명시적 복사가 없어졌지만 cudaDeviceSynchronize()는 여전히 필수다. 동기화 없이 커널 직후 호스트에서 y[i]를 읽으면 커널이 아직 안 끝났을 수 있다(데이터 레이스). 통합 메모리는 편의이지 성능 보장이 아니다 — 마이그레이션 비용·프리페치 튜닝은 2.5.

2.2.5 트레이드오프 — 명시적 vs 통합 메모리

항목명시적(cudaMalloc·cudaMemcpy)통합(cudaMallocManaged)
코드량많음(복사 명시)적음(복사 없음)
데이터 이동 제어완전 수동 — 언제·무엇을 옮길지 내가드라이버가 페이지 폴트로 자동
성능 천장높음 — 오버랩·핀드로 튜닝(2.5)마이그레이션 비용, 프리페치 튜닝 필요
학습·프로토타입개념이 명확히 드러남빠르게 실행해 보기 좋음
추천(견해)성능 중요한 실전 커널·라이브러리첫 학습, 빠른 실험, 복잡한 자료구조 포팅 초안

견해: 처음엔 명시적 버전으로 H→D→커널→D→H 흐름을 확실히 익히고, 그다음 통합 메모리로 단순화하라. 명시적 모델을 건너뛰면 데이터가 "자동으로" 옮겨진다고 착각하기 쉽고, 나중에 성능 디버깅 때 그 착각이 비용이 커진다. 두 모델의 비용·튜닝은 2.5에서 정량 비교.

비엔비디아 정직: 같은 SAXPY를 OpenMP·SYCL·OpenCL·HIP로도 쓸 수 있고 이식성은 그쪽이 유리. CUDA를 택하는 이유는 엔비디아 GPU에서의 성숙한 툴체인·라이브러리 생태계(2.15 이후)다 — 이식성과 생태계·성능 사이의 선택.

2.2.6 중간 점검 (체크포인트)

  • [ ] (n+block-1)/block 올림으로 남는 스레드가 생기고 경계 검사가 그것을 막음을 설명한다.
  • [ ] grid-stride loop가 그리드 크기를 N에서 분리함을 알고, stride=blockDim.x*gridDim.x를 제시한다.
  • [ ] 통합 메모리에서도 cudaDeviceSynchronize가 필수인 이유(데이터 레이스)를 제시한다.
  • [ ] "컴파일·실행됨 ≠ 정답"을 알고 호스트 독립 검증을 한다.

결과가 틀리면:

증상의심
RESULT: FAIL, 일부만 틀림경계 검사(i<n) 누락 또는 grid 올림 계산 오류
가끔만 PASS, 비결정적동기화 누락
illegal memory access디바이스 포인터를 호스트에서 역참조, 또는 인덱스 오버런

2.2.7 흔한 함정

  • 동기화 누락. 커널 런치는 비동기. cudaDeviceSynchronize()(또는 동기 cudaMemcpy) 없이 결과를 읽으면 레이스. "가끔 맞고 가끔 틀린" 버그의 1순위.
  • 경계 검사 누락. N이 blockDim 배수가 아니면 마지막 블록 일부가 범위를 넘는다. if (i < n) 필수. compute-sanitizer로 확인.
  • 올림 나눗셈 실수. grid = N / block(내림)이면 꼬리 원소가 처리 안 됨. 반드시 (N + block - 1) / block.
  • 포인터 혼동. 통합 메모리는 호스트/디바이스 포인터 혼동을 없애지만 동기화 의무는 남는다.
  • 검증 생략. 항상 호스트에서 독립 검산.

2.2.8 연습문제

연습 1 (경계 버그 재현). if (i < n)을 지우고 N=1000, block=256으로 실행하라. 무슨 일이 나는가? compute-sanitizer로는?

정답

grid=4 → 1024 스레드, 24개가 i=1000..1023으로 범위를 넘어 쓴다. 운 좋으면 PASS처럼 보이지만(인접 메모리를 건드림), compute-sanitizer는 Invalid __global__ write of size 4를 줄 번호와 함께 보고. "오류 없이 실행됐다"가 안전을 뜻하지 않는 교훈.

연습 2 (grid-stride 변환). 2.1의 SAXPY를 grid-stride로 바꾸고, 그리드를 데이터와 무관한 고정값(예: 80)으로 런치해도 정답이 나오는지 확인하라.

정답

int stride = blockDim.x*gridDim.x; for(int i=idx; i<n; i+=stride){...}. 그리드를 작게(80) 줘 스레드 수 < N이어도 모든 원소가 처리되면 grid-stride가 맞게 동작하는 것. SM 수는 cudaGetDeviceProperties().multiProcessorCount.

연습 3 (동기화 버그 재현). 통합 버전에서 cudaDeviceSynchronize를 지워라. 결과가 어떻게 되나?

정답

동기화가 없으면 호스트가 커널 완료 전 y를 읽어 초기값·부분결과가 섞여 비결정적으로 FAIL할 수 있다. 명시적 버전의 표준 cudaMemcpy(동기)는 암묵적 장벽이라 운 좋게 은폐되지만, 통합 버전은 동기화 의무를 더 적나라하게 드러낸다.

2.2.9 요약 · 다음 장

  • 커널의 둘째 필수 줄은 경계 검사 if (i < n) — 올림 나눗셈으로 남는 스레드의 메모리 오버런을 막는다.
  • grid-stride loop를 임의 크기 처리의 기본 패턴으로 삼는다(그리드 크기를 N에서 분리).
  • 통합 메모리는 코드를 단순화하지만 동기화 의무는 남는다. 명시적 모델로 흐름을 익힌 뒤 단순화하라.
  • "컴파일·실행 ≠ 정답" — 항상 호스트에서 독립 검증한다.

다음 장: 2.3 실행 구성 — 그리드·블록·grid-stride 설계. <<<grid, block>>>의 두 숫자를 근거를 갖고 고르는 법 — 32 정렬, 블록 크기 선택, 2D 매핑·병합, 다이버전스 회피, 점유율 API를 본다.