一行代码,为何使 24 核服务器比笔记本还慢
【编者按】想象一下,你编写了一个处理并行问题的程序,每个线程都独立执行其被分配的任务,除了在最后汇总结果外, 线程之间不需要协同。显然,你会认为如果将该程序在更多核心上运行,运行速度会更快。你首先在笔记本电脑上进行基准测试,发现它几乎能完美地利用所有的 4 个可用核心。然后你在更多核服务器上运行该程序,期待有更好的性能表现,却发现实际上比笔记本运行的还慢。太不可思议了!
原文链接:https://pkolaczk.github.io/server-slower-than-a-laptop/
未经允许,禁止转载!
我最近一直在改进一款 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 团队极其乐于助人,短时间内帮我扫清了所有障碍。
以下是一个完整的工作负载示例,用于测量通过随机键选择行时的性能:
const ROW_COUNT = latte::param!("rows", 100000);
const KEYSPACE = "latte";
const TABLE = "basic";
pub async fn schema(ctx) {
ctx.execute(`CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} \
WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }`).await?;
ctx.execute(`CREATE TABLE IF NOT EXISTS ${KEYSPACE}.${TABLE}(id bigint PRIMARY KEY)`).await?;
}
pub async fn erase(ctx) {
ctx.execute(`TRUNCATE TABLE ${KEYSPACE}.${TABLE}`).await?;
}
pub async fn prepare(ctx) {
ctx.load_cycle_count = ROW_COUNT;
ctx.prepare("insert", `INSERT INTO ${KEYSPACE}.${TABLE}(id) VALUES (:id)`).await?;
ctx.prepare("select", `SELECT * FROM ${KEYSPACE}.${TABLE} WHERE id = :id`).await?;
}
pub async fn load(ctx, i) {
ctx.execute_prepared("insert", [i]).await?;
}
pub async fn run(ctx, i) {
ctx.execute_prepared("select", [latte::hash(i) % ROW_COUNT]).await?;
}
如果你想进一步了解如何编写该脚本可以参考:README。
对基准测试程序进行基准测试
尽管脚本尚未编译为本机代码,但速度已可接受,而且由于它们通常包含的代码量有限,所以在性能分析的顶部并不会显示这些脚本。我通过实证发现,Rust-Rune FFI 的开销低于由 mlua 提供的Rust-Lua,这可能是由于mlua使用的安全检查。
一开始, 为了评估基准测试循环的性能,我创建了一个空的脚本:
pub async fn run(ctx, i) {
}
尽管函数体为空, 但基准测试程序仍需要做一些工作来真正运行它:
使用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,重新进行了实验,尽管仍然启用了计时和统计代码:
// Executes a single iteration of a workload.
// This should be idempotent –
// the generated action should be a function of the iteration number.
// Returns the end time of the query.
pub async fn run(&self, iteration: i64) -> Result<Instant, LatteError> {
let start_time = Instant::now();
let session = SessionRef::new(&self.session);
// let result = self
// .program
// .async_call(self.function, (session, iteration))
// .await
// .map(|_| ()); // erase Value, because Value is !Send
let end_time = Instant::now();
let mut state = self.state.try_lock().unwrap();
state.fn_stats.operation_completed(end_time - start_time);
// ...
Ok(end_time)
}
在 48 个线程上,每秒超过 1 亿次调用的扩展表现良好!所以问题一定出现在Program::async_call函数下面的某个地方:
// Compiled workload program
pub struct Program {
sources: Sources,
context: Arc<RuntimeContext>,
unit: Arc<Unit>,
}
// Executes given async function with args.
// If execution fails, emits diagnostic messages, e.g. stacktrace to standard error stream.
// Also signals an error if the function execution succeeds, but the function returns
// an error value.
pub async fn async_call(
&self,
fun: FnRef,
args: impl Args + Send,
) -> Result<Value, LatteError> {
let handle_err = |e: VmError| {
let mut out = StandardStream::stderr(ColorChoice::Auto);
let _ = e.emit(&mut out, &self.sources);
LatteError::ScriptExecError(fun.name, e)
};
let execution = self.vm().send_execute(fun.hash, args).map_err(handle_err)?;
let result = execution.async_complete().await.map_err(handle_err)?;
self.convert_error(fun.name, result)
}
// Initializes a fresh virtual machine needed to execute this program.
// This is extremely lightweight.
fn vm(&self) -> Vm {
Vm::new(self.context.clone(), self.unit.clone())
}
async_call函数做了几件事:
它准备了一个新的 Rune VM - 这应当是一个非常轻量级的操作,基本上是准备一个新的堆栈;VM 并没有在调用或线程之间共享,所以它们可以完全独立地运行
它通过传入标识符和参数来调用函数
最后,它接收结果并转换一些错误;我们可以安全地假定在一个空的基准测试中,这是空操作 (no-op)
我的下一个想法是只移除 send_execute和 async_complete调用,只留下 VM 的准备。所以我想对这行代码进行基准测试:
Vm::new(self.context.clone(), self.unit.clone())
代码看起来相当无辜。这里没有锁,没有互斥锁,没有系统调用,也没有共享的可变数据。有一些只读的结构 context和 unit通过 Arc共享,但只读共享应该不会有问题。
VM::new也很简单:
impl Vm {
// Construct a new virtual machine.
pub const fn new(context: Arc<RuntimeContext>, unit: Arc<Unit>) -> Self {
Self::with_stack(context, unit, Stack::new())
}
// Construct a new virtual machine with a custom stack.
pub const fn with_stack(context: Arc<RuntimeContext>, unit: Arc<Unit>, stack: Stack) -> Self {
Self {
context,
unit,
ip: 0,
stack,
call_frames: vec::Vec::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 结构,然后确保每个线程都获取它自己的副本:
// Makes a deep copy of context and unit.
// Calling this method instead of `clone` ensures that Rune runtime structures
// are separate and can be moved to different CPU cores efficiently without accidental
// sharing of Arc references.
fn unshare(&self) -> Program {
Program {
sources: self.sources.clone(),
context: Arc::new(self.context.as_ref().clone()), // clones the value under Arc and wraps it in a new counter
unit: Arc::new(self.unit.as_ref().clone()), // clones the value under Arc and wraps it in a new counter
}
}
顺便说一下:sources 字段在执行过程中除了用于发出诊断信息并未被使用,所以它可以保持共享。
注意,我最初发现性能下降的那一行代码并不需要任何改动!
Vm::new(self.context.clone(), self.unit.clone())
这是因为 self.context 和 self.unit 不再在线程之间共享。幸运的是频繁更新非共享计数器通常很快。
最终结果
现在吞吐量按符合预期,从 1 到 24 个线程吞吐量线性增大:
经验总结
在某些硬件配置上,如果在多个线程上频繁更新一个共享的 Arc,其代价可能会高得离谱。
不要假设单条汇编指令不可能造成性能问题。
不要假设在单个 CPU 上表现良好的应用程序也会在多 CPU 机器上具有相同的性能表现和可扩展性。