Haskell - 优化微分方程求解器

时间:2012-10-11 02:09:07

标签: haskell ode differential-equations

我正在学习Haskell,并且正在尝试尽可能快地编写代码。在本练习中,我正在为一个简单的一维物理系统编写Euler integrator

  • C代码是使用GCC 4.5.4和-O3编译的。它以 1.166 秒运行。
  • Haskell代码使用GHC 7.4.1和-O3编译。它在 21.3 秒内运行。
  • 如果我使用-O3 -fllvm编译Haskell,它将在 4.022 秒内运行。

那么,我是否遗漏了一些优化我的Haskell代码的东西?

PS。:我使用了以下参数:1e-8 5

C代码:

#include <stdio.h>
double p, v, a, t;

double func(double t) {
  return t * t;
}

void euler(double dt) {
  double nt = t + dt;
  double na = func(nt);
  double nv = v + na * dt;
  double np = p + nv * dt;

  p = np;
  v = nv;
  a = na;
  t = nt;
}

int main(int argc, char ** argv) {
  double dt, limit;
  sscanf(argv[1], "%lf", &dt);
  sscanf(argv[2], "%lf", &limit);
  p = 0.0;
  v = 0.0;
  a = 0.0;
  t = 0.0;

  while(t < limit) euler(dt);
  printf("%f %f %f %f\n", p, v, a, t);
  return 0;
}

Haskell代码:

import System.Environment (getArgs)

data EulerState = EulerState !Double !Double !Double !Double deriving(Show)
type EulerFunction = Double -> Double

main = do
  [dt, l] <- fmap (map read) getArgs
  print $ runEuler (EulerState 0 0 0 0) (**2) dt l

runEuler :: EulerState -> EulerFunction -> Double -> Double -> EulerState
runEuler s@(EulerState _ _ _ t) f dt limit = let s' = euler s f dt
                                             in case t `compare` limit of
                                                  LT -> s' `seq` runEuler s' f dt limit
                                                  _ -> s'

euler :: EulerState -> EulerFunction -> Double -> EulerState
euler (EulerState p v a t) f dt = (EulerState p' v' a' t')
    where t' = t + dt
          a' = f t'
          v' = v + a'*dt
          p' = p + v'*dt

4 个答案:

答案 0 :(得分:12)

已经提到了关键点by hammarby Philip JF。但是让我收集它们并添加一些解释。

我将从上到下进行。

data EulerState = EulerState !Double !Double !Double !Double

您的类型具有严格的字段,因此每当该类型的对象被评估为WHNF时,其字段也会被评估为WHNF。在这种情况下,这意味着对象被完全评估。这很好,但不幸的是,在我们的情况下还不够好。这种类型的对象仍然可以使用指向原始数据的指针来构造,而不是将原始数据解压缩到构造函数中,这就是加速字段会发生的事情(模数是循环不直接使用类型的事实,但是通过组件作为参数)。由于euler中未使用该字段,因此

