10.4. 字符串之外的优化应用

局部缓冲区优化的应用远不止于短字符串,每当需要在运行时动态分配小块内存时,都应考虑使用此优化。在本节中,将探讨几种适用此优化的数据结构。

10.4.1 小型数组

另一个常受益于局部缓冲区优化的常见数据结构是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倍,在多线程环境下运行基准测试时,提升可能更大。

当其他数据结构存储少量动态分配的数据时,也可以用类似的方式进行优化。这些数据结构的优化在本质上是相似的,但其中有一个值得注意的变体需要特别指出。

10.4.2 小型队列

小型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 缓冲区、并发程序中线程间交换数据的管道等。

现在,来看看局部缓冲区优化在常见数据结构之外的应用。

10.4.3 类型擦除与可调用对象

还有另一种截然不同,但能非常有效地应用局部缓冲区优化的场景 —— 存储可调用对象,即可像函数一样被调用的对象。许多模板类都提供使用可调用对象来自定义其部分行为的选项。例如,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纳秒。这微小的开销来自于需要检查删除器是存储在本地还是远程。

10.4.4 标准库中的局部缓冲区优化

局部缓冲区优化的最后一个应用 —— 为类型擦除对象存储可调用对象 —— 在 C++ 标准模板库中被广泛使用。例如,std::shared_ptr 就具有一个类型擦除的删除器,并且大多数标准库实现都采用了局部缓冲区优化技术。当然,删除器是存储在引用计数对象中,而不是每个 shared_ptr 的副本里,从而避免了重复分配。另一方面,std::unique_ptr 标准则完全不使用类型擦除,以避免微小的运行时开销;如果删除器无法放入局部缓冲区,可能会导致更大的开销。

C++ 标准库中“终极”的类型擦除对象 std::function,也通常通过局部缓冲区来实现,以便在无需动态分配的情况下存储小型可调用对象。此外,自 C++17 引入的通用类型容器 std::any,在可能的情况下也通常采用无需动态分配的实现方式,其中也常常利用了类似的优化技术(如小型对象优化)。这些例子充分说明,局部缓冲区优化不仅是理论上的技巧,更是现代 C++ 高性能库实现中的核心实践之一。