MEMORY INDUSTRY INTELLIGENCE
트리톤 프로그래머를 위한 GPU 기초: 커널에서 메모리까지
한국어 번역·요약·분석
원문 제목: GPU fundamentals for Triton programmers: kernels to memory
원문 문장이 일치하지 않은 주장 3건은 근거 등록에서 제외했습니다.
핵심 요약
이 글은 GPU 프로그래밍 입문 시리즈의 1부로, PyTorch의 한 줄 표현이 실제로는 두 개의 GPU 커널 실행과 다섯 번의 전역 메모리 접근을 유발한다는 점을 설명한다. GPU가 데이터 병렬 처리를 위해 설계되었으며, CPU는 지연 시간 최소화에, GPU는 처리량 극대화에 실리콘을 사용한다는 점을 대비시킨다. CUDA C++의 기본 구조(커널, 블록, 스레드, 워프, SM)와 호스트-디바이스 간 분리된 메모리, 데이터 복사 과정을 설명하고, 배열 제곱 예제를 통해 다섯 단계의 CUDA 프로그램 흐름을 보여준다. 또한 GPU 작업의 비동기적 특성과 정확한 벤치마킹을 위해 CUDA 이벤트와 L2 캐시 제거가 필요함을 언급한다. 트리톤은 이러한 저수준 세부 사항을 추상화하는 Python 내장 DSL로 소개된다.
메모리 산업 영향 분석
이 원문은 GPU 프로그래밍 기초를 설명하는 기술 교육 자료로, 메모리 산업과의 직접적인 연결 근거는 부족하다. 원문에서 확인되는 사실은 PyTorch의 eager 모드가 한 줄의 (a+b).relu() 연산을 두 개의 커널로 실행하며, 이로 인해 전역 메모리를 통한 다섯 번의 배열 크기 왕복(읽기 a, 읽기 b, 쓰기 tmp, 읽기 tmp, 쓰기 c)이 발생한다는 점이다. 또한 CUDA 프로그램에서 호스트와 디바이스 메모리가 분리되어 PCIe 버스를 통해 데이터를 복사해야 하며, GPU는 처리량을 위해 수천 개의 단순 산술 유닛을 병렬로 사용하도록 설계되었다는 점을 설명한다. 분석가 가설로는, 커널 융합(kernel fusion)을 통해 중간 텐서 쓰기/읽기를 제거하면 전역 메모리 대역폭 사용량이 줄어들 수 있다는 점이 시사되지만, 이는 원문에서 명시적으로 메모리 산업 영향으로 연결되지 않았다. 메모리 산업 관점에서 이 원문은 GPU의 메모리 계층 구조와 데이터 이동 패턴을 설명할 뿐, 특정 메모리 제품(HBM, DDR, LPDDR 등)의 수요나 공급, 고객 인증, 제품 믹스, 투자 일정에 대한 정보를 제공하지 않는다. 따라서 메모리 산업과의 직접 연결 근거 부족이라고 판단한다. 확인할 지표로는 GPU 커널 실행 횟수, 전역 메모리 왕복 횟수, 커널 융합 시 메모리 대역폭 절감 효과 등이 있으나, 이는 메모리 제품 수요와 직접 연결되지 않는다.
한국어 번역 읽기
수집된 원문 v1의 분석에 제공된 본문 기준 · 15534자
엔지니어링
클라우드
2026년 10월 7일
트리톤을 이용한 GPU 프로그래밍, 1부
한 줄의 PyTorch 코드가 두 번의 GPU 커널 실행과 다섯 번의 전역 메모리 왕복을 만든다. 1부에서는 GPU가 실제로 실행하는 것, 즉 커널과 워프에서 메모리 계층까지 추적한 다음, 동일한 벡터 덧셈을 두 번 작성한다: 한 번은 CUDA C++로, 한 번은 트리톤으로.
수만 데브나스
개발자 관계 디렉터
다니엘 셴
머신러닝 엔지니어
자나키 램 고테티
시니어 스태프 소프트웨어 엔지니어
코너 게레로
시니어 개발자 관계 매니저
레이 마
테크니컬 콘텐츠 마케팅 매니저
2026년 10월 7일
목차
이것은 div 블록 안의 텍스트입니다.
공유:
우리 대부분은 프레임워크를 통해 GPU를 사용하기 시작한다. 모델을 디바이스로 옮기면 학습이 더 빨라지고, 오랫동안 그것만으로 충분하다. 어려움은 무언가가 예상보다 느릴 때 시작되는데, GPU가 줄곧 블랙박스였기 때문에 그 이유를 추론할 방법이 없다.
이 시리즈는 그 상자를 열어보려는 우리의 시도다. 이 시리즈에서 우리는 GPU가 코드를 실행하는 방법을 배우고, 트리톤으로 커널을 작성하며, 우리가 작성하는 모든 것을 측정할 것이다. 트리톤은 Python에 내장된 GPU 커널용 도메인 특화 언어(DSL)이며, 컴파일러와 런타임을 모두 갖추고 있다. 그 의미에 대해서는 이 글의 끝부분에서 더 배울 것이다. 우리가 트리톤을 선택한 이유는 NVIDIA CUDA® C++로 시작할 필요 없이 빠른 GPU 커널을 작성할 수 있게 해주기 때문이다. 그러나 효율적인 GPU 커널을 작성하는 것은 기초, 즉 GPU가 작업을 실행하고, 스레드를 구성하고, 메모리를 통해 데이터를 이동시키는 방법을 이해하는 것에서 시작된다.
따라서 이 첫 번째 부분은 주로 GPU 기초에 초점을 맞추어, 트리톤으로 커널을 작성하기 전에 필요한 토대를 쌓는다. 우리는 GPU에 간단한 텐서를 만들고 다른 텐서에 더하는 한 줄의 PyTorch 코드로 시작하여, 그 줄이 실행될 때 GPU가 실제로 무엇을 실행하는지 질문할 것이다.
그런 다음 CUDA C++로 들어가는데, 여기서는 GPU 프로그래밍의 모든 세부 사항이 명시적이다: 스레드가 어떻게 구성되고, 작업이 어떻게 분배되며, 데이터가 CPU, GPU, 그리고 그 사이의 메모리 계층 간에 어떻게 이동하는지. 이러한 메커니즘을 직접 보는 것이 트리톤이 우리를 위해 처리하는 것을 이해하는 최선의 방법이다. 마지막으로 동일한 커널을 트리톤으로 작성하고 둘을 나란히 비교한다.
한 줄의 PyTorch
GPU에 도달할 때 무슨 일이 일어나는지 따라가며 간단한 PyTorch 예제로 시작하자:
import
torch
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
c = (a + b).relu()
처음 두 줄은 각각 백만 개의 난수를 포함하는 텐서를 만들어 GPU 메모리에 배치한다. 세 번째 줄은 단순해 보인다: 두 텐서를 더한 다음 ReLU를 적용한다.
Python의 관점에서 이것은 단일 표현식이다. 그러나 GPU는 실제로 무엇을 실행하는가?
그것은 두 개의 별도 프로그램, 즉 커널을 실행한다.
커널은 GPU 함수로, GPU가 일반적으로 서로 다른 요소에 걸쳐 병렬로 실행하는 함수다. CPU는 GPU에게 커널을 실행하도록 요청하며, 그 요청을 커널 실행(kernel launch)이라고 한다. 여기서 커널이라는 단어는 운영체제 커널이나 컨볼루션에 사용되는 작은 행렬과는 아무 관련이 없다; GPU 프로그래밍에서는 단순히 GPU가 실행하는 함수를 의미한다.
PyTorch가 a + b를 평가할 때, a와 b를 읽고, 그 요소들을 더하고, 결과를 쓰는 하나의 커널을 실행한다. 그런 다음 .relu()를 평가하여 중간 결과를 읽고, 음수 값을 0으로 바꾸고, 최종 출력을 쓰는 두 번째 커널을 실행한다.
따라서 한 줄의 PyTorch는 두 번의 GPU 커널 실행이 된다.
한 줄, 두 커널: 덧셈은 tmp를 전역 메모리에 쓰고 ReLU()는 그것을 다시 읽어, 총 다섯 번의 배열 크기 왕복이 발생한다.
첫 번째 커널은 a와 b를 읽고, 더하고, 결과를 중간 텐서 tmp로 GPU 메모리에 다시 쓴다. 두 번째 커널은 tmp를 읽고, ReLU를 적용하고, 최종 결과를 c에 쓴다.
이러한 메모리 작업을 세어보면 전역 메모리를 통한 다섯 번의 배열 크기 왕복이 있다:
a 읽기
b 읽기
tmp 쓰기
tmp 읽기
c 쓰기
그러나 실제 계산은 a와 b의 값으로 c를 생성하는 데만 필요하다. 덧셈과 ReLU가 함께 일어날 수 있다면, 세 번의 배열 크기 메모리 작업만 필요할 것이다: a 읽기, b 읽기, c 쓰기. tmp의 추가 쓰기와 읽기는 덧셈과 ReLU가 별도의 커널로 실행되기 때문에 발생한다.
PyTorch는 왜 이렇게 하는가? 기본 eager 모드에서 PyTorch는 Python이 도달하는 대로 각 연산을 실행한다. 덧셈을 실행할 때 PyTorch는 앞을 내다보며 뒤따르는 ReLU와 함께 최적화하지 않는다. 덧셈을 자체 연산으로 실행하고 결과를 GPU 메모리에 쓴다. Python이 .relu()에 도달하면 PyTorch는 또 다른 커널을 실행하여 중간 결과를 읽고 최종 출력을 생성한다.
나중에 이 두 연산이 단일 커널로 결합되어 전역 메모리를 통한 중간 쓰기와 읽기를 제거할 때 어떤 일이 일어나는지 보게 될 것이다.
커널 세기
PyTorch의 프로파일러를 사용하여 우리 코드가 실행하는 GPU 커널을 직접 볼 수 있다:
import
torch
from
torch.profiler
import
ProfilerActivity, profile
def
add_relu
(
a, b
):
return
(a + b).relu()
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
# Warm up once so one-time initialization stays out of the trace.
add_relu(a, b)
torch.cuda.synchronize()
with
profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA])
as
prof:
add_relu(a, b)
torch.cuda.synchronize()
# wait for the GPU before ending the trace
print
(prof.key_averages().table(sort_by=
"cuda_time_total"
, row_limit=
12
))
이것이 출력하는 것을 보기 전에 먼저 위 코드를 이해하자. add_relu()에 대한 첫 번째 호출과 그 직후의 torch.cuda.synchronize()는 단순히 워밍업이므로 지금은 무시해도 된다. 이들은 프로파일링을 시작하기 전에 일회성 초기화 작업이 완료되도록 한다.
프로파일러 내부에서 다시 torch.cuda.synchronize()를 호출한다. GPU 작업은 비동기적이므로, CPU는 GPU에서 작업이 계속 실행되는 동안 다음 문장으로 진행한다. 따라서 이 호출이 없으면 프로파일링 영역이 우리가 관찰하려는 커널이 완료되기 전에 끝날 수 있다. 이 호출은 대기 중인 모든 GPU 작업이 완료될 때까지 CPU를 차단한다. 비동기 실행은 우리가 직접 커널을 작성한 후에 더 자세히 살펴볼 것이다.
다음은 Crusoe의 NVIDIA L40S GPU(이 시리즈에서 사용하는 GPU)에서 출력된 표이며, 공간상 CPU 타이밍 열은 생략했다:
Name Self CUDA # of Calls
-------------------------------------------------------- ---------- ----------
aten::add 3.680us 1
void at::native::vectorized_elementwise_kernel<4, at... 3.680us 1
aten::relu 0.000us 1
aten::clamp_min 2.976us 1
void at::native::vectorized_elementwise_kernel<4, at... 2.976us 1
cudaLaunchKernel 0.000us 2
cudaDeviceSynchronize 0.000us 2
-------------------------------------------------------- ---------- ----------
Self CUDA time total: 6.656us
언뜻 보면 프로파일러 출력이 다소 복잡해 보일 수 있는데, 이는 상위 수준 PyTorch 연산과 그것이 트리거하는 실제 GPU 커널을 혼합하기 때문이다. 이를 분석하면:
PyTorch 연산: aten::add, aten::relu, aten::clamp_min과 같은 행은 프레임워크 수준 호출이다.
하드웨어 커널: vectorized_elementwise_kernel로 시작하는 두 행은 GPU에서 실행되는 실제 루틴이다.
첫 번째 GPU 커널은 덧셈을 3.680us에 처리한다. ReLU의 경우 PyTorch는 내부적으로 clamp_min을 거쳐 두 번째 커널을 실행하며 2.976us가 걸린다. aten::relu가 자체 시간 0.000us를 기록하는 것을 알 수 있는데, 프로파일러는 GPU 시간을 실제로 커널을 실행하는 가장 안쪽 리프 연산(clamp_min)에 할당하고 aten::relu와 같은 부모 래퍼에는 할당하지 않는다.
개수를 확인하는 더 간단한 방법도 있다. cudaLaunchKernel 행은 2회 호출을 보여주며, 이는 PyTorch가 CUDA에 정확히 두 개의 커널 실행을 요청했음을 의미한다. 이는 다이어그램에서 본 것과 일치한다: 덧셈을 위한 커널 하나와 ReLU를 위한 커널 하나.
두 개의 GPU 행은 덧셈 커널과 ReLU 뒤의 clamp 커널이다. PyTorch 2.6이 설치된 L40S에서 측정됨.
정확한 커널 이름은 중요하지 않으며 PyTorch 버전 간에 바뀔 수 있다. 중요한 점은: 우리의 단일 Python 표현식이 두 번의 GPU 커널 실행을 초래했다는 것이다.
이제 PyTorch가 GPU에 무엇을 실행하도록 요청하는지 알았으니, 다음 단계는 GPU가 실제로 그 작업을 어떻게 실행하는지 이해하는 것이다. 이를 위해 GPU가 어떻게 구성되는지 살펴봐야 한다.
GPU가 왜 그렇게 설계되었는가
천 개의 숫자로 이루어진 배열의 모든 요소를 두 배로 만들고 싶다고 가정하자. 요소들은 독립적이다: 예를 들어 배열의 5번째 요소를 두 배로 만드는 데 4번째 요소는 전혀 필요하지 않다. 따라서 한 프로세서가 요소들을 차례로 방문하는 대신, 각 요소를 자체 작업자에게 맡기고 한 단계로 끝낼 수 있다. 이것이 데이터 병렬성이다: 하나의 연산이 많은 독립적인 데이터 요소에 동시에 수행되는 것. 조각들이 전혀 조정될 필요가 없을 때, 그 작업은 종종 embarrassingly parallel이라고 불린다. 덧셈과 ReLU 같은 요소별 연산은 정확히 이런 형태를 가지며, 행렬 곱셈, 컨볼루션, 어텐션 같은 더 무거운 워크로드가 수천 개의 더 작은 병렬 계산으로 분해될 때도 이와 동일한 패러다임을 따른다. 이것이 딥러닝이 GPU에 잘 매핑되는 이유다. GPU는 동일한 연산이 서로 다른 데이터 집합에 수행되어야 하는 이러한 병렬 처리에 적합하다.
그러나 모든 것이 그런 것은 아니다. 배열을 단일 값으로 합산하는 것은 부분 결과를 결합해야 하므로 작업자 간에 의존성이 생긴다. 이런 연산을 리덕션이라고 하며, 다른 전략이 필요하고, 시리즈 후반에 살펴볼 것이다.
그 설계 목표는 GPU 하드웨어에 나타난다. CPU는 소수의 명령 스트림에서 낮은 지연 시간을 위해 만들어졌다. 몇 개의 큰 코어, 정교한 제어 로직, 큰 캐시, 분기 예측, 비순차 실행 등이 모두 하나의 스레드를 빠르게 실행하는 데 목표를 둔다. GPU는 처리량, 즉 단위 시간당 많은 작업을 완료하기 위해 만들어졌으며, 대신 수천 개의 단순한 산술 유닛을 병렬로 실행하는 데 실리콘을 사용한다.
CPU는 하나의 스레드를 빠르게 만드는 데 면적을 쓰고, GPU는 수천 개의 스레드를 동시에 실행하는 데 쓴다.
이제 GPU가 왜 대규모 병렬성을 중심으로 설계되었는지에 대한 직관을 얻었다. 다음 질문은 어떻게 GPU에 작업을 줄 것인가이며, 이에 답하기 위해 첫 번째 GPU 커널을 작성해보자.
첫 번째 CUDA 프로그램
CUDA는 NVIDIA가 자사 GPU를 프로그래밍하기 위한 플랫폼이다. C++에 몇 가지 키워드와 특별한 함수 호출 구문을 추가하며, 컴파일러 nvcc와 함께 제공된다.
[원문 후반 발췌]
PU. 둘 다 표준 C++ 구문의 일부가 아니다.
__global__은 GPU 커널을 표시한다: __global__ 키워드는 hello_kernel을 커널로 표시하며, 이는 호스트가 실행하고 디바이스가 실행함을 의미한다. CUDA 커널은 호스트에 값을 직접 반환하지 않는다. 결과를 생성하는 커널은 일반적으로 메모리에 쓰며, 호스트나 다른 커널이 나중에 접근할 수 있다.
<<<1, 8>>>은 커널을 실행한다: 실행 구문 <<<1, 8>>>은 호스트가 GPU에 커널 실행을 요청하는 방법이다. 두 숫자는 생성할 스레드 그룹 수와 각 그룹에 포함될 스레드 수를 지정한다. CUDA는 스레드 그룹을 블록이라고 부르므로, 여기서는 8개의 스레드를 포함하는 하나의 블록을 실행한다.
커널에는 루프가 없지만, 그 본문은 8개의 스레드 각각에 대해 한 번씩 실행된다. 8개의 항목을 차례로 처리하는 루프를 작성하는 대신, 8개의 스레드를 실행하고 GPU가 병렬로 실행하도록 한다. 블록이 하나 이상 필요할 때 블록에 대해 훨씬 더 많은 이야기를 할 것이다; 지금은 단일 블록으로 충분하다.
threadIdx.x는 블록 내에서 스레드를 식별한다: 내장 변수 threadIdx.x는 각 스레드에게 블록 내에서의 위치를 알려주며, 여기서는 0부터 7까지다. 모든 스레드는 동일한 커널 코드를 실행하지만, threadIdx.x는 각각 다른 값을 가진다. 그 인덱스는 종종 스레드가
[원문 후반 발췌]
우리가 사용하는 L40S GPU는 142개의 SM을 가지고 있다.
비교를 위해, NVIDIA A100 Tensor Core GPU는 108개의 SM을, NVIDIA H100 Tensor Core GPU는 SXM 버전에서 132개, PCIe 버전에서 114개의 SM을 가진다. 각 SM은 실행 유닛과 스레드에 필요한 저장소(레지스터, 작은 온칩 메모리, 캐시)를 포함한다. 메모리 계층에 도달하면 각각을 살펴볼 것이다; 지금은 SM이 워프가 실행되는 곳이라는 것만 알면 충분하다.
하드웨어는 스레드를 32개 단위의 워프로 그룹화하고 SM에서 워프를 스케줄링한다. 이 중 어느 것도 프로그래머가 선택하지 않는다.
NVIDIA GPU는 32개 스레드의 워프를 사용하므로 CUDA 블록 크기는 일반적으로 32의 배수로 선택된다. 128, 256, 512 스레드당 블록과 같은 선택이 더 큰 커널을 작성할 때 왜 그렇게 자주 나타나는지 알게 될 것이다.
배열 제곱하기: 두 개의 메모리
프린트만 하는 커널은 별로 유용하지 않다. 실제 작업을 하려면 커널은 입력을 읽고 출력을 써야 하며, 이는 GPU 프로그램의 가장 중요한 구조적 사실로 이어진다: 호스트와 디바이스는 별도의 메모리를 가진다. CPU는 시스템의 주 메모리를 사용하고, GPU는 자체 전역 메모리를 가진다.
어느 프로세서도 다른 쪽의 메모리를 직접 읽을 수 없으므로, 데이터는 PCIe 버스를 통해 양방향으로 복사되어야 한다.
호스트와 디바이스 메모리는 분리되어 있다. 커널이 접촉하는 모든 바이트는
[원문 후반 발췌]
디바이스 메모리 해제
다음은 8개의 숫자를 제곱하는 프로그램에서의 워크플로우이며, 숫자당 하나의 스레드를 사용한다:
#
include
<cstdio>
__global__
void
square_kernel
(
const
float
* in,
float
* out,
int
n)
{
int
i = threadIdx.x;
// one block: thread index = element index
if
(i < n) {
// never touch memory past the array's end
out[i] = in[i] * in[i];
}
}
int
main
()
{
const
int
n =
8
;
const
size_t
bytes = n *
sizeof
(
float
);
float
h_in[n], h_out[n];
// host arrays
for
(
int
i =
0
; i < n; ++i) h_in[i] = (
float
)(i +
1
);
float
*d_in =
nullptr
, *d_out =
nullptr
;
// device pointers
cudaMalloc
(&d_in, bytes);
// 1. allocate on device
cudaMalloc
(&d_out, bytes);
cudaMemcpy
(d_in, h_in, bytes, cudaMemcpyHostToDevice);
// 2. copy in
square_kernel<<<
1
, n>>>(d_in, d_out, n);
// 3. launch
cudaMemcpy
(h_out, d_out, bytes, cudaMemcpyDeviceToHost);
// 4. copy out
cudaFree
(d_in);
// 5. free
cudaFree
(d_out);
for
(int
i = 0; i < n; ++i)
printf
(
"%4.0f squared is %4.0f\n"
, h_in[i], h_out[i]);
return
0
;
}
CUDA 프로그램의 다섯 단계. 3단계만 GPU에서 실행되며, PyTorch는 텐서 뒤에서 나머지를 수행한다.
여기에는 두 가지 새로운 CUDA 함수가 있으며, 둘 다 CUDA 런타임 API에서 온다.
cudaMalloc()은 디바이스의 전역 메모리에 메모리를 할당하고 그에 대한 포인터를 제공한다.
cudaMemcpy()는
[원문 후반 발췌]
PU가 작업을 완료하기 전에. 예를 들어, NVIDIA L40S GPU에서 벡터 덧셈은 실행 후 25마이크로초 만에 반환되었지만 완료되는 데 993마이크로초가 걸렸다. 신뢰할 수 있는 벤치마크는 CUDA 이벤트로 GPU 자체 타임라인에서 시간을 측정하고, 실행 사이에 L2 캐시를 제거하여 숫자가 캐시가 아닌 메모리를 반영하도록 한다.
최신 기사
2026년 10월 9일
Crusoe의 추론 엔진은 얼마나 친환경적인가?
2026년 10월 9일
텍사스주에서 커뮤니티 구축
2026년 10월 7일
트리톤을 이용한 GPU 프로그래밍, 1부
멋진 것을 만들 준비가 되셨나요?
문의하기
클라우드
2026년 10월 7일
트리톤을 이용한 GPU 프로그래밍, 1부
한 줄의 PyTorch 코드가 두 번의 GPU 커널 실행과 다섯 번의 전역 메모리 왕복을 만든다. 1부에서는 GPU가 실제로 실행하는 것, 즉 커널과 워프에서 메모리 계층까지 추적한 다음, 동일한 벡터 덧셈을 두 번 작성한다: 한 번은 CUDA C++로, 한 번은 트리톤으로.
수만 데브나스
개발자 관계 디렉터
다니엘 셴
머신러닝 엔지니어
자나키 램 고테티
시니어 스태프 소프트웨어 엔지니어
코너 게레로
시니어 개발자 관계 매니저
레이 마
테크니컬 콘텐츠 마케팅 매니저
2026년 10월 7일
목차
이것은 div 블록 안의 텍스트입니다.
공유:
우리 대부분은 프레임워크를 통해 GPU를 사용하기 시작한다. 모델을 디바이스로 옮기면 학습이 더 빨라지고, 오랫동안 그것만으로 충분하다. 어려움은 무언가가 예상보다 느릴 때 시작되는데, GPU가 줄곧 블랙박스였기 때문에 그 이유를 추론할 방법이 없다.
이 시리즈는 그 상자를 열어보려는 우리의 시도다. 이 시리즈에서 우리는 GPU가 코드를 실행하는 방법을 배우고, 트리톤으로 커널을 작성하며, 우리가 작성하는 모든 것을 측정할 것이다. 트리톤은 Python에 내장된 GPU 커널용 도메인 특화 언어(DSL)이며, 컴파일러와 런타임을 모두 갖추고 있다. 그 의미에 대해서는 이 글의 끝부분에서 더 배울 것이다. 우리가 트리톤을 선택한 이유는 NVIDIA CUDA® C++로 시작할 필요 없이 빠른 GPU 커널을 작성할 수 있게 해주기 때문이다. 그러나 효율적인 GPU 커널을 작성하는 것은 기초, 즉 GPU가 작업을 실행하고, 스레드를 구성하고, 메모리를 통해 데이터를 이동시키는 방법을 이해하는 것에서 시작된다.
따라서 이 첫 번째 부분은 주로 GPU 기초에 초점을 맞추어, 트리톤으로 커널을 작성하기 전에 필요한 토대를 쌓는다. 우리는 GPU에 간단한 텐서를 만들고 다른 텐서에 더하는 한 줄의 PyTorch 코드로 시작하여, 그 줄이 실행될 때 GPU가 실제로 무엇을 실행하는지 질문할 것이다.
그런 다음 CUDA C++로 들어가는데, 여기서는 GPU 프로그래밍의 모든 세부 사항이 명시적이다: 스레드가 어떻게 구성되고, 작업이 어떻게 분배되며, 데이터가 CPU, GPU, 그리고 그 사이의 메모리 계층 간에 어떻게 이동하는지. 이러한 메커니즘을 직접 보는 것이 트리톤이 우리를 위해 처리하는 것을 이해하는 최선의 방법이다. 마지막으로 동일한 커널을 트리톤으로 작성하고 둘을 나란히 비교한다.
한 줄의 PyTorch
GPU에 도달할 때 무슨 일이 일어나는지 따라가며 간단한 PyTorch 예제로 시작하자:
import
torch
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
c = (a + b).relu()
처음 두 줄은 각각 백만 개의 난수를 포함하는 텐서를 만들어 GPU 메모리에 배치한다. 세 번째 줄은 단순해 보인다: 두 텐서를 더한 다음 ReLU를 적용한다.
Python의 관점에서 이것은 단일 표현식이다. 그러나 GPU는 실제로 무엇을 실행하는가?
그것은 두 개의 별도 프로그램, 즉 커널을 실행한다.
커널은 GPU 함수로, GPU가 일반적으로 서로 다른 요소에 걸쳐 병렬로 실행하는 함수다. CPU는 GPU에게 커널을 실행하도록 요청하며, 그 요청을 커널 실행(kernel launch)이라고 한다. 여기서 커널이라는 단어는 운영체제 커널이나 컨볼루션에 사용되는 작은 행렬과는 아무 관련이 없다; GPU 프로그래밍에서는 단순히 GPU가 실행하는 함수를 의미한다.
PyTorch가 a + b를 평가할 때, a와 b를 읽고, 그 요소들을 더하고, 결과를 쓰는 하나의 커널을 실행한다. 그런 다음 .relu()를 평가하여 중간 결과를 읽고, 음수 값을 0으로 바꾸고, 최종 출력을 쓰는 두 번째 커널을 실행한다.
따라서 한 줄의 PyTorch는 두 번의 GPU 커널 실행이 된다.
한 줄, 두 커널: 덧셈은 tmp를 전역 메모리에 쓰고 ReLU()는 그것을 다시 읽어, 총 다섯 번의 배열 크기 왕복이 발생한다.
첫 번째 커널은 a와 b를 읽고, 더하고, 결과를 중간 텐서 tmp로 GPU 메모리에 다시 쓴다. 두 번째 커널은 tmp를 읽고, ReLU를 적용하고, 최종 결과를 c에 쓴다.
이러한 메모리 작업을 세어보면 전역 메모리를 통한 다섯 번의 배열 크기 왕복이 있다:
a 읽기
b 읽기
tmp 쓰기
tmp 읽기
c 쓰기
그러나 실제 계산은 a와 b의 값으로 c를 생성하는 데만 필요하다. 덧셈과 ReLU가 함께 일어날 수 있다면, 세 번의 배열 크기 메모리 작업만 필요할 것이다: a 읽기, b 읽기, c 쓰기. tmp의 추가 쓰기와 읽기는 덧셈과 ReLU가 별도의 커널로 실행되기 때문에 발생한다.
PyTorch는 왜 이렇게 하는가? 기본 eager 모드에서 PyTorch는 Python이 도달하는 대로 각 연산을 실행한다. 덧셈을 실행할 때 PyTorch는 앞을 내다보며 뒤따르는 ReLU와 함께 최적화하지 않는다. 덧셈을 자체 연산으로 실행하고 결과를 GPU 메모리에 쓴다. Python이 .relu()에 도달하면 PyTorch는 또 다른 커널을 실행하여 중간 결과를 읽고 최종 출력을 생성한다.
나중에 이 두 연산이 단일 커널로 결합되어 전역 메모리를 통한 중간 쓰기와 읽기를 제거할 때 어떤 일이 일어나는지 보게 될 것이다.
커널 세기
PyTorch의 프로파일러를 사용하여 우리 코드가 실행하는 GPU 커널을 직접 볼 수 있다:
import
torch
from
torch.profiler
import
ProfilerActivity, profile
def
add_relu
(
a, b
):
return
(a + b).relu()
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
# Warm up once so one-time initialization stays out of the trace.
add_relu(a, b)
torch.cuda.synchronize()
with
profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA])
as
prof:
add_relu(a, b)
torch.cuda.synchronize()
# wait for the GPU before ending the trace
(prof.key_averages().table(sort_by=
"cuda_time_total"
, row_limit=
12
))
이것이 출력하는 것을 보기 전에 먼저 위 코드를 이해하자. add_relu()에 대한 첫 번째 호출과 그 직후의 torch.cuda.synchronize()는 단순히 워밍업이므로 지금은 무시해도 된다. 이들은 프로파일링을 시작하기 전에 일회성 초기화 작업이 완료되도록 한다.
프로파일러 내부에서 다시 torch.cuda.synchronize()를 호출한다. GPU 작업은 비동기적이므로, CPU는 GPU에서 작업이 계속 실행되는 동안 다음 문장으로 진행한다. 따라서 이 호출이 없으면 프로파일링 영역이 우리가 관찰하려는 커널이 완료되기 전에 끝날 수 있다. 이 호출은 대기 중인 모든 GPU 작업이 완료될 때까지 CPU를 차단한다. 비동기 실행은 우리가 직접 커널을 작성한 후에 더 자세히 살펴볼 것이다.
다음은 Crusoe의 NVIDIA L40S GPU(이 시리즈에서 사용하는 GPU)에서 출력된 표이며, 공간상 CPU 타이밍 열은 생략했다:
Name Self CUDA # of Calls
-------------------------------------------------------- ---------- ----------
aten::add 3.680us 1
void at::native::vectorized_elementwise_kernel<4, at... 3.680us 1
aten::relu 0.000us 1
aten::clamp_min 2.976us 1
void at::native::vectorized_elementwise_kernel<4, at... 2.976us 1
cudaLaunchKernel 0.000us 2
cudaDeviceSynchronize 0.000us 2
-------------------------------------------------------- ---------- ----------
Self CUDA time total: 6.656us
언뜻 보면 프로파일러 출력이 다소 복잡해 보일 수 있는데, 이는 상위 수준 PyTorch 연산과 그것이 트리거하는 실제 GPU 커널을 혼합하기 때문이다. 이를 분석하면:
PyTorch 연산: aten::add, aten::relu, aten::clamp_min과 같은 행은 프레임워크 수준 호출이다.
하드웨어 커널: vectorized_elementwise_kernel로 시작하는 두 행은 GPU에서 실행되는 실제 루틴이다.
첫 번째 GPU 커널은 덧셈을 3.680us에 처리한다. ReLU의 경우 PyTorch는 내부적으로 clamp_min을 거쳐 두 번째 커널을 실행하며 2.976us가 걸린다. aten::relu가 자체 시간 0.000us를 기록하는 것을 알 수 있는데, 프로파일러는 GPU 시간을 실제로 커널을 실행하는 가장 안쪽 리프 연산(clamp_min)에 할당하고 aten::relu와 같은 부모 래퍼에는 할당하지 않는다.
개수를 확인하는 더 간단한 방법도 있다. cudaLaunchKernel 행은 2회 호출을 보여주며, 이는 PyTorch가 CUDA에 정확히 두 개의 커널 실행을 요청했음을 의미한다. 이는 다이어그램에서 본 것과 일치한다: 덧셈을 위한 커널 하나와 ReLU를 위한 커널 하나.
두 개의 GPU 행은 덧셈 커널과 ReLU 뒤의 clamp 커널이다. PyTorch 2.6이 설치된 L40S에서 측정됨.
정확한 커널 이름은 중요하지 않으며 PyTorch 버전 간에 바뀔 수 있다. 중요한 점은: 우리의 단일 Python 표현식이 두 번의 GPU 커널 실행을 초래했다는 것이다.
이제 PyTorch가 GPU에 무엇을 실행하도록 요청하는지 알았으니, 다음 단계는 GPU가 실제로 그 작업을 어떻게 실행하는지 이해하는 것이다. 이를 위해 GPU가 어떻게 구성되는지 살펴봐야 한다.
GPU가 왜 그렇게 설계되었는가
천 개의 숫자로 이루어진 배열의 모든 요소를 두 배로 만들고 싶다고 가정하자. 요소들은 독립적이다: 예를 들어 배열의 5번째 요소를 두 배로 만드는 데 4번째 요소는 전혀 필요하지 않다. 따라서 한 프로세서가 요소들을 차례로 방문하는 대신, 각 요소를 자체 작업자에게 맡기고 한 단계로 끝낼 수 있다. 이것이 데이터 병렬성이다: 하나의 연산이 많은 독립적인 데이터 요소에 동시에 수행되는 것. 조각들이 전혀 조정될 필요가 없을 때, 그 작업은 종종 embarrassingly parallel이라고 불린다. 덧셈과 ReLU 같은 요소별 연산은 정확히 이런 형태를 가지며, 행렬 곱셈, 컨볼루션, 어텐션 같은 더 무거운 워크로드가 수천 개의 더 작은 병렬 계산으로 분해될 때도 이와 동일한 패러다임을 따른다. 이것이 딥러닝이 GPU에 잘 매핑되는 이유다. GPU는 동일한 연산이 서로 다른 데이터 집합에 수행되어야 하는 이러한 병렬 처리에 적합하다.
그러나 모든 것이 그런 것은 아니다. 배열을 단일 값으로 합산하는 것은 부분 결과를 결합해야 하므로 작업자 간에 의존성이 생긴다. 이런 연산을 리덕션이라고 하며, 다른 전략이 필요하고, 시리즈 후반에 살펴볼 것이다.
그 설계 목표는 GPU 하드웨어에 나타난다. CPU는 소수의 명령 스트림에서 낮은 지연 시간을 위해 만들어졌다. 몇 개의 큰 코어, 정교한 제어 로직, 큰 캐시, 분기 예측, 비순차 실행 등이 모두 하나의 스레드를 빠르게 실행하는 데 목표를 둔다. GPU는 처리량, 즉 단위 시간당 많은 작업을 완료하기 위해 만들어졌으며, 대신 수천 개의 단순한 산술 유닛을 병렬로 실행하는 데 실리콘을 사용한다.
CPU는 하나의 스레드를 빠르게 만드는 데 면적을 쓰고, GPU는 수천 개의 스레드를 동시에 실행하는 데 쓴다.
이제 GPU가 왜 대규모 병렬성을 중심으로 설계되었는지에 대한 직관을 얻었다. 다음 질문은 어떻게 GPU에 작업을 줄 것인가이며, 이에 답하기 위해 첫 번째 GPU 커널을 작성해보자.
첫 번째 CUDA 프로그램
CUDA는 NVIDIA가 자사 GPU를 프로그래밍하기 위한 플랫폼이다. C++에 몇 가지 키워드와 특별한 함수 호출 구문을 추가하며, 컴파일러 nvcc와 함께 제공된다.
[원문 후반 발췌]
PU. 둘 다 표준 C++ 구문의 일부가 아니다.
__global__은 GPU 커널을 표시한다: __global__ 키워드는 hello_kernel을 커널로 표시하며, 이는 호스트가 실행하고 디바이스가 실행함을 의미한다. CUDA 커널은 호스트에 값을 직접 반환하지 않는다. 결과를 생성하는 커널은 일반적으로 메모리에 쓰며, 호스트나 다른 커널이 나중에 접근할 수 있다.
<<<1, 8>>>은 커널을 실행한다: 실행 구문 <<<1, 8>>>은 호스트가 GPU에 커널 실행을 요청하는 방법이다. 두 숫자는 생성할 스레드 그룹 수와 각 그룹에 포함될 스레드 수를 지정한다. CUDA는 스레드 그룹을 블록이라고 부르므로, 여기서는 8개의 스레드를 포함하는 하나의 블록을 실행한다.
커널에는 루프가 없지만, 그 본문은 8개의 스레드 각각에 대해 한 번씩 실행된다. 8개의 항목을 차례로 처리하는 루프를 작성하는 대신, 8개의 스레드를 실행하고 GPU가 병렬로 실행하도록 한다. 블록이 하나 이상 필요할 때 블록에 대해 훨씬 더 많은 이야기를 할 것이다; 지금은 단일 블록으로 충분하다.
threadIdx.x는 블록 내에서 스레드를 식별한다: 내장 변수 threadIdx.x는 각 스레드에게 블록 내에서의 위치를 알려주며, 여기서는 0부터 7까지다. 모든 스레드는 동일한 커널 코드를 실행하지만, threadIdx.x는 각각 다른 값을 가진다. 그 인덱스는 종종 스레드가
[원문 후반 발췌]
우리가 사용하는 L40S GPU는 142개의 SM을 가지고 있다.
비교를 위해, NVIDIA A100 Tensor Core GPU는 108개의 SM을, NVIDIA H100 Tensor Core GPU는 SXM 버전에서 132개, PCIe 버전에서 114개의 SM을 가진다. 각 SM은 실행 유닛과 스레드에 필요한 저장소(레지스터, 작은 온칩 메모리, 캐시)를 포함한다. 메모리 계층에 도달하면 각각을 살펴볼 것이다; 지금은 SM이 워프가 실행되는 곳이라는 것만 알면 충분하다.
하드웨어는 스레드를 32개 단위의 워프로 그룹화하고 SM에서 워프를 스케줄링한다. 이 중 어느 것도 프로그래머가 선택하지 않는다.
NVIDIA GPU는 32개 스레드의 워프를 사용하므로 CUDA 블록 크기는 일반적으로 32의 배수로 선택된다. 128, 256, 512 스레드당 블록과 같은 선택이 더 큰 커널을 작성할 때 왜 그렇게 자주 나타나는지 알게 될 것이다.
배열 제곱하기: 두 개의 메모리
프린트만 하는 커널은 별로 유용하지 않다. 실제 작업을 하려면 커널은 입력을 읽고 출력을 써야 하며, 이는 GPU 프로그램의 가장 중요한 구조적 사실로 이어진다: 호스트와 디바이스는 별도의 메모리를 가진다. CPU는 시스템의 주 메모리를 사용하고, GPU는 자체 전역 메모리를 가진다.
어느 프로세서도 다른 쪽의 메모리를 직접 읽을 수 없으므로, 데이터는 PCIe 버스를 통해 양방향으로 복사되어야 한다.
호스트와 디바이스 메모리는 분리되어 있다. 커널이 접촉하는 모든 바이트는
[원문 후반 발췌]
디바이스 메모리 해제
다음은 8개의 숫자를 제곱하는 프로그램에서의 워크플로우이며, 숫자당 하나의 스레드를 사용한다:
#
include
<cstdio>
__global__
void
square_kernel
(
const
float
* in,
float
* out,
int
n)
{
int
i = threadIdx.x;
// one block: thread index = element index
if
(i < n) {
// never touch memory past the array's end
out[i] = in[i] * in[i];
}
}
int
main
()
{
const
int
n =
8
;
const
size_t
bytes = n *
sizeof
(
float
);
float
h_in[n], h_out[n];
// host arrays
for
(
int
i =
0
; i < n; ++i) h_in[i] = (
float
)(i +
1
);
float
*d_in =
nullptr
, *d_out =
nullptr
;
// device pointers
cudaMalloc
(&d_in, bytes);
// 1. allocate on device
cudaMalloc
(&d_out, bytes);
cudaMemcpy
(d_in, h_in, bytes, cudaMemcpyHostToDevice);
// 2. copy in
square_kernel<<<
1
, n>>>(d_in, d_out, n);
// 3. launch
cudaMemcpy
(h_out, d_out, bytes, cudaMemcpyDeviceToHost);
// 4. copy out
cudaFree
(d_in);
// 5. free
cudaFree
(d_out);
for
(int
i = 0; i < n; ++i)
printf
(
"%4.0f squared is %4.0f\n"
, h_in[i], h_out[i]);
return
0
;
}
CUDA 프로그램의 다섯 단계. 3단계만 GPU에서 실행되며, PyTorch는 텐서 뒤에서 나머지를 수행한다.
여기에는 두 가지 새로운 CUDA 함수가 있으며, 둘 다 CUDA 런타임 API에서 온다.
cudaMalloc()은 디바이스의 전역 메모리에 메모리를 할당하고 그에 대한 포인터를 제공한다.
cudaMemcpy()는
[원문 후반 발췌]
PU가 작업을 완료하기 전에. 예를 들어, NVIDIA L40S GPU에서 벡터 덧셈은 실행 후 25마이크로초 만에 반환되었지만 완료되는 데 993마이크로초가 걸렸다. 신뢰할 수 있는 벤치마크는 CUDA 이벤트로 GPU 자체 타임라인에서 시간을 측정하고, 실행 사이에 L2 캐시를 제거하여 숫자가 캐시가 아닌 메모리를 반영하도록 한다.
최신 기사
2026년 10월 9일
Crusoe의 추론 엔진은 얼마나 친환경적인가?
2026년 10월 9일
텍사스주에서 커뮤니티 구축
2026년 10월 7일
트리톤을 이용한 GPU 프로그래밍, 1부
멋진 것을 만들 준비가 되셨나요?
문의하기
본문이 길어 53662자 중 15534자만 처리했습니다.
브리프용 요약 초안
GPU 프로그래밍 기초를 다루는 기술 교육 자료로, PyTorch의 eager 모드가 한 줄 연산을 두 개의 커널로 실행하며 다섯 번의 전역 메모리 왕복을 유발한다는 점을 설명한다. 메모리 산업과의 직접적인 연결 근거는 부족하며, 특정 메모리 제품 수요나 공급에 대한 정보는 포함되지 않는다.
원문 텍스트
원문 열기 ↗Engineering
Cloud
October 7, 2026
GPU programming with Triton, part 1
One line of PyTorch becomes two GPU kernel launches and five trips through global memory. Part 1 traces what the GPU actually executes, from kernels and warps to the memory hierarchy, then writes the same vector addition twice: once in CUDA C++, once in Triton.
Suman Debnath
Director, Developer Relations
Daniel Shen
Machine Learning Engineer
Janaki Ram Goteti
Senior Staff Software Engineer
Connor Guerrero
Senior Developer Relations Manager
Lei Ma
Technical Content Marketing Manager
October 7, 2026
Table of contents
This is some text inside of a div block.
Share:
Most of us start using GPUs through a framework. We move a model to the device, training runs faster, and for a long time that is all we need to know. The difficulty begins when something is slower than it should be and we have no way to reason about why, because the GPU has been a black box the whole time.
This series is our attempt to open that box. In this series, we will learn how a GPU executes code, write kernels for it in
Triton
, and measure everything we write. Triton is a domain-specific language (DSL) for GPU kernels that is embedded in Python, and it comes with both a compiler and a runtime. We will learn more about what that means towards the end of this part. We chose Triton because it lets us write fast GPU kernels without requiring us to start with NVIDIA CUDA® C++. However, writing efficient GPU kernels starts with understanding the fundamentals: how GPUs execute work, organize threads, and move data through memory.
This first part therefore focuses primarily on GPU fundamentals, building the foundation we need before we start writing kernels in Triton. We will start with a single line of PyTorch code, creating a simple tensor on the GPU and adding it to another, and ask what the GPU actually executes when that line runs.
From there we step into CUDA C++, where every detail of GPU programming is explicit: how threads are organized, how work is distributed, and how data moves between the CPU, the GPU, and the levels of memory in between. Seeing those mechanics directly is the best way to appreciate what Triton takes care of for us. We finish by writing the same kernel in Triton and comparing the two side by side.
One line of PyTorch
Let’s start with a simple PyTorch example and follow what happens when it reaches the GPU:
import
torch
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
c = (a + b).relu()
The first two lines create tensors containing one million random numbers each and place them in GPU memory. The third line looks simple: add the two tensors, then apply a ReLU.
From Python’s point of view, this is a single expression. But what does the GPU actually execute?
It executes two separate programs, called
kernels
.
A kernel is a GPU function, one that the GPU typically executes across different elements in parallel. The CPU asks the GPU to run a kernel, and that request is called a
kernel launch
. Here, the word
kernel
has nothing to do with an operating-system kernel or the small matrix used in a convolution; in GPU programming it simply means a function the GPU executes.
When PyTorch evaluates
a + b
, it launches one kernel that reads
a
and
b
, adds their elements, and writes the result. It then evaluates
.relu()
by launching a second kernel, which reads that intermediate result, replaces negative values with zero, and writes the final output.
So, one line of PyTorch becomes two GPU kernel launches.
One line, two kernels: the add writes tmp to global memory and the ReLU() reads it back, five array-sized trips in all.
The first kernel reads
a
and
b
, adds them, and writes the result back to GPU memory as an intermediate tensor,
tmp
. The second kernel then reads
tmp
, applies ReLU, and writes the final result to
c
.
If we count those memory operations, we get
five array-sized trips through global memory
:
Read
a
Read
b
Write
tmp
Read
tmp
Write
c
The actual computation, however, only needs the values from
a
and
b
to produce
c
. If the addition and ReLU could happen together, we would need only three array-sized memory operations: read
a
, read
b
, and write
c
. The extra write and read of
tmp
happen because the addition and ReLU are executed as separate kernels.
Why does PyTorch do this? In its default
eager mode
, PyTorch executes each operation as Python reaches it. When it launches the addition, PyTorch does not look ahead and optimize it together with the ReLU that follows. It executes the addition as its own operation and writes the result to GPU memory. When Python reaches
.relu()
, PyTorch launches another kernel, which reads that intermediate result and produces the final output.
Later, we will see what happens when these two operations are brought together into a single kernel, eliminating the intermediate write and read through global memory.
Counting the kernels
We can see this directly using
PyTorch's profiler
, which records the GPU kernels executed by our code:
import
torch
from
torch.profiler
import
ProfilerActivity, profile
def
add_relu
(
a, b
):
return
(a + b).relu()
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
# Warm up once so one-time initialization stays out of the trace.
add_relu(a, b)
torch.cuda.synchronize()
with
profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA])
as
prof:
add_relu(a, b)
torch.cuda.synchronize()
# wait for the GPU before ending the trace
print
(prof.key_averages().table(sort_by=
"cuda_time_total"
, row_limit=
12
))
Before we see what this prints, let’s first understand the code above. The first call to
add_relu()
and the
torch.cuda.synchronize()
immediately after it are simply a
warm-up
, so we can ignore them for now. They make sure one-time initialization work is completed before we start profiling.
Inside the profiler, we call
torch.cuda.synchronize()
again. GPU operations are asynchronous, meaning the CPU continues on to its next statement while work is still running on the GPU, so without this call the profiling region could end before the kernels we want to observe have completed. The call blocks the CPU until every queued GPU operation has finished. We will explore asynchronous execution in more detail once we write a kernel of our own.
Here is the table this printed on our NVIDIA
L40S
GPU on Crusoe (the GPU we are using for the purpose of this series), with the CPU timing columns omitted for space:
Name Self CUDA # of Calls
-------------------------------------------------------- ---------- ----------
aten::add 3.680us 1
void at::native::vectorized_elementwise_kernel<4, at... 3.680us 1
aten::relu 0.000us 1
aten::clamp_min 2.976us 1
void at::native::vectorized_elementwise_kernel<4, at... 2.976us 1
cudaLaunchKernel 0.000us 2
cudaDeviceSynchronize 0.000us 2
-------------------------------------------------------- ---------- ----------
Self CUDA time total: 6.656us
At first glance, the profiler output can look a bit busy because it mixes high-level PyTorch operations with the actual GPU kernels they trigger. To break it down:
PyTorch operations
: Rows like
aten::add
,
aten::relu
, and
aten::clamp_min
are framework-level calls.
Hardware kernels
: The two rows starting with
vectorized_elementwise_kernel
are the actual routines executed on the GPU.
The first GPU kernel handles the addition in
3.680 us
. For ReLU, PyTorch routes through
clamp_min
under the hood, running a second kernel that takes
2.976 us
. You might notice that
aten::relu
logs
0.000 us
of self-time; the profiler assigns GPU time to the innermost leaf operation (
clamp_min
) that actually fires the kernel, rather than parent wrappers like
aten::relu
.
There is an even simpler way to confirm the count. The
cudaLaunchKernel
row shows 2 calls, meaning PyTorch asked CUDA to launch exactly two kernels. That matches what we saw in the diagram: one kernel for the addition and another for ReLU.
The two GPU rows are the add kernel and the clamp kernel behind ReLU. Measured on an L40S with PyTorch 2.6.
The exact kernel names are not important, and they can change between PyTorch versions. The important takeaway is: our single Python expression resulted in
two GPU kernel launches
.
Now that we can see what PyTorch is asking the GPU to execute, the next step is to understand how the GPU actually executes that work. For that, we need to look at how a GPU is built.
Why a GPU is built the way it is
Suppose we want to double every element of an array of one thousand numbers. The elements are independent: e.g., doubling the 5th element in the array needs nothing from the 4th element. So instead of one processor visiting the elements in turn, we can give each element to its own worker and finish in a single step. This is
data parallelism
: one operation carried out simultaneously on many independent data elements. When the pieces need no coordination at all, the work is often called
embarrassingly parallel
. Elementwise operations such as addition and ReLU have exactly this shape, and when heavier workloads like matrix multiplication, convolution, and attention are broken down into thousands of smaller parallel computations, they follow this exact same paradigm, which is why deep learning maps so well onto a GPU. GPUs are well suited for such parallel processing, where the same operation needs to take place on a different set of data.
But not everything does. Summing an array to a single value forces partial results to be combined, which introduces dependencies between workers. Operations like this are called
reductions
, they need a different strategy, and we will see them later in the series.
That design goal shows up in the GPU hardware. A CPU is built for low latency on a small number of instruction streams. It has a few large cores, sophisticated control logic, big caches, branch prediction and out-of-order execution all aimed at making one thread run fast. A GPU is built for throughput, completing a large amount of work per unit time, and spends its silicon on thousands of simple arithmetic units running in parallel instead.
A CPU spends its area on making one thread fast; a GPU spends it on running thousands of threads at once.
We now have an intuition for why a GPU is designed around massive parallelism. The next question is how we give it work to do, and to answer that let’s write our first GPU kernel.
A first CUDA program
CUDA
is NVIDIA's platform for programming its GPUs. It extends C++ with a few keywords and a special function-call syntax, and it comes with a compiler,
nvcc
, that produces a program containing both CPU code and GPU code. In CUDA's vocabulary, the CPU is the
host
and the GPU is the
device
, and we will use those two words from here on.
The worker we have been describing has a proper name in CUDA: a
thread
. A thread runs the kernel's instructions on its own piece of the data. Here is a small kernel that shows threads in action. It computes nothing; each thread simply announces itself with a print statement:
#
include
<cstdio>
__global__
void
hello_kernel
()
{
// threadIdx.x is this thread's position inside its block, 0 to 7 here.
printf
(
"hello from thread %d\n"
, threadIdx.x);
}
int
main
()
{
hello_kernel<<<
1
,
8
>>>();
// one block of eight threads
cudaDeviceSynchronize
();
// wait for the GPU before main() returns
return
0
;
}
If you know C++, two parts of this program probably look unusual:
__global__
and the
<<<...>>>
syntax. They are CUDA extensions to C++, understood by NVIDIA's
nvcc
compiler.
__global__
tells the compiler that a function is a GPU kernel, while
<<<...>>>
tells the CUDA runtime how that kernel should be launched on the GPU. Neither is part of standard C++ syntax.
__global__
marks a GPU kernel: The keyword
__global__
marks
hello_kernel
as a kernel, meaning the host launches it and the device executes it. A CUDA kernel does not return a value directly to the host. Kernels that produce results typically write them to memory, where the host or another kernel can access them later.
<<<1, 8>>>
launches the kernel: The launch syntax
<<<1, 8>>>
is how the host asks the GPU to run the kernel. The two numbers specify how many groups of threads to create and how many threads each group should contain. CUDA calls a group of threads a block, so here we are launching one block containing eight threads.
There is no loop in the kernel, yet its body runs once for each of the eight threads. Instead of writing a loop that processes eight items one after another, we launch eight threads and let the GPU execute them in parallel. We will have much more to say about blocks once we need more than one of them; for now, a single block is enough.
threadIdx.x
identifies a thread within the block: The built-in variable
threadIdx.x
tells each thread its position inside the block, from 0 to 7 here. Every thread runs the same kernel code, but
threadIdx.x
has a different value for each one. That index is often part of how a thread determines which piece of data it should work on.
We compile the program with
nvcc
and run the result like any other executable:
nvcc -O2 -arch=native -o 01_hello_kernel 01_hello_kernel.cu
./01_hello_kernel
On our machine, this produced:
hello from thread 0
hello from thread 1
hello from thread 2
hello from thread 3
hello from thread 4
hello from thread 5
hello from thread 6
hello from thread 7
The lines happened to appear in order on our machine, but CUDA does not guarantee that ordering. Threads execute concurrently, and a correct GPU program should never depend on the order in which individual threads happen to run or produce output.
We launched eight threads here, but the GPU does not treat them as eight completely independent pieces of work. To understand what happens next, we need to introduce one of the most important concepts in GPU execution: the
warp
.
How the hardware runs threads
We have been describing threads as if each one were an independent worker. The hardware is a little more structured than that. On NVIDIA GPUs, threads are organized into warps of 32. The 32 threads of a warp execute each instruction together, and each thread applies that instruction to its own data. NVIDIA calls this execution model
SIMT
(Single Instruction Multiple Threads).
Our block contains only eight threads, so it still occupies one warp. Of the warp's 32 lanes, only eight are active for our block and the remaining 24 are inactive. This is our first hint that the number of threads we choose for a block can affect how efficiently the hardware is used. When a block contains more than 32 threads, its threads are divided into multiple warps; a block of 256 threads, for example, is eight warps, and those warps are then scheduled for execution by the GPU.
The part of the GPU that schedules and executes warps is called an SM (Streaming Multiprocessor). A GPU contains many SMs, and the exact number depends on the GPU. The NVIDIA L40S GPU we are using has 142 SMs.
For comparison, the NVIDIA A100 Tensor Core GPU has 108 SMs, while the NVIDIA H100 Tensor Core GPU has 132 SMs in the SXM version and 114 SMs in the PCIe version. Each SM contains execution units along with the storage its threads need: registers, a small on-chip memory, and caches. We will look at each of these when we reach the memory hierarchy; for now it is enough to know that an SM is where warps run.
The hardware groups threads into warps of 32 and schedules warps on SMs. None of this is chosen by the programmer.
Because NVIDIA GPUs use warps of 32 threads, CUDA block sizes are commonly chosen as multiples of 32. We will see why choices such as 128, 256, or 512 threads per block appear so often as we write larger kernels.
Squaring an array: two memories
A kernel that only prints is not much use. To do real work, a kernel has to read inputs and write outputs, and that brings us to the most important structural fact about a GPU program: the host and the device have separate memories. The CPU uses the system's main memory, while the GPU has its own
global memory.
Neither processor can read the other's memory directly, so data has to be copied across the PCIe bus in both directions.
Host and device memories are separate. Every byte a kernel touches was first copied across the PCIe bus.
For our first example, we will use the basic explicit-memory workflow:
Allocate memory on the device
Copy the input from host to device
Launch the kernel
Copy the result from device back to host
Free the device memory
Here is that workflow in a program that squares eight numbers, using one thread per number:
#
include
<cstdio>
__global__
void
square_kernel
(
const
float
* in,
float
* out,
int
n)
{
int
i = threadIdx.x;
// one block: thread index = element index
if
(i < n) {
// never touch memory past the array's end
out[i] = in[i] * in[i];
}
}
int
main
()
{
const
int
n =
8
;
const
size_t
bytes = n *
sizeof
(
float
);
float
h_in[n], h_out[n];
// host arrays
for
(
int
i =
0
; i < n; ++i) h_in[i] = (
float
)(i +
1
);
float
*d_in =
nullptr
, *d_out =
nullptr
;
// device pointers
cudaMalloc
(&d_in, bytes);
// 1. allocate on device
cudaMalloc
(&d_out, bytes);
cudaMemcpy
(d_in, h_in, bytes, cudaMemcpyHostToDevice);
// 2. copy in
square_kernel<<<
1
, n>>>(d_in, d_out, n);
// 3. launch
cudaMemcpy
(h_out, d_out, bytes, cudaMemcpyDeviceToHost);
// 4. copy out
cudaFree
(d_in);
// 5. free
cudaFree
(d_out);
for
(
int
i =
0
; i < n; ++i)
printf
(
"%4.0f squared is %4.0f\n"
, h_in[i], h_out[i]);
return
0
;
}
The five steps of a CUDA program. Only step 3 runs on the GPU; PyTorch performs the others behind its tensors.
There are two new CUDA functions here, both from the
CUDA runtime API
.
cudaMalloc()
allocates memory in the device's global memory and gives us a pointer to it.
cudaMemcpy()
copies bytes between host and device memory. The final argument tells CUDA which direction the data should move:
cudaMemcpyHostToDevice
for the input and
cudaMemcpyDeviceToHost
for the result.
The naming convention
h_
for host data and
d_
for device data is not required by CUDA, but it is a useful convention because both are represented as pointers in C++. Keeping the distinction visible helps us remember where each piece of data lives.
The kernel receives
d_in
and
d_out
as arguments, and each of the eight threads squares the single element that its
threadIdx.x
points at. This is the data parallelism from the previous section, now written as a real kernel.
The
if (i < n)
check may look unnecessary because we launched exactly eight threads for eight elements. We include it because, once we start launching blocks with a fixed number of threads, the total number of threads will not always match the number of elements exactly. The check prevents threads whose indices fall outside the array from accessing memory they do not own. Running the program gives:
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
5 squared is 25
6 squared is 36
7 squared is 49
8 squared is 64
Frameworks such as PyTorch manage much of this memory handling for us. When we create a tensor with
device="cuda"
, PyTorch allocates storage for that tensor in GPU memory. Operations on CUDA tensors then launch kernels that operate on that device-resident data. When we call
.cpu()
on a CUDA tensor, PyTorch transfers its contents back to CPU memory.
CUDA gives us direct control over these steps. That makes the code more verbose, but it also lets us see something that frameworks usually hide: where our data lives, when it moves, and which processor is operating on it.
Launches are asynchronous
There is a detail in the squaring program that is easy to miss. When the host launches a kernel, it does not normally wait for that kernel to finish. The launch queues the work for the GPU and returns control to the host, allowing the CPU to continue executing the next statement while the GPU works independently.
The next operation was a
cudaMemcpy()
from device to host. That copy waits for the required earlier GPU work to complete before returning the result to the host. By the time we print
h_out
, the kernel has finished and the results have been copied back.
In our earlier hello program, there was no device-to-host copy to create that waiting point. That is why we explicitly called
cudaDeviceSynchronize()
, which blocks the host until previously issued GPU work has completed. Without it,
main()
could reach the end while GPU work was still outstanding.
Launch, then wait: the CPU continues after the launch, threads finish at different times, and cudaDeviceSynchronize() holds the CPU until the last one is done.
PyTorch launches are asynchronous in exactly the same way, and this has a practical consequence for anyone who tries to time GPU code. If we wrap an operation in
time.time()
, the elapsed time is the cost of issuing the launch on the CPU side and says nothing about how long the GPU spent on the kernel. To measure the kernel, the CPU has to wait for the GPU first:
import
time
import
torch
a = torch.randn(
50_000_000
, device=
"cuda"
)
b = torch.randn(
50_000_000
, device=
"cuda"
)
a + b
# warm up
torch.cuda.synchronize()
t0 = time.time()
c = a + b
t_launch = time.time() - t0
# how long the CPU spent issuing the work
torch.cuda.synchronize()
# block until the GPU has finished all queued work
t_done = time.time() - t0
# how long the GPU actually took, plus the launch
print
(
f"launch returned after
{t_launch *
1e6
:
7.0
f}
us"
)
print
(
f"work finished after
{t_done *
1e6
:
7.0
f}
us"
)
On our L40S we got this:
launch returned after 25 us
work finished after 993 us
This difference is the key idea:
launching GPU work and completing GPU work are two different events
, and only the second one tells us how long a kernel took.
Operations that require data on the CPU create synchronization points as well. For example, calling
.cpu()
on a CUDA tensor must make the requested tensor data available in CPU memory, and
.item()
must retrieve a scalar value before Python can use it. This is why synchronization matters when measuring GPU programs. Without it, we may accidentally measure how quickly the CPU can submit work rather than how quickly the GPU can finish it.
From one block to many blocks
Our squaring program used a single block, which was fine for eight elements. It cannot work for a million, because a block is limited to 1,024 threads on current NVIDIA GPUs. The limit exists because the threads of a block are guaranteed to run on the same SM, where they can share that SM's fast memory and coordinate with one another, and an SM only has the resources to track so many threads at once. To process a large array we launch many blocks, and the whole collection of blocks created by one launch is called the
grid
.
So the hierarchy has three levels. A grid contains blocks, a block contains threads, and, as we saw, the hardware executes those threads in warps of 32. There are now two related hierarchies to keep in mind.
Software, how we organize the work:
grid → blocks → threads
Hardware, how the GPU executes that work:
threads → warps → SMs
Software organizes work as grid, block, thread. Hardware executes it as thread, warp, SM. The block is where the two meet.
The grid is a purely logical description of how much work exists. Nothing in the grid specifies which SM will end up running which block, and because of that the same kernel can be launched unchanged on GPUs of very different sizes.
A launch is a grid of blocks, a block is a set of warps, and a warp is 32 threads that move in lockstep.
Finding the right element
Introducing more than one block creates a problem. The variable
threadIdx.x
restarts at zero in every block, so if we launched three blocks of four threads and used
threadIdx.x
as the element index, all three blocks would work on elements 0 to 3 and elements 4 to 11 would never be touched. Each thread needs to know not only its position in its block but also which block it is in.
CUDA provides that through two more built-in variables.
blockIdx.x
is the block's position in the grid, and
blockDim.x
is the number of threads per block, the second number in the launch syntax. We can combine these with
threadIdx.x
to calculate a thread's global index across the entire grid:
int
i = blockIdx.x * blockDim.x + threadIdx.x;
threadIdx.x restarts at zero in every block; adding the block's offset makes the index unique across the grid.
The formula skips over all the elements handled by earlier blocks, then adds the thread's position within its own block. It is similar to identifying a seat in a theater with numbered rows: the row number tells us how many full rows to skip, and the seat's position within that row tells us how far to move into the current row.
How many blocks, and how big
Two decisions remain: how many threads per block, and therefore how many blocks. Suppose we settle on 256 threads per block and have one million elements to process. One million divided by 256 is 3,906.25, and we cannot launch a quarter of a block. Rounding down would leave 64 elements unprocessed, so we round up to 3,907 blocks, which is written in integer arithmetic as:
int
blocks = (n + threads_per_block -
1
) / threads_per_block;
Rounding up means the grid now contains 3,907 × 256 = 1,000,192 threads, which is 192 more than we have elements. Those extra threads live in the last block, and their computed index
i
is 1,000,000 or above. Without a guard they would access memory beyond the end of our arrays and corrupt whatever happens to sit there. This is why the
if (i < n)
check from the squaring program is not optional: an input length seldom divides evenly by the block size, so the final block almost always extends past the end of the array, and every kernel must check before it touches memory.
Quantity
Value
Derivation
Elements
1,000,000
the input
Threads per block
256
our choice
Blocks in the grid
3,907
ceiling of 1,000,000 / 256
Warps per block
8
256 / 32
Warps in the grid
31,256
3,907 × 8
Threads in the last block with no element
192
3,907 × 256 - 1,000,000
Why 256 rather than some other number? The warp is the unit the hardware actually schedules, so a block should be a multiple of 32; a block of 250 threads would still occupy 8 warps, with 6 lanes of the last warp doing nothing. Block size also affects how many blocks and warps can be resident on an SM at the same time, along with other factors such as register and shared-memory usage. There is no single best block size for every kernel, but 128 to 256 threads per block is a good starting range, and 256 is a sensible default that we will use throughout. We will see why these choices matter once we look more closely at how the GPU keeps enough work in flight.
The complete kernel
Putting the pieces together gives the general form of an elementwise CUDA kernel. This is vector addition, the very operation PyTorch launched for us when we wrote
a + b
at the start of this part, and the kernel we will write again in Triton at the end of it:
__global__
void
add_kernel
(
const
float
* x,
const
float
* y,
float
* out,
int
n)
{
int
i = blockIdx.x * blockDim.x + threadIdx.x;
// global element index
if
(i < n) {
// last block may overrun
out[i] = x[i] + y[i];
}
}
// On the host:
int
block =
256
;
int
grid = (n + block -
1
) / block;
// ceiling division
add_kernel<<<grid, block>>>(d_x, d_y, d_out, n);
You can find the full code in the
Crusoe Developer Hub
. The example checks the result against a CPU loop, then times the kernel with
CUDA events
, which record timestamps on the GPU's own timeline and therefore measure kernel execution rather than the CPU issuing the launch. Before each timed launch it writes a 256 MB scratch buffer, for a reason we explain below, then reports the median of fifty runs and converts the time to bandwidth by counting three arrays of four-byte floats. On our L40S:
Running on NVIDIA L40S
elements blocks median ms GB/s check
1000 4 0.0041 2.9 OK
65536 256 0.0061 128.7 OK
1048576 4096 0.0205 614.4 OK
16777216 65536 0.3123 644.7 OK
Every size passes the correctness check, including the first one, where 1,000 elements do not divide evenly into blocks of 256 and the guard is doing its job. The timing column rewards a closer look. For 1,000 elements the GPU has almost no work to do, so the 4 microseconds we measure is essentially the fixed cost of a launch, and the bandwidth figure is meaningless. As the arrays grow the kernel becomes worth launching, and from one million elements upward it moves data at 614 to 645 GB/s, roughly 75 percent of the 864 GB/s the L40S's global memory is rated for.
Now for that 256 MB scratch buffer. When we first wrote this program without it, the one-million-element row reported 2,048 GB/s, more than twice what global memory can deliver, and no kernel can read faster than the memory it reads from. The data was coming from somewhere closer. Three arrays of one million floats occupy 12 MB, and the L40S has a 96 MiB cache, called the L2 cache, sitting between the SMs and global memory. After the first of the fifty timed repetitions the whole working set was in that cache, and the other forty-nine never touched DRAM at all. Writing 256 MB of unrelated data before each launch pushes our arrays out of the cache, so every repetition has to fetch them from global memory again and the table reports the memory, not the cache. At 16 million elements the working set is 192 MB and would not have fit anyway, which is why that row barely changes.
There is a general lesson here that we will rely on throughout the series: timing a kernel repeatedly on a small input measures the cache unless you deliberately evict it. Triton's own benchmarking helper does the same eviction, and we will use it from the next part onward. The next section places the L2 cache among the other levels of GPU memory.
Where blocks run
We now have 3,907 blocks and, on the L40S, 142 SMs to run them on. Each SM can hold several blocks at once, as long as their combined demands for registers and on-chip memory fit within what the SM has.
A launch of 3,907 blocks is therefore not executed all at once. A hardware scheduler places blocks on SMs that have free capacity, and as blocks complete, waiting blocks take the freed slots, until every block in the grid has run.
Blocks are placed on SMs with free slots, finish in any order, and are replaced from the grid until it is empty.
Three properties of this arrangement shape how every kernel is written.
A block runs on a single SM from start to finish and is never moved. This is what makes it possible for the threads of a block to share on-chip memory and to synchronize: they are physically together on one SM for the block's whole lifetime.
Blocks run in no particular order. The scheduler gives no guarantee that block 1 starts before block 7 or finishes before it, and correct code cannot depend on any ordering.
Blocks cannot rely on direct synchronization with one another during an ordinary kernel. There is no mechanism for block 3 to wait for a value from block 9, because block 9 may not be resident yet, and with a large grid it may not become resident until block 3 has finished. When a computation needs a result that depends on every block, such as the total of an entire array, it is either split across two kernel launches or done with special atomic operations on global memory, and we will use both approaches when we get to reductions.
These constraints are the price of portability. Because blocks are independent, the same kernel runs unchanged on a GPU with 40 SMs and on one with 140, the smaller GPU simply takes more rounds to work through the grid.
The numbers for the GPU in front of us are available from PyTorch, and it is worth printing them once. The script in the
GitHub repo
also works through our one-million-element example:
GPU NVIDIA L40S
Streaming multiprocessors 142
Warp size 32 threads
Max threads per SM 1536
Warp slots per SM 48
Global memory 44 GiB
L2 cache 96 MiB
Compute capability 8.9
1,000,000 elements at 256 threads per block
blocks in the grid 3,907
warps in the grid 31,256
warp slots on the GPU 6,816 (142 SMs x 48)
waves, at best 4.6
Each SM on this GPU can hold 1,536 threads, which is 48 warps, so across 142 SMs at most 6,816 warps are resident at any moment. Our grid has 31,256 warps, about 4.6 times the GPU's maximum number of resident warp slots. The phrase "at best" is there because a kernel that uses many registers per thread fits fewer warps on an SM than the hardware maximum, and the next section explains why that matters.
The memory hierarchy
GPU memory is not a single pool with a single access cost. It is organized as a
hierarchy
, with small, fast storage close to the execution units and progressively larger, slower memory farther away. Arithmetic instructions operate on values held in registers, so every value has to travel from wherever it is stored to the execution units before it can be used, and the cost of that trip depends entirely on where the value started. Where data lives, and how often it moves between these levels, is therefore the main determinant of a kernel's speed.
Each level is larger and slower than the one above it. A round trip to global memory costs hundreds of cycles per element.
At the top are
registers
. Registers are private to a thread and are where arithmetic instructions get their operands and place their results. Values such as
i
and the loaded values of
x[i]
and
y[i]
in our kernel are held in registers while the thread operates on them.
Next is
shared memory
, a small region of fast on-chip memory that threads within a block can use to exchange and reuse data. This is the fast memory we mentioned earlier when explaining why a block stays on one SM. The L1 cache is also located close to the SM, but unlike shared memory it is managed automatically by the hardware.
Below that is the
L2 cache
, which is shared across the GPU's SMs and sits between the SMs and global memory. Data accessed from global memory can be cached here, allowing later accesses to be served without going all the way back to DRAM. This is the 96 MiB cache that held our 12 MB working set in the vector addition timing above, and the reason the program evicts it before every timed launch.
Then comes
global memory
, the large DRAM where our CUDA arrays and PyTorch tensors normally live. Its aggregate bandwidth is enormous, but any individual access has to wait several hundred clock cycles for its data, compared with roughly one cycle for a value already in a register and tens of cycles for shared memory. That ratio of a few hundred to one is the single most important number in GPU performance, and the five trips through global memory in the first figure of this part were all on the expensive side of it.
Finally, data that lives in
host memory
may need to travel between the CPU and GPU across the host-device interconnect, as we saw with
cudaMemcpy()
.
Latency hiding and occupancy
If a single load from global memory takes hundreds of cycles, a fair question is how a GPU accomplishes anything. The answer is that it does not try to make an individual access fast. Instead it keeps a large number of warps resident on each SM, and when one warp issues a load and has to wait, the scheduler switches to another warp whose data has already arrived. With enough warps in rotation, the arithmetic units stay busy even though every single warp spends most of its time waiting.
While one warp waits for memory the scheduler runs another, so the arithmetic units stay busy.
This is one reason a GPU wants far more threads in flight than it can execute at any single instant. The extra warps give the scheduler other work to choose from when some warps are stalled. CPUs also try to hide latency using mechanisms such as caches, out-of-order execution, and speculation; GPUs rely heavily on having many threads and warps available to run.
But the number of warps that can be resident on an SM is limited by hardware resources. Every thread needs registers, and every block may require shared memory. A kernel that uses many registers per thread or a large amount of shared memory per block may therefore allow fewer blocks, and consequently fewer warps, to reside on an SM at the same time.
The fraction of the maximum supported warps on an SM that are actually resident is called
occupancy
. For example, we saw that an L40S SM can support up to 48 resident warps. If the resource requirements of a kernel allow only 24 warps to be resident, its occupancy is:
24 resident warps / 48 maximum warps = 50% occupancy
Introduction to GPU programming with Triton
A reasonable question at this point is what Triton adds over the CUDA we have just used. The short answer is that it moves the level at which we write from the thread to the block; the longer answer starts with what Triton is.
Triton is a domain-specific language, or DSL, for writing GPU kernels. A DSL is a small language designed for one kind of problem rather than for general programming SQL for database queries and regular expressions for text patterns are familiar examples, and both are standalone languages with their own syntax. Triton is instead an embedded DSL: it borrows Python's syntax and lives inside Python programs. To write a Triton kernel we write a normal Python function and place the
@triton.jit
decorator above it, and launching it looks like any other Python function call. Python has several such embedded languages.
Numba
compiles decorated Python functions to native CPU machine code, and
JAX
traces Python functions into a computation graph that it then compiles. In each case the function looks like Python and sits alongside ordinary Python, but something other than the Python interpreter ends up executing it.
Triton includes both a compiler and a runtime. The compiler takes the body of the decorated function and translates it, through several intermediate stages, into the same kind of GPU machine code that
nvcc
produces from CUDA C++. The runtime handles everything around that: it triggers compilation the first time a kernel is called with a new combination of argument types, caches the result so later calls skip straight to launching, and launches the compiled kernel on the device with the grid we ask for. Both are installed as part of the Triton package. In our environment, Triton was already available alongside PyTorch.
Where Triton sits is easiest to see against the two things we have already used. PyTorch gives us whole operations and hides the GPU entirely; CUDA gives us every thread and every byte, and asks us to manage them. Triton occupies the space between. We describe the computation we want performed on a block of data, and the compiler makes the decisions that in CUDA would be ours: how the threads share the work, how memory accesses are arranged so that neighboring threads touch neighboring addresses, when shared memory is worth using, and where synchronization is needed. Each of those decisions affects performance, and later in the series we will look at what the compiler chooses and why. For this part, the point is that they are no longer written by hand.
The Triton programming model
The one conceptual shift that matters when coming to Triton from CUDA is this:
we write programs for blocks, not for threads
.
Recall what the CUDA version of vector addition asked of us. The work was organized in two tiers, a grid of blocks and a block of threads, and the code we wrote was the program for a single thread. That thread computed its own element index from
blockIdx.x
,
blockDim.x
and
threadIdx.x
, checked that the index was in range, and handled one element. Any coordination between threads, such as sharing data through the SM's on-chip memory or waiting for one another, would also have been ours to write.
Triton removes the need to coordinate threads by hand. A Triton kernel is written from the point of view of a single program instance, and that instance operates on an entire block of data rather than on one element. We decompose the task into one level only, the block. Triton's compiler and runtime decide how the individual threads inside that block carry out the instructions, and shared memory is handled for us automatically. Because a program instance works on a whole block at once, its operations are naturally vectorized: loading, computing, storing and masking all act on vectors rather than on individual scalars. This one-tiered design is what makes the programming model simpler, and it is why the kernel we are about to read has no thread index anywhere in it.
A small example makes the difference concrete. Suppose
x
and
y
each hold eight numbers and we want
z = x + y
, and suppose we choose a block size of four, so the work splits into two blocks. In CUDA we launch two blocks of four threads, and each of the eight threads computes one output, such as
z[0] = x[0] + y[0]
.
In Triton we launch two program instances, and each one computes four outputs as a single vector operation: the first evaluates
z[0:4] = x[0:4] + y[0:4]
, the second
z[4:8] = x[4:8] + y[4:8]
. Whether that vector operation becomes one hardware instruction or several is the compiler's concern, not ours.
CUDA assigns one thread per element; Triton assigns one program instance per block of elements.
This way of thinking tends to feel natural quickly, because most numerical work already comes in blocks. Matrices are processed as tiles, images as channels, long vectors as segments. Triton lets us write the computation for one such block in plain vector operations and leaves the thread-level bookkeeping to the compiler, which is exactly the part of CUDA programming that is easiest to get wrong and slowest to debug.
Same program, many blocks: SPMD
The pattern that makes the block-level view work has a name. In the single program, multiple data (SPMD) model, many instances of one program run the same code, and each instance handles its own portion of the data. We have already been using it: the CUDA kernels in this part were one program run by thousands of threads, each on its own element. SPMD is older than GPUs; it is how programs written for message-passing and shared-memory clusters have been structured for decades.
It is worth keeping SPMD separate from SIMT, which we discussed when we looked at warps.
SPMD describes how the program is written: one body of code, applied to many pieces of data.
SIMT describes how NVIDIA hardware executes it: threads gathered into warps, one instruction issued to the whole warp at a time.
The first is a property of our source code, the second a property of the machine.
Triton keeps the SPMD pattern and changes only its granularity. Instead of one worker per element, we have one program instance per block of elements, and the same instructions apply across the whole block. We write the computation for one block, specify a grid that launches one program instance per block, and each instance locates its own portion of the input from the program ID that the launch assigns to it. Underneath, the hardware still runs warps on SMs exactly as described earlier; the compiler is what maps our block-level program onto them.
With that model in place we can write our first Triton kernel, and the natural choice is the same one we used for CUDA. Vector addition is the closest thing parallel computing has to a "Hello, World!" program: the data flow is obvious, every element is independent, and it maps directly onto the block-level view.
Here is the kernel from the
repo
, alongside the host code that launches it.
import
torch
import
triton
import
triton.language
as
tl
@triton.jit
def
add_kernel
(
x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr
):
pid = tl.program_id(axis=
0
)
# which program instance am I?
block_start = pid * BLOCK_SIZE
offsets = block_start + tl.arange(
0
, BLOCK_SIZE)
mask = offsets < n_elements
# guard for the final instance
x = tl.load(x_ptr + offsets, mask=mask)
# global memory to registers
y = tl.load(y_ptr + offsets, mask=mask)
tl.store(output_ptr + offsets, x + y, mask=mask)
def
add
(
x, y
):
output = torch.empty_like(x)
n = x.numel()
grid = (triton.cdiv(n,
1024
),)
# ceiling division, as before
add_kernel[grid](x, y, output, n, BLOCK_SIZE=
1024
)
return
output
Every line has a counterpart in the CUDA kernel we wrote a few sections ago, and reading them side by side is the best way to see what has been abstracted.
CUDA kernel
Triton kernel
blockIdx.x
tl.program_id(axis=0)
blockDim.x
, chosen at launch
BLOCK_SIZE
, a compile-time constant
blockIdx.x * blockDim.x + threadIdx.x
, one index per thread
block_start + tl.arange(0, BLOCK_SIZE)
, a vector of indices for the program instance
if (i < n)
mask = offsets < n_elements
, passed to every load and store
x[i]
, a scalar load per thread
tl.load(x_ptr + offsets, mask=mask)
, a vectorized load over the program's offsets
out[i] = x[i] + y[i]
tl.store(output_ptr + offsets, x + y, mask=mask)
(n + block - 1) / block
triton.cdiv(n, BLOCK_SIZE)
add_kernel<<<grid, block>>>(...)
add_kernel[grid](...)
cudaMalloc
,
cudaMemcpy
,
cudaFree
handled by PyTorch tensors
The most important difference is what is missing: there is no
threadIdx.x
.
Instead,
tl.program_id(axis=0)
tells us which program instance is running. From that ID we calculate
block_start
, and
tl.arange()
creates a vector of offsets:
program 0 → offsets 0 ... 1023
program 1 → offsets 1024 ... 2047
program 2 → offsets 2048 ... 3071
...
Each program instance therefore operates on a block of elements at once, and the mask given to
tl.load()
and
tl.store()
keeps the final instance from touching positions past the end of the tensor, exactly as
if (i < n)
did in CUDA.
The
script
launches this kernel for several input lengths, including one that deliberately leaves a partial final block, and compares every result with PyTorch's own addition:
Running on cuda:0 - NVIDIA L40S
size= 1 OK
size= 128 OK
size= 1024 OK
size= 1048583 OK
There is one final connection back to where we started. In the opening section we counted two kernel launches and five array-sized trips through global memory for
(a + b).relu()
, and we said we would return to what happens when the two operations are brought together. The
count_kernels.py
script in the repo does exactly that on our L40S. It runs the expression once in eager mode and once through
torch.compile
, and asks the profiler how many kernels ran and how long each one took:
eager: 2 kernel(s), 6.656 us on the GPU
3.680 us void at::native::vectorized_elementwise_kernel<4, at::nat...
2.976 us void at::native::vectorized_elementwise_kernel<4, at::nat...
torch.compile: 1 kernel(s), 3.392 us on the GPU
3.392 us triton_poi_fused_add_relu_0
The two eager rows above are the kernels we saw in the profiler table, the
add
and the
clamp
behind
ReLU
. With
torch.compile
, PyTorch fused the addition and the ReLU into a single GPU kernel. That kernel reads
a
and
b
once and writes
c
once, three trips through global memory instead of five, with one launch instead of two. The timing shows the payoff: the fused kernel finishes in 3.392 us, roughly half of the 6.656 us the two eager kernels take together (which we saw earlier). Its name,
triton_poi_fused_add_relu_0
, tells us how it was made.
PyTorch's compiler stack can generate Triton kernels as part of optimizing GPU workloads. By learning Triton ourselves, we gain the ability to write and tune specialized kernels directly when we want more control over how an operation is implemented. That is the level of abstraction we will work at for the rest of this series.
Summary
We started with a single line of PyTorch and followed it all the way down to the GPU.
Along the way, we saw how GPU work is expressed as kernels, how threads are organized into warps and blocks, how blocks are scheduled across SMs, and how data moves through the GPU's memory hierarchy. We also saw why GPUs keep many warps in flight to hide memory latency.
Most importantly, we wrote the same vector addition in both CUDA and Triton. CUDA made the underlying execution model explicit: thread indices, blocks, bounds checks, memory allocation, and kernel launches. Triton let us express the same computation at the level of blocks of data while leaving much of the thread-level mapping to the compiler.
These concepts do not disappear when we use Triton. Threads, warps, SMs, registers, caches, and global memory are still the hardware underneath our program, and understanding them gives us the vocabulary to reason about why a Triton kernel behaves the way it does. That vocabulary is what the rest of the series builds on.
Run the code
All code from this part is available in the
GPU Engineering section of the Crusoe Developer Hub
.
The repository contains the CUDA examples, the Triton vector-add kernel, profiler scripts, benchmarking code, and instructions for creating the environment and running everything on a Crusoe GPU.
To follow along on Crusoe, create a GPU VM from the
Crusoe Cloud console
. A single-GPU instance is enough to run the examples in this series. We used an NVIDIA L40S for the measurements in this part, but you can use another supported NVIDIA GPU as well.
Once the VM is running, connect to it over SSH, clone the
Crusoe Developer Hub
repository, and follow the
README
for the latest environment and setup instructions. The Python scripts only need the environment the README creates. The CUDA C++ examples also need the CUDA toolkit for the
nvcc
compiler, and the README shows how to install it.
Next in the series
In the next part, we will take the Triton vector-add kernel apart properly. We will look at
tl.constexpr
,
BLOCK_SIZE
, program instances, grids, compilation, and the intermediate representations generated by Triton. We will also establish the benchmarking approach we will use throughout the rest of the series.
From there, we can start doing what this series is really about: writing Triton kernels, measuring them, and understanding why they perform the way they do.
References
NVIDIA,
CUDA C++ Programming Guide
: the sections on the thread hierarchy, memory hierarchy, and SIMT architecture underpin the execution model described here.
NVIDIA,
CUDA C++ Best Practices Guide
: occupancy and GPU memory performance.
NVIDIA,
An Even Easier Introduction to CUDA
: an introduction to CUDA programming that builds vector addition from a single thread to a grid of threads.
OpenAI,
Triton documentation
: the programming model, language reference, and tutorials for writing Triton kernels.
Crusoe Developer Hub,
GPU Programming with Triton
: companion code for this series.
Crusoe,
Creating and Managing GPU VMs
: instructions for creating and managing the GPU instances used to follow along with the series.
Frequently
asked questions
Is a warp always 32 threads?
On NVIDIA GPUs a warp is always 32 threads, and the number is fixed in hardware rather than chosen by the programmer. The 32 threads execute each instruction together, which NVIDIA calls SIMT. A block smaller than 32 still occupies a full warp: e.g. an eight-thread block leaves 24 of the warp's lanes inactive, which is why CUDA block sizes are usually multiples of 32.
Is Triton based on CUDA?
At its core, Triton is an open-source programming language and compiler that enables researchers with little to no CUDA experience to write highly efficient GPU code. It provides a Python-based domain-specific environment for productively developing custom DNN compute kernels capable of running at maximal throughput on modern hardware. Because Triton is embedded directly in Python, a kernel is simply a standard Python function marked with the @triton.jit decorator. The fundamental difference lies in abstraction: while traditional CUDA code describes the work of a single thread, Triton code describes the work of an entire block.
What is the recommended block size for CUDA?
A CUDA block size of 128 to 256 threads is a good starting range, and 256 is a sensible default. Block size should always be a multiple of 32, because the warp is the unit the hardware schedules: a 250-thread block still occupies eight warps and leaves six lanes of the last warp idle. The hard ceiling is 1,024 threads per block on current NVIDIA GPUs.
How do you measure GPU kernel execution time?
Measuring GPU kernel execution time requires waiting for the GPU, because a kernel launch returns to the CPU before the work finishes. For example, on an NVIDIA L40S GPU, a vector addition returned from its launch after 25 microseconds but took 993 microseconds to complete. A reliable benchmark times on the GPU's own timeline with CUDA events, and evicts the L2 cache between runs so the numbers reflect memory rather than cache.
Latest articles
October 9, 2026
How green is Crusoe's inference engine?
October 9, 2026
Building community in the Lone Star State
October 7, 2026
GPU programming with Triton, part 1
Are you ready to build something amazing?
Contact Us
Cloud
October 7, 2026
GPU programming with Triton, part 1
One line of PyTorch becomes two GPU kernel launches and five trips through global memory. Part 1 traces what the GPU actually executes, from kernels and warps to the memory hierarchy, then writes the same vector addition twice: once in CUDA C++, once in Triton.
Suman Debnath
Director, Developer Relations
Daniel Shen
Machine Learning Engineer
Janaki Ram Goteti
Senior Staff Software Engineer
Connor Guerrero
Senior Developer Relations Manager
Lei Ma
Technical Content Marketing Manager
October 7, 2026
Table of contents
This is some text inside of a div block.
Share:
Most of us start using GPUs through a framework. We move a model to the device, training runs faster, and for a long time that is all we need to know. The difficulty begins when something is slower than it should be and we have no way to reason about why, because the GPU has been a black box the whole time.
This series is our attempt to open that box. In this series, we will learn how a GPU executes code, write kernels for it in
Triton
, and measure everything we write. Triton is a domain-specific language (DSL) for GPU kernels that is embedded in Python, and it comes with both a compiler and a runtime. We will learn more about what that means towards the end of this part. We chose Triton because it lets us write fast GPU kernels without requiring us to start with NVIDIA CUDA® C++. However, writing efficient GPU kernels starts with understanding the fundamentals: how GPUs execute work, organize threads, and move data through memory.
This first part therefore focuses primarily on GPU fundamentals, building the foundation we need before we start writing kernels in Triton. We will start with a single line of PyTorch code, creating a simple tensor on the GPU and adding it to another, and ask what the GPU actually executes when that line runs.
From there we step into CUDA C++, where every detail of GPU programming is explicit: how threads are organized, how work is distributed, and how data moves between the CPU, the GPU, and the levels of memory in between. Seeing those mechanics directly is the best way to appreciate what Triton takes care of for us. We finish by writing the same kernel in Triton and comparing the two side by side.
One line of PyTorch
Let’s start with a simple PyTorch example and follow what happens when it reaches the GPU:
import
torch
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
c = (a + b).relu()
The first two lines create tensors containing one million random numbers each and place them in GPU memory. The third line looks simple: add the two tensors, then apply a ReLU.
From Python’s point of view, this is a single expression. But what does the GPU actually execute?
It executes two separate programs, called
kernels
.
A kernel is a GPU function, one that the GPU typically executes across different elements in parallel. The CPU asks the GPU to run a kernel, and that request is called a
kernel launch
. Here, the word
kernel
has nothing to do with an operating-system kernel or the small matrix used in a convolution; in GPU programming it simply means a function the GPU executes.
When PyTorch evaluates
a + b
, it launches one kernel that reads
a
and
b
, adds their elements, and writes the result. It then evaluates
.relu()
by launching a second kernel, which reads that intermediate result, replaces negative values with zero, and writes the final output.
So, one line of PyTorch becomes two GPU kernel launches.
One line, two kernels: the add writes tmp to global memory and the ReLU() reads it back, five array-sized trips in all.
The first kernel reads
a
and
b
, adds them, and writes the result back to GPU memory as an intermediate tensor,
tmp
. The second kernel then reads
tmp
, applies ReLU, and writes the final result to
c
.
If we count those memory operations, we get
five array-sized trips through global memory
:
Read
a
Read
b
Write
tmp
Read
tmp
Write
c
The actual computation, however, only needs the values from
a
and
b
to produce
c
. If the addition and ReLU could happen together, we would need only three array-sized memory operations: read
a
, read
b
, and write
c
. The extra write and read of
tmp
happen because the addition and ReLU are executed as separate kernels.
Why does PyTorch do this? In its default
eager mode
, PyTorch executes each operation as Python reaches it. When it launches the addition, PyTorch does not look ahead and optimize it together with the ReLU that follows. It executes the addition as its own operation and writes the result to GPU memory. When Python reaches
.relu()
, PyTorch launches another kernel, which reads that intermediate result and produces the final output.
Later, we will see what happens when these two operations are brought together into a single kernel, eliminating the intermediate write and read through global memory.
Counting the kernels
We can see this directly using
PyTorch's profiler
, which records the GPU kernels executed by our code:
import
torch
from
torch.profiler
import
ProfilerActivity, profile
def
add_relu
(
a, b
):
return
(a + b).relu()
a = torch.randn(
1_000_000
, device=
"cuda"
)
b = torch.randn(
1_000_000
, device=
"cuda"
)
# Warm up once so one-time initialization stays out of the trace.
add_relu(a, b)
torch.cuda.synchronize()
with
profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA])
as
prof:
add_relu(a, b)
torch.cuda.synchronize()
# wait for the GPU before ending the trace
(prof.key_averages().table(sort_by=
"cuda_time_total"
, row_limit=
12
))
Before we see what this prints, let’s first understand the code above. The first call to
add_relu()
and the
torch.cuda.synchronize()
immediately after it are simply a
warm-up
, so we can ignore them for now. They make sure one-time initialization work is completed before we start profiling.
Inside the profiler, we call
torch.cuda.synchronize()
again. GPU operations are asynchronous, meaning the CPU continues on to its next statement while work is still running on the GPU, so without this call the profiling region could end before the kernels we want to observe have completed. The call blocks the CPU until every queued GPU operation has finished. We will explore asynchronous execution in more detail once we write a kernel of our own.
Here is the table this printed on our NVIDIA
L40S
GPU on Crusoe (the GPU we are using for the purpose of this series), with the CPU timing columns omitted for space:
Name Self CUDA # of Calls
-------------------------------------------------------- ---------- ----------
aten::add 3.680us 1
void at::native::vectorized_elementwise_kernel<4, at... 3.680us 1
aten::relu 0.000us 1
aten::clamp_min 2.976us 1
void at::native::vectorized_elementwise_kernel<4, at... 2.976us 1
cudaLaunchKernel 0.000us 2
cudaDeviceSynchronize 0.000us 2
-------------------------------------------------------- ---------- ----------
Self CUDA time total: 6.656us
At first glance, the profiler output can look a bit busy because it mixes high-level PyTorch operations with the actual GPU kernels they trigger. To break it down:
PyTorch operations
: Rows like
aten::add
,
aten::relu
, and
aten::clamp_min
are framework-level calls.
Hardware kernels
: The two rows starting with
vectorized_elementwise_kernel
are the actual routines executed on the GPU.
The first GPU kernel handles the addition in
3.680 us
. For ReLU, PyTorch routes through
clamp_min
under the hood, running a second kernel that takes
2.976 us
. You might notice that
aten::relu
logs
0.000 us
of self-time; the profiler assigns GPU time to the innermost leaf operation (
clamp_min
) that actually fires the kernel, rather than parent wrappers like
aten::relu
.
There is an even simpler way to confirm the count. The
cudaLaunchKernel
row shows 2 calls, meaning PyTorch asked CUDA to launch exactly two kernels. That matches what we saw in the diagram: one kernel for the addition and another for ReLU.
The two GPU rows are the add kernel and the clamp kernel behind ReLU. Measured on an L40S with PyTorch 2.6.
The exact kernel names are not important, and they can change between PyTorch versions. The important takeaway is: our single Python expression resulted in
two GPU kernel launches
.
Now that we can see what PyTorch is asking the GPU to execute, the next step is to understand how the GPU actually executes that work. For that, we need to look at how a GPU is built.
Why a GPU is built the way it is
Suppose we want to double every element of an array of one thousand numbers. The elements are independent: e.g., doubling the 5th element in the array needs nothing from the 4th element. So instead of one processor visiting the elements in turn, we can give each element to its own worker and finish in a single step. This is
data parallelism
: one operation carried out simultaneously on many independent data elements. When the pieces need no coordination at all, the work is often called
embarrassingly parallel
. Elementwise operations such as addition and ReLU have exactly this shape, and when heavier workloads like matrix multiplication, convolution, and attention are broken down into thousands of smaller parallel computations, they follow this exact same paradigm, which is why deep learning maps so well onto a GPU. GPUs are well suited for such parallel processing, where the same operation needs to take place on a different set of data.
But not everything does. Summing an array to a single value forces partial results to be combined, which introduces dependencies between workers. Operations like this are called
reductions
, they need a different strategy, and we will see them later in the series.
That design goal shows up in the GPU hardware. A CPU is built for low latency on a small number of instruction streams. It has a few large cores, sophisticated control logic, big caches, branch prediction and out-of-order execution all aimed at making one thread run fast. A GPU is built for throughput, completing a large amount of work per unit time, and spends its silicon on thousands of simple arithmetic units running in parallel instead.
A CPU spends its area on making one thread fast; a GPU spends it on running thousands of threads at once.
We now have an intuition for why a GPU is designed around massive parallelism. The next question is how we give it work to do, and to answer that let’s write our first GPU kernel.
A first CUDA program
CUDA
is NVIDIA's platform for programming its GPUs. It extends C++ with a few keywords and a special function-call syntax, and it comes with a compiler,
nvcc
, that produces a program containing both CPU code and GPU code. In CUDA's vocabulary, the CPU is the
host
and the GPU is the
device
, and we will use those two words from here on.
The worker we have been describing has a proper name in CUDA: a
thread
. A thread runs the kernel's instructions on its own piece of the data. Here is a small kernel that shows threads in action. It computes nothing; each thread simply announces itself with a print statement:
#
include
<cstdio>
__global__
void
hello_kernel
()
{
// threadIdx.x is this thread's position inside its block, 0 to 7 here.
printf
(
"hello from thread %d\n"
, threadIdx.x);
}
int
main
()
{
hello_kernel<<<
1
,
8
>>>();
// one block of eight threads
cudaDeviceSynchronize
();
// wait for the GPU before main() returns
return
0
;
}
If you know C++, two parts of this program probably look unusual:
__global__
and the
<<<...>>>
syntax. They are CUDA extensions to C++, understood by NVIDIA's
nvcc
compiler.
__global__
tells the compiler that a function is a GPU kernel, while
<<<...>>>
tells the CUDA runtime how that kernel should be launched on the GPU. Neither is part of standard C++ syntax.
__global__
marks a GPU kernel: The keyword
__global__
marks
hello_kernel
as a kernel, meaning the host launches it and the device executes it. A CUDA kernel does not return a value directly to the host. Kernels that produce results typically write them to memory, where the host or another kernel can access them later.
<<<1, 8>>>
launches the kernel: The launch syntax
<<<1, 8>>>
is how the host asks the GPU to run the kernel. The two numbers specify how many groups of threads to create and how many threads each group should contain. CUDA calls a group of threads a block, so here we are launching one block containing eight threads.
There is no loop in the kernel, yet its body runs once for each of the eight threads. Instead of writing a loop that processes eight items one after another, we launch eight threads and let the GPU execute them in parallel. We will have much more to say about blocks once we need more than one of them; for now, a single block is enough.
threadIdx.x
identifies a thread within the block: The built-in variable
threadIdx.x
tells each thread its position inside the block, from 0 to 7 here. Every thread runs the same kernel code, but
threadIdx.x
has a different value for each one. That index is often part of how a thread determines which piece of data it should work on.
We compile the program with
nvcc
and run the result like any other executable:
nvcc -O2 -arch=native -o 01_hello_kernel 01_hello_kernel.cu
./01_hello_kernel
On our machine, this produced:
hello from thread 0
hello from thread 1
hello from thread 2
hello from thread 3
hello from thread 4
hello from thread 5
hello from thread 6
hello from thread 7
The lines happened to appear in order on our machine, but CUDA does not guarantee that ordering. Threads execute concurrently, and a correct GPU program should never depend on the order in which individual threads happen to run or produce output.
We launched eight threads here, but the GPU does not treat them as eight completely independent pieces of work. To understand what happens next, we need to introduce one of the most important concepts in GPU execution: the
warp
.
How the hardware runs threads
We have been describing threads as if each one were an independent worker. The hardware is a little more structured than that. On NVIDIA GPUs, threads are organized into warps of 32. The 32 threads of a warp execute each instruction together, and each thread applies that instruction to its own data. NVIDIA calls this execution model
SIMT
(Single Instruction Multiple Threads).
Our block contains only eight threads, so it still occupies one warp. Of the warp's 32 lanes, only eight are active for our block and the remaining 24 are inactive. This is our first hint that the number of threads we choose for a block can affect how efficiently the hardware is used. When a block contains more than 32 threads, its threads are divided into multiple warps; a block of 256 threads, for example, is eight warps, and those warps are then scheduled for execution by the GPU.
The part of the GPU that schedules and executes warps is called an SM (Streaming Multiprocessor). A GPU contains many SMs, and the exact number depends on the GPU. The NVIDIA L40S GPU we are using has 142 SMs.
For comparison, the NVIDIA A100 Tensor Core GPU has 108 SMs, while the NVIDIA H100 Tensor Core GPU has 132 SMs in the SXM version and 114 SMs in the PCIe version. Each SM contains execution units along with the storage its threads need: registers, a small on-chip memory, and caches. We will look at each of these when we reach the memory hierarchy; for now it is enough to know that an SM is where warps run.
The hardware groups threads into warps of 32 and schedules warps on SMs. None of this is chosen by the programmer.
Because NVIDIA GPUs use warps of 32 threads, CUDA block sizes are commonly chosen as multiples of 32. We will see why choices such as 128, 256, or 512 threads per block appear so often as we write larger kernels.
Squaring an array: two memories
A kernel that only prints is not much use. To do real work, a kernel has to read inputs and write outputs, and that brings us to the most important structural fact about a GPU program: the host and the device have separate memories. The CPU uses the system's main memory, while the GPU has its own
global memory.
Neither processor can read the other's memory directly, so data has to be copied across the PCIe bus in both directions.
Host and device memories are separate. Every byte a kernel touches was first copied across the PCIe bus.
For our first example, we will use the basic explicit-memory workflow:
Allocate memory on the device
Copy the input from host to device
Launch the kernel
Copy the result from device back to host
Free the device memory
Here is that workflow in a program that squares eight numbers, using one thread per number:
#
include
<cstdio>
__global__
void
square_kernel
(
const
float
* in,
float
* out,
int
n)
{
int
i = threadIdx.x;
// one block: thread index = element index
if
(i < n) {
// never touch memory past the array's end
out[i] = in[i] * in[i];
}
}
int
main
()
{
const
int
n =
8
;
const
size_t
bytes = n *
sizeof
(
float
);
float
h_in[n], h_out[n];
// host arrays
for
(
int
i =
0
; i < n; ++i) h_in[i] = (
float
)(i +
1
);
float
*d_in =
nullptr
, *d_out =
nullptr
;
// device pointers
cudaMalloc
(&d_in, bytes);
// 1. allocate on device
cudaMalloc
(&d_out, bytes);
cudaMemcpy
(d_in, h_in, bytes, cudaMemcpyHostToDevice);
// 2. copy in
square_kernel<<<
1
, n>>>(d_in, d_out, n);
// 3. launch
cudaMemcpy
(h_out, d_out, bytes, cudaMemcpyDeviceToHost);
// 4. copy out
cudaFree
(d_in);
// 5. free
cudaFree
(d_out);
for
(
int
i =
0
; i < n; ++i)
printf
(
"%4.0f squared is %4.0f\n"
, h_in[i], h_out[i]);
return
0
;
}
The five steps of a CUDA program. Only step 3 runs on the GPU; PyTorch performs the others behind its tensors.
There are two new CUDA functions here, both from the
CUDA runtime API
.
cudaMalloc()
allocates memory in the device's global memory and gives us a pointer to it.
cudaMemcpy()
copies bytes between host and device memory. The final argument tells CUDA which direction the data should move:
cudaMemcpyHostToDevice
for the input and
cudaMemcpyDeviceToHost
for the result.
The naming convention
h_
for host data and
d_
for device data is not required by CUDA, but it is a useful convention because both are represented as pointers in C++. Keeping the distinction visible helps us remember where each piece of data lives.
The kernel receives
d_in
and
d_out
as arguments, and each of the eight threads squares the single element that its
threadIdx.x
points at. This is the data parallelism from the previous section, now written as a real kernel.
The
if (i < n)
check may look unnecessary because we launched exactly eight threads for eight elements. We include it because, once we start launching blocks with a fixed number of threads, the total number of threads will not always match the number of elements exactly. The check prevents threads whose indices fall outside the array from accessing memory they do not own. Running the program gives:
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
5 squared is 25
6 squared is 36
7 squared is 49
8 squared is 64
Frameworks such as PyTorch manage much of this memory handling for us. When we create a tensor with
device="cuda"
, PyTorch allocates storage for that tensor in GPU memory. Operations on CUDA tensors then launch kernels that operate on that device-resident data. When we call
.cpu()
on a CUDA tensor, PyTorch transfers its contents back to CPU memory.
CUDA gives us direct control over these steps. That makes the code more verbose, but it also lets us see something that frameworks usually hide: where our data lives, when it moves, and which processor is operating on it.
Launches are asynchronous
There is a detail in the squaring program that is easy to miss. When the host launches a kernel, it does not normally wait for that kernel to finish. The launch queues the work for the GPU and returns control to the host, allowing the CPU to continue executing the next statement while the GPU works independently.
The next operation was a
cudaMemcpy()
from device to host. That copy waits for the required earlier GPU work to complete before returning the result to the host. By the time we print
h_out
, the kernel has finished and the results have been copied back.
In our earlier hello program, there was no device-to-host copy to create that waiting point. That is why we explicitly called
cudaDeviceSynchronize()
, which blocks the host until previously issued GPU work has completed. Without it,
main()
could reach the end while GPU work was still outstanding.
Launch, then wait: the CPU continues after the launch, threads finish at different times, and cudaDeviceSynchronize() holds the CPU until the last one is done.
PyTorch launches are asynchronous in exactly the same way, and this has a practical consequence for anyone who tries to time GPU code. If we wrap an operation in
time.time()
, the elapsed time is the cost of issuing the launch on the CPU side and says nothing about how long the GPU spent on the kernel. To measure the kernel, the CPU has to wait for the GPU first:
import
time
import
torch
a = torch.randn(
50_000_000
, device=
"cuda"
)
b = torch.randn(
50_000_000
, device=
"cuda"
)
a + b
# warm up
torch.cuda.synchronize()
t0 = time.time()
c = a + b
t_launch = time.time() - t0
# how long the CPU spent issuing the work
torch.cuda.synchronize()
# block until the GPU has finished all queued work
t_done = time.time() - t0
# how long the GPU actually took, plus the launch
(
f"launch returned after
{t_launch *
1e6
:
7.0
f}
us"
)
(
f"work finished after
{t_done *
1e6
:
7.0
f}
us"
)
On our L40S we got this:
launch returned after 25 us
work finished after 993 us
This difference is the key idea:
launching GPU work and completing GPU work are two different events
, and only the second one tells us how long a kernel took.
Operations that require data on the CPU create synchronization points as well. For example, calling
.cpu()
on a CUDA tensor must make the requested tensor data available in CPU memory, and
.item()
must retrieve a scalar value before Python can use it. This is why synchronization matters when measuring GPU programs. Without it, we may accidentally measure how quickly the CPU can submit work rather than how quickly the GPU can finish it.
From one block to many blocks
Our squaring program used a single block, which was fine for eight elements. It cannot work for a million, because a block is limited to 1,024 threads on current NVIDIA GPUs. The limit exists because the threads of a block are guaranteed to run on the same SM, where they can share that SM's fast memory and coordinate with one another, and an SM only has the resources to track so many threads at once. To process a large array we launch many blocks, and the whole collection of blocks created by one launch is called the
grid
.
So the hierarchy has three levels. A grid contains blocks, a block contains threads, and, as we saw, the hardware executes those threads in warps of 32. There are now two related hierarchies to keep in mind.
Software, how we organize the work:
grid → blocks → threads
Hardware, how the GPU executes that work:
threads → warps → SMs
Software organizes work as grid, block, thread. Hardware executes it as thread, warp, SM. The block is where the two meet.
The grid is a purely logical description of how much work exists. Nothing in the grid specifies which SM will end up running which block, and because of that the same kernel can be launched unchanged on GPUs of very different sizes.
A launch is a grid of blocks, a block is a set of warps, and a warp is 32 threads that move in lockstep.
Finding the right element
Introducing more than one block creates a problem. The variable
threadIdx.x
restarts at zero in every block, so if we launched three blocks of four threads and used
threadIdx.x
as the element index, all three blocks would work on elements 0 to 3 and elements 4 to 11 would never be touched. Each thread needs to know not only its position in its block but also which block it is in.
CUDA provides that through two more built-in variables.
blockIdx.x
is the block's position in the grid, and
blockDim.x
is the number of threads per block, the second number in the launch syntax. We can combine these with
threadIdx.x
to calculate a thread's global index across the entire grid:
int
i = blockIdx.x * blockDim.x + threadIdx.x;
threadIdx.x restarts at zero in every block; adding the block's offset makes the index unique across the grid.
The formula skips over all the elements handled by earlier blocks, then adds the thread's position within its own block. It is similar to identifying a seat in a theater with numbered rows: the row number tells us how many full rows to skip, and the seat's position within that row tells us how far to move into the current row.
How many blocks, and how big
Two decisions remain: how many threads per block, and therefore how many blocks. Suppose we settle on 256 threads per block and have one million elements to process. One million divided by 256 is 3,906.25, and we cannot launch a quarter of a block. Rounding down would leave 64 elements unprocessed, so we round up to 3,907 blocks, which is written in integer arithmetic as:
int
blocks = (n + threads_per_block -
1
) / threads_per_block;
Rounding up means the grid now contains 3,907 × 256 = 1,000,192 threads, which is 192 more than we have elements. Those extra threads live in the last block, and their computed index
i
is 1,000,000 or above. Without a guard they would access memory beyond the end of our arrays and corrupt whatever happens to sit there. This is why the
if (i < n)
check from the squaring program is not optional: an input length seldom divides evenly by the block size, so the final block almost always extends past the end of the array, and every kernel must check before it touches memory.
Quantity
Value
Derivation
Elements
1,000,000
the input
Threads per block
256
our choice
Blocks in the grid
3,907
ceiling of 1,000,000 / 256
Warps per block
8
256 / 32
Warps in the grid
31,256
3,907 × 8
Threads in the last block with no element
192
3,907 × 256 - 1,000,000
Why 256 rather than some other number? The warp is the unit the hardware actually schedules, so a block should be a multiple of 32; a block of 250 threads would still occupy 8 warps, with 6 lanes of the last warp doing nothing. Block size also affects how many blocks and warps can be resident on an SM at the same time, along with other factors such as register and shared-memory usage. There is no single best block size for every kernel, but 128 to 256 threads per block is a good starting range, and 256 is a sensible default that we will use throughout. We will see why these choices matter once we look more closely at how the GPU keeps enough work in flight.
The complete kernel
Putting the pieces together gives the general form of an elementwise CUDA kernel. This is vector addition, the very operation PyTorch launched for us when we wrote
a + b
at the start of this part, and the kernel we will write again in Triton at the end of it:
__global__
void
add_kernel
(
const
float
* x,
const
float
* y,
float
* out,
int
n)
{
int
i = blockIdx.x * blockDim.x + threadIdx.x;
// global element index
if
(i < n) {
// last block may overrun
out[i] = x[i] + y[i];
}
}
// On the host:
int
block =
256
;
int
grid = (n + block -
1
) / block;
// ceiling division
add_kernel<<<grid, block>>>(d_x, d_y, d_out, n);
You can find the full code in the
Crusoe Developer Hub
. The example checks the result against a CPU loop, then times the kernel with
CUDA events
, which record timestamps on the GPU's own timeline and therefore measure kernel execution rather than the CPU issuing the launch. Before each timed launch it writes a 256 MB scratch buffer, for a reason we explain below, then reports the median of fifty runs and converts the time to bandwidth by counting three arrays of four-byte floats. On our L40S:
Running on NVIDIA L40S
elements blocks median ms GB/s check
1000 4 0.0041 2.9 OK
65536 256 0.0061 128.7 OK
1048576 4096 0.0205 614.4 OK
16777216 65536 0.3123 644.7 OK
Every size passes the correctness check, including the first one, where 1,000 elements do not divide evenly into blocks of 256 and the guard is doing its job. The timing column rewards a closer look. For 1,000 elements the GPU has almost no work to do, so the 4 microseconds we measure is essentially the fixed cost of a launch, and the bandwidth figure is meaningless. As the arrays grow the kernel becomes worth launching, and from one million elements upward it moves data at 614 to 645 GB/s, roughly 75 percent of the 864 GB/s the L40S's global memory is rated for.
Now for that 256 MB scratch buffer. When we first wrote this program without it, the one-million-element row reported 2,048 GB/s, more than twice what global memory can deliver, and no kernel can read faster than the memory it reads from. The data was coming from somewhere closer. Three arrays of one million floats occupy 12 MB, and the L40S has a 96 MiB cache, called the L2 cache, sitting between the SMs and global memory. After the first of the fifty timed repetitions the whole working set was in that cache, and the other forty-nine never touched DRAM at all. Writing 256 MB of unrelated data before each launch pushes our arrays out of the cache, so every repetition has to fetch them from global memory again and the table reports the memory, not the cache. At 16 million elements the working set is 192 MB and would not have fit anyway, which is why that row barely changes.
There is a general lesson here that we will rely on throughout the series: timing a kernel repeatedly on a small input measures the cache unless you deliberately evict it. Triton's own benchmarking helper does the same eviction, and we will use it from the next part onward. The next section places the L2 cache among the other levels of GPU memory.
Where blocks run
We now have 3,907 blocks and, on the L40S, 142 SMs to run them on. Each SM can hold several blocks at once, as long as their combined demands for registers and on-chip memory fit within what the SM has.
A launch of 3,907 blocks is therefore not executed all at once. A hardware scheduler places blocks on SMs that have free capacity, and as blocks complete, waiting blocks take the freed slots, until every block in the grid has run.
Blocks are placed on SMs with free slots, finish in any order, and are replaced from the grid until it is empty.
Three properties of this arrangement shape how every kernel is written.
A block runs on a single SM from start to finish and is never moved. This is what makes it possible for the threads of a block to share on-chip memory and to synchronize: they are physically together on one SM for the block's whole lifetime.
Blocks run in no particular order. The scheduler gives no guarantee that block 1 starts before block 7 or finishes before it, and correct code cannot depend on any ordering.
Blocks cannot rely on direct synchronization with one another during an ordinary kernel. There is no mechanism for block 3 to wait for a value from block 9, because block 9 may not be resident yet, and with a large grid it may not become resident until block 3 has finished. When a computation needs a result that depends on every block, such as the total of an entire array, it is either split across two kernel launches or done with special atomic operations on global memory, and we will use both approaches when we get to reductions.
These constraints are the price of portability. Because blocks are independent, the same kernel runs unchanged on a GPU with 40 SMs and on one with 140, the smaller GPU simply takes more rounds to work through the grid.
The numbers for the GPU in front of us are available from PyTorch, and it is worth printing them once. The script in the
GitHub repo
also works through our one-million-element example:
GPU NVIDIA L40S
Streaming multiprocessors 142
Warp size 32 threads
Max threads per SM 1536
Warp slots per SM 48
Global memory 44 GiB
L2 cache 96 MiB
Compute capability 8.9
1,000,000 elements at 256 threads per block
blocks in the grid 3,907
warps in the grid 31,256
warp slots on the GPU 6,816 (142 SMs x 48)
waves, at best 4.6
Each SM on this GPU can hold 1,536 threads, which is 48 warps, so across 142 SMs at most 6,816 warps are resident at any moment. Our grid has 31,256 warps, about 4.6 times the GPU's maximum number of resident warp slots. The phrase "at best" is there because a kernel that uses many registers per thread fits fewer warps on an SM than the hardware maximum, and the next section explains why that matters.
The memory hierarchy
GPU memory is not a single pool with a single access cost. It is organized as a
hierarchy
, with small, fast storage close to the execution units and progressively larger, slower memory farther away. Arithmetic instructions operate on values held in registers, so every value has to travel from wherever it is stored to the execution units before it can be used, and the cost of that trip depends entirely on where the value started. Where data lives, and how often it moves between these levels, is therefore the main determinant of a kernel's speed.
Each level is larger and slower than the one above it. A round trip to global memory costs hundreds of cycles per element.
At the top are
registers
. Registers are private to a thread and are where arithmetic instructions get their operands and place their results. Values such as
i
and the loaded values of
x[i]
and
y[i]
in our kernel are held in registers while the thread operates on them.
Next is
shared memory
, a small region of fast on-chip memory that threads within a block can use to exchange and reuse data. This is the fast memory we mentioned earlier when explaining why a block stays on one SM. The L1 cache is also located close to the SM, but unlike shared memory it is managed automatically by the hardware.
Below that is the
L2 cache
, which is shared across the GPU's SMs and sits between the SMs and global memory. Data accessed from global memory can be cached here, allowing later accesses to be served without going all the way back to DRAM. This is the 96 MiB cache that held our 12 MB working set in the vector addition timing above, and the reason the program evicts it before every timed launch.
Then comes
global memory
, the large DRAM where our CUDA arrays and PyTorch tensors normally live. Its aggregate bandwidth is enormous, but any individual access has to wait several hundred clock cycles for its data, compared with roughly one cycle for a value already in a register and tens of cycles for shared memory. That ratio of a few hundred to one is the single most important number in GPU performance, and the five trips through global memory in the first figure of this part were all on the expensive side of it.
Finally, data that lives in
host memory
may need to travel between the CPU and GPU across the host-device interconnect, as we saw with
cudaMemcpy()
.
Latency hiding and occupancy
If a single load from global memory takes hundreds of cycles, a fair question is how a GPU accomplishes anything. The answer is that it does not try to make an individual access fast. Instead it keeps a large number of warps resident on each SM, and when one warp issues a load and has to wait, the scheduler switches to another warp whose data has already arrived. With enough warps in rotation, the arithmetic units stay busy even though every single warp spends most of its time waiting.
While one warp waits for memory the scheduler runs another, so the arithmetic units stay busy.
This is one reason a GPU wants far more threads in flight than it can execute at any single instant. The extra warps give the scheduler other work to choose from when some warps are stalled. CPUs also try to hide latency using mechanisms such as caches, out-of-order execution, and speculation; GPUs rely heavily on having many threads and warps available to run.
But the number of warps that can be resident on an SM is limited by hardware resources. Every thread needs registers, and every block may require shared memory. A kernel that uses many registers per thread or a large amount of shared memory per block may therefore allow fewer blocks, and consequently fewer warps, to reside on an SM at the same time.
The fraction of the maximum supported warps on an SM that are actually resident is called
occupancy
. For example, we saw that an L40S SM can support up to 48 resident warps. If the resource requirements of a kernel allow only 24 warps to be resident, its occupancy is:
24 resident warps / 48 maximum warps = 50% occupancy
Introduction to GPU programming with Triton
A reasonable question at this point is what Triton adds over the CUDA we have just used. The short answer is that it moves the level at which we write from the thread to the block; the longer answer starts with what Triton is.
Triton is a domain-specific language, or DSL, for writing GPU kernels. A DSL is a small language designed for one kind of problem rather than for general programming SQL for database queries and regular expressions for text patterns are familiar examples, and both are standalone languages with their own syntax. Triton is instead an embedded DSL: it borrows Python's syntax and lives inside Python programs. To write a Triton kernel we write a normal Python function and place the
@triton.jit
decorator above it, and launching it looks like any other Python function call. Python has several such embedded languages.
Numba
compiles decorated Python functions to native CPU machine code, and
JAX
traces Python functions into a computation graph that it then compiles. In each case the function looks like Python and sits alongside ordinary Python, but something other than the Python interpreter ends up executing it.
Triton includes both a compiler and a runtime. The compiler takes the body of the decorated function and translates it, through several intermediate stages, into the same kind of GPU machine code that
nvcc
produces from CUDA C++. The runtime handles everything around that: it triggers compilation the first time a kernel is called with a new combination of argument types, caches the result so later calls skip straight to launching, and launches the compiled kernel on the device with the grid we ask for. Both are installed as part of the Triton package. In our environment, Triton was already available alongside PyTorch.
Where Triton sits is easiest to see against the two things we have already used. PyTorch gives us whole operations and hides the GPU entirely; CUDA gives us every thread and every byte, and asks us to manage them. Triton occupies the space between. We describe the computation we want performed on a block of data, and the compiler makes the decisions that in CUDA would be ours: how the threads share the work, how memory accesses are arranged so that neighboring threads touch neighboring addresses, when shared memory is worth using, and where synchronization is needed. Each of those decisions affects performance, and later in the series we will look at what the compiler chooses and why. For this part, the point is that they are no longer written by hand.
The Triton programming model
The one conceptual shift that matters when coming to Triton from CUDA is this:
we write programs for blocks, not for threads
.
Recall what the CUDA version of vector addition asked of us. The work was organized in two tiers, a grid of blocks and a block of threads, and the code we wrote was the program for a single thread. That thread computed its own element index from
blockIdx.x
,
blockDim.x
and
threadIdx.x
, checked that the index was in range, and handled one element. Any coordination between threads, such as sharing data through the SM's on-chip memory or waiting for one another, would also have been ours to write.
Triton removes the need to coordinate threads by hand. A Triton kernel is written from the point of view of a single program instance, and that instance operates on an entire block of data rather than on one element. We decompose the task into one level only, the block. Triton's compiler and runtime decide how the individual threads inside that block carry out the instructions, and shared memory is handled for us automatically. Because a program instance works on a whole block at once, its operations are naturally vectorized: loading, computing, storing and masking all act on vectors rather than on individual scalars. This one-tiered design is what makes the programming model simpler, and it is why the kernel we are about to read has no thread index anywhere in it.
A small example makes the difference concrete. Suppose
x
and
y
each hold eight numbers and we want
z = x + y
, and suppose we choose a block size of four, so the work splits into two blocks. In CUDA we launch two blocks of four threads, and each of the eight threads computes one output, such as
z[0] = x[0] + y[0]
.
In Triton we launch two program instances, and each one computes four outputs as a single vector operation: the first evaluates
z[0:4] = x[0:4] + y[0:4]
, the second
z[4:8] = x[4:8] + y[4:8]
. Whether that vector operation becomes one hardware instruction or several is the compiler's concern, not ours.
CUDA assigns one thread per element; Triton assigns one program instance per block of elements.
This way of thinking tends to feel natural quickly, because most numerical work already comes in blocks. Matrices are processed as tiles, images as channels, long vectors as segments. Triton lets us write the computation for one such block in plain vector operations and leaves the thread-level bookkeeping to the compiler, which is exactly the part of CUDA programming that is easiest to get wrong and slowest to debug.
Same program, many blocks: SPMD
The pattern that makes the block-level view work has a name. In the single program, multiple data (SPMD) model, many instances of one program run the same code, and each instance handles its own portion of the data. We have already been using it: the CUDA kernels in this part were one program run by thousands of threads, each on its own element. SPMD is older than GPUs; it is how programs written for message-passing and shared-memory clusters have been structured for decades.
It is worth keeping SPMD separate from SIMT, which we discussed when we looked at warps.
SPMD describes how the program is written: one body of code, applied to many pieces of data.
SIMT describes how NVIDIA hardware executes it: threads gathered into warps, one instruction issued to the whole warp at a time.
The first is a property of our source code, the second a property of the machine.
Triton keeps the SPMD pattern and changes only its granularity. Instead of one worker per element, we have one program instance per block of elements, and the same instructions apply across the whole block. We write the computation for one block, specify a grid that launches one program instance per block, and each instance locates its own portion of the input from the program ID that the launch assigns to it. Underneath, the hardware still runs warps on SMs exactly as described earlier; the compiler is what maps our block-level program onto them.
With that model in place we can write our first Triton kernel, and the natural choice is the same one we used for CUDA. Vector addition is the closest thing parallel computing has to a "Hello, World!" program: the data flow is obvious, every element is independent, and it maps directly onto the block-level view.
Here is the kernel from the
repo
, alongside the host code that launches it.
import
torch
import
triton
import
triton.language
as
tl
@triton.jit
def
add_kernel
(
x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr
):
pid = tl.program_id(axis=
0
)
# which program instance am I?
block_start = pid * BLOCK_SIZE
offsets = block_start + tl.arange(
0
, BLOCK_SIZE)
mask = offsets < n_elements
# guard for the final instance
x = tl.load(x_ptr + offsets, mask=mask)
# global memory to registers
y = tl.load(y_ptr + offsets, mask=mask)
tl.store(output_ptr + offsets, x + y, mask=mask)
def
add
(
x, y
):
output = torch.empty_like(x)
n = x.numel()
grid = (triton.cdiv(n,
1024
),)
# ceiling division, as before
add_kernel[grid](x, y, output, n, BLOCK_SIZE=
1024
)
return
output
Every line has a counterpart in the CUDA kernel we wrote a few sections ago, and reading them side by side is the best way to see what has been abstracted.
CUDA kernel
Triton kernel
blockIdx.x
tl.program_id(axis=0)
blockDim.x
, chosen at launch
BLOCK_SIZE
, a compile-time constant
blockIdx.x * blockDim.x + threadIdx.x
, one index per thread
block_start + tl.arange(0, BLOCK_SIZE)
, a vector of indices for the program instance
if (i < n)
mask = offsets < n_elements
, passed to every load and store
x[i]
, a scalar load per thread
tl.load(x_ptr + offsets, mask=mask)
, a vectorized load over the program's offsets
out[i] = x[i] + y[i]
tl.store(output_ptr + offsets, x + y, mask=mask)
(n + block - 1) / block
triton.cdiv(n, BLOCK_SIZE)
add_kernel<<<grid, block>>>(...)
add_kernel[grid](...)
cudaMalloc
,
cudaMemcpy
,
cudaFree
handled by PyTorch tensors
The most important difference is what is missing: there is no
threadIdx.x
.
Instead,
tl.program_id(axis=0)
tells us which program instance is running. From that ID we calculate
block_start
, and
tl.arange()
creates a vector of offsets:
program 0 → offsets 0 ... 1023
program 1 → offsets 1024 ... 2047
program 2 → offsets 2048 ... 3071
...
Each program instance therefore operates on a block of elements at once, and the mask given to
tl.load()
and
tl.store()
keeps the final instance from touching positions past the end of the tensor, exactly as
if (i < n)
did in CUDA.
The
script
launches this kernel for several input lengths, including one that deliberately leaves a partial final block, and compares every result with PyTorch's own addition:
Running on cuda:0 - NVIDIA L40S
size= 1 OK
size= 128 OK
size= 1024 OK
size= 1048583 OK
There is one final connection back to where we started. In the opening section we counted two kernel launches and five array-sized trips through global memory for
(a + b).relu()
, and we said we would return to what happens when the two operations are brought together. The
count_kernels.py
script in the repo does exactly that on our L40S. It runs the expression once in eager mode and once through
torch.compile
, and asks the profiler how many kernels ran and how long each one took:
eager: 2 kernel(s), 6.656 us on the GPU
3.680 us void at::native::vectorized_elementwise_kernel<4, at::nat...
2.976 us void at::native::vectorized_elementwise_kernel<4, at::nat...
torch.compile: 1 kernel(s), 3.392 us on the GPU
3.392 us triton_poi_fused_add_relu_0
The two eager rows above are the kernels we saw in the profiler table, the
add
and the
clamp
behind
ReLU
. With
torch.compile
, PyTorch fused the addition and the ReLU into a single GPU kernel. That kernel reads
a
and
b
once and writes
c
once, three trips through global memory instead of five, with one launch instead of two. The timing shows the payoff: the fused kernel finishes in 3.392 us, roughly half of the 6.656 us the two eager kernels take together (which we saw earlier). Its name,
triton_poi_fused_add_relu_0
, tells us how it was made.
PyTorch's compiler stack can generate Triton kernels as part of optimizing GPU workloads. By learning Triton ourselves, we gain the ability to write and tune specialized kernels directly when we want more control over how an operation is implemented. That is the level of abstraction we will work at for the rest of this series.
Summary
We started with a single line of PyTorch and followed it all the way down to the GPU.
Along the way, we saw how GPU work is expressed as kernels, how threads are organized into warps and blocks, how blocks are scheduled across SMs, and how data moves through the GPU's memory hierarchy. We also saw why GPUs keep many warps in flight to hide memory latency.
Most importantly, we wrote the same vector addition in both CUDA and Triton. CUDA made the underlying execution model explicit: thread indices, blocks, bounds checks, memory allocation, and kernel launches. Triton let us express the same computation at the level of blocks of data while leaving much of the thread-level mapping to the compiler.
These concepts do not disappear when we use Triton. Threads, warps, SMs, registers, caches, and global memory are still the hardware underneath our program, and understanding them gives us the vocabulary to reason about why a Triton kernel behaves the way it does. That vocabulary is what the rest of the series builds on.
Run the code
All code from this part is available in the
GPU Engineering section of the Crusoe Developer Hub
.
The repository contains the CUDA examples, the Triton vector-add kernel, profiler scripts, benchmarking code, and instructions for creating the environment and running everything on a Crusoe GPU.
To follow along on Crusoe, create a GPU VM from the
Crusoe Cloud console
. A single-GPU instance is enough to run the examples in this series. We used an NVIDIA L40S for the measurements in this part, but you can use another supported NVIDIA GPU as well.
Once the VM is running, connect to it over SSH, clone the
Crusoe Developer Hub
repository, and follow the
README
for the latest environment and setup instructions. The Python scripts only need the environment the README creates. The CUDA C++ examples also need the CUDA toolkit for the
nvcc
compiler, and the README shows how to install it.
Next in the series
In the next part, we will take the Triton vector-add kernel apart properly. We will look at
tl.constexpr
,
BLOCK_SIZE
, program instances, grids, compilation, and the intermediate representations generated by Triton. We will also establish the benchmarking approach we will use throughout the rest of the series.
From there, we can start doing what this series is really about: writing Triton kernels, measuring them, and understanding why they perform the way they do.
References
NVIDIA,
CUDA C++ Programming Guide
: the sections on the thread hierarchy, memory hierarchy, and SIMT architecture underpin the execution model described here.
NVIDIA,
CUDA C++ Best Practices Guide
: occupancy and GPU memory performance.
NVIDIA,
An Even Easier Introduction to CUDA
: an introduction to CUDA programming that builds vector addition from a single thread to a grid of threads.
OpenAI,
Triton documentation
: the programming model, language reference, and tutorials for writing Triton kernels.
Crusoe Developer Hub,
GPU Programming with Triton
: companion code for this series.
Crusoe,
Creating and Managing GPU VMs
: instructions for creating and managing the GPU instances used to follow along with the series.
Frequently
asked questions
Is a warp always 32 threads?
On NVIDIA GPUs a warp is always 32 threads, and the number is fixed in hardware rather than chosen by the programmer. The 32 threads execute each instruction together, which NVIDIA calls SIMT. A block smaller than 32 still occupies a full warp: e.g. an eight-thread block leaves 24 of the warp's lanes inactive, which is why CUDA block sizes are usually multiples of 32.
Is Triton based on CUDA?
At its core, Triton is an open-source programming language and compiler that enables researchers with little to no CUDA experience to write highly efficient GPU code. It provides a Python-based domain-specific environment for productively developing custom DNN compute kernels capable of running at maximal throughput on modern hardware. Because Triton is embedded directly in Python, a kernel is simply a standard Python function marked with the @triton.jit decorator. The fundamental difference lies in abstraction: while traditional CUDA code describes the work of a single thread, Triton code describes the work of an entire block.
What is the recommended block size for CUDA?
A CUDA block size of 128 to 256 threads is a good starting range, and 256 is a sensible default. Block size should always be a multiple of 32, because the warp is the unit the hardware schedules: a 250-thread block still occupies eight warps and leaves six lanes of the last warp idle. The hard ceiling is 1,024 threads per block on current NVIDIA GPUs.
How do you measure GPU kernel execution time?
Measuring GPU kernel execution time requires waiting for the GPU, because a kernel launch returns to the CPU before the work finishes. For example, on an NVIDIA L40S GPU, a vector addition returned from its launch after 25 microseconds but took 993 microseconds to complete. A reliable benchmark times on the GPU's own timeline with CUDA events, and evicts the L2 cache between runs so the numbers reflect memory rather than cache.
Latest articles
October 9, 2026
How green is Crusoe's inference engine?
October 9, 2026
Building community in the Lone Star State
October 7, 2026
GPU programming with Triton, part 1
Are you ready to build something amazing?
Contact Us