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

jak*_*kob 8 c++ assembly processor instruction-set

我正在研究Shamir的秘密共享方案的C++实现.我将消息拆分为8位块,并在每个块上执行相应的算术运算.基础有限域是Rijndael的有限域F_256 /(x ^ 8 + x ^ 4 + x ^ 3 + x + 1).

如果Rijndael的有限域计算有一些众所周知的扩散库(例如OpenSSL或类似的),我快速搜索,但没有发现任何.所以我从头开始实现它,部分是作为编程练习.然而,几天前,我们大学的一位教授提到:"现代处理器支持无进位整数运算,因此特征-2有限域乘法现在运行得很快."

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

Bee*_*ope 5

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

习语识别

用“长格式”写出C ++中未直接提供的操作,并希望您的编译器将其识别为所需基础指令的惯用法。例如,你可以写一个变量向左旋转的x通过amount(x << amount) | (x >> (32 - amount))所有的海湾合作委员会,铛和国际刑事法院将认识到这是一个旋转并发出底层rol的支持的x86指令。

有时,这种技术会使您感到有些不舒服:上面的C ++旋转实现展示了(以及)未定义的行为,因为在a上移位32的结果是未定义的,但是由这些编译器实际生成的代码就可以了在这种情况下。尽管如此,在程序中潜伏着未定义的行为还是很危险的,而且可能无法与ubsan和朋友进行清晰的交流。备用安全版本只能由icc识别,而不能由gcc或clang识别。amount == 0amount >= 32uint32_tamount ? (x << amount) | (x >> (32 - amount)) : x;

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

我认为这种技术不适用于您的无进位乘法(PCLMULQDQ),但是可能有一天(如果您对编译器提出问题)?但是,它确实适用于其他“有趣的加密”功能,包括旋转。

内在函数

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

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

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

专门针对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成立之初就名声不佳,尽管还远远不够完善,但它们已经走了很长一段路。

考虑以下简单函数,将takes payloadkey字节数组以及xor key放入有效载荷中:

void otp_scramble(uint8_t* payload, uint8_t* key, size_t n) {
    for (size_t i = 0; i < n; i++) {
        payload[i] ^= key[i];
    }
}
Run Code Online (Sandbox Code Playgroud)

当然,这是一个垒球示例,但是无论如何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
Run Code Online (Sandbox Code Playgroud)

它使用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
Run Code Online (Sandbox Code Playgroud)

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

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

有时,编译器在进行向量化时会做出“有趣的”决定,例如,内部循环的未展开小主体,然后是巨大的“ 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
Run Code Online (Sandbox Code Playgroud)

您可能有更好的东西来花费指令缓存(这与我见过的最糟糕的情况相去甚远:很容易在前奏和后奏部分中获得包含数百条指令的示例)。

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


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

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

3调用开销不仅包括calland ret和参数传递的显式开销:还包括对优化器的影响,该优化器也无法在函数调用周围的调用程序中优化代码,因为它具有未知的副作用。

4在某些方面,自动矢量化可以被视为成语识别的一种特殊情况,但是它很重要,并且具有足够的独特考虑,因此在这里可以找到自己的部分。

5略有不同:gcc如图所示,clang展开了一点,icc使用了load-op pxor而不是单独的负载。