优化热点语句

优化热点语句

在语句级别优化代码能够显著改善嵌入式的小型处理器的性能,因为在这些处理器上,指令是直接从内存中被获取,然后一条一条被执行的。不过,由于桌面级和手持设备的处理器提供了指令级的并发和缓存,因此语句级别的优化带来的回报比优化内存分配和复制要小。

语句级别的性能优化的问题在于,除了函数调用外,没有哪条C++语句会消耗许多条机器指令。通常,集中精力在这些微小的性能点上收益不大,除非开发人员找到了放大这些语句的开销、使它们称为热点代码的因素。这些因素包括:

  • 循环

    循环中的语句开销是语句的开销乘以它们被重复执行的次数。热点循环必须由开发人员自己找出来。分析器可以指出包含热点循环的函数,但它不会指出函数的热点循环;它还可能会因为某个函数被一个或多个循环调用而指出该函数,但它也不会指出具体哪个循环是热点循环。开发人员必须以分析器的输出结果作为线索,检查代码并找出热点循环。

  • 频繁被调用的函数

    函数的开销是函数自身的开销乘以它被执行的次数。分析器可以直接指出热点函数。

  • 贯穿整个程序的惯用法

    这是一个与C++语句和惯用法有关的类别。替换性能开销更小的惯用法可以提升程序的整体性能。

语句级别的性能优化还有一个问题:优化效果取决于编译器。对于如何为一条特定的C++语句生成代码,每种编译器都会有一个或多个方案。适用于某个编译器的编程惯用法可能在另外一个编译器上毫无效果,甚至反而会降低性能。当在使用GCC时可以改善性能的技巧可能无法适用于Visual C++。更关键的是,这意味着当团队升级了编译器版本后,新的编译器可能会降低他们精心优化后的代码的速度。这是语句级别的优化可能比其他性能优化手段效果更差的另一个原因。

从循环中移除代码

一个循环是由两部分组成的:一段被重复执行的控制语句和一个确定需要进行多少次循环的控制分支。通常情况下,移除C++语句中的计算指的是移除循环中的控制语句的计算。不过在循环中,控制分支也有额外的优化机会,因为从某种意义上说,它产生了额外的开销。

缓存循环结束条件值

代码清单7-1 未优化的for循环
for (size_t i = 0; i < strlen(s); ++i)
	if (s[i] == ' ')
		s[i] = '*';

调用strlen()的开销是昂贵的,遍历参数字符串对它的字符计数使得这个算法的开销从O(n)变成了O(n2)。这是一个在库函数中隐藏了循环的典型例子。

我们可以通过在进入循环时预计算并缓存循环结束条件值,来提高程序性能。

代码清单7-2 缓存了循环结束条件值的for循环
for (size_t i = 0, len = strlen(s); i < len; ++i)
	if (s[i] == ' ')
		s[i] = '*';

使用更高效的循环语句

以下是C++中for循环语句的声明语法:for(初始化表达式; 循环条件; 继续表达式) 语句;

粗略地将,for循环会被编译为如下代码:

	初始化表达式;

L1: if (!循环条件) goto L2;

	语句;

	继续表达式;

	goto L1;

L2:

for循环必须执行两次jump指令:一次是当循环条件为false时;另一次则是在计算了继续表达式之后。这些jump指令可能会降低执行速度。

C++还有一种使用不那么广泛的do-while循环:do 语句; while(循环条件);

粗略地讲,其会被编译为如下代码:

L1: 语句
	if (循环条件) goto L1;

因此,将一个for循环简化为do循环通常可以提高循环处理的速度。

这种做法在VS2010可能提高性能,但在VS2015上却可能降低性能。

用递减替代递增

许多循环都有一种结束条件判断起来比其他结束条件更高效。其中一种方法就是用递减替代递增,将循环结束条件缓存在循环索引变量中。

不过这种方法无法确定是否有显著的性能提升。

从循环中移除不变性代码

结束条件被缓存起来供复用,这样更高效。它是将具有不变性的代码移动至循环外部这个通用技巧的一个典型例子。当代码不依赖于循环的归纳变量时,它就具有循环不变性。

现代编译器非常善于找出在循环中被重复计算的具有循环不变性的代码,然后将计算移动至循环外部来改善程序性能。开发人员通常没有必要重写这段代码,因为编译器已经替我们找出了具有循环不变性的代码并重写了循环。

当在循环中有语句调用了函数时,编译器可能无法确定函数的返回值是否依赖于循环中的某些变量。被调用的函数可能很复杂,或是函数体包含在另一个编译器看不到的编译单元中。这时,开发人员必须自己找出具有循环不变性的函数调用并将它们从循环中移除。

从循环中移除无谓的函数调用

一次函数调用可能会执行大量的指令。如果函数具有循环不变性(loop-invariant),那么将它移除到循环外有助于改善性能。

没有一个简单的规则可以确定在某种情况下一个函数是否具有循环不变性。一个函数在某个循环中具有循环不变性,但在另外一个循环中却不具有循环不变性。在这种情况下,相比于编译器彻底但有限的分析,开发人员的判断更加有效。

有一种函数永远都可以被移动到循环外部,那就是返回值只依赖于函数参数而且没有副作用的纯函数(pure function)。如果在循环出现了这种函数,而且在循环中不会改变它的参数,那么这个函数就具有循环不变性。

