奇怪的算法性能

20

为了解释上下文,我编写了这个算法来获取任意字符串的唯一子字符串数。它构建了该字符串的后缀树,计算它所包含的节点并将其作为答案返回。我想要解决的问题需要一个O(n)算法,因此这个问题只涉及此代码的行为,而不是它在处理上的问题有多糟糕。

struct node{
    char value = ' ';
    vector<node*> children;
    ~node()
    {
        for (node* child: children)
        {
            delete child;
        }
    }
};

int numberOfUniqueSubstrings(string aString, node*& root)
{
    root = new node();
    int substrings = 0;
    for (int i = 0; i < aString.size(); ++i)
    {
        string tmp = aString.substr(i, aString.size());
        node* currentNode = root;
        char indexToNext = 0;
        for (int j = 0; j < currentNode->children.size(); ++j)
        {
            if (currentNode->children[j]->value == tmp[indexToNext])
            {
                currentNode = currentNode->children[j];
                j = -1;
                indexToNext++;
            }
        }
        for (int j = indexToNext; j < tmp.size(); ++j)
        {
            node* theNewNode = new node;
            theNewNode->value = tmp[j];
            currentNode->children.push_back(theNewNode);
            currentNode = theNewNode;
            substrings++;
        }
    }
    return substrings;
}

我决定对这个算法进行基准测试,我只是循环遍历一个大字符串,在每次迭代中取一个更大的子字符串,并调用numberOfUniqueSusbstrings来测量结束所需的时间。

我在Octave中绘制了它,这就是我的结果(x是字符串长度,y是微秒级别的时间)。

Performance plot, x is string length and y is time in microseconds

我一开始以为问题在输入字符串上,但它只是我从一本书中获取的字母数字字符串(任何其他文本都会表现得同样奇怪)。

我还尝试了对具有相同参数的函数进行多次调用的平均值,结果几乎相同。

这是使用g++ problem.cpp -std=c++14 -O3编译的,但似乎在-O2-O0上也是如此。

编辑: 在@interjay的回答之后,我尝试做到这一点,最终函数如下:

int numberOfUniqueSubstrings(string aString, node*& root)
{
    root = new node();
    int substrings = 0;
    for (int i = 0; i < aString.size(); ++i)
    {
        node* currentNode = root;
        char indexToNext = i;
        for (int j = 0; j < currentNode->children.size(); ++j)
        {
            if (currentNode->children[j]->value == aString[indexToNext])
            {
                currentNode = currentNode->children[j];
                j = -1;
                indexToNext++;
            }
        }
        for (int j = indexToNext; j < aString.size(); ++j)
        {
            node* theNewNode = new node;
            theNewNode->value = aString[j];
            currentNode->children.push_back(theNewNode);
            currentNode = theNewNode;
            substrings++;
        }
    }
    return substrings;
}

它确实使其变得更快。但是,对我来说这并不奇怪,因为我绘制了这个图:

plot without tmp string

x = 1000 处发生了某些事情,我不知道可能是什么。

另一个重要的绘图:

enter image description here

我现在对长度为999的字符串运行了gprof:

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  us/call  us/call  name    
100.15      0.02     0.02      974    20.56    20.56  node::~node()
  0.00      0.02     0.00   498688     0.00     0.00  void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&)
  0.00      0.02     0.00        1     0.00     0.00  _GLOBAL__sub_I__Z7imprimePK4node
  0.00      0.02     0.00        1     0.00     0.00  numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&)
^L
            Call graph


granularity: each sample hit covers 2 byte(s) for 49.93% of 0.02 seconds

index % time    self  children    called     name
                               54285             node::~node() [1]
                0.02    0.00     974/974         test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
[1]    100.0    0.02    0.00     974+54285   node::~node() [1]
                               54285             node::~node() [1]
-----------------------------------------------
                                                 <spontaneous>
[2]    100.0    0.00    0.02                 test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
                0.02    0.00     974/974         node::~node() [1]
                0.00    0.00       1/1           numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
-----------------------------------------------
                0.00    0.00  498688/498688      numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
