【问题标题】:Naive Fibonacci in C vs HaskellC 与 Haskell 中的幼稚斐波那契
【发布时间】:2012-10-24 19:02:19
【问题描述】:

请问,如何使g (fib) 的评估完全严格? (我知道这个指数解决方案不是最优的。我想知道如何使该递归完全严格/如果可能/)

Haskell

g :: Int -> Int
g 0 = 0
g 1 = 1
g x = g(x-1) + g(x-2)
main = print $ g 42

这样它的运行速度几乎与天真的 C 解决方案一样快:

C

#include <stdio.h>

long f(int x)
{
    if (x == 0) return 0;
    if (x == 1) return 1;
    return f(x-1) + f(x-2);
}

int main(void)
{
    printf("%ld\n", f(42));
    return 0;
}

注意:这个 fibs-recursion 仅用作一个超级简单的示例。我完全知道,有几十种更好的算法。但肯定有递归算法没有那么简单和更有效的替代方案。

【问题讨论】:

  • g 已经是严格的(因为它的唯一参数模式匹配)。你的意思是让它使用未装箱的Ints?
  • @AndrewC 是的!这听起来更有可能。
  • 嗯,我不明白你想要什么,一个完全严格的 Hello World,所以你可以在另一个上下文中使用该技术?如果是这样,您批准的答案并不是您问题的真正答案。
  • memoisation 是一种更有效的递归替代方案。
  • “但肯定有递归算法没有这么简单和更有效的替代方案。” ...您真正需要的是哪一个?你真的有一个你想要解决的编程问题吗?你想要达到的目标?您需要加速一些 Haskell 代码的实际情况?

标签: haskell ghc


【解决方案1】:

答案是,GHC 自己使评估完全严格(当您通过优化编译给它机会时)。原始代码产生核心

