通过重新构建,让 Ubuntu 软件包提速 90%

TL;DR (太长不看)

你可以获取 Ubuntu 用于构建 jq 的相同源代码包,重新编译它,就能获得 90% 的性能提升。

设置

我使用 jq 来处理 GeoJSON 文件和其他 JSON 格式的开放数据。今天我正在处理一个 500MB 的 GeoJSON 文件,其中包含 Alameda County Assessor 的地块地图。我想运行一个查询,打印出每个价值超过某个阈值的地块所在的城市。程序如下:

.features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity

在 Ryzen 9 9950X 系统上,文件被缓存后,这需要大约 5 秒钟。这看起来有点慢,我相信我们可以做得更好。

步骤 1:只需重新构建软件包

如果你从 Launchpad 获取 jq 的源代码,然后配置并重新构建它,完全不带任何标志,会发生什么?即使这样也比 Ubuntu 的二进制包快 2-4%。

我们使用 hyperfine 来获得可重复的结果。jq 程序被限制在逻辑 CPU 2 上,以使其远离 CPU 0 上运行的系统中断,并确保没有 CPU 迁移。

% hyperfine --warmup 1 --runs 3 -L binary ~/jq-jq-1.7.1/jq,/usr/bin/jq "taskset -c 2 {binary} -rf /tmp/select.jq /tmp/parcels.geojson"
Benchmark 1: taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.517 s ± 0.017 s  [User: 3.907 s, System: 0.610 s]
 Range (min … max):  4.497 s … 4.531 s  3 runs
Benchmark 2: taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.641 s ± 0.038 s  [User: 4.013 s, System: 0.628 s]
 Range (min … max):  4.601 s … 4.675 s  3 runs
Summary
 taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.03 ± 0.01 times faster than taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

步骤 2:使用 clang 和更好的标志重新构建

接下来,让我们用我最喜欢的编译器、更高的优化级别、LTO 以及一些我通常想要的、有助于调试和分析的标志来重新构建程序。其中一些与本例无关,但我对大多数构建使用相同的标志。似乎对性能有影响的标志是:

最后一个标志节省了很多断言的开销,这些断言在配置文件中表现得很明显。

% CC=clang-18 LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld" CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections" ./configure

Benchmark 1: taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   3.853 s ± 0.033 s  [User: 3.245 s, System: 0.608 s]
 Range (min … max):  3.822 s … 3.887 s  3 runs
Benchmark 2: taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.631 s ± 0.047 s  [User: 4.012 s, System: 0.619 s]
 Range (min … max):  4.602 s … 4.686 s  3 runs
Summary
 taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.20 ± 0.02 times faster than taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

现在我们比上游快 20%,几乎没有付出任何努力。

步骤 3:添加 TCMalloc

jq 是一个复杂的 C 程序,任何复杂度的 C 程序都倾向于依赖 mallocfree,因为该语言没有提供其他可以处理内存的方法。到目前为止,分配是配置文件中最重要的一行。如果我们使用更好的分配器,而不是 GNU libc 中的那个呢? Ubuntu 提供了一个 TCMalloc 包,它实际上已经过时了,不是当前的 TCMalloc 项目,但它是他们 repo 中的一个分配器包,所以让我们试一试。

在将 -L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimal 添加到 LDFLAGS 并重新构建后...

Benchmark 1: taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   3.253 s ± 0.009 s  [User: 2.625 s, System: 0.628 s]
 Range (min … max):  3.245 s … 3.262 s  3 runs
Benchmark 2: taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.611 s ± 0.026 s  [User: 4.015 s, System: 0.596 s]
 Range (min … max):  4.591 s … 4.640 s  3 runs
Summary
 taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.42 ± 0.01 times faster than taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

这还不错。我们现在比上游试图强加给我们的软件包快了 > 40%。

步骤 4:如果只是动态预加载 TCMalloc 呢?

如果分配器是问题所在,那么可以推断,我们只需使用动态预加载和 stock Ubuntu 二进制文件隐藏 libc 分配器,就可以获得一些好处。

Benchmark 1: LD_PRELOAD= taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.601 s ± 0.027 s  [User: 3.966 s, System: 0.634 s]
 Range (min … max):  4.577 s … 4.630 s  3 runs
Benchmark 2: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.082 s ± 0.010 s  [User: 3.476 s, System: 0.606 s]
 Range (min … max):  4.071 s … 4.091 s  3 runs
Summary
 LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.13 ± 0.01 times faster than LD_PRELOAD= taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

这本身就不错,提升了 13%。

步骤 5:动态加载其他分配器

Ubuntu 还提供了 jemallocmimalloc 的软件包。我们可以全部尝试一下。事实证明,mimalloc 击败了所有其他分配器。

注意:在环境中设置 MIMALLOC_LARGE_OS_PAGES=1 后获得 mimalloc 结果。

Benchmark 1: LD_PRELOAD= taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.636 s ± 0.055 s  [User: 4.015 s, System: 0.621 s]
 Range (min … max):  4.579 s … 4.767 s  10 runs
Benchmark 2: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.138 s ± 0.055 s  [User: 3.511 s, System: 0.627 s]
 Range (min … max):  4.080 s … 4.255 s  10 runs
Benchmark 3: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.067 s ± 0.030 s  [User: 3.345 s, System: 0.721 s]
 Range (min … max):  4.031 s … 4.123 s  10 runs
Benchmark 4: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   3.209 s ± 0.041 s  [User: 2.934 s, System: 0.274 s]
 Range (min … max):  3.160 s … 3.274 s  10 runs
Summary
 LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.27 ± 0.02 times faster than LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
  1.29 ± 0.02 times faster than LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
  1.44 ± 0.03 times faster than LD_PRELOAD= taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

仅预加载 mimalloc 就能获得 44% 的惊人加速!哇!

步骤 6:使用 mimalloc 重新构建

在这种情况下,mimalloc 很快,这很酷,但动态预加载对于性能来说并不是很好。让我们使用 mimalloc 重新构建程序。

Benchmark 1: taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   2.428 s ± 0.019 s  [User: 2.161 s, System: 0.267 s]
 Range (min … max):  2.404 s … 2.464 s  10 runs
Benchmark 2: taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson
 Time (mean ± σ):   4.606 s ± 0.039 s  [User: 3.979 s, System: 0.627 s]
 Range (min … max):  4.522 s … 4.640 s  10 runs
Summary
 taskset -c 2 /home/jwb/jq-jq-1.7.1/jq -rf /tmp/select.jq /tmp/parcels.geojson ran
  1.90 ± 0.02 times faster than taskset -c 2 /usr/bin/jq -rf /tmp/select.jq /tmp/parcels.geojson

对于此工作负载,使用更好的分配器从源代码重新构建的 jq 比 Ubuntu 二进制包快 1.9 倍,几乎快两倍。在另一个应用程序中,在 13000 个文件中处理 2.2GB 的 JSON(使用 rush 进行并行化)时,这个版本的 jq 耗时 0.755 秒,而 Ubuntu 包耗时 1.424 秒。这又是近 2 倍的加速。这些都是非常令人满意的结果。