6.5. 类型擦除性能分析

我们将测量一个类型擦除的通用函数,和一个类型擦除的智能指针 删除器 的性能。首先,需要合适的工具,需要一个微基准测试(micro-benchmarking)库。

6.5.1 安装微基准测试库

我们关注的是,使用不同类型的智能指针,来构造和删除对象的极小段代码的效率。测量这类小段代码性能的合适工具是微基准测试(micro-benchmark)。市面上有许多微基准测试库和工具;在本书中,将使用 Google Benchmark 库。

要跟随本节的示例,必须先下载并安装该库(请按照其 Readme.md 文件中的说明进行操作)。然后,就可以编译并运行这些示例了。可以构建库中包含的示例文件,以了解如何在特定系统上建立基准测试。

例如,在一台 Linux 机器上,编译并运行一个名为 smartptr.C 的基准测试程序的命令可能如下所示:

$CXX smartptr.C smartptr_ext.C -o smartptr -g –O3 \
  -I. -I$GBENCH_DIR/include \
  -Wall -Wextra -Werror -pedantic --std=c++20 \
  $GBENCH_DIR/lib/libbenchmark.a -lpthread -lrt -lm && \
./smartptr

其中,$CXX 是 C++ 编译器,例如 clang++ 或 g++-11,而 $GBENCH_DIR 是 benchmark 库的安装目录。

6.5.2 类型擦除的开销

每个基准测试都需要一个基线。我们的例子中,基线是原始指针(raw pointer)。可以合理地假设,没有智能指针能够比原始指针性能更好,而最优的智能指针应当具有零开销。首先测量使用原始指针构造和销毁一个小对象,所需的时间:

// Example 07
struct deleter {
  template <typename T> void operator()(T* p) { delete p; }
};
deleter d;

void BM_rawptr(benchmark::State& state) {
  for (auto _ : state) {
    int* p = new int(0);
    d(p);
  }

  state.SetItemsProcessed(state.iterations());
}

一个优秀的优化编译器,可能会通过优化掉这些“不必要”的工作(实际上,这个程序所做的所有工作),而严重破坏此类微基准测试的准确性。可以通过将内存分配操作移入不同的编译单元来避免这类优化:

// 07_smartptr.C:
void BM_rawptr(benchmark::State& state) {
  for (auto _ : state) {
    int* p = get_raw_ptr()
    d(p);
  }
  state.SetItemsProcessed(state.iterations());
}

// 07_smartptr_ext.C:
int* get_raw_ptr() { return new int(0); }

如果使用的编译器支持全程序优化,请在本次基准测试中将其关闭。但不要关闭对每个文件的优化:我们希望分析的是经过优化的代码,这才是实际程序会使用的版本。

基准测试报告的实际数值当然取决于其运行的机器。但我们关注的是相对变化,只要在所有测量中使用同一台机器就好:

Benchmark Time
BM_rawptr 8.72 ns

现在可以验证 std::unique_ptr 确实具有零开销(当然,前提是构造和销毁对象的方式相同):

// smartptr.C
void BM_uniqueptr(benchmark::State& state) {
  for (auto _ : state) {
    auto p(get_unique_ptr());
  }
  state.SetItemsProcessed(state.iterations());
}

// smartptr_ext.C
auto get_unique_ptr() {
  return std::unique_ptr<int, deleter>(new int(0), d);
}

其结果与原始指针的性能处于相同的测量噪声范围内:

Benchmark    Time
BM_uniqueptr 8.82 ns

同样可以测量 std::shared_ptr,以及我们自己智能指针不同版本的性能:

Benchmark          Time
BM_sharedptr       22.9 ns
BM_make_sharedptr  17.5 ns
BM_smartptr_te     19.5 ns

