Files
stgsharp/StgSharp.Common/HighPerformance/Memory/L4_design.md
T

15 KiB
Raw Blame History

L4 软件缓存器设计方案

项目:StgSharp
作者:Nitload
版本:草案 v0.1
日期:2026-03-03


1. 目的

1.1 问题背景

StgSharp 的矩阵计算系统正从"全局单一上下文表"迁移为"逐线程上下文"架构, 以适配混合架构 CPU(如 Intel P-Core / E-Core)的调度特性。 迁移后每个线程独立持有计算上下文,但矩阵数据仍可能由 HLSF 分配器 或全局 malloc 分配在远端 NUMA 节点上,导致跨节点访问延迟显著增大。

1.2 解决方案

L4 缓存器是一个纯软件实现的线程本地数据缓存层, 位于 CPU 硬件缓存(L1/L2/L3)与主存之间的逻辑位置。 其核心思路:将远端 NUMA 内存中的数据按需搬运到线程本地的连续内存区域中, 消除跨 NUMA 节点的访问延迟。

1.3 设计目标

  • 为逐线程矩阵计算上下文提供 NUMA 友好的本地数据访问
  • 固定容量(5-10 MB),不做运行时扩容,确定性延迟
  • 透明的指针重定向:上层代码通过原始指针查找,L4 返回本地缓存指针
  • 基于热度的缓存逐出策略,保留高频访问数据

2. 整体架构

线程 N 的数据访问路径:

  矩阵计算代码
       │
       │  原始指针 (HLSF/malloc 分配,可能在远端 NUMA)
       ▼
  ┌─────────────────────────────────────────────┐
  │              L4 软件缓存器                    │
  │                                              │
  │  ┌──────────┐   查找    ┌────────────────┐   │
  │  │Swiss Table├────────→│ 缓存命中        │   │
  │  │(映射表)   │          │ 返回本地指针    │   │
  │  └──────────┘          └────────────────┘   │
  │       │                                      │
  │       │ 未命中                                │
  │       ▼                                      │
  │  ┌──────────┐  分配    ┌────────────────┐   │
  │  │Slab 分配器├───────→│ 本地缓存行      │   │
  │  │(缓存行池) │          │ (1024B 对齐)   │   │
  │  └──────────┘          └────────────────┘   │
  │       │                       │              │
  │       │ 空间不足               │ 搬运数据     │
  │       ▼                       ▼              │
  │  ┌──────────┐          ┌────────────────┐   │
  │  │逐出环    │          │ memcpy 远端→本地│   │
  │  │(4 级热度) │          └────────────────┘   │
  │  └──────────┘                               │
  │       │                                      │
  │       │ 逐出冷数据(不写回)                   │
  │       ▼                                      │
  │  释放缓存行,注销映射                          │
  └─────────────────────────────────────────────┘
       │
       ▼
  返回本地缓存指针(同一 NUMA 节点,低延迟访问)

2.1 关键参数

参数 值 说明
缓存区总容量 5-10 MB 线程本地连续内存
缓存行大小 1024 Bytes = 8 个 double MAT_KERNEL 的大小
映射表类型 Swiss Table 1024 桶 × 256 槽,SIMD 加速
缓存行存储池 Slab Allocator 非并发、固定容量、不自动扩容
逐出策略 4 级热度环形链表 基于访问频率的近似 LFU
写回策略 无(只读缓存) 逐出时直接丢弃,不写回远端

3. 重要组件

3.1 Swiss Table(指针映射表)

职责:维护 原始指针 → 缓存指针 的映射关系。

  • 键(Key):原始指针 nint,指向 HLSF/malloc 分配的远端数据
  • 值(Value):缓存指针 nint,指向本地 Slab 分配的缓存行
  • 哈希:使用硬件 CRC32C 指令对 64 位指针计算哈希
    • 低 10 位 → 桶索引(1024 个桶)
    • 第 8-14 位 → H2 控制字节(用于 SIMD 预筛选)
  • 扫描:SSE2 pmovmskb 指令并行比较 16 个控制字节, 未来可升级为 AVX-512(每次 64 字节,扫描次数从 16 降至 4)
  • 墓碑机制:删除时 ctrl 设为 0xFE,保护探测链完整性
  • 容量:1024 × 256 = 262,144 条映射,远大于缓存行总数(~5000-10000 行), 保证极低的哈希碰撞率

3.2 Slab Allocator(缓存行存储池)

职责:管理固定大小(1024B)缓存行的分配与回收。

  • 模式:非并发、固定容量(Nonconcurrent Fixed 模式), 区别于现有 SlabAllocator<T> 的 Chunked 和 Sequential 模式
  • 不自动扩容:容量在初始化时确定(总缓存区大小 / 1024), 分配失败时触发逐出流程而非扩容
  • 分配复杂度:O(1),从空闲栈弹出
  • 回收复杂度:O(1),压回空闲栈
  • 对齐:所有缓存行 1024B 对齐,确保不跨硬件缓存行边界

