关于yadcc相关研究小结

Leostadyn 2022-01-19 03:17:41

一 简介

Yet Another Distributed C++ Compiler. yadcc是一套腾讯广告自研的分布式编译系统,用于支撑腾讯广告的日常开发及流水线。相对于已有的同类解决方案,我们针对实际的工业生产环境做了性能、可靠性、易用性等方面优化。目前在我们1700+核的集群中每天编译300,0000+个目标文件,产出约3~5TB,已经持续稳定运营 8 个月。

2021 年 6 月,正式对外开源。

取决于代码逻辑及本地机器配置,yadcc可以利用几百乃至1000+核同时编译(内部而言我们使用512并发编译),大大加快构建速度。

二 系统要求

  • Linux 3.10 及以上内核,暂不支持其他操作系统;
  • x86-64 处理器;
  • 编译yadcc需要 GCC 8 及以上版本的编译器,基于yadcc进行分布式编译时可以支持其他更低版本编译器。

三 基本原理

  • 我们的客户端伪装成编译器(通常是通过ln -sf yadcc g++创建的符号链接)
  • 通过将我们的客户端伪装的编译器加入PATH头部,这样构建系统就会实际执行yadcc来编译
  • yadcc会按照命令行对源代码进行预处理,得到一个自包含的的预处理结果
  • 以预处理结果、编译器签名、命令行参数等为哈希,查询缓存,如果命中,直接返回结果
  • 如果不命中,就请求调度器获取一个编译节点,分发过去做编译
  • 等待直到从编译集群中得到编译结果,并更新缓存

由于预处理时间通常远小于编译时间,因此这样可以降低单个文件的本地开销。同时,由于等待编译结果时本地无需进行操作,因此可以增大本地的编译并发度(如8核机器通常可以make -j100),以此实现更高的吞吐。

需要注意的是,分布式编译通常只能提高吞吐,但是不能降低单个文件的编译耗时(假设不命中缓存)。因此,对于无法并发编译的工程,除非命中缓存,否则分布式编译通常不能加快编译,反而可能有负面效果。

四 设计特点

我们的系统由调度器、缓存服务器、守护进程及客户端组成:

  • 对上层的构建系统(Make、CMake,Blade、Bazel 等)透明,方便适配各种构建系统。
  • 调度器全局共享,所有请求均由调度节点统一分配。这样,低负载时可允许客户端尽可能提交更多的任务,集群满载时可阻塞新请求避免过载
  • 中心的调度节点也避免了需要客户机感知编译集群的列表的需要,降低运维成本。
  • 编译机向调度器定期心跳,这样我们不需要预先在调度器处配置编译机列表,降低运维成本。
  • 分布式缓存避免不必要的重复编译。同时本地守护进程处会维护缓存的布隆过滤器,避免无意义的缓存查询引发不必要的网络延迟。
  • 使用本地守护进程和外界通信,这避免了每个客户端均反复进行TCP启动等操作,降低开销。另外这也允许我们在守护进程处维护一定的状态,提供更多的优化可能。
  • 客户端会和本地守护进程通信,综合控制本地任务并发度避免本地过载。
  • 我们通过编译器哈希区分版本,这允许我们的集群中存在多个不同版本的编译器

同时,我们做了多层重试,确保不会因为网络抖动、编译机异常离线等工业场景常见的问题导致的不必要的失败。

调度器

调度器具备全局视图,负责将各个编译任务关联到一台编译机。

不同于distcc,全局视图可以避免由任务提交机器进行本地决策而导致负载不均衡,如压垮某台编译机的同时还有另外的机器空闲等。

调度算法

目前我们的调度算法较为简单,其以如下几点为目标来分配编译机:

  • 优先考虑专有编译机。专有编译机负载不超过50%时任务始终分配至专有编译机。

    负载超过50%之后需要考虑SMT导致的单核性能下降,因此此时如果有更空闲的机器会优先考虑。

  • 除非没有其他机器,尽量避免将负载分配至提交任务的机器:这允许提交任务的机器有更多的CPU资源进行预处理。

  • 为避免某些机器自身已有负载过高而导致新分配的编译任务执行过慢,调度算法会考虑参与编译任务的机器负载。如果机器空闲CPU少于机器本身所能接受的最大任务数,则以较小值作为“实际能接受的最大任务数”。

    目前,Daemon定期在心跳包内上报自己15s内的平均负载,选择15s也是为了让daemon机器负载变化对调度算法更加敏感。

  • 在剩余可选机器中尽量保证各个机器的编译负载(任务数/实际能接受的最大任务数)均衡。

