vatt'ghern jaskier's ballads

一個 warp 裡 32 個 thread 各自要 4 bytes,這條最基本的讀取指令要先經過 coalescer、L1、TLB、crossbar、36 個 L2 slice 之一、記憶體控制器,最壞的情況才會碰到 DRAM——命中的樓層不同,代價差了將近 17 倍。

GPU 讀一次記憶體,經過幾層硬體

完這篇,你會知道一件事:GPU 的「讀一個 float」不是一步到位,是一串會分別命中或落空的硬體站點——coalescer、L1、TLB、crossbar、某個 L2 slice、記憶體控制器,最壞情況才輪到 DRAM。文章從一行你已經看過的陣列加法開始,沿著 Doubleword 創辦人暨技術成員 Fergus Finn 在一張鎖定 2.6 GHz 核心時脈的 RTX 4090 上實測到的每一站延遲,一路走到 DRAM 的 activate 與 column read,最後回到「這對你調 kernel 有什麼用」這個問題。

從 c[i] = a[i] + b[i] 到一條 LDG.E

上一篇文章追的是一個最簡單的 kernel:c[i] = a[i] + b[i],一個 thread 對一個 float,從 nvcc 編譯一路追到 warp 怎麼被排上 SM。那篇文章自己承認漏掉了一大塊——kernel 裡真正在做事的那條指令,也就是把 a[i] 這個 float 從記憶體搬進暫存器的「全域讀取」,究竟怎麼被硬體服務。這一篇補上那一塊。

c[i] = a[i] + b[i];   // 一個 thread 對一個 float

LDG.E                 // 編譯後真正去搬資料的那條指令
                      // 32 個 lane 各要 4 bytes,合計 128 bytes
                      // 服務它要動用:
                      //   四個 32-byte sector
                      //   一條 cache line
                      //   一次位址轉換
                      //   一次 crossbar 穿越
                      //   三十六個 L2 slice 其中一個
                      //   miss 的話,才輪到 DRAM

追蹤的對象很具體:一條 LDG.E 指令,warp 裡 32 個 lane 各自要 4 bytes,合計要 128 bytes。原文自己給了一句路徑摘要:服務這條指令,要經過四個 32-byte sector、一條 cache line、一次位址轉換、一次 crossbar 交叉、36 個 L2 slice 裡的其中一個,如果兩層快取都沒中,還要加一次 activate 與四次 column read。這句摘要,也是接下來整篇文章要拆開來講的內容。

這條路徑值得停下來看的原因很簡單:「讀一個 float」聽起來像是一步就到位的事,實際上是一連串會分別成功或落空的硬體查詢,每一次落空都把成本推高一個量級。

先說清楚這篇文章追蹤的範圍:作者量的是單一 warp 的單一 load。原文沒有自己聲明過這個範圍限制,但從它只架一個 one-warp kernel 來量這件事看,合理的推測是:多個 warp 同時發出請求時,記憶體控制器要怎麼排隊、同一個 bank 被連續打中會不會衝突這類「多請求交錯」的次序問題,並不在這一篇的涵蓋範圍裡。換句話說,接下來每一個數字,回答的都是「這一條路徑本身要付多少代價」,不是「一整個 kernel 實際跑起來會有多快」——後者還要疊上 occupancy、排程這些變數。這個範圍限定,也是接下來讀每一個數字時要記住的前提。

「L1、L2、DRAM 三層」這句話沒告訴你的事

多數人對 GPU 記憶體的印象停在一張三格圖:L1、L2、DRAM,一層比一層慢。這張圖沒有畫錯,但它把好幾個工程師真正要面對的細節都省略掉了。

L1 的「你問一次」跟「它抓多少」其實是兩件事——合併粒度是 32-byte 的 sector,但實際儲存組織卻是 128-byte 的 line(一條 line 裝四個 sector),而且是 4-way set-associative,用的是虛擬位址。從虛擬位址走到實體位址,中間插了一層 TLB:SM 上只留最近 16 筆轉換、所有 warp 共用,任何位址第一次用到必定 miss,miss 要多花約 4.4 ns。

