C++ 调度技术深度解析¶
标题:A Deep Dive Into Dispatching Techniques in C++
日期:2023/07/23
作者:Jonathan Müller
链接:https://www.youtube.com/watch?v=vUwsfmVkKtY
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
好的,太棒了。那么,欢迎参加我的演讲。希望你们喜欢这次会议。能回到阿斯彭真是太棒了。所以,这是一个关于性能的演讲。那么,我需要先声明一点。在运行你自己的基准测试之前,你永远不应该进行任何优化。 特别是,你不应该仅仅因为别人告诉你某个东西快就去实现它。我所有的基准测试都是在运行 Linux 和 Clang14 的 2020 款 Apple Mac Mini 上运行的,这很可能不是你的目标设置。所以,不要直接照抄我的任何基准测试结果。在演讲过程中随时可以提问,但如果有任何意见,请留到最后。我为了效果会做一些有点奇怪的事情,所以请把意见留到最后。问题很可能会得到解决。那么,我要优化的是一个调度循环,它看起来像这样。我们有一个 while
循环,里面有一个 switch
语句,我们想让它变快。我不关心的是,比如说,枚举转字符串(enum to string)的性能。如果那成为显著的性能问题,那一定是出了大问题。另外,这个例子是从工作中提取的。我们做的是一个 PowerPoint 插件。所以我们需要做很多 PowerPoint 相关的事情。在这个例子中,我们特别想处理幻灯片母版。所以我们有一个 switch
来做这件事,然后有一个 default
。有人问这是不是最好的方式。有人就向他们推荐了我的演讲。但这不是这个演讲要讲的,对吧?你应该写成类似这样。我们有一个枚举集合(enum set)。然后我们可以请求一个子集,并像这样处理。所以,我想借此机会提一下,我们正在招聘。如果你在找工作,请考虑申请。
那么,我要讲的是类似这样的东西。我们想解析一个二进制文件,比如说。我们有一些头部信息。然后我们根据头部类型进行 switch
,并据此解析一些有效载荷。然后我们在一个循环中反复做这件事。所以这最终可能会成为性能问题。我们可能想研究一下让它变快。当然,还有一个典型例子。我们想写一个字节码解释器。我们有一系列指令,我们想相应地执行它们。这就是我去年夏天研究这个小字节码解释器的动机。我想优化那种代码。这也是我在整个演讲中要使用的例子。
那么,让我们定义一个简单的基于栈的字节码。什么是字节码?字节码是由单个字节组成的指令。我们有一个字节码操作码(opcode),它是一个 uint8_t
类型的枚举。然后里面有各种指令。一条指令要么直接是操作码,要么带有一个字节的有效载荷。所以我们要么有一个无符号整数值,要么有一个有符号整数偏移量。字节码本身只是这个指令的一个向量(vector)。每条指令以操作码开始,然后有多个可选字节作为有效载荷。
字节码是基于栈的。这意味着指令不访问寄存器。它们修改一个值栈(value stack),简称 vstack。例如,我们有一个 push
指令,它将一个常量压入 vstack。这个常量在字节码的下一个字节中指定为有效载荷。例如,我们有 abc
,然后我们想 push 42
,现在 42 就在栈顶了。
我们也可以在栈上进行操作。例如,add
从栈顶弹出两个值,将它们相加,然后将和压回栈顶。这个指令不带任何有效载荷。这样,我们就可以写一个非常简单的例子,只对三个数求和。我们 push 1
和 push 2
。现在 vstack 在右边(假设展示)。所以 vstack 上有 1 和 2。然后我们 add
,将它们弹出并压入 3。然后我们再 push 3
,最后的 add
得到 6。这样我们就可以做基本的算术了。
基于 vstack 的一个问题是,如果我们想多次使用同一个值怎么办,对吧?我们只有求和的结果。我们想修改它但也想稍后再次使用它。是的。那么,我们如何多次使用同一个值呢?为此,我们可以复制它。dup
指令简单地复制 vstack 顶部的任何内容。所以现在它在栈顶出现了两次。然后我们可以弹出一次,它还在那里。
类似的问题可能发生在 vstack 上的值顺序错误时。例如,我们可能由于其他计算将两个数字压入了栈,我们想对它们做减法,但我们想以相反的顺序相减。为此,我们可以使用 swap
。它简单地弹出两个值,然后以相反的顺序压回。还有更复杂的栈操作,但我们的例子不需要它们。
对于控制流,我们的解释器维护一个指令指针(instruction pointer),简称 IP。一条普通指令只是将指令指针递增到操作码和任何数据之后。例如,在 push
之后,我们会将它递增 2,因为我们有一个字节的数据。在 add
之后,我们只递增 1,因为没有数据。
一条 jump
指令将指令指针递增由有效载荷指定的偏移量。这样,我们就可以实现一个循环,例如。循环结束后,我们想回到开头。这就是一条 jump
指令。然后我们还需要条件跳转。否则,我们的循环就会无限下去。cjump
(条件跳转)会根据 vstack 顶部是否非空来递增指令指针一个偏移量。它会从 vstack 弹出一个值。如果非零,它就跳转偏移量。否则,它就跳过跳转指令继续执行。
最后,我们想要函数调用。为简单起见,我们只允许一个函数。所以我们的整个字节码程序本质上就是那个函数。它的参数在调用前被压入 vstack。当函数结束时,它在 vstack 顶部留下一个单独的返回值,也就是函数的结果。那么,不是 call
,我们只有 recurse
(递归)。因为我们只有一个函数,所以只能递归。recurse
保存指令指针并回到开头。然后我们可以在不同的调用帧(call frame)上再次执行它。
然后是 return
。完成后,我们想回到调用者。所以我们跳转到最后保存的指令指针。指令指针保存在调用栈(call stack)或 cstack 上。这是一个单独的栈,以确保我们不会把它和值混在一起。这允许我们调用多个函数。
作为一个例子,让我们看一个递归计算斐波那契数列的程序。这并不是你真正想要的实现。但它很好,因为它的运行时间是指数级的,这在我们的情况下很方便,因为只需少量代码,我们就可以在基准测试中获得大量执行。
思路是我们计算第 n 个斐波那契数。如果 n 小于 2,那么就是 n 本身。所以第 0 个斐波那契数是 0,第一个是 1。否则,我们求和前两个数。这就是字节码。我们从 vstack 上有 n 开始,我们想把 Fibonacci(n) 放到 vstack 上。我们首先进行比较。我们不想移除 n,所以我们复制它(dup
),压入 2(push 2
)并与 2 比较(cmp
)。现在,比较结果在 vstack 顶部,我们做一个条件跳转(cjump
)。如果大于或等于 2,我们就跳转到 dup
指令(下面)。否则,我们继续执行 return
。因为 n 现在在栈顶,那就是正确的结果。所以我们直接 return
,将 n 本身返回给调用者。
否则,我们进入了那个 dup
指令。这里,我们想减 1(sub 1
)但也想保留一份副本,所以我们先复制它(dup
)。然后我们减 1 并递归调用(recurse
)。这将计算第 n-1 个数并把它放在 vstack 顶部。然后我们得到的值顺序错了(栈顶是 n-1,下面是 n)。所以我们做一次 swap
把 n 放到栈顶。从它减去 2(sub 2
)并再次递归(recurse
)。最后,我们在栈上有两个斐波那契数(F(n-1) 和 F(n-2))。所以我们把它们相加(add
)并返回(return
)。
你不需要理解为什么这是正确的所有细节。用手写基于栈的解释器编程有点棘手。你只需要理解每个指令在孤立情况下是如何工作的基本知识。我向你保证这个字节码实际上是正确的。那么,在这一点上有什么问题吗?好的。
那么,让我们为那个字节码写一个解释器。我们需要维护四种状态。
指令指针:指向当前字节码指令的指针。
vstack 指针:我们的 vstack 只是一个 int 数组,vstack 指针指向 vstack 上下一个空闲槽位。
同样,我们有调用栈(cstack)。它只是存储指令指针之类的东西。所以我们有一个存储这些的数组,也要记住当前栈顶。
还有字节码本身,这样我们就可以跳回开头。
好的。然后每条执行指令的实现都非常直接。例如:
push
:从下一个偏移量(有效载荷)取出值,将其压入 vstack 顶部,然后将指令指针递增 2 以指向下一条指令。dup
:vstack 顶部在偏移量 -1 处(因为 vstack 指针总是指向下一个空闲槽位)。所以我们取 -1 处的值(即栈顶值),然后再次压入它(push
它),然后我们递增 1(因为这次字节码中没有有效载荷)。add
或sub
或cmp
等等:它们看起来都一样。我们弹出操作数,计算结果,将其压回 vstack,然后递增 1。条件跳转(
cjump
):它只是从 vstack 弹出一个值。如果非零,我们就跳转到偏移量。否则,我们跳过操作码和偏移量(即指令指针递增 2)。recurse
:将指令指针保存在调用栈上。唯一要知道的是,我们保存的是recurse
之后的地址。否则,return
会直接跳回到recurse
,我们就会有一个无限循环,因为我们会不断递归下去,等等。然后我们只是跳转到函数的开头,也就是我们整个字节码的开头。最后,
return
只是跳转到我们刚刚保存的位置。
解释器的入口点接收字节码和函数的一个参数。然后它创建 vstack 和 cstack,它们只是一些固定大小的数组。我们初始化指令指针和 vstack 指针,然后我们想先把参数压入 vstack 顶部开始,我们还想在调用栈顶部压入一个特殊的退出指令(exit
)。这样,第一个 return
,也就是第一个函数的返回,就会转到退出指令,然后我们可以检测到并退出解释器。然后我们调用 dispatch
。这就是缺失的部分。这是我们将在演讲剩余部分实现的函数。它接收解释器状态的参数,读取当前操作码,执行处理该指令所需的相应汇编代码,然后重复这个过程,直到我们到达退出指令并完成执行。
这次会议谈了很多关于安全性。所以在这一点上,我需要声明。字节码解释器是远程代码执行(RCE)漏洞的主要候选对象。一旦你能利用它们,你就能控制它们。所以你永远不应该真正开始执行不可信且未经验证的字节码。话虽如此,这是一个关于性能的演讲。所以让我们忽略那个,继续前进。
我们可以实现字节码解释器的最基本的调度技术是 switch
。它看起来像这样。我们有一个 while true
循环(为简单起见)。然后我们对当前操作码进行 switch
,并执行 push
或 add
或任何必要的指令。当我们到达特殊的退出构造(exit
)时,我们从循环中返回并返回 vstack 顶部的最终值。
一个限定说明:即使我们在 switch
中实际覆盖了枚举中的每一个值,编译器也不知道,如果我们不告诉它,它会生成一些难看的代码(比如默认处理)。所以我们就告诉编译器我们没有 default
情况。否则,汇编代码会有点乱。我们忽略那个吧。
在这一点上,我基本上已经向你展示了字节码解释器的完整实现。那么,关于这个有什么问题吗?如果你在 YouTube 上看这个,你也可以在 GitHub 上找到源代码。
要理解编译器为 switch
做了什么,我们必须看生成的汇编代码。这里谁能大致读懂汇编代码?好的,几乎是每个人。当你回答这个问题时,谁想到的是 arm64 汇编代码?好的,没人。很好。那么让我们非常快速地概览一下 arm64 汇编代码,因为这是我运行它的机器。别担心,它比 x86 容易多了。
我们有一些寄存器,x0
到 x30
,它们是 64 位的。我们也可以通过使用 w
代替 x
来访问低 32 位(例如 w0
访问 x0
的低 32 位)。然后要提到的重要东西是内存访问。寻址模式:
例如,当我们有一个地址在寄存器中(比如
x0
),我们想读取那个地址的值,我们把它放在方括号里:[x0]
。这本质上是指针解引用。我们也可以做带偏移量的指针解引用。例如,我们可以读取
x0
加 42 处的地址:[x0, #42]
。这个偏移量是以字节为单位的。我们可能还想永久性地加 42。我们有预增量和后增量:
[x0, #42]!
(预增:先加偏移量再访问)和[x0], #42
(后增:先访问再加偏移量)。如果你想读东西的同时递增指针,这非常方便。最后,当你想读,比如说,索引到一个 8 字节对象的数组时,你想把偏移量乘以 8。为此,你可以使用带左移的索引操作:
[x0, x1, lsl #3]
。这本质上是,例如,对一个 64 位整数数组进行数组访问。
有了这些,我们可以看看 switch
语句的汇编代码。循环开始于某个标签,然后我们读取当前操作码。我们首先比较它是否等于 0。如果是 0,那么我们要跳转到 push
。然后我们比较它是否等于 1。如果是 1,我们要跳转到 add
,依此类推。如果没有匹配的,我们就跳转到 exit
。然后每个 push
指令或其他指令只有几条汇编指令。
例如 push
:我们从字节码中读取下一个字节(有效载荷),将其存储在一个寄存器中,然后将其存储到 vstack(通过 vstack 指针)。在做递增时,之后我们用后增量指令直接递增 vstack 指针(例如 str w2, [x1], #4
表示存入后指针加 4),然后我们将指令指针递增 2 并跳回循环开头。当我们到达 exit
时,我们加载最终值并从函数返回。非常直接的代码。
事实上,这太直接了,编译器实际生成的并不是这样。因为它所做的是将操作码与 0 比较,然后与 1 比较,然后与 2 比较,然后与 3 比较。这就像是操作码数量的线性比较。编译器可以更聪明一点。它可以进行二分查找。所以在汇编代码中会有点乱。这里是 C 代码形式:它首先检查操作码是否小于 4?如果是,好吧,我们处理这个情况。我们知道它在 0 到 3 之间。然后我们比较是否等于 1。然后我们只缩小到两种情况。我们跳转到 push
或 add
。这本质上是对我们想要跳转的标签进行二分搜索。它有 log n
次比较,这好一点。所以谢谢编译器。
让我们看看实际计算有多快。我们测量执行 FIB(35)
所需的时间。结果是 460 毫秒。这快吗?谁认为 460 毫秒对于 FIB(35)
是快的?谁认为他们完全没概念因为我根本没提供任何上下文?对吧?那么我们实际上如何对事物进行基准测试?我们需要进行多次运行,因为有噪音(波动)。然后我们可以报告平均值和标准差,以精确量化噪音水平。然后我们可以与别的东西比较。对吧?单独看性能数字是没有意义的。我们需要一个不同的实现。
我发现一个非常方便的工具是 Hyperfine
。这是一个命令行基准测试工具。你给它多个 shell 命令,它会多次执行每个 shell 命令,报告范围、平均值,然后告诉你哪个更好。这是一个非常方便的工具,我用来做所有的基准测试。然而,我实际上不会给你标准差等等。我只会给你平均值,因为我想让你做自己的基准测试来进行定性的性能分析。
我们只有一个基准测试,但为了这个演讲,我们就说这太慢了。我们想让它更快。那么我们如何让它更快?如何优化?第一步是我们需要猜测一个问题,比如什么实际上慢了。这可以是一个有根据的猜测,也可以是一个随意的猜测。那么我们就猜一下,说这里的问题是分支预测(branch prediction)。我这么说是什么意思?
CPU 分阶段执行汇编指令。它首先需要获取下一条要执行的汇编指令的内存。然后它需要弄清楚那到底是什么。然后它可以执行它。然后它可以将结果写回内存。关键的是,这是并行完成的。所以当它在获取内存时,当它在执行一条指令时,它已经可以开始获取下一条了,等等。因为这是由 CPU 的不同部分完成的,所以可以并行。这样我们得到了很好的加速。
然而,这只有在 CPU 知道下一条指令是什么时才能并行完成。关键的是,如果有一条分支指令,它就完全不知道下一条是什么。那么 CPU 做什么?它只是猜。它预测分支是否会被采纳,以预测下一条指令。如果它做出了正确的预测,它就高效地利用了流水线(pipeline)。所以我们碰巧预测了实际将要执行的指令,因为部分执行了它。这是好的。如果它做出了错误的预测,那就糟了,因为它已经部分执行了它。所以我们需要停止一切,回滚流水线,并开始执行真正的指令。所以我们总是想猜对。我们想看看我们如何预测历史。这本质上是记住历史。对吧?对于每个汇编分支,它会记住,比如,大多数时候我们走了这条路,所以我们就预测这次也走这条路,并开始推测性地执行它。
然而,我们在循环中做的是,每次循环迭代,我们执行不同的字节码指令。对吧?那么它应该怎么预测?因为你每次都在做完全不同的事情。分支预测器在这里帮不了我们什么忙。所以我们就说那是我们的猜测:分支预测是个问题。
下一步是测量,以验证它确实是问题的根本原因。一种测量分支预测错误(branch misses)的方法是使用 Linux 的 perf stat
。这是一个允许你查询硬件性能计数器的工具。特别是,我们本质上可以查询在执行字节码解释器期间,有多少分支被采纳,有多少分支被错误预测。然后我们得到那个结果。我们有,比如总长度是分支总数,黄色部分是错误预测的分支。我们有那么多分支预测错误。这多吗?单独看,这又是没有意义的。我们需要比较的东西。但为了这个演讲,我们就说这很多。我们想把黄色部分降下来。
最后一步,我们如何真正解决这个问题?一种方法是切换到不同的调度技术。我们要看的第一个叫做线程化(threading)。它叫线程化,但它与线程(threads)无关。这只是调用方式的问题。是的,不,但……它使用了相同的隐喻。这就是为什么它叫线程化,但它与多线程无关。
想法是使用一个函数指针数组。我们为每个可能的操作码准备一个单独的函数来执行它。所以我们有一个执行 push
的函数,一个执行 add
的函数,然后我们有一个包含这些函数的大数组。然后在我们的循环中,我们直接跳转到那个函数指针并调用它。
有两点需要注意:
在执行查找时我们没有做范围检查,如果我们不控制传给我们的字节码,这非常糟糕。所以你不应该这样做。但再次,我们忽略它。
例如,
push
修改 vstack 指针。所以我们需要通过引用(by reference)传递 vstack 指针。同样,我们需要通过引用传递指令指针。所以我们需要确保修改有效。
这是循环生成的汇编代码:我们首先为所有这些东西创建引用(reference)。例如,指令指针存储在栈指针偏移 24 的位置。所以我们把它加载到寄存器 x0
。现在我们有了指令指针的引用。同样,我们为其他参数做设置。然后这条 LDR
指令:它获取指令指针(*ip
),当前操作码(*ip
),并以此索引到执行表(execute_table
)中,该表的基地址在循环开始前已加载到地址 x20
中。所以现在 x8
存储了函数的地址。然后下一条指令用我们之前设置的参数调用该函数。当函数返回时,我们将下一条操作码加载到寄存器中,检查是否完成。如果没有,我们就返回并重复这个过程。
关于使用函数指针进行调度的技术有什么问题吗?好的。那么我们来基准测试一下。
switch
是黄色的(460ms)。这个是绿色的(约 510ms)。它明显更慢。但它不是显著地慢,因为在基准测试中,我只是随便找了个网站生成条形图。而且那个条形图实际上不是从零开始的。对吧?它从 450 开始。所以那个大柱子代表 75% 的差异(因为柱高只代表从 450 到 510 的部分,而非从 0 到 510)。所以像许多出版物使用条形图时一样,你应该注意这点。这是实际的(如果从零开始),慢这么多(510ms vs 460ms)。那么这里出了什么问题?我们实现了不同的调度技术。我们没有那些分支了。为什么我们更慢了?嗯,主要原因是这里的函数调用开销。
让我们比较一下实际执行 push
指令的汇编代码。如果 execute_push
按值(by value)接收所有东西,它知道,例如 x0
存储了指令指针。我们可以直接加载它,从它那里获取有效载荷,并加载到寄存器。然后我们可以将其存储到 vstack 指针(在 x1
)。我们可以直接操作它。最后,递增 x0
。
但我们不是按值传递。我们是按引用传递。所以 x0
不包含指令指针。它包含一个对指令指针的引用,换句话说,一个指向指令指针的指针。所以汇编首先必须将实际的指令指针加载到寄存器中。然后我们才能进行操作。对于 vstack,同样需要加载 vstack 指针,进行实际操作,然后将其存回内存。同样,最后当我们递增它时。所有这些内存访问和开销累积起来了。原因是 CPU 只能在东西在寄存器中时工作。当我们有一个引用时,它们不在寄存器里,它们在内存中。所以我们需要先将它们加载到内存(应为寄存器),修改,然后再放回去。这增加了开销。
所以那没成功。那么让我们尝试另一种解决方法。优化就是这样做的:你猜一个问题,你尝试修复它,然后你重复直到修复好。
我们可以做的另一种调度技术是令牌线程化(token threading),有时也叫间接线程化(indirect threading)。这里的想法是利用 GNU 扩展:计算跳转(computed goto)。你们都熟悉常规的 goto
,你有一个语句的标签,然后你可以跳转到那个标签。如果你想写可读的代码,这真的很好。
使用计算跳转,我们有两个扩展:
我们可以使用这个奇怪的双与号操作
&&
来获取标签的地址。这给了我们一个标签的地址。然后我们可以跳转到那个存储的地址:
goto *address
。是的,我们在goto
前面解引用了一个void*
指针,但这没问题。
现在的想法是,不是拥有一个函数指针数组,而是拥有一个标签数组。所以我们为每个指令处理程序准备一个标签,它包含代码,然后我们为它创建一个表。然后不是调用函数,我们只是跳转到那个标签。在我们执行完一个特定的字节码处理程序后,我们继续,回到循环,并开始执行下一条。
我们看看生成的汇编代码:它看起来非常相似。在循环开始时,我们加载当前操作码,然后我们索引到一个表,然后我们跳转到那里。然后在每条指令结束时,我们用适当的量递增指令指针并跳回循环开头。
这实际上,再次,不是编译器生成的汇编代码,因为它看这个代码并想:好吧,在 push
之后,我们做 add
,然后我们跳回循环。然后我们做一些事情,然后我们立即跳转到别的地方。这有点傻。让我为你修复它,并直接跳到正确的地方开始。所以它在每条指令之后复制了开头的调度代码。所以每条指令之后,它会直接跳转到下一条指令,而不是回到循环。
编译器能够做的另一个优势是:之前我们得到 add
,然后我们有一个分支(b
),然后我们加载 x0
。使用后增量方式(ldr
加 post-index
),编译器能够将两个操作合并为一次(例如 ldr x8, [x0], #1
加载操作码并自动递增 x0
)。所以我们在一条指令中同时完成了加载和递增,然后直接跳转到下一个位置。
因为编译器总是会生成那种代码,无论我做什么,我都没法阻止它生成这种代码。这也是在 C 代码中编写令牌线程化调度的规范方式。所以你有每个指令的标签,然后在每条指令之后你都有一个 goto execute_table[*ip++]
,并且只有一个这样的语句来启动整个事情。现在你看不到循环了。相反,每条指令跳转到下一条,下一条再跳转到下一条,直到我们到达 exit
,在那一刻我们返回。这就是为什么 goto
会导致控制流有点复杂,因为这是一个循环,但你永远看不到一个循环。你只是直接在整个函数中跳来跳去,这使得弄清楚指令实际执行的顺序变得非常棘手。
有什么问题?是的。这能在没有扩展的标准 C++ 中实现吗?不太能。
好的。我们来基准测试那个技术。看看那个。我们实际上让代码更快了。我们现在几乎是 switch
的两倍快,并且明显比函数指针方法快。这是因为我们现在使用表进行高效调度,但没有按引用调用的开销。这很好。
然而,我们一开始说,好吧,分支预测是个问题。所有这些跳转,它们仍然是分支。字面上,我们在任何地方都在做分支指令。那么让我们看看分支情况。上面是 switch
,下面是我们刚刚做的。我们明显有更少的分支,这说得通,因为我们每条指令之后只有一个分支,而不是二分搜索。但你也得到了绝对更少的分支预测错误,所以 CPU 能更好地预测它们。为什么?
嗯,在 switch
之后,我们只有一个调度代码。我们有一个选择下一条字节码指令的调度代码,这意味着只有一个地方,一个分支指令,CPU 可以进行分支预测。对吧?它学不到太多东西。所以它唯一能学的就是哪个指令最有可能被执行。而每个指令被执行的几率大致相等。所以这没帮上忙。
然而,我们现在在每个字节码指令之后都有单独的调度点。CPU 可以单独学习所有这些点。这真的很好,因为它本质上学习的是:在 push
之后,我们最有可能有一个 sub
指令。所以它可以预测在 push
之后,我们将去 sub
。然后在 sub
之后,它知道,好吧,我们在调用 fib
,所以我们有 recurse
指令。对吧?所以它可以单独预测接下来最常发生的是什么,并进行更好的分支预测。
所以我们说我们让它快了一倍,但我们就说这还是太慢。我们如何让它更快?为此,我们需要更多信息。一种方法是使用 perf record
。这将执行程序并频繁停止 CPU,查看当前在什么函数中。所以它记录本质上最常执行的函数。如果你看结果,我们 99.85% 的运行时间花在 dispatch
函数中,这没错。但也像没用,当然,那就是我们在做的事情。我们在做调度。我们没有其他函数。
理想情况下,我们希望有单独的函数。如果我们使用函数指针,我们会有单独的函数并得到更详细的细分。但我们不想因为单独的函数而有内存开销。那么我们能将两种技术结合成一种,获得两者的优点吗?这是可能的,使用尾调用(tail calls)。
那么这里的想法是什么?让我们回到写函数。我们有单独的函数。但为了性能,我们按值传递(by value)。现在,当然,当我们修改指令指针时,这对我们的调用者无关紧要,因为我们有一个单独的副本。那么我们就不要返回到调用者。相反,让我们直接进入下一条指令,类似于计算跳转发生的情况。
现在我们可以修改指令指针,然后用新值调用另一个函数。同样,我们仍然有一个函数指针表。所以有点像两种技术的混合。有一点要指出:让我们看看调用栈。每次我们进行一个调用,我们本质上需要将程序计数器(返回地址)压入调用栈,类似于我们的 recurse
指令。这样我们就知道返回到哪里。然后 return
只是弹出那个程序计数器并跳回那个位置。
所以我们从 dispatch
开始,它将程序计数器压入 CPU 栈。然后我们跳转到第一个 execute_push
,它将程序计数器压栈,跳转到下一个,压栈,跳转,等等,直到我们最终到达 exit
指令,在那一刻我们弹出程序计数器并返回到我们的调用者,弹出程序计数器,返回到调用者,等等,直到我们最终回到 dispatch
。
当然,这实际上不会发生。实际会发生的是:我们启动 dispatch
,调用 execute_push
,调用 execute_add
,调用下一条,直到栈溢出。因为它每次执行一条指令都在调用新函数,而我们要执行数百万条。
然而,幸运的是我们不需要那样,对吧?我们需要保存程序计数器以便能够返回到调用者。但然后我们的调用者只是返回到它的调用者,因为我们必须返回。但当我们返回时,下一步就是返回到调用者。那么为什么不直接回到祖父调用者(grand caller),省掉中间的步骤呢?这就是尾调用背后的想法。
所以我们从 dispatch
开始。这是一个实际的 call
,压入一个程序计数器(返回地址)。然后我们只是跳转到第一个 execute_dup
。然后我们跳转到下一个。这样我们不会不断往调用栈添加东西。最后,当我们到达 exit
指令时,我们有一个单独的 return
。那个单独的 return
只是跳转到调用栈顶部的程序计数器,也就是来自 dispatch
的那个。所以我们不必经过所有愚蠢的中间步骤返回,我们可以直接跳回到调度代码,这更方便,也更安全,避免了栈溢出。
如果有一个 return
语句是那种形式(return next_function(...);
),这也是编译器可以做的常见优化。然而,我们实际上很可能需要这种优化。没有它,我们会有栈溢出。所以我们需要一种好方法来请求编译器即使在调试代码中也总是生成尾调用。Clang 提供了一种方法:clang-must-tail
属性。你可以用它来注解一个 return
语句,以强制编译器生成尾调用。这甚至在没有任何优化的调试模式下也会发生,编译器将总是生成尾调用。我真的很喜欢这个属性。不幸的是,它只在 Clang 中可用。它在 GCC 中不可用。它在 MSVC 中不可用。只在 Clang 中可用。
所以,在每条指令的末尾,不是正常的调用,我们将有尾调用。这将确保我们只是跳转到下一条指令,就像使用计算跳转一样,但没有调用栈。事实上,如果你看汇编代码,我们有调度代码,它加载查找表然后跳转到第一个条目。然后在 push
结束时,我们再次加载表,然后我们跳转到下一个。这与我们使用计算跳转得到的汇编代码完全相同。唯一的区别是现在表是全局的,所以它需要先从全局内存加载它们。但除此之外,是完全相同的调度代码。
是的?所以澄清一下上一张幻灯片,为什么不能要求 GCC 启用尾调用优化(TCO)?首先,你不能要求 GCC 启用尾调用优化,因为 GCC 不提供那个属性(只有 Clang 有)。GCC 不提供这个属性是因为 GCC 支持一些深奥的硬件,这些硬件无法做尾调用。这是像 LLVM 特有的吗?是的,这是 LLVM 的东西。这就像你要求编译器生成一个尾调用,如果它做不到,你的代码就不会编译。所以这是一个保证。如果它编译了,那就是一个尾调用。我猜 GCC 不支持这个。有一些限制,比如你尾调用的函数必须具有完全相同的签名。函数中不能有任何析构函数等等,但如果不符合,它就不会编译。
是的。还有其他问题吗?是的?你能尝试在 GCC 中使用长跳转(longjmp)之类的东西来模拟这种行为吗?我没有尝试过在 GCC 中使用长跳转来模拟那种行为。我主要是为一个业余爱好项目实现的,而且我满足于只使用 Clang,对吧?所以我就没有尝试解决其他编译器缺乏功能的问题。
好的。那么让我们看看当我们基准测试那个技术时会发生什么。橙色的线在底部,我们大致相似,和计算跳转性能相同。由于某种原因我们甚至更快了,但我不知道为什么更快。我们本质上在做同样的事情。分支预测错误的数量也相似,不意外。所以我们本质上只是用尾调用实现了一个计算跳转,这让我回到那个问题。是的,你可以在没有计算跳转的情况下做,但你需要另一个编译器扩展。所以我不知道那是否实际可行。
然而,一个大问题是,既然我们现在实际上有了单独的函数,如果我们让 Perf 做分析,我们实际上得到了不同指令处理器的细分。所以我们可以看到 16% 的时间实际上花在了 push
上。所以现在我们知道,好吧,我们可能想优化 push
。例如,我们经常 push 1
。所以让我们添加一个单独的指令只压入 1,这可以优化它并降低程序运行时间,这真的很有用。
好的。让我们谈谈 register
关键字。register
关键字在 C 中可用。你可以要求 C 编译器将这个变量保存在寄存器中。正如我们所看到的,CPU 只能在值位于寄存器中时处理变量。所以我们可以某种程度上要求 C 编译器为我们做这个。
然而,现代编译器会自动为你做这个。所以我们实际上不需要告诉编译器请把指令指针放在寄存器里,比如用 register
关键字。现代编译器会为你做这个,这就是为什么 register
在 C++ 中没有意义。
但是,现代编译器并不总是为你做这个。这是 Mike Pall(LuaJIT 作者)的一段话。Mike Pall 是 LuaJIT 背后的作者。除了 Lua 的即时编译器(JIT),LuaJIT 也有一个字节码解释器。这个字节码解释器是用汇编写的。在这封邮件中,他解释了为什么他用汇编写它。例如,他说他可以使用直接/间接的每个指令调度实现(direct-indirect-perget)的计算跳转,我们做了。这复制了标签和调度以实现性能,如我们所见。但问题之一是寄存器分配器(register allocator)只能单独处理每个代码段(指每个 case
或标签块),并且会做得很差。没有办法给它一个目标函数,比如我想在每个 goto
之前保持相同的寄存器分配。
我们不希望在执行时在寄存器之间不断移动东西。我们希望为公共变量保持相同的寄存器分配。例如,假设我们想确保指令指针总是在 x0
中,而不是有时在 x1
中并来回移动。
使用计算跳转,没有办法强制做到这一点。然而,我们没有使用计算跳转。我们使用的是函数调用。而对于函数调用,有调用约定(calling convention)。在汇编层面,函数调用只是跳转到一个地址。那么参数在哪里传递?它们在寄存器中传递。在 ARM64 上,我们有八个参数可以用前八个寄存器传递。所以当我们调用一个函数时,我们把第一个参数放在 x0
,下一个放在 x1
,依此类推。然后我们跳转到地址。
所以当我们想某个东西在寄存器里时,我们把它作为参数传递。指令指针是第一个参数,所以当我们调用函数时它会在 x0
中。当然,然后编译器可以做它想做的任何事,移动它、存储在内存里等等。但最后,它调用下一个函数,该函数期望指令指针作为第一个参数,所以它必须把它放回 x0
。这意味着在实践中,指令指针将总是保持在 x0
中,因为我们为什么要移动它?
所以我们可以看到,在代码中,x0
总是指令指针,x1
是 vstack 指针,x2
是 cstack 指针,x3
是字节码。这样,我们可以利用调用约定强制编译器采用特定的寄存器分配。
ARM64 上的标准调用约定给我们八个寄存器。所以我们可以用八个参数在寄存器中保存八样东西。在 x64 Linux 上,我们只有六个寄存器,更少。在 Windows 上,只有一半,即四个,这有点不幸。所以在 Windows 上,我们只能在寄存器中保存四样东西。
然而,我们不需要使用标准调用约定。我们是唯一调用那个函数的人。没有其他人调用。所以你可以要求编译器生成一个不同的调用约定,一个给我们更多寄存器的。其中一个就是 __attribute__((regcall))
。使用 GNU regcall
,有一个调用约定,它尽可能多地用寄存器传递参数。这样,在 Linux 上,我们得到 12 个寄存器(用于参数),在 Windows 上得到 11 个。在 ARM 上,它什么也不做,因为我们已经有八个(用于参数)。所以如果我们需要超过四个东西在寄存器中,我们现在可以用 __attribute__((regcall))
来注解它。
是的?Clang 也支持 regcall
吗?是的,Clang 支持。是的。是相同的名字吗?可能在不同的名字下?我不知道。MSVC 不支持尾调用,所以… 是的?这有什么缺点吗?有性能影响吗?是的,所以性能影响是在一个函数内部,你拥有的临时寄存器更少了。对吧?所以如果你需要临时寄存器,你就没那么好了。但是如果你实际处理的唯一东西就是那些参数值,那么就没问题。所以这就是为什么默认不开启?是的,默认不开启,因为通常,一个函数会使用临时值做实际的事情等等。但在这里,所有函数都只有大约三条指令,对吧?
Daisy?为什么 ARM64 忽略它?呃,我猜是因为它们已经给了八个寄存器(用于参数)。它只是… 它是 x86 的属性,对吧?但它可以… 它可以用更多寄存器,比如还有其他寄存器。是的。
是的?register
关键字被弃用的一个原因是,如果你试图把你的寄存器都绑定住,优化器就无法工作了。是的。所以它真的、真的会损害性能。它会… 除了你这里的例子,最好不要尝试那样做。是的。因为即使你不得不从内存重新加载,也可能比不得不因为没有可用寄存器而变通处理更快。是的。所以,一般来说,通常编译器比你知道什么该放在寄存器里。但这是一个非常特殊的情况,我们想自己知道得更好,并且精确地生成我们想要的汇编。所以我们可以用那种技巧来可靠地强制编译器不做愚蠢的事情。
好的。那么,Mike Pall 的邮件继续。列举用汇编编写解释器循环的优势。例如,你可以为所有字节码指令保持固定的寄存器分配。嘿,我们也能做到。使用参数,我们可以保持固定的寄存器分配。你可以在快速路径(fast path)中将所有东西保存在寄存器中,只在慢速路径(slow path)上溢出(spill)和重新加载。你可以将慢速路径移到其他地方,以帮助提高指令缓存(iCache)密度。
那是什么意思?那么什么是 iCache?CPU 执行的汇编指令存储在内存的某个地方。内存访问很慢。所以 CPU 有缓存。它有一个专门用于当前指令的缓存,指令缓存(iCache)。然后它会预取指令来填充它。然而,这意味着你不想用你将要执行的指令污染它。对吧?所以如果你有很少执行的代码,你不想它在 iCache 里。你想把实际执行的代码保持在 iCache 里,因为它只有固定的大小。
那么让我们看一个例子。让我们添加一个愚蠢的指令:print_if_42
。如果栈顶值是 42,它就打印 42。否则,它什么也不做。这个 print
语句在里面,这是一个慢速路径。它要执行系统调用(syscall),做 I/O。这很慢。
让我们改变基准测试。实现相同,运行 FIB(35)
。但在每条字节码的开头做 print_if_42
。这将什么也不做,因为 n 总是小于 42。所以这不会改变行为。我们只是在里面有一个额外的指令。
当我们这样做时,我们明显变慢了。显著的意思是四毫秒,因为那个条形图不是从零开始的。抱歉要注意那个(指条形图缩放造成的视觉误导)。所以这里的一个因素可能是 iCache。我们把 puts
的代码加载进去了。但因为只是一个函数调用,这可能不是真正的问题。更重要的问题,当你查看汇编时就变得非常明显了。
这是 print_if_42
的整个汇编。所有这些在做什么?在中间和左下角,我们有与 42 的比较。然后我们有一个分支。如果分支被采纳(栈顶是42),我们就调用 puts
。好的。这就是我们实际想做的。然后在最后,我们必须重新加载我们的调用表,并在递增后跳转到下一条指令。
那么所有包围它的其他东西是什么?嗯,这个 puts
调用是一个函数调用。所以它调用了另一个函数。为此,它需要将参数设置到适当的寄存器中。x0
需要是字符串 “42”。所以它在调用前做了设置。但那就意味着我们当前的指令指针需要保存到其他地方。所以我们将 x0
移到 x22
。这将把 x0
保存在 x22
。然而,x22
是一个寄存器,puts
自己可能正在使用。所以它首先需要将 x22
的现有值保存到调用栈上。
所以编译器所做的是,它必须做一大堆寄存器挪动(shuffle),只是为了确保对 puts
的调用不会破坏我们的寄存器。它必须做内存访问,移动寄存器,然后才能调用 puts
。最后,它必须撤销所有操作。这有点傻,因为我们并不总是调用 puts
。我们只在栈顶是 42 时才调用 puts
。
如果编译器只在我们要调用 puts
之前做所有这些挪动,而不是每次调用这个函数时都做,那会好得多。同样,有一种方法可以调整编译器来做到这一点。我们不直接调用 puts
,我们调用另一个函数来做这件事。所以如果栈顶值是 42,我们尾调用(tail call)另一个函数。那个函数打印 42 然后继续执行。
所以我们某种程度上在这里复制了代码。现在,execute_print_if_42
没有任何函数调用了,对吧?它只有一个到另一个函数的尾调用。所以 print_if_42
的汇编代码,它只是我们期望的样子:我们比较它是否等于 42。如果是,我们就跳转到 print_input
(做打印的辅助函数)。否则,我们继续(执行下一条指令)。
然后 print_input
的汇编代码,它包含所有的寄存器挪动。它做所有的设置。它做所有的内存访问。它做所有慢的事情。这样,我们完全消除了实际 print_if_42
函数中的开销。我们又得到了相同的性能。
是的,原则上,没有什么能阻止编译器在分支内部做那个(把慢路径移出)。但我想编译器只知道:当我们调用一个函数时,我们需要保存寄存器。它不看那个函数调用发生的频率。所以它总是会在开头做保存,在结尾做恢复。而通过使用尾调用,我们没有任何函数调用。我们把它们移到别处,就得到了快速的代码。
现在,重要的是,因为我们使用的是尾调用,我们实际上不能 return
,对吧?在打印完 42 之后,我们尾调用了别的地方。现在考虑这个例子。假设我们想防止栈溢出。当我们到达调用栈末尾时,我们想分配更多内存。这很慢,所以我们把它移到一个单独的函数里。在我们分配完内存后,我们想返回到调用者。但在尾调用中,我们没有那个信息。我们已经丢失了所有关于我们从哪里来的信息。这正是尾调用的全部意义。所以,我们必须尾调用回我们来的上一条指令的开始位置(即重新执行当前指令)。再次做那个,如果这次条件不再成立,就继续,对吧?你不能写一个 return
,因为 return
会一路返回到调度循环,因为那才是实际的父函数。否则,我们一直在跳来跳去。所以你必须记住这点。
关于这个有什么问题吗?
好的。所以 clang-must-tail
,我真的很喜欢它。它通过函数调用实现了线程化调度。这算是我对这个演讲想得出的结论。所以你可以使用 clang-must-tail
。你可以用 perf record
获取详细的性能记录。你可以强制编译器使用特定的寄存器分配,这真的很酷。但你需要记住,你不能在尾调用函数内部有任何函数调用。否则性能就完蛋了。所以把它们移到别处。还要记住你实际上不能 return
(在辅助函数里需要跳转回主指令流)。有了 clang-must-tail
,你可以调整编译器生成你想要的精确汇编。
这本来是个很好的演讲。我以为我可以展示它。我可以演示它。就像我以这种方式实现了我的解释器。我想分享这个技术,所以我提交了这个演讲。我写好了所有的幻灯片。然后我对自己说,为了确认,让我在我的旧笔记本电脑上也基准测试一下。不只是在 Apple M1 上。
新的基准测试。我的旧笔记本电脑。2016 款 ThinkPad 13,运行 Arch Linux 和 Clang 14。这是我们四种技术的结果。正如预期,switch
是最快的。什么?!
但它是在做你的二分搜索吗?是的。我告诉过你。是的。那么让我们看看分支预测错误。使用线程化和尾调用,我们突然有了大量的分支预测错误。差不多所有分支的四分之一都被错误预测了。好的。但为什么?嗯,答案是分支目标预测(branch target prediction)。
在 switch
中,我们有一个条件分支,但目标是固定的。所以我们做一个比较。如果相等,我们去某个地方。我们总是知道固定的目标。比如我们总是想去 push
标签。但我们不知道我们是否会跳转过去。
而在尾调用等情况下,我们有一个查找表。所以我们加载我们想去的地方的地址,这是可变的。然后我们总是跳转。我们有一个无条件分支,但我们不知道我们要去哪里。所以在 switch
中,我们知道去哪里,但不知道是否会实际走那条分支。分支目标预测的工作就是确定一个分支要去哪里。之前我忽略了分支预测器,它决定分支是否被采纳。但一个无条件分支总是被采纳。然后我们只需要弄清楚我们要去哪里。显然,我的旧笔记本电脑的分支目标预测器真的很烂。
只是一个开始的问题,但旧笔记本电脑是 x64,对吧?所以不是这种架构… 是的,是的,是的。旧笔记本电脑是 x64,但我还在用 ARM 幻灯片因为它更易读。那么我们该怎么做?我们似乎需要解决我的旧笔记本电脑必须做的分支目标预测问题,才能获得任何性能。我们仍然可以使用尾调用。因为我喜欢尾调用。它们给你函数调用。你得到调度。你得到寄存器分配。我们只是不能使用查找表。
所以,不是在每条指令的末尾尾调用,我们可以写一个 switch
。现在我们每条指令之后写一个 switch
。然后我们跳转到一个固定地址。这样,我们仍然有固定地址。像那样,我们总是去 push
。但我们不知道我们是否会实际去那里。这样,我仍然得到了好的分支预测(指方向预测),而不需要做任何分支目标预测(指地址预测)。我仍然得到了复制的 switch
,因为我们在每个操作码之后都做。所以我得到了所有的优点。正如预期,我又得到了好的结果,更少的分支预测错误。
所以使用 switch
加尾调用,我们得到了与纯 switch
相似的性能,同时拥有了使用尾调用的所有优点:寄存器分配等等,以及完美的代码(指函数结构)。
所以,是的?为什么 Clang 不使用尾调用优化?Clang 在启用优化(-O
)时会默认进行尾调用优化。啊,好的。是的。但如果我们不做尾调用,我们会有栈溢出。所以如果你在调试时运行未优化的代码,你想要那个属性在里面。所以也许这个演讲的真正结论是:相信编译器来做这种调度,它最懂。对吧?简单的 switch
是最快的选项。
除了,嗯,那实际上不是编译器为 switch
生成的代码。它只为 switch
生成了那种代码(二分搜索),是因为我友好地请求编译器请为 switch
生成这种代码,因为我想演示不同的调度技术。如果我不向编译器传递任何特殊的编译标志,编译器实际生成的汇编看起来像这样。你看过足够多的汇编知道那是什么。那是一个跳转表(jump table)。默认情况下,编译器为 switch
生成一个跳转表,这给了我与其他跳转表实现完全相同的性能,而这在我的旧笔记本电脑上真的很糟糕。
所以不,我们不能仅仅相信编译器会做最好的事,因为编译器也不知道什么是最好的。对吧?我们用了 switch
。我们只是必须传递那个标志(-fno-jump-tables
或类似)才能在我的旧笔记本电脑上获得好的性能。
自从我第一次做这个演讲以来,我有了一个新笔记本电脑。我的新笔记本电脑是 ThinkPad X1 Carbon,带 Intel Core i5。这是在那上面的基准测试结果。为了完整运行一下。这次,正如预期,计算跳转或尾调用是使用查找表的最快实现。所以我想,比如几周前,为了完整起见,禁用那个编译器标志(-fno-jump-tables
),实际基准测试编译器如果只是说“请优化这段代码并做任何事”会生成的原生汇编代码。结果是比计算跳转还快。那么那里发生了什么?
嗯,现在我必须展示 x86 汇编了,因为那是 x86 机器。这是我们手动实现的跳转表的 x86 汇编。本质上是一样的东西。我们首先加载操作码,然后我们去查找表中那个位置。寄存器名称有点古怪,我们把跳转和地址操作结合了(jmp *(%rax, %rcx, 8)
)。所以那是我们手动想要的。
这是编译器为 switch
生成的跳转表。结果是什么?好的,我们加载操作码,然后我们从表中获取地址,然后我们这里有一个 add
,然后我们跳转。好的,所以我们在跳转之前往寄存器里加了东西,碰巧是表本身的地址。所以这有点奇怪。那么这一定意味着跳转表中的地址,它们实际上是相对于跳转表基地址存储的。所以不是绝对偏移量,它们是相对的。但这为什么更好?我们这里有一个额外的 add
。为什么这更快?
嗯,它更快是因为,如果你看进行内存访问的指令,手动实现的 execute_table
,它是 8 字节(64位地址)。但如果是 switch
生成的,它只有 4 字节(32位相对偏移)。编译器生成了一个跳转表,但使用 4 字节的相对偏移,而不是 8 字节的绝对偏移。因为它们更小或者其他原因,这在我的笔记本电脑上碰巧更快。所以我真的不知道这个演讲的结论会是什么。
因为,在旧笔记本上我们不能信任编译器,在新笔记本上我们可以信任它。而尾调用技术仍然非常好。所以也许结论就是:在做任何优化之前,先在目标硬件上进行基准测试,因为你永远不知道你会得到什么。
我想指出,即使我们有尾调用,switch
不使用尾调用,我们仍然可以将该技术与尾调用一起使用。我们可以手动实现一个相对偏移表,只需使用一些 reinterpret_cast
在函数指针之间转换,存储相对地址,让它们更小等等。所以我们可以手动实现编译器所做的技术,仍然获得良好的性能。所以尾调用本身,作为一种调度技术,它们与编译器做的任何其他事情都是兼容的,正如我所展示的。
既然我们还有不少时间,我想介绍一些更高级的调度技术。我们看了计算跳转和尾调用。本质上,它们做同样的事情。我们只是在一个中有尾调用,另一个有跳转。查找表首先包含标签,这里包含地址。然后我们使用操作码索引到数组中。如果我们不进行间接寻址,而说操作码本身是处理程序的地址会怎样?这是直接线程化(direct threading)。它可以用两种方式实现。
在第一种(计算跳转)中,我们有字节码操作码,我们有一堆地址,然后我们直接去那个地址。它们不能是 const
,因为你不能用函数内部标签的地址初始化全局变量。所以调度做的第一件事是初始化所有这些常量,然后它们就不再修改了,这有点烦人。
用尾调用更好。在那里,push
不是一个枚举,而是一个函数。然后指令指针直接存储函数指针,所以我们可以直接跳转它们。这非常好。如果你看生成的汇编,我们为了执行 push
,做所有的逻辑,然后我们将 x4
递增 16(因为我们有两个八字节项:操作码和参数),然后我们直接跳转过去(jmp *%r8
或类似),这真的很好,对吧?这是目前最好的调度代码,因为我们只有一个单独的跳转。
缺点是现在操作码不再是一个 8 位的枚举,而是一个 64 位的地址。这意味着它可能在这个(旧?)笔记本电脑上更慢,因为当我们有 32 位相对地址时我们获得了性能提升。它仍然需要分支目标预测,所以它在我的旧笔记本电脑上会非常慢,而且它让远程代码执行变得完全微不足道,因为你可以在字节码中写入任意地址,人们就高兴地跳到那里并从那里继续执行。所以从安全角度来看这也非常好。
那么,直接线程化。但假设我们有像这样的代码:我们 push 1
,然后 push 2
,然后我们想 add
。如果我们直接生成像那样的汇编代码呢?所以我们得到 push 1
的汇编代码,我们得到 push 2
的汇编代码,然后我们得到 add
的汇编代码。我们得到所有这些汇编代码块,然后我们把它们按我们想要的顺序复制粘贴在一起。这非常好,因为最快的调度就是没有调度。只是把汇编代码块,把它们直接放在前面。然后我们得到汇编代码。但这需要即时编译(JIT compilation)。你必须做一点轻量级 JIT。我们实际上并没有生成高效的汇编代码。我们仍然只是生成常规字节码解释器相同的汇编代码。我们只是把它们连接起来,没有任何调度代码。
这样,我达到了这个演讲的实际结论,再次强调:在进行任何优化之前,先在目标硬件上进行基准测试。现在我们可以,我很高兴听取更多问题或意见,我们可以讨论高级技术。谢谢。
(问答环节)
在例子中,你把所有东西都作为参数传递,你也把调度表(dispatch table)作为参数传递了吗?是的,我也可以考虑把调度表作为参数传递。我们看到它必须先把它从全局内存加载,我们可以消除那个并获得性能提升。但那么多参数可能放不下(寄存器不够)。好的。
是的。在泛型编程(generic programming)的情况下,比如使用变体(variant)的访问操作(visit operation),你如何应用这些技术?例如,如果你想实现变体的访问操作,你会有一个操作码,一个整数,告诉你变体里是哪个备选项等等。是的。首先,我不相信这种技术真的最适合那种情况,因为它是为你有一个循环而设计的,而不是一个单独的 switch
。然后,当你传入 lambda 并想分发到 lambda 时,高级的那些(尾调用/直接线程化)需要修改你要调用的函数,这在 C++ 泛型代码中不太可能。所以那里我会只写 switch
,也许要求编译器生成或不生成跳转表,取决于你实际运行的计算机,并希望它做最好的事。它在我测试的三台电脑中的一台(新x86)上做到了,但,是的。
我见过编译器总是生成跳转表,如果你做类似…的事情,例如,保留函数调用参数的顺序,你甚至可以有不同数量的函数调用参数。但…而且,在 GCC 和 Clang 之间,关于它们何时会生成跳转表,曾经有很大的差异。我想你只用 Clang 做了你的实验?是的。正确吗?是的,我只用 Clang 做了我的实验。因为 GCC 的行为有很大的不同。是的。而且我认为…(听不清名字)在去年的 CppCon 上,在泛型编程的背景下,就如何做这种分发做了广泛的评论。是的,呃,我不熟悉那个演讲,但是的,在做任何优化之前先基准测试,因为也许编译器已经做了正确的事。是的。
关于那个的说明:switch
表阈值(switch table threshold)是完全可配置的。在 GCC 中用 --param switch-table-threshold=
。你可以设置表的大小,你想从二分搜索切换到跳转表。你可以调整那个。是的。我使用了一个类似的标志(-fno-jump-tables
)来尝试不生成跳转表,因为我想要一个不同于跳转表的调度技术。是的。
在 Clang 中,对于一个数字常量的普通 if-then-else
链,它会被转换成 switch
语句或一个… 是的。是的。精确地说。是的。是的。但是,呃,据我所知,我从未学会如何控制 GCC 将一个 if-then-else
链,一个级联的 if-then-else
,转换成一个跳转表。但是,谢谢 Joe。下次我会尝试你谈论的参数。是的,也有一个关于 LLVM 的演讲,几年前来自…(听不清名字),讨论了 LLVM 在 switch
上做的优化。比如有 if-then-else
二分搜索,跳转表,还有一些像位掩码(bitmask)的技巧。当你给它一个 switch
时,它通常很聪明地找出最快的方式。所以也许我会把它加到… 所有幻灯片都在那里。另外,这是好的。我也许会添加那个演讲链接。
好的,还有别的吗?是的。你试图实现的 VM 是什么?我实现的 VM 是一个字节码解释器,旨在具有慢启动时间(?原文 slow startup time
,可能指快速启动)。所以它可以用在编译器中做常量表达式求值。这是我实现的目标项目,这也是为什么我没有实现 JIT 编译器。
好的。非常感谢大家参加演讲。 谢谢。谢谢。 . . 谢谢。