커널(Kernel)이란 정확히 무엇일까요?

@MainzOnX
영어1일 전 · 2026년 7월 14일
198K
768
77
20
1.6K

TL;DR

이 가이드에서는 GPU 커널을 작은 병렬 프로그램으로 설명하고, torch.compile을 통한 커널 퓨전이 어떻게 메모리 트래픽을 줄여 PyTorch 성능을 최적화하는지 보여줍니다.

GPU 위의 두 숫자

파이썬에서 c = a + b 를 어떻게 하는지 이미 알고 있을 거예요. 수천 번 해봤죠. 여기서는 이걸 PyTorch 텐서로 다뤄볼 거예요. 텐서는 숫자들의 배열일 뿐입니다. 텐서를 GPU에 올린다는 건 그 배열이 일반 CPU 메모리 대신 GPU 메모리에 저장된다는 뜻이에요. ab 가 GPU 위의 두 텐서라면, 그 한 줄은 충분히 빨리 끝나서 신경 쓸 일이 없죠.

이제 크기를 줄여봅시다. ab 가 GPU 위에 있는 두 개의 단일 float 값이라고 해보죠. 같은 줄입니다. c = a + b . 칩에서는 실제로 무슨 일이 일어날까요?

정답은 커널(kernel) 입니다. 이 세계에서 커널은 GPU가 데이터 조각에 대해 실행하는 작은 프로그램을 의미합니다. 노트북이 부팅할 때 사용하는 OS 커널이 아니에요. 선형대수 교과서에 나오는 수학 커널도 아니고요. 이 단어는 여러 곳에서 재사용되니까 여러분 탓이 아닙니다. GPU 세계에서 커널은 단지: GPU가 지금 당장, 병렬로, 여러분이 건넨 데이터에 대해 실행하라는 명령을 받는 작은 함수를 뜻합니다.

이 글을 다 읽고 나면, PyTorch 코드 조각을 보고 GPU가 몇 개의 커널을 실행할지 셀 수 있게 될 거예요. 작은 기술처럼 들리죠, 맞습니다. 하지만 "GPU는 블랙박스"라는 느낌에서 벗어나는 첫걸음이기도 합니다. 모델이 느린데 이유를 모르는 그런 느림 말이죠. GPU 성능에 대해 물을 수 있는 모든 질문은 결국 "어떤 커널이 실행됐고, 그게 뭘 하고 있었는지"로 돌아옵니다. 그래서 여기서부터 시작하는 겁니다.

첫 번째 커널

ab 를 조금 더 키워봅시다. 이번엔 길이 8짜리 텐서입니다. 여전히 PyTorch 한 줄: c = a + b .

이걸 실행하면, CPU(실제로 파이썬을 실행하는 기계)가 GPU에게 말합니다: "이봐, 이 데이터로 이 프로그램을 실행해줘." 이 명령을 런치(launch) 라고 합니다. 런치되는 것은 커널입니다: 실행 준비가 된 하나의 프로그램이죠. 런치 자체는 저렴합니다, 마이크로초 단위고요. 런치 주변 (GPU로 가는 데이터, 돌아오는 결과) 에 실제 비용이 있으며, 앞으로 우리가 계속 세게 될 부분입니다.

커널 내부에서는 실제 작업을 스레드(thread) 라는 아주 작은 작업자들이 수행합니다. GPU에는 수천 개의 스레드가 있습니다. 길이 8짜리 덧셈의 경우, 8개의 스레드가 작업을 나눠 맡습니다: 스레드 0은 요소 0을 처리하고, 스레드 1은 요소 1을 처리하는 식으로 스레드 7까지 이어집니다. 각 스레드는 동일한 아주 작은 프로그램을 실행합니다: a 의 한 요소를 읽고, b 의 일치하는 요소를 읽고, 더한 다음, 결과를 c 에 씁니다.

(실제로 GPU는 스레드를 워프(warp)라는 고정 크기 그룹으로 런치합니다. NVIDIA 카드에서는 항상 32개 스레드이고, 배열이 균등하게 나누어지지 않을 때는 나머지를 마스킹 처리합니다. 지금은 무시해도 됩니다.)