Rec {
Main.$wg [Occ=LoopBreaker] :: GHC.Prim.Int# -> GHC.Prim.Int#
[GblId, Arity=1, Caf=NoCafRefs, Str=DmdType L]
Main.$wg =
  \ (ww_s1JE :: GHC.Prim.Int#) ->
    case ww_s1JE of ds_XsI {
      __DEFAULT ->
        case Main.$wg (GHC.Prim.-# ds_XsI 1) of ww1_s1JI { __DEFAULT ->
        case Main.$wg (GHC.Prim.-# ds_XsI 2) of ww2_X1K4 { __DEFAULT ->
        GHC.Prim.+# ww1_s1JI ww2_X1K4
        }
        };
      0 -> 0;
      1 -> 1
    }
end Rec }

如果你知道 GHC 的核心,你会发现它是完全严格的,并且使用未装箱的原始机器整数。

(不幸的是,gcc 从 C 源代码生成的机器代码更快。)

GHC 的严格性分析器相当不错,在像这里这样的简单情况下,没有涉及多态性并且函数不太复杂,您可以指望它发现它可以解开所有值以使用 unboxed @987654322 生成工人@s.

但是,在这种情况下,生成快速代码不仅仅是在机器类型上运行。本机代码生成器以及 LLVM 后端生成的程序集基本上是将代码直接翻译为程序集,检查参数是 0 还是 1,如果不是,则调用两次函数并添加结果。两者都会产生一些我不理解的进入和退出代码,而在我的机器上,本机代码生成器会产生更快的代码。

对于 C 代码,clang -O3 生成简单的程序集,减少繁琐并使用更多寄存器,

.Ltmp8:
    .cfi_offset %r14, -24
    movl        %edi, %ebx
    xorl        %eax, %eax
    testl       %ebx, %ebx
    je          .LBB0_4
# BB#1:
    cmpl        $1, %ebx
    jne         .LBB0_3
# BB#2:
    movl        $1, %eax
    jmp         .LBB0_4
.LBB0_3:
    leal        -1(%rbx), %edi
    callq       recfib
    movq        %rax, %r14
    addl        $-2, %ebx
    movl        %ebx, %edi
    callq       recfib
    addq        %r14, %rax
.LBB0_4:
    popq        %rbx
    popq        %r14
    popq        %rbp
    ret

(由于某种原因,它今天在我的系统上的性能比昨天好得多)。 Haskell 源代码和 C 语言生成的代码在性能上的很多差异来自后者使用寄存器的情况,前者使用的是间接寻址,但两者的算法核心是相同的。

gcc,没有任何优化,使用一些间接寻址产生的结果基本相同,但比 GHC 使用 NCG 或 LLVM 后端产生的结果要少。 -O1 同上,但间接寻址更少。使用-O2,您可以进行转换,这样程序集就不会轻易映射回源代码,而使用-O3,gcc 会产生相当惊人的效果

.LFB0:
    .cfi_startproc
    pushq   %r15
    .cfi_def_cfa_offset 16
    .cfi_offset 15, -16
    pushq   %r14
    .cfi_def_cfa_offset 24
    .cfi_offset 14, -24
    pushq   %r13
    .cfi_def_cfa_offset 32
    .cfi_offset 13, -32
    pushq   %r12
    .cfi_def_cfa_offset 40
    .cfi_offset 12, -40
    pushq   %rbp
    .cfi_def_cfa_offset 48
    .cfi_offset 6, -48
    pushq   %rbx
    .cfi_def_cfa_offset 56
    .cfi_offset 3, -56
    subq    $120, %rsp
    .cfi_def_cfa_offset 176
    testl   %edi, %edi
    movl    %edi, 64(%rsp)
    movq    $0, 16(%rsp)
    je      .L2
    cmpl    $1, %edi
    movq    $1, 16(%rsp)
    je      .L2
    movl    %edi, %eax
    movq    $0, 16(%rsp)
    subl    $1, %eax
    movl    %eax, 108(%rsp)
.L3:
    movl    108(%rsp), %eax
    movq    $0, 32(%rsp)
    testl   %eax, %eax
    movl    %eax, 72(%rsp)
    je      .L4
    cmpl    $1, %eax
    movq    $1, 32(%rsp)
    je      .L4
    movl    64(%rsp), %eax
    movq    $0, 32(%rsp)
    subl    $2, %eax
    movl    %eax, 104(%rsp)
.L5:
    movl    104(%rsp), %eax
    movq    $0, 24(%rsp)
    testl   %eax, %eax
    movl    %eax, 76(%rsp)
    je      .L6
    cmpl    $1, %eax
    movq    $1, 24(%rsp)
    je      .L6
    movl    72(%rsp), %eax
    movq    $0, 24(%rsp)
    subl    $2, %eax
    movl    %eax, 92(%rsp)
.L7:
    movl    92(%rsp), %eax
    movq    $0, 40(%rsp)
    testl   %eax, %eax
    movl    %eax, 84(%rsp)
    je      .L8
    cmpl    $1, %eax
    movq    $1, 40(%rsp)
    je      .L8
    movl    76(%rsp), %eax
    movq    $0, 40(%rsp)
    subl    $2, %eax
    movl    %eax, 68(%rsp)
.L9:
    movl    68(%rsp), %eax
    movq    $0, 48(%rsp)
    testl   %eax, %eax
    movl    %eax, 88(%rsp)
    je      .L10
    cmpl    $1, %eax
    movq    $1, 48(%rsp)
    je      .L10
    movl    84(%rsp), %eax
    movq    $0, 48(%rsp)
    subl    $2, %eax
    movl    %eax, 100(%rsp)
.L11:
    movl    100(%rsp), %eax
    movq    $0, 56(%rsp)
    testl   %eax, %eax
    movl    %eax, 96(%rsp)
    je      .L12
    cmpl    $1, %eax
    movq    $1, 56(%rsp)
    je      .L12
    movl    88(%rsp), %eax
    movq    $0, 56(%rsp)
    subl    $2, %eax
    movl    %eax, 80(%rsp)
.L13:
    movl    80(%rsp), %eax
    movq    $0, 8(%rsp)
    testl   %eax, %eax
    movl    %eax, 4(%rsp)
    je      .L14
    cmpl    $1, %eax
    movq    $1, 8(%rsp)
    je      .L14
    movl    96(%rsp), %r15d
    movq    $0, 8(%rsp)
    subl    $2, %r15d
.L15:
    xorl    %r14d, %r14d
    testl   %r15d, %r15d
    movl    %r15d, %r13d
    je      .L16
    cmpl    $1, %r15d
    movb    $1, %r14b
    je      .L16
    movl    4(%rsp), %r12d
    xorb    %r14b, %r14b
    subl    $2, %r12d
    .p2align 4,,10
    .p2align 3
.L17:
    xorl    %ebp, %ebp
    testl   %r12d, %r12d
    movl    %r12d, %ebx
    je      .L18
    cmpl    $1, %r12d
    movb    $1, %bpl
    je      .L18
    xorb    %bpl, %bpl
    jmp     .L20
    .p2align 4,,10
    .p2align 3
.L21:
    cmpl    $1, %ebx
    je      .L58
.L20:
    leal    -1(%rbx), %edi
    call    recfib
    addq    %rax, %rbp
    subl    $2, %ebx
    jne     .L21
.L18:
    addq    %rbp, %r14
    subl    $2, %r13d
    je      .L16
    subl    $2, %r12d
    cmpl    $1, %r13d
    jne     .L17
    addq    $1, %r14
.L16:
    addq    %r14, 8(%rsp)
    subl    $2, 4(%rsp)
    je      .L14
    subl    $2, %r15d
    cmpl    $1, 4(%rsp)
    jne     .L15
    addq    $1, 8(%rsp)
.L14:
    movq    8(%rsp), %rax
    addq    %rax, 56(%rsp)
    subl    $2, 96(%rsp)
    je      .L12
    subl    $2, 80(%rsp)
    cmpl    $1, 96(%rsp)
    jne     .L13
    addq    $1, 56(%rsp)
.L12:
    movq    56(%rsp), %rax
    addq    %rax, 48(%rsp)
    subl    $2, 88(%rsp)
    je      .L10
    subl    $2, 100(%rsp)
    cmpl    $1, 88(%rsp)
    jne     .L11
    addq    $1, 48(%rsp)
.L10:
    movq    48(%rsp), %rax
    addq    %rax, 40(%rsp)
    subl    $2, 84(%rsp)
    je      .L8
    subl    $2, 68(%rsp)
    cmpl    $1, 84(%rsp)
    jne     .L9
    addq    $1, 40(%rsp)
.L8:
    movq    40(%rsp), %rax
    addq    %rax, 24(%rsp)
    subl    $2, 76(%rsp)
    je      .L6
    subl    $2, 92(%rsp)
    cmpl    $1, 76(%rsp)
    jne     .L7
    addq    $1, 24(%rsp)
.L6:
    movq    24(%rsp), %rax
    addq    %rax, 32(%rsp)
    subl    $2, 72(%rsp)
    je      .L4
    subl    $2, 104(%rsp)
    cmpl    $1, 72(%rsp)
    jne     .L5
    addq    $1, 32(%rsp)
.L4:
    movq    32(%rsp), %rax
    addq    %rax, 16(%rsp)
    subl    $2, 64(%rsp)
    je      .L2
    subl    $2, 108(%rsp)
    cmpl    $1, 64(%rsp)
    jne     .L3
    addq    $1, 16(%rsp)
.L2:
    movq    16(%rsp), %rax
    addq    $120, %rsp
    .cfi_remember_state
    .cfi_def_cfa_offset 56
    popq    %rbx
    .cfi_def_cfa_offset 48
    popq    %rbp
    .cfi_def_cfa_offset 40
    popq    %r12
    .cfi_def_cfa_offset 32
    popq    %r13
    .cfi_def_cfa_offset 24
    popq    %r14
    .cfi_def_cfa_offset 16
    popq    %r15
    .cfi_def_cfa_offset 8
    ret
    .p2align 4,,10
    .p2align 3
.L58:
    .cfi_restore_state
    addq    $1, %rbp
    jmp     .L18
    .cfi_endproc

这比其他任何测试都快得多。 gcc 将算法展开到一个显着的深度,而 GHC 和 LLVM 都没有,这在这里产生了巨大的差异。

【讨论】:

  • 哦,太棒了。好吧,为什么 Haskell 解决方案甚至使用 LLVM 并且 -O2C 慢 2 倍?
  • 如果我知道,就不会是:/。至于 GHC 本身,这不是它特别擅长的那种代码,努力(仍然)更多地针对更高级别的优化,而且很少有人在它上面进行黑客攻击以进行那种低级转换很快。至于 LLVM,我怀疑部分原因是需要将正确的选项传递给它的优化器,部分原因是 GHC 的输出不是那么惯用,以至于 LLVM 在所有情况下都真正擅长优化它。
  • 我刚刚发现至少我的clang版本在C代码上也比gcc慢很多,所以不仅仅是GHC。
  • 不错,在我的 Mint 13 机器上,clang 3.0 比 gcc 4.6.3 慢 34%。
  • 是的。 gcc (-O3) 产生了大约 240 行复杂(且高效)的汇编(我什至不会尝试遵循),而 clang 产生大约 40 行。
【解决方案2】:

从使用更好的算法开始!

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

fib n = fibs !! n-1

fib 42 会给你更快的答复。

使用更好的算法比进行细微的速度调整要重要得多

您可以使用此定义(长度为 25801 位)在 ghci 中愉快地快速计算 fib 123456(即解释,甚至不编译)。您可能会让您的 C 代码计算得更快,但您将花费相当长的时间来编写它。这几乎没有花费我任何时间。我花了更多时间写这篇文章!

道德:

  1. 使用正确的算法!
  2. Haskell 让您可以编写干净的代码版本,简单地记住答案。
  3. 有时,定义一个无限的答案列表并获取您想要的答案比编写一些更新值的循环版本更容易。
  4. Haskell 很棒。

【讨论】:

  • 拜托,那是不是的问题。我比这更了解对数解决方案。但想知道,如何使这种递归严格。
  • 对不起,这个答案是无关紧要的。
  • @Martin 很抱歉,但您的问题措辞误导我认为您对您提供的实际代码感兴趣。您问“请问,如何使 g (fib) 的评估完全严格?”但它已经是了,严格性与加速该代码相关,并且拆箱也没有多大帮助。你接着说“这样它的运行速度几乎与天真的 C 解决方案一样快”,我敢打赌我的 C 代码会被淘汰。如您所见,我处理的问题是为遇到您所陈述问题的人提供最佳建议。您稍后添加了所有粗体内容。
  • 我赞成您对未装箱数据类型的评论(不知道),并从您的回答中删除了反对票。
  • 并且绝对同意... Haskell 很棒,每个人都应该始终寻求最好的算法 ;-)
