同时找到最小值和最大值:算法应该更快但不是

时间:2013-05-26 22:18:16

标签: c algorithm max min

我正在尝试实现一种算法来查找文件中一组longs中的最小值和最大值。我的测试文件包含10亿个长。

该算法按预期工作,但执行速度不如天真版本。它应该明显更快,因为天真版本执行大约2n次比较,而此版本执行3n / 2比较。

$ time ./findminmax_naive somelongs 
count: 1000000000
min: 0
max: 2147483647

real    0m24.156s
user    0m4.956s
sys     0m3.896s

$ time ./findminmax_faster somelongs 
count: 1000000000
min: 0
max: 2147483647

real    0m25.048s
user    0m6.948s
sys     0m3.980s

这是“天真”版本:

#include <alloca.h>
#include <stdio.h>
#include <stdlib.h>

int
main(int ac, char *av[])
{
        FILE *f;
        long count, readcount, i, min, max;
        size_t rlen;
        long *n;

        if (ac != 2 && ac != 3) {
                fprintf(stderr, "Usage: %s <file> [readcount]\n", av[0]);
                exit(1);
        }

        f = fopen(av[1], "r");
        if (f == NULL)
            perror("fopen");
        readcount = 1024;
        if (ac == 3)
            readcount = atol(av[2]);
        n = alloca(sizeof (long) * readcount);
        rlen = fread(n, sizeof (*n), 1, f);
        min = max = n[0];
        count = 1;
        while (1) {
                rlen = fread(n, sizeof (*n), readcount, f);
                for (i = 0; i < (long)rlen; i++) {
                        count++;
                        if (n[i] < min)
                            min = n[i];
                        if (n[i] > max)
                            max = n[i];
                }
                if (feof(f))
                        break;
        }
        printf("count: %ld\n", count);
        printf("min: %ld\n", min);
        printf("max: %ld\n", max);
        exit(0);
}

以下是(应该)“更快”版本的代码:

#include <alloca.h>
#include <stdio.h>
#include <stdlib.h>

int
main(int ac, char *av[])
{
        FILE *f;
        long count, readcount, i, min, max;
        size_t rlen;
        long *n;

        if (ac != 2 && ac != 3) {
                fprintf(stderr, "Usage: %s <file> [readcount]\n", av[0]);
                exit(1);
        }

        f = fopen(av[1], "r");
        if (f == NULL)
                perror("fopen");
        readcount = 1024;
        if (ac == 3)
                readcount = atol(av[2]);
        readcount = (readcount + 1) & (-1 << 1);
        n = alloca(sizeof (long) * readcount);
        rlen = fread(n, sizeof (*n), 1, f);
        min = max = n[0];
        count = 1;
        while (1) {
                rlen = fread(n, sizeof (*n), readcount, f);
                for (i = 0; i < (long)rlen; i += 2) {
                        count += 2;
                        if (n[i] < n[i + 1]) {
                                if (n[i] < min)
                                        min = n[i];
                                if (n[i + 1] > max)
                                        max = n[i + 1];
                        } else {
                                if (n[i + 1] < min)
                                        min = n[i + 1];
                                if (n[i] > max)
                                        max = n[i];
                        }
                }
                if (feof(f))
                        break;
        }
        if (rlen % 2) {
                if (n[rlen - 1] < min)
                        min = n[rlen - 1];
                if (n[rlen - 1] > max)
                        max = n[rlen - 1];
                count++;
        }
        printf("count: %ld\n", count);
        printf("min: %ld\n", min);
        printf("max: %ld\n", max);
        exit(0);
}

你知道我错过了什么吗?

感谢您的帮助。

- Jeremie

4 个答案:

答案 0 :(得分:4)

关键是分支预测。除非文件以病态最坏情况顺序排序,否则幼稚版本将执行几乎每次都正确预测的2n个分支。您的“聪明”版本执行几乎从未正确预测的n / 2个分支,以及可能正确预测的额外n个比较。

错误预测的分支成本有多少取决于cpu体系结构甚至特定的cpu模型,但至少我预计错误预测的分支的成本是正确预测分支的几倍。在极端情况下,正确预测的分支可能具有零周期的有效成本。

作为一个有趣的例子,我最近尝试优化strlen,并发现孤立的非常幼稚的展开strlen - 一次比较和分支一个字节 - 比聪明的矢量化更快方法。这几乎肯定是因为strlen具有每个分支的特殊属性,直到最后一个分支始终被正确预测。

顺便说一句,要测试我的假设,请尝试以下输入模式:

999999999,1000000001,999999998,1000000002,999999997,11000000003,...