[10]     0.0    0.00    0.00  498688         void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&) [10]
-----------------------------------------------
                0.00    0.00       1/1           __libc_csu_init [21]
[11]     0.0    0.00    0.00       1         _GLOBAL__sub_I__Z7imprimePK4node [11]
-----------------------------------------------
                0.00    0.00       1/1           test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
[12]     0.0    0.00    0.00       1         numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
                0.00    0.00  498688/498688      void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&) [10]
-----------------------------------------------

对于一个大小为1001的字符串:

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  us/call  us/call  name    
100.15      0.02     0.02      974    20.56    20.56  node::~node()
  0.00      0.02     0.00   498688     0.00     0.00  void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&)
  0.00      0.02     0.00        1     0.00     0.00  _GLOBAL__sub_I__Z7imprimePK4node
  0.00      0.02     0.00        1     0.00     0.00  numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&)


            Call graph


granularity: each sample hit covers 2 byte(s) for 49.93% of 0.02 seconds

index % time    self  children    called     name
                               54285             node::~node() [1]
                0.02    0.00     974/974         test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
[1]    100.0    0.02    0.00     974+54285   node::~node() [1]
                               54285             node::~node() [1]
-----------------------------------------------
                                                 <spontaneous>
[2]    100.0    0.00    0.02                 test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
                0.02    0.00     974/974         node::~node() [1]
                0.00    0.00       1/1           numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
-----------------------------------------------
                0.00    0.00  498688/498688      numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
[10]     0.0    0.00    0.00  498688         void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&) [10]
-----------------------------------------------
                0.00    0.00       1/1           __libc_csu_init [21]
[11]     0.0    0.00    0.00       1         _GLOBAL__sub_I__Z7imprimePK4node [11]
-----------------------------------------------
                0.00    0.00       1/1           test(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) [2]
[12]     0.0    0.00    0.00       1         numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [12]
                0.00    0.00  498688/498688      void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&) [10]
-----------------------------------------------


Index by function name

  [11] _GLOBAL__sub_I__Z7imprimePK4node [1] node::~node()
  [12] numberOfUniqueSubstrings(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, node*&) [10] void std::vector<node*, std::allocator<node*> >::_M_emplace_back_aux<node* const&>(node* const&)

然而,似乎运行分析器会消除这种影响,两种情况下的时间几乎相同。


8
您的图表开始时分散度很大,在1000以后非常小。看起来一些标准类(vectorstring)使用另一种算法,如果它包含的元素超过1000个。 - Denis Sologub
2
这真的是1000,而不是1024吗? - user31264
2
你使用的是哪个操作系统和标准库?在进行了1000次以上分配后,堆的行为是否会有所改变呢? - user541686
3
“x = 1000 发生了某些事情”--我可能看错了,但是在 x = 256 的地方不也发生了“某些事情”吗?这感觉像是库内部的魔数缓冲区。 - LSerni
4
你有一个打字错误:char indexToNext = i; 应该改为 int indexToNext = i;。一旦 indexToNext 达到了 128,这个函数就开始访问 aString 中的负位置。 - Anton
显示剩余16条评论
3个回答

11

大多数人的工作假设似乎是库中硬编码了某种魔术数字,导致在999-1000左右(除了LSerni之外,他观察到可能有多个魔术数字)性能发生相位转变。

我将尝试系统地探索这个和下面几个假设(源代码在本答案结尾处提供)。

我在我的Intel(R) Core(TM) i5 CPU M480,Linux 4.8.0-34-generic机器上,使用G++ 6.2.0-5ubuntu2作为编译器并启用-O3优化来运行我的代码,以查看是否可以复制您在自己的机器上得出的结果。

果然,在999-1000(和1600附近)有一个神奇的下降:

Data from one machine

请注意,我的trans-1000数据集不像您的那样干净:这可能是因为我正在我的机器上玩其他一些东西,而您的测试环境更加安静。

我的下一个问题是:这个神奇的1000数字在不同环境下稳定吗?