有时候,在循环中调用的函数根本就不会工作或者只是进行一些无谓的工作。我们当然可以移除这些函数。如果迫切地需要缩短程序执行时间,那么就值得检查循环中的每处函数调用,看看是否真的需要它们。

从循环中移除隐含的函数调用

普通的函数调用很容易识别,它们有函数名,在圆括号中有参数表达式列表。C++代码还可能会隐式地调用函数,而没有这种很明显的调用语句。当一个变量是以下类型之一时就 可能会发生这种情况:

  • 声明一个类实例(调用构造函数)
  • 初始化一个类实例(调用构造函数)
  • 赋值给一个类实例(调用赋值运算符)
  • 涉及类实例的计算表达式(调用运算符成员函数)
  • 退出作用域(调用在作用域中声明的类实例的析构函数)
  • 函数参数(每个参数表达式都会被复制构造到它的形参中)
  • 函数返回一个类的实例(调用复制构造函数,可能是两次)
  • 向标准库容器中插入元素(元素会被移动构造或复制构造)
  • 向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是复制构造)

这些函数调用被隐藏起来了。你从表面上看不出带有名字和参数列表的函数调用。它们看起来更像赋值和声明。我们很容易误以为这里没有发生函数调用。

如果将函数签名从通过值传递实参修改为传递指向类的引用或指针,有时候可以在进行隐式函数调用时移除形参构建。如果将函数签名修改为通过输出参数返回指向类实例的引用或指针时,可以在进行隐式函数调用时移除函数返回值的复制。

有时,即使需要每次都将变量传递到循环中, 你也可以将声明移动到循环外部,并在每次循环中都执行一次开销较小的函数调用。这种行为不仅仅适用于字符串或是那些含有动态内存的类。类实例中还可能会含有取自操作系统的资源,如一个窗口或是文件句柄,抑或可能会在它自身的构造函数和析构函数中进行一些开销昂贵的处理。

从循环中移除昂贵的、缓慢改变的调用

有些函数调用虽然并不具有循环不变性,但是也可能变得具有循环不变性。一个典型的例子是在日志应用程序中调用获取当前时间的函数。它只需要几条指令即可从操作系统获取当前时间,但是却需要花费些时间来格式化显示时间。

日志记录必须尽可能地高效,否则会降低程序的性能。如果这降低了程序性能就糟糕了,如果性能下降改变了程序行为,进而导致在打开日志记录后程序的Bug消失就更糟了。在这个例子中,获取当前时间决定了记录日志的开销。

相比于现代计算机的指令执行速度,时间的改变非常慢。很明显,我的程序可以在两次时标之间记录100万行日志。因此,连续调用timetoa()两次获取到的当前时间可能是相同。如果需要一次记录许多行日志,那么就没有理由在记录每条时都去获取当前时间。

我的理解:简单来说,就是一些改变如果相对来讲非常缓慢,其实是可以将其视作是具有循环不变性的,就可以把一部分代码移至循环外。

将循环放入函数以减少调用开销

如果程序要遍历字符串、数组或是其他数据结构,并会在每次迭代中都调用一个函数,那么可以通过一种称为循环倒置(loop inversion)的技巧来提高程序性能。循环倒置是指将在循环中调用的函数变为在函数中进行循环。这需要改变函数的接口,不再接收一条元素作为参数,而是接收整个数据结构作为参数。按照这种方式修改后,如果数据结构中包含n条元素,那么可以节省n-1次函数调用。

不要频繁地进行操作

其他优化技巧

在互联网上有许多关于循环的底层优化技巧资料。例如,有些资料指出++i通常比i++更加高效,因为不需要保存或是返回任何中间值。有些资料建议展开循环来减少循环条件测试语句和循环条件增长语句的执行次数。

这些建议的问题在于它们并非总是有效果。你可能花费了很多时间来进行实验,但是却观察不到任何改善效果。这些建议来自于猜想而非实验结果,或者可能在某个特定的日子里在某种特定的编译器上有效果。这些建议也可能来自关于编译器设计的教材,它们所描述的性能优化技巧实际上编译器已经替我们做了。这30多年来,现代C++编译器已经非常善于将循环内的代码移动到循环外部了。事实上,编译器比绝大多数程序员的编程能力更加优秀。这也是为什么使用类似的性能优化技巧的结果总是让人沮丧,以及为什么本节中的内容并不会太多。

从函数中移除代码

与循环一样,函数也包含两部分:一部分是由一段代码组成的函数体,另一部分是由参数列表和返回值类型组成的函数头。与优化循环一样,这两部分也可以独立优化。

调用函数的开销与调用大多数C++语句的开销一样,是非常小的。

函数调用的开销

函数是编程中最古老和最重要的抽象概念。程序员先定义一个函数,接着就可以在代码中的其他地方调用这个函数。这种便利性可不是免费的。每次调用一个函数时都会发生类似下面这样的处理:

  1. 执行代码将一个栈帧推入到调用栈中来保存函数的参数和局部变量。
  2. 计算每个参数表达式并复制到栈帧中。
  3. 执行地址被赋值到栈帧中并生成返回地址。
  4. 执行代码将执行地址更新为函数体的第一条语句(而不是函数调用后的下一条语句)
  5. 执行函数体中的指令。
  6. 返回地址被从栈帧中复制到指令地址中,将控制权交给函数调用后的语句。
  7. 栈帧被从栈中弹出。

