How GPUs Ate Up The World: 병렬 컴퓨팅과 딥러닝의 급부상에 대한 단상

Daniel Hong
25 min readJun 20, 2020

지난 5년 동안 가장 많이 급등한 기술주를 꼽자면 엔비디아를 빼놓고 논의를 시작할 수 없을 것이다. 10년이라는 시간을 거치면서 엔비디아는 PC용 GPU와 마더보드 칩셋을 설계하고 납품하던 회사에서 AI와 딥러닝 기술을 선도하는 회사로 완전히 탈바꿈했고, 딥러닝 열풍을 따라 주가 또한 급등했다. 엔비디아가 나스닥에 상장된 것은 1999년이지만 이 정도 수준의 주가 급등세를 보인 것은 최소 2016년 이후의 일로, 2015년 6월 22일 주당 20.74달러였던 엔비디아의 주가는 2020년 6월 15일 368.72달러까지 치솟았다. 5년 사이 20배 가까이 주가가 급등한 것이다.

출처: Yahoo Finance

개인적인 경험으로도, 2014–2015년까지만 하더라도 GPGPU (General-Purpose GPU) Computing에 관한 주제를 다루면 관련 업계 종사자를 제외하고서 개발자 중에서도 제대로 알아듣는 사람이 거의 없다시피 했다. GPU가 애초에 Graphics Processing 유닛이니, 게임이나 3차원 모델 렌더링 같은 그래픽 관련 작업을 제외하고는 GPU에 직접 접근하는 코드를 짤 필요가 거의 없다고 생각했기 때문이다. 2020년인 지금은? 기본적인 딥러닝은 웬만해서는 다들 다룰 줄 알고, 그 과정에서 GPGPU(그 중에서도 엔비디아의 CUDA)를 사용하지 않는 사람은 거의 찾아보기 힘들다. 개인 중에서도 Titan RTX나 RTX 2080 Ti급 GPU를 2–3장씩 박아넣고 대규모 모델의 훈련을 돌리는 사람도 심심찮게 볼 수 있다. 각종 클라우드 서비스를 통해 데이터센터 전용 GPU 유닛을 임시로 빌려쓰기도 한다. 하이엔드 GPU는 게이머들의 전유물이라고 생각했던 과거와는 완전히 다른 모습이다.

그런데, 가만히 생각해보면 무언가 이상하다. GPU는 분명 “그래픽” 관련 연산을 전담하기 위해 떨어져 나온 칩이다. 그 정의대로라면, 본래 GPU는 그래픽에 관련된 연산만을 수행하고 나머지 연산은 CPU가 처리하는 것이 맞다. 그러나 최근 딥러닝 워크스테이션 등의 트렌드를 보면 완전히 정반대로, GPU가 컴퓨팅 작업의 주가 되고 CPU는 어마어마한 연산량을 뿜어내는 GPU를 다루기 위한 PCIe 레인 수와 호스트 프로세스를 띄우기 위한 수준의 퍼포먼스만 제공해 줄 수 있다면 크게 중요하게 생각하지 않는다. CPU가 가장 중요한 “두뇌”였던 전통적인 정의와는 완전히 다른 패러다임이다.

오늘 다루어볼 주제는, 어떻게 해서 GPU는 CPU의 그래픽 연산보조장치에서 출발해 현대 컴퓨팅의 주류가 되었는지에 관한 것이다. GPU가 그래픽 연산을 처리하기 위해 어떤 방식으로 아키텍처가 분화되어 왔고, 이러한 아키텍처가 어떻게 컴퓨팅 연산의 병렬화 추세와 만나게 되었으며, 이러한 패러다임의 전환으로 인해 CPU가 어떻게 해서 프로세싱 파워 경쟁에서 밀려나게 되었는지까지를 간략하게 살펴보고자 한다. 또한, GPU에서 시작된 컴퓨팅의 병렬화가 결정론적인 튜링 머신의 한계를 어떻게 극복했고, 이러한 접근이 언제까지 지속 가능한지에 대한 몇 가지 개인적인 생각들을 서술하는 것으로 글을 맺도록 하겠다.

모르겠고 일단 CPU에 다 몰아넣어 보자

먼저 현대적인 의미의 “GPU”를 정의하는 요소가 무엇인지에 대해서 정리해 둘 필요가 있겠다. PC 초반의 간단한 2차원 이미지까지는 프로세서에서 직접 소프트웨어로 렌더링을 수행해도 큰 무리는 없었다. 일반적으로 문제가 생기는 것은 3차원 그래픽부터인데, 3차원의 경우 2차원 렌더링과는 달리 말 그대로 차원이 다른 수준의 추가적인 연산이 요구되기 때문에 구현이 더 복잡해진다.

