一个编译器的好坏,主要是看这个编译器优化的效果是否良好,但曾有一位倍受编译器困扰的人曾说过:“优化设计是科学,但应用的顺序是艺术。”
原文链接:https://faultlore.com/blah/oops-that-was-important/
声明:本文为 CSDN 翻译,未经允许,禁止转载。
编译器优化应当怎样设计?或者更具体地说,实际的优化操作应该如何设计和实现?想要一次性完成所有优化需要太多的操作,甚至是不可能的,所以很多编译器优化需要分为以下几个步骤:
1.确立可以应用某个技巧的场景;
2.通过分析找到这些场景;
3.将这个技巧应用到所有能找到的地方。
将大量的这类“优化过程”放到一起(最好能重用第二步),你的编译器就能把一堆小变更变成一个巨大的、复杂的变更。
首先,你需要学习的是一种最简单的优化,只需要非常少的分析。甚至,有时候我们只需一个正则表达式就可以完成优化。比如下面这几个例子:
replace_muls_with_shifts:x * 2 等价于 x << 1,后者的开销更小。
merge_shifts:(x << 1) << 1 等价于 x << 2,后者的开销更小。
那么, (x * 2) * 2 该怎么处理呢?你可以优化为 x << 2。但是,我们不需要编写这种优化,因为只需结合上面两个优化就可以了:
(x * 2) * 2
(x << 1) << 1
x << 2
下面,我们来看看真正的编译器给出的结果:
(x << 1) << 1
优化完了,为什么结果不正确呢?等一等,让我来看看优化函数的代码:
fn do_optimizations() {
merge_shifts();
replace_muls_with_shifts();
}
哦,原来是两个优化技巧的顺序写反了。好吧,我们来交换一下二者的顺序。但是,如果我们添加了很多优化技巧,那么可能就没有所谓的最佳顺序了,那岂不是最后得到的结果必然会漏掉一些重要的优化技巧?
那么,我们可不可以反复运行优化函数呢?可是,我们怎么知道运行多少遍才能停止呢?我们是否可以根据优化的结果判断还有没有新的优化技巧可以应用呢?如果有的话,再运行一遍?但是,这样做能保证循环一定会结束吗?何时优化的成本会超过其带来的收益呢?
是不是觉得很头疼?其实,这个问题没有明确的答案,一般的做法是反复尝试,直到获得的结果感觉上合理为止。刚开始的时候你不会考虑这么多,直到你发现有两个优化技巧的顺序一直是错的,或者在你交换两个优化技巧的顺序后编译器崩溃了。
前面提到的merge_shifts太简单了,它甚至不能处理(x << 2) << 3。其实我们应该能够将(x << y) << z 优化为 x << (y + z),例如:
x << 30 << 40 优化为 x << 70
这样就好多了。但 x 是什么类型?一个 64 位整数?嗯……那就有问题了。如果你在x64的系统上原样输出代码,上述表达式会被解释为 x << 6,因为 shl 指令会对位移量进行求模运算(实际上就是截取了末尾64比特),导致实际的代码变成mod 64。即使从语义上来看这也跟原来的程序完全不一样。
所以,优化必须加倍谨慎。通常都会有一些灾难性的问题导致一个优化失效。在这个例子中,危险就在于“两次位移的总和太大了”。
有时,这些危险很容易发现,但在尝试提高优化的通用性时,你往往会遇到一些并不太明朗的情况。例如,你希望其中一个位移量可以是变量。你可以将 (x << 10) << z) 优化为 x << (10 + z) ,但你能确定这是正确的吗?当然,你可以将位移溢出当作未定义行为处理,这样编译器就会假定 z < 64,但是这跟 10 + z < 64 完全没关系。
因此,将这个优化技巧扩展到这种程度是不合理的,因为我们无法证明它是正确的。除非我们能让分析过程更加智能。如果上述代码写在了if z < 20 { 中,那么这个优化就是有效的。所以,也许我们可以利用一些数据结构和分析,通过跟踪数据的流动来证明灾难不存在。
这样我们就开始编写真正的编译器优化,而不是那种简单的小儿科了。
从这里开始,你的编译器会变得非常复杂。你会构建一些花哨的数据结构和分析,以试图证明某些优化是有效的(为了节约篇幅,下文把这些数据结构叫做“元数据”)。成功以后,你的程序就变成了你认为“更好”的形式。
这非常困难,每次优化不仅需要完成一个奇怪的技巧,而且还需要修复所有的元数据,因为这些元数据都是针对旧代码的。这些原数据包括调试信息(从当前程序映射到原始程序文件,这样如果人们需要x的位置,就可以在某个寄存器中找到)。更不用说,这个过程还会引入很多bug,只不过这些bug不在本文的讨论范围之内。
然而,我写这篇文章的原因在于,优化通常是具有破坏性的。x * 2 * 2 优化为x << 2,虽然可以提高性能,但完全抛弃了最初的乘法运算的一切信息。同样,许多优化都需要采用内联、常量折叠以及死代码删除等手段,以删除额外的细节,从而让优化所需的条件更加明显。
在大多数情况下,这样做是有好处的。如果程序中有些代码没有用处,那么删掉当然无所谓。
但问题是:如果编译器中有大量的优化,每个都做复杂的灾难检查,那么一些看似无关紧要的东西,很可能就是防止优化过程出错的关键信息……真的有那么重要吗?
而令我更加不安的是,当人们第一次得知真相时,都会大吃一惊,比如:llvm优化掉无用的指针到整数的强制类型转换,很可能是不正确的,这可能会导致正确的代码被错误地编译。
简单来说,编译器可以根据很多信息判断两个指针是否相同。如果两个指针绝对不相同,那么针对一个指针的写入就不会表现在另一个指针上。但是,如果你将两个指针转换为整数来比较它们的地址,那么编译器就会使用一个指针替换包含另一个指针的表达式,于是“写入 x,从 x 读取”就会变成“写入 y +1,从 x 读取”。
这样做十分危险,因为会导致编译器得出写入和读取操作无关的结论,从而删除写入操作。为了避免这一点,编译器应当知道,将指针转换为整数是一个巨大的指针等价分析的灾难,因此不应该轻率地下结论……除非,另一个优化能发现指针到整数的转换是不必要的,并将其删除。
就像是一个孩子非常小心地在森林里洒下面包屑,避免出来时迷路,但另一个孩子看到了面包屑然后吃掉了。这样就会在深林中迷路。
用通信打个比方,程序在交流时非常容易出错,而一些看似无关紧要的东西(例如类型转换)实际上是某种握手。但是即使如此,情况也不容乐观。当编译器中有上千个优化、几十种不同的辅助数据结构时,你保存的与程序无关的重要信息越多,优化就越容易忘记检查某个信息,或忘记更新某个信息。
这个问题我也没有好的解决办法。
最后一点。我最近才得知,与编译器使用的内存模型相比,硬件内存模型的定义实际上更好,也更健壮。但如果是这样的话,为什么编译器不直接使用这些模型呢?
据我所知,这是上述讨论的另一个结果。我们知道,编译器会在做优化时破坏代码,但同时我们希望硬件能严格执行我们要求的操作。人们喜欢说,硬件的许多内部操作都很像编译器,但是许多操作都能直接解释成指令,而且永远都有源代码可以参照。
例如,CPU也许会进行某种缓存操作,避免读取或存储,但它依然知道应该做一次逻辑内存访问,因此这次内存访问操作依然存在,它不会忘记支持特定的操作。
但是,如果编译器看到某个读取操作可以缓存,它就会从程序中永久删除读取操作。即使编译器用辅助数据结构来记下这里曾有个读取操作,但最后生成的二进制文件中却不会有读取操作的身影。没有任何信息表明该读取操作存在,因此没办法维持读取的语义!
这就是为什么 memory_order_consume 是个灾难!memory_order_consume 试图捕捉硬件中更加微妙的顺序保证。
可见,每个优化都能就访问同一片内存给出强的保证(即使宽松的原子操作也能保证整体修改顺序),但当你试图让两个无关的内存区域一致时,就会出现问题。通常,解决方案是建立某种“屏障”,让所有CPU都停下来同步,但这需要大量代码,而且很昂贵。
而硬件内存模型认识到,如果你从指针A加载指针B,那么通过指针B的访问实际上都与A有关!举个例子:
static mut LATEST_DATA: AtomicPtr<u64> = ptr::null_mut();
static mut MY_DATA: u64 = 0;
unsafe fn thread1() {
MY_DATA = 5; // Initialize our data
LATEST_DATA.store(&mut MY_DATA, Release); // Share it
}
unsafe fn thread2() {
let latest_ptr = LATEST_DATA.load(Consume); // Get the latest
let data = *latest_ptr; // Read out the data
println!("{data}");
}
一个线程向MY_DATA写入数据,然后向LATEST_DATA存入一个指针(带有Release的语义,这样MY_DATA的写入就一定先发生),以告诉所有其他线程。然后另一个线程通过LATEST_DATA读取该指针(Consume语义,不需要屏障),进而读取MY_DATA。显然,在读取LATEST_DATA的数据之前必须先读取LATEST_DATA的指针,因此有显然的关系,不需要额外的同步!
为了实现不需要锁的数据结构,我们需要“将数据的指针放入数据结构”,因此让这个操作更高效(且直觉上正确)是非常好的。
但不幸的是,这个美丽且优雅的系统在编译器优化面前会被破坏殆尽,因为编译器要做的就是遍历代码,然后尽可能删除每个读取和存储操作!这个优雅的关系链会彻底消失,因为编译器会认为加载是不必要的,从而删除它。更微妙的是,编译器还可能注意到两个指针肯定是相等的, 从而用一个指针代替另一个指针!
可见,这些微妙的条件非常重要!
因此,所有编译器都会把consume解释成更昂贵的acquire,因为它们没办法在支持所有其他优化的同时支持consume。
因此,熟悉硬件的人在做优化时都会感到很失望,我非常理解他们。对于他们来说,明明硬件都可以保证这些,为什么到了编译器内存模型就不知道如何处理了呢。
唉,编译器的优化真是太难了。
— 推荐阅读 —
☞ 马斯克“认怂”:重启 440 亿美元收购案,还想把推特变微信? ☞ 苹果矛头直指俄罗斯最大社交应用! 弃用 AWS 后,我们服务器的年成本降低了 80%