이 프레젠테이션에서 발표자인 라프 르비엔은 GPU에 프로그래밍 하는 좋은 방법은 사실 함수형 프로그래밍이며, 함수형 프로그래밍이야말로 문제를 작은 단위로 쪼개어 각 상태끼리의 의존관계가 진짜 함수 단위로 명백한 것이라고 한다. 그는 GPU 위에서 동작하는 고성능의 렌더링 프로그램 예시를 들어 GPU 프로그래밍의 구조를 설명한다.
왜 GPU에서 코드를 돌리고 싶을까?
기존의 칩 제작자들은 무어에 법칙에 따라 칩 위에 트랜지스터 갯수를 늘리는 트렌드에 따랐지만 15년전후로부터는 데너드 스케일: 칩이 또한 속도가 계속 빨라진다는 법칙을 따랐다. 이 말인즉슨, 이제 싱글코어 성능을 향상에 퍼포먼스 향상을 기대하는 것은 끝났고, 현대의 컴퓨터 칩에서 성능향상을 도모할 수 있는 방법은 병렬화와 멀티코어 뿐이라는 것이다!
CPU, GPU and MIC Hardware Characteristics over Time, by Karl Rupp
(https://www.karlrupp.net/2013/06/cpu-gpu-and-mic-hardware-characteristics-over-time/)
위 맥락에서처럼 이제는 GPU가 CPU보다 훨씬 병렬화 수준이 높아졌다. 파란선이 인텔 CPU 칩 성능 그래프, 빨간/초록선이 NVIDIA/AMD GPU 칩 성능 그래프를 나타낸다. 초당 부동소수점 연산량 기준, 이미 꾸준하게 GPU 성능이 CPU 성능을 초월했다.
ALU Core: GPU의 작업실행 유닛
우리는 GPU의 작업실행 유닛의 구조를 들여다보며, 그들이 메모리에 어떻게 접근하고, 그리고 어떻게 더 큰 단위의 연산유닛으로 조직화하는지 이해할 수 있다. 하나의 칩에는 실행유닛(Execution Unit)이 24~64개가 배치된다. 각각의 실행유닛은 위에 실행스레드(Execution Thread)들이 배치되는데, 각각의 스레드는 프로그램 카운터를 가지고, 각각의 레인(스레드는 총 8개의 레인을 가짐)에서 128개의 레지스터를 가지는 레지스터 파일을 가진다. 그래서 계산해보면 하나의 실행유닛은 4K, 즉 4킬로바이트의 레지스터 파일이 붙어있다고 볼 수 있다.
그러면 GPU의 실행유닛은 어떻게 실행되는가?
이러한 GPU의 병렬실행 원리의 핵심은 스레드 아비터에 있다. 스레드 아비터가 모든 스레드들을 스케줄링한다. 여기서 스케줄링의 의미는 실행 스레드들을 모아 작업단위와 연결해주고 매 사이클마다 작업의 진행을 관리하는 것을 말한다. 스레드 아비터는 준비가 된 스레드의 상태를 확인하여 ALU의 작업과 매칭해준다. 메모리에 읽어온 다음에 스레드에 뿌려진 작업단위를 다시 합쳐서 출력버퍼에 전달하는 역할까지를 수행한다.
Compute dispatch
이제 GPU의 스레드 구조와 메모리에서 더 나아가, 연산의 처리에 대해 들어가보자. 여기서의 핵심은 연산이 여러개의 그리드로 나누어 구성된다는 것이다. 각각의 그리드는 칩의 메모리에 연동되는 구조를 가지고, 그 그리드에 있는 각각의 스레드 그룹은 몇몇의 스레드가 되는 것이다. 이렇게 위 계층구조에 따라 이상적으로는 32~1024개의 스레드를 구성할 것이다. 이는 가장 이상적으로 병렬화를 구성할 수 있는 최적의 스레드 숫자이다.
이렇게 그리드 단위로 잘게 쪼개진 프로그램의 실행단위에 대해서 각각의 스레드 그룹 안에 있는 그리드들은 로컬 메모리에서 서로 커뮤니케이션할 수 있고, 서로 커뮤니케이션하면서 싱크를 맞추게 된다. 라프 르비엔은 이러한 GPU 프로그램들을 작성할 때 함수형 프로그래밍처럼 다룰 때 굉장히 효과적이라고 말한다. 각각의 출력 버퍼에 쓰이는 값이 결국에는 하나의 스레드 그룹에서 계산한 함수이고, 이러한 입출력들을 이어붙여 그래프로 만들어 GPU에서 스케줄링하고 다루는게 스레드 그룹이기 때문이다.
GPU의 연산과정을 보면 입력 버퍼에서 들어온 데이터를 여러 그리드 단위로 내보내고, 그리드 안에서는 서로 메모리를 공유하고 주고받으며 다음 버퍼로 보내는 작업을 반복하며 마지막에 출력 버퍼로 보내게 된다. 이러한 GPU 연산의 전체그림을 보면 머신러닝에서 벡터가 구성되는 학습과정과 매우 유사하고 이러한 머신러닝의 문제를 풀기 위해 GPU가 사용된 것이 너무나 자연스럽다.
그러면, GPU에 실행할 프로그램을 어떻게 작성하는 게 Best Practice일까?
라프 르비엔은 하나의 그림 안에서 정의된 바운딩 박스들의 오브젝트를 읽어 픽셀을 렌더링하는 프로그램의 작성 예시를 들어 설명한다.
1. Naive Functional Program의 2D 렌더링 적용예시
- parallelism: 훌륭한
- work factor: 최악
- read bandwidth: 최악 (모든 픽셀이 오브젝트 그래프를 순회함)
- write bandwidth: 훌륭한
첫번째 방식은 map과 fold를 사용하여 함수형 프로그래밍한 코드 예시이다. 이 코드의 work factor와 read bandwidth는 최악이라고 할 수 있는데, 우리가 읽는 모든 픽셀에서 그림의 모든 오브젝트를 읽어오고 최종 렌더값에 기여되는 값들을 하나하나 계산해야 하기 때문이다. 우리가 관심없는 바운딩 박스 바깥의 픽셀 값은 계산할 필요가 없고, 모든 픽셀에서 오브젝트를 렌더링하는 것이 비싸기 때문에 최적화가 더 필요하다.
2. Sequential Program의 2D 렌더링 적용예시
- parallelism: 최악
- work factor: 훌륭한
- read bandwidth: 훌륭한
- write bandwidth: 좋지 않은
두번째 방식은 sequential하게 이중 for문을 사용하여 렌더링 프로그램을 작성하는 것이다. 그 결과는 이전 함수형 프로그래밍의 정 반대이다. 여기서 프로그램은 완전히 순차적으로 실행되기 때문에 병렬화는 최악이지만, work factor와 read bandwidth는 좋아졌는데, 우리가 오브젝트를 반복문 내에서 단 한번만 읽어오기 때문이다. 단일 스레드 CPU 환경에서는 이러한 방식으로 프로그램을 작성할 수 있다.
3. Filter를 적용한 Functional Program의 2D 렌더링 적용예시
세번째 방식은 모든 픽셀을 계산하던 방식에서 filter를 이용해보는 것이다. 각각의 오브젝트에 filter를 걸어서 우리가 렌더링하는 픽셀이 바운딩 박스 내부에 있는지를 확인하고 연산한다.
하지만 이렇게 하는 것은 전혀 도움이 되지 않는다. 왜냐하면, filter를 적용하는 연산이 렌더링만큼 비싸기 때문이다. 여전히 각각의 픽셀에 대해서 오브젝트를 읽어야 하고, 그때 발생하는 연산량은 곧 차이가 없다.
4. (최적) Tile Chunking한 Functional Program의 2D 렌더링 적용예시
- parallelism:매우 좋은
- work factor: 꽤 좋은
- read bandwidth: 괜찮은
- write bandwidth: 매우 좋은
문제를 최적으로 해결하는 방식은 바로 타일 단위로 chunking 하는 것이다. 여기서 두 단계의 파이프라인을 거치는데, 1) 타일 수준의 granularity로 연산하기: 오브젝트를 타일 수준에서 필터링(16*16)하고, 그 타일의 갯수는 최적의 스레드 그룹 사이즈에 맞추어 튜닝하기 2) 타일 단위 안의 이러한 오브젝트들에 대해 렌더링을 수행하고 결과를 출력하기 이다.
결국 최적화의 핵심은 문제를 GPU에서 최적의 스레드 그룹에 매핑할 수 있게 쪼개어 함수 단위로 풀어내는 것이다!
마치며
- 문제를 작은 단위로 쪼개어 각 상태를 함수로 전달하는 함수형 프로그래밍은 그리드 조각의 연산과 메모리 읽기쓰기를 병렬적으로 수행하는 GPU 연산에 적합한 방식이다.
- 하지만, GPU 연산에 함수형 프로그래밍을 적용하는 것은 항상 최적은 아니며, 각 연산량의 로드을 따져가며 프로그램을 작성하는 것은 개발자의 몫이다.