내 GPU는 그것이 대체한 CPU보다 느렸다

언젠가 나는 무거운 연산을 CPU에서 GPU로 옮기면서 그것이 날아갈 것이라 기대했다. 그런데 오히려 기어갔다 — 대체하기로 되어 있던 평범한 코드보다 극적으로 느렸다. 나는 속도를 샀는데 어쩐 일인지 느려짐을 설치한 셈이었다.

하드웨어가 병목은 아니었다. 현대적인 GPU는 컴퓨터에 넣을 수 있는 가장 빠른 것들 중 하나다. 문제는 내가 그것에게 거의 유일무이하게 못하는 형태의 작업을 주었고, 그러고 나서 그것이 제대로 돌아가지 못하는 데 놀랐다는 것이었다.

다운그레이드였던 업그레이드

기대는 단순했고, 내 생각엔 안전했다: GPU는 빠르고, 내 작업은 느리니, 그 작업을 GPU로 옮기면 빨라질 것이다. 그것이 가속이 작동하는 방식에 대한 민간 버전이고, 그것은 특정하고 교훈적인 방식으로 틀렸다.

“가속된” 버전을 측정했을 때, 그것은 조금 느린 정도가 아니었다. 큰 배수만큼 느렸다 — 문제가 누락된 최적화가 아니라 근본적인 불일치임을 알려주는 종류의 결과였다. 내가 하고 있던 것의 형태에 관한 무언가가 하드웨어와 적극적으로 싸우고 있었다.

”GPU에 올린다”는 것이 실제로 의미한 것

내가 정말로 했던 것은 이렇다. 나에게는 기존 루프가 있었다: 작업 한 단위를 가져와, 그것을 계산하고, 다음으로 넘어가는 것을, 수천 번 반복하는 것. “GPU를 쓰려고,” 나는 그저 각 개별 단위가 GPU에서 돌아가게 만들었을 뿐 — 그리고 루프는 정확히 있던 자리에 그대로 두었다.

그래서 연달아 수천 번, 내 프로그램은 GPU 쪽으로 돌아서서, 그것에게 작은 작업 하나를 건네고, 답을 기다리고, 다음을 위해 다시 돌아섰다. 루프가 여전히 주도권을 쥐고 있었다. GPU는 그저 내가 박고 싶은 못 하나하나마다 개별적으로 전화를 거는, 아주 빠른 도급업자였을 뿐이었다.

숨은 비용: 묻는 것만으로도 비용이 든다

GPU에 작업 한 조각을 건넬 때마다, 그것을 설정하고 보내는 데 고정 비용이 든다 — 그 자체로는 작고, 한 번만 치르면 보이지 않는다. 나는 그것을 수천 번 또 수천 번 치르고 있었다. 자세히 들여다보니, 런타임의 대부분은 연산이 전혀 아니었다. 그것은 오버헤드였다: 내 프로그램이 작은 질문 하나하나를 차례로 던지는 데 시간을 쓰는 동안, GPU는 무엇을 하라는 지시를 기다리며 놀고 있었다.

나는 거의 전적으로 묻는 행위로 이루어진 프로세스를 만들어 놓았고, 실제 작업은 반올림 오차에 불과했다. CPU 버전은 느렸지만, 적어도 계속 바빴다. GPU 버전이 느렸던 것은 그 삶의 대부분을 기다리며 보냈기 때문이었다.

GPU는 택시가 아니라 버스다

마침내 그것을 고친 멘탈 모델은 이것이다: GPU는 한 가지를 하는 더 빠른 방법이 아니다. 그것은 같은 일을 엄청난 수만큼 한 번에 하는 방법이다. 그것은 스포츠카가 아니라 50인승 버스다.

항목당 한 번씩 그것을 호출하는 것은, 그 버스를 매번 승객 한 명만 태우고 도시를 가로질러 왕복으로 굴리는 것과 같다. 버스는 진짜로 빠르다. 당신의 처리량은 형편없다 — 그리고 그것이 형편없는 까닭은 바로 군중을 위해 만들어진 탈것을 사람을 한 명씩 옮기는 데 쓰고 있기 때문이다. 잘못은 버스에 있지 않다. 배차에 있다.

해법: 루프를 멈추고, 배치를 시작하라

수리책은 더 빠른 커널이나 더 좋은 카드가 아니었다. 그것은 루프를 재구조화하는 것이었다. “각 항목마다, 그것을 GPU에 보낸다” 대신, 나는 GPU가 많은 항목을 한 번에 받아 함께 처리할 수 있도록 작업을 다시 빚었다 — 수천 번의 작은 디스패치를 한 줌의 큰 디스패치로 바꿨다. 내가 거듭거듭 치르고 있던 오버헤드는 수천 번이 아니라 몇 번만 치르는 무언가로 무너져 내렸다.

이것은 사람들이 “벡터화” 또는 배치라고 말할 때 의미하는 한 가지 흔한 형태다: 많은 유사한 연산을, 작은 단계들의 긴 시퀀스로가 아니라, 함께 표현하는 것. 그것은 흔히 당신의 CPU를 부끄럽게 만드는 GPU와, 그것에게 부끄러움을 당하는 GPU 사이의 차이이고, 그 둘 사이의 간극은 엄청날 수 있다.

왜 이것이 하드웨어의 핵심 전부인가

이 구조가 왜 그토록 중요한지를, 그것을 튜닝 세부 사항으로 취급하는 대신, 내면화하는 데는 시간이 좀 걸렸다. 여기서 GPU의 강점은 어떤 단일 연산이 빠르다는 데서 오지 않았다. 그것은 방대한 수의 유사한 연산을 동시에 한다는 데서 왔다. 그 병렬성이 가치 제안의 전부다. 만약 당신의 코드가 그 작업을 “이걸 전부 동시에 하라”로 표현할 수 없다면, 병렬성이 붙잡을 것이 아무것도 없다 — 그리고 직렬로, 한 번에 하나씩 작업을 돌리는 GPU는 그저 비싸고, 어색하고, 잘못 빚어진 CPU일 뿐이다.

그러니 “GPU에 올려라”는 결코 진짜 지시가 아니었다. 진짜 지시는 “작업을 한 번에 다 할 수 있도록 다시 빚어라”였다. 하드웨어는 언제나 문제의 형태가 허락하는 만큼만 빠를 운명이었다.

도구는 잘못된 형태를 구할 수 없다

그 교훈은 GPU를 한참 넘어 잘 일반화된다. 당신의 연산 구조가 그 하드웨어와 싸우고 있다면, 더 빠른 하드웨어에 손을 뻗어도 별 소용이 없다. 이런 종류의 작업에서, 성능은 그것을 돌리는 것의 명목상 속도보다, 연산의 형태 — 그것이 어떻게 조직되고, 배치되고, 표현되는지 — 에 더 깃들어 있었다. 느린 알고리즘을 빠른 기계에 올려서 빠른 실망을 얻을 수 있다.

나는 그 느려짐을, “나는 그것을 더 빠르게 만들었다”와 “나는 그것을 더 빠른 하드웨어로 옮겼다”가 같은 문장이 아니라는 점을 일깨우는 것으로 마음에 새겨 둔다. 첫 번째는 작업을 이해하는 것에 관한 것이다. 두 번째는 실리콘이 당신을 위해 그것을 이해해 주기를 바라는 것에 관한 것이다. 그것은 그러지 않을 것이다.

— 신호도, 수익도 없으며, 투자 자문이 아니다.