了解汇编程序的原因之一是,有时可以使用汇编程序来编写比用高级语言(特别是C语言)编写的代码性能更好的代码。然而,我也听人说过很多次,尽管这并非完全错误,但实际上可以使用汇编程序来生成性能更好的代码的情况极其罕见,并且需要汇编方面的专业知识和经验。

这个问题甚至没有涉及到这样一个事实,即汇编程序指令将是特定于机器的、不可移植的,或者汇编程序的任何其他方面。当然,除了这一点之外,了解汇编还有很多很好的理由,但这是一个需要示例和数据的具体问题,而不是关于汇编程序与高级语言的扩展论述。

谁能提供一些具体的例子,说明使用现代编译器汇编代码比编写良好的C代码更快,并且您能否用分析证据支持这一说法?我相信这些案例确实存在,但我真的很想知道这些案例到底有多深奥,因为这似乎是一个有争议的问题。


当前回答

我认为汇编程序更快的一般情况是,当一个聪明的汇编程序员看到编译器的输出并说“这是性能的关键路径,我可以写这个更有效”,然后那个人调整汇编程序或从头重写它。

其他回答

下面是一个真实的例子:固定点在旧编译器上进行乘法运算。

这些不仅在没有浮点数的设备上很方便,在精度方面也很出色,因为它们可以提供32位精度和可预测的错误(浮点数只有23位,很难预测精度损失)。即在整个范围内均匀的绝对精度,而不是接近均匀的相对精度(浮点数)。


现代编译器很好地优化了这个定点示例,因此对于仍然需要特定于编译器的代码的更现代的示例,请参见

获得64位整数乘法的高部分:使用uint64_t for 32x32 => 64位乘法的便携版本在64位CPU上无法优化,因此你需要intrinsic或__int128来在64位系统上实现高效的代码。 Windows 32位上的_umul128: MSVC在将32位整数转换为64时并不总是做得很好,因此intrinsic有很大帮助。


C语言没有完整的乘法运算符(由n位输入产生2n位)。在C语言中表达它的通常方法是将输入转换为更宽的类型,并希望编译器能够识别输入的上半部分是不有趣的:

// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
  long long a_long = a; // cast to 64 bit.

  long long product = a_long * b; // perform multiplication

  return (int) (product >> 16);  // shift by the fixed point bias
}

这段代码的问题在于,我们做了一些不能直接用c语言表达的事情。我们希望将两个32位的数字相乘,得到一个64位的结果,并返回中间的32位。然而,在C语言中这个乘法是不存在的。您所能做的就是将整数提升为64位,并执行64*64 = 64乘法。

x86(以及ARM、MIPS和其他)可以在一条指令中完成乘法运算。一些编译器过去常常忽略这一事实,并生成调用运行时库函数来进行相乘的代码。移位到16也经常由库例程完成(x86也可以做这样的移位)。

所以我们只剩下一两个乘法库调用。这造成了严重的后果。不仅移位速度较慢,而且在整个函数调用中必须保留寄存器,而且对内联和展开代码也没有帮助。

如果你在(内联)汇编器中重写相同的代码,你可以获得显著的速度提升。

除此之外:使用ASM并不是解决问题的最佳方法。大多数编译器允许你以内在的形式使用一些汇编指令,如果你不能用c语言表达它们。例如,VS.NET2008编译器将32*32=64位的mul公开为__emul,将64位的移位公开为__ll_rshift。

使用intrinsic,你可以以一种c编译器有机会理解发生了什么的方式重写函数。这允许代码内联,寄存器分配,公共子表达式消除和常量传播也可以完成。与手工编写的汇编程序代码相比,您将获得巨大的性能改进。

供参考:VS.NET编译器的定点mul的最终结果是:

int inline FixedPointMul (int a, int b)
{
    return (int) __ll_rshift(__emul(a,b),16);
}

定点除法的性能差异更大。通过编写几行asm代码,我对除法重的定点代码进行了10倍的改进。


使用Visual c++ 2013为这两种方式提供了相同的汇编代码。

2007年的gcc4.1也很好地优化了纯C版本。(Godbolt编译器资源管理器没有安装任何早期版本的gcc,但即使是较旧的gcc版本也可以在没有intrinsic的情况下做到这一点。)

在Godbolt编译器资源管理器上查看用于x86(32位)和ARM的source + asm。(不幸的是,它没有任何旧到足以从简单的纯C版本生成糟糕代码的编译器。)


现代cpu可以做一些C语言根本没有操作符的事情,比如popcnt或位扫描来查找第一个或最后一个设置位。POSIX有一个ffs()函数,但是它的语义不匹配x86 bsf / bsr。见https://en.wikipedia.org/wiki/Find_first_set)。

