查看原文
其他

或将成为LTS版本,Julia 1.6 究竟有哪些亮点?

Julia官方团队 InfoQ 2021-05-10
作者 | Julia 官方团队
策划 | 田晓旭
多数 Julia 版本是有时间限制的,因此不会围绕特定特性来做规划,但是 1.6 版本是个例外。

Julia 1.6 很可能会成为 Julia 的下一个长期支持(LTS)版本。因此,我们投入了更多时间来开发这个版本,确保未来健康生态系统所需的各种特性能够在该版本中实现。此外,我们还使用所有已注册的开源软件包对这个版本进行了回归测试,并跟踪和解决了相关问题。在 Julia 1.6 经过实地测试后,大约 1.7 发行版进入稳定状态时,我们将最终决定 1.6 是否将成为新的 LTS 版本。大家可以在 NEWS 文件中找到完整的更改列表,但是在这篇文章中,我们将更深入地介绍一些版本亮点。

1并行预编译

执行一个模块中所有语句的操作通常涉及大量代码的编译,因此 Julia 创建了模块的预编译缓存以减少这部分时间开销。在 1.6 中,这个包预编译操作的速度更快了,并且发生在退出 pkg>模式之前。之前预编译仅作为单个处理的序列,在第一次 using/import 一个软件包时,在线性代码加载过程中需要时一对一地预编译依赖项。

在<=1.5 版的时代,以 DifferentialEquations 为例;这是一种流行的程序包,具有大量的依赖项:

