周五下午两点,你在国内一家头部私募的做市桌写 C++。早盘上线了一版新写的 on-tick 处理流程,沪深300成分股的 tick 流压进策略;你顺手 perf record 采了五秒看火焰图。策略逻辑本身只占 60% 周期,另外 40% 不在你写的任何函数里——而是稳稳落在 _int_malloc、_int_free、malloc_consolidate 这三个 glibc 内部函数上,深陷 ptmalloc2。每个 tick 都在内层 new 一段 252 长的 std::vector<double> 跑一次 Monte Carlo 重定价,期末再析构——形状每次都一样,默认分配器(default allocator)却被迫每次按最坏情况走完整流程。L1 教你看见这种瓶颈,L2 教你按缓存的胃口铺数据。本课的活很具体:当剖析器指出 malloc 已成为可测量的份额时,你换掉分配器——只在需要换的那段路径上换。
默认分配器究竟贵在哪
glibc Linux 上,new 与 malloc 都走 ptmalloc2。底层维护着每线程一份 arena(私有小堆)、tcache(小对象的线程本地缓存)、按尺寸分箱的自由链表(free list,fastbins / smallbins / largebins),以及堆扩张时退化到 brk 或 mmap 系统调用的兜底。无竞争快路径——一次小分配从 tcache 或 fastbin 上取——约 20–30 ns,这是基线。绝大多数代码这点开销隐形:一次请求一次分配,30 ns 对几毫秒预算根本不值得讨论。
消化不了这点开销的,是那种在已知模式下每次迭代都按同一形状分配并释放的热路径。这里最坏情况开始有意义。多线程争抢同一 arena 的自由链表时,锁竞争把分配器在多核间串行化;长跑后的碎片会把一次请求踢出 fastbin 落到 unsorted bin 的慢首次匹配搜索;brk 扩堆要几百 ns;新页首次触碰陷入内核要几千 ns。这些在平均值里看不见,在 99 分位长尾里都在——HFT 桌付你工资正是为了管那条尾巴。一条操作规则就够:只有当剖析器证明分配器相关周期占热路径的可测量份额时,自定义分配器才值得写。 没有这条证据,默认分配器是对的;自己手写的分配器只是给后来人留维护负担。
竞技场分配器
第一种模式是竞技场分配器(arena allocator),也称 bump 或 monotonic 分配器。概念两句话:你持有一段连续的 std::byte 缓冲区,以及一个名为 next_ 的指针,指向最近一次分配末尾的下一个字节。要分配 size 字节、对齐到 align,就把 next_ 向上取整到 align 的倍数,把得到的指针交给调用者,然后把 next_ 前推 size 字节;要释放某个单独对象,什么都不做;要一次性释放全部,把 next_ 重置回缓冲区起点。竞技场是「按请求」(per-request)或「按 tick」(per-tick)暂存空间的对的工具——所有生命期都被你能控制的某个边界框住的对象。代价也很直白:你失去了释放单个对象的能力,只能在某个时间点把整块竞技场一次性归还。
class ArenaAllocator {
public:
explicit ArenaAllocator(std::size_t capacity)
: buffer_(std::make_unique<std::byte[]>(capacity)),
next_(buffer_.get()),
end_(buffer_.get() + capacity) {}
void* allocate(std::size_t size, std::size_t align) {
auto p = reinterpret_cast<std::uintptr_t>(next_);
auto aligned = (p + align - 1) & ~(align - 1);
if (aligned + size > reinterpret_cast<std::uintptr_t>(end_))
throw std::bad_alloc{};
next_ = reinterpret_cast<std::byte*>(aligned + size);
return reinterpret_cast<void*>(aligned);
}
void deallocate(void*, std::size_t) {} // no-op; whole arena freed via reset()
void reset() { next_ = buffer_.get(); }
std::size_t bytes_used() const { return static_cast<std::size_t>(next_ - buffer_.get()); }
private:
std::unique_ptr<std::byte[]> buffer_;
std::byte* next_;
std::byte* end_;
};
两段算术承担了实现。第一段是 (p + align - 1) & ~(align - 1)——经典的二的幂对齐上取整。它之所以成立,是因为 C++ 里任何对象类型的 alignof(T) 都是 2 的幂(语言保证),把已经加上 align - 1 的值的低位清零,正好得到下一个 align 的倍数。第二段是 deallocate 的空实现。竞技场的全部意义就在于「不存在按对象释放」;整块缓冲区在 reset() 时一次还回。一次分配在汇编层面只剩几条指令——加、与、比较、存——耗时约 1–2 ns,比 ptmalloc2 30 ns 的快路径快一个量级,且没有系统调用、没有锁、不存在碎片。
固定大小池分配器
第二种模式是固定大小池分配器(fixed-size pool allocator),它与竞技场互补。当你有大量同尺寸小对象、生命期顺序却任意时,内存池(memory pool)就是对的工具——订单簿的价位节点、事件循环的事件结构、侵入式链表(intrusive linked list)节点都属于这一类。概念:持有一段连续缓冲区,被切成 N 个槽位,每个槽位大小够装一个 T、按 alignof(T) 对齐。维护一条单向自由链表,链表节点就是空槽。关键技巧在于这条链表是侵入式(intrusive)的:每个空槽自己存「指向下一个空槽的指针」,就放在槽自己的内存里——额外存储开销为 0。allocate() 从链表头弹一个;deallocate(p) 把 p 压回链表头。两边都是 O(1),都没有系统调用、没有锁、除了池空检测之外没有分支。
template <typename T>
class PoolAllocator {
public:
explicit PoolAllocator(std::size_t n_slots)
: storage_(std::make_unique<Slot[]>(n_slots)), free_head_(nullptr) {
for (std::size_t i = 0; i < n_slots; ++i) {
storage_[i].next = free_head_;
free_head_ = &storage_[i];
}
}
T* allocate() {
if (!free_head_) throw std::bad_alloc{};
Slot* s = free_head_;
free_head_ = s->next;
return reinterpret_cast<T*>(s);
}
void deallocate(T* p) {
auto* s = reinterpret_cast<Slot*>(p);
s->next = free_head_;
free_head_ = s;
}
private:
union Slot {
alignas(T) std::byte data[sizeof(T)];
Slot* next;
};
std::unique_ptr<Slot[]> storage_;
Slot* free_head_;
};
union Slot 是承重的关键写法。槽在用时,data 装一个 T;槽在自由链表上时,next 装下一节点指针。两者在内存里重叠;作者的纪律是任何时刻都记得当前那块槽以哪种身份出现。alignas(T) 标注字节数组,保证存储满足 T 的对齐要求——即便 T 比 union 自然继承的对齐更严。一次 allocate() 约 2 ns(load、load、store),deallocate() 同量级。这是国内 quant 桌订单簿核心在用的模式:每个价位一个小 struct,订单簿生命期内要装上百万条节点,每次 new / delete 是错的工具。把池建在 sizeof(PriceLevel) 上,这部分开销直接消失。
std::pmr——标准库给出的答案
C++17 在 <memory_resource> 里把上面两种模式做了标准化。std::pmr::memory_resource 是抽象基类;std::pmr::monotonic_buffer_resource 是标准的竞技场——与上面的 ArenaAllocator 思路一致,额外能力是初始缓冲区耗尽时,会退化到一个上游(upstream)memory_resource*(默认是 std::pmr::new_delete_resource())继续供给;std::pmr::unsynchronized_pool_resource 是单线程池;std::pmr::synchronized_pool_resource 是加锁的多线程变体,性能权衡留到 3.4.4 详谈。std::pmr::polymorphic_allocator<T> 是一个 STL 兼容的多态分配器(polymorphic allocator),内部只持有一个 memory_resource*,把所有分配请求转发过去。
杀手特性体现在容器上。std::pmr::vector<double> 是单一具体类型——它的分配器在运行时由构造它的那个 memory_resource* 决定。一个签名为 double compute_path_average(std::pmr::vector<double>& path) 的函数,可以同时接受由默认分配器、由 monotonic_buffer_resource、由池、或由任何自定义 resource 支撑的向量,既不需要模板参数,也没有实例化爆炸的代价。这是 C++17 对 STL 最痛的历史包袱的修正:std::pmr 之前,std::vector<T, AllocA> 与 std::vector<T, AllocB> 是无关类型,通用代码不得不对分配器做模板化。std::pmr 把这件事压成一个 memory_resource* 上的运行时分派。旧的 std::allocator 概念(allocate / deallocate / rebind / value_type 等)仍在,作为向后兼容;2026 年的新代码默认走 std::pmr——它就是为替换旧 Allocator 概念而生。
工作例:竞技场版 Monte Carlo
跨模块那条 Monte Carlo 欧式 call(European call)定价器是看清这次加速最合适的载体。基线版每条路径都 std::vector<double>(N_steps) 当场 new、出作用域时由析构函数 delete[] 还回。1M 条路径、N_steps = 252 步,就是 1M 次大约 2 KB 的堆分配,全部走默认分配器:
double simulate_one_path_baseline(std::mt19937_64& rng, double S0, double r,
double sigma, double T, std::size_t N_steps) {
std::vector<double> path(N_steps); // heap allocation here
double dt = T / static_cast<double>(N_steps);
double S = S0;
std::normal_distribution<double> norm(0.0, 1.0);
for (std::size_t k = 0; k < N_steps; ++k) {
double z = norm(rng);
S *= std::exp((r - 0.5 * sigma * sigma) * dt + sigma * std::sqrt(dt) * z);
path[k] = S;
}
return path.back();
// path's storage freed via the destructor — a `delete[]` call to the global allocator
}
竞技场版机械上是同一段代码,只是 path 的存储改从一段 4096 字节的栈上暂存缓冲区(scratch buffer)通过 std::pmr::monotonic_buffer_resource 切出来:
#include <memory_resource>
double simulate_one_path_arena(std::mt19937_64& rng, double S0, double r,
double sigma, double T, std::size_t N_steps) {
std::array<std::byte, 4096> scratch;
std::pmr::monotonic_buffer_resource arena(scratch.data(), scratch.size());
std::pmr::vector<double> path(N_steps, &arena);
double dt = T / static_cast<double>(N_steps);
double S = S0;
std::normal_distribution<double> norm(0.0, 1.0);
for (std::size_t k = 0; k < N_steps; ++k) {
double z = norm(rng);
S *= std::exp((r - 0.5 * sigma * sigma) * dt + sigma * std::sqrt(dt) * z);
path[k] = S;
}
return path.back();
// arena destroyed at scope exit; path's storage was never on the heap
}
循环里的算术按字节相同:相同的 RNG 种子、相同的 GBM 步长 、相同的末值。变的只是分配器。4096 字节的栈暂存足够装 字节的 path,还能放下 monotonic_buffer_resource 自己的簿记。竞技场就活在调用函数的栈帧里,创建成本大约两条 store 指令;pmr::vector 构造时一次 bump 拿到全部存储;作用域结束时两个对象一起析构,不调用一次堆。把两个定价器都用 Google Benchmark 跑,沪深300 ETF(510300.SH)的欧式 call 定价场景(,,,,),相同的确定性种子:
static constexpr std::size_t N_PATHS = 1'000'000;
static constexpr std::size_t N_STEPS = 252;
static constexpr double S0 = SCENARIO_S0; // region-specific via macro
static constexpr double K = SCENARIO_K;
static constexpr double R = SCENARIO_R;
static constexpr double SIG= SCENARIO_SIG;
static constexpr double T = SCENARIO_T;
static void BM_MonteCarloBaseline(benchmark::State& state) {
for (auto _ : state) {
std::mt19937_64 rng(42);
double sum = 0.0;
for (std::size_t i = 0; i < N_PATHS; ++i) {
double ST = simulate_one_path_baseline(rng, S0, R, SIG, T, N_STEPS);
sum += std::max(ST - K, 0.0);
}
double price = std::exp(-R * T) * sum / static_cast<double>(N_PATHS);
benchmark::DoNotOptimize(price);
}
}
BENCHMARK(BM_MonteCarloBaseline);
static void BM_MonteCarloArena(benchmark::State& state) {
for (auto _ : state) {
std::mt19937_64 rng(42);
double sum = 0.0;
for (std::size_t i = 0; i < N_PATHS; ++i) {
double ST = simulate_one_path_arena(rng, S0, R, SIG, T, N_STEPS);
sum += std::max(ST - K, 0.0);
}
double price = std::exp(-R * T) * sum / static_cast<double>(N_PATHS);
benchmark::DoNotOptimize(price);
}
}
BENCHMARK(BM_MonteCarloArena);
BENCHMARK_MAIN();
g++ -std=c++17 -O2 -g mc_bench.cpp -lbenchmark -lpthread -o mc_bench 编出来,taskset -c 2 ./mc_bench 锁到单核执行,在一台 2026 年的 Intel Xeon Gold 6342(Ice Lake-SP)上,竞技场版通常比基线快 20%–40%。具体比例随内循环中分配器开销所占的份额漂移:GBM 内核越紧,分配器份额越大,加速越多。输出的 price——CSI 300 ETF 4.30 行权 call 的 Monte Carlo 估计——必须按字节相同。同样的种子、同样的代码路径、std::exp 同样顺序的输入;换分配器不应该动一个浮点比特。这种「按字节相等」正是这种重构能在周五下午稳妥上线的理由。
生产端的两层组合
收尾一条规则:生产 C++ 里,两种分配策略叠加用。热路径以外,用 LD_PRELOAD 在进程级别替掉 glibc 的 ptmalloc2——jemalloc(Facebook 出品,Redis 在用)或 tcmalloc(Google 出品,ClickHouse 在用),能让二进制里所有 new / delete 平均快 20%–30%,99 分位长尾收得更紧,且不用改一行源码。热路径以内,按访问模式定制的 ArenaAllocator 或 PoolAllocator 把分配成本压到接近零。国内头部 quant 桌(幻方 / 鸣熙 / 九坤 / 明汯 / 灵均)的 C++ 框架普遍采用这个双层组合:LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./your_binary 当全局分配器,再把每条热路径的 arena 与 pool 编进 on-tick 处理函数和订单簿数据结构里。操作规则只有一句:热路径之内用自定义分配器,热路径之外通过 LD_PRELOAD 装 jemalloc 或 tcmalloc。
下一课
到这里,你能从头写一个竞技场分配器、一个固定大小池分配器、它们在 std::pmr 里的标准库对应版本,以及一份 Google Benchmark 测试程序,在跨模块的沪深300 ETF Monte Carlo 定价器上证出加速,而期权价估计一比特不动。第 4 课把内循环再往机器层推一步。分配器既然退出热路径,接下来浮现的瓶颈就是:内循环里不可预测的分支(branch)、阻塞编译器自动向量化的指针别名(pointer aliasing),以及把四 double 宽 __m256d 加法或乘法压成单条指令的 AVX2 内联函数(intrinsics)。本课的竞技场模式恰好是给向量化内循环喂缓存对齐暂存缓冲区的管道——L1 → L2 → L3 → L4 这条链至此串起来。
练习
Exercise
(a) 用 g++ -std=c++17 -O2 -g mc_bench.cpp -lbenchmark -lpthread -o mc_bench 编出 Monte Carlo 基准二进制,再用 taskset -c 2 ./mc_bench 锁核运行。对比 BM_MonteCarloBaseline 与 BM_MonteCarloArena 的每迭代纳秒数。在典型的 x86_64 服务器上,竞技场版应当快 20%–40%。(b) 在基准循环里把每个版本最后的 price 用 printf("%.17g\n", price); 打出来(只打一次,以 debug flag 控制),确认两个 price 是按字节相同的 double。一句话写明为什么换分配器不会影响这个数值。(c) 运行 taskset -c 2 perf stat -e cycles,instructions,page-faults,cache-misses ./mc_bench --benchmark_filter=BM_MonteCarloBaseline,再对 BM_MonteCarloArena 做同样事情。竞技场版的 page-faults 应该更少——通常少很多,因为基线每次新 std::vector 分配都可能触碰新页。(d) 把竞技场版里的 std::array<std::byte, 4096> 改成 std::array<std::byte, 1024>(装不下 252 个 double = 2016 字节)。重跑;观察到 std::pmr::monotonic_buffer_resource 在初始缓冲区耗尽时退化到上游 resource(默认是 std::pmr::new_delete_resource())——程序还能跑,但竞技场加速完全消失。一句话总结这条规则。
提示
42、同样的 GBM 步长、同样的 payoff 求和,两个版本应该返回按字节相同的 double。提示
N_steps * sizeof(double),还要留点给 pmr 自身簿记。一旦不够,monotonic_buffer_resource 静悄悄退化到堆,arena 的加速就完全消失了。