為什麼多餘的建置步驟會讓我的 Zig 應用程式快 10 倍?
原文由 Michael Lynch 于 發布,訂閱此部落格
過去幾個月來,我一直對兩項技術很感興趣:Zig 程式語言與 Ethereum 加密貨幣。為了更深入了解這兩者,我用 Zig 寫了一個Ethereum 虛擬機器的位元組碼直譯器。
Zig 是個很適合做效能優化的語言,因為它讓你能對記憶體和控制流程進行細粒度的控制。為了激勵自己,我一直在拿自己的 Ethereum 實作跟官方的 Go 實作做效能比較。
在這個過程的初期,我用 Zig 寫的這個業餘 Ethereum 實作,效能比官方的 Go 實作慢了大約 40%。
最近,我對效能測試腳本做了一次自以為很單純的重構,結果應用程式的效能卻一落千丈。我追蹤後發現,關鍵差異就在這兩行指令之間:
$ echo '60016000526001601ff3' | xxd -r -p | zig build run -Doptimize=ReleaseFast
execution time: 58.808µs
$ echo '60016000526001601ff3' | xxd -r -p | ./zig-out/bin/eth-zvm
execution time: 438.059µs
zig build run 只是編譯並執行二進位檔的捷徑指令。它應該等同於以下兩道指令:
zig build
./zig-out/bin/eth-zvm
多一個建置步驟,怎麼會讓我的程式執行速度快了將近 10 倍?
重現這個現象的最小範例
為了解開這個效能謎團,我試著把應用程式一路簡化,直到它不再是位元組碼直譯器,而只是一個計算從 stdin 讀進來有幾個位元組的程式:
// src/main.zig
const std = @import("std");
pub fn countBytes(reader: anytype) !u32 {
var count: u32 = 0;
while (true) {
_ = reader.readByte() catch |err| switch (err) {
error.EndOfStream => {
return count;
},
else => {
return err;
},
};
count += 1;
}
}
pub fn main() !void {
var reader = std.io.getStdIn().reader();
var timer = try std.time.Timer.start();
const start = timer.lap();
const count = try countBytes(&reader);
const end = timer.read();
const elapsed_micros = @as(f64, @floatFromInt(end - start)) / std.time.ns_per_us;
const output = std.io.getStdOut().writer();
try output.print("bytes: {}\n", .{count});
try output.print("execution time: {d:.3}µs\n", .{elapsed_micros});
}
即使簡化到這樣,我依然能看到效能差異。當我用 zig build run 執行這個位元組計數器時,只花了 13 微秒:
$ echo '00010203040506070809' | xxd -r -p | zig build run -Doptimize=ReleaseFast
bytes: 10
execution time: 13.549µs
而當我直接執行編譯好的二進位檔時,卻花了 12 倍的時間,耗時 162 微秒:
$ echo '00010203040506070809' | xxd -r -p | ./zig-out/bin/count-bytes
bytes: 10
execution time: 162.195µs
我的測試由 bash pipeline 中的三道指令組成:
echo會印出一串以十六進位編碼的十個位元組(0x00、0x01……)。xxd會把echo輸出的十六進位位元組轉換成二進位位元組。zig build run會編譯並執行我的位元組計數程式,計算xxd吐出來的二進位位元組數量。
zig build run 和 ./zig-out/bin/count-bytes 唯一的差別在於,後者是執行已經編譯好的應用程式,而前者會重新編譯一次。
我再次感到百思不解。
多一道編譯步驟,怎麼會讓程式變得更快?難道 Zig 應用程式剛出爐的時候跑得比較快嗎?
向 Zig 社群求助
到了這個地步,我已經完全卡住了。我把原始碼反覆讀了好幾遍,還是無法理解為什麼編譯並執行一個應用程式,會比直接執行已經編譯好的二進位檔更快。
Zig 還是個很新的語言,所以一定是我對 Zig 有什麼誤解。想必只要有經驗的 Zig 開發者看一下我的程式碼,就能立刻點出我的錯誤。
我在 Zig 的討論區在 Ziggit 上發文提問。一開始的幾個回應都說我的問題出在「輸入緩衝(input buffering)」,但沒有人提出具體的修正或進一步追查的建議。
Zig 的創辦人兼主要開發者 Andrew Kelly 意外現身在討論串中。他也無法解釋我遇到的現象,但他指出我犯了另一個效能上的錯誤:

最後,我的朋友 Andrew Ayer 在 Mastodon 上看到我關於這件事的貼文,並解開了這個謎團:

Andrew Ayer 完全說中了,我在下面會詳細解釋。
題外話:Andrew Ayer 也曾提出關鍵見解,解開了我上一次的效能謎團。
我對 bash pipeline 的想像完全錯了
我以前從來沒有仔細想過 bash pipeline 的運作方式,但 Andrew 的留言讓我意識到自己的想像完全錯誤。
想像一個像這樣的簡單 bash pipeline:
./jobA | ./jobB
我原本的想像是,jobA 會先啟動並執行到結束,然後 jobB 才會啟動,並把 jobA 的輸出當成自己的輸入。