Adam Mainz - inline image

그래서 PyTorch 한 줄, 한 번의 런치, 하나의 커널, 8개의 덧셈을 하는 8개의 스레드가 있습니다. 이제 칩을 실제로 무엇이 교차했는지 세어봅시다. 덧셈을 하려면 각 스레드는 a 의 요소와 b 의 요소가 필요합니다. a 를 8번 읽고 b 를 8번 읽는 셈이죠. 그런 다음 각 스레드는 결과를 c 에 씁니다. 8번의 쓰기입니다.

이러한 읽기와 쓰기는 GPU 칩 바로 옆에 있는 큰 메모리로 이동합니다. 데이터센터 카드(A100, H100)에서는 이 메모리를 HBM(고대역폭 메모리)이라고 합니다. 소비자용 카드(RTX 4090)와 사람들이 실제로 시도해볼 법한 Colab 스타일 T4에서는 GDDR이라고 부릅니다. 어느 쪽이든, 칩 옆에 있는 빠른 메모리이며, 그냥 GPU 메모리라고 부르겠습니다. 빠르긴 하지만 공짜는 아니고, 매번 접근할 때마다 비용이 듭니다.

하나의 커널 = 한 번의 런치 = 데이터에 대한 한 번의 패스. 커널이 본문 내에서 무엇을 하든, 그 가장자리에서의 읽기와 쓰기(입력을 가져오기 위해 GPU 메모리로 나가는 이동, 출력을 쓰기 위해 돌아오는 이동)가 비용이 드는 부분입니다. 이것이 전체 그림입니다.

텐서가 더 커져도 이 중 어떤 것도 변하지 않습니다. 같은 PyTorch 줄, 같은 하나의 커널, 단지 더 많은 스레드일 뿐입니다. ab 가 각각 백만 개의 요소라면, GPU는 더 큰 스레드 떼와 함께 동일한 커널을 런치합니다. 수학은 확장되고, 이동하는 바이트는 확장되지만, 멘탈 모델은 변하지 않습니다. 한 줄, 하나의 커널.

Adam Mainz - inline image

두 연산 사이에서 일어나는 일

python
1c = (a + b).relu()

파이썬을 충분히 안다면 이게 덧셈과 relu라는 두 연산이 연결된 것임을 알 수 있습니다. 인터프리터에서는 두 번의 함수 호출입니다. GPU에서는, eager PyTorch에서, 이는 두 번의 커널 런치입니다: 덧셈을 위한 하나, relu를 위한 하나. 지금까지는 놀랄 일이 없네요.

실제로 흥미로운 것은 두 커널 사이에서 일어나는 일입니다.

덧셈이 끝나면, 결과를 어딘가에 놓아야 합니다. 그 어딘가는 GPU 메모리입니다. 덧셈은 완전한 중간 배열(tmp 라고 부르겠습니다)을 메모리에 씁니다. 그러고 나서 잠시 후 relu가 런치되고, 첫 번째 작업은 바로 그 tmp 배열을 메모리에서 다시 읽어오는 것입니다. 전체를 읽고, 각 요소에 relu를 적용한 다음, 결과를 c 에 씁니다.

이 두 커널의 메모리 트래픽을 세어보세요:

  • 덧셈: a 를 읽고, b 를 읽고, tmp 를 씁니다. 세 번의 배열 크기 전송.
  • relu: tmp 를 읽고, c 를 씁니다. 두 번 더.

다섯 번의 배열 크기 전송입니다. 이전 섹션의 길이 8짜리 덧셈 단독(3번이었죠)과 비교해보세요. 체인에 .relu() 를 추가하는 것은 relu의 계산 비용만 발생시키는 것이 아닙니다. 배열이 GPU 메모리를 통해 완전히 왕복하는 비용이 발생한 겁니다. tmp 가 다음 커널이 다시 읽어올 수 있도록 기록되어야 했기 때문입니다.

