跳转到主要内容
工程的博客

写一个更快Jsonnet编译器

分享这篇文章

这篇文章是我们一系列的一部分内部砖上工程博客平台,基础设施管理、集成、工具、监视和配置。bob体育客户端下载看到我们之前的声明性基础设施这里讨论的一些技术背景。

在砖,我们使用Jsonnet数据模板语言以声明的方式管理运行配置服务在许多地区在Azure和AWS。超过100 k线Jsonnet定义数据砖的基础设施、生成服务配置、Kubernetes部署,Cloudformation和起程拓殖模板,以及更多。我们到达了一个规模,Jsonnet编译时间已经成为一个瓶颈:一个完整的编译了10分钟,即使在快速的多核机器。为了解决这个问题,我们实现了Sjsonnet,一个新的Jsonnet编译器,达到30-60x加速效果在我们实际使用。

Jsonnet是什么?

我们以前写的为什么我们使用Jsonnet在这里,但简而言之:

  • 它允许你templatize JSON / YAML /等。配置在一个方便的、结构化的方法:不安全使用regex string-templating,胡子/金贾的/.erb模板
  • 它允许您重用常见的配置代码片段在你基础设施,避免复制粘贴配置和便于维护和确保一致性
  • 可再生的:相比使用Python这样的通用语言配置部署,Jsonnet只有输入的.jsonnet文件,它唯一的输出. json配置文件。你可以确定你的系统将配置的今天和昨天一样,当你重构你只需diff输出JSON来验证你没有破坏任何东西

砖相对较复杂的配置矩阵。许多云服务只有一个生产部署,通常是一个单独的应用程序,由所有客户共享。虽然一些保健需求去照顾开发/分期/生产的区别,往往一个相对简单的部署脚本就足够了。

砖,另一方面,支持各种各样的部署场景:

  • N不同服务:砖有单独的服务来管理职工集群,服务web应用程序,运行调度工作,其他的事情。
  • 多租户v.s.单租户的部署和部署私有云部署v.s.免费部署如在“社区”https://community.cloud.www.neidfyre.com
  • 在Amazon Web服务部署vs微软Azure
  • 最后,开发、过渡和生产环境的所有

所有这些因素是乘法,导致几十种不同的部署配置。Jsonnet重用常见的配置和模板的能力,尽管是完全确定的,可再生的,我们理解和维护我们的部署能力的核心,即使他们不断扩展以支持新的功能和产品

Jsonnet性能问题

随着时间的推移,我们注意到,编译的.jsonnet文件的输出JSON或YAML有时花大量的时间:

美元时间jsonnet kubernetes/配置//config.jsonnet——输出文件out.json真正的3m24.375年代用户3m20.962年代sys0m1.329年代

我们的一些最慢.jsonnet文件,如上图所示,最终接管了一分钟来评估。这是一个荒谬的时间为一个编译器,只是通过一些字典!在这种情况下,输出. json文件只有一个~ 500字节:

美元du kh out.json556 k out.json

不是很小,但不是一个大量的配置编译。

哪些文件需要时间显得有点武断,,还不清楚是什么原因,但经济放缓无疑是导致问题:自动化测试套件,生产部署,以及手工测试的变化发展慢了下来。

在砖,我们努力确保我们的开发人员尽可能有效率和有一些工作流异常缓慢,没有明确的原因是没有很好。因此随着时间的推移,我们花了大量时间试图了解我们可以做关于我们Jsonnet编译性能。

为什么Jsonnet慢?

经过调查,我们发现两个主要嫌疑人Jsonnet编译缓慢:

  • Jsonnet不重用中间结果,每次都重新评估他们
  • Jsonnet的内置函数中实现Jsonnet本身,而不是宿主语言

我们将考虑每一个。

Jsonnet不重用中间结果

Jsonnet提供了一个std.trace函数表达式求值时让我们打印:

猫foo.jsonnet美元当地的x=std.trace(“评估”,123年);x
              美元jsonnet foo.jsonnet跟踪:foo.jsonnet:1评估123年

