GPU 위의 GPT-2
8부에서는 GPU 경로를 손으로 직접 구축했습니다. gpu 다이얼렉트로 작성한 커널, NVVM 로어링, PTX, 그리고 메모리를 할당하고 그리드를 실행하는 드라이버 호출이 그것입니다. 9부부터 13부까지는 출력이 CPU에서 실행되는 텐서 컴파일러를 구축했습니다. 이 장에서는 이 둘을 결합합니다. 동일한 공식과 동일한 참조 검사를 갖춘, 동일한 추적된 텐서 프로그램이 NVIDIA GPU에서 실행됩니다.
해야 할 작업은 세 가지입니다. 버퍼화 이후에 남는 병렬 루프는 커널이 되어야 합니다. 출력 하나당 스레드 하나를 배정하는 단순한 매핑은 하드웨어를 낭비하므로, 행렬 곱셈에는 공유 메모리를 사용하는 스케줄이 필요합니다. 그리고 호스트 배열과 디바이스 메모리 사이의 경계를 정해야 합니다. 이 모든 것은 tinymlir/gpu.py에 들어 있으며, 이는 약 250줄짜리 모듈로 CPU 경로에서는 전혀 호출하지 않습니다.
이 장은 모델을 컴파일하는 두 번째 방법으로 마무리됩니다. 서브레이어마다 함수 하나를 두는 대신, NumPy 참조 구현의 순방향 패스 전체를 하나의 모듈로 트레이스하고, 그 모듈을 두 타깃 중 어느 쪽에든 넘깁니다.
x86_64 기반 Linux, NVIDIA 드라이버, 그리고 compute capability sm_75 이상의 그래픽 카드가 필요합니다. Google Colab의 Tesla T4면 충분합니다. 저장소에는 바인딩의 CUDA 빌드를 설치하고 아래의 모든 내용을 실행하는 노트북이 포함되어 있습니다. 이 시리즈의 다른 부분에서는 GPU가 필요하지 않습니다.
타깃이 갈라지는 지점
12부에서 다룬 파이프라인의 구조를 떠올려 보십시오. 융합은 텐서 위에서 실행됩니다. 버퍼화는 저장 공간을 할당하고 해제 연산을 삽입합니다. 그 결과는 memref 위의 linalg 연산으로 이루어진 모듈이며, 각 타깃이 받는 것이 바로 이 모듈입니다:
PassManager.parse(BUFFERIZATION_PIPELINE).run(lowered.operation)
if target == GPU:
gpu.lower(lowered)
else:
blas.rewrite(lowered)
PassManager.parse(LOWERING_PIPELINE).run(lowered.operation)
GPU 쪽도 CPU 쪽과 동일하게 치환 후 파이프라인이 이어지는 2단계 구조를 가집니다:
def outline(module):
tiled_matmuls(module)
PassManager.parse(KERNEL_PIPELINE).run(module.operation)
managed_allocations(module)
def lower(module):
outline(module)
PassManager.parse(llvm_pipeline()).run(module.operation)
outline은 하드웨어와 무관한 부분입니다. 이 부분은 GPU가 없는 머신을 포함한 모든 플랫폼의 테스트 스위트에서 실행되며, 모듈의 모든 루프가 커널 실행으로 바뀌었는지 검증합니다. lower는 PTX까지 이어서 진행하며, Linux용으로만 존재하는 MLIR의 CUDA 빌드가 필요합니다.
outline 내부의 순서가 중요합니다. matmul 치환은 루프 패스보다 먼저 와야 합니다. 그렇지 않으면 matmul이 병렬 루프로 변환되어 치환할 대상이 남지 않습니다. 그리고 둘 다 버퍼화와 버퍼 해제 이후에 와야 합니다. 손으로 작성한 커널에는 gpu.barrier가 포함되어 있는데, 해제 분석은 이것의 메모리 효과를 꿰뚫어 볼 수 없습니다. 커널이 삽입될 시점에는 저장 공간이 이미 할당되고 해제된 상태입니다.
루프에서 커널로
두 번째 단계는 기존 MLIR 패스로 구성된 파이프라인입니다:
KERNEL_PIPELINE = (
"builtin.module("
"convert-linalg-to-parallel-loops,"
"func.func(scf-parallel-loop-tiling{parallel-loop-tile-sizes=16,16 "
"no-min-max-bounds=true}),"
"func.func(gpu-map-parallel-loops),"
"convert-parallel-loops-to-gpu,"
"gpu-kernel-outlining,canonicalize)"
)
각 linalg.generic은 병렬 반복자에 대한 scf.parallel 루프 중첩이 되며, 리덕션 반복자는 그 안에서 일반적인 순차 scf.for 루프가 됩니다. 따라서 소프트맥스 행 리덕션은 행에 대한 병렬 루프이며, 그 안에 각 행을 따라가는 직렬 루프를 포함합니다. 스레드 하나가 행 하나를 처리합니다. 리덕션 대상 차원이 많아야 수천 개 원소인 GPT-2의 형상에는 이것이 올바른 매핑이며, 이 백엔드가 가진 유일한 리덕션 전략이기도 합니다.
타일링은 각 병렬 루프를 16 크기의 타일들을 도는 바깥 루프와 타일 내부를 도는 안쪽 루프로 나눕니다. 매핑 패스는 바깥 루프를 블록 차원으로, 안쪽 루프를 스레드 차원으로 표시합니다. 변환 단계는 표시된 루프 중첩을 gpu.launch로 바꾸고, 아웃라이닝은 그 본문을 gpu.module 안의 gpu.func로 옮긴 뒤 원래 자리를 gpu.launch_func로 대체합니다. (64, 256) 행렬의 GELU에 대해 이 파이프라인을 거친 후의 호스트 함수는 다음과 같습니다.
%memref = gpu.alloc host_shared () : memref<64x256xf64>
gpu.launch_func @kernel_kernel::@kernel_kernel
blocks in (%c4, %c16, %c1) threads in (%c16, %c16, %c1)
args(%c16 : index, %c0 : index, %c16 : index, %c1 : index, %c0 : index,
%arg0 : memref<64x256xf32>, %cst_2 : f32, %cst_1 : f32,
%cst_0 : f64, %cst : f64, %cst_3 : f32, %memref : memref<64x256xf64>)
memref.copy %memref, %arg1 : memref<64x256xf64> to memref<64x256xf64>
16×16 스레드로 이루어진 블록이 4×16개 모인 그리드가 행렬 전체를 덮습니다. 커널의 본문은 Part 10의 융합된 GELU 영역에서 루프 유도 변수 대신 gpu.block_id와 gpu.thread_id를 사용한 것입니다.
gpu.func @kernel_kernel(..., %arg5: memref<64x256xf32>, ..., %arg11: memref<64x256xf64>) kernel {
%block_id_x = gpu.block_id x
%thread_id_x = gpu.thread_id x
...
%6 = memref.load %arg5[%4, %5] : memref<64x256xf32>
%7 = math.powf %6, %arg6 : f32
...
%12 = math.tanh %11 : f64
...
memref.store %16, %arg11[%4, %5] : memref<64x256xf64>
gpu.return
}
no-min-max-bounds 옵션은 차원이 16의 배수가 아닐 때 타일링 패스가 안쪽 루프를 min 표현식으로 제한하지 않고, 대신 본문 안에서 조건문을 사용하도록 지시합니다. 제한된 형태는 GPU 변환이 고정된 블록 크기로 매핑할 수 없는 루프 경계를 만들어 냅니다.
테스트 스위트는 결과를 구조적으로 검사합니다. GELU, 소프트맥스, 레이어 정규화, matmul, gather, 어텐션에 대해 outline 이후의 호스트 함수는 gpu.launch_func를 포함해야 하며 scf, linalg, memref.alloc 연산은 하나도 포함하지 않아야 합니다. 반복하던 모든 것이 이제 디바이스에서 실행됩니다.
타일링된 행렬 곱셈
linalg.matmul을 같은 파이프라인에 통과시켜도 동작은 하겠지만 느릴 것입니다. 이는 출력에 대한 병렬 루프와 그 안의 에 대한 직렬 루프가 되므로, 각 스레드가 전역 메모리에서 의 행 하나 전체와 의 열 하나 전체를 읽게 됩니다. 한 블록 안의 인접한 스레드들은 서로 겹치는 데이터를 읽는데도 아무것도 공유하지 않습니다. Part 8에서 공유 메모리를 소개한 것이 바로 이런 경우를 위해서였습니다.
그래서 백엔드는 모든 부동소수점 linalg.matmul과 linalg.batch_matmul을 직접 작성한 커널 호출로 대체합니다. 이 커널은 텍스트 형태의 MLIR 템플릿으로, 피연산자의 랭크와 원소 타입에 맞게 서식화된 뒤 ir.Module.parse로 파싱되어 모듈 안으로 옮겨집니다. 배치 처리와 일부 경계 검사를 생략하면 그 구조는 다음과 같습니다.
func.func private @tiled_matmul_2d_f32(%a: memref<?x?xf32, ...>, %b: ..., %out: ...) {
%m = memref.dim %a, %i0 : ...
%k = memref.dim %a, %i1 : ...
%n = memref.dim %b, %i1 : ...
%gx = arith.ceildivui %n, %tile : index
%gy = arith.ceildivui %m, %tile : index
gpu.launch blocks(%bx, %by, %bz) in (%sx = %gx, %sy = %gy, %sz = %i1)
threads(%tx, %ty, %tz) in (%ux = %tile, %uy = %tile, %uz = %i1)
workgroup(%sa : memref<16x16xf32, #gpu.address_space<workgroup>>,
%sb : memref<16x16xf32, #gpu.address_space<workgroup>>) {
%row = arith.addi (arith.muli %by, %tile), %ty
%col = arith.addi (arith.muli %bx, %tile), %tx
%sum = scf.for %k0 = %i0 to %k step %tile iter_args(%acc = %zero) -> (f32) {
// each thread stages one element of the A tile and one of the B tile
memref.store (load %a[%row, %k0 + %tx] or zero), %sa[%ty, %tx]
memref.store (load %b[%k0 + %ty, %col] or zero), %sb[%ty, %tx]
gpu.barrier
%part = scf.for %kk = %i0 to %tile step %i1 iter_args(%s = %acc) -> (f32) {
%x = memref.load %sa[%ty, %kk]
%y = memref.load %sb[%kk, %tx]
scf.yield (%s + %x * %y)
}
gpu.barrier
scf.yield %part : f32
}
scf.if (%row < %m and %col < %n) {
%old = memref.load %out[%row, %col]
memref.store (%old + %sum), %out[%row, %col]
}
gpu.terminator
}
return
}
각 블록은 출력의 16×16 타일 하나를 맡습니다. 블록은 리덕션 차원을 16씩 나아가며 순회합니다. 매 단계마다 블록의 256개 스레드 각각이 현재 타일의 원소 하나와 현재 타일의 원소 하나를 두 개의 workgroup 버퍼에 적재하며, 범위를 벗어난 위치는 0으로 읽힙니다. 배리어가 타일을 모든 스레드에게 보이게 합니다. 그런 다음 각 스레드는 공유 메모리에서 곱 16개를 누적하고, 두 번째 배리어는 다음 단계의 적재가 아직 읽히고 있는 타일을 덮어쓰지 않도록 막습니다. 모든 전역 원소는 스레드마다 한 번이 아니라 블록마다 한 번 적재되므로, 전역 메모리 적재가 16분의 1로 줄어듭니다.
두 가지 세부 사항이 커널을 주변 프로그램과 연결합니다. linalg.matmul은 결과를 대상에 누적하므로 마지막 저장은 출력을 덮어쓰지 않고 출력에 더합니다. 빌더가 만든 0으로 채우는 linalg.fill은 그보다 먼저 별도의 작은 커널로 실행됩니다. 또한 커널은 크기, 스트라이드, 오프셋이 모두 동적인 memref를 받습니다. 치환 과정에서 각 피연산자마다 memref.cast를 삽입하므로 랭크와 원소 타입별로 커널 하나가 모델의 모든 형태를 처리하며, 슬라이싱과 전치로 생긴 스트라이드 뷰도 복사 없이 동작합니다:
%cast = memref.cast %arg0 : memref<64x256xf32> to memref<?x?xf32, strided<[?, ?], offset: ?>>
%cast_0 = memref.cast %arg1 : memref<256x32xf32> to memref<?x?xf32, strided<[?, ?], offset: ?>>
%cast_1 = memref.cast %memref : memref<64x32xf32> to memref<?x?xf32, strided<[?, ?], offset: ?>>
call @tiled_matmul_2d_f32(%cast, %cast_0, %cast_1)
배치 변형은 배치로 인덱싱되는 세 번째 그리드 차원을 추가하며, 어텐션의 헤드별 곱셈이 이를 사용합니다.
이것은 CUDA 프로그래밍 가이드에 나오는 고전적인 공유 메모리 matmul이며, 그 이상은 아닙니다. 레지스터 타일링도, 벡터화되거나 비동기적인 로드도, 텐서 코어도 없으며 타일 크기는 고정되어 있습니다. 이러한 형태에서는 cuBLAS가 몇 배 더 빠를 것입니다. 요점은 이 커널이 읽을 수 있는 IR 60줄이며, 파이프라인에서 행렬 곱셈이 아직 행렬 곱셈으로 남아 있는 유일한 지점에 삽입되고, 모델을 실행하기에는 충분하다는 것입니다.
관리 메모리
8부에서는 디바이스와의 명시적인 복사로 데이터를 옮겼습니다. 이 백엔드는 호스트와 디바이스가 같은 포인터로 접근하는 CUDA 관리 메모리를 사용하여 그러한 관리 작업을 피합니다. 컴파일러 쪽 변경은 작습니다. 버퍼화 후 남은 모든 memref.alloc은 host_shared 속성을 가진 gpu.alloc이 되고, 모든 memref.dealloc은 gpu.wait로 감싼 비동기 gpu.dealloc이 됩니다. 이것이 런타임 라이브러리가 구현하는 형태입니다:
%memref = gpu.alloc host_shared () : memref<64x32xf32>
...
%0 = gpu.wait async
%1 = gpu.dealloc async [%0] %memref : memref<64x32xf32>
gpu.wait [%1]
런타임 쪽은 같은 할당자를 감싸는 Python 클래스입니다. 이 클래스는 MLIR의 CUDA 런타임 라이브러리에 있는 mgpuMemAlloc을 host-shared 플래그와 함께 호출하고, NumPy 배열 인터페이스를 통해 포인터를 노출합니다:
class Managed:
def __init__(self, shape, dtype):
self.pointer = runtime().mgpuMemAlloc(size, None, True)
self.__array_interface__ = {
"shape": tuple(shape), "typestr": dtype.str,
"data": (self.pointer, False), "version": 3,
}
이러한 객체에 np.asarray를 적용하면 관리 메모리를 기반으로 하는 일반 NumPy 배열이 됩니다. JIT의 호출 경로는 각 입력을 관리 메모리에 배치하되 이미 그곳에 있는 배열은 건너뛰며, 출력도 그곳에 할당합니다. 한 컴파일된 서브레이어의 출력이 다음 서브레이어의 입력이 되므로, 순방향 패스 동안 잔차 스트림은 결코 디바이스를 떠나지 않습니다. 모델은 로드 시점에 가중치를 한 번 관리 메모리에 배치합니다.
그 대가는 페이지가 언제 이동할지를 프로그램이 아니라 드라이버가 결정한다는 점입니다. 커널이 쓴 뒤 호스트가 처음 접근하면 페이지 폴트가 발생하여 페이지가 다시 호스트로 옮겨집니다. 호스트가 단계마다 로짓 한 행만 읽는 이 모델에서는 이것이 올바른 절충입니다. 지연 시간에 민감한 백엔드라면 명시적인 전송과 스트림이 필요할 것이며, gpu 다이얼렉트의 비동기 토큰이 이를 표현하는 수단이 됩니다.
실행 엔진은 일반적인 러너 유틸리티와 함께 CUDA 런타임 라이브러리를 로드합니다. 각 gpu.module의 PTX는 호스트 모듈에 gpu.binary로 포함되며, 엔진이 초기화될 때 전역 생성자가 이를 드라이버에 로드합니다. 그러면 호스트 함수에서 커널을 실행하는 것은 8부에서 직접 호출했던 바로 그 런타임 라이브러리를 호출하는 것이 됩니다.
NVVM에서 PTX로
마지막 파이프라인은 아웃라인된 모듈을 실행 경계의 양쪽 모두에서 LLVM까지 내립니다:
builtin.module(
expand-strided-metadata,lower-affine,convert-scf-to-cf,
nvvm-attach-target{chip=sm_75 O=3},
gpu.module(convert-gpu-to-nvvm),
gpu-to-llvm,finalize-memref-to-llvm,
convert-arith-to-llvm,convert-func-to-llvm,convert-cf-to-llvm,convert-index-to-llvm,
reconcile-unrealized-casts,
gpu-module-to-binary{format=isa toolkit=<path to nvidia-cuda-nvcc>})
패스들은 8부에서 사용한 것과 같습니다. 타깃 속성은 칩을 지정합니다. convert-gpu-to-nvvm은 각 커널의 본문을 스레드 인덱스, 배리어, 공유 메모리를 위한 NVVM 내장 함수로 재작성합니다. gpu-to-llvm은 호스트 쪽을 재작성하여 할당, 실행, 대기를 런타임 라이브러리 호출로 바꿉니다. gpu-module-to-binary는 각 커널 모듈을 PTX로 직렬화하는데, format=isa는 결과를 SASS로 어셈블하지 않고 텍스트로 유지합니다. SASS 어셈블은 로드 시점에 드라이버가 장착된 카드에 맞게 수행합니다.
toolkit 옵션은 직렬화기가 libdevice를 가리키도록 합니다. 커널 내부의 math.tanh와 math.exp는 단일 PTX 명령어가 아니며, NVIDIA의 수학 루틴 비트코드 라이브러리에 링크됩니다. 이 프로젝트는 PyPI의 nvidia-cuda-nvcc 패키지에서 libdevice와 ptxas를 가져오므로 시스템에 CUDA를 설치할 필요가 없으며, 패키지를 임포트하고 그 위치를 조회하여 이들을 찾습니다.
ptxas를 사용할 수 있다는 것은 GPU 없이도 PTX를 검사할 수 있다는 뜻입니다. 테스트는 위의 여섯 가지 경우를 각각 하강(lowering)하고, 모든 gpu.binary를 추출하여 sm_75용으로 어셈블합니다. 지속적 통합(CI)은 이 테스트를 일반 Linux 러너에서 실행합니다. 잘못된 변환이나 누락된 libdevice 링크로 인해 생긴 형식이 잘못된 PTX는 누군가가 하드웨어를 손에 넣기 훨씬 전에 빌드를 실패시킵니다. 같은 여섯 함수에 대해 GPU 결과와 CPU 결과를 비교하는 테스트는 장치가 있을 때만 실행됩니다.
실행하기
예제나 검증 스크립트에 --gpu를 전달(패스)하십시오:
uv run python example.py "Alan Turing theorized that computers would one day become" --tokens 10 --gpu
uv run python verify.py --gpu
이를 위해 model.py에서 바뀐 것은 없습니다. embed, attend, feedforward, project 함수는 13부의 것과 같으며, 타깃은 JIT에 전달되는 키워드 인자입니다. Colab T4에서 컴파일 시간을 포함하여 10개 토큰 생성에 약 30초가 걸렸고, CPU 및 NumPy 레퍼런스와 동일한 완성 결과를 냈습니다. 검증 스크립트는 10단계 모두에서 임베딩, 모든 블록의 잔차, 로짓, 토큰 ID를 비교했습니다. 10개 토큰이 모두 일치했으며, 수집한 배열 전체에서 가장 큰 절대 오차는 약 로, CPU에서의 약 과 비교됩니다. 이 차이는 누산 순서에서 비롯됩니다. 타일링된 커널은 한 번에 16개의 곱을 고정된 순서로 더하고, BLAS는 다른 방식으로 계산합니다.
주장하는 바는 이 수치가 전부입니다. GPU 타깃은 카드 한 장에서 레퍼런스가 요구하는 출력을 만들어 내고, 중간값도 허용 오차 이내로 일치합니다. 프로덕션 런타임의 기준으로 보면 빠르지는 않습니다.
모델 전체를 하나의 커널로
서브레이어별 설계는 작은 함수 네 개를 컴파일하고 블록을 순회하는 Python 루프에서 이를 호출합니다. 이는 이 시리즈를 위한 의도적인 선택이었습니다. 각 컴파일된 함수는 출력해서 읽을 수 있을 만큼 작습니다. 컴파일러 자체가 이를 요구하지는 않습니다. 9부의 배열 프로토콜과 12부의 트리 구조 인수를 사용하면, 트레이스된 함수는 전체 매개변수 레코드를 받아 순방향 패스 전체를 담을 수 있습니다.
fused.py 모듈은 NumPy 레퍼런스를 가지고 바로 그 일을 합니다. 이 모듈의 gpt2 함수는 tiny-gpt2의 것을 그대로 붙여 넣고 데코레이터를 붙인 것입니다:
@jit
def gpt2(inputs, params, *, n_head):
x = params.words[inputs] + params.positions[range(len(inputs))]
seq_len, embedding_dim = x.shape
head_size = embedding_dim // n_head
for block in params.blocks:
ln1_mean = np.mean(x, axis=-1, keepdims=True)
ln1_variance = np.var(x, axis=-1, keepdims=True)
ln1_normalized = (x - ln1_mean) / np.sqrt(ln1_variance + 1e-5)
ln1_output = block.attention_norm.weight * ln1_normalized + block.attention_norm.bias
qkv_proj = ln1_output @ block.attention_in.weight + block.attention_in.bias
q_proj, k_proj, v_proj = np.split(qkv_proj, 3, axis=-1)
...
causal_mask = np.triu(np.ones((seq_len, seq_len), dtype=x.dtype) * -np.inf, k=1)
attention_scores = attention_scores + causal_mask
...
모든 NumPy 호출은 우리의 기호 값으로 디스패치됩니다. params는 모델 가중치의 네임드 튜플로, JIT가 배열로 평탄화한 뒤 트레이스 안에서 다시 조립합니다. np.triu 마스크는 인자 중 어느 것도 기호 값이 아니므로 NumPy 자체가 평가합니다. 그 결과 배열은 상수로 포착되어 커널의 마지막 인자로 끌어올려집니다. 이러한 마스크는 블록마다 하나씩 모두 12개이며, 각각 (T, T) 행렬입니다. 이 구현은 11부의 마스크가 을 사용한 자리에 -np.inf로 마스킹합니다. 소프트맥스를 거치면 둘 다 미래 위치에 정확히 0의 가중치를 줍니다.
트레이싱은 12개 블록에 대해 하나의 그래프를 만들어 냅니다. 최적화 전 모듈에는 linalg.generic 연산이 652개 있으며, 원소별 융합을 거치면 209개로 줄어듭니다. 49개의 linalg.matmul 연산과 24개의 linalg.batch_matmul 연산은 손대지 않은 채 통과하는데, 바로 이것이 11부의 tensor.reshape 결정이 지키려던 것입니다. CPU에서는 이들 하나하나가 GEMM 호출이 됩니다. GPU에서는 하나하나가 타일링된 커널의 실행(launch)이 됩니다.
시퀀스 길이마다 별도로 특수화하면 생성되는 토큰마다 컴파일을 한 번씩 해야 하며, 이 모듈의 컴파일 비용은 결코 작지 않습니다. 융합 모드는 토큰 시퀀스를 다음 2의 거듭제곱 길이까지 패딩하고, 마지막 실제 토큰에 해당하는 로짓 행을 읽어 옵니다.
def logits(ids, weights, config, target):
padded = np.zeros(min(1 << (len(ids) - 1).bit_length(), config.context), np.int32)
padded[: len(ids)] = ids
output = gpt2(padded, weights, n_head=config.heads, target=target)
return output[len(ids) - 1 : len(ids)]
인과 마스크는 마지막 실제 토큰 뒤의 위치가 그 토큰에 영향을 주지 않도록 보장하므로, 패딩을 해도 우리가 읽는 행에는 아무런 변화가 없습니다. 열 개의 토큰으로 된 프롬프트로 열 개의 토큰을 생성하려면 길이 16과 32가 필요하므로, 컴파일은 열 번이 아니라 두 번으로 충분합니다.
uv run python example.py "Alan Turing theorized that computers would one day become" --tokens 10 --fused
uv run python verify.py --fused
uv run python example.py "..." --tokens 10 --gpu --fused
세 가지 모두 같은 문장을 생성합니다. 융합 모드에서는 순방향 패스가 캡처할 중간값이 없는 하나의 함수이므로, 검증 스크립트는 최종 로짓과 선택된 토큰만 비교합니다. 노트북에서는 융합 실행이 서브레이어별 실행보다 대략 세 배 오래 걸렸으며, 그 시간은 거의 전부 LLVM이 큰 모듈 두 개를 컴파일하는 데 쓰였습니다. T4에서는 두 모드의 소요 시간이 거의 같았습니다.
융합 모드가 보여 주는 것은 성능상의 결과가 아니라 컴파일러의 한 가지 속성입니다. 컴파일된 프로그램은 아무것도 수정하지 않은 레퍼런스 구현 그대로입니다. 이 시리즈는 각 부분을 따로 떼어 살펴볼 수 있도록 고유한 연산자 집합을 갖춘 프런트엔드를 네 개 장에 걸쳐 구축했습니다. 같은 프런트엔드에 기존 NumPy 프로그램을 주면, 그 프로그램 전체를 트레이싱합니다.
구조가 가져다준 것
GPU 타깃을 추가하면서 바뀌지 않은 것들을 되돌아보십시오. 텐서 언어, 빌더, 최적화 파이프라인, 버퍼화, 그리고 ops.py와 model.py의 모든 공식은 CPU 장들에서 다룬 것과 동일합니다. 타깃은 키워드 인자 하나일 뿐입니다. 추가된 것은 기존 패스들을 조합한 루프-커널 변환 파이프라인, 직접 작성한 커널 하나, 그리고 백 줄 정도의 메모리 배관 코드뿐입니다.
이것이 가능했던 것은 파이프라인이 가능한 한 오래 구조를 보존했기 때문입니다. 행렬 곱셈은 융합과 버퍼화를 거치는 동안에도 이름 있는 연산으로 남아 있었으므로, 각 타깃이 자신의 구현으로 대체할 수 있었습니다. 병렬 반복자는 병렬로 남아 있었으므로, 패스가 이를 스레드에 매핑할 수 있었습니다. 할당은 명시적으로 남아 있었으므로, 관리 메모리로 돌릴 수 있었습니다. 앞선 장들에서 미뤄 둔 각각의 결정이 여기서는 이음매가 되었습니다.
한계도 그만큼 분명히 드러납니다. 리덕션 전략도 하나, 타일 크기도 하나, 커널의 원소 타입도 하나뿐이며, 키/값 캐시도 없고 점수를 실체화하지 않는 어텐션 스케줄도 없습니다. 프로덕션 컴파일러라면 이 각각을 형상별, 디바이스별로 결정할 것입니다. 이 컴파일러는 그것들을 읽기 쉽게 만들고, 실제 하드웨어에서 결과를 레퍼런스와 대조해 검증합니다.