Go 編譯器內部知識:向 Go 新增新語句-第 2 部分

polarisxu發表於2020-08-18

這是探討 Go 編譯器兩篇文章的最後一篇。在第 1 部分中,我們通過構建自定義的編譯器,向 Go 語言新增了一條新語句。為此,我們按照此圖介紹了編譯器的前五個階段:

go compiler flow

在"rewrite AST"階段前,我們實現了 until 到 for 的轉換;具體來說,在gc/walk.go檔案中,在編譯器進行 SSA 轉換和程式碼生成之前,就已進行了類似的轉換。

在這一部分中,我們將通過在編譯流程中處理新的 until 關鍵字來覆蓋編譯器的剩餘階段。

SSA

在 GC 執行 walk 變換後,它呼叫 buildssa(gc/ssa.go)函式將 AST 轉換為靜態單賦值(SSA)形式的中間表示。

SSA 是什麼意思,為什麼編譯器會這樣做?讓我們從第一個問題開始;我建議閱讀上面連結的 SSA 維基百科頁面和其他資源,但這裡一個快速說明。

靜態單賦值意味著 IR 中分配的每個變數僅分配一次。考慮以下偽 IR:

x = 1
y = 7
// do stuff with x and y
x = y
y = func()
// do more stuff with x and y

這不是 SSA,因為名稱 x 和 y 被分配了多次。如果將此程式碼片段轉換為 SSA,我們可能會得到類似以下內容:

x = 1
y = 7
// do stuff with x and y
x_1 = y
y_1 = func()
// do more stuff with x_1 and y_1

注意每個賦值如何得到唯一的變數名。當 x 重新分配了另一個值時,將建立一個新名稱 x_1。你可能想知道這在一般情況下是如何工作的……像這樣的程式碼會發生什麼:

x = 1
if condition: x = 2
use(x)

如果我們簡單地將第二次賦值重新命名為 x_1 = 2,那麼 use 呢?x 或 x_1 或...呢?為了處理這一重要情況,SSA 形式的 IR 具有特殊的 phi(originally phony)功能,以根據其來自哪個程式碼路徑來選擇一個值。它看起來是這樣的:

simple ssa phi

編譯器使用此 phi 節點來維護 SSA,同時分析和優化此類 IR,並在以後的階段用實際的機器程式碼代替。

SSA 名稱的靜態部分起著與靜態型別類似的作用;這意味著在檢視原始碼時(在編譯時或靜態時),每個名稱的分配都是唯一的,而它可以在執行時發生多次。如果上面顯示的程式碼片段是在一個迴圈中,那麼實際的 x_1 = 2 的賦值可能會發生多次。

現在我們對 SSA 是什麼有了基本的瞭解,接下來的問題是為什麼。

優化是編譯器後端的重要組成部分[1],並且通常對後端進行結構化以促進有效和高效的優化。再次檢視此程式碼段:

x = 1
if condition: x = 2
use(x)

假設編譯器想要執行一個非常常見的優化——常量傳播; 也就是說,它想要在 x = 1 的賦值後,將所有的 x 替換為 1。這會怎麼樣呢?它不能只找到賦值後對 x 的所有引用,因為 x 可以重寫為其他內容(例如我們的例子)。

考慮以下程式碼片段:

z = x + y

一般情況下,編譯器必須執行資料流分析才能找到:

  1. x 和 y 指的是哪個定義?存在控制語句情況下,這並不容易,並且還需要進行優勢分析(dominance analysis)。
  2. 在此定義之後使用 z 時,同樣具有挑戰性。

就時間和空間而言,這種分析的建立和維護成本很高。此外,它必須在每次優化之後重新執行它(至少一部分)。

SSA 提供了一個很好的選擇。如果 z = x + y 在 SSA 中,我們立即知道 x 和 y 所引用的定義(只能有一個),並且我們立即知道在哪裡使用 z(在這個語句之後對 z 的所有引用)。在 SSA 中,用法和定義都在 IR 中進行了編碼,並且優化不會違反不變性。

Go 編譯器中的 SSA