不过,关于函数开销也有一些好消息。带有函数的程序通常都会比带有被内联展开的大型函数的程序更加紧凑。这有利于提高缓存和虚拟内存的性能。而且,函数调用与非函数调用的其他开销都相同,这使得提高会被频繁地调用的函数的性能成为了一种有效的优化手段。

1. 函数调用的基本开销

有许多细节问题都会降低C++中函数调用的速度,这些问题也构成了函数调用优化的基础。

  • 函数参数

    除了计算参数表达式的开销外,复制每个参数的值到栈中也会发生开销。如果只有几个小型的参数,那么可能可以高效地将它们传递到寄存器中;但是如果有很多参数,那么至少其中一部分需要通过栈传递。

  • 成员函数调用

    每个成员函数都有一个额外的隐藏参数:一个指向this类实例的指针,而成员函数正是通过它被调用的。这个指针必须被写入到调用栈上的内存中或是保存在寄存器中。

  • 调用和返回

    调用和返回对程序的功能没有任何影响。我们可以通过用函数体替代函数调用来移除这些开销。的确,当函数很小且在函数被调用之前已经定义了函数时,许多编译器都会试图内联函数体。如果不能内联函数,调用和返回就会产生开销。

    调用函数要求执行地址被写入到栈帧中来生成返回地址。

    函数返回要求执行地址从栈中被读取出来并加载到执行指针中。在调用和返回时,执行连续地工作于非连续的内存地址上。当程序执行需要跨越非连续地址时,可能会发生流水线停顿和高速缓存未命中。

2. 虚函数的开销

在C++中可以将任何成员函数定义为虚函数。继承类能够通过定义一个具有相同函数签名的成员函数来重写基类的虚成员函数。这样,无论是在继承类实例上调用虚函数还是在一个指向基类类型的指针或是引用上调用虚函数,都可以使用新的函数体。程序在解引类实例时会选择调用哪个函数。因此,程序是在运行时通过类实例的实际类型来确定要调用哪个重写函数的。

每个带有虚成员函数的实例都有一个无名指针指向一张被称为虚函数表的表,这张表指向类中课件的每个虚函数签名所关联的函数体。虚函数表指针通常都是类实例的第一个字段,这样解引时的开销更小。

由于虚函数调用会从多个函数体中选择一个执行,调用虚函数的代码会解引指向类实例的指针,来获得指向虚函数表的指针。这段代码会为虚函数表加上索引来得到函数的执行地址。因此,实际上这里会为所有的虚函数调用额外地加载两次非连续的内存,每次都会增加高速缓存未命中的几率和发生流水线停顿的几率。虚函数的另一个问题是编译器难以内联它们。编译器只有在它能同时访问函数体和构造实例的代码时才能内联它们。

3. 继承中的成员函数调用

当一个类继承另一个类时,继承类的成员函数可能需要进行一些额外的工作。

  • 继承类中定义的虚成员函数

    如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给this类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。这些代码会包含更多的指令字节,而且这些指令通常都比较慢,因为它们会进行额外的计算。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。

  • 多重继承的继承类中定义的成员函数调用

    代码必须向this类实例指针中加上一个偏移量来组成指向多重继承类实例的指针。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。

  • 多重继承的继承类中定义的虚成员函数调用

    对于继承类中的虚成员函数调用,如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给this类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。代码还必须向this类实例指针加上潜在的不同的偏移量来组成继承类的类实例指针。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。

  • 虚多重继承

    为了组成虚多重继承类的实例指针,代码必须解引类实例中的表,来确定要得到指向虚多重继承类的实例指针时需要加在类实例指针上的偏移量。如前所述,当被调用的函数是虚函数时,这里也会产生额外的间接开销。

4. 函数指针的开销

C++提供了函数指针,这样当通过函数指针调用函数时,代码可以在运行时选择要执行的函数体。除了基本的函数调用和返回开销外,这种机制还会产生其他额外的开销。

  • 函数指针(指向非成员函数和静态成员函数的指针)

    C++允许在程序中定义指向函数的指针。程序员可以通过函数指针显式地选择一个具有特定签名(由参数列表和返回类型组成)的非成员函数。当函数指针被解引后,这个函数将会在运行时被调用。通过将一个函数复制给函数指针,程序可以显式地通过函数指针选择要调用的函数。代码必须解引指针来获取函数的执行地址。编译器也不太可能会内联这些函数。

  • 成员函数指针

    成员函数指针声明同时指定了函数签名和解释函数调用的上下文中的类。程序通过将函数赋值给函数指针,显式地选择通过成员函数指针调用哪个函数。

    成员函数指针有多种表现形式,一个成员函数智能有一种表现形式。它必须足够通用才能够在以上列举的各种复杂的场景下调用任意的成员函数。我们有理由认为一个成员函数指针会出现最差情况的性能。

5. 函数调用开销总结

因此,C风格的不带参数的void函数的调用开销是最小的。如果能够内联它的话,就没有开销;即使不能内联,开销也仅仅是两次内存读取加上两次程序执行的非局部转移。

