C++ 核心指南 —— 性能
阅读建议:先阅读 《性能优化的一般策略及方法》
截至目前,C++ Core Guidelines 中关于性能优化的建议共有 18 条,而其中很大一部分是告诫你,不要轻易优化!
非必要,不优化
- Per.1: 不要无故优化
- Per.2: 不要过早优化
- Per.3: 只优化少数关键代码
前三条可以总结为:非必要,不优化。所谓的“优化”,是指牺牲可读性、可维护性,以换取性能提升(否则应该作为编程的标准实践)。优化可能引入新的 bug,增加维护成本。软件工程师应把重心放在编写简洁、易于理解和维护的代码,而不是把性能作为首要目标。
先测量,再优化
如果性能非常重要,应该通过精确地测量,找到程序的 hot spots,再有针对性地优化。
Per.4: 不要假设复杂的代码比简单的代码快
- 多线程未必比单线程快:考虑到线程间同步的开销、上下文切换开销,多线程未必比单线程快
- 利用一系列复杂的优化技巧编写的复杂代码未必比直接编写的简单代码快,如
// 好:简单直接
vector<uint8_t> v(100000);
for (auto& c : v)
c = ~c;
// 不好:复杂的优化技巧,本意想更快,但往往更慢!
vector<uint8_t> v(100000);
for (size_t i = 0; i < v.size(); i += sizeof(uint64_t)) {
uint64_t& quad_word = *reinterpret_cast<uint64_t*>(&v[i]);
quad_word = ~quad_word;
}
Per.5: 不要假设低级语言比高级语言快
不要低估编译器的优化能力,很多时候编译器产生的代码要比手动编写低级语言更高效!
Per.6: 没有测量就不要对性能妄下断言
- 性能优化很多时候是反直觉的,针对某些条件下的性能优化技巧在另一个环境下可能会劣化性能,因此必须要测量才知道某个改动到底会“优化”还是“劣化”性能
- 小于 4% 的代码能占用 50% 的程序执行时间。只有测量才知道时间花在哪里,才能有针对性地优化
以上 6 条建议在 《性能优化的一般策略及方法》 中有更详细的描述。
具体优化建议
Per.7 设计应当允许优化
如果设计之初完全忽视了将来优化的可能性,会导致很难修改。
过早优化是万恶之源,但这并不是轻视性能的借口。一些经过时间检验的最佳实践可以帮助我们写出高效、可维护、可优化的代码:
- 信息传递:接口设计要干净,但还要携带足够的信息,以便后续改进实现。
- 紧凑的数据结构:默认情况下,使用紧凑的数据结构,如
std::vector
,如果你认为需要一个链表,尝试设计接口使用户看不到这个结构(参考标准库算法的接口设计)。
- 函数参数的传递和返回:区分可变和不可变数据。不要把 资源管理 的任务强加给用户。不要把假想的 indirection 强加给用户。使用常规的方式传递信息,非常规或为特定实现“优化”过的数据传递方式可能会导致后续难以修改实现。
- 抽象:不要过度泛化。试图满足每种可能的使用情况(包括误用),把每个设计决策推迟(编译或运行时 indirection)会导致复杂、臃肿、难以理解。不要对未来需求的猜测来进行泛化,从具体示例中进行泛化。泛化时保持性能,理想状态是零开销泛化。
- 库:选择具有良好接口设计的库。如果没有现成的,自己写一个,模仿具有良好接口风格的库(可以从标准库找灵感)。
- 隔离:把你的代码和旧的、乱的代码隔离开。可以按照自己的风格,设计一个接口风格良好的 wrapper,把那些不得不用的旧的、乱的代码封装起来,不要污染到我们自己的代码。
"indirection"(间接)通常指的是通过引入额外的层级或中介来访问数据或功能。在 C++ 中,这可能涉及使用指针、引用或其他间接方式来访问变量、对象或函数。
注
- 设计接口时,不要只考虑第一版的用例和实现。初版实现之后,必须 review,因为一旦部署之后,弥补错误将很困难。
- 低级语言并不总是高效,高级语言的代码不一定慢。
- 任何操作都有开销,不用过分担心开销(现代计算机都足够的快),但是需要大致了解各种操作的开销。例如:内存访问、函数调用、字符串比较、系统调用、磁盘访问、网络通信。
- 不是每段代码都需要稳定接口,有的接口可能只是实现细节。但还是要停下来想一下:如果要使用多个线程实现这个操作,需要什么样的接口?是否可以向量化?
- 本条目和 Per.2 并不矛盾,而是它的补充:鼓励开发者在必要且时机成熟时进行优化。
移动语义
《C++ Core Guidelines 解析》针对本条目重点补充了移动语义:写算法时,应使用移动语义,而不是拷贝。移动语义有以下好处:
- 移动开销比拷贝低
- 算法稳定,因为不需要分配内存,不会出现
std::bad_alloc
异常
- 算法可以用于“只移类型”,如
std::unique_ptr
需要移动语义的算法遇到不支持移动操作类型,则自动“回退”到拷贝操作。
而只支持拷贝语义的算法遇到不支持拷贝操作的类型时,则编译报错。
Per.10 依赖静态类型系统
弱类型(如 void* )、低级代码(如把 sequence 作为单独的字节来操作)会让编译器难以优化。
《解析》中还给出了一些额外的帮助编译器生成优化代码的技巧:
- 本地代码。“本地”指在同一个编译单元(如同一个 .c/.cpp 文件中)。例如
std::sort
需要一个谓词,传入本地 lambda 可能会比传入函数(指针)更快。
因为对于本地 lambda,编译器拥有所有可用的信息来生成最优代码,而函数可能定义在另一个编译单元中,编译器无法获取有关该函数的细节,从而无法进行深度优化。
- 简单代码。优化器会搜寻可以被优化的已知模式,简单的代码更容易被匹配到。如果是手写的复杂代码,反而可能错失让编译器优化的机会。
- 额外提示。
const
、noexcept
、final
等关键字可以给编译器提供额外的信息,有了这些额外的信息,编译器可以大胆地做进一步优化。当然要先搞清楚这些关键字的含义及产生的影响。
Per.11 将计算从运行时提前到编译期
可以减少代码尺寸和运行时间、避免数据竞争、减少运行期的错误处理。
constexpr
将函数声明为 constexpr
,且参数都是常量表达式,则可以在编译期执行。
注意:constexpr
函数可以在编译期执行,但不意味着只能在编译期执行,也可以在运行期执行。
constexpr
函数的限制:
- 不能使用
static
或 thread_local
变量
- 不能使用
goto
- 不能使用异常
- 所有变量必须初始化为字面类型
字面类型:
- 内置类型(及其引用)
- 有
constexpr
构造的类
- 字面类型的数组
例 1
// 旧风格:动态初始化
double square(double d) { return d*d; }
static double s2 = square(2);
// 现代风格:编译期初始化
constexpr double ntimes(double d, int n) // 假设 0 <= n
{
double m = 1;
while (n--) m *= d;
return m;
}
constexpr double s3 {ntimes(2, 3)};
第一种写法很常见,但有两个问题:
- 运行时函数调用开销
- 另一个线程可能在 s2 初始化之前访问 s2
注:常量不存在数据竞争的问题
例 2
一个常用的技巧,小对象直接存在 handle 里,大对象存在堆上。
constexpr int on_stack_max = 20;
// 直接存储
template<typename T>
struct Scoped {
T obj;
};
// 在堆上存储
template<typename T>
struct On_heap {
T* objp;
};
template<typename T>
using Handle = typename std::conditional<
(sizeof(T) <= on_stack_max),
Scoped<T>,
On_heap<T>
>::type;
void f()
{
// double 在栈上
Handle<double> v1;
// 数组在堆上
Handle<std::array<double, 200>> v2;
}
编译期可以计算出最佳类型,类似地技术也可用于在编译期选择最佳函数。
注
实际上大多数计算取决于输入,不可能把所有的计算全部放到编译期。除此之外,复杂的编译期计算可能大幅增加编译时间,并且导致调试困难。甚至在极少场景下,可能导致性能劣化。
代码检查建议
- 检查是否有简单的、可以作为(但没有)
constexpr
的函数
- 检查是否有函数的所有参数都是常量表达式
- 检查是否有可以改为
constexpr
的宏
Per.19 以可预测的方式访问内存
缓存对性能影响很大,一般缓存算法对相邻数据的简单、线性访问效率更高。
当程序需要从内存中读取一个 int 时,现代计算机架构会一次读取整个缓存行(通常 64 字节),储存在 CPU 缓存中,如果接下来要读取的数据已经在缓存中,则会直接使用,快很多。
例如:
int matrix[rows][cols];
// 不好
for (int c = 0; c < cols; ++c)
for (int r = 0; r < rows; ++r)
sum += matrix[r][c];
// 好
for (int r = 0; r < rows; ++r)
for (int c = 0; c < cols; ++c)
sum += matrix[r][c];
在 C++ 标准库中,std::vector
, std::array
, std::string
将数据存在连续的内存块中的数据结构对缓存行很友好。而 std::list
和 std::forward_list
则恰恰相反。
例如在某测试环境中,从容器中读取并累加所有元素:
std::vector
比 std::list
或 std::forward_list
快 30 倍
std::vector
比 std::deque
快 5 倍
很多场景下,即使需要在中间插入/删除元素,由于缓存行的原因,std::vector
的性能也可能好于 std::list
!
除非测量的结果表明其他容器性能好于 std::vector
,否则应将 std::vector
作为首选容器。
其他
剩下的条目截至目前还只有标题,缺少详细描述:
- Per.12 Eliminate redundant aliases/消除冗余别名
- Per.13 Eliminate redundant indirections/消除冗余间接
- Per.14 Minimize the number of allocations and deallocations/尽可能减少分配和释放
- Per.15 Do not allocate on a critical branch/不在关键分支上分配
- Per.16 Use compact data structures/使用紧凑的数据结构:性能主要由内存访问决定
- Per.17 Declare the most used member of a time-critical struct first/对于时间关键的结构体,把最常用的成员定义在前
- Per.18 Space is time/空间就是时间:性能主要由内存访问决定
- Per.30 Avoid context switches on the critical path/避免关键路径上的上下文切换
总结
- 非必要,不优化
- 先测量,再优化
- 为编译器优化提供必要信息:
- 正确使用
const
、final
、noexcept
等关键字
- 为函数实现移动语义、如果可能,使之成为
constexpr
- 现代计算机架构为连续读取内存而进行了优化,应该将
std::vector
, std::array
, std::string
作为首选
Reference