【问题标题】:Coding Practices which enable the compiler/optimizer to make a faster program使编译器/优化器能够制作更快程序的编码实践
【发布时间】:2011-01-05 15:45:13
【问题描述】:

许多年前,C 编译器并不是特别聪明。作为一种解决方法,K&R 发明了 register 关键字来提示编译器,将这个变量保存在内部寄存器中可能是个好主意。他们还制作了三级运算符来帮助生成更好的代码。

随着时间的推移,编译器逐渐成熟。他们变得非常聪明,因为他们的流量分析使他们能够比您可能做的更好地决定将哪些值保存在寄存器中。 register 关键字变得不重要了。

由于alias 问题,对于某些类型的操作,FORTRAN 可能比 C 更快。理论上,通过仔细编码,可以绕过这个限制,使优化器能够生成更快的代码。

哪些编码实践可以使编译器/优化器生成更快的代码?

  • 我们将不胜感激确定您使用的平台和编译器。
  • 为什么该技术似乎有效?
  • 鼓励使用示例代码。

这是related question

[编辑] 这个问题不是关于概要分析和优化的整个过程。假设程序已正确编写、完全优化编译、测试并投入生产。您的代码中可能存在阻止优化器尽其所能完成最佳工作的结构。您可以做些什么来重构以消除这些禁令,并允许优化器生成更快的代码?

[编辑] Offset related link

【问题讨论】:

  • 可能是社区 wiki 恕我直言的好人选,因为这个(有趣的)问题没有“单一”明确的答案......
  • 我每次都想念它。感谢您指出这一点。
  • “更好”是指“更快”还是您心中有其他卓越标准?
  • 写一个好的寄存器分配器是相当困难的,尤其是可移植的,寄存器分配对于性能和代码大小是绝对必要的。 register 实际上通过与较差的编译器作斗争使性能敏感的代码更具可移植性。
  • @EvilTeach:社区 wiki 并不意味着“没有明确的答案”,它不是主观标签的同义词。社区 wiki 意味着您想将您的帖子提交给社区,以便其他人可以对其进行编辑。如果您不喜欢 wiki,请不要感到压力。

标签: c++ c performance optimization


【解决方案1】:

大多数现代编译器应该可以很好地加速tail recursion,因为可以优化函数调用。

例子:

int fac2(int x, int cur) {
  if (x == 1) return cur;
  return fac2(x - 1, cur * x); 
}
int fac(int x) {
  return fac2(x, 1);
}

当然这个例子没有任何边界检查。

后期编辑

虽然我对代码没有直接的了解;显然,在 SQL Server 上使用 CTE 的要求是专门设计的,以便它可以通过尾端递归进行优化。

【讨论】:

  • 问题是关于 C 的。C 不会删除尾递归,所以尾递归或其他递归,如果递归太深,堆栈可能会爆炸。
  • 我通过使用 goto 避免了调用约定问题。这样开销会更少。
  • @hogan:这对我来说是新的。你能指出任何这样做的编译器吗?你怎么能确定它实际上优化了它?如果它会做到这一点,真的需要确保它做到了。这不是你希望编译器优化器接受的东西(比如内联可能会也可能不会)
  • @hogan:我的立场是正确的。你说得对,Gcc 和 MSVC 都做了尾递归优化。
  • 这个例子不是尾递归,因为它不是最后的递归调用,而是乘法。
【解决方案2】:

以下是帮助编译器创建快速代码的编码实践——任何语言、任何平台、任何编译器、任何问题:

不要不要使用任何巧妙的技巧来强制甚至鼓励编译器按照你认为最好的方式将变量放置在内存中(包括缓存和寄存器)。首先编写一个正确且可维护的程序。

接下来,分析您的代码。

然后,并且只有在那时,您可能想要开始研究告诉编译器如何使用内存的效果。一次进行 1 项更改并衡量其影响。

预计会感到失望,并且确实必须非常努力地工作以实现小的性能改进。用于 Fortran 和 C 等成熟语言的现代编译器非常非常好。如果您阅读了关于从代码中获得更好性能的“技巧”的说明,请记住编译器作者也阅读过它,如果值得这样做,可能会实现它。他们可能写了你一开始读到的东西。

【讨论】:

  • 编译器开发人员的时间是有限的,就像其他人一样。并非所有优化都会进入编译器。就像 &% 的幂次方(很少,如果有的话,优化,但会产生显着的性能影响)。如果您阅读了有关性能的技巧,那么了解它是否有效的唯一方法是进行更改并衡量其影响。 永远不要假设编译器会为你优化一些东西。
  • & 和 % 几乎总是被优化,以及大多数其他便宜的免费算术技巧。没有得到优化的是右手操作数是一个变量,它恰好是 2 的幂。
  • 澄清一下,我似乎让一些读者感到困惑:我建议的编码实践中的建议是首先开发一个不使用内存布局指令的简单代码来建立性能基线.然后,一次尝试一件事情并衡量它们的影响。我没有就运营绩效提供任何建议。
  • 对于恒定的二次幂n,gcc 将% n 替换为& (n-1)即使禁用优化。这并不完全是“很少,如果有的话”......
  • % CANNOT 被优化为 & 当类型被签名时,由于 C 的负整数除法的愚蠢规则(向 0 舍入并具有负余数,而不是向下舍入和总是有正余数)。而且大多数时候,无知的编码人员使用带符号的类型......
【解决方案3】:

在您的代码中尽可能使用 const 正确性。它允许编译器更好地优化。

在这个文档中还有很多其他的优化技巧:CPP optimizations(虽然有点旧的文档)

