try ai
科普
编辑
分享
反馈
  • 机器无关优化

机器无关优化

SciencePedia玻尔百科
核心要点
  • 机器无关优化通过提炼程序的抽象逻辑来减少所需的基本工作量,而无需考虑目标硬件。
  • 中间表示(IR)是一种关键的抽象语言,它允许优化器在保留高级语义意图的同时转换代码。
  • 诸如公共子表达式消除、循环不变量代码外提和死代码消除等核心技术通过整理程序逻辑来提供普适的效率提升。
  • 将基本逻辑与执行细节分离的原则是一种基础模式,它出现在数据库系统、机器学习和像 WebAssembly 这样的可移植系统中。

引言

在对性能的不懈追求中,软件开发者和计算机科学家们求助于编译器,将人类可读的代码转换为快如闪电的机器指令。然而,现代硬件的巨大多样性带来了一个根本性挑战:我们如何能以一种既极为有效又广泛可移植的方式优化程序?答案在于一种关键的关注点分离,即两种截然不同的优化哲学方法之间的优雅合作。这种分工反映了一位完善蓝图的抽象理论家与一位使用手头工具进行建造的大师级工程师之间的协作。

本文将深入理论家的世界,探索强大而普适的​​机器无关优化​​技术。这是一种在考虑任何特定硬件之前,提炼程序核心逻辑的艺术。我们将从“原理与机制”一章开始,揭示编译器如何使用一种称为中间表示(IR)的抽象语言来执行对任何机器都有益的基础性清理任务。随后,“应用与跨学科联系”一章将揭示这种将逻辑与实现分离的核心思想如何远远超出编译器的范畴,为数据库系统、机器学习以及“一次编写,到处运行”代码的梦想提供了一个统一的效率原则。

原理与机制

哲学家与工程师:两种优化器的故事

想象一下,您想建造一台复杂的机器。您可能会雇佣两位专家:一位理论物理学家,我们的“哲学家”,和一位大师级工匠,我们的“工程师”。哲学家的工作是拿走您最初凌乱的蓝图,并将其提炼成逻辑上最高效、最优雅的形式。他们不关心机器将用钢材还是木材制造;他们只处理设计本身纯粹、抽象的原则。他们可能会发现您原始计划中的两个独立机制实际上在做同样的事情,于是将它们合并;或者发现某个齿轮在无用地旋转,可以完全移除。他们的目标很简单:减少机器必须做的基本工作量。

工程师的工作则不同。他们拿着哲学家完善的蓝图,研究如何利用他们车间里特定的工具和材料来实际建造它。如果他们的车间有一台最先进的3D打印机,可以一次性制造出一个复杂的零件,他们就会使用它。如果他们只有简单的车床和钻头,他们就必须用许多更小的部件来构建同一个零件。他们的目标是利用现有资源,尽可能高效地执行所需的工作。

这本质上就是一个现代编译器如何优化程序的故事。编译器被分为体现这种合作关系的两个主要阶段。​​机器无关优化器​​就是我们的哲学家,负责提炼程序的抽象逻辑。​​机器相关优化器​​是我们的工程师,负责将该逻辑转换为针对特定计算机处理器(“目标机器”)的最佳指令。

我们的哲学家,即机器无关优化器,执行的转换无论底层硬件如何都是有益的。这些是“大扫除”任务。例如,​​公共子表达式消除 (CSE)​​ 会找到您计算了两次完全相同内容的地方,并确保它只被计算一次。​​循环不变量代码外提 (LICM)​​ 会发现循环内有一个每次都产生相同结果的计算,并明智地将其移到循环外,只计算一次。​​死代码消除 (DCE)​​ 是最终极的整理,它会移除任何其结果从未被实际使用的代码。这些都是效率的普适真理。

我们的工程师,即机器相关优化器,接着会拿走这个清理过的逻辑,并为硬件量身定制。如果目标CPU具有强大的​​单指令,多数据 (SIMD)​​ 能力——就像拥有一把可以刷出一大片而不是一小笔的油漆滚筒——工程师将使用​​自动向量化​​来转换代码以利用它。他们进行细致的​​指令调度​​,以使CPU流水线的每个部分都保持忙碌的方式来安排操作,如同专家级厨师为厨房里的每道菜精准计时。他们处理​​寄存器分配​​,决定哪些临时值可以存放在CPU中名为寄存器的、快如闪电的微小内存“口袋”里。

