1. 前言
网上关于C代码极致优化的文章大多偏向编程技巧,比较适合新手入门学习。也有文章从缓存方向入手,考虑数据的缓存特性,以达到更短的执行时间。本篇文章从已知条件的方向切入,分享作者在给定条件下对C代码进行极致优化的一些理解。为何要往这个方向写?原因很简单,本人工作中接触到的大多数项目都是根据某些需求来开发的,并不涉及数学相关的算法开发,因此更偏向于编程技巧的灵活运用和根据已有的条件对代码进行精简。经过多年的代码经验积累,越发感觉到代码精简也是一门学问。
2. 经典案例
作者在工作中碰到过一个十分经典的案例,其中一个模块是根据用户输入的内存大小计算出需要分配的内存块数量,以便后续的内存分配。这样的设计广泛存在于嵌入式软件,比如大多数内存管理方案是以块为单位分配,FATFS以簇为单位分配存储块。
下面以该模块进行分析,假设内存块大小是16字节,用户输入的内存大小值为mem_size,模块计算并输出的块数量为mem_blocks。由于父模块已经对输入参数 mem_size 做了合法性判断,因此子模块无需重复判断,mem_size 已满足正整数的的需求。
-
输入条件
- 块大小16字节
- 用户申请的内存大小为正整数
-
输出条件
- 块数量
- 输出块的总大小必须恰好大于或等于输入长度(输入长度按块大小对齐时取等)
3. 新手级算法
int calc(int mem_size)
{
int mem_blocks;
mem_blocks = mem_size / 16; // 计算完整块的数量
if(mem_size % 16)
{
mem_blocks++; // 最后不足16字节部分也需要占据1块
}
return mem_blocks; // 返回需要分配的总块数
}
不考虑赋值过程,代码的执行包含1个除法、1个求余、1个判断、1个加法,共4个操作。实际上CPU没有求余指令,编译器会采用加减乘除等运算替代求余操作。下面给出一个比较简单的求余替代算法:
mem_size - mem_size / 16 * 16
把求余替代算法替换到原代码,代码的执行就变成2个除法、1个乘法、1个减法、1个加法、1个判断,共6个操作。
4. 老手级算法
int calc(int mem_size)
{
int mem_blocks;
mem_blocks = mem_size >> 4; // 计算完整块的数量
if(mem_size & 0xf)
{
mem_blocks++; // 最后不足16字节部分也需要占据1块
}
return mem_blocks; // 返回需要分配的总块数
}
该代码使用移位代替了除法,采用与操作代替求余操作,最终代码的执行只包含1个移位、1个按位与、1个判断、1个加法,共4个操作。只从步骤数量看,对比新手级的代码效率提升了33%。
5. 高手级算法
int calc(int mem_size)
{
return (((mem_size - 1) >> 4) + 1);
}
该算法是公司某位编程高手写的,代码的执行只包含1个移位、1个减法、1个加法,共3个操作。对比新手级的代码效率提升了50%。
这个算法看起来很精简,经验告诉我越是精简其背后的原理则越复杂,事实上确实花费了我不少时间整理,尤其是 mem_size - 1 这个操作更是让人一下子摸不着头脑。可以尝试从mem_size入手来理解这条算式。mem_size是一个32位数,由于块分配是以16字节为单位进行的,因此mem_size的 bit4 ~ bit31 可以看作是完整块的数量(下图黄色框的值为3表示包含3块完整的内存块),而 bit0 ~ bit3 一般不为0(下图红色框的值为2表示剩余了2字节),但是也要为其分配1块内存。
给 bit0 ~ bit3 占据的块定义为碎片块(这样描述可能有些歧义,暂时也想不出更合适的词语),bit4 ~ bit31 占据的块定义为完整块。综合考虑,mem_size的取值都可以对应下图三种情况的其中一种:
情况一mem_size的取值范围是 1 ≤ mem_size ≤ 15,((mem_size - 1) >> 4) + 1 的结果是1,分配1块内存。
情况二是常见的情况,mem_size > 16 且 mem_size % 16 ≠ 0,((mem_size - 1) >> 4) + 1 的结果是 mem_size / 16 + 1。
情况一和情况二比较好理解,因为有碎片的存在,所以 (mem_size - 1) >> 4 等价于 mem_size >> 4 ,这个步骤是计算完整块的数量,而后续的 +1 操作是弥补碎片块占据的内存块。
情况三是比较特殊的情况,mem_size恰好是16的倍数,换言之只有完整块没有碎片块。那么 ((mem_size - 1) >> 4) + 1 又是如何等价于 mem_size / 16 的呢?由情况一和情况二可知算式中的 +1 操作是弥补碎片块占据的内存块,但是情况三不存在碎片块,这个操作反而会使计算出错。
以 mem_size = 48 为例,下图黄色框代表完整块的数量,可以看出数值为3。上面说过 +1 操作是为了兼容碎片块,所以这步无法绕开,只能想其它办法兼容三种情况。既然 +1 操作无法避免,那么从完整块上 -1 块不就问题解决了吗?想法很简单,代码上该如何实现呢?原式在移位操作前先对mem_size补上 -1 的操作,由于低4位是0, -1 后低4位向 bit4 借位,这样黄色框的数值就由原来的3变成了2,变相 -1 了,满足了我们的需求。


