Bazel Notes

这是一篇2019年左右的记录, 内容可能过时, 也不太全面

杂谈

Bazel是Google为Monorepo服务而开发的构建工具.

首先是巨大,当问题的规模变大,事情总是会变得更复杂. 而Google面对的"巨大Monorepo",应该是世间罕有的.

然后是Monorepo,这极大的影响了代码的组织风格.例如,你要写一个操作系统内核ProjectOS,还要写一个游戏ProjectGame.在传统的开发习惯中,这两个项目会组织到两个不同的Repo里,PorjectOS和ProjectGame之间无法直接相互引用,例如,你在ProjectOS里写了一个高级的数据结构,想要在Game里也使用,要么直接复制粘贴,要么是创建一个新的CommonRepo,把可公用的代码都放在Common里,然后两个项目各自引入Common作为依赖.

使用MonoRepo则不存在这个问题,Game可以直接依赖OS内的组件,按照Bazel的语法描述,就是在Game中可以直接使用@ProjectOS//path/to/package:AdvancedStruct.当然,你仍然可以选择重构一个Common出来,但是现在已经没有这种必要了.

从技术层面说,在使用"巨大Monorepo"时,Bazel相对现有编译系统的优势能充分展现.你的项目离"巨大Monorepo"越远,使用Bazel的优势就越不明显. 宽泛的来说,对于大部分项目,使用Bazel都不能带来明显的技术收益.

一般而言,使用Bazel的优势是并不是体现在技术层面.而是让你的代码能无痛使用谷歌的开源组件. 原因很简单, Google的项目都是基于Bazel的,如果你不用Bazel,就需要自己做一些额外的工作.

换个角度来看,这也是谷歌的"阴谋":如果Google外的开发者不用Bazel,那么当Google需要依赖外部项目时,就需要手动把这些项目转换成Bazel的;而如果全世界的程序都用Bazel来组织编译,那么Google就可以无缝的从开源世界汲取力量.

吹Bazel的帖子到处都有,所以这里只说黑点,如果这些黑点你并不很care,那么使用Bazel应该是个不错的选择

  1. Bazel是后来者,熟悉它的开发者比较少,加上学习成本高,可能需要有若干"Bazel"专家负责整个构建系统的维护.
  2. 对于已经成熟的大中型项目,迁移到Bazel的时间/人力成本会比较高,而这一般也不会带来明显的收益.最好在项目早期使用Bazel
  3. Bazel是Java写的, 也就是说,它依赖JVM
  4. 使用Bazel的低级功能收益有限,而使用高级功能的学习成本又很大.
  5. 本质上说,BUILD文件和rule语法服务于构建action_graph,这是一种"声明式编程",如果你熟悉TF等框架的计算图,那么理解起来应该不难. 如果你不熟悉,那么可能就会需要适应一下.
  6. sandbox对构建系统也许是有帮助的,但是对开发者的心智和头发绝对是不友好的.
    • 例如1. 你生成了一些头文件,由于这些头文件在sandbox内,你必须用一些tricky的方法才能让IDE识别到这些头文件.
    • 例如2. 编译器报了某个文件的错误,你打开报错的文件一顿修改… 结果发现修改的只是sandbox内的副本.

How Bazel Works

Bazel的学习成本很高,为了实现很多高级功能,引入了一些复杂的概念,如果你不懂这些概念,那么有可能连帮助文档都看不懂.

Bazel的典型文件组织

  • Repo的根目录下有一个唯一的WORKSPACE文件,这个文件只用于描述该repo依赖的其他repo
  • 子目录下可以有BUILD文件,有BUILD文件的目录称为PACKAGE,BUILD文件用于描述target,PACKAGE之间没有交集.
  • BUILD文件所在目录为DIR,那么DIR内(递归)的所有文件都是PACKAGE的一个target,这个target的name就是相对路径
    • 例如,若存在/dir/foo/BUILD/dir/foo/bar/some_code.cc,那么foo这个PACKAGE内就存在一个name为bar/some_code.cc的target
  • 每个target都有一个全局唯一的label,这个label主要通过路径名和target名组合生成,典型结构为@repo_name//path_to_pacakge:target_name
    • @repo_name表示label所在的repo
    • @是一个特殊的case,它表示项目的绝对root repo
    • 例如,repoA在WORKSAPCE内依赖repoB,那么在这种场景下,repoB内的@实际代表了repoA.而对于单独的repoB,@代表了它自己
    • 在引用label时,存在各种语法糖来简化label的写法,这些语法糖并不重要

Bazel的运行流程

