在 x64 模式下从 C++/CLI 调用 MASM PROC 会产生意外的性能问题

Calling MASM PROC from C++/CLI in x64 mode yields unexpected performance problems

我正在编写一个任意精度的整数 class 以用于 C#(64 位)。目前我正在研究乘法例程,使用递归分而治之算法将多位乘法分解为一系列原始的 64 位到 128 位乘法,然后通过简单的方法重新组合其结果添加。为了获得显着的性能提升,我使用本机 x64 C++ 编写代码,嵌入到 C++/CLI 包装器中以使其可从 C# 代码调用。

到目前为止,就算法而言,一切都很好。但是,我的问题是速度优化。由于 64 位到 128 位的乘法是这里真正的瓶颈,我试图在那里优化我的代码。我的第一个简单方法是一个 C++ 函数,它通过执行四个 32 位到 64 位乘法并通过几次移位和加法重新组合结果来实现此乘法。这是源代码:

// 64-bit to 128-bit multiplication, using the following decomposition:
// (a*2^32 + i) (b*2^32 + i) = ab*2^64 + (aj + bi)*2^32 + ij

public: static void Mul (UINT64  u8Factor1,
                         UINT64  u8Factor2,
                         UINT64& u8ProductL,
                         UINT64& u8ProductH)
    {
    UINT64 u8Result1, u8Result2;
    UINT64 u8Factor1L = u8Factor1 & 0xFFFFFFFFULL;
    UINT64 u8Factor2L = u8Factor2 & 0xFFFFFFFFULL;
    UINT64 u8Factor1H = u8Factor1 >> 32;
    UINT64 u8Factor2H = u8Factor2 >> 32;

    u8ProductL = u8Factor1L * u8Factor2L;
    u8ProductH = u8Factor1H * u8Factor2H;
    u8Result1  = u8Factor1L * u8Factor2H;
    u8Result2  = u8Factor1H * u8Factor2L;

    if (u8Result1 > MAX_UINT64 - u8Result2)
        {
        u8Result1 +=  u8Result2;
        u8Result2  = (u8Result1 >> 32) | 0x100000000ULL; // add carry
        }
    else
        {
        u8Result1 +=  u8Result2;
        u8Result2  = (u8Result1 >> 32);
        }
    if (u8ProductL > MAX_UINT64 - (u8Result1 <<= 32))
        {
        u8Result2++;
        }
    u8ProductL += u8Result1;
    u8ProductH += u8Result2;
    return;
    }

此函数需要两个 64 位值,return需要一个 128 位结果作为两个 64 位量作为参考传递。这很好用。在下一步中,我尝试用调用 CPU 的 MUL 指令的 ASM 代码替换对该函数的调用。由于 x64 模式下不再有内联 ASM,因此必须将代码放入单独的 .asm 文件中。这是实现:

_TEXT segment

; =============================================================================
; multiplication
; -----------------------------------------------------------------------------
; 64-bit to 128-bit multiplication, using the x64 MUL instruction

AsmMul1 proc ; ?AsmMul1@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  rax, rcx            ; rax = Factor1
mul  rdx                 ; rdx:rax = Factor1 * Factor2
mov  qword ptr [r8], rax ; [r8] = ProductL
mov  qword ptr [r9], rdx ; [r9] = ProductH
ret

AsmMul1 endp

; =============================================================================

_TEXT ends
end

这非常简单明了。使用 extern "C" 前向定义从 C++ 代码引用该函数:

extern "C"
    {
    void AsmMul1 (UINT64, UINT64, UINT64&, UINT64&);
    }

令我惊讶的是,它比 C++ 函数慢得多。为了正确地对性能进行基准测试,我编写了一个 C++ 函数,该函数计算 10,000,000 对伪随机无符号 64 位值并在紧密循环中执行乘法,一个接一个地使用这些实现,具有完全相同的值。代码在发布模式下编译并启用优化。 ASM 版本在循环中花费的时间为 515 毫秒,而 C++ 版本为 125 毫秒(!)。

这很奇怪。于是我在调试器中打开反汇编window,将编译器生成的ASM代码复制过来。这是我在那里找到的,为了可读性和与 MASM 一起使用而略微编辑:

AsmMul3 proc ; ?AsmMul3@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  eax,  0FFFFFFFFh
and  rax,  rcx

; UINT64 u8Factor2L = u8Factor2 & 0xFFFFFFFFULL;
mov  r10d, 0FFFFFFFFh
and  r10,  rdx

; UINT64 u8Factor1H = u8Factor1 >> 32;
shr  rcx,  20h

; UINT64 u8Factor2H = u8Factor2 >> 32;
shr  rdx,  20h

