分布式编译发展历程

2023年 9月 29日 63.2k 0

写在前面

编译是每个程序员在软件研发过程中必不可少的步骤,自编程语言发明以来,围绕编译速度的优化就一直没停止过。除了编译器本身的优化,分布式编译也是业界比较流行的优化方向,在大型企业往往能发挥极大的作用。

比如Google的编译平台一直是业界的标杆,秒级的编译能力,大大的提升了工程师的生产力,这其中也离不开分布式编译这项技术,本文将聊一聊这项技术的发展历程。由于工作年限的问题,对于上古时期发生的事情会有些模糊,表述如有不准,欢迎在评论区指出。

什么是分布式编译

从源码到二进制,需要经过编译器的解析和翻译,这个过程叫做编译。对于一个标准的项目工程来说,编译包含了大量独立的子步骤,比如下图,假设工程目录下有hello.cppworld.cpp两个源码文件。编译时,先经过编译器(Compiler)把源码文件hello.cppworld.cpp编译成hello.oworld.o,再经过链接器(Linker)将hello.oworld.o连接成可执行文件hello_world

image.png

hello.cpp -> hello.oworld.cpp -> world.o是两个独立的进程,也没有执行顺序的要求,可以把这样的任务发给远程机器执行,再下载执行的结果,这就是分布式编译的雏形。

初代王者 distcc

distcc是一个历史非常悠久的项目,可以追溯到2002年,那时我甚至还在读小学。从命名也可以看出,它是一个为c系语言设计的分布式编译框架(distribution c compiler)。

distcc的原理是将compiler的工作细分为2个独立单元:预处理和编译,其中预处理在本地执行,编译则发送到远程机器上。这个设计的巧妙之处在于,远程机器无需拥有很本地一样的源码目录。因为经过了预处理的.i文件,可以被编译器直接解析,而无需依赖其他文件,但是预处理步骤,却需要依赖头文件(Header File),也就意味着远程机器需要拥有完整的源代码,在工程实现上难度增加了很多。

image.png

distcc的工作模式也很简单,作为轻量级命令行工具,直接包在compiler前面,写成下面的形式:

distcc gcc -c hello.cpp -o hello.o

distcc本身不具备编译器的功能,它只是协调生成hello.o的过程,把一部分工作分发出去而已。distcc在早期无疑是c++大型工程的优化利器,对编译性能可以达到数量级的提升。

但distcc也存在不少问题,其中预处理瓶颈和头文件 冗余是两个经典的问题。

  • 预处理瓶颈

从distcc的工作原理看,预处理依然要使用本地CPU资源,本地CPU能同时进行的预处理任务有限,因此它会导致编译并法度存在瓶颈。

  • 头文件冗余

c语言的预处理,会把依赖的头文件复制粘贴到源文件中,一旦依赖了较多的头文件,就会导致预处理之后的文件变得巨大,造成不可忽略的网络开销。而且存在不少被大量引用的头文件,这些头文件被复制到很多.i文件里,造成重复传输。

pump模式

值得尊敬的是,虽然distcc是20年前的产物,但也不断保持着更新,针对前面提到的两个问题,distcc研发了pump模式,它可以把预处理工作也交给server完成。这里面涉及到的技术细节比较复杂,本文以介绍发展史为主,也就不过多深入展开了。

Icecream

除了distcc本身在不断进化,开源界也孵化了另一个基于distcc的解决方案icecream,它的工作原理和distcc类似,但在任务调度方面做了更多的优化。distcc 需要使用者手动配置server列表,而icecream使用了更高级的分布式调度器和负载均衡器来优化任务分发和节点管理。

好伙伴 ccache

接下来我们聊聊ccache,它是distcc生态里的好伙伴,通常也是成对出现的。ccache采用了另一种常用的编译优化手段 —— “编译复用”。

它的原理是,如果某一个源代码文件,以及它依赖的所有头文件,外加编译参数和编译工具,都没有发生变化,那么不管编译多少次都应该得到相同的结果。这样,我们可以把之前的编译结果缓存起来,下次只要发现编译相关的影响因素没变时,就可以下载之前的产物。

image.png