理解Bazel的运行流程是正常使用Bazel的前提. 然而, 即便是官方文档,对此也是只言片语的三句话"Loading,Analysis,Execution". 在此,我将从类比Python的角度去描述Bazel的执行流程.

这里可以先记忆以下信息

  1. Bazel相当于是Starlark语言的Interpreter.
  2. Bazel执行时,会创建一个Sandbox, 这个Sandbox体现为一个独立的工作目录,用于隔离文件操作.用户写的各类rule,target等操作都是在sandbox内执行的.只有bazel自身能访问sandbox外的原始文件.
  3. Bazel的执行逻辑大致可以用下面的伪代码描述.
  4. Bazel的Loading/Analysis截断是在构建一个称为Action Graph的图, 这个图的每个节点都是一个编译动作, 对应了一个编译Target, target之间的依赖决定了图的结构, 也决定了编译的顺序.

注意: Bazel里的macro是"函数"的意思,并不是C++那样的,在预处理阶段通过复制粘贴的形式展开到调用处。

############### Loading
import WORKSPACE # import is same as `load`
import ALL_BUILD_FILE # import BUILD recursively

############### Analysis
bazel_build_flag,commandline_target = parse_cli_args()

action_graph=Graph()
sorted_target = topo_sort(global_target_map.to_list())
if do_analysis:
    for target in sorted_target:
        target.update()
        ret_info = target.impl(target.ctx)
        new_action_nodes = create_new_nodes(target.ctx,ret_info)
        action_graph.insert(new_action_nodes)
############### Execution
if do_execution and action_graph.isValid()
    exec_in_sandbox(action_graph,commandline_target)
  • Load阶段将会加载所有的脚本,加载脚本的顺序为:先加载repo根目录的WORKSPACE文件,然后递归的加载所有子目录下的BUILD文件.
    • WORKSPACE文件仅用于调用repo_rule,repo_rule执行后,会把第三方依赖组织到sandbox的extern内(下载或者软链接)
    • BUILD文件仅能用于调用rule,rule执行后,仅仅是创建了Target对象.
    • 逻辑上看,rule创建的target会注册到一个全局的global_map
    • rule语句创建新target时, 可以使用其他target的label来描述自己的依赖,且依赖label对应的target可以不存在(声明式)
    • 在 WORKSPACE/BUILD load的过程中,一些额外的.bzl文件也会被load进来,这些.bzl仅能用于定义marco/rule/repo_rule
    • 所有starlark脚本都能在当前文件的全局作用域定义变量,对.bzl而言,这些变量由_命名控制可见性.(BUILD/WORKSPACE文件不会被其他文件load,也就不存在可见性的概念)
  • Analysis阶段则会
    • 先根据Target之间的依赖关系对所有Target进行拓扑排序
    • 按照拓扑序顺序调用target.update();target.impl(target.ctx).
    • 执行update()主要是根据已有的信息更新target自身的部分属性值,这些值需要从依赖中获得,而由于拓扑排序,target的所有依赖一定已经执行过impl()了,一定是可以提供信息的.
    • 执行impl()用于更新自己的状态,并向后续的其他target提供信息.
    • Bazel会根据impl()返回的信息和target.ctx来构建新的action_node,并插入action_graph中
    • target.ctx内会包含所有的候选action
    • impl()返回的信息ret_info是一个列表,可以包含多种信息,最基本的是DefaultInfo,这个信息的file属性将标明当前target的输出文件.而只有与输出文件相关联的候选action才会被构建成action_node
  • Execution阶段则比较简单.
    • 只需要根据command_line给出的build target,逆着 action_graph 回溯,得到编译target所需的最小子图,然后执行这个最小子图即可.
    • Execution阶段是完全在sandbox内执行的,action所需要的文件由Bazel软链接到sandbox内.
    • 换言之,如果action的描述不正确,那么就可能有文件没有被拷贝到sandbox,进一步导致编译失败.例如,编译a.cc时需要a.h,而在描述action时,若没有把a.h对应到文件加入依赖列表,那么a.h就不会被拷贝,在sandbox内就会编译失败,提示"找不到a.h"
    • 仅在execution阶段才会动态的逐步向sandbox内添加文件.

注意,WORKSPACE内调用repo_rule时,从功能上说类似于"函数",调用的repo_rule会立即执行,并不会产生target对象;而在BUILD文件内调用rule时,rule从功能上说则是class,仅仅用于创建新的target对象.