所以我尝试在Intel(R) Xeon(R) CPU E5-2680 v3,Linux 2.6.32-642.6.1.el6.x86_64机器上使用G++ 4.9.2运行代码。并且,不出所料,神奇数字是不同的,在975-976发生:

Data from another machine

这告诉我们,如果有一个魔术数字,它已经在版本之间更改了。这减少了我对魔术数字理论的信心,有以下一些原因:(a)它是会变的。(b)1000+24字节的额外开销是魔法数字的一个好候选;而975+49字节则不太可能。(c)第一个环境在速度较慢的处理器上拥有更好的软件,但第一个环境显示了我认为性能更差的表现:等到1000才加速。这似乎是一个回归。

我尝试了另一个测试:使用不同的随机输入数据运行程序。那就得到了下面的结果:

Data from multiple runs

上图中的要点是999-1000的下降并不那么特别。它看起来像许多之前的下降:缓慢的速度下降,然后急剧地改善。值得注意的是,许多先前的下降不对齐。

这启示我这是一种与输入相关的行为,并且运行之间存在相关性。因此,我想知道如果随机排列运行次序会发生什么。这给出了:

随机排列的图

在999-1000附近仍然有些情况发生:

随机排列的图(缩放)

让我们甚至再更加缩小

随机排列的图(超级缩放)

在旧软件上在更快速的计算机上运行出现了类似的结果:

更快机器上的随机排序的图

缩放:

更快机器上的随机排序的图(缩放)

由于随机排列不同长度的字符串的顺序基本上消除了运行之间的缓慢累积(前述的相关性),这表明您看到的现象需要某种全局状态。因此,C++字符串/向量不能是一个解释。因此,malloc、“操作系统”或架构限制必须是解释。

请注意,当长度的顺序被随机化时,会有一点使代码运行变慢而不是更快。在我看来,这与某种高速缓存大小超过了有关,但信号中的噪声以及此帖子中的第一个图表也表明可能存在内存碎片问题。因此,我决定在每次运行之前重新启动程序,以确保获得新的堆。结果如下:

随机排序的图具有新的堆

现在我们看到没有更多的断点或跳跃。这表明缓存大小不是问题,而是观察到的行为与程序的总体内存使用有关。

反对缓存效应的另一个论据如下。两台机器都有32kB和256kB的L1和L2缓存,因此它们的缓存性能应该是相似的。我的慢速机器具有3,072kB的L3缓存。如果您假设每个分配使用4kB页面,则1000个节点需要4,000kB的内存,这接近于缓存大小。但是,快速机器具有30,720kB的L3缓存,并在975处显示中断。如果现象是缓存效应,则您期望中断(如果有的话)会更晚出现。因此,我非常确定这里没有缓存效应。

唯一剩下的罪魁祸首就是malloc。

为什么会发生这种情况?我不确定。但是,作为程序员,我不关心,请看以下内容:

这可能有一个解释,但是它是在太深的层次上,无法改变或真正担心。我可以采取一些奇特的措施来修复它,但那需要思考发生了什么事情在它黑暗的腹部。我们使用像C++这样的高级语言,特别是避免 messing with those kinds of details 除非我们真的必须这样做。

而我的结果表明,在这种情况下我们不必这样做。 (a) 最后一个图表告诉我们,代码的任何独立运行都可能展示接近最佳的行为,(b) 随机化顺序运行可以平衡性能,(c) 效率损失大约是一百分之一秒,这完全可以接受,除非您正在处理大量数据。

以下是源代码。请注意,此代码将您版本的 char indexToNext更改为 int indexToNext,以修复可能出现的整数溢出问题。测试interjay's suggestion 避免对字符串进行复制实际上导致性能更差。

#include <string>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <vector>
#include <time.h>
#include <algorithm>

struct profiler
{
  std::string name;
  std::chrono::high_resolution_clock::time_point p;
  profiler(std::string const &n) :
      name(n), p(std::chrono::high_resolution_clock::now()) { }
  ~profiler()
  {
      using dura = std::chrono::duration<double>;
      auto d = std::chrono::high_resolution_clock::now() - p;
      std::cout //<< name << ": "
          << std::chrono::duration_cast<dura>(d).count()
          << std::endl;
  }
};