sector、line、set-associative 這三個詞常常被當同義詞混用,但它們回答的是三個不同的問題:sector 決定「一次要抓多少 byte」,line 決定「抓進來的東西以什麼單位存放」,set-associative 的路數決定「同一個位置能不能同時放好幾份、不必立刻互相踢掉」。L1 用 32-byte sector 定址、卻用 128-byte line 存放,等於每次 miss 都會順帶多帶三個 sector 進來;4-way 代表同一個 set 最多同時留 4 條 line,超過就要踢掉一條。L2 的路數是 16-way——同一個 set 能同時留住的 line 數是 L1 的四倍。原文只給出兩層各自的組織、沒有做這個跨層比較,但照這個數字推算,合理的推測是:一些在 L1 裡會互相踢掉的資料,換到 L2 的同一個 set 裡就還有位置可以並存。

L2 也不是「一塊」。它是 36 個各自獨立、各 2 MiB 的 slice,請求要先經過 crossbar,依位址的一個「頗複雜」的函式被送到其中一個 slice——每個 slice 內部又是 1024 個 set、16-way,換成實體位址定址。從虛擬位址的 L1 走到實體位址的 L2,中間那次 TLB 轉換不只是換一個數字:它是整條路徑上位址空間真正切換的那一刻,也是為什麼「同一個位址第一次用到必定 miss」這句話單獨成立——沒有轉換過,L2、crossbar 都無從知道要把請求送去哪裡。DRAM 更不是平坦的空間:GDDR6X 用 2 條 channel、每條 16 個 bank、每個 bank 65,536 個 row 排成二維陣列,一次只能開一個 row(這一步原文自己形容成「貴」),開了之後才能對這個 row 讀 32-byte 的 column。

三格圖沒告訴你的是,每一格裡面都還有自己的組織方式——而那個組織方式,決定了你的存取模式會不會撞到代價最高的那條路。下面這張表把四層的組織方式攤開,捲進頁面時會慢慢顯出來。

L1

32-byte sector 定址・128-byte line(4 sector/line)・4-way・虛擬位址

TLB

16 筆最近轉換・warp 共用・miss 多花約 4.4 ns

L2

36 個 2 MiB slice・每 slice 1024 set・16-way・實體位址

DRAM(GDDR6X)

2 channel × 16 bank × 65,536 row・一次開一個 row,讀 32-byte column

四層各自的組織方式——這是「三層快取」那張簡圖沒畫出來的部分。

表格裡「DRAM(GDDR6X)」那一列,其實還可以再往上拉一層看。這張 RTX 4090 總共有十二個記憶體控制器,合起來組成一條 384-bit 的匯流排——原文寫得很白:十二個控制器,每個負責 32-bit。每個控制器只做一件事:對著一顆 GDDR6X 晶片說話,一個控制器配一顆晶片,彼此不共用。也就是說,表格裡「2 channel × 16 bank × 65,536 row」描述的,其實是單一一顆 GDDR6X 晶片內部的組織;卡上有十二顆這樣的晶片,各自掛在自己的控制器後面,平行運作、互不干擾。

這一層平行,是「三格圖」漏掉的另一半:一次全域讀取的位址,最終會落在十二個控制器裡的哪一個,決定了它是不是要跟其他 warp 的請求排隊等同一個控制器、同一顆晶片。下面這張圖把十二個控制器攤開——每一個各自面對一顆晶片、各自的 channel 與 bank,彼此不共用線路。

384-bit 匯流排(12 × 32-bit) 十二個獨立控制器 → 各自一顆 GDDR6X 晶片
十二個控制器合組成 384-bit 匯流排,每個控制器只對一顆 GDDR6X 晶片說話——表格裡「2 channel × 16 bank × 65,536 row」,說的就是這其中一顆晶片內部的組織,卡上有十二顆這樣的晶片並行運作。

coalescer 先決定你要問幾次

在故事真正進到 L1 之前,還有一步決定了後面所有代價:coalescer。

