为什么功能语言总是在基准测试中落后于C?如果你有一个静态类型的函数式语言,在我看来它可以被编译成与C相同的代码,或者甚至更优化的代码,因为编译器可以使用更多的语义.为什么看起来所有函数式语言都比C慢,为什么它们总是需要垃圾收集和过度使用堆?
有没有人知道适用于嵌入式/实时应用程序的功能语言,其中内存分配保持在最低限度并且生成的机器代码是精简和快速的?
功能语言本身就很慢吗?
从某种意义上说,是的.它们需要的基础设施不可避免地增加了理论上可以手动使用汇编程序获得的开销.特别是,一流的词法闭包只适用于垃圾收集,因为它们允许在范围之外执行值.
为什么功能语言总是在基准测试中落后于C?
首先,要注意选择偏差.C是基准套件中最低的共同点,限制了可以实现的目标.如果你有一个比较C与功能语言的基准,那么它几乎肯定是一个非常简单的程序.可以说是如此简单,以至于它今天没有什么实际意义.使用C作为基准来解决更复杂的问题实际上是不可行的.
最明显的例子是并行性.今天,我们都有多核.甚至我的手机也是多核的.众所周知,多核并行在C语言中很难,但在函数式语言中很容易(我喜欢F#).其他示例包括从持久性数据结构中受益的任何内容,例如,撤消缓冲区对于纯粹的功能数据结构来说是微不足道的,但在C等命令式语言中可能是大量的工作.
为什么看起来所有函数式语言都比C慢,为什么它们总是需要垃圾收集和过度使用堆?
功能语言似乎会变慢,因为你只会看到比较代码的基准测试,这些代码很容易在C语言中写得很好,而且你永远不会看到比较功能性语言开始出色的繁重任务的基准测试.
但是,您已经正确地确定了当今功能语言可能是最大的瓶颈:它们的分配率过高.干得好!
功能语言如此分配的原因可以分为历史和内在原因.
从历史上看,Lisp实现50年来已经做了很多拳击.这种特性扩展到许多其他使用类似Lisp的中间表示的语言.多年来,语言实施者不断使用拳击作为语言实现复杂性的快速解决方案.在面向对象的语言中,默认情况下总是堆分配每个对象,即使它显然可以堆栈分配.然后将效率的负担推到垃圾收集器上,并且已经投入大量精力来构建垃圾收集器,其可以获得接近堆栈分配的性能,通常通过使用缓冲分配的苗圃生成.我认为应该投入更多精力研究功能语言设计,以最大限度地减少针对不同要求进行优化的装箱和垃圾收集器设计.
分代垃圾收集器非常适合堆分配很多的语言,因为它们几乎和堆栈分配一样快.但他们在其他地方增加了大量的开销 今天的程序越来越多地使用像队列这样的数据结构(例如用于并发编程),这些程序为分代垃圾收集器提供了病态行为.如果队列中的项目比第一代更长,那么它们都会被标记,然后它们全部被复制("撤离"),然后所有对旧位置的引用都会更新,然后它们就有资格进行收集.这比它需要的慢约3倍(例如与C相比).像Beltway(2002)和Immix(2008)这样的Mark区域收集者有可能解决这个问题,因为托儿所被替换为一个可以像托儿所一样被收集的区域,或者如果它包含大部分可达到的值,它可以被替换为另一个区域并保持老化直到它包含大多数无法访问的值.
尽管存在C++,但Java的创建者犯了错误,即对泛型采用类型擦除,导致不必要的装箱.例如,我对一个运行速度比JVM快17倍的简单哈希表进行了基准测试,部分原因是因为.NET没有犯这个错误(它使用了reified泛型),而且因为.NET有值类型.我实际上责怪Lisp使Java变慢.
所有现代功能语言实现都继续过度使用.像Clojure和Scala这样的基于JVM的语言几乎没有选择,因为它们所针对的VM甚至无法表达值类型.OCaml在其编译过程的早期流失类型信息,并在运行时转换为标记整数和装箱以处理多态性.因此,OCaml通常会对单个浮点数进行封装,并始终使用方框元组.例如,OCaml中的三个字节由指针(其中嵌入了隐式1位标记,在运行时重复检查)表示为堆分配块,其中包含64位标头和192位主体包含三个标记的63位整数(在运行时再次检查3个标签!).这显然是疯了.
在函数式语言中对取消装箱优化进行了一些工作,但它从未真正获得牵引力.例如,标准ML的MLton编译器是一个完整的程序优化编译器,可以进行复杂的拆箱优化.可悲的是,这是它的时间之前和"长"的编译时间(可能是下一个现代化的机上1秒!)使用它的人望而却步.
打破这一趋势的唯一主要平台是.NET,但令人惊讶的是,这似乎是一次意外.尽管Dictionary
针对有价值类型的键和值进行了非常优化的实现(因为它们是未装箱的),但像Eric Lippert这样的微软员工仍然声称值类型的重要之处在于它们的值传递语义而不是性能特征源于他们的未装箱的内部代表.Eric似乎被证明是错误的:更多的.NET开发人员似乎更关心拆箱而不是价值转移.实际上,大多数结构都是不可变的,因此在引用上是透明的,因此在传值和传递引用之间没有语义差异.性能可见,结构可以提供巨大的性能改进.结构的性能甚至保存了Stack Overflow和结构,用于避免像Rapid Addition这样的商业软件中的GC延迟!
功能语言大量分配的另一个原因是固有的.像哈希表这样的命令式数据结构在内部使用巨大的单片阵列.如果这些是持久的,则每次进行更新时都需要复制巨大的内部数组.因此,像平衡二进制树这样的纯功能数据结构被分段为许多小堆分配块,以便于从一个版本的集合重用到下一个版本.
Clojure使用一个巧妙的技巧来缓解这个问题,当字典之类的集合只在初始化期间写入,然后从大量读取.在这种情况下,初始化可以使用变异来构建"幕后"结构.但是,这对增量更新没有帮助,并且生成的集合仍然比它们的命令等效项要慢得多.从好的方面来看,纯功能数据结构提供了持久性,而命令式数据结构却没有.然而,很少有实际应用受益于实践中的持久性,因此这通常是不利的.因此,人们渴望使用不纯的函数式语言,在这种语言中,您可以毫不费力地采用命令式的方式并获得收益.
有没有人知道适用于嵌入式/实时应用程序的功能语言,其中内存分配保持在最低限度并且生成的机器代码是精简和快速的?
如果您还没有,请查看Erlang和OCaml.两者对于内存受限系统都是合理的,但都不会产生特别好的机器代码.
没有什么本质上是什么.下面是一个解释 OCaml比同等C代码运行得更快的示例,因为OCaml优化器由于语言的不同而具有不同的可用信息.当然,一般声称OCaml明显比C更快是愚蠢的.重点是,这取决于你正在做什么,以及你是如何做到的.
也就是说,OCaml是(大多数)功能语言的一个例子,它实际上是为了性能而设计的,与纯度相反.
函数式语言需要消除在语言抽象层次上可见的可变状态.因此,需要复制由命令性语言在适当位置发生变异的数据,并在副本上进行突变.举一个简单的例子,请参阅Haskell与C中的快速排序.
此外,垃圾收集是必需的,因为free()
它不具有纯粹的功能,因为它具有副作用.因此,在语言抽象层次上释放不涉及副作用的内存的唯一方法是使用垃圾收集.
当然,原则上,足够智能的编译器可以优化大部分复制.这已经在某种程度上已经完成了,但是让编译器足够聪明地理解代码在该级别的语义只是很难.
简短的回答:因为C很快.就像在,快速疯狂地疯狂.一种语言根本不需要"缓慢"才能通过C将其交给它.
C之所以快,是因为它是由非常优秀的程序员创建的,而且gcc已经在几十年的时间里进行了优化,而且还有数十个更出色的编码器,而不是99%的语言.
简而言之,除了需要非常特定的函数式编程结构的专门任务之外,你不会打败C语言.
C很快,因为它基本上是汇编程序的一组宏:)当你用C语言编写程序时,没有"幕后".当你决定这样做的时候分配内存并且你以同样的方式释放.当您编写实时应用程序时,这是一个巨大的优势,其中可预测性很重要(实际上比其他任何事情都重要).
此外,C编译器通常非常快,因为语言本身很简单.它甚至不进行任何类型检查:)这也意味着更容易找到错误.缺少类型检查的广告优势是函数名称只能以其名称导出,这使得C代码易于与其他语言的代码链接
过程语言的控制流程更好地匹配现代计算机的实际处理模式.
C非常接近地映射到其编译产生的汇编代码,因此昵称为"跨平台汇编".计算机制造商花了几十年的时间使汇编代码尽可能快地运行,因此C继承了所有这些原始速度.
相比之下,功能语言的无副作用,固有的并行性并不能很好地映射到单个处理器上.可以调用函数的任意顺序需要被序列化到CPU瓶颈:如果没有非常聪明的编译,你将一直进行上下文切换,所有预取都不会起作用,因为你经常跳跃到处都是,......基本上,计算机制造商为优秀,可预测的继续语言所做的所有优化工作都是无用的.
然而!随着向许多功能较弱的核心(而不是一个或两个涡轮增压核心)的转变,功能语言应该开始缩小差距,因为它们自然地水平扩展.
至于现在,功能语言并没有大量用于工业项目,所以没有足够的认真工作进入优化器.此外,为命令式目标优化命令式代码可能更容易.
功能语言有一个功能,可以让它们很快超越命令式语言:琐碎的并行化.
微不足道的意思是它很容易,但它可以构建到语言环境中,而不需要开发人员考虑它.
像C这样的线程无关的语言中强大的多线程的成本对许多项目来说都是禁止的.