我對 bash pipeline 中工作運作方式的錯誤想像
事實上,bash pipeline 中的所有指令是同時啟動的。

bash pipeline 中工作的實際運作方式
為了展示 bash pipeline 中的平行執行,我用兩個簡單的 bash 腳本寫了一個概念驗證。
jobA 啟動後,會休眠三秒、印出內容到 stdout,再休眠兩秒,然後結束:
#!/usr/bin/env bash
function print_status() {
local message="$1"
local timestamp=$(date +"%T.%3N")
echo "$timestamp $message" >&2
}
print_status 'jobA is starting'
sleep 3
echo 'result of jobA is...'
sleep 2
echo '42'
print_status 'jobA is terminating'
jobB 啟動後,會等待 stdin 上的輸入,然後把從 stdin 讀到的所有內容印出來,直到 stdin 關閉:
#!/usr/bin/env bash
function print_status() {
local message="$1"
local timestamp=$(date +"%T.%3N")
echo "$timestamp $message" >&2
}
print_status 'jobB is starting'
print_status 'jobB is waiting on input'
while read line; do
print_status "jobB read '${line}' from input"
done < /dev/stdin
print_status 'jobB is done reading input'
print_status 'jobB is terminating'
如果我用 bash pipeline 來執行 jobA 和 jobB,在 jobB is starting 和 jobB is terminating 這兩則訊息之間,恰好經過了 5.009 秒:
$ ./jobA | ./jobB
09:11:53.326 jobA is starting
09:11:53.326 jobB is starting
09:11:53.328 jobB is waiting on input
09:11:56.330 jobB read 'result of jobA is...' from input
09:11:58.331 jobA is terminating
09:11:58.331 jobB read '42' from input
09:11:58.333 jobB is done reading input
09:11:58.335 jobB is terminating
如果我改成讓 jobA 和 jobB 依序執行而不是用 pipeline,那麼在 jobB 的 starting 和 terminating 訊息之間,只經過了 0.008 秒:
$ ./jobA > /tmp/output && ./jobB < /tmp/output
16:52:10.406 jobA is starting
16:52:15.410 jobA is terminating
16:52:15.415 jobB is starting
16:52:15.417 jobB is waiting on input
16:52:15.418 jobB read 'result of jobA is...' from input
16:52:15.420 jobB read '42' from input
16:52:15.421 jobB is done reading input
16:52:15.423 jobB is terminating
回頭看我的位元組計數器
一旦理解了 bash pipeline 中的所有指令都是平行執行的,我在位元組計數器上看到的行為就說得通了:
$ echo '00010203040506070809' | xxd -r -p | zig build run -Doptimize=ReleaseFast
bytes: 10
execution time: 13.549µs
$ echo '00010203040506070809' | xxd -r -p | ./zig-out/bin/count-bytes
bytes: 10
execution time: 162.195µs
看起來,執行 pipeline 中 echo '00010203040506070809' | xxd -r -p 這一段大約需要 150 微秒。而 zig build run 這個步驟至少也要花 150 微秒。
在 zig build 的版本中,等到 count-bytes 應用程式真正開始執行時,它已經不需要等待前面的工作完成。輸入早就已經在 stdin 上等著了。

使用 zig build run 時,我的應用程式執行前會有一段延遲,所以等到 count-bytes 啟動時,pipeline 中前面的工作早已完成。
當我跳過 zig build 步驟、直接執行編譯好的二進位檔時,count-bytes 會立刻啟動並開始計時。問題在於,count-bytes 必須空等大約 150 微秒,等 echo 和 xxd 把輸入送到 stdin。

