程序的分支消除
参考: 手动分支消除
为什么要进行分支消除?
消除如 if … else … 这样的结构, 其中一点原因就是分支预测(Branch Prediction),这里直接把概念粘过来了
- 当包含流水线技术的处理器处理分支指令时就会遇到一个问题,根据判定条件的真/假的不同,有可能会产生跳转,而这会打断流水线中指令的处理,因为处理器无法确定该指令的下一条指令,直到分支执行完毕。流水线越长,处理器等待的时间便越长,因为它必须等待分支指令处理完毕,才能确定下一条进入流水线的指令。其又分为静态分支预测和动态分支预测
- 最简单的静态分支预测方法就是任选一条分支。这样平均命中率为50%。更精确的办法是根据原先运行的结果进行统计从而尝试预测分支是否会跳转。
- 动态分支预测是近来的处理器已经尝试采用的的技术。最简单的动态分支预测策略是分支预测缓冲区(Branch Prediction Buff)或分支历史表(branch history table)。
即如果CPU分支预测成功, 那么程序执行的效率会非常地快, 如果失败则会降低效率. 这是其中一个原因, 还有一个原因是想利用单指令多数据流(SIMD)的优势, 一次性对多个数据进行运算操作,如果存在分支结构, 则不知道对于多个数据应该在那个分支执行运算,这就利用不了SIMD大数据计算的优势.
- 这里开始说明之前, 先抛出一个结论, 消除分支的写法并不会使得你的效率提升很大, 甚至在一些情况下还不如编译器自己优化的效果好,但是消除分支的写法会让你的程序的可读性大大降低,我这里并不推崇这种写法, 只是偶尔看到觉得这种分支消除的方法觉得很新鲜,所以这里分享出来。你的团队的其他人或许会看的很难受, 下面用3个例子来说明一下分支消除的方法。以下说明涉及数制转换和逻辑运算部分知识。
具体例子
例子1 整数转十六进制
将 0 ~ 15 整数转化为其十六进制的表示方式。 即转化为 0,1,2,…,9,A,B,C,D,E,F。
我们很容易可以写出下面这样一个函数
int fun(int n)
{
if(v < 10) return v + 48; // 0 的 ascll码是 48, A的ascll码是 65
else return v + 55;
}
故上面一个简单的函数中就存在了两个分支。在优化之前先简单介绍下基本的概念, 现在的主流计算机体系中, 数据在计算机中都是用补码进行存储的。
由于上面的两个分支其操作都是给 v 加上一个常数
考虑一下三目表达式 return v + (v < 10 ? 48: 55); 本质还是一个分支结构, 只是给人看起来进行了优化了
考虑一下优化一下这个结构
条件A ? X : Y 等价于 Y - (条件A ? (Y - X) : 0) 这个表达式, 再对这个表达式进行位运算优化得到
Y - ((true / false) & (Y - X));
但上面一步的转化存在讨论问题, 当为真时即得到X, 我们希望表达式为 Y - (Y - X), 此时 true 为 1 并不满足这个表达式
故这里我们的true的条件为 -1, 这里并不矛盾, 因为在其他语言中也是非0, 即为真。这里为什么取 -1 呢, 因为前面说过数据在
在计算机中是以补码的形式进行存储的, 而-1的补码为11111111111111111111111111111111, 即与任何数相与均得到其本身.
但条件为假时, 我们希望得到的是 Y - 0; 这个很简单, 只需条件返回值是0即可, 任何数与0的与运算均为0,即此时条件false的
结果就为0;
故我们知道了 真:—1, 假:0;
如何构造这个返回条件:
(1) 方案1 -(v < 10)
这种方式很直观,因为 v < 10 的返回值就是 0 / 1, 而我们需要的是 0 / -1, 故直接再加个负号完事,但这并不是
效率最高的写法。
(2) 方案2 位运算
位运算相比乘除加减效率快很多, 故直接位运算, (v - 10) >> (WORD_BIT - 1); WORD_BIT在climits头文件中
这里解释一下, WORD_BIT 表示的是一个字的长度,以64操作系统为例, 其int的长度位 32, 即此时WORD_BIT == 32;
这里以64操作系统为例, 若这个数正数, 则右移时高位补0, 为负数时其右移高位补1。那么右移31位后那么此时的第1位
为原本第32位的符号位, 正数的符号位为0,负数的符号位为1. 故此时如果v - 10 是一个正数,
那么(v - 10) >> (WORD_BIT - 1) 的结果的二进制表示为 000000.....00000, 即其结果为 0;
否则若 v - 10 为一个负数, 则其最后的二进制表示为 11111.....11111, 故最后其结果为-1;
故综上, 最后优化为:
return v + 55 - ((v - 10) >> WORD_BIT - 1 & (55 - 48));
int fun(int n)
{
return v + 55 - ((v - 10) >> WORD_BIT - 1 & 7);
}
完成了原本分支结构的等价, 但是可读性大大降低, 没有上面的推导一般人很难第一眼看出这是在干啥,故写好注释很重要,
但最好还是尽量避免这种写法。
例子2 求绝对值
返回一个数的绝对值
// 返回绝对值
int xabs(int n)
{
if(n >= 0) return n;
else return -n;
}
跟上面的例子1一样都是一个简单的分支结构, 要不返回本身, 要不返回其相反数.
前面说过了数据在计算机中以补码形式存储, 根据原码、反码、补码的转换关系存在下面关系:
对于一个数x, 其相反数 -x = ~x + 1; 即 [x]补 --> [-x]补 符号位和数值位全部取反, 然后末位 + 1;
而其中将一个数的符号位和数值位全部取反的这个操作, 即 ~x, 其也等价于 x ^ (-1), 因为-1补码表示为0xffffffff,
与一个数进行异或,原本是0的位异或之后得到1,原本是1的位异或得到0;
那么对于一个数x, 那么其相反数 -x = x ^ (-1) + 1;
即如果 n 是一个负数, 我们返回其相反数, -n = n ^ (-1) + 1 = n ^ (-1) - (-1);
否则我们直接返回本身, 但要写成跟上面类似的形式 n = n ^ 0 - 0 ;
故最后的统一形式为 return n ^ C - C;
其中C为 0 / -1, 即0 / -1 分别对应我们条件的真和假. 这里同样可以用我们例1中的方法, 快速构造出判断条件。
int s = n >> (WORD_BIT - 1);
return (n ^ s) - s;
最后整理得到
// 返回绝对值
int xabs(int n)
{
int s = n >> (WORD_BIT - 1);
return (n ^ s) - s;
}
例子3 二分
其实这种if… else … 分支结构在二分中应该说必定出现的了。这里我们只是演示如何二分中的分支结构, 但这样会使得二分代码可读性变得很差。
以 Leetcode 的一道题为例, 具体问题我们可以先抛开, 我们这里只是讨论如何消除其中的分支结构.
题目链接: Leetcode H指数II
正常的二分写法
class Solution
{
public:
int hIndex(vector<int>& citations)
{
int l = 0 , n = citations.size(), r = n - 1;
while(r > l)
{
int mid = l + r >> 1;
if(citations[mid] >= n - mid) r = mid;
else l = mid + 1;
}
return min(n - l, citations[l]);
}
};
重点来看其中的分支结构
{
int mid = l + r >> 1;
if(citations[mid] >= n - mid) r = mid;
else l = mid + 1;
}
第一眼这两个分支看起来好像并没有什么关系, 一个是改变l, 另一个分支是改变r, 但我们将其重写一下得到
{
int mid = l + r >> 1;
if(citations[mid] >= n - mid) l = l, r = mid;
else l = mid + 1, r = r;
}
这样看起来就变成像例子1中的形式, l 在第一个分支中等于l本身,在第二个分支中等于 mid + 1; 同理 r 在第一个分支
等于mid, 在第二个分支中等于r. 所以这等于是分别对l, r 进行分支消除。
先设这里的条件结果值为 C;
以l为例, 其结果为 l = (C ? mid + 1: l);
= l - (C & l - (mid + 1))
= l - (C & l - mid - 1);
同理 r = mid - (C & mid - r);
构造 C 这个条件, 原本执行第一个分支的条件为 citations[mid] >= n - mid , 即citations[mid] - n + mid >= 0;
故这个就是跟例子1一样的条件构造了.
故最后整理得到
int hIndex(vector<int>& citations)
{
int l = 0 , n = citations.size(), r = n - 1;
while(r > l)
{
int mid = l + r >> 1;
int s = (citations[mid] - n + mid) >> WORD_BIT - 1;
l = l - (s & l - mid - 1), r = mid - (s & mid - r);
}
return min(n - l, citations[l]);
}
总结
所谓的分支消除, 就是将原本的分支结构写成等价的形式, 上面给出的三个例子中常用的构造方法就是主要就是通过三目表达式,一步一步将其改写为等价的形式, 这里涉及了常用的位运算。 由于存在位运算, 使得程序的可读性大大降低, 所以这里只是分享一下有这种消除分支的方法,但有这种方法并不是让你程序中的所有分支结构都给消除, 恰恰正是分支结构的存在才让我们程序的可读性大大提高, 这种消除方法改写后的代码夹带着位操作,如果之前没看到类似的做法, 那看到一堆位操作是真的不知道在干啥. 而且这种改写在某些情况下, 是不如编译器自己优化的情况下运行效率高的。