Scylla 提高 CPU 密集型工作负载性能的方法 (2017)

2021-08-08 16:46:39

像 Scylla 这样的数据库可能会受到网络、磁盘 I/O 或处理器的限制。哪一个通常是动态的,取决于硬件配置和工作负载。解决这个问题的唯一方法是尝试实现良好的吞吐量和低延迟,而不管瓶颈是什么。在每种情况下都可以做很多事情,从算法的高级更改到非常低级的调整。在这篇文章中,我将仔细研究最近对 Scylla 所做的更改,这些更改提高了 CPU 密集型工作负载的性能。在调查 CPU 是瓶颈的情况时,火焰图确实是无价之宝。他们可以快速地将您指向比感觉应该慢得多的代码部分,并且可以根据这些信息采取适当的步骤来优化它。不幸的是,知道在特定功能上花费了多少处理器时间可能还不够。当 CPU 忙时,可能是因为它实际上在做一些工作或停止等待内存,正如 Brendan Gregg 在这篇文章中解释得很好。可用于快速确定处理器停顿是否是问题的一个有用指标是每个周期完成的指令数 (IPC)。如果它很低,小于 1,则很可能应用程序实际上受到内存限制。尽管 IPC 在将性能分析指向正确的方向方面非常有用,但肯定不足以完全理解问题。幸运的是,现代处理器具有性能监控单元 (PMU),可以深入了解微体系结构级别实际发生的情况。除非使用适当的方法,否则可用信息的数量可能会非常庞大​​。用于收集和分析 PMU 提供的数据的一个非常方便的工具是 toplev。它采用自上而下的分析,它使用处理器架构的分层表示来跟踪流水线插槽。这使得像 toplev 这样的工具能够显示被停止、被错误推测浪费或被成功退出指令使用的插槽的百分比。下图以非常简化的方式从自顶向下分析的角度显示了现代 x86 CPU 的微体系结构。缺少很多东西,但对于这篇文章的目的来说应该足够了。在顶层,有四大类前端绑定、后端绑定、不良投机和退休。前两个表示存在停顿,Bad Speculation 表示由于分支预测错误而进行了不必要的工作的流水线槽,而归类为 Retiring 的槽能够成功地退出正在执行的 µop。处理器的前端负责获取和解码将要执行的指令。当存在延迟问题或带宽不足时,它可能会成为瓶颈。例如,前者可能由指令高速缓存未命中引起。后者发生在指令解码器无法跟上时,解决方案可能是尝试使热路径或至少其中的重要部分适合已解码的微操作缓存 (DSB) 或可被循环检测器 (LSD) 识别)。

被自上而下分析归类为不良推测的管道槽并没有停滞,而是浪费了。当分支被错误预测并且 CPU 的其余部分执行最终无法提交的 µop 时,就会发生这种情况。虽然分支预测器通常被认为是前端的一部分,但它的问题可能会以不同的方式影响整个流水线,而不仅仅是导致后端因指令提取和解码而供应不足。后端接收解码的微操作并执行它们。由于执行端口繁忙或缓存未命中,可能会发生停顿。在较低级别,由于数据依赖性或可用执行单元数量不足,管道插槽可能会受到核心限制。由内存引起的停顿可能是由不同级别的数据缓存、外部内存延迟或带宽的缓存未命中引起的。最后,还有一些管道插槽被归类为退休。他们是幸运的,能够毫无问题地执行和提交他们的 µop。当 100% 的流水线插槽能够在没有停顿的情况下退出时,程序就达到了该 CPU 模型的每个周期的最大指令数。然而,虽然这是非常可取的,但这并不意味着没有什么可以改进的。它只是意味着CPU被充分利用,提高性能的唯一方法是减少指令数量。让我们看看 toplev 对 Scylla 1.7 有什么看法。除非另有说明,本文中显示的所有测试结果都是具有 75,000,000 个单行分区的读取工作负载。整个人口都适合内存,没有缓存未命中。有一个带有 4 核(处理器是八核 Haswell)和 64GB 内存的 Scylla 服务器。加载程序是一个单 4 核(8 个逻辑 CPU)机器,运行 4 个 scylla-bench 进程,每个进程都固定在自己的核心上。 1Gbit 网络远未饱和,因为分区非常小。此设置的目的是确保 Scylla 实际上受 CPU 限制。 PMU 试图在这里传达的信息非常清楚。 Scylla 完全由前端主导,尤其是指令缓存未命中。不过,如果我们考虑一下这一点,它应该不会真的很令人惊讶。每个客户端请求经过的管道都很长。例如,写请求可能需要经过传输协议逻辑、CQL 层、协调器代码,然后变成 commitlog 写,然后应用到 memtable。读取的情况也好不到哪里去,这意味着单个 Scylla 请求通常涉及很多逻辑和相对较少的数据,这是一个对 CPU 前端压力很大的场景。一旦问题被诊断出来,就该想出解决方案了。最明显的一种是尝试减少热路径中的逻辑量。不幸的是,这不是具有显着性能改进潜力的巨大潜力。 CQL 协议支持准备好的语句,它允许拆分请求处理的准备和执行部分,以便尽可能多的工作可以从热路径中推出。 Scylla 已经完成了所有这些工作,因此尽管肯定有一些可以改进的地方,但它们不太可能产生很大的不同。另一件要考虑的事情是模板和内联函数。 Scylla 和 Seastar 都是那些带来过度膨胀二进制文件风险的重度用户。然而,这非常棘手,因为在某些情况下,如果内联函数可以进行一些原本不可能进行的优化,那么内联实际上可以减少热路径中的代码量。我们看到显着差异的一个特殊地方是序列化代码,它编写了几个固定大小的对象。因为如果序列化函数是内联的,则要序列化的对象的大小在编译时是已知的,所以编译器能够将它们恒定折叠为几条指令,从而显着提高整体性能。关键是是否内联更多的是一个微调的问题,它的缺点是使任何优化变得脆弱,并且可能不太可能带来巨大的改进。

