原文链接 , 原文作者: Andrew Halberstadt, Marco Castelluccio
浏览器是一个极其复杂的软件。面对如此巨大的复杂性,保持快速开发速度的唯一方法是通过一个全量的CI系统,它可以让开发人员相信他们的更改不会引入bug。考虑到CI的规模,我们一直在寻找降低负载的方法,同时保持高标准的产品质量。我们想知道是否可以使用机器学习来达到更高的效率。
大规模持续集成
在Mozilla,我们有大约85000个独特的测试文件。每个都包含许多测试功能。这些测试需要在我们所有支持的平台(Windows、Mac、Linux、Android)上运行,针对各种构建配置(PGO、debug、ASan等),使用一系列运行时参数(站点隔离、WebRender、多进程等)。
虽然我们并没有针对上述所有可能的组合进行测试,但仍有超过90种独特的配置需要我们进行测试。换句话说,对于开发人员推送到存储库的每个更改,我们可能会运行所有85k测试90次。平均每个工作日,我们会看到近300个推送(包括我们的测试分支)。如果我们只需在每次推送时对每个配置运行每个测试,我们每天将运行大约23亿个测试文件!虽然我们在这个问题上投入了一定的资金,但作为一个独立的非营利组织,我们的预算是有限的。
那么我们如何保持CI负载的可管理性呢?首先,我们认识到这90种独特配置中的一些比其他配置更重要。许多不太重要的测试只运行一小部分测试,或者每天只运行少量的测试,或者两者兼而有之。其次,在我们的测试分支中,我们依赖于开发人员来指定哪些配置和测试与它们的更改最相关。第三,我们使用集成分支。
基本上,当一个补丁被推送到集成分支时,我们只对它运行一小部分测试。然后,我们周期性地运行所有的东西,并雇佣代码管理员来确定我们是否遗漏了任何回归。如果是这样的话,他们就退出了这一违规行为。一旦一切正常,集成分支就会定期合并到主分支。

一种有效测试的新方法
这些方法已经为我们服务了很多年,但结果证明它们仍然非常昂贵。即使有了所有这些优化,我们的CI仍然每天运行大约10个计算年!问题的一部分是,我们一直在使用一种简单的启发式方法来选择在集成分支上运行哪些任务。启发式算法根据任务在过去失败的频率对任务进行排序。排名与补丁的内容无关。因此,修改自述文件的push将运行与打开站点隔离的push相同的任务。此外,决定在测试分支上运行哪些测试和配置的责任已经转移到开发人员自己身上。这浪费了他们宝贵的时间,并导致他们倾向于过度选择测试范围。
大约一年前,我们开始问自己:我们如何才能做得更好?我们意识到,我们的CI目前的实施严重依赖于人为干预。如果我们可以使用历史回归数据将补丁与测试关联起来呢?我们可以用机器学习算法来计算出运行的最佳测试集吗?我们假设我们可以同时通过运行更少的测试来节省资金,更快地得到结果,并减轻开发人员的认知负担。在此过程中,我们将构建必要的基础设施,以保持CI管道的高效运行。
从历史的失败中获得乐趣
基于机器学习的解决方案的主要前提是收集足够大和足够精确的回归数据集。从表面上看,这似乎很容易。我们已经将所有测试执行的状态存储在名为ActiveData的数据仓库中。但实际上,由于以下原因,这很难做到。
因为我们总是周期性地引入它们的一个子集(因为我们总是周期性地运行它们的一个子集)。考虑以下场景:
| Test A | Test B | |
| Patch 1 | PASS | PASS |
| Patch 2 | FAIL | NOT RUN |
| Patch 3 | FAIL | FAIL |
很容易看出,“Test A”失败是由Patch 2恢复的,因为这是它第一次失败的地方。然而,由于“Test B”失败,我们不能确定。是Patch 2还是Patch 3引起的?现在假设在最后一次通过和第一次失败之间有8个Patch。这增加了很多不确定性!
间歇性(又名片状)故障也使收集回归数据变得困难。有时基于相同的原因,两种测试都会失败。结果我们无法确定patch 2是否在上表中回归了“Test A”!这是除非我们重新运行足够的失败次数,以达到统计上的自信。更糟糕的是,修补程序本身可能首先导致间歇性故障。我们不能仅仅因为失败是间歇性的就认为它不是一种倒退。

