← 학습 경로

공통 · 2026-09-11

GPU의 병렬 실행: 스레드에서 워프 스케줄링까지

공통 계산 절차와 인덱스별 실행에서 출발해 스레드·블록·그리드, SM 배정, 워프 스케줄링과 지연 숨기기를 설명합니다.

지난 글에서는 원소별 연산, reduction, 행렬 곱에서 어떤 결과를 독립적으로 계산할 수 있는지 살펴봤습니다. 그렇다면 이렇게 나눌 수 있는 계산을 GPU에는 어떻게 맡길까요? 이번에는 하나의 계산 절차를 여러 개별 실행으로 나누고, 이를 GPU에 배정해 실행하는 과정을 알아보겠습니다.

NVIDIA GPU를 기준으로, 벡터 덧셈의 작은 예시에서 시작하겠습니다. 먼저 각 실행이 자신의 데이터를 찾아 계산하는 방법을 살펴보고, 스레드와 블록을 거쳐 준비된 워프의 명령을 선택하는 방식까지 연결하겠습니다.

여러 출력에 적용하는 하나의 계산 절차

두 벡터 A와 B의 같은 위치에 있는 값을 더해 벡터 C에 저장한다고 생각해 보겠습니다. A의 값이 1, 2, 3, 4이고 B의 값이 10, 20, 30, 40이면, C에는 11, 22, 33, 44가 저장됩니다.

위치 0부터 3에서 각각 1+10, 2+20, 3+30, 4+40을 계산합니다. 아래에서는 이를 A[i]와 B[i] 읽기, 더하기, C[i]에 저장하기라는 공통 절차로 정리합니다.

그림 1에서 첫 번째 출력을 구하는 계산은 1 + 10이고, 두 번째 출력을 구하는 계산은 2 + 20입니다. 사용하는 값은 다르지만 같은 위치의 입력 두 개를 읽고, 더한 뒤, 그 위치에 결과를 저장한다는 절차는 같습니다.

이 절차에서 데이터의 위치를 나타내는 번호를 인덱스(index)라고 부르겠습니다. 그림처럼 0부터 번호를 붙이면 A[0]은 A의 첫 번째 값이고, A[1]은 두 번째 값입니다. 위치를 i로 나타내면 공통 절차는 “A[i]B[i]를 읽어 더하고, C[i]에 저장한다”로 정리할 수 있습니다.

지난 글에서 본 것처럼 C[1]을 구하는 데 C[0]의 결과는 필요하지 않습니다. 따라서 하나의 실행이 모든 위치를 차례대로 계산하도록 할 수도 있지만, 담당 위치가 서로 다른 여러 실행으로 나눌 수도 있습니다.

스레드: 같은 절차를 각자의 데이터로 실행하기

앞의 공통 절차를 세 개의 실행으로 나누어 보겠습니다. 첫 번째 실행에는 i = 0을, 두 번째에는 i = 1을, 세 번째에는 i = 2를 맡깁니다. 각 실행은 자신이 맡은 위치의 입력을 읽는 것부터 결과를 저장하는 것까지 진행합니다.

이처럼 계산 절차를 수행하는 개별 실행 흐름을 스레드(thread)라고 부릅니다. 그림 2에서는 같은 절차를 수행하되, 인덱스가 달라 서로 다른 데이터를 처리하는 세 스레드를 보여줍니다.

공통 계산 절차가 i=0, i=1, i=2인 세 실행으로 나뉩니다. 각 실행은 자신의 위치에서 입력 읽기, 덧셈, 저장을 수행하며 아래에 스레드 0, 1, 2라고 표시되어 있습니다.

스레드 0은 A[0]에서 1을, B[0]에서 10을 읽습니다. 두 값을 더해 11을 만든 뒤 C[0]에 저장합니다. 스레드 1도 같은 절차를 따르지만 A[1]B[1]을 사용하므로 22를 C[1]에 저장합니다. 스레드마다 서로 다른 계산식을 작성하는 것이 아니라, 같은 절차 안에서 자신이 사용할 데이터의 위치를 정하는 것입니다.

이 공통 절차를 GPU에서 실행하도록 작성한 함수를 커널(kernel)이라고 부릅니다. 커널을 실행하면 여러 스레드가 그 커널의 코드를 수행합니다. 각 스레드는 계산에 사용할 인덱스와 읽어 온 값, 중간 결과를 자신만의 상태로 가지고 실행을 진행합니다.

지금은 스레드 하나에 출력 하나를 맡겼습니다. 스레드는 개별 실행 흐름을 뜻하므로, 그 안에서 여러 출력을 계산하도록 작성할 수도 있습니다. 이번 예시에서는 출력 하나씩을 맡기는 구성을 유지하고, 각 스레드가 자신의 담당 인덱스를 어떻게 구하는지 살펴보겠습니다.

