提高C++性能的编程技术笔记:内联+测试代码

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fengbingchun/article/details/85030305

内联类似于宏,在调用方法内部展开被调用方法,以此来代替方法的调用。一般来说表达内联意图的方式有两种:一种是在定义方法时添加内联保留字的前缀;另一种是在类的头部声明中定义方法

虽然内联方法的调用方式和普通方法相同,但其编译过程却相差甚远。由于内联方法的代码必须内联展开,这就要求调用内联方法的代码段必须有权访问该内联方法的定义。而内联方法的定义需要整合到其调用方法之中,这就使得任何针对内联方法的更改,都将引起所有调用该方法模块的重新编译。所以,内联在显著提升性能的同时,也增加了编译时间。编译时间的增加有时是适度的,但有时却极大,并且在大多数极端情况下,对内联方法的一处修改可能会要求整体程序重新进行编译。因此,将内联过程搁置到代码开发阶段后期是明智的做法

 从逻辑上说,编译器将方法内联化的步骤如下:首先将待内联方法的连续代码块复制到调用方法中的调用点处。然后在块中为所有内联方法的局部变量分配内存。之后将内联方法的输入参数和返回值映射到调用方法的局部变量空间内。最后,如果内联方法有多个返回点,将其转变为内联代码块末尾的分支。经过这样的处理即可消除所有与调用相关的痕迹以及性能损失。避免方法调用仅仅是内联可提升的性能空间的一半。调用间(cross-call)优化是内联可提升的性能空间的另外一半。优秀的、经过优化的编译器可以使内联方法的边界痕迹难以区分。方法中大量的甚至是所有的代码经过优化后都将不复存在,因为编译器可能会对方法中大部分代码进行重新排列。因此,尽管在逻辑上可以将方法内联化看作是对一定内聚度的维持,不过编译器并未强制执行这种优化措施,这也是内联的优点之一。

大部分系统都有3或4个”内务处理”寄存器:指令指针(Instruction Pointer,常被称为程序计数器----Program Counter,但其功能并非对程序进行计数),链接寄存器(Link Register),栈指针(Stack Pointer), 帧指针(Frame Pointer)以及自变量指针(Argument Pointer),分别可以记作IP、LR、SP、FP以及AP。

指令指针(IP):存放下一条将要执行的指令地址。调用方法时,程序要跳转到被调用方法的指令并修改IP。但不能只是简单地重新IP。重写前必须先保存其旧值,否则无法返回至原调用方法。

链接寄存器(LR):存储某一方法的IP的地址,该方法对当前方法进行了调用。这个地址就是方法执行完毕后返回的地方。LR通常和体系结构调用指令的操作绑定在一起,执行调用操作时其值会被自动设定。LR是单个寄存器而非多寄存器集合。因此,如果某方法调用了其它方法,则必须保存LR的值以防止其被重写,因为调用者标识消失后,就很难有效地从调用中返回。在某些体系结构中,LR的功能是通过自动或显示地调用方法IP压入程序进程堆栈来实现的,因此这些体系结构不存在明确的LR。

栈指针(SP):方法的局部(自)变量是在进程堆栈上分配的。而SP的功能就是跟踪记录堆栈的使用情况。调用操作会消耗堆栈空间,返回操作则会释放之前分配的堆栈空间。类似于调用者IP和LR,调用返回之后,必须根据传递到堆栈的参数来进行可能的调整以恢复堆栈。这就意味着SP也必须被保存为方法调用的一部分。

自变量指针(AP)和帧指针(FP)的存在随系统而异。某些体系结构不包含这两个寄存器,某些仅包含一个,而另外一些则两者兼备。FP的作用是标识堆栈中两个区域的边界:第一个区域供调用方法用来保存需要记录状态的寄存器;第二个区域为被调用方法的自变量分配内存。在方法执行期间SP一般会频繁地变化。FP通常被当成方法中局部变量的固定引用指针。

良好的调用性能要求系统只保存方法用到的寄存器。每次调用都保存全部寄存器是无谓的浪费,但是只保存部分寄存器会导致在传给方法的参数和分配给方法自变量之间产生潜在的内存分配。如果数量可变的寄存器存储和某个给定的方法调用相关联(也即寄存器值存储的数量依赖于调用方法的状态),就需要用AP指出传给方法的参数在堆栈中的位置。

