文章摘要
英特尔最新处理器核心数量大幅增加,为ClickHouse等分析数据库带来机遇与挑战。尽管更多核心理论上能提升并行处理能力,但锁争用、缓存一致性、非统一内存访问等问题随核心数增加而加剧,导致数据库难以充分利用硬件性能。过去三年,作者致力于优化ClickHouse以应对这些挑战。
文章总结
优化ClickHouse以适配英特尔超高核心数处理器
英特尔最新的处理器系列正在将服务器的核心数推向前所未有的高度,例如Granite Rapids每插槽拥有128个P核,而Sierra Forest每插槽则拥有288个E核,未来计划每插槽核心数将超过200个。在多插槽系统中,核心数进一步增加,某些服务器可能拥有400个甚至更多的核心。这种“更多核心,而非更快核心”的趋势是由物理限制驱动的,自2000年代中期Dennard缩放定律失效以来,功率密度问题使得提升单线程性能变得越来越困难。
对于像ClickHouse这样的分析型数据库,超高核心数既是巨大的机遇,也是复杂的挑战。虽然更多的核心理论上意味着更强的并行处理能力,但大多数数据库难以充分利用这些硬件资源。随着核心数的增加,锁争用、缓存一致性、非统一内存访问(NUMA)、内存带宽和协调开销等并行处理的瓶颈问题变得更加严重。
在过去的三年中,我致力于理解和优化ClickHouse在英特尔至强超高核心数处理器上的可扩展性。通过使用perf、emon和Intel VTune等性能分析工具,我系统性地分析了43个ClickBench查询,识别瓶颈并进行了相应的优化。
优化结果令人振奋:单个查询的优化通常带来数倍的性能提升,某些情况下甚至达到10倍。所有43个ClickBench查询的几何平均性能每项优化提升了2%到10%。这些结果表明,ClickHouse在超高核心数系统上能够实现良好的扩展性。
除了单线程性能,优化超高核心数系统的性能还需要解决以下几个关键挑战:
- 缓存一致性开销:缓存行的频繁交换会消耗CPU周期。
- 锁争用:根据Amdahl定律,即使是1%的串行代码也会严重影响性能。
- 内存带宽:有效利用内存带宽是数据密集型系统的持久挑战,合理的内存重用、管理和缓存变得至关重要。
- 线程协调:线程同步的成本随着线程数量的增加呈超线性增长。
- NUMA效应:在多插槽系统中,本地和远程内存的延迟和带宽存在差异。
本文总结了我们在超高核心数服务器上对ClickHouse的优化工作。所有优化都已合并到主代码库中,并在全球范围内的ClickHouse部署中加速了查询性能。
硬件设置:我们的工作基于英特尔最新一代平台,包括2 x 80 vCPUs的Ice Lake(ICX)、2 x 128 vCPUs的Sapphire Rapids(SPR)、1 x 288 vCPUs的Sierra Forest(SRF)和2 x 240 vCPUs的Granite Rapids(GNR)。除了不支持SMT的SRF外,其他平台均启用了SMT(超线程)和高内存带宽配置。
软件设置:我们使用了perf、Intel VTune、管道可视化和其他自定义性能分析工具。
通过系统性地分析ClickHouse在超高核心数系统上的性能,我识别了五个具有高优化潜力的领域。每个领域都涉及不同的可扩展性方面,共同构成了解锁超高核心数系统全部潜力的综合方法。
我的优化之旅从最基础的挑战——锁争用开始。
瓶颈1:锁争用
根据队列理论,如果N个线程竞争同一个锁,等待时间将呈二次方增长(N^2)。例如,从8核增加到80核,锁等待时间将增加100倍。此外,互斥锁的缓存一致性流量随着核心数的增加呈线性增长,上下文切换的开销进一步加剧了问题。在这种情况下,每个互斥锁都可能成为可扩展性的障碍,看似无害的同步模式也可能导致整个系统性能急剧下降。
关键洞察是,解决锁争用不仅仅是移除锁,而是从根本上重新思考线程如何协调和共享状态。这需要多管齐下的方法:减少关键部分的持续时间,用更细粒度的同步原语替换独占锁(互斥锁),在某些情况下,完全消除共享状态。
在解决jemalloc页面错误(下文详述的优化)后,native_queued_spin_lock_slowpath函数成为了新的热点,占用了76%的CPU时间。该函数在2×240 vCPU系统上由QueryConditionCache::write调用。
查询条件缓存是什么?
ClickHouse的查询条件缓存存储了WHERE过滤器的结果,使数据库能够跳过不相关的数据。在每个SELECT查询中,多个线程根据不同的条件检查是否需要更新缓存条目:
- 过滤器条件的哈希值(作为缓存键)
- 读取标记范围
- 当前读取的部分是否具有最终标记
查询条件缓存是读密集型的,即读取操作远多于写入操作,但原始实现对所有操作都使用了独占锁。
减少读密集型工作负载中的关键路径
此优化展示了减少持有锁的时间的重要性,尤其是在读密集型代码中减少写锁的时间。
在单个查询中,240个线程的原始代码导致了完美风暴:
- 不必要的写锁:所有线程都获取了独占锁,即使它们只是读取缓存条目。
- 长关键部分:在独占锁内执行了昂贵的缓存条目更新。
- 冗余工作:多个线程可能多次更新相同的缓存条目。
我们的优化使用了带有原子操作的双重检查锁定来解决这些瓶颈:
- 代码首先通过原子读取(无锁)或在共享锁下检查是否需要更新(快速路径)。
- 接下来,代码在获取独占锁(慢速路径)后立即检查是否真的需要更新——在此期间,另一个线程可能已经执行了相同的更新。
实现
基于PR #80247,优化引入了一个快速路径,在获取昂贵的写锁之前检查是否需要更新。
性能影响
优化后的代码带来了显著的性能提升:
native_queued_spin_lock_slowpath的CPU周期从76%降至1%- ClickBench查询Q10和Q11的QPS分别提升了85%和89%
- 所有ClickBench查询的几何平均性能提升了8.1%
ClickHouse的查询分析器频繁创建和删除全局timer_id变量,导致查询分析期间的锁争用。
查询分析器计时器使用
ClickHouse的查询分析器使用POSIX计时器定期采样线程堆栈以进行性能分析。原始实现:
- 在分析期间频繁创建和删除timer_id
- 所有读取或写入计时器的操作都需要全局同步
使用需要锁保护的共享数据结构导致了显著的开销。
通过线程本地存储消除全局状态
我们通过线程本地存储消除了锁争用,移除了对共享状态的需求。现在,每个线程都有自己的timer_id。这避免了共享状态和线程同步的开销。更新计时器不再需要获取锁。
技术解决方案
性能影响
新实现具有以下优势:
- 消除了分析跟踪中的计时器相关锁争用热点
- 通过重用减少了计时器创建/删除的系统调用
- 使超高核心数服务器上的分析更具可扩展性
线程本地存储通过移除共享状态的需求消除了锁争用。如果线程维护自己的状态,全局同步变得不必要。
在超高核心数系统上进行内存优化与单线程内存管理有很大不同。内存分配器本身成为争用点,内存带宽被更多核心分摊,在小系统上运行良好的分配模式在规模上可能导致级联性能问题。因此,必须注意分配了多少内存以及如何使用内存。
这类优化涉及分配器的行为,减少内存带宽的压力,有时甚至完全重新思考算法以消除内存密集型操作。
此优化是由我们在超高核心数系统上观察到的某些聚合查询的高页面错误率和过高的常驻内存使用率所驱动的。
理解ClickHouse中的两级哈希表
ClickHouse中的聚合根据数据类型、数据分布和数据大小使用不同的哈希表。大型聚合状态保存在临时哈希表中。
- 第一级由256个静态桶组成,每个桶指向一个第二级哈希表。
- 第二级哈希表独立增长。
两级哈希表的内存重用
在聚合查询结束时,查询使用的所有哈希表都会被释放。特别是,256个子哈希表被释放,它们的内存被合并为更大的空闲内存块。
jemalloc(作为ClickHouse的内存分配器)默认情况下,只有小于请求大小64倍的内存块才能被重用。这个问题在jemalloc中非常微妙,但在超高核心数系统上至关重要。
基于jemalloc issue #2842,我们注意到jemalloc在两级哈希表的不规则大小分配中的内存重用存在根本问题:
- 扩展管理问题:当大块内存被释放时,jemalloc无法有效跟踪和重用这些内存扩展。
- 大小类碎片化:内存被困在不匹配未来分配模式的大小类中。
- 元数据开销:过多的元数据结构阻碍了内存的高效合并。
- 页面错误放大:新分配触发了页面错误,而不是重用现有的已提交页面。
我们识别出jemalloc的lg_extent_max_active_fit参数是根本原因——它对ClickHouse的分配模式过于严格。
我们向jemalloc PR #2842贡献了修复,但jemalloc在很长一段时间内没有新的稳定版本。幸运的是,我们可以在编译时通过jemalloc的配置参数解决这个问题。
基于ClickHouse PR #80245,修复涉及调整jemalloc的配置参数:
性能影响
优化显著提升了性能:
- ClickBench查询Q35的性能提升了96.1%
- 相同查询的内存使用(VmRSS,常驻内存)和页面错误分别减少了45.4%和71%
内存分配器的行为对超高核心数系统有重大影响。
ClickBench查询Q29是内存密集型的,瓶颈在于由sum(column + literal)形式的冗余计算导致的过多内存访问。
理解内存瓶颈
ClickBench查询Q29包含多个带有字面量的sum表达式:
原始查询执行:
- 加载列“ResolutionWidth”一次
- 计算表达式——90次,创建90个临时列(每个表达式一个)
- 求和值——对每个计算列执行90次单独的聚合操作
创建90个临时列并运行90次冗余聚合显然造成了巨大的内存压力。
前端查询优化以提高内存效率
此优化展示了更好的优化器规则如何通过消除冗余计算来减少内存压力。关键洞察是,许多分析查询包含可以代数简化的模式。
优化识别到sum(column + literal)可以重写为sum(column) + count(column) * literal。
性能影响
- ClickBench查询Q29在2×80 vCPU系统上加速了11.5倍
- 所有ClickBench查询的几何平均性能整体提升了5.3%
更智能的查询计划比优化执行本身更有效。避免工作比高效地完成工作更好。
快速聚合是任何分析数据库的核心承诺。从数据库的角度来看,在并行线程中聚合数据只是等式的一部分。同样重要的是并行合并本地结果。
ClickHouse的聚合操作符有两个阶段:在第一阶段,每个线程并行处理其部分数据,创建本地和部分结果。在第二阶段,所有部分结果必须合并。如果合并阶段没有正确并行化,它将成为瓶颈。更多的线程实际上可能通过创建更多的部分结果来合并而使问题恶化。
解决此问题需要仔细的算法设计、智能的数据结构选择以及对哈希表在不同负载模式下的行为的深入理解。目标是消除串行合并阶段,即使是最复杂的聚合查询也能实现线性扩展。
ClickBench查询Q5在核心数从80增加到112时表现出严重的性能下降。我们的管道分析揭示了哈希表转换中的串行处理。
理解ClickHouse中的哈希表
ClickHouse使用两种类型的哈希表进行哈希聚合:
- 单级哈希表:适用于较小数据集的扁平哈希表(更快)。
- 两级哈希表:具有256个桶的分层哈希表。两级哈希表更适合大型数据集。
数据库根据处理数据的大小选择正确的哈希表类型:一旦单级哈希表在聚合期间达到某个阈值,它就会自动转换为两级哈希表。合并不同类型哈希表的代码是串行的。
串行瓶颈
当合并来自不同线程的哈希表时:
- 单级哈希表以成对方式串行合并,例如ht1 / ht2 → result,然后result / ht3,等等。
- 两级哈希表逐个合并,但合并是跨桶并行化的。
在混合单/两级哈希表的情况下,单级哈希表必须首先转换为两级哈希表(这是一个串行过程)。一旦完成,生成的两级哈希表可以并行合并。
在Q5中,将线程数从80增加到112意味着每个线程处理的数据更少。在80个线程时,所有哈希表都是两级的。在112个线程时,聚合最终出现了混合情况:一些哈希表保持单级,而另一些变为两级。这导致了串行化——所有单级哈希表必须首先转换为两级哈希表,然后才能进行并行合并。
为了诊断问题,管道可视化是一个关键工具。明显的迹象是合并阶段的持续时间随着线程数的增加而增加——这与预期相反。
我们的优化并行化了转换阶段:现在并行转换所有单级哈希表,而不是逐个转换(串行)。由于每个哈希表可以独立转换,这消除了串行瓶颈。
性能影响
性能不仅对Q5有所提升——优化使得任何聚合密集型查询在超高核心数系统上都能实现线性扩展。
- ClickBench查询Q5在2×112 vCPU系统上提升了264%
- 24个查询实现了>5%的提升
- 整体几何平均性能提升了7.4%
优化表明,可扩展性不仅仅是使事物更加并行——它还需要消除随着并行性增加的串行部分。有时需要更深入地重构算法,而不仅仅是增加更多线程。
我们注意到,当所有哈希表都是单级时,性能也不理想。
将并行合并扩展到单级情况
基于PR #50748,此优化认识到并行合并的好处不仅限于混合哈希表。即使所有哈希表都是单级的,如果总数据量足够大,并行合并也可以提高性能。
挑战在于确定何时应该并行合并单级哈希表:
- 如果数据集太小,并行化会引入额外开销。
- 如果数据集太大,并行化可能不够有益。
基于PR #52973中的实现,优化将并行合并添加到所有单级情况:
性能影响
- 单级合并场景的性能提升了235%
- 通过系统测试确定了最佳阈值
- 在小数据集上没有回归
带有大哈希表的GROUP BY操作是串行合并的。
将并行化扩展到键控聚合
前两个优化(3.1和3.2)解决了无键合并——简单的哈希表操作,如COUNT(DISTINCT)。我们将相同的优化应用于键控合并,其中哈希表包含必须组合的键和聚合值,例如一般的GROUP BY语义。
性能影响
- ClickBench查询Q8提升了10.3%,Q9提升了7.6%
- 其他查询没有回归
- 合并阶段的CPU利用率提高
通过仔细处理取消和错误处理,并行合并可以扩展到复杂的聚合场景。
充分利用SIMD指令的潜力非常困难。编译器对向量化持保守态度,数据库工作负载通常具有复杂的控制流,抑制了自动向量化。
在数据库中有效使用SIMD指令需要超越传统的向量化思维。除了同时处理N个数据项而不是一个,还可以利用并行SIMD比较进行智能剪枝策略,从而减少总体工作量。这个想法对于字符串操作特别强大,这些操作在实践中频繁使用且计算成本高。
字符串搜索(例如普通子字符串搜索或LIKE模式搜索)是许多查询中的瓶颈,例如ClickBench查询Q20。
理解分析查询中的字符串搜索
ClickBench查询20在数百万个URL上评估LIKE模式,使得快速字符串搜索至关重要。
通过双字符过滤减少误报
PR #46289基于这样的洞察:SIMD指令可以以超越蛮力并行化的智能方式使用。原始代码已经利用了SIMD指令,但它只考虑了搜索模式的第一个字符,导致了昂贵的误报。我们重写了代码以检查第二个字符。这显著提高了选择性,同时仅增加了可忽略的新SIMD操作。
性能影响
双字符SIMD过滤显著提升了性能:
- ClickBench查询Q20加速了35%
- 执行子字符串匹配的其他查询整体提升了约10%
- 所有查询的几何平均性能提升了4.1%
性能提升是由于更少的误报、更好的缓存局部性和更高效的分支预测。
双字符SIMD过滤表明,有效的SIMD优化不仅仅是每指令处理更多数据——它还利用SIMD的并行比较能力来提高算法效率。双字符方法展示了少量额外的SIMD操作如何在某些情况下带来巨大的性能提升。
当多个线程访问同一缓存行中的变量时,会发生伪共享。CPU的缓存一致性协议在缓存行粒度上工作,这意味着任何缓存行修改——包括两个不同变量的修改——都被视为需要核心之间昂贵同步的冲突。在2 x 240 vCPU系统上,伪共享可能将简单的计数器递增操作转变为系统范围的性能灾难。
消除伪共享需要了解CPU缓存一致性在硬件层面的实现。仅仅优化算法是不够的——为了避免伪共享,还必须优化内存布局,以确保频繁访问的数据结构不会通过缓存行冲突意外干扰彼此。这涉及例如战略性的数据布局以及对齐和填充的使用。
ClickBench查询Q3在2×240 vCPU系统上显示36.6%的CPU周期花费在ProfileEvents::increment上。性能分析揭示了严重的缓存行争用。
大规模ProfileEvents计数器
Profile事件计数器指的是ClickHouse的内部事件系统——Profile事件跟踪所有内部操作,从详细的查询执行步骤到内存分配。在典型的分析查询中,这些计数器在所有线程中被递增数百万次。原始实现将多个计数器组织在同一内存区域中,没有考虑缓存行边界。
这带来了三个挑战:
- 缓存行物理:现代英特尔处理器使用64字节的缓存行。当缓存行中的任何字节被修改时,其他核心的缓存中的整个行必须失效。
- 伪共享放大:在240个线程中,每个计数器更新都会触发跨数十个核心的缓存行失效。本应独立操作通过缓存一致性协议串行化。
- 指数级退化:随着核心数的增加,同时访问同一缓存行的概率呈指数增长,加剧了缓存未命中的影响。
使用perf,我发现ProfileEvents::increment生成了大量的缓存一致性流量。确凿的证据是缓存行利用率报告,显示八个不同的计数器打包在单个缓存行中。我们还为Linux的perf c2c工具添加了新功能,并与社区合作,帮助开发人员更容易地识别此类伪共享问题。
适当的缓存行对齐确保每个计数器获得自己的64字节缓存行。这将伪共享(坏)转变为真共享(可管理)。当线程更新其计数器时,现在只会影响单个缓存行。
基于我们在PR #82697中的实现,修复改进了Profile事件计数器的缓存行对齐:
性能影响
此优化模式适用于任何频繁更新的共享
评论总结
对文章内容的赞赏
- 评论1(epistasis)认为这篇文章将成为经典,特别是关于高核数系统内存优化的部分,指出内存分配器和带宽分配在高核数系统中的挑战。
- 引用:“Memory optimization on ultra-high core count systems differs a lot from single-threaded memory management.”
- 引用:“It is crucial to be mindful of how much memory is allocated and how memory is used.”
- 评论2(pixelpoet)赞赏文章的低级优化写作风格,并对其C++编码规范表示认同。
- 引用:“This post looks like excellent low-level optimisation writing.”
- 引用:“my heart absolutely sings at their use of my preferred C++ coding convention.”
- 评论1(epistasis)认为这篇文章将成为经典,特别是关于高核数系统内存优化的部分,指出内存分配器和带宽分配在高核数系统中的挑战。
对技术细节的讨论
- 评论3(bee_rider)对288核的处理器表示惊讶,并讨论了其是否支持AVX512指令集,甚至开玩笑地建议将其作为GPU出售。
- 引用:“288 cores is an absurd number of cores.”
- 引用:“Wonder if they should put that thing on a card and sell it as a GPU.”
- 评论6(vlovich123)对使用旧版jemalloc表示惊讶,建议使用更新的内存分配器如TCMalloc或mimalloc。
- 引用:“I'm generally surprised they're still using the unmaintained old version of jemalloc.”
- 引用:“TCMalloc or mimalloc which have significantly better techniques.”
- 评论3(bee_rider)对288核的处理器表示惊讶,并讨论了其是否支持AVX512指令集,甚至开玩笑地建议将其作为GPU出售。
对ClickHouse的正面评价
- 评论4(jiehong)赞赏文章的工作,但指出文章作者身份有些混淆,同时比较了DuckDB和ClickHouse的性能。
- 引用:“Great work!”
- 引用:“clickhouse seems more focused on large scale performance.”
- 评论5(secondcoming)和评论7(lordnacho)对ClickHouse表示高度认可,认为其处理大规模数据的能力出色。
- 引用:“Those ClickHouse people get to work on some cool stuff.”
- 引用:“Clickhouse is excellent btw. The double compression does wonders.”
- 评论4(jiehong)赞赏文章的工作,但指出文章作者身份有些混淆,同时比较了DuckDB和ClickHouse的性能。
总结:评论普遍对文章的技术内容和ClickHouse的性能表示赞赏,同时对高核数系统的内存优化、编码规范和技术细节展开了讨论。