Day 3 的時候我們說過,eval hook 最後會還給 CPython 一段改寫過的 bytecode。而之後的五天其實都在講「分析」這一側,依序是翻譯(Day 4)、包裝(Day 5)、記押注(Day 6)、記修改(Day 7)、收圖(Day 8)。今天就換到「合成」這一側,看看那段新 bytecode 到底是怎麼一條一條被生出來的。
今天的主角是兩個檔案,分工非常乾淨。codegen.py 裡的 PyCodegen 負責生指令。給它一個值,它就吐出「把這個值弄上 stack」的指令序列。bytecode_transformation.py 則負責組裝,把一張指令表變回一個 CPython 肯執行的 code object,offset、跳轉、stacksize、linetable 這些髒活全都收在裡面。簡單來說,就是一個管內容、一個管形式。
正文開始!
原函式的 code object 會被換成一段等價的改寫,而它要做的事其實就只有六件。
LOAD_GLOBAL __compiled_fn_1(Day 8 已經塞進 globals)CALL,拿回輸出 tupleRETURN_VALUE
注意這張清單,裡面沒有任何一項是「計算」。乘法、加法、torch.sin 全都已經搬進 __compiled_fn_1,留在 bytecode 層的只剩搬運。所以 PyCodegen 生出來的碼幾乎清一色是 LOAD_*、STORE_*、CALL、BUILD_* 這幾類指令,這也是它能做得這麼小的原因。
它的介面小到有點誇張,給它一個值,它就往指令表補上「執行完之後,這個值會出現在 stack 頂端」的一小段指令。收圖時的所有生成,就是對著一串值逐個這樣問它。而它真正的本事在於挑最短路徑,捷徑按優先序排下來大概像下表。
| 情況 | 生成什麼 |
|---|---|
| 值有 Source | source.reconstruct():從原位置載,LOAD_FAST x 或 LOAD_GLOBAL cfg + LOAD_ATTR scale |
| 值是圖的輸出 | 從暫存的輸出 tuple 取:LOAD_FAST graph_out_0 加下標 |
| 純常數 | LOAD_CONST |
| 翻譯期新生的容器 | 重建碼:BUILD_LIST、BUILD_MAP |
第一列是最關鍵的節省點。有 Source 的值本來就在 frame 裡拿得到,就不需要讓圖多輸出一份了。這也是 Source 鏈第三次出場了,Day 6 生 Guard、Day 8 命名輸入、今天生載入的 bytecode,這些的共同點就是是 Dynamo 對一個值記下的關鍵資訊不是「它是什麼」,而是「runtime 怎麼拿到它」。
順帶一提,reconstruct 這個名字在 Day 5 VariableTracker 的介面表就出現過,回答的是「改寫後的 bytecode 要怎麼把我重建出來」。有 Source 的值從原位置載回來。沒有 Source 的值,也就是翻譯期新生的 list、dict、閉包,就用 BUILD_LIST、BUILD_MAP 一磚一瓦蓋出來,Day 4 那個圖上根本沒有的 parts 要被 return 出去時,走的就是這條路。而連 reconstruct 都寫不出來的值,就會變成一條 Reconstruction failure 的 graph break。
接下來我們就用 TORCH_LOGS="bytecode",把 f(x, n) -> x * n + 1 在 GPU上(Python 3.12、PyTorch 2.8.0)改寫前後的 bytecode 都印出來對照看看。改寫前就是 Day 4 看過的那六條,改寫後的完整版長成下面這樣。
MODIFIED BYTECODE f
0 RESUME 0
2 LOAD_GLOBAL 1 (NULL + __compiled_fn_1_cd21c25a_...)
12 LOAD_GLOBAL 3 (NULL + __import_torch_dot__dynamo_dot_utils)
22 LOAD_ATTR 4 (record_pregraph_bytecode_enter)
42 COPY 1
44 STORE_FAST 3 (tmp_1)
46 CALL 0
54 STORE_FAST 4 (tmp_2)
56 LOAD_FAST 0 (x)
58 LOAD_GLOBAL 3 (NULL + __import_torch_dot__dynamo_dot_utils)
68 LOAD_ATTR 6 (record_pregraph_bytecode_exit)
88 COPY 1
90 STORE_FAST 5 (tmp_3)
92 LOAD_FAST 4 (tmp_2)
94 CALL 1
102 POP_TOP
104 CALL 1
112 STORE_FAST 2 (graph_out_0)
114 LOAD_FAST 2 (graph_out_0)
116 LOAD_CONST 2 (0)
118 BINARY_SUBSCR
122 DELETE_FAST 2 (graph_out_0)
124 RETURN_VALUE
先把雜訊剝掉。record_pregraph_bytecode_enter/exit 那兩段是 PyTorch 2.8 給 profiler 標記「準備圖的輸入」這個區間用的包裝,跟語意無關。剝完之後剩下的主幹,其實就是上面那張任務清單的直譯。
LOAD_GLOBAL __compiled_fn_1 <- 任務 1:載入編譯產物
LOAD_FAST x <- 任務 2:擺輸入
CALL 1 <- 任務 3:呼叫
STORE_FAST graph_out_0 <- 任務 4:拆包
LOAD_FAST graph_out_0
LOAD_CONST 0
BINARY_SUBSCR
DELETE_FAST graph_out_0
RETURN_VALUE <- 任務 6
有幾處值得圈起來細讀。
n 沒被傳給 __compiled_fn_1:它是 Python int,Day 5 已經被 bake 成常數了,圖的輸入只剩 x,所以擺輸入只需要一條 LOAD_FAST x,而這條指令就是 LocalSource("x") 的 reconstruct() 生出來的。(add,)(Day 8 看過),所以要 BINARY_SUBSCR 取下標 0。graph_out_0 是現配的暫存區域變數,用完立刻 DELETE_FAST 歸還引用,跟圖裡中間值用完就 = None 是同一個潔癖。tmp_2 先收著 enter 的回傳、等輸入擺完再交給 exit,COPY 1 加 STORE_FAST tmp_1 則是把剛載上來的函式快取一份,而快取正好是下一節的主題。這段「原碼進、新碼出」的改寫現場,動起來就是下面這張圖。

