什么时候汇编比C更快?


474

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

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

谁能提供一些特定的例子说明使用现代编译器进行汇编比编写良好的C代码要快得多,并且您可以提供带有分析依据的主张吗?我对这些案例的存在很有信心,但是我真的想确切地知道这些案例有多深奥,因为这似乎有些争议。


17
实际上,对编译后的代码进行改进是非常琐碎的。精通汇编语言和C语言的任何人都可以通过检查生成的代码来了解这一点。当编译版本的一次性寄存器用完时,最容易遇到的问题就是任何容易的事情。平均而言,对于大型项目,编译器的性能要比人类好得多,但是在大小合适的项目中,在编译后的代码中发现性能问题并不困难。
old_timer 2014年

14
实际上,简短的答案是:汇编程序总是更快或等于C的速度。原因是没有C的情况下就可以进行汇编,但是没有汇编的情况下就不能具有C(采用二进制形式,这在我们以前是天称为“机器代码”)。也就是说,长答案是:C编译器非常擅长优化和“思考”您通常不会想到的事情,因此它确实取决于您的技能,但是通常您总是可以击败C编译器;它仍然只是一个无法思考和获取想法的软件。如果您使用宏并且耐心的话,也可以编写可移植的汇编程序。

11
我强烈不同意,这个问题的答案必须基于“观点”-它们可以很客观-并不是像尝试比较喜欢的宠物语言的性能那样,每种语言都有自己的长处和缺点。这是要了解编译器可以带我们走多远,从哪个角度开始最好接管。
jsbueno 2015年

21
在我职业生涯的早期,我在一家软件公司编写了很多C语言和大型机汇编程序。我的一个同龄人就是我所说的“汇编纯粹主义者”(一切都必须是汇编者),所以我打赌他可以编写一个给定的例程,该例程在C中的运行速度要比他在汇编器中的运行速度更快。我赢了。但最重要的是,我赢了之后,我告诉他我想再打赌-我可以用比以前打败他的C程序更快的速度在汇编程序中编写东西。我也赢了,证明了其中大部分归结于程序员的技能和能力,而不是其他任何事情。
Valerie R

3
除非您的大脑有一个-O3标志,否则最好将优化留给C编译器:-)
paxdiablo

Answers:


271

这是一个真实的示例:在旧编译器上不动点相乘。

这些不仅在没有浮点的设备上很方便,而且在精度方面也很出色,因为它们为您提供32位精度并带有可预测的错误(浮点只有23位,更难预测精度损失)。也就是说,在整个范围内具有统一的绝对精度,而不是相对接近的相对精度精度(float)。


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


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。

使用内在函数,您可以以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版本也可以在没有内部函数的情况下执行此操作。)

请参阅Godbolt编译器资源管理器 x86(32位)和ARM的source + asm 。(不幸的是,它没有足够老的编译器可以从简单的纯C版本生成错误的代码。)