v1.5) pkg> add DifferentialEquations...julia> @time using DifferentialEquations[ Info: Precompiling DifferentialEquations [0c46a032-eb83-5123-abaf-570d42b7fbaa] 474.288251 seconds …

在 1.6 中,pkg>模式获得了高度并行化的预编译操作,该操作在程序包动作后自动调用,以使活动环境随时可以加载。

(v1.6) pkg> add DifferentialEquations...Precompiling project...Progress [========================================>] 112/112112 dependencies successfully precompiled in 72 secondsjulia> @time using DifferentialEquations4.995477 seconds …

先前的代码加载预编译过程需要大约 8 分钟的时间来预编译和加载 DifferentialEquations,在执行过程中还不会显示进度。而新机制只会用 1 分钟的时间来做预编译,同时会显示依赖项的进度。然后在程序包第一次加载时就会全速加载。新的并行预编译过程采用了一种深度优先的方法,它会遍历清单中的依赖树,首先对没有依赖项的程序包进行预编译,然后向上处理环境的 Project.toml 中列出的程序包,这样就可以同时预编译多个包了。这个操作是多进程的,而不是多线程的,因此不受 Julia 线程数的限制,默认情况下,Julia 将立即根据 CPU 核心数量生成 CPU 利用率最高的包预编译作业。预编译期间的错误只会对项目中列出的包抛出,这样就可以允许未加载但可能列入清单的依赖项,且自动预编译过程会记住某个包是否在给定环境中出错,在其更改之前不会重试。可以使用 ctrl-c 正常中断自动预编译,并设置环境变量 JULIA_PKG_PRECOMPILE_AUTO=0 来禁用。对于正在开发的软件包,考虑到它们的代码会通过 Pkg 之外的机制来更改,这种新的工作流程不会自动避免触发标准的代码加载时预编译。但是这些包的未开发完成的依赖项会准备好随时加载,因此对于开发完成的包,顶级预编译的加载时间会保持在较低的水平。

2编译时间百分比

这个小更改应该能帮助新手理解 Julia 的一个怪癖:计时宏 @time 和它的冗长伙伴 @timev 现在会说明报告的时间中有多少是花在了编译上 [1]。

julia> x = rand(10,10);julia> @time x * x; 0.540600 seconds (2.35 M allocations: 126.526 MiB, 4.43% gc time, 99.94% compilation time)
julia> @time x * x; 0.000010 seconds (1 allocation: 896 bytes)

考虑到 Julia 的即时(JIT)/ 超前(JAOT)编译机制,第一次运行代码时,编译开销通常非常可观,而后续调用中可以看到很大的速度改进。这个更改会强调这种行为,可以作为一种提示,同时帮助消除不必要的编译工作(例如过度针对性的代码)。

[1] 请注意,在某些情况下,系统将在 @time 表达式内部查找并在顶层表达式开始执行之前编译一些被调用的代码。发生这种情况时,一些编译耗时不会被统计。你可以运行 @time @eval ... 来包括这部分时间。

3消除不必要的重编译

Julia 最强大的特性之一就是它的可扩展性:你可以向先前定义的函数添加新方法,并对新类型使用先前定义的方法,有时,这些新实体会迫使 Julia 重编译代码以解决分派中的更改。这里分两个步骤:第一,“过时的”代码被无效化,将其标记为不适合使用;第二,根据需要将新的方法和类型考虑在内,再次从头开始编译代码。

Julia 的早期版本有些保守,在某些情况下,分派中没有实际更改,却也会让旧代码无效化。此外,在许多地方,Julia 及其标准库的编写方式都会破坏 Julia 的类型推断。编译器有时仅因为新方法“可能”被应用就被迫让代码无效化,于是关于类型的任何不确定性都会放大无效化的风险和频率。在 Julia 的旧版本中,这些影响加在一起让无效化频频出现:只是加载某些程序包就会导致多达 10% 的 Julia 预编译代码被无效化。重编译的延迟有时会让交互会话出现卡顿。当 Julia 的程序包加载代码中出现无效化时,它也延迟了下一个包的加载,那么当 SomePkg 依赖其他包时,这就会延长 using SomePkg 的等待时间。

在 1.6 版中,使旧代码无效的机制变得更加准确和有选择性。此外,Julia 及其标准库得到了彻底的更新,以帮助类型推断更多地得出具体的答案。由此以来,Julia 变得更精简、更快,更难受到方法失效的影响,并且在交互会话表现更为迅速流畅。

4减少编译器延迟

除了让我们的库代码对编译器更友好之外,我们也在继续尝试加快编译器本身的速度。这仍然是我们面临的主要技术挑战之一。在这一版本中没有什么重大突破,但是我们确实做了一些适度的改进,对方法表数据结构做了优化。

方法的特定性是部分顺序,在 1.6 之前,我们以排序顺序存储方法。我们还尝试在插入时识别歧义方法,希望避免对以后的每个查询重复这一操作。不幸的是,排序一个部分顺序需要平方数的时间,并且这部分耗时在包加载期间占比非常大(当需要将程序包的方法插入当前活动的方法表中时)。

我们改进的办法是进一步延迟这个过程,将排序和歧义检测移到用于查找匹配方法的算法。这个算法的运行频率很高,因此乍看之下,这种更改是无济于事的。但关键是,绝大多数查询是针对足够特定的类型的,因此可以轻松消除大多数可能的匹配,从而让最昂贵的步骤少了很多输入。

这部分改进的效果主要体现在包加载过程中,除了解决无效化问题之外,还加快了一点速度。

为了完善我们的推断质量指标,我们付出了巨大的努力,包括在认为不利的情况下快速停止分析,以及尽可能提取更精确的信息。这些都对复杂的代码(例如一些绘图库会有一大堆不同的配置选项)有很大好处。

上述大部分改进只需更新 Julia 版本就能轻松获得,无需对代码进行任何操作!更进一步,现在还有一个用于分析编译时间的通用框架,可以研究哪些函数对执行延迟的影响最大。在每个新版本中,你可能都会发现某个原来会影响性能的旧代码模式现在表现非常出色了!

我们还对多个内部数据结构做了许多微优化。这些优化同样不会影响代码的运行,但应该能改善动态性能。例如,invokelatest 现在比 try 更快,几乎与动态分派一样快。一些复杂的内部数据结构以前是树,现在也变成了简单哈希表,这样既提升了它们的可扩展性能,又减少了它们的线程安全开销。这会影响一些关键领域,例如类型分配(apply_type 和 tuple)、方法优化查找(MethodInstance),还有分派(jl_apply_generic)。

虽然我们尚未达成性能目标,但我们希望这个版本是向前迈出的一大步。我们还做了很多工作,为将来进一步降低延迟做好准备!

5帮助优化包延迟的工具链

Julia 1.6 与 SnoopCompile v2.2.0 或更高版本共同提供了用于编译器自省的新工具,尤其是(但不仅限于)针对类型推断的用途,开发人员可以使用新工具来分析类型推断,并确定特定的包实现选项如何与编译时交互。早期采用者已使用这些工具降低了首次使用的延迟,幅度从百分之几到大部分延迟不等。

6二进制加载加速

为包提供可靠的可移植二进制文件是所有包环境都必须面对的一项挑战,尽管 Julia 的策略一直是将可靠性和可重现性放在所有问题的首位,但之前这一选择是有不小代价的。我们针对可靠性和可重现性的解决方案是完全隔离已安装的二进制文件,并使用 BinaryBuilder.jl 框架来交叉编译它们。从 BinaryBuilder.jl 构建的库往往通过所谓的 JLL 包来使用,这个包提供了一个标准化 API,Julia 包可以用它来访问给定的二进制文件。在过去,Julia 包会盲目地 dlopen() 库并加载库搜索路径中的任何库,导致加载时间暴涨,相比之下新方法的加载时间有了显著缩短。举个例子,在 Julia 1.4 中,加载 GTK+3 堆栈需要 7 秒钟,而以前在同一机器上约需 500 毫秒。经过数月的艰苦努力和仔细调查,我们很高兴地报告,在同一机器上使用 Julia v1.6 时,同一库堆栈现在只需不到 200 毫秒 的加载时间。

造成这种速度下降的原因是多方面的,并分散在 Julia 生态系统的许多不同层面上。部分原因出自一般性的编译器延迟,这在一段时间以来一直是编译器团队关注的焦点,本文中编译器延迟缩短的章节也证明了这一点。另一个重要的部分是,由于有这么多小型 JLL 包提供绑定,带来的总开销也是可观的;每个包的加载都有相当大的开销。如果想让 JLL 包足够轻量,不影响总的加载时间的话,代码推断、代码生成和数据结构加载都是要消除的部分。在我们的实验中,我们发现包加载时间的最大来源之一是对后端信息的反序列化,这是从 Base 中的函数返回到我们包的链接,如果存在影响该 Base 函数的无效化操作,则这些链接将导致我们的函数重新编译。这看起来似乎是反直觉的,只需大量使用 Base 中的函数就可以让你的包预编译缓存文件快速膨胀,结果延长加载时间!虽然每一次增加的幅度很小(最坏情况下为 3 到 10 毫秒),但当你要加载数十个 JLL 软件包时总数就会很大了。

我们精简 JLL 包的工作最后创建了一个新包,JLLWrappers.jl。这个包提供了可自动生成 JLL 程序包所需绑定的宏,并尽量减少了所使用的函数和数据结构。通过限制 backedge 和数据结构的数量,以及集中每个 JLL 包使用的模板代码段,我们不仅能够大大缩短加载时间,而且还可以改善编译时间!此外,现在可以直接在 JLLWappers.jl 中改进 JLL 包 API,而无需重新部署数百个 JLL。因为这些 JLL 包仅定义了一个瘦包装,其中简单、轻量的函数负责加载库并返回路径,所以它们无法从大多数 Julia 代码经历的繁复优化工作中受益。因此解决优化难题的最后一步是禁用优化,并使用新的按模块优化级别的功能来减少生成非常少量代码所花费的时间。

GTK3_jll 的加载时间

编译器改进和 JLLWrappers 带来的好处共同作用,在开发过程中很好地体现了出来。原始的,非 JLLWrapperized 的 GTK3_jll 包的加载时间从 Julia v1.4 的最高 6.73 秒降低到了 v1.6 的 2.34 秒,这完全来自编译器改进。对所有相关 JLLWrappers 包使用精简 JLLWrappers 实现可进一步将加载时间降低到惊人的 140ms。这意味着这项工作让大型二进制构件树的加载速度提高了大约 50 倍。我们还对流水线中共享库的延迟加载等做了一些小幅改进,相信这项工作在可预见的未来能够为 Julia 的二进制打包优化打下坚实的基础。

7下载和网络选项

在之前的版本中,当你使用 Base.download 函数直接在 Julia 中下载内容时,或者使用 Pkg 间接下载时,实际的下载过程是由某些外部过程完成的,可能是 curl、wget、fetch 或是 PowerShell,系统中有哪个就用哪个。这种方法能行得通其实挺不可思议的,实际上它的背后是多年来积累的很多复杂命令行选项调整。虽然它大多数时候都不会出问题,但还是存在一些重大缺陷。

  1. 它的速度很慢。每次下载都启动一个新进程是很大的开销;但是更糟糕的是,这些进程无法共享 TCP 连接或重用已经协商好的 TLS 连接,因此每次下载都需要先进行 TCPSYN/ACK 协商,然后再做 TLS 加密握手,所有这些都要花费很多时间。

  2. 它是不一致的。由于下载内容的具体机制取决于系统上安装的内容,因此下载行为非常不一致。在一个系统上进行的下载操作可能无法在另一系统上完成。此外,用户遇到的问题可能会是 Julia 无法解决的——典型的答案是“修复你的系统上的 curl/wget/ 之类”。对于仅仅想要下载一些内容的 Julia 用户来说,这并不是个令人满意的解决方案。

  3. 它很不灵活。下载操作的核心需求很简单:输入 URL,输出文件。但也许你需要在请求中传递一些自定义标头,或者你可能需要查看返回了哪些标头。一些下载命令有其中一些选项,但我们只能支持所有下载方法都支持的选项,这让下载操作变得非常不灵活。

在 Julia 1.6 中,所有下载操作都是通过新的 Downloads.jl 标准库使用 libcurl-7.73.0 完成的。下载是在进程中完成的,并且 TCP+TLS 连接可以共享和重用。如果服务器支持 HTTP/2,则对该服务器的多个请求甚至可以多路复用到相同的 HTTPS 连接上。所有这些都意味着下载速度更快了。

由于所有 Julia 用户现在都使用相同的方法来下载内容,因此如果一个下载操作可以在一个系统上运行,则很可能在任何地方都可以正常运行。不会再因为有的系统上 curl 版本很旧而导致下载中断。而且 libcurl 可以灵活配置:我们可以将自定义标头与请求一起传递、查看响应中包含哪些标头,并获得下载进度——在不同系统上所有配置都是一致的。

在重做下载功能的时候,我们在 macOS 和 Windows 上切换到了内置的 TLS 堆栈,允许下载操作使用内置的机制,通过系统的证书颁发机构根证书(简称“CA roots”)集合来验证 TLS 服务器的身份。在 Linux 和 FreeBSD 上,我们现在还查找带有 CAroot 证书的 PEM 文件的标准位置。使用系统 CA 根证书的优势在于,大多数系统会自动保留这些 CA roots 的最新版本,并且在 Windows 和 macOS 上,操作系统将在执行证书验证时检查已撤销的证书(Linux 尚无标准方法)。Julia 本身仍附带了相当新的 CA 根软件包,但是默认情况下,除非找不到系统 CA 根目录,否则我们将不再使用它。

使用系统 CA roots 意味着 Julia 更有可能在防火墙后面“正常工作”。许多机构防火墙都将中间人(MITM)用于你的传出 HTTPS 连接并为服务器到客户端的连接提供伪造的 HTTPS 证书。为了避免在你的客户端上触发安全警报,它们通常会向用户系统添加一个私有 CA 根证书,以让你的浏览器接受防火墙的伪造证书。由于 Julia 现在使用系统的 CA roots,因此它会尊重在那里添加的任何私有 CA roots。

如果由于某种原因该方法不起作用,Julia 1.6 还引入了 NetworkOptions.jl 标准库:这个包充当网络配置选项的中心位置,这些选项可以由各种环境变量控制,并用于一致地修改 libcurl 和 libgit2 等网络库的行为。例如,如果你想完全关闭 HTTPS 主机验证,则可以在 shell 中 export JULIA_SSL_NO_VERIFY_HOSTS="**",这样通过 HTTPS 下载时,Downloads 和 LibGit2 软件包都将不执行主机验证。在 NetworkOptions 中有很多选项,包括:

  • JULIA_SSL_CA_ROOTS_PATH 提供 CAroots 的自定义 PEM 文件

  • SSH_KNOWN_HOSTS_FILES 将非标准位置用于 SSH 已知主机

  • JULIA_*_VERIFY_HOSTS 变量可用于精细控制哪些主机应通过或不应该通过各种传输(包括 TLS 和 SSH)进行验证

现在,Julia 本身随附的所有面向网络的代码都会一致地尊重这些选项,我们将与程序包开发人员合作,鼓励他们使用 NetworkOptions 来配置 mbedtls 等库,从而实现跨整个 Julia 生态系统的网络一致配置。

8CI 稳健性

在这个发布周期中,我们花费了大量时间以持续集成(CI)过程中间歇测试失败的形式偿还技术债务。就像如今所有负责任的软件项目一样,我们为每次提交和更改提案运行完整的构建和测试套件。如果测试失败,则可以停止发布,直到问题解决——解决问题的办法可以是还原更改、提交新的修补程序或修订提案的补丁程序直到通过为止。但这确实是发生在我们身上的事情:随着时间的流逝,我们最终陷入了很大比例的测试都运行失败的境地,这些失败通常只是因为一个不起眼的测试用例失败。

造成这种困境的因素有很多:首先,基本的 Julia 测试套件相当庞大,覆盖了很多功能,从解析和编译到线性代数、程序包管理、套接字、线程、处理文件系统事件等等。因为有这么大的覆盖面,我们过于脆弱的测试很容易出现很多罕见的错误或失败。我们每天可以轻松运行超过 100 个构建,因此即使是 0.1%的失败率,也经常会出现严重的失败。对时间敏感的测试是一个典型的例子,例如,测试一个一秒的超时耗时大约就是一秒。但在托管的 VM 上,计时数字的波动可以比你在专有硬件上看到的波动大很多。在高负载的 VM 上,一个一秒钟的超时可能会花费超过 60 秒。

经过大量调试(包括很多默认在 rr 下运行测试的基础架构改进)之后,我们识别并解决了许多问题:

  • 在 FileWatching 测试中关闭一个争用条件

  • 套接字测试完成后撤消看门狗计时器

  • 减少一些开销以减少通道测试中的计时差异

  • 修复了对 mktemp 的测试,以防止偶尔出现的重复名称

  • 修复了导致 FreeBSD 偶尔失败的一个调用约定问题

  • 修复了导致 Profile 测试失败的一个 libunwind 问题

  • 修复 AsyncCondition 测试中的一个争用

  • 修复 REPL 测试中偶尔出现的死锁

  • 端口重用问题导致 Darwin 偶尔出现分布测试失败

  • 锁定泄漏导致偶尔的测试失败

结果,“绿灯”PR 的比例明显高了很多。我们还没有达到 100%,但现在用户一般都可以预期 CI 能通过。

9改进的 stacktrace 格式

在 Julia 0.6 的时候,堆栈跟踪的格式做了大修。而在新版本中,@jkrumbiegel 在这一方面做了进一步改进(在 #36134 中实现)。我们来看一个旧的堆栈跟踪打印的示例,并将其与新示例对比:

旧堆栈跟踪

1.0 中的 Stacktrace

新堆栈跟踪

1.6 中的 Stacktrace

这里应该强调的一些改进:

  • 现在会显示方法中的参数名称。

  • 与函数周围的文本相比,函数的名称会高亮显示,因为这一信息往往是最重要的。

  • 现在显示了定义方法的模块,并且这些模块也上了色。

  • 由于方法路径的重要性往往较低,因此不再高亮显示。

  • 主目录的完整路径用~代替,所以短了很多。

原文链接:
https://julialang.org/blog/2021/03/julia-1.6-highlights/#eliminating_needless_recompilation
今日好文推荐
“行业毒瘤”低代码
没有人真的想摸鱼,对开发者来说,企业的“致命”吸引力是什么?
闲鱼正在悄悄放弃Flutter吗?

InfoQ 读者交流群上线啦!各位小伙伴可以扫描下方二维码,添加 InfoQ 小助手,回复关键字“进群”申请入群。回复“资料”,获取资料包传送门,注册 InfoQ 网站后,可以任意领取一门极客时间课程,免费滴!大家可以和 InfoQ 读者一起畅所欲言,和编辑们零距离接触,超值的技术礼包等你领取,还有超值活动等你参加,快来加入我们吧!





点个在看少个 bug 👇



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存