亮点:

  • 使用构造函数初始化列表
  • 使用前缀运算符
  • 使用显式构造函数
  • 内联函数
  • 避免使用临时对象
  • 注意虚拟功能的成本
  • 通过引用参数返回对象
  • 考虑按班级分配
  • 考虑 stl 容器分配器
  • “空成员”优化

【讨论】:

  • 不多,很少。不过,它确实提高了实际的正确性。
  • 在 C 和 C++ 中,编译器不能使用 const 进行优化,因为将其丢弃是明确定义的行为。
  • +1 : const 是一个很好的例子,它会直接影响已编译的代码。重新@dsimcha 的评论——一个好的编译器会测试看看是否会发生这种情况。当然,一个好的编译器会“找到”那些无论如何都没有声明的 const 元素......
  • @dsimcha:但是,更改 const restrict 限定指针是未定义的。因此,在这种情况下,编译器可以进行不同的优化。
  • @dsimcha 在const 引用或const 指向非const 对象的指针上抛弃const 是明确定义的。修改实际的 const 对象(即最初声明为 const 的对象)不是。
【解决方案4】:

优化器并不能真正控制您的程序的性能,您可以。使用适当的算法和结构以及配置文件、配置文件、配置文件。

也就是说,您不应该在另一个文件中的一个文件中对一个小函数进行内循环,因为这会阻止它被内联。

尽可能避免获取变量的地址。请求指针不是“免费的”,因为这意味着变量需要保存在内存中。如果避免使用指针,即使是数组也可以保存在寄存器中——这对于向量化是必不可少的。

这就引出了下一点,阅读 ^#$@ 手册!如果您在此处添加__restrict__ 并在此处添加__attribute__( __aligned__ ),则GCC 可以矢量化纯C 代码。如果您希望优化器提供非常具体的内容,您可能必须具体。

【讨论】:

  • 这是一个很好的答案,但请注意,整个程序优化正变得越来越流行,实际上可以跨翻译单元内联函数。
  • @Novelocrat 是的 - 不用说,当我第一次看到来自 A.c 的内容被内联到 B.c 时,我感到非常惊讶。
【解决方案5】:

为了性能,首先要关注编写可维护的代码——组件化、松散耦合等,因此当您必须隔离某个部分以进行重写、优化或简单分析时,您可以毫不费力地做到这一点。

优化器会略微提高程序的性能。

【讨论】:

  • 只有在耦合“接口”本身可以优化时才有效。接口可能天生就“慢”,例如通过强制冗余查找或计算,或强制错误的缓存访问。
【解决方案6】:

让优化器完成它的工作。

说真的。不要试图智取优化器。它是由经验丰富的优秀人才设计的。

【讨论】:

  • 并非总是如此。还有一些事情是编译器没有足够的数据来优化和执行必要的修改。这是一个例子 - liranuna.com/sse-intrinsics-optimizations-in-popular-compilers
  • -1 问题无效。重点是询问有这种经验的 SO 上的人。我们中的许多人编写编译器 + 优化器。
  • @Hogan,坦率地说,我不理解你对尾递归的迷恋,只是说编译器可以优化它实际上并不是这个问题的答案。编译器也可以内联函数并做很多其他事情;所以呢?这很少会影响您实际需要编写代码的方式,而且几乎永远不会转化为实际的性能改进。当某些编译器可能不这样做时,为什么要依赖编译器将尾递归转换为循环?一开始就写一个循环!
  • 好的,这里有一个例子:依赖 SIMD 的程序(例如图形/渲染/图像处理)往往需要对齐的数据结构,以避免到处复制数据。这绝对属于编译器优化领域,因为它必须使用编译器特定指令 (__attribute__((aligned(x))) 用于 GCC)。
  • 我认为一个简单的事实是,通过告诉计算机以最有效的方式运行,可以使许多解决方案更快。充分了解编译器将做什么可以帮助实现这一点,但编译器无法优化所有内容。 -- 更重要的是,imo,编译器更容易优化简单代码。我怀疑投反对票的反应是“不要试图智取......”没有人提倡欺骗它,人们说,“使用编译器。”
【解决方案7】:

在大多数现代处理器上,最大的瓶颈是内存。

别名:Load-Hit-Store 在紧密循环中可能是毁灭性的。如果您正在读取一个内存位置并写入另一个内存位置并且知道它们是不相交的,那么在函数参数上仔细放置别名关键字确实可以帮助编译器生成更快的代码。但是,如果内存区域确实重叠并且您使用了“别名”,那么您将进入一个很好的未定义行为调试会话!

缓存未命中:不太确定如何帮助编译器,因为它主要是算法,但有预取内存的内在函数。

也不要过多地尝试将浮点值转换为 int,反之亦然,因为它们使用不同的寄存器,从一种类型转换为另一种类型意味着调用实际的转换指令,将值写入内存并在正确的寄存器设置。

【讨论】:

  • +1 用于加载命中存储和不同的寄存器类型。我不确定它在 x86 中的作用有多大,但他们正在开发 PowerPC(例如 Xbox360 和 Playstation3)。
  • 大多数关于编译器循环优化技术的论文都假设完美嵌套,这意味着除了最内层之外的每个循环的主体只是另一个循环。这些论文根本没有讨论概括这些所需的步骤,即使很明显它们可以。因此,我预计很多实现实际上并不支持这些概括,因为需要付出额外的努力。因此,许多用于优化循环中缓存使用的算法在完美嵌套上可能比在不完美嵌套上效果更好。
【解决方案8】:

