[RSCH] 15 分鐘閱讀OraCore 編輯部

CUDA 二進位拆成可檢查 ELF

我把 CUDA binary 的 cubin、fatbin、ELF note 一層層拆開,整理成可直接照抄的檢查模板。

分享 LinkedIn
CUDA 二進位拆成可檢查 ELF

以前我只看 CUDA source;現在我直接拆 cubin bytes 和 fatbin 包裝。

我最近一直在看 CUDA binaries,老實說,一開始真的很煩。能編、能 disassemble、能把 GPU code 打包進 wheel,流程都跑得動,可我一問「這檔案裡到底裝了什麼」,答案就開始飄。文件一會兒指向 ELF,一會兒又補個 etc.,像是在叫我自己猜。你如果也遇過那種 build 只在某個 target 爆掉、或是 source 明明只改一點點,cubin 卻整個變樣的情況,就知道問題不在「能不能 compile」,而在「你根本沒看懂 artifact 在講什麼」。

真正讓我把這件事看清楚的,是 Lorenzo Bradanini 和 Lorenzo Tettamanti 在 The Software Frontier 的拆解。他們用 CUDA 13.3.73 工具鏈,連 GPU 都不用,直接從 byte level 看 cubin 和 fatbin,不再用那種「反正就是 ELF 啦」的敷衍講法。我下面要拆的就是這種做法:不是背名詞,是直接看 bytes,然後把它變成你能在自己專案裡照做的流程。

CUDA 不是一個 compiler,是一串接力

訂閱 AI 趨勢週報

每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。

不會寄垃圾信,隨時可取消。

nvcc 不是單一 compiler,它只是把一串工具串起來的 driver。

這句話我第一次看到時,心裡只有一個感想:對,終於有人把話講白了。大家平常說「我在編 CUDA」,聽起來像一個動作;實際上是 preprocessing、切 host/device、前端處理、吐 PTX、再交給 ptxas 轉成 SASS,最後包進 fatbin,再回到 host compile。每一段都會留下可檢查的中間產物。

CUDA 二進位拆成可檢查 ELF

白話翻譯就是:你看到的 final object file,只是多個階段談判完的結果。source 變了,不代表最後 binary 的變化一定在你以為的地方。宏展開、device split、PTX emission、assembler 的選項、包裝器的內容,都可能讓輸出長得不一樣。我以前就踩過這種坑:source 看起來只動了一個 macro,結果 binary 差很多。後來把中間檔都留住,才發現問題根本出在中途,不是在最後那個 .o。

實操寫法很簡單:先別急著看結果,先把流程攤開。你要 debug build,就先跑 nvcc --dryrun 看它到底叫了哪些工具;接著用 --keep 留下中間檔;最後把 PTX、cubin、fatbin 分開看,不要把它們當成同一件事。你如果只看最終輸出,很多問題會一直像鬼一樣飄來飄去。

  • 先用 nvcc --dryrun 看整條 pipeline。
  • 再用 --keep 保留中間檔。
  • PTX、cubin、fatbin 分開檢查。
  • 把每個 stage 當成獨立契約,而不是一個黑箱。

cubin 是 ELF,但 CUDA 指紋全寫在裡面

這篇最有用的地方之一,就是直接證明 standalone cubin 真的是 ELF64 executable,不是比喻。你拿 filereadelf 去看,它就是 ELF;但 header 裡面塞了 CUDA 特有的值,像 machine type、OS ABI byte、flags word,全部都有 CUDA 的味道。

也就是說,NVIDIA 不是發明一個全新 container,而是在 ELF 上加自己的語意。這做法有好有壞。好處是 generic tooling 還能勉強讀;壞處是你以為自己在看標準 ELF,其實真正有用的資訊散在 header、note section、還有 NVIDIA 私有 section 裡。格式本身看起來很乖,內容卻一直把關鍵資訊藏在側邊。

我自己最有感的是 generic tools 只能幫你看一半。你會看到 section table,卻看不懂那些 0x70000000 範圍的 section type 在講什麼,因為它們對一般 ELF 工具來說就是 unknown。這不是工具壞掉,是格式本來就故意把重要資訊塞進 NVIDIA 自己的 namespace 裡。你如果在寫 parser,看到這裡就該醒了。

實操寫法:每次拿到 cubin,先做三件事。第一,跑 file。第二,跑 readelf -h 看 machine type、OS ABI、flags。第三,別以為看到 ELF 就代表普通工具夠用,接著要去看 NVIDIA note section。你要的是「能解釋 binary」,不是「證明它長得像 ELF」。

  • 對每個 cubin 都先看 filereadelf -h
  • 把 ELF machine type、OS ABI、flags 記下來。
  • 不要把 ELF 當成可移植保證。
  • 真正的 CUDA metadata 常在 NVIDIA 私有 section。

