④ 스케줄링

블록이 SM(공장)에 배정된 뒤, 그 안에서 일꾼 조(warp)를 "어떤 순서로 어떻게 굴리는가" — 그리고 여러 커널이 한 GPU를 두고 어떻게 경쟁하는가. 이게 성능(빠르냐)과 응답성(급한 일을 제때 하냐)의 핵심입니다.

4.1 Warp 스케줄링 — "느린 메모리를 다른 일로 가린다"

①에서 살짝 본 핵심을 제대로 풀어볼게요. SM 안의 warp 스케줄러(작업 반장, 보통 4명)는 매 순간(클럭 사이클마다) "지금 일할 준비가 된 warp"를 골라 명령을 내립니다.

포인트는 여기예요. 어떤 warp가 창고(global memory)에서 데이터를 기다리며 멈추면(수백 사이클이나 걸림!), 반장은 그 조를 기다리지 않고 즉시 다른 준비된 warp에게 일을 시킵니다. 이 조 바꾸기에 비용이 거의 0이에요(CPU의 문맥 전환과 달리).

warp A: [메모리 대기...........]        ← A는 창고 기다리는 중
warp B:      [계산][계산]               ← 그동안 B를 시킴
warp C:            [계산][계산]          ← C도 시킴
warp D:                  [계산]          ← D도 → 계산기(ALU)가 안 놂!
시간 → (한 warp가 메모리를 기다리는 동안, 다른 warp가 계산) warp A 계산 메모리 대기 … warp B 대기 계산 대기 … warp C 대기 계산 대기 … warp D 대기 계산 대기 계산기(ALU) 항상 바쁨 — 빈 시간 없음 ← latency hiding
그림 — warp 스케줄러가 "준비된 warp"를 골라 돌려서, 메모리 대기 시간을 다른 warp의 계산으로 채운다 (그래서 occupancy가 중요)

그럼 반장(warp 스케줄러)은 매 사이클 정확히 무엇을 할까요? 4단계가 무한 반복됩니다:

① 상태 확인 상주 warp 중 누가 ready? W0: ready ✓ W1: 메모리 대기 💤 W2: ready ✓ W3: 배리어 대기 💤 ② 선택 ready 중 하나를 고름 W2 선택! (정책: 교대/탐욕 등) ③ 발행 (issue) W2의 다음 명령을 실행 유닛으로 보냄 산술→ALU · 적재→LD/ST sin/sqrt→SFU ④ 상태 갱신 W2가 메모리 요청했다면 → 대기 💤로 표시 W1의 데이터가 도착했다면 → ready ✓로 복귀 (scoreboard가 의존성 추적) ⟲ 매 사이클 반복 — 이 한 바퀴가 1사이클, 전환 비용 0 · "ready" = 다음 명령의 피연산자(데이터)가 모두 준비된 상태. 메모리·배리어·이전 명령 결과를 기다리면 대기. · 전환이 0-비용인 이유: 모든 warp의 레지스터가 SM에 동시 상주 → 저장/복원 없이 ②에서 고르기만 하면 됨. · SM엔 이 스케줄러가 4개 → 매 사이클 최대 4개 warp가 동시에 명령 발행.
그림 — warp 스케줄러의 매 사이클 4단계: 상태 확인 → ready 중 선택 → 명령 발행 → 상태 갱신, 그리고 무한 반복. 앞 타임라인의 "즉시 전환"이 바로 이 루프다
💡 latency hiding (지연 숨기기)

이게 GPU의 핵심 비법입니다. CPU는 큰 캐시로 메모리 지연을 줄이려 하지만, GPU는 대기 중인 다른 일꾼으로 그 시간을 메워(가려) 버립니다. 그러려면 당연히 SM에 대기 중인 warp가 충분히 많아야 하죠. 그래서 occupancy(일꾼 채움 정도)가 중요한 거예요. 바로 다음 절입니다.

👤 CPU 개발자라면 — "문맥 전환이 0-비용이라고?"

CPU에서 스레드 전환(context switch)은 비쌉니다 — 레지스터를 메모리에 저장하고, 다른 스레드 것을 복원하고, 캐시도 식어버리죠(보통 수천 사이클). 그래서 OS는 "되도록 안 바꾸려고" 애씁니다. 그런데 GPU의 warp 전환은 말 그대로 0에 가깝습니다. 비결은 — SM에 올라온 모든 warp의 레지스터가 거대한 레지스터 파일에 "동시에 상주"하기 때문이에요(①에서 본 256KB의 이유!). 저장·복원할 게 없으니, 반장은 매 사이클 자유롭게 warp를 갈아끼웁니다. 그래서 GPU는 "메모리 기다리는 warp를 다른 warp로 가리는" 전략이 가능한 거고, CPU는 (전환이 비싸서) 대신 큰 캐시로 지연 자체를 줄이는 거예요. 이게 두 칩의 가장 근본적인 철학 차이입니다.

