【问题标题】:Why is Haskell (GHC) so darn fast?为什么 Haskell (GHC) 这么快?
【发布时间】:2016-05-03 20:22:43
【问题描述】:

Haskell(使用GHC 编译器)是lot faster than you'd expect。正确使用,它可以接近低级语言。 (Haskellers 最喜欢做的事情是尝试将 C 的误差控制在 5% 以内(甚至超过它,但这意味着您使用的是效率低下的 C 程序,因为 GHC 将 Haskell 编译为 C)。)我的问题是,为什么?

Haskell 是声明式的并且基于 lambda 演算。机器架构显然是必不可少的,大致基于图灵机。事实上,Haskell 甚至没有具体的评估顺序。此外,您总是创建代数数据类型,而不是处理机器数据类型。

最奇怪的是高阶函数。您会认为动态创建函数并将它们扔掉会使程序变慢。 But using higher order functions actually makes Haskell faster. 确实,似乎要优化 Haskell 代码,您需要使其更优雅和抽象,而不是更像机器。 Haskell 更高级的功能似乎都影响它的性能,如果它们不改进它的话。

对不起,如果这听起来很粗鲁,但这是我的问题:考虑到 Haskell(用 GHC 编译)的抽象性质和与物理机器的区别,为什么这么快?

注意:我之所以说 C 和其他命令式语言与图灵机有些相似(但不是 Haskell 类似于 Lambda 演算),是因为在命令式语言中,您有有限数量的状态(又名行号)以及磁带(内存),以便状态和当前磁带确定对磁带执行的操作。请参阅 Wikipedia 条目Turing machine equivalents,了解从图灵机到计算机的过渡。

【问题讨论】:

  • “因为 GHC 将 Haskell 编译为 C” - 它没有。 GHC 有多个后端。最古老的(但不是默认的)是 C 生成器。它确实为 IR 生成 Cmm 代码,但这不是您通常期望的“编译为 C”。 (downloads.haskell.org/~ghc/latest/docs/html/users_guide/…)
  • 我强烈推荐阅读 Simon Payton Jones(GHC 的主要实施者)的Implementation of Functional Programming Languages,它将回答您的很多问题。
  • 为什么? 25 年的辛勤耕耘。
  • “即使有一个事实的答案,它也只会征求意见。” -- 这是结束问题的最糟糕的理由。因为它可能有一个好的答案,但它也可能会吸引低质量的答案。呸!我碰巧有一个关于学术研究和某些发展何时发生的好的、历史的、事实的答案。但我不能发布它,因为人们担心这个问题也可能会吸引低质量的答案。再一次,糟糕。
  • @cimmanon 我需要一个月或几篇博文来了解函数式编译器如何工作的详细信息的基础知识。我只需要一个 SO 答案就可以大致勾勒出如何在库存硬件上干净地实现图形机,并指向相关资源以供进一步阅读......

标签: performance haskell ghc higher-order-functions lambda-calculus


【解决方案1】:

我同意 Dietrich Epp 的观点:它结合了几件事,让 GHC 变得更快。

首先,Haskell 非常高级。这使编译器能够在不破坏代码的情况下执行积极优化

想想 SQL。现在,当我编写SELECT 语句时,它可能看起来像一个命令式循环,但它不是。它可能看起来会遍历该表中的所有行,试图找到与指定条件匹配的行,但实际上“编译器”(数据库引擎)可能是而是进行索引查找 - 它具有完全不同的性能特征。但由于 SQL 是如此高级,“编译器”可以替代完全不同的算法,透明地应用多个处理器或 I/O 通道或整个服务器等等。

我认为 Haskell 是一样的。您可能认为您只是要求 Haskell 将输入列表映射到第二个列表,将第二个列表过滤为第三个列表,然后计算结果的数量。但是你没有看到 GHC 在幕后应用流融合重写规则,将整个事情转换成一个紧密的机器代码循环,在没有分配的情况下一次性遍历数据完成整个工作——那种事情会繁琐、容易出错且无法手动编写。由于代码中缺少底层细节,这才是真正可能的。

另一种看待它的方式可能是……为什么不应该 Haskell 很快?它做了什么让它变慢?

它不是像 Perl 或 JavaScript 这样的解释型语言。它甚至不是像 Java 或 C# 这样的虚拟机系统。它一直编译成本机机器代码,因此没有开销。

