5784 字
15 分钟
收紧共享元数据路径:RadixTree 与 ObjectPool 在 ThreadCachingMalloc 中的设计与实验

收紧共享元数据路径:RadixTree 与 ObjectPool 在 ThreadCachingMalloc 中的设计与实验#

摘要#

我在第一版 ThreadCachingMalloc 中使用 std::unordered_map<PAGE_ID, Span*> 保存页号到 Span 的映射,并在 Page Cache 拆分、合并内存时直接通过 new/delete 管理 Span 元数据。这两个方案容易实现,也足以验证三级缓存分配器的基本结构,但它们把通用容器和通用堆分配器带进了共享层:页号查询需要哈希和节点访问,Span 的创建与销毁则可能再次进入系统分配路径。它们不一定出现在每一次小对象申请中,却会增加 Thread Cache 未命中、批量回收和 Page Cache 调整时的临界区成本。

本文对这条共享元数据路径做了两项针对性改造。第一,用按页号位段逐层索引的 RadixTree 替换 unordered_map,将 64 位地址去掉 12 位页内偏移后的 52 位页号拆成 9 + 9 + 9 + 9 + 9 + 7,以固定六层数组访问完成映射。第二,为 Span 建立 ObjectPool,通过页级批量申请、顺序切分、侵入式空闲链表和 placement new 复用元数据存储。

为了验证这两次数据结构替换本身,我没有直接使用整个分配器的总耗时做归因,而是编写了独立微基准。在 MSVC 19.43、Release x64、/O2 环境下,每组交替测试 9 次并取耗时中位数。对于 131072 条页映射,RadixTree 的连续页查询耗时为 98.9442 ms,unordered_map 为 182.2086 ms,前者快 1.84 倍;在分段页号布局下分别为 111.9964 ms 和 270.1615 ms,前者快 2.41 倍。对于 4194304 次 Span 构造与销毁周期,预热后的 ObjectPool 耗时为 16.1680 ms,直接 new/delete 为 125.4424 ms,前者快 7.76 倍。

这些结果证明两项优化确实降低了各自负责的机制成本,但不能直接等价为整个分配器获得同倍数加速。Thread Cache 命中率、Page Cache 锁竞争、地址分布和内存占用仍会决定端到端收益。本文同时讨论当前实现的内存回收、稀疏地址、对齐和并发边界,避免只报告速度而忽略工程代价。

1 问题不在对象数据而在元数据路径#

ThreadCachingMalloc 把内存管理分为 Thread Cache、Center Cache 和 Page Cache。绝大多数稳定状态下的小对象申请只操作线程私有 FreeList,不需要进入本文讨论的路径。真正需要共享元数据的场景主要有三类:

  1. Thread Cache 从 Center Cache 批量获取对象,Center Cache 需要找到或申请可用 Span;
  2. 对象从 Thread Cache 回到 Center Cache,需要根据对象地址定位它所属的 Span;
  3. Page Cache 拆分、合并或释放页时,需要创建、更新或回收 Span 元数据。

其中第二类操作可以抽象为:

对象地址
│ 右移 12 位
▼
PAGE_ID ──映射查询──> Span*
│
├─ 页区间
├─ 使用计数
└─ 空闲对象链表

PAGE_ID 是天然的整数索引。第一版却把它交给通用哈希表处理:

std::unordered_map<PAGE_ID, Span*> _pageIDtoSpanPtrHash;

平均 O(1) 并不意味着固定成本低。哈希表查找至少包含哈希计算、桶定位和节点访问,节点式存储还可能造成额外的指针追踪与缓存未命中。更关键的是,这些查询发生在 Page Cache 的全局互斥锁保护范围内。单次多花几纳秒看似很小,在集中回收时却可能延长其他线程等待同一把锁的时间。

Span 的分配问题与此类似。Span 不是用户请求的对象,而是描述一段连续页的控制结构。如果创建 Span 仍然调用通用 new,分配器便出现了一个不够理想的递归依赖:管理用户内存的组件还要借助另一条通用堆路径管理自己的元数据。

第一版完成后,我首先选择了这两个共享层问题作为下一阶段的优化目标:

  • 已知页号时,怎样用更确定的路径找到 Span;
  • Span 生命周期频繁变化时,怎样避免重复向通用堆申请小块元数据。

