← 학습 경로

공통 · 2026-09-12

GPU 최적화의 출발점: 산술 강도와 데이터 이동

메모리와 연산의 병목을 산술 강도와 Roofline으로 연결하고, 데이터 이동 감소·동시 진행과 원소별 연산 fusion의 원리를 설명합니다.

지난 글에서는 하나의 계산 절차를 여러 스레드로 나누고, 블록을 SM에 배정하며, 실행할 수 있는 워프의 명령을 선택하는 과정을 살펴봤습니다. 그렇다면 병렬로 실행할 작업을 충분히 만들면 GPU의 연산 능력을 모두 활용할 수 있을까요?

계산을 진행하려면 필요한 데이터가 준비되어야 합니다. 많은 스레드를 배정했더라도 데이터를 기다리고 있다면 연산 장치에 일을 충분히 공급하지 못할 수 있습니다. 이번에는 계산량과 데이터 이동량의 관계를 살펴보고, 같은 결과를 더 짧은 시간에 얻으려면 무엇을 줄이거나 함께 진행할 수 있는지 알아보겠습니다.

메모리와 연산 중 무엇이 병목일까요?

GPU에서 계산을 실행하려면 메모리에 있는 입력을 읽고, 연산 장치에서 처리한 뒤, 결과를 저장해야 합니다. 앞서 메모리 구조를 살펴볼 때 HBM의 용량뿐 아니라 대역폭과 접근 지연도 중요하다고 했습니다. 모델이 메모리에 들어가더라도 계산에 필요한 데이터를 제때 공급할 수 있는지는 별개의 문제입니다.

같은 GPU에서 데이터 공급이 병목인 경우와 계산 처리 속도가 병목인 경우를 비교합니다. 위쪽에서는 입력이 부족해 계산이 뜸하고, 아래쪽에서는 입력당 필요한 계산이 많아 연산 장치의 처리 속도가 한계가 됩니다.

그림 1의 위쪽은 데이터를 공급하는 속도가 병목인 경우입니다. 연산 장치가 더 많은 계산을 처리할 수 있어도, 메모리에서 입력을 가져오는 속도가 따라오지 못합니다. 이런 계산에서는 연산 장치의 수나 계산 속도만 높여도 실행 시간이 충분히 줄어들지 않을 수 있습니다.

아래쪽은 계산을 처리하는 속도가 병목인 경우입니다. 필요한 데이터는 공급할 수 있지만, 그 데이터로 수행해야 하는 계산이 많습니다. 이번에는 연산 장치가 계산을 끝내는 속도가 전체 처리 속도를 제한합니다.

앞서 본 원소별 덧셈을 떠올려 보겠습니다. 출력 하나를 만들려면 입력 두 값을 읽고, 덧셈 한 번을 수행한 뒤, 결과 한 값을 저장합니다. 입력이 길어지면 독립적으로 계산할 출력은 많아지지만, 출력 하나에 필요한 덧셈은 여전히 한 번입니다. 따라서 동시에 할 일이 많다는 것과, 데이터를 읽고 쓰는 양에 비해 계산이 많다는 것은 다릅니다. 원소별 연산이라도 복잡한 함수를 적용한다면 출력당 계산량은 더 많아질 수 있습니다.

GPU를 효율적으로 사용한다는 것은 필요한 결과를 얻는 시간을 줄이는 방향으로 자원을 활용하는 것입니다. 이를 위해서는 먼저 데이터를 공급하는 쪽과 계산하는 쪽 중 무엇이 실행을 제한하는지 살펴봐야 합니다.

산술 강도와 Roofline: 데이터 이동을 계산량과 연결하기

데이터 이동과 계산의 관계를 숫자로 나타내 보겠습니다. 산술 강도(arithmetic intensity)는 데이터 이동량에 대한 연산량의 비율입니다. 이번 글에서는 HBM에서 읽고 HBM에 쓰는 데이터의 바이트 수를 기준으로 사용하겠습니다.

산술 강도 = 연산량 ÷ 데이터 이동량

