首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

一行代码,为何使 24 核服务器比笔记本还慢

作者 | pkolaczk     

译者 | 明明如月  责编 | 夏萌

出品 | CSDN(ID:CSDNnews)

我最近一直在改进一款 Cassandra 基准测试工具  Latte ,这可能是你能找到的 CPU 使用和内存使用都最高效的 Cassandra 基准测试工具。设计思路非常简单:编写一小部分代码生成数据,并且执行一系列异步的 CQL语句向 Cassandra 发起请求。Latte 在循环中调用这段代码,并记录每次迭代花费的时间。最后,进行统计分析,并通过各种形式展示结果。

基准测试非常适合并行化。只要被测试的代码是无状态的,就很容易使用多个线程调用。我已经在《Benchmarking Apache Cassandra with Rust》和《Scalable Benchmarking with Rust Streams》中讨论过如何在 Rust 中实现此功能。

然而,当我写这些早期的博客文章时,Latte 几乎不支持定义工作负载,或者说它的能力非常有限。它只内置两个预设的工作负载,一个用于读取数据,另一个用于写入数据。你只能调整一些参数,比如列的数量和大小,没有什么高级的特性。它不支持二级索引,也无法自定义过滤条件。对于 CQL(Cassandra Query Language)文本的控制也受到限制。总而言之,它几乎没有任何过人之处。因此,在那个时候,Latte 更像是一个用于验证概念的工具,而不是一个真正可用于实际工作的通用工具。当然,你可以 fork Latte 的源代码,并使用 Rust 编写新的工作负载,然后重新编译。但谁想浪费时间去学习一个小众基准测试工具的内部实现呢?

Rune 脚本

去年,为了能够测量 Cassandra 使用存储索引的性能,我决定将 Latte 与一个脚本引擎进行集成,这个引擎可以让我轻松地定义工作负载,而无需重新编译整个程序。在尝试将 CQL 语句嵌入 TOML 配置文件(效果非常不理想)后,我也尝试过在 Rust 中嵌入 Lua (在 C 语言中可能很好用,但在与 Rust 配合使用时,并不如我预期的那样顺畅,尽管勉强能用)。最终,我选择了一个类似于 sysbench 的设计,但使用了嵌入式的 Rune 解释器代替 Lua。

说服我采用 Rune 的主要优势是和 Rust 无缝集成以及支持异步代码。由于支持异步,用户可以直接在工作负载脚本中执行 CQL 语句,利用 Cassandra 驱动程序的异步性。此外,Rune 团队极其乐于助人,短时间内帮我扫清了所有障碍。

以下是一个完整的工作负载示例,用于测量通过随机键选择行时的性能:

如果你想进一步了解如何编写该脚本可以参考:README。

对基准测试程序进行基准测试

尽管脚本尚未编译为本机代码,但速度已可接受,而且由于它们通常包含的代码量有限,所以在性能分析的顶部并不会显示这些脚本。我通过实证发现,Rust-Rune FFI 的开销低于由 mlua 提供的Rust-Lua,这可能是由于mlua使用的安全检查。

一开始, 为了评估基准测试循环的性能,我创建了一个空的脚本:

尽管函数体为空, 但基准测试程序仍需要做一些工作来真正运行它:

使用buffer_unordered调度 N 个并行的异步调用

为 Rune VM 设置新的本地状态(例如,栈)

从 Rust 一侧传入参数调用 Rune 函数

衡量每一个返回的future完成所花费的时间

收集日志,更新 HDR 直方图并计算其他统计数据

使用 Tokio 线程调度器在 M 个线程上运行代码

我老旧的 4 核 Intel Xeon E3-1505M v6锁定在3GHz上,结果看起来还不错:

因为有 4 个核心,所以直到 4 个线程,吞吐量随着线程数的增加线性增长。然后,由于超线程技术使每个核心中可以再挤出一点性能,所以在 8 个线程时,吞吐量略有增加。显然,在 8 个线程之后,性能没有任何提升,因为此时所有的 CPU 资源都已经饱和。