當我直接執行 count-bytes 時,它必須空等約 150 微秒,直到 echo 和 xxd 把輸入餵進 stdin。
修正我的效能測試
修正我的效能測試很簡單。我不再把應用程式放在 bash pipeline 中執行,而是把準備階段和執行階段拆成兩個獨立的指令:
# Convert the hex-encoded input to binary encoding.
$ INPUT_FILE_BINARY="$(mktemp)"
$ echo '60016000526001601ff3' | xxd -r -p > "${INPUT_FILE_BINARY}"
# Read the binary-encoded input into the virtual machine.
$ ./zig-out/bin/eth-zvm < "${INPUT_FILE_BINARY}"
execution time: 67.378µs
我的測試結果從原本看到的 438 微秒,直接降到了 67 微秒。
修正效能測試腳本後,我的 Zig 應用程式在測量效能上的差異
套用 Andrew Kelly 的效能修正
還記得 Andrew Kelly 曾指出我每讀一個位元組就做一次 syscall 嗎。
var reader = std.io.getStdIn().reader();
...
while (true) {
_ = reader.readByte() { // Slow! One syscall per byte
...
};
...
}
所以,每當我的應用程式在迴圈中呼叫 readByte,就必須暫停執行、向作業系統請求讀取輸入,然後等作業系統送回那一個位元組後才能繼續。
修正方法很簡單。我只需要改用帶緩衝的讀取器。不再一次只向作業系統讀一個位元組,而是改用 Zig 內建的 std.io.bufferedReader,讓應用程式一次從作業系統讀進一大塊資料。這樣一來,我只需要發出少得多的 syscall。
以下是完整的修改內容:
diff --git a/src/main.zig b/src/main.zig
index d6e50b2..a46f8fa 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -7,7 +7,9 @@ pub fn main() !void {
const allocator = gpa.allocator();
defer _ = gpa.deinit();
- var reader = std.io.getStdIn().reader();
+ const in = std.io.getStdIn();
+ var buf = std.io.bufferedReader(in.reader());
+ var reader = buf.reader();
var evm = vm.VM{};
evm.init(allocator);
我重新跑了一次範例,效能又快了 11 微秒,算是小幅提升了 16%。
$ zig build -Doptimize=ReleaseFast && ./zig-out/bin/eth-zvm < "${INPUT_FILE_BINARY}"
execution time: 56.602µs
將輸入讀取加上緩衝後,效能又提升了 16%。
用更大的輸入做效能測試
我的 Ethereum 直譯器目前只支援 Ethereum 操作碼(opcode)的一小部分。在現階段,它能做的最複雜運算就是把數字相加。
舉例來說,以下是一個會數到三的 Ethereum 應用程式,它把 1 推入堆疊三次,再把這些值相加:
PUSH1 1 # Stack now contains [1]
PUSH1 1 # Stack now contains [1, 1]
PUSH1 1 # Stack now contains [1, 1, 1]
ADD # Stack now contains [2, 1]
ADD # Stack now contains [3]
我在效能測試中試過最大的應用程式,是一段會透過不斷把 1 相加來數到 1,000 的 Ethereum 位元組碼。
在 Andrew Kelly 的建議幫我減少 syscall 之後,我那個「數到 1,000」的應用程式執行時間從 2,024 微秒降到只有 58 微秒,快了 35 倍。現在我的實作已經比官方的 Ethereum 實作快了將近兩倍。
將輸入讀取加上緩衝後,我的 Zig 實作在測試集中最大的 Ethereum 應用程式上,執行速度比官方 Ethereum 實作快了約 2 倍。
用取巧的方式追求極致效能
看到自己的 Zig 實作終於超越官方 Go 版本,我很興奮,但我也想看看自己還能多大程度地利用 Zig 來提升效能。
軟體中常見的瓶頸之一是記憶體配置,因為程式必須向作業系統請求記憶體,並等待作業系統完成配置。
Zig 有一種叫做固定緩衝區配置器(fixed buffer allocator)的記憶體配置器。它不會向作業系統請求記憶體,而是由你提供一塊固定大小的位元組緩衝區,它只會用那塊緩衝區來配置記憶體。
我可以用取巧的方式來優化效能測試,編譯一個限制只能從堆疊配置 2 KB 記憶體的 Ethereum 直譯器版本:
diff --git a/src/main.zig b/src/main.zig
index a46f8fa..9e462fe 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -3,9 +3,9 @@ const stack = @import("stack.zig");
const vm = @import("vm.zig");
pub fn main() !void {
- var gpa = std.heap.GeneralPurposeAllocator(.{}){};
- const allocator = gpa.allocator();
- defer _ = gpa.deinit();
+ var buffer: [2000]u8 = undefined;
+ var fba = std.heap.FixedBufferAllocator.init(&buffer);
+ const allocator = fba.allocator();
const in = std.io.getStdIn();
var buf = std.io.bufferedReader(in.reader());
我稱這是「取巧」,因為我是在針對自己的特定測試做最佳化。當然,肯定有需要超過 2 KB 記憶體的合法 Ethereum 程式,但我只是好奇用了這個最佳化後到底能跑多快。
來看看如果我在編譯期就知道最大記憶體需求,效能會變成怎樣:
$ ./zig-out/bin/eth-zvm < "${COUNT_TO_1000_INPUT_BYTECODE_FILE}"
execution time: 34.4578µs
太酷了!使用固定的記憶體緩衝區後,我的 Ethereum 實作執行「數到 1,000」的位元組碼只要 34 微秒,幾乎比官方 Go 實作快了 3 倍。
如果我在編譯期就知道 Ethereum 直譯器的最大記憶體需求,就能比官方實作快上 3 倍。
結論
我從這次經驗得到的最大收穫是,要盡早並且經常做效能測試。
透過在持續整合(continuous integration)中加入效能測試腳本並保存結果,我很容易就能發現測量數據何時出現變化。如果我只是把效能測試當成手動、定期才做的工作,就很難準確找出造成數據差異的原因。
這次經驗也凸顯了理解自己指標的重要性。在遇到這個 bug 之前,我從沒想過我的效能測試其實包含了等待其他行程填滿 stdin 的時間。
原始碼
- eth-zvm:我用 Zig 實作的業餘 Ethereum 虛擬機器
隨機一篇部落格
留言
登入後參與討論