局部缓冲区优化的应用远不止于短字符串,每当需要在运行时动态分配小块内存时,都应考虑使用此优化。在本节中,将探讨几种适用此优化的数据结构。
另一个常受益于局部缓冲区优化的常见数据结构是vector。vector本质上是特定类型数据元素的动态连续数组(字符串就是字节的vector,尽管空字符结尾赋予了字符串其特有的性质)。一个基本的vector,例如 C++ 标准库中的 std::vector,需要两个数据成员:一个数据指针和一个数据大小:
// Example 06
class simple_vector {
public:
simple_vector() = default;
simple_vector(std::initializer_list<int> il) :
n_(il.size()),
p_(static_cast<int*>(malloc(sizeof(int)*n_)))
{
int* p = p_;
for (auto x : il) *p++ = x;
}
~simple_vector() { free(p_); }
size_t size() const { return n_; }
private:
size_t n_ = 0;
int* p_ = nullptr;
};
vector通常是模板类,例如标准库中的 std::vector,为了简化说明,在此示例中使用了整数vector(将此类vector类改为模板类作为练习留给读者,这并不会改变局部缓冲区优化模式的应用)。只要vector数据足够小,我们就可以应用小型vector优化,将其数据直接存储在vector对象的内部:
// Example 06
class small_vector {
public:
small_vector() = default;
small_vector(std::initializer_list<int> il) :
n_(il.size()), p_((n_ < sizeof(buf_)/sizeof(buf_[0]))
? buf_ : static_cast<int*>(malloc(sizeof(int)*n_)))
{
int* p = p_;
for (auto x : il) *p++ = x;
}
~small_vector() {
if (p_ != buf_) free(p_);
}
private:
size_t n_ = nullptr;
int* p_ = nullptr;
int buf_[16];
};
可以像优化字符串那样,进一步优化vector,将局部缓冲区与指针在内存中重叠。但不能再像之前那样使用最后一个字节作为标签,因为vector的元素都可能具有任意值,而零值通常并不具有特殊意义。但无论如何都需要存储vector的大小,可以随时利用该大小来判断是否使用了局部缓冲区。还可以进一步利用这样一个事实:如果使用了局部缓冲区优化,vector的大小不可能非常大,因此不需要使用 size_t 类型的字段来存储:
// Example 07
class small_vector {
public:
small_vector() = default;
small_vector(std::initializer_list<int> il) {
int* p;
if (il.size() < sizeof(short_.buf)/
sizeof(short_.buf[0])) {
short_.n = il.size();
p = short_.buf;
} else {
short_.n = UCHAR_MAX;
long_.n = il.size();
p = long_.p = static_cast<int*>(
malloc(sizeof(int)*long_.n));
}
for (auto x : il) *p++ = x;
}
~small_vector() {
if (short_.n == UCHAR_MAX) free(long_.p);
}
private:
union {
struct {
int buf[15];
unsigned char n;
} short_ = { {}, '\0' };
struct {
size_t n;
int* p;
} long_;
};
};
这里,根据是否使用局部缓冲区,将vector的大小存储在 size_t long_.n 或 unsigned char short_.n 中。通过在短长度字段 short_.n 中存储 UCHAR_MAX(即255)来表示使用了远程缓冲区。由于该值大于局部缓冲区的大小,这个标记是明确无误的(如果局部缓冲区被扩大到能存储超过255个元素,则 short_.n 的类型需要更改为更长的整数类型)。
可以使用与字符串类似的基准测试,来衡量小型vector优化带来的性能提升。根据vector的实际大小,在创建和复制vector时,性能提升大约可达10倍,在多线程环境下运行基准测试时,提升可能更大。
当其他数据结构存储少量动态分配的数据时,也可以用类似的方式进行优化。这些数据结构的优化在本质上是相似的,但其中有一个值得注意的变体需要特别指出。
小型vector使用局部缓冲区来存储少量的vector元素。这是优化存储可变数量元素的数据结构的标准方法,尤其适用于元素数量通常较小的情况。这种优化的一种特定形式可应用于基于队列的数据结构,其中缓冲区在一端增长,在另一端消耗。如果队列在时候都只有少量元素,则可以使用局部缓冲区进行优化。这里常用的技术是循环缓冲区:的缓冲区是一个固定大小的数组 buffer[N],当元素添加到队列末尾时,最终会到达数组的末尾。此时,一些元素已从队列中取出,数组的前几个元素已不再使用。当到达数组末尾时,下一个入队的值将放入数组的第一个元素 buffer[0] 中。数组当作环来处理时,元素 buffer[N-1] 之后是元素 buffer[0](因此这种技术也称为环形缓冲区,ring buffer)。
循环缓冲区技术通常用于队列和其他数据结构,其中数据多次添加和删除,而时候存储的数据总量是有限的。以下是循环缓冲区队列的一种可能实现:
// Example 08
class small_queue {
public:
bool push(int i) {
if (front_ - tail_ > buf_size_) return false;
buf_[(++front_) & (buf_size_ - 1)] = i;
return true;
}
int front() const {
return buf_[tail_ & (buf_size_ - 1)];
}
void pop() { ++tail_; }
size_t size() const { return front_ - tail_; }
bool empty() const { return front_ == tail_; }
private:
static constexpr size_t buf_size_ = 16;
static_assert((buf_size_ & (buf_size_ - 1)) == 0,
"Buffer size must be a power of 2");
int buf_[buf_size_];
size_t front_ = 0;
size_t tail_ = 0;
};
此示例中,仅支持局部缓冲区;如果队列需要容纳的元素数量超过了缓冲区的大小,则 push() 调用将返回 false。本可以像在 small_vector 示例07中那样,切换到堆分配的数组。
此实现中,可以对索引 front_ 和 tail_ 进行无界递增,但当这些值用作局部缓冲区的索引时,会取其对缓冲区大小的模(即 index % buf_size_)。但这里应用了一种在处理循环缓冲区时非常常见的优化:缓冲区的大小设定为2的幂次方(通过 assert 语句强制执行)。这可将通用的(较慢的)取模运算(如 front_ % buf_size_)替换为更快的位运算(例如,front_ & (buf_size_ - 1),当 buf_size_ 是2的幂时,两者等价)。
我们也不必担心整数溢出问题:即使调用 push() 和 pop() 超过 $2^{64}$ 次,无符号整数索引值溢出后会回绕到零,队列仍能正常工作。
正如预期的那样,使用了局部缓冲区优化的队列在性能上远超通用队列(如 std::queue<int>),当然前提是在优化有效的情况下,即队列中的元素数量保持较小:
Benchmark Time items_per_second
BM_queue<std::queue<int>> 472 ns 67.787M/s
BM_queue<small_queue> 100 ns 319.857M/s
循环局部缓冲区在许多场景下都非常有效,尤其是在需要处理大量数据但同一时间只需保留少量元素的情况下。可能的应用包括网络和 I/O 缓冲区、并发程序中线程间交换数据的管道等。
现在,来看看局部缓冲区优化在常见数据结构之外的应用。
还有另一种截然不同,但能非常有效地应用局部缓冲区优化的场景 —— 存储可调用对象,即可像函数一样被调用的对象。许多模板类都提供使用可调用对象来自定义其部分行为的选项。例如,C++ 标准库中的 std::shared_ptr 允许用户指定一个自定义删除器。该删除器会在对象需要被删除时被调用,并传入待删除对象的地址,因此它是一个接受 void* 类型单个参数的可调用对象。可以是一个函数指针、成员函数指针,或一个函数对象(即定义了 operator() 的对象) —— 能够在 callable(p) 这种函数调用下编译通过的类型都可以使用。
然而,删除器不仅仅是一个类型;它是一个对象,并且在运行时指定,需要存储在 shared_ptr 能够访问到的某个位置。如果删除器是 shared_ptr 类型的一部分,就可以简单地在 shared_ptr 对象中(或者对于 C++ 的 shared_ptr 而言,是在其所有副本共享的引用对象中)声明一个该类型的成员变量。可以将其视为局部缓冲区优化的一个简单应用,如下所示的智能指针,会在指针超出作用域时自动删除对象(类似于 std::unique_ptr):
// Example 09
template <typename T, typename Deleter> class smartptr {
public:
smartptr(T* p, Deleter d) : p_(p), d_(d) {}
~smartptr() { d_(p_); }
T* operator->() { return p_; }
const T* operator->() const { return p_; } private:
T* p_;
Deleter d_;
};
我们更感兴趣的是另一类情况,就是处理类型擦除对象。这类对象的细节在专门讨论类型擦除的章节中已有阐述,但简而言之,其特点是可调用对象并非包含对象类型的一部分(从包含对象的类型中“擦除”了)。相反,可调用对象存储在一个多态对象中,并通过虚函数在运行时调用具有正确类型的对象。而这个多态对象本身,则通过基类指针进行操作。
现在,我们面临一个问题,这个问题在某种程度上与前面提到的小型vector类似:需要存储一些数据 —— 在本例中是可调用对象 —— 但其类型(因此大小)在编译时是未知的。通用的解决方案是动态分配此类对象,并通过基类指针进行访问。以智能指针的删除器为例,可以这样做:
// Example 09
template <typename T> class smartptr_te {
struct deleter_base {
virtual void apply(void*) = 0;
virtual ~deleter_base() {}
};
template <typename Deleter>
struct deleter : public deleter_base {
deleter(Deleter d) : d_(d) {}
void apply(void* p) override {
d_(static_cast<T*>(p));
}
Deleter d_;
};
public:
template <typename Deleter>
smartptr_te(T* p, Deleter d) : p_(p),
d_(new deleter<Deleter>(d)) {}
~smartptr_te() {
d_->apply(p_);
delete d_;
}
T* operator->() { return p_; }
const T* operator->() const { return p_; }
private:
T* p_;
deleter_base* d_;
};
Deleter 类型不再是指针类型的一部分,已擦除。所有指向相同 T 类型对象的智能指针都具有相同的类型 smartptr_te<T>(这里的 te 代表 type-erased,即类型擦除)。然而,为这种语法上的便利付出了高昂的代价 —— 每次创建智能指针时,都会发生一次额外的内存分配。
代价有多高?我们必须再次牢记性能第一法则 —— “高昂”只是一种猜测,直到通过实验得到证实,例如下面的基准测试:
// Example 09
struct deleter { // 简单的operator new删除器
template <typename T> void operator()(T* p) { delete p; }
};
void BM_smartptr(benchmark::State& state) {
deleter d;
for (auto _ : state) {
smartptr<int, deleter> p(new int, d);
}
state.SetItemsProcessed(state.iterations());
}
void BM_smartptr_te(benchmark::State& state) {
deleter d;
for (auto _ : state) {
smartptr_te<int> p(new int, d);
}
state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_smartptr);
BENCHMARK(BM_smartptr_te);
BENCHMARK_MAIN();
对于带有静态定义删除器的智能指针,可以预期每次迭代的开销与之前测量的 malloc() 和 free() 调用开销非常相似:
Benchmark Time Items per second
BM_smartptr 21.0 ns 47.5732M/s
BM_smartptr_te 44.2 ns 22.6608M/s
而对于类型擦除的智能指针,需要进行两次内存分配而非一次,创建指针对象所需的时间翻倍。顺便提一下,我们也可以测量原始指针的性能,其结果在测量精度范围内应与智能指针相同(这也是 std::unique_ptr 标准设计的目标之一)。
我们可以在此应用相同的局部缓冲区优化思想,而且这种优化可能比在字符串上的应用更为有效;毕竟,大多数可调用对象都很小。然而,不能完全依赖,必须处理可调用对象大于局部缓冲区的情况:
// Example 09
template <typename T> class smartptr_te_lb {
struct deleter_base {
virtual void apply(void*) = 0;
virtual ~deleter_base() {}
};
template <typename Deleter>
struct deleter : public deleter_base {
deleter(Deleter d) : d_(d) {}
void apply(void* p) override {
d_(static_cast<T*>(p));
}
Deleter d_;
};
public:
template <typename Deleter>
smartptr_te_lb(T* p, Deleter d) : p_(p),
d_((sizeof(Deleter) > sizeof(buf_))
? new deleter<Deleter>(d)
: new (buf_) deleter<Deleter>(d)) {}
~smartptr_te_lb() {
d_->apply(p_);
if ((void*)(d_) == (void*)(buf_)) {
d_->~deleter_base();
} else {
delete d_;
}
}
T* operator->() { return p_; }
const T* operator->() const { return p_; }
private:
T* p_;
deleter_base* d_;
char buf_[16];
}
使用与之前相同的基准测试,可以测量应用了局部缓冲区优化的类型擦除智能指针的性能:
Benchmark Time Items per second
BM_smartptr 21.0 ns 47.5732M/s
BM_smartptr_te 44.2 ns 22.6608M/s
BM_smartptr_te_lb 22.3 ns 44.8747M/s
在相同机器上,不进行类型擦除的智能指针的构造和销毁耗时为21纳秒,进行类型擦除的版本耗时44纳秒,而经过优化的类型擦除智能指针测试耗时为22纳秒。这微小的开销来自于需要检查删除器是存储在本地还是远程。
局部缓冲区优化的最后一个应用 —— 为类型擦除对象存储可调用对象 —— 在 C++ 标准模板库中被广泛使用。例如,std::shared_ptr 就具有一个类型擦除的删除器,并且大多数标准库实现都采用了局部缓冲区优化技术。当然,删除器是存储在引用计数对象中,而不是每个 shared_ptr 的副本里,从而避免了重复分配。另一方面,std::unique_ptr 标准则完全不使用类型擦除,以避免微小的运行时开销;如果删除器无法放入局部缓冲区,可能会导致更大的开销。
C++ 标准库中“终极”的类型擦除对象 std::function,也通常通过局部缓冲区来实现,以便在无需动态分配的情况下存储小型可调用对象。此外,自 C++17 引入的通用类型容器 std::any,在可能的情况下也通常采用无需动态分配的实现方式,其中也常常利用了类似的优化技术(如小型对象优化)。这些例子充分说明,局部缓冲区优化不仅是理论上的技巧,更是现代 C++ 高性能库实现中的核心实践之一。