; u8ProductL = u8Factor1L * u8Factor2L;
mov  r11,  r10
imul r11,  rax
mov  qword ptr [r8], r11

; u8ProductH = u8Factor1H * u8Factor2H;
mov  r11,  rdx
imul r11,  rcx
mov  qword ptr [r9], r11

; u8Result1 = u8Factor1L * u8Factor2H;
imul rax,  rdx

; u8Result2 = u8Factor1H * u8Factor2L;
mov  rdx,  rcx
imul rdx,  r10

; if (u8Result1 > MAX_UINT64 - u8Result2)
mov  rcx,  rdx
neg  rcx
dec  rcx
cmp  rcx,  rax
jae  label1

; u8Result1 += u8Result2;
add  rax,  rdx

; u8Result2 = (u8Result1 >> 32) | 0x100000000ULL; // add carry
mov  rdx,  rax
shr  rdx,  20h
mov  rcx,  100000000h
or   rcx,  rdx
jmp  label2

; u8Result1 += u8Result2;
label1:
add  rax,  rdx

; u8Result2 = (u8Result1 >> 32);
mov  rcx,  rax
shr  rcx,  20h

; if (u8ProductL > MAX_UINT64 - (u8Result1 <<= 32))
label2:
shl  rax,  20h
mov  rdx,  qword ptr [r8]
mov  r10,  rax
neg  r10
dec  r10
cmp  r10,  rdx
jae  label3

; u8Result2++;
inc  rcx

; u8ProductL += u8Result1;
label3:
add  rdx,  rax
mov  qword ptr [r8], rdx

; u8ProductH += u8Result2;
add  qword ptr [r9], rcx
ret

AsmMul3 endp

将这段代码复制到我的 MASM 源文件中并从我的基准例程中调用它导致在循环中花费了 547 毫秒。这比 ASM 函数稍慢,但比 C++ 函数慢得多。这更奇怪,因为后者应该执行完全相同的机器代码。

所以我尝试了另一种变体,这次使用手动优化的 ASM 代码,它执行完全相同的四次 32 位到 64 位乘法,但方式更直接。代码应避免跳转和立即值,使用 CPU FLAGS 进行进位评估,并使用指令交错以避免寄存器停顿。这是我想出的:

; 64-bit to 128-bit multiplication, using the following decomposition:
; (a*2^32 + i) (b*2^32 + j) = ab*2^64 + (aj + bi)*2^32 + ij

AsmMul2 proc ; ?AsmMul2@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  rax,  rcx           ; rax = Factor1
mov  r11,  rdx           ; r11 = Factor2
shr  rax,  32            ; rax = Factor1H
shr  r11,  32            ; r11 = Factor2H
and  ecx,  ecx           ; rcx = Factor1L
mov  r10d, eax           ; r10 = Factor1H
and  edx,  edx           ; rdx = Factor2L

imul rax,  r11           ; rax = ab = Factor1H * Factor2H
imul r10,  rdx           ; r10 = aj = Factor1H * Factor2L
imul r11,  rcx           ; r11 = bi = Factor1L * Factor2H
imul rdx,  rcx           ; rdx = ij = Factor1L * Factor2L

xor  ecx,  ecx           ; rcx = 0
add  r10,  r11           ; r10 = aj + bi
adc  ecx,  ecx           ; rcx = carry (aj + bi)
mov  r11,  r10           ; r11 = aj + bi
shl  rcx,  32            ; rcx = carry (aj + bi) << 32
shl  r10,  32            ; r10 = lower (aj + bi) << 32
shr  r11,  32            ; r11 = upper (aj + bi) >> 32

add  rdx,  r10           ; rdx = ij + (lower (aj + bi) << 32)
adc  rax,  r11           ; rax = ab + (upper (aj + bi) >> 32)
mov  qword ptr [r8], rdx ; save ProductL
add  rax,  rcx           ; add carry (aj + bi) << 32
mov  qword ptr [r9], rax ; save ProductH
ret

AsmMul2 endp

基准测试产生了 500 毫秒,因此这似乎是这三个 ASM 实现中最快的版本。然而,它们的性能差异很小——但它们都比朴素的 C++ 方法慢四倍!

这是怎么回事?在我看来,从 C++ 调用 ASM 代码通常会降低性能,但我在 Internet 上找不到任何可以解释它的内容。我连接 ASM 的方式正是 Microsoft 推荐的方式。

但是现在,请注意另一件更奇怪的事情!好吧,有编译器内在函数,不是吗? _umul128 内在函数应该完全按照我的 AsmMul1 函数的方式执行,即调用 64 位 CPU MUL 指令。因此,我将 AsmMul1 调用替换为对 _umul128 的相应调用。现在看看我在 return 中获得的性能值(同样,我在一个函数中按顺序 运行 所有四个基准测试):