我们的启发式
为了解决这些问题,我们已经建立了一个相当大和复杂的启发集来预测哪些回归是由哪个补丁引起的。例如,如果一个补丁稍后被退回,我们将检查回退推送测试的状态。如果他们仍然失败,我们可以非常肯定的失败不是由于补丁。相反,如果他们开始通过,我们可以很肯定补丁是错误的。
有些失败是由人来分类的。这对我们有利。代码警长的一部分工作是对故障进行注释(例如,对于稍后修复的故障,可以使用“间歇性”或“通过提交修复”)。这些分类对于发现缺失或间歇性测试的回归有很大帮助。不幸的是,由于补丁和故障不断发生,无法达到100%的准确率。所以我们甚至用启发式来评估分类的准确性!

处理缺失数据的另一个技巧是回填缺失的测试。我们选择在最初没有运行的旧推送上运行测试,目的是找出哪个推送导致了回归。目前,开发都是手工操作的。然而,有计划在未来的某些情况下实现自动化。
收集有关修补程序的数据
我们还需要收集补丁程序本身的数据,包括修改的文件和差异。这使得我们能够与测试失败数据相关联。通过这种方式,机器学习模型可以确定给定补丁最有可能失败的测试集。
收集关于补丁的数据更容易,因为这是完全确定的。我们遍历Mercurial存储库中的所有提交,用rust-parsepatch项目解析补丁,用rust-code-analysis项目分析源代码。
设计训练集
现在我们有了补丁程序和相关测试(包括通过和失败)的数据集,我们可以构建一个训练集和一个验证集来教我们的机器如何为我们选择测试。
90%的数据集用作训练集,10%用作验证集。分开必须小心。验证集中的所有补丁必须在训练集中的补丁之后。如果我们随机分割,我们会将未来的信息泄露到训练集中,导致结果模型有偏差,人为地使其结果看起来比实际更好。
例如,考虑一个测试,它在上周之前从未失败过,并且从那以后失败了几次。如果我们用随机选取的训练集训练模型,我们可能会发现自己处于这样的情况:训练集中有一些失败,而验证集中有一些失败。该模型可能能够正确预测验证集中的失败,因为它在训练集中看到了一些例子。
然而,在现实世界中,我们不能展望未来。模型不知道下周会发生什么,只知道目前为止发生了什么。为了正确地进行评估,我们需要假设我们是过去的,并且未来的数据(相对于训练集)必须是不可访问的。

建立模型
我们使用test、patch和它们之间的链接来训练XGBoost模型,例如:
- 在过去,当同一个文件被触碰时,这个测试多久失败一次?
- 在目录树中,源文件与测试文件的距离有多远?
- 在VCS历史中,源文件与测试文件一起修改的频率是多少?

模型的输入是一个元组(TEST,PATCH),标签是二进制FAIL或NOT FAIL。这意味着我们有一个能够处理所有测试的单一模型。这种体系结构允许我们以一种简单的方式利用测试选择决策之间的共性。一个正常的多标签模型,其中每个测试是一个完全独立的标签,将不能外推有关给定测试的信息,并将其应用于另一个完全无关的测试。
考虑到我们有数以万计的测试,即使我们的模型是99.9%的准确率(相当准确,每1000次评估只有一个错误),我们仍然会在几乎每一个补丁中出错!幸运的是,在我们的领域中,与误报相关的成本(由模型为给定的补丁选择但不会失败的测试)并不像我们所说的那样高。我们付出的唯一代价就是做一些无用的测试。同时我们避免了运行数百个,所以最终的结果是一个巨大的节省!
随着开发人员周期性地切换他们在数据集上所做的工作,我们将对其进行培训。所以我们现在每两周再培训一次。
优化配置
在我们选择了要运行的测试之后,我们可以通过选择测试应该在哪里运行来进一步改进选择。换句话说,它们应该运行的配置集。我们使用收集到的数据集来识别任何给定测试的冗余配置。例如,在Windows7和Windows10上运行一个测试真的值得吗?为了识别这些冗余,我们使用类似于频繁项集挖掘的解决方案:
- 收集测试和配置组的故障统计信息
- 将“支撑”计算为X和Y都失败的推送次数,而不是它们都运行的推送次数
- 将“置信度”计算为X和Y都失败的推送次数,与它们同时运行且只有一次失败的推送次数相比。
我们只选择支持度高(低支持意味着我们没有足够的证据)和高置信度(低置信度意味着我们有很多冗余不适用的情况)的配置组。
一旦我们有了要运行的测试集,关于它们的结果是否依赖于配置的信息,以及运行它们的一组机器(及其相关的成本),我们就可以制定一个数学优化问题,我们可以用混合整数规划解算器来解决。这样,我们就可以很容易地改变我们想要达到的优化目标,而不会对优化算法进行侵入性的改变。目前,优化的目标是选择最便宜的配置来运行测试。

