30 秒看懂
到 2019 年,iPhone 应用、谷歌的生产服务器、PlayStation 游戏,甚至索尼的电影特效流水线,都在通过一套始于伊利诺伊大学一个小项目的基础设施进行编译。Chris Lattner 对 Lex Fridman 说,LLVM 最大的成就,是一套竞争对手也愿意共用的模块化设计;算法方面并没有什么新东西。原因是重造一个编译器的代价太高,而编译器工程师又太稀缺。同样的分层思路,后来又出现在 Clang、Swift、Swift for TensorFlow、谷歌 TPU 上的 bfloat16,以及 MLIR 里。
- 1在 LLVM 上合作的,是彼此激烈竞争的商业对手:自己从头重做代价太高,而编译器人才比钱还难招到。
- 2Lattner 反复强调同一个观点:编译器的难点在于软件工程,算法本身其实早有定论。LLVM 实现的大部分内容,早就写在教科书里了。
- 3Objective-C 无法通过打补丁实现内存安全,因为它的对象系统建立在 C 指针之上。去掉指针,它就不再是 Objective-C 了。
- 4Swift 里的 import numpy 看起来只是一行。背后却是约 1200 行 Swift 代码,外加两项新的语言特性——动态成员查找和动态调用——它们花了一年时间才落地。
- 5当自动微分(automatic differentiation,AD)内置于语言之中时,你就能复用几十年来为 Fortran 开发的源码到源码 AD 技术;这些技术需要看到整个函数,不能一次只处理一个算子。
- 6bfloat16 保留了 float32 的指数范围,同时把更少的位数分给尾数,这与训练时的微小梯度和巨大数值相吻合;而且硅片上的乘法器也更便宜,因为面积随尾数位数的平方增长。
按章节阅读整理后的全文
他通过把书上的程序敲错来学会编程
Fridman 从这类访谈常见的起点开始:第一个程序。Lattner 说,他还是个孩子的时候,父母给他买了一本 BASIC 的书,他就这样开始了。他照着书把程序敲进去,看它们运行,把代码敲错,然后盯着屏幕,直到弄明白它为什么会失败。他的记忆里并没有什么“皈依”式的转折,只有输入、出错、调试这样一个循环。
他接触的语言沿着微软的一条线演变:BASIC,然后是 GW-BASIC、QBasic 和 QuickBasic,每一个都是同一家族里稍微花哨一点的方言。之后他跳到 Pascal,写了大量汇编,最后才接触 C++。Smalltalk 也在途中出现过,但它不是起点。Fridman 开玩笑说,他选的是通向机器的“黑暗小路”,没走 Lisp 那条更高层、更哲学、也更嬉皮的路。
转向 C 发生在高中时期,因为 Pascal 不够用,而他想要一个不一样的世界。指针和 C 的语法起初让他很困惑。Pascal 本来就要求你分配并管理内存,但它同时提供了字符串类型,你可以把字符串当成一个整体来想,而不必去想连续排列的字符数组。C 则让内存布局无法回避。他后来做的几乎所有东西,都建立在这一层之上。
编译器是连接两团不断膨胀的乱麻之间的桥梁
Lattner 对编译器的定义刻意说得简单直接。一边是不愿意写二进制、也不想每出一款芯片就重新思考一遍的人类;另一边是机器,而且每年种类都在增加:x86、ARM、GPU,还有如今为机器学习打造的加速器。他说,这门艺术在于让人能在自己真正想要的抽象层次上思考,然后让写下的东西跑在某块特定的硬件上。
语言也在同时增多。C、JavaScript、Python 和 Swift,都在试图捕捉程序员的意图,好让其他人能够维护,而不只是让机器能够执行。因此编译器按阶段运行:一个面向语言的前端,一个共享的优化器,以及一个面向硬件的后端。原因在于软件工程。这些系统体量巨大,没有人愿意为每种语言、每款芯片都重写中间那一大块。
LLVM 的赌注是把中间那部分和后端标准化。Swift、Rust、Julia,以及 Clang 处理的 C、C++ 和 Objective-C,表面上差异很大,JavaScript 对“假”的理解就和 C 不同,但它们可以共用同一个优化器和同一个代码生成器。LLVM 更像一大堆可复用的代码,是构建具体编译器的平台,也是一个数百人的社区。
奇怪的地方在于商业。谷歌和苹果都投入到同一套基础设施上。AMD 和英特尔也是,在图形方面则是英伟达和 AMD。他们这样做,并不是出于善心。生产级编译器代价高昂,而相关技能又很稀缺。Lattner 认为,即便是大公司,也并不真的想独自实现整套东西。
他原本只打算待一年,结果不经意间搭起了整套技术栈
LLVM 起初是伊利诺伊大学的一个大学项目:Lattner、他的导师,还有两三名研究生。毕业后他加入苹果,苹果把它用到了产品中,先是 OpenGL 栈,后来是 C 编译器,然后是 Clang,再后来是 Swift。谷歌后来在 C++ 工具链上投入极大,按他的说法,它实际上掌控着 Clang。英伟达在 CUDA 中使用 Clang 和 LLVM。
他当初并没有瞄准一个二十年的帝国。他去伊利诺伊是为了读一年的非论文型硕士,然后回到业界。因为这份工作很有意思,扎实的软件工程,外加在真实团队里用版本控制协作,他被技术活“勾住”了,结果留了五年。访谈时,LLVM 大约 19 年了,已经比 LLVM 刚起步时的 GCC 年纪还大。
他被编译器吸引,源于一位本科教授。那是一所很小的学校,计算机专业只有七到九名学生,课程挂在数学系名下。那位教授把编译器课讲成一门把算法和数据结构缝合进一个学期代码库里的课。你得为自己的决定负责,不能为了一个 B 就丢下做到一半的项目。正是这位教授推动他去读研究生,尽管他并不想当学者。
他主要不是数学型的人,所以形式化的解析理论并不是吸引他的地方。早期的 LLVM 是在实现那些已经存在于高级编译器课程里的标准算法。动手去实现它们、读相关资料、再设定下一个目标,才是乐趣所在。按他的描述,他的研究贡献在于动手构建,没有提出新的定理。
for 循环得先不再像 for 循环
被问到为什么把 C++ 变成机器码很难,Lattner 从语言本身说起:它大约有一部 400 页的规范,外加语法、语义,以及堆积在 C 之上的数十年历史。各个子系统之间的相互作用,没有人能完全装在脑子里。Clang 与 GCC 的较量,押注的也不只是正确性。GCC 已经成了行业标准,但满是全局变量,很难复用于研究,也难以支持它原本并未设计支持的工具。
Clang 想要更好的错误信息、更快的编译速度,以及 GCC 从未支持过的一类工具,比如重构和静态分析。这意味着要保留额外的信息,同时又不能拖慢编译速度。前端仍然必须构建语法树、检查规范中的每一条规则,并把失败转换成人类真正能看懂的消息。
接下来是降级(lowering)。解析之后,程序就成了一棵抽象语法树:加号节点带两个子节点,调用节点带被调用者和参数。LLVM 的中间表示(IR)是一张控制流图(control-flow graph)。操作变成简单的指令(加法、乘法,或者一次调用),排列在基本块(basic block)中,块与块之间用分支相连,不再是嵌套的树。LLVM 可以在这种形式上运行大约 150 轮优化遍(pass)。
C 的 for 循环最能说明形态的变化。在语法树中,它只是一个节点,指向初始化、条件、增量和循环体。在控制流图中,你会得到循环之前的代码块、循环体加增量的代码块、一条回到顶部的分支,以及一个比较并跳出的分支。这看起来更像汇编,重点就在这里:语言特有的杂乱可以留在前端,让中间那部分能够共享。
编译器反复改写同一张图,神经网络则不断改变图本身
Fridman 从这段描述里听出了神经网络计算图的影子。Lattner 说,两者的思路相似,细节却不一样。网络在不同深度上学习表示,并把一层的表示变换成下一层的表示。编译器保留的表示相对较少,却会对同一个表示施加许多变换,而且往往是反复迭代,直到代码更接近机器想要的样子。
如果你优化过循环,这些优化遍就很熟悉:把不变的表达式提到循环外面,让它们少执行几次;删掉冗余的工作;做常量折叠;把 2 * x 变成左移。编译器还会运行一些分析,为后面的优化遍恢复出更高层次的属性。如果一个表示只被用了一次,它大概只是个中间产物;如果它被改写了几十轮,那它就是根基。
寄存器分配(register allocation)是让其他优化真正发挥作用的那一环
问他哪一项优化收益最大,Lattner 讲到了寄存器分配(register allocation),这个问题早在 1980 年代就已是核心。内存慢,寄存器快,而且数量很少,某些模式下的 x86 只有 16 个。源代码会产生几十个临时变量,经常位于可能运行数百万次的循环之中。把这种映射做对,至今仍是编译器最难的工作之一。
如果内层循环在不停地读写内存,它就会很慢。如果每个活跃值都能放进寄存器,速度可能快一个数量级。编译器看不到程序员写的那个 x,它看到的是若干个值,每个值在整个函数中都有一段生命周期,而每个值都可能待在寄存器里、溢出到内存,或者干脆重新计算,而不是重新加载。程序于是变成了一张带有时间维度的图,物理资源必须沿着这条时间线被分配。
RISC 流水线带来了下一个经典问题:指令调度(instruction scheduling)。只有编译器把指令排好顺序,让流水线保持满载而不停顿,处理器才能同时做多件事。约束条件,比如延迟、依赖关系和执行端口,都很杂乱。这些算法已经有几十年的历史,里面充满手写的启发式规则和魔法数字。Lattner 指出,这正是机器学习的一个研究机会,通常先在较小的空间里试,比如为 GPU 生成矩阵乘法。
算法早已写进教科书,真正的胜利是让竞争对手愿意共享它们
Lattner 并不认为 LLVM 的名声来自一批新颖的编译器成果。它有一些重要算法的强健实现,也有很聪明的人参与其中。他说,真正深远的部分是标准化:它让原本不会发生的工作成为可能。Java 在 1990 年代中期已经通过让即时编译(JIT)、垃圾回收和内存安全的可移植代码成为主流,改变了这个行业;LLVM 的贡献,则是在许多语言和芯片之下,提供了一个共享的底层基础。
索尼把 LLVM 用在电影制作流水线的图形编译上,他说这让他们能做出更好的特效。这并不是设计目标。他把意料之外的复用视为基础设施的检验标准:分层和可组合性是否良好。他认为,GCC 作为 C 或 Fortran 编译器非常出色,但作为一个可以重新针对不同目标的平台则较弱,部分原因在于,替换其中的子系统从来就不是它最初的设计。
Clang 和 LLVM 用来编译 iPhone 应用和操作系统、谷歌的生产服务器、GameCube 游戏,以及 PlayStation 4 游戏。和 Linux 上的 GCC 相比,他说两者已经足够接近,差别几乎无关紧要。在他的经验里,真正的难题是工程:几百个人在做细致的底层工作,而代码库不能崩掉。Linux 发行版之所以继续使用 GCC,是出于技术与社会两方面的原因,并非因为 LLVM 没能追上。
模块化是最初的目标。他写过一个寄存器分配器;后来有人比他更聪明,把它拿出来换掉了,他把这视为成功。社区规模的做法是设立代码负责人,对后端来说,往往是硬件厂商。他们不必审查每一个补丁,但必须确保审查和架构工作真正发生。他名义上仍处于这一技术栈的顶端,主要负责调停技术上的争执。当时已经成立 6 到 7 年的 LLVM 基金会,负责资金和活动,并不介入技术方向。
Clang 起初是一个在下班后偷偷写的 C 编译器
Lattner 在 2005 年加入苹果,目的是让 LLVM 具备量产能力。他赶上了英特尔过渡、64 位过渡,以及 iPhone 转向 ARM 的时期,模块化的后端在那时发挥了作用。面向程序员的部分仍然薄弱。如果你在大量敲写 Objective-C,那么错误信息、编译速度和 IDE 工具,都还没有达到应有的水平。
于是他利用晚上和周末开始写一个 C 编译器,没有向任何人许诺什么。预处理器,包括三字符组(trigraph)和其他历史遗留,既令人讨厌,又是编译期痛点的核心。他意识到的,是一道许多人从未跨过的坎:这件事并非不可能,只是很难。他告诉了自己的经理;经理的回应是,他们确实需要解决这个问题,并且可以再给他配一个人。
目标很务实:更好的诊断、更快的编译,以及 GCC 从未有过的重构与分析工具。C++ 是最棘手的部分。人们以为它不可能实现;Lattner 说,它几乎就是不可能的。唯一的路径是增量式地构建,并招募真正了解各个子系统的工程师,比如模板、异常表、ABI 兼容性,因为没有任何一个人能掌握全部内容。
Swift 真正的阻力,来自一群热爱 Objective-C 的人
在 Clang 的第一版 C++ 支持之后,他又开始了另一个晚上做的项目:Swift。实现 C++ 会让你觉得一定有更好的东西。他在业余时间做了原型,然后和 Bertrand Serlet 聊了聊。后者当时是苹果的软件高级副总裁,也是懂语言的人,他鼓励了早期的设计。直到后来,他才把这件事告诉自己的经理,以及更大的圈子。
剩下的阻力是社会性的。关于 iPhone 的成功,公司内部的说法是它因 Objective-C 而成功。苹果招的人大多热爱这门语言,领导层许多都出身 NeXT,Objective-C 在那里真正落地。他们是在这门语言里长大的。提议一门新语言,就像异端邪说。Lattner 的理解不同:嗓门最大的人热爱 Objective-C,而一个更安静的多数,则一直在撞上那些尖锐的角。
为什么不干脆磨平 Objective-C 的棱角?打开项目的关键答案是内存安全。Objective-C 的对象系统建立在 C 指针之上,它本就是 C 的扩展。指针是不安全的。去掉指针,它就不再是 Objective-C 了。你没法给它打补丁。一旦团队接受了这个基础无法修复,工作就变成了一个设计问题:如果要做新东西,它应该是什么样子?
有些选择是由背景决定的。它会是一门有类型的语言,以便获得性能和重构工具的支持。它会被编译:2010 年前后的 iPhone 仍然受内存限制,而交付独立编译的代码,是苹果价值体系的一部分。1990 年代末,苹果曾认真考虑过把开发体验迁移到 Java 上。那是另一个时代,也是另一套约束。
Swift 的第一行代码必须能跑起来,而 import numpy 本身就是一项语言特性
在 LLVM 上干了足够多年之后,Lattner 不再把编译看成静态、动态和解释三选一。它是一个光谱。Swift 既是静态编译的,也能动态编译;即使还没有人这么做,解释执行也是可能的。在 Colab 和 Jupyter 里,每一行代码都要经过 Swift 的前端、优化器和代码生成器,然后作为机器码注入到一个正在运行的 Unix 进程中,原地修补这个进程。这并非实现上的偶然,这套技术栈从一开始就为此分好了层。
他反复引用的设计原则,是借自 UI 的“复杂度渐进披露”。第一课是 print("hello world"),一行代码,不需要 main,不需要头文件,也不需要 Java 那句 public static void main。然后是变量、控制流、函数、类、泛型、模块。每一层都可以单独运行。这在 Python 中很正常,但在编译型语言里并不常见,后者通常要求预先声明,因为编译器需要类型。
这门语言仍然要服务两类受众:想接触底层的库作者,以及只想组合库的使用者。从外面看,与 Python 的互操作像魔法。Lattner 说,动态类型语言可以被描述为没有类型,也可以被描述为只有一种类型:Python 对象。Swift 已经有很多类型了。再加上 PythonObject。import numpy 返回的是一个 Python 对象,也就是这个模块,属性访问和调用都会变成对 Python 解释器的动态查找。
这层粘合代码大约是 1200 行纯 Swift,建立在 C 互操作之上,因为与 CPython 通信就是 C 的调用约定。要让这 1200 行代码能够写出来,需要两项 Swift 特性,也就是动态成员查找和动态调用。它们经过提议、实现、标准化,整整花了一年才落地。用户只看到一个 import,他们所依赖的,其实是一项语言变更。据他回忆,Python 里因为机器学习而做的语言改动,就是 @ 矩阵乘法运算符。
TensorFlow 本质上是一个编译器,bfloat16 则是一次以范围换精度的押注
Fridman 问,Python 的即时执行(eager)程序如何变成面向 GPU 和 TPU 的计算图。Lattner 说,他只是在 Autograph 上略有参与,并把它描述为一个装饰器:它拦截一个函数,解析它,然后把 if 和乘法降级为图节点,也就是把编译器技术用在了 Python 上。Swift for TensorFlow 则是另一个前端,接入的是与 Python、Go、Rust 和 Julia 相同的图与运行时。Python 是特例:社区力量几乎都集中在这里,它有自己的自动微分和 API,而其上限由你能在库里做到什么决定。其他语言大多只是 TensorFlow C API 的包装,通常没有自动微分。
Swift 是第三个阵营。Lattner 认为,TensorFlow 本质上是一个编译器:它获取模型,让它们在硬件上跑得快,有前端、优化器,还有许多后端。Swift 的前提是,你可以改动这个技术栈的任何一层,因此应该重新思考程序员和编译器之间的分工。类型系统让编译器能够构建图、做聚类、融合和优化,而用户不必手动完成这些工作。语言集成的自动微分是一项关键贡献,因为 1970 年代的 Fortran 源码到源码工具需要看到整个函数,这是逐个算子工作的框架做不到的。
谷歌已经在做第三代 TPU:一个大型液冷机箱里有超过 100 petaflops,而且还没有枯竭想法。硬件要花几年,算法变化更快,所以你得把晶体管押在未来上。bfloat16 就是一个例子。它是 16 位浮点数,尾数更小,指数更大,与 float32 的范围相当。训练既需要会在 IEEE 半精度里下溢的微小梯度,也需要很大的数值;中间的精度则可以放宽。乘法器的面积和延迟,随尾数位数的平方增长,而只随指数位数线性增长,所以这种格式在硅片上更便宜。它最初是一种用于压缩网络权重的研究技巧,后来被固化到了芯片里。
访谈前三周,谷歌在一场面向机器学习编译器的会议上宣布了 MLIR,即多级中间表示(Multi-Level Intermediate Representation)。TensorFlow 嵌入了好几个厂商的编译器:谷歌的 XLA、英伟达的 TensorRT、英特尔的 nGraph。它们解决的问题相互重叠,又都想接入 TensorFlow。MLIR 是共同的基础设施,让它们可以共享代码,免得各自重新发明同样的机制。有人开玩笑说,这就是把 LLVM 的经验教训再应用一遍的 LLVM。Lattner 在精神上同意:LLVM 擅长它被设计去做的事,而 20 年后,人们要解决的是需要新技术的更高层次问题。
他更欣赏谷歌的开源,敬佩特斯拉的愿景,还在幻灯片上放了一条龙
MLIR 当时还没有开源,但会开源,Lattner 说他相信这条路。被问到如何比较苹果和谷歌时,他更欣赏谷歌的做法,但也公允地指出,苹果的习惯已经有 35 年历史,而公司也在调整。企业应当赚钱。赚钱的是有差异化的产品,字符串库本身并不赚钱。把那个库保密,已经不再是要紧的一层,尽管 15 年前它曾经是。在他看来,把 TensorFlow 开源是一项深远的押注:他能想象一家公司把机器学习当作秘密武器,并拒绝分享。这个决定改变了这个领域,而且他认为,它也为谷歌带来了回报。
访谈前后,他大约有五个月在特斯拉担任 Autopilot 软件副总裁,那正是从 Hardware 1 切换到 Hardware 2 的时期。Hardware 1 最初是为简单的自动化设计的,比如感知车流的定速巡航,以及那一类功能;特斯拉把它的功能一点点扩展到了实用的车道保持,这件事至今仍让他印象深刻。困难的工作,是在时间压力下,从第三方视觉栈转向自研,同时还要利用 Hardware 1 团队的既有知识。文化上并不契合。他见过的离职率,是他待过的所有公司里最高的。他后来敬佩的,是马斯克用一个具体的未来吸引人才、让人们愿意为之投入的能力。街角站着很多说要去火星的人,能让别人相信并且铺出道路的人却很少。他敬重这份愿景,但并不敬重它的所有做法。
他认同,努力工作当然包括大量工时。真正困难的,是同时兼顾短期交付与长期判断。如果你只完成眼前的任务,就会错过本该跨过的那道坎;如果你只为三年后做架构,那就什么都交付不了。在苹果,他之所以能在两者之间摇摆,是因为他建立了能够承担责任的团队和领导层,从而能够对下一件事保持“有点疯狂”。至于他自己,这不是他对团队提出的规则,他希望热爱工作,投入时间,并把改变世界当作目的本身。
LLVM 的标志是一条龙。比较好听的解释是力量、速度、智慧、优雅和模块化。真实的原因是《龙书》(Dragon Book),也就是经典的编译器教科书。苹果当时需要一个幻灯片图标,手头却没有;有人问,编译器的标志应该是什么,他说龙是他们手上最好的选项。苹果的设计师把它做了出来,社区接受了它,后来的设计师又把它改进了。他的妻子也是一名编译器工程师,现在负责 LLVM 基金会,她会在 Grace Hopper 大会上发贴纸;《权力的游戏》让龙变得很酷,这偶尔也有助于招募下一代人才。
同频道的其他文章
3:36:56EN → 简体中文
5:15:51EN → 简体中文
3:12:58EN → 简体中文
3:46:18EN → 简体中文
3:51:47EN → 简体中文
2:53:42EN → 简体中文