警告 C4799:函数没有 EMMS 指令

warning C4799: function has no EMMS instruction

我正在尝试创建使用包含 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;
     }
}

在 运行 应用程序之后显示此警告:

warning C4799: function 'test_MMX' has no EMMS instruction.

当我想以毫秒为单位测量 运行ning 这个函数 C# 的时间时,它 returns 这个值:-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;

有什么解决办法吗?

由于 MMX 对浮点寄存器使用别名,任何使用 MMX 指令的例程都必须以 EMMS 指令结束。 This instruction "clears" the registers, making them available for use by the x87 FPU once again。 (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++变量)的使用包装在方括号中,因为您实际上是在取消引用它们。
  • 正如评论者指出的那样,您可以在内联汇编中自由使用 ESIEDI 寄存器,因为内联汇编器会检测到它们的使用并生成额外的指令 push/pop他们相应地。事实上,它将使用 all 非易失性寄存器来执行此操作。如果你需要额外的寄存器,那么你就需要它们,这是一个很好的特性。但是在这段代码中,您只使用了三个通用寄存器,并且在 __stdcall 调用约定中,有三个通用寄存器专门定义为易失性(,可以被任何函数自由破坏):EAXEDXECX。所以你应该使用 those 寄存器以获得最大速度。因此,我已将您对 ESI 的使用更改为 EAX,并将您对 EDI 的使用更改为 EDX。这将改进您无法看到的代码,编译器自动生成的序言和结尾。

虽然这里潜伏着一个潜在的速度陷阱,那就是对齐。为了获得最大速度,MMX 指令需要对在 8 字节边界上对齐的数据进行操作。在循环中,未对齐的数据会对性能产生复合的负面影响:不仅数据在第一次通过循环时未对齐,从而造成显着的性能损失,而且在随后的每次循环中都保证数据未对齐。因此,为了使这段代码有机会变得更快,调用者需要保证 first_arraysecond_array 在 8 字节边界上对齐。

如果您不能保证这一点,那么该函数确实应该添加额外的代码来修复错位。本质上,您希望在开始循环之前(在单个字节上)执行几个非向量操作,直到达到合适的对齐方式。然后,您可以开始发出向量化的 MMX 指令。

(未对齐的加载在现代处理器上不再受到惩罚,但如果您以现代处理器为目标,您将编写 SSE2 代码。在需要 运行 MMX 代码的旧处理器上,对齐将是个大问题,未对齐的数据会降低你的表现。)

现在,此内联汇编不会生成特别高效 的代码。当您使用内联汇编时,编译器总是 为该函数生成序言和结尾代码。这并不可怕,因为它在关键的内部循环之外,但仍然是您不需要的垃圾。更糟糕的是,内联汇编块中的跳转往往会混淆 MSVC 的内联汇编器并导致它生成次优代码。它过于谨慎,防止你做一些可能破坏堆栈或导致其他外部副作用的事情,这很好,除了你编写内联汇编的全部原因(大概)是因为你想要最大的性能。

(不言而喻,但如果您不需要尽可能大的性能,您应该只用 C(或 C++)编写代码并让编译器优化它。它在大多数情况下都做得很好。)

如果您确实需要最大可能的性能,并且认为编译器生成的代码不会削减它,那么内联汇编的更好替代方法是使用内在函数。内在函数通常会一对一地映射到汇编语言指令,但编译器围绕它们做了更好的优化工作。

这是我的代码版本,使用 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. 您编写的原始汇编代码使得循环 总是 至少迭代一次,因此我将其转换为 dowhile 循环。这意味着循环条件的测试只需要在循环底部完成,而不是在顶部和底部一次。
  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 秒表中获取负值的最后一个问题 class,我通常猜测这是由于溢出造成的。换句话说,您的代码执行得太慢,并且计时器回绕了。不过,Kevin Gosse 指出 this is apparently a bug in the implementation of the Stopwatch class. I don't know much more about it, since I don't really use it. If you want a good microbenchmarking library, I use and recommend 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];
   }
}

您可能会对编译器完成后的代码速度如此之快感到惊喜 auto-vectorizing the loop。 :-) 请记住,更少的代码并不一定意味着更快的代码。所有这些额外的代码都是处理对齐问题所必需的,我在整个答案中都巧妙地回避了这些问题。如果向下滚动,在 $LL4@Naive_Pack,您会发现一个与我们在这里考虑的非常相似的内部循环。