第一行 BM_sharedptr 构造并删除了一个带有自定义 删除器 的 std::shared_ptr<int>,共享指针的开销远高于独占指针。当然,原因不止一个 —— std::shared_ptr 是一种引用计数型智能指针,维护引用计数本身就有其开销。使用 std::make_shared 来分配共享指针可以显著加快其创建和销毁速度,可以在 BM_make_sharedptr 基准测试中看到。但为了确保仅测量类型擦除带来的开销,应该实现一个类型擦除的独占指针。它就是本章的 smartptr,具备了足够的功能,可以测量用于所有其他指针的性能:

void BM_smartptr_te(benchmark::State& state) {
  for (auto _ : state) {
    auto get_smartptr_te();
  }
  state.SetItemsProcessed(state.iterations());
}

smartptr_te 代表使用继承实现的类型擦除版本的智能指针,比 std::shared_ptr 稍快一些,证实了我们的猜测:后者存在多种开销来源。与 std::shared_ptr 一样,删除 smartptr_te 会访问两个内存位置:在我们的例子中,是删除的对象和删除器(嵌入在一个多态对象中)。std::make_shared 正是通过将 std::shared_ptr 的这两个内存位置合并来避免这种开销的,这显然带来了性能收益。

可以合理地假设,第二次内存分配也是类型擦除智能指针性能较差(大约比原始指针或 unique_ptr 慢两倍)的原因。如果使用智能指针对象内部预留的缓冲区,就可以避免这种分配。我们已经见过这种使用局部缓冲区的智能指针实现(此基准测试中,重命名为 smartptr_te_lb0),在此处以 BM_smartptr_te_lb0 的名称进行基准测试。另一种版本在可能的情况下使用局部缓冲区,对于较大的 删除器 则切换到动态分配,该版本名为 smartptr_te_lb,速度稍慢一些(BM_smartptr_te_lb):

Benchmark              Time
BM_smartptr_te_lb      11.3 ns
BM_smartptr_te_lb0     10.5 ns
BM_smartptr_te_static  9.58 ns
BM_smartptr_te_vtable  10.4 ns

我们还对两种不使用继承实现的类型擦除智能指针,进行了基准测试。使用静态函数的版本 BM_smartptr_te_static 比使用虚表的版本(BM_smartptr_te_vtable)略快一些。这两种实现都使用了局部缓冲区;编译器生成的虚表,与我们使用静态变量模板构建的等效结构性能完全相同,这并不令人意外。

总体而言,即使是最好的类型擦除实现也存在一些开销,在我们的例子中略低于10%。这种开销是否可以接受,取决于具体的应用场景。

我们还应该测量通用类型擦除函数的性能。可以使用可调用实体来测量其性能,例如 Lambda 表达式:

// Example 09
void BM_fast_lambda(benchmark::State& state) {
  int a = rand(), b = rand(), c = rand(), d = rand();
  int x = rand();

  Function<int(int, int, int, int)> f {
    [=](int a, int b, int c, int d) {
      return x + a + b + c + d; }
  };

  for (auto _ : state) {
    benchmark::DoNotOptimize(f(a, b, c, d));
    benchmark::ClobberMemory();
  }
}

也可以对 std::function 进行同样的测量:

Benchmark         Time
BM_fast_lambda    0.884 ns
BM_std_lambda     1.33 ns

虽然这看起来像是一个巨大的成功,但这个基准测试也隐藏着一个警告:不要过度使用类型擦除。要明确这个警告,只需测量直接调用同一个 Lambda 的性能:

Benchmark    Time
BM_lambda    0.219 ns

我们如何解释这种显著的性能下降,与之前比较智能指针和原始指针时,观察到的微小类型擦除开销之间的巨大差异呢?

关键在于要关注擦除的内容是什么。一个实现良好的类型擦除接口,其性能可以非常接近虚函数调用。一个无法内联的非虚函数调用会稍快一些(在我们的例子中,耗时略低于9纳秒的调用产生了大约10%的开销)。但类型擦除的调用始终是间接的,唯一无法与之匹敌的竞争者是内联函数调用。这正是我们在比较类型擦除调用和直接调用 Lambda 时所观察到的现象。

基于我们对类型擦除性能的了解,何时使用它呢?