与 OO 语言 [Java、C#、JavaScript…] 不同,Haskell 具有完全类型擦除 [如 C、C++、Pascal…]。所有类型检查仅在编译时发生。所以也没有运行时类型检查来减慢你的速度。 (就此而言,没有空指针检查。例如,在 Java 中,JVM 必须检查空指针并在您尊重空指针时抛出异常。Haskell 不必费心进行该检查。)

您说“在运行时动态创建函数”听起来很慢,但如果您仔细观察,您实际上并没有这样做。它可能看起来像你这样做,但你没有。如果你说(+5),那么,那是硬编码到你的源代码中的。它不能在运行时更改。所以它不是一个真正的动态函数。甚至柯里化函数实际上只是将参数保存到数据块中。所有可执行代码实际上都存在于编译时;没有运行时解释。 (与其他一些具有“评估功能”的语言不同。)

想想帕斯卡。它已经过时了,没有人真正使用它了,但没有人会抱怨 Pascal 。有很多不喜欢它的地方,但缓慢并不是其中之一。 Haskell 并没有真正做与 Pascal 不同的事情,除了垃圾收集而不是手动内存管理。并且不可变数据允许对 GC 引擎进行多项优化 [这会导致延迟评估变得有些复杂]。

我认为问题在于 Haskell 看起来高级、复杂和高级,每个人都认为“哇哦,这真的很强大,它一定非常慢!”但事实并非如此。或者至少,它不是你所期望的那样。是的,它有一个惊人的类型系统。但你知道吗?这一切都发生在编译时。到运行时,它已经消失了。是的,它允许您使用一行代码构建复杂的 ADT。但你知道吗? ADT 只是 structs 的普通 C union。仅此而已。

真正的杀手锏是懒惰的评价。当你的代码的严格性/惰性正确时,你可以编写出仍然优雅和漂亮的愚蠢快速的代码。但是,如果您弄错了这些东西,您的程序会慢上千倍,而且发生这种情况的原因真的很不明显。

例如,我编写了一个简单的小程序来计算每个字节在文件中出现的次数。对于一个 25KB 的输入文件,该程序需要 20 分钟 运行并占用 6 GB 的 RAM!太荒谬了!!但后来我意识到问题出在哪里,添加了一个 bang-pattern,运行时间下降到 0.02 秒

是 Haskell 出人意料地缓慢发展的地方。而且肯定需要一段时间才能习惯。但随着时间的推移,编写真正快速的代码会变得更加容易。

是什么让 Haskell 如此之快?纯度。静态类型。懒惰。但最重要的是,足够高级,编译器可以从根本上改变实现而不会破坏代码的期望。

但我想这只是我的意见......

【讨论】:

  • @cimmanon 我不认为它是纯粹基于意见的。这是一个有趣的问题,其他人可能想要答案。但我想我们会看看其他选民的想法。
  • @cimmanon -- 搜索只提供一个半线程,它们都与审查审计有关。并且对该线程的赞成回答说“请停止审核您不理解的内容。”我建议,如果有人认为这个问题的答案必然过于宽泛,那么他们会对答案感到惊讶并喜欢这个答案,因为答案并不太宽泛。
  • “例如,在 Java 中,JVM 必须检查空指针,如果你遵守空指针,则抛出异常。” Java 的隐式空检查(大部分)是无成本的。 Java 实现可以并且确实利用虚拟内存将空地址映射到丢失的页面,因此取消引用空指针会在 CPU 级别触发页面错误,Java 将捕获并作为高级异常抛出。因此,大多数空值检查都是由 CPU 中的内存映射单元免费完成的。
  • @MathematicalOrchid:你有一个运行了 20 分钟的原始程序的副本吗?我认为研究它为什么这么慢会很有启发性。
  • 我用 C++ 编写了一个基于哈希表的字节计数器版本,用了 7 行代码,其中两行是不必要的,它在 8.1 毫秒的时间内在一个 28KB 的文件中进行了分析。我也很好奇你是如何用一种迎合积极优化的高级语言管理 20 分钟的,以及为什么即使正确地敲击它,它也无法与现代低级语言竞争。
【解决方案2】:

长期以来,人们认为函数式语言不能很快——尤其是惰性函数式语言。但这是因为它们的早期实现本质上是解释性的,而不是真正编译的。

基于图形缩减的第二波设计出现了,为更高效的编译开辟了可能性。 Simon Peyton Jones 在他的两本书The Implementation of Functional Programming LanguagesImplementing functional languages: a tutorial 中写了关于这项研究的内容(前者由Wadler 和Hancock 撰写,后者由David Lester 撰写)。 (Lennart Augustsson 还告诉我,前一本书的一个关键动机是描述他的 LML 编译器完成编译的方式,没有得到广泛的评论)。

这些作品中描述的图缩减方法背后的关键概念是,我们不将程序视为指令序列,而是将依赖图视为通过一系列评估局部减量。第二个关键见解是,对这种图的评估不需要解释,而是图本身可以由代码构建。特别是,我们可以不将图的节点表示为“值或'操作码'和要操作的值”,而是表示为一个函数,在调用时返回所需的值。第一次调用它时,它会向子节点询问它们的值,然后对它们进行操作,然后它用一条新指令覆盖自己,只是说“返回结果”。

这在后来的一篇论文中进行了描述,该论文阐述了 GHC 如何在今天仍然工作的基础知识(尽管模数很多不同的调整):"Implementing Lazy Functional Languages on Stock Hardware: The Spineless Tagless G-Machine."。 GHC 的当前执行模型在GHC Wiki 中有更详细的记录。

因此,我们认为“数据”和“代码”的严格区别是我们认为机器如何工作的“基础”,而不是它们必须如何工作,而是由我们的编译器强加的。所以我们可以把它扔掉,并拥有生成自修改代码(可执行文件)的代码(编译器),它可以很好地工作。

因此事实证明,虽然机器架构在某种意义上是必不可少的,但语言可能会以非常令人惊讶的方式映射到它们,这看起来不像传统的 C 风格的流控制,如果我们认为足够低级,这也可能是有效的。

除此之外,还有许多其他优化,特别是纯度开放,因为它允许更大范围的“安全”转换。何时以及如何应用这些转换以使事情变得更好而不是更糟当然是一个经验问题,并且在这个和许多其他小的选择上,多年来的工作已经投入到理论工作和实际基准测试中。所以这当然也起作用。提供此类研究的一个很好例子的论文是“Making a Fast Curry: Push/Enter vs. Eval/Apply for Higher-Order Languages."

最后,应该注意的是,这个模型仍然由于间接而引入了开销。在我们知道严格执行某些事情是“安全的”并因此省略了图形间接的情况下,可以避免这种情况。 GHC Wiki 再次详细记录了推断严格性/需求的机制。

【讨论】:

  • 需求分析器链接价值连城!最后,关于这个话题的一些事情并不是像它基本上是莫名其妙的黑魔法一样。我怎么没听说过这个??它应该从任何人询问如何解决懒惰问题的任何地方链接!
  • @Evi1M4chine 我没有看到与需求分析器相关的链接,也许它已经以某种方式丢失了。有人可以恢复链接或澄清参考吗?听起来很有趣。
  • @CrisP 我相信最后一个链接就是所指的内容。它转到 GHC Wiki 上关于 GHC 中的需求分析器的页面。
  • @Serpentine Cougar,Chris P:是的,我就是这个意思。
【解决方案3】:

嗯,这里有很多要评论的。我会尽量回答。

正确使用,它可以接近低级语言。

根据我的经验,在许多情况下,通常可以将 Rust 的性能提高到 2 倍以内。但也有一些(广泛的)用例与低级语言相比性能较差。

甚至击败它,但这意味着您使用的是效率低下的 C 程序,因为 GHC 将 Haskell 编译为 C)

