为什么编译超过100000行std::vector::push_back需要很长时间

Why does compiling over 100,000 lines of std::vector::push_back take a long time?

本文关键字:back 长时间 push vector 编译 100000行 std 为什么      更新时间:2023-10-16

我正在编译一个C++库,它定义了一个从一组数据点随机采样的函数。数据点存储在CCD_ 1中。共有126272个std::vectorpush_back语句,其中所讨论的向量的类型为double。编译需要很长时间。

为什么要花这么长时间?(除了std::vectorpush_back语句之外的所有代码都需要不到1秒的时间来编译,因为其他代码很少。)

gcc中有-ftime-report选项,它打印每个编译器阶段浪费的时间的详细报告。

我使用了ubuntu 12.04 64位与gcc 4.6.3和此代码来重现您的情况:

#include <vector>
using namespace std;
int main()
{
vector<double> d;
d.push_back(5.7862517058766);
/* ... N lines generated with 
perl -e 'print(" d.push_back(",rand(10),");n") for 1..100000'
*/
d.push_back(3.77195464257674);
return d.size();
}

有各种N的-ftime-report输出(由于PC上的背景负载,wall时间不准确,所以请查看user timeusr):

N=10000

$ g++ -ftime-report ./pb10k.cpp
Execution times (seconds)
...
expand vars           :   1.48 (47%) usr   0.01 ( 7%) sys   1.49 (44%) wall    1542 kB ( 2%) ggc
expand                :   0.11 ( 3%) usr   0.01 ( 7%) sys   0.10 ( 3%) wall   19187 kB (30%) ggc
...
TOTAL                 :   3.18             0.15             3.35              64458 kB

N=100000

$ g++ -ftime-report ./pb100k.cpp
Execution times (seconds)
....
preprocessing         :   0.49 ( 0%) usr   0.28 ( 5%) sys   0.59 ( 0%) wall    6409 kB ( 1%) ggc
parser                :   0.96 ( 0%) usr   0.39 ( 6%) sys   1.41 ( 0%) wall  108217 kB (18%) ggc
name lookup           :   0.06 ( 0%) usr   0.07 ( 1%) sys   0.24 ( 0%) wall    1023 kB ( 0%) ggc
inline heuristics     :   0.13 ( 0%) usr   0.00 ( 0%) sys   0.20 ( 0%) wall       0 kB ( 0%) ggc
integration           :   0.03 ( 0%) usr   0.00 ( 0%) sys   0.04 ( 0%) wall    4095 kB ( 1%) ggc
tree gimplify         :   0.22 ( 0%) usr   0.00 ( 0%) sys   0.23 ( 0%) wall   36068 kB ( 6%) ggc
tree eh               :   0.06 ( 0%) usr   0.00 ( 0%) sys   0.14 ( 0%) wall    5678 kB ( 1%) ggc
tree CFG construction :   0.08 ( 0%) usr   0.01 ( 0%) sys   0.10 ( 0%) wall   38544 kB ( 7%) ggc
....
expand vars           : 715.98 (97%) usr   1.62 (27%) sys 718.32 (83%) wall   18359 kB ( 3%) ggc
expand                :   1.04 ( 0%) usr   0.09 ( 1%) sys   1.64 ( 0%) wall  190836 kB (33%) ggc
post expand cleanups  :   0.09 ( 0%) usr   0.01 ( 0%) sys   0.15 ( 0%) wall      43 kB ( 0%) ggc
....
rest of compilation   :   1.94 ( 0%) usr   2.56 (43%) sys 102.42 (12%) wall   63620 kB (11%) ggc
TOTAL                 : 739.68             6.01           866.46             586293 kB

因此,对于">展开vars"阶段的巨大N,还有一些额外的工作。这个阶段正好在这一行:cfgexpand.c:4463(在TV_VAR_EXPAND宏之间)。

有趣的事实是:我使用自定义编译的32位g++4.6.2的编译时间非常短(对于N=100000,大约20秒)。

我的g++和ubuntu的g++有什么区别?默认情况下,这个选项在Ubuntu中启用Gcc堆栈保护(std::vector0选项)。这种保护仅添加到"扩展vars"阶段(可在源代码cfgexpand.c:1644,expand_used_vars()中找到;此处提及):

N=100000,堆栈保护器禁用,选项-fno-stack-protector(用于您的代码):

