dbaplus社群 2024年12月22日
消除低效代码,每个程序员都该了解的硬件知识
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文旨在帮助程序员建立一个简单有效的硬件心智模型,从而更好地理解代码性能瓶颈并进行优化。文章通过多个benchmark实例,深入探讨了缓存(cache)、预取器(prefetcher)、缓存关联性(cache associativity)、伪共享(false sharing)、流水线(pipeline)和数据依赖等关键硬件概念。通过对比不同代码实现,揭示了硬件特性对程序性能的显著影响,为读者提供了实用的优化策略和深入的原理分析,帮助开发者写出更高效的代码。

📦缓存(Cache):CPU与主存之间存在缓存,按行读取数据,按行遍历数组利用缓存,按列遍历导致缓存失效,性能差异巨大。

🚀预取器(Prefetcher):预取器预测数据需求提前加载到缓存,随机访问导致预取器失效,性能下降。

🧮缓存关联性(Cache Associativity):直接映射灵活性低,组相联映射兼顾速度和灵活性,步长为2的幂次时,数据易被分到同一组,导致缓存冲突。

🧵伪共享(False Sharing):多线程操作相邻内存,即使操作不同变量,也可能因同一缓存行而竞争,导致性能下降,使用对齐方式可解决。

⏱️流水线(Pipeline)与分支预测:流水线提升指令执行效率,分支预测失败代价高,有序数据分支预测成功率高,无序数据导致预测失败,性能下降。

⛓️数据依赖:数据依赖会降低pipeline效率和指令并行效率,阻止编译器向量化优化,独立操作可提高性能。

腾讯程序员 2024-12-22 08:00 广东

帮助大家建立一个简单、有效的硬件心智模型。


在追求高效代码的路上,我们不可避免地会遇到代码的性能瓶颈。为了了解、解释一段代码为什么低效,并尝试改进低效的代码,我们总是要了解硬件的工作原理。于是,我们可能会尝试搜索有关某个架构的介绍、一些优化指南或者阅读一些计算机科学的教科书(如:计算机组成原理)。但以上的内容可能都太过繁琐、细节太多,在阅读的过程中,我们可能会迷失在纷繁的细节中,没法很好地将知识运用到实践中。


本文旨在通过多个可运行的 benchmark 介绍常见的优化细节以及与之相关的硬件知识,为读者建立一个简单、有效的硬件心智模型。



Cache


首先要介绍的就是缓存 cache 。我们先来看一个引自 CSAPP 的经典例子:


pub fn row_major_traversal(arr: &mut Vec<Vec<usize>>) {    let n = arr.len();    for i in 0..n {        assert!(arr[i].len() == n);        for j in 0..n {            arr[i][j] += j;        }    }}

pub fn column_major_traversal(arr: &mut Vec<Vec<usize>>) { let n = arr.len(); for i in 0..n { assert!(arr[i].len() == n); for j in 0..n { arr[j][i] += j; } }}

在上面两个例子中,分别按行、按列迭代同样大小的二维数组。



我们对这两个函数进行 benchmark:



在上图中,纵轴是平均耗时,横轴是数组大小(如:2000.0 表示数组大小为:2000 x 2000)。我们看到按行迭代数组比按列迭代的效率高约 10 倍。


在现代的存储架构中,cpu 和主存之间是 cache 。cpu 中的寄存器、高速缓存、内存三者的数据读写速度越来越慢。



而当 cpu 读取一个数据的时候,会先尝试从 cache 中读取。如果发生 cache miss 的时候,才会将数据从主存中加载到 cache 中再读取。而值得注意的是,cpu 每一次的读取都是以 cache line 为单位的。也就是说,cpu 在读取一个数据的时候,也会将该数据相邻的、一个 cache line 内的数据也加载到 cache 中。而二维数组在内存中是按行排布的,换句话说,数组中相邻的两行是首尾相连排列的。所以在读取 arr[i] 的时候,arr[i + 1] 、arr[i + 2] 等相邻的数组元素也会被加载到 cache 中,而当下一次迭代中,需要读取数组元素 arr[i + 1] 时,就能直接从 cache 中取出,速度非常快。而因为以列读取数组时,arr[i][j] 和 arr[i + 1][j] 在内存中的位置就不再是紧密相连,而是相距一个数组行大小。这也导致了在读取 arr[i][j] 时,arr[i + 1][j] 并没有被加载到 cache 中。在下一次迭代时就会发生 cache miss 也就导致读取速度大幅下降。



prefetcher


如果我们不再是按某种顺序,而是随机地遍历数组,结果又会如何呢?