人们编写的绝大多数代码都是 I/O 绑定的(我相信我在过去 30 年中为钱编写的所有代码都是如此绑定的),因此对于大多数人来说优化器的活动将是学术的。

但是,我要提醒人们,要优化代码,您必须告诉编译器对其进行优化 - 很多人(包括我忘记时)在这里发布 C++ 基准测试,如果没有启用优化器,这些基准测试毫无意义。

【讨论】:

  • 我承认自己很奇特——我研究的是受内存带宽限制的大型科学数字运算代码。对于项目的一般人群,我同意 Neil。
  • 真;但是现在很多 I/O 绑定代码是用实际上是悲观主义者的语言编写的——甚至没有编译器的语言。我怀疑仍然使用 C 和 C++ 的领域往往是更重要的优化领域(CPU 使用率、内存使用率、代码大小......)
  • 在过去的 30 年中,我大部分时间都在用很少的 I/O 编写代码。节省 2 年做数据库的时间。图形、控制系统、模拟 - 没有 I/O 限制。如果 I/O 是大多数人的瓶颈,我们不会过多关注 Intel 和 AMD。
  • 是的,我真的不认同这个论点——否则我们(在我的工作中)不会寻找将更多计算时间用于 I/O 的方法。另外,我遇​​到的大部分 I/O 绑定软件都是 I/O 绑定的,因为 I/O 是草率完成的;如果优化访问模式(就像使用内存一样),则可以获得巨大的性能提升。
  • 我最近发现几乎没有用 C++ 语言编写的代码是 I/O 绑定的。当然,如果您正在调用 OS 函数进行大容量磁盘传输,您的线程可能会进入 I/O 等待(但使用缓存,即使这也是有问题的)。但是通常的 I/O 库函数,因为它们是标准的和可移植的,所以每个人都推荐它们,与现代磁盘技术(即使是价格适中的东西)相比,它们实际上慢得可怜。最有可能的是,仅当您在仅写入几个字节后一直刷新到磁盘时,I/O 才是瓶颈。 OTOH,UI 是另一回事,我们人类很慢。
【解决方案9】:

我实际上已经在 SQLite 中看到了这一点,他们声称它可以提高大约 5% 的性能:将所有代码放在一个文件中或使用预处理器来完成与此相同的操作。这样优化器就可以访问整个程序,并且可以进行更多的过程间优化。

【讨论】:

  • 将在源代码中物理上非常接近的函数放在一起使用会增加它们在目标文件中彼此靠近以及在可执行文件中彼此靠近的可能性。这种改进的指令局部性有助于避免运行时指令缓存未命中。
  • AIX 编译器有一个编译器开关来鼓励这种行为 -qipa[=] | -qnoipa 打开或自定义一类称为过程间分析 (IPA) 的优化。
  • 最好是有一种不需要这个的开发方式。以此为借口编写非模块化代码总体上只会导致代码运行缓慢且存在维护问题。
  • 我认为这个信息有点过时了。理论上,现在许多编译器中内置的全程序优化功能(例如 gcc 中的“链接时间优化”)具有相同的好处,但具有完全标准的工作流程(加上重新编译时间比将它们全部放在一个文件中更快) !)
  • @Wallacoloo 当然,这已经过时了。 FWIW,我今天第一次使用 GCC 的 LTO,并且 - 在 -O3 的所有其他条件相同 - 它从我的程序中删除了原始大小的 22%。 (它不受 CPU 限制,所以我对速度没有太多要说的。)
【解决方案10】:

如果你有重复调用的小函数,我过去通过将它们作为“静态内联”放在标题中获得了很大的收益。 ix86 上的函数调用非常昂贵。

使用显式堆栈以非递归方式重新实现递归函数也可以获得很多好处,但是您确实处于开发时间与收益的领域。

【讨论】:

  • 将递归转换为堆栈是 ompf.org 上的假定优化,适用于开发光线追踪器和编写其他渲染算法的人们。
  • ...我应该补充一点,我个人光线追踪器项目中最大的开销是使用 Composite 模式通过边界体积层次结构进行基于 vtable 的递归。它实际上只是一堆嵌套的框,结构为树,但使用该模式会导致数据膨胀(虚拟表指针)并降低指令的一致性(可能是一个小/紧循环现在是一个函数调用链)
【解决方案11】:

您遍历内存的顺序会对性能产生深远的影响,而编译器并不擅长找出并修复它。如果您关心性能,则在编写代码时必须认真考虑缓存位置问题。例如,C 中的二维数组以行优先格式分配。以列主要格式遍历数组往往会使您有更多的缓存未命中,并使您的程序更多的内存限制而不是处理器限制:

#define N 1000000;
int matrix[N][N] = { ... };

//awesomely fast
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[i][j];
  }
}

//painfully slow
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[j][i];
  }
}

【讨论】:

  • 严格来说这不是优化问题,而是优化问题。
  • 肯定是优化器问题。几十年来,人们一直在撰写有关自动循环交换优化的论文。
  • @Potatoswatter 你在说什么?只要观察到相同的最终结果,C 编译器就可以做它想做的任何事情,而且事实上 GCC 4.4 有-floop-interchange,如果优化器认为它有利可图,它将翻转内部和外部循环。
  • 嗯,好了。 C 语义经常被别名问题所破坏。我想这里真正的建议是通过那面旗帜!
【解决方案12】:

这是我的第二条优化建议。与我的第一条建议一样,这是通用的,而不是特定于语言或处理器的。

彻底阅读编译器手册并理解它告诉您的内容。尽量使用编译器。

我同意其他一两位受访者的观点,他们认为选择正确的算法对于提高程序性能至关重要。除此之外,您投入使用编译器的时间回报率(以代码执行改进衡量)远高于调整代码的回报率。

