基於暫存器呼叫的軟體加速

jokerbugs發表於2021-12-10

2021年11月23日發表

Go 1.17 的釋出說明 中提到一個 Go 編譯器有趣的變更:現在將使用暫存器來代替棧,用於傳遞函式的引數和返回值。這個特性的 提案文件 提到,大量的應用預期可以提升 5-10% 的吞吐,尤其是對於開發者而言,除了使用新版的 Go 編譯器重新編譯外,沒有其他的工作量。我好奇這個變更的原理,因此決定深入研究一番。接下來的旅程多少有些技術宅的氣息,那麼坐好我們開始吧!

注意儘管本篇部落格的誘因來自於 Go 的一個變更,但即使你不使用 Go,也可以發現本文絕大部分的內容非常有趣。

複習:暫存器

此時回顧 CPU 暫存器的內容對下文的理解有用。簡單來說,暫存器是處理器中少量的高速臨時儲存。每個暫存器都有一個名字,每個可以儲存一個 word 的資料——對於現在絕大多數的計算機來說,是 64 bits。

一些暫存器有通用的目的,而其他的有特定的功能。在本文中,你將遇到 AX, BX 和 CX 通用目的的暫存器,同時也包括 SP(stack pointer,棧指標)這一特殊用途的暫存器。

複習:棧

回顧計算機程式中棧的概念也是有用的。它是放在程式記憶體頂部的一塊記憶體。棧一般用於儲存區域性變數、函式引數、函式返回值和函式返回的地址。棧會隨著不斷新增內容而逐漸向下擴大。

Eli Bendersky 有一篇解釋棧工作原理的精彩文章,其中就包含下面這張圖:

當在棧中新增元素時,我們稱為棧。當從棧中移除元素時,我們稱為棧。x86 CPU 有對應出入棧的指令。

前面提到的 SP 暫存器指向當前棧頂的元素。

注意這裡的表述有些隨意。在 x86 電腦上(和許多其他 CPU 架構上)棧的工作原理是這樣,但並非全部都是如此。

呼叫約定

在編譯好的軟體中,當某些程式碼想要呼叫一個函式時,這個函式的引數需要以某種方式傳入到函式中(當函式完成時,返回值也需要以某種方式傳出來)。完成這個目的有多種不同的可行方法,對應每種傳遞引數和返回值的方法叫做 “呼叫約定”。

Go 1.17 釋出說明中,有一部分引用了上面這段話,實際上也是對應 Go 呼叫約定的一個變更。

除非你使用匯編語言來程式設計,或者嘗試融合其他語言的程式碼,否則是無法感知這個變更的。即使這樣,能夠了解機器層面的工作方式依舊非常有趣。

一段簡單的程式碼

為了對比 Go 編譯器在 1.16 和 1.17 生成程式碼的不同,我們需要一段簡單的測試程式。這段程式無需太複雜,僅僅呼叫一個接受一些引數的函式,然後返回一個值就可以。下面這個是我想到的一個簡單的程式:

package main

import "fmt"

func add(i, j int) int {
    return i + j
}

func main() {
    z := add(22, 33)
    fmt.Println(z)
}

反彙編

為了看到 Go 編譯器生成的 CPU 指令,我們需要一個反彙編器。可以完成這個目的的一個工具是令人尊敬的 objdump,它在 GNU binutils 套件中,如果你使用的是 Linux,可能已經安裝了它。在本文中我將使用 objdump

Go 的編譯流程有些不尋常之處,在將程式碼轉換成真正的機器特定的指令前,它會先生成了一種定製的抽象組合語言。這個中間態的組合語言可以通過使用 go tool objdump 命令檢視。

使用這個輸出來探索有些誘人,但這個中間態的組合語言 不是為指定平臺生成的機器碼的直接表示。因此,我們選擇使用 objdump。

Go 1.16 的輸出

我們看下 Go 1.16 的輸出,預期呼叫使用的是棧。首先使用 Go 1.16 編譯出二進位制檔案,並確保可執行:

$ go1.16.10 build -o prog-116 ./main.go
$ ./prog-116
55

我的 Linux 發行版已經安裝了 Go 1.17.3,我使用的安裝方法參考的是安裝 Go 1.16.10 的 官方文件中描述的方法。