这并不完全正确。 Haskell 编译为 C--(C 的一个子集),然后通过本机代码生成器编译为汇编。本机代码生成器通常比 C 编译器生成更快的代码,因为它可以应用一些普通 C 编译器无法做到的优化。

机器架构显然是必不可少的,大致基于图灵机。

这不是一个好的思考方式,特别是因为现代处理器会乱序评估指令并且可能同时评估。

确实,Haskell 甚至没有具体的求值顺序。

实际上,Haskell 确实隐式定义了一个评估顺序。

此外,您总是创建代数数据类型,而不是处理机器数据类型。

只要您有足够先进的编译器,它们在许多情况下都是对应的。

您会认为动态创建函数并将它们扔掉会使程序变慢。

Haskell 已编译,因此实际上并不是动态创建高阶函数。

似乎优化了 Haskell 代码,你需要让它更优雅和抽象,而不是更像机器。

一般来说,让代码更像“机器”是在 Haskell 中获得更好性能的低效方法。但使其更抽象也并不总是一个好主意。 一个好主意是使用经过大量优化的通用数据结构和函数(例如链表)。

f x = [x]f = pure 在 Haskell 中是完全相同的,例如。在前一种情况下,好的编译器不会产生更好的性能。

考虑到 Haskell(用 GHC 编译)的抽象性质和与物理机器的区别,为什么这么快?

简短的回答是“因为它旨在做到这一点。” GHC 使用无脊椎无标签 g 机器 (STG)。你可以阅读一篇关于它的论文here(它非常复杂)。 GHC 还做了很多其他的事情,比如严格性分析和optimistic evaluation

我说 C 和其他命令式语言有点类似于图灵机的原因(但不是 Haskell 类似于 Lambda 演算的程度)是在命令式语言中,你有有限数量的状态(也就是行号),以及磁带(ram),这样状态和当前磁带就决定了要对磁带做什么。

那么混淆点是否会导致代码变慢? Haskell 的惰性实际上意味着可变性并不像你想象的那么重要,而且它是高级别的,所以编译器可以应用许多优化。因此,就地修改记录很少会比使用 C 等语言慢。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2017-01-10
    • 2022-01-14
    • 2018-03-22
    • 2021-12-19
    • 2013-09-09
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多