From b471157c677b2e1788e6d4bb22d9bb247dd66d26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 03:41:04 +0000 Subject: [PATCH 1/2] Add Korean translation draft for torch-attention-profile --- _posts/2026-07-10-torch-attention-profile.md | 471 ++++++++++++++++++ .../thumbnail.png | Bin 0 -> 41647 bytes 2 files changed, 471 insertions(+) create mode 100644 _posts/2026-07-10-torch-attention-profile.md create mode 100644 assets/images/blog/posts/2026-07-10-torch-attention-profile/thumbnail.png diff --git a/_posts/2026-07-10-torch-attention-profile.md b/_posts/2026-07-10-torch-attention-profile.md new file mode 100644 index 00000000..eca84967 --- /dev/null +++ b/_posts/2026-07-10-torch-attention-profile.md @@ -0,0 +1,471 @@ +--- +layout: post +title: "파이토치의 프로파일링(3부): 어텐션이 전부다" +author: dailybot +categories: [Translation, HuggingFace] +image: assets/images/blog/posts/2026-07-10-torch-attention-profile/thumbnail.png +authors: + - user: ariG23498 + - user: sergiopaniego + - user: sayakpaul + - user: ror +slug: "torch-attention-profile" +source_url: "https://huggingface.co/blog/torch-attention-profile" +source_published_date: "2026-07-10" +source_published_at: "2026-07-10T00:00:00+00:00" +locale: "ko" +translation_status: "draft" +translator: "openai" +--- + +* TOC +{:toc} + +_이 글은 Hugging Face 블로그의 [Profiling in PyTorch (Part 3): Attention is all you profile](https://huggingface.co/blog/torch-attention-profile)를 한국어로 번역한 글입니다._ + + + +--- + + + +# 파이토치의 프로파일링(3부): 어텐션이 전부다 + +![Thumbnail of the blog post](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/profile-3-thumbnail.png) + +
+ +

+ This is the third post of Profiling in PyTorch, a series where we slowly build the skill of reading profiler traces and use it to drive optimization: +

+ +
    +
  1. + + Profiling in PyTorch (Part 1): A Beginner's Guide to torch.profiler + +
  2. +
  3. + + Profiling in PyTorch (Part 2): From nn.Linear to a Fused MLP + +
  4. +
  5. + + Profiling in PyTorch (Part 3): Attention is all you profile + + (current) +
  6. +
+ +
+ +시리즈 "Profiling in PyTorch"는 프로파일러 트레이스와 표를 읽는 데 익숙해지게 만드는 것을 목표로 한다. [Part 1](https://huggingface.co/blog/torch-profiler)에서는 덧셈과 곱셈 같은 기본 수학 연산을 프로파일링했다. 프로파일러 표가 핫스팟을 드러내는 방식과 프로파일러 트레이스가 알고리즘이 시간에 따라 실행되는 순서를 어떻게 보여주는지 보았다. + +[Part 2](https://huggingface.co/blog/torch-mlp-fusion) 안에서 위의 덧셈과 곱셈을 torch 선형 계층으로 포장했고, 그 위에 여러 개의 선형 계층을 차례로 쌓아(다층 퍼셉트론) 그것을 프로파일링했다. 그 과정에서 융합된 커널과 수작업으로 튜닝된 커널도 함께 프로파일링했다. + +트랜스포머 아키텍처의 관점에서, 프로파일링의 다음 논리적 단계는 또 다른 기본 알고리즘인 어텐션이다. 어텐션은 이차 시간 복잡도로 악명 높지만, 이를 완화하고 빠르게 만드는 똑똑한 트릭이 많이 존재한다. 여기서의 목표는 모든 트릭을 자세히 다루는 것이 아니다. 대신 각 트릭이 프로파일러 아래에서 어떻게 다르게 보이는지 보는 것이다. + +> [!NOTE] +> 이 블로그 포스트의 스크립트는 여기에서 실행됩니다: [`04_a_naive_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_a_naive_attention.py), [`04_b_inplace_ops_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_b_inplace_ops_attention.py), [`04_c_sdpa_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_c_sdpa_attention.py), 그리고 [`04_d_kernels_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_d_kernels_attention.py). 이전과 마찬가지로 읽으면서 코드를 따라가려면 별도의 탭에서 여는 것이 도움이 됩니다. 이 스크립트를 실행하기 위해 `NVIDIA A100-SXM4-80GB` GPU를 사용합니다. 허깅페이스 인프라에서 GPU를 설정하고 [Dev Mode with Spaces](https://huggingface.co/docs/hub/spaces-dev-mode)를 사용해 스크립트를 실험하는 것은 정말 쉽습니다. 또한 [Hugging Face Jobs pipeline](https://huggingface.co/docs/huggingface_hub/en/guides/jobs)로도 스크립트를 실행할 수 있습니다. + +## 나이브 어텐션 {#section-1} + +어텐션은 쿼리(`q`), 키(`k`), 값(`v`)으로 작동합니다. 이들 간의 상호 작용은 간단한 일련의 단계로 작성할 수 있다: + +1. 어텐션 스코어를 만든다 `scores`: `matmul(q, k.T)` +2. 스코어를 스케일한다: `scores * scale` +3. 스코어에 인과 마스크를 적용한다: `scores.masked_fill(mask, "-inf")` +4. 소프트맥스(softmax)로 스코어를 정규화하여 어텐션 가중치를 얻는다 `attn`: `softmax(scores)` +5. 그 가중치로 값을 재가중한다: `matmul(attn, v)` + +그래서 어텐션은 실질적으로 원시 연산들의 모음이다. 그 중 일부는 이미 알고 있는(matmul) 연산이고, 나머지는 쉽게 발견할 수 있다. PyTorch로 네이브 어텐션 모듈을 작성하고 이를 프로파일링해 보자. + +```py +class NaiveCausalAttention(nn.Module): + def __init__(self, head_dim): + super().__init__() + self.scale = 1.0 / math.sqrt(head_dim) + + def forward(self, q, k, v, mask): + scores = torch.matmul(q, k.transpose(-2, -1)) + scores = scores * self.scale + scores = scores.masked_fill(mask, float("-inf")) + attn = torch.softmax(scores, dim=-1) + out = torch.matmul(attn, v) + return out +``` + + +트레이스를 열기 전, 보통의 연습대로 우리가 볼 수 있을 것을 추측해 보자. 이 모듈의 `forward`를 트레이스하면, 우리는 다음을 기대한다: + +- 매트멀 커널( `q . k.T` ) +- 곱 커널(스케일링) +- 마스킹 연산 +- 소프트맥스 커널 +- 매트멀 커널( `atten . v` ) + +```bash +uv run 04_a_naive_attention.py +uvx trace-util -f traces/ -b /traces +``` + + +| ![CPU lane of the naive attention profiler trace, with the `attn_fwd` block expanded to show its matmul, mul, masked_fill and softmax operations](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cpu-profile-naive.png) | +| :--: | +| 그림 1: 네이브 어텐션의 프로파일 트레이스의 CPU 레인에 표시된 이산 연산들을 강조 | + +그림 1은 프로파일의 CPU 레인( GPU 레인은 우리를 압도하지 않도록 접어 두었습니다)을 보여준다. 내부 `attn_fwd`(주석이 달린 순전파 호출)에서 우리가 추정한 정확한 연산들을 볼 수 있다. 매트멀은 이제 친숙한 친구이고, 새로 등장한 연산들은 쉽게 포착된다: + +- `mul`: 스케일링 +- `masked_fill`: 인과 마스킹 +- `softmax`: 소프트맥스 커널 + +이제 GPU 레인을 펼쳐 실제로 어떤 커널이 실행되었는지 확인해 보자. + +| ![Profiler trace of naive attention showing the CPU lane above the GPU lane, with each `attn_fwd` step mapping to a cluster of GPU kernels](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/gpu-profile-naive.png) | +| :--: | +| 그림 2: 네이브 어텐션의 프로파일 트레이스의 GPU 레인과 CPU 레인을 함께 보여주며, 하나의 프로파일러 스텝에 해당하는 커널들을 강조 | + +그림 2는 GPU 레인 옆에 있는 CPU 레인을 보여준다. GPU 레인에서 하나의 `attn_fwd` 블록을 확대하여 커널을 하나씩 살펴보자. + +| ![Zoomed-in GPU lane of naive attention showing the individual kernels for one step: two matmuls, a mul, a memory copy, a masking kernel and a softmax](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-naive.png) | +| :--: | +| 그림 3: 네이브 어텐션 구현의 프로파일 트레이스의 확대된 GPU 레인 | + +그림 3은 한 프로파일러 스텝의 개별 커널을 읽어보게 해 준다: + +1. matmul (쿼리와 키) +2. mul (스케일링) +3. 메모리 복사 🤔 +4. 인과 마스킹 +5. softmax (어텐션 가중치를 산출) +6. matmul (어텐션 가중치와 값) + +다섯 가지가 예상된다. 메모리 복사는 이상한 하나인데, 이게 어디서 나온 걸까? 힌트는 PyTorch에 in-place 연산이 있다는 점이다. 텐서에 일반적인(out-of-place) 방식으로 연산하면, PyTorch는 종종 복사를 만들고 요청된 연산을 그 복사에 적용한 뒤 복사본을 반환한다. 연산 순서를 보면 여기의 범인은 [`masked_fill`](https://docs.pytorch.org/docs/2.13/generated/torch.Tensor.masked_fill.html)이다. + +이것을 in-place 연산으로 바꾼다면 어떨까? + +## 인플레이스 인과 마스크가 적용된 나이브 어텐션 {#section-2} + +변경하는 것은 `masked_fill`에서 `masked_fill_`로의 교환뿐이다(뒤의 밑줄은 PyTorch의 in-place 연산 표기법에 주의). 같은 스크립트를 실행한다. + +```diff +def forward(self, q, k, v, mask): + # q, k, v: [batch, heads, seq, head_dim] + scores = torch.matmul(q, k.transpose(-2, -1)) # [batch, heads, seq, seq] + scores = torch.mul(scores, self.scale) +- scores = scores.masked_fill(mask, float("-inf")) ++ scores.masked_fill_(mask, float("-inf")) + attn = torch.softmax(scores, dim=-1) + out = torch.matmul(attn, v) # [batch, heads, seq, head_dim] + return out +``` + + +트레이스를 살펴보고 무언가 바뀌었는지 보자. + +```bash +uv run 04_b_inplace_ops_attention.py +uvx trace-util -f traces/ -b /traces +``` + + +| Type | CPU 스트림 | +| :--: | :--: | +| 그림 4: 나이브 마스킹 | ![CPU lane of naive attention with out-of-place `masked_fill`, showing several dispatch ops for the masking step](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cpu-profile-naive.png) | +| 그림 5: 인플레이스 마스킹 | ![CPU lane of naive attention with in-place `masked_fill_`, showing fewer dispatch ops for the masking step](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cpu-profile-inplace.png) | + +인플레이스 버전(Figure 5)은 마스킹 단계 안에 CPU 연산을 훨씬 적게 포장한다. 이는 고무적인 신호다. GPU 레인을 펼쳐 무슨 일이 일어났는지 확인해 보자. + +| Type | GPU 스트림 | +| :--: | :--: | +| 그림 6: 나이브 마스킹 | ![GPU kernels for naive attention including a separate Memcpy kernel before the masking](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-naive.png) | +| 그림 7: 인플레이스 마스킹 | ![GPU kernels for naive attention with in-place masking, with the Memcpy kernel gone](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-inpace.png) | + +GPU 레인에서 `Memcpy` 커널은 완전히 사라졌다(그림 6, 7). 한 줄의 변경으로 순전파마다 커널 하나를 제거했다. 이것만으로는 큰 차이처럼 보이지 않을 수 있지만, 이건 단일 어텐션 연산에 불과하다. 트랜스포머 기반의 대형 모델(LLMs, 확산 모델 등) 맥락에서 이는 레이어당 한 번 반복되며 레이어가 많으므로 절약 효과가 빠르게 누적된다(그리고 그것이 당신의 월급 인상에 기여한다면, 우리와 최소 10%를 나누는 것이 공정하다고 느낀다). + +> [!NOTE] +> Out-of-place는 PyTorch의 기본 설정이다. 그래디언트를 계산하려면 autograd가 순전파에서 본 텐서 값을 기억해야 하며, 많은 역전파 공식이 그 값을 재사용하기 때문이다. in-place 연산은 메모리의 그 값을 덮어써서 역전파가 잘못된 수치를 읽게 된다. `forward`를 `torch.no_grad` 아래에서 실행하기 때문에 우리에게는 in-place가 안전하며, 역전파가 없고 손상될 여지도 없다. 또한 in-place 연산은 시간 절약뿐 아니라(추가 복사 없이) 메모리 절약도 가능하므로 로짓처럼 큰 텐서에 특히 좋다. + +## 스케일드 닷 프로덕트 어텐션 {#section-3} + +우리는 막 원시 연산(primitives)으로 어텐션을 구성했고, 심지어 `Memcpy`까지 제거했다. 다행히 PyTorch 팀이 이 모든 것을 우리를 위해 처리했고, 파이프라인 전체를 단 한 줄의 함수로 패키지했다: + +```py +from torch.nn import functional as F + +F.scaled_dot_product_attention(q, k, v, is_causal=True) +``` + + +이 한 줄이 우리의 직접 작성한 모듈을 대체하고, `is_causal=True`는 수동으로 마스크를 빌드하는 수고도 덜어준다. 이 한 호출이 얼마나 많은 것을 숨기는지 음미할 가치가 있다. 그리고 그것은 코드 줄 이상으로도 숨긴다. 스케일드 닷 프로덕트 어텐션(SDPA)은 단일 구현을 가지지 않는다. 그 이면에서 여러 백엔드 중 하나로 _디스패치_되어 입력들(dtype, head dimension, mask, 하드웨어 등)을 지원하는 가장 빠른 백엔드를 선택한다. + +[official SDPA tutorial](https://docs.pytorch.org/tutorials/intermediate/scaled_dot_product_attention_tutorial.html)가 이 선택 과정을 안내하고, 백엔드 목록 자체는 `torch.nn.attention.SDPBackend` 열거형에 나열되어 있다: + +```python +from torch.nn.attention import SDPBackend + +BACKENDS = { + "math": SDPBackend.MATH, + "flash": SDPBackend.FLASH_ATTENTION, + "efficient": SDPBackend.EFFICIENT_ATTENTION, + "cudnn": SDPBackend.CUDNN_ATTENTION, +} +``` + + +일반적으로 SDPA는 우리를 위해 선택하지만, `torch.nn.attention.sdpa_kernel` 컨텍스트 매니저로 특정 백엔드를 고정할 수 있다. 이것이 우리 스크립트에서 하는 일이다. 이를 통해 각 백엔드를 독립적으로 프로파일하고, 트레이스에서 어떻게 다르게 나타나는지 읽어볼 수 있다. 하나씩 살펴보자. + +### 수학 백엔드 + +```bash +uv run 04_c_sdpa_attention.py --backend math +uvx trace-util -f traces/ -b /traces +``` + + +아무 것도 열기 전에 추측해 보자. 이 모듈의 수작업 어텐션(matmul, mul, mask, softmax, matmul)을 한 줄로 대체했으니 트레이스가 _간단하고 빨라질 것이다_. 커널 수가 적고, CPU 디스패치가 줄어들며, 어쩌면 융합 커널이 나올 수도 있다. 우선 프로파일러 표를 확인하자. + +| Metric | Where to look? | Naive in-place | SDPA math | +| :--: | :--: | :--: | :--: | +| `*_fwd` CUDA time avg | The "CUDA time avg" column for the `*_fwd` op | 1.955 ms | 7.239 ms | +| Self CUDA time total | At the bottom of the profiler table | 7.194 ms | 27.279 ms | + +이것이 우리의 첫 번째 놀라움이다. 한 줄이 `3.7x` 느리다. + +| | Profiler Trace | +| :--: | :--: | +| 그림 8: 나이브 인플레이스 어텐션의 프로파일러 트레이스가 하나의 순전파에 대해 다섯 개의 GPU 커널 런치를 보여줌 | ![GPU lane of naive in-place attention with five kernel launches for one forward pass](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/inplace-kernel-launches.png) | +| 그림 9: SDPA 수학 백엔드의 프로파일러 트레이스가 단일 어텐션 순전파에 대해 20개의 GPU 커널 런치를 보여줌 | ![GPU lane of the SDPA math backend with twenty kernel launches for a single attention forward pass](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/math-kernel-launches.png) | + +트레이스를 열어보면(그림 9) 경보가 울리는 이유를 보여준다. 수학 백엔드가 순전파당 `20`개의 GPU 커널을 실행하는 반면, 네이브 어텐션 구현(Figure 8)에서 실행된 `5`은 아니다. 이는 우리가 예상한 것의 정반대다. 왜 이런 일이 일어나는지 알아보자. + +#### 텐서 코어가 남아 있다 + +다음과 같은 습관을 사용해 커널 이름을 읽는 방법을 배웠다. 이 습관을 이 자리에 적용해 보자: + +| Run | matmul kernel | +| :--: | :--: | +| 그림 10: 네이브 어텐션 | ![Matmul kernel name for naive attention in Perfetto, carrying the s16816 bfloat16 Tensor-core GEMM signature](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cuda-core-kernels.png) | +| 그림 11: 수학 백엔드를 사용한 SDPA | ![Matmul kernel name for the SDPA math backend, carrying the sgemm FP32 CUDA-core signature](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/tensor-core-kernels.png) | + +우리가 이 트레이스를 포착하는 데 사용한 A100은 [Tensor Cores](https://www.nvidia.com/en-us/data-center/tensor-cores/)와 함께 제공되며, 행렬 곱셈을 가속하기 위한 특화된 하드웨어로 일반 CUDA 코어보다 훨씬 빠른 것으로 알려져 있다. 이것이 여기서 왜 중요한지 보려면 GPU 내부에 무엇이 있는지 알아야 한다. 스트리밍 멀티프로세서(SM)는 GPU의 계산 유닛이고, 각 SM은 두 종류의 산술 유닛(CUDA 코어와 텐서 코어)을 가진다. CUDA 코어는 범용적이고 한 번에 소수의 원소를 처리하며, 텐서 코어는 작은 행렬 타일을 한 명령으로 곱하고 누적한다. 따라서 질문은 간단하다. 각 백엔드가 실제로 빠른 경로를 사용하고 있는가? + +커널 이름이 그것에 답한다. 네이브 커널(Figure 10)의 `s16816`은 `bfloat16` 텐서 코어 매트멀의 시그니처이며( `16x8x16` 텐서 코어 명령), 따라서 네이브 버전은 빠른 경로에 있다. `sgemm`(Figure 11)는 일반 CUDA 코어에서 실행되는 단정도 싱글 프리시전(`FP32`) 매트멀이다. 다시 말해, 수학 백엔드는 텐서 코어를 전혀 건드리지 않는다: 속도와 수치 정확도 사이의 trade-off를 위해 텐서를 `FP32`로 업캐스트하고(입력이 `bf16`인 경우에도 데이터 이동을 두 배로 늘림) 느린 CUDA 코어로 되돌아간다. + +#### 인과 마스크가 생성된다 + +네이브 버전에서는 인과 마스크를 한 번 만들고 재사용했다. 이 버전에서는 `is_causal=True`를 넘겨 주었고, 수학 백엔드가 매 호출마다 하나를 생성해 주었다. CPU 레인에서 그것이 어떻게 일어나는지 지켜볼 수 있다: + +| ![CPU lane of the SDPA math backend showing the ops that rebuild the causal mask: aten::ones, aten::tril, aten::scalar_tensor, aten::fill_ and aten::where](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/mask-math.png) | +| :--: | +| 그림 12: 마스킹 연산에 대한 CPU 레인 표시 | + +다음은 그림 12에서 보이는 모습이다 + +```bash +aten::ones -> aten::tril build a [seq, seq] lower-triangular matrix +aten::scalar_tensor -> aten::fill_ make the -inf fill value +aten::where turn it into an additive bias (0 or -inf) +``` + + +GPU에서는 이것이 `triu_tril_kernel` 하나, 여러 개의 `where` 커널, 그리고 하나의 `add_`로 나타난다. 마스크를 생각하는 것을 중단하게 해 주는 편의 플래그는 작업 자체를 제거한 것이 아니라 한 층 아래로 옮겼으며, 순전파마다 마스크가 처음부터 새로 빌드된다. + +#### 안전한 소프트맥스 + +직접 작성한 버전은 일반 `aten::softmax`이라고 불렀다. 수학 백엔드는 `aten::_safe_softmax`을 호출하고, 차이가 또 다른 커널들로 나타난다(그림 13): + +| ![GPU lane of the SDPA math backend showing the extra kernels that aten::_safe_softmax launches compared to a plain softmax](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/safe-softmax-extra-kernels.png) | +| :--: | +| 그림 13: 일반 소프트맥스에 비해 추가 커널을 강조하는 Safe softmax | + +전체가 마스킹된 행(모든 항목이 `-inf`인 행)은 일반 소프트맥스가 `exp(-inf)/sum(exp(-inf)) = 0/0 = NaN`를 계산하게 만들 것이다. `_safe_softmax`는 바로 그것을 방지한다. 우리의 네이브 커널은 그 경우를 전혀 처리하지 않았고, 그 귀퉁이 케이스에서 조용히 `NaN`를 만들어 냈을 것이다. + +#### 그래서 수학 백엔드는 무엇을 위한가? + +종합하면, 수학 백엔드는 참조 구현이다. 이는 원시 ATen 연산으로 어텐션을 dtype 안전하고 NaN 안전하게 분해하는 직관적 구현이다. 본질적으로 우리가 손으로 작성한 네이브 어텐션과 같지만 더 조심스럽다. 그 신중함이 바로 그것을 매우 느리게 만든다. + +그 임무는 빠르게 작동하는 것이 아니라 항상 작동하는 것이다. 이것이 완벽한 베이스라인이 된다. 우리가 다음에 프로파일링하는 모든 백엔드(Flash, Efficient, cuDNN)는 `20` GPU 커널들을 본질적으로 하나의 융합 커널로 압축해서 bf16으로 유지하고 중간 행렬을 전혀 만들어 내지 않도록 하려 한다. + +### 효율적인 백엔드 + +```bash +uv run 04_c_sdpa_attention.py --backend efficient +uvx trace-util -f traces -b /traces +``` + + +| ![Profiler trace of the SDPA efficient backend showing a single fused fmha_cutlassF attention kernel per forward](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/efficient-backend.png) | +| :--: | +| 그림 14: sdpa의 효율적 백엔드에 대한 프로파일러 트레이스 | + +수학 백엔드가 한 프로파일러 스텝에 20개의 커널을 실행했다면, 효율적 백엔드는 단 하나의 `fmha_cutlassF_bf16_aligned_64x64_rf_sm80`만 실행한다(그림 14에서 볼 수 있다). + +커널의 이름의 의미를 해석해 보자: + +- `fmha` (퓨즈드 멀티헤드 어텐션): 어텐션의 모든 원시 연산이 하나의 연산으로 융합되어 있다. +- `cutlassF`: CUTLASS를 기반으로 한 텐서-코어 GEMM용 템플릿, forward에 대해 `F`. +- `bf16_aligned`: bf16으로 실행( FP32 업캐스트 없음, 수학과 다름). +- `64x64`: 타일 크기. +- `rf` (레지스터 파일): 작동 집합이 레지스터에 보관되어 칩에서 가장 빠른 메모리이다. +- `sm80`: Ampere에 맞춰 컴파일(A100의 컴퓨트 수준 8.0). + +메타(Meta)의 [xformers](https://github.com/facebookresearch/xformers) 라이브러리에서 나온 메모리 효율적인 어텐션 커널이며 PyTorch로 업스트림되었다. 사람들이 "xformers 백엔드"라고 말할 때 이 `fmha_cutlassF` 커널이 바로 그것이다. + +### Flash 백엔드 + +```bash +uv run 04_c_sdpa_attention.py --backend flash +uvx trace-util -f traces -b /traces +``` + + +| ![Profiler trace of the SDPA flash backend](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/flash-backend.png) | +| :--: | +| 그림 15: flash 백엔드 트레이스, forward당 하나의 융합 `pytorch_flash` 커널 | + +`void pytorch_flash` 커널(Figure 15)은 [FlashAttention-2](https://arxiv.org/abs/2307.08691)(Tri Dao의 구현)이며 PyTorch에 벤더링되어 있다. + +이제 트레이스를 더 읽기 전에, 지금까지 당신이 물어야 할 질문에 답하는 것이 가치 있다: _왜 "flash"라는 백엔드가 존재하고, 그것이 왜 이렇게 중요한가?_ + +#### 왜 flash 어텐션이 존재하는가? + +잠시 수학 백엔드으로 돌아가 보자. 실제 문제는 20개의 커널 수가 아니라, 그 커널들이 서로에게 넘겨주는 데이터였다. + +1단계는 전체 스코어 행렬 `attn = q . k.T`을 빌드하는데, 이는 head당 `[seq, seq]`이다. 시퀀스 길이가 4096인 경우 단일 head에 대해 `4096 x 4096 ≈ 16 million`개의 숫자이다. 그 행렬은 HBM(그래픽 카드의 주 메모리)로 기록되며 공간이 충분하면 기록한다. 그런 다음 다시 읽어 시그널링을 위해 스케일하고, 마스크를 위해 다시 쓰고, 다시 소프트맥스를 위해 읽는 식으로 계속된다. 어텐션의 비용은 이 HBM으로의 왕복 트래픽에 의해 좌우되며, 매트멀 그 자체보다는 이 트래픽에서 더 많은 부분을 차지한다. + +FlashAttention은 정확히 이것을 겨냥한다. 전체 `s` 행렬을 먼저 계산한 뒤 감소시키는 대신, **타일 단위로** `k`와 `v`를 따라가며 진행하고, 진행 방향에서 소프트맥스를 유지하는(“온라인 소프트맥스” 트릭) 방식으로 출력도 타일 하나씩 누적한다. 전체 `[seq, seq]` 스코어 행렬은 **HBM에 한 번도 기록되지 않고**, 칩 안에만 존재한다. 이것이 전체 어텐션 파이프라인을 bf16으로 유지되는 하나의 융합 커널로 단번에 축소시키는 유일한 아이디어다. + +#### 왜 프로파일러에서 플래시가 "잘못 보이는"가? + +| ![Perfetto footprint of the flash kernel reporting an estimated achieved occupancy of 13%](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/flash-occupancy.png) | +| :--: | +| 그림 16: 플래시 커널의 점유율 추정치가 13%로 보이는 모습 | + +여기서 플래시가 프로파일러의 발자국을 읽는 사람들을 놀라게 한다. 플래시는 가장 빠른 백엔드인데도 프로파일러가 매우 낮은 점유율로 보고한다(그림 16에 표시). 왜 그것이 괜찮은지 보려면 세 가지 빠른 정의가 필요하다. + +GPU 커널은 본질적으로 다수의 작은 실행 유닛(스레드)에 의해 실행되는 일련의 명령이다. 이 개별 실행 유닛들은 변수 로딩, 더하기, 저장 등을 처리한다. 각 커널마다 많은 스레드를 실행하고, 이를 추적하기 위해 블록으로 묶는다. + +블록은 스트리밍 멀티프로세서(SM)에 스케줄된다. 두고 온 블록은 한 SM에 완전히 남아 있으며, SM이 충분한 자원을 갖고 있으면 여러 블록을 한 번에 수용할 수 있다. 이러한 자원에는 레지스터, 공유 메모리, 최대 상주 스레드 수, 최대 상주 워프 수가 포함된다. 따라서 커널이 낮은 점유율(occupancy)을 가진다고 할 때, 이는 각 SM이 이론상 지원할 수 있는 것보다 더 적은 워프를 갖고 있음을 의미한다. + +> [!TIP] +> 스레드, 블록, 그리드 등에 대해 더 알고 싶다면 여기 [great resource](https://huggingface.co/blog/mi300kernels#a-quick-introduction-to-the-mi300x)가 있다. + +트레이스에서 플래시 커널을 클릭하면 그 발자국이 이야기를 들려준다(그림 17). + +| ![Resource footprint of the pytorch_flash kernel in Perfetto, showing a high per-thread register count and large shared memory usage per block](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/flash-reg-count.png) | +| :--: | +| 그림 17: 커널 발자국, 블록당 레지스터와 공유 메모리가 많은 편 | + +플래시는 블록당 많은 스레드와 상당량의 공유 메모리를 필요로 한다. 예를 들어 블록이 128개의 스레드이고 각 스레드가 255개의 레지스터를 사용하면 그 블록은 `128 × 255 = 32,640` 레지스터가 필요하다. 65,536 레지스터를 가진 Ampere SM에서 그런 두 블록만 한 번에 맞물린다. 각 128-스레드 블록은 `128 / 32 = 4` 워프를 가지므로 두 블록은 상주 워프가 겨우 8개다. 최대 64 상주 워프에 비하면 대략 13%의 점유율이다. 플래시는 최적화가 부족해서가 아니라, 각 블록이 온칩 자원을 의도적으로 매우 “무겁게” 사용하기 때문이다. + +그리고 그것이 핵심이다. 높은 점유율은 많은 워프를 실행 대기 중으로 유지해 지연을 숨기는 데 도움이 되지만, 작업 자체를 더 효율적으로 만들진 않는다. 플래시는 어태션 타일을 온칩에 유지하고 데이터를 적극적으로 재사용하며, 전역 메모리에 전체 어텐션 매트릭스를 한 번에 구체화시키지 않기 위해 의도적으로 그 레지스터와 공유 메모리를 사용한다. + +### cuDNN 백엔드 + +```bash +uv run 04_c_sdpa_attention.py --backend cudnn +uvx trace-util -f traces -b /traces +``` + + +| ![Profiler trace of the SDPA cuDNN backend showing a single cudnn_generated attention kernel per forward](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cudnn-backend.png) | +| :--: | +| 그림 18: cuDNN 백엔드 트레이스, 순전파당 하나의 생성된 어텐션 커널 | + +이 시점의 패턴은 익숙하다. 플래시와 효율처럼 cuDNN은 순전파당 하나의 융합된, 플래시 스타일의 커널을 제공한다(그림 18). 그래서 자연스러운 질문은: **플래시가 이미 어텐션을 융합하는데, 왜 파이토치는 또 다른 플래시 백엔드를 제공하는가?** 그 답은 _커널을 누가 쓰고 어떻게 빌드했는가_의 차이에 있으며, 그 차이가 트레이스를 다르게 보이게 만든다. + +#### cuDNN 커널은 어떻게 다른가 + +Flash와 Efficient는 PyTorch에 벤더링된 **고정된, 미리 컴파일된 커널**이다. 매번 같은 바이너리를 받는다. cuDNN은 NVIDIA의 자체 딥러닝 라이브러리이며, 그 주의 커널은 **주어진 문제에 맞춰 생성되고 튜닝된다**. 이는 고정된 cuBLAS 바이너리보다 더 코드 제너레이션에 가깝다. 그 매우 긴 커널 이름에서 그것을 바로 확인할 수 있다: + +```bash +cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x1_cga1x1x1_kernel0_0 +``` + + +- `cudnn_generated`: 미리 배송되는 바이너리가 아니며 cuDNN에 의해 생성되었다. +- `flash_fprop`: 플래시 어텐션 스타일의 순전파다. 따라서 알고리즘은 플래시 백엔드의 계열과 같다. +- `wmma_f16`: 16비트 부동 소수 파이프라인에서 WMMA API를 사용하고, 텐서 코어 경로를 따른다. +- `knob_6`: cuDNN은 미리 조정된 구성("knobs") 세트에서 선택한다. 다양한 형태가 서로 다른 knob을 선택하게 한다. 이는 cuBLAS가 타일 변형을 선택하는 방식과 유사하다. +- `128x64x64`: 그가 선택한 타일 차원. + +그 하나의 사실, 문제당 생성된 것,이 트레이스에서 보이는 다른 이상한 점들을 설명한다. + +1. No transposes: CPU 레인은 `_cudnn_attention_forward`에서 바로 곧바로 몇 개의 `aten::empty` 할당으로 이어진 다음 커널로 가며, `aten::transpose`는 전혀 없다(Figures 19, 20 및 21). Flash와 Efficient는 텐서를 재구성하기 위해 메타데이터 전치를 네 번 삽입하는 반면, cuDNN은 생성기가 그 레이아웃에 맞는 커널을 생성하기 때문에 네이티브 `[B, H, S, D]` 레이아웃을 직접 사용한다. + + | Variant | Trace | + | :--: | :--: | + | Figure 19: Flash | ![CPU lane of the flash backend showing four aten::transpose ops before the fused attention kernel](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/flash-transpose.png) | + | Figure 20: Efficient | ![CPU lane of the efficient backend showing four aten::transpose ops before the fused attention kernel](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/efficient-trasnpose.png) | + | Figure 21: cuDNN | ![CPU lane of the cuDNN backend going straight to aten::empty allocations and the kernel, with no transpose ops](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cudnn-backend.png) | + +2. It launches through `cuLaunchKernelEx`, not `cudaLaunchKernel`: Every other kernel in this whole series went through the runtime API `cudaLaunchKernel`. cuDNN uses the driver-level _extended_ launch, which carries launch attributes (Figure 22). + + | ![CPU lane of the cuDNN backend showing the cuLaunchKernelEx driver-level launch instead of cudaLaunchKernel](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cudnn-launch.png) | + | :--: | + | Figure 22: CPU lane of the cuDNN backend showing the cuLaunchKernelEx driver-level launch instead of cudaLaunchKernel | + +3. The profiler reports 0% achieved occupancy: Do not take that at face value, it is a measurement gap, not a stalled GPU. CUPTI (the profiling backend) cannot attribute occupancy to a driver-API (`cuLaunchKernelEx`) launch the way it does for `cudaLaunchKernel`, so the field reads 0. The footprint fills in the truth (Figure 23): `240 registers × 256 threads = 61,440` registers per block against the SM's 65,536, so only **one block** fits per SM (8 warps ≈ 12.5%), right in line with flash. + + | ![Perfetto footprint of the cuDNN kernel reporting 0% achieved occupancy, with 240 registers per thread and 256 threads per block](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cudnn-footprint.png) | + | :--: | + | Figure 23: cuDNN 커널이 0% 달성 점유율을 보고하고, 스레드당 240 레지스터, 블록당 256 스레드 | + +#### 비용이 CPU로 이동했다 + +"전치 없음(no transposes)" 이야기는 CPU에서 cuDNN이 가장 '가볍다'는 백엔드일 것이라고 기대하게 만든다. 그러나 실제로는 정반대다. + +| backend | CUDA 평균 시간 | CPU 평균 시간 | +| :--: | :--: | :--: | +| efficient | 277.9 µs | 117 µs | +| flash | 146.8 µs | 138 µs | +| cudnn | 186.3 µs | **214 µs** | + +심지어 전치 연산이 하나도 없더라도 cuDNN은 CPU에서 순전파당 약 **214 µs**를 소비한다. 이는 플래시(138)나 효율(117)보다 많다. 거의 전부가 `aten::scaled_dot_product_attention` 자체 시간(전체 실행의 26%)과 `_cudnn_attention_forward`에 있다. 이것은 cuDNN의 런타임 엔진이 매 호출마다 계획을 선택하고 준비하는(“knob” 탐색) 과정이다. + +더 적은 ATen 연산이 보이는 것이 CPU 작업이 적다는 뜻이 아니다. 그 작업은 라이브러리로 이동했고, 프로파일러는 이를 하나의 크고 불투명한 막대 하나로만 보여줄 뿐이다. 트레이스가 갑자기 더 깨끗해지면, 작업이 사라진 것이 아니라 프로파일러가 분해할 수 없는 곳으로 이동한 것일 수 있다. + +GPU에서 cuDNN(186.3 µs)은 효율성과 플래시 사이에 위치한다. 이 아주 플래시 친화적인 형태에서 수작업 FlashAttention-2가 그 위를 약간 앞선다. cuDNN은 더 큰 head 차원이나 다른 시퀀스 길이의 _다른_ 형태에서 자주 이긴다. 다만 그 재조정은 당신이 CPU에서 지불한 비용이기도 하다. + +## Everything we covered, at a glance {#section-4} + +마무리하기 전에, 우리가 프로파일링한 모든 어텐션 변형과 각 트레이스가 가르쳐 준 하나의 교훈을 정리한 하나의 표가 있다. + +| Variant | What we changed | Kernels / forward | What the trace revealed | +| :-- | :-- | :--: | :-- | +| Naive attention | 원시 연산으로 직접 구성된 어텐션(matmul, mul, mask, softmax, matmul) | 6 | 외부의 `masked_fill`에서 온 숨겨진 `Memcpy`. | +| Naive in-place | `masked_fill` → `masked_fill_` | 5 | 한 줄로 `Memcpy` 커널을 완전히 제거한다. | +| SDPA math | `F.scaled_dot_product_attention` 수학 백엔드에 고정 | 20 | 참조 구현: CUDA 코어의 FP32, 매 호출마다 마스크 재구성, `_safe_softmax`. 정확하지만 약 3.7배 느림. | +| SDPA efficient | Efficient(xformers) 백엔드 | 1 | 하나의 융합 `fmha_cutlassF` 커널, 텐서 코어에서 bf16 유지. | +| SDPA flash | Flash 백엔드 | 1 | 하나의 융합 `pytorch_flash` 커널(FlashAttention-2). 가장 빠르지만 13% 점유율은 "잘못 보이는" 경우. | +| SDPA cuDNN | cuDNN 백엔드 | 1 | 문제별로 생성된 커널: 전치 없음, `cuLaunchKernelEx`지만 비용이 무거운 CPU 바로 이동. | + +## Concluding the series {#section-5} + +이 시리즈에서 한 가지만 takeaway를 뽑자면, 모든 트레이스 전에 반복했던 습관인 **먼저 추측하고, 그다음 보자**가 되길 바란다. + +트레이스에 무엇이 들어 있을지 소리 내어 예측하고, 트레이스를 열어 보며, 일치하지 않는 부분을 화면에서 가장 흥미로운 것으로 여기자. 이 세 편의 포스트에서 얻은 모든 실제 통찰은 숨겨진 `Memcpy`, `addmm` 에필로그, 20 커널 수학 백엔드, 플래시의 "잘못 보이는" 점유율, cuDNN의 두툼한 CPU 바에서 비롯된 것이며, 이는 트레이스와 일치하지 않는 추측에서 비롯된다. + +프로파일링은 GPU 전문가를 위한 별도의, 위협적인 기술이 아니다. 그것은 단지 아주 면밀히 보고 “저게 왜 그런가?”를 묻고 대답이 떠오를 때까지 파고드는 훈련일 뿐이다. 이제 당신은 자신이 다루는 모델에서 이를 스스로 할 수 있는 어휘와 반사적 반응을 갖추었다. 트레이스를 열고, 추측을 세우고, 불일치를 찾아보라. + +**Profiling in PyTorch** 시리즈를 읽어 주셔서 감사합니다. 이제 뭔가를 프로파일링해 보자. 🤗 + +초기 초안에 대한 리뷰를 남겨 주신 [Noe Flandre](https://huggingface.co/NoeFlandre)께 감사드립니다! + +> [!NOTE] +> 이 블로그 포스트는 LLM을 이용해 다듬었습니다. 이것이 배경에서 에이전트가 포스트를 생성하도록 한다는 뜻은 결코 아닙니다. 팀의 일부는 영어를 모국어로 하지 않으며, LLM(대부분 영어로 학습된 모델)이 어리석은 문법 실수를 바로잡거나 더 덜 위협적으로 들리고 깔끔하게 들리는 문장을 재구성할 수 있다고 생각합니다. 이 점이 "왜 읽어야 하나, 이것이 LLM으로 생성되었기 때문인가"라는 아이디어에 도움이 되길 바랍니다. 🤗 diff --git a/assets/images/blog/posts/2026-07-10-torch-attention-profile/thumbnail.png b/assets/images/blog/posts/2026-07-10-torch-attention-profile/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb41b34d371a1cf2e226e60ae88833d41f19800 GIT binary patch literal 41647 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 jf6DNmGW Date: Fri, 24 Jul 2026 23:40:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20sentence=20ending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _posts/2026-07-10-torch-attention-profile.md | 152 +++++++++---------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/_posts/2026-07-10-torch-attention-profile.md b/_posts/2026-07-10-torch-attention-profile.md index eca84967..c16cc672 100644 --- a/_posts/2026-07-10-torch-attention-profile.md +++ b/_posts/2026-07-10-torch-attention-profile.md @@ -72,26 +72,26 @@ Review instructions: -시리즈 "Profiling in PyTorch"는 프로파일러 트레이스와 표를 읽는 데 익숙해지게 만드는 것을 목표로 한다. [Part 1](https://huggingface.co/blog/torch-profiler)에서는 덧셈과 곱셈 같은 기본 수학 연산을 프로파일링했다. 프로파일러 표가 핫스팟을 드러내는 방식과 프로파일러 트레이스가 알고리즘이 시간에 따라 실행되는 순서를 어떻게 보여주는지 보았다. +시리즈 "Profiling in PyTorch"는 프로파일러 트레이스와 표를 읽는 데 익숙해지게 만드는 것을 목표로 합니다. [Part 1](https://huggingface.co/blog/torch-profiler)에서는 덧셈과 곱셈 같은 기본 수학 연산을 프로파일링했습니다. 프로파일러 표가 핫스팟을 드러내는 방식과 프로파일러 트레이스가 알고리즘이 시간에 따라 실행되는 순서를 어떻게 보여주는지 보았습니다. -[Part 2](https://huggingface.co/blog/torch-mlp-fusion) 안에서 위의 덧셈과 곱셈을 torch 선형 계층으로 포장했고, 그 위에 여러 개의 선형 계층을 차례로 쌓아(다층 퍼셉트론) 그것을 프로파일링했다. 그 과정에서 융합된 커널과 수작업으로 튜닝된 커널도 함께 프로파일링했다. +[Part 2](https://huggingface.co/blog/torch-mlp-fusion) 안에서 위의 덧셈과 곱셈을 torch 선형 계층으로 포장했고, 그 위에 여러 개의 선형 계층을 차례로 쌓아(다층 퍼셉트론) 그것을 프로파일링했습니다. 그 과정에서 융합된 커널과 수작업으로 튜닝된 커널도 함께 프로파일링했습니다. -트랜스포머 아키텍처의 관점에서, 프로파일링의 다음 논리적 단계는 또 다른 기본 알고리즘인 어텐션이다. 어텐션은 이차 시간 복잡도로 악명 높지만, 이를 완화하고 빠르게 만드는 똑똑한 트릭이 많이 존재한다. 여기서의 목표는 모든 트릭을 자세히 다루는 것이 아니다. 대신 각 트릭이 프로파일러 아래에서 어떻게 다르게 보이는지 보는 것이다. +트랜스포머 아키텍처의 관점에서, 프로파일링의 다음 논리적 단계는 또 다른 기본 알고리즘인 어텐션입니다. 어텐션은 이차 시간 복잡도로 악명 높지만, 이를 완화하고 빠르게 만드는 똑똑한 트릭이 많이 존재합니다. 여기서의 목표는 모든 트릭을 자세히 다루는 것이 아닙니다. 대신 각 트릭이 프로파일러 아래에서 어떻게 다르게 보이는지 보는 것입니다. > [!NOTE] > 이 블로그 포스트의 스크립트는 여기에서 실행됩니다: [`04_a_naive_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_a_naive_attention.py), [`04_b_inplace_ops_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_b_inplace_ops_attention.py), [`04_c_sdpa_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_c_sdpa_attention.py), 그리고 [`04_d_kernels_attention.py`](https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/04_d_kernels_attention.py). 이전과 마찬가지로 읽으면서 코드를 따라가려면 별도의 탭에서 여는 것이 도움이 됩니다. 이 스크립트를 실행하기 위해 `NVIDIA A100-SXM4-80GB` GPU를 사용합니다. 허깅페이스 인프라에서 GPU를 설정하고 [Dev Mode with Spaces](https://huggingface.co/docs/hub/spaces-dev-mode)를 사용해 스크립트를 실험하는 것은 정말 쉽습니다. 또한 [Hugging Face Jobs pipeline](https://huggingface.co/docs/huggingface_hub/en/guides/jobs)로도 스크립트를 실행할 수 있습니다. ## 나이브 어텐션 {#section-1} -어텐션은 쿼리(`q`), 키(`k`), 값(`v`)으로 작동합니다. 이들 간의 상호 작용은 간단한 일련의 단계로 작성할 수 있다: +어텐션은 쿼리(`q`), 키(`k`), 값(`v`)으로 작동합니다. 이들 간의 상호 작용은 간단한 일련의 단계로 작성할 수 있습니다: -1. 어텐션 스코어를 만든다 `scores`: `matmul(q, k.T)` -2. 스코어를 스케일한다: `scores * scale` -3. 스코어에 인과 마스크를 적용한다: `scores.masked_fill(mask, "-inf")` -4. 소프트맥스(softmax)로 스코어를 정규화하여 어텐션 가중치를 얻는다 `attn`: `softmax(scores)` -5. 그 가중치로 값을 재가중한다: `matmul(attn, v)` +1. 어텐션 스코어를 만듭니다 `scores`: `matmul(q, k.T)` +2. 스코어를 스케일합니다: `scores * scale` +3. 스코어에 인과 마스크를 적용합니다: `scores.masked_fill(mask, "-inf")` +4. 소프트맥스(softmax)로 스코어를 정규화하여 어텐션 가중치를 얻습니다 `attn`: `softmax(scores)` +5. 그 가중치로 값을 재가중합니다: `matmul(attn, v)` -그래서 어텐션은 실질적으로 원시 연산들의 모음이다. 그 중 일부는 이미 알고 있는(matmul) 연산이고, 나머지는 쉽게 발견할 수 있다. PyTorch로 네이브 어텐션 모듈을 작성하고 이를 프로파일링해 보자. +그래서 어텐션은 실질적으로 원시 연산들의 모음입니다. 그 중 일부는 이미 알고 있는(matmul) 연산이고, 나머지는 쉽게 발견할 수 있습니다. PyTorch로 네이브 어텐션 모듈을 작성하고 이를 프로파일링해 봅시다. ```py class NaiveCausalAttention(nn.Module): @@ -109,7 +109,7 @@ class NaiveCausalAttention(nn.Module): ``` -트레이스를 열기 전, 보통의 연습대로 우리가 볼 수 있을 것을 추측해 보자. 이 모듈의 `forward`를 트레이스하면, 우리는 다음을 기대한다: +트레이스를 열기 전, 보통의 연습대로 우리가 볼 수 있을 것을 추측해 봅시다. 이 모듈의 `forward`를 트레이스하면, 우리는 다음을 기대합니다: - 매트멀 커널( `q . k.T` ) - 곱 커널(스케일링) @@ -127,25 +127,25 @@ uvx trace-util -f traces/ -b /traces | :--: | | 그림 1: 네이브 어텐션의 프로파일 트레이스의 CPU 레인에 표시된 이산 연산들을 강조 | -그림 1은 프로파일의 CPU 레인( GPU 레인은 우리를 압도하지 않도록 접어 두었습니다)을 보여준다. 내부 `attn_fwd`(주석이 달린 순전파 호출)에서 우리가 추정한 정확한 연산들을 볼 수 있다. 매트멀은 이제 친숙한 친구이고, 새로 등장한 연산들은 쉽게 포착된다: +그림 1은 프로파일의 CPU 레인( GPU 레인은 우리를 압도하지 않도록 접어 두었습니다)을 보여줍니다. 내부 `attn_fwd`(주석이 달린 순전파 호출)에서 우리가 추정한 정확한 연산들을 볼 수 있습니다. 매트멀은 이제 친숙한 친구이고, 새로 등장한 연산들은 쉽게 포착됩니다: - `mul`: 스케일링 - `masked_fill`: 인과 마스킹 - `softmax`: 소프트맥스 커널 -이제 GPU 레인을 펼쳐 실제로 어떤 커널이 실행되었는지 확인해 보자. +이제 GPU 레인을 펼쳐 실제로 어떤 커널이 실행되었는지 확인해 봅시다. | ![Profiler trace of naive attention showing the CPU lane above the GPU lane, with each `attn_fwd` step mapping to a cluster of GPU kernels](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/gpu-profile-naive.png) | | :--: | | 그림 2: 네이브 어텐션의 프로파일 트레이스의 GPU 레인과 CPU 레인을 함께 보여주며, 하나의 프로파일러 스텝에 해당하는 커널들을 강조 | -그림 2는 GPU 레인 옆에 있는 CPU 레인을 보여준다. GPU 레인에서 하나의 `attn_fwd` 블록을 확대하여 커널을 하나씩 살펴보자. +그림 2는 GPU 레인 옆에 있는 CPU 레인을 보여줍니다. GPU 레인에서 하나의 `attn_fwd` 블록을 확대하여 커널을 하나씩 살펴봅시다. | ![Zoomed-in GPU lane of naive attention showing the individual kernels for one step: two matmuls, a mul, a memory copy, a masking kernel and a softmax](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-naive.png) | | :--: | | 그림 3: 네이브 어텐션 구현의 프로파일 트레이스의 확대된 GPU 레인 | -그림 3은 한 프로파일러 스텝의 개별 커널을 읽어보게 해 준다: +그림 3은 한 프로파일러 스텝의 개별 커널을 읽어보게 해 줍니다: 1. matmul (쿼리와 키) 2. mul (스케일링) @@ -154,13 +154,13 @@ uvx trace-util -f traces/ -b /traces 5. softmax (어텐션 가중치를 산출) 6. matmul (어텐션 가중치와 값) -다섯 가지가 예상된다. 메모리 복사는 이상한 하나인데, 이게 어디서 나온 걸까? 힌트는 PyTorch에 in-place 연산이 있다는 점이다. 텐서에 일반적인(out-of-place) 방식으로 연산하면, PyTorch는 종종 복사를 만들고 요청된 연산을 그 복사에 적용한 뒤 복사본을 반환한다. 연산 순서를 보면 여기의 범인은 [`masked_fill`](https://docs.pytorch.org/docs/2.13/generated/torch.Tensor.masked_fill.html)이다. +다섯 가지가 예상됩니다. 메모리 복사는 이상한 하나인데, 이게 어디서 나온 걸까요? 힌트는 PyTorch에 in-place 연산이 있습니다는 점입니다. 텐서에 일반적인(out-of-place) 방식으로 연산하면, PyTorch는 종종 복사를 만들고 요청된 연산을 그 복사에 적용한 뒤 복사본을 반환합니다. 연산 순서를 보면 여기의 범인은 [`masked_fill`](https://docs.pytorch.org/docs/2.13/generated/torch.Tensor.masked_fill.html)입니다. -이것을 in-place 연산으로 바꾼다면 어떨까? +이것을 in-place 연산으로 바꾼다면 어떨까요? ## 인플레이스 인과 마스크가 적용된 나이브 어텐션 {#section-2} -변경하는 것은 `masked_fill`에서 `masked_fill_`로의 교환뿐이다(뒤의 밑줄은 PyTorch의 in-place 연산 표기법에 주의). 같은 스크립트를 실행한다. +변경하는 것은 `masked_fill`에서 `masked_fill_`로의 교환뿐입니다(뒤의 밑줄은 PyTorch의 in-place 연산 표기법에 주의). 같은 스크립트를 실행합니다. ```diff def forward(self, q, k, v, mask): @@ -175,7 +175,7 @@ def forward(self, q, k, v, mask): ``` -트레이스를 살펴보고 무언가 바뀌었는지 보자. +트레이스를 살펴보고 무언가 바뀌었는지 봅시다. ```bash uv run 04_b_inplace_ops_attention.py @@ -188,21 +188,21 @@ uvx trace-util -f traces/ -b /traces | 그림 4: 나이브 마스킹 | ![CPU lane of naive attention with out-of-place `masked_fill`, showing several dispatch ops for the masking step](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cpu-profile-naive.png) | | 그림 5: 인플레이스 마스킹 | ![CPU lane of naive attention with in-place `masked_fill_`, showing fewer dispatch ops for the masking step](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cpu-profile-inplace.png) | -인플레이스 버전(Figure 5)은 마스킹 단계 안에 CPU 연산을 훨씬 적게 포장한다. 이는 고무적인 신호다. GPU 레인을 펼쳐 무슨 일이 일어났는지 확인해 보자. +인플레이스 버전(Figure 5)은 마스킹 단계 안에 CPU 연산을 훨씬 적게 포장합니다. 이는 고무적인 신호다. GPU 레인을 펼쳐 무슨 일이 일어났는지 확인해 봅시다. | Type | GPU 스트림 | | :--: | :--: | | 그림 6: 나이브 마스킹 | ![GPU kernels for naive attention including a separate Memcpy kernel before the masking](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-naive.png) | | 그림 7: 인플레이스 마스킹 | ![GPU kernels for naive attention with in-place masking, with the Memcpy kernel gone](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/each-kernels-inpace.png) | -GPU 레인에서 `Memcpy` 커널은 완전히 사라졌다(그림 6, 7). 한 줄의 변경으로 순전파마다 커널 하나를 제거했다. 이것만으로는 큰 차이처럼 보이지 않을 수 있지만, 이건 단일 어텐션 연산에 불과하다. 트랜스포머 기반의 대형 모델(LLMs, 확산 모델 등) 맥락에서 이는 레이어당 한 번 반복되며 레이어가 많으므로 절약 효과가 빠르게 누적된다(그리고 그것이 당신의 월급 인상에 기여한다면, 우리와 최소 10%를 나누는 것이 공정하다고 느낀다). +GPU 레인에서 `Memcpy` 커널은 완전히 사라졌다(그림 6, 7). 한 줄의 변경으로 순전파마다 커널 하나를 제거했습니다. 이것만으로는 큰 차이처럼 보이지 않을 수 있지만, 이건 단일 어텐션 연산에 불과합니다. 트랜스포머 기반의 대형 모델(LLMs, 확산 모델 등) 맥락에서 이는 레이어당 한 번 반복되며 레이어가 많으므로 절약 효과가 빠르게 누적됩니다(그리고 그것이 당신의 월급 인상에 기여합니다면, 우리와 최소 10%를 나누는 것이 공정합니다고 느낍니다). > [!NOTE] -> Out-of-place는 PyTorch의 기본 설정이다. 그래디언트를 계산하려면 autograd가 순전파에서 본 텐서 값을 기억해야 하며, 많은 역전파 공식이 그 값을 재사용하기 때문이다. in-place 연산은 메모리의 그 값을 덮어써서 역전파가 잘못된 수치를 읽게 된다. `forward`를 `torch.no_grad` 아래에서 실행하기 때문에 우리에게는 in-place가 안전하며, 역전파가 없고 손상될 여지도 없다. 또한 in-place 연산은 시간 절약뿐 아니라(추가 복사 없이) 메모리 절약도 가능하므로 로짓처럼 큰 텐서에 특히 좋다. +> Out-of-place는 PyTorch의 기본 설정입니다. 그래디언트를 계산하려면 autograd가 순전파에서 본 텐서 값을 기억해야 하며, 많은 역전파 공식이 그 값을 재사용하기 때문입니다. in-place 연산은 메모리의 그 값을 덮어써서 역전파가 잘못된 수치를 읽게 됩니다. `forward`를 `torch.no_grad` 아래에서 실행하기 때문에 우리에게는 in-place가 안전하며, 역전파가 없고 손상될 여지도 없다. 또한 in-place 연산은 시간 절약뿐 아니라(추가 복사 없이) 메모리 절약도 가능하므로 로짓처럼 큰 텐서에 특히 좋습니다. ## 스케일드 닷 프로덕트 어텐션 {#section-3} -우리는 막 원시 연산(primitives)으로 어텐션을 구성했고, 심지어 `Memcpy`까지 제거했다. 다행히 PyTorch 팀이 이 모든 것을 우리를 위해 처리했고, 파이프라인 전체를 단 한 줄의 함수로 패키지했다: +우리는 막 원시 연산(primitives)으로 어텐션을 구성했고, 심지어 `Memcpy`까지 제거했습니다. 다행히 PyTorch 팀이 이 모든 것을 우리를 위해 처리했고, 파이프라인 전체를 단 한 줄의 함수로 패키지했습니다: ```py from torch.nn import functional as F @@ -211,9 +211,9 @@ F.scaled_dot_product_attention(q, k, v, is_causal=True) ``` -이 한 줄이 우리의 직접 작성한 모듈을 대체하고, `is_causal=True`는 수동으로 마스크를 빌드하는 수고도 덜어준다. 이 한 호출이 얼마나 많은 것을 숨기는지 음미할 가치가 있다. 그리고 그것은 코드 줄 이상으로도 숨긴다. 스케일드 닷 프로덕트 어텐션(SDPA)은 단일 구현을 가지지 않는다. 그 이면에서 여러 백엔드 중 하나로 _디스패치_되어 입력들(dtype, head dimension, mask, 하드웨어 등)을 지원하는 가장 빠른 백엔드를 선택한다. +이 한 줄이 우리의 직접 작성한 모듈을 대체하고, `is_causal=True`는 수동으로 마스크를 빌드하는 수고도 덜어줍니다. 이 한 호출이 얼마나 많은 것을 숨기는지 음미할 가치가 있습니다. 그리고 그것은 코드 줄 이상으로도 숨긴다. 스케일드 닷 프로덕트 어텐션(SDPA)은 단일 구현을 가지지 않습니다. 그 이면에서 여러 백엔드 중 하나로 _디스패치_되어 입력들(dtype, head dimension, mask, 하드웨어 등)을 지원하는 가장 빠른 백엔드를 선택합니다. -[official SDPA tutorial](https://docs.pytorch.org/tutorials/intermediate/scaled_dot_product_attention_tutorial.html)가 이 선택 과정을 안내하고, 백엔드 목록 자체는 `torch.nn.attention.SDPBackend` 열거형에 나열되어 있다: +[official SDPA tutorial](https://docs.pytorch.org/tutorials/intermediate/scaled_dot_product_attention_tutorial.html)가 이 선택 과정을 안내하고, 백엔드 목록 자체는 `torch.nn.attention.SDPBackend` 열거형에 나열되어 있습니다: ```python from torch.nn.attention import SDPBackend @@ -227,7 +227,7 @@ BACKENDS = { ``` -일반적으로 SDPA는 우리를 위해 선택하지만, `torch.nn.attention.sdpa_kernel` 컨텍스트 매니저로 특정 백엔드를 고정할 수 있다. 이것이 우리 스크립트에서 하는 일이다. 이를 통해 각 백엔드를 독립적으로 프로파일하고, 트레이스에서 어떻게 다르게 나타나는지 읽어볼 수 있다. 하나씩 살펴보자. +일반적으로 SDPA는 우리를 위해 선택하지만, `torch.nn.attention.sdpa_kernel` 컨텍스트 매니저로 특정 백엔드를 고정할 수 있습니다. 이것이 우리 스크립트에서 하는 일입니다. 이를 통해 각 백엔드를 독립적으로 프로파일하고, 트레이스에서 어떻게 다르게 나타나는지 읽어볼 수 있습니다. 하나씩 살펴봅시다. ### 수학 백엔드 @@ -237,44 +237,44 @@ uvx trace-util -f traces/ -b /traces ``` -아무 것도 열기 전에 추측해 보자. 이 모듈의 수작업 어텐션(matmul, mul, mask, softmax, matmul)을 한 줄로 대체했으니 트레이스가 _간단하고 빨라질 것이다_. 커널 수가 적고, CPU 디스패치가 줄어들며, 어쩌면 융합 커널이 나올 수도 있다. 우선 프로파일러 표를 확인하자. +아무 것도 열기 전에 추측해 봅시다. 이 모듈의 수작업 어텐션(matmul, mul, mask, softmax, matmul)을 한 줄로 대체했으니 트레이스가 _간단하고 빨라질 것입니다_. 커널 수가 적고, CPU 디스패치가 줄어들며, 어쩌면 융합 커널이 나올 수도 있습니다. 우선 프로파일러 표를 확인해봅시다. | Metric | Where to look? | Naive in-place | SDPA math | | :--: | :--: | :--: | :--: | | `*_fwd` CUDA time avg | The "CUDA time avg" column for the `*_fwd` op | 1.955 ms | 7.239 ms | | Self CUDA time total | At the bottom of the profiler table | 7.194 ms | 27.279 ms | -이것이 우리의 첫 번째 놀라움이다. 한 줄이 `3.7x` 느리다. +이것이 우리의 첫 번째 놀라움입니다. 한 줄이 `3.7x` 느리다. | | Profiler Trace | | :--: | :--: | | 그림 8: 나이브 인플레이스 어텐션의 프로파일러 트레이스가 하나의 순전파에 대해 다섯 개의 GPU 커널 런치를 보여줌 | ![GPU lane of naive in-place attention with five kernel launches for one forward pass](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/inplace-kernel-launches.png) | | 그림 9: SDPA 수학 백엔드의 프로파일러 트레이스가 단일 어텐션 순전파에 대해 20개의 GPU 커널 런치를 보여줌 | ![GPU lane of the SDPA math backend with twenty kernel launches for a single attention forward pass](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/math-kernel-launches.png) | -트레이스를 열어보면(그림 9) 경보가 울리는 이유를 보여준다. 수학 백엔드가 순전파당 `20`개의 GPU 커널을 실행하는 반면, 네이브 어텐션 구현(Figure 8)에서 실행된 `5`은 아니다. 이는 우리가 예상한 것의 정반대다. 왜 이런 일이 일어나는지 알아보자. +트레이스를 열어보면(그림 9) 경보가 울리는 이유를 보여줍니다. 수학 백엔드가 순전파당 `20`개의 GPU 커널을 실행하는 반면, 네이브 어텐션 구현(Figure 8)에서 실행된 `5`은 아닙니다. 이는 우리가 예상한 것의 정반대다. 왜 이런 일이 일어나는지 알아봅시다. -#### 텐서 코어가 남아 있다 +#### 텐서 코어가 남아 있습니다 -다음과 같은 습관을 사용해 커널 이름을 읽는 방법을 배웠다. 이 습관을 이 자리에 적용해 보자: +다음과 같은 습관을 사용해 커널 이름을 읽는 방법을 배웠습니다. 이 습관을 이 자리에 적용해 봅시다: | Run | matmul kernel | | :--: | :--: | | 그림 10: 네이브 어텐션 | ![Matmul kernel name for naive attention in Perfetto, carrying the s16816 bfloat16 Tensor-core GEMM signature](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/cuda-core-kernels.png) | | 그림 11: 수학 백엔드를 사용한 SDPA | ![Matmul kernel name for the SDPA math backend, carrying the sgemm FP32 CUDA-core signature](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/tensor-core-kernels.png) | -우리가 이 트레이스를 포착하는 데 사용한 A100은 [Tensor Cores](https://www.nvidia.com/en-us/data-center/tensor-cores/)와 함께 제공되며, 행렬 곱셈을 가속하기 위한 특화된 하드웨어로 일반 CUDA 코어보다 훨씬 빠른 것으로 알려져 있다. 이것이 여기서 왜 중요한지 보려면 GPU 내부에 무엇이 있는지 알아야 한다. 스트리밍 멀티프로세서(SM)는 GPU의 계산 유닛이고, 각 SM은 두 종류의 산술 유닛(CUDA 코어와 텐서 코어)을 가진다. CUDA 코어는 범용적이고 한 번에 소수의 원소를 처리하며, 텐서 코어는 작은 행렬 타일을 한 명령으로 곱하고 누적한다. 따라서 질문은 간단하다. 각 백엔드가 실제로 빠른 경로를 사용하고 있는가? +우리가 이 트레이스를 포착하는 데 사용한 A100은 [Tensor Cores](https://www.nvidia.com/en-us/data-center/tensor-cores/)와 함께 제공되며, 행렬 곱셈을 가속하기 위한 특화된 하드웨어로 일반 CUDA 코어보다 훨씬 빠른 것으로 알려져 있습니다. 이것이 여기서 왜 중요한지 보려면 GPU 내부에 무엇이 있는지 알아야 합니다. 스트리밍 멀티프로세서(SM)는 GPU의 계산 유닛이고, 각 SM은 두 종류의 산술 유닛(CUDA 코어와 텐서 코어)을 가진다. CUDA 코어는 범용적이고 한 번에 소수의 원소를 처리하며, 텐서 코어는 작은 행렬 타일을 한 명령으로 곱하고 누적합니다. 따라서 질문은 간단합니다. 각 백엔드가 실제로 빠른 경로를 사용하고 있는가? -커널 이름이 그것에 답한다. 네이브 커널(Figure 10)의 `s16816`은 `bfloat16` 텐서 코어 매트멀의 시그니처이며( `16x8x16` 텐서 코어 명령), 따라서 네이브 버전은 빠른 경로에 있다. `sgemm`(Figure 11)는 일반 CUDA 코어에서 실행되는 단정도 싱글 프리시전(`FP32`) 매트멀이다. 다시 말해, 수학 백엔드는 텐서 코어를 전혀 건드리지 않는다: 속도와 수치 정확도 사이의 trade-off를 위해 텐서를 `FP32`로 업캐스트하고(입력이 `bf16`인 경우에도 데이터 이동을 두 배로 늘림) 느린 CUDA 코어로 되돌아간다. +커널 이름이 그것에 답합니다. 네이브 커널(Figure 10)의 `s16816`은 `bfloat16` 텐서 코어 매트멀의 시그니처이며( `16x8x16` 텐서 코어 명령), 따라서 네이브 버전은 빠른 경로에 있습니다. `sgemm`(Figure 11)는 일반 CUDA 코어에서 실행되는 단정도 싱글 프리시전(`FP32`) 매트멀입니다. 다시 말해, 수학 백엔드는 텐서 코어를 전혀 건드리지 않습니다: 속도와 수치 정확도 사이의 trade-off를 위해 텐서를 `FP32`로 업캐스트하고(입력이 `bf16`인 경우에도 데이터 이동을 두 배로 늘림) 느린 CUDA 코어로 되돌아간다. -#### 인과 마스크가 생성된다 +#### 인과 마스크가 생성됩니다 -네이브 버전에서는 인과 마스크를 한 번 만들고 재사용했다. 이 버전에서는 `is_causal=True`를 넘겨 주었고, 수학 백엔드가 매 호출마다 하나를 생성해 주었다. CPU 레인에서 그것이 어떻게 일어나는지 지켜볼 수 있다: +네이브 버전에서는 인과 마스크를 한 번 만들고 재사용했습니다. 이 버전에서는 `is_causal=True`를 넘겨 주었고, 수학 백엔드가 매 호출마다 하나를 생성해 주었다. CPU 레인에서 그것이 어떻게 일어나는지 지켜볼 수 있습니다: | ![CPU lane of the SDPA math backend showing the ops that rebuild the causal mask: aten::ones, aten::tril, aten::scalar_tensor, aten::fill_ and aten::where](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/mask-math.png) | | :--: | | 그림 12: 마스킹 연산에 대한 CPU 레인 표시 | -다음은 그림 12에서 보이는 모습이다 +다음은 그림 12에서 보이는 모습입니다 ```bash aten::ones -> aten::tril build a [seq, seq] lower-triangular matrix @@ -283,7 +283,7 @@ aten::where turn it into an additive bias (0 or -inf) ``` -GPU에서는 이것이 `triu_tril_kernel` 하나, 여러 개의 `where` 커널, 그리고 하나의 `add_`로 나타난다. 마스크를 생각하는 것을 중단하게 해 주는 편의 플래그는 작업 자체를 제거한 것이 아니라 한 층 아래로 옮겼으며, 순전파마다 마스크가 처음부터 새로 빌드된다. +GPU에서는 이것이 `triu_tril_kernel` 하나, 여러 개의 `where` 커널, 그리고 하나의 `add_`로 나타난다. 마스크를 생각하는 것을 중단하게 해 주는 편의 플래그는 작업 자체를 제거한 것이 아니라 한 층 아래로 옮겼으며, 순전파마다 마스크가 처음부터 새로 빌드됩니다. #### 안전한 소프트맥스 @@ -293,13 +293,13 @@ GPU에서는 이것이 `triu_tril_kernel` 하나, 여러 개의 `where` 커널, | :--: | | 그림 13: 일반 소프트맥스에 비해 추가 커널을 강조하는 Safe softmax | -전체가 마스킹된 행(모든 항목이 `-inf`인 행)은 일반 소프트맥스가 `exp(-inf)/sum(exp(-inf)) = 0/0 = NaN`를 계산하게 만들 것이다. `_safe_softmax`는 바로 그것을 방지한다. 우리의 네이브 커널은 그 경우를 전혀 처리하지 않았고, 그 귀퉁이 케이스에서 조용히 `NaN`를 만들어 냈을 것이다. +전체가 마스킹된 행(모든 항목이 `-inf`인 행)은 일반 소프트맥스가 `exp(-inf)/sum(exp(-inf)) = 0/0 = NaN`를 계산하게 만들 것입니다. `_safe_softmax`는 바로 그것을 방지합니다. 우리의 네이브 커널은 그 경우를 전혀 처리하지 않았고, 그 귀퉁이 케이스에서 조용히 `NaN`를 만들어 냈을 것입니다. #### 그래서 수학 백엔드는 무엇을 위한가? -종합하면, 수학 백엔드는 참조 구현이다. 이는 원시 ATen 연산으로 어텐션을 dtype 안전하고 NaN 안전하게 분해하는 직관적 구현이다. 본질적으로 우리가 손으로 작성한 네이브 어텐션과 같지만 더 조심스럽다. 그 신중함이 바로 그것을 매우 느리게 만든다. +종합하면, 수학 백엔드는 참조 구현입니다. 이는 원시 ATen 연산으로 어텐션을 dtype 안전하고 NaN 안전하게 분해하는 직관적 구현입니다. 본질적으로 우리가 손으로 작성한 네이브 어텐션과 같지만 더 조심스럽다. 그 신중함이 바로 그것을 매우 느리게 만듭니다. -그 임무는 빠르게 작동하는 것이 아니라 항상 작동하는 것이다. 이것이 완벽한 베이스라인이 된다. 우리가 다음에 프로파일링하는 모든 백엔드(Flash, Efficient, cuDNN)는 `20` GPU 커널들을 본질적으로 하나의 융합 커널로 압축해서 bf16으로 유지하고 중간 행렬을 전혀 만들어 내지 않도록 하려 한다. +그 임무는 빠르게 작동하는 것이 아니라 항상 작동하는 것입니다. 이것이 완벽한 베이스라인이 됩니다. 우리가 다음에 프로파일링하는 모든 백엔드(Flash, Efficient, cuDNN)는 `20` GPU 커널들을 본질적으로 하나의 융합 커널로 압축해서 bf16으로 유지하고 중간 행렬을 전혀 만들어 내지 않도록 하려 합니다. ### 효율적인 백엔드 @@ -313,18 +313,18 @@ uvx trace-util -f traces -b /traces | :--: | | 그림 14: sdpa의 효율적 백엔드에 대한 프로파일러 트레이스 | -수학 백엔드가 한 프로파일러 스텝에 20개의 커널을 실행했다면, 효율적 백엔드는 단 하나의 `fmha_cutlassF_bf16_aligned_64x64_rf_sm80`만 실행한다(그림 14에서 볼 수 있다). +수학 백엔드가 한 프로파일러 스텝에 20개의 커널을 실행했습니다면, 효율적 백엔드는 단 하나의 `fmha_cutlassF_bf16_aligned_64x64_rf_sm80`만 실행합니다(그림 14에서 볼 수 있습니다). -커널의 이름의 의미를 해석해 보자: +커널의 이름의 의미를 해석해 봅시다: -- `fmha` (퓨즈드 멀티헤드 어텐션): 어텐션의 모든 원시 연산이 하나의 연산으로 융합되어 있다. +- `fmha` (퓨즈드 멀티헤드 어텐션): 어텐션의 모든 원시 연산이 하나의 연산으로 융합되어 있습니다. - `cutlassF`: CUTLASS를 기반으로 한 텐서-코어 GEMM용 템플릿, forward에 대해 `F`. - `bf16_aligned`: bf16으로 실행( FP32 업캐스트 없음, 수학과 다름). - `64x64`: 타일 크기. -- `rf` (레지스터 파일): 작동 집합이 레지스터에 보관되어 칩에서 가장 빠른 메모리이다. +- `rf` (레지스터 파일): 작동 집합이 레지스터에 보관되어 칩에서 가장 빠른 메모리입니다. - `sm80`: Ampere에 맞춰 컴파일(A100의 컴퓨트 수준 8.0). -메타(Meta)의 [xformers](https://github.com/facebookresearch/xformers) 라이브러리에서 나온 메모리 효율적인 어텐션 커널이며 PyTorch로 업스트림되었다. 사람들이 "xformers 백엔드"라고 말할 때 이 `fmha_cutlassF` 커널이 바로 그것이다. +메타(Meta)의 [xformers](https://github.com/facebookresearch/xformers) 라이브러리에서 나온 메모리 효율적인 어텐션 커널이며 PyTorch로 업스트림되었습니다. 사람들이 "xformers 백엔드"라고 말할 때 이 `fmha_cutlassF` 커널이 바로 그것입니다. ### Flash 백엔드 @@ -338,17 +338,17 @@ uvx trace-util -f traces -b /traces | :--: | | 그림 15: flash 백엔드 트레이스, forward당 하나의 융합 `pytorch_flash` 커널 | -`void pytorch_flash` 커널(Figure 15)은 [FlashAttention-2](https://arxiv.org/abs/2307.08691)(Tri Dao의 구현)이며 PyTorch에 벤더링되어 있다. +`void pytorch_flash` 커널(Figure 15)은 [FlashAttention-2](https://arxiv.org/abs/2307.08691)(Tri Dao의 구현)이며 PyTorch에 벤더링되어 있습니다. -이제 트레이스를 더 읽기 전에, 지금까지 당신이 물어야 할 질문에 답하는 것이 가치 있다: _왜 "flash"라는 백엔드가 존재하고, 그것이 왜 이렇게 중요한가?_ +이제 트레이스를 더 읽기 전에, 지금까지 당신이 물어야 할 질문에 답하는 것이 가치 있습니다: _왜 "flash"라는 백엔드가 존재하고, 그것이 왜 이렇게 중요한가?_ #### 왜 flash 어텐션이 존재하는가? -잠시 수학 백엔드으로 돌아가 보자. 실제 문제는 20개의 커널 수가 아니라, 그 커널들이 서로에게 넘겨주는 데이터였다. +잠시 수학 백엔드으로 돌아가 봅시다. 실제 문제는 20개의 커널 수가 아니라, 그 커널들이 서로에게 넘겨주는 데이터였습니다. -1단계는 전체 스코어 행렬 `attn = q . k.T`을 빌드하는데, 이는 head당 `[seq, seq]`이다. 시퀀스 길이가 4096인 경우 단일 head에 대해 `4096 x 4096 ≈ 16 million`개의 숫자이다. 그 행렬은 HBM(그래픽 카드의 주 메모리)로 기록되며 공간이 충분하면 기록한다. 그런 다음 다시 읽어 시그널링을 위해 스케일하고, 마스크를 위해 다시 쓰고, 다시 소프트맥스를 위해 읽는 식으로 계속된다. 어텐션의 비용은 이 HBM으로의 왕복 트래픽에 의해 좌우되며, 매트멀 그 자체보다는 이 트래픽에서 더 많은 부분을 차지한다. +1단계는 전체 스코어 행렬 `attn = q . k.T`을 빌드하는데, 이는 head당 `[seq, seq]`입니다. 시퀀스 길이가 4096인 경우 단일 head에 대해 `4096 x 4096 ≈ 16 million`개의 숫자입니다. 그 행렬은 HBM(그래픽 카드의 주 메모리)로 기록되며 공간이 충분하면 기록합니다. 그런 다음 다시 읽어 시그널링을 위해 스케일하고, 마스크를 위해 다시 쓰고, 다시 소프트맥스를 위해 읽는 식으로 계속됩니다. 어텐션의 비용은 이 HBM으로의 왕복 트래픽에 의해 좌우되며, 매트멀 그 자체보다는 이 트래픽에서 더 많은 부분을 차지합니다. -FlashAttention은 정확히 이것을 겨냥한다. 전체 `s` 행렬을 먼저 계산한 뒤 감소시키는 대신, **타일 단위로** `k`와 `v`를 따라가며 진행하고, 진행 방향에서 소프트맥스를 유지하는(“온라인 소프트맥스” 트릭) 방식으로 출력도 타일 하나씩 누적한다. 전체 `[seq, seq]` 스코어 행렬은 **HBM에 한 번도 기록되지 않고**, 칩 안에만 존재한다. 이것이 전체 어텐션 파이프라인을 bf16으로 유지되는 하나의 융합 커널로 단번에 축소시키는 유일한 아이디어다. +FlashAttention은 정확히 이것을 겨냥합니다. 전체 `s` 행렬을 먼저 계산한 뒤 감소시키는 대신, **타일 단위로** `k`와 `v`를 따라가며 진행하고, 진행 방향에서 소프트맥스를 유지하는(“온라인 소프트맥스” 트릭) 방식으로 출력도 타일 하나씩 누적합니다. 전체 `[seq, seq]` 스코어 행렬은 **HBM에 한 번도 기록되지 않고**, 칩 안에만 존재합니다. 이것이 전체 어텐션 파이프라인을 bf16으로 유지되는 하나의 융합 커널로 단번에 축소시키는 유일한 아이디어입니다. #### 왜 프로파일러에서 플래시가 "잘못 보이는"가? @@ -356,24 +356,24 @@ FlashAttention은 정확히 이것을 겨냥한다. 전체 `s` 행렬을 먼저 | :--: | | 그림 16: 플래시 커널의 점유율 추정치가 13%로 보이는 모습 | -여기서 플래시가 프로파일러의 발자국을 읽는 사람들을 놀라게 한다. 플래시는 가장 빠른 백엔드인데도 프로파일러가 매우 낮은 점유율로 보고한다(그림 16에 표시). 왜 그것이 괜찮은지 보려면 세 가지 빠른 정의가 필요하다. +여기서 플래시가 프로파일러의 발자국을 읽는 사람들을 놀라게 합니다. 플래시는 가장 빠른 백엔드인데도 프로파일러가 매우 낮은 점유율로 보고합니다(그림 16에 표시). 왜 그것이 괜찮은지 보려면 세 가지 빠른 정의가 필요합니다. -GPU 커널은 본질적으로 다수의 작은 실행 유닛(스레드)에 의해 실행되는 일련의 명령이다. 이 개별 실행 유닛들은 변수 로딩, 더하기, 저장 등을 처리한다. 각 커널마다 많은 스레드를 실행하고, 이를 추적하기 위해 블록으로 묶는다. +GPU 커널은 본질적으로 다수의 작은 실행 유닛(스레드)에 의해 실행되는 일련의 명령입니다. 이 개별 실행 유닛들은 변수 로딩, 더하기, 저장 등을 처리합니다. 각 커널마다 많은 스레드를 실행하고, 이를 추적하기 위해 블록으로 묶는다. -블록은 스트리밍 멀티프로세서(SM)에 스케줄된다. 두고 온 블록은 한 SM에 완전히 남아 있으며, SM이 충분한 자원을 갖고 있으면 여러 블록을 한 번에 수용할 수 있다. 이러한 자원에는 레지스터, 공유 메모리, 최대 상주 스레드 수, 최대 상주 워프 수가 포함된다. 따라서 커널이 낮은 점유율(occupancy)을 가진다고 할 때, 이는 각 SM이 이론상 지원할 수 있는 것보다 더 적은 워프를 갖고 있음을 의미한다. +블록은 스트리밍 멀티프로세서(SM)에 스케줄됩니다. 두고 온 블록은 한 SM에 완전히 남아 있으며, SM이 충분한 자원을 갖고 있으면 여러 블록을 한 번에 수용할 수 있습니다. 이러한 자원에는 레지스터, 공유 메모리, 최대 상주 스레드 수, 최대 상주 워프 수가 포함됩니다. 따라서 커널이 낮은 점유율(occupancy)을 가진다고 할 때, 이는 각 SM이 이론상 지원할 수 있는 것보다 더 적은 워프를 갖고 있음을 의미합니다. > [!TIP] -> 스레드, 블록, 그리드 등에 대해 더 알고 싶다면 여기 [great resource](https://huggingface.co/blog/mi300kernels#a-quick-introduction-to-the-mi300x)가 있다. +> 스레드, 블록, 그리드 등에 대해 더 알고 싶다면 여기 [great resource](https://huggingface.co/blog/mi300kernels#a-quick-introduction-to-the-mi300x)가 있습니다. -트레이스에서 플래시 커널을 클릭하면 그 발자국이 이야기를 들려준다(그림 17). +트레이스에서 플래시 커널을 클릭하면 그 발자국이 이야기를 들려줍니다(그림 17). | ![Resource footprint of the pytorch_flash kernel in Perfetto, showing a high per-thread register count and large shared memory usage per block](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-attention-profile/flash-reg-count.png) | | :--: | | 그림 17: 커널 발자국, 블록당 레지스터와 공유 메모리가 많은 편 | -플래시는 블록당 많은 스레드와 상당량의 공유 메모리를 필요로 한다. 예를 들어 블록이 128개의 스레드이고 각 스레드가 255개의 레지스터를 사용하면 그 블록은 `128 × 255 = 32,640` 레지스터가 필요하다. 65,536 레지스터를 가진 Ampere SM에서 그런 두 블록만 한 번에 맞물린다. 각 128-스레드 블록은 `128 / 32 = 4` 워프를 가지므로 두 블록은 상주 워프가 겨우 8개다. 최대 64 상주 워프에 비하면 대략 13%의 점유율이다. 플래시는 최적화가 부족해서가 아니라, 각 블록이 온칩 자원을 의도적으로 매우 “무겁게” 사용하기 때문이다. +플래시는 블록당 많은 스레드와 상당량의 공유 메모리를 필요로 합니다. 예를 들어 블록이 128개의 스레드이고 각 스레드가 255개의 레지스터를 사용하면 그 블록은 `128 × 255 = 32,640` 레지스터가 필요합니다. 65,536 레지스터를 가진 Ampere SM에서 그런 두 블록만 한 번에 맞물린다. 각 128-스레드 블록은 `128 / 32 = 4` 워프를 가지므로 두 블록은 상주 워프가 겨우 8개다. 최대 64 상주 워프에 비하면 대략 13%의 점유율입니다. 플래시는 최적화가 부족해서가 아니라, 각 블록이 온칩 자원을 의도적으로 매우 “무겁게” 사용하기 때문입니다. -그리고 그것이 핵심이다. 높은 점유율은 많은 워프를 실행 대기 중으로 유지해 지연을 숨기는 데 도움이 되지만, 작업 자체를 더 효율적으로 만들진 않는다. 플래시는 어태션 타일을 온칩에 유지하고 데이터를 적극적으로 재사용하며, 전역 메모리에 전체 어텐션 매트릭스를 한 번에 구체화시키지 않기 위해 의도적으로 그 레지스터와 공유 메모리를 사용한다. +그리고 그것이 핵심입니다. 높은 점유율은 많은 워프를 실행 대기 중으로 유지해 지연을 숨기는 데 도움이 되지만, 작업 자체를 더 효율적으로 만들진 않습니다. 플래시는 어태션 타일을 온칩에 유지하고 데이터를 적극적으로 재사용하며, 전역 메모리에 전체 어텐션 매트릭스를 한 번에 구체화시키지 않기 위해 의도적으로 그 레지스터와 공유 메모리를 사용합니다. ### cuDNN 백엔드 @@ -387,26 +387,26 @@ uvx trace-util -f traces -b /traces | :--: | | 그림 18: cuDNN 백엔드 트레이스, 순전파당 하나의 생성된 어텐션 커널 | -이 시점의 패턴은 익숙하다. 플래시와 효율처럼 cuDNN은 순전파당 하나의 융합된, 플래시 스타일의 커널을 제공한다(그림 18). 그래서 자연스러운 질문은: **플래시가 이미 어텐션을 융합하는데, 왜 파이토치는 또 다른 플래시 백엔드를 제공하는가?** 그 답은 _커널을 누가 쓰고 어떻게 빌드했는가_의 차이에 있으며, 그 차이가 트레이스를 다르게 보이게 만든다. +이 시점의 패턴은 익숙합니다. 플래시와 효율처럼 cuDNN은 순전파당 하나의 융합된, 플래시 스타일의 커널을 제공합니다(그림 18). 그래서 자연스러운 질문은: **플래시가 이미 어텐션을 융합하는데, 왜 파이토치는 또 다른 플래시 백엔드를 제공하는가?** 그 답은 _커널을 누가 쓰고 어떻게 빌드했는가_의 차이에 있으며, 그 차이가 트레이스를 다르게 보이게 만듭니다. #### cuDNN 커널은 어떻게 다른가 -Flash와 Efficient는 PyTorch에 벤더링된 **고정된, 미리 컴파일된 커널**이다. 매번 같은 바이너리를 받는다. cuDNN은 NVIDIA의 자체 딥러닝 라이브러리이며, 그 주의 커널은 **주어진 문제에 맞춰 생성되고 튜닝된다**. 이는 고정된 cuBLAS 바이너리보다 더 코드 제너레이션에 가깝다. 그 매우 긴 커널 이름에서 그것을 바로 확인할 수 있다: +Flash와 Efficient는 PyTorch에 벤더링된 **고정된, 미리 컴파일된 커널**입니다. 매번 같은 바이너리를 받는다. cuDNN은 NVIDIA의 자체 딥러닝 라이브러리이며, 그 주의 커널은 **주어진 문제에 맞춰 생성되고 튜닝됩니다**. 이는 고정된 cuBLAS 바이너리보다 더 코드 제너레이션에 가깝다. 그 매우 긴 커널 이름에서 그것을 바로 확인할 수 있습니다: ```bash cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x1_cga1x1x1_kernel0_0 ``` -- `cudnn_generated`: 미리 배송되는 바이너리가 아니며 cuDNN에 의해 생성되었다. -- `flash_fprop`: 플래시 어텐션 스타일의 순전파다. 따라서 알고리즘은 플래시 백엔드의 계열과 같다. -- `wmma_f16`: 16비트 부동 소수 파이프라인에서 WMMA API를 사용하고, 텐서 코어 경로를 따른다. -- `knob_6`: cuDNN은 미리 조정된 구성("knobs") 세트에서 선택한다. 다양한 형태가 서로 다른 knob을 선택하게 한다. 이는 cuBLAS가 타일 변형을 선택하는 방식과 유사하다. +- `cudnn_generated`: 미리 배송되는 바이너리가 아니며 cuDNN에 의해 생성되었습니다. +- `flash_fprop`: 플래시 어텐션 스타일의 순전파다. 따라서 알고리즘은 플래시 백엔드의 계열과 같습니다. +- `wmma_f16`: 16비트 부동 소수 파이프라인에서 WMMA API를 사용하고, 텐서 코어 경로를 따릅니다. +- `knob_6`: cuDNN은 미리 조정된 구성("knobs") 세트에서 선택합니다. 다양한 형태가 서로 다른 knob을 선택하게 합니다. 이는 cuBLAS가 타일 변형을 선택하는 방식과 유사합니다. - `128x64x64`: 그가 선택한 타일 차원. -그 하나의 사실, 문제당 생성된 것,이 트레이스에서 보이는 다른 이상한 점들을 설명한다. +그 하나의 사실, 문제당 생성된 것, 이 트레이스에서 보이는 다른 이상한 점들을 설명합니다. -1. No transposes: CPU 레인은 `_cudnn_attention_forward`에서 바로 곧바로 몇 개의 `aten::empty` 할당으로 이어진 다음 커널로 가며, `aten::transpose`는 전혀 없다(Figures 19, 20 및 21). Flash와 Efficient는 텐서를 재구성하기 위해 메타데이터 전치를 네 번 삽입하는 반면, cuDNN은 생성기가 그 레이아웃에 맞는 커널을 생성하기 때문에 네이티브 `[B, H, S, D]` 레이아웃을 직접 사용한다. +1. No transposes: CPU 레인은 `_cudnn_attention_forward`에서 바로 곧바로 몇 개의 `aten::empty` 할당으로 이어진 다음 커널로 가며, `aten::transpose`는 전혀 없다(Figures 19, 20 및 21). Flash와 Efficient는 텐서를 재구성하기 위해 메타데이터 전치를 네 번 삽입하는 반면, cuDNN은 생성기가 그 레이아웃에 맞는 커널을 생성하기 때문에 네이티브 `[B, H, S, D]` 레이아웃을 직접 사용합니다. | Variant | Trace | | :--: | :--: | @@ -426,9 +426,9 @@ cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x | :--: | | Figure 23: cuDNN 커널이 0% 달성 점유율을 보고하고, 스레드당 240 레지스터, 블록당 256 스레드 | -#### 비용이 CPU로 이동했다 +#### 비용이 CPU로 이동했습니다 -"전치 없음(no transposes)" 이야기는 CPU에서 cuDNN이 가장 '가볍다'는 백엔드일 것이라고 기대하게 만든다. 그러나 실제로는 정반대다. +"전치 없음(no transposes)" 이야기는 CPU에서 cuDNN이 가장 '가볍다'는 백엔드일 것이라고 기대하게 만듭니다. 그러나 실제로는 정반대입니다. | backend | CUDA 평균 시간 | CPU 평균 시간 | | :--: | :--: | :--: | @@ -436,20 +436,20 @@ cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x | flash | 146.8 µs | 138 µs | | cudnn | 186.3 µs | **214 µs** | -심지어 전치 연산이 하나도 없더라도 cuDNN은 CPU에서 순전파당 약 **214 µs**를 소비한다. 이는 플래시(138)나 효율(117)보다 많다. 거의 전부가 `aten::scaled_dot_product_attention` 자체 시간(전체 실행의 26%)과 `_cudnn_attention_forward`에 있다. 이것은 cuDNN의 런타임 엔진이 매 호출마다 계획을 선택하고 준비하는(“knob” 탐색) 과정이다. +심지어 전치 연산이 하나도 없더라도 cuDNN은 CPU에서 순전파당 약 **214 µs**를 소비합니다. 이는 플래시(138)나 효율(117)보다 많습니다. 거의 전부가 `aten::scaled_dot_product_attention` 자체 시간(전체 실행의 26%)과 `_cudnn_attention_forward`에 있습니다. 이것은 cuDNN의 런타임 엔진이 매 호출마다 계획을 선택하고 준비하는(“knob” 탐색) 과정입니다. -더 적은 ATen 연산이 보이는 것이 CPU 작업이 적다는 뜻이 아니다. 그 작업은 라이브러리로 이동했고, 프로파일러는 이를 하나의 크고 불투명한 막대 하나로만 보여줄 뿐이다. 트레이스가 갑자기 더 깨끗해지면, 작업이 사라진 것이 아니라 프로파일러가 분해할 수 없는 곳으로 이동한 것일 수 있다. +더 적은 ATen 연산이 보이는 것이 CPU 작업이 적다는 뜻이 아닙니다. 그 작업은 라이브러리로 이동했고, 프로파일러는 이를 하나의 크고 불투명한 막대 하나로만 보여줄 뿐입니다. 트레이스가 갑자기 더 깨끗해지면, 작업이 사라진 것이 아니라 프로파일러가 분해할 수 없는 곳으로 이동한 것일 수 있습니다. -GPU에서 cuDNN(186.3 µs)은 효율성과 플래시 사이에 위치한다. 이 아주 플래시 친화적인 형태에서 수작업 FlashAttention-2가 그 위를 약간 앞선다. cuDNN은 더 큰 head 차원이나 다른 시퀀스 길이의 _다른_ 형태에서 자주 이긴다. 다만 그 재조정은 당신이 CPU에서 지불한 비용이기도 하다. +GPU에서 cuDNN(186.3 µs)은 효율성과 플래시 사이에 위치합니다. 이 아주 플래시 친화적인 형태에서 수작업 FlashAttention-2가 그 위를 약간 앞선다. cuDNN은 더 큰 head 차원이나 다른 시퀀스 길이의 _다른_ 형태에서 자주 이긴다. 다만 그 재조정은 당신이 CPU에서 지불한 비용이기도 합니다. ## Everything we covered, at a glance {#section-4} -마무리하기 전에, 우리가 프로파일링한 모든 어텐션 변형과 각 트레이스가 가르쳐 준 하나의 교훈을 정리한 하나의 표가 있다. +마무리하기 전에, 우리가 프로파일링한 모든 어텐션 변형과 각 트레이스가 가르쳐 준 하나의 교훈을 정리한 하나의 표가 있습니다. | Variant | What we changed | Kernels / forward | What the trace revealed | | :-- | :-- | :--: | :-- | | Naive attention | 원시 연산으로 직접 구성된 어텐션(matmul, mul, mask, softmax, matmul) | 6 | 외부의 `masked_fill`에서 온 숨겨진 `Memcpy`. | -| Naive in-place | `masked_fill` → `masked_fill_` | 5 | 한 줄로 `Memcpy` 커널을 완전히 제거한다. | +| Naive in-place | `masked_fill` → `masked_fill_` | 5 | 한 줄로 `Memcpy` 커널을 완전히 제거합니다. | | SDPA math | `F.scaled_dot_product_attention` 수학 백엔드에 고정 | 20 | 참조 구현: CUDA 코어의 FP32, 매 호출마다 마스크 재구성, `_safe_softmax`. 정확하지만 약 3.7배 느림. | | SDPA efficient | Efficient(xformers) 백엔드 | 1 | 하나의 융합 `fmha_cutlassF` 커널, 텐서 코어에서 bf16 유지. | | SDPA flash | Flash 백엔드 | 1 | 하나의 융합 `pytorch_flash` 커널(FlashAttention-2). 가장 빠르지만 13% 점유율은 "잘못 보이는" 경우. | @@ -459,13 +459,13 @@ GPU에서 cuDNN(186.3 µs)은 효율성과 플래시 사이에 위치한다. 이 이 시리즈에서 한 가지만 takeaway를 뽑자면, 모든 트레이스 전에 반복했던 습관인 **먼저 추측하고, 그다음 보자**가 되길 바란다. -트레이스에 무엇이 들어 있을지 소리 내어 예측하고, 트레이스를 열어 보며, 일치하지 않는 부분을 화면에서 가장 흥미로운 것으로 여기자. 이 세 편의 포스트에서 얻은 모든 실제 통찰은 숨겨진 `Memcpy`, `addmm` 에필로그, 20 커널 수학 백엔드, 플래시의 "잘못 보이는" 점유율, cuDNN의 두툼한 CPU 바에서 비롯된 것이며, 이는 트레이스와 일치하지 않는 추측에서 비롯된다. +트레이스에 무엇이 들어 있을지 소리 내어 예측하고, 트레이스를 열어 보며, 일치하지 않는 부분을 화면에서 가장 흥미로운 것으로 여기자. 이 세 편의 포스트에서 얻은 모든 실제 통찰은 숨겨진 `Memcpy`, `addmm` 에필로그, 20 커널 수학 백엔드, 플래시의 "잘못 보이는" 점유율, cuDNN의 두툼한 CPU 바에서 비롯된 것이며, 이는 트레이스와 일치하지 않는 추측에서 비롯됩니다. -프로파일링은 GPU 전문가를 위한 별도의, 위협적인 기술이 아니다. 그것은 단지 아주 면밀히 보고 “저게 왜 그런가?”를 묻고 대답이 떠오를 때까지 파고드는 훈련일 뿐이다. 이제 당신은 자신이 다루는 모델에서 이를 스스로 할 수 있는 어휘와 반사적 반응을 갖추었다. 트레이스를 열고, 추측을 세우고, 불일치를 찾아보라. +프로파일링은 GPU 전문가를 위한 별도의, 위협적인 기술이 아닙니다. 그것은 단지 아주 면밀히 보고 “저게 왜 그런가?”를 묻고 대답이 떠오를 때까지 파고드는 훈련일 뿐입니다. 이제 당신은 자신이 다루는 모델에서 이를 스스로 할 수 있는 어휘와 반사적 반응을 갖추었다. 트레이스를 열고, 추측을 세우고, 불일치를 찾아보세요. -**Profiling in PyTorch** 시리즈를 읽어 주셔서 감사합니다. 이제 뭔가를 프로파일링해 보자. 🤗 +**Profiling in PyTorch** 시리즈를 읽어 주셔서 감사합니다. 이제 뭔가를 프로파일링해 봅시다. 🤗 초기 초안에 대한 리뷰를 남겨 주신 [Noe Flandre](https://huggingface.co/NoeFlandre)께 감사드립니다! > [!NOTE] -> 이 블로그 포스트는 LLM을 이용해 다듬었습니다. 이것이 배경에서 에이전트가 포스트를 생성하도록 한다는 뜻은 결코 아닙니다. 팀의 일부는 영어를 모국어로 하지 않으며, LLM(대부분 영어로 학습된 모델)이 어리석은 문법 실수를 바로잡거나 더 덜 위협적으로 들리고 깔끔하게 들리는 문장을 재구성할 수 있다고 생각합니다. 이 점이 "왜 읽어야 하나, 이것이 LLM으로 생성되었기 때문인가"라는 아이디어에 도움이 되길 바랍니다. 🤗 +> 이 블로그 포스트는 LLM을 이용해 다듬었습니다. 이것이 배경에서 에이전트가 포스트를 생성하도록 합니다는 뜻은 결코 아닙니다. 팀의 일부는 영어를 모국어로 하지 않으며, LLM(대부분 영어로 학습된 모델)이 어리석은 문법 실수를 바로잡거나 더 덜 위협적으로 들리고 깔끔하게 들리는 문장을 재구성할 수 있습니다고 생각합니다. 이 점이 "왜 읽어야 하나, 이것이 LLM으로 생성되었기 때문인가"라는 아이디어에 도움이 되길 바랍니다. 🤗