C++包装器之类型擦除(Type Erasure)包装器之小对象优化(SBO, Small Buffer Optimization)示例(5)

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*),但若要放置 doubleEigen::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_bufferbool 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),64128 字节比较常见。
  • 目标:覆盖 80--95% 的常见类型即可,剩余由堆分配处理。

对齐

  • 常用 alignof(std::max_align_t)alignof(void*) 做对齐。
  • 若工程中放置 Eigen::Vector4d、SSE/AVX 对齐的类型,必须确保 buffer 对齐能满足(例如 alignas(32))。

复制/移动优化

  • 优先让类型实现 noexcept move ,在 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) 常见陷阱与防御措施

  1. 对齐不够 => UB

    总是用 std::aligned_storage_talignas(...) 明确对齐。若不确定对齐,请 conservative(使用 alignof(std::max_align_t))。

  2. 缓冲区太小 => 溢出 UB

    在构造前必须 检查 sizeof(Model)alignof(Model) 与 buffer 限制;否则应强制 heap allocate。

  3. 异常安全 :构造中抛出时要保证对象处于一致状态。最好在 placement-new 后再设置 ptr_in_sbo_,或者采用临时 guard。

  4. 析构 :如果对象并不在 sbo 中而 ptr_ 仍错指向 buffer,会双析构或内存泄露。保持一致标志 in_sbo_ 严格更新。

  5. 对象生命周期与 std::launder :C++17 后在 placement-new 后访问对象地址时,若先前有不同类型活跃对象,理论上要用 std::launder。在实践中对缓冲区新构造后直接 reinterpret_cast<T*>(&buffer) 使用是常见做法,但严格标准党会建议 std::launder。示例里为简洁未展示 launder,若你在高标准审计环境下使用建议加上。

  6. 类型大小泄露/ABI:若把 module 序列化或跨 ABI 边界传递,注意不同编译器/编译选项的布局差异。SBO 缓冲区是 header-level 的决定。

  7. 多线程:包装器实例不可无锁地在多个线程间共享并写入,除非提供同步。这与任何非线程安全容器一样。


8) 何时使用 SBO

  • 使用场景适合

    • 每帧创建/销毁大量短生命周期对象(残差、临时 functor、事件处理器等)。
    • 性能关键且频繁分配/释放对象。
    • 目标对象多数较小(统计支撑)。
  • 不适合

    • 对象大且少量(例如放大型稀疏矩阵、图像贴片)
    • 内存 footprint 极其关键(每个 wrapper 都增加 SBO_SIZE

9) 使用总结

  • 使用 std::aligned_storage_talignas 定义缓冲区
  • 在构造前检查 sizeof(Model) <= SBO_SIZE && alignof(Model) <= alignof(Buffer)
  • placement-new 时保证异常安全(若构造失败不要把 ptr_ 标记为已就绪)
  • 提供 copy/move/assign/destruct 的正确实现(遵循 RAII)
  • 考虑实现无虚表 VTable 版本以极致优化调用路径
  • 基准测试以确定 SBO_SIZE 的合理值并衡量收益

相关参考内容:C++包装器之类型擦除(Type Erasure)包装器详解(4)

相关推荐
广都--编程每日问1 小时前
c++右键菜单统一转化文件为utf8编码
c++·windows·python
curry____3031 小时前
study in PTA(高精度算法与预处理)(2025.12.3)
数据结构·c++·算法·高精度算法
lijiatu100862 小时前
[C++] QTimer与Qt事件循环机制 实验探究
c++·qt
三月微暖寻春笋2 小时前
【和春笋一起学C++】(四十九)C++中string类的简介
c++·cstring·string类·string类的实现·string类方法
Bona Sun2 小时前
单片机手搓掌上游戏机(二十一)—pico运行doom之修改编译
c语言·c++·单片机·游戏机
松涛和鸣2 小时前
23、链式栈(LinkStack)的实现与多场景应用
linux·c语言·c++·嵌入式硬件·ubuntu
CC.GG2 小时前
【C++】面向对象三大特性之一——继承
java·数据库·c++
Tandy12356_2 小时前
手写TCP/IP协议栈——数据包结构定义
c语言·网络·c++·计算机网络
繁华似锦respect2 小时前
HTTPS 中 TLS 协议详细过程 + 数字证书/签名深度解析
开发语言·c++·网络协议·http·单例模式·设计模式·https