建模效率是CAD软件至关重要的指标,会极大地影响应用中的设计效率,进而决定工业产品的研发周期。鉴于CAD程序的复杂性,在编写完成之前,几乎很难准确判断这段程序是否符合预期的性能要求,往往需要通过实际测试来确认。
图1是提高程序性能的一般流程。第一步是测试总体性能,如果满足要求,则结束,否则,需要分析和定位问题,然后修改相应代码,再回到第一步重新测试,直到性能满足要求。当然,也可能存在一种情况,在现有方案下无法优化到目标性能,而面临推倒重来,这不是本文的关注点。在上述流程中,如何分析和定位问题、怎样修改代码是本文要讨论的重点。
1. 分析方法
不管是哪种分析方法,基本原则是不变的,那就是查找运行热点,或者说是定位到耗时最久的部分。这个原则是非常关键的,因为优化非热点代码的性价比可能非常低。比如,有一段代码明显有问题,但只占到总时间的10%,即使将这段代码的运行效率提高100%,也仅仅能提高总效率的5%。而如果能将占总时间80%的代码提高100%,那么就能使总效率提高40%。
分析热点的方法一般可以分成4种:
- 根据经验判断。这只适用于小段程序,且非常依赖工程师的经验。
- 查看汇编代码。由于汇编语言阅读性较差,这种方式一般只适用于深度优化。
- 编写测试程序,分解运行时间。这种方法直观且灵活度高,使用的最多。
- 运用统计和分析工具。本质上与上一种方法相同,可以减少甚至不写代码,表现形式一般也更加友好。
1.1. 计时工具
首先,我们先建立一下对计算机运行速度的大概印象,以计算两点之间距离为例,计算1,000,000次,需要多少时间呢?笔者所用电脑的表现是3ms左右。因此,如果想准确查找到热点,需要有高精度的计时工具。
以Windows系统为例,提供了多个计时器,比如clock_t。但是,clock_t的计时精度只有ms级别,通过上面的例子不难看出,如果距离的计算次数小于100,000次,clock_t就无法统计到了,因此无法满足优化性能的需求。Windows提供了高精度的性能计数器,通过QueryPerformanceCounter()和QueryPerformanceFrequency()两个函数实现,QueryPerformanceCounter()用来获取计数器当前值,QueryPerformanceFrequency()用来获取计数器的频率。实际使用中,在被测试程序之前和之后,分别调用QueryPerformanceCounter(),然后获得计数差值,除以计数器频率就是被测试程序的运行时间了。QueryPerformanceCounter()的分辨率小于1 us,计时精度可以到us级别。
实际应用中需要注意的是:
- 不要直接使用计数差值dif除以频率freq,这样得到的是以s为单位的整数值,很容易就得到0(运行时间小于1s)。如果想得到us时间,应该dif * 1000 * 1000 / freq。
- 即使计时精度为us级别,但依然会存在无法统计到的项,比如在for循环中统计点距离的时间,并累加起来,得到的数值也可能是0。
1.2. 时间分解
分析热点的核心思路就是将运行时间不断分解,一般是按照类或函数为单位模块。图2是对模块A运行时间的分解,首先,将A分解成3个子模块,分别统计运行时间,D的运行时间占比很低,而B、C的运行时间占比较多,并且相差不大。因此,需要B、C这2个子模块再次进行拆解,第三层的时间统计特点是:虽然E单次运行的时间占总时间不是很高,但是被不同的2个上级模块调用了,调用的总时间让E成为第三层的热点。继续分解E,可以看出R的时间占比很大。因此,子模块R是整个模块A的热点,是应该首先考虑优化的程序模块。
上面的分解思路是理想化的,实际过程中可能比较复杂。正如下面的代码,假设模块R中是一个for循环,其中调用了2个函数,一般的分解思路是:分别统计Fun1()和Fun2(),然后将单次调用时间累加。但是,如果2个函数单次运行的时间都在1us以下呢?累加的结果都是0。一种可行的方式是:首先,注释掉Fun2(),在for循环之外加计时代码,这样就可以统计出Fun1()的运行时间;然后,取消注释Fun2(),统计出两者的总运行时间,减去Fun1()的,就是Fun2()的运行时间。实际代码可能存在更加复杂的嵌套关系,可能需要开发人员设计更好的测试代码。
for (int i = 0; i < n; i++)
{
//计时开始
Fun1();
//计时结束
//累加时间
//计时开始
Fun2();
//计时结束
//累加时间
}
其它需要注意的是:
- 运行时间的统计和分解是一个易出错的工作,需要验证分解的正确性,确保所有子模块的时间之和近似等于父模块的时间。
- 鉴于CAD程序的复杂性,不要盲目自信,主观臆断一段程序是不耗时的,可能会错失优化性能的机会。
- 若有需要,可以在Debug模式下统计运行时间,但是,最终结果还是需要在Release模式下获取和验证,两种模式下的效率差异巨大,不同模式下得到的热点也可能截然不同。
1.3. 实用工具
上面提到的时间分解方法需要编写测试代码,这是一项比较繁琐和易出错的工作。现在有一些好用的工具,可以用来测试程序性能,比如Intel推出的VTune Profiler 和 Visual Studio中内置的性能探查器。VTune Profiler可以提供采集并分析丰富的、多层次的性能数据,对运行在Intel平台上的程序非常友好。Visual Studio内置的性能探查器,可以对内存、CPU、GPU、IO等进行数据采样,针对图2中的程序,图3和图4分别是性能探查器给出的火焰图和函数运行时间排序,从这两张图中很容易发现,程序运行的热点是函数R。
2. 修改代码
代码性能的优化没有固定的修改方式,可以从以下几个方面入手。
2.1. 减少重复操作
下面的代码是关于重复操作的2个典型例子。一个是重复计算,如果几何体是不变的,不应该在循环中计算包容盒,而应该挪到循环前面。另一个是反复申请内存,如果循环内使用的std::vector对象的大小是固定的,肯定是应该放在循环前面的,甚至在声明时设定大小。哪怕是不固定的,也可能要写在循环前面,只要当前循环需要的空间少于前一次的,就能避免再次申请内存。
//重复操作的代码
for (int i = 0; i < n; i++)
{
BoundingBox box = constGeometry.GetBoundingBox();
//使用 box
std::vector<int> vct;
//使用 vct
}
//重复利用的代码
BoundingBox box = constGeometry.GetBoundingBox();
std::vector<int> vct;
for (int i = 0; i < n; i++)
{
//使用 box
//使用 vct
}
上面的2个示例的影响范围比较小,只是将for循环之内的代码挪到前面。但是,实际编程遇到的问题会更加棘手,主要是因为复杂的调用关系:(1)广度,一个函数往往会调用多个函数;(2)深度,从第一个函数开始调用,调用层数非常多。图5简单说明了这一情况,函数A调用函数B,函数B调用函数C和D。假设在这几个函数中,都会用到某一计算结果(如上面的包容盒)或相似的内存空间(如上面的std::vector),那么就可能会出现重复操作的问题,而且这种情况在实际编程中非常常见。
在不做大范围的更改的前提下,可以采取下面2种方式:
- 通过传递参数的方式(如图5中A-B),将已有的计算结果或已申请的内存空间,传递给后续模块。
- 通过归纳公共接口(如图5中E),存储已有的计算结果或已申请的内存空间,并对需要的模块开放获取接口。
需要明确的一点是,要想重复利用已有资源,一般都会使程序模块之间的耦合度变高,这可能就是变快的代价。
2.3. 选择高效操作
自从上世纪40年代,冯 · 诺依曼提出了经典的计算机体系结构,虽然有所发展,但现代计算机的设计基本还在遵循这些关键原则。那么,我们的程序是不是也要适应某些与计算机体系相关的原则呢?是的,同样的需求可能会有不同的实现方式,根据计算特点,选择执行效率高的操作,可以提升程序运行的效率。
如图6所示,影响程序运行性能的最关键的2个部件是:存储器(内存)和运算器(CPU)。为了讲述方便,本文简单假设程序运行的过程是:存储器读取程序中的原始数据,并送入运算器中,运算器根据程序中的计算代码,完成计算任务。
本文仅打算给出几个选择高效操作的实例:
- 申请内存时,栈内存比堆内存快,条件允许的情况下,优先选择栈内存,但要注意栈溢出。
- 对于整数的2的n次幂乘法/除法,可以使用移位运算符(左移/右移)实现,速度更快。
- CPU和内存之间存在高速缓存机制,CPU访问内存数据时,如果缓存中已有,则CPU直接从缓存中读取数据,否则,才会从内存中读取,而缓存比内存快的多。正如下面的代码, i i i- j j j遍历要比 j j j- i i i遍历快的多,因为C++中的二维数组是按照行优先存储的, i i i- j j j遍历会提高数据缓存命中率。
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n ; j++)
{
arrData[i][j] = 0.0; //速度快
arrData[j][i] = 0.0; //速度慢
}
}
如何选择合适的、高效的操作/指令,是一个异常复杂和深入的主题,读者可以通过研读参考文献[2],获得更加系统、清晰的分析。
2.4. 指标权衡
对于一个CAD功能来说,除了效率,还有一些指标是必须要考虑的,而这些指标往往是与效率矛盾的,有时不得不权衡再三,做出取舍。
稳定性和精度。根据用户的指令,稳健且精确地求解各种问题是CAD设计软件发挥良好作用的基石,但这并不意味着要追求100%稳定和精确。如图7,计算一点
P
0
P_0
P0到曲线
C
(
u
)
C(u)
C(u)的最近点,以参数
u
u
u为自变量,可以得到
C
(
u
)
C(u)
C(u)上点到
P
0
P_0
P0的平方距离公式:
h
(
u
)
=
∣
∣
C
(
u
)
−
P
0
∣
∣
2
.
h(u)=||C(u)-P_0||^2.
h(u)=∣∣C(u)−P0∣∣2.
令
h
′
(
u
)
=
0
h'(u)=0
h′(u)=0,求解方程就可以得到极值点。首先,上述方程一般是一个高阶方程(5次以上),没有通解公式,只能使用数值方法得到近似解,而近似解的精度与迭代次数有关,进而影响计算效率,因此,CAD接口的设计目标一般是满足一定误差的最近点。其次,图中所示的情况,方程是有多解的(
P
1
P_1
P1和
P
2
P_2
P2),求解可能陷入局部最优。如何能保证找到
P
1
P_1
P1?有很多种补救的措施,也可以采用其它方法,但会不会导致效率低下,需要权衡。
适用场景。任何接口都是有适用场景的,适用场景越广泛,意味需要处理和检查的情况越多,可能会导致效率低下。考虑这样的需求:通过一根曲线,沿某一方向拉伸成曲面,拉伸曲面不能有自相交,否则会导致后续操作失败。那么,开发人员必须要对输入(曲线或方向)进行自相交检测吗,不一定,一种可能是真实使用场景中此曲线不可能在此方向下自相交,另一种可能是已经在其它接口中检查过了,当前接口就不用检查了,这属于开发约定,约定本接口不处理曲线有自相交的情况。
总结
- 程序性能优化应当从全局出发,不应该过于看重单一模块的性能提升还是降低。
- 关键是找到运行热点,而不是揪住每一处细节,否则只会事倍功半。
- 程序性能优化往往会使代码变得复杂或更难维护,优化的过程就是在不断的权衡过程中,找到能满足整体需求的平衡点,这是个循序渐进的过程。
参考文献
[1] C++应用程序性能优化,冯宏华等,电子工业出版社。
[2] 深入理解计算机系统(第3版),Randal E.Bryant等,机械工业出版社。
文章首发于微信公众号:CAD智造干将,欢迎关注