c++性能测试⼯具:googlebenchmark进阶(⼀)
这是c++性能测试⼯具教程的第四篇⽂章,从本篇开始我将逐步介绍⼀些性能测试的⾼级技巧。
前三篇教程可以看这⾥:
本⽂将会介绍如何使⽤模板以及参数⽣成器来批量⽣成测试⽤例,简化繁琐的性能测试代码。
测试对象
这次测试的对象是标准库的vector,我们将会在vs2019 16.10和Linux + GCC 11.1上进⾏测试。为了代码写着⽅便,我还会启⽤c++17⽀持。
枯草菌素这次的疑问来⾃于《A Tour of C++》这本书,最近在重新翻阅本书的时候发现书⾥第九章给出了⼀个很有意思的建议:尽量少⽤rerve⽅法。
我们都知道rerve会提前分配⾜够⼤的内存来容纳元素,这样在push_back时可以减少内存分配和元素移动的次数,从⽽提⾼性能。所以习惯上如果我们知道vector中存储元素的⼤致数量,就会使⽤rerve提前进⾏内存分配,这是典型的“空间换时间”。
⽽书中给出的理由仅仅是说vector的内存分配器性能已经很⾼,预分配往往是多此⼀举,收效甚微。事实到底如何呢,性能问题光靠脑补是不能定位的,所以我们⽤实验结果说话。
使⽤模板函数⽣成测试
测试⽤例的设计很简单,我们定义普通vector和rerve过的vector,然后分别对其添加⼀定数量的元素(逐步从少到多)测试性能。
同时vector本⾝是泛型容器,所以为了测试的全⾯性我们需要测试两⾄三种类型参数。
如果针对每⼀种情况写测试函数,显然违反了DRY原则,因为除了vector的类型参数不同,其他代码⼏乎是完全⼀样的。
对于上⾯的需求,就需要模板测试函数登场了:
template <typename T, std::size_t length, bool is_rerve = true>
void bench_vector_rerve(benchmark::State& state)
{
for (auto _ : state) {
std::vector<T> container;
if constexpr (is_rerve) {
}
for (std::size_t i = 0; i < length; ++i) {
container.push_back(T{});
}
}
}
⾮常的简单,我们通过length控制插⼊的元素个数;is_rerve则负责控制是否预分配内存,通过if co
nstexpr可以⽣成rerve和不进⾏任何操作的两种代码(如果不熟悉c++17的if constexpr,推荐花两分钟看看)。
然后我们像往常⼀样定义⼀个测试⽤例:
BENCHMARK(bench_vector_rerve<std::string,100>);
可是等我们编译的时候却报错了!
$ g++ test.cpp -lpthread -lbenchmark -lbenchmark_main
test.cpp:19:48: 错误:宏“BENCHMARK”传递了 2 个参数,但只需要 1 个
19 | BENCHMARK(bench_vector_rerve<std::string,100>);
| ^
what i ve doneIn file included from a.cpp:1:
/usr/local/include/benchmark/benchmark.h:1146: 附注:macro "BENCHMARK" defined here
1146 | #define BENCHMARK(n) \
|
test.cpp:19:1: 错误:‘BENCHMARK’不是⼀个类型名
19 | BENCHMARK(bench_vector_rerve<std::string,100>);
原因是这样的,在编译器处理宏的时候实际上不会考虑c++语法,所以分割模板参数的逗号被识别成了分割宏参数的逗号,因此在宏处理器的眼⾥我们像是传了两个参数。这也说明了BENCHMARK是处理不了模板的。
不过别担⼼,Google早就想到这种情况了,所以提供了BENCHMARK_TEMPLATE宏,我们只需要把模板名字和需要的类型参数依次传给宏即
可:
地址英文缩写
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 100);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 1000);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 10000);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 100000);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 100, fal);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 1000, fal);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 10000, fal);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, 100000, fal);
现在就可以正常编译运⾏了:
可以看到rerve过的容器性能⼏乎⽐默认的快了⼀倍。
不过在揭晓为什么书上不推荐rerve的谜底之前,我们的代码还有可以简化的地⽅。
定制测试参数
⾸当其冲的问题其实还是违反了DRY原则——除了数字,其他内容都是重复的。
看到这种代码直觉就告诉我该做些改进了。
⾸先我们来复习⼀下Ranges。在《》中我们已经学习过它了,Ranges接受start和end两个int64_t类型的参数,默认从start起每次累乘8,⼀直达到end。
通过RangeMultiplier我们可以改变乘数,⽐如从8改成10。
在这⾥我们的length参数其实是不必要的,所以代码可以这样改:
template <typename T, bool is_rerve = true>
void bench_vector_rerve(benchmark::State& state)
{
for (auto _ : state) {
std::vector<T> container;
if constexpr (is_rerve) {
// 通过range⽅法获取传⼊的参数
}
for (std::size_t i = 0; i < state.range(0); ++i) {
container.push_back(T{});
}
}
}
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string)->RangeMultiplier(10)->Range(10, 10000 * 10);
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, fal)->RangeMultiplier(10)->Range(10, 10000 * 10);
现在我们测试的元素数量是[10, 100, 1000, 10^4, 10^5]。
除此之外还有另⼀种叫“密集参数”的Ranges。google benchmark提供了DenRange⽅法。
这个⽅法的原型如下:
DenRange(int64_t start, int64_t end, int64_t step);
Ranges是累乘,⽽DenRange是累加,因为累乘会导致⼏何级数的增长,在数轴上的分布越来越稀疏,累加则看上去像是均匀分布的,因此累加的参数⽣成器被叫做密集参数⽣成器。
如果我们把测试⽤例这么改:
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string)->DenRange(1000, 100 * 100, 1000);
现在我们的length就是这样⼀个序列:[1000,2000,3000, ...,9000,10000]。
关于⾃定义参数最后⼀个知识点是ArgsProduct。看名字就知道这是⼀个参数⼯⼚。
ArgsProduct(const std::vector< std::vector<int64_t> >& arglists);
std::vector<int64_t>实际上就是⼀组参数,arglists就是多组参数的合集,他们之间会被求笛卡尔积,举个例⼦:
BENCHMARK(BM_test)->ArgsProduct({ {"a", "b", "c", "d"}, {1, 2, 3, 4} });
// 等价于下⾯的
BENCHMARK(BM_test)->Args({"a", 1})
->Args({"a", 2})
->Args({"a", 3})
->Args({"a", 4})
->Args({"b", 1})
->Args({"b", 2})
->Args({"b", 3})
...
->Args({"d", 3})
-
>Args({"d", 4})
我们可以看到参数⼯⼚其实得⾃⼰⼿写所有参数,那如果我想配合⼯⼚使⽤Ranges呢?
没问题,benchmark的开发者们早就想到了,所以提供了下⾯这些帮助函数:
benchmark::CreateRange(8, 128, /*multi=*/2) // ⽣成:[8, 16, 32, 64, 128]
benchmark::CreateDenRange(1, 6, /*step=*/1) // ⽣成:[1, 2, 3, 4, 5, 6]
如果换成我们的例⼦,就可以这样写:
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, fal)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
借助仅仅两⾏代码我们就能⽣成数量可观的测试⽤例:
当然,这只是⼀个类型参数,实际上我们还有另外两个类型需要测试。另外这是1.5.5新增的功能,如果你想尝鲜得先升级google benchmark。
进⼀步简化
通常做到上⾯那⼀步就⾜够了,然⽽在这⾥我们还有优化空间,因为如果我们把其他两个测试⽤的类型加上,代码是这样的,MyClass的定义后⾯会给出:
BENCHMARK_TEMPLATE(bench_vector_rerve, std::string)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
新东方女老师BENCHMARK_TEMPLATE(bench_vector_rerve, std::string, fal)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
联系人英文缩写
});
BENCHMARK_TEMPLATE(bench_vector_rerve, std::size_t)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
BENCHMARK_TEMPLATE(bench_vector_rerve, std::size_t, fal)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
BENCHMARK_TEMPLATE(bench_vector_rerve, MyClass)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
boundlessBENCHMARK_TEMPLATE(bench_vector_rerve, MyClass, fal)->ArgsProduct({
benchmark::CreateRange(10, 10000*10, 10)
});
你看见了什么?没错,重复重复重复!我们⼜违背了DRY原则。
重复说不上什么⼗恶不赦,但能避免还是要避免的,所以我准备⽤宏来简化这些代码:
#define generate_test(type) \
BENCHMARK_TEMPLATE(bench_vector_rerve, type)->ArgsProduct({benchmark::CreateRange(10, 100000, 10)}); \
BENCHMARK_TEMPLATE(bench_vector_rerve, type, fal)->ArgsProduct({benchmark::CreateRange(10, 100000, 10)});
generate_test(std::string);
generate_test(std::size_t);
generate_test(MyClass);
这下舒服多了。
接着来看我们的MyClass,我们的MyClass包含⼏个虚函数,禁⽌移动赋值,同时被刻意设计成了⾮平凡复制,这样的类型可以说是绕过了标准库容器设计的⼤部分优化措施,算是个妥妥的反⾯教材,希望你的项⽬⾥尽量不要出现这种东西:
class MyClass {
public:
long i = 2L;
MyClass() { i = 2L; }
virtual ~MyClass() {}
virtual long get() { return i; }
MyClass& operator=(MyClass&&) = delete;
MyClass(const MyClass& obj) {illustrated
i = obj.i;
}
MyClass& operator=(const MyClass& obj) {
i = obj.i;
辛普森一家第二季}
};
这个类其实就是针对内存分配器实现的,vector在重新进⾏内存分配后很可能靠移动语义或者memmove来移动数据,这两者将导致重新分配内存导致的性能损失变⼩,不利于我们观察vector的⾏为,所以我定制了这个类。
这是Windows上的结果,Linux上也差不多,到⽬前为⽌我们看到rerve过的vector有着惊⼈的性能,那书⾥说的到底是怎么回事呢?
揭晓答案
实际上上⾯测试的都是我们明确知道vector⼀定会被插⼊N个元素不多不少的情况。
然⽽这种情况其实在开发中是不多见的,更多的时候我们只能得到vector⾥元素数量的平均数、众数,甚⾄是⼀个没什么可靠依据的经验值。
所以试想⼀下这种情况,rerve给的参数是1000,⽽我的vector总是会插⼊1001~1005个参数,显然1000是不够的,除了rerve外还会进⾏⼀次内存分配,⽽且这次分配后很可能还需要把原先的元素都转移过去(realloc不是总能找到合适的位置扩展已有内存,⽽且像MyClass 那样的类在c++17中是不能bitwi复制的),那么这样的开销究竟如何呢?我们还是拿测试说话。
篇幅有限,所以我只能简单模拟⼀下上述情况:
template <typename T, bool is_rerve = true>
void bench_vector_rerve(benchmark::State& state)
{
for (auto _ : state) {
std::vector<T> container;
if constexpr (is_rerve) {
}
// 每次多插⼊两个元素,这样多半会导致⼀次内存分配(当然不保证⼀定会)
for (std::size_t i = 0; i < state.range(0)+2; ++i) {
container.push_back(T{});
}
}
}
编译均使⽤Relea模式和默认的优化级别,这是Linux上的测试结果:
和我们预期的⼀样,多出来的⼀次内存分配使rerve带来的性能优势荡然⽆存。
有意思的是Windows上的结果:
奇怪的事情发⽣了,虽说多出的⼀次分配缩⼩了性能差距,但rerve任然带来了明显的优势。
这⾥我就不卖关⼦了,我们直接看vector的源码。
⾸先是GCC11.1的,代码在/usr/include/c++/11.1.0/bits⽬录下,分散在和stl_vector.h中,其中push_back在容器内存不够的时候会
⽤_M_realloc_inrt重新分配⾜够的内存,这个函数在的432⾏有定义,使⽤_M_check_len计算重新分配的内存⼤⼩。
_M_check_len是关键,定义在stl_vector.h的1756⾏:
// Called by _M_fill_inrt, _M_inrt_aux etc.
size_type
_M_check_len(size_type __n, const char* __s) const
{
if (max_size() - size() < __n)
__throw_length_error(__N(__s));
const size_type __len = size() + (std::max)(size(), __n);
return (__len < size() || __len > max_size()) ? max_size() : __len;
}
__n在push_back的时候是1,所以不难看出GCC的vector的扩容策略是每次扩容⼀倍。
vs2019的stl实现开源在了。关键代码在,push_back在内存不够的时候会调⽤,⾥⾯会调⽤_Calculate_growth计算重新分配的内存⼤⼩:
_CONSTEXPR20_CONTAINER size_type _Calculate_growth(const size_type _Newsize) const {
// given _Oldcapacity and _Newsize, calculate geometric growth
const size_type _Oldcapacity = capacity();
const auto _Max = max_size();
if (_Oldcapacity > _Max - _Oldcapacity / 2) {chrom
return _Max; // geometric growth would overflow
}
const size_type _Geometric = _Oldcapacity + _Oldcapacity / 2; // 关键代码
if (_Geometric < _Newsize) {
return _Newsize; // geometric growth would be insufficient
}
return _Geometric; // geometric growth is sufficient
}
_Newsize相当于前⾯GCC的__n,在push_back的时候是1,所以不难看出vs2019的vector增长策略是每次扩容0.5倍。
除此之外两者的剩余部分⼤同⼩异,都是先分配新内存,然后在新内存⾥构建要插⼊的元素,再把其他元素移动到新内存⾥,就连移动元素的⽅式也差不多,都是先尝试memmove,接着试试移动语义,最后让复制操作兜底。
那么两者⾁眼可见的区别就只有扩容策略这⼀条了。所以这会带来什么影响呢?看个例⼦:
#include <iostream>
#include <vector>
void test1(std::size_t len)
{
std::vector<int> v1, v2;
for (std::size_t i = 0; i < len; ++i) {
v1.push_back(1);
v2.push_back(1);
}
std::cout << "v1: " << v1.capacity() << '\n';
建筑平面设计std::cout << "v2: " << v2.capacity() << '\n';
}
void test2(std::size_t len)
{
std::vector<int> v1, v2;
for (std::size_t i = 0; i < len + 1; ++i) {
v1.push_back(1);
v2.push_back(1);
}
std::cout << "v1: " << v1.capacity() << '\n';
std::cout << "v2: " << v2.capacity() << '\n';
}
int main()
{
test1(100000);
test2(100000);
}
/*
vs2019的运⾏结果:
v1: 138255
v2: 100000
v1: 138255
v2: 150000
GCC11.1.0的结果:
v1: 131072
v2: 100000
v1: 131072
v2: 200000
*/
如果是⼀个有10万个元素的vector想要扩容,GCC就会⽐vs多分配50000个元素需要的内存,分配如此多的内存需要花费更多的时间,即使rerve带来了性能优势在这⼀步也都挥霍的差不多了。
激进的扩容策略让GCC出现了明显的性能波动,不过这只是出现上⾯那样测试结果的原因之⼀,两个标准库的allocator实现上的区别也可能是其中⼀个原因。不过msvc的分配器实现并不公开,所以最终是什么导致了上述的结果并不能轻易断⾔。
总结
我们学习了如何使⽤模板和参数⽣成器创建⼤量测试⽤例,以提⾼编写测试的效率。
我们还顺便了解了ve对性能的影响,总结规律有⼏条:
1. 如果明确知道vector要存放元素的具体数量,推荐rerve,性能提升是有⽬共睹的;
2. 否则你不应该使⽤rerve,⼀来有提前优化之嫌,⼆是在使⽤libstdc++的程序上会产⽣较⼤的性能波动;
3. 接上⼀条,rerve使⽤不当还会造成内存的浪费。