与专家 Daniel Lemire 探讨 SIMD、缓存和 CPU 内部¶
标题:On SIMD, cache and CPU internals with the expert Daniel Lemire!
日期:2025/02/25
作者:Łukasz & Daniel Lemire
链接:https://www.youtube.com/watch?v=gqdFvYeMW5o
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
Łukasz: 大家好!我的名字是 Łukasz,这里是游戏工程播客。这次我邀请到了 Daniel Lemire,他是加拿大魁北克大学的计算机科学教授。他是 CPU 性能方面的世界级专家,也是无数开源库的作者,其中包括可能是性能最高的 JSON 解析库,名为 simdjson
。我们谈论了 CPU 架构以及如何让你的程序快如闪电。希望你们喜欢!
你能告诉我 CPU 在计算机里是做什么的吗?
Daniel Lemire: 好的,当然,我们可以花好几个小时来讨论 CPU 的作用。所以,也许回到过去会很有趣。我开始编程时,是在一台 TRSAT 上。它被称为彩色计算机,因为你可以在上面实现彩色显示。在那个时候,孩子们用汇编语言编程是很常见的。我们实际上,你知道,如果你和我这个年纪的人交谈,就会发现买一个汇编器然后开始用它编程是相当普遍的。
当时计算机的工作方式,以及处理器的工作方式,都相对简单。你有一系列指令,处理器会像你想象的那样,一次执行一条指令。所有事情都同步得很好。例如,如果你有一条加载指令,它会直接去获取内存数据然后返回。因此,那时的架构相当简单,没什么神秘的。事情就是这样运作的,你基本上只要看看指令的数量,就能很好地衡量事情能跑多快。
现代处理器要复杂得多。我认为我们无法完全拆解它们,但我们可以讨论一些具体的主题,对吧?通常,大多数程序员的入门方式是编写代码。比方说,最简单的情况是,你用 C 或 C++ 编写代码,然后编译它。这会生成一堆你可以查看的指令。这些指令被转换成二进制形式,然后发送给 CPU。CPU 接收到这些指令,这意味着它们在内存中,然后它会解码指令。这是 CPU 的一个组成部分。它必须有一个指令解码器。
在这一点上,有不同的设计选择。例如,我们使用的 x64 处理器,即 Intel 和 AMD 的处理器,它们的指令编码有些复杂,因为如果你看指令列表,它看起来就是一、二、三,一堆指令。但如果你把它们转换成二进制,你会发现它们是可变长度的。所以有些指令可能很长,而另一些则很短。对吧?这意味着解码是一项相当复杂的任务。
现在,如果你有一个 RISC-V 处理器,虽然大多数人没有,但它是一个开源设计,因其显而易见的优势而小有名气,那么你的指令长度就有一个非常固定的比特预算。所以解码起来就相当容易,很简单。
处理器的运作方式是,它解码指令,不是一次性全部解码,而是根据需要解码。在计算机中,我们经常有循环。我的意思是,计算机花费的大量时间都用在了循环上。因此,在 x64 处理器上有一个缓存是非常有用的。解码后的指令就存放在这里,这样你就不必每次都重新解码它们。
指令解码曾经是处理器的一个重要部分。但现在我们的处理器在晶体管数量方面已经变得大了很多。现在你有数十亿的晶体管。所以负责解码的部分现在的重要性已经大大降低了。这在某种程度上起了作用,因为,例如,ARM 的人会说,我们的指令更容易解码,所以我们有优势。但现在这种优势有点减弱了。
那是一个部分。然后,当然,处理器要做的是操作数据。现在,数据通常可以在两个主要地方:它可以在内存中,或者在寄存器中。
大多数时候,如果你看汇编指令,人们会告诉你,你有 16 个寄存器。它们可以被标记为从 0 到 16,或者类似这样。这在以前可能是对的,但这些实际上是“命名寄存器”。它们只是有名字。但实际上,如果你看处理器内部,你会发现有更多的寄存器。我稍后可能会回到这一点。但你有很多很多的寄存器,远远多于英特尔 x64 处理器上的 16 个。你有非常多。
这些寄存器通常在 64 位处理器上是 64 位的。这些是通用寄存器。它们被用于所有事情。无论你表示的是布尔值,还是 32 位整数,或者其他什么,它们通常都用于此。你还有用于信号传递的标志,比如溢出,或比较结果。所以,你有这个命名的部分,这是你作为程序员如果深入到汇编层面会看到的东西。现在大多数人不会接触汇编。但是,如果你接触汇编,你会看到这个命名的部分。但处理器内部要精巧得多,它不仅仅只有一个标志位,或者说意义远不止于此。所以它们相当复杂。
那是一个组成部分。你有了寄存器,它们有点像一种内存。然后你有了我们认为的真正的内存本身,而如今的内存是非常分层的。通常,你有大约两到四层的缓存。这些内存是直接与处理器融合在一起的,或者非常靠近处理器。它非常快。通常,它分层排列。所以,最快、最接近的内存体积很小。它就像一个洋葱。最快的内存在最靠近处理器的核心,但它的容量相对有限。然后你有越来越大的内存层。每次你往上一层,容量会急剧增加,但性能也会急剧下降。所以,基本上,延迟,也就是指令检索数据所需的时间,会变得越来越大。
在你经过内存缓存之后,就是 RAM 本身,甚至是磁盘和网络等等。但仅仅是 RAM,它的容量就可以大得多得多。而且随着时间的推移,它可能会变得更大,因为现在每个人都想在他们的处理器上运行大型语言模型。所以,我们将需要更多更多的 RAM。
Łukasz: 这些缓存通常被编号为 L1、L2、L3,对吗?
Daniel Lemire: 是的。但是,重要的是,这并非固定不变。如果你一直使用英特尔处理器,它可能是 L1、L2、L3,但它可能因架构而异,并且随时可能改变。所以这并非绝对。例如,英特尔完全有可能设计出 L1、L2、L3、L4,如果他们愿意,甚至可以有 L5。这完全可能。如果他们这么做,你也不能抱怨。他们也可以减少层数。所以有时你会发现层数更少。
这些层级主要起到缓存的作用。当你加载数据时,它会填充这些层级。通常,你最近访问过的内存会更靠近 CPU,而你很久没有访问过的内存会更远。最终,如果你不再访问它,它会离开缓存。然后你就不再有它的本地副本了。
比这更复杂的是,我们的 CPU 现在由多个处理器组成。我们称之为核心(cores)。核心的数量可以有很多,除了可能在嵌入式系统中,现在通常每个 CPU 都有多个核心。可以是 4 个、8 个,甚至可以上升到 32、64 等等。但是缓存本身,既可以是特定于核心的,也可以是共享的。
所以,举个例子,购买 AMD 处理器的游戏玩家。AMD 生产的处理器在游戏玩家中非常受欢迎,它们拥有大量的末级缓存。通常是 L3 缓存。它们有数兆字节的缓存。但是,这些缓存通常是在所有处理器之间共享的。不一定是对称共享。所以,一部分缓存可能,一些核心可以对这部分缓存有特权访问权,而对其他部分的访问则较慢。
内存也是这样工作的。当你有多个处理器时,你的部分 RAM 可能更直接地连接到某些核心,而离其他核心更远。所以情况变得复杂了。
我们现在总是有缓存,并且认为这是理所当然的。但如果你回到过去,就像我告诉你的,在旧的 TRSAT 电脑上,我们没有缓存。指令会立即从内存返回。现在发生的情况是,我们的时钟频率变得高得多得多。我曾经在只有几兆赫兹时钟的电脑上编程。让我回溯一下。处理器,虽然有些奇怪的处理器不这么工作,但我们今天使用的大多数处理器都在一个时钟体制下工作。所以,基本上,每秒几十亿次,会有一个虚拟的“滴答”声发生,处理器上的所有指令都是定时的。它们必须总是在这个时钟点上开始。
这个速度变得非常非常快。我们从兆赫兹(MHz)发展到了千兆赫兹(GHz)。但与此同时,内存访问速度并没有变得那么快。所以在如此短的一个时钟周期内去内存取数据并继续执行是不可能的。如果你想从 RAM 访问内存,可能需要数千个 CPU 周期。所以,这显然造成了一个瓶颈。
因此,处理器有不同的选择。他们找到的一个解决方案就是拥有这种缓存内存,它的作用是,基本上,如果你频繁访问它,那么至少它会保留在本地。你会在某种程度上加速处理过程。你还是需要等待,但比数据在更远的地方等待的时间要短。
那是一种提速的方式。另一种提速的方式是让处理器,即使是单个处理器,也具有大量的并行性。拥有多个核心是一种实现并行性的方式。也就是说,你可以同时做几件事。这很明显,因为如果一个 CPU 有多个核心,那么每个核心就像一个小的处理器,可以独立行动,即使它共享一些内存和其他资源,但它确实是自主的。
但即使是这一个处理器,内部也内置了大量的并行性。其中一种并行性是,它可以同时发出多个内存请求,既可以向缓存发出,也可以向 RAM 发出。这就是我们对抗延迟的方式。所以,延迟没有缩短。我们所做的是,我们尝试建立缓存,让一些内存保持在近处。这种洋葱式的分层方法,但我们也并行化了加载过程。所以,你的处理器可以,比如说,最近的处理器每个周期可以发出三个加载指令。但在它发出三个加载指令后,下一个周期它可以再发出三个加载指令。而这些加载请求可能需要相当长的时间才能被满足。所以它可以发出大量的加载请求。
这是两种试图对抗内存延迟的策略。还有其他策略我稍后可以讨论。
现在,这种并行性很重要,因为它是现代处理器的一个基本问题,即它们是超标量(super-scalar)的,这意味着它们可以同时发出多条指令。最初,这可能只是幸运的话能发两条指令。现在,因为我们在物理上遇到了一个极限,无法进一步提高时钟周期,CPU 厂商发现,增加每个周期可以发出的指令数量的并行性是很有用的。
这方面有时曾一度停滞不前。曾有一个时期,编写出的软件在实践中似乎很难每个周期执行超过两条指令。但在过去十年中,这个数字急剧增加。例如,在苹果公司,苹果制造了很好的处理器,在其苹果处理器上,编写现实世界的软件,可以轻松实现每个周期执行四条、六条、七条指令。所以,内置了大量的并行性。
这种并行性是我之前区分命名寄存器和真实寄存器数量的部分原因。这就是部分原因,你正在工作,你可以发出多条指令,或者在任何给定的周期内完成多条指令,这需要大量的寄存器空间。
所以,你有了这种我称之为水平的指令数量并行性。但你也有一种垂直的并行性,即我们的处理器为了实现发出大量指令的目标,它们会对指令进行流水线处理(pipeline)。它们将指令按顺序排列,并尝试预测。它们进行推测执行(speculative execution)。它们试图预测未来,并规划好它们将要执行的指令。它们真的非常努力地利用这种能力来最大化并行水平。
对于程序员来说,理解这一点非常重要,因为你可能会看到两段代码,它们看起来可能一眼没什么区别,但其中一段可能比另一段慢得多得多。而其中一段可能更慢的原因是,它遇到了一些现代处理器无法克服的障碍。
处理器可能面临的一个障碍是无法预测未来,无法进行推测执行。一个例子就是非常难以预测的分支(branch)。比如,“如果这个条件为真,做这个;否则,做那个”。如果这种情况发生,并且你的编译器确实在代码中生成了一个分支——因为有时候看起来像 if-then
的语句实际上并不会被编译成分支,因为优化编译器会尽可能避免分支或难以预测的分支——如果你有一个分支,而处理器无法很好地预测它,它仍然会尝试预测。如果它猜错了,就必须清空它正在做的一切,回滚并重新开始。这是一个随着时间推移变得越来越严重的巨大惩罚。
为了对抗这个问题,CPU 厂商的做法是,他们配备了极其强大的分支预测器(branch predictors),这是现代 CPU 的一个重要组成部分,很多年前我们并没有这个东西。而且它们变得极其智能。例如,如果你有一个循环,循环内部有很多分支,并且你总是重复,分支模式也总是一样的,比如说你有几千个这样的分支。现在的处理器足够聪明,可以实际学习成千上万个总是相同的分支。如果它们是随机变化的,那你就完蛋了,你会遭受巨大的性能损失。
Łukasz: 这是基于统计的吗?比如,哪个分支最可能出现,或者它是如何工作的?
Daniel Lemire: 是的,所以早期的做法基本上是相对简单的统计,比如,看到一个模式。比如说,我有一个序列 true, true, false, true, true, false, true, true
,然后我问你下一个是什么,这相当容易猜到,对吧?这实际上在早期就被实现了。但现在我们已经远远超出了那个阶段。现在他们声称使用像机器学习之类的东西,它们要复杂得多。它们实际上有多层的分支预测器。它们有更昂贵但需要更长时间才能启动的预测器,也有非常天真的预测器,在你没有任何信息时,它们会尽力去猜测,但并不很聪明。
这是一个竞争点。如果你是 AMD,想击败英特尔,你会努力拥有一个更好的分支预测器,对吧?因为如果你确实有一个更好的分支预测器,你就能击败对手。
对于程序员来说,理解这一点非常重要,因为你的处理器基本上就像一个人工智能,它确实在学习,对吧?这意味着你可以编写……我们现在不是在谈论 CPU 基准测试,但假设你想对这些分支预测器对性能的影响及其重要性进行基准测试。如果你编写一个非常傻的基准测试,你会经常看到人们这样做,他们用少量数据,然后在一个循环中运行它,然后测量经过的时间,然后说,“哦,如果我这样改,它就变快了”。但这可能极具误导性,因为如果你的实际软件并不是总是在相同的输入上运行相同的功能,如果在你的实际代码中有难以预测的分支,而你的基准测试没有反映这一点,那么你所做的优化实际上可能让事情变得更慢。你确实会看到这种情况。所以理解你的处理器不是那么可预测,这一点非常重要。
这还只是在分支预测的层面上。它也适用于缓存,因为我们简单地谈到了缓存。但需要理解的一件事是,作为一名程序员,你对缓存的控制权非常非常少。对吧?如果你用 C# 或其他语言编程,甚至在 C 或汇编中,你通常不能告诉处理器,“好吧,我这里有一张数据表,请把它放到 L1,最快的缓存里。”你对此没有任何控制。它实际上是会变化的。你可以重新执行相同的代码,但可能由于各种复杂的原因,数据不会被放在同一个地方。如果你换到另一台电脑,另一款处理器,这一点尤其明显,分支预测器可能截然不同,缓存算法也可能完全不同。而你对此毫无控制。事实上,你几乎没有任何可见性。
所以这使得工作变得有点棘手,但这又至关重要。我们可以稍微深入谈谈这个。
Łukasz: 那么,对代码和 CPU 进行基准测试的好方法是什么?
Daniel Lemire: 这是一个非常棘手的问题。基准测试(benchmarking)非常困难。这基本上是我几乎每周都会在我的博客上做的事情,但它非常困难。
基准测试有很多层面。有一种你可能认为是“真正的基准测试”。比如说,我不开发视频游戏,但假设你是一名视频游戏设计师。显然,你可以测量每秒帧数(FPS)作为一个参考标准,越高越好,这很清楚,对吧?如果你有一个网络服务,你可能会在真实世界中测量负载下每秒能处理多少请求。这是真实世界的。
“基准测试”这个词,如果不加限定,通常就是指这个。问题在于,这通常是你最终想要改进的产品指标。如果你在制作一个视频游戏,你希望它能以每秒 120 帧的速度运行,如果你的电视支持的话。但测量这个指标的问题在于,它通常不会告诉你太多关于内部发生了什么的信息。它只是告诉你,比如说,它很慢。你说它慢,好的。
那你该怎么办呢?然后可能你的老板打电话给你说,“让它快点”,这是什么意思?有一个有趣的轶事,就在几周前,有一个人们用来编写网站的非常重要的系统叫做 Node.js。它允许你在服务器上运行 JavaScript。有人报告说,在 Mac 平台上,仅仅是在 Mac 平台上,每当你启动 Node.js 时,都会有一个明显的延迟,大约几毫秒。一毫秒是千分之一秒,但在软件中,一毫秒是个巨大的数字。而且这个延迟会随着时间变得更糟。你可能会增加毫秒数,比如 10 毫秒,然后是 20,然后是 30,然后你知道,它变得非常大。
好了,你该怎么办?你可以测量它,没问题。你尝试不同的版本,尝试不同的脚本,但你看到的只是它变得越来越慢。这只给了你一些可以利用的原始数据,但并不能真正帮助你找到问题。
在那个案例中,问题基本上是代码使用了延迟绑定(late binding)。当你启动程序时,它会在运行时进行符号解析。它会尝试,比如说,将函数声明与其函数定义在运行时关联起来。这当然意味着在内存中进行大量的修补。通常这很快,但如果你的程序非常大,就会有这个可怕的启动时间。解决方案就是去掉这个延迟绑定,在编译时就绑定所有东西。但发现这一点非常困难。
还有很多类似的例子。你在内存中操作,然后你处理一个函数,然后你看着它说,“也许另一个版本会更快”。说“好吧,我要拿我的函数,假设我已经优化了它。然后,假设你在制作一个视频游戏,我要改变我的函数,重新编译我的游戏并重新启动它,然后测量每秒帧数。”这样做并不总是实际的。如果你尝试这样做,你可能会看到的是,“我不知道,这有帮助吗?没帮助吗?”这可能真的很难知道,对吧?所以你不会知道。
如果你对性能非常认真,你会设计我们所说的微基准测试(microbenchmarks)。这个想法是尝试隔离问题。总的来说,基准测试更像是工程学。但微基une测试更接近科学。你说,“好吧,我可以用三种不同的方式来编写这段代码。”然后你实现它们。然后你尝试找到一个用例,你知道,一种合成的用例。它也可以使用真实数据,但你尝试为它构建一个合成的用例。并尝试让它变得现实。
如果你从我之前所说的学到了东西,你会让它,如果涉及到分支,你会让它足够大。所以你不会,比如说,你不要排序……假设这是一个排序问题,你不要一遍又一遍地排序同一个数组。你尝试排序不同的数组,因为否则,你知道,处理器很可能会学会如何排序你的数组。然后它会给你,除非你的实际游戏或应用程序总是排序同一个东西。如果它总是排序同一个东西,那会很奇怪。所以它可能不会。所以你想设计你的基准测试,然后你缩小范围。
这里真正棘手的是,你可能会想,“好吧,我只要测量一下经过的时间,这就能让我做出决定了。”但这里有很多复杂的事情。其中一个问题是,通常,时间遵循一个统计分布。通常是对数正态分布(log-normal distribution)。所以它看起来,有一个最小值。通常如果你有一个给定的函数,在一些数据上运行它,它有一个,它不可能,比如说,它不可能降到零。除非它遇到了一个 bug。你的函数需要一些时间来完成它的工作。并且在某些条件下,根据缓存、分支预测和所有东西的状态,会有一个类似最小值的点,就像你所能得到的最佳情况,但这个最佳情况通常是不太可能发生的。所以它不是最常见的情况。
而且,它通常是无上限的。所以,假设你有一个视频游戏,你多次运行一个场景,你测量,“我能得到的最佳帧率是多少?”这可能有一个你可以测量的最大值。如果你多次运行它,你会发现,在某个点上,它肯定不会再高了。但另一个方向通常是没有界限的。所以,你可能非常不走运。它可能碰巧需要从磁盘加载,或者发生了什么事,或者它收到了一个中断之类的。然后你就有一个很大的延迟。
现在,如果你幸运的话,这个大延迟非常非常不可能发生。所以,你得到的是一个分布,它可以延伸到很长很长的时间。但是,它能有多短是有限制的。它看起来有点像这样:在最小值附近有一个峰值,然后是一个缓慢下降的斜坡。
这之所以重要,是因为它使得测量变得非常棘手。因为如果你有一个像钟形曲线,也就是正态分布(normal distribution),统计学家会告诉你计算它的平均值非常容易。事实上,你几乎只需要生成几个值然后取平均值。这会很快地收敛到真实的平均值。但在软件中通常不是这样。所以如果你尝试这样做,然后你说,“哦,好的,我得到了一个测量值。现在我要得到一百万个测量值,然后我就会知道确切的平均值。”这不太行,很棘手。
所以,通常你不需要,时间是很难测量的。你永远无法精确测量。就像我跟你说的那样,你必须理解,这不像过去的好日子,你可以精确测量。今天你不知道事情到底需要多长时间,这更像是一个冷问题。所以测量起来很棘手。
然后你还有其他问题。很多事情都会产生显著的影响。当然,处理器的类型。如果你换了处理器,你会发现,原本更快的东西变得更慢,反之亦然。这很棘手。确切的编译器和编译标志也很重要。所以这很棘手。
所以,你可以做的另一种方法来帮助你,因为测量时间很困难,我鼓励人们如果可以的话,去使用性能计数器(performance counters)。今天大多数计算机,几乎所有的处理器,都有性能计数器。它们可以做很多事情,但它们总是允许你计算周期数(cycles)。它们总是允许你计算通常是已完成(retired)的指令数,已完成意味着它已经执行完毕。它通常允许你计算分支的数量,无论是总分支数还是未被预测的分支数。它通常允许你计算像加载次数之类的事情。
所以,不仅仅是时间,你可以得到一张关于所有这些特性的地图。它们非常有用,因为虽然时间可能难以测量,但指令数通常要稳定得多。基本上,如果你从统计上来看,指令数几乎总是一样的。原因是,有少数几件事情可以改变一个函数执行所需的指令数,基本上是通过像分支预测这样的东西,但如果它总是遵循相同的路径,那么,你知道,它总是会调用相同数量的指令。当然,我不是在谈论多线程之类的东西,但指令数可以,而且指令数通常是一个很好的指标。
所以,如果你减少了你的函数所用的指令数,有时候,但并非总是,它会变成一个更快的函数。为什么不是总是呢?好吧,回到我之前说的,什么东西可以击败现代计算机。有一件事可以击败现代计算机,那就是关键数据依赖(critical data dependencies)。
这里有一个例子。假设,我们有一个数组,我将编写两个函数。你编写两个函数。一个与另一个相反。一个函数,我只计算连续的差值。有点像微积分中的导数,对吧?我计算两个值之间的差值。索引 1 减索引 0,索引 2 减索引 1,以此类推。这很简单。
那么与此相反的是什么呢?相反的是我们所说的前缀和(prefix sum)。你只是把值加回去。对吧?所以,你从第一个值开始,加上差值,得到第二个值,以此类推。我刚才说的很简单,但可能会让人困惑。但如果你坐下来用笔算一下,你就会明白是什么了。
这两个函数基本上会生成相同数量的指令。它们非常相似。但其中一个比另一个快得多。计算差值的那个要快得多,因为你基本上可以并行计算差值。你不需要,你可以在数组的末尾计算差值,同时在开头计算。这很容易并行化。所以你的超标量处理器会非常喜欢这个。它会去把所有的减法排列好,然后高速执行它们。没问题。
而反向操作的问题在于,在你做第四个和之前,你必须完成前一个。所以它不断地在等待前一个结果,不断地停顿。所以,你可以拿一个算法,然后经常地改变它,来移除这些关键路径。它可能会变得更快。你尝试确保计算的这一部分不依赖于前一部分,因为当你这样做时,你可以用这种方式重写它,看起来它使用了更少的指令,但它可能更慢。
不过,这些关键路径问题并不是非常普遍。所以通常减少指令数还不错。但有一个非常著名的有这种关键路径的例子是朴素的链表(naive linked list)。你知道,链表基本上是,你有一个节点,它通过一个指针连接到下一个,再下一个,以此类 D 推。现在,如果你想从这个节点开始,走 10 步,那你就卡住了,因为它必须加载下一个节点,然后再下一个,以此类推。每次它都必须等待。这使得遍历变得非常非常慢。所以,需要遍历的长链表是个非常糟糕的主意。
所以,是的,但作为一个启发式方法,如果你简化了代码并生成了更少的指令——这顺便说一下,并不等同于,比如说,你用 C# 编码并缩短了你的 C# 代码然后说,“我有了更少的指令。”不,你实际上可能有更多的指令。所以,你实际上要看它编译后的样子。
不幸的是,工具链常常使这变得困难。例如,如果你用 C# 编程,你想说,“好的,我有了我的函数。这个函数的汇编代码是什么?”这是可以做到的。我是说,有工具,有 Visual Studio 的插件等等可以做到,但它有点棘手。而且调试器可以告诉你,你可以浏览它,但它通常不是直接的。
这也变得困难,因为在现实生活中,问题在于仅仅看汇编代码就像看一团乱麻。你不知道你在看什么,太难读了。所以,如果你有客观的衡量标准,你说,“好吧,我的函数需要一千条指令,而这家伙写了一个函数只用 200 条指令。”通常那 200 条指令的函数会更快。然后你就可以有信心,那会更好。
Łukasz: 你能稍微深入讲解一下 SIMD 和程序向量化这个主题吗?
Daniel Lemire: 好的。SIMD,这是一个很好的后续话题。我之前告诉你们,我们的处理器比以前并行得多。我提到了内存级并行,你可以一次加载很多东西。有超标量执行,你可以实际上一口气执行几条指令。当然,在此之前,我们谈到了一个 CPU 有多个核心,每个核心或多或少独立工作。
现在,其中一个非常非常重要的部分,是向我们的核心添加了 SIMD 指令。现在,几乎所有大多数人编程所面向的处理器都有 SIMD 指令。这可以追溯到,嗯,SIMD 的概念我稍后会定义,但我只想给出一个历史视角。它曾经是超级计算机才有的花哨东西,但基本上从奔腾 4(Pentium 4)开始,那是很久以前了,不管你多大年纪,奔腾 4 听起来像“那是什么?”嗯,它基本上是自 64 位英特尔和 AMD 处理器开始以来的东西。所以,是很久以前了。从那时起,它们就有了相当不错的 SIMD 指令。
那么,这些是什么呢?想法是这样的。能够每个周期同时发出多条指令是很好。但正如我指出的,要正确实现它很复杂。问题是要很好地对齐才能工作。有时可能效果不是很好,你会遇到障碍。所以,这里的洞见是,为什么不设计一些可以有效地一次性组合多个小指令的指令呢?
这个想法是,例如,你有很多,比如说在电脑游戏中,你可能有四个值,需要将它们与另外四个值相加。对吧?好的。现在你可以做,一次加法,一次加法,一次加法,一次加法。所以我有四次加法。是的,好的。现在的一些迷你处理器可以做到这一点。它们可以在一个周期内完成四次加法。但是,为什么不把它们组合起来呢?你知道,所以,我用一条指令,可以拿四个整数,和另外四个整数相加。然后,我就一次性得到了四个和。
为什么要止步于此呢?为什么不做到 8 个、16 个、64 个呢?嗯,这就是他们所做的。所以,你可以一次性像这样进行多个操作。这基本上就是 SIMD 指令的要点。它基本上是能够用一条指令同时操作多个值的能力。SIMD 的意思是单指令,多数据(Single Instruction, Multiple Data),这是一个花哨的术语,但你有一条指令,和多份数据。对吧?这基本上就是我们所说的。
为了让这个工作,处理器的工作方式是,它们包含了专用的寄存器。早些时候我谈到了通用寄存器,通常是 64 位的,但可以用于更小量的值。现在,它们做的是,它们通常,许多处理器有它们自己的指令,它们自己的寄存器用于一些 SIMD 指令。而且通常,他们用了一个技巧,他们共享这些寄存器,所以这些相同的寄存器既用于浮点值,也用于 SIMD,这是出于历史原因。早期的处理器不支持浮点值,比如圆周率 pi 等等。所以当他们添加这个功能时,他们需要新的寄存器。同时,他们也在考虑构建 SIMD 寄存器。所以,他们常常把两者结合起来。所以,可以用于浮点值的同一个寄存器,通常也用于 SIMD。对吧?
它们有点像通用寄存器,可以用于不同类型的整数、布尔值等等,或者像字符值。SIMD 寄存器也被用于不止一个目的。
所以,你有不同的处理器设计了不同的指令集。你有 x64,即英特尔和 AMD,它们有一层又一层的指令集。它从 SSE
开始,在 64 位上,然后是 SSE2
,然后一直下去。有一个很长的列表。它发展到 AVX
,AVX2
,然后最新的孩子是 AVX-512
,它非常非常大。它实际上是一个指令集家族。
然后事情变得非常非常混乱,因为英特尔把它放到了笔记本电脑上,然后在某个时候又从笔记本电脑上移除了它。所以,要知道它在哪里被支持,哪里不被支持,非常困难。AMD 更为一致。他们从 Zen 4 开始就支持 AVX-512
。所以,你可以预期,下一代游戏主机,如果它们是用 AMD 处理器制造的,索尼和微软可能会朝这个方向发展。它们可能会支持 AVX-512
。而当前这一代更像是 AVX2
。
这些都是技术术语,它们的意思不是很清楚,但基本上,AVX-512
是一套非常丰富的指令集。512
对我来说意味着 512 位,这意味着寄存器可以使用 64 字节。所以它们非常宽。但这还不是最激动人心的部分。它的强大之处……嗯,这已经很惊人了,因为这意味着你可以,你可以修改 64 字节。比如说你有一个 64 字节的字符串,你可以用一条指令修改它。这真是太棒了。当然它非常非常强大,但那还不是最令人兴奋的事情。最令人兴奋的事情是它允许你做的那些疯狂的指令。
例如,它允许你做的一件疯狂的事情是,你可以拿 64 字节,然后你可以用或多或少一条指令随心所欲地重新排序它们。你可以对它们进行洗牌(shuffle)。你可以用一条指令就这样洗牌它们。这太神奇了。你可以压缩(compact)它们。你可以根据一个可以动态提供的掩码,移除你不喜欢的值。所以它可以做各种各样非常非常奇妙的事情。
但不幸的是,它在英特尔笔记本电脑上并不被广泛支持。这听起来很棒。在硬币的另一面,你有 ARM 处理器。所以,我有任天堂 Switch,它是 ARM 的。你的手机,你的平板电脑,我的 MacBook 等等,这些都支持 Neon
。它通常,有不同的名字,但我想称它们为 Neon
。ARM 也有其他指令集,但它们与参数关系不大。比如它们有 SVE
和 SVE2
,被认为是更高级的 Neon
,但它不是很重要,因为它部署得不广。但你有 Neon
指令。它们有点像 x64 的,英特尔和 AMD 的,但又不完全是。
有非常非常重要的区别。在某些方面,它更强大。Neon
的寄存器更短。它们只有 16 字节。与英特尔不同,它们没有 32 字节和 64 字节两种不同的寄存器。它们的寄存器更短,但它们有非常好且相当强大的指令。
而且重要的是,历史上,虽然这正在改变,但 x64 的人,他们典型的工作方式是,你最多可以每个周期发出或完成三条 SIMD 指令。所以这是一个小小的限制,而你可以做更多的常规指令。所以这有点限制。当它们…所以你的超标量性较低,你一次能做的指令更少。而很多 ARM 处理器可以做到四个。所以你可以发出更多的 Neon
指令。所以,你通常有更短的寄存器,但你可以发出更多的指令。所以基本上,你知道,它不那么强大,但也不像看起来那么糟。
虽然大多数现代英特尔处理器至少可能可以处理 32 字节,它们的寄存器跨度 32 字节,但很多时候它们被限制在每个周期三条指令,这是相当有限的。如果你用到 512 位,也就是 AVX-512
,你通常被限制在两条,这意味着你能发出的指令更少。所以你牺牲了超标量并行性,来换取更强大的指令。但总的来说,英特尔仍然是明显的赢家。最强大的 SIMD 指令是 AVX-512
。它们真的非常非常强大。
它们非常先进。如果你看一个有 AVX-512
的英特尔服务器芯片,你会发现处理器相当大一部分是专门用于 AVX-512
的。所以,这不是你的处理器的一个微不足道的特性。SIMD 部分可能很大。事实上,当我看到 AMD……你知道,AMD 和英特尔之间有一种竞赛,我感到非常惊讶。我实际上没想到 AMD 会像他们那样采纳 AVX-512
,因为我认为这对他们来说,在竞争上成本非常高。我以为他们会放弃它,不会走那条路,特别是在英特尔自己对它也不是很投入,从他们的一些处理器上移除它的情况下。结果他们做得非常非常好。这意味着现在,这些 AMD 芯片拥有一些最……我有一台游戏笔记本电脑,上面有 Windows,它有一个 Zen 4,一个 AMD Zen 4 处理器。它基本上拥有你能得到的最好的 SIMD 性能。
所以这是处理器的特性。现在,如果你愿意,我们可以谈谈如何为它编程。
Łukasz: 是的,是的,我想问这个。因为我们提到了缓存,作为程序员,你某种程度上需要希望你正确地对齐了你的东西,让它们能放入缓存。你对此没有直接控制。但对于 SIMD,你需要做大量的工作来理解如何使用它。所以,让我们谈谈程序员对 SIMD 的看法。
Daniel Lemire: 好的。这里有一个有趣的组成部分。我们的处理器有很多非常非常特殊和强大的指令,如果你知道它们的存在,你可以调用它们,它们会非常有用。对吧?所以,举个例子,不谈 SIMD,我只是想铺垫一下。假设你有一个 64 位的整数。你把它看作是 64 个比特,你想计算其中被设置为 1 的比特的数量。这对某些重要的应用很有用。
很久以前,当我开始编程时,这实际上是一个昂贵的操作。你会去测试每个比特然后求和。这很糟糕。现在,几乎所有的商用处理器都有非常快的指令来做这件事。ARM 有扩展,即使你没有扩展,也有一些技巧,但实际上,它变得难以置信地快。就像一个 AMD 处理器可以每个周期做一些这样的操作。
这通常的工作方式是,如果你只是尝试天真地去做,编译器可能足够聪明。它可能会尝试猜测,理解你在做什么,并用这个快速指令替换它。但通常你会去你的……如果你用 C# 编程,我面前没有 C# 的文档,但在 C# 的标准库中,某个地方会有一个函数为你做这件事,它会说,它对一个字中的比特求和。然后 C# 编译器会在某个时候知道把它转换成快速指令。
如果你用 C++ 编程,最新的 C++ 标准版本引入了一个专门的函数来做这个。所以,你有这种混合的情况,你可以希望编译器识别你的代码并将其转换为快速指令,这确实有效。所以,你可以使用你的编程语言的标准库,不管是 Java、C# 还是 C++。你可以希望,这通常效果很好。或者你可以使用一个专门的库,你知道,你依赖某个专家提供给你的库,并且你指望它能很好地处理这些事情。例如,如果你在你的应用程序中做加密,你可能不会自己写代码,你不会调用标准库,你可能会调用一个更高级的函数,而在底层,它会调用正确的指令。
对于 SIMD 来说,情况有点类似。你可以像往常一样编写你的代码,然后希望编译器足够聪明,自己搞定。这被称为自动向量化(auto-vectorization)。通常有其他名字,但就是这个意思。你可以使用更高级的库来帮助你,或者你可以使用你的编程语言及其标准库提供的设施。
大约 20 年前,人们的赌注很大程度上放在了自动向量化上,认为它将是解决方案。他们基本上说,“人们不会改变他们的代码,但编译器会变得更聪明。”的确,今天的编译器比以前好多了。代码优化要好得多,但很大程度上,自动向量化被证明是……我不会说它是一个失败,因为它有时,不,有时它工作得非常好。如果你有一个简单的案例,对吧?如果你有一个简单的案例,你有两个数组,你想把一个数组的值加到另一个数组上。你只是写你的代码,用哪种编程语言真的不太重要。然后,如果你有一个非常快、非常好的优化编译器,这包括像 Java、C#,所以它不限于低级编程语言,它会做它的事。在某些条件下。有时它会失败,因为它不确定是否可以这样做,但通常在简单情况下它会工作。
但它不是很可靠。这就是为什么,例如,C# 的人做了很多艰苦的工作,Unity 也做了同样的事情等等。他们做了很多工作来把这些指令暴露出来,这样你就可以有意识地使用它们。你可以说,“我知道我想为这段代码使用 SIMD,因为它对我来说很重要。所以,我会努力,我会搞清楚,然后我自己来编码。”即使这很痛苦。
但这之所以成为可能,不是因为人们傻,他们做辛苦的工作是因为他们应该等着编译器来做。而是因为编译器在很多情况下不会做。编译器不能做的原因有很多。其中之一是我们的编程语言和这些指令之间的不匹配。我们编码的方式,像 Java、C# 等等的设计方式,它们在设计时并没有考虑到 SIMD。所以有一种不匹配,对吧?所以,有时编译器很难做转换。它不是那么直接。
所以这是一个小问题。所以它相当有限。所以你必须自己去做。你将得到的最好结果,通常是如果你有意识地为它设计你的代码。
但接下来有不同的层次。你可以使用相当高级的代码。有时这种高级代码是与指令集无关的。它不会要求你知道你所针对的指令集。所以它可能在 ARM 和,比如说,在你的 PlayStation 和你的任天堂 Switch 上都工作得很好。它不要求你费心。有时如果它太高级,一个例子是 Java 有一个 Java Vector API。问题是,要用好它很困难。有时你用他们的高级 API 重写你的代码,但你看不到性能上的好处。事实上,你可能最终得到的是相对较慢的代码。
那只是一个例子,但在很多情况下都会发生。所以,这是一个糟糕的结果,因为你从你的常规代码开始,然后你做了所有这些工作,用某个库来转换它,然后你的代码没有变快。但你可能花了一天时间编码,然后你做基准测试,它没有变快。那你该怎么办?嗯,从工程的角度来看,唯一的解决办法就是把你写的新代码扔掉。因为你不会……因为它更复杂,别人读不懂。如果它没有变快,那就把它扔了。它没有存在的意义。
但有时它会起作用,有时取决于你的应用程序,它可以工作得很好。例如,这种情况起作用的例子是,如果你看你的浏览器。现在基本上只有两个浏览器,或多或少。你有 Firefox,它处境艰难。但基本上你有 WebKit,在苹果平台上的 Safari。然后你有 Chromium,就像 Chrome、Edge 和 Brave 这些。基本上这是两个主要的浏览器引擎,它们都使用 SIMD。但它们不是在低级别做的。它们使用相对高级的软件库来做。所以,谷歌的人使用 Highway
,这是一个谷歌的库,为你做 SIMD。它用起来更容易一些。所以你不用更高级的。而 WebKit 的人使用一个叫做 SIMD Everywhere
的东西,同样的想法。顾名思义,你只为一个平台编写,然后它会尝试在所有地方找到最好的等价物。
所以这是两个选择,对吧?很明显它们工作得很好,因为浏览器的人不会,他们不会把 SIMD 代码放进去,如果它没有帮助的话。
然后你还有你的选择,就是你编写非常专门的代码。可能你会说,“好的,我要写我的 Neon
代码。”比如说,我想让它在我的 iPhone 上运行得很快。所以你为此编写专门的代码。然后,你说,“嗯,可能有部分代码在我的 PlayStation 和我的 iPhone 上都能工作得很好,但我要写两个函数,并分别优化它们。”然后你进入更低的层次,你试图真正利用你对指令的知识,因为它们做不同的事情,有不同的策略,有些在这里最好,有些在别处最好。
这个层次需要相当多的知识,相对较少的人能达到,因为一天只有那么多小时。所以这要求更高,但这也是你可以见证奇迹发生的地方。你可以得到完全疯狂的性能,看起来就像魔法,因为没有人在不真正了解处理器工作方式的情况下能达到这种速度。这基本上需要大量的知识,因为你不仅要知道哪些指令存在,你还必须知道模式,如何使用它们。因为我给你一堆指令,你怎么解决问题?你需要在脑子里有一些模式,你知道,我可以把这个和这个放在一起解决这个问题。而且你还需要知道它们的运行速度,这并不容易,因为苹果不会告诉你。它会卖给你一部 iPhone,但它会告诉你,“是的,我们支持这个指令,因为它在那里”,但它不会告诉你它有多快。
所以,你知道,这确实是,当然对于 x64 也是如此,像 AMD 和英特尔。他们都有优化手册,但很多信息是不完整的。所以你得做一些逆向工程。信息不是那么容易获得。
Łukasz: 当你谈到这个更低的层次时,你是指针对特定处理器的特定指令吗?例如,使用 intrinsics,就像我知道我想用哪个指令,我不想写 C 代码,我想直接写出让处理器使用的指令名称。
Daniel Lemire: 嗯,通常你不会,你可以走到那一步。如果你想用 Go 编程语言做 SIMD 编程,那么你肯定会去用汇编,然后把你的汇编和……有一些软件库,比如我们用于加密的 OpenSSL,他们就这么做。但大多数人不会深入到那个层次。那不是我的意思。你通常不会到那个层次。
通常,如果你用 C 或 C++ 编程,你会用你说的那个词,你会用 intrinsic functions。这些就像常规函数,只不过编译器知道它们。它们不是通用函数,而是编译器看到它们就知道需要……编译器系统是这样设计的。它看到这个就知道确切的翻译方式,有时,通常,它会把它翻译成一条特定的指令。不总是,但通常是这样。
所以基本上你在用 C 写,但不仅仅是 C 或 C++。同样的方法在 Rust、Swift(苹果的编程语言)中也有效。在 C# 中也工作得很好。C# 做了很多工作。你甚至可以在 C# 中调用疯狂的 AVX-512
指令。实际上,像 .NET,但还有不同的,像 Unity 也有它自己的支持,为游戏程序员,它也有对 SIMD 指令的支持。这告诉你它为什么相当重要。
所以这种低级编程相当重要,因为很多人投入了大量工作使其可用。问题是……所以很有可能,如果你用 C、C++、Rust、C# 编程,你知道,还有其他编程语言,我只是没列举,不是像 JavaScript 和 Python,但是这些语言支持它。有很多人投入了大量时间来让它工作,因为这是艰苦的工作。这告诉你它确实带来了实质性的好处,因为这是如此多的艰苦工作,微软不会去做,如果它没有区别的话。对吧?那里的工程师不会浪费他们生命中的三年时间来编码这些东西,如果它没用的话。
这实际上是一种非常专业的技能。就像我说的,即使你在 C# 中,你说,“好吧,我确切地知道我想要哪些指令,我想要这个、这个和这个”,C# 会为你做,但你仍然需要知识,而他们不会免费培训你。他们可能根本不会培训你。所以你得自己学,或者找个人教你。但这并没有被很好地覆盖。我不知道现在人们去哪里上学,但大多数学校不会教这个。
Łukasz: 对此你有什么指点吗?比如,你提到为了用 SIMD 编程,你需要这些模式,对吧?这不只是学习这个指令,那个指令,更多的是一种模式,如何布局和思考问题。你有什么教程或东西可以推荐来学习这个吗?
Daniel Lemire: 我没有准备一个列表。确实有一些人准备了课程。我们可以在描述中列出它们。但是,困难在于……它就像,好的,所以它不那么简单的原因,有点像学习……在某个时候,你学习编程,对吧?我不知道你是怎么学习编程的,但我是一名计算机科学教授。我会告诉正在学习编程的学生,他们非常不喜欢我的答案,那就是你不会通过读书或看视频来学会编程。我的意思是,你的视频当然很棒,但如果有人从不编程,只是看你的视频,他们是学不会编程的。他们可能认为他们学会了,但他们没有,事情不是那样运作的。这就像学习一门语言,比如英语。在某个时候要学习英语,你不能只通过听别人说英语来学习。那行不通。你必须在某个时候说英语,然后你才能发现问题。
所以这也是一个大问题。我知道有人自学了 SIMD 编程。我们都在某个时候自学过。基本上,它的工作方式是,我建议人们这样做,我并不是说你不应该买书或看视频等等,但我建议的方式是,“好的,我有这个项目,我想用 SIMD 来做这个部分。好吧,让我试试。”
今天我们有一个多年前没有的工具,那就是大型语言模型(LLM)。对吧?大型语言模型实际上可能可以帮助你。所以,你知道,你说,“好的,我有这个”,但你必须从简单的开始,因为你必须理解你在做什么。对吧?你说,“我有这个函数。我能用一些 intrinsics 重写它吗?”大型语言模型可以,但你知道,你用它们,就像用 Copilot 之类的。它会做一些事情,可能有点错,但你会学习,你会搞清楚。
困难在于,这种方法只在部分情况下有效,因为问题在于,在某个时候,如果你想最大化性能,你需要用 SIMD 的方式来设计算法,你不能从现有代码开始。所以在高级阶段,你实际上必须思考它。因为它不,你知道,它变成了……但是,要开始,你知道,在 2025 年,我肯定会考虑使用大型语言模型,你知道,像 Copilot 或我不想为微软做广告,你用哪个工具不重要,但你知道,就是那些工具。它们可以帮助你做。你只做简单的事情。
如果你想做,你可以选择你喜欢的选项。比如,你可以说,“好吧,我更喜欢编写与指令集无关的代码。”假设我用 C++。那么你可以说,“好的,我将使用谷歌的 Highway
,或者 SIMD Everywhere
。”而且,这些大型语言模型肯定会帮助你用这些东西编码。这可能是一种方法。或者你可以说,“不,我想疯狂一点,我想针对 Neon
,我想做这个。”那可以帮助你入门。
Łukasz: 你看过 Mojo 吗?据我所知,你可以添加一些与 SIMD 相关的类型注释,然后从中获得一些性能优势。对吗?
Daniel Lemire: 是的。我曾和 Chris(Lattner)就此开过会。基本上,Mojo 的意图是……我不知道看这个的人是否知道 Mojo 是什么。
Łukasz: 没错,没错,我应该先想到这个。让我们简单介绍一下 Mojo 是什么,然后我们可以具体谈谈。
Daniel Lemire: 好的。想法是,当今最流行的编程语言可能可以说是 Python,对吧?可能是 JavaScript 或者 TypeScript,但由于数据处理、人工智能和所有这些事情,Python 已经变得非常非常流行。Python 的问题在于,嗯,这不完全是个问题。Python 是个很棒的东西,对吧?Python 的伟大之处在于,你可以编写 C 代码,然后将它与 Python 连接起来。这有点痛苦,但不是那么严重。然后你就可以得到在 Python 下运行的非常非常强大的代码。例如,PyTorch 就是一个例子,供做 AI 的人使用,还有 NumPy 等等,有很多强大的库。
问题是 Python 代码本身超级慢。它比 C 慢大概一千倍。所以你总是在你能做的事情上受到限制,你总是受限于底层库的功能。因为如果你开始写太多 Python,它会变得非常非常慢。所以,使用 Python 的技巧是编写相对较少的代码,并依赖库在底层进行数值计算。对吧?如果你开始有一堆循环之类的,然后你说,“不,嗯,我正在用 PyTorch,但我真的想做一些他们没想到的事情。所以我想写我自己的循环和所有东西。”那它就会慢得要死。那不好。
所以 Mojo 的想法是设计一种编程语言,它非常像 Python,但是可以编译成基本上和 C 一样快的东西。或者最终那可能是目标。所以,它背后的人是 Chris Lattner,他很有名,因为他可以说是 LLVM 的创始人,这是主要的编译器后端之一。它被很多编程语言使用,比如 C、C++,当你编译 Clang 时,它甚至被集成在 Visual Studio 里,你可以在 Visual Studio 内部用 LLVM 作为前端来编译。我认为其他编程语言也使用 LLVM。所以他真的是……他还设计了 Swift,苹果的编程语言。所以他不是很懒。
Łukasz: 至少可以这么说,是的。
Daniel Lemire: 是的。所以现在,他开始了这项业务,基本上你有了 Mojo。Mojo 的想法是,它或多或少像 Python,但是你在用 Python 编程,却基本上得到了像 C 一样的底层速度。所以,是的,他们确实想把 SIMD 编程集成到编程语言中。
Mojo 还不完全完整。它还没有完成。所以如果你是游戏程序员,我认为你不应该用 Mojo 来启动你的 AAA 引擎。那可能太早了。但是,是的,他们确实想尝试解决我们在做 SIMD 编程时面临的许多问题。他们会成功吗?历史会告诉我们。
我向 Mojo 的人提出的一个痛点是,你总是面临这个问题:好的,当你编程时,你说,“我要用这些花哨的指令。”现在它在你的电脑上工作,那很棒。但当然,用户可能有不同的电脑。所以,当你针对一个并非总是可用的特定指令时,这有点问题。对吧?那是个很烦人的问题。
.NET 的人解决这个问题的方式是,他们说,你可以放一个分支。你可以说,“如果我支持 ARM Neon,就做以下事情。如果不支持,就做另外的事情。”所以你有这些分支。这工作得很好,因为,嗯,比那更复杂,但基本上工作得很好,因为 .NET 通常依赖于 JIT(Just-In-Time compilation)。它在运行时编译,所以它从人们正在运行的机器上编译。所以机器知道。
所以那不是问题。但如果你像用 C++ 编写游戏,那有点棘手。因为你是在你的机器上编译一次,但你不知道(用户的机器是什么)。所以这些都是有趣的问题。不,他们有解决方案。但基本上你做的是同样的技巧。你做他们所说的运行时分发(runtime dispatching)。基本上,你的程序的一部分,它甚至可以,如果你用 GCC 编译,它有功能帮助你。你可以标记一个函数。你可以说这个函数应该在硬件支持这个和那个时使用。否则使用另一个函数。
或者你可以手动做。你可以说,“好吧,我将有这个函数,它会检查处理器支持什么。然后我会把你重定向到正确的代码段。”所以你可以做的是,你实际上可以编译不同版本的代码。当程序启动时,它会选择正确的版本。
如果你在 x64 上,这部分就很混乱了,因为 x64 有这一层层的洋葱结构。这有点棘手。如果你知道,例如,你正在为 PlayStation 5 开发,那就不是什么大问题了。对吧?那你确切地知道处理器是什么。那就没有问题了。如果你正在为 ARM 处理器开发,问题也不大。因为除了专门的指令外,它们几乎有相同的指令。但如果你为 PC 编程,那就非常混乱了。它可能是任何东西。所以你必须为所有情况做计划。这有点挑战,像 Mojo 这样的语言可以在更高的层次上解决这个问题,让你的工作更容易。
Łukasz: 有人在 X(推特)上问了一个有趣的问题。你会如何比较 SIMD(尝试让你的程序处理向量化代码,并行执行大量指令)和 SPMD(单进程多数据)方法,并在需要时通过像 ISPC(Inter-Process Communication)这样的方式交换数据?你会如何看待这个?比如,我可以运行几个进程来做一些工作,然后交换数据,相比于只运行一个进程,但花更多时间确保 intrinsics 都写到位。
Daniel Lemire: SIMD 的一个好处是,讽刺的是,SIMD 相对于大多数替代方案来说,是简单且高能效的。对于处理器来说,这是最便宜的并行化形式,而不是对于程序员。这是处理器能有的最便宜的并行化形式。这就是为什么它无处不在。你知道,没有商用处理器……它真的非常非常高效。
现在,你可以做什么。例如,如果你比较 SIMD 编程和多核编程,你会发现用 SIMD 编程,比如说,你会快两倍,而且你通常不会显著增加核心的功耗。SIMD 不会贵很多。而使用多核的家伙会使其功耗成倍增加。所以这是一个区别。
另外一个问题是,当你使用多个处理器时,如果你的数据量很大,这工作得很好,但它也面临很多困难的障碍。通信是个问题。你经常有,通信有显著的延迟,这是个问题。你需要协调,这完全不是免费的。它可能非常非常棘手。而用 SIMD,例如,你可以在 SIMD 指令和你的常规老式指令之间通信,开销非常小。我们说的是,你知道,大概三个周期之类。你可以从一个切换到另一个。来回切换的成本非常非常低。
我想说的另一件事是,所有你能用的技巧,它们本身并不是相互竞争的。它们更像是相加的关系。对吧?所以,你用不同的策略来加速你的代码。但通常你不会说,“我只用 SIMD。”例如,那真的很……有些情况下 SIMD 有用,所以你可以用 SIMD 重写你的程序的一部分,那会有用,但它可能无法解决其他问题。你可能需要其他策略。
所以,例如,如果你下载,你知道,如果你想,比如说,你想在你的 CPU 上运行一个大型语言模型。这是人们最近常做的事情。当然,CPU 的问题在于,CPU 通常访问的带宽有限。这不算是 CPU 的根本限制,但大多数情况是这样。你很难找到一个拥有高带宽 RAM 的 CPU。它们有,但并不广泛可用。所以我们大多数人的 CPU 带宽非常有限。但撇开这个不谈。
假设你想在 CPU 上运行你的大型语言模型,你会用多个线程。每个线程都会使用 SIMD,你知道,你会把两者结合起来,来最大化你的性能。你会这样做。
有一个相关的有趣问题,是关于我们 CPU 的演变。在过去的好日子里,像英特尔这样的厂商采纳了超线程(hyper-threading),你的单个核心可以运行两个线程,有时是四个。这是一种策略,用来克服我之前概述的限制,比如说你有数据依赖,无法完成足够多的指令,或者你的分支预测错误,你卡住了。所以你可以做的,是尝试在每个核心上增加多个线程。这在很大程度上被视为未来。基本上,你会有很多虚拟核心。
然后,英特尔还曾尝试过。英特尔在某个时候发布了 Knight’s Landing 处理器,它们有点像 GPU 处理器。它们有很多核心,但核心都很小。有些 ARM 厂商也这样做。这些 CPU,它们的核心不是很强大,很小,但数量很多。
这有点像是两种模式之间的竞争:一种是,“我要有一个非常强大的大核心,只运行一个线程”,另一种是“拥有很多很多很多小核心”。我认为在过去的某个时候,未来被看作是,你将会有,比如说,一百万个,或者可能不是一百万,我不知道人们在想什么,但你将会有 20000 个核心,但它们都将非常非常小。它们不会很强大。
有趣的是,那条路至少没有明显地获胜。我们看到的是,我们 CPU 上的核心数量并没有增加得那么快。核心明显变得更大了,而超线程的趋势也不那么明显。CPU 供应商并没有急于在每个核心上放更多的超线程来运行更多的线程。似乎恰恰相反。大核心,至少在 CPU 上,似乎是一个胜利的提议。我认为部分原因在于,将工作负载划分成大量不同的线程,然后协调所有这些线程的困难性。
这有点像,如果你想用一个比喻,就是开公司的区别。你可以选择雇佣 10 个世界上最优秀的工程师,或者用同样的钱,你雇佣一千个程序员,但他们不是很好,有点笨。哪个公司会更成功?对吧?所以,两种类型的公司都存在,也都成功了,但在我看来,未来很难预测。所以我不打算做预测。但我认为拥有 10 个最优秀工程师的小公司,就像拥有 10 个但非常非常智能的核心的 CPU,正在战胜那些只是最大化核心数量的家伙。
那是 CPU。现在如果你谈论 GPU,那就……
Łukasz: 是的,这也是我想问的。看起来 CPU 设计之所以朝这个方向发展,是因为你有了 GPU 这个对应物。所有那些需要大规模并行化,但每个核心强度不那么大的任务,你可以交给 GPU,对吧?这就是为什么 CPU 专注于少数几个但具有超能力的核心,而 GPU 有数千个核心但能力较弱。是这样吗?
Daniel Lemire: 这总是很难说,因为……你知道,我经常在我的博客上写关于性能的文章,我总是鼓励人们在他们自己的应用程序上测试。所以,这总是取决于。有很多不同的人做不同的事情。所以很难做出宽泛的断言。我喜欢说,在抽象中推理是困难的。在抽象中推理就像,你知道,你站在一个很高的层次说,“好吧,是这样的……”所以你必须非常谦虚。
但复杂的是,我之前也提到过,由于各种复杂的原因,我们没有带宽好的 CPU。通常,CPU 因为各种原因带宽受限。一个例外是,苹果制造了一些 Apple Studio 设备,那里的处理器拥有比普通 PC 多得多的带宽。那里有很多内存级并行等等,它们设计得非常好。你看到人们在这些 CPU 上运行大型语言模型,这在很多 PC 上是难以想象的困难。对吧?
所以这是一个你需要考虑的等式,它在某种程度上与它是 GPU 还是 CPU 完全无关。那就是你有多少内存级并行?你有多少带宽?这些都是非常非常重要的问题。我的看法是,不管其他因素如何,我认为我们将看到,我希望我们将看到更多对带宽的关注。我们都看到的一个趋势是 AMD 推出了那些拥有大量缓存的 CPU。我认为,供应商可能会找到一种方法给我们更多的内存带宽。
关于 GPU,非常复杂,因为实际上也有截然不同的 GPU 设计。如果你看苹果设计的 GPU,它与英伟达设计的 GPU 就相当不同。苹果使用的核心数量要少得多,但他们仍然取得了相当好的结果。所以两种设计在 GPU 领域似乎都是可行的。更多更小的核心,或者更大但数量更少的核心。但很明显,对于 GPU 编程来说,那些非常小、很笨的核心显然更有优势,因为问题通常更容易并行化。对吧?如果你在做矩阵乘法,很明显很容易并行化。但很多编程任务并不是那么容易并行化。
曾经有一个大趋势,人们会做一些事情,比如,GPU 被视为未来。所以他们会设计一个基于 GPU 的数据库引擎,以及各种各样的事情。然后他们会做这些基准测试,GPU 对比 CPU。然后说,“GPU 快得多”。但通常当你仔细看时,你会发现,“嗯,是的,好的。但你没有很好地利用 CPU。”如果你真的这么做了,你会发现 CPU,如果你真的利用了它的所有能力,它可以给你的 GPU 带来真正的挑战。这就是为什么,相对较少的人在 GPU 上运行 MySQL。你知道,这并不是广泛发生的事情。
所以,预测未来会走向何方是困难的,但一个非常明显的大趋势是所谓的暗硅(dark silicon)。早些时候我告诉你,如果你看一个带有 AVX-512
的 x64 处理器,你会发现他们把大量的硅用在了 AVX-512
上。如果你想一想,这有点奇怪,因为,“好吧,也许我的代码不用 AVX-512
。”那么这部分发生了什么?在很多情况下,它没有被充分利用,对吧?但他们不太在乎。他们不太在乎它是否未被充分利用。就像很多人会买一个带捆绑 GPU 的英特尔处理器,但他们从不使用它,因为他们有一个独立的显卡,对吧?这相当普遍。
所以,总的趋势是,我们正在我们的系统中积累专门的硅,而这些硅并不总是被使用。这在任何地方都是如此。你的手机里可能有一些处理器,你知道,因为某些原因从未使用过,但把它放在那里比设计一个专门的硬件设备更便宜。所以,你把它放在一个真正的机器里。这显然是由于我们越来越善于将更多的晶体管放入小空间的结果。这个趋势可能不会逆转,这意味着我们倾向于在处理器中放入大量的功能。
如果你从历史上看,你买一台 PC,它的 CPU 不会做那么多事。它只做主要的计算,但你甚至可能有一个数学协处理器来做浮点运算。它甚至不是捆绑的。如果你想播放声音,你会有一个声音处理器。如果你有那些旧的游戏机,它就是那样工作的,所有东西都是分开的。
Łukasz: 物理计算也有它自己的卡,对吧?
Daniel Lemire: 是的,是的,完全正确。但现在,我们正在创建这些大型系统,它们包含了一切,包括你永远不会用的东西。仅仅是因为它不会贵很多,因为大量的钱都花在了设计上,你知道,设计非常难。但生产可以被大量精简。它仍然非常昂贵,但你可以,他们用各种技巧来……所以,很明显会发生的是,你的……
这有点像我那个 10 个聪明人的比喻。情况比那更糟。所以,不仅仅是你雇佣 10 个聪明人,而且这些聪明人,顺便说一下,也可以是女性,我不是性别歧视。所以,他们中一个可能可以弹钢琴,一个可能是历史爱好者,一个可能擅长设计图形等等。你雇佣了他们,但也许你不需要图形,因为你只是在做后端,但没关系。你知道,他们非常非常聪明。他们在大学里修了很多课程,对吧?他们训练有素。如果你需要,你就有。否则你不在乎。对吧?所以,你真的雇佣了这些“大脑袋”的人。这似乎是我们正在采取的方向,得到这些真正强大的系统。它们有大核心,还有很多我们不用的东西。
话虽如此,也许我完全错了。未来可能会走向另一条路,因为这真的很难预测。对吧?我只能说,这给人的印象是,这是我们正在采取的趋势,但你总要保持谦虚。
我记得几年前,有一个倡议叫“ARM 上的 Windows”。这是在苹果采纳 ARM 之前。我买了一台 ARM 服务器。制造处理器的 AMD 公司,他们实际上在卖 ARM 服务器。所以,这是真的。所以我有一台。我买了这些东西,我确保,你知道,我试图学习 ARM。然后我买了一台苹果平板电脑,我把它 root 了,然后用它作为一种……编程站之类的。人们觉得我疯了,因为当时的想法是,“好吧,ARM 对于这些小型、低功耗的东西确实很好。但它永远永远永远不会赶上英特尔。大芯片是英特尔的,英特尔是不可触及的。”有一种傲慢,对吧?所以英特尔的人会说,“嗯,你知道,我们是唯一知道如何制造强大的大芯片的人。”
Łukasz: 情况真是反转了。
Daniel Lemire: 情况反转了,因为现在,你知道,你,没有人那么想了。对吧?你可以,出于各种原因,如果你在 PC 上玩游戏,你可能今天仍然会选择 x64 处理器,出于某些原因,但这并不是因为高通不能制造一个好的 CPU 供你使用,因为他们可以。对吧?可能阻止你的是一堆与软件相关的问题,而不是其他任何东西。
我们可能会在今年晚些时候得到任天堂 Switch 2。我不知道,因为是任天堂,它可能不会很强大。但是,如果他们愿意,对吧?如果他们愿意,他们可以把它做成一个猛兽。他们不是……有非常非常强大的芯片,你可以用……你可以制造出基于这些的芯片。今天绝对没有什么能阻止某人想出一个可以与 x64 抗衡的游戏机。
所以你必须谦虚,因为你不知道未来会发生什么。所以,在不远的将来,我们可能会买到有 1000 个非常小的核心的笔记本电脑,这是可能的。但这可能发生。但你必须,你知道,你必须解释你如何解决协调这些核心的问题,例如,那真的很难。你可能知道,在游戏行业,有很多视频游戏表现不佳,它们挣扎是因为它们在利用所有可用核心方面有问题,对吧?它们在核心数量上的扩展性不好。这通常与……他们不是傻瓜。他们不是不知道自己在做什么的愚蠢程序员。他们只是被困住了,因为有难题。
而我们通常谈论的是,好吧,充分利用像 8 个核心。他们常常做不到。就像,从一个核心到两个核心速度翻倍,然后从两个到四个再翻倍,这很难。如果那很容易,我们早就这么做了。但你知道,然后给这些家伙一千个核心,会发生什么?他们可能会在某个时候放弃。他们会说,“嗯,你知道,我可以很好地利用 10 个,我不知道拿剩下的……你知道,其他的怎么办。”
Łukasz: 当你看 CPU 时,你会发现那里的架构相比 GPU 来说相当开放。你至少在指令集架构(ISA)方面,你知道会发生什么样的指令,你可以直接针对它们。为什么 GPU 的世界要封闭得多?
Daniel Lemire: 嗯,我之前解释的一部分,我认为它并不是那么 невероятно开放。它有文档,但不是那么好。所以,例如,如果你想知道分支预测器是如何工作的,嗯,你知道,你最好做实验,因为他们不会告诉你。对吧?它在硅片上。所以如果你有显微镜,你也许可以研究它,但他们不会告诉你。你知道,他们不会……他们不会给你太多关于算法的东西。
就像我说的,你对缓存没有真正的控制权。他们不会给你那么多。对吧?很多都是逆向工程。对吧?所以,我们做了很多逆向工程来搞清楚事情。当然,指令集本身是开放的。他们有点没有选择,因为,我想,没有哪个 CPU 厂商真正控制编译器。可能有个别例外会证明我错了。所以,你知道,你正在写你的代码,然后你用 Visual Studio 编译它。所以英特尔有点别无选择,只能给微软……如果他们想出了新的指令,他们必须向微软提供信息。否则微软怎么在他们的编译器里用它呢?GCC 和 Clang 也是一样。对吧?
所以,你知道,PlayStation 有点是 FreeBSD 的衍生物,我想它用的是 LLVM。嗯,你知道,如果你想出了一个处理器,而你没有把信息提供给 LLVM,嗯,你可以自己编码。你可以贡献代码,但通过贡献代码,你某种程度上就记录了它是如何工作的,对吧?你必须记录。
所以,我们有一个例子,因为某种商业战争,我不谈政治,但在美国人和中国人之间,在 CPU 等方面有一点商业战争。对吧?所以,中国人开始建造他们自己的芯片。有一家公司叫龙芯(Loongson),我可能发音不准,但你知道,他们有自己的指令集架构等等。他们没有很好地记录它。但他们必须去 GCC 和 Clang,可能还有 LLVM,他们必须,“这是指令,这是你如何控制……编译你的代码。”因为,你知道,不管你是俄罗斯人、中国人、匈牙利人还是非洲人,在某个时候你都会用一个主流编译器来写你的代码。对吧?所以,他们理论上可以写他们自己的秘密编译器。你可以那么做。英特尔和 AMD,我想他们有他们自己的专门编译器,但就英特尔而言,至少他们放弃了维护自己的,他们有点像用 LLVM,是它的一个变体。
所以那是一个角度。现在,如果你看 GPU 的人,嗯,通常他们……现在,可能又有例外和细微差别我没注意到,但大多数时候,他们可以说是自己构建自己的编译器。对吧?所以,英伟达,如果你想在他们的 GPU 上运行代码,他们会基本上提供给你……你知道,软件,CUDA 和所有他们提供的东西来运行。所以他们不需要别人来做底层工作。但我不确定。我不确定这像是……所以,我不确定这是一个根本性的区别,因为它可能以不同的方式演变。
但我想说,如果你有一个,像一个,如果你有一个集成,就像,如果你是谷歌或亚马逊,你在建造你自己的专用处理器,顺便说一下,他们确实在做。而且不管它是用于 GPU 还是 CPU,它用于什么不太重要,但如果你在建造你自己的,而且你也提供了所有的软件栈、工具,这样人们就可以使用它。那么你真的不需要记录内部工作的细节,甚至指令集,因为人们真的不需要,你知道,你会为他们处理好。所以你为什么要花钱雇人写文档、维护文档等等呢?那是很多辛苦的工作,你不需要做。
所以,这有点像……所以,如果你拿 ARM 来说,例如,它为什么强大?因为如果你用他们的指令集,那么,你的处理器就可以和 GCC 一起工作,比如说,或者 Visual Studio,它会工作。所以对你来说,这有点像节省了。但如果你足够富有和强大,你愿意写你自己的编译器和所有东西,而且你不需要,你知道,开源支持,那么,这是一个不同的等式。
但我不明白为什么不可能,你知道,有一个更开放的 GPU 生态系统。那可能发生。当然,例如,AMD 在这方面比英伟达更开放,但同样,这有点是因为英伟DA在提供工具方面投入了更多。所以他们需要别人的帮助更少。所以他们可以像是,你知道,我不会把他们归类为 secretive( secretive),你知道,不是,但你知道,他们可以更封闭一点。
Łukasz: 我发现我没有问最重要的问题。所以我想我们可以用这个问题来结束。鉴于你概述的 CPU 架构,回到我们讨论的开始,程序员可以做什么来利用现代 CPU 架构让程序更快?你提到了一些关于分支的技巧,但让我们做一个技术的概览。
Daniel Lemire: 好的。有不同的策略,但对我来说,这有点像程序正确性。对于程序正确性,人们应该做什么?投入大量的测试,对吧?这就是你让一个好的程序变得正确的方式。而且你要相对早地开始。你不要等到所有东西都……我知道有些人是这样编程的,他们写好所有东西,然后把它扔进调试器,然后花六个月时间来调试。
所以我有一个非常保密的看法,我不用调试器,因为我从不需要。我做的是,我很早就写大量的测试,我从底层开始测试所有东西。所以,我从来不花很多时间调试,老实说。我维护着很多软件,我很少调试。你知道,通常人们报告一个问题,它在一小时左右就修复了。
性能有点类似。我对性能的理念有点相同。你开始,然后,一个糟糕的策略是写完所有东西。有时因为你无法控制所有事情,所以没有选择。但如果你写完所有东西,然后你才说,“好吧,让我们看看它有多快。”你知道,然后你就会得到像,你知道,《赛博朋克 2077》(Cyberpunk)的灾难,CD Projekt Red,你知道,你做出了一个游戏,它在你所针对的平台上运行不了。然后,你知道,PlayStation 不得不把你的游戏从……你知道,《赛博朋克》,不得不把你的游戏下架。这相当尴尬。
这里发生的事情可能是因为他们等到最后才说,“好吧,让我们看看它有多快。”“好的,太慢了。让我们让它变快。”然后,然后他们就挣扎了。我认为一个好得多的策略是,尽早开始基准测试,一旦你有了东西,就测量、测量、再测量。
人们做得不够的一件事是跟踪性能随时间的变化。所以,经常发生的是,有人会说,“哦,我这里有个改动。我要把这个家伙移到这里。”没有人看它对性能的影响。最糟糕的是,有时人们会说,“哦,我要优化这个”,但他们不测量。他们说,“嗯,显然它必须更快。”这种情况发生。你可以在 GitHub 上看到我,经常发生这种事。对吧?有人会说,“我优化了这部分。”然后你说,“好的,你跑了基准测试吗?”然后有时人们会沉默,有时只是因为他们没有。
所以,测量,就像测试一样。我知道,我知道这超级简单,因为它看起来几乎是愚蠢的。对吧?但在我的经验中,写出快速代码的人,他们花了大量时间做基准测试。
就像程序正确性一样。当你训练自己编写更正确的代码时,你需要更少的测试来让你保持在正轨上。对吧?所以,假设你从未用 C++ 编程。这是你写 C++ 的第一年。你会做各种愚蠢的事情。你会搞砸东西。那会很糟糕。所以你必须花很多时间,非常非常小心。但如果你非常有经验,你知道,你可以去,代码不会那么糟糕,不会有那么多坏的代码行。
性能也是一样。如果你训练自己 10 年来编写非常高效的代码,那么你就不太可能犯下可怕的错误。但它仍然可能发生。所以,一个例子,就像,如果你用 C++ 编程,经常发生的是,在 C++ 中,几乎很容易在你没有意识到的情况下进行复制。这一直在发生。好的。所以我去年有一篇关于这个的博客文章,或者关于像 Node.js 中的例子。我们有一个问题,基本上,我们架构的方式,要访问一个字符串,我们必须复制它。而且是一直在复制。但在代码中并不明显。你会想,“我只是在获取字符串”,但你实际上每次都在创建一个副本。现在你说,“好吧,复制一个字符串。能有多糟?”嗯,我的意思是,在边际上,它会累积起来。对吧?然后它可以显著降低你的速度。
所以,你怎么……在某个时候,你知道,你发现,“好的,我可以避免这个问题”,但这部分是经验,但这也有……所以,一种有帮助的方式,是多样性。所以,在我给你的那个案例中,我们怎么知道 Node 不是很快?因为有另一个系统使用完全相同的代码,但快得多。所以,Bun 是另一个 JavaScript 运行时。Bun 做到了快两倍。但区别是 Bun 没有复制代码。
所以,我称之为多样性,意思是,你和你的竞争对手比较。这通常也是非常重要的信息。例如,有些人会说,“我不需要更快,因为我的代码可能足够快了。”这很公平。但这通常,有一个你应该能提供的答案是,你的代码可以有多快?所以你让我提供一个函数,对吧?然后你说,“我写了这个,这是对的,但它没有它可能那么快。”然后你,但你可以反过来问我,“好的,好的。但如果我需要它快,它能有多快?”如果你不能回答这个问题,这是一个重要的工程问题。对吧?因为人们,有时他们对软件了解不够,不知道事情能有多快。
所以有时你可以,一种知道的方式是,拿一个竞争对手,和它做基准测试。现在,如果你,比如说,你比另一个人慢 50 倍。那么,你知道,也许你应该对此做出反应。现在,也许你只慢了 30%。所以也许那对你的应用不重要。但通常,你知道,这是一个……我认为这是一个……你知道,这是一个问题。
关键是,不幸的是,人们会说,“嗯,你知道,小的差异,不太重要”,但这些东西会累积起来。它们确实会累积起来。所以,一个关键点是,你必须有一个非常好的软件性能模型。人们有时有错误的假设。一个错误的假设是,“好吧,我写的大部分代码不需要快,我整个程序里只有 10 行代码重要。我以后可以找到这 10 行代码,优化它们,然后我的软件就会快了。”但这和认为,“嗯,我的大部分代码不需要正确,我只需要这 10 行代码正确”一样愚蠢。不,因为任何地方的任何一个 bug 都可能让你的整个东西崩溃。软件性能也是一样。所以你不能只隔离一件事。你必须考虑整个事情。
这意味着你必须不断地测量你软件的不同部分,或者至少知道它能有多快,以及它们现在有多快。所以你确切地知道这部分不重要。好的。但如果它重要,你知道你可以压榨它,让它快得多。而且你有一个如何做的想法。但这需要很多。因为如果性能是一个简单的问题,你只有一个瓶颈,那我就可以成为亿万富翁,因为他们可以雇佣我,我会找出那 10 行代码,优化它们,然后就再也没有人有性能问题了。但不,当你深入其中时,它超级复杂。有时,有时整个架构,你知道,是坏的。这就是低性能。我的意思是,基本上,有时唯一的选择是你必须扔掉所有东西重新开始。这和正确性是一样的。有时如果你的架构是错的,它总是会有 bug。你知道,因为它实际上不是正确的方式。
但如果你不测试,不测量性能,你不知道。有一种我希望稍微流行一点的编程语言是 Swift,例如。还有一个是 Go。这些语言做的是,它们有测试,你知道,支持是内置的。你支持测试,但它们也内置了对性能测试的支持。对吧?所以,你可以运行你的测试,你可以运行你的基准测试。对吧?所以,如果有人提出了一个改动,你可以说,“哦,我要对它进行基准测试。这变慢了还是变快了?”所以,你知道,你可以做的是,你可以持续监控你的东西的性能。如果在某个时候,你知道,它变糟了,那么你可以做像 bug 追踪一样的事情。你可以说,“它变糟了。”
但我想说,你真正变快的方式,这很重要。一个很好的例子是 simdjson
,它,你知道,可以说是世界上最快的 JSON 解析器。可能会错。但它是如何工作的?好的。我们开始和 Jeff Langdale 合作。第一个版本不是很快。然后它实际上令人沮丧,但它是私有的。然后我们具体化,我们优化了它。在某个时候,你知道,我们击败了当时最先进的技术。所以我们发布了它。然后我们做的是,多年来,我们优化了它。在某个时候,你达到了一个点,你知道,唯一能更快的方法就是做一些不同的事情,我们后来确实做了。
但基本上,你有一条曲线,你不是说,“好的,它很慢,然后,哦,我解决了问题。”而是这些曲线,你走,“好的,我提升了 3%。我提升了 3%。”如果每……每个星期,不,但问题是如果你像这样百分比地提升,你实际上是在指数级进步。就像,例如,你有这个节目,如果你的观众数量每天增加 3%,这听起来令人沮丧。但如果你看曲线,这实际上,你知道,你正在走向某个地方。对吧?很明显,因为你将处于指数增长。
所以软件也是一样。你必须不断地削减它。这看起来令人沮丧,但,有时你会幸运。它会,你知道,像那样,你会有一个跳跃。但你知道,就像,如果你一直努力,它会变得越来越快。然后在某个时候你会遇到收益递减。对吧?但那是一种方式,像,测量,测量,测量,一直测量。绝对的。然后你就会建立起能力等等。因为没有……有很好的书。所以我不能,你知道,有非常好的关于软件性能的书,它们当然谈论,你可以看等等。它们很有用,但,你应该记住它们,但这真的是练习、练习、再练习。就像,没有别的了。所以,如果你不测量,你就无法练习。
Łukasz: 你是如何成为 GitHub 前 100 名贡献者的?
Daniel Lemire: 所以,我可能是在,比如,前 500 名,我不知道。我很多年前开始用 GitHub。在某个时候,它对我来说只是方便,因为在那之前我用 Subversion,再之前是我自己私有服务器上的 CSV。当这些家伙开放时,我说,“这非常方便,而且还是免费的。”所以,你知道,这件奇妙的事情,还有很多工具。所以我开始用它。
然后我只是,你知道,我非常投入于开源,因为我可以。这有点像我的模式,因为我是一名大学教授。但我喜欢发表我的工作的一种方式是作为开源软件。这不,这在大学里不典型,因为教授通常不是那样工作的,但这是我发现非常有用的模式,你知道,就是写好的软件,然后把它放出去。这是一种至少对我来说效果很好的模式,因为我能得到反馈。你知道,如果你写一篇研究论文,没人读。我不知道你每周读多少篇研究论文,但大多数做软件的人,一篇都不读。
所以,你知道,我们之前谈到 Chris Lattner。我曾主张 Chris Lattner 应该获得图灵奖,这有点像计算机科学的诺贝尔奖,但我不知道他写了多少研究论文。他肯定写了一些,但我的观点是,他创造了,你知道,明显推进了技术水平的东西,但主要不是,不全是,但主要是通过开源。你知道,他贡献了工作。
所以,这就是我使用的模式。这让我能够,你知道,得到反馈,包括批评性的反馈。这帮助我,你知道,做出更好的工作,也找到问题。那真的很重要。找到人们关心的问题。我只是把它发布在 GitHub 上。所以我认为那是……然后人们决定,也许关注我会很酷,看看我接下来会做什么。但,需要澄清的是,我没有像,我没有尝试,这不是一个计划。我只是在某个时候观察到。所以,“哦,好的。所以很多人关注我,成千上万的人。”我只是注意到了。这就像是发生了。然后我说,“哦,好的。所以,这意味着,你知道,他们想知道我接下来要做什么。”但这并非有意为之。这不像,你知道,你开始一个 YouTube 频道,你想增长。然后你找出你要用什么策略来增长。对我来说,这就像是,“我要把我的软件放出去”,然后碰巧人们喜欢它。嗯,他们喜欢一些软件。
但另一件我做的事情可能也有贡献,那就是我写博客。你知道,我每周都有博客,你知道,所以我有东西。我的博客和 GitHub 是配对的。代码是发布的,不是所有的博客文章都有代码,但很多有。而且总是发布在 GitHub 上。所以这是一个联系,但这不是一个计划。这不是像一个邪恶的计划,我的博客推广我的 GitHub 账户等等。它只是……但这,这是,你知道,这是我做我的博客的事情,只是因为,你知道,作为一名教授,对我来说,这是一种与,你知道,行业中的实际人们联系的方式。所以,我表达想法,然后我得到反馈,你知道,人们说,“哦,嗯,这很有趣。你知道那个吗?”然后他们告诉我一些事情,然后我说,“哦,我不知道那个。”然后我做笔记,然后,你知道,我写东西。所以我没有一个很好的答案关于如何做到这一点。
Łukasz: 你能提一下你贡献了哪些项目,你创建了哪些项目吗?你提到了 SIMDJSON 和 Node.js,但其他的呢?
Daniel Lemire: 是的,Node.js,我只是一个,你知道,我贡献了一点点,但它就像,可能有超过一百个人,而且它运行了很长时间。所以这不是我的个人项目。我只是,你知道,在里面做点事。
我的主要……好的,我不会回到太久远的过去,因为那会很无聊。但我做的一件事是,我对性能感兴趣很长时间了。所以我研究了一个索引格式,叫做 EWAH。你不需要知道。是 E W A H。然后这被 Git 采纳了。你知道,所以如果你在用 Git,你从 GitHub 找到的主要实现就用了这个索引格式。在那之后,我,我做的一件事是,我开始了一个叫 Roaring Bitmaps 的项目,这是它的一个后续,基本上是一种索引技术,被很多重要的系统使用,包括像 YouTube 的一个数据库引擎,我想还有 Priscilla,他们用,他们用这个,他们用 Roaring Bitmaps。它被用在很多大数据系统中。而且它有不同编程语言的版本,像 Go、Java、C、C++ 等等。所以那是……但那是我工作的一个特点,顺便说一下,EWAH 也有不同编程语言的版本。所以有时我喜欢,你知道,用不同的编程语言提供同一个想法的不同版本。
所以,我对解析产生了兴趣。这是和 Langdale、Kaiser 和不同的人一起做的。这还在进行中。基本上,这里的想法是,展示 JSON 可以以每秒千兆字节的速度被解析。所以真的非常非常快。现在有基准测试,显示你可以做到,比如你可以拿 JSON,在单个线程上,在一个 CPU 上以每秒 10 千兆字节的速度解析它。
我曾写过一个库,在某个时候我很沮丧,因为解析数字很慢。所以,比如说你有 3.1416,你知道,那是个字符串,你想把它转换成一个数字,一个二进制数。这慢得愚蠢。而且唯一,唯一可用的库是 Gay 很多很多年前写的。而且没有人怎么重新审视过它。嗯,有几个实现,但非常少。所以我写了一个,和合作者一起,写了一个更好的算法。现在这个在 GCC 的后端。LLVM 的人将要实现一个版本。它在 Go 的标准库里。它在 .NET 的标准库里等等,还有 Rust。所以,如果你在解析数字,很有可能你正在用我们的算法。
我们写了一个超级快的 URL 解析器。这听起来很蠢,因为你会说 URL,解析一个 URL 能有多难?嗯,它超级超级复杂。它惊人地复杂。所以我们有最快的……这是,是的,我想是 Cloudflare,你可能知道,那家做很多网络东西的公司。这是,例如,Node 解析 URL 的一部分。
哦,我最近和很多人一起做的事情,叫做 SIMD-UTF。我刚才提到的浮点数的东西,在主流解析器里,不,在主流浏览器里。所以,它在 WebKit 和谷歌 Chrome 等等里。我们正在做的最后一个项目是 SIMD-UTF。这也用在主流浏览器里。这个库做的是,它非常快地做转码。在现代系统中,有两种字符串格式。你可以有 UTF-16 或 UTF-8。互联网上的大多数字符串是 UTF-8,但如果你在 Windows、JavaScript、Java 或 C# 上,你的字符串通常是 UTF-16,意思是每个字符用 16 位。而在互联网上,它用 8 位,或者如果你用 C 编程,你通常用 UTF-8。所以我们的 ASCII……ASCII 是 UTF-8 的一个特例。所以它非常快地做转换。它也验证输入。我们添加的最新功能是支持 Base64。当你发送一封电子邮件并附上一张图片或一个 Word 文档时,电子邮件格式不支持二进制数据。它必须是文本。所以它把它转换成文本,它看起来像随机的文本。你的加密密钥也通常用 Base64 编码。你也可以在你的浏览器里嵌入 Base64,作为一个数据 URL。所以你可以把一张图片藏在 HTML 里。为此,我们有一个非常非常快的例程来编码和解码。
我可能忘了一些东西,但……
Łukasz: 那是很多世界级的软件。所以你问为什么人们在 GitHub 上关注你。嗯,那可能就是原因,你正在非常非常努力地做很多事情。
Daniel Lemire: 我不是一个人,顺便说一下,这些都是和很多非常非常聪明的人一起做的项目。所以我参与了很多东西,并为很多不同的项目做贡献。你提到了 Node,但我,我们曾为微软的 .NET 库做过贡献。你知道,为不同的……我曾为 Linux 上的 C++ 标准库做过贡献。你知道,当我们能的时候,在这里那里做贡献。所以,这些都是,你知道,开源贡献等等。这里的想法是,你知道,我的模式是尝试有好点子,但你也让人们用这些好点子。所以,你知道,你尝试开发一个,你知道,一个算法或一个方法等等。你在博客上写它,你在研究论文里写它,你发布代码。然后有人说,“好吧,我用 Go 编程,你的 Java 软件帮不了我。”你说,“好的,没问题。我要把它移植到你的编程语言上。”等等。是的,如果你只是为了好玩,去我的 GitHub 页面,你会看到仓库的数量,特别是如果你包括我关联的组织,是很多的。所以我参与了很多不同的事情。
Łukasz: Daniel,非常感谢你加入我。这是一种荣幸。也感谢你的工作。这是杰出的工作,你为所有人带来了性能之光,特别是对于在 X 上关注你的人。你经常提到性能。非常感谢你所做的一切。也非常感谢这次谈话。
Daniel Lemire: 谢谢你。
Łukasz: 非常感谢收听本期游戏工程播客。别忘了在 YouTube 上订阅,并在 Spotify 和其他播客平台上关注。下期节目再见。