EBU R128音频响度分析从C到Rust的移植

2020-09-23 03:28:47

在过去的几周里,我将libebur128C库移植到了Rust,两者都使用了正确的Rust API和100%兼容的C API。

这篇博客文章将被分成4个部分,将在接下来的几周内发表。

如果您只对代码感兴趣,可以在giHub和crates.io上的ebur128板条箱中找到。

Ebur128机箱的初始版本是围绕libebur128C库构建的(为了便于构建,还包含了它的代码),版本0.1.2,更新版本是纯Rust实现。

Libebur128执行EBU R128响度标准。Wikipedia页面对该标准进行了很好的总结,但简而言之,它描述了如何测量音频信号的响度,以及如何将其用于响度归一化。

虽然这在直觉上听起来不是很复杂,但有很多小细节(比如人的耳朵实际上是如何工作的)使得这并不像人们想象的那样容易。这导致了有许多不同的方法来测量响度,这也是引入这一标准的原因之一。当然,这也不是唯一的标准。

Libebur128也是我在GStreamer响度归一化插件中使用的库,我几周前已经写过关于它的文章。通过将底层响度测量代码移植到Rust,该插件仅剩的C依赖项就是GStreamer本身。

除此之外,它也被FFmpeg使用,但它们包括自己修改的副本,以及许多其他需要某种响度测量但没有使用ReplayGain的项目,ReplayGain是另一个针对相同问题的较旧但广泛使用的标准。

在详细介绍我所做的工作之前,让我先解释一下我为什么要做这项工作。Libebur128是一个工作良好的库,广泛使用了很长一段时间,目前可能没有bug,而且已经可以很好地使用Rust的C实现了。这就是最初版本的ebur128板条箱所做的事情。

我这么做的主要原因很简单,因为它看起来像是一个有趣的小项目。经常更改的代码不是很多,所以一旦移植,它应该或多或少就完成了,与C版本保持同步应该不会有太多工作。在最初发布基于C的ebur128发行版之后,我就开始考虑这样做了,但是在阅读了Joe Neeman关于将另一个C音频库(RNNoise)移植到Rust的博客文章后,这给了我最后的动力,让我真正开始移植代码,并一直坚持到移植完成。

但是,不要四处走动,要求其他人用Rust重写他们的项目(不要粗鲁),或者认为您自己的重写会神奇地比现有实现快得多,错误也少得多。虽然Rust将您从一大类可能的错误中解救出来,但它并不能将您从您自己中解救出来,而且通常会重写包含原始实现中不存在的错误的代码。与其他所有语言一样,要想在Rust中取得好的表现,还需要付出一些努力。在重写任何软件之前,请现实地考虑这次重写的目标,以及实际完成它所需的努力。

除了好玩之外,还有一些技术和非技术的原因让我去研究这个问题。我在这里只列出两个(好奇心和便携性)。我将跳过通常的Rust内存安全参数,因为对于这段代码来说,这似乎不那么重要:C代码被广泛使用了很长一段时间,没有太多更改,并且很容易遵循内存访问模式。虽然它肯定有一个内存安全漏洞(见上文),但它很难触发,在此期间它被修复了。

就我个人而言,在我所在的“中心”公司,我们会尝试在“锈”有意义的地方做任何新的项目。虽然这在过去运作得很好,我们取得了很好的结果,但对于未来的项目,我有一些问题想要得到一些答案,硬数据和个人经验。

将C代码库函数逐个移植到Rust,同时保持一切正常运行有多难?

对于低级媒体处理代码,使用惯用的Rust代码获得相同或更好的性能有多难?

结果代码的大小是多少?Rust的高级概念(如迭代器)是否有助于保持代码的简洁性?

使用相同的API和ABI在Rust中创建C兼容库有多难?

我已经对所有这些问题有了一些答案,但之前关于这方面的工作没有很好的结构,结果也没有记录在案,我现在正试图在这里改变这一点。既是为了给自己将来提供参考,也是为了说服其他人,对于这样的项目来说,铁锈是一个合理的技术选择。

