为什么在C语言中,一个函数的递归版本比迭代版本更快?

7

我正在比较两个梯度下降实现之间的差异,我猜测在编译器优化后,这两个版本的算法都应该是等价的。

令我惊讶的是,递归版本要快得多。在任何一个版本中,甚至是在我测量时间的方式上,我都没有找到任何实际的缺陷。请问您能提供一些见解吗?

以下是我的代码:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>
#include <stdint.h>

double f(double x)
{
        return 2*x;
}

double descgrad(double xo, double xnew, double eps, double precision)
{
//      printf("step ... x:%f Xp:%f, delta:%f\n",xo,xnew,fabs(xnew - xo));

        if (fabs(xnew - xo) < precision)
        {
                return xnew;
        }
        else
        {
                descgrad(xnew, xnew - eps*f(xnew), eps, precision);
        }
}

double descgraditer(double xo, double xnew, double eps, double precision)
{
        double Xo = xo;
        double Xn = xnew;

        while(fabs(Xn-Xo) > precision)
        {
                //printf("step ... x:%f Xp:%f, delta:%f\n",Xo,Xn,fabs(Xn - Xo));
                Xo = Xn;
                Xn = Xo - eps * f(Xo);
        }

        return Xn;
}

int64_t timespecDiff(struct timespec *timeA_p, struct timespec *timeB_p)
{
  return ((timeA_p->tv_sec * 1000000000) + timeA_p->tv_nsec) -
           ((timeB_p->tv_sec * 1000000000) + timeB_p->tv_nsec);
}

int main()
{
        struct timespec s1, e1, s2, e2;

        clock_gettime(CLOCK_MONOTONIC, &s1);
        printf("Minimum : %f\n",descgraditer(100,99,0.01,0.00001));
        clock_gettime(CLOCK_MONOTONIC, &e1);

        clock_gettime(CLOCK_MONOTONIC, &s2);
        printf("Minimum : %f\n",descgrad(100,99,0.01,0.00001));
        clock_gettime(CLOCK_MONOTONIC, &e2);

        uint64_t dif1 = timespecDiff(&e1,&s1) / 1000;
        uint64_t dif2 = timespecDiff(&e2,&s2) / 1000;

        printf("time_iter:%llu ms, time_rec:%llu ms, ratio (dif1/dif2) :%g\n", dif1,dif2, ((double) ((double)dif1/(double)dif2)));

        printf("End. \n");
}

我正在使用gcc 4.5.2在Ubuntu 11.04上编译,选项如下: gcc grad.c -O3 -lrt -o dg
我的代码输出为:
Minimum : 0.000487
Minimum : 0.000487
time_iter:127 ms, time_rec:19 ms, ratio (dif1/dif2) :6.68421
End.

