C程序性能优化方法(一)

2.1 内容概要

  • 写程序的主要目标是使它能在所有可能的情况下都正确工作。程序员必须写出清晰简洁的代码,便于自己看懂及维护代码,也是为了他人能够快速检查代码和维护代码。
    程序运行得快是一个很重要的考虑因素,编写高效程序需要做到以下几点:
  • 1)我们必须选择一组适当的算法和数据结构;
  • 2)我们必须编写出编译器能够有效优化以转换成高效执行代码的源代码;该部分需要对优化编译器的能力和局限性的理解很重要;
  • 3)针对处理运算量特别大的计算,将一个任务分成多个部分,该部分可在多核和多处理器的某种组合上并行的运算。
  • 程序优化的第一步就是消除不必要的工作,让代码尽可能有效的执行所期望的任务,这包括消除不必要的函数调用、条件测试和内存引用。注意这些优化并不依赖于目标及其的任何具体属性。
  • 程序优化的第二步要了解处理器的运作,利用处理器提供的指令级并能能力,同时执行多条指令。程序员和编译器都需要理解目标机器的模型,指明如何处理指令,以及各个操作的时序特性。例如:编译器必须要知道时序信息,才能够确定是用一条乘法指令,还是用移位和加法的某种组合。现代计算机用复杂的技术来处理机器级程序,并行地执行许多指令,执行顺序还可能不同于他们在程序中出现的顺序。
  • 研究程序的汇编代码表示是理解编译器以及产生代码会如何运行的最有效手段之一。而仔细研究内循环的代码是一个很好的额开端,识别出降低性能的属性,例如过多的内存引用和对寄存器使用不当。从汇编代码开始,我们还可以预测什么操作会并行的执行,以及他们会如何使用处理器资源。然后,我们在回过头来修改源代码,试着控制编译器使之产生更有有效率的实现。

2.2 优化编译器的能力和局限性

  • 现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及他们是被如何使用的。然后会利用一些机会来简化表达式,在几个不同的地方使用同一个计算,以及降低一个给定的计算必须被执行的次数。GCC编译器向用户提供了一些对他们所使用的优化的控制,最简单的控制就是制定优化级别。例如:
  • -Og 调用GCC是让GCC使用一组基本的优化;
  • -O1 -O2 -O3 调用GCC让他使用更大量的优化。
  • 以上做法可以进一步提高程序的性能,也可能增加程序的规模,也可能使标准的调式共走更难对程序进行调式。而大多数使用GCC的软件项目,优化级别-O2已经成为了被接收的标准。
  • 另外编译器必须很小心的对程序只使用安全的优化,也就是说对于程序可能遇到的所有情况,在C语言标准提供的保证之下,优化后得到的程序和为优化的版本有一样的行为。限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因,但这也意味着程序员必须花费更大的力气写出编译器能够将之转换成有效及其代码的程序。
  • 为了理解决定一种程序转换是否安全的难度,可看以下两个过程:
	void twiddle1(long *xp, long *yp)
	{
		*xp += *yp;
		*xp += *yp;
	}

	void twiddle2(long *xp, long *yp)
	{
		*xp += 2 * *yp;
	}
  • 初步看这两个过程似乎有相同的行为,都是将yp所指向内存的值两次加到指针xp所指向的内存中。仔细一看,函数twiddle2效率更高一些。它只要求3次内存引用(读xp,读yp,写xp),而twiddle1需要6次(2次读xp,2次读yp,2次写xp)。于是,如果编译器编译过程twiddle1,会认为基于twiddle2执行的计算能产生更有效的代码。
  • 但是考虑xp = yp的情况,twiddle1的计算结果是xp所指向内存的值的4倍,twiddle2计算结果则是xp所指向内存的3倍。而编译器不知道twiddle1会被如何调用,因此在编译过程则必须假设参数xp和yp可能会相等,其将不能产生twiddle2风格的代码作为twiddle1的优化版本。
  • 这种两个指针可能指向同一个内存位置的情况称为内存别名使用,在只执行安全的优化中,编译器必须假设不同的指针可能指向内存中的同一位置。这就造成了一个主要的妨碍优化的因素,其可能严重限制编译器产生优化代码机会的程序的一个方面,限制了可能的优化策略。
    示例:
	void swap(long *xp, long *yp)
	{
		*xp = *xp + *yp;	/* x+y */
		*yp = *xp - *yp;	/* x+y-x = y */
		*xp = *xp - *yp;	/* x+y-x - x */
	}

如果调用这个过程时xp=yp,会有什么样的效果?

第二个妨碍优化的因素是函数调用。示例如下:

	long f();

 	long fun1()
	{
		return f()+f()+f()+f();
	}

	long fun2()
	{
		return 4*f();
	}
  • 初步看以上两个过程计算都是相同的结果,但是fun2只调用fun1次,而fun1需调用fun4次。以fun1作为源代码时,会很想产生fun2风格的代码。
    但是,考虑下面f的代码:
	long counter = 0;
	
	long f()
	{
		return counter++;
	}
  • 这个函数就会有副作用——它修改了全局程序状态的一部分。改变调用他的次数会改变程序的行为。特别地,假设开始时全局变量counter=0,对fun1的调用会返回0+1+2+3=6,而对fun2的调用会返回4*0=0。

  • 大多数编译器不会试图判断一个函数是否有没有副作用,其将会假设最糟的情况,就是保持所有的函数调用不变,也就是fun1的源代码不会被优化成像fun2中的样子,以提高程序的性能。

  • 备注:使用内联函数替换优化函数调用
    包含函数调用的代码可以用一个称为内联函数替换的过程进行优化,此时,将函数调用替换为函数体。我们通过替换掉对函数f的四次调用,展开fun1的代码:

	long fun1in()
	{
		long t = counter++;	/* +0 */
		long t = counter++;	/* +1 */
		long t = counter++;	/* +2 */
		long t = counter++;	/* +3 */
	
		return t;
	}
  • 以上的做法减少了函数调用的开销,也允许对展开的代码做进一步的优化。于是编译器可以统一fun1in中对全局变量counter的更新,产生这个函数的一个优化版本:
	long fun1opt()
	{
		long t = 4 *counter + 6;
		counter += 4;
		
		return t;
	}
  • 在编译时使用命令行选项“-finline”、“-O1”或者更高级别编译选项时,最近的GCC版本会尝试进行以上形式的优化。目前来说,GCC只尝试在单文件中定义的函数的内联。其将无法应用于常见的情况,及一组库函数在一个文件中定义,却被其他文件内的函数所调用。
  • 然而在某些情况下,最好能够阻止编译器执行内联替换。一种情况是用符号调试器来评估代码,如GDB调试程序,如一个函数已经用内联替换优化过了,则任何对这个调用进行追踪或设置断点的尝试都会失败。另外一种情况是用代码剖析的方式来评估程序的性能。
  • 针对以上的描述,我们可知就优化能力来说,GCC编译器被认为是胜任的,但其优化性能并不是特别的突出,他能完成基本的优化,但不会对程序进行更加“有进取心的”编译器所做的那种激进变换。因此,我们在编写代码要是一种不会让编译器产生歧义的代码,在不影响功能的基础上尽可能的编写出一种能让编译器生成高效的代码。

你可能感兴趣的:(程序性能)