pub fn row_major_traversal(arr: &mut Vec<Vec<usize>>) {    let n = arr.len();    for i in 0..n {        assert!(arr[i].len() == n);        let ri: usize = rand::random();        std::intrinsics::black_box(ri);        for j in 0..n {            arr[i][j] += j;        }    }}
pub fn column_major_traversal(arr: &mut Vec<Vec<usize>>) { let n = arr.len(); for i in 0..n { assert!(arr[i].len() == n); let ri: usize = rand::random(); std::intrinsics::black_box(ri); for j in 0..n { arr[j][i] += j; } }}
pub fn random_access(arr: &mut Vec<Vec<usize>>) { let n = arr.len(); for i in 0..n { assert!(arr[i].len() == n); for j in 0..n { let ri: usize = rand::random(); arr[j][ri % n] += j; } }}

理论上来说,随机遍历和按列遍历都会导致频繁地 cache miss ,所以两者的效率应该是相近的。接下来,我们进行 benchmark:



可以看到,random_access 比 column_major 的效率还要低了 2 倍。原因是,在 cache 和 cpu 间还有 prefetcher



我们可以参考维基百科上的资料:


Cache prefetching can be accomplished either by hardware or by software.



而 random_access 会让 prefetching 的机制失效,使得运行效率进一步下降。


cache associativity


如果我们按照不同的步长迭代一个数组会怎么样呢?


pub fn iter_with_step(arr: &mut Vec<usize>, step: usize) {    let n = arr.len();    let mut i = 0;    for _ in 0..1000000 {        unsafe { arr.get_unchecked_mut(i).add_assign(1); }        i = (i + step) % n;    }}


steps 为:


let steps = [    1,     2,     4,     7, 8, 9,    15, 16, 17,    31, 32, 33,    61, 64, 67,    125, 128, 131,    253, 256, 259,     509, 512, 515,     1019, 1024, 1031];

我们进行测试:



可以看见当 step 为 2 的幂次时,都会有一个运行时间的突起,一个性能的毛刺。这是为什么呢?要回答这个问题,我们需要温习一些计组知识。


cache 的大小是要远小于主存的。这就意味着我们需要通过某种方式将主存的不同位置映射到缓存中。一般来说,共有 3 种不同的映射方式。



全相联映射允许主存中的行可以映射到缓存中的任意一行。这种映射方式灵活性很高,但会使得缓存的查找速度下降。



直接映射则规定主存中的某一行只能映射到缓存中的特定行。这种映射方式查找速度高,但灵活性很低,会经常导致缓存冲突,从而导致频繁 cache miss 。



组相联映射则尝试吸收前两者的优点,将缓存中的缓存行分组,主存中某一行只能映射到特定的一组,在组内则采取全相联的映射方式。如果一组之内有 n 个缓存行,我们就称这种映射方式为 n 路组相联(n-way set associative)。


回到真实的 cpu 中,如:AMD Ryzen 7 4700u 。



我们可以看到,L1 cache 大小为 4 x 32 KB (128KB) ,采取 8 路组相联,缓存行大小为 64 bytes 。也就是说,该缓存共有 4x32x1024 byte/64 byte = 2048 行,共分为 2048/8 = 256 组。也就是说,当迭代数组的步长为 时,数据更可能会被分到同一个组内,导致 cache miss 更加频繁,从而导致效率下降。


(注:我的 cpu 是 intel i7-10750H ,算得的 L1 cache 的组数为 384 ,并不能很好地用理论解释。)



false share


我们再来观察一组 benchmark 。


use std::sync::atomic::{AtomicUsize, Ordering};use std::thread;
fn increase(v: &AtomicUsize) { for _ in 0..10000 { v.fetch_add(1, Ordering::Relaxed); }}
pub fn share() { let v = AtomicUsize::new(0); thread::scope(|s| { let ta = s.spawn(|| increase(&v)); let tb = s.spawn(|| increase(&v)); let tc = s.spawn(|| increase(&v)); let td = s.spawn(|| increase(&v));
        ta.join().unwrap(); tb.join().unwrap(); tc.join().unwrap(); td.join().unwrap(); });}
pub fn false_share() { let a = AtomicUsize::new(0); let b = AtomicUsize::new(0); let c = AtomicUsize::new(0); let d = AtomicUsize::new(0);
    thread::scope(|s| { let ta = s.spawn(|| increase(&a)); let tb = s.spawn(|| increase(&b)); let tc = s.spawn(|| increase(&c)); let td = s.spawn(|| increase(&d));
        ta.join().unwrap(); tb.join().unwrap(); tc.join().unwrap(); td.join().unwrap(); });}


