【问题标题】:Does Rust's array bounds checking affect performance?Rust 的数组边界检查会影响性能吗?
【发布时间】:2017-11-28 23:06:39
【问题描述】:

我来自 C,我想知道 Rust 的边界检查是否会影响性能。每次访问可能都需要一些额外的汇编指令,这在处理大量数据时可能会受到伤害。

另一方面,处理器性能的代价是内存,所以更多的算术汇编指令可能不会受到伤害,但在缓存线加载后,顺序访问应该非常快。

有人对此进行了基准测试吗?

【问题讨论】:

  • 为什么不在您的机器上自己进行基准测试,结果对您来说最准确?一般来说,除非您必须这样做,否则担心真正低级的性能影响是没有意义的。尝试预先优化通常会导致复杂的代码没有真正的性能优势。 基准无法模拟,也不能反映实际使用情况
  • 我稍后可能会对其进行基准测试并自己回答这个问题,但首先我必须学习基础知识(锈病中的时间测量如何工作?:-D)。我只是认为这对理论来说可能很有趣,因为我在研究 HPC 中的 C 或 C++ 等其他语言时遇到了这个主题。
  • Rust 并不总是执行边界检查。我认为遍历数组不会导致对每个项目进行边界检查。如果您需要按索引访问单个项目,则存在未经检查的访问的不安全函数。
  • @hnefatl:如果您不了解某项工作的许多细节,那么您就无法设计一个真正衡量您想要衡量的内容的基准。微基准测试非常很容易最终测量出与作者预期完全不同的东西。 “只是基准测试”的回复并不像大多数人想象的那样有用,尤其是当我们谈论微基准测试时。例如您可能会选择碰巧没有边界检查开销的情况,而忽略了在其他看起来相似或非常不同的情况下存在开销的事实。
  • @PeterCordes 是的,我用斜体表示的评论的最后一部分是为了对此有点“免责声明”,暗示“发明”的基准通常不会捕捉到发生的情况生产代码。

标签: arrays performance rust benchmarking


【解决方案1】:

不幸的是,边界检查的成本并不是一件容易估算的事情。这当然不是“每次检查一个周期”,或者任何容易猜到的成本。它产生非零影响,但可能微不足道。

理论上,可以通过修改 Rust 以禁用它们并运行大规模生态系统测试来衡量对 Vec 等基本类型进行边界检查的成本。这会给出某种经验法则,但如果不这样做,很难知道这是否会接近百分之十或十分之一的开销。

不过,有一些方法可以比计时和猜测做得更好。这些经验法则主要适用于桌面级硬件。低端硬件或针对不同细分市场的东西将具有不同的特性。

如果您的索引源自容器大小,编译器很有可能可能能够完全消除边界检查。此时,发布版本中边界检查的唯一成本是它会间歇性地干扰优化,这可能但通常不会阻碍其他优化。

如果您的代码多枝、内存访问繁重或难以优化,并且要检查的边界很容易访问,很有可能边界检查主要发生在CPU 的空闲带宽,特别是分支预测,在这种情况下总成本会特别小,尤其是与其余代码的成本相比。

如果您要检查的边界位于几层指针之后,有可能您会遇到内存延迟问题,并且会受到相应的影响。然而,CPU 中的推测和预测机制将设法隐藏这一点也是合理的。这非常依赖于上下文。如果您引用内部数据,而不是在边界检查的同时取消引用,则这种风险会放大。

如果您的边界检查处于不会使内核饱和的紧密算术循环中,您不太可能直接损害吞吐量,除非阻碍其他编译器优化。但是,阻碍其他编译器优化可能会非常糟糕,从没有区别到阻止 SIMD 并导致 10 倍的减速。

如果您的边界检查处于确实使内核饱和的紧密算术循环中,您将承担上述风险并且直接每次边界检查大约半个周期的执行惩罚。

如果您的代码大到足以对指令缓存造成压力,那么您需要担心对代码大小的影响。这通常是适度的,但特别难以衡量其对运行时的影响。

Peter Cordes 在 cmets 中添加了一些进一步的观点。首先,边界检查意味着加载和存储,因此您将运行混合加载,这很可能成为问题/重命名的瓶颈。其次,即使是并行执行的预测分支也会从预测器中占用资源,这可能会导致其他分支的预测更差。

这似乎令人生畏,但确实如此。这就是为什么在与您和您的代码相关的级别上衡量和了解您的性能非常重要的原因。

同样的情况是,自从 Rust “诞生”时就有边界检查,它产生了降低成本的方法,例如无处不在的零成本引用、迭代器(它吸收但实际上并没有删除边界检查),以及一组不同寻常的实用功能。如果您发现自己遇到了病态病例,Rust 还提供了不安全的逃生舱口。

【讨论】:

  • “使核心的功能单元饱和”:通常你会限制前端解码指令并将它们提供给乱序核心的能力:“发布/重命名”吞吐量通常是最窄的管道的一部分,以及在混合了加载、存储和 ALU 的代码中最有可能遇到的瓶颈。 (如果您需要边界检查,则必须有一些加载和/或存储)。我猜你可能会在代码中对负载端口吞吐量造成瓶颈,其中有很多负载也需要边界检查,但会在 L1D 缓存中命中。
  • 更多的分支(即使是总是走同样的路)可能会对分支预测率产生小的负面影响。他们稀释了有趣分支的分支历史。无论如何,+1不错的答案。与往常一样,性能问题的答案是“它取决于上下文”:P(以及目标硬件。现代 x86 非常擅长处理绒毛,尤其是在关键路径之外,但低功耗 CPU 的管道更窄。甚至 Xeon Phi / Silvermont 可能会从边界检查中看到更多影响。)
  • @cmaster Rust 使用usize 索引,因此您不需要检查>= 0,并且几乎在所有受吞吐量限制的情况下都会加载要检查的边界(因为你可能处于一个紧密的循环中,并且负载被提升了)。
  • 哦,这很有趣。如果您曾经为过时的 32 位 x86 进行编译,那么来自数组边界的额外寄存器压力是有问题的(7 GP regs + 堆栈指针)。幸运的是 x86 具有快速 L1D 缓存(在大核 CPU(不是 Atom/Xeon Phi)上每个时钟加载 2 次),因此将寄存器与内存进行比较几乎与 reg,reg 一样便宜(您可以将绑定复制到堆栈之外紧密循环,因此您不需要在循环内的寄存器中指向容器控制块的指针。但是在 x86-64(15 GP regs + RSP)和 ARM32(14 GP regs + 堆栈)上,寄存器压力并不重要+ 电脑)。
  • 另一个问题是 LLVM 是一个巨大的黑盒,代码更改可能会对优化产生不可预测的下游影响。我见过边界检查以某种方式具有成本的情况,也就是说,它比没有边界检查的相同代码更快。
猜你喜欢
  • 1970-01-01
  • 2011-04-09
  • 1970-01-01
  • 2011-07-07
  • 1970-01-01
  • 2010-10-06
  • 1970-01-01
  • 2012-06-25
  • 1970-01-01
相关资源
最近更新 更多