一些编译器有时可以识别一个计数整数中设置位数的循环,并将其编译为popcnt指令(如果在编译时启用),但在GNU C中使用__builtin_popcnt要可靠得多,或者在x86上(如果你的目标硬件是SSE4.2: _mm_popcnt_u32 from < immintrinh >)。

或者在c++中,赋值给std::bitset<32>并使用.count()。(在这种情况下,该语言已经找到了一种方法,可以通过标准库可移植地公开popcount的优化实现,以一种总是编译为正确的方式,并且可以利用目标支持的任何东西。)参见https://en.wikipedia.org/wiki/Hamming_weight#Language_support。

类似地,ntohl可以在一些具有它的C实现上编译为bswap(用于端序转换的x86 32位字节交换)。


intrinsic或手写asm的另一个主要领域是使用SIMD指令进行手工向量化。编译器对于dst[i] += src[i] * 10.0;这样的简单循环并不糟糕,但是当事情变得更复杂时,编译器通常做得很糟糕,或者根本不自动向量化。例如,你不太可能得到任何像如何实现atoi使用SIMD?由编译器从标量代码自动生成。

http://cr.yp.to/qhasm.html有很多例子。

不需要给出任何具体的示例或分析器证据,当您比编译器知道的更多时,您可以编写比编译器更好的汇编程序。

In the general case, a modern C compiler knows much more about how to optimize the code in question: it knows how the processor pipeline works, it can try to reorder instructions quicker than a human can, and so on - it's basically the same as a computer being as good as or better than the best human player for boardgames, etc. simply because it can make searches within the problem space faster than most humans. Although you theoretically can perform as well as the computer in a specific case, you certainly can't do it at the same speed, making it infeasible for more than a few cases (i.e. the compiler will most certainly outperform you if you try to write more than a few routines in assembler).

另一方面,有些情况下编译器没有那么多的信息——我想说主要是在使用不同形式的外部硬件时,编译器不知道这些信息。主要的例子可能是设备驱动程序,其中汇编程序结合人类对相关硬件的熟悉知识可以产生比C编译器更好的结果。

其他人提到了特殊用途指令,这就是我在上面一段中所说的——编译器可能对这些指令了解有限或根本不了解,这使得人类可以编写更快的代码。

长波克,只有一个限制时间。当你没有足够的资源来优化每一个代码的变化,并花时间分配寄存器,优化一些溢出和诸如此类的事情时,编译器每次都会赢。对代码进行修改、重新编译和度量。如有必要重复。

此外,你可以在高水平方面做很多事情。此外,检查生成的程序集可能会给人一种代码是垃圾的印象,但实际上它的运行速度比您想象的要快。例子:

Int y = data[i]; //在这里做一些事情。 call_function (y,…);

编译器将读取数据,将其推入堆栈(溢出),然后从堆栈读取并作为参数传递。听起来屎?它实际上可能是非常有效的延迟补偿,并导致更快的运行时。

//优化版本 call_function(数据[我],…);//毕竟不是那么优化。

优化版本的想法是,我们降低了寄存器压力,避免溢出。但事实上,“垃圾”版本更快!

看看汇编代码,只看指令,然后得出结论:指令越多,速度越慢,这将是一个错误的判断。

这里需要注意的是:许多组装专家认为他们知道很多,但知道的很少。规则也会随着架构的变化而变化。例如,x86代码并不存在总是最快的银弹。如今,最好还是按照经验法则行事:

记忆很慢 缓存速度快 尽量更好地使用缓存 你多久会错过一次?你有延迟补偿策略吗? 对于一个cache miss,你可以执行10-100个ALU/FPU/SSE指令 应用程序架构很重要。 . .但是当问题不在架构上时,它就没有帮助了

此外,过于相信编译器会神奇地将考虑不周到的C/ c++代码转换为“理论上最优”的代码是一厢情愿的想法。如果你关心这个低级别的“性能”,你必须知道你使用的编译器和工具链。

C/ c++中的编译器通常不太擅长重新排序子表达式,因为对于初学者来说,函数有副作用。函数式语言没有受到这个警告的影响,但它不太适合当前的生态系统。有一些编译器选项可以允许宽松的精确规则,允许编译器/链接器/代码生成器改变操作的顺序。

这个话题有点死路一条;对于大多数人来说,这是无关紧要的,而剩下的人,他们已经知道自己在做什么了。

这一切都归结为:“理解你在做什么”,这与知道你在做什么有点不同。

这个问题有点毫无意义,因为无论如何c都是编译到汇编程序的。 但是,通过优化编译器生成的汇编程序几乎是完全优化的,所以除非你在优化特定的汇编程序方面做了20个博士学位,否则你无法打败编译器。