如果基类没有虚函数,而虚函数在多重虚拟继承的继承类中,那么这是最坏的情况。不过幸运的是,这种情况非常罕见。在这种情况下,代码必须解引类实例中的函数表来确定加到类实例指针上的偏移量,构成虚拟多重继承函数的实例的指针,接着解引该实例来获取虚函数表,最后索引虚函数表得到函数执行地址。

坏消息是除非函数会被频繁地调用,否则移除一处非连续内存读取并不足以改善性能;好消息则是分析器会直接指出调用最频繁的函数,让开发人员能够快速地集中精力与最佳优化对象。

简短地声明内联函数

移除函数调用开销的一种有效方式是内联函数。要想内联函数,编译器必须能够在函数调用点访问函数定义。那些函数体在类定义中的函数会被隐式地声明为内联函数。通过将在类定义外部定义的函数声明为存储类内联,也可以明确地将它们声明为内联函数。此外,如果函数定义出现在它们某个编译单元中第一次被使用之前,那么编译器还可能会自己选择内联较短的函数。尽管C++标准说inline关键字只是对编译器的提示,但是实际上为了编译器自己的销量,它们必须善于内联函数。

当编译器内联一个函数时,那么它还有可能会改善代码,包括移除调用和返回语句。有些数学计算可能会在编译时完成。如果编译器能够确定当参数为某个特定值时有些分支永远不会执行,那么编译器会移除这些分支。因此,内联是一种通过在编译时进行计算来移除多余计算的改善性能的手段。

函数内联可能是最强力的代码优化武器。事实上,Visual Studio中Debug版本和Release版本的性能区别,主要源于Debug版本关闭了函数内联。

在使用之前定义函数

在第一次调用函数之前定义函数给了编译器优化函数调用的机会。当编译器编译对某个函数的调用时发现该函数已经被定义了,那额编译器能够自主选择内联这次函数调用。如果编译器能够同时找到函数体,以及实例化那些发生虚函数调用的类变量、指针或是引用的代码,那么这也同样适用于虚函数。

移除未使用的多态性

在C++中,虚成员函数多用来实现运行时多态性。多态性允许成员函数根据不同的调用对象,从多个不同但语义上有关联的方法中选择一个执行。

要实现多态行为,可以在基类中定义虚成员函数。然后任何继承类都能够选择使用特化行为来重写基类函数的行为。这些不同的实现是通过每个继承类都必须有不同的实现的语义概念关联在一起的。

当程序必须在运行时从多种实现中选择一种执行时,虚函数表是一种非常高效的机制,它的间接开销只有两次额外的内存读取以及与这两次内存读取相关的流水线停滞。

不过,多态仍然可能会带来不必要的性能开销。例如,一个类的本来的设计目的是方便实现派生类的层次结构,但是最后却没有实现这些派生类;或者一个函数被声明为虚函数是希望利用多态性,但这个函数却永远没有被实现。

放弃不使用的接口

在C++中可以使用虚成员函数实现接口——一组通用函数的声明。这些函数描述了对象行为,而且它们在不同的情况下有不同的实现方式。基类通过声明一组纯虚函数(有函数声明,但灭有函数体的函数)定义接口。由于纯虚函数没有函数体,因此C++不允许实例化接口基类。继承类可以通过重写(定义)接口基类中的所有纯虚函数来实现接口。C++接口惯用法的优点自安于,继承类必须实现接口中声明的所有函数,否则编译器将不会允许程序创建继承类的实例。

C++11中的关键字override是可选关键字,它告诉编译器当前的声明会重写基类中虚函数的声明。当指定了override关键字后,如果基类中没有虚函数声明,编译器会报出警告消息。

有时,一个程序虽然定义了接口,但是只提供一种实现。在这种情况下,通过移除接口,可以节省虚函数调用的开销。

  1. 在链接时选择接口实现

    虚函数允许程序在运行时从多个实现中选择一种。接口允许设计人员指定在开发过程中必须编写哪些函数,以使一个对象可以在程序中被使用。使用C++虚函数实现几口惯用法的问题在于,虚函数为设计时问题提供的是一个带有运行时开销的解决方案。

    如果无需在运行时作出选择的话,那么开发人员可以使用链接器来从多个实现中选择一种。具体做法是不声明C++接口,而是在头文件中直接声明但不实现成员函数。然后在不同的cpp文件中包含不同的实现。Visual Studio工程文件引用Windowsfile.cpp,Linux的makefile则引用Linuxfile.cpp。选择哪个实现会由链接器根据参数列表来做出决定。

    在链接时选择实现的优点是使得程序具有通用性,而缺点则是部分决定被放在了.cpp文件中,部分决定被放在了makefile或是工程文件中。

  2. 在编译时选择接口实现

    可以在编译时使用#ifdef来选择实现。这个方法要求能够使用预处理宏来选择所希望的实现。有些开发人员喜欢这种方法,因为可以在.cpp文件中做更多决定。另外一个个开发人员则认为在一个文件中编写两种实现方式是凌乱且非面对对象的。

用模板在编译时选择实现

