【问题标题】:Why a recursive version of a function would be faster than an iterative one in C?为什么函数的递归版本会比 C 中的迭代版本快?
【发布时间】:2012-01-26 07:45:53
【问题描述】:

我正在检查梯度下降的两种实现之间的差异,我的猜测是,经过编译器优化后,算法的两个版本将是等效的。

令我惊讶的是,递归版本明显更快。我没有放弃任何版本的实际缺陷,甚至没有放弃我测量时间的方式。请各位大神给点意见好吗?

这是我的代码:

#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");
}

我在 Ubuntu 11.04 上使用 gcc 4.5.2 进行编译,具有以下选项: 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(..”是必要的,因为停止条件之前发生过。

【问题讨论】:

  • 所有“为什么它更快?”问题应附有编译器输出的汇编代码列表。
  • if 语句为假的情况下,descgrad 的返回语句在哪里?此代码不应编译。
  • @user112358132134:不,它应该只是在编译时出现警告,不是吗?
  • 我不相信它会更快。您需要多次迭代这两个调用以平均抖动。您需要从基准测试部分中删除 printf。
  • 如果你以另一个顺序运行时序测试(首先是descgrad,然后是descgraditer),你会得到什么?正如 totowtwo 所暗示的那样,可能是 printf 初始化需要花费所有时间。

标签: c++ c recursion iteration


【解决方案1】:

一方面,您的递归步骤错过了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 的返回值未定义,因此编译器几乎不需要为它生成代码;)

【讨论】:

  • 我只是测试了这个理论,它并没有改变OP的观察。
  • @user112358132134:很公平。在本地测试后,我可以重现您的发现。
  • 我认为没有必要,因为停止条件是“return xnew”并且两个函数都返回了正确的值。此外,我再次尝试了您提出的代码,并且性能没有改变。
  • @Pedrom:拥有return 以获得正确的程序绝对至关重要。但是,正如我已经说过的,它似乎不会影响运行时。如果它产生正确的输出,那只是偶然。
  • @MagnusHoff 很抱歉不同意 Magnus,但是编译器正在为我修复它,或者没有必要。我期望的是在每次调用时重用堆栈环境,然后返回值以停止递归,因为我没有离开挂起的操作。这可能是错误的,但如果不是这样,为什么它会起作用?
【解决方案2】:

在许多情况下,现代硬件缓存未命中是小型循环结构的性能限制因素。递归实现不太可能在指令路径上创建缓存未命中。

【讨论】:

  • 而紧密的循环不会?我不买这个。
【解决方案3】:

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

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

【讨论】:

  • 这听起来很公平,但它并不能解释它们之间的显着差异吗?两次都没有接近
  • @Pedrom:是的,但是完全有可能实现 0.1 秒内没有 CPU 时间的非交互进程。此外,正如 totowtwo 所指出的,printf 语句引入了更多延迟,例如可能未提交的缓冲区,这会引入更多不可预测的减速。两种变体的算法可能只需要 10 毫秒,其余的是噪声,因此差异没有意义。
  • 是的,你是...... printf 正在制造噪音。
【解决方案4】:

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

因此,您的时间安排中的一个主要错误是您测量了 printf 的复杂野兽,它的运行时间使您实际尝试测量的代码相形见绌。

我的main()-function 现在看起来像这样:

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");
}

【讨论】:

  • 谢谢.. 这确实很有意义!
  • 这个答案不正确。我无法重现它的结果,尤其是在多次函数迭代中。
  • @user112358132134 究竟什么是您无法重现的?我确实可以确认那些 printf 正在减慢第一个函数的执行速度。如果您注释掉这两个 printf 的执行时间,这两个函数的执行时间应该都很接近。
  • 我发布了一个答案。总之,执行时间的差异是-O3的尾调用优化的结果。
【解决方案5】:

首先,您在尝试测量时包含了一个 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 情况下的分段错误)。
【解决方案6】:

首先,clock_gettime 似乎是在测量挂钟时间,而不是 执行时间处理时间。其次,您测量的实际时间是 printf 的执行时间,而不是你的函数的执行时间。 第三,第一次调用printf,它不在内存中,所以它 必须调入,涉及大量磁盘 IO。倒序 你运行测试,结果也会相反。

如果您想获得一些重要的测量结果,您必须确保 那个

  1. 只有您要测量的代码在测量循环中, 或者至少,与您的代码相比,附加代码非常少 测量,
  2. 你对结果做一些事情,这样编译器就不能 优化所有代码(在您的测试中不是问题),
  3. 你执行的代码要测量大量 次,取平均值,
  4. 您测量的是 CPU 时间,而不是挂钟时间,并且
  5. 在开始 测量。

【讨论】:

    【解决方案7】:

    接受的答案不正确

    迭代函数和递归函数的运行时间存在差异,原因是-O3添加的编译器优化-foptimize-sibling-calls

    一、代码:

    #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.htime_t 数据的 difftime 函数,因为经过多次迭代,任何小于一秒的东西都是没有意义的。另外,我删除了基准测试中的 printfs。

    我还修复了递归实现中的一个错误。您的原始代码的 if 语句检查了 fabs(xnew-xo) &lt; precision,这是不正确的(或至少与迭代实现不同)。当 fabs() > 精度时迭代循环,因此当 fabs 精度时递归函数不应该递归。向两个函数添加“迭代”计数器确认此修复使函数在逻辑上等效。

    使用-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,它优化了兄弟和尾递归调用,这正是你的递归函数所利用的。

    可以肯定的是,我运行它将所有-O3优化除了-foptimize-sibling-calls

    $ 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
    

    没有尾调用优化的递归的性能比迭代差,与编译时没有优化的方式相同。 Read about compiler optimizations here.

    编辑:

    为了验证正确性,我更新了包含返回值的代码。此外,我将两个静态变量设置为 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
      似乎该函数没有给出正确的答案,因为在 10000000 之后似乎真的很可疑迭代它不会持续至少 1 秒,而另一个是 34 秒。我不知道只是听起来很奇怪......
    • 我无法重现它...我复制并粘贴了您的代码并使用 -O3 进行编译,但仍然得到 1 的比率。
    • 也许这也是环境问题。你测试了什么?我在 rhel5 上测试了 5 个 CPU 和 34GB 内存。这可能对我提高的性能产生了影响,尽管应该做的只是夸大优化的好处。我也很怀疑,所以我将两个静态变量设置为 0 和 ++'d 在每次重复/交互时,结果计数是相同的(1755032704,如果你想验证;确保修复
    • 这似乎绝对是一个环境问题。我在 2 台 PC 上测试了 i5 内核,结果没有改变。从您的发现看来,您的代码以某种方式并行执行。这甚至可能吗?
    • 为什么利用尾递归会使递归函数比迭代函数更快?迭代版本就像尾递归调用一样重用变量。作为参考,我在使用 -O3 编译的 Ubuntu 11.10 上使用 gcc 4.6.1 的两个函数也得到了相同的结果。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-06-07
    • 1970-01-01
    • 2014-03-08
    • 2020-01-09
    • 2019-03-25
    • 2015-03-26
    • 1970-01-01
    相关资源
    最近更新 更多