了解数据库如何利用现代 CPU 的功能来提高性能。
译自 Optimize Database Performance by Capitalizing on the CPU,作者 Pavel Emelyanov。
数据库的内部架构对其延迟和吞吐量有重大影响。作为极其复杂的软件,数据库 并非孤立存在,而是与其环境交互,包括操作系统和硬件。
虽然构建大型 TB 级到 PB 级系统是一回事,但确保它们以最佳效率运行则是另一回事。事实上,通常不止“一件事”。大型分布式系统的性能优化通常是一个多变量问题,它结合了底层硬件、网络、操作系统调优或虚拟化层和应用程序架构方面的因素。
如此复杂的问题需要从多个角度进行探索。让我们看看数据库如何通过利用现代硬件CPU来优化性能。
当编程书籍说 CPU 可以运行进程或线程时,“运行”意味着有一些简单的顺序指令执行。但随后有一个脚注解释说,对于多个线程,您可能需要考虑进行一些同步。
实际上,CPU 内核内部执行的方式完全不同,而且更加复杂。如果我们没有书籍中提到的那些抽象,那么对这些机器进行编程将非常困难,但它们在某种程度上是谎言——如何有效地利用 CPU 能力仍然非常重要。
单个 CPU 内核的速度并没有提高。它们的时钟速度很久以前就达到了性能平台期。现在,CPU 性能的持续增长是水平的:通过增加处理单元的数量。反过来,增加内核数量意味着性能现在取决于跨多个内核的协调(而不是单个内核的吞吐量)。
在现代硬件上,标准工作负载的性能更多地取决于跨内核的锁定和协调,而不是单个内核的性能。软件架构师面临着两种不利的选择:
- 粗粒度锁定,其中应用程序线程争夺对数据的控制权并等待,而不是产生有用的工作。
- 细粒度锁定,除了难以编程和调试之外,即使没有争用,由于锁定原语,也会产生很大的开销。
考虑一个 SSD 驱动器。与现代 NVMe 设备上的 SSD 通信所需的典型时间相当长——大约 20 微秒。这足以让 CPU 执行数万条指令。开发人员应该将其视为网络设备,但通常不会以这种方式进行编程。相反,他们经常使用同步 API,该 API 会产生一个可以被阻塞的线程。
查看英特尔 Xeon 处理器的逻辑布局图,很明显这是一个网络设备。
内核通过本质上是一个网络——双环互连架构——连接在一起。有两个这样的环,它们是双向的。为什么开发人员应该为此使用同步 API?由于跨内核共享信息需要代价高昂的锁定,因此不共享模型非常值得考虑。在这种模型中,所有请求都将分片到单个内核上,每个内核运行一个应用程序线程,并且通信依赖于显式消息传递,而不是线程之间的共享内存。这种设计避免了缓慢、不可扩展的锁定原语和缓存跳跃。
在现代处理器中,跨内核共享资源必须显式处理。例如,当两个请求属于同一个会话,并且两个 CPU 分别获得一个依赖于同一个会话状态的请求时,一个 CPU 必须显式地将请求转发到另一个 CPU。任何一个 CPU 都可以处理任何一个响应。
理想情况下,您的数据库提供了限制跨内核通信需求的功能,但当通信不可避免时,它提供了高性能的非阻塞通信原语,以防止性能下降。
在多个核心之间协调工作的解决方案有很多。有些解决方案非常适合程序员,并能够开发出与在单核上运行时完全相同的软件。例如,经典的 Unix 进程模型旨在将每个进程完全隔离,并依赖内核代码为每个进程维护一个独立的虚拟内存空间。不幸的是,这会增加操作系统级别的开销。
有一种模型被称为“期货和承诺”。期货是一种数据结构,它代表着一些尚未确定的结果。承诺是该结果的提供者。可以将承诺/期货对视为一个最大长度为一个项目的先进先出 (FIFO) 队列,该队列只能使用一次。承诺是队列的生产端,而期货是消费端。与 FIFO 一样,期货和承诺用于解耦数据生产者和数据消费者。
但是,优化期货和承诺的实现需要考虑几个因素。虽然标准实现针对可能阻塞并需要很长时间才能完成的粗粒度任务,但优化的期货和承诺用于管理细粒度、非阻塞任务。为了有效地满足此要求,它们应该:
- 不需要锁定
- 不分配内存
- 支持延续
期货-承诺设计消除了操作系统维护单个线程相关的成本,并允许几乎完全利用 CPU。另一方面,它需要用户空间 CPU 调度,并且很可能限制开发人员使用自愿抢占式调度。后者反过来容易在流行的生产者-消费者编程模板中产生虚假阻塞。要了解更多信息,请观看 探索数据流中的虚假交通阻塞 或阅读 相关文章。
将期货-承诺设计应用于数据库内部具有明显的优势。首先,数据库工作负载可以自然地是 CPU 密集型的。这通常是内存数据库引擎的情况,聚合的评估也涉及相当密集的 CPU 工作。即使对于巨大的磁盘数据集,当查询时间通常受 I/O 影响时,也应该考虑 CPU。无论工作负载是 CPU 密集型还是存储密集型,解析查询都是一项 CPU 密集型任务,收集、转换和将数据发送回用户也需要仔细利用 CPU。
最后但并非最不重要的一点:处理数据总是涉及许多高级操作和低级指令。以最佳方式维护它们需要良好的低级编程范式,而期货-承诺是最佳选择之一。但是,大型指令集需要更多关注;这将我们引向了执行阶段。
让我们深入了解 CPU 微架构,因为数据库引擎 CPU 通常需要处理数百万甚至数十亿条指令,帮助这些可怜的家伙处理这些指令至关重要。
从 自上而下分析 的角度来看,现代 x86 CPU 的微架构以非常简化的方式由四个主要组件组成:前端、后端、分支预测和退休。
处理器的前端负责获取和解码将要执行的指令。当存在延迟问题或带宽不足时,它可能会成为瓶颈。前者可能是由指令缓存未命中引起的。后者发生在指令解码器跟不上时。在后一种情况下,解决方案可能是尝试使 热路径(或至少其重要部分)适合解码的微操作 (µop) 缓存 (DSB) 或被循环检测器 (LSD) 识别。
自上而下分析归类为“错误预测”的流水线槽位不会停顿,而是浪费了。当分支预测错误,并且 CPU 的其余部分执行最终无法提交的 µop 时,就会发生这种情况。分支预测器通常被认为是前端的一部分。但是,它的问题会影响整个流水线,而不仅仅是导致后端无法从指令获取和解码中获得足够的供应。
后端接收解码的 µop 并执行它们。停顿可能是由于执行端口繁忙或缓存未命中造成的。在更低级别,流水线槽位可能是核心绑定的,这可能是由于数据依赖性或可用执行单元数量不足造成的。由内存引起的停顿可能是由于不同级别的数据缓存、外部内存延迟或带宽的缓存未命中造成的。
最后,一些流水线槽位被归类为“退休”。它们是幸运儿,能够在没有任何问题的情况下执行并提交其 µop。当 100% 的流水线槽位能够在没有停顿的情况下退休时,程序就达到了该 CPU 模型的每周期最大指令数。虽然这是非常理想的,但这并不意味着没有改进的余地。相反,这意味着 CPU 已经充分利用,提高性能的唯一方法是减少指令数量。
CPU 的架构方式对数据库设计有直接的影响。单个请求可能涉及大量逻辑和相对较少的数据,这是一种对 CPU 造成很大压力的场景。这种工作负载将完全由前端主导——尤其是指令缓存未命中。如果你仔细想想,这并不奇怪。每个请求经过的流水线相当长。例如,写入请求可能需要经过传输协议逻辑、查询解析代码、缓存层查找或应用于内存结构,在那里它将等待被刷新到磁盘。
解决这个问题最明显的方法是尝试减少热路径中的逻辑量。不幸的是,这种方法并没有提供巨大的性能提升潜力。减少执行特定活动所需的指令数量是一种流行的优化实践,但开发人员无法无限地缩短任何代码。在某个时刻,代码会“冻结”——从字面上说。即使比较两个字符串并返回结果,也需要最少量的指令。不可能用单个指令执行此操作。
处理指令缓存问题的更高层次方法称为分阶段事件驱动架构 (SEDA)。它将请求处理流水线拆分为一个阶段图,从而将逻辑与事件和线程调度分离。这往往比以前的方法产生更大的性能改进。
作为数据库用户,探索帮助你的数据库从现代基础设施中榨取更多性能的数据库工程决策会很有趣。
但这并不全是关于 CPU。数据库如何与操作系统以及内存、存储和网络交互也很重要,但这些超出了本文的范围。