블록과 Grid: 실행을 묶고 담당 위치 정하기

출력이 1,024개라면 이번 구성에서는 스레드도 1,024개가 필요합니다. 개발자는 커널을 실행할 때 이 스레드들을 일정한 크기로 묶습니다. 이 스레드 묶음을 블록(block)이라고 합니다. 여기서는 스레드 128개를 블록 하나로 묶겠습니다. 그러면 출력 1,024개를 처리하는 데 블록 여덟 개가 필요합니다.

한 번의 커널 실행에 속한 블록 전체를 그리드(grid)라고 부릅니다. 따라서 이번 실행은 grid 하나에 블록 여덟 개, 각 블록에 스레드 128개가 있는 구성입니다.

각 블록에는 grid 안에서의 번호인 블록 ID가 있습니다. 각 스레드에도 자신이 속한 블록 안에서의 번호인 스레드 ID가 있습니다. 블록마다 스레드 ID가 다시 0부터 시작하므로, 블록 0에도 스레드 0이 있고 블록 1에도 스레드 0이 있습니다. 두 실행이 서로 다른 데이터를 담당하도록 하려면 이 두 ID를 함께 사용하면 됩니다.

Grid에 블록 여덟 개가 있고 각각 스레드 128개를 포함합니다. 블록 0의 스레드 0과 1은 위치 0과 1을, 블록 1의 스레드 0과 1은 위치 128과 129를 담당합니다. 블록 1의 스레드 0을 따라가면 A[128]과 B[128]을 더해 C[128]에 저장합니다.

그림 3에서 블록 0은 위치 0~127을, 블록 1은 위치 128~255를 담당합니다. 각 스레드는 블록의 시작 위치에 블록 내부의 스레드 ID를 더해 자신의 담당 위치를 구합니다.

담당 위치 i = 블록 ID × 블록당 스레드 수 + 블록 내부의 스레드 ID

블록 1의 스레드 0을 따라가 보겠습니다. 블록당 스레드 수가 128이므로 i = 1 × 128 + 0 = 128입니다. 이 스레드는 앞에서 본 공통 절차의 i에 128을 사용하여 A[128]B[128]을 읽고, 더한 뒤, C[128]에 저장합니다. 바로 옆의 스레드 1은 i = 129를 구해 다음 위치를 처리합니다.

이처럼 ID는 각 실행이 처리할 데이터를 찾는 데 사용됩니다. CUDA는 커널 안에서 자신의 블록 ID와 스레드 ID, 블록 크기를 읽을 수 있게 제공합니다. 개발자는 이 값들을 계산에 사용해 각 스레드가 맡을 일을 정합니다.

블록 크기는 커널을 구현하고 실행하는 쪽에서 정합니다. 블록당 스레드 128개라는 구성을 유지하면서 입력 길이가 2,048로 늘어난다면 블록은 16개가 됩니다. 여기까지는 많은 개별 실행을 어떻게 구성할지 정한 것입니다. 이제 GPU가 이 블록들을 SM에 어떻게 배정하는지 살펴보겠습니다.

블록: SM에 배정하고 자원을 확보하기

앞서 GPU 구조를 설명할 때, GPU 안에는 여러 SM이 있고 각 SM에 연산 장치와 register, L1 캐시·shared memory가 있다고 했습니다. GPU는 블록을 SM에 배정하며, 블록 하나의 스레드들은 같은 SM에서 실행됩니다. 작업이 SM에 배정되어 실행에 필요한 자원을 차지한 상태를 상주(resident)한다고 표현합니다.

SM 배정을 기다리는 블록 4부터 7이 위에 있고, SM 1에는 블록 0과 1, SM 2에는 블록 2와 3이 상주합니다. 각 블록에는 스레드별 register와 블록 내에서 공유하는 shared memory가 표시되어 있으며, 각 SM의 L1 캐시는 여러 블록이 활용합니다.

그림 4에서 블록 0과 1은 SM 1에, 블록 2와 3은 SM 2에 배정되어 있습니다. 한 SM에는 블록 하나만 들어가는 것이 아니라, 자원이 허용하는 만큼 여러 블록이 상주할 수 있습니다.

상주하는 작업은 SM의 저장 공간을 사용합니다. Register에는 각 스레드가 사용하는 값과 중간 결과를 보관합니다. 그림 2의 개별 실행을 떠올리면, 스레드마다 자신이 읽어 온 입력과 계산 결과가 필요합니다. 물리적인 register 자원은 SM 안에 있지만, 그 안에 보관하는 값은 스레드별로 구분됩니다.