비교적 현실적인 수준의 3차원 그래픽을 그려내기 위해서는, 3차원 벡터 공간에서 이미지를 그려내는 것 이외에도 크게 세 가지의 작업이 더 필요하다. Transform, Clipping and Lighting, 일명 T&L이라 불리는 연산이 그것이다. Transform은 그려진 3차원 도형들에 대한 2차원의 이미지를 생성해내는 것, Clipping은 사용자의 2차원 시야 내에만 들어오는 이미지만 렌더링하는 것, 그리고 Lighting은 빛의 방향에 따라 3차원 도형의 표면 색상을 조정하는 작업을 뜻한다. 이러한 추가적인 연산들이 필요한 이유는 사용자가 보는 PC의 모니터는 2차원인 반면, 실제로 그려지는 도형들은 3차원이기 때문이다. 인간은 3차원의 세상에 살지만 인간의 눈은 2차원의 이미지만을 생성해내므로, 3차원 도형과 벡터에 담긴 모든 정보를 인지적으로 한 번에 받아들이는 것은 불가능하다. 따라서, 3차원의 도형을 2차원으로 변환하며 필연적으로 관점의 차이라는 것이 발생하고, 이를 반드시 사용자에게 보여지는 이미지에 실시간으로 반영해주어야 한다.

90년대 후반까지만 해도, 개인용 PC에서는 이러한 T&L 작업을 순전히 소프트웨어로 — 즉, 프로세서 단에서 직접 — 처리하는 방식을 주로 택하고 있었다. 게임용으로 출시되는 콘솔(플레이스테이션, 닌텐도 등등)에서는 90년대 초반부터 하드웨어 레벨 T&L을 탑재했고 (애초에 GPU라는 표현 자체가 1994년 소니가 플레이스테이션을 홍보하기 위해 만들어낸 용어), PC 업계에서도 3D Accelerator라는 이름으로 디스플레이 보조 하드웨어를 판매하고 있었지만, 하드웨어 레벨에서 T&L을 포함한 대부분의 3차원 렌더링을 처리할 수 있는 장치가 PC에서도 확장 카드 형태로 사용할 수 있게 된 것은 1999년 출시된 엔비디아의 GeForce 256이 최초다. 엔비디아는 GeForce 256을 “세계 최초의 진정한 GPU”라고 홍보했고, 그 이후 엔비디아가 출시하는 모든 개인 소비자용 GPU 제품군에는 GeForce라는 브랜딩이 붙게 된다.

소비자용 GeForce 256 (NV10) GPU.

그렇다면, T&L이 소프트웨어가 아닌 별도의 하드웨어로 떨어져 나간 것이 왜 “3D Accelerator”와 “GPU”의 용어 차이를 만들어 낼 만큼 중요할까? 그 이유는, T&L의 분화가 GPU라는 칩이 병렬적인 행렬 연산을 전담하는 하드웨어로 분화되는 데에 핵심적인 역할을 했기 때문이다. 그래픽 및 이미지 처리 연산은 그 특성상 어마어마하게 많은 횟수의 vector transform을 동시에 수행해야 하는 경우가 많다. 다만, 여기에서 활용되는 연산이란 대부분의 경우 단순한 덧셈과 곱셈이 무수히 반복되는 경우이다. 즉 이런 연산의 경우, 복잡한 단일 연산을 빠르게 처리하는 것보다 클록 사이클당 단순한 덧셈과 곱셈 연산이 최대한 많이 수행되도록 아키텍처를 구성하는 편이 훨씬 유리하다.

예를 들어, 2차원으로의 이미지 Transformation을 위해 3차원 상의 좌표와 시각보정치 w를 합친 벡터 [x, y, z, w]를 4x4 Transform Matrix와 곱하는 행렬연산을 수행한다고 가정하자. 그렇다면:

A Standard 3D Matrix/Vector Transformation (Source: NVIDIA Documentation)

위와 같이 되므로, 단 하나의 좌표점에 대한 변환을 수행하기 위해 총 16번의 곱셈과 12번의 덧셈을 수행해야 한다. 덧셈과 곱셈은 절대 복잡한 연산은 아니지만, 단 하나의 좌표점에 대한 변환에 필요한 연산량이 이 정도라면 3차원 공간 전체를 2차원으로 Transform하는 데에 얼마나 많은 덧셈과 곱셈이 수행되어야 할지 생각해보자. Edge Detection이나 Gaussian Filter 등의 2차원 대 2차원 행렬의 Convolution이 반복적으로 이루어져야 하는 환경이라면 필요한 연산량은 더욱 더 증가할 수밖에 없을 것이다.