C++模板特化是另外一种在编译时选择是实现的方法。利用模板,开发人员可以创建具有通用接口的类群,但是它们的行为取决于模板的类型参数。模板参数可以是任意类型——具有自己的一组成员函数的类类型或是具有内建运算符的基本类型。因此,存在两种接口:模板类的public成员,以及由在模板参数上被调用的运算符合函数所定义的接口。抽象基类中定义的接口是非常严格的,继承类必须实现在抽象基类中定义的所有函数。而通过模板定义的接口就没有这么严格了。只有参数中那些实际会被模板某个特化所调用的函数才需要被定义。

模板的特性是一把双刃剑:一方面,即使开发人员在某个模板特化中忘记实现接口了,编译器也不会立即报出错误消息;但另一方面,开发人员也能够选择不去实现那些在上下文中没被用到的函数。

从性能优化的角度看,多态类层次与模板实例之间的最重要的区别是,通常在编译时整个模板都是可用的。在大多数用例下,C++都会内联函数调用,用多种方法改善程序性能。模板编程提供了一种强力的优化手段。对于那些不熟悉模板的开发人员拉私活,需要学习如何高效地使用C++的这个特性。

避免使用PIMPL惯用法

PIMPL是Pointer to IMPLementation的缩写,它是一种用作编译防火墙——一种防止修改一个头文件会触发许多源文件被重编译的机制——的编程惯用法。20世纪90年代是C++的快速成长期,那时候使用PIMPL是合理的,因为那是大型程序的编译时间是以消失为单位计算的。下面是PIMPL的工作原理:

要实现PIMPL,开发人员要定义一个新的类。

class Impl;
class BigClass {
    ...
    Impl* impl;
};

C++允许声明一个指向未完成类型,即一个还没有定义的对象的指针。这样的代码之所以能够工作,是因为所有指针的大小都是相同的,因此编译器知道如何预留指针的存储空间。在实现PIMPL后,在编译时,其他实现或是对impl的实现的改动都会导致bigclass.cpp被重编译,但是bigclass.h不会改变,这样就限制了重编译的范围。

在运行时情况就不同了。PIMPL给程序带来了延迟。之前BigClass中的成员函数可能会被内联,而现在则会发生一次成员函数调用,而且,现在每次成员函数调用都会调用Impl的成员函数。使用了PIMPL的工程往往会在很多地方使用它,导致形成了多层嵌套函数调用。更甚者,这些额外的函数调用层次使得调试变得更加困难。

只有当BigClass是一个非常大的类,依赖于许多头文件时,才需要使用PIMPL。这样的类违背了许多面向对象编程原则。采用将BigClass分解,使接口功能更加集中的方法,可能与PIMPL同样有效。

移除对DLL的调用

在Windows上,当DLL被按需加载后再恒旭中显式地设置函数指针,或是在程序启动时自动地加载DLL时隐式地设置函数指针,然后通过这个函数指针调用动态链接库。

有些DLL调用是必需的。例如,应用程序可能需要实现第三方插件库。其他情况下,DLL则不是必需的。例如,有时之所以使用DLL仅仅是因为它们修复了一些bug。经验证明bug修复通常都是批量的,一次性覆盖了程序中的各个地方。这限制了在一个DLL中修复所有bug的可能性,破坏了DLL的用途。

另外一种改善函数调用性能的方式是不使用DLL,而是使用对象代码库并将其链接到可执行程序上。(使用静态库)

使用静态成员函数取代成员函数

每次对成员函数的调用都有一个额外的隐式参数:指向成员函数被调用的类实例的this指针。通过对this指针加上偏移量可以获得类成员数据。虚成员函数必须解引this指针来获得虚函数表指针。

有时,一个成员函数中的处理仅仅使用了它的参数,而不用访问成员数据,也不用调用其他的虚成员函数。这种情况下,this指针没有任何作用。我们应当将这样的成员函数声明为静态函数,静态成员函数不会计算隐式this指针,可以通过普通函数指针,而不是开销更加昂贵的成员函数指针找到它们。

将虚析构函数移至基类中

任何有继承类的类的析构函数都应当被声明为虚函数。这是有必要的,这样delete表达式将会引用一个指向基类的指针,继承类和基类的析构函数都会被调用。

另外一个在继承层次关系顶端的基类中声明虚函数的理解是:确保在基类中有虚函数表指针。

继承层次关系中的基类处于一个特殊的位置。如果在这个基类中有虚成员函数声明那么虚函数表指针在其他继承类中的偏移量是0;如果这个基类声明了成员变量且没有声明任何虚成员函数,但是有些继承类却声明了虚成员函数,那么每个虚成员函数调用都会在this指针上加一个偏移量来得到虚函数表指针的地址。确保在这个基类中至少有一个虚成员函数,可以强制虚函数表指针出现在偏移量为0的位置上,这有助于产更高效的代码。

而析构函数则是最佳候选,如果这个基类有继承类,它就必须是虚函数。在类实例的生命周期中析构函数只会被调用一次,因此只要不是那些在程序中会被频繁地构造和析构的非常小的类(而且通常情况下,几乎不会让这些小的类去继承子类),将其设置为虚函数后产生的开销是最小的。

