找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1122

积分

0

好友

144

主题
发表于 前天 21:52 | 查看: 3| 回复: 0

缓存一致性是计算机系统中一个至关重要却又常常被忽视的概念。它不仅仅是硬件工程师需要关心的问题,对于编写高性能代码的软件开发者来说,深入理解其原理与影响同样不可或缺。缓存一致性主要涉及以下几个层面:

  1. 一个CPU的指令缓存(icache)和数据缓存(dcache)的同步问题。
  2. 多个CPU各自的缓存同步问题。
  3. CPU与设备(在Linux CPU看来,设备都是DMA)之间的缓存同步问题。

计算机系统多级缓存与DMA架构示意图

一、指令缓存与数据缓存的同步

首先来看ICACHE和DCACHE的同步问题。程序运行时,指令流经过icache,而指令涉及的数据流经过dcache。对于自修改代码(Self-Modifying Code,常见于JIT编译器),当我们修改内存中某地址P的指令时,是通过存储(store)操作写入的,因此新指令会进入dcache。但接下来执行P地址的指令时,icache中命中的可能还是修改前的旧指令。

CPU icache与dcache在地址P处的读写示意图

因此,软件需要将dcache中的数据写回(clean)内存,并使icache相应部分失效(invalidate),这个开销相当大。不过,像ARM64的Neoverse N1处理器已支持硬件的icache一致性,无需软件维护,消除了广播icache失效操作带来的可扩展性瓶颈。

Arm架构硬件一致性I-cache说明文本

二、多核之间的缓存同步与MESI协议

接下来是多核之间的缓存同步问题。下图是一个简化的处理器模型,反映了缓存的基本层级关系,实际的NUMA架构会更复杂。

多核处理器缓存层级结构示意图

如果CPU_A读取了地址P的变量,CPU_B、C、D又读,难道它们都必须从RAM经L3、L2、L1再读一遍吗?显然不必。硬件上的缓存嗅探(snooping)控制单元,可以直接将CPU_A的P地址缓存行拷贝到其他CPU的缓存中。

多CPU缓存共享同一数据示意图

这样所有CPU都得到了P地址的数据。但如果CPU B此时修改了这个数据,而其他CPU缓存中仍是旧值,就会产生不一致。

多CPU缓存数据不一致示意图

这就需要一套协议来维护一致性,典型的有MESI和MOESI协议。MOESI与MESI有些细微差异,但不影响全局理解。我们重点看MESI协议。

MESI协议定义了4种状态:

  • M(Modified):缓存行有效,数据已被修改且与内存不一致,数据仅存在于当前缓存。
  • E(Exclusive):缓存行有效,数据与内存一致,且数据仅存在于当前缓存。
  • S(Shared):缓存行有效,数据与内存一致,且数据存在于多个缓存中。
  • I(Invalid):缓存行无效。

下图展示了这几种状态的示例:

MESI协议中Modified状态示例
MESI协议中Exclusive状态示例
MESI协议中Shared状态示例

MESI协议通过一个状态机来管理这些状态的转换,该状态机监控所有CPU的缓存读写操作。

MESI协议状态转换图

状态机的核心目的是保证一致性。例如,最初A、B、C CPU共享一个干净的数据(S状态)。当CPU B写入修改后,其状态变为M。此时硬件会自动将A、C CPU中该缓存行置为无效(I状态),无需软件干预。

多CPU缓存因写入失效示意图

如果此时CPU C要读这个数据,而它在B中是M状态(与RAM不一致),硬件会将B的修改写回内存,然后B、C的状态都变为S,数据恢复一致。

缓存行从Modified经写回变为Shared状态示意图

这一系列硬件自动完成的操作并非没有代价,它会消耗时间。如果编程不当,引发大量缓存同步,程序性能会急剧下降。

感知缓存同步的开销:一个实验

下面这个程序有两个线程,一个写变量,一个读变量,且变量是缓存行对齐的。

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

static int x __attribute__((aligned (64)));
static int y __attribute__((aligned (64)));

void* thread_fun1(void* param)
{
 for (int i = 0; i < 1000000000; ++i)
 x++;

 return NULL;
}

void* thread_fun2(void* param)
{
 volatile int c;
 for (int i = 0; i < 1000000000; ++i)
 c = x;

 return NULL;
}

int main()
{
 pthread_t tid1,tid2;
 pthread_create(&tid1,NULL,thread_fun1,NULL);
 pthread_create(&tid2,NULL,thread_fun2,NULL);
 pthread_join(tid1,NULL);
 pthread_join(tid2,NULL);
}

双线程读写同一变量的C代码

