1) 为什么要用 小对象优化( Small Buffer Optimization)SBO
类型擦除包装器(例如 std::function/std::any)通常在内部对任意类型做 new Model<T>(...) 动态分配并保存基类指针。动态分配的成本(堆分配/释放、缓存抖动)在高频路径(比如每帧创建残差对象、或实时点云处理)会成为瓶颈。
SBO 的思路 :在包装器内部预留一段固定大小、对齐良好的缓冲区(stack-like buffer)。当要存放的具体对象 T 足够小时,将其直接 placement new 在这个缓冲区里,不做堆分配。只有当对象太大或对齐不兼容时,才回退到堆分配。
收益:
- 避免堆分配:减少延迟和内存碎片
- 更好缓存局部性:对象与容器对象邻近
- 对小可复制/可移动的 functor/模块/残差特别有效
代价:
- 包装器对象变大(包含 buffer)
- 更复杂的实现(要管理析构/复制/移动)
- 对"很大的"类型仍需堆分配
2) 内存与对齐基础
预留缓冲区通常用 std::aligned_storage_t<SIZE, ALIGN>(C++20 之前)或 alignas(...) char buf[SIZE](C++11+):
SIZE:字节数,决定能内联存放的最大对象大小ALIGN:对齐要求,通常取alignof(std::max_align_t)或alignof(void*),但若要放置double、Eigen::Vector4d等需更大对齐则要更高- 使用
placement new在该缓冲区构造对象 - 销毁时手动调用析构
ptr->~T() - C++17+ 要注意
std::launder在某些内存重用场景(严格别名规则)
示例声明:
cpp
static constexpr size_t SBO_SIZE = 64;
alignas(std::max_align_t) unsigned char buffer[SBO_SIZE];
或:
cpp
using Buffer = std::aligned_storage_t<SBO_SIZE, alignof(std::max_align_t)>;
Buffer buffer;
关键:必须保证缓冲区的对齐能满足将要放入对象的对齐,否则 UB。
3) 实现策略对比
两种主流实现思路:
A. 传统虚拟接口(Concept / Model)+ SBO(简单,易实现)
- 有一个抽象
Concept(虚析构 + 纯虚方法) Model<T>继承Concept,在Model<T>内部T obj;或把T放到外部 buffer(placement new)Wrapper持有Concept* ptr:当T小则ptr指向 buffer(reinterpret_cast),否则ptr指向 heap- 在 copy/move 时调用
ptr->clone_to(destination)或ptr->clone_inplace(...)等
优点:实现直观、容易扩展多个接口方法。
缺点:每次调用仍通过虚表间接(不过这是必须的),以及虚函数开销与不可避免的间接跳转。
B. 无虚表、函数指针表(vtable struct)+ SBO(高性能)
- 维护一个
VTable结构,包含函数指针(destroy,copy,move,invoke, ...) Wrapper存储:缓冲区 +VTable*+ 标志(是否在-buffer)- 对象放到 buffer 或 heap,
vtable指向针对具体T的静态函数集合 - 调用时用直接函数指针,不走 C++ 虚函数表(但仍是间接函数指针调用)
优点:更轻量(可实现 noexcept move 优化、减少虚表查找),std::function/llvm::any 系统就类似此类实现。
缺点:实现更繁琐,需要仔细处理移动/拷贝语义和异常安全。
4) 完整示例:带 SBO 的类型擦除包装器(虚函数版本,含 copy/move/clone)
下面是一个工程可用且带 SBO 的 多方法 类型擦除示例(适合模块或可调用对象)。这个实现选择"虚接口 + placement new 的 Model 放 buffer 或 heap"的方式,代码尽量完整,注释说明关键点。
cpp
// sbo_type_erasure.hpp
#pragma once
#include <memory>
#include <type_traits>
#include <utility>
#include <new>
#include <cassert>
class IModuleConcept {
public:
virtual ~IModuleConcept() = default;
virtual std::unique_ptr<IModuleConcept> clone_heap() const = 0; // clone to heap
virtual void clone_inplace(void* buffer) const = 0; // clone into raw buffer (placement-new)
virtual void process() = 0;
virtual double cost() const = 0;
};
template<typename T>
class ModuleModel : public IModuleConcept {
public:
T obj;
template<typename U>
ModuleModel(U&& u) : obj(std::forward<U>(u)) {}
std::unique_ptr<IModuleConcept> clone_heap() const override {
return std::make_unique<ModuleModel<T>>(obj);
}
void clone_inplace(void* buffer) const override {
// placement-new into buffer; caller guarantees buffer has sufficient size and alignment
::new (buffer) ModuleModel<T>(obj);
}
void process() override { obj.process(); }
double cost() const override { return obj.cost(); }
};
// Wrapper with SBO
class Module {
static constexpr size_t SBO_SIZE = 64; // tuned
using Buffer = std::aligned_storage_t<SBO_SIZE, alignof(std::max_align_t)>;
IModuleConcept* ptr_ = nullptr; // points either into buffer_ (in-place) or heap
bool in_sbo_ = false;
Buffer buffer_;
void destroy_current() noexcept {
if (!ptr_) return;
if (in_sbo_) {
// call destructor in place
ptr_->~IModuleConcept();
} else {
delete ptr_;
}
ptr_ = nullptr;
in_sbo_ = false;
}
public:
Module() = default;
// constructor from any T that implements required methods
template<typename T, typename = std::enable_if_t<!std::is_same<std::decay_t<T>, Module>::value>>
Module(T&& v) {
using Model = ModuleModel<std::decay_t<T>>;
if (sizeof(Model) <= SBO_SIZE && alignof(Model) <= alignof(Buffer)) {
// placement-new into buffer
ptr_ = reinterpret_cast<IModuleConcept*>(&buffer_);
::new (ptr_) Model(std::forward<T>(v));
in_sbo_ = true;
} else {
// allocate on heap
ptr_ = new Model(std::forward<T>(v));
in_sbo_ = false;
}
}
// copy ctor
Module(const Module& o) {
if (!o.ptr_) return;
if (o.in_sbo_) {
// clone into our buffer
o.ptr_->clone_inplace(&buffer_);
ptr_ = reinterpret_cast<IModuleConcept*>(&buffer_);
in_sbo_ = true;
} else {
ptr_ = o.ptr_->clone_heap().release();
in_sbo_ = false;
}
}
// move ctor
Module(Module&& o) noexcept {
if (!o.ptr_) return;
if (o.in_sbo_) {
// move the in-place object: we clone into our buffer then destroy the source
o.ptr_->clone_inplace(&buffer_);
ptr_ = reinterpret_cast<IModuleConcept*>(&buffer_);
in_sbo_ = true;
o.destroy_current();
} else {
ptr_ = o.ptr_;
in_sbo_ = false;
o.ptr_ = nullptr;
}
}
Module& operator=(const Module& o) {
if (this == &o) return *this;
destroy_current();
if (!o.ptr_) return *this;
if (o.in_sbo_) {
o.ptr_->clone_inplace(&buffer_);
ptr_ = reinterpret_cast<IModuleConcept*>(&buffer_);
in_sbo_ = true;
} else {
ptr_ = o.ptr_->clone_heap().release();
in_sbo_ = false;
}
return *this;
}
Module& operator=(Module&& o) noexcept {
if (this == &o) return *this;
destroy_current();
if (!o.ptr_) return *this;
if (o.in_sbo_) {
o.ptr_->clone_inplace(&buffer_);
ptr_ = reinterpret_cast<IModuleConcept*>(&buffer_);
in_sbo_ = true;
o.destroy_current();
} else {
ptr_ = o.ptr_;
in_sbo_ = false;
o.ptr_ = nullptr;
}
return *this;
}
~Module() {
destroy_current();
}
void process() {
assert(ptr_);
ptr_->process();
}
double cost() const {
assert(ptr_);
return ptr_->cost();
}
bool empty() const { return ptr_ == nullptr; }
};
说明与要点:
SBO_SIZE需要工程测量(见后面的调优建议),上面示例用 64 字节做演示。ModuleModel<T>::clone_inplace必须在接收端缓冲区空间够且对齐正确时调用(示例假定对齐满足)。clone_heap()返回堆上新对象的unique_ptr,用于当目标无法放入 SBO 时的fallback。- copy/move 实现采用保守策略:移动时若源在 SBO 中,我们还用
clone_inplace把它 copy 到目标,然后销毁源;若源在堆则直接移动指针(更快)。 ~Module必须在析构前正确调用destroy_current()。- 示例里
IModuleConcept是虚表接口方式,性能上每次调用process()会发生虚函数跳转(通常可以接受)。
5) 更高性能:无虚函数 + vtable struct(示例框架)
若在工程中追求更低的间接开销,可以用一个手写 VTable(函数指针表)替代虚函数。核心思路:
VTable存储函数指针:destroy(void*),copy_to(void*, const void*),move_to(void*, void*),process(void*),cost(void*)Module存储VTable const* vptr_与buffer与bool in_sbo_- 对象放入 buffer 或 heap,
vptr_固定指向类型专属 vtable - 调用时直接
vptr_->process(ptr)(函数指针调用,比虚函数少一个 level? 实际微差异,关键是更灵活)
这个模式非常接近 std::function 的内部实现。
6) 性能/容量选择与调优建议
如何选 SBO_SIZE?
-
基于分配频率与典型对象大小做决定:用工具统计真实项目中被封装类型的大小分布(histogram)。
-
经验法:
- 对于
std::function类的场景,SBO =sizeof(void*) * 3 ~ 4(24--32 字节)常见。 - 对于 SLAM 模块(含 Eigen small vectors),
64或128字节比较常见。
- 对于
-
目标:覆盖 80--95% 的常见类型即可,剩余由堆分配处理。
对齐
- 常用
alignof(std::max_align_t)或alignof(void*)做对齐。 - 若工程中放置 Eigen::Vector4d、SSE/AVX 对齐的类型,必须确保 buffer 对齐能满足(例如
alignas(32))。
复制/移动优化
- 优先让类型实现
noexceptmove ,在Module的移动构造/赋值里可以选择在 noexcept 情况下直接memcpy/转移指针以便实现更快的移动。 - 若
T的移动异常安全不好,就 fallback 到 copy-and-swap 等策略。
内存布局与缓存
- 把
buffer_放在 wrapper 的对象里(而不是 heap),访问时更顺缓存。 - 小对象优化能显著降低缓存 miss 与分配延迟,提高每帧处理速度。
基准测试要点
- Measure:
throughput,latency,allocations/sec,cache-misses. - 场景:创建/销毁很多对象、调用方法、移动对象队列(STL容器 push_back/pop_back)。
- Compare:SBO vs no-SBO、virtual vs vtable-pointer versions。
7) 常见陷阱与防御措施
-
对齐不够 => UB
总是用
std::aligned_storage_t或alignas(...)明确对齐。若不确定对齐,请 conservative(使用alignof(std::max_align_t))。 -
缓冲区太小 => 溢出 UB
在构造前必须 检查
sizeof(Model)与alignof(Model)与 buffer 限制;否则应强制 heap allocate。 -
异常安全 :构造中抛出时要保证对象处于一致状态。最好在 placement-new 后再设置
ptr_与in_sbo_,或者采用临时 guard。 -
析构 :如果对象并不在 sbo 中而
ptr_仍错指向 buffer,会双析构或内存泄露。保持一致标志in_sbo_严格更新。 -
对象生命周期与 std::launder :C++17 后在 placement-new 后访问对象地址时,若先前有不同类型活跃对象,理论上要用
std::launder。在实践中对缓冲区新构造后直接reinterpret_cast<T*>(&buffer)使用是常见做法,但严格标准党会建议std::launder。示例里为简洁未展示launder,若你在高标准审计环境下使用建议加上。 -
类型大小泄露/ABI:若把 module 序列化或跨 ABI 边界传递,注意不同编译器/编译选项的布局差异。SBO 缓冲区是 header-level 的决定。
-
多线程:包装器实例不可无锁地在多个线程间共享并写入,除非提供同步。这与任何非线程安全容器一样。
8) 何时使用 SBO
-
使用场景适合:
- 每帧创建/销毁大量短生命周期对象(残差、临时 functor、事件处理器等)。
- 性能关键且频繁分配/释放对象。
- 目标对象多数较小(统计支撑)。
-
不适合:
- 对象大且少量(例如放大型稀疏矩阵、图像贴片)
- 内存 footprint 极其关键(每个 wrapper 都增加
SBO_SIZE)
9) 使用总结
- 使用
std::aligned_storage_t或alignas定义缓冲区 - 在构造前检查
sizeof(Model) <= SBO_SIZE && alignof(Model) <= alignof(Buffer) - placement-new 时保证异常安全(若构造失败不要把
ptr_标记为已就绪) - 提供 copy/move/assign/destruct 的正确实现(遵循 RAII)
- 考虑实现无虚表
VTable版本以极致优化调用路径 - 基准测试以确定
SBO_SIZE的合理值并衡量收益