【编者按】遐想一下,你编写了一个处理并行问题的步地,每个线程都寂然施行其被分拨的任务,除了在临了汇总收尾外, 线程之间不需要协同。显然,你会认为淌若将该步地在更多中枢上运行,运行速率会更快。你开首在札记本电脑上进行基准测试,发现它险些能完好地垄断悉数的 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 团队极其乐于助东谈主,短时刻内帮我扫清了悉数阻碍。
以下是一个完整的使命负载示例,用于测量通过未必键聘任行时的性能:
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使用的安全查验。
一运行, 为了评估基准测试轮回的性能,我创建了一个空的剧本:
尽管函数体为空, 但基准测试步地仍需要作念一些使命来信得过运行它:
使用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函数底下的某个方位:
// 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函数作念了几件事:
它准备了一个新的 Rune VM - 这应当是一个相配轻量级的操作,基本上是准备一个新的堆栈;VM 并莫得在调用或线程之间分享,是以它们可以完全独未必运行 它通过传入象征符和参数来调用函数 临了,它摄取收尾并转换一些舛错;咱们可以安全地假设在一个空的基准测试中,这是空操作 (no-op)我的下一个目的是只移除 send_execute和 async_complete调用,只留住 VM 的准备。是以我想对这行代码进行基准测试:
代码看起来十分无辜。这里莫得锁,莫得互斥锁,莫得系统调用,也莫得分享的可变数据。有一些只读的结构 context和 unit通过 Arc分享,但只读分享应该不会有问题。
VM::new也很通俗:
// 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缓存:4个周期 L2缓存:12个周期 L3缓存:43个周期 RAM:62个周期 + 100 nsL1 和 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 机器上具有相易的性能进展和可扩张性。 new线程代码ctxawait发布于:北京市声明:该文不雅点仅代表作家本东谈主,搜狐号系信息发布平台,搜狐仅提供信息存储空间劳动。