coalescer 的工作,原文寫得很白:figure out the minimal number of L1 sectors it needs to retrieve to service our 4-byte requests——用最少的 sector 數量,服務 warp 裡每個 lane 各自的 4-byte 請求。追蹤的這個例子裡,32 個 lane 要的是連續的 128 bytes,coalescer 剛好合併成 4 個連續的 32-byte sector 請求,128 bytes 需求對上 128 bytes 抓取,一滴不浪費。

這句話反過來讀才是重點:如果 32 個 lane 的位址不連續——每個 lane 跳著讀,中間夾了 stride——coalescer 沒辦法把它們塞進同樣少的 sector 裡,同一個 128 bytes 的邏輯需求就要拆成更多次 sector 請求,每一次沒被用滿的 sector,都是抓進來又丟掉的頻寬。下面這個工具把這條規則攤開:拖動 stride,看 32 個 lane 各自的 4 bytes 落進哪些 32-byte sector,連續讀取跟跳著讀取之間,實際要抓的 sector 數量差了多少。

拖動 stride,看 32 個 lane 落進幾個 sector · stride 1–8

1 ・ 4 個 sector ・ 128 bytes ・ 效率 100%
8 8 8 8 lane 0 lane 31
stride = 1 對應原文給的例子:32 個 lane 位址連續,coalescer 剛好合併成 4 個 32-byte sector,128 bytes 需求對上 128 bytes 抓取。stride 變大之後的 sector 數,是同一條「最少化」規則算出來的結果,不是另一組實測數字。

這件事值得講得更白一點:coalescer 省下來的,不只是這一次 L1 查詢的次數。任何一個沒被用滿的 sector,都是要一路揹到底的重量——它要先在 L1 miss,再翻 TLB、跨 crossbar、找 L2 slice,運氣不好還要在 L2 也 miss,一路揹到 DRAM 去換一次 activate 加 column read。換句話說,stride 一拉大,浪費的不是「多讀了幾個 byte」這麼小的事,而是把原本一次 15.4 ns 就能結束的請求,多出來的那幾個 sector 各自都有機會被推去啃 127 ns 甚至 255 ns 那個量級的代價。coalescer 站在整條路徑最前面,做的其實是「幫你決定要不要把後面幾層的代價都乘上去」這件事。

追一次真正的 LDG.E:從暫存器到 DRAM 再折返

把前兩節的規則收斂成一條時間軸,原文給的答案很乾脆:命中的樓層不同,代價差了一個量級。

如果資料已經在 L1,這條 LDG.E 大約 15.4 ns(40 cycles)就能返回。如果 L1 沒中,要往下問 TLB、crossbar、某個 L2 slice——只要 L2 有,總成本大約 127 ns(330 cycles)。如果兩層都沒中,原文自己把完整路徑寫了出來:L1、TLB、crossbar、L2、controller,再折返,總成本大約 255 ns(660 cycles)——大約是 L1 命中的 17 倍。

LDG.E R4, [R2]        // 每個 lane 各要 4 bytes,warp 共 32 個 lane

255 ns 對 15.4 ns,約 17 倍——這個倍數值得多停留一下。它不是「DRAM 比 L1 慢一點」這種模糊印象,而是同一條 LDG.E 指令,光是「在哪一層被找到」這一件事,就能把總延遲乘上一個量級。而三種情境走的其實是同一條路徑,差別只在於在哪一站提早結束:L1 命中,走了三站;L2 命中,走了六站;DRAM 往返,走完全部八站再折返。多出來的每一站,都是前面那張表裡各自有自己組織方式的硬體——不是抽象的「慢一點」,是 TLB 要多花 4.4 ns、crossbar 要多繞一趟、L2 slice 要多查一次、記憶體控制器要多送一次 activate。

這三個數字都不是規格書上的公稱值,是作者自己在一張鎖定 2.6 GHz 核心時脈的 RTX 4090(sm_89)上,用「指標在特定 eviction set 裡連續跳轉兩萬次」量出平均延遲,再搭配硬體計數器比例與 alias mapping 反推出快取的組織方式。下面這個工具讓你切三種命中情境,看代價怎麼疊上去。

切三種命中情境,看代價從哪一站開始疊加 · 3 個情境