Rec {
Main.$wrunEuler [Occ=LoopBreaker]
  :: GHC.Prim.Double#
     -> GHC.Prim.Double#
     -> GHC.Types.Double
     -> GHC.Prim.Double#
     -> Main.EulerFunction
     -> GHC.Prim.Double#
     -> GHC.Prim.Double#
     -> (# GHC.Types.Double,
           GHC.Types.Double,
           GHC.Types.Double,
           GHC.Types.Double #)

带有盒装参数的循环。这意味着在每次迭代中,一些Double#需要装箱,一些Double未装箱。拳击和拆箱不是非常昂贵的操作,但是在一个本来会很紧张的循环中,它们会花费很多性能。同一装箱/拆箱问题的另一个实例与类型EulerFunction的参数相关联,稍后将详细介绍。 -funbox-strict-fields suggested by Philp JF或至少加速度字段的{-# UNPACK #-} pragma在这里有帮助,但只有在消除了功能评估的装箱/拆箱时,差异才会变得相关。

print $ runEuler (EulerState 0 0 0 0) (**2) dt l

你在这里作为一个论点传递(** 2)。这与你在C中使用的功能不同,相应的C函数将是return pow(t,2);,并且使用我的gcc,使用它几乎可以使C程序的运行时间加倍(尽管clang没有区别)。这是一个很大的性能问题,(**)函数。由于(** 2)对于许多参数有不同的\x -> x*x结果,因此没有重写规则,所以你真的用GHC的本机代码生成器得到了那么慢的函数(似乎LLVM用{{1替换它}然而,来自两个GHC后端和铿锵声结果的巨大性能差异)。如果您通过\x -> x*x(\x -> x*x)而不是(^ 2),则会得到乘法(自7.4以来,(** 2)有重写规则)。此时,在我的系统上,NCG生成的代码与LLVM生成的代码之间没有太大差异,但NCG大约10%更快

现在是个大问题

(^ 2)

runEuler :: EulerState -> EulerFunction -> Double -> Double -> EulerState runEuler s@(EulerState _ _ _ t) f dt limit = let s' = euler s f dt in case t `compare` limit of LT -> s' `seq` runEuler s' f dt limit _ -> s' 是递归的,因此无法内联。这意味着传递的函数也不能在那里内联,并且每次迭代也传递runEulerdt参数。该函数无法内联意味着在循环中,其参数必须在传递给函数之前被加框,然后必须取消装箱其结果。那很贵。这意味着在内联函数参数后不能进行任何优化。

如果你使用worker / wrapper转换和静态参数转换suggested by hammar,可以内联limit,因此可以内联传递的函数,并且 - 在这种情况下 - 参数的装箱,以及可以消除其结果的拆箱。此外,甚至更大的影响,在这种情况下,可以消除功能调用并用一个机器操作代替。这导致了一个很好的紧密循环,如

所示
runEuler

相比
       174,208 bytes allocated in the heap
         3,800 bytes copied during GC

原作。

使用本机代码生成器实现C程序速度的一半,与我的盒子上的LLVM后端速度相同(本机代码生成器不是特别擅长优化循环,而LLVM是,因为循环在通过LLVM编译的许多语言中非常常见。)

答案 1 :(得分:6)

通过将worker-wrapper transformation应用于runEuler,我得到了很好的推动。

runEuler :: EulerState -> EulerFunction -> Double -> Double -> EulerState
runEuler s f dt limit = go s
  where go s@(EulerState _ _ _ t) = if t < limit then go (euler s f dt) else s

这有助于f内联到循环中(这可能也发生在C版本中),从而消除了大量开销。

答案 2 :(得分:5)

我目前没有一个正常运行的LLVM,但是我得到的是

  1. 在GHC中使用-O2代替-O3(虽然我怀疑它很重要,但-O3未得到维护)
  2. 使用-funbox-strict-fields
  3. 使用x*x代替x ** 2(与您的C代码相同)
  4. 将您的“euler函数”移动为与C中相同的独立函数。
  5.   func :: EulerFunction
      func x = x * x
    
      runEuler :: EulerState -> Double -> Double -> EulerState
      runEuler s@(EulerState _ _ _ t) dt limit = let s' = euler s dt
                                                 in case t `compare` limit of
                                                      LT -> s' `seq` runEuler s' dt limit
                                                      _ -> s'
    
      euler :: EulerState -> Double -> EulerState
      euler (EulerState p v a t) dt = (EulerState p' v' a' t')
        where t' = t + dt
              a' = func t'
              v' = v + a'*dt
              p' = p + v'*dt
    

    你可以推进它(或者像Dons这样的Haskell性能专家会出现一个解决方案),我甚至没有看过这个生成的核心,但总的来说,制作Haskell代码的方法“一样快” C“是”用C语言编写并使用FFI。“

答案 3 :(得分:4)

很少有参考资料:

以下是代表普通民间传说的传福音。所以带上一粒盐。

你不可能在不同的微基准测试中获得稳定的类似C的性能,而不是像C,Fortran,Ada和C ++这样的史前语言。即使Java还没有完全存在。有时你会得到,但有时编译器会失败,GHC会经常失败。

但微基准测试并不能告诉你一切。

问题在于,对于大型项目而言,在各处获得微调的低级C代码在经济上是不可行的。所以C程序最终会有糟糕的算法,糟糕的架构,没有低级别的瓶颈,并计划最终重写所有内容。在C中,很容易调整低级代码,但很难进行大规模的体系结构更改。在Haskell中它反之亦然,所以用haskell和C的混合编写应该可以为你提供最好的两个世界。