这个问题非常广泛,因为有多种方法可以访问底层硬件的能力,所以这里列出了一些方式,可以尝试使用所有现代处理器的指令:
成语识别
将在C ++中直接提供的操作“展开”,并希望编译器将其识别为您想要的底层指令的成语。例如,您可以将左旋变量x
的amount
写为(x << amount) | (x >> (32 - amount))
,gcc、clang和icc都会将其识别为旋转,并发出由x86支持的底层rol
指令。
有时,这种技术会让你感到有点不舒服:上面的C++ rotate实现在
amount == 0
(以及
amount >= 32
)时会出现
未定义行为,因为在
uint32_t
上进行32次移位的结果是未定义的,但是由
这些编译器生成的代码在这种情况下都是没问题的。尽管如此,在程序中潜伏着这种未定义行为仍然很危险,并且它很可能无法通过ubsan和其他工具的检测。安全的替代版本
amount ? (x << amount) | (x >> (32 - amount)) : x;
只被icc识别,而gcc和clang则不识别。
这种方法通常适用于直接映射到汇编级指令的常见习语,这些指令已经存在一段时间:旋转、位测试和设置、比输入更广泛的乘法(例如,将两个32位值相乘得到64位结果),条件移动等等。但是,它不太可能掌握最新的指令,这些指令也可能与加密有关。例如,我非常确定目前没有编译器能够识别
AES指令集扩展的应用。它最适合那些编译器开发人员花费了大量精力的平台,因为每个被识别的习语都必须手动添加。
我认为这种技术不能处理你的无进位乘法(
PCLMULQDQ),但也许有一天(如果你向编译器提交问题反馈)?它确实适用于其他“加密相关”的函数,包括旋转。
内置函数
作为扩展,编译器通常会提供内置函数,这些函数不属于语言本身,但通常直接映射到大多数硬件提供的指令。虽然它看起来像一个函数调用,但编译器通常只在调用它的地方发出所需的单个指令。
GCC将这些称为内建函数,您可以在此处找到通用内建函数列表 。例如,您可以使用__builtin_popcnt
调用来发出popcnt
指令,如果当前目标支持它。gcc内置函数也由icc和clang支持,在这种情况下,如果架构(-march = Haswell
)设置为Haswell,则所有gcc、clang和icc都支持此调用并发出popcnt
。否则,clang和icc会内联一个使用一些聪明的SWAR技巧的替换版本,而gcc则调用由运行时库提供的__popcountdi2
1。
上面列出的内置函数是通用的,通常在编译器支持的任何平台上都可以使用。您还可以找到特定于平台的内置函数,例如来自gcc的
this list。
特别针对x86 SIMD指令,英特尔提供了一组声明头文件的
内置函数,涵盖其ISA扩展,例如通过包含
#include <x86intrin.h>
。这些内置函数具有比gcc内置函数更广泛的支持,例如它们受到Microsoft的Visual Studio编译套件的支持。通常会在支持它们的芯片之前添加新的指令集,因此您可以在发布后立即使用它们来访问新指令。
使用SIMD内置函数进行编程有点像C++和完整汇编之间的折衷方案。编译器仍然负责调用约定和寄存器分配,并且进行了一些优化(尤其是用于生成常量和其他广播)-但通常您所编写的代码就是在汇编级别上得到的结果。
内联汇编
如果你的编译器支持,你可以使用内联汇编调用任何指令
2。这与使用内部函数有很多相似之处,但难度略高,并且优化程序的机会较少。除非你有特殊原因需要使用内联汇编,否则应该
优先选择内部函数。例如,如果优化程序使用内部函数时效果非常差:你可以使用内联汇编块来获得你想要的代码。
外联汇编
你也可以直接在汇编中编写整个内核函数,按照你想要的方式进行汇编,然后声明为extern "C"
并从C++中调用它。这类似于内联汇编选项,但适用于不支持内联汇编的编译器(例如64位Visual Studio)。你还可以使用不同的汇编器,这对于针对多个C++编译器的情况特别方便,因为你可以为所有编译器使用单个汇编器。
您需要自己处理调用约定,以及其他混乱的事情,例如DWARF展开信息和Windows SEH处理。
对于非常短的函数,这种方法效果不好,因为呼叫开销可能是禁止的3。
自动向量化4
如果您想要为CPU编写快速的加密代码,那么您几乎将主要针对SIMD指令。 大多数使用软件实现的新算法也考虑到了矢量化。
您可以使用内在函数或汇编语言编写SIMD代码,但也可以编写普通的标量代码,并依赖于自动向量化程序。 在SIMD的早期阶段,这些程序声名狼藉,虽然它们远非完美,但已经取得了长足的进步。
考虑以下这个简单的函数,该函数获取payload
和key
字节数组并将key
异或到payload
中:
void otp_scramble(uint8_t* payload, uint8_t* key, size_t n) {
for (size_t i = 0; i < n; i++) {
payload[i] ^= key[i];
}
}
这只是一个垒球的例子,但无论如何gcc、clang和icc都会将其向量化到类似于这个内循环
4的方式:
something like。
movdqu xmm0, XMMWORD PTR [rdi+rax]
movdqu xmm1, XMMWORD PTR [rsi+rax]
pxor xmm0, xmm1
movups XMMWORD PTR [rdi+rax], xmm0
它使用SSE指令每次加载和异或16个字节。开发者只需要考虑简单的标量代码即可!这种方法与内部函数或汇编语言相比的优点之一是,您不会在源级别上固定指令集的SIMD长度。使用
-march=haswell
编译上述相同的C++代码将产生如下循环:
vmovdqu ymm1, YMMWORD PTR [rdi+rax]
vpxor ymm0, ymm1, YMMWORD PTR [rsi+rax]
vmovdqu YMMWORD PTR [rdi+rax], ymm0
它使用Haswell上可用的AVX2指令一次处理32个字节。如果使用
-march=skylake-avx512
编译,clang会在
zmm
寄存器上使用64字节的
vxorps
指令(但gcc和icc仍然坚持使用32字节的内部循环)。因此,原则上只需重新编译即可利用新的ISA获得一些优势。
自动向量化的缺点是相当脆弱的。一个编译器上自动向量化的内容,在另一个编译器上甚至在同一编译器的另一个版本上可能无法自动向量化。所以你需要检查是否得到了想要的结果。自动向量化器通常工作时比你拥有的信息更少:它可能不知道输入长度是某个二的幂的倍数,或者输入指针以某种方式对齐。有时可以将这些信息传达给编译器,但有时无法做到。
有时编译器在向量化时会做出“有趣”的决策,例如对内部循环进行小型未展开体,但随后处理奇数迭代的巨大“intro”或“outro”,就像上面第一个循环后
gcc
所产生的那样。
movzx ecx, BYTE PTR [rsi+rax]
xor BYTE PTR [rdi+rax], cl
lea rcx, [rax+1]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+1+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+2]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+2+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+3]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+3+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+4]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+4+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+5]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+5+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+6]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+6+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+7]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+7+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+8]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+8+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+9]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+9+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+10]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+10+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+11]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+11+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+12]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+12+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+13]
cmp rdx, rcx
jbe .L1
movzx r8d, BYTE PTR [rsi+13+rax]
xor BYTE PTR [rdi+rcx], r8b
lea rcx, [rax+14]
cmp rdx, rcx
jbe .L1
movzx eax, BYTE PTR [rsi+14+rax]
xor BYTE PTR [rdi+rcx], al
你可能有更好的东西可以用指令高速缓存(这还不算最糟糕的:很容易在引言和结尾部分找到几百条指令的例子)。
不幸的是,向量化器可能不会生成加密特定指令,如无进位乘法。您可以考虑使用混合标量代码进行向量化,并仅针对编译器不生成的指令使用内置函数,但这比成功建议要容易得多。此时,您最好使用内置函数编写整个循环。
1 GCC的优点在于,运行时如果平台支持popcnt
,此调用可以解析为一个使用popcnt
指令的实现,使用GNU IFUNC机制。
2 假设底层汇编器支持它,但即使不支持,您也可以在内联汇编块中编码原始指令字节。
3 调用开销包括不仅是call
和ret
以及参数传递的显式成本:它还包括对优化器的影响,因为它无法在调用函数周围优化代码,因为它具有未知的副作用。
4 在某些方面,自动向量化可以被视为成语识别的特殊情况,但它非常重要且具有足够独特的考虑因素,因此在这里它有自己的部分。
5 有些细微的差别:GCC如图所示,Clang展开了一点,而ICC使用了一个load-op pxor
而不是单独的load。
_mm_clmulepi64_si128
。 - harold