이 글은 Chapel의 GPU 프로그래밍 기능을 소개합니다. 로케일과 on 문, 순서 독립적 루프(foreach), 프로모션, 함수 호출, 2차원 배열 예제 등을 통해 NVIDIA와 AMD 모두에서 동작하는 벤더 중립적 GPU 지원과 CPU-as-device 모드, 실행 환경 설정 방법을 설명합니다.
on 문: Chapel의 토대Chapel은 생산적인 병렬 컴퓨팅을 위한 프로그래밍 언어입니다. 최근 몇 년 사이 병렬 컴퓨팅의 한 하위 영역이 폭발적으로 인기를 끌고 있는데, 바로 GPU 컴퓨팅입니다. 이에 따라 Chapel 팀은 벤더에 구애받지 않으면서도 성능이 뛰어난 GPU 프로그램을 쉽게 만들 수 있도록 GPU 지원을 추가하는 데 많은 노력을 기울여 왔습니다. 이 부분은 초기 릴리스 이후로 빠르게 개선되어 왔으며, 지속적으로 기능 향상과 성능 개선이 이루어지고 있습니다. 이 튜토리얼은 Chapel의 GPU 프로그래밍 기능을 소개합니다.
CUDA나 HIP 같은 프레임워크가 GPU 프로그래밍에 흔히 사용되지만, 이 튜토리얼은 그들에 대한 사전 지식을 전제로 하지 않습니다. 예제는 Chapel의 범용 기능을 사용하며, 진행하면서 필요한 부분을 설명합니다. Chapel에 대해 보다 체계적으로 배우고 싶다면 이 블로그의 Advent of Code 시리즈나 Learning Chapel 페이지의 자료를 참고하세요.
이 글에서는 바로 Chapel의 GPU 프로그래밍 기능을 사용하는 법으로 들어갑니다. GPU가 어떤 문제에 유용한지 상기하고 싶다면, 부록 섹션을 참조하세요.
on 문: Chapel의 토대GPU가 관여하면, 코드가 서로 다른 위치에서 실행될 수 있습니다. 대부분의 프로그래머가 익숙한 CPU에서 실행될 수도 있고, GPU에서 실행될 수도 있죠. GPU와 CPU는 크게 다르며, 한쪽에 잘 맞는 코드는 다른 쪽에는 맞지 않을 수 있습니다. Chapel은 로케일(locale)이라는 개념을 통해 코드가 어디에서 실행되는지를 프로그래머가 제어할 수 있게 합니다. Chapel 사양에서의 정의를 인용하면:
Chapel에서
locale타입은 프로그램이 실행되는 머신 자원의 단위를 가리킨다.
즉, 로케일은 코드를 실행할 수 있는 컴퓨터의 한 부분을 의미하며, GPU나 CPU를 나타낼 수 있습니다. Chapel의 _on 문_은 로케일을 지정해 특정 코드 조각을 어디에서 실행할지 명시할 수 있습니다. 예를 들어, 다음 코드는 첫 번째 GPU 로케일에서 계산을 수행하여 10까지의 짝수를 계산하고 출력합니다.
1
2
3
4
5
6
7
8
9
10
11
12
config const n = 5; // 예제에서 간결함을 위해 기본적으로 길이 5짜리 배열 사용
on here.gpus[0] { var A: [1..n] int; // 길이 n인 배열 선언 // (GPU의 이점을 보려면 n은 아마 5보다 훨씬 커야 함) foreach i in 1..n do A[i] = i * 2;
writeln("배열 A 전체: ", A); for i in 1..n do writeln("A[", i, "] = ", A[i]); }
이 코드는 GPU _서브 로케일_을 대상으로 하는 `on` 블록으로 시작합니다. 이 블록은 현재 코드를 실행 중인 로케일을 가리키는 특수 변수 `here`를 참조합니다. GPU 지원이 활성화되면, 로케일에는 설치된 각 GPU를 나타내는 서브 로케일들의 배열인 `gpus`라는 필드가 포함됩니다. GPU가 하나뿐인 로케일에서는 `here.gpus`가 길이 1의 배열입니다. GPU가 여러 개인 로케일(슈퍼컴퓨터에서 흔함 [note:여기서 슈퍼컴퓨터를 언급한 데에는 이유가 있습니다. Chapel의 GPU 지원은 대형 머신에서 테스트되었으며, 게시 시점의 TOP500 목록에서 유일한 엑사스케일 슈퍼컴퓨터인 Frontier에서도 테스트되었습니다.])에서는 GPU 개수만큼 요소가 있습니다. 따라서 `here.gpus[0]`은 시스템의 첫 번째 GPU를 의미합니다.
**(GPU 지원은 어떻게 활성화하나요?)**
GPU 지원을 활성화하려면 `CHPL_LOCALE_MODEL` 환경 변수를 `gpu`로 설정한 상태로 Chapel을 빌드해야 합니다. Bash 세션에서는 다음과 같이 설정할 수 있습니다:
export CHPL_LOCALE_MODEL=gpu
NVIDIA GPU가 있다면, 이 설정만으로 GPU 지원 프로그램을 컴파일하고 실행할 수 있습니다. AMD GPU의 경우에는 추가로 `CHPL_GPU_ARCH` 환경 변수에 GPU 아키텍처를 지정해야 합니다:
export CHPL_GPU_ARCH=your_arch_here
마지막으로, GPU가 없어도 Chapel은 ‘cpu-as-device’ 모드를 제공합니다. 이 모드에서는 여전히 `here.gpus` 배열이 제공되고 GPU를 대상으로 하는 코드를 작성할 수 있지만, 실제 실행은 CPU가 담당합니다. 이는 GPU 없이도 GPU 대상 코드를 개발할 수 있게 해줍니다. `CHPL_GPU` 환경 변수를 `cpu`로 설정하면 ‘cpu-as-device’ 모드가 활성화됩니다:
export CHPL_GPU=cpu
GPU 관련 환경 변수에 대한 더 자세한 정보는 [Chapel 빌드](https://chapel-lang.org/docs/usingchapel/building.html) 페이지와 [GPU 기술 노트](https://chapel-lang.org/docs/technotes/gpu.html)를 참고하세요.
위 코드는 1부터 5까지의 인덱스를 순회하면서 각 값을 2배로 곱해 짝수를 생성합니다. GPU는 동일한 하위 문제를 많은 수로 병렬 처리하는 데 뛰어납니다. 각 수를 2배로 만드는 작업은 각각이 독립적인 하위 문제이므로, 예제의 루프는 GPU 실행에 잘 맞습니다. 곱셈 루프는 `foreach` 키워드를 사용해 작성되었는데, 이는 병렬 실행이 안전함을 Chapel에 알려주는 것입니다. 나머지는 언어가 처리합니다. Chapel에는 예제의 두 번째 루프처럼 전통적인 `for` 루프도 있습니다. 두 루프의 차이점은 아래에서 더 이야기하겠습니다.
예제의 `foreach` 루프는 GPU에서 실행되는 반면, 9행의 `writeln`은 그렇지 않습니다. 곱셈과 달리, 단일 문자열을 콘솔에 출력하는 일은 GPU에 잘 맞지 않습니다. 일반적으로 GPU 실행에 적합한 코드는 많은 유사하고 독립적인 조각들로 분해될 수 있습니다. 루프의 경우, 이는 _순서 독립성_으로 이어집니다. 루프가 순서 독립적이라는 것은 어떤 반복도 다른 반복의 결과에 영향을 주지 않는다는 뜻입니다. 예제로 돌아가서 곱셈 루프를 다시 보겠습니다:
foreach i in 1..n do A[i] = i * 2;
`5*2`의 결과가 `3*2`의 계산에 영향을 주지 않는다는 것을 알 수 있습니다. 각 반복은 `A`의 서로 다른 원소를 접근하므로 데이터 레이스가 없습니다. 따라서 이 예제의 루프는 순서 독립적 루프입니다. Chapel은 어떤 루프가 이런 성질을 가지는지 프로그래머가 표시하도록 합니다. 루프가 순서 독립적임을 주장하려면 `foreach` 키워드를 사용해 작성하면 됩니다. [note:Chapel에는 `forall` 루프도 있습니다. 이 루프들은 순회하는 자료구조가 반복을 어떻게 병렬화할지 결정할 수 있게 해줍니다. Chapel 표준 라이브러리의 자료구조들은 순서 독립성을 충분히 활용할 만큼 똑똑하므로, GPU 로케일에서 적합한 `forall` 루프 역시 GPU에서 실행됩니다.
Chapel의 다양한 루프에 대해 더 알아보려면 [loops primer](https://chapel-lang.org/docs/main/primers/loops.html)를 참고하세요.]
순서 독립적 루프가 GPU 실행에 잘 맞는 이유는 분명합니다. 반복의 실행 순서가 중요하지 않다면, 각 반복을 GPU 코어에 넘길 독립적인 하위 문제로 볼 수 있습니다. 이 관찰이 Chapel의 GPU 지원의 토대입니다: **순서 독립적 루프는 GPU에서 병렬로 실행될 수 있습니다.** 실제로 Chapel은 가능한 경우 순서 독립적 루프를 자동으로 GPU 코드로 변환합니다.
**(CUDA/HIP에 익숙합니다. 더 자세히 설명해 주시겠어요?)**
CUDA와 HIP에서는 일반적으로 `device` 또는 `global`로 표시된 함수를 만들고, 이 함수를 커널 런치에 사용하여 GPU 지원 프로그램을 작성합니다. Chapel도 내부적으로 같은 일을 합니다.
Chapel이 GPU 적합 루프를 만나면, 그 루프 본문을 `chpl_gpu_kernel_filename_linenumber` 같은 이름의 함수로 변환합니다. 루프 본문/새로 정의된 커널 함수가 다른 함수나 메서드를 호출한다면, Chapel은 이들에 대한 `device` 버전도 생성합니다.
Chapel은 원래 루프 옆에 커널 런치를 삽입합니다. 동일한 코드가 `here.gpus[0]`(GPU)에서 실행될 수도 있고 기본 로케일(CPU)에서 실행될 수도 있으므로, Chapel은 루프도 보존합니다. 따라서 GPU에서는 커널을 런치하고, CPU에서는 루프 실행으로 대체합니다.
이에 비해, 예제의 두 번째 루프는 _순서 의존적_입니다.
for i in 1..n do writeln("A[", i, "] = ", A[i]);
`A`의 원소를 순서대로 출력하려는 의도가 분명합니다. 그렇기 때문에 다섯 번째 원소를 출력하는 반복은 네 번째 원소를 출력한 후에 실행되어야 하며, 의존성이 존재합니다. Chapel에서는 반복이 차례대로 실행되어야 하는 루프(이를 _직렬 루프_라고 함)를 `for` 키워드로 작성합니다. `for`로 작성된 루프는 컴파일러의 GPU 실행 고려 대상이 아닙니다.
이제 예제의 거의 모든 부분을 살펴보았습니다. 남은 중요한 부분 하나는 짝수를 담을 배열 `A`를 선언한 것입니다:
on here.gpus[0] { var A: [1..n] int;
// ... 짝수 예제의 나머지 코드 }
`on` 문의 중요한 점 하나는, `on` 블록 안에서 선언된 변수들은 논리적으로 그 문이 대상으로 지정한 로케일에 “존재한다”는 것입니다. 보통 이는 같은 로케일에서 이 변수들에 접근할 때 더 빠르고, 다른 로케일에서 접근할 때 더 느리다는 뜻입니다. 예를 봅시다:
on firstLocale { var A: [0..10] int; A[0] = 1; // 'A'에 대한 접근 비용이 낮음: // 'A'가 존재하는 로케일과 같은 로케일에서 접근함
on anotherLocale { A[0] = 2; // 'A'에 대한 접근 비용이 더 큼: // 'A'가 존재하는 로케일과 다른 로케일에서 접근함 } }
현재로서는, [note:향후 Chapel은 GPU 로케일 밖에서 선언된 배열을 GPU 코드가 접근할 수 있도록 할 계획입니다. 그러나 이를 위해서는 GPU가 시작하는 형태의 GPU–CPU 간 통신이 필요합니다. 이런 _GPU 주도 통신_은 로드맵에 있으나, 작성 시점에는 지원되지 않습니다.] GPU에서 실행되는 코드(예: 우리의 `foreach` 루프)에서 배열에 접근하려면, 그 배열이 GPU 로케일에 존재해야 합니다. 그래서 `A`를 `on` 블록 밖이 아니라 그 안에서 선언한 것입니다. 만약 `A`를 `on` 블록 밖에서 선언했다면, 그것은 CPU 로케일(즉 CPU 메모리)에 존재하게 됩니다.
이제 `on` 블록과 `foreach` 루프를 사용하여 Chapel에서 GPU를 타깃팅하는 방법을 보았습니다. 다만 이는 핵심 개념을 소개하기 위한 아주 간단한 프로그램일 뿐입니다. 지금까지 본 것은 시작에 불과합니다. 다음 섹션에서는 GPU 프로그래밍과 매끄럽게 맞물리는 Chapel의 다른 기능들을 살펴보겠습니다.
### [GPU에서 또 무엇을 할 수 있을까?](https://chapel-lang.org/blog/posts/intro-to-gpus/#what-else-can-you-do-on-a-gpu)
Chapel 언어의 상당 부분은 GPU에서 실행될 수 있습니다. 이 섹션에서는 무엇을 할 수 있는지 빠르게 둘러보겠습니다. 개별 언어 기능에 대해 더 알고 싶다면 글 서두의 참고 자료를 확인하세요.
예제를 살펴보면서 “정말 이 코드가 GPU에서 실행됐을까?”라는 의문이 들 수 있습니다. 어떤 코드가 어디서 실행됐는지를 추적하는 것은 약간 더 고급 주제입니다. 단순화를 위해 `numKernelLaunches`라는 보조 함수를 정의하겠습니다. _커널_은 보통 GPU에서 실행되는 코드 조각을 뜻합니다. _커널 런치_는 CPU가 GPU에서 커널 실행을 시작하는 과정을 의미합니다. 이 보조 함수가 어떻게 구현되는지는 몰라도 됩니다. 최근에 확인한 이후 지금까지 발생한 커널 런치의 총수를 반환한다고만 생각하면 됩니다. 이 함수를 이용해, 예상한 대로 코드가 GPU에서 실행되었는지 확인하겠습니다.
**(정말로 함수 정의를 보고 싶습니다!)**
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
use GpuDiagnostics;
proc startCountingKernelLaunches() {
resetGpuDiagnostics();
startGpuDiagnostics();
}
proc numKernelLaunches() {
stopGpuDiagnostics();
var result = + reduce getGpuDiagnostics().kernel_launch;
startCountingKernelLaunches();
return result;
}
startCountingKernelLaunches();
```
이 섹션의 모든 예제는 GPU 로케일에서 수행합니다.
```
30
on here.gpus[0] {
재미있는 것부터 시작해봅시다. 글의 처음에서 `foreach`를 사용해 숫자를 2배로 만드는 작업을 GPU로 수행했습니다. Chapel의 [_프로모션(promotion)_](https://chapel-lang.org/docs/users-guide/datapar/promotion.html)이라는 기능을 사용하면 이를 더 간결하게 할 수 있습니다. 프로모션은 스칼라 값을 요구하는 연산에 배열을 넘길 수 있게 해주며, 그러면 그 연산이 배열의 각 원소에 자동으로 적용됩니다. 이 작업은 자동으로 병렬 수행되며 — 잠재적으로 GPU에서 실행됩니다.
연산이 “2를 곱하기”라면, 다음과 같이 쓸 수 있습니다:
32 33 34 35 36
var Evens = 2 * [1,2,3,4,5];
writeln(Evens);
// 프로모션된 초기화식에서 발생한 1회 커널 런치
assert(numKernelLaunches() == 1);
```
단 한 줄로 GPU에서 실행되는 코드를 작성했습니다.
다음 예제로 넘어가봅시다. GPU에서 실행 중인 코드에서 대부분의 Chapel 함수를 호출할 수 있습니다. 아래 예제는 [0,2 π)[0, 2\pi) 구간을 10등분하여 내장 사인 함수 `sin`을 샘플링합니다:
```
38
39
40
41
42
43
44
45
46
use Math; // 'sin'과 'pi' 사용을 위해 'Math' 모듈 포함
const numSamples = 10; var A = [i in 0..#numSamples] sin(2 * pi * i / numSamples);
writeln(A);
// 루프 표현식 초기화식에서 발생한 1회 커널 런치 assert(numKernelLaunches() == 1);
GPU에서 호출되는 함수는 사용자 정의일 수 있고, 임의로 복잡할 수 있습니다. 다음 예제에서는 다시 프로모션을 사용해 처음 20개의 피보나치 수를 계산합니다. 이 예제는 전혀 최적화되어 있지 않지만, [note:이 예제는 알고리즘 측면과 GPU 특화 측면 모두에서 최적화되어 있지 않습니다.
알고리즘 측면에서, 알고리즘 분석에 익숙한 분들은 n번째 피보나치 수를 O(n)에 계산하는 알고리즘이 존재한다는 것을 아실 것입니다. 반면 우리의 순진한 알고리즘은 O(2^n)으로 매우 좋지 않습니다. 또한 구현에 메모이제이션을 사용하지 않아, 배열 원소 간에 중복 계산이 많이 발생합니다.
GPU 특화 측면에서는 _스레드 발산(thread divergence)_을 유발하는 코드를 작성하고 있습니다. 각 스레드는 약간씩 다른 단계의 시퀀스를 수행하게 되며, 이는 GPU가 실행하기 훨씬 더 어렵게 만듭니다.] 커널에서 임의의 함수(재귀 함수 포함)를 사용하는 좋은 데모가 됩니다.
48 49 50 51 52 53 54 55 56 57
proc fib(x: int): int {
if x <= 1 then return 1;
return fib(x-1) + fib(x-2);
}
var Fibs = fib(0..#20);
writeln(Fibs);
// 초기화식의 프로모션된 표현식에서 발생한 1회 커널 런치
assert(numKernelLaunches() == 1);
```
여기서 `fib`는 정수 범위 `0..#20`을 인자로 호출해 프로모션된 일반 Chapel 함수입니다. 특別한 작업 없이도 GPU 커널에서 이를 사용할 수 있음을 볼 수 있습니다. 이는 일반적으로 참입니다. Chapel에서 한 번 정의된 함수는 GPU와 CPU 양쪽에서 호출될 수 있습니다.
루프 또한 커널의 일부로 실행될 수 있습니다. 마지막 예제로, 2차원 배열 `Square`를 정의한 다음, GPU에서 각 열의 합을 구하겠습니다. 다음 코드는 이 정사각 배열을 초기화하고 채운 뒤 출력합니다:
```
59
60
61
62
63
64
65
66
67
68
var rows, cols = 1..5; var Square: [rows, cols] int; foreach (r, c) in Square.indices do Square[r, c] = r * 10 + c;
writeln("원본 배열:"); writeln(Square);
// Square 초기화 1회, 루프 1회 — 합계 2회 커널 런치 assert(numKernelLaunches() == 2);
이제 새 `Square` 배열이 준비되었으니, 각 열의 합을 구합니다. `foreach` 루프 내부에서 또 다른 `for` 루프를 평소처럼 사용합니다.
70 71 72 73 74 75 76 77 78 79 80 81 82 83
var ColSums: [cols] int;
foreach c in cols do {
var sum = 0;
for r in rows do
sum += Square[r, c];
ColSums[c] = sum;
}
writeln("열 합계:");
writeln(ColSums);
// ColSums 초기화 1회, 루프 1회 — 합계 2회 커널 런치
assert(numKernelLaunches() == 2);
```
몇 가지 예제를 거쳤으니, 이제 `on` 블록을 마무리하고 CPU에서의 계산으로 돌아갑니다.
```
85
} // on here.gpus[0] 끝
이제 Chapel의 GPU 지원을 사용한 여러 예제 계산을 보았습니다. 그렇다면 이 모든 코드는 어디에서 실행할 수 있을까요? 다음에서 살펴보겠습니다.
### [GPU용 Chapel 코드는 어디에서 실행할 수 있나?](https://chapel-lang.org/blog/posts/intro-to-gpus/#where-can-i-run-chapel-code-for-gpus)
서론에서 Chapel의 GPU 지원이 벤더 중립적이라고 했던 것을 기억하실 겁니다. 이는 GPU 지원 Chapel 코드가 수정 없이 NVIDIA와 AMD GPU 모두에서 실행될 수 있음을 의미합니다! 이 글의 모든 코드가 여기에 해당됩니다.
사실 GPU조차 필요하지 않습니다. Chapel은 ‘CPU-as-device’ 모드를 지원하여 GPU를 대상으로 한 코드가 CPU에서 투명하게 실행되도록 합니다. 전용 그래픽이 없는 노트북에서도 GPU 지원 코드를 프로토타이핑하고, 필요할 때 GPU가 있는 머신으로 전환할 수 있습니다. 실제로 이 글도 그렇게 작성되었습니다.
마지막으로, Chapel의 GPU 지원은 언어의 다른 부분과 마찬가지로 확장성이 뛰어납니다. 노트북에서 프로토타이핑한 프로그램을 슈퍼컴퓨터에서 쉽게 좋은 성능으로 실행할 수 있습니다. 확장성 있게 코드를 작성하려면 몇 가지 추가 단계가 필요하지만, [note:`here.gpus[0]`처럼 첫 번째 GPU만 사용하지 않는 것이 그중 하나입니다.] Chapel은 어디서든 실행되는 병렬 코드를 쉽게 작성할 수 있도록 해줍니다.
### [요약](https://chapel-lang.org/blog/posts/intro-to-gpus/#summary)
Chapel의 GPU 지원은 훨씬 더 깊지만, 입문 글로서는 여기서 마무리하기 좋겠습니다 — 이미 많은 내용을 다뤘습니다! 지금까지 살펴본 내용을 정리해 봅시다:
* Chapel의 _로케일(locale)_은 코드를 실행하고 변수를 저장할 수 있는 머신의 일부를 나타냅니다.
* `on` 문은 코드가 실행될 위치(예: GPU)를 지정합니다.
* `foreach`로 작성된 _순서 독립적_ 루프는 자동으로 GPU에서 실행됩니다.
* Chapel 언어의 많은 기능을 GPU 코드에서 사용할 수 있습니다.
* 여기에는 _프로모션_, 일반 함수와 재귀 함수, 그리고 루프가 포함됩니다.
* 이 모든 것은 벤더 중립적이며, Chapel은 NVIDIA와 AMD GPU 모두에서 동작합니다.
Chapel의 GPU 지원에 대한 더 많은 정보가 필요하다면, [기술 노트](https://chapel-lang.org/docs/technotes/gpu.html)에 GPU 코드의 상세 내용과 예제가 많이 수록되어 있으며, [Chapel 1.32 릴리스 노트의 GPU Programming 섹션](https://chapel-lang.org/releaseNotes/1.31-1.32/05-gpus.pdf)에 “GPU 프로그래밍 속성 코스(crash course)”가 포함되어 있습니다.
물론 아직 GPU로 실제 문제를 푸는 실용 예제를 보진 않았습니다. 또한 Chapel에서 GPU 지원 프로그램의 성능을 분석하고 개선하는 방법도 아직 보지 않았습니다. 마지막으로, Chapel의 GPU 지원이 언어의 나머지 부분과 얼마나 매끄럽게 통합되어 모든 GPU와 컴퓨트 노드 전반에 걸쳐 실행되는 코드를 작성하게 하는지도 보지 않았습니다. 그 마지막 부분을 살짝 엿보려면, 시스템의 모든 GPU에서 실행되는 “짝수” 예제의 다음 버전을 보세요:
87 88 89 90 91 92
coforall loc in Locales do on loc {
coforall gpu in here.gpus do on gpu {
var Evens = 2 * [1,2,3,4,5];
writeln("", gpu, "에서 계산한 짝수들: ", Evens);
}
}
```
단 6줄의 코드로, 슈퍼컴퓨터 전체의 연산 자원을 활용할 수 있는 프로그램을 작성했습니다.
이후 게시물에서 위의 모든 주제로 돌아올 예정이며, 멀티 노드와 멀티 GPU 프로그램 작성부터 시작하겠습니다. 기대해 주세요!
**이 글에 나온 전체 Chapel 프로그램은 아래에서 확인할 수 있습니다:**
```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
config const n = 5; // 예제에서 간결함을 위해 기본적으로 길이 5짜리 배열 사용
on here.gpus[0] { var A: [1..n] int; // 길이 n인 배열 선언 // (GPU의 이점을 보려면 n은 아마 5보다 훨씬 커야 함) foreach i in 1..n do A[i] = i * 2;
writeln("배열 A 전체: ", A); for i in 1..n do writeln("A[", i, "] = ", A[i]); }
use GpuDiagnostics;
proc startCountingKernelLaunches() { resetGpuDiagnostics(); startGpuDiagnostics(); }
proc numKernelLaunches() { stopGpuDiagnostics(); var result = + reduce getGpuDiagnostics().kernel_launch; startCountingKernelLaunches(); return result; }
startCountingKernelLaunches();
on here.gpus[0] {
var Evens = 2 * [1,2,3,4,5]; writeln(Evens);
// 프로모션된 초기화식에서 발생한 1회 커널 런치 assert(numKernelLaunches() == 1);
use Math; // 'sin'과 'pi' 사용을 위해 'Math' 모듈 포함
const numSamples = 10; var A = [i in 0..#numSamples] sin(2 * pi * i / numSamples);
writeln(A);
// 루프 표현식 초기화식에서 발생한 1회 커널 런치 assert(numKernelLaunches() == 1);
proc fib(x: int): int { if x <= 1 then return 1; return fib(x-1) + fib(x-2); }
var Fibs = fib(0..#20); writeln(Fibs);
// 초기화식의 프로모션된 표현식에서 발생한 1회 커널 런치 assert(numKernelLaunches() == 1);
var rows, cols = 1..5; var Square: [rows, cols] int; foreach (r, c) in Square.indices do Square[r, c] = r * 10 + c;
writeln("원본 배열:"); writeln(Square);
// Square 초기화 1회, 루프 1회 — 합계 2회 커널 런치 assert(numKernelLaunches() == 2);
var ColSums: [cols] int; foreach c in cols do { var sum = 0;
for r in rows do
sum += Square[r, c];
ColSums[c] = sum;
} writeln("열 합계:"); writeln(ColSums);
// ColSums 초기화 1회, 루프 1회 — 합계 2회 커널 런치 assert(numKernelLaunches() == 2);
} // on here.gpus[0] 끝
coforall loc in Locales do on loc { coforall gpu in here.gpus do on gpu { var Evens = 2 * [1,2,3,4,5]; writeln("", gpu, "에서 계산한 짝수들: ", Evens); } }
### [부록: 어떤 문제들이 GPU의 이점을 받는가?](https://chapel-lang.org/blog/posts/intro-to-gpus/#appendix-what-sorts-of-problems-benefit-from-gpus)
GPU와 CPU의 엄청난 차이는 _코어_ 수에 있습니다. 코어는 명령/기계어를 실행하는 프로세서의 부분입니다. 제가 이 글을 쓰는 컴퓨터에는 약 10개의 CPU 코어가 있는 반면, 간단히 검색해 보니 NVIDIA RTX 4070 GPU(“consumer NVIDIA GPU”로 검색해서 임의로 선택)의 코어 수는 5888개로 — 제 CPU 코어 수의 500배가 넘습니다! [note:실제로, 동시에 코드를 실행하는 코어 수가 아주 많기 때문에, GPU는 [매시브 병렬(massively parallel)](https://en.wikipedia.org/wiki/Massively_parallel) 아키텍처의 한 예입니다.] 물론 CPU 코어와 GPU 코어는 동일하지 않으며, GPU 코어는 개별적으로는 훨씬 약한 편입니다.
많은 수의 유사한 [note:GPU의 경우 하위 문제들이 유사하다는 점이 실제로 매우 중요합니다. 단일 GPU 코어는 사실 여러 하위 문제의 인스턴스를 _동시에_ 처리하며, 그들의 코드를 락스텝(lock-step)으로 실행합니다. 락스텝 실행이 더 이상 가능하지 않고 두 하위 문제가 서로 다른 접근을 필요로 하는 순간, GPU는 훨씬 더 고생하게 됩니다. 이 문제를 _스레드 발산(thread divergence)_이라고 합니다.] 조각으로 분해할 수 있는 문제에서는, 많은 수의 약한 코어가 오히려 이상적일 수 있습니다. 각 코어에 문제의 일부를 할당하여 독립적으로 처리할 수 있고; [note:GPU 코어들 사이에 소규모의 조율이 필요한 경우도 흔합니다. GPU는 코어 간에 공유되는 메모리를 할당할 수 있고, 코어들의 실행을 동기화할 수도 있습니다. 다만 이는 이 입문 글의 범위를 약간 벗어나는 주제입니다.] 각 하위 문제가 독립적이므로 모든 코어가 정확히 같은 시간에 자신의 작업을 수행할 수 있습니다. 이는 단일 강력한 코어가 모든 조각을 하나씩 처리해야 하는 경우에 비해 매우 큰 속도 향상을 가져올 수 있습니다.
GPU 실행에 적합한 문제의 예로 화면에 이미지를 렌더링하는 작업이 있습니다. 사실 _Graphics Processing Unit_이라는 이름이 시사하듯, GPU는 바로 이 문제를 해결하기 위해 개발되었습니다. 렌더링 시 각 픽셀의 색상은 깊이와 텍스처 정보를 바탕으로 비교적 독립적으로 계산할 수 있습니다. 따라서 GPU의 각 코어는 동시에 소수의 픽셀을 병렬로 처리할 수 있어, 전체 과정을 크게 가속화합니다.
GPU 실행에 잘 맞는 전체 문제 영역으로는 선형대수가 있습니다. 예를 들어 행렬 곱셈에서 출력 행렬의 각 셀은 다른 셀들과 독립적으로 계산할 수 있습니다. 다시 말해 GPU의 각 코어가 소수의 셀을 병렬로 처리할 수 있다는 뜻입니다. 많은 것들(신경망 포함)은 행렬 연산으로 기술할 수 있으며, 빠른 선형대수는 더 빠른 머신러닝 모델로 이어집니다.
위 설명은 GPU로 _무엇을_ 할 수 있는지에 대한 맛보기를 제공했습니다. 그 다음 당연한 질문은 Chapel로 이것을 _어떻게_ 할 수 있느냐는 것입니다. 바로 이 블로그 글의 주제가 Chapel이 GPU에서 코드를 실행하게 하는 방법입니다.