这两种角色的影响可能会因车间的不同而产生巨大差异。在一台拥有SIMD单元的高科技机器上,工程师向量化一个循环的单一决定就可以产生 4×、8×4\times、8\times4×、8× 甚至更高的速度提升,这种提升往往让哲学家的清理工作相形见绌。但在一个简单的、仅支持标量运算的机器上,那种向量化技巧是无法使用的。在这里,哲学家减少总指令数的基础性工作就凸显出来,成为主要的改进来源。

真理的语言:中间表示

为了使这种协作能够奏效,哲学家和工程师必须说同一种语言。这种语言是编译器设计中最优美、最关键的概念之一:​​中间表示 (IR)​​。IR是一种抽象的、理想化的语言,旨在捕捉程序的意图,而不与任何特定机器绑定。这种语言中词语和语法的选择是极其重要的。

想象一下,IR需要描述一个按位旋转,即一个数的比特位被循环移位。一种说法是描述其底层机制:(shift the bits right by k) OR (shift the bits left by width - k)。这很笨拙。这就像通过详述单个面部肌肉的运动来描述微笑。更糟糕的是,如果移位量不在特定范围内,这种描述在IR自身的语法规则下甚至是“不合法”的。

一种好得多的方式是让IR有一个单一、强大的词来表示这个概念:rotate(x, k)。这是一个高级别的语义内在函数。它捕捉了做什么(意图),而不是怎么做(实现)。哲学家喜欢这个。他们现在可以应用简单的代数规则,比如知道先旋转 k1k_1k1​ 再旋转 k2k_2k2​ 等同于一次性旋转 k1+k2k_1 + k_2k1​+k2​。工程师也喜欢这个。对于一个有原生 rotate 指令的目标机器,转换是微不足道的。对于没有的目标机器,工程师确切地知道意图是什么,并可以生成最优的移位和或运算序列。通过保留高级别的含义,IR巧妙地服务于两者。

这种保留语义意图的原则是一个反复出现的主题。在编译来自不同编程语言的代码时,我们可能会发现针对同一思想的各种“惯用法”。一种语言可能用显式的 if 语句检查空指针;另一种可能使用辅助函数调用。一个好的IR流水线会首先将这些不同的惯用法​​规范化​​为单一的、规范的形式——比如一个显式的条件分支——这样哲学家就可以统一地对它们进行推理。然而,对于像​​饱和加法​​(即在8位值上 250 + 10 的结果是 255 而不是 4)这样的概念,在IR中保留一个高级别的 saturating_add 内在函数要好得多。过早地将其降级为一个 min 和 max 操作序列会模糊意图,使得工程师在存在快速、原生的饱和加法指令时,更难发现使用它的机会。

纯粹的力量:规范化及其局限

哲学家在追求优雅的过程中,试图为每个思想找到一种单一的“规范”形式。这个过程,即​​规范化​​,简化了世界并解锁了强大的优化。例如,按位操作 NOT x(翻转 x 的所有位)在代数上等同于 x XOR -1(其中 -1 是一个全为1的掩码)。一个机器无关的遍(pass)可能会决定将所有 NOT 操作规范化为这种 XOR 形式。

为什么这如此强大?想象一下,程序的一部分计算 A AND (NOT B),而完全不同的另一部分计算 A AND (B XOR -1)。对于一个天真的观察者来说,它们看起来是不同的。但在规范化之后,两者都变成了 A AND (B XOR -1)。现在,像​​全局值编号 (GVN)​​ 这样的优化(它能识别冗余计算)就可以轻易地看出这两个表达式是相同的。如果它们是在相同条件下计算的,那么第二次计算就可以被消除,并用第一次的结果替换。这正是如何消除IR中冗余的地址计算(如多次计算 base + index * 4),从而减少总体工作负载的方式。

但这种哲学上的纯粹性可能会带来意想不到的后果。优化过程是哲学家和工程师之间一场精妙的舞蹈。有时,哲学家必须“温和”一些,故意避免一个逻辑上合理的简化,因为它会掩盖了工程师受过训练去寻找的关键模式。这种“交接协议”对于性能至关重要。

例如,工程师可能有一个用于​​融合乘加 (FMA)​​ 操作的特殊工具,该操作计算 a * b + c 比先乘后加更快、更精确。如果哲学家看到 d + c + a * b,并遵循严格的操作数排序规则将其重写为 a * b + d + c,他们可能无意中破坏了 a * b 和 c 项的结构上的邻近性,使得FMA模式对工程师来说变得不可见。在这种情况下,机器无关的遍必须被设计为保留这些有价值的、惯用的形式。哲学家的世界不是纯粹抽象的世界;它是在服务于一个实际目标的抽象世界。

