利用pre-compiled headers技术以加速编译速度
--以Borland C++ Builder为例
(二)
本文作者:王森
台湾交通大学科技管理研究所
moli.mt88g@nctu.edu.tw
<初见pre-compiled headers技术>
过去我们撰写C/C++程序时,每个档案都必须利用编译器指令 #include 引入许多的系统标头文件才能够使程序顺利编译,接着经由连结产生执行档。假如我们的Project(程序项目)存有两个档案a.cpp以及b.cpp,当我们在a.cpp里面用到getch()这个函式,我们就必须在使用a.cpp的开头处写着: #include <conio.h>
否则编译器一定告诉我们这个函式没有定义。同样地,即使b.cpp 这个档案和a.cpp同属一个Project之中,档案里面只要有用到getch()这个函式,一样得在b.cpp的开头处写着: #include <conio.h>
当编译器编译a.cpp的时候,编译器必须编译conio.h一次,接着编译b.cpp的时候,同样必须重新编译conio.h一次。因此,一旦a.cpp引入更多和b.cpp相同的标头档,也就代表编译器将浪费许多时间在编译同样的标头档上。举例来说,如果我们的Project里面有十个cpp档同时引入相同的标头档,那幺就代表编译的时间有9/10都因为被用来编译相同的标头档而浪费掉了。因此BCB引进了pre-compiled headers技术,主要就是为了解决这个会使得编译器做过多重复的编译工作而导致编译过程漫长的问题。
所谓的pre-compiled headers技术,在笔者的记忆中是从BCB 3.0开始引进的概念,其实概念很简单,就是”预先编译标头档”的意思。以我们拿刚刚提到的例子来说,编译器第一次编译a.cpp的时候,就会因为使用pre-compiled headers技术,会先把conio.h的编译结果先”cache”起来,然后等到待会编译b.cpp的时候,编译器会发现conio.h已经先被编译过了,因此编译器就直接把刚刚cache起来的conio.h编译结果直接拿来使用,这样一来就省掉了了大量的编译时间,而程序设计师就可以从此向冗长的编译过程说bye bye。
在BCB之中,pre-compiled headers技术是透过编译器指令#pragma hdrstop来达成,从BCB的help里面得知,出现在这个编译器指令之前的标头文件即代表告知编译器要使用pre-compiled headers技术来加速编译。但是事情并没有我们想象的那幺单纯,所以接下来笔者会花很长的篇幅来探讨编译器指令#pragma hdrstop对编译效能所带来的影响。
<pre-compiled headers技术对编译速度的影响 - 1>
首先,我们先按照<前置作业>所提到的方式,删掉所有在编译过程之中产生的所有档案(*.exe 、 *.obj 、 *.tds ,请注意 , *.tds必须关掉整个Project之后才能删除,也请大家先删除BCB所在目录的Lib子目录里面的*.csm以及*.#??,『?』代表是一个0~9的数字)。
接下来,我们做个简单的测试,用干净的程序原始码(就是尚未编译过的原始码)来做编译速度的测试,以下的数据以笔者的计算机输出结果为基准,笔者的计算机配备为: Intel PIII 450 、 胜创PC100 128MB RAM 、华硕P2B主机板、操作系统是Windows 2000 Professional:
程序代码1:
#include <iostream.h>
#include <vcl.h>
#pragma hdrstop
#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}
测试结果1:
使用build
使用make
编译次数
编译行数
编译时间
编译次数
编译行数
编译时间
第一次
419266
7.90
第一次
419266
7.73
第二次以后
510638
8.48
第二次以后
0
0.14
程序代码2:
#pragma hdrstop
#include <iostream.h>
#include <vcl.h>
#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}
斜体字部分为与程序代码1不同之处
测试结果2:
使用build
使用make
编译次数
编译行数
编译时间
编译次数
编译行数
编译时间
第一次
419266
5.60
第一次
419266
5.86
第二次以后
510638
4.42
第二次以后
0
0.15
提醒读者一点,空白行也算被编译器在编译行数中,所以大家的测试结果在编译行数上可能会有些微的差距。
从上面的列表可以得到以下结论:
对这两组测试程序而言,第一次编译的时候不管用make或是build,其编译速度几乎没差别。但是第二次以后的编译,使用make的编译速度压倒性的快,而且快很多,原因请读者参照一些介绍make的相关书籍。在这里笔者介绍O’Reilly (台湾欧来礼) 出版的Managing Project with make,这本书在台湾欧来礼的网站www.oreilly.com.tw似乎有看到会出现中文本的消息。
把标头文件放在编译器指令 #pragma hdrstop之前在第一次编译所花的时间要比把标头文件放在编译器指令 #pragma hdrstop之后要久。咦? 之前不是还提到利用pre-compiled headers技术会加快编译速度,怎幺经过实验之后发现竟然编译速度变慢了呢? 要找寻原因,我们可以使用windows开始菜单里面的 搜寻/档案或资料夹 功能,搜寻BCB所在目录,将搜寻日期限定为您计算机上目前的日期,读者就可以发现,一旦您把标头文件放在编译器指令 #pragma hdrstop之前,在BCB所在目录下的Lib子目录就会出现vcl50.#00以及vcl50.csm两个档案,而且档案的size还蛮大的,但是如果把标头文件放在编译器指令 #pragma hdrstop之后,这两个档案并不会出现(如果读者在测试的时候先测试程序代码1,再测试程序代码2,那幺这两个档案依旧会出现,因为这两个档案并不会因为重新打开Project或是重新开启BCB而被删除,因此读者看到的可能是前次编译所产生的vcl50.#00以及vcl50.csm)。
如此一来,我们可以大胆地推测:之所以把标头文件放在编译器指令 #pragma hdrstop之前在第一次编译所花的时间会比较长,是因为编译器花了额外的功夫去产生这两个档案。因为编译后所显示的编译时间并非只有单纯的编译时间,还包括了连结目的档以产生执行档所耗费的时间。精确的说,应该是『从开始编译原始码到产生最后的执行档总共所花的时间』,所以前面的测试数据会给大家一种『使用pre-compiled headers技术反而会减慢编译速度』的假象。
读者在前面所看到测试结果1所得的数据,是笔者每次测试过后,除了删掉编译时产生的*.obj 、 *.tds 、 *.exe档之外,还外加删掉vcl50.#00以及vcl50.csm两个档案所测得。如果在测试程序代码1/使用make的时候,笔者没有删除测试程序代码1/使用build的时候所产生vcl50.#00以及vcl50.csm这两个档案,则测试结果1的数据会变成:
测试结果3:
使用build
使用make
编译次数
编译行数
编译时间
编译次数
编译行数
编译时间
第一次
201723
13.57
第一次
17
1.87
第二次以后
201723
13.98
第二次以后
0
0.14
是什幺原因导致结果有所差异呢? 因为笔者一开始先使用build指令测试编译效能,然后关掉Project,删掉编译过程中产生的.obj 、 .tds 、 .exe档,然后重新开启Project,再使用make指令测试编译效能,此时,由于之前build时所产生的vcl50.#00以及vcl50.csm这两个档案依旧留在硬盘中,所以make时编译器就直接拿来用啦! 也因此我们看到编译器只编译了17行就结束。由此我们更可以证明, vcl50.#00以及vcl50.csm这两个档案就是我们所谓的cache档,而它们的作用就是让编译器可以减少编译的标头文件数目以加速编译。
由以上所得到的结论告诉我们,如果接下来我们要做pre-compiled headers技术对编译效能所产生影响之编译效能评估,应该在第一次编译的时候使用build指令,第二次以后都使用make指令,这样才能精确地测出pre-compiled headers技术对编译效能所带来的改善,因为从数据中我们可以看出,build指令会让编译器从头到尾重新编译一次,所以只看build之后产生的结果是没有意义的。
不过,有时候重头到尾重新编译整个系统也是在所难免。比方说我们一旦把程序从debug版本变成release版本,或是把程序从release版本变成debug版本,之后的第一次编译,即使我们使用make来编译程序,编译器所花的时间和使用build来编译的结果是一样的,都是重头到尾重新编译一次。我们还是可以利用pre-compiled headers技术让这种从头到尾的编译可以更快,在本篇文章的后面会提到。
<pre-compiled headers技术对编译速度的影响 -2>
前一段里面的测试程序只有一个单一的程序原始文件,接着我们来试试如果Project里面有多个程序原始文件的时候会有何种情形。为了避免情况复杂,我们只测试Project里头有两个程序原始文件的情况。首先,请使用 File/New新增一个Unit:
并将档案存成Unit2.cpp,此时我们Project之中就多出了两个档案,分别是Unit2.h以及Unit2.cpp,他们的内容如下:
程序代码3:
Unit2.h
#ifndef Unit2H
#define Unit2H
void test(void) ;
#endif
Unit2.cpp
#include <vcl.h>
#include <stdio.h>
#pragma hdrstop
#include "Unit2.h"
void test(void)
{
printf("test") ;
}
测试结果4:
编译次数
编译行数
编译时间
第一次(build)
375564
10.03
第二次(make)
0
0.16
我们观察BCB所在目录之下的Lib目录,此时会发现之前的测试只有多两个档案,而这次的测试竟然又多了一个档案,他们分别是: vcl50.csm、vcl50.#00、vcl50.#01。
这样的测试结果似乎没有什幺结论,所以我们在第二次编译之后,第三次编译之前,把Unit2.cpp的内容修改如下:
程序代码4:
#include <vcl.h>
#include <stdio.h>
#pragma hdrstop
#include "Unit2.h"
void test(void)
{
printf("test") ;
printf("test1") ;
}
则测试结果变成:
测试结果5(接测试结果4):
编译次数
编译行数
编译时间
第三次(make)
30
1.19
这跟我们预期的结果相同,pre-compiled headers技术完全发挥了缩短编译时间的功能。
后来笔者在Project多加了几个Unit来测试,证明BCB除了会产生vcl50.csm之外,他会为每个Unit都产生一个vcl50.#??的档案(这里的前提是:每个Unit所引入的标头档彼此都不同,如果相同的话,会有另外一种情况发生),如果我们有三个Unit,他就会在第一次build的时候产生vcl50.#00、vcl50.#01、vcl50.#02,然后加上原本一定会产生的vcl50.csm,总共就会有4个cache档案,因此我们几乎可以认定『编译器会为每一个使用编译器指令#pragma hdrstop的档案产生一个cache文件,以加速编译』这个我们所见的事实。也就是说,如果我们把Unit2.cpp里头的编译器指令#pragma hdrstop拿掉,那幺每次我们修改Unit2.cpp之后所测得的结果应该是:
测试结果6:
编译次数
编译行数
编译时间
第三次(make)
173843
2.69
而不是前面测试结果5的测试结果。因为没有预先编译好的cache檔,所以Unit2.cpp在修改程序后,必须从头到尾从新编译。
笔者的另外一个测试,是先在Unit2.cpp中使用编译器指令#pragma hdrstop,然后用make编译执行档,让编译器帮我们产生Unit2.cpp的cache档,笔者并没有删除任何的vcl50.#??档案。接着我将Unit2.cpp里头的编译器指令#pragma hdrstop拿掉,虽然每次原始码的更改都会造成编译行数多达173843上下,可是在make几次之后,如果笔者重新将编译器指令#pragma hdrstop放回Unit2.cpp之中原来的位置(也就是在#include <vcl.h>与#include <stdio.h>之下),则每次修改程序之后编译的结果会比较接近测试结果5。
在此我们暂且先把编译器指令#pragma hdrstop之前所有引入的标头档档名所构成的集合称做『预先编译标记』,我们大胆假设编译器会帮我们把这个标记记录在cache文件里头,以方便下次编译器在编译其它档案的时候作为辨识用。
è我们根据上面的假设归纳了一个暂时的结论,就是:编译器每次在编译程序原始码的时候,一般都是重新编译所有的标头档。但是,如果程序原始文件中含有编译器指令#pragma hdrstop,那幺编译器就会去寻找Lib目录底下的vcl50.#??,看看这些档案是否符合目前的『预先编译标记』,如果符合,那幺编译器就直接引用之前编译后留下的cache,因而省下许多重新编译标头档的时间,如果没有任何cache文件一个符合标记,那幺编译器仍然会重头开始编译所有的标头档,并在编译后自己产生一个和这个程序原始文件有相同『预先编译标记』的cache文件,待下次有程序原始文件的预先编译标记和这个cache文件的预先编译标记相同时,编译器就会直接引用这个cache檔。