基於暫存器呼叫的軟體加速
- 原文地址:https://menno.io/posts/golang-register-calling/
- 原文作者:Menno Finlay-Smits
- 本文永久連結:https://github.com/gocn/translator/blob/master/2021/w47_faster_software_through_register_based_calling.md
- 譯者:cvley
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
(返回)指令從棧中拿到返回地址,執行 main
中 call
後面的指令。
哈!對於這麼簡單的程式來說,程式碼真多。儘管理解工作原理很有用,但謝天謝地我們現在幾乎不寫組合語言了!
檢查 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 Hoyt 和 Brian Thorne 對本文的詳細審閱。
更新:本文在其他地方獲得非常多的討論,尤其是:
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- CS 暫存器 和 IP 暫存器
- 函式呼叫暫存器及棧幀結構函式
- 暫存器
- 暫存器定址和暫存器間接定址的區別
- iOS彙編基礎(二)暫存器iOS
- PC暫存器
- 基於VEH&除錯暫存器實現無痕HOOK(5)除錯Hook
- 暫存器,觸發器,三極體小結觸發器
- 為什麼Modbus的只讀暫存器被稱為“輸入暫存器(Input Registers)”而不是“輸出暫存器”
- 關於STM32的BSRR(埠位設定/清除暫存器) 和 BRR(埠位清除暫存器) 的理解(初學32)
- CPU 中通用暫存器的作用
- 浪潮儲存基於智慧運維技術,加速儲存自治運維
- 3. 暫存器(記憶體) | 問題 3.7 - 3.10記憶體
- 【STM32】【暫存器】暫存器位讀寫方式配置系統時鐘
- 程式設計中暫存器的使用程式設計
- STM32 GPIO 暫存器的配置
- 基於開源軟體構建儲存解決方案的思考
- CS、IP和PC暫存器
- 暫存
- Java讀取暫存器資料的方法Java
- 基於軟體工程的Qt播放器探索(一) 概述軟體工程QT播放器
- 新手分享_再談FS暫存器
- 【STC8H】STC8系列專有的特殊的暫存器位——PW_2暫存器的最高位 EAXFR
- CPU中跟蹤後繼指令地址的暫存器
- 6.常見暫存器和指令
- 10.1 除錯事件讀取暫存器除錯事件
- 第五章:通用暫存器是()。
- 暫存器指定為寫0或者1
- Intel 8086微處理器暫存器結構Intel
- 在控制器的方法裡面呼叫中介軟體
- 基於雲的MES系統軟體
- XCKU115硬體加速卡學習資料儲存:基於Xilinx XCKU115的半高PCIe x8 硬體加速卡
- 同樣是電晶體,為什麼暫存器比記憶體快呢?記憶體
- 一文搞懂 ARM 64 系列: 暫存器
- STM32暫存器操作、模板構建
- 程式分析與優化 - 8 暫存器分配優化
- 【C/C++】 C++暫存器優化C++優化
- 例項七— 8位移位暫存器的設計