在没有机器有空闲资源(包括提交方自身)时,调度器会阻塞分配请求,避免过多任务压垮编译集群。

缓存布隆过滤器管理

调度器会定期扫描我们的缓存并构造相应的缓存布隆过滤器。

除此之外,为了保证布隆过滤器的时效性,在守护进程和调度器的心跳中,我们会:

  • 对于接收编译任务的守护进程:上报新填充的缓存的Key来更新调度器维护的布隆过滤器。
  • 对于提交编译任务的守护进程:返回其上次心跳至今,整个编译集群新填充的缓存的Key。

部分情况下一台守护进程即提交任务又接受任务,此时上述两种行为这个守护进程和调度器之间均会发生。

缓存服务器

由于编译缓存通常较大(我们的负载中单个编译结果在zstd压缩之后平均约1M),使用Redis这样的全内存数据库并不经济,因此我们决定自行设计一套缓存服务。考虑到需求可能随时会发生改变,我们也保留充分扩展缓存服务器的可能。

总体设计

参考缓存多层次结构,我们将缓存设计成了2层(简称L1和L2)。通常L1比L2缓存更快成本也更昂贵,L2缓存相对来说比较大并且更加可靠,可以认为是主缓存。

L1缓存

L1缓存毫无疑问是基于内存,直觉上是缓存热点数据,并采用一定淘汰策略保持大小可控。算法是参照ARC实现,该算法会自适应的在LRU和LFU中进行折中,并不需要人工去调节参数。短期我们认为不会有明显更优的方案,所以采用默认实现足以,并不需要考虑扩展问题。

L2缓存

为了便于系统今后方便扩展,适应更多存储方案,我们抽象了底层存储引擎实现。当我们需要其他存储方案时,可以快速实现一套底层存储方案,并通过修改配置选择对应的存储方案,并不需要修改核心逻辑。

可考虑缓存方案如下:

  • NULL缓存(已支持。表示无L2缓存,完全依赖L1)
  • 基于磁盘(已支持)
  • 基于NoSQL(待支持)
  • 基于分布式文件系统(待支持)
NULL缓存

如何使用该L2缓存选项,表示你并不需要L2缓存,那么所有的缓存将在内存进行。如果程序重启,将丢失所有的缓存记录。

基于磁盘

考虑到编译缓存的如下一些特点:

  • 编译缓存对空间占用敏感:NoSQL引擎通常使用SST+Compaction,这样的设计通常有较大的额外空间开销。
  • 单个编译结果通常较大:将各个缓存项分别作为磁盘文件的设计引发的“大量小文件导致磁盘利用率低”的问题对我们的场景并不存在。
  • 虽然系统管理的Page Cache不一定可以做到最优,但是可以预期的在一定程度上可以实现“分级存储”的效果。另外,以1M的平均大小来计算,做到100QPS就可以跑满1Gbps带宽,即便Page Cache完全不命中,实际场景中磁盘IO不一定会是性能瓶颈。

因此我们放弃了使用LevelDB、RocksDB等NoSQL引擎,而选择直接基于磁盘文件保存缓存。

守护进程

守护进程在Yadcc编译工具中主要负责两大板块的内容:

  • 处理本地请求:接收本地编译器wrapper提交的任务、请求调度器获取空闲的编译机、将任务提交至具体编译机、等待任务完成并将结果返回给wrapper。
  • 处理网络请求:接受网络上其他编译机提交的任务、执行、和提交方通信(返回结果等)。

五 开始使用

Yadcc自带了必要的第三方库,因此通常不需要额外安装依赖。

需要注意的是,yadcc通过git-submodule引用flare,因此编译之前需要执行git submodule update拉取flare。另外由于flare代码仓库需要git-lfs支持,因此您还需要安装git-lfs。

Flare 是我们吸收先前服务框架和业界开源项目及最新研究成果开发的现代化的后台服务开发框架,旨在提供针对目前主流软硬件环境下的易用、高性能、平稳的服务开发能力。