当世界碰撞:盈利性与逆向决策

到目前为止,我们的哲学家一直关心逻辑和正确性。但如果一个转换在逻辑上是合理的,却让最终的机器运行得更慢怎么办?这就是​​盈利性​​的问题,而回答这个问题几乎总是落在工程师的肩上。

再次考虑融合乘加 (FMA) 操作。将一个独立的乘法和加法转换为一个FMA并非完全等价;由于只执行一次舍入而非两次,它会轻微改变结果。程序员必须通过一个特殊标志来授予这种“收缩”的权限。但即使有权限,哲学家也不能盲目地执行这个转换。为什么?因为如果目标机器没有原生的FMA指令,工程师将被迫用一个缓慢的软件库调用来模拟它——这是一场性能灾难。这个转换仅在某些机器上才是有利可图的。因此,形成FMA的决定必须在机器相关的阶段做出,在那里工程师可以检查他们是否有合适的工具来完成这项工作。

这引出了一个更有趣的动态:工程师有时不得不撤销哲学家的工作。想象一下,哲学家使用一种名为​​范围分析​​的聪明技术来证明一个循环的数组访问永远不会越界。他们得意洋洋地从每次迭代中消除了边界检查的 if 语句,移除了一个分支指令。这似乎是一个明确的胜利。

但在一个寄存器非常少的特定目标机器上,这会产生一个新问题。移除 if 语句简化了控制流,使得许多变量的生命周期变长。这种增加的“寄存器压力”意味着没有足够的快速寄存器“口袋”来存放所有的值,迫使工程师将其中一些​​溢出​​到缓慢的主存中。工程师快速计算一下:哪个成本更高,是我们移除的高度可预测的分支的成本,还是我们刚刚造成的内存溢出的成本?如果溢出更昂贵,工程师将做出反直觉的、务实的决定,重新引入该分支,为了在该特定机器上的整体性能而有效地逆转了机器无关优化。

看不见的世界:并发与内存模型

到目前为止,我们的故事一直假设是单一的思路,单一的执行线程。现代世界是一个大规模并行的世界,有多个线程同时运行。这引入了一个微妙的、我们的哲学家也必须理解的“看不见”的交互世界。

考虑循环不变量代码外提 (LICM)。哲学家在循环内部发现一个从内存中 load 的操作,其地址在循环内永不改变。从单线程的角度来看,这是一个经典的、安全的优化,即将这个加载操作提升到循环之外。

但如果这是一个多线程程序呢?这个循环可能是一个“自旋等待”,反复检查一个标志,直到另一个线程改变它。这个 load 的目的可能是读取另一个线程在设置标志之前准备好的数据。通过将 load 提升到自旋等待循环之前,哲学家破坏了同步协议。程序现在在数据准备好之前就读取了它,导致了竞争条件和不正确的结果。

为了驾驭这个看不见的世界,IR语言需要新的词汇来描述并发通信的规则。现代IR包含内存排序语义,如 ​​acquire​​ (获取) 和 ​​release​​ (释放)。一个标记为 acquire 语义的 load 就像一个屏障:其后的任何内存操作都不能被重排到它之前。一个带有 release 语义的 store 也是一个屏障:其前的任何内存操作都不能被移动到它之后。

这些注解是抽象的交通信号。机器无关的哲学家不需要知道工程师将使用什么样的硬件围栏或原子指令来实现它们。他们只需要遵守抽象的规则:不要将这段代码移动过那个信号。这使得优化在保持机器无关的同时,在并发世界中是正确和安全的。

合作蓝图:成本模型 API

我们如何能形式化哲学家和工程师之间这种错综复杂的对话,允许基于成本的决策而又不打破机器无关性的壁垒呢?解决方案是一个优美而抽象的通信协议,一个​​成本模型 API​​。

一个机器无关的遍永远不应该问:“这是一台Intel CPU还是一台ARM CPU?”。那会打破其哲学的纯粹性。相反,它通过API提出抽象的问题:“在32位浮点数上进行宽度为8的向量加法的相对成本是多少?”或者“这个目标是否偏爱分支更少的代码,即使以更多算术运算为代价?”。

特定于目标的工程师为这个API提供具体的实现。对于一个强大的、支持AVX的CPU,工程师的实现将报告向量加法极其廉价。对于一个简单的微控制器,工程师将报告它非常昂贵。

