为什么这个 Haskell 程序的汇编相对于等效的 C 代码来说相当大?或者我编译时做错了什么?

Enr*_*lis 2 c assembly gcc haskell ghc

有一次我问为什么简单的“times 2”程序的 Haskell 二进制文件这么大(例如 C++)?,但这被欺骗了用 GHC 编译成巨大的二进制文件的小型 Haskell 程序。

\n

我决定问一个类似的问题,但重点是代码的实际作用,因此是它的汇编形式,而不是二进制文件。我还没有找到我的问题的现有答案。

\n

所以不,这不能回答我的问题。

\n

这是 C++(实际上是 C)的哑程序,它假设给出了 1 个表示数字的命令行参数,将其转换为 anint并将return其乘以 2 作为可执行文件的退出代码(是的,我可以\不超过 255)

\n
int main(int argc, char * argv[]) {\n    int r{0};\n    int c{0};\n    while (argv[1][c] != \'\\0\') {\n        r *= 10;\n        r += argv[1][c++] - \'0\';\n    }\n    return r*2;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

汇编非常短(共 21 行):

\n
main:                                   # @main\n        mov     rcx, qword ptr [rsi + 8]\n        movzx   edx, byte ptr [rcx]\n        test    dl, dl\n        je      .LBB0_1\n        inc     rcx\n        xor     eax, eax\n.LBB0_3:                                # =>This Inner Loop Header: Depth=1\n        movsx   edx, dl\n        lea     eax, [rax + 4*rax]\n        lea     eax, [rdx + 2*rax]\n        add     eax, -48\n        movzx   edx, byte ptr [rcx]\n        inc     rcx\n        test    dl, dl\n        jne     .LBB0_3\n        add     eax, eax\n        ret\n.LBB0_1:\n        xor     eax, eax\n        ret\n
Run Code Online (Sandbox Code Playgroud)\n

据我所知,这是一个“类似的”Haskell 程序,它完成了完全相同的任务,但它仍然生成了一个更大的程序集(257 行,因此长了 12 倍以上\xc2\xb9:

\n
main:                                   # @main\n        mov     rcx, qword ptr [rsi + 8]\n        movzx   edx, byte ptr [rcx]\n        test    dl, dl\n        je      .LBB0_1\n        inc     rcx\n        xor     eax, eax\n.LBB0_3:                                # =>This Inner Loop Header: Depth=1\n        movsx   edx, dl\n        lea     eax, [rax + 4*rax]\n        lea     eax, [rdx + 2*rax]\n        add     eax, -48\n        movzx   edx, byte ptr [rcx]\n        inc     rcx\n        test    dl, dl\n        jne     .LBB0_3\n        add     eax, eax\n        ret\n.LBB0_1:\n        xor     eax, eax\n        ret\n
Run Code Online (Sandbox Code Playgroud)\n

是的,它以不同的方式做到这一点,特别是因为我无法真正用 Haskell 吐出程序代码,但我的观点是,当我编译上面的代码时,编译器应该知道那是main那为计算退出值而创建的任何中间结果都不需要在其他地方使用。为什么不能证明所有这些代码所做的事情都可以通过几行汇编来完成(正如上面的 C/C++ 程序所演示的那样?)

\n

或者,如果情况并非如此,那么为什么装配体如此之大?我只是忘记了适当的编译器标志吗?或者是什么?

\n

难道只是开发GHC的功夫没有投入到优化这么小的代码上吗?(在这种情况下,我会想知道为什么,考虑到组件化意味着将一个大程序分成越来越小的程序:如果我不优化它的小部分,我怎么能有一个最佳程序?)或者是优化这么小的程序由于我仍然不明白的根本原因,程序根本不可能实现?

\n

我想到的另一个想法是:也许我编写代码的方式并不容易被优化?也许如果我以另一种形式编写它,编译器可以做得更好?

\n

至于优化是一个难题,是的,我相信,但我对此一无所知。但我认为给编译器的保证越多,它就越能优化。在这方面我认为 Haskell 更好。

\n
\n

当然,我可以花一些时间来编写 Haskell 代码,它表达的过程与我在 C 中表达的过程完全相同。但是考虑到我目前拥有的程序集的大小......它真的会产生很大的差异吗?

\n

如果您认为是这样,请告诉我,我将删除这个问题并花时间尝试一下。

\n
\n

有些评论提到了类似的事情

\n
    \n
  • 它包含一个运行时环境来确定要评估的 thunk 等。
  • \n
  • 由于 Haskel 需要大量的库和启动代码,因此它不会在很长一段时间内成长
  • \n
\n

但只是为什么呢?我是说,

\n
    \n
  • 该程序的作用很简单。编译器真的这么难解决吗?如果程序归结为如此简单的操作,为什么还需要 thunk?
  • \n
  • 在我看来,必须达到代码量的临界值是非常“错误”的。
  • \n
\n

作为比较,当我读到(并验证)Clang 计算出这段 C 代码时,我感到很惊讶

\n
import Data.List (foldl\')\nimport System.Environment (getArgs)\nimport System.Exit (exitWith, ExitCode (ExitFailure))\nmain :: IO Int\nmain = do\n  [str] <- getArgs\n  exitWith $ ExitFailure $ (*) 2 $ foldl\' step 0 str\n  where\n    step i c = 10 * i + fromEnum c - fromEnum \'0\'\n
Run Code Online (Sandbox Code Playgroud)\n

归结为设定的位数,因此它将其编译为

\n
foo(int):                                # @foo(int)\n        popcntl %edi, %eax\n        retq\n
Run Code Online (Sandbox Code Playgroud)\n

如果架构有popcntl.

\n
\n

(\xc2\xb9) 事实证明,@Cubic\ 的注释提供了一种通过删除以下假设来获得稍短的程序集的方法(182 行,因此仍然比上面的 C 程序长几乎 9 倍)只有一个命令行参数,通过更改此

\n
int foo(int a) {\n    int count = 0;\n    while (a) {\n        ++count;\n        a &= a - 1;\n    }\n    return count;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

对此,

\n
foo(int):                                # @foo(int)\n        popcntl %edi, %eax\n        retq\n
Run Code Online (Sandbox Code Playgroud)\n

至于 @Cubic 提出的另一点,即 Haskell 程序需要迭代(惰性)链接列表来解析字符串,我认为不能删除。删除它,即使它是(是吗?) 可能的,在我看来意味着我们正在放弃 Haskell 的本质,放弃惯用的做事方式,并拥抱另一种语言的模拟,而这种语言与 Haskell 无关。与哈斯克尔一起做。

\n

tha*_*guy 11

C++ 编译器并未编写这种高度优化的代码。你是。

如果您开始在 C++ 中使用一些与 Haskell 中相同类型的库,您会发现它不再生成整洁且简短的汇编:

#include <gmpxx.h>
#include <string>

int main(int argc, char* argv[]) {
  mpz_class r{0};
  mpz_class c{0};
  for(char c : std::string(argv[1])) {
    r *= 10;
    r += c - '0';
  }
  r *= 2;
  return r.get_ui();
}
Run Code Online (Sandbox Code Playgroud)

同时,如果你开始像编写 C++ 一样编写 Haskell(这确实不是我的强项,但这是我的第一次尝试):

foo :: Ptr CChar -> Int
foo !ptr = unsafePerformIO $ do
    n <- f 0 ptr
    return $! n*2
  where
    f :: Int -> Ptr CChar -> IO Int
    f !n !ptr = do
        c <- peek ptr
        if c == 0 then return n else f (n*10+(fromEnum c - 0x30)) (plusPtr ptr 1)
Run Code Online (Sandbox Code Playgroud)

然后你会发现 GHC 还生成更紧凑的代码:

  406c48:  add    r12,0x10
  406c4c:  cmp    r12,QWORD PTR [r13+0x358]
  406c53:  ja     406c8a <Main_zdwf_info+0x42>
  406c55:  movsx  rax,BYTE PTR [rsi]              # c = *s
  406c59:  test   rax,rax                         # if c == 0
  406c5c:  je     406c75 <Main_zdwf_info+0x2d>      
  406c5e:  add    r12,0xfffffffffffffff0
  406c62:  inc    rsi                             # s++
  406c65:  imul   r14,r14,0xa                     # r *= 10
  406c69:  add    r14,rax                         # r += c
  406c6c:  lea    rax,[r14-0x30]                  # tmp = r - '0'
  406c70:  mov    r14,rax                         # r = tmp
  406c73:  jmp    406c48 <Main_zdwf_info>           
Run Code Online (Sandbox Code Playgroud)

  • 它应该为“foo :: Ptr CChar -&gt; IO Int”生成基本相同的代码,但没有“unsafePerformIO”。我建议这样做,因为“foo”实际上不是引用透明的——如果在调用之间修改了指针后面的数据,则多次传递同一指针可能会导致不同的整数。 (2认同)