使用模型
一个机器学习模型只有在消费者有能力使用它时才有用。为此,我们决定在Heroku上托管一个服务,使用专用的worker dyno来服务请求,并在后端和前端之间建立Redis队列。前端公开了一个简单的REST API,因此用户只需要指定他们感兴趣的推送(由分支和最上面的修订标识)。后端将使用mozilla central的克隆自动确定更改的文件及其内容。
根据推送的大小和要分析的队列中的推送数量,服务可能需要几分钟来计算结果。因此,我们确保在任何给定的推送过程中,我们永远不会超过一个作业排队。一旦计算出来,我们就会缓存结果。这允许使用者异步启动查询,并定期轮询以查看结果是否准备就绪。
我们目前在调度集成分支上的任务时使用该服务。当开发人员运行特殊的mach try auto命令在测试分支上测试他们的更改时,也可以使用它。将来,我们还可以使用它来确定开发人员应该在本地运行哪些测试。

测量和比较结果
从这个项目一开始,我们就觉得至关重要的是,我们能够运行和比较实验,衡量我们的成功,并确信对我们算法的更改实际上是对现状的改进。在调度算法中,我们实际上关心两个变量:
- 使用的资源量(以小时或美元计)。
- 回归检出率。也就是说,引入的回归中,直接被导致它们的推动所捕获的百分比。换言之,我们不需要依靠人类来填补失败,从而找出是哪一次推动才是罪魁祸首。
我们定义了我们的指标:
** 调度程序有效性=1000*每次推送的回归检测率/小时**
这个指标越高,调度算法就越有效。现在我们有了度量标准,我们发明了“影子调度器”的概念。影子调度程序是在每次推送时运行的任务,它会隐藏实际的调度算法。只不过,它们并不是实际调度事务,而是输出默认情况下应该调度的内容。每个影子调度器对机器学习服务返回的数据的解释可能稍有不同。或者他们可以在机器学习模型推荐的基础上运行额外的优化。
最后,我们编写了一个ETL来查询所有这些影子调度器的结果,计算每个影子调度器的有效性度量,并将它们全部绘制在仪表板中。目前,大约有十几种不同的影子调度程序,我们正在对它们进行监视和微调,以找到可能的最佳结果。一旦我们确定了赢家,我们就把它作为默认算法。然后我们重新开始这个过程,创建进一步的实验。
结论
这个项目的早期成果很有希望。与以前的解决方案相比,我们将集成分支上的测试任务数量减少了70%!与没有测试选择的CI系统相比,几乎高出99%!我们也看到了mach-try-auto工具的快速采用,这意味着可用性的提高(因为开发人员不再需要考虑选择什么)。但还有很长的路要走!
我们需要提高模型选择配置和默认配置的能力。我们的回归检测启发式方法和数据集的质量需要改进。我们还需要对mach-try-auto进行可用性和稳定性修复。
虽然我们不能做出任何承诺,但我们希望以一种对Mozilla以外的组织有用的方式来打包模型和服务。目前,这项工作是一个更大项目的一部分,该项目包含其他机器学习基础设施,最初是为了帮助管理Mozilla的Bugzilla实例而创建的。敬请期待!