我正在编写一个用于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位值,并返回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这是最简单和最直接的。该函数使用C++转发定义从extern "C"代码中引用:
extern "C"
{
void AsmMul1 (UINT64, UINT64, UINT64&, UINT64&);
}令我惊讶的是,它被证明比C++函数慢得多。为了正确地对性能进行基准测试,我编写了一个C++函数,它计算10,000,000对伪随机的无符号64位值,并在一个紧循环中执行乘法,使用这些实现一个接一个地使用完全相同的值。代码是在打开优化的情况下以发布模式编译的。ASM版本在循环中花费的时间为515 msec,而ASM版本为125 msec (!)对于C++版本。
太奇怪了。因此,我在调试器中打开了反汇编窗口,并复制了编译器生成的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 msec。这比ASM函数稍慢,也比C++函数慢得多。这就更奇怪了,因为后者应该执行完全相同的机器代码。
因此,我尝试了另一个变体,这次使用手工优化的ASM代码,完全相同的四个32到64位的乘法,但以一种更直接的方式。代码应避免跳转和直接值,使用CPU标志进行进位评估,并使用指令交织以避免注册中断。这就是我想出来的:
; 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代码有一些一般的性能损失,但我在互联网上找不到任何可能解释这一点的东西。我连接ASM的方式正是微软推荐的方式。
但是现在,注意另一件更奇怪的事情!嗯,有编译器的本质,对不对?据推测,_umul128内部应该做我的AsmMul1函数所做的事情,即调用64位CPU MUL指令。因此,我将AsmMul1调用替换为对_umul128的相应调用。现在看看我得到了哪些性能值(同样,我是在一个函数中依次运行所有四个基准):
_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位IMULs慢?!
发布于 2019-03-21 15:01:48
经过大量的尝试和错误,以及互联网上更多的广泛研究,我似乎找到了这种奇怪的性能行为的原因。神奇的词是函数入口点的thunking。但让我从头开始。
我所做的一个观察是,使用哪一个编译器内在来将我的基准测试结果倒过来并不重要。实际上,只要在函数中的任何地方放置一个__nop() (CPU NOP操作码)就可以触发这种效果。即使它被放置在return之前,它也能工作。更多的测试表明,这种效果仅限于包含本征函数的函数。__nop()对代码流不做任何操作,但显然它更改了包含函数的属性。
我发现了一个关于堆栈溢出的问题,它似乎解决了一个类似的问题:如何最好地避免C++/CLI本机类型中的双重阻塞在评论中找到了以下附加信息:
在我们的基础库中,我自己的一个类--使用MFC --被调用了大约一百万次。我们看到了大量零星的性能问题,并且启动了分析器--我可以在这条链的底部看到一声巨响。这种方法调用花费的时间比方法调用的时间要长。
这也正是我所观察到的--函数调用过程中的“某样东西”花费的时间大约是我的代码的四倍。函数块在clrcall修饰符文档和一篇关于双击的文章中作了一定程度的解释。在前一种情况中,有一个提示是使用本质的副作用:
只要__clrcall函数有C++实现,就可以从使用/clr编译的现有C++代码中直接调用该函数。__clrcall函数不能直接从具有内联asm和调用CPU特定内部结构的函数调用,例如,即使这些函数是用/clr编译的。
因此,据我所知,包含本质信息的函数失去了它的__clrcall修饰符,当指定/clr编译器开关时会自动添加该修饰符--如果应该将C++函数编译成本机代码,通常就是这种情况。
我并没有完全了解这个thunking和double thunking的细节,但是很明显,它需要使非托管函数从托管函数中调用。但是,可以通过将其嵌入到#pragma managed(push, off) / #pragma managed(pop)对中来关闭每个函数。不幸的是,此#杂注在命名空间块中不起作用,因此可能需要进行一些编辑,以便将其放置在任何应该发生的位置。
我尝试过这个技巧,将我的所有本机多精度代码都放在这个#杂注中,并获得了以下基准测试结果:
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放置到我的多精度乘法器中,将产生的执行时间是不使用C++函数的版本的执行时间的两倍。我的解释是,在我看来,这段代码调用其他类中的非托管函数,这些类在#杂注之外,因此有一个__clrcall修饰符。这似乎又造成了很大的开销。
坦率地说,我厌倦了进一步调查这个问题。尽管带有单一MUL指令的ASM程序似乎比所有其他尝试都要好,但收益并不像预期的那么大,而且,如果不使用这种方法,我的代码就会发生如此多的变化,我认为这不值得麻烦。因此,我将继续我在一开始写的C++函数,它最初注定只是一个占位符,用于更好的.
在我看来,C++/CLI中的ASM接口不受很好的支持,或者我仍然缺少一些基本的东西。也许有一种方法可以让这个函数摆脱ASM函数的阻碍,但到目前为止,我还没有找到解决方案。甚至远距离都没有。
在这里自由地添加你自己的想法和观察--即使它们只是推测性的。我认为这仍然是一个非常有趣的话题,需要更多的调查。
https://stackoverflow.com/questions/55266411
复制相似问题