我們繼續描述 Go 編譯器中如何構造和使用 SSA。SSA 是 Go 的一個相對較新的功能。除了將 AST 轉換為 SSA 的大量程式碼(gc/ssa.go),其它大部分程式碼都位於ssa目錄中,ssa 目錄中的 README 檔案是對 Go SSA 的非常有用的說明,請閱讀一下!

Go SSA 實現還擁有我見過的一些最好的編譯器工具(已經在編譯器上工作了很多年)。通過設定 GOSSAFUNC 環境變數,我們將獲得一個 HTML 頁面,其中包含所有編譯階段以及每個編譯階段之後的 IR,因此我們可以輕鬆地檢索出需要進行哪些優化。額外的設定可以將控制流程圖繪製成 SVG。

讓我們研究一下從 AST 為該以下程式碼段建立的初始 SSA:

func usefor() {
  i := 4
  for !(i == 0) {
    i--
    sayhi()
  }
}

func sayhi() {
  fmt.Println("Hello, for!")
}

我將移除列印輸出函式的原因是為了使輸出的 SSA 更簡潔。使用-l 進行編譯以禁用內聯,這將導致對 sayhi()的微小呼叫(由於常量字串而生成更多的程式碼,對 fmt.Println()[2]的呼叫會生成更多程式碼)。

產生的 SSA 為:

b1:

        v1 (?) = InitMem <mem>
        v2 (?) = SP <uintptr>
        v3 (?) = SB <uintptr>
        v4 (?) = Const64 <int> [4] (i[int])
        v6 (?) = Const64 <int> [0]
        v9 (?) = Const64 <int> [1]
    Plain → b2 (10)

    b2: ← b1 b4

        v5 (10) = Phi <int> v4 v10 (i[int])
        v14 (14) = Phi <mem> v1 v12
        v7 (10) = Eq64 <bool> v5 v6
    If v7 → b5 b3 (unlikely) (10)

    b3: ← b2

        v8 (11) = Copy <int> v5 (i[int])
        v10 (11) = Sub64 <int> v8 v9 (i[int])
        v11 (12) = Copy <mem> v14
        v12 (12) = StaticCall <mem> {"".sayhi} v11
    Plain → b4 (12)

    b4: ← b3
    Plain → b2 (10)

    b5: ← b2

        v13 (14) = Copy <mem> v14
    Ret v13

這裡要注意的有趣部分是:

  • bN 是控制流圖的基本塊。
  • Phi 節點是顯式的。最有趣的是對 v5 的分配。這恰恰是分配給 i 的選擇器;一條路徑來自 V4(初始化),從另一個 v10(在 i--)內迴圈中。
  • 出於本練習的目的,請忽略帶有 的節點。Go 有一種有趣的方式來顯式地在其 IR 中傳播記憶體狀態,在這篇文章中我們不討論它。如果感興趣,請參閱前面提到的 README 以瞭解更多詳細資訊。

順便說一句,這裡的 for 迴圈正是我們想要將 until 語句轉換成的形式。

將 until AST 節點轉換為 SSA

與往常一樣,我們的程式碼將以 for 語句的處理為模型。首先,讓我們從控制流程圖開始應該如何尋找 until 語句:

until cfg

現在我們只需要在程式碼中構建這個 CFG。提醒:我們在第 1 部分中新增的新 AST 節點型別為 OUNTIL。我們將在 gc/ssa.go 中的state.stmt方法中新增一個新的分支語句,以將具有 OUNTIL 操作的 AST 節點轉換為 SSA。case 塊和註釋的命名應使程式碼易於閱讀,並與上面顯示的 CFG 相關。