这一步延续了第一版论文中列出的优化方向。第一版已经完成三级缓存、规格桶、Span 拆分合并和多线程 benchmark,本文不再重复介绍整套分配器,而是集中回答两个尚未解决的问题:unordered_map 能否被更适合页号的数据结构替代,Span 元数据能否由分配器自己高效管理。

2 RadixTree 将页号转换为固定层级索引#

2.1 从 64 位地址中剥离页内偏移#

当前实现的页大小为 4 KiB,因此:

inline constexpr size_t PAGE_SHIFT = 12;

对象地址右移 12 位即可得到 PAGE_ID。在 64 位构建中,可参与映射的位数为:

64 - PAGE_SHIFT = 52 bit

RadixTree 每层处理 9 bit,对应 512 个分支。52 bit 最终被拆成:

9 + 9 + 9 + 9 + 9 + 7 = 52

因此,一个页号最多经过六层即可到达叶子。Page Cache 中的类型定义为:

RadixTree<Span*, 8 * sizeof(PAGE_ID) - PAGE_SHIFT, 9>
_pageIDtoSpanPtrHash;

这里保留了原成员名中的 Hash,但底层结构已经不再是哈希表。

2.2 逐层取位而不是计算哈希#

RadixTree 的核心操作是 Access。第一次进入时,代码先把 PAGE_ID 左移 12 位,恢复它在完整地址位宽中的位置;随后每层读取最高 9 bit 作为数组下标,再把剩余位左移交给下一层。

简化后的逻辑如下:

T* Access(PAGE_ID id, bool first, bool isCreate) {
if (first) {
id <<= PAGE_SHIFT;
}
if constexpr (AllBits <= LayerBits) {
// 到达叶子,直接返回数组槽位
return leaf_array + leaf_index;
} else {
// 取当前 9 bit,进入下一层
return children[index].Access(id << LayerBits, false, isCreate);
}
}

这条路径没有哈希函数,也没有“桶内再找节点”的第二阶段。只要树的相应分支已经建立,查找次数由位宽决定,而不是由元素数量决定。对于当前参数,它是固定的六层索引。

2.3 延迟创建与存在位图#

如果启动时直接建立完整的 52 位索引空间,内存开销显然无法接受。当前实现只在写入经过某个分支时才分配下一层数组:

if (_treeList == nullptr) {
_treeList = new RadixTree<...>[1 << LayerBits];
}

叶子层也按需分配值数组,并使用 std::bitset 记录槽位是否存在。这样可以区分“没有映射”和“映射值恰好为空指针”,count/find/erase 也不需要额外的节点对象。

延迟创建对分配器的典型地址分布尤其有效。一次系统页申请会产生连续页号,这些页共享绝大部分高位前缀,只在靠近叶子的层级分叉。多个页的映射可以复用同一条上层路径,既减少结构分配,也提高查询时的局部性。

2.4 与 Page Cache 的结合#

Page Cache 在把 Span 交给上层时,会为 Span 覆盖的每一页写入映射:

for (size_t i = 0; i < span->pageNums; ++i) {
_pageIDtoSpanPtrHash[span->id + i] = span;
}

对象释放时,地址先转换为页号,再通过同一个 RadixTree 找回 Span。Span 合并后,受影响页的映射也会指向合并后的 Span;大块 Span 归还操作系统时,对应页号则从树中擦除。

RadixTree 本身没有加锁。当前 Page Cache 仍然使用 _mtx 保护 Span 桶、页号映射和 ObjectPool。这项优化没有消除全局锁,而是尝试缩短锁内映射操作的执行时间。这样的改法侵入性较低,因为它不改变 Span 生命周期和现有同步边界。

3 ObjectPool 让 Span 元数据在分配器内部循环#

3.1 为什么 Span 适合对象池#

Span 的大小固定,创建位置集中,生命周期也由 Page Cache 控制。拆分大 Span 时会产生新 Span,合并时会销毁被吸收的 Span,大对象直接申请和释放也会创建、回收 Span。与大小和生命周期不可预测的普通用户对象相比,这正是对象池容易发挥作用的场景。

ObjectPool 首先计算单个槽位大小:

_sizeofItem = std::max(sizeof(T), sizeof(void*));
_sizeofItem = AlignUp(_sizeofItem, sizeof(void*));

槽位至少能容纳一个指针,因为对象销毁后,槽位的首个机器字会被复用为空闲链表的 next 指针。空闲节点不需要额外分配链表结构。

3.2 慢路径按页扩容#

当空闲链表为空且当前内存块剩余空间不足时,ObjectPool 通过 SystemAllocPage 申请新页。基础页数由对象大小与页大小的最小公倍数决定:

_applyPageNums =
std::lcm(_sizeofItem, 1 << PAGE_SHIFT) >> PAGE_SHIFT;

这样得到的内存块既是完整页,又能被固定大小槽位整齐切分。当前扩容级别从 5 开始,之后按 5、10、20、40……倍增长;实际申请页数是基础页数与该级别的乘积。这能减少对象池扩大后反复进入系统页申请的次数。

从新块分配对象时只需要移动游标:

T* obj = reinterpret_cast<T*>(_memory);
_memory += _sizeofItem;
_remainBytes -= _sizeofItem;
new (obj) T(std::forward<Args>(args)...);

这里的 placement new 只负责调用构造函数,不再为对象单独申请存储。

3.3 热路径是一次链表弹出或压入#

对象池已经拥有空闲槽位时,New 从侵入式链表头部弹出一个节点,然后在原地址重新构造 Span:

void* next = *reinterpret_cast<void**>(_freeList);
T* obj = static_cast<T*>(_freeList);
_freeList = next;
new (obj) T(args...);

Delete 则执行相反过程:

obj->~T();
*reinterpret_cast<void**>(obj) = _freeList;
_freeList = obj;

在稳定复用阶段,这条路径不调用通用 new/delete,也不产生独立链表节点。它把 Span 的“逻辑生命周期”和承载 Span 的“存储生命周期”分开:构造函数和析构函数仍然按次执行,底层槽位却可以长期循环使用。

3.4 为什么对象池仍放在 Page Cache 锁内#

当前 ObjectPool 没有内部互斥量。Page Cache 是它的唯一所有者,所有创建与回收操作都在 Page Cache _mtx 的同步范围内完成。这保持了较简单的所有权关系,也避免了对象池再嵌套一把锁。

代价是,ObjectPool 节省下来的时间主要体现为缩短 Page Cache 临界区,而不是让多个线程同时操作池。若未来拆分 Page Cache 锁,必须重新确认 ObjectPool 的同步方式,不能直接把当前实现当作线程安全组件使用。

4 两项优化为什么要一起看又必须分开测#

RadixTree 和 ObjectPool 都服务于 Span 元数据,但解决的是不同问题:

组件优化对象原始成本优化后的主要操作
RadixTreePAGE_ID -> Span* 映射哈希、桶和节点访问固定层数的位段索引
ObjectPoolSpan 存储每个 Span 独立 new/delete批量页、游标切分、空闲链表复用

两者一起接入 Page Cache 后,典型慢路径变为:

系统页或缓存 Span
│
├─ ObjectPool 创建 Span 元数据
│
└─ RadixTree 登记每个 PAGE_ID
│
对象归还 ─> PAGE_ID 查询 ─┘
│
└─ 合并后 ObjectPool 回收旧 Span

端到端 benchmark 只能给出整个分配器的最终耗时,无法区分页号查询和 Span 复用各自贡献了多少。因此本文没有简单地用“第一版总耗时减去优化版总耗时”进行归因,而是分别构造两个机制级实验。这样的数字不等于真实业务收益,却更适合回答“这项数据结构替换本身是否有效”。

5 实验方法#

5.1 环境与计时#

实验在第一版论文使用的同一台 Intel Core i7-13700K、32 GB 内存、Windows 10 64 位主机上运行。测试程序使用 MSVC 19.43.34810,Release x64、C++17、/O2、NDEBUG 编译,计时器为 std::chrono::steady_clock。

每个测试先预热,再正式执行 9 次并取中位数。每轮交替改变优化实现与基线实现的执行先后顺序,以降低温度、调度和缓存状态造成的固定顺序偏差。所有查找都通过校验和产生可观察结果,防止编译器删除循环。

完整程序见 benchmark_metadata_paths.cc,原始计时见 metadata_microbenchmark_raw.csv,汇总数据见 metadata_microbenchmark.csv。

5.2 页号映射实验#

映射表包含 131072 个有效页号,查询阶段执行 12000000 次保证命中的伪随机访问。基线 unordered_map 在插入前执行 reserve(131072),避免因未预留容量而人为放大重哈希成本。查询使用 operator[],与 Release 构建中 Page Cache 对已存在页号的实际访问方式一致。

实验设置两种地址布局:

  • 连续页。 页号为 base + i,模拟一段或少量大段连续虚拟内存;
  • 分段页。 每个区域包含 64 个连续页,相邻区域间隔 4096 页,模拟多个分散的系统页区域。

