What optimizations can GHC be expected to perform reliably?
GHC有很多可以执行的优化,但我不知道它们都是什么,也不知道它们被执行的可能性有多大,在什么情况下。
我的问题是:我可以期望它每次应用什么转换,或者几乎每次都应用什么转换?如果我看到一段经常执行(评估)的代码,我的第一个想法是"嗯,也许我应该优化它",在这种情况下,我的第二个想法应该是"别想了,GHC明白了"?
我正在阅读"纸流融合":从列表到流,再到完全没有,他们使用的将列表处理重写为另一种形式的技术,然后GHC的正常优化将可靠地优化为简单的循环,对我来说是新奇的。我怎样才能知道我自己的程序何时有资格进行这种优化?
GHC手册中有一些信息,但它只是回答问题的一部分。
编辑:我要开始赏金。我想要的是一个低级转换的列表,比如lambda/let/case浮动、类型/构造函数/函数参数专门化、严格性分析和取消装箱、工作程序/包装器,以及我遗漏的其他重要的GHC操作,以及输入和输出代码的解释和示例,以及理想的情景说明。当总效果大于各部分之和时。理想情况下,我们会提到什么时候转换不会发生。我不希望对每一个转换都有新颖的长度解释,只要在转换结束时大局清晰,一句话和内嵌的一行代码示例就足够了(或者链接,如果不是20页科学论文的话)。我希望能够看到一段代码,并且能够很好地猜测它是否会编译成一个紧密的循环,或者为什么不编译,或者我必须更改什么来生成它。(我对像流融合这样的大型优化框架不太感兴趣(我刚刚读了一篇关于它的文章);更多的是编写这些框架的人所拥有的知识。)
这个GHC TRAC页面也很好地解释了通行证。这个页面解释了优化排序,但是,和大多数trac wiki一样,它已经过时了。好的。
具体来说,最好的办法可能是看看特定程序是如何编译的。查看正在执行哪些优化的最佳方法是使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | Glasgow Haskell Compiler, Version 7.4.2, stage 2 booted by GHC version 7.4.1 Using binary package database: /usr/lib/ghc-7.4.2/package.conf.d/package.cache wired-in package ghc-prim mapped to ghc-prim-0.2.0.0-7d3c2c69a5e8257a04b2c679c40e2fa7 wired-in package integer-gmp mapped to integer-gmp-0.4.0.0-af3a28fdc4138858e0c7c5ecc2a64f43 wired-in package base mapped to base-4.5.1.0-6e4c9bdc36eeb9121f27ccbbcb62e3f3 wired-in package rts mapped to builtin_rts wired-in package template-haskell mapped to template-haskell-2.7.0.0-2bd128e15c2d50997ec26a1eaf8b23bf wired-in package dph-seq not found. wired-in package dph-par not found. Hsc static flags: -static *** Chasing dependencies: Chasing modules from: *SleepSort.hs Stable obj: [Main] Stable BCO: [] Ready for upsweep [NONREC ModSummary { ms_hs_date = Tue Oct 18 22:22:11 CDT 2011 ms_mod = main:Main, ms_textual_imps = [import (implicit) Prelude, import Control.Monad, import Control.Concurrent, import System.Environment] ms_srcimps = [] }] *** Deleting temp files: Deleting: compile: input file SleepSort.hs Created temporary directory: /tmp/ghc4784_0 *** Checking old interface for main:Main: [1 of 1] Compiling Main ( SleepSort.hs, SleepSort.o ) *** Parser: *** Renamer/typechecker: *** Desugar: Result size of Desugar (after optimization) = 79 *** Simplifier: Result size of Simplifier iteration=1 = 87 Result size of Simplifier iteration=2 = 93 Result size of Simplifier iteration=3 = 83 Result size of Simplifier = 83 *** Specialise: Result size of Specialise = 83 *** Float out(FOS {Lam = Just 0, Consts = True, PAPs = False}): Result size of Float out(FOS {Lam = Just 0, Consts = True, PAPs = False}) = 95 *** Float inwards: Result size of Float inwards = 95 *** Simplifier: Result size of Simplifier iteration=1 = 253 Result size of Simplifier iteration=2 = 229 Result size of Simplifier = 229 *** Simplifier: Result size of Simplifier iteration=1 = 218 Result size of Simplifier = 218 *** Simplifier: Result size of Simplifier iteration=1 = 283 Result size of Simplifier iteration=2 = 226 Result size of Simplifier iteration=3 = 202 Result size of Simplifier = 202 *** Demand analysis: Result size of Demand analysis = 202 *** Worker Wrapper binds: Result size of Worker Wrapper binds = 202 *** Simplifier: Result size of Simplifier = 202 *** Float out(FOS {Lam = Just 0, Consts = True, PAPs = True}): Result size of Float out(FOS {Lam = Just 0, Consts = True, PAPs = True}) = 210 *** Common sub-expression: Result size of Common sub-expression = 210 *** Float inwards: Result size of Float inwards = 210 *** Liberate case: Result size of Liberate case = 210 *** Simplifier: Result size of Simplifier iteration=1 = 206 Result size of Simplifier = 206 *** SpecConstr: Result size of SpecConstr = 206 *** Simplifier: Result size of Simplifier = 206 *** Tidy Core: Result size of Tidy Core = 206 writeBinIface: 4 Names writeBinIface: 28 dict entries *** CorePrep: Result size of CorePrep = 224 *** Stg2Stg: *** CodeGen: *** CodeOutput: *** Assembler: '/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-I.' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' 'SleepSort.o' Upsweep completely successful. *** Deleting temp files: Deleting: /tmp/ghc4784_0/ghc4784_0.c /tmp/ghc4784_0/ghc4784_0.s Warning: deleting non-existent /tmp/ghc4784_0/ghc4784_0.c link: linkables are ... LinkableM (Sat Sep 29 20:21:02 CDT 2012) main:Main [DotO SleepSort.o] Linking SleepSort ... *** C Compiler: '/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.c' '-o' '/tmp/ghc4784_0/ghc4784_0.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include' *** C Compiler: '/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' '/tmp/ghc4784_0/ghc4784_1.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include' *** Linker: '/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-o' 'SleepSort' 'SleepSort.o' '-L/usr/lib/ghc-7.4.2/base-4.5.1.0' '-L/usr/lib/ghc-7.4.2/integer-gmp-0.4.0.0' '-L/usr/lib/ghc-7.4.2/ghc-prim-0.2.0.0' '-L/usr/lib/ghc-7.4.2' '/tmp/ghc4784_0/ghc4784_0.o' '/tmp/ghc4784_0/ghc4784_1.o' '-lHSbase-4.5.1.0' '-lHSinteger-gmp-0.4.0.0' '-lgmp' '-lHSghc-prim-0.2.0.0' '-lHSrts' '-lm' '-lrt' '-ldl' '-u' 'ghczmprim_GHCziTypes_Izh_static_info' '-u' 'ghczmprim_GHCziTypes_Czh_static_info' '-u' 'ghczmprim_GHCziTypes_Fzh_static_info' '-u' 'ghczmprim_GHCziTypes_Dzh_static_info' '-u' 'base_GHCziPtr_Ptr_static_info' '-u' 'base_GHCziWord_Wzh_static_info' '-u' 'base_GHCziInt_I8zh_static_info' '-u' 'base_GHCziInt_I16zh_static_info' '-u' 'base_GHCziInt_I32zh_static_info' '-u' 'base_GHCziInt_I64zh_static_info' '-u' 'base_GHCziWord_W8zh_static_info' '-u' 'base_GHCziWord_W16zh_static_info' '-u' 'base_GHCziWord_W32zh_static_info' '-u' 'base_GHCziWord_W64zh_static_info' '-u' 'base_GHCziStable_StablePtr_static_info' '-u' 'ghczmprim_GHCziTypes_Izh_con_info' '-u' 'ghczmprim_GHCziTypes_Czh_con_info' '-u' 'ghczmprim_GHCziTypes_Fzh_con_info' '-u' 'ghczmprim_GHCziTypes_Dzh_con_info' '-u' 'base_GHCziPtr_Ptr_con_info' '-u' 'base_GHCziPtr_FunPtr_con_info' '-u' 'base_GHCziStable_StablePtr_con_info' '-u' 'ghczmprim_GHCziTypes_False_closure' '-u' 'ghczmprim_GHCziTypes_True_closure' '-u' 'base_GHCziPack_unpackCString_closure' '-u' 'base_GHCziIOziException_stackOverflow_closure' '-u' 'base_GHCziIOziException_heapOverflow_closure' '-u' 'base_ControlziExceptionziBase_nonTermination_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnMVar_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnSTM_closure' '-u' 'base_ControlziExceptionziBase_nestedAtomically_closure' '-u' 'base_GHCziWeak_runFinalizzerBatch_closure' '-u' 'base_GHCziTopHandler_flushStdHandles_closure' '-u' 'base_GHCziTopHandler_runIO_closure' '-u' 'base_GHCziTopHandler_runNonIO_closure' '-u' 'base_GHCziConcziIO_ensureIOManagerIsRunning_closure' '-u' 'base_GHCziConcziSync_runSparks_closure' '-u' 'base_GHCziConcziSignal_runHandlers_closure' link: done *** Deleting temp files: Deleting: /tmp/ghc4784_0/ghc4784_1.o /tmp/ghc4784_0/ghc4784_0.s /tmp/ghc4784_0/ghc4784_0.o /tmp/ghc4784_0/ghc4784_0.c *** Deleting temp dirs: Deleting: /tmp/ghc4784_0 |
从第一个
首先,简化器在几乎所有的阶段之间运行。这使得写许多通行证容易得多。例如,在实现许多优化时,它们只需创建重写规则来传播更改,而不必手动执行。简化器包含许多简单的优化,包括内联和融合。我知道这一点的主要局限性是,ghc拒绝内联递归函数,必须正确命名,以便Fusion工作。好的。
接下来,我们将看到执行的所有优化的完整列表:好的。
专业好的。
专门化的基本思想是通过识别调用函数的位置并创建非多态的函数版本来消除多态性和重载——它们是特定于调用函数的类型的。您还可以告诉编译器使用
SPECIALISE pragma执行此操作。以阶乘函数为例:好的。号
因为编译器不知道要使用的乘法的任何属性,所以它根本无法优化这个乘法。但是,如果它看到它在
Int 上使用,它现在可以创建一个新版本,只在类型上有所不同:好的。接下来,下面提到的规则可能会被触发,并且您最终会得到一些处理未绑定
Int s的方法,这比原始方法快得多。查看专业化的另一种方法是对类型类字典和类型变量的部分应用。好的。这里的源代码中有大量注释。好的。
浮动好的。
编辑:我之前显然误解了这一点。我的解释完全变了。好的。
其基本思想是将不应重复的计算移出函数。例如,假设我们有:好的。
1\x -> let y = expensive in x+y。
在上面的lambda中,每次调用函数时,都会重新计算
y 。浮动产生的一个更好的函数是好的。1let y = expensive in \x -> x+y为了方便这个过程,可以应用其他转换。例如,这种情况会发生:好的。
1
2
3
4\x -> x + f 2
\x -> x + let f_2 = f 2 in f_2
\x -> let f_2 = f 2 in x + f_2
let f_2 = f 2 in \x -> x + f_2。
同样,重复计算被保存。好的。
在这种情况下,源代码是非常可读的。好的。
目前,两个相邻的lambda之间的绑定没有浮动。例如,这种情况不会发生:好的。
1\x y -> let t = x+x in ...。
去好的。
1\x -> let t = x+x in \y -> ...向内浮动好的。
引用源代码,好的。
floatInwards 的主要目的是浮动到案例的分支中,这样我们就不会分配东西,将它们保存在堆栈中,然后发现在所选分支中不需要它们。好的。例如,假设我们有这样的表达式:好的。
1
2
3
4let x = big in
case v of
True -> x + 1
False -> 0如果
v 对False 进行评估,那么通过分配x ,这可能是一个很大的影响,我们浪费了时间和空间。向内浮动可修复此问题,产生此问题:好的。1
2
3case v of
True -> let x = big in x + 1
False -> let x = big in 0号
,随后被简化器替换为好的。
1
2
3case v of
True -> big + 1
False -> 0本文虽然涉及其他主题,但也作了较为清晰的介绍。请注意,尽管有它们的名称,但浮动输入和浮动输出不会进入无限循环,原因有两个:好的。
- float in float允许进入
case 语句,而float out处理函数。 - 传球顺序是固定的,所以不能无限交替。
好的。
需求分析好的。
需求分析,或者说严格性分析,不是一种转换,而是更多的信息收集过程,顾名思义。编译器查找总是计算其参数(或至少部分参数)的函数,并使用按值调用而不是按需要调用来传递这些参数。既然你能避开雷声的干扰,这通常会快得多。haskell中的许多性能问题都是由这个过程失败或者代码不够严格引起的。一个简单的例子是使用
foldr 、foldl 和foldl' 求和整数列表之间的差异:第一个导致堆栈溢出,第二个导致堆溢出,最后一个由于严格而运行良好。这可能是最容易理解和最好记录的所有这些。我相信多态性和CPS代码经常会击败这一点。好的。工作包装绑定好的。
Worker/Wrapper转换的基本思想是在一个简单的结构上做一个紧密的循环,在结构的两端进行转换。例如,以这个函数为例,它计算一个数的阶乘。好的。
。
根据GHC中
Int 的定义,我们得出好的。1
2
3
4注意到
I# 中如何包含代码?我们可以通过这样做来移除它们:好的。1
2
3
4
5
6。
尽管这个特定的示例也可以由specconstr完成,但是worker/wrapper转换在它可以做的事情中是非常普遍的。好的。
公共子表达式好的。
这是另一个非常简单的优化,非常有效,比如严格性分析。基本思想是,如果有两个相同的表达式,它们将具有相同的值。例如,如果
fib 是fibonacci数字计算器,cse将转换好的。1fib x + fib x。
进入之内好的。
1let fib_x = fib x in fib_x + fib_x把计算减半。不幸的是,这有时会妨碍其他优化。另一个问题是,两个表达式必须在同一个位置,并且它们在语法上必须相同,而不是在值上相同。例如,如果没有一堆内联,CSE将不会在以下代码中激发:好的。
1
2
3x = (1 + (2 + 3)) + ((1 + 2) + 3)
y = f x
z = g (f x) y号
但是,如果您通过llvm编译,由于它的全局值编号传递,您可能会得到其中一些组合。好的。
释放案例好的。
这似乎是一个非常有文档记录的转换,除了它可能导致代码爆炸之外。以下是我发现的小文档的重新格式化(并稍微重写)版本:好的。
该模块遍历
Core ,在自由变量上查找case 。条件是:如果在递归调用的路由上有一个自由变量上的case ,那么递归调用将替换为展开。例如,在好的。1f = \ t -> case v of V a b -> a : f t更换内
f 。使好的。1f = \ t -> case v of V a b -> a : (letrec f = \ t -> case v of V a b -> a : f t in f) t。
注意阴影的必要性。简化,我们得到好的。
1f = \ t -> case v of V a b -> a : (letrec f = \ t -> a : f t in f t)这是更好的代码,因为
a 在内部letrec 中是免费的,而不需要v 的投影。注意,这处理的是自由变量,与specconstr不同,specconstr处理的是已知形式的参数。好的。有关specconstr的更多信息,请参见下文。好的。
specconstr-这将转换类似好的。
1
2f (Left x) y = somthingComplicated1
f (Right x) y = somethingComplicated2。
进入之内好的。
1
2
3
4
5
6f_Left x y = somethingComplicated1
f_Right x y = somethingComplicated2
{-# INLINE f #-}
f (Left x) = f_Left x
f (Right x) = f_Right x。
作为一个扩展的例子,以
last 的定义为例:好的。我们先把它转换成好的。
1
2
3
4
5
6
7。
接下来,简化程序运行,我们有好的。
1
2
3
4
5
6
7请注意,该程序现在速度更快,因为我们不会重复装箱和拆箱列表的前面。还要注意,内联是至关重要的,因为它允许实际使用新的、更有效的定义,并使递归定义更好。好的。
specconstr由许多启发式方法控制。本文所述内容如下:好的。
- lambda是明确的,arity是
a 。 - 右手边"足够小",由旗子控制的东西。
- 函数是递归的,在右边使用了可专门化的调用。
- 函数的所有参数都存在。
- 至少有一个参数是构造函数应用程序。
- 这个论点在函数的某个地方进行了案例分析。
然而,启发式方法几乎肯定已经改变了。事实上,论文提到了第六种启发式的替代方案:好的。
仅当
这是一个非常小的文件(12行),因此可能不会触发许多优化(尽管我认为它完成了所有优化)。这也不能告诉你它为什么选择这些通行证,为什么把它们按顺序排列。好的。好啊。
懒惰好的。
它不是一个"编译器优化",但它是由语言规范所保证的,所以您可以一直依赖它的发生。从本质上来说,这意味着只有在对结果"做些什么"之后才能执行工作。(除非你做了几件事中的一件,故意让懒惰消失。)好的。
很明显,这是一个完整的主题,因此已经有了很多关于它的问题和答案。好的。
在我有限的经验中,使您的代码变得过于懒惰或过于严格比我要谈论的任何其他东西都要大得多的性能损失(在时间和空间上)。好的。
严格性分析好的。
懒惰是为了逃避工作,除非必要。如果编译器可以确定某个给定的结果"总是"需要,那么它就不需要存储计算并稍后执行;它只需要直接执行,因为这样更有效。这就是所谓的"严格性分析"。好的。
很明显,关键是编译器不能总是检测什么时候某些东西可以变得严格。有时需要给编译器一些提示。(我不知道有什么简单的方法可以确定严格性分析是否完成了您认为它所做的工作,除了深入了解核心输出。)好的。
内衬好的。
如果您调用一个函数,并且编译器可以告诉您调用的是哪个函数,那么它可能会尝试"内联"该函数——也就是说,用函数本身的副本替换函数调用。函数调用的开销通常很小,但内联通常会使其他优化发生,否则不会发生,因此内联可能是一个巨大的胜利。好的。
函数只有在"足够小"的情况下才是内联的(或者如果您添加了一个专门要求内联的pragma)。此外,只有当编译器能够告诉您正在调用的函数时,才能内联函数。有两种编译器无法分辨的主要方法:好的。
如果您正在调用的函数是从其他地方传入的。例如,在编译
filter 函数时,不能内联筛选器谓词,因为它是用户提供的参数。好的。如果您调用的函数是一个类方法,而编译器不知道所涉及的类型。例如,在编译
sum 函数时,编译器不能内联+ 函数,因为sum 使用几种不同的数字类型,每种类型都有不同的+ 函数。好的。
在后一种情况下,可以使用
不过,请注意,只有当编译器知道我们正在使用
公共子表达式消除好的。
如果某个代码块两次计算同一个值,编译器可以用同一计算的单个实例替换该值。例如,如果你这样做好的。
然后编译器可能会将其优化为好的。
1 |
号
您可能期望编译器总是这样做。但是,显然在某些情况下,这会导致性能下降,而不是更好,因此GHC并不总是这样做。坦率地说,我不太明白这件事背后的细节。但底线是,如果这个转换对您很重要,那么手动完成它并不困难。(如果这不重要,你为什么担心它?)好的。
大小写表达式好的。
考虑以下内容:好的。
1 2 3 4 | foo (0:_ ) ="zero" foo (1:_ ) ="one" foo (_:xs) = foo xs foo ( []) ="end" |
前三个等式都检查列表是否为非空(除其他外)。但是三次检查同样的事情是浪费的。幸运的是,编译器很容易将其优化为几个嵌套的case表达式。在这种情况下,好的。
1 2 3 4 5 6 7 8 | foo xs = case xs of y:ys -> case y of 0 ->"zero" 1 ->"one" _ -> foo ys [] ->"end" |
。
这不是很直观,但更有效。因为编译器可以很容易地进行这种转换,所以您不必担心它。只需以最直观的方式编写模式匹配;编译器非常擅长重新排序和重新排列,以使其尽可能快。好的。
融合好的。
列表处理的标准haskell习惯用法是将接受一个列表并生成新列表的函数链接在一起。典型的例子是好的。
不幸的是,尽管懒惰可以保证跳过不必要的工作,但是中间列表SAP性能的所有分配和释放都是如此。""融合"或"毁林"是编译器试图消除这些中间步骤的地方。好的。
问题是,这些函数大部分是递归的。如果没有递归,将所有的函数压缩成一个大的代码块、在上面运行简化器并生成没有中间列表的真正最优的代码将是内联中的一个基本练习。但是由于递归,这不起作用。好的。
您可以使用
1 | {-# RULES"map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-} |
。
现在,每当GHC看到
问题是,这只适用于
它的长短不一是:这些都是由程序员编写的特殊优化技巧。GHC本身对Fusion一无所知;它都在列表库和其他容器库中。因此,优化的发生取决于容器库的编写方式(或者更现实地说,取决于您选择使用的库)。好的。
例如,如果您使用Haskell'98数组,不要期望任何类型的融合。但是我知道
元:好的。
我同意人们所说的"代码第一,配置第二,优化第三"。好的。
我也同意人们所说的"对于一个给定的设计决策有多少成本有一个心理模型是有用的"。好的。
在所有事情上保持平衡…好的。好啊。
如果一个let绑定v=rhs只在一个地方使用,那么即使rhs很大,也可以指望编译器将其内联。
例外情况(在当前问题的背景下几乎不是这样)是兰伯斯冒着工作重复的风险。考虑:
1 2 3 |
因为一个(句法)用法将转化为99个额外的rhs评估,所以内联v是危险的。然而,在这种情况下,您也不太可能希望手动内联它。所以本质上你可以使用规则:
如果您考虑内嵌一个只出现一次的名称,编译器无论如何都会这样做。
作为一个好的推论,使用let绑定简单地分解一个长语句(希望获得清晰)本质上是免费的。
这是从community.haskell.org/~simonmar/papers/inline.pdf文件其中包含了更多关于内联的信息。