【解决方案3】:

这是完全严格的。

g :: Int -> Int
g 0 = 0
g 1 = 1
g x = a `seq` b `seq` a + b where
   a = g $! x-1
   b = g $! x-2
main = print $! g 42

$!$(低优先级函数应用)相同,只是在函数参数中是严格的。

你也想用-O2 编译,虽然我很好奇你为什么不想使用更好的算法。

【讨论】:

  • 但是您严格评估xs,即来自42,41...,0 的数字。不是函数值。对不对?
  • 抱歉,我意识到在你写评论时我只把它严格了一半。
【解决方案4】:

函数已经很严格了。

函数严格的通常定义是,如果你给它未定义的输入,它本身就是未定义的。我从上下文假设您正在考虑不同的严格概念,即如果函数在产生结果之前评估其参数,则该函数是严格的。但通常检查一个值是否未定义的唯一方法是对其求值,因此两者通常是等价的。

根据第一个定义,g 肯定是严格的,因为它必须在知道使用定义的哪个分支之前检查参数是否等于 0,所以如果参数未定义,g 本身就会窒息当它试图读取它时。

根据更非正式的定义,g 会做错什么?前两个子句显然很好,这意味着当我们到达第三个子句时,我们必须已经评估了n。现在,在第三个子句中,我们增加了两个函数调用。更全面地说,我们有以下任务:

  1. n 中减去 1
  2. n 中减去 2
  3. 调用g,结果为1。
  4. 调用g,结果为2。
  5. 将 3. 和 4. 的结果相加。

懒惰可能会稍微扰乱这些操作的顺序,但由于 +g 在运行代码之前都需要它们的参数值,所以实际上没有任何东西可以延迟任何大量的东西,当然如果编译器只能显示+ 是严格的(它是内置的,因此不应该太难)并且g 是严格的(但它显然 是)。所以任何合理的优化编译器都不会对此有太大的麻烦,而且任何非优化的编译器都不会因为做完全幼稚的事情而产生任何显着的开销(肯定不像foldl (+) 0 [1 .. 1000000]的情况)。

教训是,当一个函数立即将其参数与某个东西进行比较时,该函数已经是严格的,任何体面的编译器都可以利用这一事实来消除通常的惰性开销。这并不意味着它将能够消除其他开销,例如启动运行时系统所花费的时间,这往往会使 Haskell 程序比 C 程序慢一点。如果您只是查看性能数据,那么除了您的程序是严格还是懒惰之外,还有很多事情要做。

【讨论】:

    猜你喜欢
    • 2017-12-06
    • 2015-01-06
    • 1970-01-01
    • 2011-02-17
    • 2021-02-14
    • 2018-07-03
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多