$ g++ -ftime-report -fno-stack-protector pb100k.cpp 2>&1 |egrep 'TOTAL|expand vars'
expand vars           :   0.08 ( 0%) usr   0.01 ( 1%) sys   0.09 ( 0%) wall   18359 kB ( 3%) ggc
TOTAL                 :  23.05             1.48            24.60             586293 kB

运行时间为24秒,低于800秒。

更新:

callgrind(从Valgrind调用图形评测工具)中启动gcc之后,我可以说有N个堆栈变量。如果启用了堆栈保护器,它们将在"扩展变量"阶段使用三个O(N^2)算法进行处理。实际上,有N^2个成功的冲突检测和1,5*N^2位的操作,加上一些嵌套的循环逻辑。

为什么堆栈变量的数量如此之多?因为代码中的每个双常量都保存到堆栈中的不同插槽中。然后,它从插槽中加载,并按照调用约定进行传递(通过x86中的栈顶;通过x8_64中的寄存器)。有趣的是:所有用-fstack-protector或用-fno-stack-protector编译的push_back代码都是相同的;常量的堆栈布局也是一样的。只有一些非push_back代码的堆栈布局偏移会受到影响(用-Sdiff -u检查了两次)。启用的堆栈保护程序未创建其他代码。

启用堆栈保护程序会致命地更改编译器内部的某些行为。无法说出确切的位置(注意:通过将堆栈轨迹与Juan M.Bello Rivas的callgraph.tar.gz进行比较,可以找到这个转折点)。

第一个大N*(N+1)/2=O(N^2)遍历是在expand_used_vars_for_block (tree block, level)函数中,用于设置有关堆栈变量对之间冲突的信息:

/* Since we do not track exact variable lifetimes (which is not even
possible for variables whose address escapes), we mirror the block
tree in the interference graph.  Here we cause all variables at this
level, and all sublevels, to conflict.  */
if (old_sv_num < this_sv_num)
{
new_sv_num = stack_vars_num;
for (i = old_sv_num; i < new_sv_num; ++i)
for (j = i < this_sv_num ? i : this_sv_num; j-- > old_sv_num ;)
add_stack_var_conflict (i, j);
}
}

add_stack_var_conflict(i,j)转向

  • 正在分配(每个变量一次)大小为…的位图。。。哦,动态大小将增长到N位
  • 在第i个var的位掩码中设置一个位以与j冲突
  • 在第j个var的位掩码中设置与i冲突的位

add_alias_set_conflicts存在第二次N^2行走。它使用objects_must_conflict_p对每一对进行类型检查。它检查两个变量是否属于同一类型(大多数对都是;这是基于类型的别名分析,TBAA)。否则,调用add_stack_var_conflict;从这个N^2循环嵌套只有N个这样的调用。

最后一个巨大的遍历是在partition_stack_vars()函数中,qsorting的堆栈变量(O(NlogN))和N*(N-1)/2=O(N^2)遍历来找到所有不冲突的对。以下是cfgexpand.c文件中partition_stack_vars的伪代码:

Sort the objects by size.
For each object A {
S = size(A)
O = 0
loop {
Look for the largest non-conflicting object B with size <= S.
/* There is a call to stack_var_conflict_p to check for 
* conflict between 2 vars */
UNION (A, B)
offset(B) = O
O += size(B)
S -= size(B)
}
}

函数stack_var_conflict_p只是检查在某个第i个变量中是否存在冲突位掩码,以及是否有第j个位被设置为与第j个变量的冲突标志(调用bitmap_bit_p(i->conflict_mask,j))。这里真正坏的消息是,callgrind说每个冲突检查都成功了,并且每对都跳过了UNION逻辑。

因此,O(N^2)位集和O(N^2/2)位检查浪费了大量时间;所有这些工作都无助于优化任何东西。是的,这一切都是-O0的一部分,由-fstack-protector触发。

更新2:

看起来,转折点是4.6中的expand_one_varcfgexpand.c,检查堆栈上变量的立即或延迟分配:

1110      else if (defer_stack_allocation (var, toplevel))
1111        add_stack_var (origvar);
1112      else
1113        {
1114          if (really_expand)
1115            expand_one_stack_var (origvar);
1116          return tree_low_cst (DECL_SIZE_UNIT (var), 1);
1117        }

