关于性能:什么时候组装比C快?

When is assembly faster than C?

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

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

有人能提供一些具体的例子来说明程序集比使用现代编译器编写良好的C代码更快的情况吗?你能用分析证据支持这种说法吗?我很有信心这些案件是存在的,但我真的想知道这些案件有多深奥,因为它似乎是一些争论点。


下面是一个现实世界的例子:旧编译器上的定点乘法。好的。

这些不仅在没有浮点的设备上很有用,而且在精度方面也很有用,因为它们给你32位的精度和可预测的误差(浮点只有23位,很难预测精度损失)。即整个范围内的绝对精度一致,而不是接近于相对精度一致(float)。好的。

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

  • 得到64位整数乘法的最高部分:对于32x32=>64位乘法,使用uint64_t的可移植版本无法在64位CPU上进行优化,因此需要intrinsics或__int128在64位系统上实现高效代码。
  • _在Windows32位上使用UMUL128:MSVC在将32位整数乘以64时并不总是做得很好,因此intrinsics帮助了很多。

C没有一个完整的乘法运算符(n位输入的2n位结果)。用C表示它的通常方法是将输入强制转换为更宽的类型,并希望编译器认识到输入的高位不有趣:好的。

1
2
3
4
5
6
7
8
9
// 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公开为_uuEmu,64位移位公开为_uuull_rshift。好的。

使用内部函数,您可以用C编译器有机会理解正在发生的事情的方式重写函数。这允许代码被内联、寄存器分配、公共子表达式消除和常量传播。与手工编写的汇编程序代码相比,您将获得巨大的性能改进。好的。

供参考:vs.net编译器定点mul的最终结果是:好的。

1
2
3
4
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的情况下完成此操作。)好的。

有关x86(32位)和ARM的信息,请参见source+asm。(不幸的是,它没有足够旧的编译器来从简单的纯C版本生成错误的代码。)好的。

现代的CPU可以做C根本没有操作员的事情,比如popcnt或位扫描来查找第一个或最后一个设置位。(posix有一个ffs()函数,但是它的语义与x86 bsfbsr不匹配。请参见https://en.wikipedia.org/wiki/find_first_set)。好的。

有些编译器有时可以识别一个循环,该循环计算整数中的设定位数,并将其编译为popcnt指令(如果在编译时启用),但在GNU C中使用__builtin_popcnt要可靠得多,或者在x86上使用来自的sse4.2:_mm_popcnt_u32作为硬件目标。好的。

或者在C++中,分配给EDCOX1×8,并使用EDCOX1×9 }。(在这种情况下,语言找到了一种通过标准库可移植地公开popcount优化实现的方法,这种方法将始终编译为正确的内容,并且可以利用目标支持的任何内容。)另请参见https://en.wikipedia.org/wiki/hamming-weight语言支持。好的。

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

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


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

我向他演示了如何使用位移位来重铸问题,在他拥有的非优化编译器上,处理时间缩短到了大约30秒。

我刚得到一个优化编译器,同样的代码在5秒内旋转了图形。我看了一下编译器正在生成的汇编代码,从我所看到的结果来看,我编写汇编程序的日子结束了。


只要编译器看到浮点代码,手工编写的版本就会更快。主要原因是编译器不能执行任何健壮的优化。有关此主题的讨论,请参阅来自msdn的这篇文章。下面是一个示例,其中程序集版本的速度是C版本的两倍(使用VS2K5编译):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#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;
}

以及我的电脑上运行默认版本构建*的一些数字:

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

出于兴趣,我用dec/jnz交换了这个循环,它对计时没有任何影响——有时更快,有时更慢。我想记忆有限的方面与其他的优化相形见绌。

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


在不给出任何特定示例或探查器证据的情况下,当您知道的比编译器更多时,您可以编写比编译器更好的汇编程序。