这看似是非常罕见的情况,不需要太过关注:在重要类层次的基类中有引用计数、事务ID或者其他类似的变量。这个基类对可能继承它的类没有任何了解。通常,类层次关系中的第一个类都是一个声明了一组虚成员函数的抽象基类。基类肯定知道的一件事情就是实例最终会被销毁。

优化表达式

在语句级别下面是设计基本数据类型的数学计算。这也是最后的优化机会。如果一个热点函数中只有一条表达式,那么它可能是唯一的优化机会。

现代编译器非常善于优化设计基本数据类型的表达式。但是它们不够勇敢,只有当它们能够确保改动不会影响程序行为时,才会进行优化表达式。开发人员尽管没有编译器那么细致,但是比编译器更聪明,开发人员能够优化哪些编译器无法确定优化是否安全的代码。

优化表达式在每次执行一条指令的小型处理器上有很好的效果,在桌面级的具有多段流水线的处理器的改进效果并不明显。因此,不值得投入大量精力进行优化表达式,除非及其罕见的情况。

简化表达式

C++会严格地以运算符的优先级和可结合性的顺序来计算表达式。C++之所以让程序员手动优化表达式,是因为C++的int类型的模运算并非是整数的数学运算,C++的float类型的近似计算也并非真正的数学运算。C++必须给予程序员足够的权利来清晰地表达他的意图,否则编译器会对表达式进行重排序,从而导致控制流程发生各种变化。这意味着开发人员必须尽可能使用最少的运算符来书写表达式。

用于计算多项式的霍纳法则(Horner Rule)证明了以一种更高效的形式重写表达式有多么厉害。尽管大多数C++开发人员并不会每天都进行多项式计算,但是我们都很熟悉它。多项式y = ax3 + bx2 + cx + d;在C++中可以写为 y = [(a * x + b) * x + c] * x + d; 这条优化后的语句只会执行3次乘法运算和3次加法运算。通常,霍纳法则可以将表达式的乘法运算次数从n(n-1)减少为n,其中n是多项式的维数。

C++之所以不会重排序算数表达式是因为这非常危险。数值分析是一个非常大的主题,围绕这个主题可以写出一本书。

开发人员是唯一必须知道表达式的写法、它的参数的数量级并对其输出结果负责的人。编译器不会帮助我们完成这项任务,这也是它不会优化表达式的原因。

将常量组合在一起

编译器可以帮我们做的一件事是计算常量表达式。

它们会将second = 24 * 60 * 60 * days;second = 24 * 60 * 60 * days;优化成second = 86400 * days;

但如果程序员这样写:second = 24 * days * 60 * 60;编译器只能在运行时进行乘法计算了。

因此,我们应当总是用括号将常量表达式组合在一起,或是将它们放在表达式的左端。或者更好的一种做法是,将它们独立出来初始化给一个常量,或者将它们放在一个常量表达式(constexpr)函数中(如果你的编译器支持C++11的这一特性)。这样编译器能够在编译时高效地计算常量表达式。

使用更高效的运算符

有些数字运算符在计算时比其他运算符更低效。

对于PC,乘法是一种类似于我们在小学学到的十进制乘法的迭代计算。除法是一种更复杂的迭代处理。这种开销结构为性能优化提供了机会。

例如,整数表达式x*4可以被重编码为更高效的x<<2。任何差不多的编译器都可以优化这个表达式。但许多情况下,编译器都无法确定参数值一定是2的幂,这时需要程序员去确认并重写表达式,用位移运算替代乘法运算。

另一种优化是用位移运算和加法运算替代乘法。例如,x * 9可以重写为x * 8 + x * 1,进而可以重写为 (x<<3) + x。当常量运算子中没有许多置为1的位时,这种优化最有效,因为每个置为1的位都会扩展为一个位移和加法表达式。在拥有指令缓存和流水线执行单元的桌面级或是手持级处理器上,以及在长乘法被实现为子例程调用的小型处理器上,这种优化同样有效。与所有优化方法一样,我们必须测试性能结果来确保在某种处理器上它确实提高了性能,但通常情况下确实都是这样的。

使用整数计算替代浮点型计算

浮点数计算的开销是昂贵的。浮点数值内部的表现比较复杂,它带有一个整数型尾数、一个独立的指数以及两个符号。PC上实现了浮点型计算单元的硬件可能占到芯片面积的20%。有些多核处理器会共享一个单独的浮点型计算单元,但是却在每个核心上都有多个独立的整数计算单元。

即使是在具有浮点型计算硬件单元的处理器上,即使对计算结果的整数部分进行了舍入处理,而不是截取处理,计算整数结果仍然能够比计算浮点型结果快至少10倍。如果是在没有浮点型计算硬件单元的小型处理器上用函数库进行浮点型计算,那么整数的计算速度会快更多。但是我们仍然可以看到,有些开发人员在命名可以使用整数计算时,却使用浮点型计算。

代码清单 7-16 对浮点类型进行舍入操作得到整数值
unsigned q = (unsigned)round((double)n / (double)d));

C++提供了来自C运行时库的ldiv函数,它会生成一种同时包含整数和余数的结构。

