一行代码,为何使 24 核服务器比笔记本还慢(3)
时间:2023-06-27 01:44 来源:网络整理 作者:墨客科技 点击:次
它准备了一个新的 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 的工作负载脚本通常很小,所以内存使用增加可能不是一个大问题。 (责任编辑:admin) |