GeForce 256이 도입되기 이전에는, T&L을 포함한 이러한 반복적인 행렬연산을 모두 CPU에서 소프트웨어로 처리했다. 문제는, 대부분의 PC 프로세서 아키텍처는 의외로 단순 반복에 그닥 최적화되어 있지 않다는 점이다. CPU는 복잡하고 직렬적인 연산을 빠르게 수행하는 데에 주로 초점이 맞추어져 있기 때문이다. 이게 무슨 말이냐 하면, 가령 위와 같은 좌표변환 연산을 오직 직렬적인 연산만을 수행하는 CPU에서 어떻게 풀어내야 할지 생각해보자. 이런 식의 코드를 생각해 볼 수 있을 것이다:

A.init();
C.init();
martix_len = 4;
for B in matrixes {
while i < matrix_len {
while j < matrix_len {
C[i] += A[i][j] * B[i]; // Assuming that all matrixes are initialized with 0
j++;
}
i++;
}
}

…대충만 생각해 보아도, 변환이 필요한 각 좌표점당 이중반복문을 돌면서 총 16회의 연산을 수행해야 한다. matrixes가 변환이 필요한 모든 좌표점을 담은 배열이라 가정하면, 이 알고리즘의 총 실행 횟수는 matrixes.length*16으로 늘어난다. 조금 더 로우-엔드로 가서, 위의 C[i] += A[i][j] * B[i]; 라는 코드가 실제로 x86 어셈블리로 빌드되었을 때 대략 어떻게 되는지를 살펴보면 더 골치가 아파진다:

; previous lines omitted for simplicity's sake
; move numbers to register
mov eax, [source]
mov ebx, [source+4]
mov ecx, [addr]
; perform addition and multiplication
imul eax, ebx
add ecx, eax
push ecx
mov [addr], ecx
pop [addr]

스택에서 source 어드레스로부터 전 32비트를 읽어 eax 레지스터에, 후 32비트를 읽어 ebx 레지스터에 읽은 후, imul 인스트럭션으로 두 레지스터 값을 곱한 뒤 임의의 addrecx 레지스터 값을 옮긴다. 여기에서는 x86을 예시로 들었지만, 대부분의 프로세서들은 스택 머신 구조를 가지고 있는 현대 컴퓨터의 특성상 (최초의 컴퓨터들이 사용했던) 테이프처럼 프로그램 인스트럭션들을 직렬적으로 읽어내려가기 때문에, 어떤 단순한 작업을 반복해서 수행한다는 것은 메모리 및 프로세스 관리 측면에서도 꽤나 비효율적인 일이다. O(n²)만큼의 클록 사이클을 지속적으로 낭비하기 때문이다.

그렇다면, 바깥쪽의 for문 말고 동시에 연산을 수행할 수 있도록 멀티쓰레딩을 사용하면 어떨까? 우리는 더 이상 8086의 시대에 살고 있지 않고 개인 워크스테이션에 64코어 128쓰레드 CPU가 탑재되는 세상에 살고 있지 않은가? 물론 가능하긴 하지만, 극도로 비효율적인 건 마찬가지이다. 화려한 그래픽을 렌더링하기 위해서는 수십억 개의 폴리곤을 계속해서 실시간으로 그려내야 하는데, 이 폴리곤 하나에는 대체 몇 개의 좌표점들이 들어가며 이에 따른 변환 연산만 몇 번을 수행해야 할지를 생각해보자. 대체 몇 개의 쓰레드를 실시간으로 관리해야 할까? 대중적인 8코어짜리 프로세서에서는 엄두도 못 낼 정도일 것이다.

CPU의 그래픽 렌더링이 GPU에 비해 얼마나 비효율적인지를 느껴보려면 Cinebench 같은 CPU 기반 렌더링 벤치마크가 얼마나 느린지를 생각해보면 된다. 하나의 Scene을 렌더링하는 데에 수 초나 수 분 정도의 시간이 걸리지만, 게임을 원활하게 돌리기 위해서는 이러한 Scene들이 1초에 최소 60번 이상은 렌더링되어야 한다. 물론 CPU 기반 렌더링은 GPU 렌더링과는 용도가 다르기는 하나, 최신 고성능 CPU만으로 왜 고성능 게임을 즐길 수 없는지에 관한 설명은 이 정도로 충분할 듯하다. 두 하드웨어의 용도와 구조가 완전히 다르기 때문에 이러한 차이가 발생하는 것이다.

