如何使用C++中的处理器指令来实现快速算术运算

How to use processor instructions in C++ to implement fast arithmetic operations

本文关键字:实现 算术运算 指令 处理器 何使用 C++      更新时间:2023-10-16

我正在研究Shamir秘密共享计划的C++实施。我将消息分成 8 位块,并在每个块上执行相应的算术。底层有限域是 Rijndael 的有限域 F_256/(x^8 + x^4 + x^3 + x + 1)。

我快速搜索了一下,是否有一些著名的有限场计算库(例如OpenSSL或类似),但没有找到。所以我从头开始实现它,部分作为编程练习。 然而,几天前,我们大学的一位教授提到:"现代处理器支持无进位整数运算,因此现在特征 2 有限域乘法运行速度很快。

因此,由于我对硬件、汇编程序和类似的东西知之甚少,我的问题是:在构建加密软件时,我如何实际使用(C++)所有现代处理器的指令——无论是 AES、SHA、上面的算术还是其他任何东西?我找不到任何令人满意的资源。我的想法是构建一个包含"现代方法快速实现"和回退"纯C++无依赖代码"的库,并让 GNU Autoconf 决定在每个相应的主机上使用哪一个。任何关于此主题的书籍/文章/教程推荐将不胜感激。

这个问题非常广泛,因为您可以通过多种方式访问底层硬件的强大功能,因此您可以尝试使用所有现代处理器指令的方法列表,而不是一种特定方式:

成语识别

以"长格式"写出未直接以C++提供的操作,并希望您的编译器将其识别为您想要的基础指令的习语。例如,您可以编写一个变量,xamount作为(x << amount) | (x >> (32 - amount)),所有 gcc、clang 和 icc 都会将其识别为旋转并发出 x86 支持的底层rol指令。

有时这种技术会让你有点不舒服:上述C++轮换实现对amount == 0(也是amount >= 32)表现出未定义的行为,因为uint32_t上 32 的偏移结果是未定义的,但在这种情况下,这些编译器实际生成的代码很好。尽管如此,在你的程序中潜伏这种未定义的行为是危险的,它可能不会对 ubsan 和朋友清楚。替代安全版本amount ? (x << amount) | (x >> (32 - amount)) : x;仅由 icc 识别,而不被 gcc 或 clang 识别。

这种方法往往适用于直接映射到已经存在了一段时间的程序集级指令的常见习语:旋转、位测试和集合、结果比输入更广泛的乘法(例如,将两个 32 位值相乘以获得 64 位结果)、条件移动等,但不太可能选择密码学也可能感兴趣的前沿指令。例如,我很确定当前没有编译器可以识别AES指令集扩展的应用程序。它在编译器开发人员付出大量努力的平台上也效果最好,因为每个公认的习语都必须手动添加。

我不认为这种技术适用于您的无进位乘法 (PCLMULQDQ),但也许有一天(如果您针对编译器提出问题)?不过,它确实适用于其他"crypt-interest"功能,包括旋转。

内在函数

作为扩展,编译器通常会提供内部函数,这些函数不是语言本身的一部分,但通常直接映射到大多数硬件提供的指令。虽然它看起来像一个函数调用,但编译器通常只发出调用它的位置所需的单个指令。

GCC 调用这些内置函数,您可以在此处找到通用函数的列表。例如,如果当前目标支持__builtin_popcnt调用,则可以使用 调用发出popcnt指令。gcc内置的人也受到icc和clang的支持,在这种情况下,只要架构(-march=Haswell)设置为Haswell,所有gcc,clang和icc都支持此调用并发出popcnt。否则,clang 和 icc 使用一些聪明的 SWAR 技巧内联替换版本,而 gcc 调用运行时1提供的__popcountdi2

上面的内部函数列表是通用的,通常在编译器支持的任何平台上提供。您还可以找到特定于平台的内在函数,例如来自 gcc 的此列表。

特别是对于 x86 SIMD 指令,英特尔提供了一组声明标头的内部函数,涵盖其 ISA 扩展,例如,通过包含#include <x86intrin.h>。这些比gcc instrinsics有更广泛的支持,例如,它们由Microsoft的Visual Studio编译器套件支持。新指令集通常在支持它们的芯片可用之前添加,因此您可以使用它们在发布后立即访问新指令。

使用 SIMD 固有函数进行编程介于C++和完整汇编之间。编译器仍然负责调用约定和寄存器分配之类的事情,并进行一些优化(特别是对于生成常量和其他广播) - 但通常您编写的内容或多或少是您在程序集级别获得的内容。