在 share 函数中,四个线程同时竞争原子变量 v 。而在 false_share 函数中,四个线程分别操作不同的原子变量,理论上线程之间不会产生数据竞争,所以 false_share 的执行效率应该比 share 要高。但 benchmark 的结果却出乎意料:



可以看到 false_share 比 share 的执行效率还要低。


在前文中提到,cpu 在读取数据时,是以一个 cache line 大小为单位将数据从主存中加载到 cache 中的。在前文中提到,笔者机器的 cache line 大小为:64 bytes 。而 false_share 函数中,四个原子变量在栈中的排布可能是:



a, b, c, d 四个原子变量在同一个 cache line 中,也就是说实际上四个线程实际上还是发生了竞争,产生了 false share 的现象。


那要如何解决这个问题呢?我们可以采用 #[repr(align(64))] (在不同的编程语言中又不同的写法),告知编译器将原子变量的内存地址以一个 cache line 大小对齐,从而避免四个原子变量位于同一个 cache line 中。


fn increase_2(v: &AlignAtomicUsize) {    for _ in 0..10000 {        v.v.fetch_add(1, Ordering::Relaxed);    }}
#[repr(align(64))]struct AlignAtomicUsize { v: AtomicUsize,}
impl AlignAtomicUsize { pub fn new(val: usize) -> Self { Self { v: AtomicUsize::new(val) } }}
pub fn non_share() { let a = AlignAtomicUsize::new(0); let b = AlignAtomicUsize::new(0); let c = AlignAtomicUsize::new(0); let d = AlignAtomicUsize::new(0);
    thread::scope(|s| { let ta = s.spawn(|| increase_2(&a)); let tb = s.spawn(|| increase_2(&b)); let tc = s.spawn(|| increase_2(&c)); let td = s.spawn(|| increase_2(&d));
        ta.join().unwrap(); tb.join().unwrap(); tc.join().unwrap(); td.join().unwrap(); });}


我们再次进行 benchmark,这一次 benchmark 的结果就符合我们的预测了:



可以看见 non_share 相比前两者,提升了近乎两倍的效率。


pipeline


我们再看一个 benchmark:


pub trait Get {    fn get(&self) -> i32;}
struct Foo /* ... */ }struct Bar { /* ... */ }
impl Get for Foo { /* ... */ }impl Get for Bar { /* ... */ }
let mut arr: Vec<Box<dyn Get>> = vec![];for _ in 0..10000 { arr.push(Box::new(Foo::new()));}for _ in 0..10000 { arr.push(Box::new(Bar::new()));}
// 此时数组中元素的排列时有序的arr.iter().filter(|v| v.get() > 0).count()
// 将数组打乱arr.shuffle(&mut rand::thread_rng());
// 再次测试arr.iter().filter(|v| v.get() > 0).count()

测试结果为:



可以看见,sorted 和 unsorted 之间效率差约 2.67 倍。这是因为频繁的分支预测失败导致的。


在CPU 中,每一条指令的执行都会分为多个步骤,而现代计算机架构中存在一个结构 pipeline 可以同时执行处于不同执行阶段的指令。



而 pipeline 要高效地工作,需要在执行指令 A 时就将接下来要执行的指令 B, C, D 等提前读入。在一般的代码中,pipeline 可以有效地工作,但遇到分支的时候,我们就遇到难题了:



如图,pipeline 应该读入 Code A 还是 Code B 呢?在执行分支语句前,谁也不知道,CPU 也是。如果我们还想要 pipeline 高效工作的话,我们就只剩下一条路:猜。只要猜得足够准,我们的效率就能够接近没有分支的情况。“猜”这一步也有一个专业名词——**流水线冒险**。而现代计算机在编译器配合以及一些算法的帮助下,某些分支(如下图所示)的预测成功率可以高达 99% 。



分支预测失败的代价是要付出代价的。首先,我们要清除 pipeline 中的指令,因为它们不是接下来要执行的指令。其次,我们要将接下来要执行的指令一一加载进 pipeline 。最后,指令经过多个步骤被执行。


在测试代码中,我们打乱数组后,就会导致分支预测频繁失败,最终导致了执行效率的下降。


数据依赖


我们再来看一段代码:


pub fn dependent(a: &mut Vec<i32>, b: &mut Vec<i32>, c: &Vec<i32>) {    assert!(a.len() == 100000);    assert!(b.len() == 100000);    assert!(c.len() == 100000);
    for i in 0..=99998 { a[i] += b[i]; b[i + 1] += c[i]; } a[9999] += b[9999];}
pub fn independent(a: &mut Vec<i32>, b: &mut Vec<i32>, c: &Vec<i32>) { assert!(a.len() == 100000); assert!(b.len() == 100000); assert!(c.len() == 100000);
    a[0] += b[0]; for i in 0..=99998 { b[i + 1] += c[i]; a[i + 1] += b[i + 1]; }}