我对获取的绝对数值感到满意。几百万个空调用在笔记本上每秒听起来像基准测试循环足够轻量,不会在真实测量中造成重大开销。同一笔记本上,如果请求足够简单且所有数据都在内存中,本地 Cassandra 服务器在全负载情况下每秒只能做大约 2 万个请求。当我在函数体中添加了一些实际的数据生成代码,但没有对数据库进行调用时,一如预期性能变慢,但不超过 2 倍,仍在 "百万 OPS"范围。

我本可以在这里停下来,宣布胜利。然而,我很好奇,如果在一台拥有更多核心的大型服务器上运行,它能跑多快。

在 24核上运行空循环

一台配备两个 Intel Xeon CPU E5-2650L v3 处理器的服务器,每个处理器有 12 个运行在 1.8GHz 的内核,显然应该比一台旧的 4 核笔记本电脑快得多,对吧?可能单线程会慢一些,因为 CPU 主频更低(3 GHz vs 1.8 GHz),但是它应该可以通过更多的核心来弥补这一点。

用数字说话:

你肯定也发现了这里不太对劲。两个只是线程比一个线程好一些而已,随着线程的增加吞吐量增加有限,甚至开始降低。我无法获得比每秒约 200 万次调用更高的吞吐量,这比我在笔记本上得到的吞吐量差了近 4 倍。要么这台服务器有问题,要么我的程序有严重的可扩展性问题。

查问题

当你遇到性能问题时,最常见的调查方法是在分析器下运行代码。在 Rust 中,使用cargo flamegraph生成火焰图非常容易。让我们比较在 1 个线程和 12 个线程下运行基准测试时收集的火焰图:

我原本期望找到一个瓶颈,例如竞争激烈的互斥锁或类似的东西,但令我惊讶的是,我没有发现明显的问题。甚至连一个瓶颈都没有!Rune的VM::run代码似乎占用了大约 1/3 的时间,但剩下的时间主要花在了轮询 futures上,最有可能的罪魁祸首可能已经被内联了,从而在分析中消失。

无论如何,由于VM::run和通往 Rune 的路径rune::shared::assert_send::AssertSend,我决定禁用调用 Rune 函数的代码,并且我只是在一个循环中运行一个空的 future,重新进行了实验,尽管仍然启用了计时和统计代码:

在 48 个线程上,每秒超过 1 亿次调用的扩展表现良好!所以问题一定出现在Program::async_call函数下面的某个地方:

async_call函数做了几件事:

它准备了一个新的 Rune VM - 这应当是一个非常轻量级的操作,基本上是准备一个新的堆栈;VM 并没有在调用或线程之间共享,所以它们可以完全独立地运行

它通过传入标识符和参数来调用函数

最后,它接收结果并转换一些错误;我们可以安全地假定在一个空的基准测试中,这是空操作 (no-op)

我的下一个想法是只移除 send_execute和 async_complete调用,只留下 VM 的准备。所以我想对这行代码进行基准测试:

代码看起来相当无辜。这里没有锁,没有互斥锁,没有系统调用,也没有共享的可变数据。有一些只读的结构 context和 unit通过 Arc共享,但只读共享应该不会有问题。

VM::new也很简单:

然而,无论代码看起来多么无辜,我都喜欢对我的假设进行双重检查。我使用不同数量的线程运行了那段代码,尽管现在比以前更快了,但它依然没有任何扩展性 - 它达到了大约每秒 400 万次调用的吞吐量上限!

问题

虽然从上述代码中看不出有任何可变的数据共享,但实际上有一些稍微隐蔽的东西被共享和修改了:即 Arc引用计数器本身。那些计数器是所有调用共享的,它们来自多线程,正是它们造成了阻塞。

一些人会说,在多线程下原子的增加或减少共享的原子计数器不应该有问题,因为这些是"无锁"的操作。它们甚至可以翻译为单条汇编指令(如 lock xadd)! 如果某事物是一个单条汇编指令,它不是很慢吗?不幸的是这个推理有问题。

问题的根源其实不在于计算本身,而在于维护共享状态的代价。