使用寄存器的典型调用顺序如下:

(1). 调用方法整理需要传给被调用方法的参数。此步骤通常意味着要将参数压栈,压栈时一般采用倒序。所有参数入栈后,SP将指向第一个参数。

(2). 把将要返回的指令地址压栈,然后调用指令跳转到被调用方法的第一条指令处。

(3). 由被调用方法在堆栈中保存调用方法的SP、AP以及FP,并调整每个”管家”寄存器以反映被调用方法的上下文环境。

(4). 同时,被调用方法保存(压入堆栈)其将会用到的所有其它寄存器(此步骤是必要的,以便被调用方法返回后不会中断调用方法的上下文环境,通常要保存另外的3或4个寄存器)。

清除调用的典型返回序列如下:

(1). 如果方法有返回值,则通常将该返回值存储到寄存器0(有时是寄存器1)中。这就意味着寄存器0和寄存器1必须是暂时性寄存器(此类寄存器的存储和恢复不是方法调用和返回的一部分)。通过寄存器使得返回操作的堆栈清理工作更加容易。

(2). 将由于方法调用而保存的寄存器从堆栈中恢复至其初始位置。

(3). 将保存的调用者的FP和AP寄存器值从堆栈中恢复到其相应的位置。

(4). 修改SP使其指向将方法第一个参数压栈前的位置。

(5). 从堆栈中找到返回地址并将其存入IP,强制返回至调用者中紧接调用点的位置。

简单算一算单次方法调用过程中的数据移动次数可以看出,6~8个寄存器(4个寄存器用于维护现场,2~4个供方法使用)被保存过,其中4个稍后被修改过。通常情况下这些操作至少需要12个时钟周期(实际中数据移入/移出内存很少只花费单个时钟周期),有时甚至会消耗多达40个时钟周期。因此,就机器时钟周期方面的花费而言,与方法调用相关的操作的代价非常昂贵。不幸的是,以上所述只是所有开销的一半。方法返回时,为调用过程所做的工作必须全部撤销。之前保存的值必须从堆栈中恢复,机器状态也必须恢复到和调用前类似。这就意味着单次方法调用通常需要消耗25~100个时钟周期,有时这甚至仅是保守估计。之所以说是保守估计,部分原因与参数的准备及获取有关。被压栈的参数作为调用开始阶段的一部分,通常直接映射到被调用方法的内存映像中。对于引用来说始终如此,而对于指针和对象则是有时如此。因而会产生额外的调用开销,这种开销与调用前参数的压栈以及被调用方法将其从堆栈中读回的操作相关。某些情况下可以通过寄存器进行参数传递,虽然这种机制可以提供很好的性能特性(尽管不无代价),但是公认的机制还是使用内存来传递参数。

如果方法有返回值,特别当其返回值是一个对象时,被调用方法将对象复制到调用方法为返回值预留的存储空间中也是一笔开销。对于较大的对象而言,这笔额外开销将更加客观,尤其是使用复杂的拷贝构造函数执行该任务时(这种会产生两份调用/返回开销:一份开销是因为对方法的显示调用,另一份则是拷贝构造函数返回一个对象时产生的开销)。如果将所有调用者/被调用者的通信因素和系统维护因素也考虑在内,方法调用的代价大约为25~250个时钟周期。通常被调用方法越大,所产生的开销也越大,取决于保存恢复所有寄存器、传递大量参数、调用自定义方法以构造返回值等操作的最大开销。

使用异常处理可以显著降低内联返回值优化的性能。从逻辑上来说,返回值的复制工作是作为被调用方法返回过程一部分的、由拷贝构造函数执行的原子操作。这就意味着如果返回前有异常被抛出,则返回值将不会返回,而且存放方法返回值的变量也不会改变,从而导致异常发生时,必须为返回值使用复制语义。这在某些情况下也成为避免使用异常处理的正当理由。理想情况下,为达到优化目的,如果存在一些语法标记以允许异常发生时对返回值进行优化,将会带来极大便利。某些针对异常发生时的返回值优化已经可以实现。例如,如果返回值变量的作用域和内联方法处在同一try代码块内,则返回值可被优化。不幸的是,尽管这种情形在大多数情况下可以轻易确定,但其要求的调用间优化往往代价高昂且实现起来十分复杂。