暫存器 coalescer L1 TLB crossbar L2 控制器 DRAM 15.4 ns(40 cycles) 資料已經在 L1,不必往下問。
三個數字都是作者在鎖定 2.6 GHz 核心時脈的 RTX 4090 上實測的平均延遲,不是規格書公稱值;命中的樓層每往下一層,代價跳一個量級。

命中在哪一層決定了總成本,但真正付出代價的是沿路每一站各自的工作。拖著下面這個把手,沿著這條路徑走一遍——從暫存器出發,經過 coalescer 把 32 個請求收成 4 個 sector,進 L1,沒中就查 TLB,拿到實體位址後跨過 crossbar,落進 36 個 L2 slice 裡的其中一個,如果還是沒中,最後才輪到記憶體控制器對 GDDR6X 送出 activate 與 column read。

拖著把手沿路徑走一遍,或用左右鍵切換 · 8 站

暫存器 coalescer L1 TLB crossbar L2 控制器 DRAM
L132-byte sector 定址、128-byte line(4-way)、虛擬位址。命中的話到這裡就結束:約 15.4 ns(40 cycles)。
把手預設停在 L1——大多數請求走到這裡就結束了;拖到 DRAM 才是最壞情況。

怎麼在 profiler 上看到這幾層

前面幾節講的 sector、TLB、crossbar、L2 slice,都不是憑空畫出來的抽象分層——作者在原文裡直接把每一站對應到 Nsight Compute(ncu)底下可以讀到的硬體計數器,讓這條路徑不只是「重建出來的架構圖」,而是可以在自己的 kernel 上重新量一次的東西。

最直接的一個實驗,呼應前面那個拖 stride 的工具:架一個只有一個 warp、每個 lane 讀 4 bytes、stride 固定的合成 kernel,丟進 ncu 裡跑,計數器 l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio 會直接報出「這次 load 平均用了幾個 sector」。stride 為 1 時,32 個 lane 覆蓋連續的 128 bytes,計數器讀到 4——跟前面 coalescer 合併出來的 4 個連續 sector 完全對上。stride 拉大之後,這個數字就等於 32 個 lane 各自位址落進了幾個不同的 32-byte 區間,而且原文說得很清楚:不會多讀,沒有多餘的 sector 被要求。這代表拖 stride 那個工具算出來的「sector 數」,不是憑空模擬的示意,而是這顆計數器實際會讀到的值。

再往下一站,crossbar 本身也不是無限頻寬:每個 SM 每個 cycle 最多能把兩個 line-request 丟給 crossbar,三十六個 slice 各自獨立服務,不互相卡住。對應的出口計數器是 l1tex__m_l1tex2xbar_req_cycles_active——這一站忙不忙、是不是變成瓶頸,同樣有名字可以查,不必只靠猜。

到了 L2 入口,原文也給出對應的一組計數器:lts__t_requests_srcunit_tex_op_read 與它的 t_sectors 對應版本,分別讀出「crossbar 送過來的請求,在 L2 這端變成幾次 request、幾個 sector」。作者也提醒了量測本身的眉角:要把 ncu 的讀數當成迭代次數的斜率去取,把 kernel launch 這類固定開銷從讀數裡消掉,量到的才是這條路徑本身的成本,不是量測雜訊。

想確認什麼在哪一站查計數器
32 個 lane 合併成幾個 sectorcoalescer/L1 出口l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio
crossbar 這一跳是不是被塞爆L1 到 L2 的交叉口l1tex__m_l1tex2xbar_req_cycles_active
L2 端實際收到幾次 request、幾個 sectorL2 入口lts__t_requests_srcunit_tex_op_readt_sectors
三個計數器分別卡在路徑上不同的站——coalescer 出口、crossbar 出口、L2 入口,讀數要取多次迭代的斜率,把固定開銷濾掉。

換句話說,coalescer 是不是把 32 個 lane 收斂成最少 sector、crossbar 是不是被塞爆、L2 端實際處理了多少 request——這三個問題,分別對應三個可以直接讀出來的數字,不必只憑架構圖猜測自己的 kernel 落在代價曲線的哪一段。

