<返回更多

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

2023-06-26  CSDN  
加入收藏

【编者按】想象一下,你编写了一个处理并行问题的程序,每个线程都独立执行其被分配的任务,除了在最后汇总结果外, 线程之间不需要协同。显然,你会认为如果将该程序在更多核心上运行,运行速度会更快。你首先在笔记本电脑上进行基准测试,发现它几乎能完美地利用所有的 4 个可用核心。然后你在更多核服务器上运行该程序,期待有更好的性能表现,却发现实际上比笔记本运行的还慢。太不可思议了!

原文链接:https://pkolaczk.Github.io/server-slower-than-a-laptop/

作者 | 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 团队极其乐于助人,短时间内帮我扫清了所有障碍。

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

constROW_COUNT = latte::param!( "rows", 100000);

constKEYSPACE = "latte"; constTABLE = "basic";

pub asyncfn 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 asyncfn erase(ctx) { ctx.execute( `TRUNCATE TABLE ${KEYSPACE}. ${TABLE}` ).await?; }

pub asyncfn 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 asyncfn load(ctx, i) { ctx.execute_prepared( "insert", [i]).await?; }

pub asyncfn 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) {}

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

我老旧的 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> { letstart_time = Instant::now; letsession = SessionRef::new(& self.session); // let result = self// .program// .async_call(self.function, (session, iteration))// .await// .map(|_| ); // erase Value, because Value is !Sendletend_time = Instant::now; letmut 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 programpub 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::ExecError( 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函数做了几件事:

我的下一个想法是只移除 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 constfn 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 constfn 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 和 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 counterunit: 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 个线程吞吐量线性增大:

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>