(根据callgrind的说法,expand_one_stack_var仅在快速变体中被调用)

启用-fstack-protect时强制执行延迟分配(有时需要对所有堆栈变量重新排序)。甚至有一条关于"二次问题"的评论,它现在对我们来说太熟悉了:

969 /* A subroutine of expand_one_var.  VAR is a variable that will be
970    allocated to the local stack frame.  Return true if we wish to
971    add VAR to STACK_VARS so that it will be coalesced with other
972    variables.  Return false to allocate VAR immediately.
973 
974    This function is used to reduce the number of variables considered
975    for coalescing, which reduces the size of the quadratic problem.  */
976 
977 static bool
978 defer_stack_allocation (tree var, bool toplevel)
979 {
980   /* If stack protection is enabled, *all* stack variables must be deferred,
981      so that we can re-order the strings to the top of the frame.  */
982   if (flag_stack_protect)
983     return true;

(-O2及更高版本的堆栈分配也被推迟)

以下是承诺:http://gcc.gnu.org/ml/gcc-patches/2005-05/txt00029.txt这增加了这个逻辑。

这个问题完全被osgx的精彩回答所回答。

也许还有一个额外的方面:push_back()与初始化列表

当用100000个push_backs运行上面的测试时,我在Debian 6.0.6系统上用gcc 4.4.6得到了以下结果:

$ time g++ -std=c++0x -ftime-report ./pb100k.cc 
Execution times (seconds)
garbage collection    :   0.55 ( 1%) usr   0.00 ( 0%) sys   0.55 ( 1%) wall       0 kB ( 0%) ggc
...
reload                :  33.95 (58%) usr   0.13 ( 6%) sys  34.14 (56%) wall   65723 kB ( 9%) ggc
thread pro- & epilogue:   0.66 ( 1%) usr   0.00 ( 0%) sys   0.66 ( 1%) wall      84 kB ( 0%) ggc
final                 :   1.82 ( 3%) usr   0.01 ( 0%) sys   1.81 ( 3%) wall      21 kB ( 0%) ggc
TOTAL                 :  58.65             2.13            60.92             737584 kB
real    1m2.804s
user    1m0.348s
sys     0m2.328s

当使用初始化列表时,速度很多:

$ cat pbi100k.cc
#include <vector>
using namespace std;
int main()
{
vector<double> d {
0.190987822870774,
/* 100000 lines with doubles generated with:
perl -e 'print(rand(10),",n") for 1..100000'
*/
7.45608614801021};
return d.size();
}
$ time g++ -std=c++0x -ftime-report ./pbi100k.cc 
Execution times (seconds)
callgraph construction:   0.02 ( 2%) usr   0.00 ( 0%) sys   0.02 ( 1%) wall      25 kB ( 0%) ggc
preprocessing         :   0.72 (59%) usr   0.06 (25%) sys   0.80 (54%) wall    8004 kB (12%) ggc
parser                :   0.24 (20%) usr   0.12 (50%) sys   0.36 (24%) wall   43185 kB (65%) ggc
name lookup           :   0.01 ( 1%) usr   0.05 (21%) sys   0.03 ( 2%) wall    1447 kB ( 2%) ggc
tree gimplify         :   0.01 ( 1%) usr   0.00 ( 0%) sys   0.02 ( 1%) wall     277 kB ( 0%) ggc
tree find ref. vars   :   0.01 ( 1%) usr   0.00 ( 0%) sys   0.01 ( 1%) wall      15 kB ( 0%) ggc
varconst              :   0.19 (15%) usr   0.01 ( 4%) sys   0.20 (14%) wall   11288 kB (17%) ggc
integrated RA         :   0.02 ( 2%) usr   0.00 ( 0%) sys   0.02 ( 1%) wall      74 kB ( 0%) ggc
reload                :   0.01 ( 1%) usr   0.00 ( 0%) sys   0.01 ( 1%) wall      61 kB ( 0%) ggc
TOTAL                 :   1.23             0.24             1.48              66378 kB
real    0m1.701s
user    0m1.416s
sys     0m0.276s

这大约快30多倍!

我认为长时间与向量作为模板有关。编译器需要用相应的函数重写push_back的每一次出现。这就像有许多重载函数一样,编译需要进行名称篡改来寻址正确的函数。与简单地编译非重载函数相比,这是一项额外的工作。