【问题标题】:Assembly code's length can indicate execution speed?汇编代码的长度可以指示执行速度吗?
【发布时间】:2016-06-13 10:02:27
【问题描述】:

我正在学习C,考虑下面的代码sn-p:

#include <stdio.h>

int main(void) {
  int fahr;
  float calc;

  for (fahr = 300; fahr >= 0; fahr = fahr - 20) {
    calc = (5.0 / 9.0) * (fahr - 32);
    printf("%3d %6.1f\n", fahr, calc);
  }

  return 0;
}

这是从 300 到 0 打印摄氏到华氏转换表。我编译它:

$ clang -std=c11 -Wall -g -O3 -march=native main.c -o main

我也用这个命令生成汇编代码:

$ clang -std=c11 -Wall -S -masm=intel -O3 -march=native main.c -o main

生成 1.26kb 文件和 71 行。

我稍微编辑了代码并将逻辑移到另一个在 main() 处初始化的函数中:

#include <stdio.h>

void foo(void) {
  int fahr;
  float calc;

  for (fahr = 300; fahr >= 0; fahr = fahr - 20) {
    calc = (5.0 / 9.0) * (fahr - 32);
    printf("%3d %6.1f\n", fahr, calc);
  }
}

int main(void) {
  foo();
  return 0;
}

这将生成 2.33kb 的 128 行汇编代码。

time ./main 运行这两个程序我发现执行速度没有差异。

我的问题是,通过汇编代码的长度来优化你的 C 程序有什么关系吗?

【问题讨论】:

  • 您看不到任何区别,因为这两个程序之间的唯一区别是一个调用foo。查看生成的汇编代码,不要只查看不相关的可执行文件的大小。 BTW 大部分 CPU 时间都被printf 占用了,粗略估计:95%
  • 如果你正在学习 C,只关注语言本身,不要担心优化。当您更精通该语言时,您可以稍后进行优化,
  • @GiacomoDegliEsposti:如果您已经知道计算机是如何工作的,那么看看 C 如何编译成 asm 将使您对 C 有更深入的了解,因为它与 asm 非常接近,可以理解简单函数是如何发生的。不过,大多数时候,您应该强迫自己不要过多考虑 asm,而只需编写人类可读且可靠运行的代码。
  • 指令少不一定表示速度。许多容易创建的情况是更多的指令比更少的指令更快......“这取决于”。测试速度的唯一方法是测量速度。凭借经验,您可能能够弄清楚哪些事情比其他事情花费的时间更长,并能够调整您的高级代码以提高速度。但是您首先必须从准确计时代码开始,然后进行调整并了解这些调整为什么会帮助或伤害。对于具有如此多变体和变量的 x86 系统,几乎不值得花时间进行调整,因为调整一个会使另一个变慢。

标签: c assembly clang


【解决方案1】:

您似乎在比较 GCC 生成的 .S 文件的大小,因为这显然没有意义,我只是假装您面对的是 GCC 生成的代码 sn-ps 的二进制大小。

虽然在所有其他条件相同的情况下,更短的代码大小可能会提高速度(由于更高的代码密度),但通常 x86 CPU 足够复杂,需要在代码大小优化和针对代码大小的优化之间解耦代码速度。

特别是如果您以代码速度为目标,您应该针对...代码速度进行优化。有时这需要选择最短的 sn-p,有时则不需要。

考虑编译器优化的经典示例,乘以 2 的幂:

int i = 4;
i = i * 8;

这可能被错误地翻译为:

;NO optimizations at all

mov eax, 4        ;i = 4        B804000000       0-1 clocks
imul eax, 8       ;i = i * 8    6BC009           3 clocks
                  ;eax = i      8 bytes total    3-4 clocks total

;Slightly optimized
;4*8 gives no sign issue, we can use shl

mov eax, 4        ;i = 4        B804000000       0-1 clocks
shl eax, 3        ;i = i * 8    C1E003           1 clock
                  ;eax = i      8 bytes total    1-2 clocks total

两个 sn-ps 的代码长度相同,但第二个的执行速度几乎是后者的两倍。

这是一个非常基本的示例1,其中甚至不需要考虑微架构。

另一个更微妙的例子如下,取自 Agner Fog 关于部分寄存器停顿2的讨论:

;Version A                        Version B

mov al, byte ptr [mem8]           movzx ebx, byte ptr [mem8]
mov ebx, eax                      and eax, 0ffffff00h
                                  or ebx, eax

;7 bytes                           14 bytes

两个版本的结果相同,但 版本 B版本 A 快 5-6 个时钟,尽管前者的大小是后者的两倍。


那么答案是不,代码大小不够;不过,这可能是一个平局

