AI 기본 지식

What's happening on the CPU and GPU When you train a Deep Learning Model? (진짜 강추)

study_love 2025. 12. 5. 20:19

 오늘은 딥러닝 모델을 학습(train)하거나 추론(inference)할 때, 하드웨어 내부에서 실제로 어떤 일이 일어나는지 살펴보려고 한다. 딥러닝 훈련 과정에서 CPU와 GPU 같은 다양한 하드웨어들이 어떻게 작업을 분담하고 처리하는지 이해하는 것이 목표다. 이 내용을 제대로 이해하고 나면, 어떤 종류의 하드웨어 환경에서도 딥러닝 모델을 구동시킬 수 있는 기반 지식을 갖추게 된다.

 먼저 CPU와 GPU의 세부적인 아키텍처나 학습/추론 과정은 모두 추상화한 상태에서, 두 장치에서 어떤 일이 일어나는지만 간단히 살펴보자. 지금 당장 알아야 할 핵심은 CPU와 GPU는 서로 다른 메모리 공간을 사용하며, 각 장치의 연산 유닛은 자신에게 할당된 메모리에만 직접 접근할 수 있다는 점이다.

Abstract explanation 

딥러닝의 모든 연산이 GPU에서 일어나는 매우 간단한 pseudo-code를 준비해 봤다. 이 프로그램이 실행된다고 가정하고, CPU와 GPU에서 어떤 일이 일어나는지 알아보도록 하자. 

data = numpy.load("~")
data.to_gpu()
GPU_processing_function(data)
data.to_cpu()
print(data)

 

위 프로그램을 실행하면, OS는 이 프로그램을 구동하는 하나의 process를 생성한다. GPU에서 일어나는 작업은 OS가 관리하는 프로세스 단위의 작업이 아니기 때문에, 우리가 어떤 프로그램을 실행하면, OS는 하나의 프로세스를 만들고, 그 프로세스 안에서 단지 GPU를 “사용”한다고 이해하면 된다. 그러면 이제 코드를 분석해보자. 

  • line 1: 디스크에 있는 데이터를 CPU memory로 불러온다.
  • line 2: CPU memory에 올라온 데이터를 GPU memory로 전달한다.
  • line 3: 모든 연산은 GPU 내부에서 수행된다.
  • line 4: GPU memory에 있는 결과를 다시 CPU memory로 옮긴 뒤
  • line 5: CPU memory에서 값을 읽어, print 연산을 실행한다.

GPU를 이용하는 제일 기본적인 간단한 코드이므로, 어렵지 않게 코드를 해석할 수 있을 것이다.  

 

자 그러면 이제 조금 더 complicated한 경우에 대해 살펴보도록 하자. 만약 GPU_processing_function(data) 내부에서 CPU에서만 수행할 수 있는 연산이 일부 포함되어 있다면 어떻게 해야할까? 정답은 다음과 같다.

  • line 1: 디스크에 있는 데이터를 CPU memory로 불러온다.
  • line 2: CPU memory에 올라온 데이터를 GPU memory로 전달한다.
  • line 3: 대부분의 연산은 GPU 내부에서 수행된다.
    • CPU 연산이 필요한 데이터만 CPU memory로 옮긴다.
    • CPU에서 해당 연산을 수행한다.
    • 다시 그 데이터를 GPU memory로 보낸다.
  • line 4: GPU memory에 있는 결과를 다시 CPU memory로 옮긴 뒤
  • line 5: CPU memory에서 값을 읽어, print 연산을 실행한다.

여기까지가 abstract한 설명이다. 다들 쉽게 이해했으리라고 믿어 의심치 않는다 :) 사실 여기까지만 읽으면 "왜 gpu memory로 disk에 있는 data를 바로 안읽어오고 cpu memory를 거쳐서 읽지?" 같은 여러 질문들이 떠오를 것이다. 이러한 질문에 대한 답들은 뒤에서 대부분 해소할 수 있을 것이므로, 일단 천천히 글을 전부 읽어보길 바란다.   

그러면 이제 GPU에 대해서 detail하게 공부해보면서, GPU에 대한 이해를 길러보고, 그 후에 detail한 process를 공부해보자. 

Preliminary : GPU Architecture

우리에게 조금은 생소한 GPU Architecture를 공부해보자. 그렇게 깊게 알지는 않아도 되니까 너무 겁먹을 필요는 없다. 

<GPU Architecture>

 

 GPU는 여러 개의 SM(Streaming Multiprocessor)으로 이루어져 있다. CPU의 core같은 느낌인데, 각 SM은 다른 SM과 공유하지 않는 독립적인 Register, 독립적인 Shared Memory(SMEM), Read-only Memory, 그리고 Global Memory 접근을 위한 L1 Cache를 각각 따로 가지고 있다. 또한, 각 SM은 matmul과 같은 연산을 수행하기 위한 연산 유닛도 자체적으로 보유한다.