물론 앞서 언급한 64코어 128쓰레드짜리 괴물 CPU인 AMD의 Threadripper 3990X는 엄청난 수의 쓰레드를 앞세워, 무려 Crysis를 CPU 렌더링만으로 플레이가 “일단" 가능하도록 하는 기염을 토한다. 하지만 저 괴물도 10년도 더 전에 나온 게임을 원활하게 구동하지 못한다. 앞서 언급했듯 애초에 그럴 용도가 아니기 때문이다. 궁금하신 분은 위 Linus Tech Tips의 TR-3990X 리뷰 영상을 참고하시길.

그렇다면 GPU는 무엇이 다른가: 티끌 모아 태산

앞선 문단들에서는, GeForce 256 이전 PC에서 사용되던 CPU 기반 렌더링 방식이 왜 극도로 비효율적인지를 서술했다. 그럼, GPU는 무엇이 다르길래 이와 같은 그래픽 렌더링에서 훨씬 더 효율적인 것일까?

위의 간단한 벡터 행렬곱 문제를 다시 꺼내와 보자.

A Standard 3D Matrix/Vector Transformation (Source: NVIDIA Documentation)

이와 같은 연산을 빠르고 효율적으로 수행하려면, 기존의 직렬적인 프로세서 아키텍처로는 아무래도 무리다. 벡터와 행렬끼리의 연산에서는 CPU처럼 다양하고 복잡한 옵코드를 지원할 필요가 없다. 덧셈과 곱셈, 이 두 가지만 잘해주면 된다. 대신 최대한 한 번에 많이, 그리고 빠르게 처리해줄수록 유리하다. 한 번에 간단한 연산을 많이 — 즉 병렬적으로 — 처리해줄수록 더 고수준의 그래픽 연산들을 더 빠르게 화면에 뿌려줄 수 있기 때문이다.

이것이 어떻게 이루어지는지를 서술하기 위해 현대 GPU의 아키텍처에 대해서 자세히 설명할 필요는 없지만, 대략적으로 보자면 GPU의 핵심은 기존의 CPU처럼 복잡한 연산을 지원하는 코어를 몇 개만 두는 것이 아니라, 비슷한 실리콘 공간 안에 매우 간단한 연산만을 수행할 수 있는 코어를 최대한 많이 집어넣는 것이다. 즉, 복잡한 연산을 최대한 빠르게 처리하는 것이 아니라 간단한 연산을 최대한 많이 처리하는 쪽에 방점이 찍힌 거다. 고복잡도 직렬 연산이 아닌, 저복잡도 병렬 연산에 최적화되어 있는 칩이 GPU이다. (GPU의 경우 제조사별로 아키텍처나 접근 방식이 크게 갈리기 때문에, 아키텍처에 대한 더 이상의 상세한 설명은 큰 의미가 없다.)

NVIDIA의 Pascal 아키텍처 중, 단일의 SM을 나타내는 다이어그램.

가령, 엔비디아의 파스칼 아키텍처는 SM당 총 64개의 CUDA 코어와 2048개의 쓰레드를 갖추고 있고, 파스칼의 풀칩인 GP100은 무려 3584개의 CUDA 코어를 갖추고 있다. 이와 경쟁하는 AMD 베가의 풀칩인 베가64는 64개의 Compute Unit과 4096개의 Shader를 가지고 있다. (앞서도 언급했지만, 이 두 회사는 매우 다른 설계철학과 GPU 아키텍처를 가지고 있기 때문에 이런 식의 수치상으로 비교하는 것은 아무런 의미가 없다.) GP100의 경우 FP16 반정밀도 연산에서 총 20.7TFLOPS의 연산성능을 발휘하는데, 이는 2000년대 초반 슈퍼컴퓨터의 2배에 가까운 연산성능이다. 물론 Floating Point에서 정밀도가 16비트로 다소 떨어지기 때문에 아주 정확한 비교는 아니지만, 현재 GPU가 사용되는 용도를 고려하면 연산정밀도는 생각보다 그리 중요하지 않다 (이에 관련해서는 이후 문단에서 서술하고자 한다).

위의 벡터 변환 연산을 직렬적인 CPU 연산 대신, 엔비디아의 CUDA를 사용해서 다시 표현해보면 대략 다음과 같다 (이해를 돕기 위한 수도코드 정도여서 실제 실행되지는 않는다).

// kernel.cu__global__ void vectorTransformKernel(float* A, float* B, float* C, int rowLength, int colLength) {