case OUNTIL:
  // OUNTIL: until Ninit; Left { Nbody }
  // cond (Left); body (Nbody)
  bCond := s.f.NewBlock(ssa.BlockPlain)
  bBody := s.f.NewBlock(ssa.BlockPlain)
  bEnd := s.f.NewBlock(ssa.BlockPlain)

  bBody.Pos = n.Pos

  // first, entry jump to the condition
  b := s.endBlock()
  b.AddEdgeTo(bCond)
  // generate code to test condition
  s.startBlock(bCond)
  if n.Left != nil {
    s.condBranch(n.Left, bEnd, bBody, 1)
  } else {
    b := s.endBlock()
    b.Kind = ssa.BlockPlain
    b.AddEdgeTo(bBody)
  }

  // set up for continue/break in body
  prevContinue := s.continueTo
  prevBreak := s.breakTo
  s.continueTo = bCond
  s.breakTo = bEnd
  lab := s.labeledNodes[n]
  if lab != nil {
    // labeled until loop
    lab.continueTarget = bCond
    lab.breakTarget = bEnd
  }

  // generate body
  s.startBlock(bBody)
  s.stmtList(n.Nbody)

  // tear down continue/break
  s.continueTo = prevContinue
  s.breakTo = prevBreak
  if lab != nil {
    lab.continueTarget = nil
    lab.breakTarget = nil
  }

  // done with body, goto cond
  if b := s.endBlock(); b != nil {
    b.AddEdgeTo(bCond)
  }

  s.startBlock(bEnd)

如果您想知道 n.Ninit 的處理位置——它在 switch 之前針對所有節點型別統一完成。

實際上,這是我們要做的全部工作,直到在編譯器的最後階段執行語句為止!如果我們執行編譯器-像以前一樣在此程式碼上轉儲 SSA:

func useuntil() {
  i := 4
  until i == 0 {
    i--
    sayhi()
  }
}

func sayhi() {
  fmt.Println("Hello, for!")
}

正如預期的那樣,我們將獲得 SSA,該 SSA 在結構上等效於條件為否的 for 迴圈的 SSA 。

轉換 SSA

構造初始 SSA 之後,編譯器會在 SSA IR 上執行以下較長的遍歷過程:

  1. 執行優化
  2. 將其降低到更接近機器程式碼的形式

所有這些都可以在在 ssa/compile.go 中的passes切片以及它們執行順序的一些限制passOrder切片中找到。這些優化對於現代編譯器來說是相當標準的。降低由我們正在編譯的特定體系結構的指令選擇以及暫存器分配。

有關這些遍的更多詳細資訊,請參見SSA README這篇帖子,其中詳細介紹瞭如何指定 SSA 優化規則。

生成機器碼

最後,編譯器呼叫 genssa 函式(gc/ssa.go)從 SSA IR 發出機器程式碼。我們不必修改任何程式碼,因為 until 語句包含在編譯器其他地方使用的構造塊,我們才為之發出的 SSA-我們不新增新的指令型別,等等。

