240 元素的奥秘:为什么你的 Rust 代码会突然变慢?
\n\n想象一下这个场景:你正在用 Rust 编写高性能代码,一切运行如飞。你在 200 个元素的数组上测试算法——效果很好。当你再增加 40 个元素(总共 240 个)时——性能却下降了 2-3 倍。听起来像魔法?不,这是物理定律。更准确地说——是现代处理器的架构。
\n\n在 Stack Overflow 上,问题 "Why is there a large performance impact when looping over an array with 240 or more elements?" 获得了数千次浏览,并成为了经典。在 2025 年,随着新微架构的出现和缓存层次结构的复杂化,理解这个问题对于任何 Rust 开发者来说都至关重要。
\n\n在本文中,我们不仅仅是复述答案。我们将展示这在 Rust 中是如何体现的,如何诊断问题,以及最重要的是——如何利用 2025 年的现代实践来避免它。
\n\n问题的根源:缓存未命中(Cache Misses)
\n\n处理器不直接与内存(RAM)交互。它使用 缓存——芯片上的超高速内存。数据以称为 缓存行(通常为 64 字节)的块加载到缓存中。
\n\n数字 240 的魔力与 L1 缓存(通常数据缓存为 32 KB)的大小以及元素的大小有关。如果数组是 i32(4 字节),那么 240 个元素占用 960 字节。看起来这可以放入 L1 缓存。但问题在于,算法可能同时操作两个或三个数组,或者编译器无法有效地向量化代码,从而导致 缓存未命中。
在原始的 C++ 问题中,阈值恰好是 240 个元素,因为它触及了预取(prefetching)特性以及 L1/L2 缓存大小的限制。
\n\n这在 Rust 中是如何体现的?
\n\n让我们模拟一下这个场景。我们将对数组元素求和,但使用一个会破坏缓存的“步长”。
\n\nuse std::time::Instant;\n\nfn main() {\n // 240 个元素的数组\n let size = 240;\n let data: Vec<i32> = (0..size).collect();\n \n // 冷启动\n let start = Instant::now();\n let sum: i32 = data.iter().sum();\n let duration = start.elapsed();\n println!("240 个元素(顺序访问): {:?}", duration);\n \n // 现在使用步长 64(缓存行大小)进行“糟糕”的访问\n let mut sum2 = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(64) {\n sum2 += data[i];\n }\n let duration2 = start.elapsed();\n println!("240 个元素(步长 64): {:?}", duration2);\n}\n\n\n你会惊讶地发现,差异可能高达 10 倍!第一个循环使用 顺序访问——处理器会预测并提前加载缓存行。第二个循环是 随机访问,步长较大,会将数据从缓存中踢出。
\n\n为什么恰好是 240?深层原因
\n\n在 2025 年,处理器的 L1 数据缓存大小从 32 KB 到 64 KB 不等。但问题不仅仅在于大小。关键在于缓存的 组相联性。
\n\n把缓存想象成一个有 64 个书架(组)的图书馆。每个书架上可以放 8 本书(路)。当处理器访问内存时,它会根据地址计算“书架编号”。如果书架上已经有 8 本书,就必须扔掉一本。
\n\n对于大小为 2 的幂次倍的数组(例如 256),所有元素可能落入同一个“书架”,导致 缓存冲突未命中。240 是 256 减去 16,这打破了这种倍数关系,并且……由于预取的特性,反而使情况变得更糟。
\n\nRust 中的实际测试
\n\nuse std::time::Instant;\n\nfn bench_access(size: usize) {\n let data: Vec<i32> = (0..size as i32).collect();\n \n // “糟糕”的模式:访问每第 16 个元素\n let mut sum = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(16)