int rows = blockIdx.y*blockDim.y+threadIdx.y;
int cols = blockIdx.x*blockDim.x+threadIdx.x;

float sum = 0;

if (rows < rowLength && cols < colLength) {
for (int i = 0; i < colLength; i++) {
// transform 2D array into 1D
sum += A[rows * rowLength + i] * B[i * colLength + cols];
}
}
C[rows * rowLength + cols] = sum;
}

void vectorTransform(float *A, float *B, float *C, int rowLength, int colLength){

dim3 threadsPerBlock(rowLength, colLength);
dim3 blocksPerGrid(1, 1);

vectorTransformKernel<<<blocksPerGrid,threadsPerBlock>>>(A, B, C, rowLength, colLength);
}
// main.cu// Allocate memory on host
vector<float> h_A(4*4);
vector<float> h_B(1*4);
vector<float> h_C(1*4);
// Allocate memory on device (GPU)
device_array<float> d_A(4*4);
device_array<float> d_B(1*4);
device_array<float> d_C(1*4);

// Set value from device to host
d_A.set(&h_A[0], 4*4);
d_B.set(&h_B[0], 1*4);

// Run kernel on device
vectorTransform(d_A.getData(), d_B.getData(), d_C.getData(), 4, 4);
cudaDeviceSynchronize();

d_C.get(&h_C[0], 1*4);
cudaDeviceSynchronize();

일반인이 접근하기 쉬운 코드는 아니지만, 대략 풀어쓰자면 다음과 같다.

  • vectorTransformKernel: GPU 장치의 각 thread에서 병렬 실행될 연산 코드
  • vectorTransform: GPU 장치에 blockthread 16개를 할당하고 그 수만큼 vectorTransformKernel을 호출
  • main.cu: CPU가 관리하는 메인 메모리와 GPU의 VRAM에 각각 행렬 크기만큼의 메모리를 할당해주고, VRAM에 있는 행렬 A와 B의 초기값을 메인 메모리에 있는 값대로 설정한 다음, VRAM에 있는 행렬들의 데이터와 총 연산 사이즈(4*4)를 vectorTransform에 인자로 넘겨 호출한 후, 결과값으로 반환되는 행렬 C를 VRAM에서 메인 메모리로 복사

vectorTransformKernel에서 수행되는 행렬곱 연산이 직렬로 16번 반복되는 것이 아니라, 16개의 쓰레드로 분리되어 동시에 병렬로 실행되기 때문에, 실제 연산에 소요되는 시간은 행렬의 수나 크기가 아무리 늘어나도 (단, 쓰레드 수는 블록당 1024개로 제한) 덧셈과 곱셈을 한 번씩 수행하는 시간만큼만 소요된다. 벡터 및 행렬 변환에 크게 의존하는 그래픽 연산에 극도로 유리할 수밖에 없다.

…그런데 현재 GPU가 사용되는 빈도를 보면, 그래픽 관련 작업보다 훨씬 더 넓은 범주의 연산에 사용되고 있고 그 비중은 점점 늘어나고 있다. 이건 또 어떻게 된 걸까?

GPGPU와 CUDA: 태생의 한계를 뛰어넘다

2007년 6월, 엔비디아는 CUDA(Compute Unified Device Architecture)라는 새로운 프로그래밍 모델을 발표한다. 그 당시에는 일부 과학자들을 제외하고는 그렇게까지 반향이 크지 않은 기술이었으나, 이후 CUDA는 엔비디아가 컴퓨터 회사에서 AI 회사로 완전히 탈바꿈하는 데에 핵심적인 역할을 한다. 개인적으로는 같은 해에 발표된 오리지널 아이폰만큼이나 중요한 혁신이라고 보고 있다. 그래픽 연산에만 한정되었던 GPU를 일반적으로 프로그래밍되어 확장될 수 있도록 하는 플랫폼을 깔아주었기 때문이다. 일반적인 GPU의 사용, 즉 GPGPU의 시대가 열린 것이다.

앞서 병렬화가 그래픽 연산에서 왜 중요한지를 언급했지만, GPU는 그 병렬적인 특성 때문에 그래픽 이외 분야에서도 매우 유용한 도구가 되었다. 바로 수치해석, 통계 및 시뮬레이션 분야이다. 사실 복잡한 연산을 고성능으로 구동하는 것보다 단순한 연산을 많이 구동하는 것이 중요한 곳은 더 있었고 과학이 그 중 하나였던 것이다.