4.2 Occupancy 튜닝 — 실제로 어떻게 올리나

①에서 본 occupancy를 코드에서 끌어올리는 실전 방법입니다. occupancy를 막는 건 보통 "일꾼이 자원을 너무 많이 쓰는" 경우예요:

막히는 원인올리는 법
스레드가 레지스터(메모장)를 너무 많이 씀변수를 줄이거나, __launch_bounds__로 컴파일러에 레지스터 상한을 힌트
블록이 shared memory(작업대)를 너무 많이 씀타일 크기·버퍼를 줄임
블록 크기가 부적절128~256 스레드 등으로 조정 (NVIDIA의 occupancy calculator 활용)

일일이 계산하기 귀찮다면, CUDA가 직접 계산해주는 API도 있습니다:

int minGrid, blockSize;
cudaOccupancyMaxPotentialBlockSize(&minGrid, &blockSize, myKernel);
// → "이 커널은 블록 크기 blockSize일 때 occupancy가 최대"를 런타임이 알려줌
myKernel<<<(n + blockSize - 1) / blockSize, blockSize>>>(...);

커널의 레지스터·shared memory 사용량을 자동으로 고려해주므로, grid-stride loop와 함께 쓰면 "어떤 GPU에서든 알아서 적절한 구성"이 됩니다. 실제 사용량 확인은 nvcc --ptxas-options=-v로.

⚠️ occupancy 100%가 늘 최고는 아니다

occupancy를 높이면 보통 좋지만, 무조건 100%를 목표로 삼지 마세요. 때로는 레지스터를 더 써서(occupancy↓) 일꾼 한 명에게 일을 더 몰아주는 게 빠를 때도 있어요(이걸 ILP — 명령 수준 병렬성이라 합니다). 원칙은 "지연을 가릴 만큼만 충분히" 확보하는 것. 진짜 빠른지는 추측 말고 프로파일러로 측정하세요.

4.3 스트림 우선순위 — 급한 일 먼저

여러 스트림(줄)이 동시에 일할 때, "이 작업은 급하니 먼저 해줘"라고 표시하고 싶다면 우선순위 스트림을 만듭니다. 자원이 비었을 때, 우선순위가 높은 스트림의 블록이 먼저 SM에 올라가요.

int lo, hi;
cudaDeviceGetStreamPriorityRange(&lo, &hi);  // hi가 더 높은 우선순위(값은 더 작음)
cudaStream_t urgent;
cudaStreamCreateWithPriority(&urgent, cudaStreamNonBlocking, hi);
// 급한 커널을 urgent 스트림에 넣으면 우선 배정됨

주의: 이건 "다음에 올릴 블록"을 고를 때 영향을 줄 뿐, 이미 돌고 있는 걸 멈추진 못합니다(다음 절).

4.4 선점(preemption)이 약하다 — GPU의 중요한 한계

선점(preemption)이란 "돌고 있는 일을 잠깐 멈추고 더 급한 일을 끼워넣는" 것입니다. CPU는 이걸 아주 잘해요(운영체제가 수시로 스레드를 갈아끼움). 그런데 GPU는 이게 약합니다.

이유는 ①에서 본 구조 때문이에요. 블록은 한 번 SM에 올라가면 자원을 차지한 채 끝까지 실행(run-to-completion)됩니다. 그래서 거대한 커널의 블록들이 모든 SM을 꽉 채우고 있으면, 뒤늦게 온 급한 커널은 자리가 빌 때까지 그냥 기다려야 해요. (이걸 head-of-line blocking — 앞 차가 막아서 뒤가 못 가는 상황이라고 합니다.)

CPUGPU
선점강함 — 급한 일이 즉시 끼어듦약함 — 블록은 보통 끝까지 실행
우선순위 효과실행 중인 것도 밀어냄주로 "다음에 올릴 것"에만 영향
👤 CPU 개발자라면 — "OS가 스케줄 안 하나? nice/우선순위는?"

