一次顺带的语言性能评测 -- 以2D水波特效的实现为例

    本来一开始我只是想学习一下如何实现2D水波特效,关于这个问题早有现成的效果不错且简单的算法,google一下可以找到很多资料,比如这篇http://freespace.virgin.net/hugo.elias/graphics/x_water.htm。但是由于性能上的问题,导致我更换了多种开发语言,最终也使得这次的学习变成了一次编程语言的shoot-out游戏。

    简单来说,2D水波特效是个2D图像处理问题,着迷于DSL的我首先选择了Pan#(http://www.haskell.org/edsl/pansharp.html),一种函数式的针对图像处理的DSL,它的思想、语法和语义都完全来源于Haskell社区的Pan(http://conal.net/Pan/)。作为一种图像处理的DSL,它的基本思想是将图像看成是把2D连续空间映射为色彩的函数,通过应用各种各样的变换函数便可以改变图像函数的映射规则,从而改变图像,这也就实现了各种各样的图像处理。但是当我开始动手做的时候,还是发现了不便。其实Pan#提供的编程模型与流(stream)编程非常类似,同一个2D连续空间中的点与点(同一个流里的元素)之间是比较独立的,绝大多数时候它们不会同时参与运算。但是水波特效不同,图像所在的2D空间中相邻的点与点之间是要进行混合运算的。而且由于Pan#是纯函数式的,没有可变状态(mutable state),所以我用了一些手段迂回地实现了一个demo,但是很可惜它能编译通过,但是启动时却会抛出CLR异常(Pan#是针对.NET平台的)。

    接下来,我换用NetLogo(http://ccl.sesp.northwestern.edu/netlogo/)。这是一个用Java实现的大规模多主体(multi-agent)可编程系统。因为NetLogo支持大规模并发,我想当然地认为它在计算密集型的图像处理中也能胜任,但是最后的结果却惨不忍睹。

    然后我选择了Haskell,底层采用SDL。这个版本的实现我尽量能够functional一点,并且此时我对Haskell默认的lazy evaluation策略对性能的影响也一无所知。最后这个demo的表现也不忍卒睹。

    再接下来我拣起了熟悉的Lua,底层使用由IUP(http://www.tecgraf.puc-rio.br/iup)、
CD(http://www.tecgraf.puc-rio.br/cd)和IM(http://www.tecgraf.puc-rio.br/im)组成的GUI框架。
最后的效果是,在我的机器上(Windows XP,Pentium4 2.8GHz 双核, 1G memory),对于100X100大小的图像能达到每秒1帧。这个结果让我大为吃惊。一直以来我都认为现在的CPU已经足够强大了,而且Lua也是动态语言中的性能佼佼者,应该能胜任部分类似图像处理这样的大计算量工作。但是事实无情地打击了我,有些事情用动态语言去做确实非常不靠谱。

    到了这个时候我不得不求助于C(仍然采用SDL库)。谢天谢地,用gcc -O2编译的demo对于
640X480的图像可以接近每秒100帧。

    本来故事到这里就应该结束了,可是我实在有些不甘心,都已经2009年了,为什么这样的事情还是只能用C来做?shootout网站上(http://shootout.alioth.debian.org/)的评测表明Hakell这样的语言在速度上虽然比C平均慢5倍,但已属同一个数量级了。为什么我的Haskell版本会比C慢这么多?

    一定是我没写好。于是我再次开始捣鼓Haskell。在初步了解了lazy evaluation对性能的影响后,我开始使用Bang Patterns(http://www.haskell.org/ghc/docs/latest/html/users_guide/bang-patterns.html)以期达到strict application,并且使用了不那么具有函数式风格的mutable array
(http://www.haskell.org/haskellwiki/Arrays)。结果终于有点起色,640X480的图像可以跑每秒5帧。
当然这一点也不让人满意,在用尽了我所知道的所有优化手段后,仍然没有更进一步的发展,我实在有些心灰意冷了,于是把它扔在一边几个星期没管。但是前几天当我心血来潮偶然地将一个原本用round实现的操作改为truncate,速度立马变成了每秒20帧,是原来的4倍,比C版本慢5倍,达到了同一个量级,很符合shootout上的评测。至此,我已经相当满意Haskell的表现了。

    水波demo的build需要安装SDL开发库(到http://www.libsdl.org/下载),C程序版本和Haskell版本以及一张640X480大小的ripple.bmp都打包在这里。程序运行起来会找当前目录下的ripple.bmp,然后可以用鼠标左键点击加入一个水波。C版本用gcc -O2 build,Haskell版本得用GHC(http://haskell.org/ghc/,并且要安装一个SDL的binding:http://hackage.haskell.org/cgi-bin/hackage-scripts/package/SDL),build命令是ghc -O2 -XBangPatterns -package SDL -o ripple ripple.hs。当然算法实现本身还有很多可优化的空间,比如用整数运算代替浮点运算等等。

    此后我还对C、Haskell、C#、Lua、Scheme(Gambit Scheme,http://dynamo.iro.umontreal.ca/~gambit/)和Clean(与Haskell非常类似的纯函数式语言,http://clean.cs.ru.nl/)做了一个更简单的评测:对某个变量累加10000000次,除C和C#外都是用尾递归实现的循环(源码见附录)。结果发现C的速度最快,以C的耗时为基准,其它语言耗时与它的比值是:Haskell 4.5倍;C# 6.4倍;Lua 44倍;Scheme 9倍;Clean 1.2倍(太神奇了)。

    虽然如此折腾最终却感觉颇有收获。随着函数式语言设计与实现技术的不断发展,在保持非常优雅的抽象机制的前提下,它们在速度上已经越来越接近C了,Haskell和Clean是这类语言的代表。不过我本人更喜欢Haskell,除了GHC开源并且许可证非常宽松以外,更重要的是GHC Haskell是我见过的对并发支持得最完善的语言,甚至更胜过以并发见长的Erlang,这在多核时代是一个巨大的优势。另外值得注意的是Gambit Scheme系统,Scheme语言和Lua语言都是动态语言,Gambit编译器首先把Scheme翻译成C,再用C编译器编译生成的C代码产生最终的本地代码,这种方法能显著地提高速度。作者本人借鉴了这样的思路和方法,针对Lua写了一个简陋的Lua to C的编译器(只能编译Lua的一个小的子集),感觉很有趣。

    经过这一次的实践,我对现代函数式语言的性能已经比较有谱了,我认为程序员应该严肃地对待它们,尤其是Haskell,已经非常成熟,良好的编程模型和相当不错的性能使得它即使在诸如游戏这样性能关键的应用领域里也颇具竞争力。


附录:

1) C (gcc -O2)
#include <stdio.h>
int
main()
{
    double i;
    double accumv = 34.5;
   
    for (i = 0.0; i < 10000000.0; i += 1.0)
    {
        accumv = accumv + 2.0;
    }

    printf("%f/n",accumv);

    return 0;
}

2) Haskell (ghc -O2 -XBangPatterns)
count :: Double
count = 10000000.0
main = do
  let !v = iterate 0.0 34.5
  print v
  where
    iterate :: Double -> Double -> Double
    iterate !times !accumv =
        if times < count
        then iterate (times + 1.0) (accumv + 2.0)
        else accumv

3) C# (csc /o)
using System;
namespace Foo
{
static class Program
{
    [STAThread]
    static void Main(string[] args)
    {
        System.Console.Write(iterate(100000000,34.5));
    }

    static double iterate(double times, double accumv)
    {
        for (double i = 0; i < times; ++i)
        {
            accumv = accumv + 2.0;
        }

        return accumv;
    }
}
}

4) Lua
local count = 10000000
local accumv = 34.5;
local function iterate (times,accumv)
    if 0 == times then
        return accumv
    else
        return iterate (times - 1, accumv + 2)
    end
end
print(iterate(count,accumv))

5) Scheme
(begin
  (define count 10000000)
  (define (iterate times accumv)
       (if (= 0 times)
           accumv
           (iterate (- times 1) (+ accumv 2.0))))
  (print (iterate count 34.5))
)

6) Clean
module speed
import StdEnv
count :: Real
count = 10000000.0
Start :: *World -> *World
Start world
# (console,world) = stdio world
# console = fwrites (toString (iterate count 34.5)) console
# (ok,world) = fclose console world
= world
  where iterate :: !Real !Real -> Real
        iterate 0.0 accumv = accumv
        iterate times accumv =
          iterate (times - 1.0) (accumv + 2.0)

你可能感兴趣的:(Scheme,haskell,lua,语言,编译器,图像处理)