3.3 逐出环(4 级热度环形链表)

职责:追踪缓存行的访问热度,决定逐出优先级。

结构:

逐出环 — 4 个热度级别的环形数组

  ┌─────┐    ┌─────┐    ┌─────┐    ┌─────┐
  │ H=0 │───→│ H=1 │───→│ H=2 │───→│ H=3 │──┐
  │(冷) │    │     │    │     │    │(热) │  │
  └──┬──┘    └──┬──┘    └──┬──┘    └──┬──┘  │
     │          │          │          │      │
     │ 环形首尾相连                          │
     └──────────────────────────────────────┘

每个热度级别是一个单链表桶:

  H=0:  [行A] → [行B] → [行C] → null
         桶顶                      桶底

  H=1:  [行D] → [行E] → null
  H=2:  [行F] → null
  H=3:  [行G] → [行H] → null

3.4 缓存行元数据

每个被缓存的数据行需要维护以下元数据(存储在 Slab 分配的额外结构中, 不占用缓存行本身的 1024 字节):

  • 原始指针(用于 Swiss Table 反向注销)
  • 缓存指针(本地地址)
  • 所在热度级别
  • 单链表的 next 指针

4. 操作流程

4.1 缓存查找(命中)

  1. 计算原始指针的 CRC32C 哈希
  2. Swiss Table 查找:SIMD 扫描 ctrl → 比较 key → 返回缓存指针
  3. 热度提升:将该缓存行从当前桶移至下一级桶的桶底
  4. 同时将下一级桶的桶顶移至当前桶的桶底(热度守恒)
  5. 返回缓存指针,上层代码直接访问本地数据

4.2 缓存未命中

  1. Swiss Table 查找未命中
  2. 从 Slab 分配器申请一个 1024B 缓存行
  3. 若 Slab 无空闲 → 触发逐出(见 4.3)
  4. memcpy 远端数据到本地缓存行
  5. 在 Swiss Table 中插入 原始指针 → 缓存指针 映射
  6. 将缓存行加入逐出环的 H=0 桶底
  7. 返回缓存指针

4.3 缓存逐出

  1. 取 H=0 桶的桶顶
  2. 若 H=0 非空 → 逐出该缓存行���
    • 从 Swiss Table 中删除映射(ctrl 设为 0xFE)
    • 将缓存行归还 Slab 分配器
    • 不写回远端(只读缓存语义)
  3. 若 H=0 为空 → 旋转环形链表:
    • 环形数组整体前移,所有缓存行热度降低一级
    • 重复步骤 1,直到 H=0 非空
  4. 最坏情况:旋转 3 次(4 级热度),保证一定能逐出

4.4 热度更新规则

访问缓存行 X(当前在 H=k 桶中):

  若 k < 3:
    将 X 从 H=k 桶移至 H=k+1 桶底        (X 热度提升)
    将 H=k+1 桶顶移至 H=k 桶底            (某行热度下降,守恒)
  
  若 k == 3:
    X 已在最高热度,不移动

这个设计保证了:

  • 每次访问只涉及两个链表的头尾操作,O(1)
  • 热度级别总数恒定,不会无限膨胀
  • 高频访问的缓存行自然沉淀在 H=3,不易被逐出

5. 独特性与优势

5.1 NUMA 感知的线程本地缓存

与 CPU 硬件缓存不同,L4 缓存器在软件层面感知 NUMA 拓扑。 硬件 L3 缓存对 NUMA 的处理依赖处理器微架构, 而 L4 通过显式的 memcpy 搬运保证数据一定位于当前线程所在 NUMA 节点, 消除了不可预测的远端访问延迟。

5.2 缓存行大小与矩阵 Kernel 对齐

1024B 的缓存行大小不是任意选取的:

  • 1 个 double MAT_KERNEL(4×4 double 矩阵)= 128 Bytes
  • 1 个缓存行 = 8 个 MAT_KERNEL = 恰好是一个 Panel 计算单元的数据量
  • 矩阵乘法的微内核(MAT_PANEL_sse / MAT_PANEL_avx) 每次处理的数据量与缓存行完全对齐
  • 避免了一次矩阵运算需要跨多个缓存行的情况

5.3 只读语义简化设计

不实现写回策略是经过考量的设计决策:

  • 矩阵计算中,输入矩阵在计算过程中不被修改
  • 缓存的数据是远端数据的只读副本
  • 逐出时直接丢弃,无需脏位追踪、写回队列、一致性协议
  • 如果远端数据被修改,L4 的行为是"下次未命中时重新搬运最新数据", 这对矩阵计算场景是可接受的

5.4 固定容量的确定性

  • Slab 分配器不扩容 → 内存占用上界已知
  • 逐出环保证 O(1) 逐出 → 延迟上界已知
  • Swiss Table 固定 262,144 槽位 → 查找延迟上界已知
  • 整个 L4 的最坏延迟 = 查找 + 逐出 + 分配 + memcpy,全部可静态计算

