1

我正在尝试创建使用包含 C++ 代码和内联汇编的 dll 库的 C# 应用程序。在函数 test_MMX 中,我想添加两个特定长度的数组。

extern "C" __declspec(dllexport) void __stdcall test_MMX(int *first_array,int *second_array,int length)
{
    __asm
    {
         mov ecx,length;
         mov esi,first_array;
         shr ecx,1;
         mov edi,second_array;
     label:
         movq mm0,QWORD PTR[esi];
         paddd mm0,QWORD PTR[edi];
         add edi,8;
         movq QWORD PTR[esi],mm0;
         add esi,8;
         dec ecx;
         jnz label;
     }
}

运行应用程序后,它显示此警告:

警告 C4799:函数“test_MMX”没有 EMMS 指令。

当我想以毫秒为单位测量运行此函数 C# 的时间时,它返回此值:-922337203685477而不是(例如0,0141)...

private Stopwatch time = new Stopwatch();
time.Reset();
time.Start();
test_MMX(first_array, second_array, length);
time.Stop();
TimeSpan interval = time.Elapsed;
return trvanie.TotalMilliseconds;

任何想法如何解决它?

4

1 回答 1

1

由于 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++ 变量)在方括号中,因为您实际上是在取消引用它们。
  • 正如评论者指出的那样,您可以在内联汇编中自由使用ESIandEDI寄存器,因为内联汇编程序将检测它们的使用并生成相应的推送/弹出它们的附加指令。事实上,它将对所有非易失性寄存器执行此操作。如果您需要额外的寄存器,那么您就需要它们,这是一个不错的功能。但是在这段代码中,您只使用了三个通用寄存器,并且在__stdcall调用约定中,有三个通用寄存器被专门定义为 volatile(,可以被任何函数自由破坏):EAXEDXECX. 因此,您应该使用这些寄存器来获得最大速度。因此,我改变了你的使用ESIto EAX,以及你对EDIto的使用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();
}

它做同样的事情,但通过将更多内容委托给编译器的优化器来提高效率。几点注意事项:

  1. 由于您的汇编代码将length其视为无符号整数,因此我假设您的接口要求它实际上无符号整数。(如果是这样,我想知道你为什么不在函数的签名中声明它。)为了达到同样的效果,我将它转换为unsigned int,随后用作counter. (如果我没有这样做,我必须对有符号整数进行移位操作,这可能会导致未定义的行为,或者除以二,编译器会为此生成较慢的代码来正确处理符号位。)
  2. 分散在各处的*reinterpret_cast<__m64*>业务看起来很吓人,但实际上是安全的——至少,相对而言是这样。这就是你应该对 MMX 内在函数做的事情。MMX 数据类型是__m64,您可以认为它大致相当于一个mm?寄存器。它的长度为 64 位,加载和存储是通过强制转换完成的。这些被直接翻译成MOVQ指令。
  3. 你原来的汇编代码是这样编写的,循环总是至少迭代一次,所以我把它转换成一个do…<code>while循环。这意味着循环条件的测试只需要在循环的底部进行,而不是在顶部一次和底部一次。
  4. _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,您会发现一个与我们在这里考虑的非常相似的内部循环。

于 2017-04-23T12:15:39.507 回答