现代的CPU可以做的事情C没有运营商一样,popcnt还是位扫描,找到第一个或最后一组位。(POSIX具有ffs()功能,但其语义与x86 bsf/ 不匹配bsr。请参见https://en.wikipedia.org/wiki/Find_first_set)。

有些编译器有时可以识别出一个循环,该循环可以计算整数中设置位的数量并将其编译为一条popcnt指令(如果在编译时启用),但是__builtin_popcnt在GNU C或x86(如果您只使用x86)上使用则更加可靠使用SSE4.2定位硬件:_mm_popcnt_u32<immintrin.h>

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

类似地,ntohl可以bswap在具有此功能的某些C实现中编译为(x86 32位字节交换以进行字节序转换)。


内在函数或手写汇编的另一个主要领域是使用SIMD指令进行手动向量化。编译器对于像这样的简单循环来说还不错dst[i] += src[i] * 10.0;,但是当事情变得更加复杂时,它们通常做得不好或者根本不会自动向量化。例如,您不太可能获得诸如如何使用SIMD实现atoi的信息?由编译器从标量代码自动生成。


6
{x = c%d; y = c / d;},编译器是否足够聪明,可以使单个div或idiv?
JensBjörnhager2010年

4
实际上,一个好的编译器会从第一个函数生成最佳代码。用内在函数或内联汇编掩盖源代码是绝对没有好处的,但这并不是最好的选择。
松弛者

65
嗨,Slacker,我认为您之前无需处理时间紧迫的代码,...内联汇编可以发挥很大的作用。同样对于编译器来说,内在函数与C语言中的普通算术相同。这就是内在函数的意义所在。它们使您可以使用体系结构功能,而不必处理缺点。
尼尔斯·派宾布林克

6
@slacker实际上,这里的代码可读性强:内联代码执行一个唯一的操作,读取方法签名会立即变得不稳定。使用晦涩的指令时,代码在可读性上只会缓慢丢失。在这里重要的是,我们有一种方法只能执行一个清晰可辨的操作,而这实际上是产生这些原子函数可读代码的最佳方法。顺便说一句,这并不是那么模糊的小注释,例如/ *(a * b)>> 16 * /不能立即解释它。
Dereckson

5
公平地说,至少在今天,这是一个可怜的例子。即使语言没有直接提供,C编译器也能够执行32x32-> 64乘法:他们认识到,当您将32位参数强制转换为64位然后乘以它们时,它不需要做一个完整的64位乘法,但是32x32-> 64会很好。我检查了一下,所有的clang,gcc和MSVC当前版本都正确。这不是什么新鲜事-我记得十年前曾看过编译器输出并注意到这一点。
BeeOnRope '18

143

许多年前,我在教别人用C语言编程。练习是将图形旋转90度。他回来了,花了几分钟的时间才能完成解决方案,这主要是因为他使用的是乘法和除法等。

我向他展示了如何使用位移来解决问题,在他拥有的非优化编译器上,处理时间减少到大约30秒。

我刚得到一个优化的编译器,并且相同的代码在不到5秒的时间内旋转了图形。我查看了编译器正在生成的汇编代码,从中看到的决定在那里,然后我写汇编程序的日子就结束了。


3
是的,它是一个单色系统,特别是Atari ST上的单色图像块。
lilburne

16
优化的编译器是编译原始程序还是您的版本?
托尔比约恩Ravn的安徒生

在什么处理器上?在8086上,我希望针对8x8旋转的最佳代码将使用SI将16位数据加载到DI,add di,di / adc al,al / add di,di / adc ah,ah对所有8个8位寄存器重复执行此类操作,然后再次对所有8个寄存器进行操作,然后重复整个过程三更多次,最后在ax / bx / cx / dx中保存四个单词。汇编程序绝对不可能做到这一点。
超级猫

1
我真的想不出任何平台,对于8x8旋转,编译器可能会在最佳代码的一到两个系数之内。
超级猫

65

几乎在编译器看到浮点代码的任何时候,如果您使用的是旧的错误编译器,手写版本都会更快。(2019更新:对于现代编译器而言通常情况并非如此。 尤其是当针对x87以外的任何其他版本时;与x87相比,编译器可以更轻松地使用SSE2或AVX进行标量数学运算,或者使用具有平坦FP寄存器集的任何非x86进行寄存器堆栈。)

主要原因是编译器无法执行任何可靠的优化。有关此主题的讨论,请参见MSDN上的这篇文章。这是一个示例,其中汇编版本的速度是C版本(与VS2K5一起编译)的两倍:

#include "stdafx.h"
#include <windows.h>

float KahanSum(const float *data, int n)
{
   float sum = 0.0f, C = 0.0f, Y, T;

   for (int i = 0 ; i < n ; ++i) {
      Y = *data++ - C;
      T = sum + Y;
      C = T - sum - Y;
      sum = T;
   }

   return sum;
}

float AsmSum(const float *data, int n)
{
  float result = 0.0f;

  _asm
  {
    mov esi,data
    mov ecx,n
    fldz
    fldz
l1:
    fsubr [esi]
    add esi,4
    fld st(0)
    fadd st(0),st(2)
    fld st(0)
    fsub st(0),st(3)
    fsub st(0),st(2)
    fstp st(2)
    fstp st(2)
    loop l1
    fstp result
    fstp result
  }

  return result;
}

int main (int, char **)
{
  int count = 1000000;

  float *source = new float [count];

  for (int i = 0 ; i < count ; ++i) {
    source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
  }

  LARGE_INTEGER start, mid, end;

  float sum1 = 0.0f, sum2 = 0.0f;

  QueryPerformanceCounter (&start);

  sum1 = KahanSum (source, count);

  QueryPerformanceCounter (&mid);

  sum2 = AsmSum (source, count);

  QueryPerformanceCounter (&end);

  cout << "  C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
  cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;

  return 0;
}

还有一些运行默认发行版*的 PC上的数字:

  C code: 500137 in 103884668
asm code: 500137 in 52129147

出于兴趣,我将循环换为dec / jnz,这对时间没有影响-有时更快,有时更慢。我猜想内存有限的方面使其他优化相形见war。(编者注:FP延迟瓶颈很可能足以隐藏的额外成本loop。对奇/偶元素并行执行两个Kahan求和,然后在末尾添加两个,可能会使速度提高2倍。 )

糟糕,我运行的代码版本稍有不同,它以错误的方式输出数字(即C更快!)。修复并更新了结果。


20
或在GCC中,您可以使用flag来释放编译器在浮点优化上的作用(只要您保证不对infinities或NaN做任何事情)-ffast-math。它们具有的优化级别-Ofast当前等于-O3 -ffast-math,但是将来可能包括更多的优化,这些优化可能导致在极端情况下(例如,依赖于IEEE NaN的代码)错误生成代码。
David Stone

2
是的,浮点数不是可交换的,编译器必须完全执行您所写的内容,基本上是@DavidStone所说的内容。
2014年

2
您尝试过SSE数学吗?性能是MS完全放弃x86_64中的x87和x86中80位长的double的原因之一
phuclv 2014年

4
@Praxeolitic:FP add是可交换的(a+b == b+a),但不是关联的(操作的重新排序,因此中间体的舍入是不同的)。回复:这段代码:我认为未经注释的x87和一条loop指令并不是快速汇编的绝佳展示。 loop由于FP延迟,这显然不是瓶颈。我不确定他是否正在流水线作业。x87让人难以阅读。最后两个fstp resultsinsn显然不是最佳的。使用非存储更好地从堆栈中弹出额外的结果。就像fstp st(0)IIRC。
彼得·科德斯

2
@PeterCordes:使加法可交换的一个有趣的结果是,虽然0 + x和x + 0彼此相等,但两者都不总是等同于x。
超级猫

58

如果不提供任何特定的示例或分析器证据,则当您比编译器了解更多信息时,可以编写比编译器更好的汇编程序。

在一般情况下,现代C编译器对如何优化所讨论的代码了解更多:它知道处理器管道的工作方式,可以尝试比人类更快地重新排序指令,依此类推-基本上与一台与棋类游戏等方面的最佳人类玩家一样好或更好的计算机,仅仅是因为它可以使问题空间内的搜索比大多数人类更快。尽管理论上您在特定情况下的性能可以与计算机媲美,但是您肯定不能以相同的速度运行,因此在很多情况下都不可行(即,如果尝试编写,编译器肯定会胜过您)更多的汇编程序例程)。

