让 rav1d 视频解码器提速 1%

2025年5月22日

在配备 M3 芯片的 macOS 上,在一个特定的基准测试中,速度提升略高于 1%,而且没有引入任何新的 unsafe 代码。

不久前,memorysafety.org 宣布了一项竞赛,旨在提高 rav1d 的性能,rav1ddav1d AV1 解码器的 Rust 移植版本。

这简直就像写了我的名字一样,我觉得尝试一下会很有趣(即使我 可能 无法参加比赛)。

这是一篇关于我发现的两个小的性能改进的说明 (第一个 PR, 第二个 PR) 以及我是如何找到它们的(你也可以直接跳转到[最后的总结](https://ohadravid.github.io/posts/2025-05-rav1d-faster/<#summary>))。

drakeposting meme - working on rav1d because there’s a contest with money, working on rav1d because my last name is Ravid

背景和方法

rav1d (https://github.com/memorysafety/rav1d) 是 dav1d (https://code.videolan.org/videolan/dav1d) 的一个移植版本,创建方式是: (1) 在 dav1d 上运行 c2rust (https://github.com/immunant/c2rust); (2) 合并 dav1d 经过汇编优化的函数;以及 (3) 修改代码,使其更符合 Rust 的风格,也更安全。

作者还发表了一篇详细文章来介绍这个过程以及他们所做的性能优化工作。

最近,比赛宣布了,基准线是:

我们基于 Rust 的 rav1d 解码器目前比基于 C 的 dav1d 解码器慢大约 5%。

视频解码器是出了名的复杂软件,但是因为我们是在比较两个相似的确定性二进制文件的性能,我们或许可以避免很多复杂性——只要有正确的工具。

我们不能期望找到巨大的性能提升,而且一些性能退化可能难以解决(例如,LLVM 发现 Rust 函数比 C 版本更难优化),但是值得一试,特别是考虑到 aarch64(我的环境)的优化可能不如 x86_64。

我的方法是:

  1. 使用抽样分析器来捕获在同一输入上运行的两个版本的快照。
  2. 使用优化的汇编调用作为“锚点”,因为它们应该完全匹配。
  3. 逐个函数地比较 Rust 和 C 版本,如果存在足够大的差异,就深入研究该函数。

基准测试

首先,我们需要在本地构建和比较性能(使用 hyperfine 和比赛规则中以及 rav1dCI 中提到的示例文件)。

我们将使用单线程版本 (--threads 1) 来简化操作。

对于 rav1d

$ git clone git@github.com:memorysafety/rav1d.git && cd rav1d && git log -n1
commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
$ rustc --version --verbose # set by rust-toolchain.toml
rustc 1.88.0-nightly (b45dd71d1 2025-04-30)
...
LLVM version: 20.1.2
$ cargo build --release
  Finished `release` profile [optimized] target(s) in ..
$ hyperfine --warmup 2 "target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1"
Benchmark 1: target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
 Time (mean ± σ):   73.914 s ± 0.151 s  [User: 73.295 s, System: 0.279 s]
 Range (min … max):  73.770 s … 74.132 s  10 runs

对于 dav1d

$ git clone https://code.videolan.org/videolan/dav1d.git && cd dav1d && git checkout 1.5.1
$ brew install llvm@20 && export CC=clang; $CC --version
Homebrew clang version 20.1.4
$ meson setup build "-Dbitdepths=['8','16']"
$ bear -- ninja -C build tools/dav1d
...
[88/88] Linking target tools/dav1d
$ hyperfine --warmup 2 "build/tools/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1"
Benchmark 1: build/tools/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
 Time (mean ± σ):   67.912 s ± 0.541 s  [User: 67.208 s, System: 0.282 s]
 Range (min … max):  66.933 s … 68.948 s  10 runs

因此,对于这个示例文件,rav1ddav1d 慢了大约 9%(6 秒),至少在 M3 芯片上是这样。(理想情况下,clangrustc 应该使用相同的 LLVM 版本,但是补丁版本的差异可能没问题。)(在配备 8 个内核的 MacBook Air M3 上测量。)

性能分析

我使用了 samply,这是我目前首选的抽样分析器:

./dav1d $ sudo samply record ./build/tools/dav1d -q -i /Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
./rav1d $ sudo samply record ./target/release/dav1d -q -i /Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1

(Rust 二进制文件也叫做 dav1d,这有点令人困惑。)

默认情况下,samply 使用 1000Hz 的采样率,这意味着(例如)一个函数中任何 500 个样本的差异将占大约 0.5 秒的运行时差异。

通常,从“反向堆栈”视图开始有助于缩小感兴趣的选项范围(我们将在下一节中探讨),但这次我们要专注于我们知道应该匹配的锚点:汇编函数。

你可以在 Firefox Profiler 中在线查看完整的分析器快照 (dav1d, rav1d),但是这里有一些相关的、经过筛选的片段(注意:这些不是交互式的。如果想探索更多,请查看链接)。

首先,这是 dav1d (C) 版本(样本总数:~69,500):

接下来,这是 rav1d (Rust) 版本(样本总数:~75,150):

看一下突出显示的函数,dav1d_cdef_brow_8bpcrav1d_cdef_browTotal 样本计数是看到此函数“在堆栈中的任何位置”的样本数,这意味着它包括它调用的任何“子”函数。Self 样本计数是其中这是正在执行的函数的样本数,因此它不包括子函数的样本计数。

dav1drav1d 之间存在细微的差异:虽然 _neon 扩展表示两个二进制文件之间共享的特定于 Arm 的汇编函数,但我们看到:

  1. dav1d 调用 cdef_filter_8x8_neoncdef_filter_4x4_neon,并且它们各自调度相关的汇编函数(分别是 84 版本)。
  2. rav1d 调用 cdef_filter_neon_erased,它处理所有汇编函数的调度。

我们还可以看到 cdef_filter8_pri_sec_edged_8bpc_neon 在两个快照中的样本计数几乎相同,这意味着我们的方向是正确的。

让我们忽略 cdef_filter4_pri_edged_8bpc_neon 函数,它 不匹配,至少现在是这样(预示着该系列的可能第二部分)。

这意味着 (A) dav1d_cdef_brow_8bpcSelf 样本计数应该与 rav1d_cdef_brow 匹配,并且 (B) cdef_filter_{8x8,4x4}_neonSelf 样本计数的总和应该与 cdef_filter_neon_erasedSelf 样本计数匹配。

现在我们看到了一些有趣的东西:关注第二部分,cdef_filter_{8x8,4x4}_neonSelf 样本计数的总和大约为 400 个样本,而 rav1dcdef_filter_neon_erased 几乎为 670 个样本。我们还可以看到 dav1d_cdef_brow_8bpc 是 1790 个样本,而 rav1d_cdef_brow 是 2350 个样本。

总而言之,这种差异占 rav1d 总运行时的约 1%!

跳转到 cdef_filter_neon_erased 的实现,除了使用 .cast() 的一堆指针转换之外,只有一件“大事”不是汇编调用机制的一部分:

#[deny(unsafe_op_in_unsafe_fn)]
pub unsafe extern "C" fn cdef_filter_neon_erased<
  BD: BitDepth,
  const W: usize,
  const H: usize,
  const TMP_STRIDE: usize,
  const TMP_LEN: usize,
>(
  // .. snip ..
) {
  use crate::src::align::Align16;
  // .. snip ..
  let mut tmp_buf = Align16([0u16; TMP_LEN]);
  let tmp = &mut tmp_buf.0[2 * TMP_STRIDE + 8..];
  padding::Fn::neon::<BD, W>().call::<BD>(tmp, dst, stride, left, top, bottom, H, edges);
  filter::Fn::neon::<BD, W>().call(dst, stride, tmp, pri_strength, sec_strength, dir, damping, H, edges, bd);
}

其中 TMP_LEN12 * 16 + 8 = 20012 * 8 + 8 = 104,因此在最坏的情况下 tmp_buf = [u16; 200]。对于一个临时缓冲区来说,要清零这么多内存!

dav1d 在这里做了什么?

#define DEFINE_FILTER(w, h, tmp_stride)                   \
static void                                 \
cdef_filter_##w##x##h##_neon(/* .. snip .. */)                \
{                                      \
  ALIGN_STK_16(uint16_t, tmp_buf, 12 * tmp_stride + 8,);          \
  uint16_t *tmp = tmp_buf + 2 * tmp_stride + 8;              \
  BF(dav1d_cdef_padding##w, neon)(tmp, dst, stride,            \
                  left, top, bottom, h, edges);      \
  BF(dav1d_cdef_filter##w, neon)(dst, stride, tmp, pri_strength,      \
                  sec_strength, dir, damping, h, edges   \
                  HIGHBD_TAIL_SUFFIX);           \
}
DEFINE_FILTER(8, 8, 16)
DEFINE_FILTER(4, 8, 8)
DEFINE_FILTER(4, 4, 8)

在进行一些宏展开之后,我们得到 uint16_t tmp_buf[200] __attribute__((aligned(16)));

这意味着 tmp_buf 没有被 cdef_filter_{8x8,4x4}_neon 函数初始化:相反,它被用作 padding 汇编函数的写入目标,稍后被 filter 汇编函数按原样使用。编译器似乎不知道这种初始化可以被消除,我们也可以使用 --emit=llvm-ir 来更直接地看到它:

$ RUSTFLAGS="--emit=llvm-ir" cargo build --release --target aarch64-apple-darwin

; rav1d::src::cdef::neon::cdef_filter_neon_erased
; Function Attrs: nounwind
define internal void @_ZN5rav1d3src4cdef4neon23cdef_filter_neon_erased17h7e4dbe8ecff68724E(ptr noundef %dst, i64 noundef %stride, ptr noundef %left, ptr noundef %top, ptr noundef %bottom, i32 noundef %pri_strength, i32 noundef %sec_strength, i32 noundef %dir, i32 noundef %damping, i32 noundef %edges, i32 noundef %bitdepth_max, ptr nocapture readnone %_dst, ptr nocapture readnone %_top, ptr nocapture readnone %_bottom) unnamed_addr #1 {
start:
 %tmp_buf = alloca [400 x i8], align 16
 call void @llvm.lifetime.start.p0(i64 400, ptr nonnull %tmp_buf)
 call void @llvm.memset.p0.i64(ptr noundef nonnull align 16 dereferenceable(400) %tmp_buf, i8 0, i64 400, i1 false)
 %_37 = getelementptr inbounds nuw i8, ptr %tmp_buf, i64 80
 call void @dav1d_cdef_padding8_16bpc_neon(ptr noundef nonnull %_37, ptr noundef %dst, i64 noundef %stride, ptr noundef %left, ptr noundef %top, ptr noundef %bottom, i32 noundef 8, i32 noundef %edges) #121
 %edges2.i = zext i32 %edges to i64
 %_0.i.i.i.i = and i32 %bitdepth_max, 65535
 call void @dav1d_cdef_filter8_16bpc_neon(ptr noundef %dst, i64 noundef %stride, ptr noundef nonnull readonly align 2 %_37, i32 noundef %pri_strength, i32 noundef %sec_strength, i32 noundef %dir, i32 noundef %damping, i32 noundef 8, i64 noundef %edges2.i, i32 noundef %_0.i.i.i.i) #121
 call void @llvm.lifetime.end.p0(i64 400, ptr nonnull %tmp_buf)
 ret void
}

使用 MaybeUninit 避免不必要地清零缓冲区

这实际上应该很容易!Rust 有 std::mem::MaybeUninit (https://doc.rust-lang.org/std/mem/union.MaybeUninit.html) 专门用于这种情况:

-let mut tmp_buf = Align16([0u16; TMP_LEN])
+let mut tmp_buf = Align16([MaybeUninit::<u16>::uninit(); TMP_LEN]);

我们仍然可以安全地获取一个子切片 (&mut tmp_buf.0[2 * TMP_STRIDE + 8..]),但是我们需要更新内部函数的签名以使用新的类型 (tmp: *mut MaybeUninit<u16>, tmp: &[MaybeUninit<u16>])。

由于使用这些代码无论如何都是不安全的,因此我们不需要添加任何新的 unsafe 块——只需要验证现有代码没有改变(相对于 dav1d 而言)依赖于这个缓冲区被清零。

之前,cdef_filter_neon_erased 有 670 个 Self 样本。重新运行分析器,我们得到一个新的快照

只有 274 个样本!略低于 dav1dcdef_filter_{8x8,4x4}_neonSelf 样本计数。

也许这不是唯一浪费时间清零缓冲区的地方?快速搜索其他大的 Align16 缓冲区导致了这个幸运的发现:

pub(crate) fn rav1d_cdef_brow<BD: BitDepth>(/* .. snip ..*/)
{
  // .. snip ..
  for by in (by_start..by_end).step_by(2) {
    // .. snip ..
    let mut lr_bak =
      Align16([[[[0.into(); 2 /* x */]; 8 /* y */]; 3 /* plane */ ]; 2 /* idx */]);
    // .. snip ..
  }
}

同样,来自 dav1d 的匹配代码没有初始化这个缓冲区。在这里,切换到 MaybeUninit 更加困难,但是我们仍然可以提供一个适度的改进:如果我们将 lr_bak 提升到顶层,我们只需要执行 一次 初始化!

pub(crate) fn rav1d_cdef_brow<BD: BitDepth>(/* .. snip ..*/)
{
  // .. snip ..
+  let mut lr_bak =
+    Align16([[[[0.into(); 2 /* x */]; 8 /* y */]; 3 /* plane */ ]; 2 /* idx */]);
  for by in (by_start..by_end).step_by(2) {
    // .. snip ..
-    let mut lr_bak =
-      Align16([[[[0.into(); 2 /* x */]; 8 /* y */]; 3 /* plane */ ]; 2 /* idx */]);
    // .. snip ..
  }
}

由于 dav1d 无论如何都没有初始化它,我们知道从这个缓冲区读取的任何数据都是事先用有效值写入的(这确实有助于强调 未定义行为应该享有更好的声誉)。这里的节省非常小,但是每一分钱都很重要!

运行完整的基准测试,我们从最初的 73.914 s ± 0.151 s 获得了不错的加速:

$ hyperfine --warmup 2 "target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1"
Benchmark 1: target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
 Time (mean ± σ):   72.644 s ± 0.250 s  [User: 72.023 s, System: 0.239 s]
 Range (min … max):  72.281 s … 73.098 s  10 runs

距离 dav1d67.912 s ± 0.541 s 还有一段路要走,但是总运行时提高了 1.2 秒(1.5%)是一个很好的开始,并且弥补了两者之间大约 20% 的性能差异。

再次进行性能分析,但反向显示堆栈

让我们从头开始重新加载分析器输出,但是使用“反向堆栈”视图。dav1d (C) (链接):rav1d (Rust) (链接):

我们可以探索一些优化选项,但是引起我注意的是 add_temporal_candidate 函数:Rust 和 C 版本之间的差异非常显着(约 400 个样本,约 0.5 秒),而且函数本身看起来无害:它大约有 50 行 iffor 语句,以及对一些短效用函数的调用。

为了帮助我们找出丢失的性能,我们可以尝试使用调试符号重新编译 rav1drav1d 项目很有帮助地在其 Cargo.toml 中定义了 [profile.release-with-debug],允许我们运行:

$ cargo build --profile=release-with-debug
$ sudo samply record target/release-with-debug/dav1d ...

我们得到的结果与之前略有不同 (链接):release-with-debug profile 不会像以前那样进行优化,小函数调用看起来比实际更大,但是我们得到了 函数的逐行样本分解,它应该引导我们朝着正确的方向前进。

如果稍微滚动一下,你会发现 if cand.mv.mv[0] == mv {if cand.mv == mvp { 行似乎总共覆盖了 600 个样本!

让我们查看 mv: Mv 的定义:

#[derive(Clone, Copy, PartialEq, Eq, Default, FromZeroes, FromBytes, AsBytes)]
#[repr(C)]
pub struct Mv {
  pub y: i16,
  pub x: i16,
}

咦。这怎么会很慢?它只是 #[derive(PartialEq)]

Futurama Fry Looking Suspicious

更可疑的是,dav1d 版本略有不同,并使用 mvstack[n].mv.n == mvp.n 来进行相同的比较。但是 n 是什么?查看 dav1dmv 定义,我们发现:

typedef union mv {
  struct {
    int16_t y, x;
  };
  uint32_t n;
} mv;

看起来 dav1d 的作者知道比较两个 i16 可能会很慢,所以当他们比较两个 mv 时,他们将它们视为 u32

用优化效果更好的按字节相等性替换按字段相等性

这会是问题所在吗?在 Rust 中将 Mv 定义为 union 有一个很大的缺点:它使得访问 union 的任何字段都是 unsafe 的,这将“感染” Mv 的每个用法,这与我们通常在 Rust 中想要做的事情相反(尝试将不安全性封装在一个安全的 API 中)。

幸运的是,我们有另一个选择:我们可以使用 transmuteMv 重新解释为 u32,并使用它来实现 PartialEq

启动 Godbolt,我们可以检查两种比较方式生成的代码:

显然 transmute 版本更优越,但是我们能避免 unsafe 块吗?1

事实证明,zerocopy crate 可以静态验证将 struct 表示为 &[u8]安全要求,允许我们编写:

use zerocopy::{AsBytes, FromBytes, FromZeroes};
#[derive(Clone, Copy, Eq, Default, FromZeroes, FromBytes, AsBytes)]
#[repr(C), align(32)]
pub struct Mv {
  pub y: i16,
  pub x: i16,
}
impl PartialEq for Mv {
  #[inline(always)]
  fn eq(&self, other: &Self) -> bool {
    self.as_bytes() == other.as_bytes()
  }
}

这会产生与我们使用 transmute 时看到的相同(优化)汇编代码。

在为 RefMvs{Mv,Ref}Pair 实现了类似的优化之后,我们可以重新运行基准测试:

$ hyperfine --warmup 2 "target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1"
Benchmark 1: target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
 Time (mean ± σ):   72.182 s ± 0.289 s  [User: 71.501 s, System: 0.242 s]
 Range (min … max):  71.850 s … 72.722 s  10 runs

这比我们之前的结果 (72.644 s ± 0.250 s) 提高了 0.5 秒,或者比基准 (73.914 s ± 0.151 s) 提高了 2.3%。

我们现在距离 dav1d67.912 s ± 0.541 s 只有 4.2 秒了,所以我们弥补了本文开头看到的性能差异的 30%。

你可能想知道为什么 PartialEq 的默认实现会导致糟糕的代码生成,PR 上的一条评论 指出了 Rust issue #140167,该问题跟踪了完全相同类型的问题。

如果你考虑 C 的情况,当使用 struct { int16_t y, x; } 时,可以只初始化 y,而将 x 保持为未初始化状态。只要使用 this.y == other.y && this.x == other.x 检查相等性,并且所有 y 都不同,你就不会得到任何 UB。

因此,除非代码可以保证所有字段始终都被初始化,否则将其优化为单个内存加载和比较是无效的。但是,引用 @hanna-kruppe 在此问题上的评论

这不仅仅是一个错过的优化机会。虽然第二个字段的加载不能加载 poison/undef,但该属性是控制相关的。……解决这个问题似乎很困难:我不认为 LLVM 有办法表达“通过这个指针加载总是读取初始化的字节”。

总结

使用来自 samply 分析器的一些分析器快照,我们将 rav1ddav1d 在同一输入文件上的运行进行了比较,看到了 6 秒(9%)的运行时差异,并找到了两个相对容易优化的点:

  1. 避免了在 Arm 专用热代码路径中进行昂贵的零初始化 (PR),将运行时提高了 1.2 秒(-1.6%)。