부동소수점 덧셈이나 곱셈 한 번을 1 FLOP으로 세면, 산술 강도의 단위는 FLOP/byte가 됩니다. 이는 데이터 1 byte를 이동할 때 얼마나 계산하는지를 뜻합니다. FLOP은 연산의 양이고, FLOPS는 초당 수행하는 연산량이라는 차이가 있습니다.

그림 2의 계산 A는 데이터를 64 byte 이동할 때 128 FLOP을 수행하므로 산술 강도가 2 FLOP/byte입니다. 계산 B는 같은 이동량에 512 FLOP을 수행하므로 8 FLOP/byte입니다. 이 비율의 계산이 충분히 많이 반복된다고 생각해 보겠습니다.

왼쪽에서는 128 FLOP을 64 byte로 나누어 산술 강도 2를, 512 FLOP을 64 byte로 나누어 8을 구합니다. 오른쪽 Roofline은 대역폭에 따른 기울어진 상한과 연산 처리량에 따른 수평 상한을 보여줍니다.

이제 메모리 대역폭이 8 GB/s이고, 연산 처리량의 상한이 32 GFLOPS인 장치를 가정하겠습니다. GB/s는 초당 십억 바이트, GFLOPS는 초당 십억 번의 부동소수점 연산을 뜻합니다. 계산 A처럼 데이터 1 byte당 2 FLOP을 수행한다면, 초당 8 GB를 공급받아 할 수 있는 계산은 최대 16 GFLOPS입니다. 연산 장치가 32 GFLOPS를 처리할 수 있더라도, 데이터 공급이 허용하는 계산량은 그보다 적습니다.

계산 B에서는 데이터 1 byte당 8 FLOP을 수행합니다. 같은 대역폭으로 초당 64 GFLOP에 해당하는 데이터를 공급할 수 있지만, 연산 장치는 최대 32 GFLOPS까지만 처리할 수 있습니다. 이 경우에는 연산 처리량이 상한을 정합니다.

이 관계를 나타낸 것이 오른쪽의 Roofline입니다. 가로축은 산술 강도, 세로축은 연산 처리량입니다. 기울어진 선은 메모리 대역폭 × 산술 강도로 구하는 상한이고, 수평선은 연산 장치가 처리할 수 있는 상한입니다. 실제로는 두 조건을 모두 만족해야 하므로, 둘 중 낮은 값이 처리량의 상한이 됩니다.

산술 강도가 낮은 구간에서는 이동하는 데이터에 비해 계산이 적어 대역폭이 상한을 정합니다. 산술 강도가 커지면 같은 데이터 공급량으로 더 많은 계산을 할 수 있지만, 연산 장치의 상한에 도달한 뒤에는 계속 높아지지 않습니다. 그림에서는 4 FLOP/byte에서 두 선이 만납니다.

Roofline은 실행 성능을 보장하는 선이 아니라, 넘을 수 없는 상한을 나타냅니다. 앞선 글에서 본 것처럼 작업이 부족하거나 워프들이 모두 대기한다면 실제 처리량은 더 낮아질 수 있습니다. 또한 연산 종류와 자료형에 맞는 처리량 상한을 사용해야 합니다. 행렬 곱을 수행하는 Tensor Core의 최대 처리량을 모든 연산에 그대로 적용할 수는 없습니다.

그렇다면 같은 계산을 유지하면서 필요한 데이터 이동을 줄이면 어떨까요? 산술 강도의 분모가 작아집니다. 데이터를 이동하는 데 드는 부담을 줄여, 연산 장치가 일을 진행할 여지를 늘릴 수 있습니다. 뒤에서 볼 원소별 연산의 fusion이 이런 예입니다.

데이터 이동을 줄이거나, 기다리는 동안 다른 계산 진행하기

데이터가 준비될 때까지 계산을 기다려야 한다면 두 방향을 생각할 수 있습니다. 필요한 데이터 이동 자체를 줄이거나, 데이터를 기다리는 동안 독립적인 다른 계산을 진행하는 것입니다.