另一方面,在某些情况下,编译器没有太多信息-我主要是在使用不同形式的外部硬件(编译器不了解)时使用的。主要的示例可能是设备驱动程序,其中汇编程序与人类对相关硬件的深入了解相结合,可以产生比C编译器更好的结果。

其他人提到了特殊目的的指令,这就是我在上一段中所说的-编译器可能只具有有限的知识或根本没有知识的指令,使人们可以编写更快的代码。


通常,此陈述是正确的。编译器最好做到DWIW,但在某些情况下,当必须具备实时性能时,手工编码汇编程序就可以完成工作。
spoulson

1
@Liedman:“它可以尝试比人类更快地重新排序指令”。OCaml以快速而著称,令人惊讶的是,其本机代码编译器ocamlopt跳过了x86上的指令调度,而是将其留给CPU,因为它可以在运行时更有效地重新排序。
乔恩·哈罗普

1
现代编译器可以做很多事情,而且手工完成会花费很长时间,但是还远远不够完美。在gcc或llvm的错误跟踪器中搜索“缺少优化”的错误。有许多。另外,在编写asm时,您可以更轻松地利用诸如“此输入不能为负”之类的前提条件,这对于编译器来说很难证明。
彼得·科德斯

48

在我的工作中,有三个原因使我知道并使用汇编。按重要性顺序:

  1. 调试-我经常会得到带有错误或不完整文档的库代码。我通过在组装级别介入来弄清楚它在做什么。我必须每周大约做一次。我还将它用作调试问题的工具,在这些问题中,我的眼睛看不到C / C ++ / C#中的惯用错误。看看装配就可以了。

  2. 优化-编译器在优化方面做得很好,但我的工作方式与大多数人不同。我写的图像处理代码通常以如下代码开头:

    for (int y=0; y < imageHeight; y++) {
        for (int x=0; x < imageWidth; x++) {
           // do something
        }
    }

    “做某事”通常发生几百万次(即3到30之间)。通过在“执行某些操作”阶段中刮除周期,可以极大地提高性能。我通常不从那里开始-我通常从编写代码开始就开始工作,然后尽我所能将C重构为自然更好的代码(更好的算法,更少的循环负载等)。我通常需要阅读程序集以了解发生了什么,而很少需要编写它。我大概每两三个月这样做一次。

  3. 做一些语言不会让我做的事。这些包括-获取处理器体系结构和特定的处理器功能,访问不在CPU中的标志(伙计,我真希望C允许您访问进位标志)等。我可能一年或两年执行一次。


您没有平铺循环吗?:-)
乔恩·哈罗普

1
@plinth:“刮擦周期”是什么意思?
lang2

@ lang2:这意味着要消除在内部循环中花费的尽可能多的多余时间-编译器未能设法取出的任何内容,其中可能包括使用代数将一个循环中的乘积提升为加法在内部等
基座

1
如果仅对数据进行一次传递,则不需要进行循环平铺。
James M. Lay 2015年

@ JamesM.Lay:如果只触摸每个元素一次,则更好的遍历顺序可以为您提供空间位置。(例如,使用您触摸过的缓存行的所有字节,而不是使用每个缓存行一个元素向下循环矩阵的列。)
Peter Cordes

42

仅当使用一些特殊目的的指令集时,编译器才不支持。

为了使具有多个流水线和预测分支的现代CPU的计算能力最大化,您需要以一种方式来构造汇编程序:a)人类几乎不可能写; b)甚至难以维护。

而且,更好的算法,数据结构和内存管理将使您的性能至少比组装中的微优化高出一个数量级。


4
+1,即使最后一句话并不是本次讨论的真正内容-人们会假设只有在对算法等进行了所有可能的改进之后,汇编程序才起作用。
mghie

18
@马特:手写ASM往往是一个很大的一些与有蹩脚的厂家编译器支持微小的CPU EE工作的更好。
Zan Lynx

5
“仅在使用某些专用指令集时”?您可能以前从未编写过手动优化的asm代码。对正在使用的体系结构有一定程度的了解,可以为您提供比编译器更好的代码(大小和速度)的机会。显然,正如@mghie所评论的那样,您总是开始编写可以为您解决问题的最佳算法。即使对于非常优秀的编译器,您实际上也必须以一种使编译器获得最佳编译代码的方式编写C代码。否则,生成的代码将不是最佳的。
ysap 2011年

2
@ysap-在实际使用中的实际计算机(不是功率不足的微型嵌入式芯片)上,“最优”代码不会更快,因为对于任何大数据集,您的性能都会受到内存访问和页面错误的限制(如果您没有大数据集,那么这两种方法都会很快,并且没有必要对其进行优化。)-那些日子里,我大部分时间都在C#中工作(甚至不使用c),而压缩内存管理器的性能却超出了-权衡垃圾收集,压缩和JIT编译的开销。
Nir

4
+1表示如果编译器(特别是JIT)针对运行的硬件进行了优化,它们比人类可以做得更好
塞巴斯蒂安

38

