【萨德基】想像一下,你撰写了两个处理博戈达问题的流程,每个缓存都独立继续执行其被分配的任务,除了在最终汇整结论外 , 缓存之间不需要协作。显然,你会认为如果将该流程在更多核心理念上运转,运转速度会更快。你首先在笔记型电脑电脑上展开计算方法试验,辨认出它基本上能轻松地利用所有的 4 个需用核心理念。接着你在更多核伺服器上运转该流程,期盼有更快的性能表现,却辨认出实际上比笔记型电脑运转的还慢。太难以置信了!
书名镜像:https://pkolaczk.github.io/server-slower-than-a-laptop/
需经允许,明令禁止转发!
作者 | pkolaczk
翻译者 | 没错如月 白眉林 | 夏萌
公司出品 | CSDN(ID:CSDNnews)
我最近一直在改良这款 Cassandra 计算方法IO Latte ,这可能是你能找到的 CPU 采用和缓存采用都最高效率的 Cassandra 计算方法IO。设计路子比较简单:撰写少部分标识符生成统计数据,并且继续执行一系列触发器的 CQL 句子向 Cassandra 发动允诺。Latte 在循环式中初始化这段标识符,并历史记录每天插值耗费的时间。最终,展开系统分析,并通过各种形式展现结论。
计算方法试验适于博戈达化。只要被试验的标识符是无状态的,就很容易采用多个缓存初始化。我已经在《Benchmarking Apache Cassandra with Rust》和《Scalable Benchmarking with Rust Streams》中讨论过如何在 Rust 中同时实现此功能。
然而,当我写这些早期的网志该文时,Latte 基本上不全力支持表述组织工作阻抗,换句话说它的能力非常有限。它只内建两个预设的组织工作阻抗,两个用作抹除,另两个用作载入统计数据。你只能调整一些模块,比如列的数量和大小,没有什么高阶的优点。它不全力支持二级检索,也无法自订过滤器条件。对于 CQL(Cassandra Query Language)文档的控制也受限。总之,它基本上没有任何可取之处。因此,在那个时候,Latte 更像是两个用作校正概念的辅助工具,而不是两个真正需用作实际组织工作的通用型辅助工具。当然,你可以 fork Latte 的源标识符,并采用 Rust 撰写捷伊组织工作阻抗,接着重新校对。但谁想无用去学习两个小众计算方法IO的内部同时实现呢 ?
Rune JAVA
去年,为了能够测量 Cassandra 采用存储检索的性能,我决定将 Latte 与两个JAVA引擎展开集成,这个引擎可以让我轻松地表述组织工作阻抗,而无需重新校对整个流程。在尝试将 CQL 句子嵌入 TOML 配置文件(效果非常不理想)后,我也尝试过在 Rust 中嵌入 Lua (在 C 语言中可能很好用,但在与 Rust 配合采用时,并不如我预期的那样顺畅,尽管勉强能用)。最终,我选择了两个类似于 sysbench 的设计,但采用了嵌入式的 Rune 解释器代替 Lua。
说服我采用 Rune 的主要优势是和 Rust 无缝集成以及全力支持触发器标识符。由于全力支持触发器,用户可以直接在组织工作阻抗JAVA中继续执行 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?;}
如果你想进一步了解如何撰写该JAVA可以参考:README。
对计算方法试验流程展开计算方法试验
尽管JAVA尚未校对为本机标识符,但速度已可接受,而且由于它们通常包含的标识符量有限,所以在性能分析的顶部并不会显示这些JAVA。我通过实证辨认出,Rust-Rune FFI 的开销低于由 mlua 提供的 Rust-Lua,这可能是由于 mlua 采用的安全检查。
一开始, 为了评估计算方法试验循环式的性能,我创建了两个空的JAVA :
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 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::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 架构有一致性的统计数据缓存。这基本上意味着,你在两个核心理念上载入的内容,你可以在另两个核心理念上读回。多个核心理念存储有冲突统计数据的缓存行是不可能的。一旦两个缓存决定更新两个共享的缓存行,那么在所有其他核心理念上的该行就会失效,因此那些核心理念上的后续加载将不
我们的引用计数器是原子的 , 这让事情变得更加复杂。尽管采用原子指令常常被称为 ” 无锁编程 “,但这有点误导性——实际上,原子操作需要在硬件级别展开一些锁定。只要没有阻塞这个锁非常细粒度且廉价,但与锁定一样 , 如果很多事物同时争夺同两个锁,性能就会下降。如果需要争夺同两个锁的不仅仅是相邻的单个核心理念,而是涉及到整个 CPU,通信和同步的开销更大,而且可能存在更多的竞争条件,情况会更加糟糕。
解决方法
解决方案是避免 共享 引用计数器。Latte 有两个比较简单的分层生命周期结构,所以所有的 Arc 更新让我觉得有些多余,它们可以用更简单的引用和 Rust 生命周期来代替。然而,说起来容易做起来难。不幸的是,Rune 需要将对 Unit 和 RuntimeContext 的引用包装在 Arc 中来管理生命周期(可能在更复杂的场景中),并且它还在这些结构的一部分中采用一些 Arc 包装的值。仅仅为了我的小用例来重写 Rune 是不切实际的。
因此,Arc 必须保留。我们不采用单个 Arc 值 , 而是每个缓存采用一个 Arc。这也需要分离 Unit 和 RuntimeContext 的值,这样每个缓存都会得到它们自己的。作为两个副作用,这确保了完全没有任何共享,所以即使 Rune 克隆了两个作为那些值的一部分内部存储的 Arc,这个问题也会解决。这种解决方案的缺点是缓存采用更高。幸运的是,Latte 的组织工作阻抗JAVA通常很小,所以缓存采用增加可能不是两个大问题。
为了能够采用独立的 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 机器上具有相同的性能表现和可扩展性。