그림 3에는 계산 A와 B가 있습니다. A는 메모리에서 가져올 입력을 기다리고 있고, B는 필요한 입력이 이미 준비되어 있어 A와 독립적으로 실행할 수 있습니다. 세 경우 모두 같은 A와 B의 계산을 완료합니다.

기준 실행은 A의 입력을 기다린 뒤 A와 B를 계산합니다. 이동 감소는 A에 필요한 데이터 이동을 줄입니다. 동시 진행은 A의 입력을 준비하는 동안 B를 먼저 계산하며, A의 입력 준비 시간 자체는 유지됩니다.

첫 번째 시간선에서는 A의 입력이 도착할 때까지 기다린 뒤 A와 B를 차례로 계산합니다. B는 먼저 진행할 수 있었지만, 여기서는 데이터가 준비되는 동안 연산 장치가 기다립니다.

두 번째 시간선에서는 A를 위해 옮겨야 하는 데이터를 줄였습니다. 이미 가까이에 보관한 값을 재사용하거나, 중간값을 메모리에 저장했다가 다시 읽는 과정을 없애면 이런 방향의 개선이 가능합니다. 계산 A와 B는 그대로 두고, 데이터 준비에 드는 부담을 줄이는 것입니다.

세 번째 시간선에서는 A의 입력을 준비하는 동안 B를 먼저 계산합니다. A가 데이터를 기다리는 시간은 그대로지만, 그 시간에 해야 할 계산의 일부를 끝냅니다. A의 입력이 준비되면 A를 실행할 수 있으므로, 두 계산을 모두 완료하는 시점이 앞당겨집니다.

지난 글의 워프 스케줄링도 이 원리와 연결됩니다. 한 워프가 메모리 읽기 결과를 기다릴 때, 스케줄러는 준비된 다른 워프의 명령을 선택할 수 있습니다. 그 결과 데이터 이동과 다른 계산이 동시에 진행될 수 있습니다. 중요한 것은 계산 A에 필요한 입력이 도착하기 전에 A를 실행하는 것이 아니라, 그 입력에 의존하지 않는 작업을 진행하는 것입니다.

동기화도 같은 관점에서 살펴볼 수 있습니다. 여러 스레드가 중간 결과를 공유한다면, 그 결과가 준비된 뒤 사용해야 합니다. 이 조건은 지키면서 불필요한 동기화의 횟수나 참여 범위를 줄이고, 기다리는 동안 진행할 수 있는 다른 작업이 있는지 살펴봅니다. 데이터 이동과 동기화가 필요한 이유를 확인해야 무엇을 없애고 무엇을 기다려야 하는지 판단할 수 있습니다.

Fusion: 중간값을 저장하지 않고 계산 이어가기

이번에는 데이터 이동을 줄이는 예를 살펴보겠습니다. 입력 x에 bias를 더해 중간값 u를 만들고, u에 ReLU를 적용해 출력 y를 구합니다. ReLU는 값이 양수이면 그대로 사용하고, 그렇지 않으면 0을 반환하는 함수입니다. 각 위치에서 수행하는 계산은 다음과 같습니다.

u = x + bias → y = ReLU(u)

예를 들어 어떤 위치에서 x가 −3이고 bias가 1이라면, 덧셈 결과 u는 −2이고 ReLU를 적용한 y는 0입니다. 다른 위치에서 x가 2이고 bias가 1이면 u는 3, y도 3입니다. 각 위치의 출력은 다른 위치의 결과를 기다리지 않고 구할 수 있습니다.

두 연산을 별도의 커널로 실행하면, 첫 번째 커널이 계산한 값을 두 번째 커널에 전달해야 합니다. 첫 번째 커널의 스레드가 사용하던 레지스터나 블록의 공유 메모리는 그 실행에 속한 공간이므로, 다음 커널이 그대로 이어받아 사용할 수 없습니다. 따라서 커널 실행이 끝난 뒤에도 유지되는 전역 메모리(global memory)의 버퍼에 중간 결과를 저장합니다. 버퍼는 여기서 계산 결과를 담아 두기 위해 할당한 메모리 공간을 뜻합니다.