它将为天真算法提供最坏情况的分支预测,并为您的聪明版本提供外部条件的最佳情况。

答案 1 :(得分:1)

正如@chr所说,“文件I / O会使算法本身的任何优化都相形见绌。”

此外,较少的比较并不等于较少的运行时间消耗。这两种算法的时间复杂度为O(n),忽略了实际的语句成本和抽象 成本。

例如,作为这两种算法的两个粗略框架,时间消耗是程序中所有语句成本的时间。

例如:

//max and min initlaized as 0.
//c1,... reprents the time cost of each instruction.
while(i<count) {//c1
    if(a[i]>max)  //c2
        max =  a[i]; //c3
    i++;    //c4
}
//search of min is like below

时间成本:

T1 = 2n * c1 + 2n * c2 + x * c3 + y * c3 + 2n * c4    = 2n *(c1 + c2 + c4)+(x + y)* c3

其中x和y符合数据的顺序。

而且,(3/2)n的比较,

while(i<count)  //c1 
    if(a[i]<a[i+1]) {//c5
        if(a[i]<min) //c2
            min = a[i]; //c3
        if(a[i+1>max]) //c2
            max = a[i+1]; //c3
    }
    else
        ...
        //same as below,that swap i and i+1
    i+=2; //c6
}

时间成本:

T2 = n * c1 + n * c5 + n * 2 * c2 +(x'+ y')* c3 + n * c6    = n *(c1 + c5 + c6)+ 2n * c2 +(x'+ y')* c3

如果max和min是数据的前两个元素,则x = x'= 1; y = y'= 1。

T1-T2 = n * c1 + 2n * c4-n * c5 -n * c6。 为了区分编码器,T1-T2可能不同。

更复杂的是x,y,x',y'是可变的,但我不会进一步讨论。我的目的是显示真正的运行时间成本。

如果您在阅读上述内容后仍感到困惑,则应阅读算法导论的第2.2章。

答案 2 :(得分:0)

我可以想到两件事:

  1. 它正在与分支预测器一起玩。
  2. 天真版本由编译器自动矢量化,“聪明”版本不是。

答案 3 :(得分:0)

首先,请原谅我在一个答案中回答所有问题。我知道我不应该在stackoverflow.com上这样做,但鉴于不同的主题或多或少交织在一起,这样就更容易了。

简介

所以,这是我现在用来测试算法的代码。 与以前版本的差异:

  • 它包括您通过命令行参数选择的两个版本;
  • @chr,@ Dukeling:mmap(2)s文件以阻止系统调用或库调用;
  • @chr,@ Dukeling:它有一个可选的“预热”选项,可以在运行所选算法之前将所有页面故障排入内存;
  • @Dukeling:程序将使用gettimeofday(2)记录算法本身所花费的时间。

以下是代码:

#include <sys/mman.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define ROUNDUP(x, y)   ((((x) + ((y) - 1)) / (y)) * (y))
#define USCARRY(x)      ((x) < 0 ? (x) + 1000000 : (x))

int
main(int ac, char *av[])
{
        struct stat st;
        struct timeval start, end;
        long count, min, max, pgsiz;
        long *n, *p, *endp;
        int fd, warmup;

        if (ac != 3 && ac != 4) {


              fprintf(stderr, "Usage: %s <\"trivial\"|\"faster\"> "
                    "<file> [warmup]\n", av[0]);
                exit(1);
        }

        fd = open(av[2], O_RDONLY);
        if (fd == -1)
                perror("open");
        fstat(fd, &st);
        pgsiz = sysconf(_SC_PAGESIZE);
        n = mmap(NULL, ROUNDUP(st.st_size, pgsiz), PROT_READ,
            MAP_SHARED, fd, 0);
        if (n == MAP_FAILED)
                perror("mmap");
        warmup = 0;
        if (ac == 4)
                warmup = atoi(av[3]);
        // warm up the filesystem cache
        count = st.st_size / sizeof (*n);
        endp = &n[count - 1];
        //printf("%zu\n", sizeof (*p));
        //printf("%zu\n", sizeof (count));
        //exit(0);
        if (warmup) {
                for (p = n; p <= endp; p++) {
                        fwrite(p, sizeof (*p), 1, stdout);
                        min = *p;
                }
        }
        // start algorithm
        gettimeofday(&start, NULL);
        if (!strcmp(av[1], "trivial")) {
                min = max = n[0];
                p = &n[1];
                while (p <= endp) {                     // c1 * n
                        if (p[0] < min)                 // c2 * n
                                min = p[0];             // c3 * x
                        if (p[0] > max)                 // c2 * n
                                max = p[0];             // c3 * y
                        p++;                            // c4 * n
                }                       
        } else if (!strcmp(av[1], "faster")) {
                min = max = n[0];
                p = &n[1];
                while (p < endp) {                      // c1 * n/2
                        if (p[0] < p[1]) {              // c2 * n/2
                                if (p[0] < min)         // c2 * n/4
                                        min = p[0];     // c3 * x/2
                                if (p[1] > max)         // c2 * n/4
                                        max = p[1];     // c3 * y/2
                        } else {
                                if (p[1] < min)         // c2 * n/4
                                        min = p[1];     // c3 * x/2
                                if (p[0] > max)         // c2 * n/4
                                        max = p[0];     // c3 * y/2
                        }
                        p += 2;                         // c5 * n
                }                                       
                if (p == endp) {
                        if (*endp < min)
                                min = *endp;
                        else if (*endp > max)
                                max = *endp;
                }
        } else {
                printf("sorry\n");
                exit(1);
        }
        gettimeofday(&end, NULL);
        printf("time: %ld.%ld\n", end.tv_sec - start.tv_sec,
            USCARRY(end.tv_usec - start.tv_usec));
        printf("count: %ld\n", count);
        printf("min: %ld\n", min);
        printf("max: %ld\n", max);
        exit(0);
}