正如您可以看到的,这些问题的一般模式是将Rust引入现有的代码库,用Rust替换现有的组件,并用Rust编写新的组件,这也与我在Rust GStreamer绑定方面的工作有关。

C是一种非常古老的语言,虽然有一个标准,但每个编译器都有自己的怪癖,每个平台在C标准定义的最低限度之上都有不同的API。C本身是非常可移植的,但是编写可移植的C代码并不容易,特别是当不使用像Glib这样的库时,它隐藏了这些差异并提供了基本的数据结构和算法。

当C的可移植性作为反对Rust的论据时,这似乎是经常被遗忘的东西,这就是为什么我想在这里特别提到这一点的原因。虽然基本上到处都可以得到C编译器,但编写在任何地方都能很好运行的C代码则是另一回事,而C语言在设计上并不容易做到这一点。另一方面,根据我的经验,铁锈使编写可移植代码变得相当容易。

在实践中,我对这个代码库有三个具体的问题。铁锈在这里的大部分优势是因为它是一种新的语言,不需要背负太多的历史包袱。

数学常量实际上不是任何C标准的一部分。虽然大多数编译器只在math.h中定义了M_PI(用于π)、M_E(用于𝖾)等,但是它们是由POSIX和UNIX98定义的。

微软的MSVC没有,但是在包含math.h之前,您必须使用#DEFINE_USE_MATH_DEFINES。

虽然这本身不是一个大问题,但它很烦人,而且确实导致ebur128锈箱的初始版本不能用MSVC编译,因为我忘了这一点。

同样,哪些数学函数可用在很大程度上取决于目标平台以及支持哪个版本的C标准。这方面的一个例子是用于计算以10为底的对数的log10函数。出于可移植性的原因,libebur128没有使用它,而是通过自然对数(ln(X)/ln(10)=log10(X))来计算它,因为它只在POSIX和C99以后的版本中可用。虽然C99来自1999年,但仍然有许多编译器不完全支持它,直到最近,最突出的仍然是MSVC。

由于浮点数的原因,使用log10而不是自然对数会更快、更精确,这就是Rust实现使用它的原因,但在C中,它需要在构建时检查函数是否可用,这会使构建过程变得复杂,并且很容易被遗忘。Libebur128决定不考虑这些并发症,干脆不使用它。因此,Rust实现中的一些条件代码是必要的,以确保两个实现在测试中返回相同的结果。

Libebur128使用基于链表的队列数据结构。由于C标准库非常小,因此不包括集合数据结构。但是,在BSD和带有GNU C库的Linux上,sys/queue e.h中有一个可用的库。

当然MSVC没有,其他编译器/平台可能也没有,所以libebur128包含了该队列实现的本地副本。现在,在构建时,必须决定是否有可用的系统实现,或者使用内部版本。或者干脆总是使用内部版本。

将基本数据结构和算法的实现复制到每个单独的项目中既难看又容易出错,所以我们可能不要这样做。C没有标准化的依赖项处理机制对此无济于事,不幸的是,这就是为什么这在C项目中非常常见的原因。

线程安全的一次性初始化是C标准没有定义的另一件事,根据您的平台的不同,有不同的API可用于它,或者根本没有API可用。POSIX再次定义了一个广泛可用的,但是您不能真正无条件地依赖它。

这会使代码和构建过程复杂化,因此libebur128根本没有这样做,而是在每次创建新实例时对一些全局数组进行一次性初始化。这可能很好,但是有点浪费,而且严格地说,根据C标准,实际上不是线程安全的。

Ebur128 Rust机箱的初始版本只需使用Rust标准库提供的API执行一次初始化,即可避开此问题。有关这方面的更多详细信息,请参阅本博客文章的第2部分和第3部分。

Rust端口只需要一个Rust编译器,混合的C/Rust代码库至少需要一个C编译器和某种C代码编译系统。

Libebur128使用cMake,这将是一个额外的依赖项,因此在ebur128板条箱的初始版本中,我通过Cargo的build.rs构建脚本和cc板条箱构建libebur128非常容易。这是可行的,但是构建脚本对于将Rust代码集成到除Cargo之外的其他构建系统是有问题的。