#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)

struct node {
  char value = ' ';
  std::vector<node*> children;
  ~node(){
    for (node* child: children)
      delete child;
  }
};

int numberOfUniqueSubstrings(const std::string aString, node*& root)
{
    root = new node();
    int substrings = 0;
    for (int i = 0; i < aString.size(); ++i)
    {
        node* currentNode = root;
        int indexToNext = i;
        for (int j = 0; j < currentNode->children.size(); ++j)
        {
            if (currentNode->children[j]->value == aString[indexToNext])
            {
                currentNode = currentNode->children[j];
                j = -1;
                indexToNext++;
            }
        }
        for (int j = indexToNext; j < aString.size(); ++j)
        {
            node* theNewNode  = new node;
            theNewNode->value = aString[j];
            currentNode->children.push_back(theNewNode);
            currentNode = theNewNode;
            substrings++;
        }
    }
    return substrings;
}


int main(int argc, char **argv){
  const int MAX_LEN = 1300;

  if(argc==1){
    std::cerr<<"Syntax: "<<argv[0]<<"<SEED> [LENGTH]"<<std::endl;
    std::cerr<<"Seed of -1 implies all lengths should be explore and input randomized from time."<<std::endl;
    std::cerr<<"Positive seed sets the seed and explores a single input of LENGTH"<<std::endl;
    return -1;
  }

  int seed = std::stoi(argv[1]);

  if(seed==-1)
    srand(time(NULL));
  else
    srand(seed);

  //Generate a random string of the appropriate length
  std::string a;
  for(int fill=0;fill<MAX_LEN;fill++)
      a.push_back('a'+rand()%26);

  //Generate a list of lengths of strings to experiment with
  std::vector<int> lengths_to_try;
  if(seed==-1){
    for(int i=1;i<MAX_LEN;i++)
      lengths_to_try.push_back(i);
  } else {  
    lengths_to_try.push_back(std::stoi(argv[2]));
  }

  //Enable this line to randomly sort the strings
  std::random_shuffle(lengths_to_try.begin(),lengths_to_try.end());

  for(auto len: lengths_to_try){
    std::string test(a.begin(),a.begin()+len);

    std::cout<<len<<" ";
    {
      PROFILE_BLOCK("Some time");
      node *n;
      int c = numberOfUniqueSubstrings(test,n);
      delete n;
    }
  }
}

substr 是一个“常数”

OP 的原始代码包括以下内容:

for (int i = 0; i < aString.size(); ++i)
{
  string tmp = aString.substr(i, aString.size());

substr操作的时间复杂度为O(n),n为字符串长度。在下面的答案中,有人认为这个O(n)操作导致了OP原始代码的性能问题。

我不同意这种评估。由于缓存和SIMD操作,CPU可以按块读取和复制数据,每块可达到64字节(或更多!)。因此,内存分配的成本可能会支配复制字符串的成本。因此,对于OP的输入大小,substr操作更像是一个昂贵的常数而不是额外的循环。

这可以通过使用例如g++ temp.cpp -O3 --std=c++14 -g编译代码并使用例如sudo operf ./a.out -1进行分析来进行测试。生成的时间使用概要如下:

25.24%  a.out    a.out                [.] _ZN4nodeD2Ev        #Node destruction                                                                           
24.77%  a.out    libc-2.24.so         [.] _int_malloc                                                                                    
13.93%  a.out    libc-2.24.so         [.] malloc_consolidate                                                                            
11.06%  a.out    libc-2.24.so         [.] _int_free                                                                                      
 7.39%  a.out    libc-2.24.so         [.] malloc                                                                                        
 5.62%  a.out    libc-2.24.so         [.] free                                                                                          
 3.92%  a.out    a.out                [.] _ZNSt6vectorIP4nodeSaIS1_EE19_M_emplace_back_auxIJRKS1_EEEvDpOT_                              
 2.68%  a.out    a.out                [.]
 8.07%  OTHER STUFF

显然,内存管理在运行时占据着主导地位。


1
我认为你写的所有内容 - 顺便说一下,这是一个不错的分析 - 都符合我的理论,即这是malloc的空闲列表维护的一个副产品。这可以通过完全避免在计时中使用malloc来进一步支持:预先分配一个节点池,其中包含最大可能大小的字符和子数组,而不是动态分配。 - Gene
1
谢谢,@Gene:我觉得你的回答抓到了问题的核心(所以我投了赞)。不过,如果在没有使用malloc的情况下使用运行结果,我会感到有些紧张,因为这会同时影响缓存行为。对于这种效果的分析,我并不太熟悉,但这似乎是最好的经验方法。 - Richard

10
for (int i = 0; i < aString.size(); ++i)
{
    string tmp = aString.substr(i, aString.size());

这使得你的算法复杂度已经达到了O(n^2)或更糟。调用substr会平均创建一个大小为n/2的子字符串,所以它需要O(n)的时间,并且你会调用它n次。

看起来你实际上并不需要tmp字符串,因为你只从中读取数据。相反,从原始字符串中读取,但相应调整索引。

for (int j = indexToNext; j < tmp.size(); ++j)循环也会让你的算法总时间复杂度达到O(n^2)(我说“可能”是因为它取决于计算出的indexToNext的值,但通过随机字符串测试似乎成立)。它被执行了O(n)次,每次最多需要O(n)次迭代。


5
您应该添加一个关于std::string_view的部分,它可以让他在不复制的情况下处理子字符串。 - Guillaume Racicot
@Richard,1000字节绝对足够引起问题了。substr的时间复杂度是O(k),其中k是结果的大小。你可以清楚地看到OP帖子中原始图形是抛物线形状,而在他修复我指出的问题后编辑的图形则呈现线性形状。我不知道你所说的“传递常量字符串引用到子程序中以及长度”的意思,因为我从未建议过这样做。顺便说一句,如果你因为测试规模较小就认为某个算法的时间复杂度是O(1),那说明你并不理解大O符号(它关注的是渐近性能)。 - interjay
算法的行为可能会因缓存效应而根据样本大小发生显著变化。我将您的陈述反馈给您:由于OP使用短字符串,因此您声称substrO(k)复杂度不适用。此网站的示例1和示例2提供了有关该效应的简要说明。我同意您正在为OP节省时间,但我认为节省的时间来自于减少内存分配和销毁,而不是从复制字符串所需的微不足道的时间。 - Richard
@interjay:让我再试着理解一下。你认为 substr 的成本是 O(n),至少对于大的 n 是这样的。我不反对这个观点。但我认为,由于 OP 所描述的输入大小中分配的成本占主导地位,因此在这种情况下,substr 实际上是一个昂贵的常数。你不同意吗? - Richard
@interjay: 我声称您可以将substr视为OP输入大小的常量,这正是因为与算法的其他部分(特别是为容纳substr结果所需的分配)相比,其O(n)成本微不足道。听起来你同意我的看法。如果不是这样,我很乐意查看您提供的任何测试。 - Richard
显示剩余12条评论

3
我怀疑mallocstringvector更有问题。它完全有可能对小于1000字节和大于1000字节的数据块进行不同的处理。合并释放的块可能很昂贵。它可能会避免尝试合并较大的块(即通过将它们保留在池中来维护)。但这只是一个猜测。为什么不尝试使用分析器并获取一些实际数据呢? gprof易于使用。 本文提供了关于glibc malloc的一些有趣细节。如果你的程序底层就是这个,那么其中描述的bin类型之间的差异可能正在起作用。确实,块被释放到偶尔重新组织的"未排序bin"中。这些峰值可能是为了防止堆增长而进行的重新组织。如果这个理论是正确的,那么平滑可能是扩大堆区大小以减少重新组织的开销的结果。
但再次强调,这都是猜测,可以通过运行分析器查看小于1000和大于1000时时间的使用情况来解决。

刚刚添加了999和1001的分析结果。这是我第一次运行gprof,所以可能不是预期的结果。我使用标志-pg进行编译,然后按照教程使用gprof -b a.out gmon.out > analysis.txt进行分析。 - mjgalindo

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