测试用例

以下是我用于测试用例的文件:

$ ls -l _*
-rw-r--r-- 1 jlh jlh 2400000000 May 27 23:37 _bestcase
-rw-r--r-- 1 jlh jlh 2400000000 May 27 08:40 _random
-rw-r--r-- 1 jlh jlh 2400000000 May 27 23:38 _worstcase

$ od -N 64 -t dL _bestcase 
0000000                    0            300000000
0000020                    1            299999999
0000040                    2            299999998
0000060                    3            299999997
0000100

$ od -N 64 -t dL _random
0000000  8600270969454287374  8436406000964321037
0000020  7348498334162074664  2050332610730417325
0000040  8183771519386464726  4134520779774161130
0000060  2045475555785071180  2859007060406233926
0000100

$ od -N 64 -t dL _worstcase 
0000000            150000000            150000000
0000020            149999999            150000001
0000040            149999998            150000002
0000060            149999997            150000003
0000100

I / O惩罚

好的,首先让我们预热缓存并确认没有丢失的页面然后可能搞砸了结果:

$ ./findminmax trivial _random 
time: 3.543573
count: 300000000
min: 31499144704
max: 9223372004409096866

$ ./findminmax trivial _random 
time: 1.466323
count: 300000000
min: 31499144704
max: 9223372004409096866

$ perf stat -e  minor-faults,major-faults ./findminmax trivial _random 
time: 1.284729
count: 300000000
min: 31499144704
max: 9223372004409096866

 Performance counter stats for './findminmax trivial _random':

           586,066 minor-faults                                                
                 0 major-faults                                                

       1.350118552 seconds time elapsed

因为你可以看到没有重大的页面错误。从现在开始,我们可以认为不会产生I / O影响。 2.指令计数

指令计数和分支未命中

@ H2CO3,@ vvy,你对其他指令也很重要的事实是完全正确的(我会考虑每个操作在这里占用相同数量的CPU周期,并且会活动appart CPU cache miss)。我不知道为什么到目前为止我读到的有关算法的文章只关注比较的数量(好吧,我承认我没有读过很多文章;)。

我已经用自己的平均情况估算了循环中的每一步(我认为最坏的情况在这里并不有趣),这对你来说略有不同。

如果我没弄错的话: - 对于平凡版本:n *(c1 + 2 * c2 + c4)+(x + y)* c3 - 对于更快的版本:n / 2 *(c1 + 3 * c2 + c5)+(x + y)* c3

现在在我的理解中,很难进一步说明每个cN需要多少CPU周期,因为它因CPU而异。

让我们检查一下我的计算机上发生了多少指令,分支和分支未命中,这些指令大多处于空闲状态,而每个算法都在每个测试用例上执行,并带有一个热缓存(请注意,我测试了每个案例3次以验证没有重大偏差):

随机案例

$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow trivial _random
time: 1.547087
count: 300000000
min: 31499144704
max: 9223372004409096866

 Performance counter stats for './findminmax_stackoverflow trivial _random':

     1,083,101,126 branches                                                    
            52,388 branch-miss

     4,335,175,257 instructions              #    0.00  insns per cycle        

       1.623851849 seconds time elapsed


