文字内容全是我自己的理解,可能理解错误了,就传达了错误的信息了。感觉也没有什么太多新鲜的内容,可能各种文章流传太多了,当然这些文章的来源也许就源自这本书。
第一章 优化概述
总的来说介绍了一下优化的背景、目的,以及本书将要讲解的优化的手段。提前总的叙述了一下在未来的章节中要讲到的优化的手段,具体如下:
-
用好的编译器并用好编译器
- 虽然编译器都是按照C++的标准要求实现的,但是不同的编译器针对同一源码会产生不同的可执行文件,会带来不同的优化机会。所以尝试不同的编译器也许可以带来性能提升。
- 选择支持C++11的编译器会更好,因为C++11实现了右值引用和移动语义,省去很多11以前无法避免的复制操作!
- 编译器的优化选项是否打开?虽然调试变复杂了,但是性能确实提升了。
-
使用更好的算法
选择一个最优算法对性能优化的效果最大。
- 使用更好的库
商业C++编译器的库仍然有bug,有一些开源库提供的功能可能比供应商提供的更快、更强。
-
减少内存分配和复制
- 调用内存管理器的开销是数千个指令,因此减少对内存管理器的调用就是一种非常有效的优化手段。
- 而复制一次调用也可能消耗数千个CPU周期,而复制往往和内存分配有关系,因此对它的优化是一举两得
-
移除计算
比如移除在循环中被执行的可以提到循环外的计算/内存分配/复制性质的代码。
-
使用更好的数据结构
- 插入、迭代、排序和检索元素的算法运行开销取决于数据结构
- 不同数据结构使用内存管理器的方式不同,导致不同的开销
-
提高并发性
如果一个程序的处理进度因需要等待某些事件被暂停,而没有利用这些时间进行其他处理,都是一种浪费
- 优化内存管理
C++ 为内存管理提供了丰富的 API,多数开发人员都从来没有使用过,后面会讲一些改善内存管理效率的技术。
第二章 影响优化的计算机行为
本章的目的是为读者提供与本书中所描述的优化技术相关的计算机硬件的最基本的背景知识。
-
一些C++的信息
- C++底层语句并不是顺序执行的,它可能为了获得更快的运行速度来改变语句执行顺序,但会确保每次计算的含义不变
- 某些内存地址可能是设备寄存器,在同一线程的两次读之间其地址存的值可能会变化,用volatile会使得编译器在每次用它的值的时候,都会去获取一份副本。
- std::atomic<>和volatile是不一样的
-
计算机的真相(其实都是我们知道的了)
- 内存很慢。指的是和逻辑门、寄存器相比的话,从主内存读一个数据字的时间,可以执行数百条指令。
- 内存访问并非以字节为单位。大概说的是计算机读取数据不是以字节为单位的,所以才会有C++的内存对齐的工作,但是内存对齐后会有很多空内存,所以也要注意变量的顺序,使得内存利用更紧凑。
- 某些内存访问会比其他的更慢。意思就是在高速缓存中的数据,访问肯定比不在高速缓存中的数据速度快。
- 内存字分为大端和小端。0x1234存在计算机里,
0:0x12 1:0x34的是大端,反过来是小端。 - 内存容量是有限的。虚拟内存制造出了拥有充足的物理内存的假象。高速缓存和虚拟内存导致的一些现象:1)单独测某个函数,速度可能很快,因为信息都存在高速缓存中了;在实际程序运行中,速度比较慢,因为所需信息不太可能全部在高速缓存中。2)如果一个大程序访问许多离散的内存地址,那么可能没有足够的高速缓存来保存程序刚刚使用的数据,会导致性能衰退。
- 指令执行缓慢。处理器中包含一条指令的“流水线”,可以实现并发的处理更多指令,但如果指令B需要指令A的计算结果,那么就不能并发了,会导致流水线停滞,拖累高性能微处理器。
- 计算机难以做决定。在执行了一个条件分支指令后,执行可能会走向两个方向:下一条指令或者分支目标地址中的指令。最终会走向哪个方向取决于之前的某些计算的结果。这时,流水线会发生停滞.
- 程序执行中的多个流。主要是讲的上下文切换所带来的巨大的性能开销。
- 调用操作系统的开销是昂贵的。
-
C++的谎言
- 并非所有语句性能开销相同。这个很好理解,比如两个int类型的B赋值给A的操作和类B赋值给类A的操作肯定开销不同。
- 语句并非是按顺序执行的。因为编译器底层会做优化。
-
总结
- 处理器访问内存的性能开销远比其他操作的性能开销大。
- 非对齐访问所需的时间是所有字节都在同一个字中时的两倍。
- 访问频繁使用的内存地址的速度比访问非频繁使用的内存地址的速度快。访问相邻地址的内存的速度比访问互相远隔的地址的内存快。
- 由于高速缓存的存在,一个函数运行于整个程序的上下文中时的执行速度可能比运行于测试套件中时更慢。
- 访问线程间共享的数据比访问非共享的数据要慢很多。
- 计算比做决定快。
- 每个程序都会与其他程序竞争计算机资源。
- 如果一个程序必须在启动时执行或是在负载高峰期时执行,那么在测量性能时必须加载负载。
- 每一次赋值、函数参数的初始化和函数返回值都会调用一次构造函数,这个函数可能隐藏了大量的未知代码。有些语句隐藏了大量的计算。从语句的外表上看不出语句的性能开销会有多大。
- 当并发线程共享数据时,同步代码降低了并发量。
第三章 测量性能
本章主要讲的一些测量性能的思想、方法和工具。具体内容就不展开了。
- 一个程序会花费 90% 的运行时间去执行 10% 的代码,这段代码就是热点,优化它就可以事半功倍。
- 计算一条 C++ 语句对内存的读写次数,可以估算出一条 C++ 语句的性能开销。
第四章 优化字符串的使用
本章开始进行案例研究
-
字符串的一些基本信息
- std::string随着C++标准的变化在不断变化,C++98和C++11的实现是不同的
- 字符串内存是动态分配的,对C++来说,动态分配内存耗时耗力,因此是性能优化热点
- 字符串的内存并不一定和存储的字符一样大,可能更大,这样追加的时候不用重新分配内存了
-
第一次尝试优化字符串
测量方法:我也不知道这样做是否符合规范
int main(int argc, char const *argv[])
{
std::string s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':,.<>?/`~";
auto start = std::chrono::system_clock::now();
for (int i = 0; i < 1000000; i++) {
// 要测试的函数
}
auto end = std::chrono::system_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(end-start).count();
std::cout << "duration: " << duration << " ns" << std::endl;
return 0;
}
原始函数
std::string remove_ctrl(std::string s)
{
std::string result;
for (int i = 0; i < s.length(); i++) {
if (s[i] >= 0x20)
result = result + s[i];
}
return result;
}
// 输出 duration: 3246861616 ns ==> 3.25 s
优化一番
std::string remove_ctrl(std::string const& s) (3)
{
std::string result;
result.reserve(s.length()); (2)
for (int i = 0; i < s.length(); i++) {
if (s[i] >= 0x20)
result += s[i]; (1)
}
return result;
}
// 优化了1之后输出 duration: 445272568 ns ==> 0.45 s
// 优化了1和2之后输出 duration: 377406305 ns ==> 0.38 s
// 优化了1、2和3之后 duration: 348101212 ns ==> 0.35 s
(1)位置:原始语句会存在临时变量保存result + s[i]的结果,开销很大,赋值操作也可能分配额外的字符(取决于字符串是如何实现的)。改善移除了所有为了分配临时字符串对象来保存连接结果而对内存管理器的调用,以及相关的复制和删除临时字符串的操作。赋值时的分配和复制操作也可以被移除(取决于字符串的实现方式)。
(2)位置:通过预留存储空间减少内存的重新分配。因为每次字符串空间不够时,就会有一个申请两倍内存空间再复制的操作,提前分配空间可以改善。
(3)位置:使用引用,免除复制。书中这一步优化不但没起作用,还使得效果变差了,作者推测是s现在变成“指针”了,每次使用s都会解引指针,导致增加了额外的影响,进而使得整体性能变差。不过我的生效了,可能是节省的足够超过新增的消耗?
再次优化:作者想消除所谓的解引指针操作
std::string remove_ctrl(std::string const& s)
{
std::string result;
result.reserve(s.length());
for (auto it = s.begin(), end = s.end(); it != end; ++it) {
if (*it >= 0x20)
result += *it;
}
return result;
}
// 输出 duration: 482791472 ns ==> 0.48 s
按照书中优化,这样可以节省两次解引用操作。但是效果反而变差了。也许是因为我用的是Linux系统,gcc的编译器,和作者在windows中使用的编译器不同,对字符串的实现方式不同?
然后再优化
作者想把result的返回值当做参数,std::string& result,但是理论上编译器应该会帮忙做这一步优化的吧?果然,尝试了一下,基本没有变化
再再优化:用字符数组代替字符串
void remove_ctrl6(char* destp, char const* srcp, size_t len)
{
for (size_t i = 0; i < len; i++) {
if (srcp[i] >= 0x20)
*destp++ = srcp[i];
}
*destp = '\0';
}
// 输出 duration: 85610404 ns ==> 0.09 s
这次效果非常显著。获得这种改善效果的原因之一是移除了若干函数调用以及改善了缓存局部性。
- 第二次尝试优化字符串
这次直接换个算法
std::string remove_ctrl7(std::string s)
{
std::string result;
for (size_t b=0, i=b, e=s.length(); b<e; b = i+1) {
for (i=b; i<e; i++) {
if (s[i] < 0x20)
break;
}
result = result + s.substr(b, i-b);
}
return result;
}
// 输出 duration: 140745166 ns ==> 0.14 s
和我测试的字符串内容也有一定关系,但是比起最原始的算法,是提升了不少的,从3.25s到了0.14s。
继续优化:
std::string remove_ctrl8(std::string const& s)
{
std::string result;
result.reserve(s.length());
for (size_t b=0, i=b, e=s.length(); b<e; b = i+1) {
for (i=b; i<e; i++) {
if (s[i] < 0x20) break;
}
result.append(s, b, i-b);
}
return result;
}
// 输出 duration: 119457958 ns ==> 0.12 s
换个编译器可能会使结果发生变化
或者换个字符串库,比如Boost的,比如使用std::stringstream
再或者使用更好的内存分配器。
-
消除字符串转换
- 比如c风格的字符串转换为std::string。代码中会涉及到很多c风格字符串到std::string的没有必要的转换,会浪费CPU周期。
- 可能会涉及到不同字符集的转换,最好的方式当然是固定一种格式。
-
总结
- 字符串是动态分配内存的,它们的实现方式中需要大量的复制,因此它们的性能开销非常大
- 将字符串作为对象而非值可以降低内存分配和复制的频率。然后函数可以选择&引用
- 为字符串预留内存空间可以减少内存分配的开销。比如reserve(n)
- 根据具体使用场景,换一种算法,或者换一种库,效果都会好
第五章 优化算法
-
算法的时间开销
- 一些基础知识,O(1) O(n) O(log n)
- 一些开销的概念
-
优化查找和排序的工具箱
- 用平均时间开销更低的算法替换平均时间开销较大的算法。
- 加深对数据的理解(例如,知道数据是已经排序完成的或是几乎排序完成的),然后根据数据的特性选择具有最优时间开销的算法,避免使用那些针对这些数据特性有较差时间开销的算法。
- 调整算法来线性提高性能
-
高效查找算法
-
查找算法的开销:
- 线性是O(n),
- 二分查找是O(log n),
- 插补查找在数据均匀的情况下可达到O(log log n)
- 散列法是O(1)
-
当n很小时,所有算法的时间开销都一样,没必要搞什么
-
-
高效排序算法
- 基数排序算法时间开销是O(n log_r n),其中r是基数,即排序桶的个数。
- 不能认为快速排序算法总是具有优秀的性能,你必须对输入数据集有所了解,在已经(或几乎)排序完成的数组上使用快速排序算法,而且使用第一个或最后一个元素作为支点元素,那么它的性能是非常差的
- 内省排序。是快速排序和堆排序的混合形式。首先以快速排序算法开始进行排序,但是当输入数据集导致快速排序的递归深度太深时,会切换为堆排序。自 C++11 开始,内省排序已经成为了 std::sort() 的优先实现。
-
优化模式
- 预计算:提前计算来达到从热点代码中移除计算的目的。比如,C++ 编译器会对常量表达式的值自动地进行预计算,比如
int x = 60 * 60 * 24; - 延迟计算:将计算推迟至更接近真正需要进行计算的地方。意思就是可能本来没有必要执行的代码,就让它尽可能不要执行了。比如:写时复制,在要修改该变量时,才会真正进行复制。
- 批量处理:收集多份工作,然后一起处理它们。比如:缓存输出,输出字符会一直被缓存,直至缓存满了或是程序遇到行尾(EOL)符或是文件末尾(EOF)符。相比于为每个字符都调用输出例程,将整个缓存传递给输出例程节省了性能开销。
- 缓存:通过保存和复用昂贵计算的结果来减少计算量的方法。避免每次都需要重新计算。
- 特化:移除在某种情况下不需要执行的昂贵的计算。就是没必要用大炮打蚊子?简单举例:std::string 可以动态地改变长度,容纳不定长度字符的字符串。但是如果只需要比较固定的字符串,那么使用 C 风格的数组等会更性能低。
- 提高处理量:减少重复操作的迭代次数,削减重复操作带来的开销。和批量处理感觉有点类似,也是让它处理尽可能多的数据。比如落盘的时候,不一条一条的落,大量数据一起落,这不就是批量处理吗?
- 提示:使用提示来减少计算量,可以达到减少单个操作的开销的目的。意思是根据已知内容做选择?书中举例是std::map 中有一个重载的 insert() 成员函数,它有一个表示最优插入位置的可选参数。意思是不用这个参数和用这个参数时间复杂度不一样,不写程序就通过比较去找位置,写了就直接插到那个位置?
- 优化期待路径:比如写if-else。让最容易发生的在前面,不容易发生的在后面
- 散列法:大型数据结构或长字符串会被一种算法处理为一个称为散列值的整数值。通过比较两个输入数据的散列值,可以高效地判断出它们是否相等。如果散列值相等了,再去看具体内容。
- 双重检查:首先使用一种开销不大的检查来排除部分可能性,然后在必要时再使用一个开销很大的检查来测试剩余的可能性。比如,比较两个字符串,可以先比较他们的长度,再比较他们的具体内容。
- 预计算:提前计算来达到从热点代码中移除计算的目的。比如,C++ 编译器会对常量表达式的值自动地进行预计算,比如
第六章 优化动态分配内存的变量
优化内存管理的目标并不是避免使用用到动态分配内存的变量的 C++ 特性。它的目标是通过巧妙地使用这些特性移除对内存管理器的无谓的、会降低性能的调用
-
C++变量回顾
-
变量生命周期:
- 静态存储期,静态变量会被分配固定位置和大小的空间,在程序的生命周期内一直保留
- 线程局部存储期,线程局部变量在进入线程时被构建,在退出线程时被析构,生命周期与线程一样,访问线程局部变量可能会比访问静态变量开销更高
- 自动存储期,被分配在编译器在函数调用栈上预留的内存空间中.在程序执行于大括号括起来的代码块内的这段时间,自动变量是一直存在的。当程序运行至声明自动变量的位置时,会构建自动变量;当程序离开大括号括起来的代码块时,自动变量将会被析构
- 动态存储期,具有动态存储期的变量被保存在程序请求的内存中。
-
变量所有权:大概就是谁能用它
-
值对象与实体对象:
- 实体对象:通过在程序中扮演的角色体现出他们的意义,比如互斥锁。实体是独一无二的、可变的、不可复制、不可比较的
- 值对象:通过它们的内容体现出它们在程序中的意义。可互换,可比较,但是值是不可变的。变量是带有唯一名字的实体,可以改变变量的值,但是无法改变值本身。 值是可复制的。
-
-
C++动态变量API回顾
-
使用智能指针实现动态变量所有权的自动化
- 共享动态变量的所有权的开销更大:由于在引用计数上会发生性能开销昂贵的原子性的加减运算,因此 shared_ptr 可以工作于多线程程序中。 std::shared_ptr 也因此比 C 风格指针和std::unique_ptr 的开销更大。
-
动态变量有运行时开销
- 如果没有可用的内存块来满足请求,那么分配函数会调用操作系统内核,从系统的可用内存池中请求额外的大块内存,这次调用的开销非常大。
-
-
减少动态变量的使用
-
静态地创建类实例
-
使用静态数据结构
-
用 std::array 替代 std::vector
-
在栈上创建大块缓冲区:担心可能会发生局部数组溢出的谨慎的开发人员,可以先检查参数字符串或是数组的长度,如果发现参数长度大于局部数组变量的长度了,那么就使用动态构建的数组。
-
静态地创建链式数据结构,比如下面这样
-
struct treenode { char const* name; treenode* left; treenode* right; } tree[] = { { "D", &tree[1], &tree[2] } { "B", &tree[3], &tree[4] }, { "F", &tree[5], nullptr }, { "A", nullptr, nullptr }, { "C", nullptr, nullptr }, { "E", nullptr, nullptr }, };
-
-
在数据中创建二叉树:就是left和right不用指针,用数组索引。如果节点的索引是 i ,那么它的两个子节点的索引分别是 2i 和 2i+1。对于平衡二叉树而言,数组形式的树可能会比链式树低效。有些平衡算法保存一棵有 n 个节点的树可能需要 2n 长度的数组。而且,一次平衡操作需要复制节点到不同的数组位置中,而不仅仅是更新指针。
-
用环形缓冲区替代双端队列
-
-
用std::make_shared代替new:
std::shared_ptr<MyClass> p(new MyClass("hello", 123));会调用两次内存管理器:第一次用于创建 MyClass 的实例,第二次用于创建被隐藏起来的引用计数对象- std::make_shared() 函数可以分配一块独立的内存来同时保存引用计数和 MyClass的一个实例。
-
不要无谓的共享所有权:多个 std::shared_ptr 实例可以共享一个动态变量的所有权,但它的开销也很昂贵。对引用计数操作是一次非常昂贵的原子性操作。下面这个操作不错:
void fiddle(Foo& f); ... shared_ptr<Foo> myFoo = make_shared<Foo>(); ... if (myFoo) fiddle(*myFoo.get()); // 解引运算符 * 将 get() 返回的指向 FOO 的指针转换为指向 FOO 的引用。也就是说,这不会 // 产生任何代码,只是对编译器的提示。在 C++ 的编程世界中,引用是一种“习俗”,它表 // 示“无主且非空的指针” ``` -
使用 “ 主指针 ” 拥有动态变量:大概说定义了一个动态变量会被传递给函数处理,或者被函数返回,但是这些引用的生命周期都没有哪个比主引用长,这种情况下,可以用unique_ptr,传递的时候用C风格指针,或引用来引用该对象。
-
-
减少动态变量的重新分配
- 预分配动态变量以防止重新分配:调用 reserve()
- 在循环外创建动态变量:比如每个循环都在创建字符串,可以把它拿到外面声明一次,用的时候在里面clear一下就好了,只要下一次用的内存大小不大于上一次,string就不会重新分配内存。
-
移除不必要的复制:必须特别注意赋值和声明,因为在这些地方可能会发生昂贵的复制。可能会发生于以下任何一种情况下:初始化(调用构造函数)、赋值(调用赋值运算符)、函数参数(每个参数表达式都会被移动构造函数或复制构造函数复制到形参中)、函数返回 (调用移动构造函数或复制构造函数,甚至可能会调用两次)、插入一个元素到标准库容器中(会调用移动构造函数或复制构造函数复制元素)、插入一个元素到 vector 中(如果需要重新为 vector 分配内存,那么所有的元素都会通过移动构造函数或复制构造函数复制到新的 vector 中)
- 在类定义中禁止不希望发生的复制:复制构造函数和赋值运算符的可见性声明为 private,或者后面加上 delete 关键字
- 移除函数调用上的复制:函数参数为引用,大致就是
- 移除函数返回上的复制:只有在某些特殊的情况下编译器才能够进行 RVO。函数必须返回一个局部对象。编译器必须能够确定在所有的控制路径上返回的都是相同的对象。和编译器能力有关,函数比较复杂就不要返回比较庞大的结构了,直接用引用的函数参数返回出去吧。
- 免复制库:就是使用不用复制的库
- 实现写时复制惯用法:写时复制的核心思想是,在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。写时复制首先会进行一次“浅复制”,然后将深复制推迟至对象的某个元素发生改变时。在现代 C++ 的 COW 的实现方式中,任何引用动态变量的类成员都是用如 std::shared_ptr 这样的具有共享所有权的智能指针实现的。类的构造函数复制具有共享所有权的指针,将动态变量的一份新的复制的创建延迟到任何一份复制想要修改该动态变量时。
- 切割数据结构:它指的是一个变量指向另外一个变量的一部分
-
实现移动语义
-
右值:临时值,可以初始化、赋值给一个变量或作为函数参数,但是接下来会被立即销毁。左值:通过变量命名的值。比如y=2x+1,2x+1的结果是右值,它没有名字,左侧变量是左值,名字是y。
-
如果开发人员没有提供或是编译器没有自动生成移动构造函数和移动赋值运算符,程序仍然可以编译通过。这时候,编译器会使用比较低效的复制构造函数和复制赋值运算符。
-
移动语义的微妙之处:
- std::vector的机制,强异常安全保证:执行操作后,如果发生异常,会保证vector状态与执行操作前一样。但是移动构造函数会销毁源对象,与该机制冲突。因此要把移动构造函数/赋值运算符声明为noexcept,否则它会用低效的赋值构造函数。noexcept很危险,但这是高效要付出的代价。下面链接里有个例子:浅析vector容器(3)-使用移动语义提高性能_vector扩容时移动构造-CSDN博客
- 右值引用参数是左值:右值引用做形参,但是形参有名字,所以它仍然是左值。但可以用std::move来将左值变为右值引用。
- 不要返回右值引用:返回右值引用会妨碍返回值优化,只要可以使用 RVO,无论是返回语句中的实参还是函数的返回类型,都不应当使用右值引用。
- 要想为一个类实现移动语义,你必须为所有的父类和类成员也实现移动语义。否则,父类和类成员将会被复制,而不会被移动。
-
-
相比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势