内联装配

如果您的编译器提供了它,则可以使用内联程序集来调用所需的任何指令2.这与使用内部函数有很多相似之处,但难度更高,优化器帮助您的机会更少。您可能应该更喜欢内部函数,除非您有内联装配的特定原因。一个例子是,如果优化器在内部函数上做得非常糟糕:你可以使用内联程序集块来获取你想要的代码。

下线组装

你也可以只在汇编中编写整个内核函数,按照你想要的方式组装它,然后extern "C"声明它并从C++调用它。这类似于内联程序集选项,但适用于不支持内联程序集的编译器(例如,64 位 Visual Studio)。如果需要,您还可以使用不同的汇编程序,如果您面向多个C++编译器,这尤其方便,因为您可以对所有编译器使用单个汇编程序。

你需要自己处理调用约定,以及其他混乱的事情,如DWARF展开信息和Windows SEH处理。

对于非常短的函数,此方法效果不佳,因为调用开销可能会令人望而却步3

自动矢量化4

如果你想在今天为 CPU 编写快速加密,你几乎主要针对 SIMD 指令。大多数使用软件实现设计的新算法在设计时也考虑了矢量化。

您可以固有函数或汇编来编写 SIMD 代码,但也可以编写普通标量代码并依赖自动矢量化器。这些在 SIMD 的早期就名声不好,虽然它们还远非完美,但它们已经走了很长一段路。

考虑这个简单的函数,将payloadkey字节数组和xorskey到有效负载中:

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的内容:

movdqu xmm0, XMMWORD PTR [rdi+rax]
movdqu xmm1, XMMWORD PTR [rsi+rax]
pxor xmm0, xmm1
movups XMMWORD PTR [rdi+rax], xmm0

它使用 SSE 指令一次加载和异或 16 个字节。但是,开发人员只需要对简单的标量代码进行推理!

与内部函数或汇编方法相比,此方法的一个优点是,您不会在源代码级别烘焙指令集的 SIMD 长度。与上面编译的相同C++代码-march=haswell会产生如下循环:

vmovdqu ymm1, YMMWORD PTR [rdi+rax]
vpxor ymm0, ymm1, YMMWORD PTR [rsi+rax]
vmovdqu YMMWORD PTR [rdi+rax], ymm0

它使用Haswell上提供的AVX2指令一次执行32字节。如果使用-march=skylake-avx512clang 在zmm寄存器上使用 64 字节vxorps指令(但 gcc 和 icc 坚持使用 32 字节的内部循环)。因此,原则上,您只需重新编译即可利用新的 ISA。

自动矢量化的一个缺点是它相当脆弱。在一个编译器上自动矢量化的内容可能不会在另一个编译器上,甚至在同一编译器的另一个版本上也不会。因此,您需要检查是否获得了所需的结果。自动矢量化器通常处理的信息比您少:它可能不知道输入长度是某个幂或两个幂的倍数,或者输入指针以某种方式对齐。有时您可以将此信息传达给编译器,但有时不能。

有时编译器在矢量化时会做出"有趣"的决定,例如内部循环的小未展开主体,但随后是一个巨大的"介绍"或"结束"处理奇怪的迭代,就像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

你可能有更好的东西来花你的指令缓存(这远不是我见过的最糟糕的:在介绍和结尾部分很容易获得数百个指令的示例)。

不幸的是,矢量化器可能不会产生特定于加密的指令,例如无进位乘法。您可以考虑混合使用矢量化的标量代码和仅用于编译器不会生成的指令的内部代码,但这比实际成功更容易建议。在这一点上,你最好用内部函数编写整个循环。


1gcc 方法的优点是,在运行时,如果平台支持popcnt则此调用可以使用 GNU IFUNC 机制解析为仅使用popcnt指令的实现。

2假设底层汇编程序支持它,但即使不支持,您也可以在内联汇编块中对原始指令字节进行编码。

3调用开销不仅包括callret以及参数传递的显式成本:它还包括对优化器的影响,优化器无法在调用者中优化代码,因为它具有未知的副作用。

4在某些方面,自动矢量化可以被视为习语识别的一个特例,但它足够重要,并且具有足够独特的考虑因素,因此它在这里有自己的部分。

5略有不同:gcc 如图所示,clang 展开了一点,ICC 使用了加载操作pxor而不是单独的加载。