有用的資訊大多在 note,不在 code

這也是我最想罵醒大家的一段。文章裡一個很小的 kernel,cubin 總大小 3,968 bytes,真正 machine code 只有 512 bytes。其餘大部分都是 metadata、symbols、notes、relocations、CUDA-specific sections。這個比例很重要:code 很小,圍著 code 的契約很大。

CUDA 二進位拆成可檢查 ELF

原文把幾個關鍵 section 點得很清楚,像 .note.nv.tkinfo.note.nv.cuinfo.nv.info.nv.compat.nv.callgraph,還有 kernel 專屬的 .nv.info.<mangled-symbol>。其中 .note.nv.tkinfo 會記錄 compiler build 和 command line。這超實用,因為它等於把 provenance 直接寫進 artifact。你如果哪天在查 wheel 是誰、用哪個 toolkit 產的,這種 note 就是救命繩。

白話翻譯就是:binary 比你想的更會講故事。build flags 不只在 CI log 或 shell script 裡,也可能直接躺在檔案本身。這對 reproducibility、audit、supply chain tracing 都很重要。以前我也遇過團隊說「我們知道這包是誰 build 的」,結果六週後沒人記得到底是哪個 CUDA 版本。這種時候,你最好先看 note section,而不是先翻 Slack。

實操寫法:先把 note section 當成第一級資訊來源。用 cuobjdump -elf.note.nv.tkinfo.note.nv.cuinfo,把輸出存進 CI artifact。之後如果 binary 有變,就先 diff 這些 note,再去看 code。很多時候你會發現,變的不是 kernel 本身,是 build contract。

  • 先查 .note.nv.tkinfo 看 provenance。
  • 再查 .note.nv.cuinfo 看 CUDA metadata。
  • 把 note dump 存進 CI artifact。
  • 先 diff metadata,再看 machine code。

你以為的 target,不一定是檔案裡記的 target

這段很容易讓人不爽,但它是真的。文章提到,老工具曾經把 PTX architecture 放在 ELF flags 裡;現在 CUDA tooling 把這資訊搬到 note section,cuobjdump 會把它報成 CUDA Virtual SM。flags word 還是有架構相關資料,但它已經不是全部答案。

也就是說,如果你以前寫過 parser,假設 e_flags 就是 virtual architecture 的唯一來源,那你很可能早就偏掉了。原文還示範了同一個 kernel,在不同 PTX architecture、相同 real target 下,flags 可能還是一樣。架構意圖被記到別的地方去了。這就是那種看起來沒出事、其實已經悄悄 drift 的格式變動,最會害工具壞掉。

我對這種變動很有意見,因為它不會大張旗鼓告訴你。文件零碎、release note 零碎、disassembler output 也零碎,整個生態就是要你自己拼。實務上你要同時看三件事:real target、virtual target、還有像 sm_100f 這種 feature suffix。這些東西不一定都會影響最後 binary,但你不看就會誤判。

實操寫法:別只讀 e_flags,把 note section 和 disassembler output 一起看。測 parser 的時候,至少拿不同 CUDA 版本交叉驗證。你要假設 architecture metadata 會移位,而且不會先通知你。

  • 不要只信 e_flags
  • note section 和 disassembler 要一起看。
  • parser 要跨 CUDA 版本測。
  • 架構資訊會搬家,這很正常,也很煩。

支援 target 變少,build matrix 就不能裝死

文章還點出一個很實際的坑:CUDA 13 的支援 target list 其實很短。Turing 是底線,Maxwell、Pascal、Volta 這些舊架構在 CUDA 13.0 的 offline compilation support 已經沒了。這不是小修小補,這會直接影響你的 build matrix。如果你還在維護要給舊 GPU 用的套件,toolkit 版本就不是可有可無。

白話翻譯就是:CUDA 版本不只是多幾個 feature,它也在決定你還能對哪些硬體說話。文章也提到 Jetson AGX Thor 的 sm_101 改名成 sm_110,這種事最討厭,因為你的 script 看起來完全沒問題,實際上 target name 已經換了。這種 silent compatibility trap 我最不愛,因為它不會當場炸,只會在你最不想 debug 的時候炸。

實操寫法很直接:CI 裡要 pin CUDA toolkit 版本,target list 不要手寫死,最好從安裝好的 toolkit 生成。你還要檢查腳本裡有沒有硬編 architecture 名稱。只要你還要支援舊 GPU,就別把舊 toolchain 丟掉,留著才有退路。

  • CI 先 pin CUDA toolkit version。
  • target list 從 toolkit 產生,不要手打。
  • 檢查腳本裡的硬編架構名稱。
  • 還要支援舊 GPU,就保留舊 toolchain。

