函数式语言天生就慢吗?
在某种意义上,是的。他们需要的基础设施不可避免地增加了理论上可以使用手工汇编程序实现的开销。特别是,一流的词法闭包只适用于垃圾回收,因为它们允许值超出范围。
为什么函数式语言在基准测试中总是落后于 C?
首先,谨防选择偏差。 C 作为基准套件中的最低公分母,限制了可以完成的工作。如果你有一个比较 C 和函数式语言的基准,那么它几乎可以肯定是一个非常简单的程序。可以说如此简单,以至于在今天几乎没有实际意义。仅使用 C 作为基准来解决更复杂的问题实际上是不可行的。
最明显的例子是并行性。今天,我们都有多核。甚至我的手机也是多核的。众所周知,多核并行在 C 语言中很困难,但在函数式语言中却很容易(我喜欢 F#)。其他示例包括任何受益于持久数据结构的东西,例如撤消缓冲区对于纯函数式数据结构来说是微不足道的,但在 C 等命令式语言中可能需要大量工作。
为什么看起来所有函数式语言都比 C 慢,为什么它们总是需要垃圾收集和过度使用堆?
函数式语言看起来会更慢,因为您只会看到比较容易用 C 编写好的代码的基准测试,而您永远不会看到比较函数式语言开始表现出色的更复杂任务的基准测试。
但是,您已经正确地确定了当今函数式语言的最大瓶颈可能是什么:它们的分配率过高。干得好!
函数式语言分配如此繁重的原因可以分为历史原因和固有原因。
从历史上看,Lisp 实现已经进行了 50 年的大量拳击。这种特性传播到许多其他使用类似 Lisp 的中间表示的语言。多年来,语言实施者一直在使用拳击作为解决语言实施复杂性的快速解决方案。在面向对象的语言中,默认设置总是堆分配每个对象,即使它显然可以被堆栈分配。然后效率的负担被推到了垃圾收集器上,并且已经投入了大量精力来构建垃圾收集器,这些垃圾收集器可以达到接近堆栈分配的性能,通常是通过使用凹凸分配的 Nursery 生成。我认为应该投入更多的精力来研究功能语言设计,以最大限度地减少装箱和垃圾收集器设计,这些设计针对不同的需求进行了优化。
通用垃圾收集器非常适合堆分配大量的语言,因为它们几乎可以与堆栈分配一样快。但它们在其他地方增加了大量的间接费用。今天的程序越来越多地使用队列之类的数据结构(例如用于并发编程),这些数据结构给分代垃圾收集器带来了病态的行为。如果队列中的项目超过第一代,那么它们都被标记,然后它们都被复制(“撤离”),然后对其旧位置的所有引用都得到更新,然后它们就有资格被收集。这比它需要的速度慢了大约 3 倍(例如,与 C 相比)。像Beltway(2002)和Immix(2008)这样的标记区域收集器有可能解决这个问题,因为托儿所被一个可以像托儿所一样被收集的区域所取代,或者如果它包含大部分可达值,它可以被另一个区域替换并保持老化,直到它包含大部分无法访问的值。
尽管 C++ 已经存在,但 Java 的创建者错误地为泛型采用了类型擦除,从而导致了不必要的装箱。例如,I benchmarked a simple hash table running 17× faster on .NET than the JVM 部分是因为 .NET 没有犯这个错误(它使用了具体化的泛型),也因为 .NET 具有值类型。我实际上责怪 Lisp 让 Java 变慢了。
所有现代函数式语言实现都继续过度装箱。 Clojure 和 Scala 等基于 JVM 的语言几乎没有选择余地,因为它们所针对的 VM 甚至无法表达值类型。 OCaml 在其编译过程的早期就丢弃类型信息,并在运行时使用标记整数和装箱来处理多态性。因此,OCaml 经常将单个浮点数装箱并且总是将元组装箱。例如,OCaml 中的三个字节由一个指针(其中嵌入了一个隐式的 1 位标记,在运行时重复检查)表示,该指针指向一个堆分配的块,该块具有 64 位标头和 192 位主体,其中包含三个标记的 63 位整数(其中 3 个标记再次在运行时重复检查!)。这显然是疯了。
在函数式语言的拆箱优化方面已经做了一些工作,但从未真正获得关注。例如,标准 ML 的 MLton 编译器是一个全程序优化编译器,它进行了复杂的拆箱优化。可悲的是,它早于它的时代,而且“长”的编译时间(在现代机器上可能不到 1 秒!)阻止了人们使用它。
唯一打破这一趋势的主要平台是 .NET,但令人惊讶的是,这似乎是个意外。尽管有一个 Dictionary 实现对值类型的键和值进行了非常优化(因为它们是未装箱的),但像 Eric Lippert 这样的 Microsoft 员工继续使用 claim that the important thing about value types is their pass-by-value semantics 而不是源自其未装箱内部表示的性能特征。 Eric 似乎被证明是错误的:更多的 .NET 开发人员似乎更关心拆箱而不是按值传递。实际上,大多数结构是不可变的,因此是引用透明的,因此按值传递和按引用传递之间没有语义差异。性能是可见的,结构可以提供巨大的性能改进。 structs 的性能甚至节省了 Stack Overflow 和 structs 用于避免商业软件中的 GC 延迟,如Rapid Addition's!
函数式语言分配繁重的另一个原因是固有的。像哈希表这样的命令式数据结构在内部使用巨大的整体数组。如果这些是持久的,那么每次进行更新时都需要复制巨大的内部数组。因此,像平衡二叉树这样的纯功能数据结构被分割成许多小的堆分配块,以便于从一个版本的集合到下一个版本的重用。
当字典之类的集合仅在初始化期间写入然后大量读取时,Clojure 使用了一个巧妙的技巧来缓解这个问题。在这种情况下,初始化可以使用变异来构建“幕后”结构。但是,这对增量更新没有帮助,并且生成的集合仍然比它们的命令式等价物读起来慢得多。从好的方面来说,纯函数式数据结构提供持久性,而命令式数据结构则没有。然而,很少有实际应用程序从实践中的持久性中受益,因此这通常不是有利的。因此,人们渴望使用不纯的函数式语言,您可以轻松地使用命令式风格并从中获益。
有谁知道适合嵌入式/实时应用程序的函数式语言,其中内存分配保持在最低限度并且生成的机器代码精简且快速?
如果您还没有,请查看 Erlang 和 OCaml。两者对于内存受限的系统都是合理的,但都不会生成特别好的机器代码。