到底什麼是 Kernel?

@MainzOnX
英語1 天前 · 2026年7月14日
198K
768
77
20
1.6K

TL;DR

本指南將 GPU Kernel 解釋為小型平行程式,並示範如何透過 torch.compile 進行 Kernel 融合,藉此減少記憶體流量以優化 PyTorch 效能。

GPU 上的兩個數字

你已經知道如何在 Python 中執行 c = a + b,而且做過上千次了。這裡我們要討論的是在 PyTorch 張量中的運作方式。張量就是一個數值陣列。把它放到 GPU 上,代表這個陣列存在 GPU 的記憶體中,而不是一般的 CPU 記憶體。當 ab 都是 GPU 上的張量時,這一行程式碼執行得飛快,快到你根本不會去想它。

現在把它縮小。假設 ab 是兩個單一的浮點數,都放在 GPU 上。同樣一行程式碼:c = a + b。晶片上實際執行了什麼?

答案是 kernel。在這個領域中,kernel 是一個小程式,讓 GPU 在某一塊資料上執行。不是你筆電開機時的那個 OS kernel,也不是線性代數課本裡的數學 kernel。這個詞被重複使用很多次,那不是你的錯。在 GPU 的世界裡,kernel 就是:一個小函式,被告知要立刻、平行地在你交給它的資料上執行。

到這篇文章結束時,你將能夠看著一段 PyTorch 程式碼,數出 GPU 會執行多少個 kernel。這聽起來像是一個小技巧,事實上也的確是,但這是打破「GPU 是黑盒子」那種感覺的第一步——那種當你的模型跑很慢卻不知道原因的感覺。所有你能問的關於 GPU 效能的問題,最終都會回到「哪個 kernel 跑了,它們在做什麼」。所以我們從這裡開始。

你的第一個 kernel

讓我們把 ab 稍微變大一點。這次是長度為 8 的張量。仍然是同一行 PyTorch 程式碼:c = a + b

當你執行這行程式碼時,你的 CPU(實際執行 Python 的機器)對 GPU 說:嘿,去執行這個程式,處理這筆資料。這個指令稱為 launch(啟動)。被啟動的是一個 kernel:一個準備好執行的程式。啟動本身很便宜,每次幾微秒。真正昂貴的是圍繞在啟動周圍的部分(資料送到 GPU、結果傳回來),這才是我們要計算的成本。

在 kernel 內部,實際工作是由稱為 threads(執行緒)的小型工作者完成的。GPU 有成千上萬個這樣的執行緒可用。對於我們的長度 8 加法,8 個執行緒接手工作:執行緒 0 處理元素 0,執行緒 1 處理元素 1,依此類推直到執行緒 7。每個執行緒執行相同的小程式:讀取 a 的一個元素,讀取對應的 b 元素,將它們相加,然後將結果寫入 c

(實際上,GPU 會以固定大小的群組啟動執行緒,稱為 warp,在 NVIDIA 顯卡上總是 32 個執行緒,當你的陣列無法均分時,會遮蔽掉多餘的執行緒。目前暫時可以忽略這個細節。)

Adam Mainz - inline image

所以我們有一行 PyTorch 程式碼、一次啟動、一個 kernel、8 個執行緒執行 8 次加法。現在讓我們計算實際上穿越晶片的資料量。為了執行加法,每個執行緒需要它的 a 元素和 b 元素。那就是 8 次 a 的讀取和 8 次 b 的讀取。然後每個執行緒將結果寫入 c。那就是 8 次寫入。

這些讀寫操作是針對位於 GPU 晶片旁邊的大型記憶體。在資料中心顯卡(A100、H100)上,這種記憶體稱為 HBM(高頻寬記憶體)。在消費級顯卡(RTX 4090)以及人們實際上可能用來測試的 Colab 風格 T4 上,則稱為 GDDR。無論哪種,都是放在晶片旁邊的快速記憶體,我們就稱之為 GPU 記憶體。它很快,但並非免費,每次存取都有成本。

一個 kernel = 一次啟動 = 一次對資料的遍歷。 無論 kernel 內部做什麼,其邊界上的讀寫操作(到 GPU 記憶體提取輸入、寫回輸出)都是成本所在。這就是整體的樣貌。

當張量變大時,這些都不會改變。同樣的 PyTorch 程式碼,同樣的一個 kernel,只是執行緒更多。如果 ab 各有 100 萬個元素,GPU 會啟動同一個 kernel,但使用更大群的執行緒。運算量隨之增加,位元組移動量也隨之增加,但思維模型不變。一行程式碼,一個 kernel。

Adam Mainz - inline image

兩個運算之間發生什麼事

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

