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

364 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 缓存查找(命中)
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>` 实现