這三個計數器合在一起看,其實是把前面整條路徑翻譯成一個可以反覆迭代的迴圈:改一次存取模式,重新跑一次 ncu,看 sector-per-request 有沒有往 4 靠近、crossbar 出口計數器有沒有下降——不用先猜對,量出來的數字會直接告訴你這次改動是不是真的把請求收斂得更緊。這也是為什麼作者要先把每一站的計數器名字寫出來:架構圖能解釋「為什麼慢」,但只有這幾個數字能回答「這次改動有沒有用」。

這對你調 kernel 有什麼用

原文本身沒有另外寫一段「給工程師的建議」——它是一篇拆解硬體路徑的文章,不是效能調校指南。但前面四節攤開的機制,已經足夠讓人自己推出幾個合理的方向。

合理的推測是:coalescing 之所以是每一份 CUDA 教材都反覆強調的第一課,是因為它決定了你落在哪一條代價曲線上——連續存取讓 32 個 lane 剛好湊滿 4 個 sector,一滴不浪費;跳著存取則會讓同樣的邏輯需求拆成更多次沒被用滿的 sector 請求,而每一次多出來的 sector 請求,都有可能把你從「L1 命中的 15.4 ns」推向「L2 命中的 127 ns」,甚至「DRAM 往返的 255 ns」。合理的推測是:working set 大小之所以重要,也是同一個原因的另一面——只要反覆存取的資料塞得進 L1 或某個 L2 slice,就一直吃 15.4 ns 或 127 ns 這個量級;一旦溢出,每一次落空都要多繞 TLB、crossbar,甚至 DRAM 的 activate。

DRAM 這一段還有一個原文明講的細節值得單獨拉出來看:打開一個 row(activate)跟從已經打開的 row 讀一個 column,是兩個不同重量的操作——原文自己用「expensive」形容 activate。追蹤的這次請求,送出的是一次 activate 加四次 read,四個 32-byte column 正好湊滿原本要的 128 bytes。這也是為什麼「同一塊資料要不要盡量放在一起用」在 GPU 上不只是快取問題,還牽涉到 DRAM 那顆晶片願不願意讓同一個 row 多開一會兒。

如果要把這幾節收斂成一件具體能做的事,合理的推測是:比起憑印象猜「這段存取應該有 coalesce 到吧」,直接去讀 l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio 這個數字——它跟 stride 為 1 時的理論值 4 差多少,就是這次讀取實際多揹了多少 sector;如果 crossbar 的出口計數器持續偏高,合理的推測是問題已經不在單一 kernel 的存取模式,而是同時有太多 warp 把請求往同一批 L2 slice 送,跟十二個控制器各自對一顆晶片、彼此不共用的結構疊在一起看,位址落在哪個控制器,也決定了它是不是要排隊。這些都是原文親自標出對應計數器與硬體結構的環節,不是額外延伸出來的猜測。

這篇文章沒有給出一份「怎麼優化」的清單,也沒有替 coalescing、working set、tiling 這些常見建議背書或反駁——它做的是把「為什麼」攤開:15.4 ns、127 ns、255 ns 這三個數字之間隔著的不是抽象的「層級」,是 sector、TLB、crossbar、slice、row 這些各自有自己組織方式的真實硬體,而作者是在一張鎖定時脈的卡上,一個一個把它們量出來的。

把這篇文章跟上一篇追 kernel launch 的文章擺在一起看,兩者其實在做同一件事:把「軟體工程師習慣略過的一步」攤開成可以逐站檢查的硬體。上一篇拆的是「這個 kernel 怎麼被排上 SM」,這一篇拆的是「排上去之後,一條 load 指令怎麼被服務」——兩篇合起來,一個 warp 從 launch 到讀到第一個 float,中間經過的每一站,現在都有名字,也有可以直接量測的計數器可以對照。

心智模型:GPU 讀一個 float,不是一步到位,是一連串會分別命中或落空的站點——coalescer 決定你問幾次,每一次命中的樓層決定你付多少代價,15.4 ns、127 ns、255 ns 之間隔著的是 sector、TLB、crossbar、slice、row 這些各自有組織方式的真實硬體,不是一張三格圖能講完的東西。