我读到了一个帖子,也提到递归版本的算法比迭代版本更快。那里的解释是递归版本使用堆栈,而另一个版本使用一些向量,堆上的访问会减慢迭代版本的速度。但在这种情况下(在我最好的理解中),我只是在两种情况下都使用堆栈。
我是否漏掉了什么?有什么明显的东西我没有看到吗?我的计时方式有问题吗?有什么见解?
编辑: 在评论中解决了谜团。正如@TonyK所说,printf的初始化减慢了第一次执行的速度。很抱歉我错过了这个显而易见的事情。
顺便说一下,代码编译没有警告。我认为“return descgrad(…”不必要,因为停止条件发生在之前。

8
所有“为什么更快?”的问题都应该附上你的编译器输出的汇编代码清单。 - Greg Hewgill
1
if语句为false时,descgrad的返回语句在哪里?这段代码不应该编译。 - Christopher Neylan
4
我不认为它更快。你需要多次迭代两个调用,以平均抵消抖动。你需要从基准测试部分中删除printf。 - totowtwo
1
如果您按照另一种顺序运行定时测试(先运行“descgrad”,然后再运行“descgraditer”),会得到什么结果?正如totowtwo所暗示的那样,可能是由于printf初始化导致了所有这些时间。 - TonyK
修复了缺失的 return,进行了足够数量的迭代,并从基准测试中删除了 printf 后,两个时间非常相似。链接 - Mike Seymour
显示剩余2条评论
7个回答

10

我已经在本地编译并运行了您的代码。将printf移到定时块之外,可以使两个版本每次都在约5毫秒内执行。

因此,您在计时时的一个主要错误是测量了复杂的printf,它的运行时间使您实际想要测量的代码相形见绌。

我的main()函数现在看起来像这样:

int main() {
    struct timespec s1, e1, s2, e2;

    double d = 0.0;

    clock_gettime(CLOCK_MONOTONIC, &s1);
    d = descgraditer(100,99,0.01,0.00001);
    clock_gettime(CLOCK_MONOTONIC, &e1);
    printf("Minimum : %f\n", d);

    clock_gettime(CLOCK_MONOTONIC, &s2);
    d = descgrad(100,99,0.01,0.00001);
    clock_gettime(CLOCK_MONOTONIC, &e2);
    printf("Minimum : %f\n",d);

    uint64_t dif1 = timespecDiff(&e1,&s1) / 1000;
    uint64_t dif2 = timespecDiff(&e2,&s2) / 1000;

    printf("time_iter:%llu ms, time_rec:%llu ms, ratio (dif1/dif2) :%g\n", dif1,dif2, ((double) ((double)dif1/(double)dif2)));

    printf("End. \n");
}

谢谢!这确实很有道理! - Pedrom
这个答案是不正确的。我无法重现它的结果,特别是在几个函数迭代中。 - Christopher Neylan
@user112358132134 你到底无法复现什么?我确实可以确认那些printf语句会减慢第一个函数的执行速度。如果你注释掉这两个printf语句,两个函数的执行时间应该接近。 - Pedrom
我发布了一个答案。简而言之,执行时间的差异是-O3的尾调用优化的结果。 - Christopher Neylan

5

我的时间测量方式有问题吗?

是的。在您所测量的短时间内,调度程序可能会对您的程序产生巨大的影响。您需要将测试时间延长以平均出这些差异,或者使用CLOCK_PROCESS_CPUTIME_ID来测量进程使用的CPU时间。


这听起来很公平,但它并没有解释它们之间的显着差异?两个时间都不接近。 - Pedrom
@Pedrom:是的,但一个非交互式进程在0.1秒内没有获得CPU时间是完全可能的。此外,正如totowtwo所指出的那样,printf语句会引入更多的延迟,例如潜在的未提交缓冲区,这会导致更不可预测的减速。你的算法可能只需要10毫秒就可以完成两种变体,其余的都是噪音,因此差异没有意义。 - thiton
是的,你是对的...printf语句在发出声音。 - Pedrom

3
首先,你的递归步骤中缺少了一个 return
double descgrad(double xo, double xnew, double eps, double precision)
{
    if (fabs(xnew - xo) < precision)
        return xnew;
    else
        descgrad(xnew, xnew - eps*f(xnew), eps, precision);
}

应该是:

double descgrad(double xo, double xnew, double eps, double precision)
{
    if (fabs(xnew - xo) < precision)
        return xnew;
    else
        return descgrad(xnew, xnew - eps*f(xnew), eps, precision);
}

这个疏忽导致descgrad的返回值未定义,因此编译器几乎不需要为其生成任何代码;)