内联的另一个好处是无须跳转执行被调用方法。跳转,即便是无条件跳转,都会对现代处理器的性能产生负面影响。频繁跳转会造成执行流水线迟滞,这是因为预取缓存中没有需要执行的指令。跳转还需要运算单元为其确定跳转目标地址,使指令直到跳转地址可知方可执行。流水线的延迟意味着处理器将会因为大量时间被用于重定向指令流而处于闲置状态。每次方法调用时这种情况都会发生两次----方法被调用阶段和返回阶段。

在某些情况下,方法调用最大的代价就是无法对跨越方法边界的代码进行优化。

内联可能是C++中可用的最有效的性能提升机制。通过内联的方式,无须进行任何重写即可使大型的系统迅速提升性能。

内联是一种由编译器/配置器/优化器执行的、基于编译和配置的优化操作

保留字”inline”仅表示对编译器的一种建议。它告诉编译器,将方法代码内联展开而不是调用可以获得更佳性能。但是编译器没有义务答应内联请求。因此编译器可以根据自己的意愿或者能力来选择是否进行内联。这就意味着即使没有被明确告知需要内联(对低价值方法编译器会自动内联,这常常是优化的副作用)编译器也会这样去做,或者即便被明确告知需要内联却不进行内联

内联还会引起一些值得注意的副作用:从逻辑上来说,虽然经常被存放于单独的.inl文件中,但其实内联方法的定义应为类头文件的一部分。头文件及其逻辑上包含的.inl文件随后被用到它们的.c或.cpp文件包含。源文件被编译为目标文件后,就不需要在目标文件做任何标示以说明目标文件包含哪些内联方法了。也就是说,通常情况下,目标文件已完全解析了内联方法且不需要再对其存在性进行保存(不存在链接需求)。因此,尽管C++语言明文禁止,但是源文件仍然可以和内联方法的定义一起编译,而另一源文件也可以和另一版本不同但方法相同的文件一起编译。

如果编译器足够完善,许多对虚方法的调用是可以内联化的。因此,如果配置文件指出某些虚方法需要占用程序过多的运行时间,则可通过将部分方法调用内联化来挽回一些开销。这也说明如果编译器有能力并且选择了将虚方法内联化,那么几乎可以保证一定会有一些针对同一方法的内联调用实例以及虚方法调用实例。

内联就是用方法的代码来替换对方法的调用

内联通过消除调用开销来提升性能,并且允许进行调用间优化

内联的主要作用是对运行时间进行优化,当然它也可以使可执行映像变得更小

调用间(cross-call)优化:面向某一方法的调用过程,基于对上下文场景更加全面的理解,使得编译器在源代码层面及机器代码层面对方法进行优化。这种优化的一般形式为:在编译期间进行一部分预处理,从而避免在运行时重复类似的过程。内联的这类优化应是编译器的职责,而不是程序员的

与避免方法调用这种简单的做法相比,调用间代码优化更可能获得巨大的性能提升。但从另一个角度来看,避免方法调用获得的性能提升是确定的,虽然有时效果并不尽如人意,但这种做法具有普遍性。代码优化与编译器密切相关,高层次的优化方法将会使编译过程变得漫长,实际上有时还会打乱代码。

何时避免内联:当程序中所有能够内联的方法都进行内联,代码膨胀将不可估量,这将对性能产生巨大的二次负面影响,如缓存命中问题和页面错误,而这些将令我们的工作得不偿失。另一方面,滥用的内联程序将执行较少的指令,但会耗费较多的时钟周期。内联的滥用导致的缓存错误会使性能锐减。代码膨胀所带来的副作用可能是无法承受的。

内联所引发的代码膨胀现象有时会导致另一类退化特征。将某个方法内联可能会导致指数级的代码膨胀。这种现象通常会发生在相对大规模的例程互相内联的情况下。

内联方法不仅在实现层面会产生编译依赖,在接口层面亦然。正因如此,对于在程序开发阶段经常发生变动的方法,不应将其列入可内联的范畴。可以用一条规则来总结:能够缩减代码大小的内联都是可取的,而任何显著增大代码大小的内联都是不可取的。第二条有用的规则:如果方法的实现是易变的,则不应将其内联。