铁锈端口还在各地使用条件编译。与使用预处理器的C不同,非标准化和不一致的Platform#定义了需要以自定义方式将所有内容集成到构建系统中,Rust有一个原则性的、设计良好的方法来解决这个问题。这使得代码更容易保持整洁,更易于维护,更便于移植。

除了构建与系统相关的简化之外,由于没有任何C代码,将代码编译到其他目标(如WebAssembly,这是Rust本地支持的)也容易得多。也可以将C编译为WebAssembly,但是让两个工具链彼此一致并生成兼容的代码似乎并不是很容易。

如上所述,代码可以在GitHub和crates.io上的ebur128机箱中找到。

当前版本的代码会产生与C版本完全相同的结果。这是由QuickCheck测试强制执行的,QuickCheck测试在两个版本中运行随机输入,并检查结果是否相同。该代码还成功通过了EBU响度测试集中的所有测试,因此只要测试实现没有错误,就有望符合标准。

就性能而言,Rust实现至少与C实现一样快。在某些配置中,它的速度要快几个百分点,但可能还不够,以至于它在实践中确实很重要。这两个版本都有不同配置的各种基准测试。基准是以标准板条箱为基础的,该标准使用统计方法来给出尽可能准确的结果。Criteria还以图形生成良好的结果,使结果的分析更加令人愉快。有关更多详细信息,请参阅本博客文章的第3部分。

为Rust编写测试和基准测试比用C编写测试和基准要容易得多,感觉也更自然,所以Rust实现现在已经很好地覆盖了不同的代码路径。特别是,由于Cargo和Rust有内置的支持,所以不需要像在C中那样纠结于构建系统。单单这一点似乎就有可能导致锈色代码比用C编写的类似代码具有更好的平均质量。

还可以使用强大的Cargo-c工具将Rust实现编译成C库。这可以轻松地将代码构建为静态/动态C库,并安装库、C头文件和pkg-config文件。有了这一点,Rust实现是对C libebur128的100%替代。甚至不需要重新编译现有代码。有关更多详细信息,请参阅本博客文章的第4部分。

除了Rust标准库之外,Rust的实现还依赖于另外两个小而广泛使用的板条箱。与C不同的是,对于铁锈和货物,依赖外部依赖关系相当简单。有问题的两个板条箱是。

Smallvec用于动态调整大小的向量/数组,这些向量/数组可以存储在堆栈上,直到达到一定的大小,然后才能回退到堆分配。这允许在正常使用情况下避免两个堆分配。

位标志,它提供用于实现正确键入的位标志的宏。它用在main类型的构造函数中,用于选择应该启用的特性和模式,它直接映射到C API的工作方式(只是类型安全性较低)。

在宣布某些C库的Rust移植时,一个常见的问题是需要多少不安全代码才能达到与C代码相同的性能。在这种情况下,在FFI代码之外有两种使用不安全代码的方法来调用测试/基准测试中的C实现和C API。

真峰值测量是使用重采样器将音频信号上采样到更高的采样率。作为重采样器最内部循环的一部分,使用静态大小的环形缓冲器。

作为环形缓冲区的一部分,需要显式索引切片。虽然已经手动检查了索引以便在需要时进行回绕,但Rust编译器和LLVM无法确定这一点,因此在编译后的代码中存在额外的边界检查和死机处理。除了使用附加条件减慢循环的速度外,死机代码还会导致整个循环的优化效果较差。

因此,为了解决这一问题,出于性能原因,使用了不安全的片索引。虽然现在需要人工检查代码的内存安全性,而不是依赖编译器,但所讨论的代码足够简单和小,在实践中应该不会成为问题。

不安全代码的另一个用途是应用于传入音频信号的滤波器。在x86/x86-64上,MXCSR寄存器临时将_MM_FLUSH_ZERO_ON位设置为将非正规浮点数刷新为零。也就是说,作为任何浮点运算的结果的反规格化(即非常小的接近于零的数字)被认为是零。

