ClickHouse 的主要卖点之一是它速度非常快,在许多情况下能够充分利用硬件,达到理论极限。许多独立基准测试都注意到了这一点,例如这个基准测试。这种速度归结为架构选择和算法优化的正确结合,并加入了一点魔法。我们的网站上概述了这些因素,ClickHouse 首席开发人员 Alexey Milovidov 也发表了“ClickHouse 性能优化的秘密”的演讲。但这只是“现状”的静态画面。软件是一个活的、不断变化的有机体,ClickHouse 的变化非常快——为了给您一个概念,2021 年 7 月,我们合并了 60 位不同作者提交的 319 个拉取请求(实时统计数据在此)。任何没有积极选择的质量都会在这个无休止的变化流中丢失,性能也不例外。因此,我们必须制定一些流程来确保 ClickHouse 始终保持快速。
测量和比较性能
我们如何首先知道它很快?我们做了很多基准测试,很多种类的基准测试。最基本的一种基准测试是微基准测试,它不使用服务器的完整代码,而是在隔离状态下测试特定的算法。我们使用它们来为某些聚合函数选择更好的内部循环,或者测试哈希表的各种布局等等。例如,当我们发现竞争数据库引擎以两倍的速度完成带有 sum
聚合函数的查询时,我们测试了数十个 sum
的实现,最终找到了性能最佳的实现(参见关于此的演讲,用俄语)。但是,仅测试特定的算法不足以说明整个查询将如何工作。我们还必须对整个查询进行端到端测量,通常使用真实的生产数据,因为数据的特性(例如,基数和值的分布)会严重影响性能。目前,我们有大约 3000 个端到端测试查询,组织成大约 200 个测试。其中许多测试使用真实数据集,例如 Yandex.Metrica 的生产数据,使用 clickhouse-obfuscator
进行了混淆,如此处所述。
微基准测试通常由开发人员在编写代码时运行,但对于每个更改都手动运行整个端到端测试套件是不切实际的。我们使用自动化系统来为每个拉取请求执行此操作,作为持续集成检查的一部分。它测量拉取请求引入的代码更改是否影响了性能,影响了哪些类型的查询以及影响了多少,并在出现回归时提醒开发人员。以下是一个典型的报告示例。
要讨论“性能变化”,我们首先必须测量此性能。对于单个查询,最自然的度量标准是运行时间。它容易受到随机波动的影响,因此我们必须进行多次测量并以某种方式对它们进行平均。从应用程序的角度来看,最有趣的统计数据是最大值。我们希望保证例如构建在 ClickHouse 上的分析仪表板是响应迅速的。然而,由于磁盘负载突然飙升或网络延迟等随机因素,查询时间可能会无限增长,因此使用最大值是不切实际的。最小值也很有趣——毕竟,它有一个理论上限。我们知道特定的算法在特定的硬件上,在理想的条件下,只能运行得这么快。但是,如果我们只关注最小值,我们将错过某些查询运行缓慢而另一些查询运行不慢的情况(例如,某些缓存中的边界效应)。因此,我们通过测量中位数来进行折衷。这是一个稳健的统计量,对异常值相当敏感,并且对噪声足够稳定。
在测量性能之后,我们如何确定性能发生了变化?由于各种随机和系统因素,查询时间总是会漂移,因此数字总是会变化,但问题是这种变化是否有意义。如果我们有一个旧版本的服务器和一个新版本的服务器,它们是否会始终如一地为这个查询给出不同的结果,或者这只是一个侥幸?为了回答这个问题,我们必须采用一些统计方法。这些方法的核心思想是将观察到的值与一些参考分布进行比较,并确定我们观察到的值是否可以合理地属于此分布,或者相反,它不能,这意味着修补后的服务器的性能特征确实不同。
选择参考分布是起点。获得参考分布的一种方法是构建过程的数学模型。这对于简单的东西(例如,固定次数地抛硬币)非常有效。我们可以分析推导出我们得到的正面朝上的次数服从二项分布,并在给定所需的显著性水平的情况下,获得此次数的置信区间。如果观察到的正面朝上的次数不属于此区间,我们可以得出结论,硬币是有偏差的。然而,从第一原理对查询执行进行建模太复杂了。我们能做的最好的事情是使用硬件功能来估计查询原则上可以运行多快,并尝试实现此吞吐量。
对于难以建模的复杂过程,一个实用的选择是使用来自同一过程的历史数据。我们实际上过去对 ClickHouse 就是这样做的。对于每个经过测试的提交,我们测量每个测试查询的运行时间,并将它们保存到数据库中。我们可以将修补后的服务器与这些参考值进行比较,构建随时间变化的图表等等。这种方法的主要问题是环境引起的系统误差。有时,性能测试任务最终会在一台 HDD 快要坏掉的机器上结束,或者他们将 atop
更新到损坏的版本,该版本将每个内核调用减慢一半,等等,以此类推,永无止境。这就是为什么现在我们采用另一种方法。
我们同时在同一台机器上运行参考版本的服务器进程和测试版本的服务器进程,并依次对它们中的每一个运行测试查询。这样,我们消除了大多数系统误差,因为两个服务器都受到它们的同等影响。然后,我们可以比较我们从参考服务器进程获得的结果集和从测试服务器进程获得的结果集,以查看它们是否看起来相同。使用两个样本比较分布本身就是一个非常有趣的问题。我们使用非参数引导法来构建观察到的中位数查询运行时间差异的随机分布。 [1] 中详细描述了这种方法,他们在其中应用该方法来查看改变肥料混合物如何改变番茄植株的产量。 ClickHouse 与番茄没有太大区别,只是我们必须检查代码中的更改如何影响性能。
这种方法最终给出了一个单一的阈值 T:即使没有任何变化,我们也可以观察到的新旧服务器之间中位数查询运行时间的最大差异是多少。然后,我们有一个简单的决策协议,给定此阈值 T 和测量的中位数差异 D
- abs(D) <= T — 更改在统计上不显著,
- abs(D) <= 5% — 更改太小,不重要,
- abs(T) >= 10% — 测试查询具有过度的运行时间方差,导致灵敏度差,
- 最后,abs(D) >= T and 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. 实验人员统计学,p. 78:用于比较番茄植株的标准和改良肥料混合物的随机设计。