5.5 与 StgSharp 现有基础设施的复用

L4 组件 复用的 StgSharp 基础设施
映射表 SIMD SIMDID 架构检测体系
缓存行分配 SlabAllocator<T> 的新模式
哈希计算 与 native 层 city_hash_simplify_sse 同族
内存分配 NativeMemory 对齐分配
未来多平台支持 IBucketScanner + NRGA 编译管道

5.6 开创性分析

经过对现有主流数值计算库的调研,L4 缓存器的设计在已知公开实现中 没有直接的先例或等价物。

现有库的 NUMA 策略对比

库 / 框架 NUMA 策略 软件缓存层 映射表 逐出策略
Intel MKL 线程绑核 + numactl 外部控制 无 无 无
OpenBLAS 线程亲和性 + 分块对齐硬件缓存 无 无 无
BLIS 多级分块(MC×KC×NC)对齐 L1/L2/L3 无 无 无
Eigen 无显式 NUMA 处理 无 无 无
cuBLAS / rocBLAS GPU 显存即为本地缓存(硬件解决) 无 无 无
StgSharp L4 软件管理的线程本地缓存 有 Swiss Table 4 级热度环

差异的本质

现有库处理 NUMA 的标准做法是两种:

  1. 分配时绑定:通过 numactl --membind 或 mbind() 系统调用, 在分配时将内存锁定到特定 NUMA 节点。 缺陷:需要调用者(而非库本身)感知 NUMA 拓扑, 且对已分配的远端数据无能为力。

  2. 分块对齐硬件缓存:通过精心选择分块参数(MC、KC、NC), 使矩阵子块恰好填满 L1/L2/L3 硬件缓存。 缺陷:依赖硬件缓存对 NUMA 的透明处理, 在跨节点访问时硬件缓存的延迟惩罚不可控。

L4 缓存器的独特之处在于不信任硬件缓存对 NUMA 的处理, 而是在软件层面显式地将数据搬运到线程本地内存。 这在 CPU 端数值计算库中是一种全新的方法。

为什么没有人做过

  1. 传统 BLAS 库假设内存已经就位: BLAS 接口定义为 dgemm(A, B, C), 调用者负责保证 A/B/C 的内存位置合理。 库本身只负责计算,不负责数据搬运。

  2. 硬件缓存在多数场景下够用: 对于单路或双路服务器,L3 缓存的 NUMA 惩罚通常在 1.5-2x, 尚可接受。只有在多路(4P/8P)或混合架构 CPU 上, NUMA 惩罚才会恶化到���须在软件层面解决。

  3. 复杂度高: 实现一个完整的软件缓存层需要协调哈希表、内存分配器、 逐出策略三个子系统。大多数数值计算库选择将这个问题 留给操作系统或用户空间的 NUMA 工具。

  4. GPU 时代转移了注意力: 过去十年,高性能计算的焦点转向 GPU。 GPU 的显存本身就是"本地缓存",NUMA 问题被硬件解决了。 CPU 端的 NUMA 优化因此缺乏投入。

StgSharp L4 的定位

L4 缓存器填补了一个特定的空白: 在 CPU 端、线程级别、由库自身管理的 NUMA 数据缓存。 它不依赖调用者的 NUMA 感知,不依赖操作系统的页面迁移, 也不依赖硬件缓存的透明处理——而是将 NUMA 数据局部性 作为库内部的一等公民来管理。

这种设计尤其适配 StgSharp 所面对的场景: 混合架构 CPU(P-Core / E-Core 分布在不同 NUMA 域)

  • 逐线程独立计算上下文
  • 数据由外部分配器(HLSF / malloc)分配在不可预测的位置。 在这种场景下,"分配时绑定"策略失效(因为数据已经被分配了), "分块对齐硬件缓存"策略不够(因为硬件缓存不感知线程迁移), L4 软件缓存层成为唯一可行的解决方案。

6. 未来工作

  • L4 根类型实现(组合 Swiss Table、Slab、逐出环)
  • Slab Allocator Nonconcurrent Fixed 模式实现
  • Swiss Table ARM NEON 实现
  • Swiss Table AVX-512 升级(256 槽扫描从 16 次降至 4 次)
  • 墓碑累积监控与自动 rehash 策略
  • 性能基准测试(NUMA 本地 vs 远端访问延迟对比)
  • 与逐线程矩阵上下文的集成验证
  • 远期:写回策略评估(当计算模式扩展到原地修改时)
  • 远期:PPC64LE 平台的 VSX 原生 Swiss Table 扫描支持

7. 参考

  • Google Abseil Swiss Tables Design Notes
  • StgSharp HLSF Allocator 设计文档
  • StgSharp SIMD Architecture (SIMDID 体系)
  • StgSharp NRGA Roadmap
  • StgSharp SlabAllocator<T> 实现