这既是出于性能原因,也是出于正确性原因。对反规格化的运算通常比对规格化浮点数的运算慢得多。在这种情况下,这对性能有可衡量的影响。

同样,正如C库所做的那样,不将去规格化刷新为零会导致略有不同的结果。虽然这种差异在实践中无关紧要,因为它非常非常小,但它会使比较两种实现的结果变得更加困难,因为它们将不再那么接近。

这样做会影响在设置该位时发生的每个浮点操作,但因为这些只是该机箱执行的浮点操作,并且保证在离开过滤器之前再次取消设置该位(即使在死机的情况下),所以这应该不会对其他代码造成任何问题。

一旦移植了C库并且性能与C实现相当,我很快就检查了C库上报告的问题,以检查是否有任何有用的功能请求或错误报告可以在Rust实现中实现/修复。有三个,其中一个我还想在未来的项目中使用。

出于兼容性原因,目前还不能通过C API提供任何新功能。

对于这个,C库已经有了PR。以前,重置所有度量的唯一方法是创建一个新实例,这涉及到新的内存分配、过滤器初始化等。

提供一个Reset方法非常简单,只需做最少的工作就可以重置所有度量并以新状态重新启动,所以我已经将其添加到Rust实现中。

这是不久前在C实现中引入的一个错误,目的是在计算内存分配大小时防止整数溢出,这会导致内存安全错误,因为分配的内存比预期的要少。意外地,此修复程序过多地限制了最大窗口大小的允许值。在C实现中有用于修复此问题的PR。

在铁锈方面,这个bug也存在,因为我只是简单地移植了检查。如果我没有移植检查,或者移植了没有检查的更早的版本,幸运的是铁锈端不会有任何内存安全错误,相反,会发生以下两种情况中的一种。

在调试构建中,整数溢出会导致死机,因此在参数设置期间不会分配比预期更少的内存,而是会立即发生死机,而不是稍后进行无效的内存访问。

在发布版本中,出于性能原因,整数溢出只是简单地绕回。这会导致分配的内存少于预期,但是稍后尝试访问内存时,在尝试访问分配区域之外的内存时会出现死机。

虽然恐慌也不是好事,但它至少不会导致不明确的行为,并防止更糟糕的事情发生。

在这种情况下,正确的修复方法是不静态地限制最大窗口大小,而是在计算期间检查是否有溢出。这与C实现的PR所做的相同,但是在Rust端,这要容易得多,因为有一些内置的操作,如用于执行溢出检查乘法的check_mul。在C语言中,这需要一些相当复杂的代码(有关详细信息,请查看PR)。

我实现的最后一个附加功能是对平面音频输入的支持,对于这一点,C实现的PR也已经存在。

大多数时候,音频信号的每个声道的采样相互交错,因此例如对于立体声,您有一个采样数组,第一个采样用于左声道,第一个采样用于右声道,第二个采样用于左声道,依此类推。虽然这种表示法有一些优点,但在其他情况下,使用平面音频会更容易或更快:每个声道的样本都是一个接一个连续的,因此,例如,您首先有左声道的所有样本,然后才有右声道的所有样本。

C实现的PR通过现有宏代码的一些代码复制来做到这一点(这可以通过使宏变得更复杂来防止),在RUST方面,我通过添加交错/平面音频的内部抽象并迭代样本,然后在普通的泛型RUST代码中使用该抽象,在没有任何代码复制的情况下实现了这一点。这需要一些较小的重构和代码重组,但最终相当轻松。请注意,大部分更改是添加了新的测试并移动了一些代码。

当查看这种重构的主要部分Samples特性时,您可能会想知道为什么我使用闭包而不是Rust迭代器来迭代示例,不幸的是,原因是性能。在这篇博客文章的第3部分中有更多关于这方面的信息。

在这篇博客文章的下一部分中,我将详细描述移植方法,并给出如何将C代码移植到惯用Rust的各种示例,以及我遇到的一些问题示例。