ClickHouse 的主要卖点之一是它速度非常快,在许多情况下,可以将硬件利用到理论极限。许多独立基准测试都注意到了这一点,例如 这个。这种速度归结于架构选择和算法优化的正确组合,再加上一点魔法。在我们的网站上有一个关于这些因素的 概述,或者由 ClickHouse 首席开发人员 Alexey Milovidov 做的演讲 "ClickHouse 性能优化的秘密"。但这只是对“现状”的静态描述。软件是一个不断变化的生物,ClickHouse 变化得很快——为了让你了解规模,在 2021 年 7 月,我们合并了 60 位不同作者提交的 319 个请求 (此处查看实时统计信息)。任何没有积极选择的品质都会在这个无休止的变化流中丢失,性能也不例外。因此,我们必须有一些流程来确保 ClickHouse 始终保持快速。
衡量和比较性能
我们如何知道它首先是快的?我们做了很多基准测试,包括各种类型。最基本的基准测试是微基准测试,它不使用服务器的完整代码,而是独立测试特定的算法。我们使用它们为某些聚合函数选择更好的内部循环,或者测试哈希表的各种布局等等。例如,当我们发现一个竞争数据库引擎比 sum
聚合函数快两倍时,我们测试了数十个 sum
实现,最终找到了性能最好的实现(见 演讲,俄语)。但是,仅测试特定算法不足以说明整个查询将如何运行。我们还必须对整个查询进行端到端测量,通常使用实际的生产数据,因为数据的具体情况(例如值的基数和分布)会极大地影响性能。目前,我们大约有 3000 个端到端测试查询,组织成大约 200 个 测试。其中许多测试使用真实数据集,例如 Yandex.Metrica 的生产数据,使用 clickhouse-obfuscator
进行混淆,如 此处 所述。
微基准测试通常由开发人员在编写代码时运行,但对于每次更改手动运行整个端到端测试集是不切实际的。我们使用一个自动化系统,在每个拉取请求中作为持续集成检查的一部分执行此操作。它测量拉取请求引入的代码更改是否影响了性能,对哪些类型的查询影响了多少,并在出现回归时提醒开发人员。这是一个典型报告的外观。
要谈论“性能变化”,我们首先必须衡量此性能。对于单个查询,最自然的度量是经过时间。它容易受到随机变化的影响,因此我们必须进行多次测量并将它们以某种方式平均。从应用程序的角度来看,最有趣的统计数据是最大值。我们希望保证例如基于 ClickHouse 构建的分析仪表板具有响应能力。但是,由于随机因素(例如突然的磁盘负载峰值或网络延迟),查询时间几乎可以无限增长,因此使用最大值不切实际。最小值也很有趣——毕竟,它有一个理论界限。我们知道特定算法在特定硬件上,在理想条件下只能运行得这么快。但如果我们只关注最小值,就会错过某些查询运行缓慢而某些查询没有运行缓慢的情况(例如某些缓存中的边界效应)。因此,我们通过测量中位数来妥协。它是一个稳健的统计数据,对异常值相当敏感,并且对噪声足够稳定。
在测量性能后,我们如何确定它发生了变化?由于各种随机和系统因素,查询时间总是会漂移,因此数字总是会变化,但问题是这种变化是否有意义。如果我们有一个旧版本的服务器,还有一个新版本的服务器,它们是否会始终如一地对这个查询给出不同的结果,或者只是一个偶然事件?为了回答这个问题,我们必须采用一些统计方法。这些方法的核心思想是将观察到的值与某个参考分布进行比较,并决定我们观察到的结果是否可能属于该分布,或者相反,它不属于该分布,这意味着修补后的服务器的性能特征确实不同。
选择参考分布是起点。一种方法是建立过程的数学模型。这对于像固定次数抛硬币这样简单的事情非常有效。我们可以通过分析推断出我们得到的正面次数服从二项分布,并获得该次数的置信区间,前提是所需的 显著性水平。如果观察到的正面次数不属于此区间,我们可以得出结论,硬币是有偏差的。但是,从第一原理对查询执行进行建模过于复杂。我们能做的最好的就是利用硬件功能来估计查询原则上可以运行的速度,并尝试达到此吞吐量。
对于难以建模的复杂过程,一个实用的选择是使用来自同一过程的历史数据。我们过去实际上也为 ClickHouse 这样做过。对于每个测试提交,我们测量每个测试查询的运行时间并将其保存到数据库中。我们可以将修补后的服务器与这些参考值进行比较,构建随时间变化的图表等等。这种方法的主要问题是环境引起的系统性错误。有时,性能测试任务最终会在硬盘即将损坏的机器上完成,或者他们将 atop
更新到一个损坏的版本,该版本将每个内核调用速度降低一半,等等,无穷无尽。这就是我们现在采用另一种方法的原因。
我们在同一台机器上同时运行参考版本和测试版本的服务器进程,并依次在每个版本上运行测试查询。这样,我们消除了大多数系统性错误,因为两个服务器都受到它们的影响。然后,我们可以比较从参考服务器进程获得的结果集和从测试服务器进程获得的结果集,看看它们是否相同。使用两个样本比较分布本身就是一个非常有趣的问题。我们使用非参数自助法为观察到的中位查询运行时间差构建随机化分布。此方法在[1]中详细描述,他们将其应用于查看改变肥料混合物如何改变番茄植物的产量。ClickHouse 与番茄没什么不同,只是我们必须检查代码中的更改如何影响性能。
此方法最终给出一个单一的阈值数字 *T*:即使没有任何变化,我们也能观察到旧服务器和新服务器之间中位查询运行时间的最大差异。然后,我们可以根据此阈值 *T* 和测量到的中位数差 *D* 进行简单的决策协议
- abs(D) <= T — 变化在统计学上不显著,
- abs(D) <= 5% — 变化太小,不重要,
- abs(T) >= 10% — 测试查询的运行时间方差过大,导致灵敏度较差,
- 最后,abs(D) >= T 且 abs(D) >= 5% — 存在统计学上显著且幅度显著的更改。
最有趣的情况是不稳定的查询(3)。当即使在同一版本的服务器上运行时,经过时间也发生显著变化时,这意味着我们将无法检测到性能的变化,因为它们会被噪声淹没。此类查询往往是最难调试的,因为没有直接的方法可以比较“好”和“坏”的服务器。这个主题值得单独写一篇文章,我们将在下篇文章中发布。现在,让我们考虑一下顺利的情况(4)。这是此系统旨在捕获的真实且显著的性能变化的情况。接下来该怎么办?
了解更改背后的原因
代码性能调查通常从使用分析器开始。在 Linux 上,您将使用 `perf`,这是一种采样分析器,它会定期收集进程的堆栈跟踪,以便您可以看到程序在何处花费最多时间的聚合视图。在 ClickHouse 中,我们实际上有一个内置的采样分析器,它将结果保存到系统表中,因此不需要外部工具。它可以为所有查询或特定查询启用,方法是传递设置,如文档中所述。它默认情况下处于启用状态,因此如果您使用的是最新版本的 ClickHouse,您已经拥有生产服务器负载的组合概要文件。要将其可视化,我们可以使用一个著名的脚本构建火焰图
clickhouse-client -q "SELECT
arrayStringConcat(
arrayMap(
x -> concat(splitByChar('/', addressToLine(x))[-1],
'#', demangle(addressToSymbol(x))),
trace),
';') AS stack,
count(*) AS samples
FROM system.trace_log
WHERE trace_type = 'Real'
AND query_id = '4aac5305-b27f-4a5a-91c3-61c0cf52ec2a'
GROUP BY trace" \
| flamegraph.pl
例如,让我们使用上面看到的测试运行。已测试的拉取请求 应该可以加快对可空整数类型的 `sum` 聚合函数的执行速度。让我们看看测试“sum”的查询 #8:`SELECT sum(toNullable(number)) FROM numbers(100000000)`。测试系统报告说它的性能提高了 38.5%,并为此构建了火焰图的“差异”变体,它显示了在不同函数中花费的相对时间。我们可以看到,计算总和的函数 `DB::AggregateFunctionSumData<unsigned long>::addManyNotNull<unsigned long>` 现在花费的时间减少了 15%。
为了进一步了解性能发生变化的原因,我们可以检查旧服务器和新服务器之间各种查询指标的变化情况。这包括来自 `system.query_log.ProfileEvents` 的所有指标,例如 `SelectedRows` 或 `RealTimeMicroseconds`。ClickHouse 还使用 Linux `perf_event_open` API 跟踪硬件 CPU 指标,例如分支或缓存未命中的数量。下载测试输出存档后,我们可以使用一个简单的即席脚本 来构建这些指标的一些统计信息和图表。
此图表显示了旧服务器和新服务器上每秒的分支指令数量。我们可以看到,分支指令的数量急剧下降,这可能解释了性能差异。已测试的拉取请求删除了一些 `if` 并用乘法替换它们,因此这种解释听起来合乎逻辑。
虽然并排比较对系统性错误更稳健,但历史数据仍然非常宝贵,可以用于查找回归是在哪里引入的或调查不稳定的测试查询。这就是为什么我们将所有测试运行的结果保存到 ClickHouse 数据库中的原因。让我们考虑一下来自 `sum` 测试的相同查询 #8。我们可以使用这个SQL 查询 到 ClickHouse CI 实时数据库。打开链接并运行查询,以便您可以自己检查查询并查看结果。在整个测试历史中,性能发生了三次重大变化。最近的一次是我们在开头提到的 PR 的加速。第二次加速与完全切换到 clang 11 相关。奇怪的是,还有一个 PR 引入了轻微的减速,而它本该加速。
可用性注意事项
无论内部是如何工作的,测试系统必须作为开发过程的一部分真正可用。首先也是最重要的是,误报率应尽可能低。误报成本很高,如果经常发生,开发人员会认为测试总体不可靠,并倾向于忽略真正的阳性结果。测试还必须提供简明的报告,使其很清楚地表明出了什么问题。我们在这方面并没有真正成功。此测试的故障模式比简单的功能测试多得多,更糟糕的是,其中一些故障是量化的,而不是二元的。许多复杂性是必不可少的,我们试图通过提供良好的文档并从报告页面链接到相关部分来缓解它。另一件重要的事情是,用户必须能够在事后调查有问题的查询,而无需在本地再次运行它。这就是为什么我们尝试以易于操作的纯文本格式导出我们拥有的每个指标和每个中间结果。
从组织上来说,很难阻止演变成一个系统,该系统会做很多繁琐的工作,只是为了显示一个绿色的勾号,而没有提供任何洞察力。我喜欢将此过程称为“挖掘绿色勾号”,类似于加密货币。我们之前的系统就是这样做的。它使用了越来越复杂的启发式方法,针对每个测试查询进行调整,以防止误报,如果结果看起来不好,它会多次重新启动自己,等等。最终,它浪费了大量的处理能力,而没有提供服务器性能的真实情况。如果您想确保性能确实发生了变化或没有变化,您必须手动重新检查。这种糟糕的状态是开发环境中激励机制如何对齐的结果——大多数时候,开发人员只想合并他们的拉取请求,而不希望被一些晦涩的测试失败所困扰。编写一个好的性能测试查询也不总是简单的。任何其他查询都不行——它必须提供可预测的性能,不能太快也不能太慢,实际上要测量一些东西,等等。在收集了更精确的统计数据后,我们发现我们的几百个测试查询没有测量任何有意义的东西,例如它们给出的结果在运行之间会发生 100% 的变化。另一个问题是,性能经常在统计学上显著地发生变化(真阳性),而没有相关的代码更改(例如,由于可执行文件布局的随机差异)。鉴于所有这些困难,一个有效的性能测试系统肯定会给开发过程带来明显的摩擦。大多数消除这种摩擦的“明显”方法最终归结为“挖掘绿色勾号”。
从实现角度来看,我们的系统很特殊,因为它不依赖于众所周知的统计包,而是大量使用 `clickhouse-local`,这是一种将 ClickHouse SQL 查询处理器变成命令行实用程序的工具。在 ClickHouse SQL 中完成所有计算有助于我们发现 `clickhouse-local` 的错误和可用性问题。性能测试继续以双重目的作为一项重量级的 SQL 测试,有时会捕获复杂连接等方面中新引入的错误。查询分析器始终在性能测试中处于启用状态,这发现了我们在 `libunwind` 分支中的一些错误。为了运行测试查询,我们使用了一个第三方 Python 驱动程序。这是我们在 CI 中唯一使用此驱动程序的地方,它也帮助我们发现本机协议处理中的一些错误。一个不太光彩的事实是,脚手架包含了大量的 bash,但这至少使我们确信在 CI 中运行 shellcheck 非常有用。
这结束了 ClickHouse 性能测试系统的概述。敬请关注下一篇文章,我们将讨论最麻烦的性能测试失败类型——不稳定的查询运行时间。
2021-08-20 Alexander Kuzmenkov。标题照片由 Alexander Tokmakov 提供
参考文献
1. Box,Hunter,Hunter,2005。实验统计,第 78 页:用于比较标准和改良肥料混合物以用于番茄植物的随机化设计。