다만 일부 전문가 집단을 제외하고는, 한동안 CUDA 플랫폼은 여전히 컴퓨터 그래픽 분야에서 그래픽 API를 거치지 않고 GPU에 직접적으로 접근해 성능최적화를 이루어내고자 하는 용도로 가장 많이 사용되었다. 이러한 추세가 깨진 것은 딥러닝에 GPGPU가 매우 유용하다는 사실이 밝혀지면서부터다.

현재까지도 어마어마한 거품이 끼어 있지만, 딥러닝이라는 기술분야의 본질은 데이터에 기반한 통계적 프로그래밍이다. 기존과는 완전히 다른 프로그래밍 방식의 일종이라는 뜻이다. 이러한 통계적 방법을 사용하면 결정론적인 튜링 머신 — 즉, 컴퓨터 — 의 한계를 뛰어넘을 수 있기 때문에 현재까지도 각광을 받고 있는 거다. 그리고 이러한 통계적 방식의 프로그래밍에 프로세스의 병렬화와 GPU는 사실상 필수품이다.

컴퓨터라는 기계는 기본적으로 좀 똑똑한 계산기다. 보다 엄밀하게 정의하자면, 현대의 모든 컴퓨터는 앨런 튜링이 상상해낸 가상의 기계인 튜링 머신에 기반을 두고 있고, 튜링 머신은 유한한 명령어(Instruction)들의 집합 상에서 결정론적인 상태 변환(State Transition)을 하며, 그 상태와 데이터를 무한한 길이의 테이프에 유한한 종류의 문자로 기록하는 수학적 모델이다. 다시 말해:

  • 상태 변환은 명령어의 실행에 의해 일어난다.
  • 가정 상태(Predefined State)와 명령어의 순서, 즉 프로그램이 모두 일치한다면, 변환된 상태 (Transformed State)는 항상 일치한다. 즉, 튜링 머신에서의 상태 변환은 결정론적(definative)이다.
  • 상태 변환은 지정된 명령어의 순열에 따른 결과이다. 따라서, 튜링 머신은 스스로 상태 변환을 일으키지 못하며 오직 명령어의 결과에 의해서만 상태가 변경된다.

상태 변환이 항상 결정론적 (state transitions are always definative) 라는 표현이 다소 애매하게 들릴 수 있는데, 이것은 다르게 표현하자면 튜링 머신은 정의상 순수하게 외부적 요인에 의해서만 구동되며 스스로는 아무것도 결정하지 못한다는 의미이다. 외부에서 넣어 준 명령어들의 집합이 있고, 사전에 지정된 초기 상태값이 있으며, 모든 프로그램의 실행 및 상태 변환은 오직 이 두 가지에 의해서만 결정되기 때문에 다른 요인이 끼어들 구석이 전혀 없다. 따라서 튜링 머신의 가정값들을 알고 있다면, 그 결과값도 항상 일치하고 예측 가능하다는 것이다.

컴퓨터가 처음 등장하고서부터 비교적 최근에 이르기까지 모든 컴퓨터는 이러한 방식에 기반해 동작했다. 사람이 프로그램을 짜 주고, 프로그램에 설정값과 입력값을 넣어 주면, 그 실행 결과는 항상 일치해야만 했다. 문제는 이러한 결정론적인 방식으로 풀어낼 수 없는 문제들이 산더미처럼 쌓여있다는 것이었고, 그 원인은 인간이 프로그램으로 하여금 결정론적으로 문제를 해결하도록 하는 데에 필요한 모든 요소와 인자 — 즉 상태값 — 을 파악하기에는 사고의 범위가 너무나도 좁다는 것이었다.

가령 손글씨를 인식하는 프로그램을 짜야 한다고 생각해 보자. 결정론적인 튜링 머신에서는, 인간이 모든 전제조건과 명령어를 제공해 예측 가능한 범위에서 결과값을 도출해야만 하기 때문에 모든 판단 기준 또한 직접 제공해주어야 한다. 그런데 손글씨 같은 이미지를 0에서 9까지의 숫자로 분류하기 위한 기준을 인간이 짜넣기가 영 까다로운 것이 아니다. 어떤 경우에 0이고 어떤 경우에 9인가? “동그라미에 꼬리가 삐죽 튀어나와 있으면 9이고 아니면 0” 이러한 조건들을 어떻게 튜링 머신이 이해할 수 있는 형태로 집어넣을 수 있다는 건가? 이것에 관한 수많은 논의들이 있지만 결론은 인간의 인지 범위가 턱없이 좁아 인식에 필요한 모든 전제조건들을 집어넣기에는 한계가 분명하다는 것이었다.