CPU에선 OS 스케줄러가 선점적으로 스레드를 굴립니다 — 타이머 인터럽트로 수시로 끼어들어 공평하게 나눠주고, nice·실시간 우선순위로 급한 걸 즉시 앞세울 수 있죠. GPU엔 그런 OS 스케줄러가 없습니다. 하드웨어 warp 스케줄러가 굴리는데, 이건 (warp끼리는 0-비용으로 잘 섞지만) 한 번 SM에 올라간 블록을 도중에 쫓아내지 못합니다(run-to-completion). 그래서 CPU에선 당연한 "급한 작업이 긴 작업을 밀어내고 즉시 실행"이 GPU에선 잘 안 돼요. cudaStreamCreateWithPriority가 있지만, 이건 "다음에 올릴 블록"을 고를 때만 영향을 줄 뿐, 실행 중인 걸 멈추진 못합니다. 실시간 응답이 중요하면 이 한계를 설계로 우회해야 합니다(아래).

(최신 GPU(Pascal 이후)엔 명령 단위 부분 선점이 있긴 한데, 거칠고 비쌉니다.) 그래서 응답성이 필요하면 이렇게 대처합니다:

  • 커널을 작게 쪼개기 — 블록이 빨리 끝나면 SM 자리가 자주 비어서, 급한 일이 곧 올라갈 수 있어요.
  • 스트림 우선순위 — 자리 빌 때 급한 블록부터.
  • oversubscribe 금지 — GPU가 감당할 양보다 일을 더 쌓지 않기.

4.5 MIG · MPS — GPU를 나눠 쓰기

그럼 여러 프로그램(프로세스)이 한 GPU에 동시에 일을 시키면 어떻게 될까요? 의외로 기본 동작은 "동시에"가 아닙니다 — GPU는 프로세스마다 별도의 컨텍스트(작업 환경)를 두고, 이들을 시간 분할(time-slicing)로 번갈아 실행합니다. A 프로세스의 커널이 도는 동안 B는 통째로 대기하고, 컨텍스트를 바꾸는 비용도 들어요. 그래서 각 프로세스가 GPU를 절반도 못 채우는 작은 커널을 던지면, GPU가 절반씩 놀면서도 서로 기다리는 비효율이 생깁니다.

이 문제를 푸는 두 가지 방법이 MPS와 MIG입니다:

MPS (Multi-Process Service)MIG (Multi-Instance GPU)
방식여러 프로그램의 커널을 합쳐 한 GPU에서 동시 실행GPU를 물리적으로 여러 조각으로 분할(SM·메모리 격리)
격리(간섭 차단)약함 — 서로 성능 영향 줄 수 있음강함 — 하드웨어로 완전 분리
쓰는 곳작은 커널 여러 개의 GPU 활용률↑"이 조각은 이 작업 전용" 같은 보장(멀티테넌시·QoS)
지원대부분의 GPUA100/H100 등 데이터센터 GPU

쉽게 말하면 — MPS는 "한 주방을 같이 쓰되 주문을 합쳐 처리", MIG는 "주방을 벽으로 나눠 각자 전용 주방"입니다.

MPS — 한 주방을 같이 쓰기 (논리적 공유) 프로세스 A 프로세스 B → 커널을 합쳐 제출 GPU (SM들을 섞어서 공유) SM: A SM: B SM: A SM: A SM: B SM: A SM: B (경쟁) 메모리·L2·대역폭도 함께 사용 — 경계 없음 활용률 ↑ · 격리 약함 (서로 성능 간섭 가능) 작은 커널 여러 개로 GPU를 꽉 채우고 싶을 때 MIG — 주방을 벽으로 나누기 (물리적 분할) 인스턴스 1 (A 전용) 인스턴스 2 (B 전용) 벽(HW 격리) SM: A SM: A SM: A SM: A 전용 SM + 전용 메모리/L2 SM: B SM: B SM: B SM: B 전용 SM + 전용 메모리/L2 완전 격리 — A가 폭주해도 B는 영향 없음 (QoS) 멀티테넌시·성능 보장이 필요할 때 (A100/H100 등)
그림 — MPS는 SM을 섞어 쓰며 활용률을 높이고(간섭 가능), MIG는 벽을 세워 SM·메모리를 전용으로 나눈다(완전 격리)
✅ ④ 장 정리

① SM은 준비된 warp로 0-비용 전환해 메모리 지연을 가립니다(occupancy가 그 연료).
② occupancy는 레지스터·shared·블록크기로 조절하되, 100%가 답은 아님 → 측정.
③ 블록은 끝까지 실행이라 GPU 선점이 약하니, 응답성은 작은 커널·우선순위·MIG/MPS로 확보.
반복 워크로드의 launch 비용 문제는 다음 장에서 그래프로 해결합니다.