아무것도 캐시되지 않았습니다. tmp 는 레지스터나 빠른 로컬 캐시에 머물지 못했습니다. GPU 메모리(느리고 먼 종류)로 나갔다가 바로 돌아왔습니다. 두 커널은 서로에게 낯선 사람입니다. 둘 다 통신할 줄 아는 유일한 매체인 GPU 메모리를 통해 데이터를 전달해야 합니다.

PyTorch가 이렇게 하는 이유는 무엇일까요? eager 모드에서는 a + b 를 작성할 때 PyTorch가 그것을 지금 실행하기 때문입니다. 여러분이 다음에 .relu() 를 호출할지 알지 못합니다. 각 연산은 해당 파이썬 줄이 실행되는 순간 디스패치됩니다. 계획도, 미리 내다보지도 않습니다. 모든 연산은 독립적으로 서서, 실제 배열을 생성하고, 메모리를 통해 다음에 오는 것에 전달합니다.

이것이 기억해야 할 패턴입니다. PyTorch 코드의 모든 중간 값은 물리적으로 GPU 메모리에 기록되고 다음 연산에 의해 다시 읽힙니다. 모든 값이 그렇습니다. 이것이 "커널 수"가 실제로 측정하는 것입니다. 모든 추가 커널은 데이터가 GPU 메모리를 통해 수행해야 하는 또 다른 왕복입니다.

퓨전: 두 연산, 하나의 커널

이 모든 것을 한 번에 수행하는 하나의 커널을 상상해보세요: a 의 요소를 읽고, b 의 요소를 읽고, 더하고, 결과에 relu를 적용합니다(이 모든 것이 커널 내부에서, 칩을 절대 떠나지 않는 아주 작은 스레드별 스크래치 공간에서 이루어집니다), 그리고 나서야 최종 값을 c 에 씁니다. 중간 (a + b) 는 여전히 존재하지만, 커널 내부, 각 스레드의 개인 스크래치 공간에만 있습니다. GPU 메모리에 기록되지 않습니다. tmp 는 실제 배열로서 전혀 존재하지 않습니다.

이제 전송을 세어보세요. a 읽기: 요소당 1번. b 읽기: 요소당 1번. c 쓰기: 요소당 1번. 세 번의 배열 크기 전송. 두 커널 버전과 동일한 수학이지만, 왕복이 두 번 줄었습니다.

길이 8에서는 이것은 반올림 오류 수준입니다. 아무도 신경 쓰지 않아요. 길이가 100만, 또는 1억이 되면, 이러한 추가 왕복은 실행 시간의 큰 부분을 차지하게 되고, 실제 시간이 이를 반영합니다. 메모리 트래픽이 왜 그렇게 지배적이 되는지는 이 시리즈의 두 번째 기사 전체 주제이므로, 여기서는 "왜"는 다루지 않겠습니다. 지금 중요한 점은 이것입니다: 같은 수학, 더 적은 이동, 실제로 더 빠름.

별개의 커널이었을 연산들을 하나의 커널로 결합하여 중간 값이 GPU 메모리를 방문할 필요가 없게 만드는 이 트릭에는 이름이 있습니다. 퓨전(fusion) 이라고 합니다. 그게 전부입니다. 그게 전체 아이디어입니다.

이제 어색한 부분이 있습니다. 그 결합된 커널을 손으로 작성하는 것은 add + relu의 경우 쉬워 보입니다. 두 개의 연산. 중간에 "계산" 한 줄. 하지만 실제 PyTorch 코드에는 수십 개의 연산이 연결되어 있고, 각각 고유한 모양과 데이터 타입, 브로드캐스팅 규칙이 있습니다. 이 모든 것을 올바르게 처리하는 퓨전 커널을 작성하는 것은 실제 엔지니어링 작업입니다. 일반적으로 이러한 일상적인 요소별 커널을 손으로 작성하지는 않을 겁니다.

좋은 소식: PyTorch에는 이러한 종류의 경우에 대해 자동으로 재작성을 해주는 도구가 내장되어 있습니다. torch.compile 이라고 합니다.

