為什麼多餘的建置步驟會讓我的 Zig 應用程式快 10 倍?
過去幾個月來,我一直對兩項技術感到好奇:Zig 程式語言和 Ethereum 加密貨幣。為了更深入了解兩者,我一直使用 Zig 來撰寫適用於 Ethereum Virtual Machine 的 bytecode interpreter(位元組碼直譯器)。
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 管線中的三個指令組成:
echo會印出一串由十個經 hex 編碼的位元組(0x00、0x01、…)。xxd會將echo輸出的 hex 編碼位元組轉換為二進位編碼的位元組。zig build run會編譯並執行我的位元組計數器程式,計算xxd送出的二進位編碼位元組數量。
zig build run 與 ./zig-out/bin/count-bytes 唯一的差別在於,第二個指令是執行已經編譯好的應用程式,而前者會重新編譯應用程式。
我再次感到百思不解。
多一個編譯步驟怎麼會讓程式更快?難道 Zig 應用程式剛出爐時跑得比較快嗎?
向 Zig 社群求助
到了這個階段,我已經束手無策。我反覆檢視自己的原始碼,卻無法理解為何編譯並執行應用程式會比直接執行已編譯好的二進位檔更快。
Zig 仍是一個相當新的語言,所以肯定是我對 Zig 有什麼誤解。想必只要有經驗的 Zig 開發者看一下我的程式碼,就能立刻發現我的錯誤。
我在 Ziggit 上發布了我的問題,這是一個 Zig 的討論論壇。最初的幾個回覆都說我的問題出在「input buffering(輸入緩衝)」,但他們沒有提供具體的修正或進一步調查的建議。
Zig 的創辦人暨主要開發者 Andrew Kelly(安德魯·凱利)意外地在討論串中現身。他無法解釋我所觀察到的現象,但他指出我在另一個地方犯了效能上的錯誤:

最後,我的朋友Andrew Ayer(安德魯·艾爾)在 Mastodon 上看到了我關於此事的貼文,並解開了這個謎團:

安德魯·艾爾完全說中了,我會在下面詳細說明。
題外話:安德魯·艾爾也曾提供關鍵洞見,解開了我上一次的效能之謎。
我對 bash 管線的理解是錯的
我從未仔細思考過 bash 管線的運作方式,但安德魯的留言讓我意識到自己的心智模型是錯的。
想像一個簡單的 bash 管線,如下所示:
./jobA | ./jobB
我原本的心智模型是,jobA 會先啟動並執行到完成,然後 jobB 才會以 jobA 的輸出作為輸入開始執行。

我對 bash 管線中工作運作方式的錯誤心智模型
事實上,bash 管線中的所有指令都是同時啟動的。

bash 管線中工作的實際運作方式
為了展示 bash 管線中的平行執行,我用兩個簡單的 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 管線中執行 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 改為循序執行而非透過管線,那麼在 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 管線中的所有指令都是平行執行,我在位元組計數器中看到的行為就說得通了:
$ 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
看起來,執行管線中 echo '00010203040506070809' | xxd -r -p 這一段大約需要 150 微秒。zig build run 步驟至少也需要 150 微秒。
在 zig build 版本中,等到 count-bytes 應用程式真正開始執行時,它不必等待前面的工作完成。輸入資料已經在 stdin 上等著了。

使用 zig build run 時,在我的應用程式執行之前會有一段延遲,因此當 count-bytes 啟動時,管線中先前的工作已經完成了。
當我跳過 zig build 步驟直接執行已編譯的二進位檔時,count-bytes 會立即啟動並開始計時。問題在於,count-bytes 必須空等約 150 微秒,等待 echo 和 xxd 指令將輸入送達 stdin。

當我直接執行 count-bytes 時,它必須空等約 150 微秒,直到 echo 和 xxd 將輸入餵給 stdin。
修正我的基準測試
修正我的基準測試很簡單。我不再將應用程式作為 bash 管線的一部分來執行,而是將準備階段與執行階段拆成兩個獨立的指令:
# 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 應用程式在測量效能上的差異
套用安德魯·凱利的效能修正
回想一下,安德魯·凱利曾指出我每個位元組的讀取都要做一次 syscall(系統呼叫)。
var reader = std.io.getStdIn().reader();
...
while (true) {
_ = reader.readByte() { // Slow! One syscall per byte
...
};
...
}
因此,每當我的應用程式在迴圈中呼叫 readByte 時,它就必須暫停執行、向作業系統請求讀取輸入,然後在作業系統送回單一位元組後才恢復執行。
修正方法很簡單。我必須使用 buffered reader(緩衝讀取器)。與其每次只向作業系統讀取單一位元組,我改用 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 操作碼的一小部分。我的直譯器目前能做的最複雜運算就是把數字相加。
舉例來說,以下是一個會數到三的 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 位元組碼。
在安德魯·凱利的建議幫我減少 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(持續整合) 中加入基準測試腳本並保存結果,我就能輕鬆識別測量結果何時發生變化。如果我把基準測試當作手動且定期才執行的任務,就很難準確找出造成測量差異的原因。
這次經驗也凸顯了理解指標的重要性。在遇到這個錯誤之前,我從未考慮過我的基準測試包含了等待其他處理程序填滿 stdin 的時間。
原始碼
- eth-zvm:我用 Zig 實作的業餘 Ethereum 虛擬機
隨機一篇部落格