你對 Python 夠熟悉,知道這是兩個運算:一個加法,然後一個 relu,串聯在一起。在直譯器中,這是兩個函式呼叫。在 GPU 上,在 eager 模式下的 PyTorch 中,這是兩個 kernel 啟動:一個用於加法,一個用於 relu。到目前為止,並不令人意外。

真正有趣的是兩個 kernel 之間發生的事。

當加法完成時,它必須把結果放到某處。那個某處就是 GPU 記憶體。加法將一個完整的中間陣列(稱之為 tmp)寫入記憶體。然後過了一會兒,relu 啟動,它的第一個工作就是從記憶體中讀回同一個 tmp 陣列。它讀取整個陣列,對每個元素應用 relu,然後將結果寫入 c

計算這兩個 kernel 的記憶體流量:

  • 加法: 讀取 a,讀取 b,寫入 tmp。三次陣列大小的傳輸。
  • relu: 讀取 tmp,寫入 c。再多兩次。

總共五次陣列大小的傳輸。相比之下,前一節中單獨的長度 8 加法只有三次。在鏈中加入 .relu() 不僅僅花費了 relu 的計算成本,還讓你付出了一次完整的陣列往返 GPU 記憶體的成本,因為 tmp 必須被寫出,讓下一個 kernel 才能讀回來。

沒有任何東西被快取。tmp 並沒有留在暫存器或快速的本地快取中。它被送到 GPU 記憶體(較慢且較遠的那種)然後立刻回來。這兩個 kernel 彼此不認識。它們必須透過它們都知道如何溝通的中介來傳遞資料:GPU 記憶體。

為什麼 PyTorch 要這樣做?因為在 eager 模式下,當你寫 a + b 時,PyTorch 會立刻執行它。它不知道你接下來要呼叫 .relu()。每個運算在其 Python 行執行時就被派發。沒有計畫,沒有前瞻。每個運算獨立存在,產生一個真實的陣列,並透過記憶體交給接下來的運算。

這是你要記住的模式。 你 PyTorch 程式碼中的每一個中間值都會被實際寫入 GPU 記憶體,然後被下一個運算讀回來。每一個都是。這就是「kernel 計數」真正衡量的東西。每一個額外的 kernel 都代表你的資料必須多一次往返 GPU 記憶體。

融合:兩個運算,一個 kernel

想像一個 kernel 一次完成所有工作:讀取它的 a 元素,讀取它的 b 元素,相加,對結果應用 relu(全部在 kernel 內部進行,使用每個執行緒的小型私有暫存空間,永遠不會離開晶片),然後才將最終值寫入 c。中間的 (a + b) 仍然存在,但只在 kernel 內部,在每個執行緒的私有暫存空間中。它從未被寫入 GPU 記憶體。tmp 作為一個真實的陣列,根本不存在。

現在計算傳輸次數。讀取 a:每個元素 1 次。讀取 b:每個元素 1 次。寫入 c:每個元素 1 次。三次陣列大小的傳輸。 與兩個 kernel 的版本數學相同,但少了兩次往返。

在長度 8 時,這只是個四捨五入的誤差。沒人在乎。在長度 100 萬或 1 億時,那些額外的往返就會佔據執行時間的很大一部分,而且實際時鐘時間也會反映出來。為什麼記憶體流量會如此主導,是這個系列文章第 2 篇的主題,所以我在這裡不深入探討原因。現在的重點只是:相同的數學,更少的往返,實際執行更快。

這個技巧——將原本會是分開的 kernel 的運算組合成一個單一的 kernel,使得中間值不必訪問 GPU 記憶體——有一個名字。它叫做 fusion(融合)。就是這個詞。就是這個概念。

現在尷尬的部分來了。手動撰寫那個組合的 kernel 看起來很簡單,就是加法加 relu。兩個運算。中間一行「計算」。但真實的 PyTorch 程式碼有數十個運算串聯在一起,每個都有自己的形狀、資料類型和廣播規則。編寫一個能正確處理所有這些情況的融合 kernel 是真正的工程工作。你通常不會手動編寫這些常規的元素級 kernel。

好消息是:PyTorch 提供了一個工具,可以自動為你重寫這類情況。它叫做 torch.compile

你幾乎肯定看過 torch.compile 這樣使用:

python
1model = torch.compile(model)

一行程式碼。網路上有人告訴你這樣做會讓程式更快。以下是用白話文說明它實際做了什麼:torch.compile 不會像 eager 模式那樣一次一個運算地執行你的操作,而是捕捉你的函式所執行的張量運算,尋找合併的機會,並產生最佳化的程式碼。後續符合相同假設的呼叫可以重複使用該程式碼。

我們在紙上做的融合(加法與 relu,共用一個 kernel,tmp 從不接觸記憶體)正是 torch.compile 會自動對你的程式碼做的事情,只要運算夠簡單。當人們說 torch.compile「讓 PyTorch 更快」時,這正是他們意思中的一大部分。