이에 대한 대안으로 떠오른 것이 통계적인 해결책인데, 가령 이런 류의 방법이다. 이미지를 크기가 일정한 (가령 20x20 픽셀짜리) 사각형 안에 들어오도록 변형한다. 검은색으로 칠해진 영역을 1, 그렇지 않은 영역을 0으로 표현할 수 있으므로, 모든 손글씨 이미지를 20x20짜리 이진법 행렬로 표현할 수 있다. 충분한 수의 행렬을 0부터 9까지의 숫자에 일일이 연결시킨 후, 차원변환을 거쳐 좌표평면 위에 나열하면 좌표상 거리를 기준으로 정렬해 클러스터를 만든 뒤 각 클러스터를 숫자에 연결시킬 수 있다. 같은 방식으로 새로운 데이터가 들어오면, 똑같이 20x20 행렬로 변환해 어느 클러스터에 가장 가까운지를 좌표평면 상 거리순으로 판단하면 이 숫자가 0부터 9 중 어느 숫자를 표현한 것인지를 파악할 수 있다. 이러한 방법론들이 기초적인 머신러닝이다. 2000년대 초중반까지의 손글씨 인식 PDA들은 이런 방식으로 동작했고, 실제로도 쓸만한 정도의 정확도를 보였다.

다만 이러한 방법론들도 튜링 머신의 근본적인 한계를 완전히 극복하지는 못했는데, 가령 이런 방법으로 만들어진 손글씨 인식 프로그램들은 미리 넣어진 거리값들과 계산식을 기준으로 새로운 손글씨의 글자를 판명할 수는 있었으나 그로부터 새롭게 “배우지”는 못했다. 사실 생각해보면 당연한 일인 것이, 튜링 머신은 결정론적이기 때문에 가정조건들을 모두 직접 입력해주어야 하고 스스로 할 수 있는 일이 아무것도 없다. 따라서 인간이 데이터셋을 일일이 업데이트해주기 이전까지는 해당 시스템에 그 어떠한 개선도 바랄 수 없었던 것이다.

이 결정론적인 튜링 머신의 한계까지 극복해낸 것이 딥러닝이다. 계속해서 늘어나는 데이터로부터 통계적인 모형을 이끌어낸 후, 사전에 해석적인 데이터의 패턴을 모르더라도 인간의 뇌에서 모티브를 얻은 신경망과 역전파, 활성 임계치 모델을 따와 전혀 모르는 지형을 탐험하듯 계속 시도하면서 데이터 분포의 지형을 탐색(prediction — loss — backpropagation의 feedback loop)해 “통계적”인 규칙을 뽑아낸다는 것이다. 이러한 방식은 프로그램 자체는 고정되어 있어 결정론적인 소프트웨어를 사용하더라도, 사전에 지정된 해석적인 규칙이 아닌 통계적인 모델에 기반하기 때문에 끊임없이 스스로 성능을 개선하는 것이 가능해진다. 그리고 여기에 필수적으로 필요해진 것이 바로 GPU의 병렬성인 것이었다.

전혀 모르는 지형을 탐색하고 그림을 그리는데, 어차피 계속해서 틀릴 것이기 때문에 이러한 분야에서는 연산의 정밀도가 그닥 중요하지 않다. 부동소수점 실수를 16비트로 표현해 (FP16) 값의 표현과 그 연산이 정확하지 않더라도, 단순한 연산들을 대규모의 데이터값에 대해서 최대한 많이 반복 실행하는 것이 가장 중요하다. GPU와 딥러닝의 접점은 여기에서 생겼다. 오히려 거꾸로 딥러닝이 GPU의 설계에 영향을 미치는 일은 계속해서 반복되고 있는데, 가령 반정밀도(FP16)에 중점을 두고 GPU 아키텍처를 설계한다거나, 텐서 코어 같이 표준적인 GPU ALU 파이프라인을 변형해 딥러닝에 최적화된 다중정밀도 (multi-precision) 코어를 집어넣는 것 같은 일들이다 (텐서 코어는 FP16 반정밀도의 4x4 행렬 두 개의 곱에, FP16 반정밀도 또는 FP32 단정정밀도 4x4 행렬을 더하는 연산을 수행해 FP16 또는 FP32 행렬을 반환하는데, 표준적인 ALU Math와는 다르게 SMID 스칼라값이 아닌 고정 행렬연산을 수행하기 때문에 한정된 일부 용도(딥러닝)에서 특출난 성능을 보인다는 점을 제외하고는 극도로 연산유연성이 떨어진다. 가령 텐서코어로 표준 스칼라 연산을 수행하려고 한다면 끔찍할 정도의 성능을 보일 것이다).