我刚刚测试了这个理论,它并没有改变原帖作者的观察结果。 - Christopher Neylan
@user112358132134:好的。在本地测试后,我能够复现您的发现。 - Magnus Hoff
@Pedrom:在程序中加上return语句是绝对关键的,这样才能得到正确的程序。然而,正如我已经说过的那样,它似乎并不影响运行时间。如果它产生了正确的输出,那只是偶然的结果。 - Magnus Hoff
@Pedrom:它的工作方式与您所设想的完全相同。但是,这不是语言标准所规定的。根据标准,缺少返回值会导致“未定义行为”:标准不关心编译器在这种情况下做什么。因此,它可能编译成您打算的程序(似乎是这样),也可能不是。如果您想保证程序在将来、在不同的计算机上、在不同的编译器上正确运行,请在其中加入返回值。无论如何,它不应该减慢事情的进展 ;) - Magnus Hoff
@MagnusHoff 谢谢您的见解 :) 我认为这确实违反了标准,但这只是我随手写的代码,在中间遇到了这个问题... 另外感谢您的答案。 :) - Pedrom
显示剩余2条评论

2
首先,您在尝试测量时间时包含了printf。这是一个巨大的禁忌,因为它可能会挂起您的进程并进行控制台输出。实际上,任何系统调用都可能完全扰乱这些时间测量。
其次,正如其他人提到的那样,在这么短的采样周期内,调度器中断可能会产生巨大的影响。
这并不完美,但请尝试使用以下主要内容,您将看到实际上几乎没有什么区别。随着循环次数的增加,比率趋近于1.0。
#define LOOPCOUNT 100000
int main() 
{
    struct timespec s1, e1, s2, e2;
    int i;
    clock_gettime(CLOCK_MONOTONIC, &s1);
    for(i=0; i<LOOPCOUNT; i++)
    {
      descgraditer(100,99,0.01,0.00001);
    }
    clock_gettime(CLOCK_MONOTONIC, &e1);

    clock_gettime(CLOCK_MONOTONIC, &s2);
    for(i=0; i<LOOPCOUNT; i++)
    {
      descgrad(100,99,0.01,0.00001);
    }
    clock_gettime(CLOCK_MONOTONIC, &e2);

    uint64_t dif1 = timespecDiff(&e1,&s1) / 1000;
    uint64_t dif2 = timespecDiff(&e2,&s2) / 1000;

    printf("time_iter:%llu ms, time_rec:%llu ms, ratio (dif1/dif2) :%g\n", dif1,dif2, ((double) ((double)dif1/(double)dif2)));

    printf("End. \n");

编辑:在使用objdump -dS查看反汇编输出后,我注意到几件事情:
在-O3优化下,上述代码完全优化掉了函数调用。然而,它仍会为这两个函数生成代码,但实际上两者都不是递归的。

其次,在-O0下,即使结果代码实际上是递归的,递归版本也慢了一万亿倍。我猜测这是因为调用栈强制变量最终进入内存,而迭代版本则用尽了寄存器和/或缓存。


谢谢您的见解。我之前尝试过使用-O0,但是gcc没有生成我期望的代码。我期望它能像Scheme一样工作,如果没有挂起的操作,它会重用环境。实际上,在-O0下,没有办法编写一个无限迭代的递归函数,它总是会在堆栈溢出时结束(在gcc的情况下是分段错误)。 - Pedrom

2

被接受的答案是不正确的

迭代函数和递归函数的运行时间确实有所不同,原因是编译器优化-foptimize-sibling-calls-O3添加。

首先,看一下代码:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>
#include <stdint.h>

double descgrad(double xo, double xnew, double eps, double precision){
        if (fabs(xnew - xo) <= precision) {
                return xnew;
        } else {
                return descgrad(xnew, xnew - eps*2*xnew, eps, precision);
        }
}

double descgraditer(double xo, double xnew, double eps, double precision){
        double Xo = xo;
        double Xn = xnew;

        while(fabs(Xn-Xo) > precision){
                Xo = Xn;
                Xn = Xo - eps * 2*Xo;
        }
        return Xn;
}

int main() {
        time_t s1, e1, d1, s2, e2, d2;
        int i, iter = 10000000;
        double a1, a2;

        s1 = time(NULL);
        for( i = 0; i < iter; i++ ){
            a1 = descgraditer(100,99,0.01,0.00001);
        }
        e1 = time(NULL);
        d1 = difftime( e1, s1 );

        s2 = time(NULL);
        for( i = 0; i < iter; i++ ){
            a2 = descgrad(100,99,0.01,0.00001);
        }
        e2 = time(NULL);
        d2 = difftime( e2, s2 );

    printf( "time_iter: %d s, time_rec: %d s, ratio (iter/rec): %f\n", d1, d2, (double)d1 / d2 ) ;
    printf( "return values: %f, %f\n", a1, a2 );
}

之前的帖子指出,为了平均环境干扰,需要多次迭代。因此,我弃用了您的差分函数,转而使用time.hdifftime函数对time_t数据进行操作,因为在许多迭代中,精度高于一秒是没有意义的。此外,我删除了基准测试中的printf语句。
我还修复了递归实现中的一个错误。您原来的代码中的if语句检查fabs(xnew-xo) < precision,这是不正确的(或者至少与迭代实现不同)。迭代循环时,fabs() > precision,因此递归函数应该在fabs<=precision时停止递归。将“迭代”计数器添加到两个函数中可以确认此修复使函数在逻辑上等效。
使用-O3编译和运行:
$ gcc test.c -O3 -lrt -o dg
$ ./dg
time_iter: 34 s, time_rec: 0 s, ratio (iter/rec): inf
return values: 0.000487, 0.000487

在没有使用-O3的情况下编译和运行

$ gcc test.c -lrt -o dg
$ ./dg
time_iter: 54 s, time_rec: 90 s, ratio (iter/rec): 0.600000
return values: 0.000487, 0.000487

未经过优化,迭代比递归表现更好。

然而,在-O3优化下,递归运行了一千万次迭代,不到一秒钟。原因是它添加了-foptimize-sibling-calls,这优化了兄弟和尾递归调用,这正是你的递归函数正在利用的。

为了确保,我使用除-foptimize-sibling-calls之外的所有-O3优化来运行它:

$ gcc test.c -lrt -o dg  -fcprop-registers  -fdefer-pop -fdelayed-branch  -fguess-branch-probability -fif-conversion2 -fif-conversion -fipa-pure-const -fipa-reference -fmerge-constants   -ftree-ccp -ftree-ch -ftree-copyrename -ftree-dce -ftree-dominator-opts -ftree-dse -ftree-fre -ftree-sra -ftree-ter -funit-at-a-time -fthread-jumps -falign-functions  -falign-jumps -falign-loops  -falign-labels -fcaller-saves -fcrossjumping -fcse-follow-jumps  -fcse-skip-blocks -fdelete-null-pointer-checks -fexpensive-optimizations -fgcse  -fgcse-lm  -fpeephole2 -fregmove -freorder-blocks  -freorder-functions -frerun-cse-after-loop  -fsched-interblock  -fsched-spec -fschedule-insns  -fschedule-insns2 -fstrict-aliasing  -ftree-pre -ftree-vrp -finline-functions -funswitch-loops  -fgcse-after-reload -ftree-vectorize
$ ./dg
time_iter: 55 s, time_rec: 89 s, ratio (iter/rec): 0.617978
return values: 0.000487, 0.000487

递归,如果没有尾调用优化,执行效率会比迭代差,就像在没有进行任何优化编译时一样。可以在这里了解有关编译器优化的内容。

编辑:

为了验证正确性,我更新了代码以包括返回值。此外,我设置了两个静态变量为0,并在递归和迭代中分别增加它们以验证正确输出:

int a = 0;
int b = 0;

double descgrad(double xo, double xnew, double eps, double precision){
        if (fabs(xnew - xo) <= precision) {
                return xnew;
        } else {
                a++;
                return descgrad(xnew, xnew - eps*2*xnew, eps, precision);
        }
}

double descgraditer(double xo, double xnew, double eps, double precision){
        double Xo = xo;
        double Xn = xnew;

        while(fabs(Xn-Xo) > precision){
                b++;
                Xo = Xn;
                Xn = Xo - eps * 2*Xo;
        }
        return Xn;
}

int main() {
    time_t s1, e1, d1, s2, e2, d2;
    int i, iter = 10000000;
    double a1, a2;

    s1 = time(NULL);
    for( i = 0; i < iter; i++ ){
        a1 = descgraditer(100,99,0.01,0.00001);
    }
    e1 = time(NULL);
    d1 = difftime( e1, s1 );

    s2 = time(NULL);
    for( i = 0; i < iter; i++ ){
        a2 = descgrad(100,99,0.01,0.00001);
    }
    e2 = time(NULL);
    d2 = difftime( e2, s2 );

    printf( "time_iter: %d s, time_rec: %d s, ratio (iter/rec): %f\n", d1, d2, (double)d1 / d2 ) ;
    printf( "return values: %f, %f\n", a1, a2 );
    printf( "number of recurs/iters: %d, %d\n", a, b );
}

输出结果:
$ gcc optimization.c -O3 -lrt -o dg
$ ./dg
time_iter: 41 s, time_rec: 24 s, ratio (iter/rec): 1.708333
return values: 0.000487, 0.000487
number of recurs/iters: 1755032704, 1755032704

答案是相同的,重复也是相同的。

值得注意的是,静态变量的获取/递增对尾调用优化有很大影响,但递归仍然优于迭代。


谢谢!这是一份非常完整的代码分析,实际上比我预期的要详细得多。然而,我不理解这个结果:time_iter: 34 s,time_rec: 0 s,ratio (iter/rec): inf <br/> 看起来函数没有给出正确的答案,因为在10000000次迭代之后,它不至少持续1秒钟,而另一个则需要34秒钟。我不知道,听起来很奇怪... - Pedrom
我无法复现它... 我复制并粘贴了您的代码,并使用 -O3 编译,但仍然得到了 1 的比率。 - Pedrom
也许这也是一个环境问题。你在什么上面测试了?我在RHEL5上进行了测试,使用了5个CPU和34GB的内存。这可能对我的性能有影响,但所有这些都应该只是夸大了优化效果。我也有怀疑,所以我将两个静态变量设置为0,并在每次循环/迭代中将它们递增,结果计数相同(如果您想验证,请确保修复<=错误,计数为1755032704)。另外有趣的是,递增静态变量会破坏尾部优化收益:time_iter:34秒,time_rec:24秒,比率(iter/rec):1.416667。 - Christopher Neylan
这似乎明显是一个环境问题。我在两台电脑上都测试了,都是i5核心,结果没有改变。根据你的发现,看起来你的代码以某种方式被并行执行了。这真的可能吗? - Pedrom
1
为什么利用尾递归能让递归函数的速度比迭代版本更快?迭代版本也像尾递归调用一样重复使用变量。参考资料显示,我在Ubuntu 11.10上使用gcc 4.6.1编译时,两个函数的结果完全相同,都使用了-O3优化选项。 - David Brown

1
首先,clock_gettime 似乎是在测量墙上时钟时间,而不是执行时间。其次,你所测量的实际时间是 printf 的执行时间,而不是你的函数的执行时间。第三,当你第一次调用 printf 时,它还没有在内存中,因此需要进行大量的磁盘 IO 来将其换入内存。如果你反转测试的顺序,结果也会反转。
如果你想得到一些有意义的测量结果,你必须确保:
  1. 只有你想要测量的代码位于测量循环中,或者至少与你要测量的代码相比,额外的代码非常少。
  2. 对结果进行一些操作,以防止编译器将所有代码都优化掉(在你的测试中不是问题)。
  3. 多次执行要测量的代码,取平均值。
  4. 测量 CPU 时间,而不是墙上时钟时间。
  5. 确保在开始测量之前,所有内容都已经换入内存。

0
在现代硬件上,对于小型循环结构,缓存未命中往往是性能的瓶颈。递归实现较少会在指令路径上产生缓存未命中。

紧密循环不会?我不相信这个。 - Michael Dorgan

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