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

Michael Lynch

为什么一个多余的构建步骤让我的 Zig 应用快了 10 倍?

过去几个月,我一直对两项技术很感兴趣:Zig 编程语言和以太坊(Ethereum)加密货币。为了同时深入了解这两者,我一直在用 Zig 编写一个以太坊虚拟机(Ethereum Virtual Machine)的字节码解释器

Zig 是一门非常适合性能优化的语言,因为它让你能够对内存和控制流进行细粒度的控制。为了激励自己,我一直在把我的以太坊实现与官方的 Go 实现进行基准测试对比。

在这个过程的初期,我的业余以太坊 Zig 实现比官方 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 打印十个十六进制编码的字节(0x000x01……)。
  2. xxdecho 的十六进制编码字节转换成二进制编码字节。
  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(安德鲁·凯利)在这个帖子里意外现身。他无法解释我看到的现象,但他指出我犯了另一个性能错误:

Looks like you’re doing 1 syscall per byte read? That’s going to perform extremely poorly. My guess is that the extra steps of using the build system incidentally introduced some buffering. Not sure why though. The build system is making the child process inherit the file descriptors directly.

最终,我的朋友 Andrew Ayer(安德鲁·艾尔)在 Mastodon 上看到我关于此事的帖子,并解开了这个谜团

Do you still see the 10x disparity with significantly larger inputs (i.e. > 1MB)? Do you still the disparity if you redirect stdin from a file instead of a pipe? My guess is that when you execute the program directly, xxd and count-bytes start at the same time, so the pipe buffer is empty when count-bytes first tries to read from stdin, requiring it to wait until xxd fills it. But when you use zig build run, xxd gets a head start while the program is compiling, so by the time count-bytes reads from stdin, the pipe buffer has been filled.

Andrew Ayer 说得完全正确,我会在下面详细拆解。

题外话:Andrew Ayer 还曾凭借一个关键洞察解开了我上一个性能谜团

我对 bash 管道的认知模型是错的

我从来没有仔细思考过 bash 管道,但 Andrew 的评论让我意识到我的认知模型是错的。

设想一条简单的 bash 管道,如下所示:

./jobA | ./jobB

我的认知模型是:jobA 先启动并运行到完成,然后 jobB 再启动,以 jobA 的输出作为输入。

Gantt chart of jobB starting after jobA finishes

我对 bash 管道中任务如何运作的错误认知模型

事实是,bash 管道中的所有命令是同时启动的。

Gantt chart of jobA and jobB starting simultaneously, but jobB is longer because it has to wait for jobA's results

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

如果我把 jobAjobB 放在一条 bash 管道里运行,从 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 上等着它。

Gantt chart where echo, xxd, and zig build run start at the same time, but the execute phase of zig build run starts after echo and xxd are complete

使用 zig build run 时,我的应用在执行前有一段延迟,所以等 count-bytes 启动时,管道中前面的任务已经完成了。

而当我跳过 zig build 步骤、直接运行编译好的二进制文件时,count-bytes 立刻启动,计时器也随之开始。问题在于,count-bytes 必须干等着约 150 微秒,直到 echoxxd 命令把输入送到 stdin。

Gantt chart where echo, xxd, and count-bytes all start at the same time, but count-bytes can't begin processing input until 150 microseconds after starting, as it's waiting on results from 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 应用测得性能的变化

应用 Andrew Kelly 的性能修复

还记得 Andrew Kelly 指出我每读取一个字节就进行一次系统调用(syscall)吗?

var reader = std.io.getStdIn().reader();
...
while (true) {
      _ = reader.readByte() { // Slow! One syscall per byte
          ...
      };
      ...
  }

也就是说,每次我的应用在循环中调用 readByte 时,它都得暂停执行,向操作系统请求读取一个输入字节,然后等操作系统交付这单个字节后再恢复执行。

修复很简单。我必须使用带缓冲的读取器(buffered reader)。我不再一次从操作系统读取单个字节,而是改用 Zig 内置的 std.io.bufferedReader,它让我的应用一次从操作系统读取大块数据。这样,我只需进行一小部分的系统调用。

以下是完整的改动:

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%。

对更大输入进行基准测试

我的以太坊解释器目前只支持以太坊操作码的一个小子集。此刻,我的解释器能执行的最复杂的计算就是把数字相加。

例如,下面是一个以太坊应用,它通过把 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 的以太坊字节码。

在 Andrew Kelly 的建议帮我减少了系统调用之后,我的“数到 1,000”应用的运行时间从 2,024 微秒降到了 58 微秒,提速 35 倍。我现在几乎以两倍的优势击败了官方以太坊实现。

对输入读取进行缓冲后,在我的测试集中最大的以太坊应用上,我的 Zig 实现比官方以太坊实现快了约 2 倍。

用“作弊”手段冲击极限性能

看到我的 Zig 实现终于超越了官方 Go 版本,我很兴奋,但我想看看究竟能借助 Zig 把性能提升到什么程度。

软件中一个常见的瓶颈是内存分配,因为程序必须向操作系统请求内存,并在操作系统满足请求期间等待。

Zig 提供了一种叫做固定缓冲分配器(fixed buffer allocator)的内存分配器。这种分配器不向操作系统请求内存,而是由你提供一块固定大小的字节缓冲区,它只用这些字节来分配内存。

我可以通过编译一个限制在从栈上分配的 2 KB 内存内的以太坊解释器版本来给基准测试“作弊”:

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 内存的合法以太坊程序,但我只是好奇,借助这个优化我能跑多快。

我们来看看,如果我在编译期就知道最大内存需求,性能会是什么样:

$ ./zig-out/bin/eth-zvm < "${COUNT_TO_1000_INPUT_BYTECODE_FILE}"
execution time:  34.4578µs

很酷!使用固定内存缓冲后,我的以太坊实现运行“数到 1,000”的字节码只需 34 微秒,比官方 Go 实现快了近 3 倍。

如果我在编译期就知道以太坊解释器的最大内存需求,我可以比官方实现快 3 倍。

结论

我从这次经历中得到的教训是:尽早且频繁地进行性能基准测试。

通过把基准测试脚本加入我的持续集成并归档结果,我很容易就能发现测量结果何时发生了变化。如果我把基准测试当作一项手动的、周期性的任务,那我就很难准确找出造成测量差异的原因了。

这次经历也凸显了理解你的指标的重要性。在遇到这个 bug 之前,我从没想过我的基准测试中包含了等待其他进程填充 stdin 的时间。

源代码

  • eth-zvm:我用 Zig 实现的业余以太坊虚拟机

原文由 Michael Lynch 发布

本文章由 stealth/ox-alpha 进行翻译