代码清单7-17 使用ldiv()对整数除法进行舍入
inline unsigned div0(unsigned n, unsigned d){
    auto r = ldiv(n, d);
    return (r.rem >= (d >> 1)) ? r.quot + 1 : r.quot;
}
代码清单7-18 对整数除法结果舍入
inline unsigned div1(unsigned n, unsigned d){
    unsigned q = n / d;
    unsigned r = n % d;
    return r >= (d >> 1) ? q + 1 : q;
}

如果余数大于或等于除数的一半,那么就会对商+1。编译器所进行的一项优化是这个函数成功的关键。x86机器对两个整数进行除法的指令会同时得到商和余数。Visual C++编译器非常聪明,当执行这段代码时它只会执行一次这个指令。

代码清单7-19 对整数除法结果舍入
inline unsigned div2(unsigned n, unsigned d){
    return (n + (d >> 1)) / d;
}

div2()在进行除法之前,在分子n上加上了 除数d的二分之一。其缺点在于,如果n很大,那么n + (d >> 1)可能会溢出。如果开发人员知道参数的数量级没有问题,那么就可以使用这个非常高效的函数。

双精度类型可能会比浮点型更快

Visual C++生成的浮点型指令会引用老式的“x87 FPU coprocessor”寄存器栈。在这种情况下,所有浮点计算都会以80位格式进行。当单精度float和双精度double值被移动到FPU寄存器中时,它们都会被加长。对float进行转换的时间可能比对double进行转换的时间更长。

有多种编译浮点型计算的方式。在x86平台上,使用SSE寄存器允许直接以四种不同大小完成计算。使用SSE指令的编译器的行为可能会与为非x86处理器进行编译的编译器不同。

用闭形式替代迭代计算

C++的位操作是怎样的呢?C++中丰富的计算和位逻辑运算符只是将位移动来移动去,还是从设备寄存器和网络包获取信息位以及将信息位放到设备寄存器和网络包的需求使得C++变成今天这个样子?

有许多特殊情况都需要对置为1的位计数,找到最高有效位,确定一个字的奇偶校验位,确定一个字的位是否是2的幂,等等。大多数这些问题都可以通过简单地遍历字中的所有位来解决。这种解决方法的时间开销为O(n),其中n是字的位数。也可能还有一些效率更高的迭代解决方法。但是对某些问题,还有更快更紧凑的闭形式解决方法:计算的时间开销为常量,不进行任何迭代。

例如,考虑一个简单的用于确定一个整数是否是2的幂的迭代算法。所有这些值都只有1个置为1的位,因此算出置为1的位的数量是一种解决方法。

代码清单7-20 判断一个整数是否是2的幂的迭代算法的一种实现
inline bool is_power_2_iterative(unsigned n) {
	for (unsigned one_bits = 0; n != 0; n >>= 1)
		if ((n & 1) == 1)
			if (one_bits != 0)
				return false;
			else 
				one_bits += 1;
	return true;
}

这个问题同样有一种闭形式解决方法。如果x是2的n阶幂,那么它只在第n位有一个置为1的位(以最低有效位作为第0位)。接着,我们用x-1作为当置为1的位在第n-1,……,0位时的位掩码,那么x&(x-1)=0。如果x不是2的n阶幂,那么它就有不止一个置为1的位,那么使用x-1作为掩码计算后只会将最低有效位置为0,x&(x-1)≠0。

代码清单7-21 判断一个整数是否是2的幂的闭形式
inline bool is_power_2_closed(unsigned n) {
	return ((n != 0) && !(n & (n - 1)));
}

其实还有更快的方法,下面的网站上记录了10种方法,而且都附有时间测量结果。

https://www.exploringbinary.com/ten-ways-to-check-if-an-integer-is-a-power-of-two-in-c/

对优化表达式感兴趣的开发人员,应该拥有一本Henry S. Warren, Jr的Hacker's Delight(中文版《高效程序的奥秘》)。同时这本书还维护了一个网站(http://hackersdelight.org/)。

优化控制流程惯用法

由于当指令指针必须被更新为非连续地址时,在处理器中会发生流水线停顿,因此计算比控制流程更快。C++编译器会努力地减少指令指针更新的次数。了解这些知识有助于我们编写更快的代码。

用switch替代if-else

if-else语句中的流程控制是线性的。如果测试一个变量的值n次,那么需要n个if-else分支。如果所有的条件为真的概率都是一样的,那么if-else将会进行O(n)次判断。如果这段代码执行得非常频繁,那么开销将会显著增加。

switch语句也会测试一个变量是否等于这n个值,但是由于switch语句的形式比较特殊,它用switch的值与一系列常量进行比较,这样编译器可以进行一系列有效的优化。

一种常见的情况是被测试的常量是一组连续值或是近似一组连续值,这时switch语句会被编译为jump指令表,其索引是要测试的值或是派生于要测试的值的表达式。switch语句会执行一次索引操作,然后跳转到表中的地址。无论有多少种要比较的情况,每次比较处理的开销都是O(1)。我们在程序中不必对各种要比较的情况排序,因为编译器会排序jump指令表。

如果这些被测试的常量不是连续值,而是互相之间相差很大的数值,那么jump指令表会变得异常庞大,难以管理。编译器可能仍然会排序这些要测试的常量并生成执行二分查找的代码。对于一个会与n个值进行比较的switch语句,这种查找的最差情况的开销是O(logn)。在任何情况下,编译器编译switch语句后产生的代码都不会编译if-else语句后产生的代码的速度慢。

