你讓 LLM 寫了一個 GPU kernel,跑了 benchmark,綠燈,allclose 通過——你以為它對了。論文《The Correctness Illusion in LLM-Generated GPU Kernels》說:綠燈只代表那一個固定輸入沒抓到 bug,不代表 kernel 是對的。
LLM 產的 GPU kernel 看起來對——是測試在說謊
讀完這篇之後,你會分得清「一個 GPU kernel 通過了 allclose 測試」和「一個 GPU kernel 是正確的」這兩句話差在哪——以及為什麼把它們當成同一句話,是 2026 年評測 LLM 生成 kernel 時最常見的陷阱。Dipankar Sarkar 這篇 2026 年 6 月 18 日掛上 arXiv 的論文,做的事情很單純:他把幾個主流 benchmark 用的正確性 oracle 拿出來,實測它到底有多容易被騙,然後給了一個更難騙的替代方案。結論用他自己的詞講,叫「正確性的錯覺」。
先說清楚:一個 GPU kernel「對」是什麼意思
GPU kernel 是一段在 GPU 上平行跑的小程式。你寫一個 matmul kernel、一個 softmax kernel、一個 flash-attention kernel,輸入是幾塊 tensor,輸出是另一塊 tensor。Triton 是現在很多人拿來寫這種 kernel 的語言,因為它讓你用接近 Python 的寫法控制 tile、控制 memory access,而不必直接寫 CUDA。
那「對」是什麼意思?直覺上很簡單:給同一組輸入,你的 kernel 算出來的結果,要跟「正確答案」一樣。問題在「一樣」這兩個字。GPU 上跑的幾乎都是浮點運算,浮點運算不滿足結合律——同一串加法換個順序相加,結果的最後幾個 bit 會不同。GPU 把一個 reduction 拆成幾千個 thread 平行做、再合併,合併順序跟單執行緒的 CPU 不一樣,所以 bit-for-bit 相等這個標準從一開始就用不上。你不能要求 GPU 的輸出跟 reference 完全相等,你只能要求它「夠接近」。
這裡值得把「為什麼不能要求完全相等」再講細一點,因為它是整套測法的地基。浮點數是用有限的 bit 去逼近實數,每做一次運算就要把結果捨入回最接近的可表示值,這個捨入誤差會一路累積。更麻煩的是浮點加法不滿足結合律:(a + b) + c 跟 a + (b + c) 在實數上相等,在 fp16 或 fp32 上可能差最後幾個 bit,因為中間那次捨入發生的位置不同。一個 reduction——把一整列幾千個數加起來——在 GPU 上是先讓每個 thread 各加一段、再把這些部分和兩兩合併成樹狀,合併的順序跟 CPU 上由左到右一個一個加完全不同。順序不同,捨入累積的路徑就不同,最後那幾個 bit 也就不同。這不是哪一邊算錯,是浮點本身的性質。所以「對」永遠只能定義成「夠接近」,這也是 allclose 這個工具存在的理由:它不問兩個數是否相等,只問它們的差是否落在一條事先講好的容差線以內。
於是正確性的定義被迫變成這樣:對一個 op,存在一個可信的 reference 實作;對任何合法輸入,kernel 的輸出與 reference 的輸出之間的差距,要小於某個容差。這就帶出三個必須有人來定的東西——哪些輸入算「任何合法輸入」、reference 從哪來、容差設多少。整篇論文的爭點,全在這三個東西被設得太隨便。
難測的地方也在這裡。一個 matmul kernel 的合法輸入包含各種 shape、各種 dtype、各種數值範圍,邊界上還有零、有 NaN、有 Inf、有剛好踩到 tile 邊界不整除的尺寸。你不可能把所有輸入都跑一遍,所以任何測試都是抽樣。抽得好不好,決定了你抓不抓得到 bug。一個只在某個特定 shape、某個特定數值才會錯的 kernel,用對的輸入測會立刻現形,用錯的輸入測就一路綠燈。
allclose-on-one-shape:被一個固定輸入騙過去
論文點名的對象很具體。它說現有的 benchmark——KernelBench、TritonBench、GEAK——「score correctness through fixed-shape, small-sample allclose-style checks」,也就是用固定 shape、小樣本的 allclose 風格檢查來評正確性。再往下一句講得更死:「The shape, dtype, and tolerance are fixed for each kernel.」對每一個 kernel,shape、dtype、tolerance 三件事都是固定的,只有輸入的數量在各 benchmark 間略有不同。
把這句話翻成操作步驟,就是所謂的 allclose-on-one-shape:挑一個 shape(比如 1024×1024)、一個 dtype(比如 fp16)、設一個容差,隨機生幾組輸入,跑 kernel,跟 reference 做一次 torch.allclose,過了就標正確。allclose 本身只是逐元素檢查 |a - b| ≤ atol + rtol × |b|,是個合理的工具——問題不在 allclose,問題在「on-one-shape」:你只在一個固定的座標上比了一次。
下面這個 widget 把這件事做成可以親手玩的東西。左邊是一個刻意有 bug 的 kernel(這是為了說明用的假想例子,不是論文裡的特定數字):它只在某些輸入上算錯。你可以選評測它的方式,看兩個 oracle 給出的判決怎麼分歧。
拖滑桿掃過 16 種測試輸入 · 看兩個 oracle 判決怎麼分歧
玩過一輪你會抓到關鍵:左邊那個 allclose-on-one-shape 永遠只盯著 #0 那一格,不管你怎麼拖,它的判決都是 PASS——因為它從頭到尾只比那一個固定輸入。bug 確實在 kernel 裡,只是它選的那格剛好沒踩到。右邊把輸入空間掃過去,掃到含 Inf、含 NaN、極長 reduction 那些格子,差距瞬間從 0.0003 暴增到遠超容差,判決翻成 FAIL。同一個 kernel,沒有改一行 code,結論卻相反。論文把「allclose-on-one-shape 認證為正確、其實有 bug」的這種 kernel 叫做 illusion——正確性的錯覺。
脆弱在哪四個維度
「只比一格」這件事可以拆成四個獨立的失效來源。把它們分開看,你會發現每一個都對應到一個你以為 benchmark 幫你顧到、其實沒有的東西。下面四個 tab 各講一個。
固定 shape 漏掉的東西:kernel 通常把輸出切成一塊塊 tile,每個 thread block 算一塊。如果你只用 1024×1024 這種剛好被 tile size 整除的 shape 測,那段「最後一塊 tile 不滿、要做邊界處理」的程式碼根本沒被執行到。一個在 mask 邊界寫錯的 kernel,在整除的 shape 上完全正確,換成 1000×1000 或 17×4096 就開始算錯。論文點名的固定 shape,就是固定在這種好看的尺寸上。
broadcast、stride、非連續 memory layout 也一樣——它們都跟 shape 綁在一起。一個 shape 等於把這些維度全部釘死成一個值。
固定隨機分布抽不到的值:allclose 餵進去的輸入通常是 torch.randn 那種標準常態分布的隨機值,集中在 0 附近的一般範圍。但會觸發 bug 的往往是分布尾巴上的值——非常大、非常小、剛好抵消成 0。標準常態幾乎抽不到這些。
更隱蔽的是:同一個 seed 每次跑出同一組「一般」的值,於是 bug 永遠在同一個地方躲過。你以為跑了很多次很安全,其實每次都在問同一個問題。隨機不等於有覆蓋率。
固定 tolerance 兩面都會錯:容差設太鬆,真正的 bug 造成的偏差被吞進容差裡,FAIL 變成 PASS——漏抓。設太緊,fp16 本身正常的捨入誤差就超過容差,對的 kernel 被判 FAIL——誤殺。
關鍵在於,正確的容差跟 op 與 dtype 有關:一個 fp16 的長 reduction 累積的誤差,本來就比一個 fp32 的逐元素 op 大得多。用一個固定的 tolerance 套所有 kernel,等於假設所有 op 的數值行為一樣——它們不一樣。
不測邊界就是不測最容易壞的地方:NaN、+Inf、−Inf、0、subnormal、剛好溢位的極大值——這些是浮點程式最常出錯的輸入。softmax 要減最大值防溢位、attention 要處理 −Inf 的 mask,這些路徑只有邊界輸入會走到。
標準隨機分布永遠不會自己生出一個 NaN 或一個 Inf。所以 allclose-on-one-shape 不是「邊界測得不夠」,是「根本沒測邊界」。最容易壞的地方,剛好是它完全沒看的地方。
四個維度合起來,allclose-on-one-shape 的問題就清楚了:它把一個高維的輸入空間壓成一個點,在那個點上比一次。只要 bug 不住在那個點上——而 bug 幾乎都住在邊界、住在尾巴、住在不整除的 shape 上——它就抓不到。論文做的,是把那個點換成一張覆蓋這四個維度的網。
把四個維度擺成一張表會更清楚:每一行是一個失效來源,左邊是 allclose-on-one-shape 在那個維度上釘死成什麼,中間是因此漏掉的東西、也就是 bug 躲藏的地方,右邊是論文的強 oracle 用哪個機制把那個洞補起來。讀的時候可以橫著看一行,把「釘死成一個值」「於是漏掉一整片」「換成主動覆蓋」這條因果串起來。
| 維度 | allclose-on-one-shape 釘死成 | 於是漏掉(bug 躲在這) | 強 oracle 怎麼補 |
|---|---|---|---|
| 形狀 | 一個好看的 shape,多半被 tile size 整除 | 最後一塊不滿的 tile、邊界 mask、broadcast 與非連續 stride 的路徑從沒被執行 | schema 生多形狀,刻意包含不整除 tile 與極瘦極長的尺寸 |
| 隨機性 | 固定 seed 的標準常態分布,集中在 0 附近 | 分布尾巴上極大/極小/剛好抵消的值,每次都被同一個 seed 跳過 | seeded fuzzing 主動往尾巴與極端尺寸生輸入,且每組可重播 |
| 精度 | 一個全域容差,套到所有 op 與 dtype | 太鬆吞掉真 bug、太緊誤殺 fp16 正常誤差,兩面都會錯 | per-(op, dtype) 各設一個絕對容差,跟著數值特性走 |
| 邊界 | 不主動生任何特殊值 | NaN、±Inf、0、subnormal、近溢位的極值——浮點最常壞的輸入 | fuzzer 把這些邊界值當成必生的測試點,搭配 fp64 參考解判對錯 |
這張表也順手點出一件事:強 oracle 的四個機制不是隨意疊上去的功能,而是一對一去堵這四個洞。下一節把這四個機制逐一拆開,正好對應表格右欄那四格。
把點換成網:op-schema 感知的 fuzzing
論文提出的測法,用它原文的描述,是「op-schema-aware seeded fuzzing with a high-precision (fp64) CPU reference and per-(op, dtype) absolute tolerances」。拆成三塊看。
第一塊,op-schema-aware seeded fuzzing。每個 op 有一個 schema——它吃幾個 tensor、各自的 rank 與 dtype 約束、哪些參數合法。fuzzer 不再固定一個 shape,而是照 schema 去生成大量合法輸入:各種 shape(包含不整除 tile 的、極瘦極長的)、各種 dtype、各種數值範圍,而且刻意塞進 NaN、Inf、0 這些邊界值。schema 感知的意思是 fuzzer 知道這個 op 接受什麼,所以生的每個輸入都合法、又盡量打到角落。
seeded 是另一個關鍵字。每組輸入由一個 seed 決定,所以整個 fuzzing 是可重現的。論文最後一句講得很明白:「Every flagged failure replays byte-for-byte from a stored seed.」每個被標記的失敗,都能從存下的 seed byte-for-byte 重播。這把 fuzzing 從「跑一次運氣好抓到、再跑一次又不見了」變成「抓到就釘死、可以丟給人去 debug」。
可重現這件事在實務上比它聽起來重要。一般 fuzzing 的難處不是抓不到 bug,是抓到了卻交不出來——你看到某一次跑出了異常,但用的是當下的隨機狀態,下一次換個 seed 就再也重現不了,工程師收到一張「某處可能有錯」的單子卻無從下手。把每組輸入綁死在一個 seed 上,等於把測試結果從一次性的觀察變成一份可以反覆執行的證據:報 bug 時附上 seed,對方在自己機器上跑同一個 seed,會生出同一組 byte 完全一樣的輸入、走同一條路徑、踩到同一個錯。抓得到只是第一步,能原封不動交給別人重現,才讓這個失敗有修的價值。
第二塊,fp64 CPU 參考解。前面說過正確性需要一個 reference,但 reference 自己也是浮點算的,也有誤差。論文用 fp64 在 CPU 上算 reference——雙精度的誤差比 GPU 上的 fp16/bf16 小好幾個數量級,所以可以當成接近 ground truth 的標準答案。被測的 GPU kernel 跟這個高精度解比,差距才有意義。
第三塊,per-(op, dtype) 絕對容差。不再用一個全域 tolerance,而是針對每一組(op, dtype)各設一個絕對容差。一個 fp16 的 reduction 容差設得寬一點是合理的,一個 fp32 的逐元素 op 容差就該緊。容差跟著 op 與 dtype 的數值特性走,這樣既不會把 fp16 正常的誤差誤判成 bug,也不會讓 fp32 的真 bug 躲在過寬的容差裡。
下面這張圖把弱 oracle 與強 oracle 的流程並排畫出來。上排是 allclose-on-one-shape,下排是論文的方法。點下排任何一個階段,看它負責擋掉哪一類失效。
點下排任一階段 · 看它負責擋哪一類失效 · 共 4 階段
這四塊不是各自獨立的優化,而是分別堵住前一節那四個維度的洞:schema 生多形狀堵「形狀」,fuzzing 主動塞邊界值堵「隨機性」與「邊界」,fp64 參考解堵「reference 自己也不準」,per-(op, dtype) 容差堵「精度」。換句話說,論文的方法不是把 allclose 調得更嚴,而是把「在一個點上比一次」改成「在一張覆蓋四個維度的網上比,每個點都跟高精度解對照」。
跑出來的數字,以及作者自己畫的那條線
方法講完,看它在受控語料上的表現。作者刻意建了一個能驗證 oracle 的語料,而不是直接拿線上 LLM 的輸出來測——因為他要回答的是「oracle 強不強」,不是「某個 LLM 爛不爛」。語料的組成是這樣:「a controlled corpus of 24 Triton and CPU stand-in kernels (15 correct controls and 9 LLM-style buggy variants seeded with documented transcription errors)」。24 個 kernel,15 個正確控制組,9 個刻意埋了 transcription error 的壞變體。transcription error 就是 LLM 抄寫時常犯的那種錯——index 算錯、邊界少減一、把某個 dim 弄反。
第一輪結果:「The seeded oracle flags 9 of 9 buggy kernels and passes 15 of 15 correct controls, at zero precision cost on controls.」9 個壞的全抓出來,15 個對的全放行,而且控制組零精度代價——也就是換成這個嚴格 oracle 並沒有誤殺任何一個正確 kernel。這一點很重要:一個會把對的也判錯的嚴格 oracle 沒有用,論文的方法在抓全部壞的同時,沒有製造誤殺。
然後是規模化驗證。作者把語料擴到 26 個 op(加了一對 flash-attention),在五種 GPU 上重跑同一套協定:「five GPU classes (RTX 3060, A10, L40S, A100 SXM4, H100 NVL)」。這五張卡橫跨消費級到資料中心級。結果是:「The verdicts are identical across all five GPUs: 10 of 10 illusions caught and 16 of 16 controls clean.」五張卡判決完全一致,10 個 illusion 全抓、16 個控制組全乾淨。跨硬體一致這件事說明這個 oracle 不是靠某張卡的特性碰運氣,而是穩定的。
controlled corpus(第一輪)
24 kernels = 15 correct + 9 buggy
seeded oracle:9/9 壞的抓到 · 15/15 對的放行 · 控制組零精度代價
擴大重跑(26 ops · 5 GPU classes)
GPUs:RTX 3060 / A10 / L40S / A100 SXM4 / H100 NVL
五張卡判決一致:10/10 illusion 抓到 · 16/16 控制組乾淨
每個 failure 可從 stored seed byte-for-byte 重播
這裡要把作者自己畫的那條線講清楚,不然很容易把這篇讀成「LLM 寫的 kernel 有很多 bug」。論文白紙黑字寫:「The corpus result is about LLM-style transcription bugs that the allclose-on-one-shape oracle certifies as correct, not about the bug rate of any specific deployed LLM.」這個語料結果講的是「allclose-on-one-shape 會把這類 transcription bug 認證為正確」這件事,不是在量某個實際部署的 LLM 出 bug 的比率。換句話說,這篇不是在攻擊 LLM 的 kernel 寫得多差——9 個壞 kernel 是作者自己手動埋進去的——而是在示範:就算 bug 明明在那裡,弱 oracle 也照樣發綠燈。它測的是測試本身,不是被測的模型。
所以你下週該改什麼
如果你的工作牽涉到讓 LLM 生成 Triton 或 CUDA kernel、或是你在用這些 benchmark 的數字做決策,這篇的可操作結論有三條。
第一,把 benchmark 的 pass 當成「還沒被那個固定輸入抓到」,不是「正確」。KernelBench、TritonBench、GEAK 的分數是在 allclose-on-one-shape 下產生的,一個在這些 benchmark 上滿分的 kernel,完全可能在邊界 shape 或特殊值上崩掉。要上線的 kernel,benchmark 的綠燈只是入場券,不是證明。
第二,自己的驗證流程往這三個方向補:用 op schema 生多形狀、多 dtype、含邊界值的輸入,而不是單一固定 shape;用 fp64 的 CPU 參考解當 ground truth,而不是拿另一個也有誤差的 GPU 實作互比;容差按(op, dtype)分開設,而不是一個數字套到底。這三件事都不需要新硬體,是測試流程的改法。
第三,讓失敗可重播。論文整套方法的可信度很大一部分來自「Every flagged failure replays byte-for-byte from a stored seed」——抓到 bug 就把 seed 存下來,任何人都能 byte-for-byte 重現。一個抓得到但重現不了的 bug,工程上幾乎沒用;seeded fuzzing 把「抓到」和「能丟給人 debug」連起來。
Take-away:allclose-on-one-shape 通過,只證明 kernel 在那一個固定輸入上沒被抓到——要證明它對,得用 op-schema 感知的 seeded fuzzing,掃過多形狀與邊界值,每個點都跟 fp64 參考解比,容差按 op 與 dtype 分開設。