3、共享内存架构:UMA与NUMA的区别、缓存一致性协议(MESI、MOESI、MESIF)原理、目录协议

好,咱们进入第三章。这一章讲的是共享内存架构,说白了就是多核怎么“看”同一块内存。我当年刚接触多核SoC时,觉得这玩意儿不就是多个核挂一条总线上嘛,有啥难的?结果第一个项目就踩了坑——性能上不去,调试调到头秃。后来才明白,内存架构和缓存一致性,才是多核系统的灵魂。

3.1 UMA与NUMA:内存访问的“公平”与“效率”

先聊聊UMA和NUMA。这两个词,你面试时肯定见过。

UMA(Uniform Memory Access),统一内存访问。所有CPU核访问内存的时间是一样的。听起来很公平对吧?

但问题来了——总线带宽是有限的。核一多,大家都去抢总线,延迟就上去了。我做过一个4核的SoC,用UMA架构,跑单核任务时性能还不错,一上4核,内存访问延迟直接翻倍。嗯,这就是UMA的瓶颈。

NUMA(Non-Uniform Memory Access),非统一内存访问。每个CPU核有自己的“本地内存”,访问本地内存快,访问别人的内存慢。

为什么会这样?因为NUMA把内存分成了多个“节点”。每个节点离某些CPU近,离其他CPU远。你想想看,这就像你在公司里,拿自己工位上的文件最快,要去隔壁工位拿就慢一些。

我个人习惯,在设计大型多核SoC时,如果核数超过8个,基本就得上NUMA了。UMA在4-8核时还能凑合,再往上就扛不住了。

核心区别总结:

  • UMA:所有内存访问延迟相同,适合核数少的系统,设计简单
  • NUMA:本地内存快,远端内存慢,适合核数多的系统,但需要软件感知

我曾经在一个项目中,把UMA改成NUMA,性能提升了30%多。代价是操作系统得支持NUMA调度,不然线程跑在错误的核上,反而更慢。

3.2 缓存一致性协议:MESI、MOESI、MESIF

好,接下来是重头戏——缓存一致性。多核系统里,每个核都有自己的L1/L2缓存。问题来了:如果核A改了某个变量,核B不知道,还在用自己缓存里的旧值,那就出大事了。

缓存一致性协议就是解决这个问题的。我挑三个最经典的讲:MESI、MOESI、MESIF。

3.2.1 MESI协议

MESI是基础,四个状态:

  • M(Modified):缓存行被修改,与主存不一致,数据只在本缓存中有效
  • E(Exclusive):缓存行未被修改,且只在本缓存中,与主存一致
  • S(Shared):缓存行未被修改,可能在多个缓存中,与主存一致
  • I(Invalid):缓存行无效

状态转换的核心是“嗅探”(snooping)。每个缓存控制器都在总线上监听其他核的操作。比如核A要写一个处于S状态的行,它得先发一个“Invalidate”广播,让其他核把这个行置为I,然后自己才能写。

我记得第一次看MESI的状态机图时,觉得好复杂。后来自己动手画了一遍,发现其实就几个关键路径:读命中、写命中、读缺失、写缺失。嗯,画一遍就记住了。

避坑指南:我曾经在一个项目中,MESI协议的“写缺失”处理没做好,导致频繁的缓存行 bouncing(乒乓效应)。两个核来回抢同一个缓存行,性能直接腰斩。解决办法是调整数据布局,避免伪共享(false sharing)。

3.2.2 MOESI协议

MOESI在MESI基础上加了一个状态:O(Owned)

O状态是什么意思?说白了,就是某个缓存行被修改了,但其他核也有这个行的副本(S状态)。拥有O状态的核负责在需要时把数据写回主存。

为什么要加O?因为MESI里,如果一个行是M状态,其他核想读,得先把数据写回主存,然后其他核才能读。这多了一步写回操作,浪费了总线带宽。

MOESI的O状态允许“直接转发”:拥有O状态的核可以直接把数据给其他核,不用先写回主存。我做过一个AMBA ACE总线的项目,用的就是MOESI,总线利用率比MESI高了大概15%。

3.2.3 MESIF协议

MESIF是Intel用的协议,在MESI基础上加了F(Forward)状态。

F状态和S状态类似,但多了一个“转发者”的角色。当多个核都有同一个缓存行的副本时,只有F状态的核可以响应其他核的读请求。

为什么需要F?因为MESI里,如果多个核都是S状态,一个读请求来了,所有S状态的核都会响应,造成总线冲突。MESIF指定了唯一的F状态核来响应,避免了冲突。

你想想看,这就像一群人都有同一本书,但只有一个人负责回答“这本书第几页写了什么”。其他人就不用开口了,省事。

协议 状态数 新增状态 主要优势 典型应用
MESI 4 - 基础,实现简单 早期多核处理器
MOESI 5 Owned 减少写回操作,总线利用率高 AMBA ACE总线
MESIF 5 Forward 避免多核同时响应,减少总线冲突 Intel处理器

3.3 目录协议:当嗅探不够用了

嗅探协议(snooping)有个致命问题:它依赖广播。每个缓存操作都要通知所有其他核。核数一多,广播风暴就来了。

我做过一个16核的SoC,用嗅探协议,总线上全是Invalidate和Read消息,有效带宽不到30%。嗯,那感觉就像早高峰的地铁——全是人,但动不了。

目录协议就是来解决这个问题的。它维护一个“目录”,记录每个缓存行在哪些核的缓存里。当核A要写一个缓存行时,它只需要通知目录中记录的那些核,而不是广播给所有人。

目录协议的核心数据结构:

// 目录项示例(简化版)
struct DirectoryEntry {
    uint64_t tag;           // 内存地址标签
    uint32_t sharers;       // 位图,记录哪些核有该缓存行
    uint32_t owner;         // 拥有M/O状态的核(如果有)
    bool dirty;             // 是否被修改
};

目录协议有三种常见实现:

  • 全映射目录:每个缓存行对应一个目录项,记录所有核的状态。简单但目录体积大。
  • 有限指针目录:只记录有限个共享者,超出则用“溢出位”标记。节省空间,但处理复杂。
  • 链式目录:用链表形式组织共享者列表。灵活,但查找慢。

注意:目录协议虽然减少了广播,但引入了“目录访问”的额外延迟。我建议在核数超过8个时考虑目录协议,核数少时用嗅探协议更简单高效。

我曾经在一个项目中,把嗅探协议改成目录协议,总线带宽利用率从30%提升到了70%以上。代价是目录存储占了不少面积——大概每16个核需要额外1MB的SRAM来存目录。嗯,这就是典型的“用面积换性能”。

好了,这一章的内容就到这儿。共享内存架构和缓存一致性,说白了就是解决“多核怎么高效地共享数据”这个问题。UMA和NUMA决定了内存怎么布局,MESI/MOESI/MESIF决定了缓存怎么同步,目录协议决定了怎么避免广播风暴。下一章我们聊聊更高级的同步机制——锁和事务内存。