[TOC]
性能
一、程序性能分析
本章作者通过科学家 Appel 对三维空间中的 n 个物体的运动仿真程序的优化案例,介绍了程序优化的几个切入点:
- 问题定义:原话是“良好的问题定义可以避免用户对问题需求的过高估算”。也就是说接到一个问题时,也许问题本身就存在问题,也就是说可能在问题产生阶段可以通过一些简单的机制变动或措施就可以简单地解决或者显著简化问题,而无需使用非常复杂的程序手段去解决。因此,在开始程序设计之前,理解问题的本质也很重要。另外,程序设计阶段结合问题的具体场景,将问题的上下文因素考虑进来,例如输入数据的特点,也可能找到程序优化的突破口;
- 系统结构:合理的系统架构,模块结构可以提升程序的调试、执行、调优效率;
- 算法和数据结构:采用更高效的算法可以大幅提升程序执行效率,例如将 N2 复杂度的算法替换为 NLogN 复杂度的算法就足以是程序执行效率产生质的飞跃;
- 代码调优:对代码的少量改进也可能显著提升程序运行效率;
- 系统软件:考量是否有更快的操作系统、数据库系统,编译器选项是否有优化的空间;
- 硬件:采用更高硬件配置固然能提高程序的运行效率,而且采用更适配场景的硬件也可以加快程序的运行。例如,选用附带浮点加速器的 CPU 可以提高浮点运算的效率,使用 GPU 计算多并行流水线作业或相互独立的小粒度海量任务,效率可能会比 CPU 更高;
二、粗略估算
本章开篇作者即抛出了一个估算密西西比河每天流入大海的水量的问题。文中介绍了两种方法:
- 已知密西西比河出海口宽度大约为 1 英里,约 20 英尺深(约等于 1/250 英里),且猜测河水流速约为 5 英里/小时,则每天流入大海的水量为:1 英里 × (1/250) 英里 × 5 英里/小时 × 24 小时 ≈ 1/2 英里3/天;
- 已知密西西比河流域的面积约为 1000 英里 × 1000 英里,每年的降雨量约为 1 英尺(约等于 1/5000 英里),因此一年的降雨量为 1000 英里 × 1000 英里 × (1/5000) 英里/年 ≈ 200 英里3/年,每年约有 400 天,则每天流入大海的水量估算为 (200 英里3/年) / (400 天/年) = 1/2 英里3;
作者最后还根据密西西比河某年年鉴录得的每秒排水量 640,000 立方英尺,更精确的计算出密西西比河每天流入大海的水量也恰巧是 1/2 英里3。
虽说巧得实在有点牵强🤣,但作者的实际意图在于说明:基于经验和已知数据作出的合理估算,可以非常接近基于实验数据得到的精确结果。因此估算对于开发者评估程序性能、资源损耗等指标具有十分重要的参考意义,估算是程序员的必备能力之一。
2.1 基本技巧
关于估算的基本技巧作者介绍了总结了以下几点:
- 两个答案总比一个答案好:由于估算是根据有限的现有数据计算一个粗略的结果,因此方法有很多,得出的未必是精确的答案,但如果方法正确至少可以接近答案,因此经验和练习对提高估算的精度非常重要,思考多个答案恰恰能开拓思维,达到练习和积累经验的效果;
- 快速检验:快速检验是为了以最快的速度排除错误的估算结果,以及以最快的速度检验估算结果的可参考性。文中提出了几种方法:1、量纲检查(下文注意中有说明);2、对于乘法,可只取第一个数位和指数来快速得到相近结果,对于加法,可通过结果位数、末位校验等方式快速校验;3、根据常识快速校验,文中的例子是“密西西比河每天流出的水量为 450 升”,恐怕傻子都知道这个结论错得离谱;
- 经验法则:这里作者介绍了两种实用且有趣的经验法则:1、“72法则”,来自金融投资领域,用于估算指数型增长过程,例如,如果某个基金的年化收益率为 12%,则投资者今年投入的资金在 6 年后可以实现翻倍(12 × 6 = 72),如果年化收益率是 4%,则需要 18 年才能翻倍;2、在误差不超过千分之五的情况下,“π 秒等于一个纳世纪”(100 年 = 3.14 × 109 秒),面对一个非常大的单位为秒的时间,运用该经验法则可以将其快速转换为年、月、天这种粒度更大的单位,最主要的是这个法则比起死记 1 年 = 3.155 × 107 秒要来得简单而且有趣;
- 实践:估算技巧只能通过实践来提高,日常生活中有很多练习估算能力的机会;
注意:翻译版本冒出了“量纲”这个词,实际上并不是什么深奥的东西,它的意思是在计算过程中所有数据都要带上单位,估算结果的单位正确才能保证估算结果有参考价值。“量纲”上需要满足两条原则:1、加式中各量纲必须相同,例如,英里 + 英里 = 英里,英里不能与磅详加,英里也不能与英尺直接详加;2、乘式结果的量纲是各乘数的量纲乘积,例如,英里 × 英里 = 英里2,英里 / 秒 = 英里/秒。
2.2 性能估计
关于性能估计作者列举了三个常见的指标:
- 运行内存:估算运行内存需要开发者对编程语言的数据结构有深刻的认知基础,例如,C 语言结构体的内存对齐原则,数组的连续内存分配,不同的整型数的占用字节数、函数的运行栈空间分配、递归程序的栈空间消耗,Objective-C 类的实例占用内存空间等等,知晓单个数据的内存占用才能估算程序运行所占用的内存;
- 运行时长:运行时长主要通过“单次运算的大致时长 × 运算的执行次数”得出,得出的大量级的秒时间,可以利用前面介绍的“π 秒等于一个纳世纪”原则转化为单位粒度更粗的时间;
- 网络传输时长:网络传输速率主要取决于两个因素:1、通信主机之间的空间距离,例如美国东西海岸相离 5000 英里,则网络信号以光速在东西海岸的往返时长约为 27 毫秒;2、网络带宽,100 Mbps 带宽的信道每秒可以传送约 10 MB 的内容;
2.3 安全系数
计算的输入决定了其输出的质量,基于良好的数据,简单的计算也可以得到精确的计算结果,这些计算结果有时特别有用(原话)。当我们对估算的结果没有足够的自信时(当然估算结果与实际的偏差也不能太离谱),需要在安全系数上保留足够大的余量,也就是安全系数足够大以至于能够弥补设计阶段的估算结果的不准确性。这样即使程序本身存在设计缺陷,但是在安全系数的护航下,仍然能保证程序正常运行。
2.4 Little定律
本章作者又介绍了一种有趣的估算技巧。Little 定律的含义是:队列中物体的平均数量为进入速率与平均停留时长的乘积。
文中首先举了个不太“正经”的例子。某个夜店的最多能容纳 60 人(队列长度),每个人在夜店中的逗留时长平均为 3 小时(停留时长),则根据 Little 定律平均每小时进入夜店的人数为 20 人,因此可以根据前面排队的人数估算出自己大概需要等多长时间,假设在你前面正在排队进入夜店的有 20 人,则可以估算大约需要等 1 小时。
然后作者又举了一个“根正苗红”的例子,不妨将其简化一下。假定 n 个户同时登录到响应时间为 r 的系统,根据 Little 定律此时系统的吞吐量 x = n/r。用户可以容忍的平均响应时长可以根据经验或调查得出,用户登录数峰值是直接给定的指标,此时可以估算出可以保证系统在用户登录来临时,仍能在用户忍耐范围内响应的吞吐量峰值。可以此作为系统吞吐量的最低设计指标。
2.5 小结
在进行粗略估算的时候要切记爱因斯坦的名言:“任何事情都应尽量简单,但不宜过于简单”。估算并不是特别简单,其中还包含安全系数的考量,以补偿估算参数时的错误以及对问题的了解不足。
- 首先需要认识到估算能力的重要性;
- 灵活使用各种经验法则,例如,72法则、π 秒等于一个纳世纪、Little 定律;
- 通过实践提高估算能力,把握日常生活中各种联系估算能力的机会;
- 对于不够自信的估算结果要设置足够高的安全系数以抵消或减轻估算误差带来的影响;
三、算法设计技术
本章作者选取的例子是讨论算法时间复杂度问题时经常会选用的经典例子:求最大子序列和问题。具体是:
输入具有 n 个浮点数的向量 x,输出是输入向量的任何连续子向量中的最大和。例如,如果输入向量如下包括 10 个元素:
{31, -41, 59, 26, -53, 58, 97, -93, -23, 84},则最大子序列和为x[2..6],为 187。注意:本题有个重要的前提条件,当所有输入都是负数时,总和最大的子向量为空向量,总和为 0,也就是说最大子序列和的最小值是 0。
3.1 方法一:暴力计算
可以使用“暴力计算”的方式,遍历向量的所有子序列组合,筛选出最大子序列和,子序列可以用(i, j)表示,假设向量长度为 n,则遍历所有子序列组合的时间复杂度是 n2,因此整个算法的时间复杂度是 N2,该实现的 C 语言代码如下:
/** 暴力计算最大子序列和 */
double maxSubarraySum_Violant(double* array, int length){
// 记录最大子序列和,初始化为 0,因为最大子序列和的最小值是 0
double maxSofar = 0;
for(int i = 0; i < length; i++){
// 计算以索引 i 为起始的子序列最大和
double sum = 0;
for(int j = i; j < length; j++){
sum += array[j];
maxSofar = MAX(sum, maxSofar);
}
}
return maxSofar;
}
3.2 方法二:分治算法
运用分治的思想和递归的方式,可以将算法的时间复杂度降到 NLogN。首先将向量 x 以向量的中点划分为 xl 和 xr 左右两个子序列,则最大和子序列要么全在 xl 中,要么全在 xr 中,要么横跨 xl 和 xr。因此可以将求解 x 向量的子序列最大和问题,划分为三个子问题:
- 子问题一:求解 xl 的子序列最大和;
- 子问题二:求解 xr 的子序列最大和;
- 子问题三:求解横跨 xl 和 xr 的子序列最大和;
求解上诉三个子问题,取三者中的最大值就可以得到 x 向量的子序列最大和。子问题一和子问题二可以通过递归方式求解,递归的出口是向量长度为 0 或 1 的情形。此时可以暂时搁置子问题三(标记为 TODO),先写出算法的实现框架:
/** 利用分治思想计算最大子序列和 */
double maxSubarraySum_DevideAndConquer(double* array, int length){
// 1. 空向量最大子序列和为 0
if(length == 0)
return 0;
// 2. 只包含一个元素的向量最大子序列和是元素值和 0 之间的大者
if(length == 1)
return MAX(0, array[0]);
// 3. 取向量中点划分左右两个子序列
int mid = length / 2;
// 4. 计算横跨左右子序列的最大子序列(子问题三的结果)
double maxAcross = maxAcrossSubarraySum(array, length);
// 5. 返回左子序列的子序列最大和、右子序列的子序列最大和、横跨左右子序列的子序列最大和,之中的最大者
double left = maxSubarraySum_DevideAndConquer(array, mid); // 子问题一的结果
double right = maxSubarraySum_DevideAndConquer(array + mid, length - mid); // 子问题二的结果
return MAX(MAX(left, right), maxAcross);
}
double maxAcrossSubarraySum(double* array, length){
// TODO: 实现 maxAcrossSubarraySum 函数求解子问题三
}
于是只剩下一个问题:如何计算横跨 xl 和 xr 的子序列最大和。首先给出计算方法:横跨 xl 和 xr 的子序列最大和等于,“以向量 x 的中点为终点的子序列最大和”,加上“以向量 x 的中点为起点的子序列最大和”。可以使用反证法证明该结论。
反证过程:如果“以向量 x 的中点为终点的子序列最大和”,加上“以向量 x 的中点为起点的子序列最大和”,不是横跨 xl 和 xr 的子序列最大和,则必然存在更大的“以向量 x 的中点为终点的子序列和”,或者“更大的以向量 x 的中点为起点的子序列和”。直接与题设矛盾。
此时可以实现maxAcrossSubarraySum函数:
/** 求解子问题三 */
double maxAcrossSubarraySum(double* array, int length){
int mid = length / 2;
double maxSumLeft = 0, maxSumRight = 0;
// 1. 计算以向量的中点为终点的子序列最大和
double sum = 0;
for(int i = mid - 1; i >= 0; i--){
sum += array[i];
maxSumLeft = MAX(maxSumLeft, sum);
}
// 2. 计算以向量的中点为起点的子序列最大和
sum = 0;
for(int i = mid ; i < length; i++){
sum += array[i];
maxSumRight = MAX(maxSumRight, sum);
}
// 3. 返回两者之和
return maxSumLeft + maxSumRight;
}
注意:将这个算法分为两个函数实现只是为了可以更好地理解这个问题的求解过程,原文中是用一段简短的伪代码表示的。
方法二的求解过程,实际是将主问题划分为三叉树树型结构的子问题集合,树的叶节点为空向量或元素数量为 1 的子问题的求解、以及子问题三的求解。若向量的元素个数为 N,则三叉树的高度平均为 Log2N,每层的计算主体是问题三叶子节点的求解,三叉树每层的问题三节叶子点平均计算次数是 N,因此算法整体时间复杂度为 NLogN。下图为向量{31, -41, 59, 26, -53, 58, 97, -93, -23, 84}的完整问题分解示意图。
注意:上图中红色标记的节点表示需要分治的子问题,黄色标记是子问题三对应的叶子节点,绿色标记是递归的出口,为向量长度为 0 或 1 的子问题一、子问题二对应的叶子节点,问题数的高度平均为 Log2N。可见问题求解的主体是子问题三的求解,每层节点的问题三求解的计算次数平均为 N,则问题三的总计算次数约为 NLog2N。子问题一、子问题二叶子节点的计算次数为 N。分治次数约为 N。因此,算法总时间复杂度为 NLogN。
3.3 方法三:线性扫描算法
使用线性扫描的方式,只要遍历向量一次,就可以计算向量的子序列最大和,算法时间复杂度可以降到 N。首先需要确立几个结论:
- 结论一:最大和子序列要么为空向量,否则首尾元素必定是正数元素;
- 结论二:最大和子序列的首元素,要么是总序列的首元素,否则其上一个元素必定是非正元素;
- 结论三:最大和子序列的尾元素,要么是总序列的尾元素,否则其下一个元素必定是非正元素;
由上面的结论不难推断出以下推论:
- 推论一:最大和子序列的如果包含某个正数元素,则最大和子序列必然也包含“该正数元素所在的最长正数子序列”;
- 推论二:最大和子序列的如果包含某个负数元素,则最大和子序列必然也包含“该正数元素所在的最长负数子序列”;
注意:介绍下最长正数子序列的概念。直接举例子,向量
{31, -41, 59, 26, 53, 58, -97, -93, -23, 84},正数元素 59 所在的最长正数子序列就是{59, 26, 53, 58},负数元素 -23 的最长负数子序列是{-97, -93, -23}。
基于上面的推论,在求解子序列最大和时完全可以将连续的正数元素累加合并为单个正数元素,连续的负数元素累加合并为单个负数元素。因此序列可以转化成正数、负数相间的序列,以简化问题的理解过程。
现在开始考虑数组的线性扫描过程。根据结论一,若序列的首个合成元素为非正元素,则可以直接排除该元素,因此扫描必定从序列的首个正数元素开始。这就意味着所有扫描序列都可以统一表示成{正1, 负1, 正2, 负2, 正3, 负3, ... }。不妨设置maxSumEndingHere变量记录以当前扫描元素为终点的子序列最大和,maxSumSoFar记录当前探测到的子序列最大和。开始逐个元素扫描:
第一步:已扫描子序列为{正1}
- 更新
maxSumEndingHere = 正1,maxSumSoFar = 正1;
第二步:已扫描子序列为{正1, 负1}:
-
若
正1 + 负1 > 0:则正1仍然是包含负1元素的最大和子序列的起点,更重要的是正2不可能是最大子序列和的起点(用反证法:若以正2为起点的最大子序列和为X,则X + 正1 + 负1 > X直接和题设相矛盾),更新maxSumEndingHere = 正1 + 负1,而maxSumSoFar保持不变。这个过程实际上是将正1和负1两个元素合并成(正1 + 负1)一个整体的正元素; -
若
正1 + 负1 <= 0:则以正1为起点的最大和子序列必定不包含负1元素(用反正法:若以正1为起点,且包含负1元素的子序列最大和为X,则X + 正1 + 负1 < X直接和题设相矛盾)。此时由于maxSumSoFar已经记录了以正1为起点的且不包含负1元素的子序列最大和,因此可以直接排除正1和负1,maxSumEndingHere清零;
第三步:已扫描子序列为{正1, 负1, 正2}:
-
若
正1 + 负1 > 0:当前扫描序列实际上转化为{(正1 + 负1), 正2},可以直接合并成{(正1 + 负1 + 正2)},更新maxSumEndingHere = 正1 + 负1 + 正2,比较maxSumEndingHere和maxSumSoFar,若maxSumEndingHere大于maxSumSoFar则更新maxSumSoFar = sum; -
若
正1 + 负1 <= 0:当前扫描序列实际上转化为{正2},更新maxSumEndingHere = 正2,比较maxSumEndingHere和maxSumSoFar,若maxSumEndingHere大于maxSumSoFar则更新maxSumSoFar = maxSumEndingHere;
行至第三步,已扫描的向量子序列,要么合成为单元素向量,要么被排除为单元素向量,后续继续扫描负2、正3、负3等元素时,实际上就是不断重复第二步和第三步的元素合成和排除过程。扫描完整个向量,得到的maxSumSoFar就是向量的子序列最大和。以下两个推论,可以直接证明线性扫描算法可行性:
- 推论三:
maxSumEndingHere记录的总是“以当前扫描元素为终点的子序列的最大和”。可以用反正法证明:假设maxSumEndingHere对应的子序列中,存在“和更大的以当前扫描元素为终点的子序列” Xright,则maxSumEndingHere对应的子序列可以表示为 {Xleft, Xright},其中 Xleft 的和为负数,因此要么 Xleft 为仅包含一个负数元素的序列,与结论一矛盾,要么 Xleft 至少存在一次排除行为,与算法的基本行为(若存在排除则清空maxSumEndingHere的值)相矛盾; - 推论四:
maxSumSoFar记录的总是“截至当前扫描元素的子序列的最大和”。由于maxSumEndingHere记录过所有已扫描元素的“以当前扫描元素为终点的子序列的最大和”,而且maxSumSoFar与其中的每个maxSumEndingHere都做过比较,显然成立;
算法实现如下:
double maxSubarraySum_LinearScanning(double* array, int length){
int maxSumEndingHere = 0, maxSumSoFar = 0;
for(int i = 0; i < length; i++){
// 1. 元素合并
maxSumEndingHere += array[i];
if(maxSumEndingHere > 0){
// 2. 更新包含当前扫描元素的子序列最大和
maxSumSoFar = MAX(maxSumSoFar, maxSumEndingHere);
}else{
// 3. 元素排除
maxSumEndingHere = 0;
}
}
return maxSumSoFar;
}
/** 与上面的程序等价的更精简的代码 */
double maxSubarraySum_LinearScanning2(double* array, int length){
int maxSumEndingHere = 0, maxSumSoFar = 0;
for(int i = 0; i < length; i++){
maxSumEndingHere = MAX(maxSumEndingHere + array[i], 0);
maxSumSoFar = MAX(maxSumSoFar, maxSumEndingHere);
}
return maxSumSoFar;
}
本章求解最大子序列和问题,将算法的时间复杂度逐步从 N2 降到 NLogN 再到 N。实际上算法从 N2 进化到 NLogN 就可以让算法性能产生质的飞跃,而线性算法则可以将算法的效率提升到极致。
注意:运行时间为 O(n) 的算法称之为线性算法。
3.4 小结
本章最后作者给出了几个重要的算法设计技术:
- 保存状态,避免重复计算:花费可接受的内存空间保存中间处理结果,避免在重复的计算上花费不必要的时间。上面的扫描算法就是使用了简单的动态规划形式;
- 将信息预处理至数据结构中:花费可接受的内存空间保存预处理数据,有时可以显著提高算法的时间效率,这是一种用空间换时间的思路;
- 分治算法:试图将问题划分为若干子问题,再尝试使用递归的方式求解子问题,使用分治算法通常要结合递归,使用递归时,必须对递归的出口有清晰的认知;
- 扫描算法:与数组相关的算法通常可以通过思考“如何将 x[0...i-1] 的解扩展为 x[0...i] 的解”来解决。并结合存储已有答案以及其他辅助数据等措施来解决。;
- 累加算法:维护一张累加表(向量 x 的累加表 A 满足:A[i] = x[0] + x[1] + ... + x[i]),有时能简化算法的计算过程;
- 下界:知道算法的下界后,才能确定算法是否还有进一步优化的需要;
四、代码调优
程序员很容易陷入两个极端:要么过于在于细小的优化导致程序过于精妙难以维护;要么很少关注程序的性能,程序有漂亮的结构但是效率太低导致完全不实用。优秀的程序员应该把效率纳入整体考虑。代码调优是更接近底层的程序性能优化方法,需要程序员对程序语言的底层实现有一定的了解。代码调优首先确定程序中开销较大的代码,然后进行少量的修改,以提高其运行速度。
4.1 四个例子
文章首先举了四个例子:
- 第一个例子意在说明定位开销较大的代码的重要性,是代码调优推进的基础;
- 第二个例子意在说明程序员对开销较大的语句要有预判能力。例如,
%取模运算要比+、-、*、/基本运算的运行时间长大约 10 倍;由于调用函数需要额外的分配栈空间以及保存上下文的操作,因此宏的运行效率要高于相同功能的函数,但是有时使用宏也可能拖慢程序的运行速度; - 第三个例子意在说明将循环过程展开可以提高程序的运行效率。例如,[0...8n] 的循环过程,若将每 8 个迭代过程合并成 1 次迭代,则循环过程可以转换为 n 次迭代。其原理是,将循环展开有助于避免管道阻塞、减少分支、提高指令级并行性;
- 第四个例子意在说明转变数据的表示形式(数据结构)并缓存可以提升运算速度的数据,有时也能提升程序的运行性能。这是典型的用空间换时间的策略。该例子是,计算球面上的包含 5000 个点的点集 S 中,距离点 p 最近的点。若直接计算经纬度的两点的球面距离需要调用 10 个三角函数,而三角函数的运算效率是很低的。优化的方法是,首先调用 3 个三角函数将经纬度形式的点数据转化为三维坐标 (x, y, z) 保存到数组,计算三维空间中两点的距离则不需要使用三角函数,只要使用 x2 + y2 + z2 比较即可;
注意:为什么说有时使用宏也可能拖慢程序的运行速度?以上一章分治算法解决最大子序列和的程序为例,若使用宏
#define max(a, b) ((a) > (b) ? (a) : (b))代替数学函数max,例如a是递归表达式b是可以直接计算结果的表达式,若a > b则递归表达式a会执行两次,第二次显然是冗余的计算,会显著降低程序运行效率。使用max函数则不存在这个问题。
4.2 二分搜索的代码调优
文章随后介绍了对长度为 1000 的升序数组的二分搜索算法的调优,意思是即使时间复杂度上已经达到极限,但通过代码调优还能进一步提升效率。以下是未经优化的原始代码:
int binarySearch1(int* x, int n, int t){
int l = 0; int u = n - 1; int p = -1;
do {
if(l > u){
p = -1;
break;
}
int m = (l + u) / 2;
if(x[m] < t){
l = m + 1;
}else if(x[m] == t){
p = m;
break;
}else{
u = m - 1;
}
} while (1);
return p;
}
上面的程序中,对x[m]和t的比较分支有三个,实际上用两个判断分支就可以实现。上面的第二个if判断的 fast exit 并不能提升程序的平均效率。改进判断逻辑后的程序如下所示:
int binarySearch2(int* x, int n, int t){
int l = -1; int u = n;
while(l + 1 != u){
int m = (l + u) / 2;
if(x[m] < t){
l = m;
}else{
u = m;
}
}
int p = u;
if(p >= n || x[p] != t)
p = -1;
return p;
}
作者继续对程序作了进一步改造,不过不是为了提升程序的效率,而是为了更好的理解最终的优化程序。最终的优化程序利用了数组长度为 1000 的前提条件。基于前面介绍过的“展开循环过程有利于提高算法的运行效率”的方法,在探测过程中保持目标探测范围的元素数量为 2k,将上述循环过程展开为 10 个if判断。例如,第一探测探测范围为 [0...999],探测目标定为x[511],若x[511] < t则将范围缩小为 [488...999](488 = 1000 - 512),若x[512] >= t则将范围缩小为 [0...511];从第二次探测探测开始,探测范围长度就可以固定为 2k,因此只要记录范围左边界 left,右边界可通过左边界和范围长度得出 right = left + 2k。最终优化程序如下:
int binarySearch3(int* x, int n, int t){
int l = -1;
if(x[511] < t)
l = 1000 - 512;
if(x[l+256] < t)
l += 256;
if(x[l+128] < t)
l += 128;
if(x[l+64] < t)
l += 64;
if(x[l+32] < t)
l += 32;
if(x[l+16] < t)
l += 16;
if(x[l+8] < t)
l += 8;
if(x[l+4] < t)
l += 4;
if(x[l+2] < t)
l += 2;
if(x[l+1] < t)
l += 1;
int p = l + 1;
if(p > 1000 || x[p] != t)
p = -1;
return p;
}
由此可见,代码调优所做的程序改动是非常精细的。个人观点,在最终确定程序的主体处理逻辑和算法事件复杂度,程序在达到稳定运行的前提下,才能进入代码调优过程。否则,过早的对程序进行代码调优,可能会造成代码难以维护的问题。
4.3 小结
- 效率的角色:软件的其他许多性质和效率一样重要,甚至更重要。不成熟的优化是大量编译灾害的根源,它会危机程序的正确性、功能性以及可维护性。当可能的危害影响较大时,请考虑适当将效率放一放;
- 度量工具:当确定对程序调优时,首先需要对系统性能进行监测和度量,性能监测可以帮助我们找到程序中的关键区域,对其他区域则遵循“没有坏的话就不要修”;
- 设计层面:效率问题可以通过很多其他方法来解决,只有在确定没有更好的解决方案时才考虑进行代码调优;
- 双刃剑:上面介绍的代码调优的套路并不总是有效。例如,有时算法中的
%模运算并不是程序中的关键代码,对程序运行时长影响微乎其微;使用宏代替函数有时能提升程序运行效率,有时也可能拖慢程序的运行速度。总之,调优过程中必须确立具有代表性的程序输入,时刻监控程序的性能。调优固然重要,但必须重视规避调优可能带来的风险。
五、节省空间
即使随着计算机的性能的提升,内存造价越来越低廉,程序也越来越少为内存空间所掣肘。但是,更简单的程序意味着更好的维护性,控制内存空间有利于提升程序加载及运行效率,而且可以适配更多硬件配置较低的平台。因此节省空间仍然很重要。
在系统及其软件方面,总是存在着相当严重的空间约束。如果同时对合理的效率和强大的能力提出要求,那么空间约束不仅具有经济上的意义,还会使设计更优雅一些。来自 Dennis Ritchie 和 Ken Thompson。
5.1 简化问题
在节省空间的问题上,简单的设计很重要。文中的举的例子是,本来问题的数据需要使用二维数组来表示,后来通过调查问题背景,发现了另一种简单的计算方式而且占用内存空间也更小。因此在问题本身做功课,简化问题也是节省空间很重要的一种思路。
5.2 数据结构
作者举了一个存储地理邻居的问题。相邻关系可以用二维数组表示,例如,存储 1000 个地理位置的相邻关系,可以用 1000 * 1000 的二维数组 A 表示,索引 A[i][j] = 1 则表示位置 i 和位置 j 相邻。这种表示方式存在问题,二维数组是很花费空间的,当用于保存稀疏矩阵时空间浪费问题尤其严重。
保存稀疏矩阵比较合理的方式是使用链表法。首先用一个一维的长度为 1000 的数组保存 1000 个地理位置。数组的元素除了保存地理位置数据外,还保存一个链表的头节点,链表保存该地理位置所有邻居的引用以及相邻关系的其他数据(例如距离)。使用这种数据结构所占的内存空间要远远小于上面的二维数组方式。另外,选择该方法中数组和链表的元素的数据结构也会影响程序的内存损耗,例如,能使用char表示的数据就不要使用int表示。
5.3 数据空间技术
本节介绍的是减少程序运行内存的几种技术:
- 不存储,重新计算。这种方法采用的是用时间换空间的策略,适用于空间限制条件严格,时间限制条件相对宽松的场景。例如对于跨网络运行的程序,数据传输时间是处理耗时的主体,因此减少传输数据量是更应该关心的的问题,此时可以考虑采用“不存储重新计算”的方式;
- 稀疏数据结构:上一节的链表法实际就是一种稀疏数据结构,但稀疏数据结构不仅限于此。例如使用指针(对象的引用)来共享大型对象、当语音数据的音量达到临界点以下时采用简单的数据表示形式发送静音;
- 数据压缩:对于占用空间较大的数据,可以考虑使用压缩编码的方式保存数据,在使用时再解码压缩数据得出源数据再使用;
- 分配策略:分配策略是内存空间是使用方式,对于大量的内存空间(尤其是可变长的),可以使用动态分配的策略在需要时才进行分配;
- 垃圾回收:不需要使用的内存资源要及时释放,尤其是占用内存较大的对象,和较大的集合类型数据;
5.4 代码空间技术
本节介绍的时减少程序本身所占用内存的几种技术:
- 函数定义:将相同模式的代码抽象为函数,可以减少程序本身占用的空间,同时增加代码的清晰性,注意定义宏函数并不能实现这种效果;
- 解释程序:解释型语言语法通常更为精炼,尤其是 python、javascript 等描述型语言,因此可以减少程序本身占用的空间
- 翻译成机器语言:对编译器进行一些微小的更改可以减少代码占用空间,将大型系统中的关键部分用汇编语言进行手工编码也可以作为减少代码占用空间的最后手段,但是需要强调这个高开销、易出错的过程只能带来一点点好处,不过该方法长长用于一些内存宝贵的系统,如数字信号处理器。
5.5 小结
- 空间开销:考虑节省空间的问题前,需要了解如程序使用的内存增加 10% 意味着什么。首先对于内存空间紧缺的系统可能会引发内存溢出;其次如果内存中的数据需要通过网络进行传输,那么传输时长很可能会增加 10%。最后在一些缓存和分页系统中,运行时间可能会急剧增加,因为数据可能逆行到离 CPU 更“远”的二级缓存、内存甚至可能已被交换到磁盘空间中;
- 空间度量:大多数系统提供了性能监视器,用于观察程序运行时内存的使用情况,很多 IDE 工具也提供类似的功能,要重视和利用起来;
- 折中:有时程序员必须牺牲程序的性能、功能或可维护性以获得内存,这样的工程决策需要在所有可选办法都研究过后才能做出。在选择折中之前应努力寻找能够改善解决方案的各方面性能的方法;
- 与环境协作:编译器和运行时系统所使用的表示方式、内存分配策略以及分页策略等变成环境相关问题,对程序空间效率也具有重要影响;
- 使用适合任务的正确工具:文中所介绍的四种数据空间技术、三种代码空间技术、以及简单性原则,都是优化内存空间使用的可选方法,要根据实际场景进行选择。