모든 SM이 공유하는 자원으로는 L2 Cache와 Global Memory(VRAM)가 있다. 정리하면, 다음과 같다.

  • 각 SM의 독립적인 자원
    • Register
    • Shared Memory(SMEM)
    • Read-only Memory
    • L1 Cache
    • 연산 유닛 
  • SM 전체에서 공유되는 자원
    • L2 Cache
    • Global Memory

 이 구조를 보면 GPU가 여러 SM에 작업을 나누어 병렬로 수행한다는 점을 쉽게 이해할 수 있다. 각 SM은 기본적으로 Global Memory에서 자신이 사용할 데이터를 가져와 작업을 수행하는데, 반복적으로 수정이 필요한 데이터는 SMEM에, 반복적으로 읽기만 하는 데이터는 Read-only Memory에 저장해놓고 작업을 수행한다. 그리고 연산이 끝나면 그 결과를 다시 Global Memory로 기록하는 식으로 작업이 반복된다고 보면 된다. 

GPU Architecture : GPU Memory and Disk cannot communicate directly. 

놀랍게도 GPU Memory는 Disk와 데이터를 직접 주고받을 수 있는 경로가 없다. 그래서 위의 abstract example에서도 CPU Memory를 통해서 data를 주고받았던 것이다. 왜 이런 경로를 만들지 않았는지 한번 같이 알아보도록 하자. 이유를 알기 위해서 예제 하나를 생각해보자. 

 

만약 GPU가 처리해야 하는 데이터의 크기가 GPU 메모리보다 크다면 어떻게 해야 할까?
CPU 아키텍처를 공부한 사람이라면 아마 이렇게 답할 것이다.

 

“지금 필요한 데이터만 GPU 메모리에 올리고,

아직 사용하지 않을 데이터는 디스크에 두었다가
필요할 때마다 GPU 메모리로 불러오면 되지 않을까?”

 

하지만 GPU는 이러한 방식의 data transition을 지원하지 않는다. 그 이유는 다음과 같다.

  • GPU는 CPU처럼 메모리 가상화(memory virtualization) 를 통해
    GPU 메모리 공간을 디스크까지 확장하는 방식을 사용하지 않는다.
  • 만약 GPU가 작업 중간중간 디스크에서 데이터를 읽어야 한다면,
    디스크 I/O는 매우 느리기 때문에 GPU 연산 유닛 대부분이 I/O를 기다리느라 놀게 된다.
  • 이렇게 되면 GPU를 사용해서 calculation time을 줄이는 의미가 퇴색된다. 

이러한 이유로 GPU는 연산이 진행되는 중간에 디스크에서 데이터를 읽어오는 기능을 지원하지 않고, GPU가 처리해야하는 데이터의 크기가 GPU Memory보다 크다면, OOM(Out Of Memory) error를 내버린다. 디스크 접근은 지나치게 느리기 때문에, 작업 중간에 디스크 I/O가 끼어들면 대부분의 GPU 연산 유닛이 기다리면서 멈추게 되고, GPU를 사용하는 의미가 사라지기 때문이다.

(이것과 거의 같은 이유로, 작업 중간에 CPU Memory에 데이터를 보내서 CPU에서 연산하는 것도 최대한 안하는게 좋다.)

그리고 바로 이런 이유 때문에 GPU 메모리는 디스크와 직접 데이터를 주고받을 수 있는 버스(bus)를 아예 가지고 있지 않다. GPU를 설계한 개발자들의 관점에서는, 디스크 ↔ GPU 메모리 간 데이터 이동은 연산을 시작하기 전에 한 번, 그리고 모든 연산이 끝난 후에 한 번만 필요하다고 판단했다. 즉, 중간에 디스크와 주고받는 상황 자체를 고려하지 않기 때문에 굳이 GPU–디스크 간 전용 경로를 만들 필요가 없었던 것이다.

결국 디스크와 GPU 메모리 간 데이터 전송은 항상 CPU 메모리를 거쳐서 이루어지며, 이러한 구조는 GPU의 설계 철학—“연산 중에는 GPU 내부에서 모든 일을 처리한다”—와 정확히 맞아떨어진다.

Detailed Explanation

자, 이제 우리는 드디어 조금 더 디테일한 설명을 받아들일 준비가 되었다. 본격적으로 시작해보자. 

data = numpy.load("~")
data.to_gpu()
GPU_processing_function(data)
data.to_cpu()
print(data)

 

아까의 코드를 다시 가져왔다. 우리가 집중할 것은 line 3이 실행되는 순간이다.
GPU_processing_function()이 호출되면, 함수 내부에서 GPU로 보낼 여러 연산들이 정의되고, 그 중 완전히 병렬로 실행될 수 있는 연산 단위들이 각각 kernel로 묶인다. 여기서 말하는 kernel은 GPU에서 병렬로 실행되는 최소 실행 단위로, 예를 들면
addVector kernel, matrixMultiplication kernel 등이 있다.

