Why does an extraneous build step make my Zig app 10x faster?

Michael Lynch

為什麼多餘的建置步驟會讓我的 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 中的三道指令組成:

  1. echo 會印出一串以十六進位編碼的十個位元組(0x000x01……)。
  2. xxd 會把 echo 輸出的十六進位位元組轉換成二進位位元組。
  3. zig build run 會編譯並執行我的位元組計數程式,計算 xxd 吐出來的二進位位元組數量。

zig build run./zig-out/bin/count-bytes 唯一的差別在於,後者是執行已經編譯好的應用程式,而前者會重新編譯一次。

我再次感到百思不解。

多一道編譯步驟,怎麼會讓程式變得更快?難道 Zig 應用程式剛出爐的時候跑得比較快嗎?

向 Zig 社群求助

到了這個地步,我已經完全卡住了。我把原始碼反覆讀了好幾遍,還是無法理解為什麼編譯並執行一個應用程式,會比直接執行已經編譯好的二進位檔更快。

Zig 還是個很新的語言,所以一定是我對 Zig 有什麼誤解。想必只要有經驗的 Zig 開發者看一下我的程式碼,就能立刻點出我的錯誤。

我在 Zig 的討論區在 Ziggit 上發文提問。一開始的幾個回應都說我的問題出在「輸入緩衝(input buffering)」,但沒有人提出具體的修正或進一步追查的建議。

Zig 的創辦人兼主要開發者 Andrew Kelly 意外現身在討論串中。他也無法解釋我遇到的現象,但他指出我犯了另一個效能上的錯誤:

看起來你每個位元組的讀取就做了一次 syscall?這樣效能會非常差。我猜使用建置系統時多出來的步驟,意外帶來了一些緩衝。不過我也不確定為什麼。建置系統是讓子行程直接繼承檔案描述符的。

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

用明顯大很多的輸入(例如 > 1MB)還會看到 10 倍的差距嗎?如果把 stdin 從檔案重新導向而不是用 pipe,差距還在嗎?我的猜測是,當你直接執行程式時,xxd 和 count-bytes 是同時啟動的,所以 count-bytes 第一次嘗試從 stdin 讀取時,pipe 的緩衝區是空的,必須等到 xxd 把資料填進去才能繼續。但當你用 zig build run 時,xxd 在程式編譯的期間就搶先起跑了,所以等到 count-bytes 要從 stdin 讀取時,pipe 緩衝區已經被填滿了。

Andrew Ayer 完全說中了,我在下面會詳細解釋。

題外話:Andrew Ayer 也曾提出關鍵見解,解開了我上一次的效能謎團

我對 bash pipeline 的想像完全錯了

我以前從來沒有仔細想過 bash pipeline 的運作方式,但 Andrew 的留言讓我意識到自己的想像完全錯誤。

想像一個像這樣的簡單 bash pipeline:

./jobA | ./jobB

我原本的想像是,jobA 會先啟動並執行到結束,然後 jobB 才會啟動,並把 jobA 的輸出當成自己的輸入。

jobB 在 jobA 結束後才開始的甘特圖

我對 bash pipeline 中工作運作方式的錯誤想像

事實上,bash pipeline 中的所有指令是同時啟動的。

jobA 和 jobB 同時啟動的甘特圖,但 jobB 耗時較長,因為它必須等待 jobA 的結果

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'

下載 jobA

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'

下載 jobB

如果我用 bash pipeline 來執行 jobAjobB,在 jobB is startingjobB 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

如果我改成讓 jobAjobB 依序執行而不是用 pipeline,那麼在 jobBstartingterminating 訊息之間,只經過了 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 上等著了。

echo、xxd 和 zig build run 同時啟動的甘特圖,但 zig build run 的執行階段是在 echo 和 xxd 完成後才開始

使用 zig build run 時,我的應用程式執行前會有一段延遲,所以等到 count-bytes 啟動時,pipeline 中前面的工作早已完成。

當我跳過 zig build 步驟、直接執行編譯好的二進位檔時,count-bytes 會立刻啟動並開始計時。問題在於,count-bytes 必須空等大約 150 微秒,等 echoxxd 把輸入送到 stdin。

echo、xxd 和 count-bytes 同時啟動的甘特圖,但 count-bytes 要等到啟動後 150 微秒才能開始處理輸入,因為它在等 xxd 的結果

當我直接執行 count-bytes 時,它必須空等約 150 微秒,直到 echoxxd 把輸入餵進 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 虛擬機器

本文章由 muse-spark-1.2-contributor 進行翻譯

留言