Julia 项目,就像任何大型开源项目一样,每天都会收到大量的错误报告。作为该语言的开发者,我们尽力尽可能地响应,并尽快对错误进行分类、调查和修复。对于一些错误,这很容易。如果错误报告写得很好,并且问题很明显,那么修复它通常很快。但是,对于大量的报告,情况并不总是那么简单。错误长期未得到解决的原因有很多,例如
错误可能无法确定性地重现,或者可能只在报告者的机器上重现(有时被称为 Heisenbug)。
错误报告可能没有完整地说明错误发生的具体环境,这使得难以重现。
错误报告者可能只见过一次错误,但并不完全确定是什么导致了它,这使得很难提供一个重现步骤,也使得错误报告基本上无法操作。
错误可能只发生在一个大型项目中,而该项目很难设置。
错误可能需要专业知识才能诊断(例如,缺少 GC 根)。通常,这类专家的时间需求很高,这使得为重现和调查这类错误所需的精力投入成为不可能。
此外,还有一些从未被提交的错误,因为用户可能觉得写高质量的错误报告的付出太高了。这样的经历对遇到错误的用户和我们来说都很令人沮丧。我们常常不知道这些经历,有时直到几年后才会听到。有些人可能因为遇到了无法简化成简洁的错误报告的崩溃问题而放弃了在 Julia 中进行的项目,然后就放弃了在项目中使用 Julia。最后,我们不想也不希望我们的用户成为专家级的错误报告者。他们通常是善于编程的科研人员,但可能没有软件工程方面的背景。他们是我们最有价值的用户,我们要确保他们的错误得到解决。
在过去,对于遇到特别困难问题的用户,我们一直有一个答案:如果你可以在 Linux 机器上重现问题,并从 rr
工具 https://rr-project.org/ 中获取跟踪信息,我们就可以快速帮你解决问题。对于不太熟悉 rr
的人来说,它是一个 Linux 调试工具,最初由 Robert O'Callahan 和其他人开发于 Mozilla。它被称为“时光倒流调试器”或“逆向执行引擎”。本质上,rr
将重现错误分为两个阶段:“记录”和“回放”。记录阶段由错误报告者完成。在此阶段,rr
会创建执行的完美记录,包括按位精确的内存和寄存器状态,在每条指令执行后都会记录。然后可以在回放阶段(可以在不同的机器上由不同的开发者完成)分析该跟踪信息。这些功能在学术界一直被设想,但很难以不引入大型开销或扭曲正常执行的方式实现。rr
是第一个(根据我们的经验)性能足以在开发的日常工作中使用的工具。值得讨论一下它是如何实现的(以及这种方法的局限性),但首先让我们假设这些功能的存在,看看它支持的工作流程。
在将于几周后发布的 Julia 1.5 中,现在有一个新的命令行标志 --bug-report=rr
,它会自动创建和上传一个 rr
记录。本篇文章开头的动画展示了一个示例用法(它只是通过不安全地取消引用一个错误指针来故意导致崩溃)。然而总结一下
错误报告者向她的 Julia 实例传递 --bug-report=rr
,并重现她试图重现的任何错误。
一旦 Julia 退出或崩溃,错误报告者会被提示通过点击一个链接来授权上传(使用基于 GitHub 的身份验证来防止滥用)。然后她会得到一个链接,在向 Julia 或其他软件包提交错误报告时需要包含该链接。
任何开发者都可以使用该链接获取记录并分析其自身的机器。
除了这种手动机制之外,我们还将我们的 Linux CI 系统切换到自动创建任何执行的 rr
跟踪信息。这样,如果 CI 运行失败,我们就可以保证能够调试它。
如果错误报告中包含指向 rr
跟踪信息的链接,理论上就不需要任何其他重现说明。rr
跟踪信息可以保证完美地捕获重现错误的环境。当然,如果错误不是显而易见的,比如意想不到的行为,一些关于预期行为的评论可能仍然会有所帮助。拥有完美的可重现性几乎立即消除了我文章开头提到的所有常见问题。“在我的机器上可以正常运行”不再是一个可行的答案。如果它在跟踪信息中,那么它就在某个人的机器上崩溃了,可以进行调试。 “Heisenbug”不再是一个问题。如果它在 rr
中被捕获了一次,那么它就可以随时被调试。它甚至解决了繁忙的专家问题,因为它允许非专家参与分类。如果这样的报告不包含 rr
跟踪信息,任何开发者,尤其是非专家,都可以尝试重现错误并创建跟踪信息。即使专家仍然需要进行最终诊断,从 rr
跟踪信息中进行诊断也要比从简单的错误报告中进行诊断快几个数量级。
计算机从根本上来说是确定性的机器。给定等效状态作为输入,大多数指令会产生确定的状态作为输出。那么,所有导致执行差异并阻止错误重现的细微执行差异从何而来呢?好吧,完整的答案很复杂,有很多细节,但大致可以分为以下几种情况
输入状态的大小。简单来说,这种状态至少包含你的整个硬盘和内存。这至少是 个位的状态,每个位都可能导致执行差异。你真的需要弄清楚哪些状态是相关的,因为 可能太多了,无法发送给任何人。
任何输入或其他异步事件。这可能意味着用户输入,或来自网络(通过 NIC)或其他设备的数据。它也意味着诸如中断的计时之类的事件。
多线程执行中的执行顺序和数据竞争。
对非确定性硬件效应的直接观察(即上述的“大多数”限定词)。这包括故意非确定性的指令,例如 RDRAND,它会生成一个随机数。它还包括可观察到的,但不可取的硬件状态效应(例如,来自缓存或分支预测器状态的计时侧信道)。
然而,理论上,如果一个工具能够捕获来自这些类别的 100% 的相关状态,它就可以重复生成完全相同的内存映像。这不是一个新颖的想法,但关键在于细节。讨论这些细节超出了本文的范围,但这里提供一个简单的解释:对于异步事件,我们如何定义该事件相对于其他执行的“何时”发生。也就是说,时间的正确概念是什么?实时不适用,因为指令发出频率不是恒定的(除了处理时间的所有常见问题)。也许最方便的是使用已经执行的指令数,但根据硬件(将在下面的硬件部分进行一些讨论),这里存在一些挑战。
概括地说,对于某个输入状态 要执行的指令,或者某个异步事件 。 , 对应于在时间 由某个纯粹的、确定性的函数 , 输出状态
这有帮助吗?没有?哦。好吧,至少让我对自己的物理学位感到好些,而且听说没有至少一个公式就不是研究,所以这就有了。另外,大家为了让这个博客的公式渲染正常可是付出了很多努力,所以我必须用它。总之,我们说到哪里了?
啊,是的——`rr`是如何运作的?它使用的一个关键技巧是重用一个已经存在的抽象边界:应用程序和底层操作系统(更准确地说,是 Linux 内核)之间的边界。利用这个抽象边界作为确定性边界(即依赖确定性来应用对应用程序内部进行的任何更改,但显式记录内核进行的任何更改)有很多优势。首先,内核隔离了很多状态。如果某个磁盘状态(例如文件)不是通过内核请求的,那么可以保证它不会影响进程状态(当然,假设内核运行正常)。它对硬件细节进行了抽象,并为网络等资源提供了统一的接口。它还隔离了进程之间的相互影响,因此,如果有一个感兴趣的进程(例如 julia),那么只需要恢复与其相关的活动。
也就是说,这种方案也带来了一些复杂性。为了正常工作,`rr` 必须对内核的操作以及内核与用户空间交互的方式有极其精确的模型。Linux 开发人员努力尝试保持这个接口稳定,但很少有应用程序像 `rr` 那样对这个承诺施加如此严格的限制。有时甚至内核行为的单个位差异都会产生问题!
如果您对这些细节感兴趣,我建议阅读 Robert 的 技术报告,该报告更深入地概述了 `rr` 的一些设计要点(尽管即使那样也只触及了表面)。
本节的最后一个想法是“时间旅行”调试器的术语来源。它源于这种系统启用的分析模式。在传统的调试器中,可以一次执行一条指令,并向前逐步执行从内存状态到内存状态的过程。像 `rr` 这样的系统在回放期间允许反向操作:反向逐步执行系统的状态。在幕后,这是通过从头开始播放(或者更可能是出于性能原因创建的某个中间检查点)实现的,直到达到先前状态。然而,对于最终用户来说,呈现了向后移动时间的错觉,这是一种非常有用的调试心理模型。
虽然上一节描述了 `rr` 的工作原理,但它没有说明 `rr` 如此有效的原因。这个原因很简单:性能。记录单线程进程的开销通常低于 2 倍,大多数情况下在 2% 到 50% 之间(对于纯粹的数值计算较低,对于与操作系统交互的工作负载较高)。记录多个线程或共享内存的进程(而不是使用基于内核的消息传递)更难。默认情况下,`rr` 对此类任务进行序列化(即按顺序运行,而不是并行运行)执行。通常,这对于正确性是必需的,因为无法观察和记录对共享内存空间的内存操作的交错。因此,共享内存应用程序可能会产生与并发线程数量成线性关系的开销。因此,最好尝试在较低的内核数量下重现任何问题。关于高效率、共享内存应用程序的并行记录,有一些有趣的学术思考,但还没有任何接近生产就绪的东西。
考虑到这一点,以下是 Julia 测试套件开销的一些真实基准数字(如 CI 所示)。测试套件主要使用基于消息传递的并行性来并行运行多个测试。只有 `threads` 测试会产生共享内存开销惩罚。下面,我们绘制了 Julia 测试套件中每个测试的记录开销。在整个测试套件中,平均开销为 50%(即,平均而言,记录的测试比未记录的测试耗时 50%,例如 3 秒而不是 2 秒)。正如预期的那样,`threads` 测试是罪魁祸首,开销约为 600%。但是,许多计算基准测试(例如,在 LinearAlgebra 或 SparseArrays 中)显示出非常少的开销。这是预期的,因为这些测试在用户空间花费了大量时间,这不需要记录任何数据。
您可能想知道为什么某些测试在 rr 下运行得更快。我没有详细调查过,但我认为这是一种测量伪像。测试的运行时间可能取决于之前在同一工作程序上运行的代码(因为公共代码结果被缓存),并且由于工作被贪婪地分配给工作程序,因此调度更改有时会导致测试在已经缓存了一些本来需要运行测试的工作的工作程序上运行。此外,幸运的是,虽然平均减速在测试的基础上为 50%,但测试套件的总运行时间主要由低开销的纯粹计算测试以及 `threads` 测试的单独运行决定。结果,在 rr 下运行测试套件所带来的总时间增加几乎完全归因于 `threads` 测试。令人惊讶的是,跟踪通常非常小!完整运行测试套件的跟踪的总压缩大小(在 10 个核心上大约 30 分钟)约为 3 吉字节。作为比较,这明显小于测试套件运行结束时的完整内存核心转储(约 10GB 或更少,尽管可能使用标准技术可以很好地压缩)。从跟踪中,我们不仅可以重现这样的核心转储,还可以重现数万亿个中间状态的每一个状态!
正如之前提到的,`rr` 目前仅在 Linux 上工作。但是,还有更多限制。目前,仅支持具有英特尔微体系结构的 x86 芯片。`rr` 依赖于硬件性能计数器来为异步事件建立一个精确且准确的时间概念。如果这些硬件计数器不可用或不够精确,则使用 `rr` 进行记录将无法工作。我们和其他研究人员已经调查过是否可以将 rr 移植到其他架构。据我所知,目前的想法如下:
对于具有 AMD Ryzen 微体系结构的 x86_64,似乎有可能,但 AMD 性能计数器不如英特尔芯片上的性能计数器准确。可能可以通过软件来弥补这一点,但这些不准确性尚不清楚。欢迎调查方面的帮助(问题)。
对于 AArch64,基于 ll/sc 的原子操作引入了额外的问题,因为各种外部因素(中断、调度解除等)可以观察为 sc 终止。如果硬件支持得当,这将可以通过解决,但这种硬件支持似乎在旧一代 AArch64 芯片上不准确,并且在最近一代芯片上已被删除。也就是说,AArch64 微体系结构多种多样,正如 x86_64 经验所证明的那样,相关硬件支持的实现质量在不同微体系结构之间可能会有很大差异。非常欢迎对各种 AArch64 微体系结构的硬件功能进行调查(问题)。
我们已经研究了在 POWER9 上的 `rr`,它存在于几个运行 Julia 作业的大型超级计算机中。POWER9 与 AArch64 存在类似问题,因为它也是一个 ll/sc 架构,并且支持硬件事务性内存。理论上,我们认为硬件存在于可以弥补这些问题,但初步调查表明硬件不够准确,无法支持 `rr`。也就是说,我知道与 IBM 有积极的讨论,以确定这种分析是否正确。
根据我们的经验,另一个复杂的问题是,虚拟机管理程序(例如云供应商使用的虚拟机管理程序)通常会禁用 `rr` 所需的 CPU 功能,使其无法在虚拟机内部运行。`rr` 似乎在最新一代的亚马逊 AWS 机器上运行良好(超过一定大小),但在 Google GCP 或 Microsoft Azure 上则无法运行。
最后,我们不应该忘记 GPU。GPU 被 Julia 用户广泛使用,但目前不受 `rr` 支持。改变这一点很棘手。GPU 通常允许设备与正在记录的用户空间程序之间进行直接内存访问。如果知道 GPU 的接口,这绝对可以缓解,可能以一定性能成本为代价,例如,通过双缓冲。对于具有开源驱动程序的 GPU,例如 AMDGPU,社区对构建这种解决方案有积极的兴趣。这并非易事,但我认为它有相当大的成功机会,因为开源驱动程序可以让您深入了解 GPU 的工作原理以及它何时修改进程内存。具有专有驱动程序的 GPU 则要难得多。首先,它们的用户空间接口不一定已知。此外,专有驱动程序往往比开源驱动程序的行为更糟糕(Linux 内核审查流程往往会过滤掉最严重的错误行为)。例如,已经观察到专有驱动程序使用用户空间堆栈作为临时空间。对于大量 Julia 用户使用的那些 GPU,我们希望与相关供应商合作,将这种功能引入他们的平台,但这可能是一条漫长的道路。
总之,这些新功能目前仅在英特尔 x86_64 芯片上受支持。然而,这是一个先有鸡还是先有蛋的问题。硬件要求非同寻常,但并非过分苛刻。如果硬件供应商关心这个问题,他们绝对可以构建支持 `rr` 工作的硬件(如果他们真的关心,他们可以构建硬件辅助功能,使 `rr` 速度更快),但在没有此类功能的显著用户群的情况下,他们几乎没有动机去关心。我希望通过广泛推出这些功能,这些动机会开始出现。从社区规模来看,并扣除使用不受支持的硬件或操作系统的用户,将可以使用此功能的用户数量可能在数十万。从硬件供应商的标准来看,这不算太大,但也不算小。如果任何硬件供应商有兴趣让它工作,我们很乐意与您交谈(正如我提到的,其中一些工作已经在进行中)。
本质上,`rr` 跟踪将包含进程在其生命周期中接触到的任何文件。特别是,这可能包含您的 Julia 历史记录、您的进程环境(包括您可能从 bashrc 或类似文件自动添加到其中的任何秘密)、系统中的任何配置文件、输入或读取的任何秘密(即,确保使用 ssh-agent,如果您要重现的内容涉及使用 SSH 进行身份验证)、您可能使用的任何私有代码等。我们正在研究构建工具,以帮助了解跟踪中包含的内容以及匿名化可能敏感但不会影响跟踪的其他部分。目前,我们在创建 rr 跟踪时默认情况下禁用读取历史记录(在必要时可以明确选择使用历史记录以进行重现)。尽管如此,请确保在使用 `--bug-report` 功能之前获得系统管理员的许可。如果您不确定,我们建议您在干净的隔离环境(例如 Docker 容器)中创建可重现的内容。或者,虽然 `--bug-report` 选项默认情况下会创建公共上传,但如果您是具有访问专业 Julia 支持的用户(例如,通过您的雇主、超级计算中心或类似机构),您可能可以请求通过这种方式共享跟踪的机制私下。
能够回放跟踪仅仅是开始。我喜欢说,使用 `rr`,调试变成了数据分析问题,因为您可能提出的关于程序的任何问题的答案都已包含在跟踪中,它只是变成了提取答案的问题。默认情况下,`rr` 会让您进入 GDB,但 GDB 不太可能是这种分析的正确前端。`rr` 的最初作者 Robert O'Callahan 现在有一个初创公司在 https://pernos.co/ 上开发下一代前端(没有关联,但他的工作使这一切成为可能,因此在这里插一句是最起码的)。也就是说,Julia 本身往往是数据分析的非常好的工具,我有一些关于这种前端可能是什么样子的想法(笔记本风格,能够生成记录的 2D 时间 x 空间状态上的图表)。有很多很酷的事情可以做。仅举几个例子:事后检查断言、事后验证 GC 根节点准确性、性能分析、超级精确的覆盖测试等。实际上,拥有记录使某些分析技术成为可能,而这些技术在实时情况下过于昂贵,但这是另一个时间的话题。
这篇博文中提到的几乎所有公司过去都曾向 Julia 项目提供过财务或其他支持。特别是,英特尔和 IBM 过去曾通过财务支持 Julia 项目,以改善对各自架构的支持。亚马逊、谷歌和微软也提供了云计算积分。英特尔、谷歌和微软以前也赞助过 JuliaCon。
这项工作主要由我的雇主 JuliaHub(以前称为 Julia Computing)资助。JuliaHub 的部分开源工作由外部赠款资助。因此,这项工作的一部分是由 Moore 基金会的一项赠款资助的,我们对此表示衷心的感谢。
这项工作的一部分是在与英特尔签订的合同下完成的,旨在提高 Julia 在英特尔平台上的多线程稳定性。此外,在工作过程中遇到硬件错误时,这些错误已根据现有的资助合同向相关供应商报告。
此外,JuliaHub 已经 宣布,`rr` 集成可能会在未来的商业产品中提供。这是一项单独的工作,不是 JuliaHub 产品。如果您有兴趣在 JuliaHub 产品中或根据您的 JuliaHub 支持协议使用这些功能,请联系您的支持代表。
最后,您的作者披露了自己的利益。他花费了太多时间试图让错误报告在 `rr` 下重现,以便可以修复它们,因此,如果您能为我做到这一点,那就太好了。