圖一:f(x, n) -> x * n + 1 的改寫現場。三條運算指令被吸進 __compiled_fn_1 的膠囊,n 那條因為被 bake 成常數直接淡出,留下的 LOAD_FAST x 和 RETURN_VALUE 原樣沿用。接著新的搬運碼逐條長出來,膠囊本身坍縮成那條 CALL,拆輸出的五條補齊中段。最後 transform_code_object 組裝,offset 這時才逐行浮現。
先想一個小問題。如果圖有兩個輸入 cfg.a.x 和 cfg.a.y,loading code 是不是就得把 LOAD_FAST cfg、LOAD_ATTR a 這條前綴走兩次?
答案是不用,PyCodegen 的做法很像寫文章前先打草稿。第一遍就是草稿,生出來的指令直接丟掉,只做一件事,用 uses 計數器數清楚每個值總共被載了幾次。數完掃一輪,發現某個值被用了不只一次、而且每次都得走一長串指令才拿得到,就把它登記進 tempvars,意思是「這個值得先存起來」。第二遍才是正式謄寫。被登記的值第一次載出來時,順手多生一條 COPY 和 STORE_FAST tmp_N 存進暫存變數,之後每次要用都只是一條便宜的 LOAD_FAST tmp_N,那串長前綴只需要走一次。原始碼的註解「This essentially implements CSE.」說的就是這件事,也就是編譯器教科書裡的 common subexpression elimination,在 bytecode 生成層又出現了一次。
另一個更便宜的快取是 top_of_stack。PyCodegen 會記著「上一次生成完,stack 頂端是誰」,下一個要的值剛好就是它的話,一條 COPY 1 複製了事,連 LOAD_FAST 都省下來了。
再問一個問題。RETURN 的時候 stack 上不就剩一個回傳值嗎?那「重建 stack」到底是在重建什麼?
乖乖翻完的情況確實沒什麼好重建的。但 compile_subgraph 的另一個觸發點是 graph break。斷點可以落在任何一條指令前,那一刻符號 stack 上可能疊著好幾個算到一半的值,locals 裡還有一堆斷點之後要用的變數。CPython 從斷點接手的前提,是真實 frame 的狀態要跟 Dynamo 模擬到那一刻的狀態一模一樣,所以生成的 bytecode 得把這個狀態原樣排出來。符號 stack 上的每個值逐個丟給 PyCodegen(restore_stack),有 Source 的從原位置載、是圖輸出的從 graph_out_0 取、常數直接 LOAD_CONST。活著的區域變數再一人一條 STORE_FAST 放回去。明天就會看到,resume function 的參數表接的就是這裡排好的東西。
整段後綴的順序也是固定的。先把翻譯期新生、又逃出去的物件蓋出來存好,再排 stack,然後 replay side effect 帳本,最後放回活變數、交出控制權。
生指令本身不難,真正難的是要組回一個 CPython 肯認帳的 code object,而難的部分全都收在這個檔案裡。關鍵想法有三個。
第一,指令要先變成可以編輯的東西。標準庫 dis 吐出的指令是唯讀的視角,沒辦法拿來改。所以第一步是把 code object 轉成一串自家的 Instruction 物件,欄位可以改、可以隨意插入刪除。
第二,跳轉要先「虛擬化」。bytecode 裡的跳轉原本寫的是數字位置,像「跳到第 84 個 byte」。這種寫法很脆,中間插入或刪除任何一條指令,後面全部位移,每個寫死的數字同時作廢。所以跳轉目標改存成指向另一條指令的參照,比較像書籤而不是頁碼。我們可以實際跑一段來看看。
from torch._dynamo.bytecode_transformation import cleaned_instructions
def g(x):
if x is None:
return 1
return 2
for i in cleaned_instructions(g.__code__):
print(i.opname, i.arg, i.target)
LOAD_FAST arg=0
LOAD_CONST arg=None
IS_OP arg=1
POP_JUMP_IF_TRUE arg=1 -> target=RETURN_CONST@12
RETURN_CONST arg=1
RETURN_CONST arg=2
可以看到 POP_JUMP_IF_TRUE 的 target 直接指著那條 RETURN_CONST 物件。中途隨意增刪都不會斷鏈,最後組裝時才把參照換算回真正的 offset。這也是為什麼動畫裡 offset 是最後才浮現的,數字位置是組裝的產物,不是編輯時的座標。
第三,形式問題全部留到最後一次算清。offset、跳轉距離太遠要墊的 EXTENDED_ARG、stacksize、linetable、跨 Python 版本的指令差異,這些互相牽動的帳全部留到組裝時反覆掃描直到收斂,編輯期完全不用管。總出口是 transform_code_object,它把「改寫一個 code object」做成一個模板,拆開、交給 callback 改、組回去。Dynamo 的主改寫就是餵給它的一個 callback,明天 resume function 的生成則是另一個,而 Day 3 的 eval hook 還給 CPython 的,就是它的回傳值。
組裝前還有最後一輪 bytecode_analysis 清掃,把沒人讀的 STORE_FAST 和多餘的跳轉拔掉。它掃的是浪費,不是錯誤。生成路徑有很多條(return、break、side effect、resume 的各種組合),廢碼集中交給一個清潔工,每條路徑就都能無腦生。
PyCodegen 就是一位只寫搬運碼的代筆,按 Source 挑最短路徑,還會先打草稿再謄寫,把重複載入折進暫存變數。bytecode_transformation 則收走全部的形式問題,最後吐出一個合法的新 code object,交還給 Day 3 的 eval hook。
到這裡,「乖乖能翻完」的路線就全部打通了,攔截、翻譯、包裝、記前提、記修改、收圖、寫碼。不過 Day 4 就說過,翻譯隨時可能舉手放棄。明天就來把 Graph Break 的全套機制攤開,斷點前後兩段怎麼接、resume function 怎麼用今天這套工具生出來、以及 fullgraph=True 和 explain() 怎麼幫你抓 break。那我們明天見!