10.2. 小内存分配的额外开销

局部缓冲区优化正是一种优化手段,是一种面向性能的设计模式,我们必须牢记性能优化的第一条原则:切勿猜测关于性能的事情。性能以及优化的效果都必须通过测试来验证。

10.2.1 内存分配的代价

既然正在探讨内存分配的开销,以及降低这种开销的方法,那么首先要回答的问题就是:一次内存分配的代价究竟有多高?毕竟,没有人会想去优化一个本身已经足够快、无需优化的东西。可以使用 Google Benchmark(或者你偏好的其他微基准测试工具)来回答这个问题,用于测量内存分配成本的最简单基准测试如下所示:

void BM_malloc(benchmark::State& state) {
  for (auto _ : state) {
    void* p = malloc(64);
    benchmark::DoNotOptimize(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free);

benchmark::DoNotOptimize 包装器可防止编译器将未使用的变量优化掉,这个实验很可能无法顺利结束;微基准测试库需要多次运行测试,通常要运行数百万次,才能累积出足够精确的平均运行时间。极有可能在基准测试完成之前,机器就已经耗尽内存了。

解决方法很简单,必须同时释放已分配的内存:

// Example 01
void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  for (auto _ : state) {
    void* p = malloc(S);
    benchmark::DoNotOptimize(p); free(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free)->Arg(64);

现在测量的是内存分配和释放两者的开销,这也反映在函数名称的更改上。这种改变是合理的,已分配的内存最终都需要释放,这部分开销是必须付出的。我们还修改了基准测试,使其能够根据分配的内存大小进行参数化。如果运行此基准测试,会得到类似如下的结果:

Benchmark            Time    Items per second
BM_malloc_free/64   19.2 ns  52.2041M/s

这台特定机器上,分配和释放 64 字节内存的开销大约为 19 纳秒,相当于每秒可进行约 5200 万次的分配/释放操作。如果好奇 64 字节这个大小是否有特殊之处,可以更改基准测试参数中的大小值,或针对一系列不同的大小运行基准测试:

void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  for (auto _ : state) {
    void* p = malloc(S);
    benchmark::DoNotOptimize(p); free(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free)->
  RangeMultiplier(2)->Range(32, 256);

目前为止,我们测试的是程序运行后第一次内存分配所需的时间,尚未进行其他分配。虽然 C++ 运行时系统可能在程序启动时已进行了一些动态分配,但这样的测试仍然不够贴近现实。为了使测量结果更具参考价值,需要在每次测试中重新分配一定量的内存:

// Example 02
#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)

void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  const size_t N = state.range(1);
  std::vector<void*> v(N);
  for (size_t i = 0; i < N; ++i) v[i] = malloc(S);
  for (auto _ : state) {
    REPEAT({
      void* p = malloc(S);
      benchmark::DoNotOptimize(p);
      free(p);
    });
  }
  state.SetItemsProcessed(32*state.iterations());
  for (size_t i = 0; i < N; ++i) free(v[i]);
}

BENCHMARK(BM_malloc_free)->
RangeMultiplier(2)->Ranges({{32, 256}, {1<<15, 1<<15}});

开始基准测试之前先进行 N 次 malloc 调用。通过在重新分配期间改变分配大小,还可以进一步改进测试。使用 C 预处理器宏将基准测试循环体复制了 32 次,以减少循环本身对测量结果带来的开销。现在基准测试报告的时间是执行 32 次分配和释放所需的时间,这虽然不太方便直接解读,但分配速率仍然是有效的,已经考虑了循环展开的因素,并在设置处理项数时将迭代次数乘以了 32(在 Google Benchmark 中,“项”可以是你定义的单位,每秒处理的项数会在基准测试结束时报告出来,将一次分配/释放定义为一项)。

即使进行了所有这些修改和改进,最终结果仍会与最初的测量值(每秒约 5400 万次分配)非常接近。这看起来非常快,仅需 18 纳秒。现代 CPU 在这段时间内,可以执行数十条指令。由于处理的是小内存分配,每个已分配内存块上的处理时间很可能也很短,分配开销不可忽视。当然,这仍然属于“性能猜测”,我们将通过直接实验来验证这一说法。

不过,首先展示小内存分配效率特别低下的另一个原因。目前为止,仅探讨了单线程环境下内存分配的开销。如今,所有对性能有要求的程序都是并发的,而 C++ 支持并发和多线程编程。来看看当多个线程同时进行内存分配时,其开销会发生怎样的变化:

// Example 03
void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  const size_t N = state.range(1);
  std::vector<void*> v(N);
  for (size_t i = 0; i < N; ++i) v[i] = malloc(S);
  for (auto _ : state) {
    REPEAT({
      void* p = malloc(S);
      benchmark::DoNotOptimize(p);
      free(p);
    });
  }
  state.SetItemsProcessed(32*state.iterations());
  for (size_t i = 0; i < N; ++i) free(v[i]);
}

BENCHMARK(BM_malloc_free)->
RangeMultiplier(2)->Ranges({{32, 256}, {1<<15, 1<<15}})
  ->ThreadRange(1, 2);

结果在很大程度上取决于硬件以及系统所使用的 malloc 版本。此外,在拥有大量 CPU 的大型机器上,可以使用远超两个线程进行测试。

尽管如此,整体趋势应该类似于下图所示:

Benchmark                           Time     Items per second
BM_malloc_free/32/32768/threads:1   778 ns   41.1468M/s
BM_malloc_free/32/32768/threads:2   657 ns   24.3749M/s
BM_malloc_free/32/32768/threads:4   328 ns   24.3854M/s
BM_malloc_free/32/32768/threads:8   242 ns   16.5146M/s

这结果相当令人沮丧;当从单线程增加到双线程时(在大型机器上,类似的增长也会发生,但可能出现在更多线程的情况下),内存分配的开销增加了数倍。系统的内存分配器似乎成了高效并发的瓶颈。虽然存在性能更优的分配器可用于替代默认的 malloc(),但自身也存在缺点。此外,如果 C++ 程序性能不依赖于特定的、非标准的系统库替换,那将更为理想。因此,我们需要一种更优的内存分配方式。接下来,让我们深入探讨这一问题。