# 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` 的 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` 的新模式 | | 哈希计算 | 与 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` 实现