在一般情况下,现代C编译器更了解如何优化有问题的代码:它知道处理器管道是如何工作的,它可以尝试以比人类更快的速度重新排序指令,等等-它基本上与计算机一样,对于董事会游戏来说,它和最好的人类玩家一样或更好,等等,因为它在问题空间中进行搜索的速度比大多数人都快。虽然理论上你可以在特定情况下和计算机一样出色地运行,但你肯定不能以相同的速度运行,这使得它在多个情况下都不可行(也就是说,如果你试图在汇编程序中编写多个例程,编译器的性能肯定会比你好)。

另一方面,在某些情况下,编译器没有那么多的信息——我认为主要是在处理不同形式的外部硬件时,编译器对此一无所知。主要的例子可能是设备驱动程序,在这里汇编程序结合人类对相关硬件的深入了解可以产生比C编译器更好的结果。

其他人提到了特殊用途的指令,这就是我在上面一段中所说的,编译器可能对这些指令知之甚少,或者根本不知道,这使得人类能够更快地编写代码。


在我的工作中,有三个原因让我了解和使用组装。按重要性排序:

  • 调试-我经常得到库代码有缺陷或不完整的文档。我在装配级别介入,了解它在做什么。我必须每周做一次。我也用它作为调试问题的工具,在这个问题中,我的眼睛没有注意到C/C++/C语言中的习惯错误。看着这个集会就可以过去了。

  • 优化-编译器在优化方面做得相当好,但我的表现与大多数人不同。我编写的图像处理代码通常以如下代码开头:

    1
    2
    3
    4
    5
    for (int y=0; y < imageHeight; y++) {
        for (int x=0; x < imageWidth; x++) {
           // do something
        }
    }

    "做某件事"通常以数百万次的顺序发生(即3到30次之间)。通过在"做点什么"阶段中刮取周期,性能提升被极大地放大。我通常不会从那里开始-我通常首先编写代码来工作,然后尽我最大的努力将C重构为自然更好的(更好的算法,更少的循环负载等)。我通常需要阅读汇编来了解正在发生的事情,很少需要编写它。我可能每两三个月做一次。

  • 做一些语言不会让我做的事。这些包括获取处理器架构和特定的处理器特性,访问不在CPU中的标志(伙计,我真希望C给你访问进位标志),等等。我这样做可能一年一次或两年。


  • 只有在使用某些特殊用途的指令集时,编译器才不支持。

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

    此外,更好的算法、数据结构和内存管理将为您提供至少一个数量级的性能,这比您可以在汇编中进行的微观优化要好。


    虽然C"接近"8位、16位、32位、64位数据的低级操作,但C不支持一些数学操作,这些操作通常可以在某些汇编指令集中优雅地执行:

  • 定点乘法:两个16位数字的乘积是一个32位数字。但是C语言中的规则是两个16位数字的乘积是一个16位数字,两个32位数字的乘积是一个32位数字——在这两种情况下都是下半部分。如果你想要一个16x16乘法或32x32乘法的上半部分,你必须用编译器玩游戏。一般的方法是强制转换为大于所需的位宽度、乘法、下移和强制转换:

    1
    2
    3
    4
    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乘法的上半部分,并用机器的本机16x16乘法做正确的事情。或者它可能是愚蠢的,需要一个库调用来进行32x32的乘法运算,这太过分了,因为您只需要16位的产品——但是C标准没有给您任何表达自己的方法。

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

    1
    2
    3
    4
    5
    6
    7
    // 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中不能真正做到),这使得一些算法更容易实现。


    简短答案?有时。

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

    The C Programming Language -- A
    language which combines the
    flexibility of assembly language with
    the power of assembly language.

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

    值得注意的是,汇编语言只在编写时运行。然而,在C语言和它生成的汇编语言之间有一个编译器,这非常重要,因为C代码的速度与编译器的性能有很大关系。

    当gcc出现在现场时,使它如此受欢迎的原因之一是它通常比附带许多商业Unix风格的C编译器要好得多。它不仅是ANSIC(这些K&R C垃圾中没有一个),更健壮,而且通常生成更好(更快)的代码。不总是,但经常。

    我告诉你们所有这些是因为对于C和汇编程序的速度没有一个笼统的规则,因为对于C没有客观的标准。

    同样,汇编程序也会因运行的处理器、系统规范、使用的指令集等的不同而变化很大。历史上有两个CPU体系结构家族:CISC和RISC。cisc中最大的参与者是Intelx86体系结构(和指令集)。RISC控制着Unix世界(mips6000、alpha、sparc等等)。中钢集团赢得了心灵之战。

    不管怎样,当我还是一个年轻的开发人员的时候,人们普遍认为手写的x86通常比C快得多,因为架构的工作方式,它的复杂性得益于人类的操作。另一方面,RISC似乎是为编译器而设计的,所以没有人(我知道)写过"sparc汇编程序"。我相信这样的人确实存在,但毫无疑问,他们都疯了,现在已经被制度化了。

    即使在同一系列处理器中,指令集也是一个重要点。某些Intel处理器具有SSE到SSE4等扩展。AMD有自己的SIMD指令。像C这样的编程语言的好处是,有人可以编写自己的库,因此它针对运行的任何处理器进行了优化。那是装配工的艰苦工作。

    在汇编程序中仍然可以进行一些没有编译器可以进行的优化,而且一个编写良好的汇编程序算法将比它的C等价物快或快。更大的问题是:它值得吗?

    最终,尽管汇编程序是当时的产物,在CPU周期昂贵的时候更受欢迎。如今,一台制造成本为5-10美元的CPU(IntelAtom)几乎可以满足任何人的任何需求。现在编写汇编程序的唯一真正原因是因为操作系统的某些部分(即使大多数Linux内核都是用C编写的)、设备驱动程序、可能的嵌入式设备(尽管C在这方面也占主导地位)等低级别的原因。或者只是为了踢(有点自虐)。


    一个可能不再适用的用例,但是为了满足你的书呆子的需求:在amiga上,CPU和图形/音频芯片将为访问RAM的某个区域而斗争(RAM的前2MB是特定的)。因此,当您只有2MB内存(或更少)时,显示复杂的图形加上播放声音会降低CPU的性能。

    在汇编程序中,您可以巧妙地交织您的代码,使CPU仅在图形/音频芯片内部繁忙时(即总线空闲时)尝试访问RAM。因此,通过重新排序您的指令,巧妙地使用CPU缓存,总线定时,您可以达到一些效果,这是根本不可能使用任何更高级的语言,因为您必须计时每个命令,甚至在这里和那里插入nop,以防止不同的芯片彼此雷达。

    这也是为什么CPU的nop(无操作-不做任何事情)指令实际上可以使整个应用程序运行更快的另一个原因。

    [编辑]当然,这项技术取决于具体的硬件设置。这就是为什么许多amiga游戏不能处理更快的CPU的主要原因:指令的时间被关闭。


    第一点,这不是答案。
    即使您从未在其中编程,我发现了解至少一个汇编程序指令集也是很有用的。这是程序员不断追求了解更多,从而变得更好的一部分。当进入没有源代码的框架,并且至少对发生的事情有一个大致的了解时,也很有用。它还可以帮助您理解Javabytecode和.NET IL,因为它们都类似于汇编程序。

    在有少量代码或大量时间时回答问题。最适用于嵌入式芯片,在这种芯片中,针对这些芯片的编译器的低复杂度和低竞争性可以使平衡有利于人类。此外,对于受限设备,您通常以一种难以指示编译器执行的方式来权衡代码大小/内存大小/性能。例如,我知道这个用户操作不是经常调用的,所以我的代码大小很小,性能也很差,但是另一个类似的函数每秒都会使用一次,所以我的代码大小更大,性能更快。这是一个熟练的程序设计人员可以使用的一种权衡。

    我还想补充一点,这里有很多中间的地方,您可以在C语言中编写代码,编译和检查生成的程序集,然后更改C代码或调整并作为程序集进行维护。

    我的朋友在微控制器上工作,目前是控制小型电动机的芯片。他在低水平C和装配的组合下工作。有一次他告诉我工作的好日子,他把主循环从48条指令减少到43条。他也面临着这样的选择,比如代码已经增长到可以填满256K芯片,而企业想要一个新的功能,是吗?

  • 删除现有功能
  • 减少部分或所有现有功能的大小,可能会以性能为代价。
  • 提倡使用成本更高、功耗更高、形状系数更大的芯片。
  • 我想作为一个商业开发人员,添加相当多的产品组合或语言、平台、应用程序类型,我从来没有觉得有必要深入编写程序集。我一直很感激我所学到的知识。有时也会调试它。

    我知道我已经回答了更多的问题"为什么我要学习汇编程序",但我觉得它比什么时候更快更重要。

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

    • 低级别操作系统功能的工作
    • 使用编译器。
    • 在非常有限的芯片、嵌入式系统等上工作

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

    戴维。


    我很惊讶没人这么说。如果用汇编语言编写,strlen()函数的速度会快得多!在C语言中,你能做的最好的事是

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

    在装配过程中,可以大大加快速度:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    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倍!


    我不能给出具体的例子,因为它是很多年前的事情了,但是有很多情况下手写汇编程序可以执行任何编译器。原因:

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

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

    • 对于跳转表之类的东西,可以避免对索引进行边界检查。

    基本上,编译器在优化方面做得很好,这几乎总是"足够好",但在某些情况下(如图形渲染),你要为每个周期付出高昂的代价,你可以走捷径,因为你知道代码,而编译器却不能,因为它必须在安全的方面。

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

    也就是说,我想让编译器为我生成好的汇编代码,但不要太聪明,它们大多是这样做的。事实上,我讨厌fortran的一个原因是它为了"优化"它而对代码进行了加扰,通常没有任何意义。

    通常,当应用程序出现性能问题时,这是由于浪费的设计。现在,我绝不会推荐汇编程序来提高性能,除非整个应用程序已经在其使用寿命的一英寸内进行了调整,仍然不够快,并且一直在紧凑的内部循环中运行。

    补充:我见过很多用汇编语言编写的应用程序,与C、Pascal、Fortran等语言相比,主要的速度优势在于程序员在汇编程序中编码时要小心得多。他或她每天要写大约100行代码,不管是哪种语言,用一种等于3或400条指令的编译器语言。


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


    我的经验中有几个例子:

    • 访问C无法访问的指令。例如,许多体系结构(如x86-64、IA-64、DEC Alpha和64位MIPS或PowerPC)支持64位乘64位乘法,产生128位结果。GCC最近增加了一个扩展,提供对这些指令的访问,但在需要组装之前。在实现类似于RSA的东西时,对64位CPU的访问可能会产生巨大的影响,有时甚至是性能提高4倍的一个因素。

    • 访问CPU特定标志。经常咬我的是进位标志;当进行多精度加法时,如果您没有访问CPU进位的权限,则必须将结果进行比较,以查看它是否溢出,这会使每个肢体多接受3-5条指令;更糟的是,它在数据访问方面非常串行,这会降低现代Superscal的性能。AR处理器。当一行处理数千个这样的整数时,能够使用addc是一个巨大的胜利(在进位上也存在超标量的争用问题,但是现代CPU处理得相当好)。

    • SIMD。即使是自动矢量化编译器也只能处理相对简单的情况,因此,如果您希望具有良好的SIMD性能,不幸的是通常需要直接编写代码。当然,您可以使用intrinsic而不是assembly,但是一旦进入intrinsic级别,您基本上就是在编写assembly,只需将编译器用作寄存器分配器和(名义上)指令调度程序。(我倾向于对simd使用intrinsics,因为编译器可以生成函数序言,而我不需要处理函数调用约定之类的ABI问题,所以我可以在Linux、OS X和Windows上使用相同的代码,但除此之外,SSE的intrinsics确实不是很好——尽管我觉得Altivec的并不好。对他们没有太多经验)。作为一个(今天)矢量化编译器无法解决的问题的例子,我们可以想象一个编译器可以分析算法并生成这样的代码,但在我看来,这样一个聪明的编译器至少离现有的编译器有30年之遥(最好)。

    另一方面,多核机器和分布式系统已经将许多最大的性能优势转移到了另一个方向——在汇编中编写内部循环的速度提高了20%,或者在多个核心上运行它们的速度提高了300%,或者在一组机器上运行它们的速度提高了10000%。当然,与C或ASM相比,高级优化(如未来、记忆化等)在高级语言(如ML或scala)中通常要容易得多,而且通常可以获得更大的性能胜利。所以,和往常一样,需要权衡。


    紧密的循环,就像处理图像时一样,因为图像可能由数百万像素组成。坐下来思考如何最好地利用有限数量的处理器寄存器可以产生不同的效果。这是一个真实的例子:

    Optimizing away II

    然后,处理器通常会有一些深奥的指令,这些指令对于编译器来说过于专业化,因而不必费心,但有时汇编程序程序员可以很好地利用它们。以XLAT指令为例。如果您需要在一个循环中查找表,并且该表被限制为256字节,那就太好了!

    更新:哦,当我们一般谈论循环时,想想最关键的是什么:编译器通常不知道有多少迭代将是常见的情况!只有程序员知道一个循环将被迭代很多次,因此,用一些额外的工作来准备循环是有益的,或者如果它被迭代的次数太少,那么设置实际上将花费比预期的迭代时间更长的时间。


    比你想象的更频繁的是,C需要做一些看起来不必要的事情,从汇编程序员的角度来看,仅仅是因为C标准这么说。

    例如,整数提升。如果您想在C语言中转换char变量,通常会期望代码只做一个位转换。

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


    如果您没有研究编译器所产生的代码的反汇编过程,实际上您不知道编写良好的C代码是否真的很快。很多时候,你看到"写得好"是主观的。

    因此,没有必要用汇编程序编写代码来获得有史以来最快的代码,但出于同样的原因,了解汇编程序肯定是值得的。


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


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

    对于日常操作,C和C++是很好的,但是有一定的工作负载(任何涉及视频(压缩、解压缩、图像效果等)的转换,几乎都需要汇编来表现。

    它们还通常涉及使用针对这些类型操作而调优的CPU特定芯片组扩展(mme/mmx/sse/whatever)。


    这也许值得一看Walter Bright优化不可变和纯粹性。这不是一个分析测试,但向您展示了手写和编译器生成的ASM之间区别的一个很好的例子。沃尔特·布赖特写了一些优化的编译程序,所以可能值得看一下他的其他博客文章。


    我已经阅读了所有的答案(超过30个),没有找到一个简单的原因:如果你已经阅读并练习了英特尔,汇编程序比C快?64和IA-32体系结构优化参考手册,所以汇编速度可能会变慢的原因是写这么慢的汇编的人没有阅读优化手册。

    在英特尔80286过去的好日子里,每一条指令都以固定的CPU周期计数执行,但自从1995年发布奔腾Pro以来,英特尔处理器变得超标量,利用复杂的流水线:无序执行和寄存器重命名。在此之前,在1993年生产的Pentium上,存在U和V管道:如果两条管道不相互依赖,则可以在一个时钟周期内执行两条简单指令;但是,与Pentium Pro中出现的无序执行和寄存器重命名相比,这并不是什么可比的,现在几乎保持不变。

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

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

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

    大多数人只是不知道无序执行,所以他们编写自己的程序如80286,期望他们的指令无论上下文如何都需要固定的时间来执行;而C编译器知道无序执行并正确生成代码。这就是为什么这些不知情的人的代码会变慢,但是如果你意识到了,你的代码会变快。


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

    它通过固定映射(硬件约束)发生。使用C,大约需要10微秒。当我把它翻译成汇编程序时,考虑到这个映射的特定特性,特定的寄存器缓存,以及使用面向位的操作;执行这项操作只需要不到3.5微秒。


    简单的答案…熟悉汇编的人(aka在他旁边有一个引用,并且正在利用每一个小的处理器缓存和管道特性等)一定能比任何编译器生成更快的代码。

    然而,这些天的差异在典型的应用程序中并不重要。


    Linux程序集Howto,提出了这个问题,并给出了使用程序集的优缺点。


    如何在运行时创建机器代码?

    我哥哥曾经(大约2000年)通过在运行时生成代码实现了一种非常快速的实时光线跟踪器。我记不清细节,但有一种主模块在对象之间循环,然后它准备和执行一些特定于每个对象的机器代码。

    然而,随着时间的推移,这种方法被新的图形硬件所取代,变得毫无用处。

    今天,我认为用这种方法可能可以优化一些对大数据(数百万条记录)的操作,如数据透视表、钻孔、动态计算等。问题是:这些努力值得吗?


    这个问题有点误导人。答案就在你的帖子里。对于执行速度比编译器生成的任何问题都快的特定问题,始终可以编写程序集解决方案。问题是,您需要成为一名汇编专家来克服编译器的局限性。一个有经验的汇编程序员可以在任何HLL中编写程序,它的执行速度比一个没有经验的人写的快。事实上,您总是可以编写执行速度比编译器生成的程序快的程序集。


    其中一个更著名的组装片段来自Michael Abrash的纹理映射循环(此处详细说明):

    1
    2
    3
    4
    5
    6
    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的本质,因此,您必须少担心下拉到装配以利用特定于平台的指令。VisualC++也可以利用实际的体系结构,使用适当的ARCH设置。


    GCC已经成为一个广泛使用的编译器。一般来说,它的优化没有那么好。远好于一般程序员编写汇编程序,但对于实际的性能来说,没有那么好。有些编译器在其生成的代码中简直令人难以置信。因此,作为一个一般的答案,有很多地方可以让您进入编译器的输出中,对汇编程序进行性能调整,和/或只是从头开始重新编写例程。


    对CP/M-86版本的polypascal(与turbo pascal兄弟)的一个可能性是用机器语言程序替换"使用bios将字符输出到屏幕"功能,在Essense中,机器语言程序给出了x、y和要放在那里的字符串。

    这使得更新屏幕的速度比以前快得多!

    二进制文件中有嵌入机器代码的空间(几百个字节),还有其他的东西,所以必须尽可能地压缩。

    事实证明,由于屏幕是80x25,两个坐标都可以放在一个字节中,所以两个坐标都可以放在一个双字节字中。这允许以更少的字节进行计算,因为一个加法可以同时操作两个值。

    据我所知,没有一个C编译器可以将多个值合并到一个寄存器中,对它们执行simd指令,然后再将它们拆分出来(我认为机器指令不会缩短)。


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


    如果有了合适的程序员,汇编程序总是可以比C语言的对应程序更快(至少是稍微快一点)。如果您不能从汇编程序中取出至少一条指令,则很难创建一个C程序。


    长戳,只有一个限制:时间。当您没有资源来优化代码的每一个变更,花时间分配寄存器,优化很少的溢出,而不是什么时候,编译器将赢得每一次。您可以对代码进行修改、重新编译和度量。必要时重复。

    另外,你可以在高层做很多事情。此外,检查生成的程序集可能会给人留下代码是垃圾代码的印象,但实际上,它的运行速度会比您认为的更快。例子:

    int y=数据[i];//在这里做些事情……调用函数(y,…);

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

    //优化版本调用_函数(data[i],…);//毕竟没有优化。

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

    查看汇编代码,只需查看指令并得出结论:更多指令,越慢,将是错误的判断。

    这里要注意的是:许多装配专家认为他们知道很多,但知道的很少。规则也会从架构转换到下一个。例如,没有银弹x86代码,它总是最快的。现在最好按经验法则去做:

    • 记忆迟钝
    • 高速缓存
    • 尝试更好地使用缓存
    • 你多久会错过一次?你有延迟补偿策略吗?
    • 您可以对单个缓存未命中执行10-100 ALU/FPU/SSE指令
    • 应用程序架构很重要。
    • …但当问题不在体系结构中时,它没有帮助

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

    C/C++中的编译器通常不擅长重新排序子表达式,因为函数具有副作用。功能语言不受此警告的影响,但不适合当前的生态系统。有些编译器选项允许放松的精度规则,允许编译器/链接器/代码生成器更改操作顺序。

    这个话题有点死气沉沉;对大多数人来说,它是不相关的,其余的人,他们都知道他们已经在做什么了。

    归根结底,这就是:"要理解你在做什么",这与知道你在做什么有点不同。


    在以兆赫为单位测量处理器速度和屏幕尺寸低于一百万像素的日子里,一个众所周知的提高显示速度的方法是展开循环:为屏幕的每一扫描行执行写操作。它避免了维护循环索引的开销!加上屏幕刷新检测,效果相当好。这是C编译器无法做到的…(尽管通常您可以在速度优化和大小优化之间进行选择,但我认为前者使用了一些类似的技巧。)

    我知道有些人喜欢用汇编语言编写Windows应用程序。他们声称他们更快(很难证明)更小(事实上!).显然,虽然做起来很有趣,但这可能是浪费时间(当然,除了学习目的!),特别是对于GUI操作…现在,也许一些操作,比如在文件中搜索字符串,可以通过精心编写的汇编代码进行优化。


    这很难具体回答,因为这个问题非常不具体:什么是"现代编译器"?

    理论上,几乎任何手工汇编程序优化都可以由编译器来完成——无论它是否真的完成,一般来说都不能说,仅仅是关于特定编译器的特定版本。许多人可能需要花费大量的精力来确定它们是否可以在特定的上下文中应用而不产生副作用,以至于编译器编写人员不必为它们操心。


    实际上,你可以在一个大模型模式下构建大规模的程序,segaments可能被限制为64kb代码,但是你可以编写许多segaments,人们对asm提出异议,因为它是一种古老的语言,我们不需要再保留内存了,如果是这样的话,为什么我们要用内存来打包我们的PC,这是我能用as找到的唯一缺陷。M是因为它或多或少是基于处理器的,所以大多数为英特尔体系结构编写的程序很可能不会在AMD体系结构上运行。至于C比ASM快,没有比ASM快的语言,ASM可以做很多事情,而其他HLL在处理器级别做不到。asm是一种很难学习的语言,但是一旦你学习了它,没有任何一个hll能比你更好地翻译它。如果您只能看到HLL对您的代码所做的一些事情,并且了解它在做什么,那么您可能会想知道为什么更多的人不使用ASM,为什么不再更新assembles(无论如何都是为了一般的公共用途)。所以没有C比ASM快。甚至经验C++程序员仍然使用和写入代码块在ASM中添加到C++代码中用于速度。还有一些人认为过时或可能不好的其他语言有时是神话,例如photoshop是用pascal/asm编写的,souce的第一个版本已经提交给技术历史博物馆,而paintshop pro仍然用python、tcl和asm编写。其中一个共同的特点是"快速和伟大的图像处理器是asm,虽然photoshop可能已经升级到delphi现在它仍然是pascal。所有的速度问题都来自帕斯卡,但这是因为我们喜欢程序的外观,而不是他们现在所做的。我想用我一直在研究的纯ASM制作一个photoshop克隆,它可以很好地混合在一起。不是代码、解释、表述、重写等。只需编写代码并完成流程。


    现在,考虑到英特尔C + +编译器非常优化C代码,很难与编译器输出竞争。


    我想说的是,当你比编译器更好地处理一组给定的指令时。所以我想没有一般的答案


    我曾经和一些人一起工作,他们说:"如果编译器笨到搞不清你想做什么,而不能优化它,那么你的编译器就坏了,现在是时候换一个新的了。"我敢肯定在某些边缘情况下,程序集会击败C代码,但如果您经常发现自己使用汇编程序"赢得"编译器,则编译器会崩溃。

    写"优化的"SQL也可以这样说,它试图强迫查询计划器做一些事情。如果你发现自己在重新安排查询以让计划员做你想做的事情,那么你的查询计划器就被破坏了——换一个新的。