读取或写入数据需要的时间主要受 CPU 核心和需要访问数据的远近影响。根据 这个网站,Intel Haswell Xeon CPUs 的标准延迟如下:

L1缓存:4个周期

L2缓存:12个周期

L3缓存:43个周期

RAM:62个周期 + 100 ns

L1 和 L2 缓存通常属于一个核心(L2 可能由两个核心共享)。L3 缓存由一个 CPU 的所有核心共享。主板上不同处理器的 L3 缓存之间还有直接的互连,用于管理 L3 缓存的一致性,所以 L3 在逻辑上是被所有处理器共享的。

只要你不更新缓存行并且只从多个线程中读取该行,多个核心会加载该行并标记为共享。频繁访问这样的数据可能来自 L1 缓存, 非常快。所以只读共享数据完全没问题,并具有很好的扩展性。即使只使用原子操作也足够快。

然而,一旦我们对共享缓存行进行更新,事情就开始变得复杂。x86-amd64 架构有一致性的数据缓存。这基本上意味着,你在一个核心上写入的内容,你可以在另一个核心上读回。多个核心存储有冲突数据的缓存行是不可能的。一旦一个线程决定更新一个共享的缓存行,那么在所有其他核心上的该行就会失效,因此那些核心上的后续加载将不得不从至少L3中获取数据。这显然要慢得多,而且如果主板上有多个处理器则更慢。

我们的引用计数器是原子的,这让事情变得更加复杂。尽管使用原子指令常常被称为“无锁编程”,但这有点误导性——实际上,原子操作需要在硬件级别进行一些锁定。只要没有阻塞这个锁非常细粒度且廉价,但与锁定一样, 如果很多事物同时争夺同一个锁,性能就会下降。如果需要争夺同一个锁的不仅仅是相邻的单个核心,而是涉及到整个CPU,通信和同步的开销更大,而且可能存在更多的竞争条件,情况会更加糟糕。

解决方法

解决方案是避免 共享 引用计数器。Latte 有一个非常简单的分层生命周期结构,所以所有的 Arc 更新让我觉得有些多余,它们可以用更简单的引用和 Rust 生命周期来代替。然而,说起来容易做起来难。不幸的是,Rune 需要将对 Unit 和 RuntimeContext 的引用包装在 Arc 中来管理生命周期(可能在更复杂的场景中),并且它还在这些结构的一部分中使用一些 Arc 包装的值。仅仅为了我的小用例来重写 Rune 是不切实际的。

因此,Arc 必须保留。我们不使用单个 Arc 值,而是每个线程使用一个 Arc。这也需要分离 Unit 和 RuntimeContext 的值,这样每个线程都会得到它们自己的。作为一个副作用,这确保了完全没有任何共享,所以即使 Rune 克隆了一个作为那些值的一部分内部存储的 Arc,这个问题也会解决。这种解决方案的缺点是内存使用更高。幸运的是,Latte 的工作负载脚本通常很小,所以内存使用增加可能不是一个大问题。

为了能够使用独立的 Unit 和 RuntimeContext,我提交了一个 补丁 给 Rune,使它们可 Clone。然后,在 Latte 这边,整个修复实际上是引入了一个新的函数用于 "深度" 克隆 Program 结构,然后确保每个线程都获取它自己的副本:

顺便说一下:sources 字段在执行过程中除了用于发出诊断信息并未被使用,所以它可以保持共享。

注意,我最初发现性能下降的那一行代码并不需要任何改动!

这是因为 self.context 和 self.unit 不再在线程之间共享。幸运的是频繁更新非共享计数器通常很快。

最终结果

现在吞吐量按符合预期,从 1 到 24 个线程吞吐量线性增大:

经验总结

在某些硬件配置上,如果在多个线程上频繁更新一个共享的 Arc,其代价可能会高得离谱。

不要假设单条汇编指令不可能造成性能问题。

不要假设在单个 CPU 上表现良好的应用程序也会在多 CPU 机器上具有相同的性能表现和可扩展性。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20230625A07CX100?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券