由于 MMX 对浮点寄存器具有别名,因此任何使用 MMX 指令的例程都必须以该EMMS
指令结束。该指令“清除”寄存器,使它们再次可供 x87 FPU 使用。(x86 的任何 C 或 C++ 调用约定都假定是安全的。)
编译器警告您,您编写了一个使用 MMX 指令但不以指令结束的例程EMMS
。这是一个等待发生的错误,只要一些 FPU 指令尝试执行。
这是 MMX 的一个巨大缺点,也是你不能自由混合 MMX 和浮点指令的原因。当然,你可以扔EMMS
指令,但它是一个缓慢、高延迟的指令,所以这会降低性能。SSE 在这方面与 MMX 有相同的限制,至少对于整数运算。SSE2 是第一个解决这个问题的指令集,因为它使用了自己的离散寄存器集。它的寄存器也是 MMX 的两倍宽,因此您一次可以做更多。由于 SSE2 完成了 MMX 所做的一切,但更快、更容易、更高效,并且受到 Pentium 4 及更高版本的支持,因此现在很少有人需要编写使用 MMX 的新代码。如果你可以使用 SSE2,你应该。它会比 MMX 更快。不使用 MMX 的另一个原因是 64 位模式不支持它。
无论如何,编写 MMX 代码的正确方法是:
__asm
{
mov ecx, [length]
mov eax, [first_array]
shr ecx, 1
mov edx, [second_array]
label:
movq mm0, QWORD PTR [eax]
paddd mm0, QWORD PTR [edx]
add edx, 8
movq QWORD PTR [eax], mm0
add eax, 8
dec ecx
jnz label
emms
}
请注意,除了EMMS
指令(当然,它位于循环之外)之外,我还做了一些额外的更改:
- 汇编语言指令不以分号结尾。事实上,在汇编语言的语法中,分号是用来开始注释的。所以我删除了你的分号。
- 为了便于阅读,我还添加了空格。
- 而且,虽然这不是绝对必要的(Microsoft 的内联汇编程序足够宽容,可以让您侥幸逃脱),但最好明确并包装地址的使用(C/C++ 变量)在方括号中,因为您实际上是在取消引用它们。
- 正如评论者指出的那样,您可以在内联汇编中自由使用
ESI
andEDI
寄存器,因为内联汇编程序将检测它们的使用并生成相应的推送/弹出它们的附加指令。事实上,它将对所有非易失性寄存器执行此操作。如果您需要额外的寄存器,那么您就需要它们,这是一个不错的功能。但是在这段代码中,您只使用了三个通用寄存器,并且在__stdcall
调用约定中,有三个通用寄存器被专门定义为 volatile(即,可以被任何函数自由破坏):EAX
、EDX
和ECX
. 因此,您应该使用这些寄存器来获得最大速度。因此,我改变了你的使用ESI
to EAX
,以及你对EDI
to的使用EDX
。这将改进你看不到的代码,由编译器自动生成的序言和尾声。
不过,你有一个潜在的速度陷阱潜伏在这里,那就是对齐。为了获得最大速度,MMX 指令需要对在 8 字节边界上对齐的数据进行操作。在一个循环中,未对齐的数据会对性能产生复杂的负面影响:不仅数据在第一次通过循环时未对齐,从而产生显着的性能损失,而且在随后的每次循环中也保证未对齐。因此,为了让这段代码有任何快速的机会,调用者需要保证这一点first_array
并且second_array
在 8 字节边界上对齐。
如果您不能保证这一点,那么该函数确实应该添加额外的代码来修复错位。本质上,您希望在开始循环之前执行几个非向量操作(在单个字节上),直到达到合适的对齐。然后,您可以开始发出矢量化 MMX 指令。
(在现代处理器上,未对齐的负载不再受到惩罚,但如果您的目标是现代处理器,您将编写 SSE2 代码。在需要运行 MMX 代码的旧处理器上,对齐将是一个大问题,未对齐的数据将杀死你的表现。)
现在,这个内联汇编不会产生特别高效的代码。当您使用内联汇编时,编译器总是为函数生成序言和结尾代码。这并不可怕,因为它在关键的内部循环之外,但仍然——你不需要它。更糟糕的是,内联汇编块中的跳转往往会混淆 MSVC 的内联汇编器并导致它生成次优代码。它过于谨慎,防止您做一些可能破坏堆栈或导致其他外部副作用的事情,这很好,除了您编写内联汇编的全部原因(大概)是因为您希望获得最高性能。
(不言而喻,但如果您不需要最大可能的性能,您应该只用 C(或 C++)编写代码并让编译器对其进行优化。在大多数情况下,它都做得很好。 )
如果您确实需要最大可能的性能,并且确定编译器生成的代码不会削减它,那么内联汇编的更好替代方法是使用intrinsics。内在函数通常会一对一地映射到汇编语言指令,但编译器在围绕它们进行优化方面做得更好。
这是我的代码版本,使用 MMX 内在函数:
#include <intrin.h> // include header with MMX intrinsics
void __stdcall Function_With_Intrinsics(int *first_array, int *second_array, int length)
{
unsigned int counter = static_cast<unsigned int>(length);
counter /= 2;
do
{
*reinterpret_cast<__m64*>(first_array) = _mm_add_pi32(*reinterpret_cast<const __m64*>(first_array),
*reinterpret_cast<const __m64*>(second_array));
first_array += 8;
second_array += 8;
} while (--counter != 0);
_mm_empty();
}
它做同样的事情,但通过将更多内容委托给编译器的优化器来提高效率。几点注意事项:
- 由于您的汇编代码将
length
其视为无符号整数,因此我假设您的接口要求它实际上是无符号整数。(如果是这样,我想知道你为什么不在函数的签名中声明它。)为了达到同样的效果,我将它转换为unsigned int
,随后用作counter
. (如果我没有这样做,我必须对有符号整数进行移位操作,这可能会导致未定义的行为,或者除以二,编译器会为此生成较慢的代码来正确处理符号位。)
- 分散在各处的
*reinterpret_cast<__m64*>
业务看起来很吓人,但实际上是安全的——至少,相对而言是这样。这就是你应该对 MMX 内在函数做的事情。MMX 数据类型是__m64
,您可以认为它大致相当于一个mm?
寄存器。它的长度为 64 位,加载和存储是通过强制转换完成的。这些被直接翻译成MOVQ
指令。
- 你原来的汇编代码是这样编写的,循环总是至少迭代一次,所以我把它转换成一个
do
…<code>while循环。这意味着循环条件的测试只需要在循环的底部进行,而不是在顶部一次和底部一次。
_mm_empty()
内在函数导致发出EMMS
指令。
只是为了笑,让我们看看编译器把它变成了什么。这是 MSVC 16 (VS 2010) 的输出,针对 x86-32 并针对速度超过大小进行了优化(尽管在这种特殊情况下没有区别):
PUBLIC ?Function_With_Intrinsics@@YGXPAH0H@Z
; Function compile flags: /Ogtpy
_first_array$ = 8 ; size = 4
_second_array$ = 12 ; size = 4
_length$ = 16 ; size = 4
?Function_With_Intrinsics@@YGXPAH0H@Z PROC
mov ecx, DWORD PTR _length$[esp-4]
mov edx, DWORD PTR _second_array$[esp-4]
mov eax, DWORD PTR _first_array$[esp-4]
shr ecx, 1
sub edx, eax
$LL3:
movq mm0, MMWORD PTR [eax]
movq mm1, MMWORD PTR [edx+eax]
paddd mm0, mm1
movq MMWORD PTR [eax], mm0
add eax, 32
dec ecx
jne SHORT $LL3
emms
ret 12
?Function_With_Intrinsics@@YGXPAH0H@Z ENDP
它与您的原始代码明显相似,但做一些不同的事情。特别是,它以不同的方式跟踪数组指针,它(和我)认为它比您的原始代码更有效,因为它在循环内做的工作更少。它还分解了您的PADDD
指令,以便它的两个操作数都是 MMX 寄存器,而不是源是内存操作数。同样,这往往会以破坏额外的 MMX 寄存器为代价使代码更高效,但我们有很多空闲的,所以这当然是值得的。
更好的是,随着优化器在新版本的编译器中的改进,使用内在函数编写的代码可能会变得更好!
当然,重写函数以使用内在函数并不能解决对齐问题,但我假设您已经在调用方处理了该问题。如果没有,您将需要添加代码来处理它。
如果你想使用 SSE2——也许是这样,test_SSE2
并且你会根据当前处理器的特性位动态地委托给适当的实现——那么你可以这样做:
#include <intrin.h> // include header with SSE2 intrinsics
void __stdcall Function_With_Intrinsics_SSE2(int *first_array, int *second_array, int length)
{
unsigned int counter = static_cast<unsigned>(length);
counter /= 4;
do
{
_mm_storeu_si128(reinterpret_cast<__m128i*>(first_array),
_mm_add_epi32(_mm_loadu_si128(reinterpret_cast<const __m128i*>(first_array)),
_mm_loadu_si128(reinterpret_cast<const __m128i*>(second_array))));
first_array += 16;
second_array += 16;
} while (--counter != 0);
}
我编写的这段代码没有假设对齐,所以当加载和存储未对齐时它会起作用。为了在许多旧架构上实现最大速度,SSE2 需要 16 字节对齐,如果您可以保证源和目标指针因此对齐,您可以使用稍快的指令(例如,MOVDQA
与 相对MOVDQU
)。如上所述,在较新的体系结构上(至少是 Sandy Bridge 和后来,也许更早),这并不重要。
为了让您了解 SSE2 基本上只是 Pentium 4 及更高版本上 MMX 的直接替代品,除了您还可以执行两倍宽的操作,请查看编译为的代码:
PUBLIC ?Function_With_Intrinsics_SSE2@@YGXPAH0H@Z
; Function compile flags: /Ogtpy
_first_array$ = 8 ; size = 4
_second_array$ = 12 ; size = 4
_length$ = 16 ; size = 4
?Function_With_Intrinsics_SSE2@@YGXPAH0H@Z PROC
mov ecx, DWORD PTR _length$[esp-4]
mov edx, DWORD PTR _second_array$[esp-4]
mov eax, DWORD PTR _first_array$[esp-4]
shr ecx, 2
sub edx, eax
$LL3:
movdqu xmm0, XMMWORD PTR [eax]
movdqu xmm1, XMMWORD PTR [edx+eax]
paddd xmm0, xmm1
movdqu XMMWORD PTR [eax], xmm0
add eax, 64
dec ecx
jne SHORT $LL3
ret 12
?Function_With_Intrinsics_SSE2@@YGXPAH0H@Z ENDP
至于关于从 .NET Stopwatch 类中获取负值的最后一个问题,我通常猜测这是由于溢出造成的。换句话说,您的代码执行得太慢了,并且计时器开始循环。不过,Kevin Gosse 指出,这显然是Stopwatch
类实现中的一个错误。我不太了解它,因为我并没有真正使用它。如果你想要一个好的微基准测试库,我使用并推荐Google Benchmark。但是,它适用于 C++,而不是 C#。
当您进行基准测试时,当您以幼稚的方式编写编译器生成的代码时,一定要花时间计时。说,类似:
void Naive_PackedAdd(int *first_array, int *second_array, int length)
{
for (unsigned int i = 0; i < static_cast<unsigned int>(length); ++i)
{
first_array[i] += second_array[i];
}
}
在编译器完成自动向量化循环后,您可能会对代码的速度感到惊喜。:-) 请记住,更少的代码并不一定意味着更快的代码。所有这些额外的代码都是处理对齐问题所必需的,我在整个答案中都巧妙地避开了这些问题。如果向下滚动,在 处$LL4@Naive_Pack
,您会发现一个与我们在这里考虑的非常相似的内部循环。