GCC 文档的热心读者会注意到有一个优化标志“-freorder-blocks-and-partition”,它承诺将函数的热部分和冷部分分开,并将它们放在不同的位置。这听起来非常令人鼓舞,但不幸的是,该标志仅适用于 PGO(配置文件引导优化),并且与 C++ 异常实现所需的堆栈展开支持相冲突。最重要的是,遗憾的是,这个标志对于 Scylla 是不可用的。然而,有一种更高级的方法来处理指令缓存问题。 SEDA(分阶段事件驱动架构)是一种服务器架构,它将请求处理管道拆分为阶段图。每个阶段代表服务器逻辑的一部分,由一个队列和一个线程池组成。当请求在阶段之间移动时,这种架构的主要问题是上下文切换和处理器间通信,但这可以通过批量处理来部分缓解。这对指令缓存非常友好,因为每个线程总是在上下文切换发生之前多次执行相同的操作,因此可以预期代码局部性非常好。不幸的是,当主要关注实现高吞吐量时,这种架构是可以接受的,但它不太适合低延迟应用程序。 Scylla 使用 Thread Per Core 架构,避免上下文切换并最小化处理器间通信,使其比 SEDA 更适合低延迟任务。但是,在请求处理管道中插入队列的能力将允许批处理某些操作,这可能非常有用,前提是要小心完成以免影响延迟。这正是我们实施的。处理前端延迟问题的想法是批量处理某些函数调用,以便第一次调用预热指令缓存,随后的调用可以以最少的 icache 未命中次数执行。幸运的是,Seastar 基于未来、承诺、延续模型这一事实使得在现有代码中引入执行阶段变得非常容易。假设我们有一些带有 process_request() 函数的服务器代码,它调用包含实际逻辑的内部 do_process_request(): 执行阶段被实现为包装器,它产生一个函数对象,其签名与原始函数几乎相同。现在,如果我们想批量调用 do_process_request() 我们可以这样写: future<response> do_process_request(request r); thread_local auto processing_stage = seastar::make_execution_stage("processing-stage", do_process_request);未来<响应> process_request(request r) { return processing_stage(std::move(r)); }