fatbin 是包裝器,不是 payload

很多人講到 fatbin 就停了,像是講完一個名詞就算理解。我覺得這很危險。fatbin 是包裝層,裡面可以放一個或多個 images,也會帶 command-line metadata,讓 runtime 決定要載哪個 payload。它不是 kernel 本體,它比較像 dispatch table。

也就是說,如果你 ship 多個 architecture,fatbin 會幫 runtime 在不同 image 間做選擇;就算你只放一個 architecture,它還是會帶 build metadata。這讓它很適合做相容性包裝,但也很容易讓你誤以為自己送出去的東西比實際更單純。我以前看過 build system 外表很乾淨,結果 fatbin 裡面塞了多個 image,還附帶一堆 command-line 歷史。這些東西不是不能有,是你得知道它們在。

實操寫法:用 cuobjdumpnvdisasm 直接看 fatbin contents,確認 bundled 了哪些 images、runtime 會挑哪個、wrapper 裡還藏了什麼 metadata。如果你在發佈 CUDA code,至少要能回答三件事:裡面有哪些 image、會選哪個、wrapper 有沒有把你不想暴露的東西也一起帶出去。

  • cuobjdumpnvdisasm 看 fatbin。
  • 確認每個 target bundle 了哪些 images。
  • 多架構發佈時,明確設計 fatbin。
  • 不要把 wrapper 當 payload。

最省事的流程就是 inspect、diff、repeat

如果要我把這篇文章濃縮成一個習慣,我會說:別再只信 source,開始 diff artifact。原文作者很明確地說,所有數字和 hex dump 都是他們在自己機器上產出的檔案。這才是對的做法。格式如果文件不完整,你最可靠的 source of truth 就是你自己能重現的 bytes。

白話翻譯就是:CUDA binary work 本質上比較像系統考古。你先產出一個 cubin,再把 header、section table、disassembly 全部 dump 出來,接著跨 toolkit 版本、跨 target、跨 feature suffix 去比。格式真正會說話的地方,不在長篇說明,而在差異本身。

我自己在解釋兩個 build 為什麼 source 看起來一樣、output 卻不一樣時,最常用的也是這招。driver 載的是 output,不是 source。效能、相容性、reproducibility,最後都落在 artifact 上。只要你能 diff,通常就能把事情講清楚。

實操寫法:留一個很小的 kernel 當 binary-format test case,固定在不同 toolkit 和 target 下 build,然後把 ELF header、section table、disassembly output 全部存起來。先 diff 這些,再去怪 compiler。這樣你比較不會把直覺當真相。

可抄的模板

# CUDA binary inspection checklist

## Inputs
- CUDA toolkit version:
- Target architecture:
- Source file:
- Build flags:

## Commands
nvcc -arch=${ARCH} -cubin -o kernel.cubin kernel.cu
file kernel.cubin
readelf -h -S kernel.cubin
cuobjdump -elf kernel.cubin
nvdisasm kernel.cubin

## What to record
- ELF machine type
- OS ABI byte
- ABI version
- e_flags value
- CUDA virtual SM from notes
- Toolkit provenance note
- Section table entries
- .text section size
- .nv.info and .nv.compat contents
- Fatbin images, if present

## What to compare
- Same source, different toolkit versions
- Same source, different -arch values
- Same source, with and without feature suffixes
- Same source, with and without debug info

## Questions to answer
- Which stage changed the output?
- Is the change in code or metadata?
- Did the virtual architecture move?
- Did the wrapper change even if the kernel did not?
- Can I reproduce the same bytes on another machine?

## CI artifact suggestion
Save:
- build log
- readelf output
- cuobjdump output
- nvdisasm output
- section-table diff
- toolkit version string

這段我真的會直接拿去用。它不花俏,但它能最快把 CUDA binaries 從「像傳說」變成「像系統」。你先從一個小 kernel 開始,把所有輸出都留下來,最好進版控。當你這樣做一輪,很多原本看起來玄學的問題,會突然變得很平。

我最後的結論很簡單:CUDA binaries 之所以難懂,不是因為它們神秘,而是因為重要資訊被拆散在 ELF header、NVIDIA notes、section table、wrapper format 裡面,而大多數人只看最後那一層。你把整條鏈看完,bytes 其實會講得很清楚。

來源致謝:原始技術拆解來自 Lorenzo Bradanini 和 Lorenzo Tettamanti 的文章 https://www.thesoftwarefrontier.com/p/how-cuda-binaries-actually-work。上面內容是我根據這篇文章整理出的實作版理解,最後的模板則是我改寫成可直接套用的版本。