我们的哲学家,即机器无关优化器,接收这些成本——通常是代表诸如延迟、吞吐量和代码大小等权衡的多维向量——并用它们来指导其决策。例如,它可能仅当目标报告向量操作廉价时,才决定展开和向量化一个循环。这种优雅的设计保持了优化逻辑的整洁和可移植性,同时使其决策能够敏锐地意识到物理世界的现实。正是这种关注点分离,这种抽象与具体之间的结构化对话,构成了现代高性能编译器的基本原则。

应用与跨学科联系

在遍历了机器无关优化的原理和机制之后,我们可能会倾向于认为这是一个有些深奥的话题,是编译器架构师们关心的一个小众领域。但事实远非如此。计算的核心逻辑与执行它的机器的偶然细节之间的区别,是计算机科学中最强大、最美妙的思想之一。这个原则在远超编译器设计的领域中回响,从繁忙的Web服务世界到机器学习的前沿,甚至物理学的基本定律。在本章中,我们将探索这片广阔的领域,发现这个简单的思想如何为理解各处的性能、可移植性和效率提供了一个统一的视角。

完美食谱的艺术

想象你有一个蛋糕食谱。一个写得好的食谱就是机器无关优化的杰作。它指定了一系列逻辑上合理并能产生美味结果的动作——筛面粉、搅打黄油和糖、拌入鸡蛋。食谱本身不关心你用的是手持搅拌器、立式搅拌器还是木勺;它不指定你烤箱的品牌,也不管它是燃气还是电的。这些都是“机器相关”的细节。

一个聪明的厨师,就像一个编译器,可能会对食谱本身应用进一步的机器无关优化。如果食谱要求你在一个步骤中融化黄油,稍后又为另一个部分融化更多黄油,你可能会意识到可以一次性融化所有黄油,然后留出一部分备用。这就是​​公共子表达式消除​​,优化的一个基石,它说:“不要重复计算相同的值。”在现代软件环境中,这不仅仅关乎算术。想象一个系统,其中查找一个值需要一次昂贵的对远程微服务的调用。如果该服务调用是“纯”的——意味着对于相同的输入它总是给出相同的结果且没有副作用——那么用相同的输入调用它两次就是浪费。一个机器无关的优化遍会识别出这种冗余并存储第一次调用的结果,从而节省第二次调用昂贵的网络往返时间。无论代码在哪个特定的处理器上运行,这种优化都是有效且有利可图的。其逻辑是普适的。

另一个经典的“食谱”改进是​​循环融合​​。假设一个程序遍历一个大的数字列表来计算它们的平方,并将结果写入一个新列表。然后第二个循环读取这个新列表来计算总和。这就像对你的配料进行一次完整的处理来准备它们,然后再进行第二次完整的处理来组合它们。一个机器无关优化器可能会将这两个循环融合成一个单一的循环,该循环计算一个平方并立即将其加到一个运行总和上。这里的好处是深远的:中间的平方列表永远不需要被完全写入主存然后再读回。这个值可以在处理器寄存器中“保温”,这在计算上等同于将一种配料放在砧板上直到片刻后需要它。这节省了巨大的内存带宽,并且是对程序结构的纯粹逻辑转换。

这些优化——以及其他诸如通过移除无意义的绕行来简化控制流 或为更自然的数据访问而重排循环——都是关于提炼抽象的食谱。它们关心的是做什么,而不是怎么做。决定是否融合一个循环与处理器的流水线深度无关,而决定是否消除一个冗余的函数调用与CPU是否拥有融合乘加 (FMA) 指令无关。这种分离是创造不仅高效而且可移植代码的关键。

一种普适模式:从数据库到深度学习

这个原则的美妙之处在于它不仅限于传统编译器。它作为一种基本的组织模式出现在许多复杂系统中。

考虑​​数据库查询优化​​的世界。当你提交一个SQL查询时,你是在陈述你想要什么数据,而不是如何获取它。数据库的首要任务是充当一个机器无关优化器。它接收你的查询并将其转换为各种逻辑上等价的“查询计划”。例如,如果你要连接三个表 RRR、SSS 和 TTT,它可以先连接 RRR 和 SSS,也可以先连接 SSS 和 TTT。优化器使用关于数据——例如每个表中的记录数和结果的预期大小——的统计信息来选择可能创建最小中间表的计划。这是一个机器无关的决策,纯粹基于数据的抽象属性和查询的代数。只有在选择了这个逻辑计划之后,机器相关优化器才会接手。它决定是使用基于哈希的算法还是排序合并算法来执行特定的连接,这个选择严重依赖于目标硬件的内存速度和CPU成本。