其他细节

  • 在Ananysis阶段,target.update()会做很多事,其中比较重要的一项就是更新target.ctx
    • 例如ctx.files就会被更新为具体的文件路径.例如,ctx.attr.depend_target是一个file型label,那么更新后,ctx.file.depend_target就是一个字符串,代表了对应的文件路径.
  • bazel rule的impl函数返回的DeafultInfo直接决定了最终的action_graph包含了哪些action.而没有进入action_graph的action,都不会被执行.
  • python是bazel的依赖,支持bazel的平台一定会有python,但是shell则不一定,所以跨平台项目中,当需要调用自定义脚本时,最好是python脚本
  • 只能使用--workspace_status_command来在原始的repo目录内执行用户自定义脚本,自定义脚本输出到std::cout的信息可以被bazel重定向到两个特殊的文本文件.
  • impl()函数内创建候选action时,粒度越细越好,这样可以充分利用bazel的cache及action_graph最小执行.
    • bazel的最小执行和cache系统都非常激进,如果impl给出的候选action和DefaultInfo不正确… 那么cache/最小执行就很可能不能正确工作.
  • action的输入/输出不能是目录

Bazel Document Note

bazel的platform分为三个概念: host:执行bazel程序的主机,execution:执行构建程序的主机,target:运行最终产物的主机

.bazelrc文件用于自动为bazel 执行提供额外的参数

build文件描述的依赖关系必须严格和实际代码一致,且包含所有的直接依赖,否则bazel的cache系统就无法正确的识别出依赖,导致使用错误的cache。

Read more

Coroutine

从一般概念上说, 协程是特殊的函数调用: 被调用的函数可以在可控的位置被中断,然后在下一次调用时,继续从上次中断的位置继续执行。 本文主要通过Python的协程来介绍协程, 这是我唯一熟悉的一种协程实现. Classic Coroutine 下面的python代码很好的说明了协程的核心功能 def co_routine(): recv0 = yield 996 # hangs here after first coro.send assert recv0 == "Second" yield 711 # hangs here after second coro.send return def main(): coro = co_routine() # Create a new coroutine object value = coro.send(None)

By Edimetia3D

GDB with Python

这篇文章的主要应用场景是调试Python的C/C++ Extension 1. 同时使用pdb / gdb 进行调试. 通俗点说, 既可以break在 .py 文件中,也可以break在 .cc 文件中 2. 在gdb中不但可以获得常规的调试信息, 还可以获得python VM 的调试信息, 例如获得python的调用栈, 访问Python局部变量等. 这将会在调试exception时(如Segmentfalut)非常有用, 这种场景下, 定位 Python VM 正运行到哪一行代码往往可以提供一些直观的重要信息. 第一步: 编译源码以获得一些辅助数据. 我们并不真的需要使用从源码编译的Python, 但是一些调试相关的辅助文件需要从源码中获得, 包括 python-gdb.py及debug symbol等. 在 https://www.python.org/ftp/python/ 或 https://github.com/python/cpython

By Edimetia3D

Unix related things

这是一篇2017年左右的记录, 仅用作分享 杂 * 在shell内能干的事,我们都可以比较简单地通过系统调用实现. * `称为反引号,^称为脱字符,常用来表示CTRL * windows的系统调用是不开放的,windows下只能直接使用windows.h里的windows API. * /dev目录下的设备是供用于程序直接使用的,主要由block,char,pipe,socket类型 * 并不是所有设备都能映射为这种形式 * /sys/device/目录称为sysfs,他下面存放了所有设备的信息.(不能直接从/dev获得任何设备信息) * udevadm info --query=all --name="/dev/sda1"可以用于查询/dev下某个设备对应的sysfs路径 权限系统 * 权限系统由两部分组成 * 文件属性:用于标注文件owner,所属组,以及权限的设定(默认只有owner和root可以修改权限设置) *

By Edimetia3D

Docker

这是一篇2017年左右的记录, 内容可能已经过时 * Docker的image类似于Git的repo,而docker的tag则类似于git的branch * 由于内核共享, Docker container 里的uid/gid是和宿主机复用的, 所以相关的鉴权系统也和系统一致. * 用户名可能不一致, container内可以使用自己的用户名. * 可以使用 --user来指定docker container内所有进程的执行身份 * Docker 可以近似为特化的虚拟机,除了Kernel外,所有的其余部分都可以是Docker独占的。 * 例如,可以制作完整的OS镜像,这些OS镜像除了没有内核,其余都和正常的OS是一致的。 * Docker之间的隔离相比VM要浅一些,可能存在一些安全问题;另一方面,VM则由于可攻击面更大,也有安全问题 * Docker可以说是一个Utility, 并没有自创新技术,所以Docker中的技术主体为Docker-Engine,它只是驱动整个体系高效的运转. * Docker是通过K

By Edimetia3D