CPU는 이러한 kernel들을 어떤 순서로 실행할지 결정하기 위해 여러 개의 scheduling queue(stream)를 구성한다.

  • 서로 순차적으로 실행되어야 하는 kernel들은 같은 stream에 넣고,
  • 서로 병렬로 실행될 수 있는 kernel들은 서로 다른 stream에 배치한다.

예를 들어, 다음과 같이 세 개의 stream을 만들고 서로 다른 kernel들을 배치할 수 있다.

Stream 1:  A → B → C
Stream 2:  D → E
Stream 3:  F → G → H → I

 

  • Stream 1 안의 A, B, C는 반드시 위 순서대로 실행
  • Stream 2는 D 다음에 E
  • Stream 3은 F → G → H → I 순서로 진행
  • 하지만 Stream 1, 2, 3은 서로 병렬로 실행될 수 있다
    (GPU 리소스가 허용하는 범위 내에서 동시에 스케줄링됨)

 

여기까지는 모두 CPU 측에서 이루어지는 작업이다. stream들로 정리된 모든 kernel launch 요청이 준비되면, CPU는 이 stream들을 GPU의 scheduling hardware에 전달한다. 

 

이제 GPU의 차례가 왔다. GPU는 하드웨어 리소스가 허락하는 한 여러 개의 kernel task를 최대한 병렬로 실행하려고 한다.

GPU는 하나의 kernel을 다시 block 단위로 분할하는데, 같은 kernel 내부의 block들은 서로 독립적이기 때문에 모두 병렬 실행이 가능하다. 그래서 GPU는 이러한 block들을 여러 SM(Streaming Multiprocessor)에 분산 배치해 동시에 처리한다.

일반적으로 하나의 block 내부에는 100~500개 정도의 thread가 존재하며, SM은 이 thread들을 다시 32개씩 묶은 warp 단위로 스케줄링한다. 각 SM은 이러한 warp들을 순환시키며 실행하며, 하드웨어 구조에 따라 동시에 1~4개의 warp를 병렬로 처리할 수 있다.

GPU
├── Kernel A                          (Stream 1)
│     └── Grid A
│          ├── Block A0(SM 1)
│          │      ├── Warp A0-0
│          │      │      ├── Thread 0
│          │      │      ├── Thread 1
│          │      │      └── ...
│          │      └── Warp A0-1
│          │             ├── Thread ...
│          │             └── ...
│          │
│          └── Block A1(SM 2)
│                 ├── Warp A1-0
│                 └── Warp A1-1
│
└── Kernel B                          (Stream 2)
      └── Grid B
           ├── Block B0(SM 3)
           │      ├── Warp B0-0
           │      └── Warp B0-1
           │
           └── Block B1(SM 4)
                  ├── Warp B1-0
                  └── Warp B1-1

 

그리고 Kernel에서 작업을 할당한 모든 SM에서 Block 처리 작업이 끝나면, 그 kernel의 작업은 완료된 것이다. 그렇게 모든 kernel의 작업이 완료되면, GPU Memory에 최종 결과값들이 만들어지게 되고, 그걸 CPU memory -> disk에 옮기면 끝나는 것이다. 

Wrap-up

마지막으로 cuda kernel programming에 대해서도 설명해보겠다. 

종종 “CUDA kernel programming을 한다”라는 표현을 들을 수 있는데, 이는 하나의 병렬 실행 단위인 kernel을 직접 커스터마이징해서 만든다는 의미다.
kernel을 직접 작성하면, 해당 kernel이 실행될 때

  • block을 어떻게 구성할지,
  • block 내부에서 어떤 연산 흐름을 만들지,
  • thread들이 메모리에 어떻게 접근할지(shared memory, global memory 등),

와 같은 세부 동작 방식을 모두 사용자가 직접 정의할 수 있다.

조만간 kernel programming도 해볼 예정이라서, 하고나면 관련 글을 또 올려보도록 하겠다.

 

 오늘은 딥러닝 모델을 train하거나 inference할 때, 내부에서 작업이 어떻게 병렬화되는지, 그리고 CPU와 GPU가 각각 어떤 역할을 수행하는지를 살펴보았다. 나 역시 완전한 전문가는 아니기 때문에, 엄밀하게 들어가면 일부 내용은 최근 트렌드와 다를 수 있다. 예를 들어, 요즘에는 LLM의 KV cache가 워낙 커져서 모든 data를 GPU Memory에 올리는 것은 무리가 있어서, 느림을 감수하고 GPU가 계산을 수행하는 도중에 데이터를 disk에 저장했다가 다시 불러오는 방식까지 사용되기도 한다. 그럼에도 불구하고, 입문자의 관점에서 전체적인 흐름을 이해하기에는 충분히 좋은 설명이라고 생각한다. 질문이나 discussion이 있으면 댓글도 좋고 개인적으로 연락도 환영이다!