同样的双层策略也是现代​​机器学习编译器​​的核心。一个神经网络本质上是一个大型、复杂的数据流图。一个机器无关的遍可以对这个图执行代数简化。例如,如果网络中的一个通道正在与一个在编译时已知为零的掩码相乘,那么所有导致该通道的复杂计算都是“死代码”,可以被剪除。这是一个纯粹的语义转换。之后,一个机器相关的遍会将剪除后的图映射到专用硬件上,例如谷歌的张量处理单元 (TPU)。这个遍可能涉及将矩阵乘法“分块”成与硬件张量核心完美匹配的特定块大小,并精心安排内存移动以保持这些核心的数据供给——所有这些决策都与它们所针对的特定芯片紧密相连。

“一次编写,到处运行”的梦想

关注点分离是可移植高性能系统的哲学基础。两项现代技术出色地阐明了这一点:即时 (JIT) 编译器和 WebAssembly。

一个​​JIT 编译器​​,例如Java虚拟机或现代JavaScript引擎内部的编译器,在代码运行时进行优化。这使它能够收集关于代码哪些部分是“热点”的性能分析信息。这个性能分析数据本身可以分为两层。机器无关层捕捉程序的内在行为:哪些分支被采用,哪些函数被调用最频繁。这个性能分析是可移植的,即使程序后来在完全不同的处理器上运行,也可以保存和重用。然而,机器相关层捕捉硬件特定的事件,如分支目标缓冲器 (BTB) 的未命中率或微操作缓存的效率。这些数据用于细粒度的代码布局优化,这些优化仅对该特定微架构有效,应用到别处将是无稽之谈。

也许对这一哲学的终极表达是​​WebAssembly (WASM)​​。WASM被设计为一个可移植、高效的编译目标。其目标是让像C++、Rust和Go这样的语言能够编译成单一、通用的字节码格式,可以在Web浏览器或服务器上安全运行。为实现这一点,编译器在生成WASM代码之前,对其内部表示执行大量积极的机器无关优化。它们会进行常量折叠、消除冗余计算、简化控制流,同时一丝不苟地遵守WASM的严格语义——其对整数溢出的定义行为、对除零的陷阱规则,以及对浮点数IEEE 754标准的精确遵循。这就创造了一个高度优化但仍然抽象的程序。最后一步——将WASM预先(AOT)或即时(JIT)编译为用户的本地机器代码——就可以完全专注于机器相关的任务:指令选择、寄存器分配,以及利用该CPU特定的SIMD向量单元。

统一的视角:计算物理学

归根结底,优化意味着什么?它意味着用最少的必要资源实现一个目标。在计算中,这些资源通常是时间和能源。机器无关优化通过减少所需的基本工作量来做出贡献。如果你能通过代数简化一个表达式,使其使用15个操作而不是20个,你就做出了一个普适的改进。这种工作量的减少直接影响到能源消耗。

这接着为机器相关优化器创造了机会。考虑一个具有动态电压和频率缩放 (DVFS) 功能的处理器。在程序的内存密集型部分,CPU大部分时间都在等待数据,此时让CPU全速运行纯属浪费。电源管理系统——一个机器相关优化器——可以降低频率和电压以节省能源而不损害性能。在一个计算密集型部分,一个减少了总操作数的机器无关遍可能会在时间预算中创造足够的余地,从而允许DVFS系统以更低的频率运行该部分,从而带来巨大的能源节省。抽象的、逻辑的优化使得更有效的物理的、硬件级别的优化成为可能。

这让我们回到了一个来自物理学的优美类比。当物理学家分析一个系统时,他们通常从量纲分析开始。这是一种纯粹的数学技术,它基于物理量(质量、长度、时间)的单位来探索它们之间的关系。它可以在不参考任何特定实验装置的情况下,揭示基本的标度律并检查一个方程的合理性。这是机器无关阶段。设计一个特定的实验来测量这些量——选择合适的激光器、校准探测器、屏蔽干扰——则是机器相关阶段。

通过将一个问题的本质、语义核心与其执行的偶然细节分离开来,我们所做的不仅仅是构建更快的软件。我们正在参与一种深刻而强大的抽象形式,它使我们能够清晰地推理,构建可移植和健壮的系统,并在贯穿科学与工程的优化原则中发现一种惊人的统一性。