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

10

我正在开发Shamir秘密共享方案的C++实现。 我将消息分成8位块,并对每个块进行相应的算术运算。底层有限域是Rijndael的有限域F_256 /(x ^ 8 + x ^ 4 + x ^ 3 + x + 1)。

我曾经快速搜索是否存在用于Rijndael有限域计算的知名库,例如OpenSSL或类似库,并未找到。因此,我从头开始实现它,部分是为了练习编程。

然而,几天前,我们大学的一位教授提到了以下内容:“现代处理器支持无进位整数操作,因此特征-2有限域乘法现在运行得很快。”。

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


你可以阅读 OpenSSL(或更好的 libreSSL)源代码,它正是这样做的。我想知道为什么你还没有找到这些有限域计算。它们在所有主要的加密库中都得到了实现,而且好的库都有汇编实现。 - fuz
你是否搜索过“Intel有限域”或“Intel伽罗瓦域”?即使你不需要伽罗瓦域,这些搜索结果会带你到学术论文和Intel文档,详细解释如何使用Intel的SIMD指令集来完成你想要的事情:极快的有限域算术运算。实际上,搜索“极快的有限域”,看看你能得到什么。 - davidbak
您还可以在纠错码中找到很好的示例,GF字段广泛使用。 - doug
1
花式指令可以通过内置函数访问,例如 _mm_clmulepi64_si128 - harold
1个回答

9

这个问题非常广泛,因为有多种方法可以访问底层硬件的能力,所以这里列出了一些方式,可以尝试使用所有现代处理器的指令:

成语识别

将在C ++中直接提供的操作“展开”,并希望编译器将其识别为您想要的底层指令的成语。例如,您可以将左旋变量xamount写为(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则调用由运行时库提供的__popcountdi21

上面列出的内置函数是通用的,通常在编译器支持的任何平台上都可以使用。您还可以找到特定于平台的内置函数,例如来自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的早期阶段,这些程序声名狼藉,虽然它们远非完美,但已经取得了长足的进步。

考虑以下这个简单的函数,该函数获取payloadkey字节数组并将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 调用开销包括不仅是callret以及参数传递的显式成本:它还包括对优化器的影响,因为它无法在调用函数周围优化代码,因为它具有未知的副作用。

4 在某些方面,自动向量化可以被视为成语识别的特殊情况,但它非常重要且具有足够独特的考虑因素,因此在这里它有自己的部分。

5 有些细微的差别:GCC如图所示,Clang展开了一点,而ICC使用了一个load-op pxor而不是单独的load。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接