push_back()和emplace_back()背后的运作机制

12

我目前正在自学C++,并且对于push_back()emplace_back()在底层是如何工作很好奇。我一直认为当你尝试构造并将一个大对象推入容器(如vector)的末尾时,emplace_back()更快。

假设我有一个Student对象,我想将其附加到一个包含Student的向量的末尾。

struct Student {
   string name;
   int student_ID;
   double GPA;
   string favorite_food;
   string favorite_prof;
   int hours_slept;
   int birthyear;
   Student(string name_in, int ID_in, double GPA_in, string food_in, 
           string prof_in, int sleep_in, int birthyear_in) :
           /* initialize member variables */ { }
};

假设我调用push_back()并推送一个Student对象到向量的末尾:

vector<Student> vec;
vec.push_back(Student("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997));

我的理解是push_back在向量外部创建一个Student对象实例,然后将其移至向量的末尾。

示意图:https://ibb.co/hV6Jho

我也可以选择使用emplace而不是push:

vector<Student> vec;
vec.emplace_back("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997);

我的理解是,学生对象在向量的最后构建,因此不需要移动。

图表:enter image description here

因此,调用 emplace 的速度会更快,特别是当添加许多学生对象时。然而,当我计时这两个代码版本时:

for (int i = 0; i < 10000000; ++i) {
    vec.push_back(Student("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997));
}

for (int i = 0; i < 10000000; ++i) {
    vec.emplace_back("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997);
}

我本以为使用emplace_back会更快,因为不需要复制大型的Student对象。然而奇怪的是,在多次尝试后,emplace_back版本实际上变得更慢了。我还尝试插入10000000个Student对象,其中构造函数接受引用,并且push_back()emplace_back()中的参数存储在变量中。但这也行不通,因为emplace仍然更慢。

我已经检查过确保在两种情况下都插入相同数量的对象。时间差异并不太大,但emplace比push_back慢几秒钟。

我的push_back()emplace_back()的理解有问题吗?非常感谢您的时间!

如请求所示,以下是代码。我正在使用g++编译器。

Push back:

struct Student {
   string name;
   int student_ID;
   double GPA;
   string favorite_food;
   string favorite_prof;
   int hours_slept;
   int birthyear;
   Student(string name_in, int ID_in, double GPA_in, string food_in, 
           string prof_in, int sleep_in, int birthyear_in) :
           name(name_in), student_ID(ID_in), GPA(GPA_in), 
           favorite_food(food_in), favorite_prof(prof_in),
           hours_slept(sleep_in), birthyear(birthyear_in) {}
};

int main() {
    vector<Student> vec;
    vec.reserve(10000000);
    for (int i = 0; i < 10000000; ++i) 
         vec.push_back(Student("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997));
    return 0;
}

插入到末尾:

struct Student {
   string name;
   int student_ID;
   double GPA;
   string favorite_food;
   string favorite_prof;
   int hours_slept;
   int birthyear;
   Student(string name_in, int ID_in, double GPA_in, string food_in, 
           string prof_in, int sleep_in, int birthyear_in) :
           name(name_in), student_ID(ID_in), GPA(GPA_in), 
           favorite_food(food_in), favorite_prof(prof_in),
           hours_slept(sleep_in), birthyear(birthyear_in) {}
};

int main() {
    vector<Student> vec;
    vec.reserve(10000000);
    for (int i = 0; i < 10000000; ++i) 
         vec.emplace_back("Bob", 123456, 3.89, "pizza", "Smith", 7, 1997);
    return 0;
}

6
编译器?编译选项?可编译代码? - user2100815
2
一个区别是代码必须向 emplace_back 传递7个参数,而只需要向 push_back 传递一个参数。 - Bo Persson
1
@KillzoneKid push_back可以使用右引用和移动,但它会移动临时构造的对象。 - Gem Taylor
2
除非您在向量上使用了reserve(),否则对于大数量的情况,每次向量增长时移动所有现有元素的成本将会淹没插入成本的差异,我认为! - Gem Taylor
1
在 push_back 的情况下,优化器可能会认识到临时的 Student 对象是循环不变量,因此它可以被提升出循环,仅构造一次,并将其复制多次到向量中。在 emplace_back 的情况下,您必须每次都构造一个新对象,这可能涉及每次从字符串字面值创建 std::strings。 - Adrian McCarthy
显示剩余10条评论
1个回答

9
这种行为是由于 std::string 的复杂性引起的。有几个因素在此相互作用:
  • 小字符串优化(SSO)
  • 在使用push_back版本时,编译器能够在编译时确定字符串的长度,而编译器无法对emplace_back版本进行这样的操作。因此,emplace_back调用需要调用strlen。此外,由于编译器不知道字符串字面量的长度,因此它必须为SSO和非SSO情况分别生成代码(请参见Jason Turner的"Initializer Lists Are Broken, Let's Fix Them";这是一个很长的演讲,但他一直跟踪解决将字符串插入向量的问题)

考虑这个更简单的类型:

struct type {
  std::string a;
  std::string b;
  std::string c;

  type(std::string a, std::string b, std::string c)
    : a{a}
    , b{b}
    , c{c}
  {}
};

请注意构造函数是如何 复制 abc 的。 将其与仅分配内存的基准测试进行比较, 我们可以看到 push_back 的性能优于 emplace_back

enter image description here

点击图像获取quick-bench链接

因为你的示例中的所有字符串都适合SSO缓冲区,所以在这种情况下复制和移动的成本是相同的。因此,构造函数非常高效,并且emplace_back的改进效果较小。

此外,如果我们搜索汇编代码,寻找对push_backemplace_back的调用:

// push_back call
void foo(std::vector<type>& vec) {
    vec.push_back({"Bob", "pizza", "Smith"});
}

// emplace_back call
void foo(std::vector<type>& vec) {
    vec.emplace_back("Bob", "pizza", "Smith");
}

汇编代码没有在此处复制。它太庞大了。 std::string 很复杂。

我们可以看到 emplace_back 调用了 strlen,而 push_back 没有。由于字符串字面量和正在构造的 std::string 之间的距离增加了,编译器无法优化掉对 strlen 的调用。

显式调用 std::string 构造函数将消除对 strlen 的调用,但将不再原地构造它们,因此无法加速 emplace_back

所有这些说法都是建立在 如果我们使用足够长的字符串离开 SSO 的基础上。在这种情况下,分配成本完全淹没了这些细节,因此 emplace_backpush_back 的性能相同:

enter image description here

单击图像以获取快速工作台链接


如果您修复type的构造函数来移动其参数,则emplace_back在所有情况下都会更快。
struct type {
  std::string a;
  std::string b;
  std::string c;

  type(std::string a, std::string b, std::string c)
    : a{std::move(a)}
    , b{std::move(b)}
    , c{std::move(c)}
  {}
};

SSO案例

enter image description here

点击图片获取快速工作台链接

长案例

enter image description here

点击图像以获取快速工作链接

然而,SSO push_back 案例变慢了; 编译器似乎会发出额外的副本。

完美转发的最佳版本不受此缺点影响(请注意垂直轴上的比例变化):

struct type {
  std::string a;
  std::string b;
  std::string c;

  template <typename A, typename B, typename C>
  type(A&& a, B&& b, C&& c)
    : a{std::forward<A>(a)}
    , b{std::forward<B>(b)}
    , c{std::forward<C>(c)}
  {}
};

enter image description here

点击图片获取快速测试链接


1
这是一个不错的答案,但是emplace_back正在进行strlen调用的观察非常有趣。 - Cornstalks
@Cornstalks 更像是 emplace_back 防止编译器优化掉了 strlen,但是没错。 - Justin
你激发了我开启一个关于 emplace_backstrlen 的新问题。链接 - Cornstalks

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接