作者注:太平洋时间 7 月 13 日上午 9:02 – 修正了测试文件数量和相关计算。
浏览器是一个非常复杂的软件。面对如此巨大的复杂性,维持快速开发节奏的唯一方法是通过一个广泛的 CI 系统,让开发者确信他们的更改不会引入错误。考虑到我们 CI 的规模,我们一直在寻找在保持高产品质量标准的同时降低负载的方法。我们想知道是否可以使用机器学习来实现更高程度的效率。
规模化的持续集成
在 Mozilla,我们拥有约 85,000 个独特的测试文件。每个文件包含多个测试函数。这些测试需要在所有受支持的平台(Windows、Mac、Linux、Android)上针对各种构建配置(PGO、调试、ASan 等)进行运行,并使用一系列运行时参数(站点隔离、WebRender、多进程等)。
虽然我们不会针对上述所有可能的组合进行测试,但我们仍然会针对超过 90 种独特的配置进行测试。换句话说,对于开发人员推送到存储库的每次更改,我们都可能运行所有 85k 次测试 90 次。在平均工作日,我们看到近 300 次推送(包括我们的 测试分支)。如果我们只是在每次推送时对每种配置运行每项测试,那么我们每天将运行大约 23 亿 个测试文件!虽然我们在一定程度上为这个问题投入了资金,但作为一家独立的非盈利组织,我们的预算有限。
那么,我们如何使我们的 CI 负载保持在可控范围内?首先,我们认识到,这 90 种独特的配置中,有些比其他配置更重要。许多不太重要的配置只运行一小部分测试,或者每天只运行几次推送,或者两者兼而有之。其次,在我们的测试分支的情况下,我们依靠开发人员来指定哪些配置和测试与他们的更改最相关。第三,我们使用集成分支。
基本上,当一个补丁被推送到集成分支时,我们只针对它运行一小部分测试。然后,我们定期运行所有测试,并聘用 代码警长 来找出我们是否错过了任何回归。如果有,他们会回滚有问题的补丁。集成分支定期合并到主分支,前提是所有东西看起来都很好。
我们在单个 mozilla-central 推送上运行的一组任务。完整的任务集在缩放到适合单个图像时难以区分。
一种高效测试的新方法
这些方法已经为我们服务了很多年,但事实证明它们仍然非常昂贵。即使经过所有这些优化,我们的 CI 每天仍然运行大约 10 个计算年!问题的一部分是我们一直在使用一种简单的启发式方法来选择在集成分支上运行哪些任务。启发式方法根据任务在过去失败的频率对任务进行排名。排名与补丁的内容无关。因此,修改 README 文件的推送将运行与启用站点隔离的推送相同的任务。此外,确定在测试分支上运行哪些测试和配置的责任已经转移到开发人员自己身上。这浪费了他们宝贵的时间,并且倾向于过度选择测试。
大约一年前,我们开始问自己:我们还能做得更好吗?我们意识到,我们 CI 的当前实现严重依赖人工干预。如果我们可以用历史回归数据将补丁与测试相关联呢?我们可以使用机器学习算法来找出要运行的最佳测试集吗?我们假设我们可以通过运行更少的测试来节省资金,更快地获得结果,并减轻开发人员的认知负担。在这个过程中,我们将构建必要的基础设施来保持我们的 CI 管道高效运行。
享受历史失败
基于机器学习的解决方案的主要先决条件是收集足够大且足够精确的回归数据集。从表面上看,这似乎很简单。我们已经在称为 ActiveData 的数据仓库中存储了所有测试执行的状态。但实际上,由于以下原因,这很难做到。
由于我们只在任何给定的推送上运行一小部分测试(然后定期运行所有测试),因此并不总是很明显什么时候引入了回归。考虑以下场景
测试 A | 测试 B | |
补丁 1 | 通过 | 通过 |
补丁 2 | 失败 | 未运行 |
补丁 3 | 失败 | 失败 |
很容易看出,“测试 A” 失败是由补丁 2 引起的,因为这是它第一次开始失败。但是,对于“测试 B” 失败,我们无法确定。它是由补丁 2 还是 3 引起的?现在假设在最后一次通过和第一次失败之间有 8 个补丁。这会增加很多不确定性!
间歇性(也称为不稳定)失败 也使得收集回归数据变得很困难。有时,测试可以在同一个代码库上既通过又失败,这是由于 各种不同的原因 造成的。事实证明,我们无法确定上述表格中的补丁 2 是否确实导致了“测试 A”的回归!除非我们重新运行失败的次数足够多,以至于具有统计上的信心。更糟糕的是,补丁本身可能会在第一时间引入间歇性失败。我们不能假设仅仅因为失败是间歇性的,它就不是一个回归。
本文作者遇到了困难。
我们的启发式方法
为了解决这些问题,我们建立了一套相当 大而复杂的启发式方法集 来预测哪些回归是由哪些补丁引起的。例如,如果一个补丁后来被回滚,我们会检查回滚推送上测试的状态。如果它们仍然失败,我们很有把握地认为失败不是由于补丁造成的。相反,如果它们开始通过,我们很有把握地认为补丁有错。
一些失败是由人工分类的。这可能对我们有利。代码警长部分工作是为失败的测试添加注释(例如,对于在某个时间点修复的失败测试,添加注释“间歇性”或“由提交修复”)。这些分类对于在缺少或间歇性测试的情况下查找回归非常有用。不幸的是,由于持续发生的补丁和失败数量众多,无法达到 100% 的准确率。因此,我们甚至有启发式方法来评估分类的准确性!
警长抱怨间歇性失败。
处理丢失数据的另一个技巧是 回填 丢失的测试。我们选择在以前没有运行测试的旧推送上运行测试,目的是找出哪个推送导致了回归。目前,警长手动执行此操作。但是,将来计划在某些情况下实现自动化。
收集有关补丁的数据
我们还需要收集有关补丁本身的数据,包括修改的文件和 diff。这使我们能够与测试失败数据相关联。通过这种方式,机器学习模型可以确定针对给定补丁最有可能失败的测试集。
收集有关补丁的数据要容易得多,因为它是完全确定的。我们遍历 Mercurial 存储库中的所有提交,使用我们的 rust-parsepatch 项目解析补丁,并使用我们的 rust-code-analysis 项目分析源代码。
设计训练集
现在我们已经有了补丁和关联测试(包括通过和失败)的数据集,我们可以构建训练集和验证集来教我们的机器如何为我们选择测试。
数据集的 90% 用作 训练集,10% 用作验证集。必须谨慎地进行拆分。验证集中的所有补丁都必须在训练集中的补丁之后。如果我们随机拆分,我们会将来自未来的信息泄露到训练集中,导致生成的模型存在偏差,并人为地使其结果看起来比实际情况更好。
例如,考虑一项测试,该测试直到上周才失败,并且此后失败了几次。如果我们使用随机选择的训练集训练模型,我们可能会发现自己处于这样的情况:训练集中有一些失败,验证集中有一些失败。模型可能能够正确预测验证集中的失败,因为它在训练集中看到了一些示例。
但是,在现实世界中,我们无法看到未来。该模型无法知道下周会发生什么,只能知道到目前为止发生了什么。为了正确评估,我们需要假装我们处于过去,并且未来的数据(相对于训练集)必须是不可访问的。
构建模型
我们使用来自测试、补丁以及它们之间链接的特征训练一个XGBoost模型,例如
- 在过去,当相同的文件被修改时,这个测试失败了多少次?
- 源文件与测试文件在目录树中相距多远?
- 在 VCS 历史中,源文件与测试文件一起修改了多少次?
模型的输入是一个元组 (TEST, PATCH),标签是一个二元 FAIL 或 NOT FAIL。这意味着我们有一个模型可以处理所有测试。这种架构使我们能够以一种简单的方式利用测试选择决策之间的共性。一个正常的多标签模型,其中每个测试都是一个完全独立的标签,将无法推断有关给定测试的信息并将其应用于另一个完全无关的测试。
鉴于我们有数万个测试,即使我们的模型的准确率达到 99.9%(这已经非常准确了,每 1000 次评估才出现一次错误),我们仍然会在几乎每个补丁上都犯错!幸运的是,误报(模型为给定补丁选择的但没有失败的测试)在我们的领域中所带来的成本并不像识别面部用于执法目的那样高。我们唯一付出的代价是运行一些无用的测试。但同时,我们避免了运行数百个测试,因此最终结果是巨大的节省!
由于开发人员定期切换他们正在处理的内容,我们用来训练的数据集也在不断演变。因此,我们目前每两周重新训练一次模型。
优化配置
在选择要运行的测试之后,我们可以通过选择在哪里运行测试来进一步改进选择。换句话说,即它们应该运行的配置集。我们使用我们收集的数据集来识别任何给定测试的冗余配置。例如,在 Windows 7 和 Windows 10 上运行一个测试真的值得吗?为了识别这些冗余,我们使用了类似于频繁项集挖掘的解决方案。
- 收集测试和配置组的失败统计数据
- 将“支持”计算为 X 和 Y 都失败的推送次数除以 X 和 Y 都运行的推送次数。
- 将“置信度”计算为 X 和 Y 都失败的推送次数除以 X 和 Y 都运行且其中只有一个失败的推送次数。
我们只选择支持度高(支持度低意味着我们没有足够的证据)且置信度高(置信度低意味着我们有许多冗余不适用的情况)的配置组。
一旦我们有了要运行的测试集、有关它们的结果是否依赖于配置的信息以及一组运行这些测试的机器(及其相关成本),我们就可以制定一个数学优化问题,并使用混合整数规划求解器来求解。这样,我们就可以轻松地改变我们想要实现的优化目标,而无需对优化算法进行侵入性更改。目前,优化目标是选择运行测试的最便宜的配置。
最小化 | ![]() |
受制于 | ![]() |
以及 | ![]() |
使用模型
机器学习模型的实用性取决于消费者使用它的能力。为此,我们决定在 Heroku 上托管一项服务,使用专门的worker dynos来处理请求,以及使用Redis 队列来连接后端和前端。前端提供了一个简单的 REST API,因此消费者只需要指定他们感兴趣的推送(由分支和最高修订版标识)。后端将使用mozilla-central的克隆来自动确定更改的文件及其内容。
根据推送的大小以及要分析的队列中的推送数量,服务可能需要几分钟才能计算出结果。因此,我们确保我们永远不会为任何给定的推送排队超过一项作业。我们缓存一旦计算出的结果。这使消费者能够异步启动查询,并定期轮询以查看结果是否已准备就绪。
我们目前在我们的集成分支上调度任务时使用这项服务。它还用于开发人员运行特殊的mach try auto命令以在测试分支上测试其更改。将来,我们也可能使用它来确定开发人员应该在本地运行哪些测试。
衡量和比较结果
从这个项目开始之初,我们就觉得我们能够运行和比较实验、衡量我们的成功并确信对我们算法的更改实际上是现状的改进至关重要。在调度算法中,我们实际上关心的是两个变量
- 使用的资源量(以小时或美元衡量)。
- 回归检测率。也就是说,直接在导致回归的推送上发现的引入的回归的百分比。换句话说,我们不必依赖人类来回填失败以找出哪个推送是罪魁祸首。
我们定义了我们的指标
scheduler effectiveness = 1000 * regression detection rate / hours per push
该指标越高,调度算法的效率就越高。现在我们有了指标,我们发明了“影子调度器”的概念。影子调度器是在每次推送时运行的任务,它们模拟实际的调度算法。只是它们不实际调度任何东西,而是输出如果它们是默认值的话它们将调度的内容。每个影子调度器可能会对我们的机器学习服务返回的数据进行略微不同的解释。或者它们可能会在机器学习模型推荐的基础上运行额外的优化。
最后,我们编写了一个ETL来查询所有这些影子调度器的结果,计算每个调度器的调度效率
指标,并将它们全部绘制在仪表板中。目前,我们监控和微调了大约十几个不同的影子调度器,以找到最佳结果。一旦我们确定了一个获胜者,我们就将其设置为默认算法。然后我们重新开始整个过程,创建进一步的实验。
结论
该项目的早期结果非常令人鼓舞。与我们之前的解决方案相比,我们已将集成分支上的测试任务数量减少了 70%!与没有测试选择的 CI 系统相比,减少了近 99%!我们还看到了对我们的mach try auto工具的快速采用,这表明可用性的提高(因为开发人员不再需要考虑选择什么)。但我们还有很长的路要走!
我们需要提高模型选择配置的能力,并默认使用它。我们的回归检测启发式算法和数据集的质量需要改进。我们尚未对mach try auto
实施可用性和稳定性修复。
虽然我们无法做出任何承诺,但我们很乐意以对 Mozilla 之外的组织有用的方式打包模型和服务。目前,这项工作是更大项目的一部分,该项目包含其他机器学习基础设施最初是为了帮助管理 Mozilla 的 Bugzilla 实例而创建的。敬请关注!
如果您想了解更多关于这个项目或 Firefox 的 CI 系统的信息,请随时在我们的 Matrix 频道上提问,#firefox-ci:mozilla.org.
关于 Andrew Halberstadt
Andrew 从事 CI、测试基础设施、代码风格检查和通用开发人员生产力工具方面的工作。
关于 Marco Castelluccio
Marco 是一位充满激情的 Mozilla 黑客(黑客和工程师的奇怪混合体),他为 Firefox、PluotSorbet、Open Web Apps 做出了贡献,并一直在做出贡献。最近,他一直在研究将机器学习和数据挖掘技术用于软件工程(测试、崩溃处理、错误管理等)。
4 条评论