第一个参数“评估”打印调试信息(文件名、行号),而第二个参数123年的返回值std.trace函数调用。这里,我们看到:

  • “评估”要打印一次
  • 123年是回来std.trace
  • 123年局部变量的值x
  • 123年最后的输出值foo.jsonnet文件和打印到控制台。

如果我们一起链局部变量,我们可以看到每个地方只计算一次:

猫foo.jsonnet美元当地的x1=std.trace(“评估”,1);当地的x2=x1+x1;x2
              美元jsonnet foo.jsonnet跟踪:foo.jsonnet:1评估2

到目前为止,这是不错,你会如果你来自编程在Python中,Javascript,或任何其他常用的语言。

然而,奇怪的行为出现在其他一些情况下:例如,如果不是直接引用当地人,我引用对象的字段:

猫foo.jsonnet美元当地的x1={“k”: std.trace(“评估”,1)};当地的x2={“k”: x1.k+x1.k};x2.k
              美元jsonnet foo.jsonnet跟踪:foo.jsonnet:1评估跟踪:foo.jsonnet:1评估2

在这里我们可以看到,当x2评估x1。k + x1.k,std.trace叫运行两次!事实证明评价字典字段被更像方法的评价比字段引用:每次你问的价值,被重新评估。虽然Jsonnet的语义(reproduciblity和纯度)确保重复评价的结果总是相同的,它掩盖了潜在的性能问题。简单地说,这是你期望什么,从另一个编程语言来Jsonnet像Python或Javascript !

现在我们知道这一点,很明显,无害的片段可以有一个令人惊讶的看放大的时间评估:

猫foo.jsonnet美元当地的x0={k:1};当地的x1={k: x0.k+x0.k};当地的x2={k: x1.k+x1.k};当地的x3={k: x2.k+x2.k};当地的x4={k: x3.k+x3.k};当地的x5={k: x4.k+x4.k};当地的x6={k: x5.k+x5.k};当地的x7={k: x6.k+x6.k};当地的的混合体={k: x7.k+x7.k};当地的x9={k: x8.k+x8.k};当地的x10={k: x9.k+x9.k};当地的x11={k: x10.k+x10.k};当地的x12={k: x11.k+x11.k};当地的* 13={k: x12.k+x12.k};当地的x14英寸={k: x13.k+x13.k};当地的连接={k: x14.k+x14.k};当地的乘16={k: x15.k+x15.k};当地的x17={k: x16.k+x16.k};当地的x18={k: x17.k+x17.k};当地的x19={k: x18.k+x18.k};当地的x20={k: x19.k+x19.k};当地的x21={k: x20.k+x20.k};x21.k美元时间jsonnet foo.jsonnet2097152真正的0m5)年代用户0m5.031年代sys0m0.015年代

在这里,我离开了std.trace调用,因为它会打印超过二百万次在评估这个小片段。即便如此,从命令可以看到需要一个5秒计算这个片段中,这一数字将上升指数随着阶段的数量增加。

这些病理情况下不存在真正的用法:没人会写一个级联的地方xN = {k: xM。k + xM.k};绑定时试图配置cloudformation数据库或kubernetes集群。另一方面,在实际使用值的类型和操作你最终做更复杂的比1 + 1:你可能构建大型字符串,排序数组,或md5散列输入。在这种情况下完全有可能与小结束,innocuous-looking Jsonnet产生小的片段,innocuous-looking JSON输出,然而花多时间编译!

Jsonnet Jsonnet本身的内置函数实现

Jsonnet提供了一个标准库的功能性病对象。我们已经看到std.trace,但还有其他常见之类的东西std.parseInt将一个字符串转换为一个数字,std.base64base64编码字符串,或std.sort排序的数组值。这些都是常见的任务放在一起时您的配置。

事实证明Jsonnet标准库,在c++和实现,主要实现Jsonnet本身。