但是,研究的 useuntil 函式生成的機器程式碼對我們是有指導意義的。Go 有自己的具有歷史根源的彙編語法。我不會在這裡討論所有細節,但是以下是帶註釋的(帶有#註釋)程式集轉儲,應該相當容易。我刪除了一些垃圾回收器的指令(PCDATA 和 FUNCDATA)以使輸出變小。

"".useuntil STEXT size=76 args=0x0 locals=0x10
  0x0000 00000 (useuntil.go:5)  TEXT  "".useuntil(SB), ABIInternal, $16-0

  # Function prologue

  0x0000 00000 (useuntil.go:5)  MOVQ  (TLS), CX
  0x0009 00009 (useuntil.go:5)  CMPQ  SP, 16(CX)
  0x000d 00013 (useuntil.go:5)  JLS  69
  0x000f 00015 (useuntil.go:5)  SUBQ  $16, SP
  0x0013 00019 (useuntil.go:5)  MOVQ  BP, 8(SP)
  0x0018 00024 (useuntil.go:5)  LEAQ  8(SP), BP

  # AX will be used to hold 'i', the loop counter; it's initialized
  # with the constant 4. Then, unconditional jump to the 'cond' block.

  0x001d 00029 (useuntil.go:5)  MOVL  $4, AX
  0x0022 00034 (useuntil.go:7)  JMP  62

  # The end block is here, it executes the function epilogue and returns.

  0x0024 00036 (<unknown line number>)  MOVQ  8(SP), BP
  0x0029 00041 (<unknown line number>)  ADDQ  $16, SP
  0x002d 00045 (<unknown line number>)  RET

  # This is the loop body. AX is saved on the stack, so as to
  # avoid being clobbered by "sayhi" (this is the caller-saved
  # calling convention). Then "sayhi" is called.

  0x002e 00046 (useuntil.go:7)  MOVQ  AX, "".i(SP)
  0x0032 00050 (useuntil.go:9)  CALL  "".sayhi(SB)

  # Restore AX (i) from the stack and decrement it.

  0x0037 00055 (useuntil.go:8)  MOVQ  "".i(SP), AX
  0x003b 00059 (useuntil.go:8)  DECQ  AX

  # The cond block is here. AX == 0 is tested, and if it's true, jump to
  # the end block. Otherwise, it jumps to the loop body.

  0x003e 00062 (useuntil.go:7)  TESTQ  AX, AX
  0x0041 00065 (useuntil.go:7)  JEQ  36
  0x0043 00067 (useuntil.go:7)  JMP  46
  0x0045 00069 (useuntil.go:7)  NOP
  0x0045 00069 (useuntil.go:5)  CALL  runtime.morestack_noctxt(SB)
  0x004a 00074 (useuntil.go:5)  JMP  0

如果您注意的話,您可能已經注意到“cond”塊移到了函式的末尾,而不是最初在 SSA 表示中的位置。是什麼賦予的?

答案是,“loop rotate”遍歷將在 SSA 的最末端執行。此遍歷對塊重新排序,以使主體直接流入 cond,從而避免每次迭代產生額外的跳躍。如果您有興趣,請參閱ssa/looprotate.go瞭解更多詳細資訊。

結論

就是這樣!在這兩篇文章中,我們以兩種不同的方式實現了一條新語句,從而知道了 Go 編譯器的內部結構。當然,這只是冰山一角,但我希望它為您自己開始探索提供了一個良好的起點。

最後一點:我們在這裡構建了一個可執行的編譯器,但是 Go 工具都無法識別新的 until 關鍵字。不幸的是,此時 Go 工具使用了完全不同的路徑來解析 Go 程式碼,並且沒有與 Go 編譯器本身共享此程式碼。我將在以後的文章中詳細介紹如何使用工具處理 Go 程式碼。

附錄-複製這些結果

要重現我們到此為止的 Go 工具鏈的版本,您可以從第 1 部分開始 ,還原 walk.go 中的 AST 轉換程式碼,然後新增上述的 AST 到 SSA 轉換。或者,您也可以從我的 fork 中獲取adduntil2 分支

要獲得所有 SSA 的 SSA,並在單個方便的 HTML 檔案中傳遞程式碼生成,請在構建工具鏈後執行以下命令:

GOSSAFUNC=useuntil <src checkout>/bin/go tool compile -l useuntil.go

然後在瀏覽器中開啟 ssa.html。如果您還想檢視 CFG 的某些通行證,請在函式名後新增通行名,以:分隔。例如 GOSSAFUNC = useuntil:number_lines。

要獲取彙編程式碼碼,請執行:

<src checkout>/bin/go tool compile -l -S useuntil.go

[1] 我特別嘗試避免在這些帖子中過多地講“前端”和“後端”。這些術語是過載和不精確的,但通常前端是在構造 AST 之前發生的所有事情,而後端是在表示形式上更接近於機器而不是原始語言的階段。當然,這在中間位置留有很多地方,並且 中間端也被廣泛使用(儘管毫無意義)來描述中間發生的一切。

在大型和複雜的編譯器中,您會聽到有關“前端的後端”和“後端的前端”以及類似的帶有“中間”的混搭的資訊。

在 Go 中,情況不是很糟糕,並且邊界已明確明確地確定。AST 在語法上接近輸入語言,而 SSA 在語法上接近。從 AST 到 SSA 的轉換非常適合作為 Go 編譯器的前/後拆分。

[2] -S 告訴編譯器將程式集原始碼轉儲到 stdout; -l 禁用內聯,這會通過內聯 fmt.Println 的呼叫而使主迴圈有些模糊。


via: https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-2/

作者:Eli Bendersky 譯者:keob 校對:unknwon

本文由 GCTT 原創編譯,Go語言中文網 榮譽推出

相關文章