Go Switch to a register-based calling convention for Go functions

自己對於 memory layout 相關議題都蠻感興趣的,而這次 Go 1.17 有一項效能改善的 proposal: switch to a register-based calling convention for Go functions 剛好跟 memory 有相關,因此就看了一下 proposal 文件介紹,不但複習了在計算機架構中曾接觸到的 calling convention 知識,也對於 Go 內部機制有更多認識。

Application Binary Interface (ABI)

在談 calling convention 之前,先來談 ABI。Calling convention 是 application binary interface (ABI) 的一部分,而定義 ABI 最主要的目的是建立應用程式與其他應用程式或是與作業系統服務之間低階溝通方式 (依賴 machine code) ,讓應用程式能夠在特定的環境下正確執行。

在早期,通常提供硬體的廠商也會提供對應的作業系統和編譯器,因此 ABI 也就由硬體廠商所定義規範。不過現在硬體、作業系統、編譯器都可能由獨立的廠商所負責,所以具體 ABI 的實作內容會根據不同廠商而有所不同(當然愈上層的 ABI 規範要能相容底層的規範)。舉例來說,ARM 有 arm ABI,而 Microsoft 作業系統有自己的 ABI,再來 Go compiler 又因為其語言特性而定義 Go ABI。

Calling Convention

Calling convention 主要定義 callee function 要如何處理從 caller function 接收傳遞過來的 parameters 和如何回傳結果的規範,其中具體實作方式可能包含:

  1. parameters 如何被傳遞 (e.g. stack, register)
  2. 在 callee function 中必須保存其值的 registers (callee-saved registers)
  3. caller function 呼叫 callee function 前的準備流程和得到回傳結果後的 restore 流程

Example

如同介紹 ABI 時所提到,calling convention 實作方式與當前的 CPU 架構、作業系統、語言編譯器有關,以 x86_64 架構、Linux 作業系統、搭配 C/C++ compiler 為例:

  1. 前六個型態為 integer 或 pointer 的 parameter 會使用 register 來傳遞,其順序為:RDI, RSI, RDX, RCX, R8, R9
  2. 更多的 parameter 則會使用 stack 傳遞
  3. callee function 回傳的結果,會放在 RAX 和 RDX register
  4. 如 callee function 要使用 RBX, RSP, RBP, R12–R15 等 register,則 callee 要負責保存其值

1. Register-based calling convention

Register-based calling convention 顧名思義就是先以 register 來傳遞 parameter 給 callee function,上面提到在 x86_64 Linux 系統下, C program 會使用 RDI, RSI, RDX, RCX, R8, R9 表示 1 - 6 參數,這就是 register-based calling convention 的實作。

我們用簡單的 c program 搭配 objdump 來觀察 machine code

1int foo(int a, int b)
2{
3  return a + b;
4}
5
6int main()
7{
8  int n = foo(1, 2);
9}

在 disable optimization (-O0) 情況下, machine code 如下

1100003f70: 55                          	pushq	%rbp
2100003f71: 48 89 e5                    	movq	%rsp, %rbp
3100003f74: 89 7d fc                    	movl	%edi, -4(%rbp) # a
4100003f77: 89 75 f8                    	movl	%esi, -8(%rbp) # b
5100003f7a: 8b 45 fc                    	movl	-4(%rbp), %eax
6100003f7d: 03 45 f8                    	addl	-8(%rbp), %eax # return a + b
7100003f80: 5d                          	popq	%rbp
8100003f81: c3                          	retq

可以看到 foo callee function 分別使用 DI , SI 來接收 a 和 b parameter,並且透過 AX register 保存其結果值。只有在 parameter 個數大於 6 的時候,才會使用 stack 空間來傳遞參數。(推薦閱讀 Stack frame layout on x86-64)

2. Stack-based calling convention

與 register-based 不同,stack-based calling convention 則是直接使用 stack 空間來傳遞參數給 callee function。由於 Go ABI 是採用 stack-based calling convention,所以我們同樣地用 Go 寫個簡單例子,並且 objdump machine code 觀察。

 1package main
 2
 3import "fmt"
 4
 5func foo(a, b int) int {
 6	c := a + b
 7	return c
 8}
 9
10func main() {
11	res := foo(1, 2)
12}

在 disable optimization (-gcflags '-N -l') 情況下, machine code 如下

 1109cfa0: 48 83 ec 10                  	subq	$16, %rsp
 2109cfa4: 48 89 6c 24 08               	movq	%rbp, 8(%rsp)
 3109cfa9: 48 8d 6c 24 08               	leaq	8(%rsp), %rbp
 4109cfae: 48 c7 44 24 28 00 00 00 00   	movq	$0, 40(%rsp)
 5109cfb7: 48 8b 44 24 18               	movq	24(%rsp), %rax  # a
 6109cfbc: 48 03 44 24 20               	addq	32(%rsp), %rax  # b
 7109cfc1: 48 89 04 24                  	movq	%rax, (%rsp) # c
 8109cfc5: 48 89 44 24 28               	movq	%rax, 40(%rsp) # return
 9109cfca: 48 8b 6c 24 08               	movq	8(%rsp), %rbp
10109cfcf: 48 83 c4 10                  	addq	$16, %rsp
11109cfd3: c3                           	retq

可以看到 foo function 是直接從 stack 中取出其 a 與 b 的值並放到 AX register 進行計算,計算出的結果也是直接放入指定的 stack 位置中。下圖為 stack 配置:

The difference between register-based and stack-based calling convention

其中兩者最大的差異就在於 memory access performance,畢竟 access register 的速度還是高於 stack memory address,根據 proposal 文件指出,其效能差距約 40%。另外,文件也指出另一個 performance 議題,就是 stack-based calling convention 完全仰賴 stack memory,會增加額外的 memory traffic。

Why does Go use stack-based calling convention

既然 register-based calling convention 的效能很明顯比較好,那為什麼當初設計 Go 的時候,會採用 stack-based calling convention 呢?其主要原因還是跟實作難易度和 Go 的特性有關,在文件中有提到幾點:

  1. The rules of the calling convention are simple and build on existing struct layout rules
  2. All platforms can use essentially the same conventions, leading to shared, portable compiler and runtime code
  3. Call frames have an obvious first-class representation, which simplifies the implementation of the go and defer statements and reflection calls.

可以看到,如果 Go 初期就採用 register-based calling convention 設計,那麽在實作上難度會增加許多。不過,在經過這十一年來 Go 逐漸穩定及愈來愈多公司和開發者使用,他們可能也覺得該是重視此效能改善問題的時候了,因此預計 Go 1.17 會先基於 amd64 架構實作 register-based calling convention,再逐漸地拓展到其他 CPU 架構並且根據反應來改善實作方式。

後記

如果不是因為這個 proposal,我應該都還不知道 Go 是 stack-based calling convention,甚至因為只有仔細看過 C program 的 machine code,我一直以為 parameter 都是先放在 register 的,現在才比較清楚知道原來每個語言也會根據需求來實作不同的 ABI,以及為什麼 Go 會需要專屬的 ABI 設計,而這是其中最重要的學習。

References

  1. https://people.freebsd.org/~obrien/amd64-elf-abi.pdf
  2. https://en.wikipedia.org/wiki/X86_calling_conventions
  3. https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64