在这一系列文章的第一篇中,我们探讨了一些优化技术,例如分支预测、缓存优化和SIMD向量化。这些基础技术帮助您的C++应用程序充分利用现代CPU架构的强大功能。在这里,我们将深入探讨更多强大的策略,进一步实现更精细的优化。接下来,我们将讨论以下内容:
- 循环展开和向量化
- 函数内联和指令缓存的影响
- 返回值优化(RVO 和 NRVO)
- 链接时间优化(LTO)和全程序优化
- 内存对齐和填充
- 数据导向设计与面向对象设计的性能差异
这些技术与之前介绍的技术结合,将有助于打造能够充分发挥硬件潜力、减少延迟时间以及提高吞吐量的高性能 C++ 应用程序。
循环展开与向量计算循环展开是一种经典的优化技术,其中循环体重复多次,减少了索引递增和循环分支/跳转等开销,并且有时还能触发其他优化。它常常与向量处理和指令级并行性配合使用。
什么是循环展开呢?通过一个例子来说明会更简单,假设我们有一个简单的循环,比如:
// 这是一个简单的循环,用于计算数组元素的总和
for (int i = 0; i < N; ++i) {
sum += arr[i];
}
一个展开后的版本(展开因子为4)会是这样的,比如:
// 将循环展开(因子为4)
int i = 0;
for (; i + 3 < N; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}
for (; i < N; ++i) { // 处理剩余部分
sum += arr[i];
}
现在循环开销(检查和递增操作)每4个元素才发生一次,而不是每个元素都发生。这可以减少分支指令的数量并提高吞吐量。此外,循环展开可能为CPU流水线指令提供更多机会,或使编译器更合理地安排指令(掩盖延迟)。
福利
- 更少的分支指令(每次检查循环计数器并跳转)意味着需要处理的分支预测更少,流水线中断的几率更小。
- 更多的连续代码在内循环中,可以由CPU的指令调度器更好地优化。这还有助于自动向量化,因为编译器可以更轻松地确定如何利用SIMD。
- 与其他优化技术结合的可能性:例如,软件流水线或展开并插入(展开外部循环并将内部循环的操作交错)。
不足:
- 代码体积增大: 展开后的循环可能导致指令缓存膨胀,甚至无法有效存放整个循环,影响CPU的循环缓冲区或微操作缓存。这会因更多的指令缓存未命中而降低性能。如果N不是展开因子的整数倍,需要额外处理剩余迭代,增加一些复杂性。
- 收益递减:超出某个点后,继续展开循环不仅无益,甚至可能有害。通常,展开2倍或4倍已经足够。编译器在较高优化级别时会自动展开小循环,因此手动展开可能多余。
何时使用它: 循环展开在循环开销占工作量显著部分的紧密循环中很有用。它通常用于低级库代码(如向量数学、图像处理例程)中,其中每个周期都至关重要。如果你正在使用SIMD内置函数,你可能会展开循环以每次迭代处理更多的数据(如前面的AVX示例中,我们每次循环处理8个元素而不是1个)。然而,始终进行测量 — 现代CPU在预测循环分支和重叠指令执行方面相当好,因此简单的循环展开可能并不总是有效。而且记住编译器可能会自动为你完成。如果你选择手动展开循环,请确保展开因子合理,并在目标硬件上进行测试。与向量化结合使用 — 循环展开与向量化相辅相成。你可能会按SIMD宽度或其倍数的因子展开循环。例如,如果使用256位向量(8个浮点数),你可以将其展开两次,每次迭代处理16个浮点数(相当于两个向量操作)。这可以充分利用执行单元,前提是CPU能够并行执行多个向量操作。
总之,循环展开可以减少循环开销并能提升指令级并行性,但要注意代码大小和缓存的权衡。在循环中每次迭代的工作量很小(因此循环控制开销较大)时使用它,并测试以找到展开因子的最佳值。通常情况下,信任编译器在 -O3
优化级别上的判断,但在关键情况下,如果你对上下文更了解,准备好覆盖编译器的决策。
函数内联和指令缓存的影响
频繁的函数调用会增加开销——不仅包括调用和返回指令,如果函数代码在内存中相距较远,还可能导致指令缓存未命中。内联是一种优化方法,其中编译器将函数调用替换为函数体(就像你直接在调用处编写了函数体一样)。这可以节省调用开销,并且还可以跨函数边界进行进一步优化。
内联的好处:
- 消除调用开销: 一个正常的函数调用涉及跳转到另一个位置(这可能会扰乱指令的执行流程),压入返回地址等。内联避免了这些操作。对于非常小的函数(比如获取器、设置器、简单的算术操作),这可以节省几个周期并提高性能。
- 启用进一步优化: 一旦内联,函数的代码就成了调用者的代码的一部分,编译器可以对其进行更多的优化。它可以常量传播、删除死代码等,这些操作以前横跨调用边界。内联也可以帮助自动向量化(auto-vectorization)或展开循环内的函数调用。
- 更小的代码(在某些情况下): 如果一个函数只被调用一次,内联它可以消除单独保存该函数代码的需要,并减少调用设置,从而使程序更小(然而,当一个函数被多次调用时,情况通常相反(见下文))。
内联的缺点:
- 代码膨胀问题: 内联会在每个调用点复制函数的代码。如果你内联了一个被10个地方调用的中等大小的函数,你现在就有10份相同的代码。这可能对指令缓存产生负面影响,并增加二进制文件的大小。CPU的指令缓存和解码管道容量有限,如果热点代码放不下,速度会变慢,从而影响性能。因此,内联是一个权衡:避免调用带来的速度提升与代码体积变大可能带来的潜在变慢之间的权衡。
- 如果编译器认为内联的代价大于收益,它可能会拒绝内联一个函数(或函数是递归的、虚函数等)。C++中的
inline
关键字更多是关于链接而非强制内联,尽管编译器会将其视为提示信息。如果你真的需要强制内联,可以使用[[gnu::always_inline]]
(GCC/Clang)或__forceinline
(MSVC),但这通常只在关键的底层代码中才需要使用。
小贴士:
- 经常调用的小型函数,特别是在循环中频繁使用。合适的例子包括简单的getter/setter或小的数学函数。现代C++标准库广泛使用内联函数,例如
std::vector::size()
(通常只是返回一个成员数据)。 - 对大型或复杂的逻辑函数要谨慎使用内联,因为它可能会增加代码大小。测量其影响——有时保留函数调用(因为CPU的返回预测器可能预测良好)也是可以接受的。
- 请记住,LTO(链接时间优化技术),稍后会讨论,可以在不同文件间内联函数。因此,你不需要将所有内容都放在头文件中作为
inline
函数来实现跨模块内联——如果启用了LTO,它也可以内联非头文件中的函数,从而实现跨模块内联。
指令缓存: 指令缓存(L1i)在许多CPU上通常约为32KB(英特尔解码器中也有微操作缓存)。该缓存保存了你的程序正在执行的指令。如果你有许多代码集中在热路径中(例如由于内联和展开),你可能会溢出缓存并造成指令获取延迟。这是一个微妙的问题:更多的内联意味着更少的函数调用,但也会增加代码的字节数。作为专家,你应该注意这种平衡。有时,少即是多:不内联某些函数可能有助于使紧循环更小,从而更好地适应微操作缓存或L1i。这高度依赖于具体的微架构,因此如果你怀疑存在指令缓存问题,可以使用性能分析工具以及处理器性能计数器(例如ITLB未命中或L1i未命中)来定位问题。
返回值优化技术(RVO (即返回值优化) 和 NRVO (即带名返回值优化))C++ 通常因返回大对象时复制而受到批评,但现代 C++ 编译器具有优化功能,可以消除这些复制。返回值优化 (RVO) 和 命名返回值优化 (NRVO) 指的是编译器可以直接在调用者的空间中构造返回值,从而避免任何复制或移动。
比如说:
定义一个名为Big的结构体,其中包含一个包含1000个整数的数组。
Big makeBig() {
Big b;
// ... 给 b 赋值 ...
return b; // NRVO 可以在这里避免复制
}
在上面的代码中,可能会误以为b
被复制给调用者。然而,编译器通常会执行非命名返回值优化(NRVO):它们会直接在函数返回值应该存放的位置构造b
。因此,实际上并没有发生复制。同样地,如果你return Big{};
(返回一个未命名的临时对象),这就是典型的返回值优化(RVO)情况,临时对象会在调用位置直接生成。
C++17要求 在某些特定情况下,编译器必须省略拷贝。具体来说,当返回值是与函数返回类型相同类型的prvalue(纯右值)时,编译器必须省略拷贝并在原地构造对象。而对于命名返回值优化(NRVO),标准并未强制执行,但编译器通常会在可能的情况下执行这种优化。
为什么这很重要: 如果你在编写返回大型对象的代码(这是一种很好的、安全的风格,得益于移动语义特性和RVO),在这种情况下,编译器能够优化它。幸运的是,大多数现代编译器通常会自动优化这种情况。例如,
Big f() {
return Big(); // 在 C++17 中,这里保证了 RVO,完全不进行拷贝
}
Big g() {
Big x;
// ...
if (condition) {
return x; // NRVO(虽然可选,但大多数编译器都会这么做)
} else {
return Big();
}
}
在函数 g
中,如果有多个返回路径的话(编译器可能无法证明一种构造适用于所有情况),NRVO 可能无法应用。但是,返回一个临时的 Big()
会经历 RVO 优化。
编写代码的启示:
- 如果这样做可以让接口更干净,你可以自由地通过值返回大型对象。在过去(C++11之前),人们为了避免拷贝带来的开销而避免通过值返回对象,但在现代编译器中,这些拷贝通常会被省略或转换为移动。这通常比使用输出参数更加优雅和直接。
- 不过,要小心别无意中破坏了RVO(返回值优化)。例如,如果代码中包含:
Big makeBig(布尔 flag) {
Big a, b;
如果(flag) 返回 a;
否则 返回 b;
}
编译器在这里无法应用NRVO(即无复制值优化),因为可能会返回两个不同的局部实例。它可能会执行移动(C++11及其后续版本),这比复制更经济。但如果Big
不可移动,它可能会复制。如果这种情况很重要,你可以重构代码(例如,使用单个对象并在满足条件时设置它,然后返回该对象)。
- 命名 RVO 和未命名 RVO: 它们只是不同的形式,但作为开发人员,你通常只需依赖编译器。标准确保返回临时对象是安全的。对于命名 RVO,大多数编译器(如 GCC、Clang、MSVC)在开启优化时会执行这种优化。
总之,RVO/NRVO(返回值优化/非局部返回值优化)使得返回大型对象变得高效。 最佳做法是设计函数时自然地通过值返回对象,并相信编译器。如果你好奇或谨慎,可以通过在拷贝或移动构造函数中添加日志输出来测试你特定的编译器,看看它们是否被调用。在优化构建中,你通常会发现它们根本没有被调用,这证明优化有效。这意味着更简洁的C++代码,同时不牺牲性能。
链接时间优化(LTO)<sup>注:首次提及的LTO是指链接时间优化。</sup>和整个程序的编译优化默认情况下,每个 C++ 源文件都是单独编译的,然后链接器将对象文件拼接在一起。链接时间优化 (LTO) 是一种方式,在这种方式下,编译器的优化器在所有文件编译完毕后运行,能够纵观整个程序。这使得整个程序的优化成为可能,而在逐个编译文件时这些优化是无法实现的。
LTO能做些什么
- 跨模块内联优化: 如果你在某个
.cpp
文件中定义了一个函数,并在另一个文件中调用它,通常编译器无法对其进行内联处理,因为它仅能看到该函数的声明。使用 LTO,编译器可以在链接阶段看到函数体,并在有益的情况下进行内联处理(即使你没有标记为内联)。 - 跨文件的函数间优化: 例如,常量传播可以跨越跨文件的函数调用,移除未使用的函数(全局死代码消除),当整个程序已知时,优化虚调用(如果可以确定某个类没有被子类化,则可以进行去虚化优化)。
- 更佳的优化决策: 有时,由于缺乏全局程序信息,编译器会相对保守。例如,它可能未能识别出某个函数只有一个调用者,因而不会对其进行内联或完全优化。使用 LTO,编译器会知道情况并进行相应的优化(就像将所有内容静态链接成一个整体一样)。
使用 LTO 通常就像设置一个编译器或链接器标志那么简单(例如,对于 GCC 或 Clang 使用 -flto
,而对于 MSVC 使用 /GL
和 /LTCG
)。这会让构建过程更慢并占用更多内存,因为优化器在链接阶段做了更多的工作。但运行时性能会提升。
实际影响: LTO 通常能带来几个百分点的性能提升,但如果你的代码中有大量的跨模块交互,提升可能会更大。在关键系统中,这几个百分点的提升可能就很有价值了。一些现代应用程序和游戏在发布构建中启用 LTO 以榨取更多的性能。它在大小优化上也有帮助(可能移除冗余代码)。
关于LTO的最佳实践:
- 确保您的构建流水线支持该功能(所有对象都必须使用LTO编译,并且链接器需要支持它)。
- 仅用于发行版构建,不一定适用于每个调试构建(以节省编译时间)。
- 如果您想进一步优化性能,可以考虑将LTO与性能剖析引导优化(PGO)结合使用。PGO利用运行时的性能数据来指导优化(如分支概率、热/冷函数),与LTO结合后,优化器可以全面了解程序,从而更好地重组代码并优化热路径。
整个程序优化超越了LTO: 在某些情况下,整个程序分析可以实现一些巧妙的事情,比如对整个程序进行去虚拟化(消除程序中的动态绑定),激进的内联,或者识别某些检查总是为真或假并移除它们。这些虽然小众,但说明了更多的全局知识可以带来更好的优化机会。
需要注意的一点是,LTO 有时会揭示之前不明显的代码中的错误或 ODR 违规。因为内联和优化可能导致不同的行为或发现不一致。确保彻底的测试。
总之,LTO 是一个强大的工具,可以让编译器优化整个 C++ 程序,而不是针对每个文件。它通常通过启用跨模块内联和分析来提升性能。如果你追求最佳性能,它在生产构建中绝对值得一试。
内存对齐及填充内存对齐会直接影响低级别的性能表现。对齐意味着数据对象地址为某2的幂次方的倍数,通常是该对象的大小或总线传输的尺寸。CPU通常以缓存行为单位获取对齐的内存,并且某些指令需要或偏好对齐的操作数(例如,SSE加载曾要求16字节对齐,AVX则需要32字节对齐)。此外,对齐有助于避免跨越缓存行边界,并且通过对齐到缓存行大小,可以避免假共享。
这里的关键点是:
- 自然对齐方式: 默认情况下,编译器会将数据结构对其为成员所在地址对类型最优化的位置(例如,
int
类型默认对齐为 4 字节,double
类型在 64 位系统上默认对齐为 8 字节)。编译器还会在结构体中插入填充字节,以满足每个成员的对齐需求。例如,一个包含int
和double
类型成员的结构体。
结构体 Foo {
字符 c;
整型 x;
}
在这里,x
通常会被调整至 4 字节的边界,因此在 c
之后会有 3 字节的填充空间。结构体 Foo
可能会是 8 字节的大小:1 个字节用于 c
,3 字节的填充,4 个字节用于 x
。这种填充可以确保如果有一个 Foo
数组,每个 x
都会正确对齐。编译器这样做是为了保证正确性和提高性能。
- 缓存行对齐: 有时候你希望将整个结构体或全局变量对齐到64字节的边界(假设缓存行为64B),以优化访问或避免假共享问题。我们看到使用
alignas(64)
对结构体或成员进行对齐就可以做到这一点。这在你希望频繁访问的数据独占一个缓存行时非常有用。 - SIMD对齐: 如果你计划使用对齐的SIMD指令(如
_mm256_load_ps
),你需要将数据对齐到向量宽度(例如AVX为32字节)。你可以使用C++17的std::aligned_alloc
或第三方分配器来分配动态内存,或超额分配内存并调整指针。或者,使用允许指定对齐方式的容器类型。对于栈或全局数据,你可以使用alignas(32)
对数组进行对齐。例如:
alignas(32) float vec[ EightOrMore ];
现在 vec
将从一个 32 字节的边界开始,因此 _mm256_load_ps(vec)
变得安全了。如果错误地进行了未对齐的访问,可能会带来轻微的性能损失,甚至在老版本的 SSE 中可能导致程序崩溃,所以在进行低级指令时这一点非常重要。
- 为了使结构体大小成为64字节的倍数(缓存行)而添加填充: 有时你可能在结构体末尾添加填充,以确保结构体的大小是64字节的倍数,这样做的目的是为了当你需要此类结构体的数组时,使每个结构体从新的缓存行开始,以避免两个元素共享同一个缓存行。例如:
struct CacheLineSlot {
int value;
char _pad[60]; // 填充结构体以使其大小为64字节
};
静态断言(sizeof(CacheLineSlot) == 64);
现在一个 CacheLineSlot arr[100]
数组中的每个元素将恰好占用一条缓存线。
- 数据对齐和性能: 未正确对齐的数据有时会使访问变慢,因为CPU可能需要进行两次内存访问而不是一次(如果数据跨越了边界)。例如,一个8字节的
double
如果未对齐到8字节边界,可能会跨越缓存行边界,从而需要访问两个缓存行。确保对齐可以避免这样的性能损失。
通常,编译器会帮你解决大部分的对齐问题,但作为专家,你拥有一些工具(如 alignas
,特殊分配器)来对齐关键数据。当误对齐导致性能下降时,或在编写大量处理大型数组的SIMD代码时,请使用这些工具(如 alignas
,特殊分配器)。
一注意: 对齐许多小对象过度会导致空间浪费。如果你将每个 int
对齐到 64 字节,那么你实际上分配的主要是填充空间。因此,谨慎使用缓存行对齐是很重要的,例如在线程计数器数组或大结构体中使用时,其中成本可以忽略不计,这时可以放心使用。对于普通结构体,相信编译器的默认对齐和填充,这通常是最优的选择。
关于对齐:它是一种微妙的优化,可以增强其他技术如SIMD和避免假共享。它通常不是你首先需要处理的问题,但它是一种可以帮助巩固高性能系统的微调措施。
基于数据的设计与面向对象编程在性能上的对比数据导向设计(DOD,Data-Oriented Design 的缩写) 是一种设计哲学,强调围绕优化访问模式和提高性能来组织代码和数据,而不是围绕对象和封装的概念。该理念是将程序视为对数据流的转换过程,并在内存中排列数据以最大化吞吐量,尤其是在利用缓存和SIMD技术时,即使这意味着打破一些传统的面向对象抽象概念。
在面向对象编程的方法中,你可以将系统看作是由对象组成的集合,每个对象都包含数据和行为。例如,一个游戏可能有一个 Enemy
类,其中包含位置、速度、生命值等等属性,以及诸如 update()
或 takeDamage()
这样的方法。如果你有一大组 Enemy
对象,面向对象的更新循环可能看起来像这样:
对于每个敌方单位e来说:
e->update(deltaTime);
在底层实现中,这可能涉及虚拟函数调用(如果 Enemy
是多态的),并且每个 Enemy
可能位于不可预测的内存位置(尤其是在堆上通过 new
分配的)。CPU 需要在内存中跳转来更新每个敌人,会导致缓存未命中的情况。此外,如果 update()
只使用一部分数据(例如只是位置和速度),那么缓存行中的其余数据则有些浪费。
在数据导向设计中,你可能会将数据和行为分开,并分别为位置、速度、生命值等创建数组。
struct 游戏状态结构 {
std::vector<float> posX, posY;
std::vector<float> velX, velY;
std::vector<int> health;
// ... 等等
} gameState;
然后在紧密的循环中使用数组(这些数组是连续内存),更新位置。
// 对于每个索引i,从0到state.posX的大小
for (size_t i = 0; i < state.posX.size(); ++i) {
// 根据速度和时间更新位置
state.posX[i] += state.velX[i] * deltaTime;
state.posY[i] += state.velY[i] * deltaTime;
}
这种方法实际上是使用结构数组(SoA)而不是数组的结构(AoS)。其好处是具有优秀的缓存局部和易于向量化:所有的posX
都放在一个数组中,很可能在连续的缓存行中,因此更新它们对缓存更友好。你可以将一堆posX
值加载到SIMD寄存器中,并一次性更新多个值。面向数据的设计使得内存访问更可预测,并减少了缓存未命中。在我们的例子中,处理完一个posX
的缓存行(可能包含16个浮点数,64字节)后,下一次迭代将顺序访问新的缓存行,这是高效的。相比之下,追逐指向Enemy
对象的指针时,每个对象可能会带来一个不相关的缓存行。
另一个例子是在算法方面:例如,不是对每个对象都执行一次操作,而是收集所有需要的数据,然后一次性处理。这就是图形 API 的做法(处理整个顶点数组而不是一个个顶点)。
数据驱动 vs 面向对象:
- 面向对象编程(OOP)强调抽象化、易于维护和对问题域进行建模。但它会在内存中分散数据(每个对象单独分配),这可能导致数据在内存中分散,并且可能关注的调用模式对硬件性能未必最优。
- 然而,数据导向设计(DOD)认为:弄清楚系统中的数据流动,然后再以便于CPU处理的线性或结构化方式组织数据。这通常会导致看起来更像过程化的代码(主要操作原始数据数组),并且可能会牺牲一些封装性(你可能需要使用全局或外部数据结构)。
- 数据导向设计并不意味着彻底放弃面向对象编程,而是将“热数据”路径与其余部分分离。例如,在游戏开发中,常见的模式是实体-组件-系统(ECS):实体是ID,组件是数据(通常按组件类型存储成数组),系统则是这些数据数组上的函数。这是一种数据导向设计,尽管在概念上仍有实体这样的“对象”,但实际的数据布局则是根据组件类型进行组织的。
性能影响方面: 数据导向设计可以带来巨大的性能优势。将一个算法从面向对象风格转换为数据导向设计处理大数据集时,这样的性能提升并不罕见(例如,从OOP风格转换为DOD风格,gamesfromwithin.com)。通过连续处理数据块并减少缓存未命中,你可以充分发挥CPU(包括SIMD)的全部潜力。例如,可以比较以下两种方法:
-
AoS/OOP: 例如,
struct Particle { float x, y, z; /*...*/ };
和一个Particle
数组。如果你只需要 x,面向对象编程(OOP)仍然会将 y, z 一起加载到缓存中,这会导致带宽浪费。 - SoA/DOD: 分离
float x[N], y[N], z[N];
- 如果你只需要 x 进行某些计算,只需遍历x
数组即可。每次加载的缓存行都被完全利用(64 字节可以容纳大约 16 个 x 的浮点数,全部立即使用)。这种效率会转化为更高的速度。
另一个方面是分支和逻辑:面向对象的代码可能包含大量的虚调用或根据类型的不同分支(例如,不同的子类实现update()
的方式不同)。数据导向设计通常鼓励将类似的数据显示组,并通过数据驱动的方法处理差异(如为类型使用单独的数组,在内循环外部使用switch语句,甚至更好的做法是为每种类型使用单独的循环)。这可以减少分支预测错误,因为每个循环处理的是同一种类型的数据。
DOD的一些缺点: 如果你习惯于用对象思维,这段代码可能看起来不太直观。它通常需要更多的前期设计来优化布局。它也可能稍微增加内存使用量(填充和多个数组的使用)。但这些通常是为了性能提升而做出的一点牺牲。
实践中,许多高性能的 C++ 系统采用混合方式:关键的内循环和数据结构遵循面向数据的设计原则,而较高层次的逻辑可能仍然使用面向对象编程来组织。关键在于识别瓶颈并确保这些部分的数据布局是最优的。正如性能圈中的一个著名谚语所说,“好的数据布局胜过复杂的算法”——当数据量很大时,线性扫描良好布局的数据可能胜过理论上更快但缓存不友好的算法。总之,数据导向设计 是关于如何优化数据的访问,通常通过使用数据数组和顺序处理。
总结在这篇文章中,我们扩展了我们的工具箱,引入了几个优化策略,从循环展开和函数内联到整个程序优化和面向数据的设计。这些技术对性能有着显著的影响,特别是当它们与第一部分讨论的基础策略精心结合使用时。通过让你的代码结构紧密贴合硬件的实际情况——例如CPU缓存、指令流水线和内存访问模式——你可以实现显著的性能提升效果。
在下一部分 - 第三部分,我们将探讨线程处理和并行执行策略,无锁技术和原子操作,以及如何避免常见的陷阱,比如假共享。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章