这个程序中,thread1的写操作会不断与thread2的读操作进行缓存同步。其执行时间如下:

$ time ./a.out
real  0m3.614s
user  0m7.021s
sys   0m0.004s

如果在两个CPU上运行,累计用户态时间7.021秒,真实时间3.614秒。

如果将thread2中的c = x改为c = y,两个线程读写的是不同的缓存行,就避免了硬件的缓存同步开销。

双线程读写不同变量的C代码对比

修改后的运行时间:

$ time ./b.out
real  0m1.820s
user  0m3.606s
sys   0m0.008s

现在只需要1.8秒,几乎快了一倍。

更有趣的是,如果用单核运行第一个程序:

$ time taskset -c 0 ./a.out
real  0m3.299s
user  0m3.297s
sys   0m0.000s

单核跑完甚至比双核(3.614s)还快!因为单核内没有跨核的缓存同步开销。这个例子深刻揭示了错误共享(False Sharing)对性能的毁灭性影响。

三、CPU与设备之间的缓存同步

另一个缓存同步的重大问题是设备与CPU之间。如果设备感知不到CPU的cache,那么在进行DMA前后,CPU就需要进行cache清理(clean)和失效(invalidate)操作,软件开销很大。

CPU、Cache、DMA与外设数据流向示意图

在Linux编程时,如果使用流式DMA API(如dma_map_single(), dma_unmap_single()等),这些同步操作会被自动处理。如果使用dma_alloc_coherent() API,则CPU与设备之间的缓冲区是缓存一致的。对于不支持硬件一致性的设备,该API可能将对应的CPU内存访问设置为不可缓存(uncachable)。

更好的硬件实现是让总线协议支持设备嗅探CPU缓存。

支持DMA嗅探CPU Cache的架构示意图

  • 做内存到外设的DMA时,直接从CPU缓存中取已修改(M状态)的数据。
  • 做外设到内存的DMA时,直接使CPU中相应的缓存行失效。

这样就实现了硬件级的缓存同步。当然,这种同步仍会消耗总线周期。同步开销还与距离相关,下图中A、B间的同步开销就比A、C小。在NUMA服务器中,跨NUMA的缓存同步开销远大于NUMA内部。

多核多级缓存结构示意,显示距离差异

四、缓存感知编程与性能优化

意识到缓存问题并据此进行编程,对开发者至关重要。从CPU流水线角度看,内存访问延迟可简化为:平均访问延迟 = 命中时间 + 未命中率 × 未命中惩罚。缓存未命中会导致CPU停顿(stall)。

现代CPU微架构分为前端(frontend)和后端(backend)。前端取指令给后端执行,后端执行依赖于运算能力和内存子系统(包括缓存)的延迟。

CPU流水线槽位分类与瓶颈分析图

后端执行中访问数据导致的缓存未命中会导致后端停顿,从而降低每周期指令数(IPC)。

1. 缓存预取(Prefetch)

减少缓存未命中是软硬件协同的任务。硬件支持预取,通过分析未命中模式提前取数据。对于复杂无规律的数据,则需要软件使用预取指令来提示CPU。例如在ARM上,可以使用PLD指令。

Linux内核中大量使用了预取来提升性能。例如下面这个WiFi驱动提交,在收到skb后立即预取其数据,以便后续包分类和IP栈处理时能命中缓存。

Linux内核中预取skb数据的提交记录

预取的原理类似“周末让北京同事飞过来,避免周一开会等待”。下面以经典的二分查找代码为例,展示预取的效果。

带有预取指令的二分查找C代码

代码中#ifdef DO_PREFETCH部分提前预取了下次迭代可能访问的中间值。对比开启和关闭预取的性能:

# 开启预取
real 0m10.000s
user 0m9.611s
sys  0m0.389s

# 关闭预取
real 0m11.606s
user 0m11.186s
sys  0m0.420s

开启预取后性能提升约14%。

使用性能工具(如perf和pmu-tools)进行自上而下(Topdown)分析,可以清晰看到差异。在不预取的情况下,程序后端受限严重,其中DRAM_Bound占比高达75.8%。开启预取后,DRAM_Bound占比下降至60.7%。

通过perf stat监控cycle_activity.stalls_l3_miss事件,可见开启预取后,尽管指令数增多,但每周期指令数(IPC)提升,总周期数减少,关键原因就是L3未命中停顿周期大幅减少。

进一步使用perf record -e mem_load_retired.l3_miss定位热点,不预取时,99.93%的L3未命中发生在main函数,注解汇编后可见热点在array[mid] < key这一内存访问。预取后,main函数的占比降至80%,热点被分散,内存瓶颈得到缓解。

无预取程序perf report输出
有预取程序perf annotate汇编片段