是的,编译器编写者不是来自编码巨人的种族,编译器包含错误,根据手册和编译器理论,什么应该使事情变得更快有时会使事情变慢。这就是为什么您必须一次迈出一步并衡量调整前后的性能。

是的,最终,您可能会面临编译器标志的组合爆炸,因此您需要有一两个脚本来运行带有各种编译器标志的 make、在大型集群上排队作业并收集运行时统计信息。如果在 PC 上只有你和 Visual Studio,那么在你尝试足够多的编译器标志组合之前,你就会失去兴趣。

问候

标记

当我第一次拿起一段代码时,我通常可以获得 1.4 - 2.0 倍的性能(即新版本的代码运行时间是旧版本的 1/1.4 或 1/2 ) 通过摆弄编译器标志在一两天内。诚然,这可能是对我编写的大部分代码的原创科学家缺乏编译器精通的评论,而不是我卓越的表现。将编译器标志设置为最大(很少只是 -O3)可能需要几个月的努力才能获得另一个 1.05 或 1.1 的因子

【讨论】:

    【解决方案13】:
    1. 对所有变量声明尽可能使用最局部的范围。

    2. 尽可能使用const

    3. 不要使用 register 除非您打算同时使用和不使用它来进行概要分析

    其中的前 2 个,尤其是第 1 个帮助优化器分析代码。这将特别有助于它对哪些变量保存在寄存器中做出正确的选择。

    盲目地使用 register 关键字可能有助于优化,也可能会损害您的优化,在查看程序集输出或配置文件之前,很难知道什么是重要的。

    还有其他一些事情对于从代码中获得良好的性能很重要;例如,设计数据结构以最大化缓存一致性。但问题是关于优化器的。

    【讨论】:

      【解决方案14】:

      我一直怀疑,但从未证明声明数组以使其具有 2 的幂作为元素的数量,使优化器能够通过将乘法替换为移位数位来执行strength reduction , 查找单个元素时。

      【讨论】:

      • 这曾经是真的,现在已经不复存在了。事实上恰恰相反。如果你用 2 的幂来声明你的数组,你很可能会遇到这样的情况,即你在内存中使用两个相隔 2 的幂的指针。问题是,CPU 缓存的组织方式就是这样,您最终可能会遇到两个阵列围绕一个缓存线争吵的情况。那样你会得到可怕的表现。将其中一个指针提前几个字节(例如,非 2 的幂)可以防止这种情况发生。
      • +1 Nils,其中一个具体的例子是英特尔硬件上的“64k 别名”。
      • 顺便说一句,这很容易通过查看反汇编来反驳。几年前,我很惊讶看到 gcc 如何通过移位和加法优化各种常量乘法。例如。 val * 7 变成了原本看起来像 (val &lt;&lt; 3) - val
      【解决方案15】:

      写入局部变量而不是输出参数!这对于避免混叠减速非常有帮助。例如,如果您的代码看起来像

      void DoSomething(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
      {
          for (int i=0; i<numFoo, i++)
          {
               barOut.munge(foo1, foo2[i]);
          }
      }
      

      编译器不知道 foo1 != barOut,因此每次循环都必须重新加载 foo1。在对 barOut 的写入完成之前,它也无法读取 foo2[i]。您可以开始使用受限指针,但这样做同样有效(而且更清晰):

      void DoSomethingFaster(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
      {
          Foo barTemp = barOut;
          for (int i=0; i<numFoo, i++)
          {
               barTemp.munge(foo1, foo2[i]);
          }
          barOut = barTemp;
      }
      

      这听起来很傻,但是编译器可以更聪明地处理局部变量,因为它不可能在内存中与任何参数重叠。这可以帮助您避免可怕的 load-hit-store(Francis Boivin 在此线程中提到)。

      【讨论】:

      • 这还有一个额外的好处,那就是经常让程序员更容易阅读/理解,因为他们也不必担心可能出现的不明显的副作用。
      • 大部分 IDE 默认显示局部变量,所以打字比较少
      • 您还可以使用受限指针启用该优化
      • @Ben - 没错,但我认为这种方式更清晰。此外,如果输入和输出确实重叠,我相信结果是未指定的受限指针(可能在调试和发布之间得到不同的行为),而这种方式至少是一致的。不要误会我的意思,我喜欢使用限制,但我更喜欢不需要它。
      • 你只需要希望 Foo 没有定义复制几兆数据的复制操作;-)
      【解决方案16】:

      通用优化

      这里是我最喜欢的一些优化。通过使用这些,我实际上增加了执行时间并减少了程序大小。

      将小函数声明为inline 或宏

      对函数(或方法)的每次调用都会产生开销,例如将变量压入堆栈。某些函数也可能会在返回时产生开销。效率低下的函数或方法在其内容中的语句少于组合开销。无论是作为#define 宏还是inline 函数,这些都是内联的好候选。 (是的,我知道inline 只是一个建议,但在这种情况下,我认为它是对编译器的提醒。)

      删除死代码和冗余代码

      如果代码没有被使用或对程序的结果没有贡献,请删除它。

      简化算法设计

      我曾经写下程序计算的代数方程,然后简化代数表达式,从而从程序中删除了大量汇编代码和执行时间。简化代数表达式的实现比原函数占用更少的空间和时间。

      循环展开

      每个循环都有递增和终止检查的开销。要估计性能因素,请计算开销中的指令数(最少 3 个:递增、检查、转到循环开始)并除以循环内的语句数。数字越低越好。

      编辑:提供循环展开的示例 之前:

      unsigned int sum = 0;
      for (size_t i; i < BYTES_TO_CHECKSUM; ++i)
      {
          sum += *buffer++;
      }
      

      展开后:

      unsigned int sum = 0;
      size_t i = 0;
      **const size_t STATEMENTS_PER_LOOP = 8;**
      for (i = 0; i < BYTES_TO_CHECKSUM; **i = i / STATEMENTS_PER_LOOP**)
      {
          sum += *buffer++; // 1
          sum += *buffer++; // 2
          sum += *buffer++; // 3
          sum += *buffer++; // 4
          sum += *buffer++; // 5
          sum += *buffer++; // 6
          sum += *buffer++; // 7
          sum += *buffer++; // 8
      }
      // Handle the remainder:
      for (; i < BYTES_TO_CHECKSUM; ++i)
      {
          sum += *buffer++;
      }
      

      在这个优势中,获得了第二个好处:在处理器必须重新加载指令缓存之前执行更多语句。

      当我将一个循环展开为 32 条语句时,我得到了惊人的结果。这是瓶颈之一,因为程序必须计算 2GB 文件的校验和。这种优化与块读取相结合,将性能从 1 小时提高到 5 分钟。循环展开在汇编语言中也提供了出色的性能,我的memcpy 比编译器的memcpy 快很多。 -- T.M.

      减少if 语句

      处理器讨厌分支或跳转,因为它会强制处理器重新加载其指令队列。

      布尔算术(编辑:将代码格式应用于代码片段,添加示例)

      if 语句转换为布尔赋值。一些处理器可以有条件地执行指令而无需分支:

      bool status = true;
      status = status && /* first test */;
      status = status && /* second test */;
      

      如果statusfalse逻辑与 运算符(&amp;&amp;) 的短路 会阻止执行测试。

      例子:

      struct Reader_Interface
      {
        virtual bool  write(unsigned int value) = 0;
      };
      
      struct Rectangle
      {
        unsigned int origin_x;
        unsigned int origin_y;
        unsigned int height;
        unsigned int width;
      
        bool  write(Reader_Interface * p_reader)
        {
          bool status = false;
          if (p_reader)
          {
             status = p_reader->write(origin_x);
             status = status && p_reader->write(origin_y);
             status = status && p_reader->write(height);
             status = status && p_reader->write(width);
          }
          return status;
      };
      

      循环外的因子变量分配

      如果在循环中动态创建变量,请将创建/分配移至循环之前。在大多数情况下,不需要在每次迭代期间分配变量。

      循环外的因子常量表达式

      如果计算或变量值不依赖于循环索引,请将其移到循环之外(之前)。

      块中的 I/O

      以大块(块)读取和写入数据。越大越好。例如,一次读取一个 八位字节 的效率低于一次读取 1024 个八位字节的效率。
      示例:

      static const char  Menu_Text[] = "\n"
          "1) Print\n"
          "2) Insert new customer\n"
          "3) Destroy\n"
          "4) Launch Nasal Demons\n"
          "Enter selection:  ";
      static const size_t Menu_Text_Length = sizeof(Menu_Text) - sizeof('\0');
      //...
      std::cout.write(Menu_Text, Menu_Text_Length);
      

      可以直观地展示这种技术的效率。 :-)

      不要将printf family用于常量数据

      可以使用块写入输出恒定数据。格式化写入将浪费时间扫描文本以格式化字符或处理格式化命令。参见上面的代码示例。

      格式化到内存,然后写入

      使用多个sprintf 格式化为char 数组,然后使用fwrite。这也允许将数据布局分解为“恒定部分”和可变部分。想想邮件合并

      将常量文本(字符串文字)声明为static const

      在没有static 的情况下声明变量时,一些编译器可能会在堆栈上分配空间并从ROM 中复制数据。这是两个不必要的操作。这可以通过使用static 前缀来解决。

      最后,像编译器这样的代码

      有时,编译器可以优化几个小语句,而不是一个复杂的版本。此外,编写代码来帮助编译器优化也有帮助。如果我希望编译器使用特殊的块传输指令,我将编写看起来应该使用特殊指令的代码。

      【讨论】:

      • 有趣的是,您能否提供一个示例,用一些小语句而不是更大的语句获得更好的代码。您能否展示一个使用布尔值重写 if 的示例。通常,我会将循环展开到编译器,因为它可能对缓存大小有更好的感觉。我对 sprintfing 然后 fwriting 的想法有点惊讶。我认为 fprintf 实际上是在幕后做到这一点的。你能在这里提供更多细节吗?
      • 无法保证fprintf 格式化为单独的缓冲区,然后输出缓冲区。流线型(用于内存使用)fprintf 将输出所有未格式化的文本,然后格式化和输出,并重复直到处理整个格式字符串,从而为每种类型的输出(格式化与未格式化)进行 1 次输出调用。其他实现将需要为每个调用动态分配内存以保存整个新字符串(这在嵌入式系统环境中很糟糕)。我的建议是减少输出的数量。
      • 我曾经通过滚动循环获得了显着的性能提升。然后我想出了如何通过使用一些间接来更紧密地滚动它,并且程序变得明显更快。 (分析显示这个特定函数占运行时的 60-80%,我在前后仔细测试了性能。)我相信改进是由于更好的局部性,但我对此并不完全确定。
      • 其中许多是程序员的优化,而不是程序员帮助编译器进行优化的方式,这是最初问题的主旨。例如,循环展开。是的,您可以自己展开,但我认为弄清楚编译器为您展开并消除这些障碍会更有趣。
      【解决方案17】:

      对于嵌入式系统和用 C/C++ 编写的代码,我尽量避免动态内存分配。我这样做的主要原因不一定是性能,但这个经验法则确实对性能有影响。

      用于管理堆的算法在某些平台(例如 vxworks)中速度非常慢。更糟糕的是,从调用 malloc 返回所需的时间高度依赖于堆的当前状态。因此,任何调用 malloc 的函数都会受到无法轻易解释的性能影响。如果堆仍然是干净的,那么对性能的影响可能很小,但是在该设备运行一段时间后,堆可能会变得碎片化。调用将花费更长的时间,您无法轻松计算性能将如何随着时间的推移而下降。你不能真正产生更坏的情况估计。在这种情况下,优化器也无法为您提供任何帮助。更糟糕的是,如果堆变得过于碎片化,调用将开始完全失败。解决方案是使用内存池(例如,glib slices)而不是堆。如果操作正确,分配调用将更快且具有确定性。

      【讨论】:

      • 我的经验法则是,如果您必须动态分配,请获取一个数组,这样您就不需要再做一次了。预先分配它们的向量。
      【解决方案18】:

      你在这里得到了很好的答案,但他们认为你的程序一开始就非常接近最优,你说

      假设程序已经 编写正确,编译完整 优化、测试并投入使用 生产。

      根据我的经验,一个程序可能被正确编写,但这并不意味着它接近于最佳状态。达到这一点需要额外的工作。

      如果我可以举个例子,this answer 展示了如何通过宏优化 将一个看起来非常合理的程序提高了 40 倍以上。在最初编写的每个程序中无法实现大幅加速,但根据我的经验,在许多(非常小的程序除外)中可以。

      完成之后,微优化(热点的)可以给你带来不错的回报。

      【讨论】:

        【解决方案19】:

        我使用英特尔编译器。在 Windows 和 Linux 上。

        当或多或少完成时,我会分析代码。然后挂在热点上并尝试更改代码以使编译器做得更好。

        如果代码是计算代码并且包含很多循环 - 英特尔编译器中的矢量化报告非常有用 - 在帮助中查找“vec-report”。

        所以主要思想 - 完善性能关键代码。至于其余的 - 优先正确和可维护 - 简短的功能,清晰的代码,可以在 1 年后理解。

        【讨论】:

        • 您已经接近回答这个问题了.....您对代码做了哪些处理,以使编译器能够进行这些优化?
        • 尝试以 C 风格(与 C++ 相比)编写更多内容,例如避免没有绝对需要的虚函数,特别是如果它们经常被调用,避免 AddRefs.. 和所有很酷的东西(再次除非它真的需要)。编写易于内联的代码 - 更少的参数,更少的“if”-s。除非绝对需要,否则不要使用全局变量。在数据结构中 - 首先放置更宽的字段(双精度,int64 在 int 之前) - 因此编译器将结构对齐在第一个字段自然大小上 - 对齐有利于性能。
        • 数据布局和访问对于性能来说绝对是至关重要的。所以在分析之后 - 我有时会根据访问的位置将一个结构分成几个结构。一个更通用的技巧 - 使用 int 或 size-t 与 char - 即使数据值很小 - 避免各种性能。惩罚存储加载阻塞,部分寄存器停顿的问题。当然,这不适用于需要大量此类数据的情况。
        • 还有一个 - 避免系统调用,除非有真正的需要:) - 它们非常昂贵
        • @jf:我对你的回答做了+1,但请你把答案从 cmets 移到回答 body 吗?它会更容易阅读。
        【解决方案20】:

        我在 80 年代的 cobol 中模糊记得的一件事是,有链接器选项允许您影响函数链接在一起的顺序。这允许您(可能)增加代码局部性。

        沿着同样的想法。如果想知道是否可以通过使用该模式来实现可能的优化

        for (some silly loop)
        if (something)
            if (somthing else)
                if (somthing else)
                    if (somthing else)
                        /* This is the normal expected case */ 
                    else error 4
                else error 3
            else error 2
        else error 1
        

        for 头和 ifs 可能适合缓存块, 这在理论上可以导致更快的循环执行。

        我猜其他类似的可以在某种程度上进行优化。

        评论? 我在做梦吗?

        【讨论】:

          【解决方案21】:

          我在 C++ 中使用的一个优化是创建一个什么都不做的构造函数。必须手动调用 init() 才能使对象进入工作状态。

          这在我需要这些类的大向量的情况下很有用。

          我调用reserve() 为向量分配空间,但构造函数实际上并没有触及对象所在的内存页。所以我花了一些地址空间,但实际上并没有消耗很多物理内存。我避免了与相关构建成本相关的页面错误。

          当我生成对象来填充向量时,我使用 init() 设置它们。这限制了我的总页面错误,并避免了在填充向量时需要 resize() 。

          【讨论】:

          • 我相信 std::vector 的典型实现在您 reserve() 更多容量时实际上并没有构造更多对象。它只是分配页面。当您实际将对象添加到向量中时,稍后会使用placement new 调用构造函数——这(可能)就在您调用init() 之前,因此您实际上并不需要单独的init() 函数。还要记住,即使你的构造函数在源代码中是“空的”,编译后的构造函数也可能包含初始化虚拟表和 RTTI 之类的代码,所以无论如何页面都会在构造时被触及。
          • 是的。在我们的例子中,我们使用 push_back 来填充向量。对象没有任何虚函数,所以这不是问题。我们第一次尝试使用构造函数时,对页面错误的数量感到震惊。我意识到发生了什么,我们猛拉构造函数的胆量,页面错误问题消失了。
          • 这让我很惊讶。您使用了哪些 C++ 和 STL 实现?
          • 我同意其他人的观点,这听起来像是 std::vector 的糟糕实现。即使您的对象确实有 vtable,它们也不会在您的 push_back 之前构建。您应该能够通过将默认构造函数声明为私有来对此进行测试,因为所有 vector 需要的是 push_back 的复制构造函数。
          • @David - 在 AIX 上实现。
          【解决方案22】:

          当 DEC 推出其 alpha 处理器时,建议将函数的参数数量保持在 7 个以下,因为编译器总是会尝试自动将最多 6 个参数放入寄存器中。

          【讨论】:

          • x86-64 位还允许大量寄存器传递参数,这会对函数调用开销产生显着影响。
          【解决方案23】:

          上面列表中我没有看到的两种编码技术:

          通过将代码编写为唯一源来绕过链接器

          虽然单独编译确实可以节省编译时间,但当您谈到优化时,它就非常糟糕了。基本上编译器无法优化超出编译单元,即链接器保留域。

          但是如果你的程序设计得很好,你也可以通过一个独特的公共源来编译它。这不是编译 unit1.c 和 unit2.c,而是链接两个对象,编译仅 #include unit1.c 和 unit2.c 的 all.c。因此,您将从所有编译器优化中受益。

          这很像在 C++ 中只编写标头程序(在 C 中更容易编写)。

          如果您编写程序以从一开始就启用它,则该技术很容易,但您还必须注意它会改变 C 语义的一部分,并且您可能会遇到一些问题,例如静态变量或宏冲突。对于大多数程序来说,克服发生的小问题很容易。另请注意,作为唯一源进行编译的速度要慢得多,并且可能会占用大量内存(通常在现代系统中不是问题)。

          使用这个简单的技术,我碰巧让我编写的一些程序快了十倍!

          就像 register 关键字一样,这个技巧也可能很快就会过时。编译器开始支持通过链接器进行优化gcc: Link time optimization

          在循环中分离原子任务

          这个比较棘手。它是关于算法设计与优化器管理缓存和寄存器分配的方式之间的交互。很多时候,程序必须循环一些数据结构,并为每个项目执行一些操作。执行的操作通常可以拆分为两个逻辑上独立的任务。如果是这种情况,您可以编写完全相同的程序,在同一边界上使用两个循环来执行一项任务。在某些情况下,以这种方式编写它可能比唯一循环更快(细节更复杂,但可以解释为,对于简单的任务情况,所有变量都可以保存在处理器寄存器中,而对于更复杂的情况,这是不可能的,有些寄存器必须写入内存,稍后再读回,成本高于额外的流控制)。

          小心这个(使用或不使用此技巧的配置文件性能)就像使用寄存器一样,它可能会提供比改进的性能更低的性能。

          【讨论】:

          • 是的,到目前为止,LTO 已经使这篇文章的前半部分变得多余,而且可能是不好的建议。
          • @underscore_d:仍然存在一些问题(主要与导出符号的可见性有关),但仅从性能的角度来看,可能已经没有问题了。
          【解决方案24】:

          尽量使用静态单一赋值进行编程。 SSA 与您在大多数函数式编程语言中最终得到的完全一样,这也是大多数编译器将您的代码转换为进行优化的原因,因为它更易于使用。通过这样做,编译器可能会感到困惑的地方被暴露出来。它还使除了最差的寄存器分配器之外的所有寄存器分配器都像最好的寄存器分配器一样工作,并允许您更轻松地调试,因为您几乎不必怀疑变量从哪里获得它的值,因为它只有一个被分配的位置。 避免使用全局变量。

          当通过引用或指针处理数据时,将其拉入局部变量,完成您的工作,然后将其复制回来。 (除非你有充分的理由不这样做)

          利用大多数处理器在进行数学或逻辑运算时提供的与 0 的几乎免费比较。您几乎总是会得到 ==0 和

          x= f();
          if(!x){
             a();
          } else if (x<0){
             b();
          } else {
             c();
          }
          

          几乎总是比测试其他常数便宜。

          另一个技巧是使用减法来消除范围测试中的比较。

          #define FOO_MIN 8
          #define FOO_MAX 199
          int good_foo(int foo) {
              unsigned int bar = foo-FOO_MIN;
              int rc = ((FOO_MAX-FOO_MIN) < bar) ? 1 : 0;
              return rc;
          } 
          

          这通常可以避免在布尔表达式上进行短路的语言中的跳转,并避免编译器不得不尝试弄清楚如何处理保持 与第一次比较的结果同时进行第二次比较,然后将它们组合起来。 这看起来可能会用完一个额外的寄存器,但它几乎从来没有。通常你不再需要 foo 了,如果你这样做了 rc 还没有被使用,所以它可以去那里。

          当在 c (strcpy, memcpy, ...) 中使用字符串函数时,请记住它们返回的内容——目的地!您通常可以通过“忘记”指向目标的指针副本并从这些函数的返回中获取它来获得更好的代码。

          永远不要忽视返回与您调用的最后一个函数返回的完全相同的东西的机会。编译器不太擅长处理这一点:

          foo_t * make_foo(int a, int b, int c) {
                  foo_t * x = malloc(sizeof(foo));
                  if (!x) {
                       // return NULL;
                       return x; // x is NULL, already in the register used for returns, so duh
                  }
                  x->a= a;
                  x->b = b;
                  x->c = c;
                  return x;
          }
          

          当然,如果且只有一个返回点,你可以颠倒逻辑。

          (我后来回忆的技巧)

          尽可能将函数声明为静态总是一个好主意。如果编译器可以向自己证明它已经解释了特定函数的每个调用者,那么它可以以优化的名义打破该函数的调用约定。编译器通常可以避免将参数移动到被调用函数通常期望其参数所在的寄存器或堆栈位置(它必须在被调用函数和所有调用者的位置中都存在偏差才能做到这一点)。编译器通常还可以利用知道被调用函数需要哪些内存和寄存器,并避免生成代码来保留被调用函数不会干扰的寄存器或内存位置中的变量值。当对函数的调用很少时,这特别有效。这获得了内联代码的大部分好处,但实际上并没有内联。

          【讨论】:

          • 在测试范围时实际上没有必要使用减法,LLVM、GCC 和我的编译器至少会自动执行此操作。可能很少有人会理解减法代码的作用,甚至更少人会理解它为什么起作用。
          • 在上面的例子中,b() 不能被调用,因为如果 (x
          • @EvilTeach 不,不会的。导致调用 a() 的比较结果是 !x
          • @nategoose。如果 x 为 -3,则 !x 为真。
          • @EvilTeach In C 0 为假,其他一切为真,所以 -3 为真,所以 !-3 为假
          【解决方案25】:

          我做过的一件事是尝试将代价高昂的操作保留在用户可能期望程序会延迟一点的地方。整体性能与响应能力有关,但并不完全相同,而且对于许多事情而言,响应能力是性能中更重要的部分。

          上一次我真的必须对整体性能进行改进时,我一直关注次优算法,并寻找可能存在缓存问题的地方。我首先分析和测量性能,并在每次更改后再次测量。然后公司倒闭了,但无论如何,这是一项有趣且有启发性的工作。

          【讨论】:

            【解决方案26】:
            【解决方案27】:

            不要一遍又一遍地做同样的工作!

            我看到的一个常见的反模式是这样的:

            void Function()
            {
               MySingleton::GetInstance()->GetAggregatedObject()->DoSomething();
               MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingElse();
               MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingCool();
               MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingReallyNeat();
               MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingYetAgain();
            }
            

            编译器实际上必须一直调用所有这些函数。假设你,程序员,知道聚合对象在这些调用的过程中没有改变,因为对所有神圣事物的热爱......

            void Function()
            {
               MySingleton* s = MySingleton::GetInstance();
               AggregatedObject* ao = s->GetAggregatedObject();
               ao->DoSomething();
               ao->DoSomethingElse();
               ao->DoSomethingCool();
               ao->DoSomethingReallyNeat();
               ao->DoSomethingYetAgain();
            }
            

            在单例 getter 的情况下,调用可能不会太昂贵,但这肯定是一个成本(通常,“检查对象是否已创建,如果没有,则创建它,然后返回它). 这个 getter 链越复杂,我们浪费的时间就越多。

            【讨论】:

              【解决方案28】:

              我想起了我曾经遇到过的事情,症状只是内存不足,但结果是性能大幅提高(以及内存占用量大幅减少)。

              在这种情况下,问题在于我们使用的软件进行了大量的小分配。比如,在这里分配 4 个字节,那里分配 6 个字节,等等。很多小对象也在 8-12 字节范围内运行。问题不在于程序需要很多小东西,而在于它单独分配了很多小东西,这使得每个分配(在这个特定平台上)膨胀到 32 个字节。

              解决方案的一部分是将 Alexandrescu 风格的小对象池放在一起,但扩展它以便我可以分配小对象数组以及单个项目。这也极大地提高了性能,因为任何时候都有更多的项目可以放入缓存中。

              解决方案的另一部分是将大量使用手动管理的 char* 成员替换为 SSO(小字符串优化)字符串。最小分配为 32 字节,我构建了一个字符串类,它在 char* 后面嵌入了 28 个字符的缓冲区,因此我们 95% 的字符串不需要进行额外分配(然后我手动替换了几乎所有出现的char* 在这个库中使用这个新类,这很有趣)。这也极大地帮助了内存碎片,然后增加了其他指向对象的引用局部性,同样也提高了性能。

              【讨论】:

                【解决方案29】:

                一个愚蠢的小技巧,但可以为您节省一些微量的速度和代码。

                始终以相同的顺序传递函数参数。

                如果您有调用 f_2 的 f_1(x, y, z),请将 f_2 声明为 f_2(x, y, z)。不要将其声明为 f_2(x, z, y)。

                这样做的原因是 C/C++ 平台 ABI(AKA 调用约定)承诺在特定的寄存器和堆栈位置传递参数。当参数已经在正确的寄存器中时,它不必移动它们。

                在阅读反汇编代码时,我看到了一些荒谬的寄存器洗牌,因为人们没有遵守这条规则。

                【讨论】:

                • C 和 C++ 都没有对传递特定寄存器或堆栈位置做出任何保证,甚至没有提及。 ABI(例如 Linux ELF)决定了参数传递的细节。
                【解决方案30】:

                我从@MSalters 对this answer 的评论中学到的一项巧妙的技术允许编译器在根据某些条件返回不同对象时进行复制省略:

                // before
                BigObject a, b;
                if(condition)
                  return a;
                else
                  return b;
                
                // after
                BigObject a, b;
                if(condition)
                  swap(a,b);
                return a;
                

                【讨论】:

                  猜你喜欢
                  • 1970-01-01
                  • 2012-07-10
                  • 1970-01-01
                  • 1970-01-01
                  • 1970-01-01
                  • 2016-02-14
                  • 2013-05-24
                  • 2012-04-07
                  相关资源
                  最近更新 更多