對於 torch.compile 無法自行融合的情況(它無法識別的自訂運算、不常見的歸約、奇怪的記憶體佈局),仍然需要有人手動編寫 kernel。這就是 Triton 和 CUDA 等工具的用途。另一篇文章再談。

Adam Mainz - inline image

親眼看看

以上所有內容都是在紙上計數 kernel。現在是時候在真實的 GPU 上計數了。如果你有任何配備 CUDA GPU 的機器(桌機、Colab 筆記本、雲端實例),你可以在幾分鐘內自己執行看看。

使用的工具是 torch.profiler。它內建在 PyTorch 中。它所做的就是記錄 GPU 在執行程式碼時實際做了什麼,並回傳一個你可以閱讀的表格。

步驟 1:eager 版本。

將兩行運算包在一個函式中,以便我們可以呼叫:

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

torch.cuda.synchronize() 只是為了確保 GPU 在我們讀取時間之前已完成工作。GPU 工作是非同步執行的,如果沒有同步,有時你會測量到啟動開銷,而不是實際的 kernel 工作。

步驟 2:讀取輸出。

你的實際分析器輸出可能會有比預期更多的行。會混雜一些記憶體配置和 PyTorch 簿記行。我們關心的行是 CUDA kernel,也就是 GPU 實際執行的函式。尋找名稱中包含 kernel 的行。這兩個 kernel 看起來大致像這樣:

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

確切的模板名稱會隨著 PyTorch 版本而變化(relu 通常顯示為 threshold,因為那是底層的運算,而 add 有時顯示為 CUDAFunctor_add)。不要試圖解析整個名稱。只要數行數就好。兩行。兩個 kernel。 一個用於加法,一個用於 relu。完全符合我們在上一節中說的。

步驟 3:編譯版本。

改變一行程式碼。將函式包在 torch.compile 中:

python
1compiled = torch.compile(add_relu)

現在有一個你在執行之前需要知道的陷阱。第一次呼叫 torch.compile 編譯過的函式會很慢,有時異常地慢,因為那是 torch.compile 做它的工作的時候:分析你的程式碼,找出要融合的內容,產生融合的 kernel。如果你分析第一次呼叫,你測量到的是編譯步驟,而不是 kernel。所以模式是:先呼叫一次來暖機,丟棄結果,然後再分析。

text
1compiled(a, b) # 暖機,丟棄結果
2torch.cuda.synchronize()
3
4with profile(activities=[ProfilerActivity.CUDA]) as prof:
5 compiled(a, b)
6 torch.cuda.synchronize()
7
8print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))

步驟 4:再次讀取輸出。

這次:

text
1triton_poi_fused_add_relu_0 1 14us

一行。一個 kernel。 名稱甚至告訴你它做了什麼:融合的加法與 relu。與之前相同的數學運算,一次啟動而不是兩次。

你剛剛做到了這篇文章一直在說的事情,用一句話總結:你要求 PyTorch 將兩個運算組合成一個 kernel,觀察了分析器,並確認計數從兩個變成一個。融合,在真實環境中,在你的機器上。

如果你想看到更明顯的效果,可以在不同的張量大小下試試看。在長度 100 時,兩個版本都跑得非常快,差異會消失在雜訊中。在長度 1000 萬或 1 億時,編譯版本開始明顯領先,因為我們減少的往返在這種規模下是實際工作的一部分。

計數 kernel 不再是抽象的建議。你現在有方法可以驗證了。

Adam Mainz - inline image

總結一下

以下是全部內容的濃縮版。

你的 PyTorch 程式碼在 GPU 上執行時,會轉換成一系列 kernel。每個 kernel 是一次啟動、一次對資料的遍歷、一次往返 GPU 記憶體以提取輸入和寫出輸出。簡單的運算變成一個 kernel。多個運算串聯預設變成每個運算一個 kernel,中間值在記憶體中往返。torch.compile 可以為你融合簡單的運算鏈,使這些中間值永遠不接觸記憶體。較少的 kernel 通常意味著較少的記憶體流量。而較少的記憶體流量通常意味著更快。

二次創作

使用 YouMind 創作爆款文章

收集素材、拆解爆點、生成視覺資產、撰寫內容,並在一個 AI 工作空間裡完成分發。

了解 YouMind
寫給創作者

把你的 Markdown 變成乾淨的 𝕏 文章

圖片上傳、表格、程式碼區塊,往 𝕏 上手動重排太痛苦。YouMind 把整篇 Markdown 一鍵轉成乾淨、可直接發佈的 𝕏 文章草稿。

試試 Markdown 轉 𝕏

更多可拆解樣本

近期爆款文章

探索更多爆款文章