过去几天这个问题一直困扰着我,所以我决定做更多的调查。我最初的回答集中在两个测试之间数据值的差异上。我的断言是,如果其中一个操作数为零,处理器中的整数乘法单元会在更少的时钟周期内完成一次操作。
虽然有明确记录的指令以这种方式工作(例如整数除法),但有非常强烈的迹象表明,在现代处理器中整数乘法是在恒定数量的周期内完成的,不管输入。最初让我认为整数乘法的周期数可能取决于输入数据的英特尔文档中的注释似乎不适用于这些指令。此外,我使用相同的指令序列对零和非零操作数进行了一些更严格的性能测试,结果并没有产生显着差异。据我所知,harold's comment on this subject 是正确的。我的错;对不起。
在考虑是否有可能完全删除此答案,以免将来误入歧途,但我意识到在这个主题上还有很多值得说的事情。我还认为至少还有另一种方式可以使数据值影响此类计算中的性能(包括在最后一节中)。所以,我决定重组和增强我最初答案中的其余信息,开始写作,并且......有一段时间没有完全停下来。是否值得由您决定。
信息分为以下几个部分:
- 代码的作用
- 编译器的作用
- 处理器的作用
- 你能做些什么
- 未回答的问题
代码的作用
主要是溢出。
在第一个版本中,n 在33rd 迭代中开始溢出。在第二个版本中,随着移位,n 在52nd 迭代中开始溢出。
在没有移位的版本中,从128th 迭代开始,n 为零(它“干净地”溢出,在结果的最低有效 128 位中只留下零)。
在第二个版本中,右移(除以4)在每次迭代中从n 的值中取出更多的因子,而不是新操作数带来的值,因此移位导致某些迭代的舍入.快速计算:从1到128的所有数中二的因数总数等于
128 / 2 + 128 / 4 + ... + 2 + 1 = 26 + 25 + ... + 2 + 1 = 27 - 1
而右移所取出的二的因数(如果它有足够的取出量)是 128 * 2,是两倍多。
有了这些知识,我们可以给出第一个答案:从 C++ 标准的角度来看,这段代码大部分时间都在未定义的行为领域,所以所有的赌注都没有了。问题解决了;现在停止阅读。
编译器做了什么
如果您仍在阅读,从现在开始,我们将忽略溢出并查看一些实现细节。在这种情况下,“编译器”是指 GCC 4.9.2 或 Clang 3.5.1。我只对 GCC 生成的代码进行了性能测量。对于 Clang,我查看了一些测试用例的生成代码,并注意到了一些我将在下面提到的差异,但我实际上并没有运行代码;我可能错过了一些东西。
乘法和移位操作都可用于 64 位操作数,因此需要根据这些来实现 128 位操作。首先,乘法:n 可以写成 264nh + nl,其中 nh 和 nl 分别是高和低 64 位的一半。 i 也是如此。所以,乘法可以写成:
(264nh + nl)(264ih + il) = 2128@ 987654354@ ih + 264 (nh il + nl ih) + nl il
第一项在低 128 位部分没有任何非零位;它要么全部溢出,要么全部为零。由于忽略整数溢出对于 C++ 实现是有效且常见的,这就是编译器所做的:完全忽略第一项。
括号仅对 128 位结果的高 64 位贡献位;两次乘法或加法导致的任何溢出也会被忽略(结果被截断为 64 位)。
最后一项决定了结果的低 64 位一半中的位,如果该乘法的结果超过 64 位,则需要将额外的位添加到从括号之前讨论过。 x86-64 汇编中有一条非常有用的乘法指令,它可以满足需要:接受两个 64 位操作数并将结果放在两个 64 位寄存器中,因此高半部分可以添加到运算结果中括号。
这就是 128 位整数乘法的实现方式:三个 64 位乘法和两个 64 位加法。
现在,移位:使用与上述相同的符号,nh 的两个最低有效位需要成为nl 的两个最高有效位,在后者的内容右移两位之后。使用 C++ 语法,它看起来像这样:
nl = nh << 62 | nl >> 2 //Doesn't change nh, only uses its bits.
除此之外,nh 还需要移动,使用类似
nh >>= 2;
这就是编译器实现 128 位移位的方式。对于第一部分,有一条 x86-64 指令具有该表达式的确切语义;它被称为SHRD。使用它可能是好是坏,正如我们将在下面看到的,两个编译器在这方面做出的选择略有不同。
处理器做什么
... 高度依赖处理器。 (不……真的吗?!)
有关 Haswell 处理器的详细信息,请参见 harold's excellent answer。在这里,我将尝试在更高的层次上覆盖更多的领域。有关更详细的数据,以下是一些来源:
我将参考以下架构:
我有在 IntelSB 系统上获取的测量数据;我认为它足够精确,只要编译器不采取行动。不幸的是,当使用如此紧密的循环时,这很容易发生。在测试过程中的不同阶段,我不得不使用各种愚蠢的技巧来避免 GCC 的特性,通常与寄存器使用有关。例如,在编译更简单代码时,它似乎有一种不必要地随机调整寄存器的趋势,而不是在生成最佳汇编时的其他情况。具有讽刺意味的是,在我的测试设置中,它倾向于使用移位为第二个样本生成最佳代码,而为第一个样本生成更差的代码,从而使移位的影响不那么明显。 Clang/LLVM 似乎很少有这些坏习惯,但话又说回来,我看到的使用它的例子更少,而且我没有测量它们中的任何一个,所以这并不意味着什么。为了将苹果与苹果进行比较,以下所有测量数据均指为每种情况生成的最佳代码。
首先,让我们将上一节中 128 位乘法的表达式重新排列成一个(可怕的)图表:
nh * il
\
+ -> tmp
/ \
nl * ih + -> next nh
/
high 64 bits
/
nl * il --------
\
low 64 bits
\
-> next nl
(对不起,我希望它明白了)
一些要点:
- 这两个加法只有在各自的输入准备好后才能执行;在其他一切都准备好之前,最后的添加无法执行。
- 理论上,三个乘法可以并行执行(没有输入依赖于另一个乘法的输出)。
- 在上述理想情况下,完成一次迭代的整个计算的总循环数是一次乘法和两次加法的循环数之和。
-
next nl 可以提前准备好。这一点,再加上下一个il 和ih 的计算成本非常低,意味着下一次迭代的nl * il 和nl * ih 计算可以提前开始,可能在计算next nh 之前开始。
乘法不能在这些处理器上完全并行执行,因为每个内核只有一个整数乘法单元,但它们可以通过流水线同时执行。 Intel 上的每个周期都可以开始执行一次乘法运算,AMD 上每 4 个周期开始执行一次乘法运算,即使之前的乘法运算尚未完成执行。
以上所有内容意味着,如果循环体不包含任何其他阻碍,处理器可以重新排序这些乘法以实现尽可能接近上述理想情况的结果。这适用于第一个代码 sn-p。在 IntelH 上,正如 harold 所衡量的那样,这正是理想的情况:每次迭代 5 个周期由 3 个周期组成,一个乘法,一个周期,两个加法(老实说,令人印象深刻)。在 IntelSB 上,我测量了每次迭代 6 个周期(实际上接近 5.5)。
问题是在第二个代码 sn-p 中确实有一些东西妨碍了:
nh * il
\ normal shift -> next nh
+ -> tmp /
/ \ /
nl * ih + ----> temp nh
/ \
high 64 bits \
/ "composite" shift -> next nl
nl * il -------- /
\ /
low 64 bits /
\ /
-> temp nl ---------
next nl 不再提前准备好。 temp nl 必须等待 temp nh 准备好,以便两者都可以输入到 composite shift,然后我们才能拥有 next nl。即使两个班次都非常快并且并行执行,它们也不只是将一次班次的执行成本添加到迭代中;它们还改变了循环“管道”的动态,就像一种同步屏障。
如果两个班次同时完成,那么下一次迭代的所有三个乘法将准备好同时执行,并且它们不能都并行开始,如上所述;他们将不得不互相等待,浪费周期。 IntelSB 就是这种情况,其中两个班次同样快(见下文);对于这种情况,我测量了每次迭代 8 个周期。
如果两个班次没有同时完成,通常会先完成正常班次(在大多数架构上,复合班次较慢)。这意味着next nh 将提前准备好,因此顶部乘法可以提前开始进行下一次迭代。但是,其他两个乘法仍然需要等待更多(浪费的)周期才能完成复合移位,然后它们将同时准备好,一个必须等待另一个开始,浪费更多时间。在 IntelH 上就是这种情况,由 harold 在每次迭代 9 个周期时测量。
我希望 AMD 也属于最后一类。虽然在这个平台上复合移位和普通移位之间的性能差异更大,但 AMD 上的整数乘法也比 Intel 上的慢(慢两倍多),这使得第一个样本开始时更慢。作为一个非常粗略的估计,我认为第一个版本在 AMD 上可能需要大约 12 个周期,第二个大约需要 16 个周期。不过,如果有一些具体的测量结果会很好。
更多关于复杂移位的数据,SHRD:
- 在 IntelSB 上,它与简单的班次一样便宜(太棒了!);简单的班次几乎与它们的成本一样便宜:它们在一个周期内执行,两个班次可以开始执行每个周期。
- 在 IntelH 上,
SHRD 需要 3 个周期来执行(是的,在新一代中变得更糟),任何类型(简单或复合)的两个班次都可以开始执行每个周期;
- 在 AMD 上,情况更糟。如果我正确读取数据,则执行
SHRD 会使两个班次执行单元保持忙碌,直到执行完成——没有并行性,也没有流水线;它需要 3 个周期,在此期间没有其他班次可以开始执行。
你能做些什么
我能想到三个可能的改进:
- 在有意义的平台上将
SHRD 替换为更快的东西;
- 优化乘法以利用此处涉及的数据类型;
- 重组循环。
1. SHRD 可以替换为两个移位和一个按位或,如编译器部分所述。 128 位右移两位的 C++ 实现可能如下所示:
__int128_t shr2(__int128_t n)
{
using std::int64_t;
using std::uint64_t;
//Unpack the two halves.
int64_t nh = n >> 64;
uint64_t nl = static_cast<uint64_t>(n);
//Do the actual work.
uint64_t rl = nl >> 2 | nh << 62;
int64_t rh = nh >> 2;
//Pack the result.
return static_cast<__int128_t>(rh) << 64 | rl;
}
虽然看起来有很多代码,但只有中间部分做实际工作会产生班次和 OR。其他部分仅向编译器指示我们要使用哪些 64 位部分;由于 64 位部分已经在单独的寄存器中,因此在生成的汇编代码中这些部分实际上是无操作的。
但是,请记住,这相当于“尝试使用 C++ 语法编写程序集”,这通常不是一个非常好的主意。我只使用它是因为我验证它适用于 GCC,并且我试图将这个答案中的汇编代码量保持在最低限度。即便如此,还是有一个惊喜:LLVM 优化器检测到我们正在尝试对这两个班次和一个 OR 做什么,并且...在某些情况下用 SHRD 替换它们(下面有更多关于此的内容)。
相同形式的函数可用于移位其他位数,小于 64。从 64 到 127,它变得更容易,但形式会改变。要记住的一件事是,将移位的位数作为运行时参数传递给shr 函数是错误的。在大多数架构上,可变位数的移位指令比使用常数位数的指令要慢。您可以使用非类型模板参数在编译时生成不同的函数 - 毕竟这是 C++...
我认为在除 IntelSB 之外的所有架构上使用这样的函数都有意义,其中SHRD 已经尽可能快了。在 AMD 上,它肯定会是一个进步。在 IntelH 上则更少:对于我们的情况,我认为这不会有什么不同,但通常它可以在一些计算中减少一次循环;从理论上讲,它可能会使事情变得更糟,但我认为这些情况非常罕见(像往常一样,没有什么可以替代测量)。我认为这不会对我们的循环产生影响,因为它会将事情从 [nh 在一次循环后准备好,nl 在三个循环后] 变为 [都在两个循环后准备好];这意味着下一次迭代的所有三个乘法将同时准备好,它们将不得不相互等待,基本上浪费了移位获得的循环。
GCC 似乎在所有架构上都使用SHRD,并且上面的“C++ 汇编”代码可以用作有意义的优化。 LLVM 优化器使用了一种更细微的方法:它自动为 AMD 进行优化(替换 SHRD),但不是为 Intel 进行优化,它甚至会反转它,如上所述。正如the patch for LLVM 上实现此优化的讨论所示,这可能会在未来的版本中发生变化。目前,如果您想在 Intel 上使用 LLVM 的替代方案,您将不得不求助于汇编代码。
2. 优化乘法:测试代码使用 128 位整数来表示 i,但在这种情况下不需要这样做,因为它的值很容易适应 64 位(实际上是 32 ,但这对我们没有帮助)。这意味着ih 将始终为零;这将 128 位乘法的图表简化为:
nh * il
\
\
\
+ -> next nh
/
high 64 bits
/
nl * il
\
low 64 bits
\
-> next nl
通常,我只会说“将i 声明为long long 并让编译器优化”但不幸的是,这在这里不起作用;两个编译器都采用标准行为,在进行计算之前将两个操作数转换为它们的通用类型,因此 i 即使从 64 位开始,也会以 128 位结束。我们将不得不以艰难的方式做事:
__int128_t mul(__int128_t n, long long i)
{
using std::int64_t;
using std::uint64_t;
//Unpack the two halves.
int64_t nh = n >> 64;
uint64_t nl = static_cast<uint64_t>(n);
//Do the actual work.
__asm__(R"(
movq %0, %%r10
imulq %2, %%r10
mulq %2
addq %%r10, %0
)" : "+d"(nh), "+a"(nl) : "r"(i) : "%r10");
//Pack the result.
return static_cast<__int128_t>(nh) << 64 | nl;
}
我说过我试图在这个答案中避免使用汇编代码,但这并不总是可能的。我设法诱使 GCC 使用“C++ 中的汇编”为上面的函数生成正确的代码,但是一旦函数被内联,一切都崩溃了——优化器会看到完整循环体中发生的事情并将所有内容转换为 128 位。 LLVM 似乎在这种情况下表现良好,但是,由于我在 GCC 上进行测试,我必须使用可靠的方法来获取正确的代码。
将i 声明为long long 并使用此函数而不是正常的乘法运算符,我在 IntelSB 上测量了第一个样本的每次迭代 5 个周期和第二个样本的 7 个周期,在每种情况下都增加了一个周期.我希望它也能将 IntelH 上的两个示例的迭代周期缩短一个。
3. 当(至少有一些)迭代并不真正依赖于以前的结果时,有时可以重新构建循环以鼓励流水线执行,即使看起来确实如此。例如,我们可以将第二个示例的 for 循环替换为以下内容:
__int128_t n2 = 1;
long long j = 1000000000 / 2;
for(long long i = 1; i < 1000000000 / 2; ++i, ++j)
{
n *= i;
n2 *= j;
n >>= 2;
n2 >>= 2;
}
n *= (n2 * j) >> 2;
这利用了一些部分结果可以独立计算并且仅在最后聚合的事实。我们还向编译器暗示我们要对乘法和移位进行流水线化处理(并非总是必要的,但对于这段代码的 GCC 确实有一点点不同)。
上面的代码只不过是一个简单的概念证明。真正的代码需要以更可靠的方式处理总的迭代次数。更大的问题是此代码不会生成与原始代码相同的结果,因为存在溢出和舍入时的行为不同。即使我们在第 51 次迭代时停止循环,为了避免溢出,结果仍然会相差 10% 左右,因为右移时舍入的方式不同。在实际代码中,这很可能是个问题;但话又说回来,你不会有这样的真实代码,对吗?
假设此技术应用于不出现上述问题的情况,我在少数情况下测量了此类代码的性能,再次在 IntelSB 上。结果以“每次迭代的周期数”给出,和以前一样,其中“迭代”是指原始代码中的结果(我将执行整个循环的总周期数除以原始代码执行的迭代总数,不为重组的,进行有意义的比较):
- 上述代码每次迭代执行 7 个周期,比原始代码增加一个周期。
- 上面的代码用我们的
mul() 函数替换了乘法运算符,每次迭代需要 6 个周期。
重组后的代码确实会遭受更多的寄存器改组,不幸的是,这是无法避免的(更多变量)。较新的处理器(如 IntelH)具有架构改进,使寄存器移动在许多情况下基本上是免费的;这可以使代码产生更大的收益。为 IntelH 使用像 MULX 这样的新指令可以完全避免一些寄存器移动; GCC 在使用-march=haswell 编译时确实使用了这样的指令。
未回答的问题
到目前为止,我们没有任何测量结果可以解释 OP 报告的性能差异,以及我在不同系统上观察到的差异。
我最初的计时是在一个远程系统(Westmere 系列处理器)上进行的,当然,很多事情都可能发生;然而,结果却出奇地稳定。
在那个系统上,我还尝试使用右移和左移执行第二个样本;使用右移的代码始终比其他变体慢 50%。我无法在 IntelSB 上的受控测试系统上复制它,我也无法解释这些结果。
我们可以将以上所有内容视为编译器/处理器/整体系统行为的不可预测的副作用,但我无法摆脱这里并未解释所有内容的感觉。
确实,在没有受控系统、精确工具(计数周期)和查看每种情况下生成的汇编代码的情况下,对如此紧密的循环进行基准测试并没有多大意义。编译器特性很容易导致代码人为地引入 50% 或更多的性能差异。
另一个可以解释巨大差异的因素是英特尔超线程的存在。启用此功能后,内核的不同部分的行为会有所不同,并且处理器系列之间的行为也发生了变化。这可能会对紧密循环产生奇怪的影响。
最重要的是,这里有一个疯狂的假设:翻转比特比保持它们不变消耗更多的能量。在我们的例子中,第一个样本大部分时间都使用零值,它翻转的位将比第二个样本少得多,因此后者将消耗更多功率。许多现代处理器具有根据电气和热限制(Intel Turbo Boost / AMD Turbo Core)动态调整核心频率的功能。这意味着,理论上,在正确(或错误?)条件下,第二个样本可能会触发内核频率的降低,从而使相同数量的周期需要更长的时间,并使性能数据依赖。