ccache的技术难点在于如何判断两次compiler调用之间,是否存在“复用”的可能。这里它采用了一种叫做“构建摘要”(compile digest)的技术,把源文件,头文件,编译参数,编译器版本等信息作为入参,通过哈希(hash)算法计算出一个摘要值,存入文本数据库里。下次编译时,如果发现了相同的构建摘要,则直接复用编译产物。

image.png

另一个难点在于如何精确获取cpp文件依赖的header files列表,ccache巧妙的利用了编译器生成的.d文件,这个文件记录了编译的全部依赖,包括头文件的列表。这里有一个前提是,当下一次编译新增或减少了头文件依赖时,必然对已有的文件进行改变,比如增加下面的一行:

#include "new_header.h"

因此这个方法绝大多数场景是有效的。

ccache可以单独使用,也可以配合distcc,安装在分布式编译机上。ccache最大的弊端在于构建产物只支持存储在本地磁盘上,这样无法进行跨机器复用。

其实并不是ccache不想拓展这个能力,而是这个能力牵扯到另一个令人头秃的问题——路径无关。

ccache和distcc的用法一样,在编译命令前作为一个wrapper进行调用,例如:

ccache gcc -c hello.cpp -o hello.o

由于原始的编译命令不可控,可能出现一些本地环境信息(例如绝对路径),这种情况下,直接拓展跨机器复用也起不到效果。

进阶版本 sccache

鉴于ccache在跨机器复用能力的不足,Mozilla(rust语言的母公司)在ccache的基础上开发了集群版的sccache, 它不仅可以使用本地磁盘存储编译产物,也可以将编译产物发送到云端,并对接AWS S3,Azure,Google Cloud Stroage等对象存储服务。

作为新项目,sccache不仅支持distcc,也支持icecream。而且在编程语言上额外支持了rust,现在已经成为rust编译加速的标准解决方案之一。

集大成者 bazel & remote execution protocal

前面提到的所有工具和解决方案,都采用了hook编译器的方式。优点是开箱即用,兼容任何编译系统(例如make,cmake,ninja),但缺点也很明显,对编译命令的生成缺乏掌控力。因此在具体实现的时候,就会遇到很多麻烦的问题,比如命令中出现了绝对路径该如何处理。

如果说分布式编译领域的早期探索是在外围寻找解决方案,那Bazel则属于“内部玩家”亲自下场比赛了。Bazel是Google开源的构建系统,也是Google内部颇受欢迎的构建系统Blaze的开源版本。它负责构建配置的解析,命令的生成与执行。

Bazel对分布式编译进行了更高维度的抽象,不再局限于某种编程语言或某种编译器,而可以适用于任何可独立执行的进程,由于不再局限于编译领域,Bazel抽象出的协议从命名上也不再和编译绑定,而叫做"Remote Execution Protocal"

该协议把可远程执行的最小单元抽象为Action结构,Action的主体由文件依赖和执行命令构成。

image.png

虽然乍看起来和前面那些工具的区别不大,但该协议在内容复用上做了非常巧妙的设计,这也是它在性能上远超distcc生态的原因。

在大型企业内部,有大量同时发起的构建任务,这些构建任务往往依赖了相同的文件。remote execution协议巧妙的把文件存储和分布式编译这两部分剥离开。当客户端向集群发送编译任务时,只需发送依赖文件的摘要(Digest),而不必发送文件本身,这个优化大大减少了重复的文件传输。

但是客户端怎么知道某个文件是否在服务端存在呢,协议规定客户端在发送编译任务之前,需要先做一次批量查询,然后只发送服务端缺少的那部分文件,这样就做到了每一份文件仅需要发送一次,具体的工作原理如下图所示:

image.png

Remote Execution协议也规定了构建缓存的实现方式,称之为Action Cache,同样采用了构建摘要的技术,针对每个Action,在执行前先计算Action Digest,如果命中了缓存,就可以直接下载Action Result。

协议给了客户端以及集群的实现者很高的自由度,例如缓存集群可以自己选择blob文件的存储方式,可以存储磁盘或云盘,甚至可以通过proxy的方式外包给下一级缓存服务。

