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

Michael Lynch

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

  1. echo 會印出一串由十個經 hex 編碼的位元組(0x000x01、…)。
  2. xxd 會將 echo 輸出的 hex 編碼位元組轉換為二進位編碼的位元組。
  3. zig build run 會編譯並執行我的位元組計數器程式,計算 xxd 送出的二進位編碼位元組數量。

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

我再次感到百思不解。

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

向 Zig 社群求助

到了這個階段,我已經束手無策。我反覆檢視自己的原始碼,卻無法理解為何編譯並執行應用程式會比直接執行已編譯好的二進位檔更快。

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

在 Ziggit 上發布了我的問題,這是一個 Zig 的討論論壇。最初的幾個回覆都說我的問題出在「input buffering(輸入緩衝)」,但他們沒有提供具體的修正或進一步調查的建議。

Zig 的創辦人暨主要開發者 Andrew Kelly(安德魯·凱利)意外地在討論串中現身。他無法解釋我所觀察到的現象,但他指出我在另一個地方犯了效能上的錯誤:

看起來你每讀取一個位元組就做了一次 syscall?這樣效能會非常差。我猜使用建置系統的多餘步驟意外引入了某些緩衝。不過不太確定原因。建置系統是讓子處理程序直接繼承檔案描述符。

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

你用明顯更大的輸入(例如 > 1MB)時,是否仍會看到 10 倍的差距?如果你把 stdin 從檔案重新導向而不是透過管線,是否仍有差距?我猜當你直接執行程式時,xxd 和 count-bytes 會同時啟動,所以當 count-bytes 第一次嘗試從 stdin 讀取時,管線緩衝區是空的,必須等到 xxd 填滿它。但當你使用 zig build run 時,程式在編譯期間讓 xxd 有了領先的時間,所以等到 count-bytes 從 stdin 讀取時,管線緩衝區已經被填滿了。

安德魯·艾爾完全說中了,我會在下面詳細說明。

題外話:安德魯·艾爾也曾提供關鍵洞見,解開了我上一次的效能之謎

我對 bash 管線的理解是錯的

我從未仔細思考過 bash 管線的運作方式,但安德魯的留言讓我意識到自己的心智模型是錯的。

想像一個簡單的 bash 管線,如下所示:

./jobA | ./jobB

我原本的心智模型是,jobA 會先啟動並執行到完成,然後 jobB 才會以 jobA 的輸出作為輸入開始執行。

jobB 在 jobA 完成後才啟動的甘特圖

我對 bash 管線中工作運作方式的錯誤心智模型

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

jobA 和 jobB 同時啟動的甘特圖,但 jobB 的執行時間更長,因為它必須等待 jobA 的結果

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'

下載 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 管線中執行 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 改為循序執行而非透過管線,那麼在 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 管線中的所有指令都是平行執行,我在位元組計數器中看到的行為就說得通了:

$ 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 上等著了。

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

使用 zig build run 時,在我的應用程式執行之前會有一段延遲,因此當 count-bytes 啟動時,管線中先前的工作已經完成了。

當我跳過 zig build 步驟直接執行已編譯的二進位檔時,count-bytes 會立即啟動並開始計時。問題在於,count-bytes 必須空等約 150 微秒,等待 echoxxd 指令將輸入送達 stdin。

echo、xxd 和 count-bytes 同時啟動的甘特圖,但 count-bytes 在啟動後約 150 微秒內無法開始處理輸入,因為它正在等待 xxd 的結果

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

原文由 Michael Lynch 發布

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