Ninja([ˈnɪndʒə]忍者)是一个构建系统,与 Make 类似。作为输入,你需要描述将源文件处理为目标文件这一过程所需的命令。 Ninja 使用这些命令保持目标处于最新状态。与其它一些构建系统不同,Ninja 的主要设计目标是速度。
我在参与 Google Chrome 项目时编写了 Ninja。一开始,我将 Ninja 视作一个实验——看看能不能让 Chrome 构建的更快。为了成功地构建 Chrome,Ninja 也有其它一些设计目标:Ninja 必须易于嵌入大型构建系统。
Ninja 获得了相当的成功,逐渐取代了 Chrome 所使用的构建系统。Ninja 公开后,一些人贡献了代码,使得流行的 CMake(产生构建目标文件) 构建系统能够生成 Ninja 文件。现在,Ninja 也被用来开发基于 CMake 的系统,如 LLVM 和 ReactOS。其它一些拥有定制构建系统的项目,如 TextMate,直接将 Ninja 作为其构建目标。
2007 年至 2012 年间,我参与了 Chrome 项目,Ninja 则开始于 2010 年。对于像 Chrome 这样的项目(如今有约 40000 个 C++ 代码文件,生成的二进制文件约有 90 MB),有许多因素能对构建性能造成影响。我当时也接触到了其中一些,涵盖多机器分布编译到链接技巧。Ninja 起初只针对一个领域——构建的前端。也即介于开始构建到第一个编译指令开始运行这一段时间的事。要理解这一阶段为何如此重要,有必要首先理解我们是怎样看待 Chrome 本身的性能的。
Chrome 历史小介对 Chrome 的目标进行全面讨论超出了本文的范畴。但速度是其中非常明确的一点。性能是整个计算机科学领域共同的追求。Chrome 几乎用到了所有可能的计巧,从缓存到并行再到 just-in-time 编译。还有启动速度——在点击了那个看起来有点无聊的图标后 Chrome 要花多久在屏幕上显示出来。
为什么要关心启动速度?对于浏览器而言,迅速的启动给人一种轻便的感觉,操作 Web 上的东西如同在本地打开一个文本文件一样便捷。其实,人机交互领域已经深入地研究了延迟对情绪和思维的影响。像 Google 或 Amazon 这样的互联网公司对延迟非常关注,他们在探知延迟的影响方面也有一定的优势。他们已有的实验结果显示,仅仅是毫秒级的延迟都会对人们使用网站或在网上购买商品的频率产生可见的影响。这是微小的挫折感在潜意识中累积的结果。
Chrome 的快速启动是通过 Chrome 最早期的某个工程师设计的技巧达成的。当他们刚搭建出程序框架,仅能在屏幕上显示一个窗口时,他们就建立了基准测试,并在随后的构建过程中对其进行追踪。正如 Brett Wilson 所说:“规定非常简单:这个测试不能变慢。”随着 Chrome 的代码量不断增长,维持这一基准测试需要付出额外的努力——有时,某些计算工作会被推迟到真正需要的时候,或者,启动时所需的数据是预先计算的。但是,对性能最首要的优化,也是给我印象最深刻的,是做得更少。
我起初加入 Chrome 团队时,并未想到在构建工具方面着手。我的背景和平台选择是 Linux,而且我想要成为一名 Linux 极客(对计算机和网络技术有狂热兴趣并投入大量时间钻研的人)。为了限定工作范围,Chrome 项目起初是 Windows 专属的。我认为我的职责是帮助完成 Windows 版。如此,我再让它在 Linux 上运行。
当我们开始向其它平台移植时,第一个困难是选择构建系统。彼时,Chrome 已经非常庞大(补充,实际上,Windows 版的 Chrome 在 2008 年发布,当时任何移植都还没有开始),启图从基于 Visual Studio 的 Windows 平台构建系统大规模地切换到某个不同的构建悉统与当前的开发进程相冲突。这如同在使用一栋建筑的同时替换其根基。
Chrome 团队的某些成员想到了一个增量的解决方案,叫 GYP。用它可以以一次一个子组件的方式生成 Chrome 已经在用的 Visual Studio 构建文件,以及其它平台会用到的构建文件。
GYP 的输入非常简单:希望得到的输出文件名称,伴有纯文本描述的源文件列表,偶尔会有定制的规则,诸如“处理每个 IDL 文件,生成一个额外的源文件”,也有一些条件动作(俐如,只在特定平台使用某文件)。GYP 会通过这一高级描述生成各平台原生的构建文件。
在 Mac 上,“原生构建”意味着 Xcode 工程文件。但是,在 Linux 上,并没有一个明确的优势选项。我首先尝试了 Scon,但我发现,通过 GYP 生成的 Scons 构建会在启动前花 30 秒计算文件变更情况。我以识到 Chrome 的规模与 Linux kernel 相当,在 Linux kernel 中使用的一些技巧也应该对 Chrome 管用。我撸起袖子干了起来,使 GYP 生成普通的 Makefiles 并使用了从 Kernel 的 Makefiles 借鉴的技巧。
于是,我无意间开始陷入构建系统的天坑。导致构建耗时的原因有许多,诸如缓慢的链接器以及缺乏并行能力,我把它们研究了个遍。使用 Makefile 的方法一开始相当快。但随着我们向 Linux 移植越来越多的部份,构建中使用的文件量不断增加,这个方法也变慢了。
在我进行移植时,我发现构建过程的一部分让人非常不爽:可能我修改了某个文件,运行 make,意识到漏打了一个分号,补上后再次运行 make,这之中每次等待都长到足以使我忘记我原本在做什么。回想起我们对延迟的抗争,“怎么会这么久?”,我自问,“应该没有这么多工作要做。”我开始了 Ninja,作为一个实验,看看我能简化多少。
Ninja 的设计在高层视角下,任何构建系统主要执行三项任务。(1)加载和分析构建目标,(2)计算出达到构建目标所需的步骤,(3)执行这些步骤。
为了使第一步迅速,Ninja 在加载构建文件时只做最少量的工作。构建系统一般是面向人的,这意味着它们会提供一个方便的、高层的语法来表达构建目标。这同样意味着在实际进行构建时,构建系统必须进一步处理指令:例如,在某一时刻,Visual Studio 必须基于其构建配置具体地决定输出文件的去向,或某个文件必须由 C++ 还是 C 编译器编译。
因此,GYP 在生成 Visual Studio 时的工作实际上被控制在了将源文件列表转译到 Visual Studio 语法,剩下的工作统统交给 Visual Studio。有了 Ninja,我看到了机会,可以在 GYP 中做尽可能多的工作。在某种意义上,当 GYP 生成 Ninja 构建文件时,GYP 会进行上述的计算。GYP 随后保存这一中间数据的一个快照——以一种 Ninja 可以快速读取的格式,供后续构建使用。 Ninja 的构建文件使用的语言因此简单到了不便于人类书写的程度。没有条件语句或基于文件拓展名的规则。相反,格式仅仅是一个列表,记录确切路径所产生的确切结果。这种文件可以被快速载入,几乎不需要解释。
最小化的设计产生了极大的灵活性。因为 Ninja 缺少对高层次构建概念的了解(如输出目录或当前配置),Ninja 易于嵌入各种更大型的系统(例入,CMake,我们一会就会看到),这些系统对于构建该如何组织往往有不同的观点。例如,Ninja 对于构建输出是与源文件放在同一目录中(有人认为不怎么“卫生”),还是放在另一个构建输出目录(有人认为这样会使别人疑惑)。在 Ninja 发布很久以后,我终于想到了正确的比喻:如果将其它构建系统视为编译器,Ninja 则如同汇编器一般。先汇编,再编译。
Ninja 做了什么如果 Ninja 将绝大部分工作推给了构建文件生成器,那自己还有什么事呢?上述想法理论看上很不错,但真实世界的需要永远更复杂。Ninja 在开发过程中添加(也失去)了很多特性。不论何时,重要的问题都是“我们能做得更少吗?”此处概述这如何运作。 在构建规则出错时需要人去调试(构建)文件,所以 .ninja 构建文件是普通文本,与 Makefiles 类似。为了增强可读性.ninja 也支持一些抽象。
第一种抽象是“rule”,可以代表单个命令行调用,rule 定义后在不同的构建步骤间共享。这是 Ninja 语法的一个例子,声明了一条名为“compile”的 rule——这条 rule 会调用 gcc 编译器,此外还有两条 build 语句对特定文件使用了 compile。
rule compile
command = gcc -Wall -c $in -o $out
build out/foo.o: compile src/foo.c
build out/bar.o: compile src/bar.c
第二种抽象是变量。在上面的例子中,那些以”"为前缀的标识符就是变量("为前缀的标识符就是变量(in 和 $out)。变量即可以表示命令的输入,也可以表示命令的输出,也可以给长字符串起一个短点的名字。这里有一个compile定义,用一个变量表示编译器的标志:
cflags = -Wall
rule compile
command = gcc $cflags -c $in -o $out
一条规则中使用的 变量 可以在单个 build 块中被缩进表述的新定义覆盖。继续上面的例子,cflags 的值可以在单个文件处调整:
build out/file_with_extra_flags.o: compile src/baz.c
cflags = -Wall -Wextra
rule 与函数很像,而且变量又酷似参数。这两个简单的功能使 Ninja 的语法与编程语言过于相似,这很危险——是“不做多余事”的对立面。但他们可以减少重复字符串,这不仅对人非常有用,也有利于电脑计算,因为减少了需要分析的文本量。
构建文件,一旦完成分析,就可以描绘出一幅依赖图:最终的二进制输出依赖于一组对象文件,这组对象文件中的每个都是编译源文件的结果。特别地,这是一幅二分图(bipartite graph),“结点”(输入文件)指向“边”(构建指令),构建指令再指向“结点”(输出文件)。构建过程就是遍历这幅图。
给定一个构建目标,Ninja 首先遍历这幅图以确定每条“边”上输入文件的状态:即,输入文件是否存在,以及被修改的时间。Ninja 随即计算出一份计划。计划即是为了保证最终目标处于最新状态而必须执行的“边”的集合,依据中间文件的修改时间判断。最后,执行计划,遍历图并将“边”标记为已执行,至此顺利结束。
这些功能一就位,就可以建立起基于 Chrome 的参考基线:成功完成一次构建后再次运行 Ninja 所需的时间。即载入构建文件,验正构建状态,决定是否有工作要做所需的时间。这一基准只需要不到一秒。这是我的新启动指标。但是,随着 Chrome 日渐庞大,Ninja 必须持续变快,以保证这一指标不退化。
优化 NinjaNinja 最初的实现仔细地组织了数据结构,为快速构建创造条件。但从优化的角度来说这并不是个聪明的想法。在程序完成之际,我想到,一个分析器(profiler)可以揭示哪些代码对性能产生重要影响。
这些年来,分析(profiling)的结果指向过程序的不同的区域。有时是单个热点程序,可以微优化(micro-optimized)。更多时候,分析会指向一些更广泛的问题,如,除非必要,不要分配或复制内存。也存在某些情型,采用更好的表示方法或数据结构可以获得最好的效果。接下来是对 Ninja 的实现的简单表述,以及围绕 Ninja 性能的有趣故事。
解析(Parsing)起初,Ninja 使用的是手写的词法分析器和递归下降分析器。我以为语法足够简单了。事实证明,对于像 Chrome 这样够大的项目,仅仅是解析构建文件(拓展名以 .ninja 结尾)所消耗的时间都十分令人吃。
很快,最初用来解析单个字符的函数很快出现在分析结果中:
static bool IsIdentifierCharacter(char c) {
return
('a'
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?