如果你真的对优化装配感兴趣,你会喜欢这两个读物:

第一个链接也有优化 C 和 C++ 代码的手册。


如果您使用 C 语言编写,请记住影响最大的优化是 1) 如何表示/存储数据,即数据结构 2) 如何处理数据,即算法。
有宏优化。

考虑到生成的程序集正在转向微优化,最有用的工具是 1) 智能编译器 2) 一组良好的内在函数3


1 在实践中进行优化非常简单。
2 现在可能有点过时了,但它可以达到目的。
3 内置的非标准函数,可转换为特定的汇编指令。

【讨论】:

    【解决方案2】:

    与往常一样,答案是“视情况而定”。有时,让代码更长 使其更高效:例如,CPU 不必浪费在每个循环之后跳转额外的指令。一个经典的例子(字面意思是“经典”:1983!)是"Duff's Device"。以下代码

    register short *to, *from;
    register count;
    {
        do {                          /* count > 0 assumed */
            *to = *from++;
        } while(--count > 0);
    }
    

    通过使用这个很多更大、更复杂的代码使 更快:

    register short *to, *from;
    register count;
    {
        register n = (count + 7) / 8;
        switch (count % 8) {
        case 0: do { *to = *from++;
        case 7:      *to = *from++;
        case 6:      *to = *from++;
        case 5:      *to = *from++;
        case 4:      *to = *from++;
        case 3:      *to = *from++;
        case 2:      *to = *from++;
        case 1:      *to = *from++;
                } while (--n > 0);
        }
    }
    

    但这可以走极端:使代码过大会增加缓存未命中和各种其他问题。简而言之:“过早的优化是邪恶的”——您需要在确定它是一个好主意之前测试您的前后,并且经常在多个平台上进行测试。

    我会问你:上述代码的第二个版本是否比第一个版本“更好”?与它所取代的代码相比,它的可读性、可维护性和复杂性都要低得多。

    【讨论】:

    • IDK 为什么人们喜欢推出 Duff 的设备,而不是使用单独的序言/尾声循环展开循环的简单示例。 (或gcc -funroll-loops 让编译器为您完成)。用 8 个入口点编译该循环的直接方法失去了在没有自动增量寻址模式的 x86 等架构上展开的许多好处。 (即每个步骤单独的inc 指令,而不是在寻址模式中一个增量然后位移。)
    • @Peter 我举了 Duff 的设备作为反例。 (起初)对新手来说非常复杂;这是对 C 的大规模使用;大多数程序员最终都可以理解它;尽管它很复杂,但它编译成的只是一个简单的循环展开。我故意选择它来说明为什么您应该优化 - 但我仍然喜欢 Michael Abrash 的书“代码优化之禅”ISBN 1-883577-03-9
    • 我的观点是循环展开对于微小的循环来说是一个非常好的优化,并且可以在没有 Duff 的设备的情况下轻松完成。所以这是反对优化的稻草人论据。但是,是的,有一个简单的实现作为性能改进的基线是必不可少的。如果没有可比较的东西,你就无法知道你的“聪明”想法是否成功。
    【解决方案3】:

    在这两种情况下实际运行的代码在内联之后是相同的。第二种方式更大,因为它还必须发出函数的独立定义,而不是内联到 main

    如果你在函数上使用了static,你会避免这种情况,所以编译器会知道没有任何东西可以从编译单元外部调用它,因此不需要独立定义内联到它唯一的调用者中。

    此外,编译器输出中的大多数 .s 行是 cmets 或汇编程序指令,而不是指令。所以你甚至没有计算指令。

    Godbolt 编译器资源管理器是查看编译器 asm 输出的好方法,仅包含指令和实际使用的标签。看看your code there


    如果存在循环或分支,则计算可执行文件中的指令总数是完全错误的。或者特别是循环内的函数调用,就像在这种情况下一样。 动态指令计数(实际运行的指令数,即每次循环计数等)与性能非常大致相关,但有些代码每个周期运行 4 条指令, 而有些则远低于 1(例如,大量 div 或 sqrt、缓存未命中和/或分支错误预测)。

    要详细了解导致代码运行缓慢或快速的原因,请参阅 标签 wiki,尤其是 Agner Fog's stuff

    我最近还给Deoptimizing a program for the pipeline in Intel Sandybridge-family CPUs 写了一个答案。想一想使程序运行速度变慢的可怕方法是一种有趣的练习。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2019-01-15
      • 1970-01-01
      • 1970-01-01
      • 2012-05-16
      • 1970-01-01
      • 2017-04-10
      • 2022-01-23
      • 1970-01-01
      相关资源
      最近更新 更多