2. 避免伪共享(False Sharing)

如前文实验所示,伪共享问题会引发剧烈的缓存同步。解决方法是让可能被不同线程频繁读写的数据处于不同的缓存行。例如,对于一个结构体:

struct s {
    int a;
    int b;
};

如果线程1写a,线程2写b,就可能产生伪共享。可以通过填充(padding)来隔离它们:

struct s {
    int a;
    char padding[L1_CACHE_BYTES - sizeof(int)];
    int b;
};

Linux内核中有大量此类优化。例如下面这个提交,在tw_count后面填充60字节,以避免与后续数据伪共享。

内核inet_timewait_death_row结构体填充优化提交
对应的内核头文件diff,展示填充字节添加

另一个例子是通过调整结构体成员顺序来优化。page_counter结构体中,频繁写的usage和频繁读的parent原本靠得很近,导致伪共享。将parent移至末尾后,性能得到显著提升(最高达9.9%)。

page_counter结构体成员重排性能优化说明
page_counter.h头文件diff,展示parent成员移动及注释

五、CPU缓存基础概念回顾

为了更全面地理解缓存一致性,我们有必要回顾一些关于计算机体系结构中CPU缓存的基础知识。

1. 什么是CPU缓存?

CPU缓存是CPU内部的高速缓存。当CPU从内存读取数据时,会读取一个缓存行(通常是64字节)到高速缓存中。后续访问相邻数据时,可直接从缓存读取,速度远快于访问内存。

CPU、Cache与主存间的字/块传输示意图

2. 为什么需要多级缓存?

引入缓存的理论基础是程序局部性原理(时间局部性和空间局部性)。由于速度越快的存储设备成本越高,单纯增大一级缓存性价比低。因此采用多级缓存架构:速度越快、容量越小、越接近CPU的缓存级别越高(如L1),反之则速度较慢、容量较大、成本较低(如L3)。

单级与三级缓存组织结构对比图
存储器层次金字塔结构图

3. 缓存大小与速度

通常L1 Cache最小最快(每个核心独有指令和数据缓存),L2 Cache较大较慢(每个核心独有),L3 Cache最大最慢(所有核心共享)。访问延迟逐级递增。

历代处理器缓存大小历史表格
各级缓存访问延迟周期数柱状图
多核CPU多级缓存架构示意图

4. 缓存行(Cache Line)

缓存行是缓存与内存之间数据传输的最小单位,通常是64字节。

5. 写入策略

  • 直写(Write Through):数据同时写入缓存和内存,简单但慢。
  • 回写(Write Back):数据只写入缓存,仅在被替换时才写回内存,速度快,但需要MESI这类一致性协议保证数据一致性。

6. 缓存一致性(Cache Coherence)

多个CPU读写同一内存区域引起的冲突问题。MESI协议通过使其他CPU缓存失效并回写脏数据来解决。

7. 缓存映射方式

  • 直接映射:每个主存块只能映射到缓存的一个特定位置。简单快,但易冲突。
  • 全相联映射:主存块可映射到缓存任意位置。灵活,命中率高,但查找电路复杂。
  • 组相联映射:直接映射与全相联的折中。缓存分组,组间直接映射,组内全相联。最常用。

缓存关联性类型对比图:直接映射、2路/4路组相联、全相联

8. 替换策略

最常用的是LRU(最近最少使用)算法。

9. 编程实践:利用局部性

遍历二维数组时,按行遍历比按列遍历快得多,因为按行遍历更好地利用了空间局部性,提高了缓存命中率。

// 按行遍历 - 缓存友好
for (int r = 0; r < row; r++) {
    for (int c = 0; c < col; c++) {
        sum += matrix[r][c];
    }
}
// 按列遍历 - 缓存不友好
for (int c = 0; c < col; c++) {
    for (int r = 0; r < row; r++) {
        sum += matrix[r][c];
    }
}

总结

缓存一致性是一个涉及硬件和软件的深度话题。理解MESI等一致性协议的工作原理,认识到伪共享、缓存未命中带来的性能损失,并学会使用预取、数据对齐与填充等优化技巧,是编写高性能多线程程序的关键。在现代多核乃至众核时代,具备良好的缓存感知编程能力,能让开发者更好地驾驭硬件潜力,避免陷入“内卷却卷不赢”的境地。如果你想与更多开发者交流此类底层性能优化经验,欢迎访问云栈社区




上一篇:从纸带打孔到UNIX时代:在没有Stack Overflow的年代程序员如何编程?
下一篇:苹果配件新品盘点:2024新春马年限定系列与独家配件推荐
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-12 01:09 , Processed in 0.204469 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表