通常应避免递归方法内联

从逻辑上讲,内联方法应定义在其类的头文件中。这对于使用内联方法代码体的那些调用者是必要的。不幸的是,一旦内联方法的内容有所改变,都将导致用到内联方法的相关模块重新编译----是重新编译而不只是重新连接。对于大规模程序来说,由于每次编译都会带来额外的时间消耗,这无疑增加了程序的开发时间。

针对内联方法的调试较为复杂,因为单个断点无法跟踪内联方法的入口和出口

内联方法通常并不出现在程序的配置表中(配置表基于某些示例程序的模板,显示程序的执行行为)。配置表有时无法察觉对内联方法的”调用”。

基于配置的内联:配置是寻求适合内联的方法的最佳手段,尤其当我们拥有可以用来产生配置数据的代表性数据样本时,配置的优势将会更加明显。配置(Profiling)是一种依靠配置工具(软件包)的性能测试技术,通过为程序生成工具代码(插入测试代码),在程序样本执行期间使性能具备某些特征。生成配置的数据样本质量直接决定了配置文件的质量。配置文件的形式和大小不拘一格,其可能的输出范围也是千差万别,然而一般来说,所有的配置文件都应至少提供如:哪些方法正在执行,这些方法多久被调用一次等相关信息。

配置文件要同时兼顾指令数的计算和时间的度量。时间是一种更为精确的度量指标,但指令数的生成更为简单,并且它所提供的数据可以用做内联决策的依据。

编译器通常禁止内联复杂的方法

内联规则

(1).唯一化(singleton)方法:是指方法在程序中的调用点是唯一的,而它并不代表方法在程序执行过程中只被调用一次。某个方法或许会出现在循环中,被成千上万次调用,但是只要其在程序中的调用点唯一,我们就称其为唯一化方法。唯一化方法具备与生俱来的内联特性。唯一的调用点意味着我们不需要考虑方法的大小和调用频率,对于内联后的唯一化方法,其代码将比原先更小,运行更快。或许由此获得的性能提升并不明显,但是有一点要清楚,我们在内联方面付出的努力并不总是能带来性能上的提升。一般来说,唯一化方法的鉴别较为困难,方法的唯一化往往是临时的,并且与环境相关,而有时,唯一化是设计的产物。

(2).精简化(trivial)方法:都是一些小型方法,通常包含4条以下的源代码级语句,这些语句被编译后将形成10条以下的汇编指令。这些方法包含的语句很少,从而产生代码膨胀的可能性几乎为零。将小规模的精简化方法内联实际上将减少代码量,将较大规模的精简化方法内联可能会使总体代码量略微增加。总的来说,将精简化方法进行内联的最终效果不会影响代码大小。

直接量参数与内联结合使用,为编译器性能的大幅提升开辟了更为广阔的空间。

使用内联有时会适得其反,尤其是滥用的情况下,内联可能会使代码量变大,而代码量增多后会较原先出现更多的缓存失败和页面错误

非精简方法的内联决策应根据样本执行的配置文件来制定,不能主观臆断。

对于那些调用频率高的方法,如果其静态尺寸较大,而动态尺寸较小,可以考虑将其重写,从而抽取其核心的动态特性,并将动态组件内联。

精简化与唯一化方法总是可以被内联

条件内联:编译、调试和配置等过程与内联是有一些冲突的,做这些工作时,都希望将内联决策推迟到开发周期的后期,在大部分调试工作完成之后进行。预处理可以协助我们实现在内联与外联之间的轻松转移。这项技术的基本思路是利用编译器行参数向编译器传递一个宏定义。输入参数用来定义名为INLINE的宏,也可以忽略这个参数而不定义INLINE。这种技术基于对两种定义的划分,即需要内联的方法及需要外联的方法。外联方法包含于标准的.c文件中,需要内联的方法放置在.inl文件中。如果对.inl文件中的方法内联,可以在编译命令中使用-D选项来定义INLINE宏。