블록은 SM에 함께 배정되는 단위이면서, 그 안의 스레드들이 협력하는 범위이기도 합니다. Shared memory는 같은 블록의 스레드들이 데이터를 공유하며 협력할 때 사용할 수 있는 공간입니다. 앞의 벡터 덧셈에서는 각 스레드가 자신의 입력을 읽어 계산하면 되지만, 여러 스레드가 부분합을 만든 뒤 함께 합치는 계산에서는 중간 값을 이 공간에 놓고 사용할 수 있습니다.

이때 다른 스레드가 만든 부분합을 읽으려면 그 값이 먼저 준비되어야 합니다. 이처럼 필요한 작업이 끝난 뒤 다음 단계로 넘어가도록 실행 시점을 맞추는 것을 동기화라고 합니다. 같은 블록의 스레드들은 데이터를 공유하고 동기화하며 하나의 계산을 나누어 수행할 수 있습니다.

L1 캐시는 같은 SM에 있는 여러 블록의 메모리 접근에 활용됩니다. Shared memory와 L1 캐시는 앞서 살펴본 통합 공간을 나누어 사용하지만, 스레드들이 데이터를 명시적으로 공유하는 공간과 하드웨어가 관리하는 캐시라는 역할의 차이가 있습니다.

SM의 자원은 한정되어 있습니다. 먼저 배정된 작업들이 register나 shared memory를 많이 사용한다면 추가 블록을 받아들일 공간이 부족할 수 있습니다. 그림 위쪽의 블록 4부터 7은 아직 배정되지 않은 작업입니다. 실행 중인 블록이 완료되어 자원이 반환되면 기다리던 블록을 추가로 배정할 수 있습니다. 따라서 grid에 속한 블록이 많다고 해서 모두 같은 순간에 SM에 상주하는 것은 아닙니다. 상주 가능한 수는 자원 사용량뿐 아니라 하드웨어의 블록·스레드 수 한도에도 영향을 받습니다.

워프: 32개 스레드의 명령을 함께 실행하기

블록이 SM에 배정되었다면 그 안의 스레드들은 어떻게 실행될까요? NVIDIA GPU는 같은 블록의 스레드들을 32개씩 묶은 워프(warp)를 단위로 명령을 스케줄링합니다. 그림 5에서는 스레드 128개인 블록 하나가 네 개의 워프로 나뉩니다.

스레드 128개로 이루어진 블록 하나에 워프 네 개가 있고, 각 워프에는 스레드 32개가 있습니다. 워프 하나의 스레드 네 개를 확대하면 같은 덧셈 명령으로 각각 1+10, 2+20, 3+30, 4+40을 계산합니다.

이번처럼 스레드가 한 줄로 구성된 블록에서는 스레드 ID 0~31이 워프 0, 32~63이 워프 1에 속합니다. 개발자가 정한 128개짜리 블록 안에서 GPU가 32개씩 묶어 명령을 실행하는 것입니다.

워프 0 안의 스레드 네 개를 확대해 보겠습니다. 스레드 0은 1 + 10을, 스레드 1은 2 + 20을 계산합니다. 나머지 두 스레드도 각자 다른 입력을 더합니다. 실행하는 명령은 덧셈으로 같지만, 그 명령에 사용하는 값과 결과는 스레드마다 다릅니다.

GPU는 이런 방식으로 여러 스레드에 공통 명령을 적용해 계산합니다. 이를 SIMT(Single Instruction, Multiple Threads)라고 부릅니다. 그림처럼 모든 스레드가 같은 덧셈에 참여할 수도 있고, 조건문에 따라 한 명령에 일부 스레드만 참여할 수도 있습니다.

앞의 그림 2에서는 입력 읽기부터 결과 저장까지를 스레드 하나의 흐름으로 보았습니다. 그림 5는 그 흐름 안의 덧셈 명령을 여러 스레드에 함께 적용하는 순간을 보여줍니다. 스레드는 계산을 수행하는 실행 흐름이고, CUDA Core는 명령을 처리하는 물리적인 연산 장치입니다. 스레드마다 전용 CUDA Core를 하나씩 차지하는 관계는 아닙니다.

여기까지 연결하면, 블록은 SM에 배정되는 단위이고 워프는 SM 안에서 명령을 스케줄링하는 단위입니다. 그렇다면 SM에 여러 워프가 상주할 때, 다음에는 어느 워프의 명령을 실행해야 할까요?

워프 스케줄링: 준비된 다음 명령 선택하기

SM 안의 워프 스케줄러자신이 담당하는 워프들 가운데 다음 명령을 실행할 수 있는 워프를 선택합니다. 선택한 명령을 실행 장치로 보내는 것을 명령 발행(issue)이라고 합니다. 발행한 명령의 결과는 실행을 거쳐 나중에 준비됩니다.

SM에는 여러 워프 스케줄러가 있습니다. 그림 6은 그중 한 스케줄러가 담당하는 워프 네 개와 다음 명령을 선택하는 모습을 보여줍니다.

