优化 LLVM 的 bump 分配器
摘要
这篇博客文章详细介绍了对 LLVM 的 BumpPtrAllocator 进行的三项近期优化,通过移除冗余对齐、空指针检查以及每次分配的记账开销来减少快速路径开销,从而提升了 Clang、lld 及其他 LLVM 组件的性能。
<p><a href="https://lobste.rs/s/ltc5ca/optimizing_llvm_s_bump_allocator">评论</a></p>
查看缓存全文
缓存时间: 2026/06/29 06:24
# 优化 LLVM 的 bump allocator
来源:https://maskray.me/blog/2026-06-28-optimizing-llvm-bump-allocator
`BumpPtrAllocator` 是 LLVM 的缓冲分配器(arena allocator):每次分配时在一个 slab 内向前移动指针,并在分配器销毁时一次性释放所有内存。它支撑着 Clang 的 `ASTContext`、lld 的 `make` 对象池、TableGen 的记录以及其他许多 arena。以下是最近三次改动之前的快速路径代码:
```cpp
__attribute__((returns_nonnull)) void *Allocate(size_t Size, Align Alignment) {
BytesAllocated += Size;
uintptr_t AlignedPtr = alignAddr(CurPtr, Alignment);
size_t SizeToAllocate = Size;
#if LLVM_ADDRESS_SANITIZER_BUILD
SizeToAllocate += RedZoneSize;
#endif
uintptr_t AllocEndPtr = AlignedPtr + SizeToAllocate;
if (LLVM_LIKELY(AllocEndPtr <= uintptr_t(End) && CurPtr != nullptr)) {
CurPtr = reinterpret_cast<void*>(AllocEndPtr);
...
return reinterpret_cast<void*>(AlignedPtr);
}
return AllocateSlow(Size, SizeToAllocate, Alignment);
}
```
三次改动对标注的三行进行了精简。
## 最小对齐跳过重对齐(#205240 (https://github.com/llvm/llvm-project/pull/205240))
`alignAddr(CurPtr, Alignment)` 是浪费的:刚被移动过的指针通常已经足够对齐。#205240 (https://github.com/llvm/llvm-project/pull/205240) 将每个大小向上取整到 `MinAlign`(默认 8),这样快速路径只在对齐要求超过 `MinAlign` 时才进行重对齐。我从《Bump Allocation: Up or Down?》(https://coredumped.dev/2024/03/25/bump-allocation-up-or-down/) 中学到了这个技巧:
```cpp
SizeToAllocate = alignToPowerOf2(SizeToAllocate, MinAlign);
uintptr_t AlignedPtr = uintptr_t(CurPtr);
if (Alignment.value() > MinAlign) AlignedPtr = alignAddr(CurPtr, Alignment);
```
`SpecificBumpPtrAllocator` 使用 `MinAlign = 1`——它的 `DestroyAll` 按 `sizeof(T)` 步进,因此需要紧凑排列,而不是取整。我在第一次尝试中犯了一个错误:`nullptr` 加上非零偏移量触发了 UBSan 诊断。通过在 `uintptr_t` 域中保留数学运算来解决。
## 哨兵 End 消除了空指针检查(#205485 (https://github.com/llvm/llvm-project/pull/205485))
`__attribute__((returns_nonnull))` 指定返回值非空。在一个 `CurPtr` 和 `End` 均为空的分配器中,`Allocate(0)` 原本返回空。2022 年,https://reviews.llvm.org/D125040 在快速路径条件中增加了 `&& CurPtr != nullptr` 检查,这并不理想。我尝试过:
```cpp
if (LLVM_LIKELY(AlignedPtr + SizeToAllocate - 1 < uintptr_t(End))) { ... }
```
但后来采纳了 aengelke 的建议。将 end 存储为超过实际 end 的哨兵(`EndSentinel = realEnd + 1`,无 slab 时为 `0`),这样将两个条件合并为一个无符号比较:
```cpp
if (LLVM_LIKELY(AllocEndPtr < EndSentinel)) { ... }
```
空的分配器有 `EndSentinel == 0`,因此 `AllocEndPtr < 0` 始终为假,空情况直接落入慢路径,无需单独分支。
## 去掉每次分配的统计(#205711 (https://github.com/llvm/llvm-project/pull/205711))
`BytesAllocated += Size` 在每次分配时对成员进行读-修改-写,它支持 `getBytesAllocated()` 报告 *请求的* 字节数——与 `getTotalMemory()` 的 slab 容量不同。它只有统计/诊断消费者:lldb 的 ConstString 内存报告、clangd 的调试日志、TableGen 的 `dumpAllocationStats`,以及一个 Clang 回归测试。去掉该成员并将这些消费者迁移(大多数迁移到 `getTotalMemory()`)消除了热路径存储。
**细节:红区和 ABI。** ASan 红区大小也是一个成员。用 `#if LLVM_ADDRESS_SANITIZER_BUILD` 在发行版中去掉它会成为 ABI 隐患:该宏是 *每个翻译单元* 的,因此一个 ASan 检测的 TU 和未启用 ASan 的 `libLLVM` 会在结构体布局上默默不一致。该成员改为用 `LLVM_ENABLE_ABI_BREAKING_CHECKS` 守卫,该宏在每个库构建中是固定的,并通过链接时强制(通过 `EnableABIBreakingChecks` 符号);然后红区运算由这两个宏共同守卫。
结合后的快速路径变为:
```cpp
void *Allocate(size_t Size, Align Alignment) {
size_t SizeToAllocate = Size;
#if LLVM_ADDRESS_SANITIZER_BUILD && LLVM_ENABLE_ABI_BREAKING_CHECKS
SizeToAllocate += RedZoneSize;
#endif
SizeToAllocate = alignToPowerOf2(SizeToAllocate, MinAlign);
uintptr_t AlignedPtr = uintptr_t(CurPtr);
if (Alignment.value() > MinAlign)
AlignedPtr = alignAddr(CurPtr, Alignment);
uintptr_t AllocEndPtr = AlignedPtr + SizeToAllocate;
if (LLVM_LIKELY(AllocEndPtr < EndSentinel)) {
CurPtr = reinterpret_cast<void*>(AllocEndPtr);
...
return reinterpret_cast<void*>(AlignedPtr);
}
return AllocateSlow(Size, SizeToAllocate, Alignment);
}
```
## 生成的汇编
分配一个典型的 arena 对象——通过 `Allocate()` 分配一个 24 字节、8 字节对齐的节点——编译成六条指令的快速路径(`clang -O2`,发行版):
```asm
mov rax, [rdi] # CurPtr(也是返回值)
lea rcx, [rax + 0x18] # new = CurPtr + 24
cmp rcx, [rdi + 0x8] # 与 EndSentinel 比较
jae .slow
mov [rdi], rcx # CurPtr = new
ret
```
这与经典的 bump 快速路径一致。*向下* 移动的分配器不需要 `rax`/`rcx` 的区别——少一个活跃值,但指令数相同。LLVM 设计为向上移动:`identifyObject`、分配顺序以及 `SpecificBptrPtrAllocator::DestroyAll` 的向前 `sizeof(T)` 步进都基于此。剩余的差距在于空间,而非指令。
## 整体编译时影响
这些改动使 `Allocate` 低于内联器的成本阈值,因此它的调用者(例如 `new (Context) T`)在之前调用非内联函数的位置现在可以内联。执行的指令数下降——但作为一种 *再分配*:内联链变长的目标文件变大,而其余文件因去掉了存储而略有缩小。性能提升在 stage2(由 stage1 Clang 构建)比 stage1(由系统 GCC 构建)更明显。在 `main` 之上回退所有三个改动可以隔离它们的组合效果(比较 (https://llvm-compile-time-tracker.com/compare.php?from=dbd070fbd793c8a9129044abd669466e87d2ea8e&to=3a7d64a882421052101899d7d9c23685db5fd355&stat=instructions:u)):
显著(≥3σ 相对于测量噪声):🟢 改善。未标记 = 在噪声范围内。
| 配置 | instructions:u | max-rss |
|------|----------------|---------|
| stage1-O3 | −0.04% | +0.04% |
| stage1-ReleaseThinLTO | −0.04% | −0.01% |
| stage1-ReleaseLTO-g | −0.04% | +0.06% |
| stage1-O0-g | 🟢 −0.09% | +0.25% |
| stage1-aarch64-O3 | −0.04% | +0.04% |
| stage1-aarch64-O0-g | 🟢 −0.12% | −0.01% |
| stage2-O3 | 🟢 −0.14% | −0.15% |
| stage2-O0-g | 🟢 −0.36% | −0.06% |
## 要点
- 一个 bump 分配器的快速路径本质上是几条指令的实质工作,包裹在重对齐和统计中;每一项都可以从常见情况中提取出来。
- 将“空”编码为 `0` 哨兵,将空指针检查折叠到边界比较中。
- 可测量的指令数收益来自于更廉价的 `Allocate` 解锁的内联,而非去除的微操作——并且它表现为大小的 *再分配*,而非统一缩小。
- 影响布局的成员可以以 `LLVM_ENABLE_ABI_BREAKING_CHECKS`(链接时强制)为键,但绝不能以每个 TU 的 `LLVM_ADDRESS_SANITIZER_BUILD` 为键。
相似文章
一个更快的 Rust 内存块分配器
Stumpalo 是一个新的高性能 Rust 内存块分配器(bump allocator),在各类内存分配操作的基准测试中,其性能显著优于 blink 和 bumpalo 等现有替代方案。它还支持作用域栈,并已作为 Rust crate 发布。
libffi 的性能改进
本文详细介绍了 libffi 中的一项性能改进:将参数放置缓存为扁平移动列表(即“计划”),从而消除了每次函数调用时的冗余重新分类,在不使用 JIT 编译的情况下实现了显著的加速。
过早优化有时也挺有趣
一篇探讨为存储ping时间戳优化环形缓冲区数据结构的博客文章,讨论了标签联合、位域和结构体填充以减少内存占用。
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
mimalloc:面向现代时代的新型高性能可扩展内存分配器
mimalloc 是一个开源、高性能、可扩展的内存分配器,可作为 malloc 和 free 的直接替代品。它专为现代高并发应用和大内存规模而设计,被用于 Bing 等主要服务,并集成到 NoGIL CPython 和 Unreal Engine 等项目中。