如何使用 SIMD 加速两个内存块的异或?
Posted
技术标签:
【中文标题】如何使用 SIMD 加速两个内存块的异或?【英文标题】:How can I use SIMD to accelerate XOR two blocks of memory? 【发布时间】:2013-02-25 12:36:09 【问题描述】:我想尽快对两块内存进行异或运算,如何使用 SIMD 来加速呢?
我的原始代码如下:
void region_xor_w64( unsigned char *r1, /* Region 1 */
unsigned char *r2, /* Region 2 */
int nbytes) /* Number of bytes in region */
uint64_t *l1;
uint64_t *l2;
uint64_t *ltop;
unsigned char *ctop;
ctop = r1 + nbytes;
ltop = (uint64_t *) ctop;
l1 = (uint64_t *) r1;
l2 = (uint64_t *) r2;
while (l1 < ltop)
*l2 = ((*l1) ^ (*l2));
l1++;
l2++;
我自己写了一个,但速度几乎没有提高。
void region_xor_sse( unsigned char* dst,
unsigned char* src,
int block_size)
const __m128i* wrd_ptr = (__m128i*)src;
const __m128i* wrd_end = (__m128i*)(src+block_size);
__m128i* dst_ptr = (__m128i*)dst;
do
__m128i xmm1 = _mm_load_si128(wrd_ptr);
__m128i xmm2 = _mm_load_si128(dst_ptr);
xmm2 = _mm_xor_si128(xmm1, xmm2);
_mm_store_si128(dst_ptr, xmm2);
++dst_ptr;
++wrd_ptr;
while(wrd_ptr < wrd_end);
【问题讨论】:
您在哪个平台上运行?您可以使用的 SIMD 工具非常特定于平台。 @JasonR 64 位 linux 支持 SSE4.2 您可以尝试展开循环。当您没有对每个输出值进行大量运算时,很难获得较大的性能提升。此外,如果您要使用对齐的加载/存储指令,请注意缓冲区的对齐。 【参考方案1】:更重要的问题是您为什么要手动执行此操作。你有一个古老的编译器,你认为你可以超越它吗?那些不得不手动编写 SIMD 指令的美好时光已经结束。今天,在 99% 的情况下,编译器会为你完成这项工作,而且很有可能它会做得更好。此外,不要忘记每隔一段时间就会出现新的架构,并带有越来越多的扩展指令集。所以问自己一个问题——你想为每个平台维护 N 个实现的副本吗?您想不断测试您的实现以确保它值得维护吗?答案很可能是否定的。
您唯一需要做的就是编写尽可能简单的代码。编译器将完成其余的工作。例如,这是我编写函数的方式:
void region_xor_w64(unsigned char *r1, unsigned char *r2, unsigned int len)
unsigned int i;
for (i = 0; i < len; ++i)
r2[i] = r1[i] ^ r2[i];
简单一点,不是吗?猜猜看,编译器生成的代码使用 MOVDQU
和 PXOR
执行 128 位 XOR,关键路径如下所示:
4008a0: f3 0f 6f 04 06 movdqu xmm0,XMMWORD PTR [rsi+rax*1]
4008a5: 41 83 c0 01 add r8d,0x1
4008a9: f3 0f 6f 0c 07 movdqu xmm1,XMMWORD PTR [rdi+rax*1]
4008ae: 66 0f ef c1 pxor xmm0,xmm1
4008b2: f3 0f 7f 04 06 movdqu XMMWORD PTR [rsi+rax*1],xmm0
4008b7: 48 83 c0 10 add rax,0x10
4008bb: 45 39 c1 cmp r9d,r8d
4008be: 77 e0 ja 4008a0 <region_xor_w64+0x40>
正如@Mysticial 所指出的,上面的代码使用了支持非对齐访问的指令。那些比较慢。但是,如果程序员可以正确地假设对齐访问,那么就有可能让编译器知道它。例如:
void region_xor_w64(unsigned char * restrict r1,
unsigned char * restrict r2,
unsigned int len)
unsigned char * restrict p1 = __builtin_assume_aligned(r1, 16);
unsigned char * restrict p2 = __builtin_assume_aligned(r2, 16);
unsigned int i;
for (i = 0; i < len; ++i)
p2[i] = p1[i] ^ p2[i];
编译器为上述 C 代码生成以下内容(通知 movdqa
):
400880: 66 0f 6f 04 06 movdqa xmm0,XMMWORD PTR [rsi+rax*1]
400885: 41 83 c0 01 add r8d,0x1
400889: 66 0f ef 04 07 pxor xmm0,XMMWORD PTR [rdi+rax*1]
40088e: 66 0f 7f 04 06 movdqa XMMWORD PTR [rsi+rax*1],xmm0
400893: 48 83 c0 10 add rax,0x10
400897: 45 39 c1 cmp r9d,r8d
40089a: 77 e4 ja 400880 <region_xor_w64+0x20>
明天,当我给自己买一台配备 Haswell CPU 的笔记本电脑时,编译器将为我生成一个使用 256 位指令而不是 128 位指令的代码,这给了我两倍的向量性能。即使我不知道 Haswell 有能力,它也会这样做。您不仅需要了解该功能,还需要编写另一个版本的代码并花一些时间对其进行测试。
顺便说一句,您的实现中似乎还有一个错误,代码可以跳过数据向量中最多 3 个剩余字节。
无论如何,我建议您信任您的编译器并学习如何验证生成的内容(即熟悉 objdump
)。下一个选择是更改编译器。然后才开始考虑手动编写向量处理指令。否则你会过得很糟糕!
希望对您有所帮助。祝你好运!
【讨论】:
忘记我的评论。我有点分心,没有注意到你最后提到了对齐的东西。 同时,我会注意到增加数据类型大小实际上并没有帮助,除非你一直到__m128i
。因为即使是 64 位整数对齐也不足以消除对 movdqu
的需求。
我从未真正使用过它,因为我的大部分工作都是在 MSVC 中完成的。我发现在大多数情况下,如果一个简单的循环恰好对性能至关重要,那么通常可以进行更高级别的转换以获得更多的改进,而不仅仅是简单的矢量化。但话虽如此,这些转换是特定于应用程序的,有时并不那么容易做到。因此,对大多数人来说,使用编译器扩展可能是更简单的方法。
@VladLazarenko: 非常感谢,这个函数是我代码的一部分,对齐检查函数会确保大小是 128 位的倍数。
99% 的时间都是夸大其词。有很多关于向量化 gcc 根本不会自动向量化的东西的问题。有时clang或ICC会。或者有时 gcc 会但 clang 不会。您可以使用 SSE4 / AVX2 做很多事情,而不仅仅是像这样的琐碎纯垂直的东西。我的意思是是的,通过在适当的情况下使用restrict
来启用自动矢量化,编译器会在这种情况下做得很好。在涉及扩大或缩小的更复杂的情况下,它们有时会在自动矢量化方面做得糟糕,你可以将它们击败 2 倍或更多。【参考方案2】:
由于区域的大小是按值传递的,为什么代码不是:
void region_xor_w64(unsigned char *r1, unsigned char *r2, unsigned int i)
while (i--)
r2[i] = r1[i] ^ r2[i];
甚至:
void region_xor_w64(unsigned char *r1, unsigned char *r2, unsigned int i)
while (i--)
r2[i] ^= r1[i];
如果偏好向前(“向上内存”)和使用指针,那么:
void region_xor_w64(unsigned char *r1, unsigned char *r2, unsigned int i)
while (i--)
*r2++ ^= *r1++;
【讨论】:
HW 预取通常会更好地工作。 Intel CPU 中的 L2 流媒体在任一方向上都同样有效,但我不确定 L1d 预取。如果你有 AVX,那么你通常希望编译器使用指针增量而不是索引寻址模式,所以用 OP 的方式编写它更接近你想要的 asm。除了编译器实际做什么无关紧要。 (或者对于 Sandybridge/IvyBridge,也使用 SSE2 存储,pxor xmm0, [rsi]
如果编译器对齐输入以便它可以在主循环中折叠负载。)
@PeterCordes 我已经添加了一个用于“向上”内存和使用指针的版本,但仍然没有使用 for(...) 循环来保存每个周期的比较。在与寄存器相关的标志中跟踪零的 CPU 上更好 - 想想 Z80 DJNZ ;-)
您使用 C 语言编写,而不是 asm。一个体面的编译器已经为您完成了该转换,或者根据目标 ISA 完全优化计数器并进行指针比较。为了让它以最少的开销自动矢量化,最好的办法可能是使用unsigned char *restrict r1
向编译器保证块不会重叠,因此它可以省略重叠检查。 godbolt.org/z/KYrCw_ 展示了它如何使用 gcc/clang/MSVC for x86-64 进行编译。 MSVC 无法自动 vec。您可以查看 AArch64、PowerPC(它有一个 dec-and-branch insn、IDK 如果它会被使用)等。
TL:DR:这些编译为与其他答案基本相同的 asm。但是递减版本欺骗 GCC 改组其 SIMD 向量以反转 XOR 之前的顺序,因为 GCC 不是超级智能。哦,MSVC 确实 使用for
循环对另一个答案中的版本进行矢量化,但这里不是while(i--)
版本,因为MSVC 是愚蠢的。因此,您的更改会导致广泛使用的 x86 编译器之一的速度降低约 16 倍。以上是关于如何使用 SIMD 加速两个内存块的异或?的主要内容,如果未能解决你的问题,请参考以下文章
如何不利用一个额外的变量来达到交换两个变量值的目的-------位上的异或运算
LeetCode 1442 形成两个异或相等数组的三元组数目[异或 位运算 数学] HERODING的LeetCode之路