한 스케줄러에 상주하는 워프 A와 C는 각각 메모리 결과와 동기화를 기다립니다. B와 D는 실행 가능한 상태이고, 스케줄러는 이번에 B를 선택해 다음 명령을 발행합니다. 상주 4개, 실행 가능 2개, 이번에 선택 1개입니다.

워프 A는 메모리에서 읽어 올 값을 기다리고 있습니다. 다음 덧셈에 그 값이 필요하다면 아직 덧셈을 실행할 수 없습니다. 워프 C는 동기화 지점에서 다른 스레드들의 작업이 준비되기를 기다립니다. 반면 B와 D는 다음 명령을 진행할 준비가 되어 있습니다.

이 순간에는 상주하는 워프가 네 개이지만, 실행 가능한 후보는 두 개입니다. 스케줄러는 그중 B를 선택해 다음 명령을 발행합니다. D도 준비되어 있지만 이번에는 선택되지 않았습니다. A·C처럼 진행 조건을 기다리는 상태와 D처럼 준비되었으나 선택되지 않은 상태는 다릅니다. 실행 가능한 후보가 되려면 필요한 입력과 앞선 계산 결과뿐 아니라 명령을 처리할 실행 자원도 준비되어야 합니다.

선택하는 대상은 워프가 수행할 다음 명령입니다. B의 작업 전체가 끝날 때까지 계속 B만 실행해야 하는 것은 아닙니다. B의 명령을 발행한 뒤에는, 그다음 시점에 준비된 후보 가운데 D를 선택할 수도 있습니다. 아직 진행 중인 명령이 있더라도 다른 명령을 발행할 수 있는지는 각 명령의 의존성과 실행 자원에 달려 있습니다.

기다리는 워프의 register 값과 실행 상태도 SM 안에 유지됩니다. 스케줄러는 이렇게 상태가 보관된 워프들 중에서 다음 명령을 실행할 대상을 고릅니다. 대기하던 워프의 진행 조건이 충족되면 다시 선택 후보가 됩니다.

지연 숨기기: 기다리는 동안 다른 작업 진행하기

이제 앞서 설명한 메모리 접근 지연과 연결해 보겠습니다. 메모리에 값을 요청한 뒤 실제로 사용할 수 있을 때까지는 시간이 걸립니다. 그 값을 기다리는 계산은 멈춰 있어도, 준비된 다른 워프의 계산은 진행할 수 있습니다.

워프 A가 읽기를 요청하고 입력을 기다리는 동안 다른 워프가 준비되어 있으면 B와 D의 명령을 발행합니다. 다른 워프도 모두 기다리는 경우에는 같은 대기 시간 동안 명령 발행이 비어 있습니다. A의 입력이 준비되면 덧셈을 진행할 수 있습니다.

그림 7의 위쪽에서 A는 메모리 읽기를 요청합니다. 이어지는 덧셈에는 그 입력이 필요하므로 데이터가 도착할 때까지 기다립니다. 그동안 스케줄러는 준비된 B와 D의 명령을 발행합니다. 이후 A의 입력이 준비되면 A의 덧셈도 선택해 실행할 수 있습니다.

아래쪽에서는 A뿐 아니라 다른 워프들도 모두 기다리고 있습니다. 이 경우 스케줄러에 실행 가능한 후보가 없으므로 새로운 명령을 발행하지 못합니다. 앞서 발행한 메모리 요청이나 연산은 계속 진행될 수 있지만, 이 스케줄러가 실행 장치에 추가로 맡길 명령은 없는 상태입니다.

두 경우에서 A의 데이터 대기 시간은 같다고 가정했습니다. 차이는 그 시간 동안 다른 작업을 진행했는지입니다. 이처럼 다른 작업의 실행으로 대기 시간의 영향을 줄이는 것을 지연 숨기기(latency hiding)라고 합니다.

상주하는 워프가 많으면 다른 작업을 선택할 여지가 생깁니다. 하지만 그 워프들이 모두 같은 시점에 기다리고 있다면 실행할 후보가 없습니다. 따라서 GPU의 실행을 볼 때는 얼마나 많은 작업을 배정했는지와, 그중 지금 진행할 수 있는 작업이 얼마나 있는지를 함께 생각해야 합니다. 이것이 많은 스레드를 묶어 관리하고 준비된 워프의 명령을 선택하는 방식이 중요한 이유입니다.

다음 글에서는 이 실행 구조를 바탕으로 작업을 어떻게 나누면 GPU를 더 효율적으로 사용할 수 있는지 살펴보겠습니다. 원소별 연산과 행렬 곱에서 각 작업이 맡을 계산의 양, 필요한 데이터의 이동, 가져온 데이터의 재사용을 연결하겠습니다.