16 KiB
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 指针
3.5 并发模型
L4 采用「部分并发」设计:
| 操作 | 执行者 | 并发性 |
|---|---|---|
| Lookup(Swiss Table 读) | 各工作线程 | ✅ 完全并行 |
| Load(Slab分配+插入) | L4 服务线程 | 串行(单消费者) |
| Evict(逐出+可选写回) | L4 服务线程 | 串行 |
| Flush(dirty批量写回) | MatrixParallelWrap | 计算任务结束后单次执行 |
Swiss Table 并发读安全保证:服务线程插入时遵守严格写顺序:
先写 key/value,sfence,再写控制字节,确保工作线程
读到控制字节时 key/value 已完整可见。
热度更新简化:热度仅在 Load 时由服务线程设置, Lookup 命中时不修改热度,确保 Lookup 路径零写操作。
4. 操作流程
4.1 缓存查找(命中)
- 计算原始指针的 CRC32C 哈希
- Swiss Table 查找:SIMD 扫描 ctrl → 比较 key → 返回缓存指针
- 热度提升:将该缓存行从当前桶移至下一级桶的桶底
- 同时将下一级桶的桶顶移至当前桶的桶底(热度守恒)
- 返回缓存指针,上层代码直接访问本地数据
4.2 缓存未命中
- Swiss Table 查找未命中
- 从 Slab 分配器申请一个 1024B 缓存行
- 若 Slab 无空闲 → 触发逐出(见 4.3)
memcpy远端数据到本地缓存行- 在 Swiss Table 中插入
原始指针 → 缓存指针映射 - 将缓存行加入逐出环的 H=0 桶底
- 返回缓存指针
4.3 缓存逐出
- 取 H=0 桶的桶顶
- 若 H=0 非空 → 逐出该缓存行���
- 从 Swiss Table 中删除映射(ctrl 设为
0xFE) - 将缓存行归还 Slab 分配器
- 不写回远端(只读缓存语义)
- 从 Swiss Table 中删除映射(ctrl 设为
- 若 H=0 为空 → 旋转环形链表:
- 环形数组整体前移,所有缓存行热度降低一级
- 重复步骤 1,直到 H=0 非空
- 最坏情况:旋转 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 的标准做法是两种:
-
分配时绑定:通过
numactl --membind或mbind()系统调用, 在分配时将内存锁定到特定 NUMA 节点。 缺陷:需要调用者(而非库本身)感知 NUMA 拓扑, 且对已分配的远端数据无能为力。 -
分块对齐硬件缓存:通过精心选择分块参数(MC、KC、NC), 使矩阵子块恰好填满 L1/L2/L3 硬件缓存。 缺陷:依赖硬件缓存对 NUMA 的透明处理, 在跨节点访问时硬件缓存的延迟惩罚不可控。
L4 缓存器的独特之处在于不信任硬件缓存对 NUMA 的处理, 而是在软件层面显式地将数据搬运到线程本地内存。 这在 CPU 端数值计算库中是一种全新的方法。
为什么没有人做过
-
传统 BLAS 库假设内存已经就位: BLAS 接口定义为
dgemm(A, B, C), 调用者负责保证 A/B/C 的内存位置合理。 库本身只负责计算,不负责数据搬运。 -
硬件缓存在多数场景下够用: 对于单路或双路服务器,L3 缓存的 NUMA 惩罚通常在 1.5-2x, 尚可接受。只有在多路(4P/8P)或混合架构 CPU 上, NUMA 惩罚才会恶化到���须在软件层面解决。
-
复杂度高: 实现一个完整的软件缓存层需要协调哈希表、内存分配器、 逐出策略三个子系统。大多数数值计算库选择将这个问题 留给操作系统或用户空间的 NUMA 工具。
-
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>实现