Remote Execution协议在性能上还有很多巧妙的设计,本文不多展开。可以看到的是,现在各大厂的分布式编译技术已经开始拥抱这套协议的生态,在未来围绕这套协议,还可能展开构建工具和构建集群等细分领域的发展。

新生态下的工具 goma/recc

goma和recc就是Remote Execution协议下诞生的新型客户端工具。值得一提的是,如果你使用Bazel作为编译系统,实际上是不需要再额外使用客户端工具的,因为Bazel已经把协议的客户端部分实现了。

但是Bazel的使用成本本身较高,如果还想像以前使用distcc一样,通过hook命令行的方式使用,还有办法吗。goma和recc就是在这种场景下的选择。

goma来自于Google的另一个内部项目chromium,它原本的作用是为chromium的贡献者提供编译加速服务。虽然它的代码开源了,但你没办法直接部署使用,必须获得Google签发的token才可以使用它们的构建集群。

recc则来自一个民间开源项目,它非常轻量,可以说和distcc非常类似。

goma和recc的核心工作原理都是基于编译命令,组装Action数据结构,再与协议中的Server端进行通信。

image.png

其中最大的难点就是如何基于command,找到依赖的所有文件。

虽然也可以仿照distcc,把预处理放在本地,把编译分发出去,但这样的做法,和distcc比起来就没有什么优势了,所以它们两均采用了获取依赖头文件的方式。

recc直接复用了compiler的能力,通过gcc -M命令获取实际依赖的头文件,这个方法虽然实现简单,但执行命令本身也存在开销,因此依然存在类似distcc的性能瓶颈。

goma选择了一条复杂的道路,它自研了一套依赖分析引擎,通过读文件的方式,直接分析代码中的 #include预处理指令,实现了更快速的依赖分析能力。为了提高对操作系统的复用度,goma还把依赖分析做成了常驻进程compiler_proxy,性能得到了极大的提升,使用goma甚至可以达到几百的编译并行度。

image.png

goma虽然性能非常优秀,但由于他没有完全开源,外加部署非常复杂,在生产环境投入使用难度较大。

除了goma和recc依赖,也有其他的构建系统直接复用了该协议,比如Meta的Buck2,Twitter的Panks,它们虽然是Bazel的竞争对手,但也认可了这套协议,足以见得协议的设计还是比较优秀的。

集群生态

最后聊一聊Remote Execution的集群侧又涌现出哪些玩家。

首先是Bazel团队官方出品bazel-remote (Go) 和buildfarm (Java),它们分别负责Blob存储和调度执行。它们的特点是部署比较简单,开箱即用,但是在大规模场景下,也会存在性能瓶颈。

除了官方出品外,也涌现了一些优秀的第三方开源项目,例如buildbarn,采用了微服务架构,实现了高性能的调度器和存储引擎。本人在另一篇文章中详细介绍过buildbarn是如何通过块存储技术大幅度提升存储引擎的性能。

Turbo cache也是最近涌现的一个高性能存储引擎项目,虽然对它不是十分了解,但它采用了高性能编程语言rust,其表现同样值得期待。

除了开源软件,在这个领域也涌现出了企业版玩家,比如Engflow和Buildbuddy,作为企业版,他们在对接云资源托管方面做了更多的努力,同时也提供了强大的构建数据分析能力,让付费玩家可以更清晰的掌握企业的构建效率,从而进一步提升人效。

Remote Execution的集群生态依然在快速发展,从协议Readme页面的提交记录也能看出来。也许未来这个赛道还会涌现出更多优秀的玩家。

总结

分布式编译领域,自distcc以来,就不断涌现着优秀的解决方案。从distcc + cache 到Bazel的remote execution协议,该技术逐步往高性能,大规模的方向演进。remote execution在设计上是一个非常优秀的协议,它充分考虑到了资源的复用性,现阶段主流的解决方案也大多围绕它来展开。

对于大型公司来说,分布式编译如果能准确选型,收益是巨大的。为每天数以万计的构建任务提速,可以节约相当大的人力成本。赢得了效率的竞争,就更有希望赢得商业的竞争。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论