torch.compile 이 다음과 같이 사용되는 것을 거의 확실히 본 적이 있을 겁니다:

python
1model = torch.compile(model)

한 줄입니다. 인터넷에서 누군가가 속도를 높여준다고 말했죠. 여기서 실제로 하는 일을 평이한 영어로 설명하자면: eager 모드처럼 연산을 하나씩 실행하는 대신, torch.compile 은 함수가 수행하는 텐서 연산을 캡처하고, 이를 결합할 기회를 찾아 최적화된 코드를 생성합니다. 이후 동일한 가정과 일치하는 호출은 해당 코드를 재사용할 수 있습니다.

위에서 종이 위에서 했던 퓨전(add와 relu, 하나의 커널 공유, tmp 가 메모리에 닿지 않음)은 정확히 torch.compile 이 여러분의 코드에 자동으로 수행할 종류의 작업입니다. 연산이 충분히 간단하다면 말이죠. 사람들이 torch.compile 이 "PyTorch를 더 빠르게 만든다"고 말할 때, 이것이 그 의미의 큰 부분입니다.

torch.compile 이 자체적으로 퓨전할 수 없는 경우(인식하지 못하는 사용자 정의 연산, 특이한 리덕션, 이상한 메모리 레이아웃)에는 여전히 누군가가 수동으로 커널을 작성해야 합니다. 그럴 때 Triton이나 CUDA 같은 도구가 필요합니다. 이것은 별도의 기사에서 다루겠습니다.

Adam Mainz - inline image

직접 확인해보세요

지금까지의 모든 내용은 종이 위에서 커널을 세는 것이었습니다. 이제 실제 GPU에서 세어볼 시간입니다. CUDA GPU가 있는 머신(워크스테이션, Colab 노트북, 클라우드 인스턴스)이 있다면, 몇 분 안에 직접 실행해볼 수 있습니다.

사용할 도구는 torch.profiler 입니다. PyTorch에 내장되어 있습니다. 하는 일은 코드가 실행되는 동안 GPU가 실제로 수행한 작업을 기록하고, 읽을 수 있는 테이블을 반환하는 것뿐입니다.

1단계: eager 버전.

두 연산 줄을 함수로 감싸서 호출할 무언가를 만듭니다:

python
1import torch
2from torch.profiler import profile, ProfilerActivity
3
4def add_relu(a, b):
5 return (a + b).relu()
6
7a = torch.randn(1_000_000, device="cuda")
8b = torch.randn(1_000_000, device="cuda")
9
10with profile(activities=[ProfilerActivity.CUDA]) as prof:
11 add_relu(a, b)
12 torch.cuda.synchronize()
13
14print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))

torch.cuda.synchronize() 는 시간 측정을 읽기 전에 GPU가 작업을 완료했는지 확인하기 위한 것입니다. GPU 작업은 비동기적으로 실행되며, 동기화 없이 가끔 실제 커널 작업 대신 런치 오버헤드를 측정할 수 있습니다.

2단계: 출력 읽기.

실제 프로파일러 출력에는 예상보다 더 많은 행이 있을 수 있습니다. 메모리 할당 및 PyTorch 부기(bookkeeping) 라인이 많이 섞여 있을 겁니다. 우리가 관심 있는 행은 CUDA 커널, 즉 GPU가 실제로 실행한 함수들입니다. 이름에 kernel 이 있는 행을 찾으세요. 두 개는 대략 다음과 같이 보일 겁니다:

text
1vectorized_elementwise_kernel<...BinaryFunctor_add...> 1 12us
2vectorized_elementwise_kernel<...threshold_kernel...> 1 9us

정확한 템플릿 이름은 PyTorch 버전에 따라 다릅니다(relu는 종종 threshold 로 표시되는데, 그게 기본 연산이기 때문이고, add는 가끔 CUDAFunctor_add 로 표시됩니다). 전체를 파싱하려고 하지 마세요. 그냥 행 수를 세세요. 두 행. 두 개의 커널. 하나는 덧셈용, 하나는 relu용입니다. 한 섹션 전에 우리가 말했던 것과 정확히 일치합니다.