对代码的更改相当少,但不需要更多。不是调用 do_process_request() 而是调用 processing_stage 对象,该对象可以自由决定何时调用原始函数,其结果将被转发到执行阶段返回的未来。下面发生的事情是,对执行阶段的每次调用都会将函数参数推送到队列中,并返回一个将在函数调用实际发生时解析的未来。在某个时候,执行阶段将被刷新,一个新任务将被安排执行所有排队的函数调用。这是必须小心的时候,因为最后一句话引入了两个潜在的延迟问题。首先,虽然大批量是有益的,但函数调用不能在队列中停留太久。解决方案相当简单,Seastar 会定期轮询新事件,并努力确保在称为任务配额的指定时间间隔内这种情况不会少于一次。执行阶段将自己注册为轮询器并在轮询时刷新队列。如果服务器负载很重,每个任务配额刷新阶段的频率将不少于一次,如果服务器负载较轻,则每次没有其他任务要运行时都会发生刷新。在这种情况下,批次可能非常小,但保持低延迟更为重要。其次,处理整批函数调用可能需要很多时间。这可能会破坏其他任务的延迟,因此执行阶段需要尊重任务配额本身,并在其时间配额用完时中断执行排队的函数调用。同样,这可能会限制批次的大小和执行阶段的整体效率,但这是延迟和吞吐量之间的权衡,正确的解决方案介于中间。通过在 Seastar 级别实施的执行阶段,很容易将它们介绍给 Scylla。在请求处理从一个子系统移动到另一个子系统的地方或多或少地添加了它们。传输协议——CQL 本地协议是异步的,因为在单个连接的范围内可能有许多未完成的请求,在实际请求处理逻辑之前添加了一个执行阶段。 CQL 层——在 SELECT 、 BATCH 、 UPDATE 或 INSERT 语句开始执行之前引入了一个阶段。

Coordinator——所有写请求都排队,然后由协调器逻辑批量处理。数据库写入 - 在数据库写入实现之前有一个执行阶段,用于收集来自本地和远程协调器的传入请求。数据库读取——Scylla 读取是数据查询或变异查询。前者更轻量级用于单分区读取,而后者用于范围查询和作为数据查询的回退。两者现在都有自己的执行阶段。是时候展示一些数字了。负载再次读取而没有缓存未命中,服务器受 CPU 限制。我们先来看看吞吐量。这是一项重大改进,但值得确保延迟不会成为函数调用批处理的牺牲品。为了测试这一点,加载器被限制为每秒 28,000 次操作。下图比较了带有和不带有执行阶段补丁的 Scylla 1.7 的 99% 请求延迟。延迟实际上稍微好一点。这可以归因于这样一个事实,即适度批处理的惩罚低于更好的指令缓存局部性带来的性能提升。 15 分钟运行的摘要显示了请求延迟的总体减少。正如 perf stat 所报告的那样,吞吐量的增加伴随着 IPC(每周期指令数)的增加,从大约 0.81 增加到 1.31。指令缓存未命中率从 5.39% 降低到 3.05%。 Toplev 结果也发生了变化:

'显然,前端延迟问题仍然存在,但它们不像过去那样占据主导地位。当指令由 MITE(微指令翻译引擎)而不是解码的 icache 或循环检测器发出时,前端的带宽也限制了 Scylla 的性能。还有 L1 缓存未命中,这使后端成为比以前更重要的瓶颈。执行阶段的引入并没有消除指令缓存未命中,以至于 toplev 没有注意到它们,但在这一点上,简单地添加更多阶段不一定是最好的解决方案。每个阶段都可能会损害延迟,因此必须明智而谨慎地添加它们。影响阶段有效性的重要因素是我们实际可以获得的批次大小。引入了适当的指标来监控这一点。对于每个分片上的读取测试数据查询阶段,每秒处理大约 58,000 个函数调用并每秒调度 1,000 个批次,这使得平均批次大小为 58。这当然不错,但如果 Scylla未满载或网络或磁盘 I/O 是瓶颈,当批次较小时,阶段成为开销而不是改进。人们也不能忘记批处理是延迟的敌人,如果它过于激进,延迟惩罚可能会超过更好的指令缓存局部性的好处。因此,尽管尝试使批处理更具侵略性很诱人,但这不一定是正确的方向。可能有益的是对管道中引入实际队列的位置进行更精细的调整。正如之前提到的,在 Scylla 子系统的概念边界处或多或少地添加了执行阶段,这并不一定意味着这些是最佳位置。然而,在这一点上,它越来越成为一个微调的问题,虽然它仍然可以让我们从 Scylla 中榨取更多的性能,但当代码更改时它可能非常脆弱,并且可能更值得关注其他瓶颈。受 SEDA 启发的函数调用批处理为 CPU 绑定负载带来了重大的性能改进,并有助于将瓶颈转移到处理器微架构的其他部分。更改本身并不具有侵入性,也不复杂,但在指令缓存未命中是限制因素的情况下,它显着增加了吞吐量。使用适当的方法来分析 PMU 提供的信息,找出问题所在并选择最具成本效益的解决方案相对容易。提高性能还有很多事情可以做,但是这必须一步一步来,每次处理当前最重要的瓶颈,在这种情况下,减少其影响的目标已经明确实现。