分段布局仍限制在合理的用户态地址范围内,没有把页号均匀撒满整个 52 位空间。后者会明显放大当前 RadixTree 的稀疏分支内存成本,却不符合这个分配器以连续页块为主的使用方式。

5.3 Span 对象池实验#

对象池实验维护 4096 个同时存活的 Span。每轮先构造 4096 个 Span,再全部销毁,共执行 1024 轮,即 4194304 个完整的“构造加销毁”周期。

优化组使用实际的 ObjectPool<Span>::New/Delete,基线使用 new Span/delete。正式计时前,ObjectPool 已经建立足以容纳 4096 个 Span 的空闲槽位,因此该实验测量的是设计目标中的稳定复用路径,不包含对象池首次 VirtualAlloc 的冷启动成本。

6 实验结果#

6.1 RadixTree 页号映射#

操作地址布局RadixTree 中位耗时unordered_map 中位耗时RadixTree 加速比
插入 131072 条映射连续页0.8648 ms6.7293 ms7.78 倍
查询 12000000 次连续页98.9442 ms182.2086 ms1.84 倍
插入 131072 条映射分段页1.6205 ms6.4607 ms3.99 倍
查询 12000000 次分段页111.9964 ms270.1615 ms2.41 倍

换算为单次操作后,连续页查询由 15.18 ns 降至 8.25 ns,分段页查询由 22.51 ns 降至 9.33 ns。连续页插入时,RadixTree 只需 6.60 ns/条;分段布局需要创建更多分支,插入成本上升到 12.36 ns/条,但仍低于 unordered_map 的 49.29 ns/条。

结果体现出两个特点。

第一,连续页共享高位前缀,RadixTree 可以复用已经建立的上层节点,因此插入收益最大。第二,分段布局下 unordered_map 的随机节点访问代价进一步上升,而 RadixTree 的查询深度仍然固定,所以查询加速比从 1.84 倍扩大到 2.41 倍。

不过,RadixTree 并没有把查询变成一次数组访问。当前 52 位配置仍需走六层,分段地址也会降低层级节点的缓存局部性。它的优势来自路径可预测、无需哈希节点,以及相邻页共享前缀,而不是“树一定比哈希表快”这样的普遍结论。

6.2 ObjectPool 元数据复用#

实现4194304 个 Span 生命周期中位耗时单周期耗时
ObjectPool16.1680 ms3.85 ns
直接 new/delete125.4424 ms29.91 ns

ObjectPool 的稳定复用路径相对直接 new/delete 快 7.76 倍。原因并不神秘:测试开始后,所需槽位已经存在,每次 New/Delete 只执行空闲链表操作、Span 构造和 Span 析构;基线则需要为每个生命周期进入通用堆分配与释放路径。

这项结果对 Page Cache 的意义不只是“Span 创建更快”。Span 创建、合并回收都发生在共享锁保护的元数据操作中。对象池缩短这些操作后,其他线程占用同一临界区的等待时间也可能下降。但本实验是单线程微基准,没有测量锁竞争,因此这里使用“可能”而不是直接宣称并发吞吐获得 7.76 倍提升。

7 正确理解这些数字#

7.1 它们证明了什么#

实验可以支持以下结论:

  • 对当前页号位宽和典型连续页分布,RadixTree 的命中查询与插入都比预留容量后的 unordered_map 更快;
  • 对预热后的固定大小 Span,侵入式 ObjectPool 显著降低了重复构造与销毁的存储管理成本;
  • 两项改动分别针对映射和元数据存储,不依赖特定的上层工作负载即可独立验证。

7.2 它们不能证明什么#

实验不能直接证明整个 ThreadCachingMalloc 获得 1.84 倍、2.41 倍或 7.76 倍吞吐提升。真实分配路径还包含 Thread Cache 命中、Center Cache 分桶锁、FreeList 操作、Span 切分、内存触碰和操作系统页申请。若大部分请求始终命中 Thread Cache,页号映射和 Span 创建所占比例很低,端到端收益自然会小于微基准结果。

微基准也没有测量 RSS、缓存未命中、分支预测或锁等待时间。因此它更适合说明“被替换的机制本身更轻”,而不是完成全部性能归因。后续若要评估实际并发收益,应增加 Page Cache 进入次数、映射查询次数、锁持有时间和锁等待时间等内部计数器,并在相同工作负载下做逐项消融。

8 当前实现的工程边界#

