我正在学习Haskell,并且正在尝试尽可能快地编写代码。在本练习中,我正在为一个简单的一维物理系统编写Euler integrator。
-O3
编译的。它以 1.166 秒运行。-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
答案 0 :(得分:12)
已经提到了关键点by hammar和by 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'
是递归的,因此无法内联。这意味着传递的函数也不能在那里内联,并且每次迭代也传递runEuler
和dt
参数。该函数无法内联意味着在循环中,其参数必须在传递给函数之前被加框,然后必须取消装箱其结果。那很贵。这意味着在内联函数参数后不能进行任何优化。
如果你使用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,但是我得到的是
-O2
代替-O3
(虽然我怀疑它很重要,但-O3
未得到维护)-funbox-strict-fields
x*x
代替x ** 2
(与您的C代码相同)即
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的混合编写应该可以为你提供最好的两个世界。