Flare 项目开始于 2019 年,目前广泛应用于腾讯广告的众多后台服务,拥有数以万计的运行实例,在实际生产系统上经受了足够的考验。

git clone https://github.com/Tencent/yadcc --recurse-submodules

git clone https://github.com/Tencent/yadcc
cd yadcc
git submodule init
git submodule update .

源码编译

  • Github仓库拉取
git clone https://github.com/Tencent/yadcc --recurse-submodules
  • 安装git-lfs
cd yadcc
git submodule init
git submodule update .
  • 编译yadcc项目
./blade build yadcc/...

单编译系统部署

yadcc并不区分编译机及用户机。默认情况下,用户为了提交任务至编译集群,即会自动贡献一部分CPU至编译集群供其他用户使用。

  • 启动调度器
./yadcc-scheduler
  • 启动缓存
yadcc/cache/yadcc-cache
  • 启动守护进程
./yadcc-daemon --scheduler_uri=flare://ip-port-of-scheduler --cache_server_uri=flare://ip-port-of-cache-server 

复合编译系统部署

yadcc可以和类似于distcc / icecream等分布式编译系统相同的方式,既可以通过软链接到g++ / gcc实现整合,也可以通过(对于某些构建系统)覆盖CXX / CC / LD环境变量整合。

通过符号链接构建

  1. 创建目录~/.yadcc/bin~/.yadcc/symlinks
  2. 复制build64_release/yadcc/client/yadcc~/.yadcc/bin
  3. 创建软链接~/.yadcc/symlinks/{c++,g++,gcc}~/.yadcc/bin/yadcc
  4. ~/.yadcc/symlinks加入PATH的头部。

**请注意,创建软链并通过软链~/.yadcc/symlinks/g++执行并不等同于直接执行~/.yadcc/bin/yadcc**。

通过环境变量构建

CXX='/path/to/yadcc g++' CC='/path/to/yadcc gcc' LD='/path/to/yadcc g++' ./blade build //path/to:target -j500

六 效果

我们搭建了一个 1000 多核的测试机群,在一些大型 C++ 项目上实测了效果。

LLVM 项目:

  • 源代码:llvm-project-11.0.0.tar.xz。
  • 机型:8C16G。
  • 编译器:GCC 8.2.0。

在我们的测试环境中共计 6124 个编译目标,结果如下:

  • 本地8并发编译:47分51秒
  • 分布式256并发:3分11秒

对于我们内部的一组更大的实际产品项目代码上:

  • 16C 开发机本地 8 并发:2时18分17秒
  • ccache+distcc, -j144:44分23秒
  • 76C 高性能开发机,-j80:25分18秒
  • yadcc:9分25秒

性能对比

此处列出了一些对比数据。所有测试均不命中缓存。

相对于本地编译的对比

8C虚拟机,256并发度,使用 llvm-project-11.0.0.tar.xz

可能取决于机器环境,不同机器上cmake3生成的目标数不一定一样。此处我们的环境中共计6124个编译目标。

本地编译

命令行:time ninja

[6124/6124] Linking CXX executable bin/opt

real    47m51.414s
user    356m17.391s
sys    23m25.461s

分布式编译

分布式256并发、本地4并发

YADCC_CACHE_CONTROL=2表示不读取缓存,但是执行缓存相关逻辑并写入缓存。主要用于调试目的。

命令行:time YADCC_CACHE_CONTROL=2 ninja -j256

[6124/6124] Linking CXX executable bin/clang-check

real    3m11.292s
user    16m48.304s
sys    4m24.946s

相对于ccache + distcc的对比

我们基于内部的蓝盾平台提供的ccache + distcc进行对比,16C虚拟机中以144并发度编译约18k个目标:

本地编译采用16并发时编译过程中OOM导致编译器被kill,故本地编译采用8并发。

  • 本地8并发:2时18分17秒。
  • ccache+distcc:44分23秒。
  • yadcc:9分25秒。

需要注意的是,我们内部提供的ccache + distcc会对本地并发度进行限制(同yadcc一样,避免本地过载)。且我们观察到了部分失败重试,这些重试一定程度上阻塞了后续分布式编译任务的生成,因此跑出来的结果可能较于理想条件下更差。但是整体而言,yadcc相对于ccache + distcc依然应当有较为明显的优势。

总体而言,yadcc 有相当明显的性能优势。

作者:NP153

...全文
179 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

571

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