尽管C接近8位,16位,32位,64位数据的低级操作,但是C不支持一些数学运算,这些运算通常可以在某些汇编指令中优雅地执行设置:

  1. 定点乘法:两个16位数字的乘积是一个32位数字。但是C中的规则说两个16位数字的乘积是一个16位数字,两个32位数字的乘积是一个32位数字-两种情况的下半部分。如果您想要16x16乘法或32x32乘法的半部分,则必须使用编译器进行游戏。通用方法是将其强制转换为大于所需的位宽,相乘,向下移位并回退:

    int16_t x, y;
    // int16_t is a typedef for "short"
    // set x and y to something
    int16_t prod = (int16_t)(((int32_t)x*y)>>16);`

    在这种情况下,编译器可能足够聪明,知道您实际上只是在尝试获取16x16乘法的上半部分,并使用计算机的本机16x16multiply做正确的事情。否则可能很愚蠢,并且需要执行库调用来执行32x32乘法运算,这实在是太过分了,因为您只需要16位乘积即可,但是C标准并没有给您任何表达自己的方式。

  2. 某些移位操作(旋转/进位):

    // 256-bit array shifted right in its entirety:
    uint8_t x[32];
    for (int i = 32; --i > 0; )
    {
       x[i] = (x[i] >> 1) | (x[i-1] << 7);
    }
    x[0] >>= 1;

    在C语言中这不是太优雅,但是,除非编译器足够聪明以意识到您在做什么,否则它会做很多“不必要的”工作。许多汇编指令集允许您在进位寄存器中向左旋转或向右移动结果,因此您可以在34条指令中完成上述操作:将指针加载到数组的开头,清除进位,然后执行32 8-使用指针上的自动递增,将数据右移。

    再举一个例子,有线性反馈移位寄存器(LFSR)可以在汇编中很好地执行:取一块N位(8、16、32、64、128等),将整个对象右移1(请参见上文)算法),那么如果结果进位为1,则您将以表示多项式的位模式进行XOR。

话虽如此,除非我有严重的性能限制,否则我不会求助于这些技术。就像其他人所说的那样,汇编/编写/调试/测试/维护比C代码要难得多:性能的提高要付出沉重的代价。

编辑: 3.可以在汇编中进行溢出检测(在C语言中实际上无法做到),这使某些算法更加容易。


23

简短的答案?有时。

从技术上讲,每个抽象都有代价,而编程语言是CPU工作方式的抽象。但是C非常接近。几年前,当我登录到UNIX帐户并收到以下财富消息时(当这种事情流行时),我记得大声笑:

C编程语言-一种将汇编语言的灵活性与汇编语言的功能相结合的语言。

这很有趣,因为它是真的:C就像可移植的汇编语言。

值得注意的是,无论您编写哪种汇编语言,它都可以运行。但是,在C和它生成的汇编语言之间有一个编译器,这非常重要,因为 C代码的运行速度与编译器的性能有很大关系。

当gcc出现时,使其流行的原因之一是,它通常比带有许多商业UNIX风格的C编译器要好得多。它不仅是ANSI C(没有K&R C垃圾),而且更健壮并且通常生成更好(更快)的代码。不总是但经常。

我告诉你所有这一切是因为对于C和汇编器的速度没有明确的规定,因为对于C没有客观的标准。

同样,汇编程序也有很大不同,具体取决于运行的处理器,系统规格,使用的指令集等。历史上有两个CPU体系结构家族:CISC和RISC。CISC的最大参与者过去是,现在仍然是Intel x86架构(和指令集)。RISC统治了UNIX世界(MIPS6000,Alpha,Sparc等)。CISC赢得了全心全意的战斗。

无论如何,当我还是一个年轻的开发人员时,流行的观点是手写x86可能通常比C快得多,因为该体系结构的工作方式,复杂性得益于人类的实践。另一方面,RISC似乎是为编译器设计的,因此没有人(我知道)写道Sparc汇编器。我敢肯定,这样的人确实存在,但毫无疑问,他们现在已经疯了并且已经被制度化了。

即使在同一系列处理器中,指令集也很重要。某些英特尔处理器具有从SSE到SSE4的扩展。AMD有自己的SIMD指令。像C这样的编程语言的好处是有人可以编写他们的库,因此可以针对正在运行的任何处理器对其进行优化。那是汇编程序中的艰苦工作。

您仍然可以在汇编器中进行优化,而编译器无法进行这种优化,编写良好的汇编器算法将比C等效或快。更大的问题是:这值得吗?

最终,尽管汇编程序是其时代的产物,但在CPU周期昂贵的时代更受欢迎。如今,制造成本为5至10美元的CPU(英特尔凌动)几乎可以满足任何人的需求。如今,编写汇编程序的唯一真正原因是针对底层的东西,例如操作系统的某些部分(即使如此,绝大多数Linux内核是用C编写的),设备驱动程序,可能是嵌入式设备(尽管C往往在其中占主导地位)也是如此)。或只是为了踢腿(有些受虐)。


有很多人在Acorn机器(90年代初)上使用ARM汇编器作为首选语言。IIRC他们说,小的risc指令集使它变得更容易,更有趣。但是我怀疑这是因为C编译器对于Acorn来说很晚,并且C ++编译器从未完成。
Andrew M

3
“ ...因为没有C的主观标准。” 你的意思是客观的
Thomas Thomas

@AndrewM:是的,我用BASIC和ARM汇编器编写了混合语言应用程序大约10年。我在那段时间学过C,但是它不是很有用,因为它和汇编器一样麻烦,而且速度较慢。Norcroft做了一些很棒的优化,但是我认为条件指令集对于当今的编译器来说是个问题。
乔恩·哈罗普

1
@AndrewM:嗯,实际上ARM是一种向后完成的RISC。其他RISC ISA是从编译器将要使用的内容开始设计的。ARM ISA似乎是从CPU提供的功能开始设计的(桶形移位器,条件标志→让我们在每条指令中公开它们)。
ninjalj 2013年

16

一个用例可能不再适用,但可能会让您感到讨厌:在Amiga上,CPU和图形/音频芯片将争夺访问RAM的特定区域(特定的前2MB RAM)。因此,当您只有2MB RAM(或更少)时,显示复杂的图形以及播放声音会降低CPU的性能。

在汇编器中,您可以以一种巧妙的方式来插入代码,以使CPU仅在图形/音频芯片内部繁忙时(即,总线空闲时)才尝试访问RAM。因此,通过重新排序指令,巧妙地使用CPU缓存,总线时序,您可以实现某些效果,而这些效果根本无法使用任何高级语言实现,因为您必须定时每个命令,甚至在此处和此处插入NOP来保持各种命令的有效性。相互干扰。

这是CPU的NOP(无操作-不执行任何操作)指令实际上可以使整个应用程序运行更快的另一个原因。

[编辑]当然,该技术取决于特定的硬件设置。这就是为什么许多Amiga游戏无法处理更快的CPU的主要原因:指令的时间已到。


Amiga没有16 MB的芯片RAM,具体取决于芯片组,更像是512 kB至2 MB。另外,由于您所描述的技术,很多Amiga游戏无法在更快的CPU上运行。
bk1e

1
@ bk1e-Amiga生产了各种各样的不同型号的计算机,Amiga 500附带的512K内存扩展到了我的1Meg。amigahistory.co.uk/amiedevsys.html是配备128Meg Ram
David Waters

@ bk1e:我的立场是正确的。我的内存可能使我失败,但是芯片RAM是否不限于前24位地址空间(即16MB)?快速映射在那上面吗?
亚伦·迪古拉09年

@Aaron Digulla:维基百科提供了有关芯片/快速/慢速RAM之间区别的更多信息:en.wikipedia.org/wiki/Amiga_Chip_RAM
bk1e

@ bk1e:我的错。68k CPU仅具有24条地址通道,这就是为什么我只有16MB的原因。
亚伦·迪古拉

15

要点一不是答案。
即使您从未在其中进行编程,我也发现了解至少一个汇编程序指令集很有用。这是程序员不断追求的知识,因此更好的一部分。当您进入没有源代码并且至少大致了解正在发生的情况时,它也很有用。它还帮助您了解JavaByteCode和.Net IL,因为它们都类似于汇编程序。

当您使用少量代码或大量时间时回答问题。最适合用于嵌入式芯片,在这些芯片中,较低的芯片复杂度和针对这些芯片的编译器竞争较弱,可以使平衡趋于有利于人类。同样对于受限设备,您通常会以难以指示编译器执行的方式来权衡代码大小/内存大小/性能。例如,我知道不经常调用此用户操作,因此我的代码量较小且性能较差,但是每秒都使用此外观相似的其他功能,因此我的代码量较大且性能更快。这是熟练的汇编程序员可以使用的折衷方案。

我还想补充一点,您可以使用C进行代码编译和检查生成的Assembly,然后更改C代码或调整并作为程序集进行维护。

我的朋友在研究微控制器,目前是用于控制小型电动机的芯片。他将低级c和汇编结合在一起工作。他曾经告诉我在工作中有美好的一天,他将主循环从48条指令减少到43条。他还面临着很多选择,例如代码已经增长到可以填充256k芯片,并且企业想要一个新功能,您呢?

  1. 删除现有功能
  2. 减少部分或全部现有功能的大小可能会降低性能。
  3. 提倡以更高的成本,更高的功耗和更大的尺寸转向更大的芯片。

作为一个商业开发人员,我想补充一下自己的投资组合或语言,平台,应用程序类型,而我从来没有觉得需要深入编写汇编程序。我曾经如何总是欣赏我所获得的知识。有时会调试到它。

我知道我对“为什么我应该学习汇编程序”这个问题的回答要多得多,但是我觉得这是一个更重要的问题,何时才能更快。

所以让我们再试一次您应该考虑组装

  • 在底层操作系统功能上工作
  • 在编译器上工作。
  • 在极其有限的芯片,嵌入式系统等上工作

切记将程序集与生成的编译器进行比较,以查看哪个更快/更小/更好。

大卫。


4
+1用于考虑微型芯片上的嵌入式应用。这里太多的软件工程师要么不考虑嵌入式,要么认为这意味着智能手机(32位,MB RAM,MB闪存)。
马丁

1
时间嵌入式应用程序就是一个很好的例子!经常有一些奇怪的指令(甚至是非常简单的指令,例如avr sbicbi),由于它们对硬件的了解有限,编译器过去(有时仍然没有)没有充分利用它们。
felixphew

15

我很惊讶没有人这么说。strlen()如果用汇编语言编写,该功能要快得多!在C语言中,您能做的最好的事情是

int c;
for(c = 0; str[c] != '\0'; c++) {}

在组装时,您可以大大提高速度:

mov esi, offset string
mov edi, esi
xor ecx, ecx

lp:
mov ax, byte ptr [esi]
cmp al, cl
je  end_1
cmp ah, cl
je end_2
mov bx, byte ptr [esi + 2]
cmp bl, cl
je end_3
cmp bh, cl
je end_4
add esi, 4
jmp lp

end_4:
inc esi

end_3:
inc esi

end_2:
inc esi

end_1:
inc esi

mov ecx, esi
sub ecx, edi

长度以ecx为单位。一次比较4个字符,因此快4倍。并考虑使用eax和ebx的高阶单词,它将比以前的C例程快8倍



@ninjalj:他们是同一件事:)我不认为可以在C中用这种方法完成。我认为它可以稍作改进
BlackBear

在C代码中的每个比较之前,还有一个按位与运算。编译器可能足够聪明,可以将其简化为高字节和低字节比较,但我不会为此花钱。实际上,有一个更快的循环算法是基于(word & 0xFEFEFEFF) & (~word + 0x80808080)零属性的,前提是单词中的所有字节都不为零。
user2310967

@MichaWiedenmann是的,我应该在比较ax中的两个字符后加载bx。谢谢
BlackBear

14

使用SIMD指令进行矩阵运算可能比编译器生成的代码更快。


一些编译器(VectorC,如果我没记错的话)会生成SIMD代码,因此即使这样,也可能不再是使用汇编代码的参数。
OregonGhost,

编译器创建SSE感知代码,因此参数不正确
vartec

5
在许多情况下,您可以使用SSE本质来代替汇编。这将使您的代码更具可移植性(gcc visual c ++,64位,32位等),并且您无需进行寄存器分配。
Laserallan

1
当然可以,但是这个问题没有问我应该在哪里使用汇编语言而不是C语言。它说的是C编译器无法生成更好的代码。我假设一个C源代码没有使用直接的SSE调用或内联汇编。
Mehrdad Afshari

9
梅赫达德是对的。对于编译器而言,正确设置SSE相当困难,即使在显而易见的情况下(对于人类而言),大多数编译器都没有使用它。
康拉德·鲁道夫

13

我无法给出具体示例,因为它已经很多年了,但是在很多情况下,手写汇编程序可能会胜过任何编译器。原因:

  • 您可以偏离调用约定,在寄存器中传递参数。

  • 您可以仔细考虑如何使用寄存器,并避免将变量存储在内存中。

  • 对于诸如跳转表之类的事情,您可以避免必须对索引进行边界检查。

基本上,编译器在优化方面做得很好,而且几乎总是“足够好”,但是在某些情况下(例如图形渲染),您每个周期都要付出高昂的代价,因此您可以采用捷径,因为您知道代码,因为编译器必须出于安全考虑而无法编译。

实际上,我听说过一些图形渲染代码,其中的一个例程(例如线条绘制或多边形填充例程)实际上在堆栈上生成了一小块机器代码并在其中执行,以避免进行连续决策。关于线条样式,宽度,图案等

就是说,我希望编译器为我生成良好的汇编代码,但又不要太聪明,而他们大多这样做。实际上,我不喜欢Fortran的一件事是它为了使代码“优化”而加扰了代码,通常没有明显的目的。

通常,当应用程序出现性能问题时,这是由于浪费设计所致。这些天来,我绝不建议使用汇编程序来提高性能,除非整个应用程序已经在其生命周期内进行了微调,但仍然不够快,并且将所有时间都花在紧密的内部循环中。

补充:我已经看到很多用汇编语言编写的应用程序,并且与C,Pascal,Fortran等语言相比,主要的速度优势是因为程序员在汇编程序中进行编码时要更加谨慎。无论语言如何,他或她每天将编写大约100行代码,并且使用相当于3或400条指令的编译器语言。


8
+1:“您可以偏离调用约定”。C / C ++编译器倾向于在返回多个值时很烂。他们经常使用sret形式,其中调用程序堆栈为结构分配一个连续的块,并传递给它一个引用以供被调用者填充。在寄存器中返回多个值的速度快了好几倍。
乔恩·哈罗普

1
@Jon:当函数内联时,C / C ++编译器就可以做到这一点(非内联函数必须符合ABI,这不是C和C ++的限制,而是链接模型)
Ben Voigt

@BenVoigt:这是一个反例,flyingfrogblog.blogspot.co.uk / 2012/04 /…
Jon Harrop

2
我没有看到任何函数调用在那里内联。
Ben Voigt 2014年

13

根据我的经验,有几个例子:

  • 访问无法从C访问的指令。例如,许多体系结构(如x86-64,IA-64,DEC Alpha和64位MIPS或PowerPC)支持64位乘64位乘法,产生128位结果。GCC最近添加了扩展名,以提供对此类说明的访问,但在需要该汇编之前。当实施RSA之类的东西时,访问此指令可能对64位CPU产生巨大的影响-有时性能会提高4倍。

  • 访问特定于CPU的标志。咬住我很多的是进位标志;当进行多精度加法运算时,如果您无法访问CPU进位,则必须比较结果以查看其是否溢出,这每条肢体需要3-5条指令;更糟糕的是,就数据访问而言,这是串行的,这会破坏现代超标量处理器的性能。当连续处理成千上万个这样的整数时,能够使用addc是一个巨大的胜利(进位位上的争用也存在超标量问题,但现代CPU处理起来很不错)。

  • SIMD。甚至自动向量化的编译器也只能做相对简单的情况,因此,如果您希望获得良好的SIMD性能,通常常常需要直接编写代码。当然,您可以使用内部函数而不是汇编程序,但是一旦您进入内部函数级别,则基本上无论如何都在编写汇编程序,只需将编译器用作寄存器分配器和(名义上)指令调度程序即可。(我倾向于将内在函数用于SIMD只是因为编译器可以为我生成函数序言,而不是为我生成函数序言,因此我可以在Linux,OS X和Windows上使用相同的代码而不必处理函数调用约定之类的ABI问题,但其他比起SSE内在函数确实不是很好-Altivec的内在函数似乎更好,尽管我对此没有太多经验。对AESSIMD纠错进行比特切片 -可以想象有一个编译器可以分析算法并生成这样的代码,但在我看来,像这样的智能编译器距离现有技术至少有30年的距离。

另一方面,多核计算机和分布式系统已将许多最大的性能优势转移到了另一个方向上-将内部循环以汇编形式编写可额外提高20%的速度,或者通过在多个内核上运行它们来实现300%的加速,或在10000%的速度下达到10000%在一组机器上运行它们。当然,使用ML或Scala这样的高级语言比使用C或asm进行高级优化(诸如期货,备忘录等之类的东西)通常要容易得多,并且通常可以带来更大的性能优势。因此,一如既往,需要进行权衡。


2
@Dennis这就是为什么我写道:“您当然可以使用内在函数而不是汇编程序,但是一旦您到达内在函数级别,您基本上无论如何都在编写汇编程序,只是将编译器用作寄存器分配器和(名义上)指令调度程序。”
杰克·劳埃德

此外,本征基于SIMD代码往往是较少比用汇编语言编写相同的代码可读:许多SIMD代码依赖于载体中的数据,这是一个PITA做的数据类型编译器本征提供的隐式重新解释。
cmaster-恢复莫妮卡

10

由于图像可能包含数百万个像素,因此像播放图像时一样,紧密循环。坐下来弄清楚如何最好地利用有限数量的处理器寄存器会有所作为。这是一个真实的示例:

http://danbystrom.se/2008/12/22/optimizing-away-ii/

然后,处理器通常会使用一些深奥的指令,这些指令太过专门以至于编译器不愿打扰,但有时汇编程序员会充分利用它们。以XLAT指令为例。如果您需要循环进行表查找,并且表限制为256个字节那!

更新:哦,一般来说循环时,最重要的是想一想:编译器通常不知道常见的迭代次数!只有程序员知道一个循环将被迭代很多次,因此准备一些额外的工作为循环做准备是有益的,或者如果它被迭代的次数很少以至于设置实际上要花费比迭代更长的时间,那将是有益的。预期。


3
配置文件定向优化为编译器提供了有关使用循环频率的信息。
Zan Lynx

10

从汇编编码器的角度来看,C经常比您想像的要多做不必要的事情,因为C标准如此说。

例如,整数提升。如果要在C中移动char变量,通常会希望代码实际上只是这样做,即一次移位。

但是,这些标准强制编译器在移位之前对int进行符号扩展,然后将结果截断为char,这可能会使代码复杂化,具体取决于目标处理器的体系结构。


小型微质量编译器多年来一直能够避免处理值的上半部分,以免影响结果。升级规则确实会引起问题,但是最常见的情况是编译器无法知道哪些极端情况是相关的或无关的。
超级猫

9

如果您没有查看编译器产生的反汇编,您实际上并不知道编写良好的C代码是否真的很快。很多次您查看它,都发现“写得好”是主观的。

因此,不必编写汇编程序来获得有史以来最快的代码,但出于同样的原因,了解汇编程序当然也很值得。


2
“因此,不必编写汇编程序来获得有史以来最快的代码。”好吧,我还没有看到编译器在任何情况下都不是最理想的。在几乎所有情况下,有经验的人都可以比编译器做得更好。因此,绝对有必要编写汇编程序以获取“有史以来最快的代码”。
cmaster-恢复莫妮卡

@cmaster以我的经验,编译器的输出是随机的。有时它确实是很好且最佳的,有时是“该垃圾如何被释放”。
Sharptooth

9

我已经阅读了所有答案(超过30个),却没有找到简单的原因:如果您阅读并练习过 英特尔®64和IA-32架构优化参考手册》那么更慢的是写这样慢的汇编的人们没有看过“优化手册”

在英特尔80286的美好时光中,每条指令均以固定的CPU周期计数执行,但是自1995年发布的奔腾Pro以来,英特尔处理器就利用了复杂流水线:乱序执行和寄存器重命名,成为了超标量。在此之前,在1993年生产的Pentium上有U和V管线:双管线可以在不依赖时在一个时钟周期执行两条简单指令的情况;但这与Pentium Pro中出现的乱序执行和寄存器重命名没有什么可比的,如今几乎保持不变。

用几句话来解释,最快的代码是指指令不依赖于先前的结果,例如,您应始终清除整个寄存器(通过movzx)或使用 add rax, 1代替,或inc rax删除对先前标志状态的依赖等。

如果时间允许,您可以阅读有关乱序执行和注册重命名的更多信息,Internet上有大量可用信息。

还有其他重要问题,例如分支预测,加载和存储单元的数量,执行微操作的门的数量等,但是要考虑的最重要的事情是无序执行。

大多数人根本不了解乱序执行,因此他们像80286一样编写汇编程序,希望他们的指令将花费固定的时间来执行,而不管上下文如何。而C编译器知道乱序执行并正确生成代码。这就是为什么这种不了解的人的代码速度较慢的原因,但是如果您意识到这一点,您的代码就会更快。


8

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


7

这完全取决于您的工作量。

对于日常操作,C和C ++很好,但是某些工作负载(涉及视频的任何转换(压缩,解压缩,图像效果等))都需要汇编才能实现。

它们通常还涉及使用针对这些类型的操作而调整的特定于CPU的芯片组扩展(MME / MMX / SSE /其他)。


6

我有一个需要转换的位的操作,每个中断需要192或256位,每50微秒发生一次。

它是通过固定映射(硬件约束)发生的。使用C,大约需要10微秒的时间。当我将此翻译为Assembler时,要考虑到此映射的特定功能,特定的寄存器缓存以及使用面向位的操作;执行时间不到3.5微秒。




5

简单的答案...谁 熟悉汇编的(也有他的参考,并且正在利用每个小的处理器高速缓存和管道功能等)可以保证比任何其他程序产生更快的代码编译器。

但是,这些天的差异在典型应用中无关紧要。


1
您忘记说“付出了很多时间和精力”,而忘记了“制造维护噩梦”。我的一个同事正在致力于优化OS代码的性能关键部分,他在C语言方面的工作远胜于汇编语言,因为它使他能够在合理的时间内研究高级更改对性能的影响。
Artelius 2010年

我同意。有时,您可以使用宏和脚本来生成汇编代码,以节省时间并快速开发。如今,大多数汇编程序都有宏。如果不是,则可以使用(非常简单的RegEx)Perl脚本制作一个(简单的)宏预处理器。

这个。精确地 还没有发明击败领域专家的编译器。
cmaster-恢复莫妮卡

4

CP / M-86版本的PolyPascal(同为Turbo Pascal的同级产品)的一种可能性是用一种机器语言例程代替“ use-bios-to-character-to-character-to-screen”功能。被赋予了x和y以及放置在那里的字符串。

这样就可以比以前更快得多地更新屏幕!

二进制文件中有足够的空间来嵌入机器代码(几百个字节),并且那里还有其他东西,因此,尽可能多地压缩是至关重要的。

事实证明,由于屏幕为80x25,因此两个坐标每个都可以容纳一个字节,因此两个坐标都可以容纳两个字节的单词。这允许用更少的字节来进行所需的计算,因为单个加法可以同时操纵两个值。

据我所知,没有C编译器可以将多个值合并到一个寄存器中,对它们执行SIMD指令,然后在以后再次将其拆分(而且我认为机器指令也不会更短)。


4

汇编中最著名的片段之一来自Michael Abrash的纹理映射循环(在此进行了详细说明):

add edx,[DeltaVFrac] ; add in dVFrac
sbb ebp,ebp ; store carry
mov [edi],al ; write pixel n
mov al,[esi] ; fetch pixel n+1
add ecx,ebx ; add in dUFrac
adc esi,[4*ebp + UVStepVCarry]; add in steps

如今,大多数编译器将特定于CPU的高级指令表示为内在函数,即将函数编译为实际指令的函数。MS Visual C ++支持MMX,SSE,SSE2,SSE3和SSE4的内在函数,因此您不必担心下拉至汇编程序即可利用特定于平台的指令。Visual C ++还可以通过适当的/ ARCH设置来利用您要针对的实际体系结构。


更好的是,这些SSE内部函数由Intel指定,因此它们实际上是可移植的。
詹姆斯

4

只要有合适的程序员,汇编程序总是可以比其C语言更快(至少在某些程度上)。要创建一个C语言程序,而您至少不能取出汇编程序的一条指令,将很困难。


这可能会更正确:“ 在... 上创建非平凡的 C程序将很困难。”或者,您可以说:“ 在... 上找到真实的 C程序将很困难。” ,存在一些琐碎的循环,编译器确实会为其产生最佳输出。不过,很好的答案。
cmaster-恢复莫妮卡


4

gcc已成为一种广泛使用的编译器。一般来说,它的优化效果不佳。比一般程序员编写汇编程序要好得多,但是对于实际性能而言,并不是那么好。有些编译器在其生成的代码中简直令人难以置信。因此,作为一个普遍的答案,将有很多地方可以进入编译器的输出并调整汇编程序的性能,和/或仅从头开始重写例程。


8
GCC进行了极其智能的“独立于平台”的优化。但是,在充分利用特定指令集方面并不是很好。对于这样的可移植编译器,它做得很好。
Artelius 2009年

2
同意 它的可移植性,语言输入和目标输出都是惊人的。那样的便携性可以而且确实会妨碍您真正地精通一种语言或目标。因此,针对特定目标的特定优化存在着人类做得更好的机会。
old_timer 2009年

+1:GCC在生成快速代码方面当然没有竞争力,但是我不确定这是因为它具有可移植性。LLVM是可移植的,我已经看到它生成的代码比GCC快4倍。
乔恩·哈罗普

我更喜欢GCC,因为它已经运行了很多年,而且几乎可以在运行现代便携式编译器的所有平台上使用。不幸的是,我无法构建LLVM(Mac OS X / PPC),因此我可能将无法切换到它。GCC的优点之一是,如果您编写的代码是在GCC中构建的,则很可能会接近标准,并且您将确保几乎可以在任何平台上构建该代码。

4

Longpoke,只有一个限制:时间。如果您没有足够的资源来优化代码的每个更改,并花时间分配寄存器,优化少量溢出,而没有的话,则编译器将每次都获胜。您对代码进行修改,重新编译和测量。如有必要,请重复。

另外,您可以在高级方面做很多事情。同样,检查生成的程序集可能会使IMPRESSION感觉到代码已被废弃,但实际上它的运行速度要比您认为的更快。例:

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

编译器将读取数据,将其压入堆栈(溢出),然后从堆栈中读取并作为参数传递。听起来很糟糕?它实际上可能是非常有效的延迟补偿,并且可以加快运行时间。

//优化的版本call_function(data [i],...); //毕竟没有那么优化。

优化版本的想法是,我们减少了套准压力并避免了溢出。但实际上,“糟糕”版本的速度更快!

查看汇编代码,仅查看说明并得出结论:更多的说明,较慢的说明将是错误的判断。

这里要注意的事情是:许多组装专家认为他们了解很多,但了解很少。规则也从体系结构更改为下一个。例如,没有银弹x86代码,它总是最快的。这些天最好遵循经验法则:

  • 记忆很慢
  • 快取
  • 尝试更好地使用缓存
  • 你多久想念一次?您有延迟补偿策略吗?
  • 您可以为一个缓存未命中执行10-100条ALU / FPU / SSE指令
  • 应用程序体系结构很重要。
  • ..但是当问题不在体系结构中时它没有帮助

同样,过分相信编译器会神奇地将思想欠佳的C / C ++代码转换为“理论上最佳”的代码,这是一厢情愿的想法。如果您在此低级关注“性能”,则必须了解所使用的编译器和工具链。

对于初学者来说,C / C ++中的编译器通常不太擅长重新排序子表达式,因为这些函数具有副作用。函数式语言不会受到这种警告的困扰,但并不能很好地适应当前的生态系统。有一些编译器选项允许宽松的精度规则,这些规则允许由编译器/链接器/代码生成器更改操作顺序。

这个话题有点死胡同。对于大多数情况而言,这是无关紧要的,其余的,他们无论如何都知道自己在做什么。

归结为:“了解自己在做什么”,这与知道自己在做什么有些不同。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.