第七章 优化热点语句
-
从循环中移除代码
char s[] = "xxx"; for (size_t i = 0; i < strlen(s); ++i) if (s[i] == ' ') s[i] = '*';-
上述代码有个问题是,strlen的实现本身就是遍历一遍字符串,然后获得计数。本来想要实现的是一个O(n)的遍历s的操作,但是因为strlen的引入,导致变成了O(n2)。
-
改进:要么将strlen结果缓存下来,不要每次计算
for (size_t i = 0, len = strlen(s); i < len; ++i) if (s[i] == ' ') s[i] = '*'; -
将一个 for 循环简化为 do 循环通常可以提高循环处理的速度,因为他们转换成汇编的语句是不一样的,do while只有一次jump,for会执行两次jump。测试了一下,好像确实如此
size_t i = 0, len = strlen(s); // for循环初始化表达式 do { if (s[i] == ' ') s[i] = ' '; ++i; // for循环继续表达式 } while (i < len); // for循环条件 // for循环的汇编 // 初始化表达式 ; // L1: if ( ! 循环条件 ) goto L2; // 语句 ; // 继续表达式 ; // goto L1; // L2: // do while的汇编 // L1: 控制语句 // if ( 循环条件 ) goto L1; -
缓存循环结束条件的另一种方法是用递减替代递增,将循环结束条件缓存在循环索引变量中
for (int i = (int)strlen(s)-1; i >= 0; --i) if (s[i] == ' ') s[i] = '*'; -
从循环中移除不变性代码:比如循环中有几个不变的变量的计算,把他们拿出来提前计算,用变量存储起来。
-
从循环中移除不必要的函数调用:比如最上面的strlen
-
从循环中移除隐含的函数调用:比如声明、初始化、赋值类实例,或者类实例的计算,等等。
-
从循环中移除昂贵的、缓慢改变的调用:比如在for循环中打印日志,格式化是比较耗时的。
-
将循环放入函数减少调用开销:比如for循环中调用了个自定义的函数,那不如把它拿进函数里去,因为会导致频繁调用函数,造成额外开销
-
不要频繁进行操作:比如频繁调用一个消耗很大的函数去检测有没有满足什么条件,需要选择一个合适的调用间隔
-
其实以上这些内容,很可能在编译器优化的时候已经做了,所以并不总是有效果
-
-
从函数中移除代码
-
函数调用本身就有一些开销:
- 基本的:参数复制到栈中、成员函数的this指针复制、调用和返回时跨越非连续地址导致的流水线停顿和高速缓存未命中
- 虚函数:如果函数是虚函数,导致的流水线停顿和高速缓存未命中的几率提高
- 继承中的成员函数调用:继承+虚函数,各种组合导致的给this指针加偏移量的问题
- 函数指针的开销
-
内联函数:短小的代码使用内联函数;在使用之前定义函数:主要是为了让编译器能优化,内联。比如递归,就没办法内联,因为在调用的时候,还没定义
-
能不用多态就不用,即移除不必要的多态函数
-
避免使用PIMPL惯用法,不过现在没人用了应该
-
移除对动态连接口的调用,比如被按需加载后在程序中显式地设置函数指针,或是在程序启动时自动地加载 DLL 时隐式地设置函数指针
-
使用静态成员函数取代成员函数:假如,一个成员函数中的处理仅仅使用了它的参数,而不用访问成员数据,也不用调用其他的虚成员函数。在这种情况下, this 指针没有任何作用,应当将这样的成员函数声明为静态函数。静态成员函数不会计算隐式 this 指针,可以通过普通函数指针,而不是开销更加昂贵的成员函数指针找到它们
-
将虚析构函数移至基类中:确保在这个基类中至少有一个成员函数,可以强制虚函数表指针出现在偏移量为 0 的位置上,这有助于产生更高效的代码。
-
-
优化表达式
-
简化表达式:
y = a*x*x*x + b*x*x + c*x + d;这条语句将会执行 6 次乘法运算和 3 次加法运算,y = (((a*x + b)*x) + c)*x + d;这条优化后的语句只会执行 3 次乘法运算和 3 次加法运算 -
将常量组合到一起:
seconds = 24 * 60 * 60 * days;会被编译器优化为seconds = 86400 * days;,但是seconds = 24 * days * 60 * 60;没办法被优化。 -
使用更高效的运算符:乘法耗费时钟周期比加法和位移操作大,x*4 可以写 x<<2。
-
整数计算替代浮点型计算。例子如下,主要是对n除以q的结果做四舍五入操作:
// 最耗时的操作 要做浮点型运算 unsigned q = (unsigned)round((double)n / (double)d)); // 利用整数实现这个功能 如果余数大于等于1/2的d,那么说明小数位>=0.5,+1,否则是舍 inline unsigned div1(unsigned n, unsigned d) { unsigned q = n / d; unsigned r = n % d; return r >= (d >> 1) ? q + 1 : q; } // 更快: 代价是移位操作可能溢出 inline unsigned div2(unsigned n, unsigned d) { return (n + (d >> 1)) / d; } -
双精度类型可能比浮点型更快:和硬件架构有关系,因为某些硬件处理浮点型计算的时候都会转换为80位格式进行,单精度和双精度进入寄存器时都会被加长,但是float比double加长的耗时长
-
-
优化控制流程惯用法
- 用switch提到if-else
- 用虚函数替代switch或if
- 使用无开销的异常处理:可以说,使用异常处理可以使程序在通常运行时更加快速,在出错时表现得更加优秀。按作者所说,早期应该不是这样的,但在C++优化后满足了。
第八章 使用更好的库
-
优化标准库的使用,有一些注意事项:
- 标准库实现是有bug的,毕竟都是人写的,在所难免
- 标准库实现可能也不符合标准
- 标准库实现,性能不是最重要的事情,稳定、简单可维护
- 库实现可能让一些优化手段无用
- 标准库中有些东西大家都不用的
- 标准库不如最好的原生函数高效
-
优化现有库:可能也有必要,但是要耐心和细心
- 改动越少越好:注意修改后前后的兼容性
- 添加函数,不要改动功能:谨慎选择名字,因为未来可能库的新版本和自己的改动重名了
-
设计优化库:优秀和最实用的库都实现了下面这些远大的目标
-
草率编码后悔多:
- 接口的稳定性是设计可持续交付的库的核心
- 设计优化库与设计其他 C++ 代码是一样的 , 不过风险更高,不要随意进行编码
-
简约是一种美德:库应当专注于某项任务,而且只应当使用最小限度的资源来完成这项任务
-
不要在库内分配内存:
- 将内存分配移动到库函数外部,可以在每次调用函数时尽可能地重用内存,而不是分配新的存储空间
- 可以减少在函数之间传递数据时保存数据的存储空间被复制的次数
-
若有疑问,以速度为准:库必须注重性能,因为一旦发布产生了性能问题,再去优化会非常困难,因为可能已经广泛使用了
-
函数比框架更容易优化:函数可以独立测量和优化性能,框架会牵扯到内部的所有类和函数,难以隔离和测试。
-
扁平继承层次关系:多数抽象都不会有超过三层类继承层次,继承层次越深。一旦继承层次超过了三层,表明类的层次结构不够清晰,其引入的复杂性会导致性能下降,在成员函数被调用时引入额外计算的风险就越高
-
扁平调用链:绝大多数抽象的实现都不会超过三层嵌套函数调用
-
扁平分层设计:每穿越一层都会发生一次额外的函数调用和返回,导致每次函数调用的性能都会降低。
-
避免动态查找:动态查找天生低效
-
留意上帝函数:是指实现了高级策略的函数。在程序中使用这种函数,会导致链接器向可执行文件中添加许多库函数。在嵌入式系统中,可执行文件的增大会耗尽物理内存;而在桌面级计算机上,可执行文件的增大则会增加虚拟内存分页。
-
第九章 优化查找和排序
-
使用键值对表:std::map<std::string, int>:让我们无需太多思考和编码即可实现不错的大 O 性能
-
改善查找性能的工具箱:有条理的推进性能优化工作:
std::map<std::string, unsigned> const table { { "alpha", 1 }, { "bravo", 2 }, { "charlie", 3 }, { "delta", 4 }, { "echo", 5 }, { "foxtrot", 6 }, { "golf", 7 }, { "hotel", 8 }, { "india", 9 }, { "juliet", 10 }, { "kilo", 11 }, { "lima", 12 }, { "mike", 13 }, { "november",14 }, { "oscar", 15 }, { "papa", 16 }, { "quebec", 17 }, { "romeo", 18 }, { "sierra", 19 }, { "tango", 20 }, { "uniform",21 }, { "victor", 22 }, { "whiskey",23 }, { "x-ray", 24 }, { "yankee", 25 }, { "zulu", 26 }};-
测量当前的实现方式的性能来得到比较基准
-
作者为上面创建的表编写了一个测试。在这项测试中总共查找了 53 个对象,其中包含表中存在的 26 个值和不存在的 27 个值。做了测试,得到了基准的程序运行时间。
-
识别出待优化的抽象活动
-
分析器指出 std::map::find() 是热点代码。待优化的活动非常明显:在以字符串作为键的 map 实现的键值对表中查找值的活动。以文本作为键在键值对表中查找值
-
将待优化的活动分解为组件算法和数据结构
- 表是一种包含键和键所关联的值的数据结构。
- 键是一种包含文本的数据结构。
- 有一种比较键的算法。
- 有一种查询表数据结构以判断键是否存在,如果存在就取得它所关联的值的算法。
- 有一种构造表或是向表中插入键的算法。
-
修改或是替换那些可能并非最优的算法和数据结构,然后进行性能测试以确定修改是否有效果
-
在待优化的活动中有如下优化机会。
- 可以换一种表数据结构或是提高它的性能。某些表数据结构的选择制约了查找和插入算法的选择。如果在数据结构中包含需要调用内存管理器的动态变量,那么表数据结构的选择还会对性能有影响。
- 可以替换一种键数据结构或是提高它的性能。
- 可以替换一种比较算法或是提高它的性能。
- 尽管查找算法受到表数据结构的选择的制约,但是我们仍然可以替换一种查找算法或是提高它的性能。
-
对应到真实的数据结构的话
- std::map底层是平衡二叉树,时间开销是O(log_2 n),如果能更开销小,就能更快
- std::map在被构造时,会频繁地调用内存管理器,且有很强的动态性,导致缓存局部性比较差。因为本例中并没有频繁构造,因此优化点是改善缓存局部性差的问题
- std::string作为键值有一些冗余,查找一个字符串字面常量会导致发生一次从字符串字面常量到std::string 的开销昂贵的类型转换,如果可以使用其他数据结构作为键,那么就可以避免这种类型转换
- 比较上没有什么优化点
-
-
-
优化std::map的查找
- 以固定长度的字符数组作为std::map的键:
std::map<char[10],unsigned> table。创建时不会动态开销,减小创建时的开销。如果用C分割的字符串字面常量查找时,也不会遇到问题。因此,作者定义了一个固定长度字符数组的模版类,重载了它的比较和赋值,使得性能提升了。 - 使用char作为键,不过为了比较的时候比较真实的字符串,而非char,要设置一个比较函数。
- 当键就是值,用std::set
- 以固定长度的字符数组作为std::map的键:
-
使用 头文件优化算法
std::map的线性查找std::binary_serch的二分查找:不返回值,只返回有没有std::equal_range的二分查找:查找到了返回子序列[begin, end) (即能查到多个),查不到返回begin==end- std::lower_bound的二分查找:查到了返回一个指向表中第一个元素的迭代器。没查到会返回一个指向表末尾的迭代器。
-
优化键值对散列表中的查找
-
斯特潘诺夫的抽象惩罚:库一般设计来是为了解决通用性问题的,所以即使标准库算法具有优秀的性能,它也往往无法与最佳手工编码的算法匹敌。当开发人员需要提高程序性能时必须注意的事情
-
C++标准库优化排序
- std::stable_sort() 通常都是归并排序的变种。
- std::heap_sort 将一个具有堆属性的范围转换为一个有序范围。 heap_sort 不是稳定排序。
- std::partition会执行快速排序的基本操作。
- std::merge 会执行归并排序的基本操作
- 各种序列容器的 insert 成员函数会执行插入排序的基本操作
第十章 优化数据结构
扫了一下,感觉都是在讲STL库,感觉可以去读一下《STL源码xx》那本书,可能比这个更详细点
第十一章 优化IO
- 读取文件的秘诀
最初分析的示例代码如下:
std::string file_reader(char const* fname) {
std::ifstream f;
f.open(fname);
if (!f) {
std::cout << "Can't open " << fname << " for reading" << std::endl;
return "";
}
std::stringstream s;
std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
std::istreambuf_iterator<char>(),
std::ostreambuf_iterator<char>(s) );
return s.str();
}
上述代码从一个库的角度来看有一些问题:
- 用户做了异常处理,想打印一些信息的话,无法做到
- 返回值的地方可能会发生复制
- 如果文件内容为空,返回值都是空字符串,用户无法分辨原因
升级一下:
- 把打开文件和读取文件的操作分离
- 将 result 的动态存储空间与 s.str()的动态存储空间通过swap进行交换(有点问题) 。除非编译器和标准库实现都支持移动语义,否则将 s.str() 赋值给result这样做会导致内存分配和复制。
void stream_read_streambuf_stringstream(std::istream& f, std::string& result) {
std::stringstream s;
std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
std::istreambuf_iterator<char>(), std::ostreambuf_iterator<char>(s) );
// std::swap(result, s.str()); 书中这样写的 但是是错误的 因为s.str()是个右值,编译会报错
result = s.str();
}
// 读了一个文件 运行好多次 基本测了一个耗时 0.025209913 s
但是上面的代码使用字符迭代器一次复制一个字符。因此有可能每获取一个字符都会在std::istream,甚至是在主机操作系统的文件 I/O API 中发生大量的机械操作。同样,可能std::stringstream 中的 std::string 每次只会增长一个字符,导致产生了内存分配器的大量调用。因此可以再优化一下,直接将一个迭代器从输入流中复制到一个string中:
void stream_read_streambuf_string(std::istream& f, std::string& result) {
result.assign(std::istreambuf_iterator<char>(f.rdbuf()),
std::istreambuf_iterator<char>());
}
// 作者猜测的好像有问题
// 1.310542529 s 反而是这种方式更加耗时
std::istream 有一个 << 运算符,它接收流缓冲区作为参数。 << 运算符可能会绕过 istream API 直接调用流缓冲区。
void stream_read_streambuf(std::istream& f, std::string& result) {
std::stringstream s;
s << f.rdbuf();
std::swap(result, s.str());
}
// 耗时 0.024669496 s 其实和第一种方式差不多
所以结果可能不一致,但是思路还是值得学习的:
- 缩短调用链,即免去不必要的额复制之类的,假如数据通过一层层复制上来,那不如去调用最底层的函数
- 减少重新分配,就是充分利用内存,避免多次调用内存管理器做重新分配
- 更大的吞吐量:使用更大的缓冲区;一次读取一行
- 写文件
// endl会刷新输出,所以写文件速度慢一些
void stream_write_line(std::ostream& f, std::string const& line) {
f << line << std::endl;
}
// 这个速度就快很多了
void stream_write_line_noflush(std::ostream& f, std::string const& line) {
f << line << "\n";
}
第十二章 优化并发
-
复习并发:主要是讲了一下并发的那些基础概念
-
复习C++并发方式:主要是讲了C++的并发相关的变量、函数、机制。
-
优化多线程C++程序
-
用std::async替代std::thread
- std::thread每次调用都会启动一个新的软件线程,开销很高,创建时要分配线程空间、初始化寄存器等,运行时由于每个线程都占用一些内存空间,间接增加了使用的内存总量;另外操作系统调度线程的过程也是一种开销
- std::async()与std::thread()最明显的不同,就是async并不一定创建新的线程
std::thread t; t = std::thread([]() { return; }); t.join(); std::async(std::launch::async, []() { return; }); // 作者说这两个函数差距巨大, 但是我实际测试,并没有,后者反而比前者差了 // std::launch::async:强制这个异步任务在新线程上执行,系统必须创建出新线程来运行函数 -
创建与核心数量一样多的可执行线程:这个几乎不可能限制
-
实现任务队列和线程池
-
在单独的线程执行IO操作
-
同步和互斥会降低多线程程序的速度。摆脱同步可以提升程序性能。
-
-
让同步更加高效
-
减小临界区的范围:很好理解的道理,锁的范围越小越好,节约其他线程的等待时间
-
限制并发线程的数量:一个是可能会发生调度开销,另外一个是会更可能发生惊群
-
避免惊群:当有许多线程挂起在一个事件上时导致的。事件发生,所有线程都醒来,由于核心数有限,只能几个线程运行,其中一个拿到互斥量后,其他线程进入可运行队列,被操纵系统逐个运行,继续挂起,消耗很多时间但是没进展。
-
避免锁护送:就像是护送着锁一个线程一个线程的走一样。一直发生惊群就是这个现象。
-
减少竞争
- 从设计上注意对内存和I/O资源的使用
- 复制资源:让每个线程都保存一份数据可能会浪费,但是能减少竞争
- 分割资源:可以分割数据结构,每个线程访问一部分
- 细粒度锁:和分割资源类似,锁住一部分,不要锁整个数据
- 无锁数据结构:比如无锁栈、无锁散列表什么的
-
不要在单核系统上频繁等待
-
不要永远等待:作者说如果子线程在等待,主线程退出了,子线程可能会一直等待,占用资源;但是实际测了一下,并不是,主线程被杀掉了之后,子线程都挂了。
-
不要自己设计互斥量,除非非常熟悉操作系统的设计
-
要限制队列长度,满后阻塞。生产者比消费者快,就会导致数据累计,进而导致内存占用。此时消费者在处理消息的过程中还被生产者抢占,降低自身速度,问题恶化。
-
-
并发库
十三章 优化内存管理
-
复习C++内存管理器API:回顾了new和delete的各种形式
-
高性能内存管理器有下面这样的需求:
- 必须足够高效,它非常有可能成为热点代码
- 必须能够在多线程程序中正常工作
- 必须能够高效地分配许多相同大小(如链表节点)或不同大小的(如字符串)的对象
- 必须既能够分配非常大的数据结构(I/O 缓冲区)也能分配非常小的数据结构(例如一个指针)
- 必须至少知道较大内存块的指针的对齐边界、缓存行和虚拟内存页
- 它的运行时性能不能随着时间而降低
- 必须能够高效地复用返回给它的内存
-
提供类专用内存管理器
- 为申请相同大小内存块的请求分配内存的内存管理器是很容易编写的,它的运行效率也很高,因为不用担心内存碎片之类的,复用性也好
-
自定义标准库分配器
- 编写一个自定义的内存管理器或分配器可以提高程序性能,但相比于移除对内存管理器的调用等其他优化方法,它的效果没有那么明显。