到此该算法对于上述三种情况的兼容情况已经明了,但是留下了一个问题:算式中为何是 -1 而不是 -2 -3 等等?
这个问题直击要害,也反映出这个算法的“讲究”。对情况三而言,只要低4位发生借位行为就可以,减数的选取从 1 ~ 15 都不影响最终的结果,为何偏偏选择1呢?原因在于情况一的碎片数值范围是 1 ~ 15,如果减数是2,而碎片值是1,那么 mem_size - 1 就会算出一个负数!这就是为何减数只能取1而不能是其它数值的原因。
6. 效率提升的根本
高手级算法比老手级算法少了一个步骤,所以效率提升了,但是上面没有说明效率究竟是如何提升上来的。
int calc(int mem_size)
{
int mem_blocks;
mem_blocks = mem_size >> 4; // 计算完整块的数量
if(mem_size & 0xf)
{
mem_blocks++; // 最后不足16字节部分也需要占据1块
}
return mem_blocks; // 返回需要分配的总块数
}
回头看老手级算法, if(mem_size & 0xf) 这步操作先获取碎片的长度信息,然后使用该长度数值进行零值判断。深入思考一下,该步骤只是用于判断碎片长度是否为零(true or false),具体的长度数值其实是没用的,这就好比辛勤种植的大白菜,收成时只摘取中间的嫩心,其余全部丢弃。所以老手级算法的问题是存在冗余。
7. 极致算法
章节6简单说明了算法提升的方向是要减少代码的冗余。那么高手级的算法是不是最优算法呢?作者发现其实还有提升空间。
int calc(int mem_size)
{
return ((mem_size + 0xf) >> 4);
}
算法执行只包含1个加法与1个移位,共2个操作。该算法不仅充分利用了输入条件,甚至利用了二进制溢出的特点,把计算过程压缩到了极致!
如何确认这就是最极致的算法,作者一般是从功能需求来判定的。需求是要算出块数,而输入的是长度值,也就是说算法上必须有一个除法或移位操作才能把长度转成块数;另一方面,输出块要恰好满足输入长度(碎片块处理)。综上,要完成这个需求的功能,理论上至少需要2个操作。而作者提出的算法正好就是2步操作步骤实现,mem_size + 0xf 解决了碎片块问题,右移4位解决了长度转块数的需求,所以可以认为这个算法就是最极致的算法。
回到算法本身,mem_size + 0xf 到底如何解决碎片块问题的?如果把低4位看成一个容器,那么这个容器可容纳的值范围就是 0 ~ 15。在章节6提到过,我们并不需要知道碎片的确切数值,只关心碎片值是否为零(true or false)。如果碎片值是0,mem_size + 0xf 的低4位不会溢出;如果碎片值非零,mem_size + 0xf 低4位就会向高位溢出1(块数+1)。这就把两个分支处理压缩到一个操作里面。至于低4位为何最多只会向高位溢出1,这个编程基础问题可以自行证明。
既然极致算法比那么高手级算法少一个操作步骤,意味着高手级算法也是存在冗余的,那么冗余的地方在哪呢?再深入分析高手级算法,它的情况一和情况二理论上只需要两个操作步骤 (mem_size >> 4) + 1 就能完成,而情况三理论上只需要一个操作步骤 mem_size >> 4 就能完成。很明显原本最多两个操作步骤就能完成的,为了共用同一条算式,只能把操作步骤扩展到三个,导致了这条算式无论对哪种情况来说,至少有一个步骤是多余的。
8. 总结
通过上述的案例我们可以总结出一个结论:减少冗余是算法实现极致优化的根本。减少冗余的具体实现方法是从功能需求出发,归纳出实现功能的最少操作步骤,然后根据每个步骤先写出算法的框架,最后合并框架中的分支。
一个极致算法背后蕴含的数学原理真的让人惊讶,明明只是简单的几行代码却足以让我们学习到很多编程思想。对于绝大多数程序员而言只要代码能跑就足够了,而对于编程老手可能写过几万行甚至几十万行代码,认为自己已经足够优秀了,很少有人思考自己写的代码有多少提升的空间,对编程缺乏足够的敬畏。作者不是什么编程高手,只是平时喜欢鼓捣偏底层的一些东西,本篇抛砖引玉对极致优化提出个人的一些看法只是编程海洋的冰山一角,后面有机会再和大家分享C算法和arm汇编极致优化的一些见解!