STL对字符串向量和字符串指针向量进行排序的性能比较

3

我尝试比较STL排序在字符串向量和指向字符串的指针向量上的性能。

我原本以为指针版本会表现更好,但对于随机生成的500万个字符串,实际结果是:

字符串向量:12.06秒
指向字符串的指针向量:16.75秒

这种行为有什么解释呢?我原本以为交换指向字符串的指针应该比交换字符串对象更快。

这500万个字符串是通过转换随机整数生成的。
编译器使用(gcc 4.9.3): g++ -std=c++11 -Wall
CPU: Xeon X5650

// sort vector of strings
 int main(int argc, char *argv[])
    {
      const int numElements=5000000;
      srand(time(NULL));
      vector<string> vec(numElements);

      for (int i = 0; i < numElements; i++)
            vec[i] = std::to_string(rand() % numElements);

      unsigned before = clock();

      sort(vec.begin(), vec.end());

      cout<< "Time to sort: " << clock() - before << endl;

       for (int i = 0; i < numElements; i++)
         cout << vec[i] << endl;

      return 0;
    }



// sort vector of pointers to strings
    bool comparePtrToString (string *s1, string *s2)
    {
      return (*s1 < *s2);
    }

    int main(int argc, char *argv[])
    {
      const int numElements=5000000;
      srand(time(NULL));
      vector<string *> vec(numElements);

      for (int i = 0; i < numElements; i++)
            vec[i] = new string( to_string(rand() % numElements));

      unsigned before = clock();

      sort(vec.begin(), vec.end(), comparePtrToString);

      cout<< "Time to sort: " << clock() - before << endl;

       for (int i = 0; i < numElements; i++)
         cout << *vec[i] << endl;

      return 0;
    }

2
使用指针版本,字符串可能不会存储在连续的内存中?也许你一直在经历缓存交换而导致的减速? - Fantastic Mr Fox
1
缓存局部性为王。 - Chad
@ThomasG 我不知道有哪些工具可以做到这一点。 - Fantastic Mr Fox
如果您需要对字符串进行排序,请使用链表。这样,您只需移动指针而不是数据。 - graham.reeds
正如答案所指出的那样,交换字符串对象至少与交换指向字符串的指针一样有效。原因是,更近期的编译器版本使用移动语义和调整指针等高效实现它。我进一步研究了这个问题,通过生成长度从10到1024不等的随机字符串并对它们进行排序。 普通字符串版本总是比指针版本表现更好,即使对于长度超过1000字节的字符串也是如此。 - ramana_k
显示剩余3条评论
5个回答

5
这是因为sort在对strings进行操作时,所执行的全部操作都是移动和交换。对于std::string,移动和交换都是常数时间操作,这意味着它们只涉及更改一些指针。
因此,在两种排序方式中,数据的移动具有相同的性能开销。然而,在指向字符串的指针的情况下,您需要支付额外的成本来解除引用每个比较中的指针,这会导致其明显变慢。

所有其他答案都说_casting_是额外的成本,但实际上,casting是一个常数时间操作。正如你所提到的,_dereferencing_才是杀手。 - Chad
嗯,一些类型转换(例如从 char*std::string)是有代价的,但它们都不在 OP 的代码片段中。 - Ishamael
同意,任何具有非显式单参数构造函数的类型都可能会引起“隐藏”的成本 - 隐藏在转换是隐式的这一事实上。最小化动态分配(以避免内存碎片化及其带来的所有危险)是一个值得追求的目标。 - Chad
1
一个std::string通常比一个std::string*大约多4倍左右。这个因素虽然被其他效应所掩盖,但也不是没有作用的。 - Deduplicator

2
在第一种情况下,字符串表示的内部指针交换,而不是完整数据被复制。
使用指针实现不会带来任何好处,事实上更慢,因为需要额外解引用指针才能执行比较。

2

这种行为是如何解释的?我本来以为交换字符串指针比交换字符串对象更快。