选择性内联:内联机制的语法和机动性是C++最严重的缺陷之一。尽管通常情况下它很有用,但却存在一个令人头疼的缺陷,即它没有针对选择性内联的机制;所谓选择性内联,是指在某些场合下对方法进行内联而在另外一些场合则不然。这种缺陷使得内联决策成为非全则无的选择,从而忽略了快速路径优化的真实情况。

递归内联:直接递归方法是无法内联的。尾部递归是递归方法中的一种。它表现为方法在达到它的基线条件之前一直递归下降,当到达基线条件后执行一些操作并终止方法,可能还会返回一个值。典型的二叉树搜索就是一个很好的尾部递归方法的例子。

对静态局部变量进行内联:对基于编译器的内联解决方案来说,局部静态变量可能会造成很大问题。这是因为一些编译器会拒绝内联任何包含静态变量声明的方法。还有一些编译器允许内联静态变量,但是在运行时它们会错误地为这些内联变量创建多个实例。

与体系结构有关的注意事项:对于各种不同体系结构来说,它们的调用/返回性能不尽相同。

内联可以改善性能。目标是找到程序的快速路径,然后内联它,尽管内联这个路径可能要费点工夫。

条件内联可以阻止内联的发生。这样就减少了编译时间,同时也简化了开发前期的调试工作。

选择性内联是一种只在某些地方内联方法的技术。在对方法进行内联时,为了抵消可能的代码尺寸膨胀的影响,选择性内联只在对性能有重大影响的路径上对方法调用进行内联。

递归内联是一种让人感觉别扭,但对于改善递归方法性能却很有效的技术

内联的目标是消除调用开销。在使用内联之前须先弄清当前系统中真正的调用代价。

以下是测试代码(inline.cpp):

#include "inline.hpp"
#include <iostream>
#include <chrono>
#include <string>
#include <random>
#include <cmath>

namespace inline_ {

// reference: 《提高C++性能的编程技术》:第八、九、十章:内联
//////////////////////////////////////////////////////////////
void generator_random_number(double* data, int length, double a, double b)
{
	//std::random_device rd; std::mt19937 generator(rd()); // 每次产生不固定的不同的值
	std::default_random_engine generator; // 每次产生固定的不同的值
	std::uniform_real_distribution<double> distribution(a, b);
	for (int i = 0; i < length; ++i) {
		data[i] = distribution(generator);
	}
}

double calc1(double a, double b) // 非内联
{
	return (a+b);
}

inline double calc2(double a, double b) // 内联
{
	return (a+b);
}

int test_inline_1()
{
	using namespace std::chrono;
	high_resolution_clock::time_point time_start, time_end;
	const int count{2000};
	const int cycle_number1{count}, cycle_number2{count}, cycle_number3{count};
	double x[count], y[count], z1[count], z2[count];

	generator_random_number(x, count, -1000., 1000.);
	generator_random_number(y, count, -10000, 10000.);
 
{ // 测试简单的非内联函数调用执行时间
	time_start = high_resolution_clock::now();
	for (int j = 0; j < cycle_number1; ++j) {
		for (int i = 0; i < cycle_number2; ++i) {
			for (int k = 0; k < cycle_number3; ++k) {
				z1[i] = calc1(x[k], y[k]);
			}
		}
	}
	time_end = high_resolution_clock::now();
 
	fprintf(stdout, "z1: %f, %f, %f, no inline calc time spent: %f seconds\n",
		 z1[0], z1[1], z1[2], (duration_cast<duration<double>>(time_end - time_start)).count());
}

{ // 测试简单的内联函数调用执行时间
	time_start = high_resolution_clock::now();
	for (int j = 0; j < cycle_number1; ++j) {
		for (int i = 0; i < cycle_number2; ++i) {
			for (int k = 0; k < cycle_number3; ++k) {
				z2[i] = calc2(x[k], y[k]);
			}
		}
	}
	time_end = high_resolution_clock::now();
 
	fprintf(stdout, "z2: %f, %f, %f, inline calc time spent: %f seconds\n",
		z2[0], z2[1], z2[2], (duration_cast<duration<double>>(time_end - time_start)).count());
}

	return 0;
}

} // namespace inline_

执行结果如下:

GitHub: https://github.com/fengbingchun/Messy_Test 

展开阅读全文

没有更多推荐了,返回首页