_umul128: 109 msec
AsmMul2: 94 msec (hand-optimized ASM)
AsmMul3: 125 msec (compiler-generated ASM)
C++ function: 828 msec

现在 ASM 版本速度快得惊人,相对差异与以前大致相同。但是,C++ 函数现在非常懒惰!不知何故,内在函数的使用会颠倒整个性能值。可怕...

对于这种奇怪的行为,我还没有得到任何解释,如果能提供任何关于这里发生的事情的提示,我将不胜感激。如果有人可以解释如何控制这些性能问题,那就更好了。目前我很担心,因为代码中的一个小改动显然会对性能产生巨大影响。我想了解这里的潜在机制,以及如何获得可靠的结果。

还有一件事:为什么 64 到 128 位 MUL 比四个 64 到 64 位 IMUL 慢?!

经过大量的反复试验,并在 Internet 上进行了额外的广泛研究,我似乎找到了这种奇怪的性能行为的原因。神奇的词是 thunking 函数入口点。但让我从头说起。

我的一个观察是,使用哪个编译器内在函数来颠倒我的基准测试结果并不重要。实际上,将 __nop() (CPU NOP 操作码) 放在函数内的任何位置就足以触发此效果。即使将它放在 return 之前,它也能正常工作。更多测试表明,效果仅限于包含内在函数的函数。 __nop() 对代码流没有任何作用,但显然它改变了包含函数的属性。

我在 Whosebug 上发现了一个似乎解决类似问题的问题:How to best avoid double thunking in C++/CLI native types在评论中,找到了以下附加信息:

One of my own classes in our base library - which uses MFC - is called about a million times. We are seeing massive sporadic performance issues, and firing up the profiler I can see a thunk right at the bottom of this chain. That thunk takes longer than the method call.

这也正是我所观察到的 - "something" 在函数调用的过程中花费的时间比我的代码长四倍。 __clrcall modifier and in an article about Double Thunking 的文档中对函数 thunk 进行了一定程度的解释。在前者中,有一个使用内在函数的副作用的提示:

You can directly call __clrcall functions from existing C++ code that was compiled by using /clr as long as that function has an MSIL implementation. __clrcall functions cannot be called directly from functions that have inline asm and call CPU-specific intrinisics, for example, even if those functions are compiled with /clr.

因此,据我所知,包含内在函数的函数会丢失其 __clrcall 修饰符,该修饰符会在指定 /clr 编译器开关时自动添加 - 如果 C++ 函数应该被编译为本机代码。

我没有得到这个 thunking 和 double thunking 的所有细节,但显然需要使非托管函数可从托管函数调用。但是,可以通过将其嵌入到 #pragma managed(push, off) / #pragma managed(pop) 对中来关闭每个函数。不幸的是,这个 #pragma 在命名空间块内不起作用,因此可能需要进行一些编辑才能将它放在它应该出现的任何地方。

我已经尝试过这个技巧,将我所有的本机多精度代码放在这个 #pragma 中,并获得了以下基准测试结果:

AsmMul1: 78 msec (64-to-128-bit CPU MUL)
AsmMul2: 94 msec (hand-optimized ASM, 4 x IMUL)
AsmMul3: 125 msec (compiler-generated ASM, 4 x IMUL)
C++ function: 109 msec

现在这看起来很合理,终于!现在所有版本都有大致相同的执行时间,这是我对优化的 C++ 程序的期望。唉,仍然没有幸福的结局……将获胜者 AsmMul1 放入我的多精度乘法器中,所产生的执行时间是没有 #pragma 的 C++ 函数版本的执行时间的两倍。在我看来,解释是这段代码调用了其他 类 中的非托管函数,这些函数在 #pragma 之外,因此具有 __clrcall 修饰符。这似乎又产生了巨大的开销。

坦率地说,我厌倦了进一步调查这个问题。尽管带有单个 MUL 指令的 ASM PROC 似乎击败了所有其他尝试,但收益并没有预期的那么大,而且取消 thunking 会导致我的代码发生如此多的变化,我认为这不值得麻烦。所以我会继续我一开始写的C++函数,本来注定只是一个更好的东西的占位符...

在我看来,C++/CLI 中的 ASM 接口没有得到很好的支持,或者我可能仍然缺少一些基本的东西。也许有一种方法可以让这个函数只对 ASM 函数不产生影响,但到目前为止我还没有找到解决方案。连远程都没有。

请随意在此处添加您自己的想法和观察 - 即使它们只是推测。我认为这仍然是一个非常有趣的话题,需要更多的调查。