很棒!現在我們進行反編譯,看下生成的指令:

$ objdump -d prog-116 > prog-116.asm

我首先注意到的是,程式碼真多:

$ wc -l prog-116.asm
164670 prog-116.asm

對於這麼簡短的程式來說,生成的指令太多了,這是因為每個 Go 程式包含了 Go 的執行時,而執行時的程式碼量較多,用於排程 goroutines 並提供 Go 開發的所有便利。幸運的是,和測試程式直接相關的指令在底部:

(為了簡單,我省略了 objdump 一般會提供的偏移量和 raw 位元組;同時也省略一些 Go 設定的程式碼)

0000000000497640 <main.main>:
  ...
  movq   $0x37,(%rsp)
  call   40a3e0 <runtime.convT64>
  mov    0x8(%rsp),%rax
  xorps  %xmm0,%xmm0
  movups %xmm0,0x40(%rsp)
  lea    0xaa7e(%rip),%rcx        # 4a2100 <type.*+0xa100>
  mov    %rcx,0x40(%rsp)
  mov    %rax,0x48(%rsp)
  mov    0xb345d(%rip),%rax        # 54aaf0 <os.Stdout>
  lea    0x4290e(%rip),%rcx        # 4d9fa8 <go.itab.*os.File,io.Writer>
  mov    %rcx,(%rsp)
  mov    %rax,0x8(%rsp)
  lea    0x40(%rsp),%rax
  mov    %rax,0x10(%rsp)
  movq   $0x1,0x18(%rsp)

  movq   $0x1,0x20(%rsp)

  nop
  call   491140 <fmt.Fprintln>
  ...

奇怪!這和我們程式碼看起來完全不一樣。 add 函式的呼叫在哪裡?事實上, movq $0x37,(%rsp)(把 0x37 移動到棧指標暫存器指向的記憶體位置)看起來非常可疑。22 + 33 = 55,是十六進位制的 0x37。看起來 Go 編譯器已經對程式碼進行了優化,在編譯時計算出了加法的結果,在這個過程中消除了大部分的程式碼。

為了進一步研究,我們需要使用一個特殊的註釋來標識 add 函式,以此告訴 Go 編譯器不要內聯 add 函式。add 現在看起來是這樣:

//go:noinline
func add(i, j int) int {
    return i + j
}

編譯程式碼並再次執行 objdump,反編譯的結果看起來更符合我們的預期。我們從 main() 開始——我已經把反彙編進行了拆分並新增了註釋。

main 函式以基址指標和棧指標的初始化開始:

0000000000497660 <main.main>:
    mov    %fs:0xfffffffffffffff8,%rcx

    cmp    0x10(%rcx),%rsp
    jbe    497705 <main.main+0xa5>
    sub    $0x58,%rsp
    mov    %rbp,0x50(%rsp)
    lea    0x50(%rsp),%rbp

接下來,

movq   $0x16,(%rsp)
movq   $0x21,0x8(%rsp)

這裡我們看到 add 的引數已經被放到棧上,用於準備函式呼叫。0x16 (22) 移動到棧指標指向的地方。0x21 (33) 複製了棧指標指向位置後的 8 個位元組(也就是之前棧上的內容)。8 這個偏移量很重要,因為我們處理的是 64 位(8 位元組)的整數。一個 8 位元組的偏移表示的是 33 被直接放在 22 後面的棧中。

call   4976e0 <main.add>
mov    0x10(%rsp),%rax
mov    %rax,0x30(%rsp)

這裡是 add 函式被呼叫的地方。當 call 指令在 CPU 上執行時,指令指標的當前值被推到棧上,執行跳到 add 函式。一旦 add 返回,執行會繼續把返回值(棧指標 + 0x10)分配給 z(棧指標 + 0x30 作為結果)。

在 main 函式呼叫裡還有更多的程式碼,用於處理 fmt.Println 的呼叫,但它們不在本文的討論範圍。

在觀察這段程式碼時,我發現一個有趣的情況是,並沒有使用經典的 push 指令來加和。值通過 mov 放入棧中。這麼做是為了效能考慮。一次 mov 比一次 push 一般需要更少得 CPU 週期