有时,if-else语句的某个条件分支的可能性非常大。在这种情况下,如果首先测试最可能出现的条件的话,if语句的摊销性能可能会接近常量。

用虚函数替代switch或if

在C++出现之前,如果开发人员想要在程序中引入多态行为,那么他们必须编写一个带有标识变量的结构体或是联合体,然后通过这个标识变量来辨别出当前使用的是哪个结构体或联合体。经验丰富的开发人员都知道这个反模式是面向对象编程的典型代表。从性能优化的角度看,问题在于使用了if语句来识别对象的继承类型。C++类已经包含了一种机制来实现功能:需成员函数和作为识别器的虚函数表指针。

虚函数调用会通过索引虚函数表得到虚函数体的地址。这个操作的开销总是常量时间。因此,基类中的虚成员函数会被继承类重写。

使用无开销的异常处理

异常处理是应当在设计阶段就进行优化的项目之一。错误传播方法的设计会影响程序中的每一行代码,因此改造程序的异常处理的代价可能会非常昂贵。可以说,使用异常处理可以使程序在通常运行时更加快速,在出错时表现得更加优秀。

有些开发人员对C++的异常处理持有怀疑态度。一般认为,异常处理会使程序变得更加庞大和慢,因此关闭编译器的异常处理开关是一项优化。

其实真相比较复杂。确实,如果程序不使用异常处理,那么关闭编译器的异常处理开关可以使得程序变得更小,而且可能更快。C++标准库中的所有容器都使用new表达式来抛出异常。dynamic_cast运算符也会抛出异常。如果关掉了异常处理,无法确定当程序遇到异常被抛出的情况时会如何。

如果程序不抛出异常,它可能会完全忽略错误码。那么在这种情况下,开发人员就会得到报应了。另外一种情况是,程序必须在各层函数调用之间耐心地、小心地传递错误码,然后在调用库函数的地方将错误码从一种形式转换为另一种形式并相应地释放资源。而且,无论每次运算是成功还是失败,都不能遗漏这些处理。

如果有异常,处理错误的部分开销就被从程序执行的正常路径转移至错误路径上。除此之外,编译器会通过调用在抛出异常和try/catch代码块之间的执行路径上的所有自动变量的析构函数,自动地回收资源。这简化了程序执行的正常路径的逻辑,从而提升性能。

在C++的早期,每个栈帧都包含一个异常上下文:一个指向包含所有被构建的对象的链表的指针,因此当异常穿过栈帧被抛出时,这些对象也必须被销毁。随着程序的执行,这些上下文会被动态地更新。这并非大家所希望看到的,因为这导致了在程序执行的正常路径上增加了运行时开销。这可能会是高开销的异常处理之源。后来出现了一种新的实现方式,它的原理是将那些需要被销毁的对象映射到指令指针值上。除非抛出了异常,否则这种机制不会发生任何运行时开销。Visual Studio会在狗仔64位应用程序时使用这种无开销机制,而在构建32位应用程序时则会使用旧机制。Clang则提供了一个编译器选项让开发人员选择使用哪种机制。

不要使用异常规范

异常规范是对函数声明的修饰,指出函数可能会抛出什么异常。不带有异常规范的函数抛出异常可能不会有任何乘法。而带有异常规范的函数可能只会抛出在规范中列出的异常。但是如果它抛出了其他异常,那么程序会被terminate()无条件地立即终止。

异常规范有两个问题。一个问题是开发人员很难知道被调用的函数可能会抛出什么异常,特殊是在使用不熟悉的库时。这使得使用了异常规范的程序变得脆弱且可能会突然停止。第二个问题是异常规范对性能有负面影响。程序必须要检查被抛出的异常,就像是每次对带有异常规范的函数的调用都进入了一个try/catch代码块一样。

C++11弃用了传统的异常规范。

在C++11中引入了一种新的异常规范,称为noexcept。声明一个函数为noexcept会告诉编译器这个函数不可能抛出任何异常。如果这个函数抛出了异常,那么如同在throw()规范中一样,terminate()将会被调用。不过,不同的是,编译器要求将移动构造函数和移动赋值语句声明为noexcept来实现移动语义。在这些函数上的noexcept规范的作用就像是发表一份声明,表明对于某些对象而言,移动语义比强异常安全保证更重要。

小结

  • 除非有一些因素放大了语句的性能开销,否则不值得进行语句级别的性能优化,因为带来的性能提升不大。
  • 循环中的语句的性能开销被放大的倍数是循环的次数。
  • 函数中的语句的性能开销被放大的倍数是其在函数中被调用的次数。
  • 被频繁地调用的编程惯用法的性能开销被放大的倍数是其被调用的次数。
  • 有些C++语句(赋值、初始化、函数参数计算)中包含了隐藏的函数调用。
  • 调用操作系统的函数的开销是昂贵的。
  • 一种有效的移除函数调用开销的方法是内联函数
  • 现在几乎不再需要PIMPL编程惯用法了。如今程序的编译时间只有发明PIMPL的那个年代的1%左右。
  • double计算可能会比float计算更快。
posted @ 2020-06-09 18:59  睿阳  阅读(258)  评论(0编辑  收藏  举报