SIMD 增强的 libc 字符串函数实现¶
标题:SIMD-enhanced libc string functions: how it’s done
日期:2025/06/11
作者:Robert Clausecker
链接:https://www.youtube.com/watch?v=kbSFSxHUmN4
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
是的,大家好,欢迎来到我的演讲。我是 Robert Clausecker。大约两年前,我为 FreeBSD 基金会做了一个项目,使用汇编和 SIMD 技术优化了所有 libc 的字符串函数。在这次演讲中,我想向大家展示其中遇到的问题,以及可以期待什么样的性能提升。也许这些技术对于处理其他像字符串处理一样棘手的任务也会有用。
那么,我们用字符串做什么呢?在座有多少人写过 C 语言?可能大部分都写过吧?是的。所以你们都知道,如果使用 string.h
里的基本字符串函数,你可以复制字符串、获取字符串长度、查找字符、比较字符串、查找子字符串、按分隔符拆分字符串,你也可以把这整个东西当成一团燃烧的垃圾扔进垃圾桶。你可以对字符串做很多不同的事情,但它们基本上都归结为一些非常简单的原语。
在大多数情况下,你想要读取一个字符串,然后可能想把它写到别的地方。或者你可能想读取一个字符串,然后将每个字符与别的东西进行比较。还有一些特殊情况,比如查找子字符串,这非常复杂,有很多相关的研究。按分隔符拆分也是一个我们只会简要提及的案例,因为它需要一个特殊的集合匹配操作,这用 C 语言也是可以做到的。
那么这些操作意味着什么呢?如果我们想读取一个字符串——这里我们讨论的是 C 语言字符串——我们必须一个字符一个字符地读取。因为我们只有看到空字节(null byte)时才知道字符串在哪里结束。而这个空字节可能在任何地方。所以从技术上讲,我们必须逐个字符读取,并对每个字符检查:这是空字节吗?不是。这个是空字节吗?不是。那个呢?不是。但总得有个空字节在某个地方。对于写入,情况也类似。我们必须一个字符一个字符地写,直到写完整个字符串。比较操作看起来也差不多。在很多情况下,我们每个字符都会有一个分支(branch),而计算机并不喜欢分支,尤其是在短时间内连续出现的分支。所以,所有这些操作都很慢。
我们能从中得出什么结论呢?字符串糟透了。它们太可怕了。
但我们能做些什么呢?嗯,我们可以尝试设计一个不需要字符串的操作系统。嗯,有人尝试过,在很多嵌入式应用中,我们或许可以不用字符串。我们可以请求 CPU 制造商在 CPU 中加入一些特殊指令,你知道的,让字符串处理变得更快。我听说他们在大型机(mainframe)上就是这么做的。当然,很多 CPU 上确实有一些加速指令。如果你写过 x86 汇编,你可能知道有一系列的字符串指令,但通常大家都会避开它们,因为它们实际上太慢了。这是一个大问题。一些 CPU 制造商有这样的特殊指令,但这些指令并不是他们优化的重点。所以,通常你可以用更好的编程技术来超越它们的性能。
有两个函数通常会使用 CPU 加速,那就是 memcpy
和 memset
,即批量数据复制和清空缓冲区,因为这两个操作太常见了,CPU 制造商们确实很在乎,并且会把它们做得很快。也许我们可以用一些奇怪的黑科技来代替。
那么我们能做什么呢?让我们引入 SIMD。在座有多少人之前用 SIMD 编程过?哦,相当多。SIMD 的思想是这样的:在 90 年代,人们意识到让计算机运行得更快变得非常困难。但有一件事你可以做,就是让它们每条指令做更多的工作。最简单的方法就是你把四个数字放进一个寄存器里,然后让计算机对这四个数字做同样的事情。砰!四倍的性能。超级简单。只需要多加几个算术逻辑单元(ALU)就行,这并不复杂。复杂的是让解码器更快,提高时钟频率,但仅仅增加并行度并不是特别难。
所以,现在基本上所有的计算机都有 SIMD,它被用于各种场景,比如视频解码、数据压缩、数据加密,以及所有涉及数学运算、处理数字的事情。SIMD 的核心是我们有一个数据向量,通常我们想对向量中的所有元素执行相同的操作。如果我们这样做,这个操作的速度就和只对一个数字操作一样快。所以直观地看,如果我们处理一个 16 字节的向量——这是 x86 架构支持的最低级别 SIMD,也是 ARM 架构支持的标准级别 SIMD——那么我们可以获得的性能可能高达标量(scalar)性能的 16 倍,因为我们每条指令做了 16 倍的工作。这是我们使用 16 字节 SIMD 所能期望的性能极限。
我们可以这样想。如果我们有一个标量操作,想要把两个数组相加,那么我们必须为每个元素做一次加法。但使用 SIMD,我们只需要一个大的加法指令,它花费的时间和之前那些小的加法一样长。
那么 SIMD 指令集能做什么呢?它们可以进行整数和浮点数的算术运算。它们可以做加、减、乘,通常没有除法,但这不那么重要。它们可以做某种逻辑运算。你通常可以做类似逐元素比较的操作。你可以做位运算的与、或、异或、同或、非等等。你可以进行数据传输。你可以从内存读取、写入内存。有时你可以做一些事情,比如将每个向量元素的最高位提取到一个掩码(mask)中,然后传输到标量寄存器。这是我们进行字符串操作时非常需要的一个功能。
还有一些非常复杂的,我们称之为“水平”指令,其中元素不是各自做同样的事情,而是协同工作来完成某项任务。例如,有归约(reduction)指令,可以对向量的内容求和。或者一个常见的例子是置换(permutation)指令,它接受一个索引向量和一个被当作数组的向量,然后使用这些索引从数组中选择元素。还有很多其他的功能。近年来,人们想出了许多创造性的方式来扩展 SIMD 指令集。如果你去看最新的指令集,比如 AVX-512,它们能做的神奇事情会让所有这一切变得容易得多。但问题是,几乎没有人拥有一台支持这些最新指令集的电脑。所以在这个项目中,我们决定坚持使用保证可用的东西,那就是 SSE2,它已经很古老了。但事实证明,对于这个问题来说,它已经相当不错了。
那么我们该怎么做呢?我们如何使用 SIMD 来处理字符串?看起来我们希望能一次性加载多个字符,然后一次性处理它们,也许就完成了。但事情没那么简单,因为我们有那个空字符在字符串末尾,而且我们不能越过空字符读取。所以看起来我们至少被迫要一直读,直到看到一个空字节,然后才能并行工作。但是,当我们逐字符读取时,我们本可以在读取的同时就把工作做了。
这里有一个例子来说明问题可能是什么样的。假设你有一个字符串,比如 “foo bar bass”,末尾有一个空字节。然后这里我们的向量长度是 4 个字节。所以我们读取 4 个字节,一切正常。我们再读取 4 个字节,一切正常。我们再读取 4 个字节,现在我们超出了字符串的范围。通常这被认为是一个错误,比如缓冲区溢出读取(overreading a buffer)。这听起来不太好。计算机会崩溃。字符串后面可能紧跟着未映射的内存。静态分析工具也不喜欢这样。我们稍后会讨论这个问题。
我们如何避免这种情况?主要问题是,我们似乎可能会超出缓冲区的范围,这可能会因为存在未映射的内存而导致程序崩溃。但也许我们可以通过认识到计算机实际上并不知道字符串从哪里开始或结束来避免这个问题。它不在乎。这只是我们作为程序员编造出来的东西。我们决定有一个字符串,一个对象,它从这里开始,到那里结束。这只是一个高层次的概念。在汇编层面,我们并不真的需要遵守这个概念。
所以我们必须克服字符串边界的限制,能够在不使计算机崩溃的情况下超读(overread)对象。核心思想是,计算机不知道什么是错误,而计算机实际知道数据是否存在的主要方式是通过分页(paging)。因此,如果我们确保我们不会越界太多,比如保持在同一个页面(page)内,那么就不会崩溃。因为计算机不知道字符串在页面结束之前早就结束了。它只知道页面在某个地方结束。所以只要我们待在同一个页面上,一切都会没问题。
我们将用汇编来完成所有这些工作,因为如果你读 C 语言规范,它有非常明确的语言描述这里的未定义行为,那里的未定义行为。而我不喜欢未定义行为,所以我们将使用一种没有未定义行为的语言。我们将使用汇编。这也能稍微提高一点性能,因为编译器对于如何生成代码有它自己的看法,有时这些看法和我的不一致。这样优化起来更容易。
有一些例外情况这种方法行不通。例如,有 CHERI,你们可能听说过。这是一个用于细粒度内存保护的研究项目。我们这次演讲不涉及 CHERI,但在 CHERI 上这种方法行不通。实际上,如何在 CHERI 上实现这一点还是一个悬而未决的研究问题。我未来可能会研究这个问题。
情况是这样的。我在这里用竖线画出了页面边界。你可以想象我们的字符串作为一个对象从页面内的某个地方开始。然后我们知道这个页面一直延伸到那里。所以现在我们可以假装,好的,如果我们知道了页面边界,我们就可以假装这个对象一直延伸到页面边界。现在,如果我们以对齐的方式读取字符串,而不跨入我们不知道是否属于字符串的页面,一切就都正常了。
所以我们可以读取第一个块。我们没有看到空字节。所以我们知道会有第二个块,我们可以跨到下一个页面。读取下一个块。我们没有看到空字节。我们知道还会有另一个块。我们可以跨到下一个页面。我们跨到下一个页面,我们看到,哦,这里有一个空字节。所以字符串在这里结束。我们完成了。当然,我们必须做一些特殊的处理来忽略字符串前后部分的垃圾数据。但这相对容易做到。
所以,这就是我们并行处理字符串的基本思想。我们执行与字符串有某种交集的对齐操作,因为如果我们进行一次对齐读取,其中至少有一个字节已知是某个对象的一部分,它就不可能崩溃,因为一个页面要么是完全映射的,要么是完全没有映射的。没有部分映射的页面。
这对读取字符串来说行得通,但写入字符串要困难一些。因为超读一个缓冲区(overread)在某种程度上是无害的,但超写一个缓冲区(overwrite)则非常危险,因为字符串后面的那个字节可能属于另一个对象。我们不希望写入另一个对象。有人可能会说,但我们不能 просто,你知道的,读取那个对象,然后把相同的字节写回去吗?嗯,理论上,这可能行得通。但在实践中,我们有并行的操作系统和并行的程序。那个字节可能属于一个被另一个线程访问的对象。如果我们读取一个字节然后写回去,我们实际上制造了一个数据竞争(data race),因为另一个线程可能在此期间试图修改那个对象,而那个更新就会丢失。所以,确保我们不写入任何不属于该对象的字节是非常重要的。
所以我们的做法是,我们进行非对齐写入。我们正常地写入字符串。在末尾,我们只做一个重叠写入,这个写入会覆盖前一个字节,只是为了覆盖最后那几个比特。关于这一点有不同的策略。也可以只进行对齐写入,然后用字节写入来覆盖开头和结尾部分。这在某种程度上取决于你为哪个架构编程。在大多数现代计算机上,你可以进行非对齐的 SIMD 写入,它们工作得很好。它们并不比常规写入慢多少。事实上,因为它们是流水线化的,所以实际上根本没有减速。但在某些架构上,非对齐写入是通过陷阱(trap)来模拟的,或者根本不支持。在这些架构上,你必须采取一些特殊的预防措施,并以不同的方式设计。所以,结果可能会因人而异。
情况是这样的。假设我们想把字符串复制到这个新缓冲区。我们从写入开头开始,写入下一个块,然后对于最后一个块,我们只是覆盖一点前一个块的内容。这是允许的。我们只是把其中一个字节写了两次。在某些微架构上,先写最后一部分是有益的。因为如果你做一个重叠写入,有时这会混淆存储缓冲区(store buffer),导致一次停顿。所以如果你先写最后一个块,存储缓冲区就有时间清空字符串,也就是在重叠写入到来之前“最终确定”这次写入。所以这不是问题。
一个更棘手的问题出现在比较字符串时。到目前为止,我们所做的事情只涉及一个字符串。我们读取一个字符串,也许把它写回内存。这很简单。但是当你比较字符串时,你面临的问题是两个字符串可能有不同的对齐方式。一个字符串可能是对齐的,而另一个可能是未对齐的。所以如果你读取两个字符串的对应块来进行比较,这是行不通的。因为其中一次读取将是非对齐的,而我们那个为了避免跨入未映射页面而从不进行非对齐读取的技巧突然就失效了。所以我们必须更聪明一点。
在某些架构上,你可以说,好吧,我们进行非对齐加载,然后用一些移位和置换指令把它们拼接起来。这在更新的 SIMD 架构上是可行的。但是我们使用的 SSE2 实际上没有可变移位指令。所以如果你想移动一个字符串,你需要预先知道移位的量。但我们不知道,因为我们不知道它们之间的相对未对齐量。实际上,英特尔有一个字符串比较函数的实现,它就是针对 16 种可能的相对未对齐情况分成了 16 个不同的分支。这绝对是丑陋的。但它确实能工作。它很慢,很丑陋,但它能工作。我为此给他们点赞。
那么我们做什么呢?我们对每块输入总共进行三次读取。我们进行对齐读取来检查空字节。一旦我们知道字符串延伸得足够远,我们就进行一次非对齐读取,这个读取对应于另一个字符串中的一次对齐读取,以比较它们是否相等。
情况是这样的。假设我们这里有两个字符串。如你所见,它们是相互未对齐的。一个字符串未对齐了 2 个字节,另一个未对齐了 1 个字节。所以我们首先检查字符串的开头,确保字符串不会在开头就结束。一旦我们知道了这一点,我们就知道两个字符串在这种情况下都跨入了另一个页面。所以我们就可以比较它们的开头部分是否相等。一旦我们完成了这个,我们就可以检查下一个块,以确保字符串确实延伸得更远。然后我们就可以用第一个字符串的一个对齐块和第二个字符串的一个非对齐块进行比较。这看起来非常复杂。它确实非常复杂。但如果你盯着这个图足够长的时间,它实际上是可以理解的。
然后我们就这样继续下去。我们再进行一次读取。在这种情况下,我们意识到,好的,字符串的末尾到了。所以我们无论如何都必须在某种情况下中止。我们进行比较,我们看到,好的,两个字符串在同一个地方结束。我们完成了。这是可行的。它实际上相当快。ARM 的人也使用了类似的方法,但设计略有不同。你也可以通过在处理空字节的同时跟踪字符串长度,来将此方法扩展到计数型字符串。
当字符串非常短时,会出现一个问题。例如,在这种情况下,我们有一个只有三个字节的字符串。所以无论我们做什么,我们都无法在一开始就从两个字符串中进行对齐加载。因为对于第二个字符串,我们知道它在一个页面结束之前就结束了。如果我们从它的开头进行一次对齐加载,它无论如何都会跨入某个未映射的页面。所以这非常烦人,而且有点难以修复。我们的做法是,我们将字符串的开头部分复制到栈上的一个临时缓冲区(bounce buffer),然后从这个临时缓冲区进行非对齐加载。这有点慢,但它只发生在非常罕见的情况下,即非常短的字符串正好位于页面末尾。所以你可以把额外的开销分摊到这些罕见情况中去。
另一个有趣的案例是集合匹配,比如 strcspn
。这个函数接受一个字符串和一个分隔符集合,然后返回第一个出现的分隔符的位置。这是构建像可怕的 strtok
或不那么可怕的 strsep
这样的分词函数的基本构件。这非常复杂,因为你必须将字符串中的每个字符与集合中的所有字符进行比较。一些架构有专门的指令来做这件事,这也是我们下一节要用的。在一般情况下,有一位波兰计算机科学家 Wojciech Müller 和 Hyperscan 项目的 Jeff Langdale 开发了一种 SIMD 技术来并行处理它。这有点疯狂,但如果你有专门的指令,速度会更快。
我们将要使用的少数几个专用指令之一是英特尔的 p-cmp-i-str-m
指令,即 “packed, compare, implicitly terminated string, return, mask”(打包、比较、隐式终止字符串、返回掩码),它几乎可以做你想要的一切。这个指令有大约 256 个选项来配置这个功能。它需要 10 个周期。它的微码(microcode)绝对疯狂。但基本上,它可以用一条指令完成集合匹配。所以在英特尔平台上,我们用它来做集合匹配。它仅仅比使用 Müller-Langdale 算法快一点点,而后者是我们会在其他架构上使用的方法。
子字符串匹配可以说是最后的战场。用 SIMD 来做这件事非常棘手,因为子字符串匹配的性能真正来源于能够知道子字符串不可能在某个位置,因此我们必须跳到更远的位置。这真的很难。我们还遇到了一个问题,就是所有那些复杂的算法都是为非常长的字符串优化的。而字符串处理中一个反复出现的问题是,程序中出现的字符串通常都非常短。
谷歌做过一项研究,他们对自己公司的 libc
进行了检测,用来统计传递给所有基本字符串操作的字符串长度。他们发现平均字符串长度大约是……我想如果我没记错那项研究的话,是 20 个字符左右。所以,对于我们所有的函数来说,对短字符串有出色的性能是至关重要的。这和你通常优化的方向恰恰相反。如果你写 SIMD 代码,你通常期望的是一个巨大的数据数组,你可以花很长时间来启动算法并设置好一切,因为热循环(hot loop)会占据 99.5% 的时间。但对于字符串,我们的热循环可能只运行一两次,因为字符串通常只有一个或两个向量的数据长度。所以即使对于短输入,它也必须很快。如果你使用一个花哨的字符串比较算法,那么,如果你的字符串在 20 个字符后就结束了,建立复杂的数据结构就没有任何意义。这完全没道理。所以我们还没有完全解决这个问题,我将来会进一步研究。
这里是一些在 FreeBSD 操作系统上的性能数据。黄色的是新的 SSE 实现。蓝色的是通用的 C 代码。橙色的是一个标量(非 SIMD)实现。我受委托做一个向量化实现,以及一个不使用任何 SIMD 的实现,因为在一些操作系统中,内核在进入时会关闭 SIMD。如果你在程序中完全不使用 SIMD,那么上下文切换会快一点。所以他们希望有不使用 SIMD 的选项。所以大多数函数也有一个标量版本。如果你没有看到蓝色的条,那意味着已经有别人写的旧的标量汇编代码,我懒得重做了。
如你所见,SIMD 实现通常快得多。事实上,我们平均性能提升了 5.5 倍。所以它比旧代码快了 5.5 倍。这也与 glibc
的实现相比有优势。当然,glibc
对所有这些字符串例程也有 SIMD 实现。但他们犯了一个,我会说是,错误,就是他们为长字符串进行了优化。所以如果你看 glibc
的代码,它有非常长的内核,一次可以处理 512 字节的字符串。这很酷,而且对于长字符串来说,它确实比我的例程快得多。但没人有那么长的字符串。这根本不重要。对于实际用例来说,这并不重要。
(观众提问)你这个图表用的是什么长度的字符串?
对于这个图表,我们使用的平均字符串长度是 64 字节,并且是指数分布的。所以我们有一个平均值为 64 字节的指数分布,如果我没记错的话。
后来,在 Gatz Mikkelsen 的帮助下,我们将这些成果移植到了 AArch64 架构。也许 Gatz 你可以挥挥手。是的,这位就是 Gatz。祝贺 Gatz。这是去年一个 Google Summer of Code 项目的一部分。Gatz 把所有这些例程都移植到了 ARM 上。在 ARM 平台上,ARM 公司已经开发了他们自己的字符串例程,我们做的这个移植算是对 ARM 例程的一个补充。所以,当 ARM 的快速字符串例程已经有一个快速实现时,我们就直接用那个,如果没有,我们就会加入我们自己的实现。在某些情况下,我们能够超越 ARM 的代码,做得更快。而在其他情况下,我们发现 ARM 虽然有某个函数,但没人费心去把它实际接入使用。所以最终,我们设法覆盖了一堆函数。
其中唯一一个你可能不熟悉的函数是时间安全(timing safe)函数。这些函数就像 memcmp
,它们比较两个缓冲区是否相等或有序,但是它们是时间安全的,也就是说无论输入是什么,它们总是花费相同的时间。这对于像比较两个密码是否相等这样的事情很重要,因为你不希望人们能通过时间侧信道(timing side channel)一个字符一个字符地猜出密码。
我们还能够通过利用这些新的原语来包装一些高层函数,比如 strpbrk
或 strsep
以前是用 C 代码循环实现的,我们只是把它们重新实现为调用快速的汇编函数,因为它们不那么常用,这样你就可以利用快速代码而无需再写一个汇编文件。
在 AArch64 上有一些问题。最重要的是,很难得到我们所说的“综合征位掩码”(syndrome bit mask)。在很多情况下,你想将一个字符缓冲区与另一个缓冲区进行比较。例如,如果你用 strchar
在字符串中查找一个字符,你有一个缓冲区,里面只包含重复 16 次的同一个字符,另一个缓冲区包含输入的一个块。你进行逐元素比较,然后你会得到一个综合征位掩码,在字符出现的所有位置上都有一个 1。在 x86 上,有一条特殊的指令叫做 pmovmskb
,也就是“packed move-mask of bytes”,它会取向量中每个字节的最高位,并将它们复制到一个标量寄存器中。然后你就可以用标量指令来处理它,做出决定等等。
不幸的是,AArch64 没有这个功能,我们唯一能做的就是用一种叫做“右移并收窄”(shift, write, and narrow)的方法,将字节掩码收窄为半字节(nibble)掩码,即每个条目 4 位,这将一个 128 位的向量变成一个 64 位的向量,这刚好足够放入一个标量寄存器。然后我们可以用一个浮点移动指令把它弄到标量寄存器里,接着我们必须修改我们的代码,让它期望每个条目是 4 位而不是 1 位。这有点复杂,也有点慢,但至少我们能做到。
如果我们只需要一个“零/非零”的判断,还有一些其他的技巧。也就是说,如果你实际上不关心匹配在哪里,只关心是否“有匹配”。这经常被用作循环的退出条件,因为你想尽快地跳出循环,而计算退出条件的代码是热循环的一部分。所以你想先跳出来,然后再做一个耗时较长的计算来得到实际结果。
我们没有像 p-cmp-i-str-m
那样的专用指令。我们使用通用算法。另一个问题是我们实际上无法找到缓冲区内第一个匹配的位置。有一条指令可以找出一个数字末尾有多少个零(trailing zeros),这在 ARM 上不存在。他们只能计算前导零(leading zeros),所以你得通过翻转比特顺序之类的办法来绕过它,做一些不那么疯狂的事情。
我们还研究了将这些代码移植到 SVE 的可能性,这是 ARM 最新的 SIMD 指令集,但这有点挑战性,因为很难找到支持 SVE 的硬件。所以对于家庭玩家来说,很长一段时间里,唯一的选择就是买一个亚马逊的虚拟机,比如 Amazon Graviton 实例,但从长远来看,那就像是把你的信用卡点着了烧。直到今年,才真正有你能买到的支持 SVE 的台式电脑。现在有一款中国产的机器,叫做 Rackstar Orion 06,它支持 SVE,是你能买得起的第一款。一些智能手机的 SoC 也支持,但给你的手机越狱然后在上面搞 SIMD 开发有点烦人。所以,是的。而且,你知道,如果没人拥有 SVE,那么做移植真的有意义吗?这也是一个棘手的问题。
SVE 将会特别有趣,因为它是一个可变长度向量指令集,所以你直到程序开始运行时才知道向量有多长。因此,许多算法将不得不重新设计以应对这种情况,这在某些方面有点棘手,因为一些代码步骤严重依赖于固定的向量长度。
这个项目的当前进展是,我们已经完成了 AMD64 的移植,这是由 FreeBSD 基金会委托并支付的工作。我们设法完成了几乎所有 string.h
中的函数,除了一些罕见的函数。我们没有做宽字符(wide characters),因为宽字符是完全另一个世界。谁喜欢宽字符?是的。我就知道。是的。这部分代码已经随 FreeBSD 14.1 版本发布。我们编写了一个分发框架(dispatch framework),所以你会根据你的 CPU 支持的扩展获得不同的 SIMD 代码。如果你有一台只有基本指令集的老式 AMD64 CPU,你只能得到一部分功能。如果你有新一点的,你会得到一些新的。我们正在考虑将移植扩展到 AVX2,这是更新的指令集,或者是 AVX512 作为补充,但初步工作表明性能优势并不大,因为再次强调,字符串很短,如果你的字符串勉强能放进一个寄存器,那么用一个更长的寄存器就没有意义了。这只意味着更长的寄存器有更多被浪费的空间。AVX512 的主要有趣之处在于我们可以避免一些非对齐加载的繁琐操作,因为 AVX512 有能力对加载进行掩码操作。所以你可以告诉计算机只加载向量中的这几个字节,如果向量中的其他字节在未映射的页面上,也没关系。它不会导致故障。
Michael Gatz 做了到 AArch64 的移植,这将随 15.0 版本发布。来自塞尔维亚的 Google Summer of Code 学生 Strahinja Stanišić 做了到 RISC-V 64 位的移植,其中遇到了一些复杂情况,并且由于硬件可用性差,目前正在进行验收测试,可能会在今年晚些时候发布。
对于未来的工作,我计划可能将它移植到 PowerPC 或者你选择的任何 LibC。所以如果你在写一个 LibC,请告诉我,我会把补丁发给你,你可以集成它们。我很高兴把它们给你。它们是 BSD 许可的。也许我们还会研究是否可能为 Morello 和 CHERI 做 SIMD 字符串处理,它们是基于能力(capability-based)的架构,其中指针本身就强制了对象的边界,这将排除一些优化,但也许有办法利用这个信息来做。我还没有研究过,一个大问题是硬件非常难获得,除非你是这个研究项目的一部分,但未来可能会有解决方案。
好了,这就是我的演讲。全部内容就是这些。我希望你们觉得有趣,现在我准备好回答问题了。
(掌声)
问答环节
我只想指出,把信用卡点着烧是降低成本的好方法。(笑声)
提问者1: 我很好奇,如果你没有空字节,而是有字符串的起始地址和长度(计数型字符串),这会变得更容易还是更糟?
回答: 这是一个非常有趣的问题。问题是,如果你有一个计数型字符串,你知道它的长度,而不是去寻找一个空字节,事情会不会更容易?有趣的答案是,对于许多函数来说,它实际上更慢了。因为如果你有一个计数器,你需要在循环中检查这个计数器。而如果你没有计数器,而是有一个空字节,你需要找到那个空字节。所以,如果你有一个像 strchar
这样的函数,它归结为寻找两个字符:一个空字节或者你正在寻找的那个字符。所以你可以同时与这两个字符进行比较,然后将结果进行“或”运算,然后只根据是否找到其中任何一个字符进行一次分支。这是每次迭代一次分支。
如果你有一个计数型字符串,现在你每次迭代必须做两次分支,因为你必须检查计数器,你必须检查是否找到了字符,并且你必须检查字符串是否已经结束。每次迭代两次分支给 CPU 的分支预测器带来了更大的负载,这意味着你会得到更多的分支预测失误,从而降低性能。我们确实做了一些非常有创意的技巧,我们试图把字符串的结尾变成一个假的匹配,所以当字符串结束时,你向源缓冲区注入一个假的匹配,然后你每次迭代就只有一个分支了。但总的来说,事实证明计数型字符串比空字节终止的字符串要稍微复杂一些。这有点奇怪。
提问者1: 好的,那我就不提 Rust 的事了。
提问者2: 当你谈到对齐那些本身带有空字符的字符串时,难道把它们全部移动一下,让它们都从相同的位置开始,通常不会更有利吗?
回答: 问题是,如果你有一个未对齐的字符串,为什么不直接移动所有字符?嗯,原则上我们很想那么做。问题是 SSE2 没有可变移位指令。所以没有办法取一个字节向量,然后把它们整体向左移动一个可变的数量。有针对固定数量字节的这种指令,但没有针对可变数量的。而为一个跳转表(jump table)——对应 16 种可能的未对齐情况的 16 个不同例程——付出一次分支预测失误的代价是如此之高,以至于你无法承受。我们实际上已经做过原型,它的表现并不好。这基本上就是英特尔那帮人做的事情。所以他们对 16 种可能的相对未对齐情况有 16 个不同的分支,然后他们使用固定移位指令。这真的很糟糕。真的很糟糕。
提问者3: 请原谅一个很久没看过编译器生成代码的老家伙。大概有 35 年左右没在这方面关注了。但在过去,我们试图弄清楚,特别是 libc 和编译器生成的代码之间的交互。我在想,现在我们有了原生的字符串类型等等。所有事情都是交给 libc 处理,还是说你也必须考虑编译器在如何处理内存方面的责任?
回答: 问题是,在现代编译器中,编译器的责任是什么,libc 的责任又是什么?你能对编译器做什么抱有什么样的期望?令我非常惊讶,也有点懊恼的是,GCC 和 Clang 实际上能识别标准的 libc 字符串函数,如果它们对输入有一点了解,它们会尝试对其进行特例处理。例如,如果你做一个字符串复制,并且它知道源是一个固定长度的字符串,那么它会把这个操作变成一个将固定长度字符串块复制到目标缓冲区的操作。编译器能识别并转换成其他代码的特例有很多,这比调用一个例程要快得多,因为我们的库函数对输入一无所知。它必须通过对指针进行位运算,获取字符串长度并与某个值比较来发现关于输入的一切,而这需要时间。如果编译器能在编译时就完成这些,那就快多了。缺点之一是,如果你写一个单元测试来检查你的字符串,然后你用 -O3
选项编译,结果发现你的函数根本没被调用。这挺有趣的。
提问者4: 我的意思是,如果编译器可以把所有东西优化掉,比如在编译时知道常量等等,而在运行时,如果你在内核中运行,你可以有一个 slab 分配器,所以每个分配的字符串都会在 8 字节边界上对齐,或者类似的。这样你几乎永远不会遇到那些边缘情况。
回答: 问题是,如果你有一个像内核中的 slab 分配器,那么你可以期望所有的字符串都是对齐的,所以边缘情况应该很少见。在用户空间也是如此,你也有内存分配器,通常会给你对齐的缓冲区。但事实证明,许多这些字符串函数是在静态字符串上调用的。例如,你有一个命令行参数列表,你想在其中找到某个关键字,所以你用一个关键字进行子字符串搜索,而这个关键字是一个固定的字符串。如果你看过 ELF 二进制文件的 objdump
,你会发现所有的字符串都被连续地转储到一个节(section)中。所以字符串从随机的对齐偏移量开始。人们还喜欢做的一件事是,他们拿一个长字符串,然后从中切出一些部分。例如,如果你有一个像文件系统路径之类的东西,很常见的做法是从路径中切出各个路径组件,用空字节替换斜杠,然后把它喂给某个 libc 字符串函数。所以,字符串对齐是常见情况,但它们不对齐也非常常见。常见到在处理这类字符串时需要特别注意。
提问者5: 我有一个后续问题。静态字符串当然是由编译器或链接器生成的。那么,如今编译器是否有设置让它们也对齐呢?或者这样做的经济效益如何?
回答: 我其实没太明白问题……(提问者补充:如果有很多静态字符串,它们在某些情况下会破坏优化。静态字符串也应该是编译器输出的,那么编译器为什么不对齐它们,或者生成对齐它们的目标数据呢?)是的,编译器通常不会对齐字符串,除非你明确要求它这么做。如果你想一想,在很多情况下,静态字符串并不是性能特别关键的,而且很多都不会被传递给 libc 字符串函数。你看到的最常见的静态字符串类型是像 printf
的格式化字符串,这个字符串是否对齐一点关系都没有,因为 printf
会逐字符处理字符串以寻找格式化指令。我们也曾考虑过改进 printf
,但这非常复杂。所以我不会说让编译器对齐所有这些字符串是个好主意,因为在大多数情况下,这并没有特别大的用处。但也许未来的编译器开发者可以考虑对齐那些传递给已知能从中受益的 libc 函数的字符串。主要的可能是像 strcmp
这样的函数,用来比较字符串,因为这样你就可以避免一些潜在的复杂情况。但最终,编译器仍然调用同一个例程,而例程并不知道字符串是对齐的,所以它无论如何都必须检查未对齐的情况,这意味着好处相当小,因为未对齐检查是无分支的。我们每次都走同样的代码路径,只是在没有垃圾数据时,不去忽略字符串开头的垃圾数据。我不确定,你必须对此进行基准测试,我没有一个具体的答案。
提问者6: 关于对齐和字符串末尾跨越页面边界的问题,我不知道你是否听说过一个叫做“分割锁”(split lock)的问题?如果有,你是否研究过,也许有什么共同的解决方法,因为听起来很相似。
回答: (提问者补充:分割锁是,例如,在 64 位架构上,你有一个锁数据结构或长整型,由于历史原因,它被分割了,4 个字节在一个页面上,4 个字节在另一个页面上。要获取那个锁,你无法一次性得到包含两部分的缓存行。)啊,分割锁问题,是的。嗯,这些字符串函数都不做原子操作,所以你没有分割锁的可能性。(提问者:我是说关于页面边界,因为它们的实现可能类似,为了同时获取两部分。)当你进行一次跨越缓存行(cache line)边界的加载时——这也意味着每个页面边界也是一个缓存行边界——发生的情况是 CPU 会把它分成两次加载,一次用于加载的前半部分,一次用于后半部分。所以你有点像有了一个系数为 2 的加载放大。但同时,如果你进行一系列非对齐加载,加载单元会看到已经有一个针对这个缓存行的加载被调度了,所以它不会加载两次,而是一次性完成两次加载。因为这两个加载是独立的,它们不需要是原子的,所以它并不比从两个不同页面进行两次独立加载慢多少。
如果你试图做一个原子操作,比如原子比较交换操作,这就会成为一个问题,因为那就回到了分割锁的情况,那非常糟糕。我实际上准备了基准测试,检查了各种未对齐情况下的性能,你并不会真的看到什么差别。对于长字符串,当字符串完全对齐时,你确实会看到一点点性能提升,但如果它稍微未对齐,就没什么区别了。对于短字符串,情况复杂,随机性太大,你可能会得到一些差异,但有很高的不确定性,很难说得更具体。
提问者7: 你有没有考虑过你做的超读(overread)所带来的任何安全影响?
回答: 我考虑过,并没有。主要的安全影响是静态分析器不喜欢这个。如果你在这段代码上运行 Valgrind,它会非常困惑。我们通过给 Valgrind 打补丁解决了这个问题。基本上,Valgrind 知道要忽略这些函数。这是解决虚假静态分析问题的最好方法,你直接跟你的 Valgrind 负责人说,这东西会成为 libc 的一部分,你最好告诉 Valgrind 别管它。
(提问者补充:我不是安全专家,但我很好奇这是否会为侧信道攻击打开一个攻击面。)嗯,你知道,你做了一次超读,如果你有时间关键的代码,你根本不会使用任何 libc 字符串函数,因为它们本身都是数据依赖的。所以,是的,存在时间侧信道,但如果你使用这些函数,它们本身就已经存在时间侧信道了。除了那些时间安全函数,它们是经过精心设计以保证时间安全的,并且实际上不会超读数据,这使得它们在某些情况下稍微慢一些。对于那些重要的函数,即时间安全函数,我非常小心,并且有额外的安全代码审查来确保这些函数是正确且时间安全的。所以我希望这回答了你的问题。
(观众补充关于 Valgrind 的 detour libc)是的,这基本上就是他们在 Valgrind 里做的。他们有个叫做“detour libc”的东西,他们只是预加载它,它会把所有这些有问题的函数替换成简单的 C 实现。这是同样的方法。不过,如果你用内存标记做硬件净化(hardware sanitization),你就会遇到问题。那么你就必须做特例处理。是的。不过,如果你做硬件净化,通常内存标记也是在块边界上的。如果你说块边界不大于一个缓存行边界,也不小于一个缓存行边界,那么就不会有错误,因为代码从不跨越它不被允许跨越的缓存行边界。所以在某种意义上,因为硬件能做的标记太粗糙了,不会在意像单个字节这样的微小差异,所以它不会触发。
提问者8: 你的微基准测试,比如你展示的那些,你的 Y 轴是吞吐量,对吧?(单位是 GB/s)这可能是一个误导性的指标,它不一定能转化为应用程序的性能。
回答: 你的 libc 代码只是大型应用程序的一小部分,这部分是正确的。当然,在一个像 Perl 这样的文本处理应用中,你不会得到 5.5 倍的性能提升,你可能会得到 30% 或 40% 的提升,我们确实对其中一些应用进行了测量。但是说只测量吞吐量不一定意味着它有多快……(提问者:我同意这一点,但我想说的是……)我试图用我的第一个评论来回应这一点。这些基准测试的工作方式是,你有一个非常短的字符串数组,这些字符串是随机的,但平均长度约为 64 字节,然后函数依次在这些字符串上运行。所以你测量的是整个函数在随机大小、随机对齐的字符串上运行所需的时间,这在某种程度上代表了真实程序的情况,因为你无法真正预测字符串会是什么样子。我们唯一有点偏离的是,我们是按顺序处理字符串的,所以你的缓存局部性可能比在真实程序中要好。但非常重要的一点是,我们确实对函数的启动和关闭成本进行了基准测试。我们也有针对稳态(steady state)的基准测试,即我们有一个非常大的字符串,那些数字看起来就非常不同了。所以,对这一点进行基准测试是非常重要的。我同意你的看法。谢谢。