我們也應該看一下 add

0000000000497640 <main.add>:
    mov    0x10(%rsp),%rax
    mov    0x8(%rsp),%rcx
    add    %rcx,%rax
    mov    %rax,0x18(%rsp)
    ret    

第二個引數(在 SP + 0x10)複製到 AX 暫存器中,第一個引數(在 SP +0x08)複製到 CX 暫存器中。但等一下,引數不應該在 SP 和 SP+0x10 嗎?當 call 指令執行時,它們確實在那裡,指令指標入棧,所以棧指標需要減少,才有空間放入它們——這表明引數的偏移需要根據這個情況來調整。

add 指令簡單易懂。其中 CX 和 AX 相加(結果存在 AX)。然後結果傳入返回的位置(SP +0x18)。

ret(返回)指令從棧中拿到返回地址,執行 maincall 後面的指令。

哈!對於這麼簡單的程式來說,程式碼真多。儘管理解工作原理很有用,但謝天謝地我們現在幾乎不寫組合語言了!

檢查 Go 1.17 的輸出

現在我們看下通用的程式使用 Go 1.17 編譯的情況。編譯和反彙編的步驟和 Go 1.16 相似:

$ go build -o prog-117 ./main.go
$ objdump -d prog-117 > prog-117.asm

主要的反彙編程式碼和 Go 1.16 下開始的地方是一樣的——符合預期——但在 add 呼叫的地方不同:

mov    $0x16,%eax
mov    $0x21,%ebx
xchg   %ax,%ax
call   47e260 <main.add>

不同於被複制到棧上,函式引數被複制到 AX 和 BX 暫存器上。這是基於暫存器呼叫的本質。

~xchg %ax,%ax 指令有些神祕,我僅知道對應的原理。如果你瞭解,可以發郵件聯絡,我將會在這裡新增細節資訊。~

更新: xchg %ax,%ax 指令可以確定是為了相容在一些 Intel 處理器上的 bug。這個指令("exchange AX with AX")除了在 call 指令前引入兩個位元組的對齊填充外,沒有其他功能——這用於相容處理器的 bug。Go GitHub issue 有詳細的資訊。感謝那些作者。

正如我們前面所見,call 指令把執行移動到 add 函式中。

現在我們看下 add:

000000000047e260 <main.add>:
    add    %rbx,%rax
    ret    

就是這麼簡單!不像 Go 1.16 版本,無需把棧上的引數移動到暫存器再相加,也無需再把結果移動到棧。函式引數預期是在 AX 和 BX 暫存器中,返回值預期返回到 AX。ret 指令在 call 執行後,通過使用 call 留在棧上的返回地址,返回執行流程。

在函式引數和返回值的處理過程中,僅有少量的工作量,基於暫存器呼叫可能更快的答案也逐漸浮出水面。

效能對比

那麼基於暫存器的呼叫可以快多少呢?我建立了一個簡單的 Go 基準程式來驗證:

package main

import "testing"

//go:noinline
func add(i, j int) int {
    return i + j
}

var result int

func BenchmarkIt(b *testing.B) {
    x := 22
    y := 33
    var z int
    for i := 0; i < b.N; i++ {
        z = add(x, y)
    }
    result = z
}

注意在基準函式外使用變數,是為了保證編譯器不會優化z 的分配。

基準測試可以這麼執行:

go test bench_test.go -bench=.

在我這個有些年頭的筆記本上,在 Go 1.16 下能得到的最好結果是:

BenchmarkIt-4       512385438            2.292 ns/op

Go 1.17 的結果:

BenchmarkIt-4       613585278            1.915 ns/op

提升很顯著——我們的例子執行時間下降了 16%。效果不錯,尤其是這個提升對於所有的 Go 程式都很容易,僅僅需要使用編譯器的新版本就可以了。

結論

我希望你對探索一些軟體底層細節的內容感興趣,畢竟我們現在很少考慮這方面的內容,希望你在這個過程中有所收穫。

非常感謝 Ben HoytBrian Thorne 對本文的詳細審閱。

更新:本文在其他地方獲得非常多的討論,尤其是:

更多原創文章乾貨分享,請關注公眾號
  • 基於暫存器呼叫的軟體加速
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章