我试图比较内联汇编语言和 C++ 代码的性能,所以我写了一个函数,将两个大小为 2000 的数组相加 100000 次。这是代码:
#define TIMES 100000
void calcuC(int *x,int *y,int length)
{
for(int i = 0; i < TIMES; i++)
{
for(int j = 0; j < length; j++)
x[j] += y[j];
}
}
void calcuAsm(int *x,int *y,int lengthOfArray)
{
__asm
{
mov edi,TIMES
start:
mov esi,0
mov ecx,lengthOfArray
label:
mov edx,x
push edx
mov eax,DWORD PTR [edx + esi*4]
mov edx,y
mov ebx,DWORD PTR [edx + esi*4]
add eax,ebx
pop edx
mov [edx + esi*4],eax
inc esi
loop label
dec edi
cmp edi,0
jnz start
};
}
这是main()
:
int main() {
bool errorOccured = false;
setbuf(stdout,NULL);
int *xC,*xAsm,*yC,*yAsm;
xC = new int[2000];
xAsm = new int[2000];
yC = new int[2000];
yAsm = new int[2000];
for(int i = 0; i < 2000; i++)
{
xC[i] = 0;
xAsm[i] = 0;
yC[i] = i;
yAsm[i] = i;
}
time_t start = clock();
calcuC(xC,yC,2000);
// calcuAsm(xAsm,yAsm,2000);
// for(int i = 0; i < 2000; i++)
// {
// if(xC[i] != xAsm[i])
// {
// cout<<"xC["<<i<<"]="<<xC[i]<<" "<<"xAsm["<<i<<"]="<<xAsm[i]<<endl;
// errorOccured = true;
// break;
// }
// }
// if(errorOccured)
// cout<<"Error occurs!"<<endl;
// else
// cout<<"Works fine!"<<endl;
time_t end = clock();
// cout<<"time = "<<(float)(end - start) / CLOCKS_PER_SEC<<"\n";
cout<<"time = "<<end - start<<endl;
return 0;
}
然后我运行该程序五次以获得处理器的周期,这可以看作是时间。每次我只调用上面提到的函数之一。
结果来了。
汇编版功能:
Debug Release
---------------
732 668
733 680
659 672
667 675
684 694
Average: 677
C++版本功能:
Debug Release
-----------------
1068 168
999 166
1072 231
1002 166
1114 183
Average: 182
发布模式下的 C++ 代码几乎比汇编代码快 3.7 倍。为什么?
我猜我写的汇编代码没有GCC生成的那么有效。像我这样的普通程序员很难写出比编译器生成的对手更快的代码。这是否意味着我不应该相信自己亲手编写的汇编语言的性能,专注于 C++ 而忘记汇编语言?
是的,大多数时候。
首先,您从错误的假设开始,即低级语言(在这种情况下为汇编)总是会比高级语言(在这种情况下为 C++ 和 C)生成更快的代码。这不是真的。 C 代码总是比 Java 代码快吗?不,因为还有另一个变量:程序员。您编写代码的方式和架构细节知识会极大地影响性能(正如您在本例中看到的那样)。
您可以总是制作一个示例,其中手工汇编代码比编译代码更好,但通常这是一个虚构的示例或单个例程而不是 真正的程序500.000+ 行 C++ 代码)。我认为编译器会在 95% 的时间内生成更好的汇编代码,并且有时,仅在极少数情况下,您可能需要为少数、简短、highly used、performance critical 例程或当您有访问您最喜欢的高级语言未公开的功能。您想了解一下这种复杂性吗?请在此处阅读this awesome answer。
为什么这个?
首先是因为编译器可以进行我们甚至无法想象的优化(参见 this short list),而且他们会在 秒 内完成(当 we may need days 时)。
当您在汇编中编码时,您必须使用定义良好的调用接口来制作定义良好的函数。但是,它们可以考虑 whole-program optimization 和 inter-procedural optimization,例如 register allocation、constant propagation、common subexpression elimination、instruction scheduling 和其他复杂的、不明显的优化(例如 Polytope model)。在 RISC 架构上,人们多年前就不再担心这个问题了(例如,指令调度很难tune by hand),现代的 CISC CPU 也有很长的 pipelines。
对于一些复杂的微控制器,甚至系统库都是用 C 语言而不是汇编语言编写的,因为它们的编译器会生成更好(且易于维护)的最终代码。
编译器有时可以自己automatically use some MMX/SIMDx instructions,如果您不使用它们,您根本无法比较(其他答案已经很好地审查了您的汇编代码)。仅对于循环,这是编译器通常检查的short list of loop optimizations(当您为 C# 程序确定了日程安排后,您认为您可以自己完成吗?)如果您编写汇编中的某些东西,我认为您至少必须考虑一些simple optimizations。数组的教科书示例是 unroll the cycle(它的大小在编译时已知)。这样做并再次运行您的测试。
如今,由于另一个原因需要使用汇编语言也很少见:plethora of different CPUs。你想支持他们吗?每个都有一个特定的 microarchitecture 和一些 specific instruction sets。它们具有不同数量的功能单元,应安排组装说明以使它们都忙碌。如果您用 C 编写,您可以使用 PGO,但在汇编中,您将需要对该特定架构有深入的了解(并且重新考虑并重做其他架构的所有内容)。对于小型任务,编译器通常会做得更好,而对于复杂的任务通常,工作不会得到回报(无论如何compiler may do better)。
如果您坐下来查看您的代码,您可能会发现重新设计算法比转换为汇编(阅读此great post here on SO)获得的收益更多,这里有高级优化(和提示编译器)您可以在需要求助于汇编语言之前有效地应用。可能值得一提的是,经常使用内在函数可以获得所需的性能提升,并且编译器仍然能够执行大部分优化。
综上所述,即使您可以生成快 5 到 10 倍的汇编代码,您也应该询问您的客户是否愿意支付您一周的时间或购买速度快 50 美元的 CPU。我们大多数人通常不需要极端优化(尤其是在 LOB 应用程序中)。
您的汇编代码不是最理想的,可以改进:
您正在内部循环中推送和弹出寄存器(EDX)。这应该移出循环。
在循环的每次迭代中重新加载数组指针。这应该移出循环。
您使用循环指令,众所周知,在大多数现代 CPU 上该指令非常慢(可能是使用古代汇编书的结果*)
您没有利用手动循环展开。
您不使用可用的 SIMD 指令。
因此,除非您大大提高了汇编程序方面的技能,否则您编写汇编程序代码以提高性能是没有意义的。
*当然我不知道你是否真的从一本古老的汇编书中得到了loop
指令。但是您几乎从未在现实世界的代码中看到它,因为那里的每个编译器都足够聪明,不会发出 loop
,您只能在恕我直言的糟糕和过时的书籍中看到它。
loop
(以及许多“已弃用”的指令)
甚至在深入研究汇编之前,就存在更高级别的代码转换。
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int i = 0; i < TIMES; i++) {
for (int j = 0; j < length; j++) {
x[j] += y[j];
}
}
}
可以通过 Loop Rotation 转换为:
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int j = 0; j < length; ++j) {
for (int i = 0; i < TIMES; ++i) {
x[j] += y[j];
}
}
}
就内存局部性而言,这要好得多。
这可以进一步优化,做 a += b
X 次相当于做 a += X * b
所以我们得到:
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int j = 0; j < length; ++j) {
x[j] += TIMES * y[j];
}
}
但是,我最喜欢的优化器(LLVM)似乎没有执行这种转换。
[edit] 我发现如果我们将 restrict
限定符用于 x
和 y
,则执行转换。实际上,如果没有此限制,x[j]
和 y[j]
可能会别名为同一位置,这会导致此转换错误。 [结束编辑]
无论如何,我认为这是优化的 C 版本。它已经简单多了。基于此,这是我对 ASM 的破解(我让 Clang 生成它,我对此毫无用处):
calcuAsm: # @calcuAsm
.Ltmp0:
.cfi_startproc
# BB#0:
testl %edx, %edx
jle .LBB0_2
.align 16, 0x90
.LBB0_1: # %.lr.ph
# =>This Inner Loop Header: Depth=1
imull $100000, (%rsi), %eax # imm = 0x186A0
addl %eax, (%rdi)
addq $4, %rsi
addq $4, %rdi
decl %edx
jne .LBB0_1
.LBB0_2: # %._crit_edge
ret
.Ltmp1:
.size calcuAsm, .Ltmp1-calcuAsm
.Ltmp2:
.cfi_endproc
恐怕我不明白所有这些指令的来源,但是您总是可以玩得开心并尝试看看它的比较...但是我仍然会在代码中使用优化的 C 版本而不是汇编版本,更便携。
x
和 y
之间可能的aliasing。也就是说,编译器不能确定对于 [0, length)
中的所有 i,j
我们都有 x + i != y + j
。如果有重叠,那么优化是不可能的。 C 语言引入了 restrict
关键字来告诉编译器两个指针不能别名,但它不适用于数组,因为即使它们不完全别名,它们仍然可以重叠。
__restrict
,则在检查非重叠之后)。 SSE2 是 x86-64 的基线,通过改组 SSE2 可以一次进行 2 次 32 位乘法(生成 64 位产品,因此改组将结果重新组合在一起)。 godbolt.org/z/r7F_uo。 (pmulld
需要 SSE4.1:压缩 32x32 => 32 位乘法)。 GCC 有一个巧妙的技巧,可以将常量整数乘法器转换为移位/加法(和/或减法),这对于设置少量位的乘法器很有用。 Clang 的 shuffle-heavy 代码将成为 Intel CPU 上 shuffle 吞吐量的瓶颈。
简短的回答:是的。
长答案:是的,除非您真的知道自己在做什么,并且有理由这样做。
我已经修复了我的 asm 代码:
__asm
{
mov ebx,TIMES
start:
mov ecx,lengthOfArray
mov esi,x
shr ecx,1
mov edi,y
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
dec ebx
jnz start
};
发布版本的结果:
Function of assembly version: 81
Function of C++ version: 161
发布模式下的汇编代码几乎比 C++ 快 2 倍。
xmm0
而不是 mm0
),您将获得另一个两倍的加速;-)
paddd xmm
自动矢量化(在检查 x
和 y
之间的重叠之后,因为您没有使用 int *__restrict x
)。例如 gcc 这样做:godbolt.org/z/c2JG0-。或者在内联到 main
之后,它不需要检查重叠,因为它可以看到分配并证明它们不重叠。 (在某些 x86-64 实现中,它也会假设 16 字节对齐,而独立定义的情况并非如此。)如果您使用 gcc -O3 -march=native
编译,您可以获得 256 位或512 位矢量化。
这是否意味着我不应该相信我亲手编写的汇编语言的性能
是的,这正是它的意思,并且适用于每种语言。如果你不知道如何用 X 语言编写高效的代码,那么你不应该相信你用 X 编写高效代码的能力。因此,如果你想要高效的代码,你应该使用另一种语言。
装配对此特别敏感,因为,你所见即所得。您编写希望 CPU 执行的特定指令。对于高级语言,中间有一个编译器,它可以转换你的代码并消除许多低效率的地方。有了组装,你就靠自己了。
现在使用汇编语言的唯一原因是使用该语言无法访问的一些功能。
这适用于:
需要访问某些硬件功能(例如 MMU)的内核编程
使用编译器不支持的非常特定的向量或多媒体指令的高性能编程。
但是当前的编译器非常聪明,它们甚至可以用一条指令替换两个单独的语句,如 d = a / b; r = a % b;
,如果它可用,即使 C 没有这样的运算符,也可以一次性计算除法和余数。
的确,现代编译器在代码优化方面做得非常出色,但我仍然鼓励您继续学习汇编。
首先,您显然不会被它吓倒,这是一个非常棒的加分项,接下来 - 您通过分析走在正确的轨道上以验证或放弃您的速度假设,您正在向有经验的人征求意见,并且您拥有人类已知的最强大的优化工具:大脑。
随着经验的增加,您将了解何时何地使用它(通常是代码中最紧凑、最内层的循环,在您在算法级别进行深度优化之后)。
为了获得灵感,我建议您查阅 Michael Abrash 的文章(如果您还没有收到他的消息,他是一位优化大师;他甚至与 John Carmack 合作优化 Quake 软件渲染器!)
“没有最快的代码”——迈克尔·阿布拉什
我改变了asm代码:
__asm
{
mov ebx,TIMES
start:
mov ecx,lengthOfArray
mov esi,x
shr ecx,2
mov edi,y
label:
mov eax,DWORD PTR [esi]
add eax,DWORD PTR [edi]
add edi,4
dec ecx
mov DWORD PTR [esi],eax
add esi,4
test ecx,ecx
jnz label
dec ebx
test ebx,ebx
jnz start
};
发布版本的结果:
Function of assembly version: 41
Function of C++ version: 161
发布模式下的汇编代码几乎比 C++ 快 4 倍。恕我直言,汇编代码的速度取决于程序员
shr ecx,2
是多余的,因为数组长度已经在 int
中给出,而不是在字节中。所以你基本上达到了同样的速度。您可以尝试 harolds 答案中的 paddd
,这确实会更快。
这是一个非常有趣的话题!我在 Sasha 的代码中通过 SSE 更改了 MMX 这是我的结果:
Function of C++ version: 315
Function of assembly(simply): 312
Function of assembly (MMX): 136
Function of assembly (SSE): 62
使用 SSE 的汇编代码比 C++ 快 5 倍
大多数高级语言编译器都非常优化并且知道它们在做什么。您可以尝试转储反汇编代码并将其与您的本机程序集进行比较。我相信您会看到您的编译器正在使用的一些不错的技巧。
举个例子,即使我不确定它是否正确:):
正在做:
mov eax,0
花费更多的周期比
xor eax,eax
它做同样的事情。
编译器知道所有这些技巧并使用它们。
编译器打败了你。我会试一试,但我不会做任何保证。我将假设 TIMES 的“乘法”旨在使其成为更相关的性能测试,y
和 x
是 16 对齐的,并且 length
是 4 的非零倍数。这可能是反正都是真的。
mov ecx,length
lea esi,[y+4*ecx]
lea edi,[x+4*ecx]
neg ecx
loop:
movdqa xmm0,[esi+4*ecx]
paddd xmm0,[edi+4*ecx]
movdqa [edi+4*ecx],xmm0
add ecx,4
jnz loop
就像我说的,我不做任何保证。但是,如果它可以做得更快,我会感到惊讶——这里的瓶颈是内存吞吐量,即使一切都是 L1 命中。
mov ecx, length, lea ecx,[ecx*4], mov eax,16... add ecx,eax
然后在任何地方使用 [esi+ecx] 您将避免每条指令出现 1 个周期停顿,从而加快循环批次。 (如果您拥有最新的 Skylake,则不适用)。 add reg,reg 只是使循环更紧密,这可能有帮助,也可能没有帮助。
只是盲目地在汇编中逐条执行完全相同的算法,保证比编译器可以做的要慢。
这是因为即使是编译器所做的最小优化也比完全没有优化的僵化代码要好。
当然,有可能击败编译器,特别是如果它是代码的一小部分本地化部分,我什至不得不自己做才能获得大约。速度提高了 4 倍,但在这种情况下,我们必须严重依赖对硬件的良好了解和众多看似违反直觉的技巧。
作为编译器,我会将具有固定大小的循环替换为许多执行任务。
int a = 10;
for (int i = 0; i < 3; i += 1) {
a = a + i;
}
会产生
int a = 10;
a = a + 0;
a = a + 1;
a = a + 2;
最终它会知道“a = a + 0;”没用,所以它会删除这条线。希望您现在愿意将一些优化选项作为评论附加在您的脑海中。所有这些非常有效的优化都会使编译语言更快。
a
是易变的,否则编译器很有可能从一开始就执行 int a = 13;
。
这正是它的意思。将微优化留给编译器。
我喜欢这个例子,因为它展示了关于低级代码的重要一课。是的,您可以编写与 C 代码一样快的程序集。这在同义反复中是正确的,但并不一定意味着什么。显然有人可以,否则汇编程序将不知道适当的优化。
同样,当您向上提升语言抽象层次时,同样的原则也适用。是的,你可以用 C 编写一个解析器,它的速度与快速而肮脏的 perl 脚本一样快,而且很多人都这样做。但这并不意味着因为您使用了 C,您的代码就会很快。在许多情况下,高级语言会进行您可能从未考虑过的优化。
在许多情况下,执行某些任务的最佳方式可能取决于执行任务的上下文。如果例程是用汇编语言编写的,那么指令序列通常不可能根据上下文而改变。作为一个简单的例子,考虑以下简单的方法:
inline void set_port_high(void)
{
(*((volatile unsigned char*)0x40001204) = 0xFF);
}
鉴于上述情况,32 位 ARM 代码的编译器可能会将其呈现为:
ldr r0,=0x40001204
mov r1,#0
strb r1,[r0]
[a fourth word somewhere holding the constant 0x40001204]
也许
ldr r0,=0x40001000 ; Some assemblers like to round pointer loads to multiples of 4096
mov r1,#0
strb r1,[r0+0x204]
[a fourth word somewhere holding the constant 0x40001000]
这可以在手工组装的代码中稍微优化,如:
ldr r0,=0x400011FF
strb r0,[r0+5]
[a third word somewhere holding the constant 0x400011FF]
或者
mvn r0,#0xC0 ; Load with 0x3FFFFFFF
add r0,r0,#0x1200 ; Add 0x1200, yielding 0x400011FF
strb r0,[r0+5]
两种手工组装的方法都需要 12 个字节的代码空间,而不是 16 个;后者将用“添加”替换“加载”,这将在 ARM7-TDMI 上更快地执行两个周期。如果代码要在 r0 不知道/不关心的上下文中执行,那么汇编语言版本会比编译版本好一些。另一方面,假设编译器知道某个寄存器 [例如 r5] 将保存一个位于所需地址 0x40001204 [例如 0x40001000] 的 2047 字节内的值,并且进一步知道某个其他寄存器 [例如 r7] 正在执行保存一个低位为 0xFF 的值。在这种情况下,编译器可以将 C 版本的代码优化为:
strb r7,[r5+0x204]
甚至比手动优化的汇编代码更短、更快。此外,假设 set_port_high 发生在上下文中:
int temp = function1();
set_port_high();
function2(temp); // Assume temp is not used after this
在为嵌入式系统编码时一点也不难以置信。如果 set_port_high
是用汇编代码编写的,编译器必须在调用汇编代码之前将 r0(它保存 function1
的返回值)移到其他位置,然后再将该值移回 r0(因为 function2
将期望它的第一个参数在 r0),因此“优化”的汇编代码需要 5 条指令。即使编译器不知道任何寄存器保存地址或要存储的值,它的四指令版本(它可以适应使用任何可用的寄存器——不一定是 r0 和 r1)也会击败“优化”程序集- 语言版本。如果编译器在 r5 和 r7 中有必要的地址和数据,如前所述,function1
不会更改这些寄存器,因此它可以用单个 strb
指令替换 set_port_high
-四个指令更小并且比“手动优化”的汇编代码更快。
请注意,在程序员知道精确的程序流程的情况下,手动优化的汇编代码通常可以胜过编译器,但在一段代码在其上下文已知之前编写的情况下,或者在一段源代码可能是从多个上下文调用[如果 set_port_high
在代码中的 50 个不同位置使用,编译器可以为每个位置独立决定如何最好地扩展它]。
总的来说,我建议汇编语言在可以从非常有限数量的上下文中处理每段代码的情况下产生最大的性能改进,并且在一段代码的情况下往往会损害性能。可以从许多不同的上下文中处理代码。有趣(并且方便地)汇编对性能最有利的情况通常是代码最简单易读的情况。汇编语言代码会变成一团糟的地方通常是那些用汇编编写会提供最小性能优势的地方。
[小提示:在某些地方可以使用汇编代码来产生超优化的糊状混乱;例如,我为 ARM 编写的一段代码需要从 RAM 中获取一个字,并根据值的高六位执行大约十二个例程之一(许多值映射到同一个例程)。我想我将该代码优化为:
ldrh r0,[r1],#2! ; Fetch with post-increment
ldrb r1,[r8,r0 asr #10]
sub pc,r8,r1,asl #2
寄存器 r8 始终保存主调度表的地址(在循环中代码花费了 98% 的时间,没有将它用于任何其他目的);所有 64 个条目都指向其前面 256 个字节中的地址。由于在大多数情况下主循环的硬执行时间限制约为 60 个周期,因此 9 个周期的获取和分派对于实现该目标非常有帮助。使用包含 256 个 32 位地址的表会快一个周期,但会占用 1KB 非常宝贵的 RAM [闪存会添加多个等待状态]。使用 64 个 32 位地址需要添加一条指令来屏蔽提取的字中的一些位,并且仍然会比我实际使用的表多吞噬 192 个字节。使用 8 位偏移量表产生了非常紧凑和快速的代码,但我不希望编译器会想出这样的东西;我也不希望编译器专门用一个寄存器“全职”来保存表地址。
上面的代码被设计为作为一个独立的系统运行;它可以定期调用 C 代码,但只有在某些时候,它与之通信的硬件可以安全地进入“空闲”状态,每 16 毫秒有两个大约 1 毫秒的时间间隔。
最近,我所做的所有速度优化都是用合理的代码替换大脑受损的慢代码。但是因为速度真的很关键,我认真地努力让事情变得更快,结果总是一个迭代过程,每次迭代都能更深入地了解问题,找到如何用更少的操作解决问题的方法。最终速度始终取决于我对问题的了解程度。如果在任何阶段我使用汇编代码或过度优化的 C 代码,寻找更好解决方案的过程就会受到影响,最终结果也会变慢。
这里的所有答案似乎都排除了一个方面:有时我们编写代码不是为了实现特定目标,而是为了纯粹的乐趣。花时间这样做可能不经济,但可以说,没有比手动滚动 asm 替代方案在速度上击败最快的编译器优化代码片段更令人满意的了。
除非您以正确的方式使用具有更深入知识的汇编语言,否则 C++ 会更快。
当我在 ASM 中编码时,我会手动重新组织指令,以便 CPU 可以在逻辑可能的情况下并行执行更多指令。例如,当我在 ASM 中编码时,我几乎不使用 RAM:ASM 中可能有 20000 多行代码,而我从未使用过 push/pop。
您可能会跳到操作码的中间以自我修改代码和行为,而不会受到自我修改代码的惩罚。访问寄存器需要 CPU 的 1 个滴答声(有时需要 0.25 个滴答声)。访问 RAM 可能需要数百个。
在我上一次 ASM 冒险中,我从未使用过 RAM 来存储变量(用于数千行 ASM)。 ASM 可能比 C++ 快得难以想象。但这取决于许多可变因素,例如:
1. I was writing my apps to run on the bare metal.
2. I was writing my own boot loader that was starting my programs in ASM so there was no OS management in the middle.
我现在正在学习 C# 和 C++,因为我意识到生产力很重要!!您可以尝试在空闲时间单独使用纯 ASM 来完成可以想象的最快程序。但是为了产生一些东西,使用一些高级语言。
例如,我编写的最后一个程序是使用 JS 和 GLSL,我从未注意到任何性能问题,即使谈到速度很慢的 JS。这是因为仅仅为 3D 编程 GPU 的概念使得向 GPU 发送命令的语言的速度几乎无关紧要。
汇编程序单独在裸机上的速度是无可辩驳的。在 C++ 中它会更慢吗? - 这可能是因为您正在使用编译器编写汇编代码,而不是使用汇编程序开始。
我个人的想法是,如果可以避免的话,永远不要编写汇编代码,即使我喜欢汇编。
如果您的编译器生成大量 OO 支持代码,汇编可能会更快。
编辑:
对于投票者:OP 写道“我应该......专注于 C++ 而忘记汇编语言吗?”我坚持我的回答。您始终需要密切关注 OO 生成的代码,尤其是在使用方法时。不要忘记汇编语言意味着您将定期查看您的 OO 代码生成的程序集,我认为这是编写性能良好的软件所必需的。
实际上,这适用于所有可编译的代码,而不仅仅是 OO。
不定期副业成功案例分享