在这段代码中,我们通过两种不同的方式迭代数组,并最终达成一致的效果。我们画出,数据流图如下图:



在上图中,我们用箭头表示依赖关系(a[0] -> b[0] 表示 a[0] 的结果依赖于 b[0] ),用黑色箭头表示在循环外进行的操作,用不同的颜色,表示不同迭代中的操作。我们可以看到,在 dependent 中,不同颜色的箭头会出现在同一个数据流中,如:(a[1]->b[1]->c[0] 中就出现了红色和蓝色箭头),这就意味着第 n + 1 次迭代会依赖于第 n 次迭代的结果,而 independent 中则没有这种情况。


这会产生什么影响呢?我们来进行测试:



可以看到,出现了近 3 倍的效率差距。这有两方面原因。


一是数据依赖会导致 pipeline 效率以及 cpu 指令级并行的效率变低。



二是这种迭代之间的依赖会阻止编译器的向量化优化。我们观察等价的 cpp 代码(rust 1.71 的优化能力并不足以将 independet 向量化,我略感悲伤)。


#include <vector>
using i32 = int;
template<typename T>using Vec = std::vector<T>;
void dependent(Vec<i32> &a, Vec<i32> &b, Vec<i32> &c) { for (int i = 0; i < 9999; i++) { a[i] += b[i]; b[i + 1] += c[i]; } a[9999] += b[9999];}
void independent(Vec<i32> &a, Vec<i32> &b, Vec<i32> &c) { a[0] += b[0]; for (int i = 0; i < 9999; i++) { b[i + 1] += c[i]; a[i + 1] += b[i + 1]; }}

查看汇编:


dependent(...):    mov     rax, rdx    mov     rdx, QWORD PTR [rsi]    mov     rcx, QWORD PTR [rdi]    mov     rdi, QWORD PTR [rax]    xor     eax, eax.L2:    mov     esi, DWORD PTR [rdx+rax]    add     DWORD PTR [rcx+rax], esi    mov     esi, DWORD PTR [rdi+rax]    add     DWORD PTR [rdx+4+rax], esi    add     rax, 4    cmp     rax, 39996    jne     .L2    mov     eax, DWORD PTR [rdx+39996]    add     DWORD PTR [rcx+39996], eax    ret

independent(...): mov rax, QWORD PTR [rdi] mov rcx, rdx mov rdx, QWORD PTR [rsi] lea rdi, [rax+4] mov esi, DWORD PTR [rdx] add DWORD PTR [rax], esi lea r8, [rdx+4] mov rsi, QWORD PTR [rcx] lea rcx, [rdx+20] cmp rdi, rcx lea rdi, [rax+20] setnb cl cmp r8, rdi setnb dil or ecx, edi mov rdi, rdx sub rdi, rsi cmp rdi, 8 seta dil test cl, dil je .L9 mov rcx, rax sub rcx, rsi cmp rcx, 8 jbe .L9 mov ecx, 4.L7: movdqu xmm0, XMMWORD PTR [rsi-4+rcx] movdqu xmm2, XMMWORD PTR [rdx+rcx] paddd xmm0, xmm2 movups XMMWORD PTR [rdx+rcx], xmm0 movdqu xmm3, XMMWORD PTR [rax+rcx] paddd xmm0, xmm3 movups XMMWORD PTR [rax+rcx], xmm0 add rcx, 16 cmp rcx, 39988 jne .L7 movq xmm0, QWORD PTR [rsi+39984] movq xmm1, QWORD PTR [rdx+39988] paddd xmm0, xmm1 movq QWORD PTR [rdx+39988], xmm0 movq xmm1, QWORD PTR [rax+39988] paddd xmm1, xmm0 movq QWORD PTR [rax+39988], xmm1 mov ecx, DWORD PTR [rdx+39996] add ecx, DWORD PTR [rsi+39992] mov DWORD PTR [rdx+39996], ecx add DWORD PTR [rax+39996], ecx ret.L9: mov ecx, 4.L6: mov edi, DWORD PTR [rdx+rcx] add edi, DWORD PTR [rsi-4+rcx] mov DWORD PTR [rdx+rcx], edi add DWORD PTR [rax+rcx], edi add rcx, 4 cmp rcx, 40000 jne .L6 retAI助手

可以看到,independent 函数被成功向量化。



作者丨shizhaoyang

来源丨公众号:腾讯技术工程(ID:Tencent_TEG)

dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn


阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

硬件心智模型 代码优化 缓存 预取器 流水线
相关文章