$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow faster _random
time: 2.748967
count: 300000000
min: 31499144704
max: 9223372004409096866

 Performance counter stats for './findminmax_stackoverflow faster _random':

       783,120,927 branches                                                    
        75,063,008 branch-miss

     3,735,286,264 instructions              #    0.00  insns per cycle        

       1.824884443 seconds time elapsed

请注意,对于速度较快的版本,我们的指令较少,但运行起来要花费的时间要长得多,但可能因为有更多的分支未命中,按顺序或数量而退出!

最佳案例

$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow trivial _bestcase 
time: 1.267697
count: 300000000
min: 0
max: 300000000

 Performance counter stats for './findminmax_stackoverflow trivial _bestcase':

     1,082,801,759 branches                                                    
            49,802 branch-miss

     4,334,200,448 instructions              #    0.00  insns per cycle        

       1.343425753 seconds time elapsed


$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow faster _bestcase 
time: 0.957440
count: 300000000
min: 0
max: 300000000

 Performance counter stats for './findminmax_stackoverflow faster _bestcase':

       782,844,232 branches                                                    
            49,768 branch-miss

     3,734,103,167 instructions              #    0.00  insns per cycle        

       1.035189822 seconds time elapsed

最坏情况

$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow trivial _worstcase 
time: 7.860047
count: 300000000
min: 1
max: 299999999

 Performance counter stats for './findminmax_stackoverflow trivial _worstcase':

     1,490,947,270 branches                                                    
         2,127,876 branch-miss

     7,159,600,607 instructions              #    0.00  insns per cycle        

       6.916856158 seconds time elapsed


$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow faster _worstcase 
time: 7.616476
count: 300000000
min: 1
max: 299999999

 Performance counter stats for './findminmax_stackoverflow faster _worstcase':

     1,196,744,455 branches                                                    
         2,024,250 branch-miss

     6,594,182,002 instructions              #    0.00  insns per cycle        

       6.675068846 seconds time elapsed

所以,非常有趣的是,“随机”情况实际上比最坏情况更快,但没有显示出太大差异。我看到的唯一区别是我的最坏情况包含“小”数字(可以用32位编码),而随机情况包含真正的64位数字。

具有“小”整数的随机情况

让我们试试一组“小”随机数(仍以64位存储编码):

$ od -N 64 -t dL _randomsmall 
0000000           1418331637           2076047555
0000020             22077878           1677970822
0000040           1845481621            609558726
0000060           1668260452            335112094
0000100

$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow trivial _randomsmall 
time: 7.682443
count: 300000000
min: 9
max: 2147483647

 Performance counter stats for './findminmax_stackoverflow trivial _randomsmall':

     1,481,062,942 branches                                                    
         2,564,853 branch-miss

     6,223,311,378 instructions              #    0.00  insns per cycle        

       6.739897078 seconds time elapsed


$ perf stat -e branches,branch-miss,instructions ./findminmax_stackoverflow faster _randomsmall 
time: 7.772994
count: 300000000
min: 9
max: 2147483647

 Performance counter stats for './findminmax_stackoverflow faster _randomsmall':

     1,177,042,675 branches                                                    
        77,686,346 branch-miss

     5,607,194,799 instructions              #    0.00  insns per cycle        

       6.834074812 seconds time elapsed

因此,正如我猜测的那样,小数字实际上比大数字更慢,即使它们都包含64位字。有很多分支未命中的“小”数字,原因可能只有CPU设计师能够说出: - )。

另一个值得注意的事情是,perf(1)测量的经过时间通常小于程序本身测量的经过时间。我认为这可以通过程序本身使用实时的事实来解释,而perf(1)使用处理时间(流程实际运行的时间)。我尝试使用getrusage(2),但是我到达这里的时间不匹配(例如我将1.6s作为用户时间,1.4s作为系统时间,而perf(1)测量6.8s)。

结论

  • 两个算法在执行时间方面没有太大的实际区别,尽管平凡的一个具有远远少于“更快”的缓存未命中(一个数量级),但这似乎通过增加的数量来平衡指示(10-20%);
  • 更具体地说,这种缓存未命中差异只能在随机情况下看到:最佳和最差情况似乎在这方面退化,因为它们导致两种算法的相同数量的缓存未命中;因此,在这些情况下,“更快”的算法确实比微不足道的算法快一些;在“常见”随机情况下,平凡的算法要快一点;
  • 小整数在我的CPU上产生更多缓存未命中;
  • 最坏情况与使用小int的随机测试用例之间没有实际区别。

所以,如果你走得那么远,谢谢:)。我很抱歉,所有这些实验都只能得出含糊不清的结论。希望开明的读者会对此有所了解:)。

- Jeremie