栈buffer对比堆buffer
我印象中一直有这样一个观点:栈内存比堆内存更快。但是自从尝试《十亿行挑战》后,便对其怀疑。 本文将通过实验调查堆内存和栈内存用作buffer时表现的差异及其原因。
验证case
栈buffer使用std::array
,堆buffer使用std::unique_ptr
。buffer定义如下:
|
|
最多4MB的buffer,这是考虑了栈的大小限制,一般是8MB。为什么不直接使用数组或raw指针?主要是考虑C++中我们一般会使用std::array
和std::unique_ptr
(或其他智能指针)来使用栈和堆内存,所以这里也使用这两种方式。另外,下面的对比中也可以分析出纯数组和raw指针的性能。
栈buffer通过[]访问,也对比一下分配是否耗时(分析:栈空间是程序启动时就分配好的,所以最多占用构造函数时间):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
static void bench_stack_memory_normal_access(benchmark::State &state) { std::array<ele_t, buffer_size> arr; for (auto _ : state) { for (uint32_t idx = 0; idx < buffer_size; idx++) { arr[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_stack_memory_normal_access); static void bench_stack_memory_normal_access_with_alloc(benchmark::State &state) { for (auto _ : state) { std::array<ele_t, buffer_size> arr; for (uint32_t idx = 0; idx < buffer_size; idx++) { arr[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_stack_memory_normal_access_with_alloc);
栈buffer通过指针访问
1 2 3 4 5 6 7 8 9 10 11
static void bench_stack_memory_pointer_access(benchmark::State &state) { std::array<ele_t, buffer_size> arr; for (auto _ : state) { ele_t *ptr = &(arr[0]); for (uint32_t idx = 0; idx < buffer_size; idx++, ptr++) { *ptr = idx & 0x1; benchmark::DoNotOptimize(ptr); } } } BENCHMARK(bench_stack_memory_pointer_access);
栈buffer通过指针的[]访问
1 2 3 4 5 6 7 8 9 10 11
static void bench_stack_memory_pointer_normal_access(benchmark::State &state) { std::array<ele_t, buffer_size> arr; for (auto _ : state) { ele_t *ptr = &(arr[0]); for (uint32_t idx = 0; idx < buffer_size; idx++) { ptr[idx] = idx & 0x1; benchmark::DoNotOptimize(ptr); } } } BENCHMARK(bench_stack_memory_pointer_normal_access);
堆buffer通过[]访问,也对比一下频繁分配是否耗时
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
static void bench_heap_memory_normal_access(benchmark::State &state) { auto arr = std::make_unique<ele_t[]>(buffer_size); for (auto _ : state) { for (uint32_t idx = 0; idx < buffer_size; idx++) { arr[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_heap_memory_normal_access); static void bench_heap_memory_normal_access_with_alloc(benchmark::State &state) { for (auto _ : state) { auto arr = std::make_unique<ele_t[]>(buffer_size); for (uint32_t idx = 0; idx < buffer_size; idx++) { arr[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_heap_memory_normal_access_with_alloc);
堆buffer通过指针访问
1 2 3 4 5 6 7 8 9 10 11
static void bench_heap_memory_pointer_access(benchmark::State &state) { auto arr = std::make_unique<ele_t[]>(buffer_size); for (auto _ : state) { ele_t *ptr = arr.get(); for (uint32_t idx = 0; idx < buffer_size; idx++, ptr++) { *ptr = idx & 0x1; benchmark::DoNotOptimize(ptr); } } } BENCHMARK(bench_heap_memory_pointer_access);
堆buffer通过指针的[]访问: 智能指针的[]操作符会检查边界,然后通过get()获取指针,再通过指针访问;这里不考虑边界的影响,直接通过get()获取指针,然后通过指针访问,以对比差异。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
static void bench_heap_memory_pointer_normal_access(benchmark::State &state) { auto arr = std::make_unique<ele_t[]>(buffer_size); for (auto _ : state) { ele_t *ptr = arr.get(); for (uint32_t idx = 0; idx < buffer_size; idx++) { ptr[idx] = idx & 0x1; benchmark::DoNotOptimize(ptr); } } } BENCHMARK(bench_heap_memory_pointer_normal_access); static void bench_heap_memory_get_normal_access(benchmark::State &state) { auto arr = std::make_unique<ele_t[]>(buffer_size); for (auto _ : state) { for (uint32_t idx = 0; idx < buffer_size; idx++) { arr.get()[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_heap_memory_get_normal_access);
预分配堆buffer通过[]或指针访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
static auto preload_heap_buffer = std::make_unique<ele_t[]>(buffer_size); static void bench_preload_heap_memory_normal_access(benchmark::State &state) { for (auto _ : state) { auto &arr = preload_heap_buffer; for (uint32_t idx = 0; idx < buffer_size; idx++) { arr[idx] = idx & 0x1; benchmark::DoNotOptimize(arr); } } } BENCHMARK(bench_preload_heap_memory_normal_access); static void bench_preload_heap_memory_pointer_access(benchmark::State &state) { for (auto _ : state) { ele_t *ptr = preload_heap_buffer.get(); for (uint32_t idx = 0; idx < buffer_size; idx++, ptr++) { *ptr = idx & 0x1; benchmark::DoNotOptimize(ptr); } } } BENCHMARK(bench_preload_heap_memory_pointer_access);
benchmark结果
命名规则,bench_[preload_]{stack|heap}_memory_{normal|pointer|pointer_normal}_access[_with_alloc]
,其中preload
表示预分配,stack
表示栈内存,heap
表示堆内存,normal
表示通过[]访问,pointer
表示通过指针访问,pointer_normal
表示通过指针的[]访问,_with_alloc
表示是否频繁分配。
编译参数为-std=c++17 -O3
,需要开启优化,然后调用顺序随机,通过名称排序输出结果:
|
|
一般把5%差异以内(高中物理上我们一般都这样)认为无明显差异,将上述结果按照速度从快到慢分成几个梯度:
T0:
1 2 3 4
bench_stack_memory_normal_access_with_alloc 216673 ns 216680 ns 3275 bench_heap_memory_pointer_normal_access 218123 ns 218117 ns 3254 bench_stack_memory_normal_access 215381 ns 215376 ns 3252 bench_stack_memory_pointer_normal_access 213314 ns 213304 ns 3248
T1:
1 2 3
bench_heap_memory_normal_access 241877 ns 241869 ns 2850 bench_preload_heap_memory_normal_access 241593 ns 241598 ns 2839 bench_heap_memory_get_normal_access 248895 ns 248892 ns 2801
T2:
1 2 3
bench_stack_memory_pointer_access 274659 ns 274668 ns 2595 bench_preload_heap_memory_pointer_access 275220 ns 275222 ns 2532 bench_heap_memory_pointer_access 270874 ns 270867 ns 2443
T3:
1
bench_heap_memory_normal_access_with_alloc 331070 ns 331075 ns 2033
多次验证也满足这样的分类结果,此处就不再展示多次统计的数据结果了,仅以上结果来分析。
先看最慢的T3,只有一项,其对比项是bench_stack_memory_pointer_access,分类在T1,与之差异就是会频繁分配和构造内存。在《glibc-malloc源码阅读》系列](/202203/glibc-malloc)中已经提到,较大的内存会通过mmap
分配。他会被真正的频繁分配和释放+构造所影响,所以速度慢。
类似的,对比栈上的bench_stack_memory_normal_access_with_alloc,尽管看起来也会频繁分配,但是栈空间是程序启动时就分配好的,所以不会有频繁分配的问题,所以速度比较快。
T2的三项都揭示,通过指针++访问会比通过[]访问慢,如何理解这个现象?一般分析来说,指针++和下标访问都需要一次“位置计算”的操作,差异会很大吗?具体原因需要通过下一节的汇编分析来解释。
T1的三项都是通过[]访问,且都是堆内存。T1、T2也说明对于不频繁分配内存的场景,预分配和使用时分配的差异并不大。不过也要针对实际情况,如果一次性需要几百MB/GB的内存,那么还是会倾向于预分配。
T0的几项,堆内存是通过raw指针的[]访问,栈内存除了raw指针的[]访问,还有直接[]访问。上面也提到栈内存频繁分配对速度影响并不大。这里来看,std::array
的直接下标访问也比智能指针的下标访问快。这部分也可以直接通过这两个类的实现找到原因:
|
|
可以看到,std::array
的[]访问相当于直接访问的raw指针,而std::unique_ptr
的[]访问会先做检查然后再访问。这样来看,频繁的__glibcxx_assert(get() != pointer());
检查,对速度影响还是比较大的。
汇编差异
这一节需要分析的问题是,为什么指针++访问会比[]访问慢。以bench_heap_memory_pointer_normal_access和bench_heap_memory_pointer_access为例。
bench_heap_memory_pointer_normal_access内存访问部分:
|
|
mov ecx, eax
,idx
赋值给ecx
and ecx, 1
,idx
与1做与操作,即idx & 0x1
mov DWORD PTR [rdx+rax*4], ecx
,将idx & 0x1
赋值给ptr[idx]
add rax, 1
,idx++
bench_heap_memory_pointer_access内存访问部分:
|
|
mov ecx, edx
,idx
赋值给ecx
and ecx, 1
,idx
与1做与操作,即idx & 0x1
mov DWORD PTR [rax], ecx
,将idx & 0x1
赋值给*ptr
add edx, 1
,idx++
add rax, 4
,ptr++
最后多了一步add rax, 4
,但是并没有说服力;我做了一下改造,把bench_heap_memory_pointer_access变得简单一些,即使下面的实现是错误的,但是它和bench_heap_memory_pointer_normal_access的差异仅有一处,即解引用还是下标访问,这样对比起来简单一些:
|
|
得到的结果依然是直接[]访问比指针++访问快,此外,还尝试将内存范围部分*ptr = idx & 0x1;
和ptr[idx] = idx & 0x1;
注释,得到的结果则是没有明显差别,这说明确实是内存访问的问题。上文说的改造后的汇编部分是怎样的呢?
|
|
和bench_heap_memory_pointer_normal_access没有差别,甚至还不需要rdx+rax*4
这样的计算,除了寄存器名字不同。总之,在这一小节中我还没有找到能够支撑“指针++访问会比[]访问慢”的证据。所以目前也只能把实验结论当作知识,先有个印象了。
总结
本想通过实验看看又无什么确定性的结论,但是并没有得到。不过也获得了一些参考。
根据以上结果和经验,可以得出以下结论:
- 对于内存不大且不频繁分配的场景,栈内存和堆内存的性能差异不大,可以根据实际情况选择。
- 对于频繁分配的场景,堆内存的性能会受到影响,所以需要考虑预分配或者自建内存池等方式;栈内存则不会受到影响。
- 如果使用智能指针管理内存,尽量避免频繁的且直接的[]访问,可以通过raw指针访问来提高性能;对栈内存则没有这个问题。
- 不要为了高性能而一味的使用栈buffer,我曾经遇到过,在某个项目的开发中因为频繁使用栈buffer,待调用栈比较深后,再申请栈buffer就会导致栈溢出的问题。
- 指针++访问会比[]访问慢,需要有这个意识;如果生产环境中关心这个影响,则需要根据使用的编译器和编译选项来验证。
如果用两个字总结的话,我会用“实验”。
上面输出的对比结论仅是在benchmark这个环境中得到的,如果在实际生产或者稍微复杂一些的环境,没准又会有不同的结果。所以对于一些性能场景,除非是可以被理论证明正确的结论,否则我认为很多经验“不靠谱”,但凡有一点怀疑,都应该通过实验来验证。
另外一个想法是不要迷信书本,技术发展很快,但是很多书本知识比较老旧。类似的,如果是查看某个技术帖子,我也得看看他的成文时间,如果太久了,一般就只能说仅供参考。