8.1 RadixTree 的空间成本和生命周期#

当前 RadixTree 延迟创建分支,但每次创建中间层时会一次分配 512 个子节点。连续页可以很好地共享这些节点,极度稀疏且遍布大地址空间的页号则可能消耗较多内存。本文的分段实验已经展示了更多分支会提高插入成本,但没有统计实际提交内存。

此外,当前类没有析构函数,erase 只清除叶子存在位,不回收叶子数组和中间节点。这与 Page Cache 进程级生命周期相符,也避免频繁收缩树,但它意味着树占用的峰值结构内存会保留到进程结束。若把该类用于可反复创建销毁的普通容器,就会发生内存泄漏。

operator[] 在路径不存在时会自动创建节点。Page Cache 在 Release 模式下依赖“查询页号必然已经登记”这一不变量;如果该不变量被破坏,查询可能悄悄创建空映射。更稳健的生产实现应让只读查询使用不会创建节点的 find,并把断言之外的错误处理保留下来。

8.2 ObjectPool 的通用性限制#

当前 ObjectPool 只把槽位对齐到指针宽度,没有按 alignof(T) 处理过度对齐类型。它用于当前 Span 没有问题,但还不能被无条件视为任意 C++ 类型的通用对象池。

对象池也没有记录并释放所有历史页块。对于与 Page Cache 同寿命的 Span 池,这是用内存保留换取后续复用速度的主动选择;若工作负载曾达到很高峰值后长期下降,池内存不会主动归还操作系统。

最后,ObjectPool 本身不是线程安全的,构造函数抛异常时的槽位恢复也没有单独处理。这些边界在当前 Page Cache 所有权和 Span 构造函数下尚可控制,但在扩大用途前需要补齐。

8.3 全局锁仍然存在#

RadixTree 和 ObjectPool 优化的是锁内工作量,并未改变 Page Cache 由一把全局 _mtx 串行化的事实。当线程缓存频繁回收对象时,真正的上限仍可能来自锁竞争。更进一步的优化可以考虑区分映射只读路径和 Span 结构修改路径,但必须先解决 Span 生命周期:读线程拿到 Span* 时,另一个线程不能同时合并并回收该 Span。

因此,下一步不应只是机械地把 mutex 换成 shared_mutex。需要先定义读者持有期、删除时机以及映射更新的一致性,再选择分段锁、读写锁、epoch 或其他方案。

9 后续工作#

基于本文结果,后续可以沿四个方向继续:

  1. 为 RadixTree 增加析构、禁止浅拷贝,并统计每层节点数与实际内存占用;
  2. 将只读查询改为不创建节点的接口,避免 Release 模式依赖断言维持正确性;
  3. 按 alignof(T) 修正 ObjectPool 槽位对齐,并记录所有页块以支持可选的整体释放;
  4. 为 Page Cache 增加查询次数、树分支创建数、Span 池命中率、锁持有时间和等待时间计数器,完成端到端消融实验。

这些工作比继续调整单个常数更重要,因为它们能回答性能改善到底发生在哪里,以及速度换来了多少常驻内存。

10 结论#

第一版 ThreadCachingMalloc 已经用三级缓存把大多数小对象操作留在线程本地,但共享层仍沿用了 unordered_map 和 new/delete 两个通用机制。本文在保留三级缓存架构和外部接口的前提下,使用 RadixTree 与 ObjectPool 重新设计了页号映射和 Span 存储路径。

RadixTree 利用 PAGE_ID 的整数结构,把 52 位页号拆成固定六层索引;ObjectPool 利用 Span 固定大小、统一所有权和高复用率的特点,把元数据存储变成页级批量申请与侵入式链表循环。独立微基准中,RadixTree 的命中查询相对 unordered_map 快 1.84 至 2.41 倍,预热后的 ObjectPool 相对直接 new/delete 快 7.76 倍。这说明两项改动在机制层面达到了目标。

更重要的结论是:分配器优化不能只盯着用户对象的申请接口。页号映射、Span 构造、存在位、节点布局和锁内执行时间都属于分配器的真实成本。把这些元数据路径做得更确定、更局部、更可复用,才能为后续降低共享层竞争建立可靠基础。

复现材料#

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

收紧共享元数据路径:RadixTree 与 ObjectPool 在 ThreadCachingMalloc 中的设计与实验
http://soraonly.me/posts/radix-tree-object-pool-optimization/
作者
蒋志昂
发布于
2026-08-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录