对齐和 SSE 奇怪的行为
Alignment and SSE strange behaviour
我尝试使用 SSE,但遇到了一些奇怪的行为。
我使用 SSE Intrinsics 编写了用于比较两个字符串的简单代码,运行 它成功了。但后来我明白了,在我的代码中,一个指针仍然没有对齐,但是我使用了 _mm_load_si128
指令,它要求指针在 16 字节边界上对齐。
//Compare two different, not overlapping piece of memory
__attribute((target("avx"))) int is_equal(const void* src_1, const void* src_2, size_t size)
{
//Skip tail for right alignment of pointer [head_1]
const char* head_1 = (const char*)src_1;
const char* head_2 = (const char*)src_2;
size_t tail_n = 0;
while (((uintptr_t)head_1 % 16) != 0 && tail_n < size)
{
if (*head_1 != *head_2)
return 0;
head_1++, head_2++, tail_n++;
}
//Vectorized part: check equality of memory with SSE4.1 instructions
//src1 - aligned, src2 - NOT aligned
const __m128i* src1 = (const __m128i*)head_1;
const __m128i* src2 = (const __m128i*)head_2;
const size_t n = (size - tail_n) / 32;
for (size_t i = 0; i < n; ++i, src1 += 2, src2 += 2)
{
printf("src1 align: %d, src2 align: %d\n", align(src1) % 16, align(src2) % 16);
__m128i mm11 = _mm_load_si128(src1);
__m128i mm12 = _mm_load_si128(src1 + 1);
__m128i mm21 = _mm_load_si128(src2);
__m128i mm22 = _mm_load_si128(src2 + 1);
__m128i mm1 = _mm_xor_si128(mm11, mm21);
__m128i mm2 = _mm_xor_si128(mm12, mm22);
__m128i mm = _mm_or_si128(mm1, mm2);
if (!_mm_testz_si128(mm, mm))
return 0;
}
//Check tail with scalar instructions
const size_t rem = (size - tail_n) % 32;
const char* tail_1 = (const char*)src1;
const char* tail_2 = (const char*)src2;
for (size_t i = 0; i < rem; i++, tail_1++, tail_2++)
{
if (*tail_1 != *tail_2)
return 0;
}
return 1;
}
我打印了两个指针的对齐方式,其中一个是对齐的,但第二个 - 不是。并且编程仍然 运行ning 正确且快速。
然后我像这样创建综合测试:
//printChars128(...) function just print 16 byte values from __m128i
const __m128i* A = (const __m128i*)buf;
const __m128i* B = (const __m128i*)(buf + rand() % 15 + 1);
for (int i = 0; i < 5; i++, A++, B++)
{
__m128i A1 = _mm_load_si128(A);
__m128i B1 = _mm_load_si128(B);
printChars128(A1);
printChars128(B1);
}
正如我们预期的那样,它在第一次迭代时崩溃,当尝试加载指针 B 时。
有趣的是,如果我将 target
切换为 sse4.2
,那么我的 is_equal
实现将会崩溃。
另一个有趣的事实是,如果我尝试对齐第二个指针而不是第一个指针(因此第一个指针将不对齐,第二个 - 对齐),那么 is_equal
将崩溃。
所以,我的问题是:"Why is_equal
function works fine with only first pointer aligned if i enable avx
instruction generation?"
UPD: 这是 C++
代码。我在 Windows、x86.
下用 MinGW64/g++, gcc version 4.9.2
编译我的代码
编译字符串:g++.exe main.cpp -Wall -Wextra -std=c++11 -O2 -Wcast-align -Wcast-qual -o main.exe
TL:DR:来自 _mm_load_*
内在函数的加载可以折叠(在编译时)到其他指令的内存操作数中。 The AVX versions of vector instructions don't require alignment for memory operands,除了专门对齐的 load/store 指令,如 vmovdqa
.
在向量指令的传统 SSE 编码中(如 pxor xmm0, [src1]
),未对齐的 128 位内存操作数将出错,但特殊未对齐的 load/store 指令(如 movdqu
/ movups
)除外。
VEX-encoding of vector instructions (like vpxor xmm1, xmm0, [src1]
) doesn't fault with unaligned memory, except with the alignment-required load/store instructions (like vmovdqa
, or vmovntdq
).
_mm_loadu_si128
与 _mm_load_si128
(和 store/storeu)内在函数向编译器传达对齐保证,但不强制它实际发出独立加载指令。 (或者任何东西,如果它已经在寄存器中有数据,就像取消引用标量指针一样)。
优化使用内部函数的代码时,as-if 规则仍然适用。加载可以 折叠 到使用它的向量 ALU 指令的内存操作数,只要这不会引入错误的风险。由于代码密度的原因,这是有利的,并且由于微融合 (see Agner Fog's microarch.pdf),在 CPU 的部分中跟踪更少的微指令也是有利的。 -O0
未启用执行此操作的优化传递,因此未优化的代码构建可能会因未对齐的 src1 而出错。
(相反,这意味着 _mm_loadu_*
只能折叠到 AVX 的内存操作数中,但不能折叠到 SSE。因此即使在 CPUs 上 movdqu
与 movqda
当指针恰好对齐时,_mm_loadu
会损害性能,因为 movqdu xmm1, [rsi]
/ pxor xmm0, xmm1
是前端发出的 2 个融合域微指令,而 pxor xmm0, [rsi]
只有 1。并且不需要临时寄存器。另见 Micro fusion and addressing modes).
在这种情况下对 as-if 规则的解释是,在某些情况下,如果天真地翻译成 asm 会出错,那么程序不会出错是可以的。 (或者相同的代码在未优化的构建中出错但在优化的构建中没有出错)。
这与浮点异常的规则相反,在浮点异常中,编译器生成的代码仍必须引发任何和所有可能在 C 抽象机上发生的异常。这是因为有明确定义的机制来处理 FP 异常,但没有用于处理段错误。
请注意,由于存储不能折叠到 ALU 指令的内存操作数中,因此 store
(不是 storeu
)内在函数将编译成
的代码
具体来说:考虑以下代码片段:
// aligned version:
y = ...; // assume it's in xmm1
x = _mm_load_si128(Aptr); // Aligned pointer
res = _mm_or_si128(y, x);
// unaligned version: the same thing with _mm_loadu_si128(Uptr)
当以 SSE 为目标时(代码可以 运行 on CPUs without AVX support),对齐版本可以将负载折叠到 por xmm1, [Aptr]
,但未对齐版本必须使用
movdqu xmm0, [Uptr]
/ por xmm0, xmm1
。如果在 OR.
之后仍然需要 y
的旧值,对齐版本也可能会这样做
当以 AVX(gcc -mavx
、或 gcc -march=sandybridge
或更高版本为目标时,发出的所有向量指令(包括 128 位)都将使用 VEX 编码。所以你从相同的 _mm_...
内在函数中得到不同的 asm。两个版本都可以编译成vpor xmm0, xmm1, [ptr]
。 (而 3 操作数的非破坏性特征意味着这实际上会发生,除非多次使用加载的原始值)。
ALU 指令的一个操作数只能是内存操作数,因此在您的情况下,必须单独加载一个。当第一个指针未对齐时,您的代码会出错,但不关心第二个指针的对齐方式,因此我们可以得出结论,gcc 选择使用 vmovdqa
加载第一个操作数并折叠第二个,而不是副 -相反。
您可以在 the Godbolt compiler explorer 上的代码中实际看到这种情况。不幸的是,gcc 4.9(和 5.3)将其编译为次优代码,在 al
中生成 return 值,然后对其进行测试,而不是仅在 vptest
的标志上分支 :( clang-3.8 的表现要好得多。
.L36:
add rdi, 32
add rsi, 32
cmp rdi, rcx
je .L9
.L10:
vmovdqa xmm0, XMMWORD PTR [rdi] # first arg: loads that will fault on unaligned
xor eax, eax
vpxor xmm1, xmm0, XMMWORD PTR [rsi] # second arg: loads that don't care about alignment
vmovdqa xmm0, XMMWORD PTR [rdi+16] # first arg
vpxor xmm0, xmm0, XMMWORD PTR [rsi+16] # second arg
vpor xmm0, xmm1, xmm0
vptest xmm0, xmm0
sete al # generate a boolean in a reg
test eax, eax
jne .L36 # then test&branch on it. /facepalm
请注意,您的 is_equal
是 memcmp
。我认为 glibc 的 memcmp 在许多情况下会比您的实现做得更好,因为它有 hand-written asm versions for SSE4.1 和其他处理缓冲区相对于彼此未对齐的各种情况的方法。 (例如,一个对齐,一个不对齐。)请注意,glibc 代码是 LGPLed,因此您可能无法复制它。如果您的用例具有通常对齐的较小缓冲区,则您的实现可能很好。在从其他 AVX 代码调用它之前不需要 VZEROUPPER 也很好。
编译器生成的用于最后清理的字节循环绝对是次优的。如果大小大于 16 字节,则执行在每个 src 的最后一个字节结束的未对齐加载。重新比较已经检查过的一些字节并不重要。
无论如何,一定要用系统 memcmp
对您的代码进行基准测试。除了库实现之外,gcc 知道 memcmp 的作用,并且有自己的内置定义,可以为其内联代码。
我尝试使用 SSE,但遇到了一些奇怪的行为。
我使用 SSE Intrinsics 编写了用于比较两个字符串的简单代码,运行 它成功了。但后来我明白了,在我的代码中,一个指针仍然没有对齐,但是我使用了 _mm_load_si128
指令,它要求指针在 16 字节边界上对齐。
//Compare two different, not overlapping piece of memory
__attribute((target("avx"))) int is_equal(const void* src_1, const void* src_2, size_t size)
{
//Skip tail for right alignment of pointer [head_1]
const char* head_1 = (const char*)src_1;
const char* head_2 = (const char*)src_2;
size_t tail_n = 0;
while (((uintptr_t)head_1 % 16) != 0 && tail_n < size)
{
if (*head_1 != *head_2)
return 0;
head_1++, head_2++, tail_n++;
}
//Vectorized part: check equality of memory with SSE4.1 instructions
//src1 - aligned, src2 - NOT aligned
const __m128i* src1 = (const __m128i*)head_1;
const __m128i* src2 = (const __m128i*)head_2;
const size_t n = (size - tail_n) / 32;
for (size_t i = 0; i < n; ++i, src1 += 2, src2 += 2)
{
printf("src1 align: %d, src2 align: %d\n", align(src1) % 16, align(src2) % 16);
__m128i mm11 = _mm_load_si128(src1);
__m128i mm12 = _mm_load_si128(src1 + 1);
__m128i mm21 = _mm_load_si128(src2);
__m128i mm22 = _mm_load_si128(src2 + 1);
__m128i mm1 = _mm_xor_si128(mm11, mm21);
__m128i mm2 = _mm_xor_si128(mm12, mm22);
__m128i mm = _mm_or_si128(mm1, mm2);
if (!_mm_testz_si128(mm, mm))
return 0;
}
//Check tail with scalar instructions
const size_t rem = (size - tail_n) % 32;
const char* tail_1 = (const char*)src1;
const char* tail_2 = (const char*)src2;
for (size_t i = 0; i < rem; i++, tail_1++, tail_2++)
{
if (*tail_1 != *tail_2)
return 0;
}
return 1;
}
我打印了两个指针的对齐方式,其中一个是对齐的,但第二个 - 不是。并且编程仍然 运行ning 正确且快速。
然后我像这样创建综合测试:
//printChars128(...) function just print 16 byte values from __m128i
const __m128i* A = (const __m128i*)buf;
const __m128i* B = (const __m128i*)(buf + rand() % 15 + 1);
for (int i = 0; i < 5; i++, A++, B++)
{
__m128i A1 = _mm_load_si128(A);
__m128i B1 = _mm_load_si128(B);
printChars128(A1);
printChars128(B1);
}
正如我们预期的那样,它在第一次迭代时崩溃,当尝试加载指针 B 时。
有趣的是,如果我将 target
切换为 sse4.2
,那么我的 is_equal
实现将会崩溃。
另一个有趣的事实是,如果我尝试对齐第二个指针而不是第一个指针(因此第一个指针将不对齐,第二个 - 对齐),那么 is_equal
将崩溃。
所以,我的问题是:"Why is_equal
function works fine with only first pointer aligned if i enable avx
instruction generation?"
UPD: 这是 C++
代码。我在 Windows、x86.
MinGW64/g++, gcc version 4.9.2
编译我的代码
编译字符串:g++.exe main.cpp -Wall -Wextra -std=c++11 -O2 -Wcast-align -Wcast-qual -o main.exe
TL:DR:来自 _mm_load_*
内在函数的加载可以折叠(在编译时)到其他指令的内存操作数中。 The AVX versions of vector instructions don't require alignment for memory operands,除了专门对齐的 load/store 指令,如 vmovdqa
.
在向量指令的传统 SSE 编码中(如 pxor xmm0, [src1]
),未对齐的 128 位内存操作数将出错,但特殊未对齐的 load/store 指令(如 movdqu
/ movups
)除外。
VEX-encoding of vector instructions (like vpxor xmm1, xmm0, [src1]
) doesn't fault with unaligned memory, except with the alignment-required load/store instructions (like vmovdqa
, or vmovntdq
).
_mm_loadu_si128
与 _mm_load_si128
(和 store/storeu)内在函数向编译器传达对齐保证,但不强制它实际发出独立加载指令。 (或者任何东西,如果它已经在寄存器中有数据,就像取消引用标量指针一样)。
优化使用内部函数的代码时,as-if 规则仍然适用。加载可以 折叠 到使用它的向量 ALU 指令的内存操作数,只要这不会引入错误的风险。由于代码密度的原因,这是有利的,并且由于微融合 (see Agner Fog's microarch.pdf),在 CPU 的部分中跟踪更少的微指令也是有利的。 -O0
未启用执行此操作的优化传递,因此未优化的代码构建可能会因未对齐的 src1 而出错。
(相反,这意味着 _mm_loadu_*
只能折叠到 AVX 的内存操作数中,但不能折叠到 SSE。因此即使在 CPUs 上 movdqu
与 movqda
当指针恰好对齐时,_mm_loadu
会损害性能,因为 movqdu xmm1, [rsi]
/ pxor xmm0, xmm1
是前端发出的 2 个融合域微指令,而 pxor xmm0, [rsi]
只有 1。并且不需要临时寄存器。另见 Micro fusion and addressing modes).
在这种情况下对 as-if 规则的解释是,在某些情况下,如果天真地翻译成 asm 会出错,那么程序不会出错是可以的。 (或者相同的代码在未优化的构建中出错但在优化的构建中没有出错)。
这与浮点异常的规则相反,在浮点异常中,编译器生成的代码仍必须引发任何和所有可能在 C 抽象机上发生的异常。这是因为有明确定义的机制来处理 FP 异常,但没有用于处理段错误。
请注意,由于存储不能折叠到 ALU 指令的内存操作数中,因此 store
(不是 storeu
)内在函数将编译成
具体来说:考虑以下代码片段:
// aligned version:
y = ...; // assume it's in xmm1
x = _mm_load_si128(Aptr); // Aligned pointer
res = _mm_or_si128(y, x);
// unaligned version: the same thing with _mm_loadu_si128(Uptr)
当以 SSE 为目标时(代码可以 运行 on CPUs without AVX support),对齐版本可以将负载折叠到 por xmm1, [Aptr]
,但未对齐版本必须使用
movdqu xmm0, [Uptr]
/ por xmm0, xmm1
。如果在 OR.
y
的旧值,对齐版本也可能会这样做
当以 AVX(gcc -mavx
、或 gcc -march=sandybridge
或更高版本为目标时,发出的所有向量指令(包括 128 位)都将使用 VEX 编码。所以你从相同的 _mm_...
内在函数中得到不同的 asm。两个版本都可以编译成vpor xmm0, xmm1, [ptr]
。 (而 3 操作数的非破坏性特征意味着这实际上会发生,除非多次使用加载的原始值)。
ALU 指令的一个操作数只能是内存操作数,因此在您的情况下,必须单独加载一个。当第一个指针未对齐时,您的代码会出错,但不关心第二个指针的对齐方式,因此我们可以得出结论,gcc 选择使用 vmovdqa
加载第一个操作数并折叠第二个,而不是副 -相反。
您可以在 the Godbolt compiler explorer 上的代码中实际看到这种情况。不幸的是,gcc 4.9(和 5.3)将其编译为次优代码,在 al
中生成 return 值,然后对其进行测试,而不是仅在 vptest
的标志上分支 :( clang-3.8 的表现要好得多。
.L36:
add rdi, 32
add rsi, 32
cmp rdi, rcx
je .L9
.L10:
vmovdqa xmm0, XMMWORD PTR [rdi] # first arg: loads that will fault on unaligned
xor eax, eax
vpxor xmm1, xmm0, XMMWORD PTR [rsi] # second arg: loads that don't care about alignment
vmovdqa xmm0, XMMWORD PTR [rdi+16] # first arg
vpxor xmm0, xmm0, XMMWORD PTR [rsi+16] # second arg
vpor xmm0, xmm1, xmm0
vptest xmm0, xmm0
sete al # generate a boolean in a reg
test eax, eax
jne .L36 # then test&branch on it. /facepalm
请注意,您的 is_equal
是 memcmp
。我认为 glibc 的 memcmp 在许多情况下会比您的实现做得更好,因为它有 hand-written asm versions for SSE4.1 和其他处理缓冲区相对于彼此未对齐的各种情况的方法。 (例如,一个对齐,一个不对齐。)请注意,glibc 代码是 LGPLed,因此您可能无法复制它。如果您的用例具有通常对齐的较小缓冲区,则您的实现可能很好。在从其他 AVX 代码调用它之前不需要 VZEROUPPER 也很好。
编译器生成的用于最后清理的字节循环绝对是次优的。如果大小大于 16 字节,则执行在每个 src 的最后一个字节结束的未对齐加载。重新比较已经检查过的一些字节并不重要。
无论如何,一定要用系统 memcmp
对您的代码进行基准测试。除了库实现之外,gcc 知道 memcmp 的作用,并且有自己的内置定义,可以为其内联代码。