操作系统笔记:NUMA:内存分配与远端访问的代价

1 分钟阅读

发布于:

NUMA(Non-Uniform Memory Access)机器上,“同样是内存访问”并不等价:访问本地 NUMA 节点的内存更快,访问远端节点更慢,且会引入额外互连流量。对延迟敏感服务而言,NUMA 问题经常表现为:

  • P99/P999 抖动
  • 线程迁移后性能突然变化
  • 某些核很忙但整体吞吐不高

这篇笔记把 NUMA 相关的三个工程问题串起来:

  • 内存分配在哪里发生(first-touch)
  • 线程跑在哪里(CPU 亲和性)
  • 数据被谁访问(跨节点共享与伪共享)

1. NUMA 的结构:socket = node,内存就近

简化示意:

Socket/Node 0: CPUs (cores) + Local DRAM 0
Socket/Node 1: CPUs (cores) + Local DRAM 1

Remote access:
  CPU0 reads DRAM1 -> via interconnect (slower)

代价通常体现为:

  • 更高的访问延迟
  • 更低的有效带宽
  • 更大的互连拥塞风险(多核同时跨节点访问)

2. first-touch:页在哪里分配,取决于第一次写

很多 Linux 配置下,匿名页(heap 等)采用 first-touch:

  • 某线程第一次写入该页时,页被分配在该线程所在 CPU 的 NUMA 节点

这意味着:

  • 初始化线程在哪个节点触页,决定后续数据的位置
  • 如果初始化在 node0,但主要访问在 node1,就会产生大量远端访问

示意:

Thread on node0 initializes array -> pages allocated on node0
Thread on node1 later processes array -> remote accesses

工程策略:

  • 让“初始化/触页”的线程与“主要使用”的线程在同一节点
  • 或按分区初始化:每个 worker 初始化自己会处理的数据分片

3. CPU 亲和性:线程跑哪决定了访问路径

如果线程频繁跨节点迁移:

  • cache/TLB 失效
  • 新节点访问旧节点内存 -> remote -> 延迟与吞吐更不稳定

常见策略:

  • 绑定线程到一组核(cpuset/affinity)
  • NUMA aware 调度(让线程尽量留在其数据所在节点)

但也要避免过度绑定导致:

  • 某节点过载而另一个节点空闲

4. 跨节点共享:远端访问与一致性流量

当两个节点的核频繁写同一 cache line:

  • 发生 cache coherence 流量
  • 在 NUMA 下不仅跨核,还跨 socket 互连 -> 代价更大

这类问题常与伪共享相互叠加:

  • 数据结构布局不当,多线程写落在同一 cache line
  • 即使逻辑上“不同字段”,也会互相拉扯

工程手段:

  • padding/对齐,避免多线程写共享同一 line
  • 把写热点改为分片计数(per-thread/per-core),最后聚合

5. 内存策略:interleave、bind 与大页

常见内存策略(抽象):

  • bind:把分配限制在某个节点(更可预测,但可能耗尽)
  • preferred:优先本地,必要时可远端
  • interleave:在多个节点间交错分配(带宽更均匀,但延迟更平均)

选择取决于负载:

  • 延迟敏感:通常 prefer/bind,本地化优先
  • 带宽敏感(流式扫描):interleave 可能更好

大页(THP/hugepage)也会影响:

  • 页分配粒度更大,first-touch 影响更显著
  • TLB miss 降低,但分配/回收成本可能上升

6. 观测与排查

建议关注:

  • 远端访问比例(local vs remote)
  • 节点间互连带宽/拥塞(如果可观测)
  • 线程迁移次数、跨节点迁移次数
  • 每节点内存分配量与回收压力

典型定位路径:

  • P99 抖动 + 远端访问上升:数据不本地或线程漂移
  • 吞吐下降 + 互连忙:跨节点共享写热点(coherence 风暴)

7. 小结

NUMA 的工程本质是“数据与执行的空间一致性”:

  • first-touch 决定数据在哪里
  • 亲和性决定线程在哪里
  • 共享写决定互连与一致性流量

在高并发服务中,NUMA 问题往往不是“平均变慢”,而是“尾延迟变差”。把数据分片、初始化本地化、减少跨节点共享写,是最常用也最有效的稳定性手段。