…그런데, 계속 이래도 되나?

개인적으로 나는 GPU와 꽤 친한 사람이라고 생각한다. 오랫동안 블록체인 했지만 또 학사 전공은 딥러닝이고 (그러니 당연히 GPU를 클라우드든 워크스테이션이든 달고 살 수밖에 없다), 2009–2010년 아무도 비트코인에 관심을 주지 않을 때 CUDA와 OpenCL로 비트코인 채굴기도 만들었었으며, 그 이후 라데온 중고 “하와이” 카드들로 이더리움 채굴도 꽤나 했었다. CUDA/OpenCL로 이것저것 만들어 보면서, GPU가 제공하는 병렬성이 보여줄 수 있는 가능성에 대해서도 꽤 잘 알고 있다고 믿고 있다.

그런데, 현재 GPU와 그에 기반한 딥러닝에 관한 열풍이 다소 지나치다고 느껴지는 것 또한 사실이다. GPU의 저정밀 병렬성이 딥러닝 알고리즘과 합쳐지면서 튜링 머신의 한계를 뛰어넘어 기존의 프로그래밍 방식으로는 상상도 하지 못했던 일들을 해내고 있지만, 또 이러한 메커니즘 자체로의 한계 또한 존재하기 때문이다.

딥러닝은 모든 문제를 해결해주는 만능 도구가 아니다. 단지 전체적인 모델을 해석적으로 파악하고 하드코딩하기 힘들 때, 병렬적인 반복 연산을 통해서 통계적인 답을 찾아줄 뿐이다. 그렇기 때문에 이러한 통계적인 방법론들은 때로 컴퓨팅 자원 측면에서 매우 비효율적이며, 특히나 정형화된 방법론들이 이미 상당 부분 정해질 정도로 성숙한 시장에서는 더욱 더 비효율의 위험이 높을 수밖에 없다. (가령 덧셈 연산을 딥러닝으로 재창조한다고 생각해보자. 이런 일은 누가 보아도 비효율적이니 실제 적용될 일은 없겠지만, 굳이 딥러닝 없이도 결정론적인 프로그래밍의 범위 내에서 해결될 수 있는 문제에 딥러닝을 활용하는 것은 상당한 컴퓨팅 자원의 낭비라고 생각한다.)

이러한 비효율은, 기반 시스템의 문제점들을 “극복”해낸 신분야들에서 흔히 찾아볼 수 있는 것들이기도 하다. 블록체인은 디지털 세상에서의 비트 복제성 문제와 거기에서 기인하는 소유권의 문제를 해결해 주었지만 (non-replicability on a bit-by-bit replicable system), 그 대신 영원히 처음부터 끝까지 트랜잭션 기록의 체인을 끌고 가면서 계속해 블록을 붙여주어야만 하는 운명을 떠안았다. 작업증명 기반 체인이면 영원히 GPU로 전기 먹으면서 채굴까지 해주어야 한다. 딥러닝은 튜링 머신의 결정론적 특성과 거기에서 기인하는 해석적 비효율의 문제를 통계학과 데이터로 해결해 주었지만 (self-operability on a deterministic Turing machine) 그 대신 데이터의 필요로 인한 프라이버시와의 충돌, 블랙박스 모델의 특성으로 인한 설명 불가능성, 그리고 연산적 비효율이라는 또다른 문제들을 안고 있다.

천재들이 포진해 있는 양쪽 모두 점진적으로 자신들이 가지고 있는 문제를 해결해 나갈 것이라고 믿어 의심치 않으나, 염려되는 점은 지나친 하이프가 훌륭한 기술을 망치거나 되려 비효율만 초래할 수 있다는 것이다.

기술 무용론을 주장하려는 것은 아니다. GPU와 딥러닝이 기존에 상상도 하지 못했던 엄청난 일들을 이루어냈고, 이게 미래 그 자체라는 것은 사실이니까. 다만 무엇이든 조금 더 냉정하게, 객관적으로 바라보아 주었으면 좋겠다. 기술은 도구일 뿐이다. 거품이 빠지고 기술이 대중화되면 서비스와 비즈니스 모델이 더 중요해지지, 기술이 우선순위에 설 일은 결코 없다.

더 많은 사람들이, 새로운 시각으로 한계를 깨는 접근들을 더 많이 보여주었으면 한다.

--

--

Daniel Hong

🌈(🇰🇷,🏳️‍🌈) bitcoiner since 2008 | hobo @nonce_community | building universes