3단계: 컴파일된 버전.

한 줄 변경입니다. 함수를 torch.compile 로 감쌉니다:

python
1compiled = torch.compile(add_relu)

여기 실행하기 전에 알아두면 좋은 함정이 하나 있습니다. torch.compile 된 함수에 대한 첫 번째 호출은 느립니다. 가끔 이상할 정도로 느린데, 그때 torch.compile 이 작업을 수행하기 때문입니다: 코드를 분석하고, 무엇을 퓨전할지 파악하고, 퓨전된 커널을 생성합니다. 첫 번째 호출을 프로파일링하면 커널이 아닌 컴파일 단계를 측정하는 것입니다. 따라서 패턴은 이렇습니다: 한 번 호출해서 워밍업하고, 결과를 버린 다음 프로파일링합니다.

text
1compiled(a, b) # warm-up, throw away
2torch.cuda.synchronize()
3
4with profile(activities=[ProfilerActivity.CUDA]) as prof:
5 compiled(a, b)
6 torch.cuda.synchronize()
7
8print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))

4단계: 출력을 다시 읽습니다.

이번에는:

text
1triton_poi_fused_add_relu_0 1 14us

한 행. 하나의 커널. 이름에서 무엇을 했는지도 알려줍니다: fused add and relu. 이전과 같은 수학, 두 번 대신 한 번의 런치.

방금 이 기사가 이야기해온 것을 한 문장으로 해낸 겁니다: PyTorch에게 두 연산을 하나의 커널로 결합하도록 요청하고, 프로파일러를 확인하고, 개수가 2에서 1로 줄어든 것을 확인했습니다. 실제 환경에서, 여러분의 머신에서의 퓨전입니다.

더 극적으로 보고 싶다면, 몇 가지 다른 텐서 크기로 시도해보세요. 길이 100에서는 두 버전 모두 너무 빨라서 차이가 노이즈에 묻힙니다. 길이 1000만 또는 1억에서는 컴파일된 버전이 확실히 앞서기 시작합니다. 왜냐하면 우리가 제거한 왕복이 그 규모에서 실제 작업의 상당 부분이기 때문입니다.

커널 수를 세는 것은 더 이상 추상적인 조언이 아닙니다. 확인할 방법이 생겼습니다.

Adam Mainz - inline image

정리하자면

전체 내용을 한 번에 요약하겠습니다.

PyTorch 코드는 GPU에서 실행될 때 일련의 커널로 변환됩니다. 각 커널은 한 번의 런치, 데이터에 대한 한 번의 패스, 입력을 가져오고 출력을 쓰기 위한 GPU 메모리를 통한 한 번의 왕복입니다. 간단한 연산은 하나의 커널이 됩니다. 연산 체인은 기본적으로 연산당 하나의 커널이 되며, 중간 값은 그 사이에 메모리를 왕복합니다. torch.compile 은 간단한 체인을 퓨전하여 해당 중간 값이 메모리에 닿지 않게 할 수 있습니다. 일반적으로 커널이 적다는 것은 메모리 트래픽이 적다는 것을 의미합니다. 그리고 메모리 트래픽이 적다는 것은 일반적으로 더 빠르다는 것을 의미합니다.

YouMind에서 다시 만들기

Turn one viral article into a full content workflow

Collect the source, decode the pattern, create assets, draft the story, and distribute from one AI workspace.

Explore YouMind
크리에이터를 위해

당신의 Markdown을 깔끔한 𝕏 글로

직접 쓴 장문을 올릴 때 이미지, 표, 코드 블록을 𝕏에 맞게 정리하는 일은 번거롭습니다. YouMind는 전체 Markdown 초안을 깔끔하고 바로 게시할 수 있는 𝕏 글로 바꿔 줍니다.

Markdown → 𝕏 사용해 보기

분석할 패턴 더 보기

최근 바이럴 아티클

더 많은 바이럴 아티클 보기