这是一个稍微不寻常的决定:Jsonnet编译器设计定制配置:如让你重用一个普通的字符串而不是复制粘贴不同的YAML文件之间,或定义一个标准部署模板,每个部署可以与主机名/凭证/ ip地址/填充等。这通常不是特别计算密集型:传递一些字典和添加键。非常不同的计算,比如从实现base64编码:

base64(输入):本地字节=如果std.type(输入)= =“字符串”然后std.map (函数(c)性病codepoint(c),输入)其他的输入;
              当地辅助(加勒比海盗,我,r) =如果我> = std.length (arr)r其他的如果我+1> = std.length (arr)当地str =/ / 6 MSB的我base64_table [(arr[我]&252年)> >2)+/ / 2 LSB的我base64_table [(arr[我]&3)= std.length (arr)当地str =/ / 6 MSB的我base64_table [(arr[我]&252年)> >2)+/ / 2 LSB的我,4 MSB i + 1base64_table [(arr[我]&3)>4)+/ / 4 i + 1 LSBbase64_table [(arr [i +1)&15)>2)+/ / 2 LSB的我,4 MSB i + 1base64_table [(arr[我]&3)>4)+/ / 4 i + 1 LSB, 2 MSB i + 2base64_table [(arr [i +1)&15)>6)+/ / 6的LSB我+ 2base64_table [(arr [i +2)&63年));辅助(加勒比海盗,我+3、r + str) tailstrict;当地的理智= std.foldl (函数(r,)r& & (一个以base64作为一个例子,可能是可以实现的东西吗10快-100倍编译优化宿主语言(c++ /去)解释,not-very-optimized Jsonnet语言。考虑下面的代码片段,我们对一个字符串组成执行base64编码“你好”重复5000年时报》:< pre > std.base64 (“hello1 hello2……hello4999 hello5000”)

这需要近2500毫秒运行谷歌/ jsonnet:

美元时间jsonnet foo.jsonnet真正的0平方米.492年代用户0平方米.022年代sys0m0.455年代

而等效二进制base64将barely-measurable 14 C运行女士:

猫foo.txt美元hello1 hello2……hello4999 hello5000美元时间base64 foo.txt真正的0m0.014年代用户0m0.005年代sys0m0.006年代

有很多这样的功能,除了std.base64:std.escapeStringJson它走一个字符串逐字符来决定要做什么,std.format,重新实现的大部分Python的非平凡的%字符串格式化功能,std.manifestJson函数旨在把可能很大JSON blob嵌入作为您的配置的一部分,所有实现Jsonnet本身和运行使用not-especially-fast Jsonnet编译器。

像问题Jsonnet不重用对象字段的值计算,这里的问题不仅仅是标准库函数,但他们出乎意料地缓慢:

  • 任何背景的快速编译语言,C, c++, Java,——希望核心标准库函数被编译和快速像一切
  • 任何背景的慢解释语言——Python、Ruby、PHP -预计核心标准库函数是用C写的,快速执行无论如何翻译是缓慢的。

一页页的列表最慢.jsonnet文件来评估,很明显,他们中的许多人包括使用这些内置函数。直接实现他们的主机运行时应大大提高性能,大量使用这些功能。

一个新的编译器

从根本上讲,Jsonnet编译器的问题似乎可以避免的:Jsonnet,尽管它的怪癖,仍然是一个非常简单的语言。它不应该太难为它编写一个简单的编译器,甚至天真的编译器应该足够性能不花multiple-minutes评估小JSON blob。

我们写了Sjsonnet编译器,用Scala编写和运行在JVM上。

一个不可避免的问题是为什么写一个新的编译器,而不是贡献改进现有Jsonnet编译器?有许多因素:现有的谷歌/ jsonnet实现的复杂性,固有的额外的困难重构现有的/不熟悉的代码库,一个缺乏熟悉c++ /语言,事实上,似乎简单写的翻译语言jsonnet一样简单。

对于这样一个简单的语言,Sjsonnet相应简单的翻译:只有~ 2600行Scala,它实现了简单的两级解析- >评估管道常见最天真的口译员,与一个额外的“物质化”一步独特Jsonnet评估值变成print-able JSON:

解析评估实现输入- - - - - - - - - - - - >语法树- - - - - - - - - - - - - - - - - - - - - - > >值JSON. lang。字符串sjsonnet。Expr sjsonnet。Val . lang。字符串

Scala编程语言使写作这种编译器非常简单:整个解析器300行代码,使用FastParse解析库。的评估者是一个简单的函数,递归访问语法树中的每个节点,并返回它评估的价值。JSON字符串、数字、序列和地图为代表字符串年代,年代,Seq年代和地图在宿主语言。

为了解决这个问题,我们认为导致谷歌/ jsonnet解释器表现不佳,避免不必要的re-computation Sjsonnet缓存对象字段,实现了在Scala中,而不是Jsonnet标准库函数

值得注意的是,Sjsonnet不包括任何你看到的高级特性更成熟的编程语言翻译:没有中间字节码转换,没有任何形式的静态优化,没有JIT编译。这样的事情可能被添加之后,但目前Sjsonnet没有其中任何一个。

性能

Sjsonnet执行比谷歌/ jsonnet病理情况下我们先前讨论的。

基准翻译 谷歌/ jsonnet 砖/ sjsonnet 加速
kubernetes / config /…/ config.jsonnet 女士204375 446毫秒 458 x
std.base64长字符串 2492毫秒 220毫秒 11 x
当地的x1 ={凯西:x0。k + x0.k}20倍 5064毫秒 214毫秒 23 x

在这里你可以看到sjsonnet完成200 - 400 ms,有点误导:200 ms的启动开销,我们将在下面讨论。这意味着实际的时间执行后两个任务几乎是不可估量的。这是你应该期待从程序评估这些琐碎的计算,并比几秒钟,谷歌/ jsonnet呈现相同的输入!大,慢config.jsonnet现在文件花了超过三分钟不到半秒。从这个我们可以看到Sjsonnet成功在特别的情况下我们知道谷歌/ jsonnet表现不佳。

写一个新的编译器的动机是真实的代码的性能。维护一个编译器,即使是一个简单的语言,始终是一个大投资:我们需要看到实际使用相应大的性能提升为了这个项目是值得的。幸运的是,我们看到了一个:

基准翻译 谷歌/ jsonnet 砖/ sjsonnet 加速
运行谷歌/ jsonnet测试套件中 2222毫秒 67毫秒 33 x
编译所有Monorepo Jsonnet,笔记本电脑 2362年代 84年代 28 x
编译所有Monorepo Jsonnet m4.4xlarge 994年代 16 s 60 x

在现实使用中,砖/ sjsonnet看见一个在谷歌/ jsonnet 30-60x性能改进。许多工作流程,现在花了几十分钟到一个小时1分钟或更少,一个大为穷人生活质量改进工程师必须坐在那里等待才能更新Kubernetes集群。

启动开销

JVM Sjsonnet在JVM上运行,应用程序开始缓慢:hello world的Java程序可能会在100 ms,非简单的应用程序往往需要额外的0.5 1 s的类加载时间任何代码运行之前,在那之后它仍然需要时间JVM热身全速。Sjsonnet需要0.5 1 s每次调用,不管多小的输入。我们可以看到通过运行Sjsonnet琐碎jsonnet文件,使用-我国旗禁用长时间运行的守护进程:

猫foo.jsonnet美元1+2美元时间sjsonnet- - - - - -我foo.jsonnet3真正的0m0.726年代用户0m0.891年代sys0m0.089年代

为了避免这种启动开销,Sjsonnet JVM-thin-client捆绑在一起,使长时间运行的编译器守护进程在后台运行,减少启动开销0.2 - -0.3 s。我们可以看到这

猫foo.jsonnet美元1+2美元时间sjsonnet foo.jsonnet3真正的0m0.213年代用户0m0.180年代sys0m0.049年代

并不完美,但足够好的交互式使用没有感觉迟钝。在我们主要的存储库中,我们使用我们的巴泽尔构建工具支持长期工作进程保持过程在编译:这让我们保持Sjsonnet编译器总是热,准备评估没有JVM启动/预热开销。

解析

Sjsonnet解析器是一个主要的瓶颈:分析器显示40 - 50%的执行时间被花在解析器。这并不出人意料,因为FastParse解析库,同时方便和相对时髦的至于解析器组合子库,仍可能比手写的递归下降语法分析器慢10倍。

在未来,我们可以重写手工解析器解决这个性能瓶颈。现在,我们只是变通方法通过缓存运行之间的解析语法树,这样绝大多数文件依旧不需要每次都重新解析。这只需要几兆字节的内存的.jsonnet文件在我们的主存储库,减少了执行时间解析几乎没有。

缓存

Sjsonnet目前不做任何聪明的缓存的评估Jsonnet代码:如果您评估多个文件,导入相同的常见的配置,常见的配置将会重新评估每次即使并没有什么改变。这是一个可能的未来工作方向,可以使我们进一步改善的性能Jsonnet编译工作流。

兼容性

Sjsonnet通过整个谷歌/ jsonnet测试套件,并产生大致相当的错误消息在所有失败的情况下。砖上运行自己的语料库的100 k行Jsonnet,唯一的区别是在floating-point-rendering细微变化:

< br / > -“targetRate”:0.050000000000000003,+“targetRate”:0.05,

不知何故,谷歌/ jsonnet有点异乎寻常的floating-point-rendering算法并不完全与你可能期望来自Java、Javascript或Python背景。不管原因,这种变化是良性的,不会影响任何解析这些数字为64位双精度浮点值。

除了上面的差异外,其余的砖Jsonnet输出byte-for-byte谷歌/ Jsonnet和砖/ sjsonnet下相同。谷歌/ jsonnet Sjsonnet甚至故意遵循不正确的~作为一个unicode字符的转义,额外的工作,以确保其输出JSON格式精确匹配谷歌/ jsonnet输出。Jsonnet以来唯一的输出是在它的JSON输出,我们可以非常确定我们没有打破任何迁移从谷歌/ Jsonnet砖/ sjsonnet。

也许唯一的用户可能会注意到不同的地方是两个实现的错误消息,砖/ sjsonnet遵循堆栈跟踪的JVM格式打印,而不是谷歌/ jsonnet格式化。

美元jsonnet foo.jsonnet运行时错误:除零。foo.jsonnet: 1:1-6
              美元sjsonnet foo.jsonnetsjsonnet。错误:除零在(foo.jsonnet: 1:3)。

高度的兼容性为我们提供了其他好处:我们不需要修复或修改我们的100000行.jsonnet文件使用Sjsonnet。如果将来我们决定迁移回谷歌/ jsonnet,这将是一个简单的替代交换回来。

期待

Sjsonnet Jsonnet性能的解决我们当前的问题。现在工作流,用来把分钟到一小时秒到1分钟。一起Jsonnet Intellij插件我们写,Sjsonnet大大加快工作在云系统在砖的士气和效率,提高工程师的工作。

而不是把我们锁在Sjsonnet给选项:三分之一Jsonnet实现我们可以选择除了谷歌/ Jsonnet和谷歌/ go-jsonnet,快速,简单,易于理解和扩展。也许从这个实现的一些想法会让它回到上游,如果谷歌/ jsonnet或谷歌/ go-jsonnet赶上性能在某种程度上超越Sjsonnet,我们可以切换。即便如此,这可能是方便的保持在一个JVM实现Jsonnet,作为一个公司在两种技术投入巨资。

Sjsonnet可作为一个独立的可执行文件,或使用Maven中央在你现有的Java / Scala项目。看到https://github.com/databricks/sjsonnet sjsonnet使用说明,如果你想试试!

免费试着砖
看到所有工程的博客的帖子