분리 실행은 첫 커널에서 덧셈 결과 u를 전역 메모리의 중간 텐서로 저장하고, 두 번째 커널이 다시 읽어 ReLU를 적용합니다. Fusion은 한 커널 안에서 덧셈의 중간값을 바로 ReLU에 사용하고 최종 결과 y만 저장합니다.

그림 4의 왼쪽에서는 덧셈과 ReLU를 별도의 커널로 실행합니다. 첫 번째 커널의 스레드들은 자신이 맡은 위치의 x와 bias를 읽고 더한 뒤, u를 전역 메모리의 중간 텐서에 저장합니다. 이번 실행에서는 첫 번째 커널이 끝난 뒤 두 번째 커널을 실행합니다. 두 번째 커널의 스레드들은 이 텐서에서 u를 읽어 ReLU를 적용하고 y를 저장합니다.

전역 메모리는 프로그램에서 데이터를 저장하고 접근하는 공간을 뜻합니다. 이번에 다루는 GPU에서는 이 버퍼의 저장 공간을 HBM에 할당하지만, 실제 읽기·쓰기에는 캐시가 관여합니다. 중간값이 L2 캐시에 남아 있다면 다음 커널은 HBM에서 다시 가져오지 않고 사용할 수 있습니다. 커널 사이에 값을 전달하려면 전역 메모리에 저장해야 하지만, 커널이 끝날 때마다 모든 결과를 HBM까지 써 내려가야 한다는 뜻은 아닙니다.

오른쪽에서는 두 연산을 하나의 커널로 묶었습니다. 이처럼 여러 연산을 한 커널 안에서 이어서 수행하도록 합치는 것을 커널 fusion이라고 합니다. 스레드는 자신의 위치에서 x와 bias를 읽고, 덧셈으로 얻은 중간값을 레지스터에 유지한 채 바로 ReLU에 사용합니다. 같은 스레드의 계산이 이어지므로, 다음 커널에 넘길 중간 텐서를 만들지 않고 최종 결과 y만 전역 메모리에 저장할 수 있습니다.

두 경우 모두 덧셈과 ReLU를 수행합니다. 차이는 중간값 u를 별도의 텐서로 저장하고 다시 읽는 과정이 필요한지입니다. Fusion에서는 커널 내부에서 값을 이어 사용하여, 커널 사이에 값을 전달하기 위한 중간 텐서의 전역 메모리 읽기·쓰기를 줄일 수 있습니다.

출력 요소가 N개라면 분리 실행에는 중간값 N개를 쓰고 다시 N개를 읽는 과정이 있습니다. 이 접근을 생략하면서 필요한 계산을 유지하면 데이터 이동 대비 계산량이 높아집니다. 다만 실제로 줄어드는 HBM 이동량은 중간값이 캐시에 남는지 등의 영향을 받습니다. 두 커널이 하나가 되므로 커널 실행을 요청하고 시작하는 데 드는 비용도 줄일 여지가 있습니다.

물론 연산을 많이 합칠수록 항상 유리한 것은 아닙니다. 한 커널이 더 많은 중간값을 유지하면 레지스터 사용량이 늘 수 있고, 앞선 글에서 본 것처럼 한 SM에 함께 상주할 수 있는 작업 수에도 영향을 줄 수 있습니다. 줄어드는 데이터 이동과 늘어나는 자원 사용을 함께 봐야 합니다.

이번에는 같은 위치의 계산을 이어서 수행해 중간값의 저장과 재읽기를 줄였습니다. 다음 글에서는 행렬 곱을 예로, 여러 출력의 계산이 공통으로 사용하는 입력을 재사용하는 방법을 살펴보겠습니다. 출력 영역을 나누어 협력해서 계산할 때 데이터 이동과 자원 사용이 어떻게 달라지는지 연결하겠습니다.