这里有很多因素可能会影响性能。

  1. 交换字符串的成本相对较低。 对于大字符串,交换字符串通常是浅操作(只交换像指针和整数这样的POD),对于小字符串可能会是深操作(但仍然非常便宜--取决于实现)。 因此,交换字符串总体上往往相当便宜,并且通常不比简单地交换指向它们的指针更昂贵*。

    [ sizeof(string)肯定比sizeof(string*)更大,但基本上操作仍然在常量时间内完成,而且在这种情况下,当字符串字段已经被获取到更快的内存中以供比较器使用时,这种操作要便宜得多,使我们在其字段方面具有时间局部性。 ]

  2. 无论哪种方式,都必须访问字符串内容。 即使是使用指针版本的比较器,也必须检查字符串内容(包括指定sizecapacity的字段)。 因此,我们最终都要付出获取字符串内容数据的内存成本。 当然,如果您只按指针地址(例如,而不是使用字典序比较字符串内容)对字符串进行排序,则性能优势应该转向指针版本,因为这将大大减少访问的数据量,同时提高空间局部性(例如,更多指针可以适合在缓存行中而不是字符串)。

  3. 指针版本会使内存中的字符串字段分散(或至少增加步幅)。 对于指针版本,您将每个字符串都分配给自由存储区(除了可能分配给自由存储区的字符串内容之外)。 这可能会分散内存并降低参考局部性,因此您在比较器中潜在地承担更大的成本,其中包括增加的缓存未命中。 即使这种顺序分配导致分配非常连续的页面的理想情况,由于分配元数据/对齐开销(并非所有分配器都要求元数据直接存储在块中,但通常它们至少会向块大小添加一些小开销),从一个字符串的字段到下一个字符串的字段的步幅也往往会变得至少有点更大。

    这可能更简单地归因于解引用指针的成本,但在这种相对上下文中,昂贵的不是执行内存寻址的mov/load指令的成本,而是从未被缓存/分页到更快,更小的内存中加载的更慢/更大的形式的内存。 单独在自由存储区上分配每个字符串通常会增加这种成本,无论是由于连续性的丧失还是由于每个字符串条目之间的较大或可变步幅(在理想情况下)。

    即使在不太努力诊断在内存级别发生了什么的基本水平上,这也会增加机器必须查看的数据总量(字符串内容/字段+指针地址),除了降低局部性/较大或变量步幅(通常如果增加访问的数据量,则它必须具有改进的局部性才有可能有益)。 如果您只对已经按顺序分配的字符串进行排序(不是指字符串

    交换较小的数据类型,如索引或指针,有时可能会带来好处,但它们通常需要避免检查所引用的数据的原始内容或提供显著更便宜的交换/移动行为(在这种情况下,字符串已经很便宜,并且在考虑时间局部性的情况下变得更加便宜)。


1
在使用指向字符串的指针版本时,每个“string”都会单独分配。很难设计微基准来精确测量您想要测量的内容并避免混淆因素。通常需要足够的知识来对结果做出相当坚实的猜测。一个更有趣的基准测试将是“std::string”与“char *”之间的比较。 - Peter Cordes

1

好的,一个 std::string 通常比一个 std::string* 大3-4倍。
因此,直接交换两个前者会更多地移动内存。

但这被以下效果所淹没:

  1. 引用局部性。您需要跟随一个指针到一个随机位置来读取字符串。
  2. 更多的内存使用:每个 std::string 的指针加上分配的书面记录。

这两者都对缓存产生额外的需求,而前者甚至无法预取。


0

交换容器只会改变容器的内容,在字符串的情况下是指向字符串第一个字符的指针,而不是整个字符串。

在指向字符串的指针向量的情况下,您需要执行一个额外的步骤 - 强制转换指针。


强制转换指针?不太可能,而且通常这是一项零开销操作。 - Deduplicator

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