正如其他人所说,您基本上回答了自己的问题。但我想你可能想要一个更精简和具体的例子,说明在哪里使用相位控制和RULES/INLINE 是有益的。* 除了通常很复杂的高度优化的库之外,你看不到它们,所以它是很高兴看到较小的案例。
这是我最近使用递归方案实现的示例。我们将使用变质的概念来说明这一点。您不需要详细了解这些内容,只需知道它们是“折叠”运算符的特征。 (真的,这里不要过多关注抽象概念。这只是我拥有的最简单的示例,您可以在其中获得很好的加速。)
变质快速介绍
我们从定点类型Mu 和Algebra 的定义开始,这只是一个“解构”f a 值以返回a 的函数的奇特同义词。
newtype Mu f = Mu { muF :: f (Mu f) }
type Algebra f a = f a -> a
我们现在可以定义两个运算符,ffold 和 fbuild,它们是用于列表的传统 foldr 和 build 运算符的高度通用版本:
ffold :: Functor f => Algebra f a -> Mu f -> a
ffold h = go h
where go g = g . fmap (go g) . muF
{-# INLINE ffold #-}
fbuild :: Functor f => (forall b. Algebra f b -> b) -> Mu f
fbuild g = g Mu
{-# INLINE fbuild #-}
粗略地说,ffold 破坏由Algebra f a 定义的结构并产生a。 fbuild 改为 创建 由其 Algebra f a 定义的结构并产生 Mu 值。 Mu 值对应于您正在谈论的任何递归数据类型。就像普通的 foldr 和 build 一样:我们使用它的 cons 解构一个列表,我们也使用它的 cons 构建一个列表。我们的想法是我们刚刚推广了这些经典运算符,因此它们可以处理任何递归数据类型(如列表或树!)
最后,有一个伴随这两个运算符的规律,它将指导我们的整体RULE:
forall f g. ffold f (build g) = g f
这条规则基本上概括了森林砍伐/融合的优化 - 去除中间结构。 (我想该定律的正确性证明留给读者作为练习。通过等式推理应该很容易。)
我们现在可以使用这两个组合子以及Mu 来表示递归数据类型,如列表。我们可以在该列表上编写操作。
data ListF a f = Nil | Cons a f
deriving (Eq, Show, Functor)
type List a = Mu (ListF a)
instance Eq a => Eq (List a) where
(Mu f) == (Mu g) = f == g
lengthL :: List a -> Int
lengthL = ffold g
where g Nil = 0
g (Cons _ f) = 1 + f
{-# INLINE lengthL #-}
我们也可以定义一个map 函数:
mapL :: (a -> b) -> List a -> List b
mapL f = ffold g
where g Nil = Mu Nil
g (Cons a x) = Mu (Cons (f a) x)
{-# INLINE mapL #-}
内联 FTW
我们现在有一种方法可以在我们定义的这些递归类型上编写术语。但是,如果我们要写一个像
这样的术语
lengthL . mapL (+1) $ xs
然后,如果我们扩展定义,我们基本上得到了两个ffold 运算符的组合:
ffold g1 . ffold g2 $ ...
这意味着我们实际上是在破坏结构,然后重建它并再次破坏。真是太浪费了。另外,我们可以将mapL重新定义为fbuild,希望能与其他功能融合。
好吧,我们已经有了我们的法律,所以RULE 是合适的。让我们编码:
{-# RULES
-- Builder rule for catamorphisms
"ffold/fbuild" forall f (g :: forall b. Algebra f b -> b).
ffold f (fbuild g) = g f
-}
接下来,我们将根据 fbuild 重新定义 mapL 以进行融合:
mapL2 :: (a -> b) -> List a -> List b
mapL2 f xs = fbuild (\h -> ffold (h . g) xs)
where g Nil = Nil
g (Cons a x) = Cons (f a) x
{-# INLINE mapL2 #-}
啊啊啊,我们完成了,对吧?错了!
乐趣和利润的阶段
问题是内联发生时的约束为零,这会完全搞砸。考虑之前我们想要优化的案例:
lengthL . mapL2 (+1) $ xs
我们希望内联lengthL 和mapL2 的定义,以便ffold/fbuild 规则可以在正文中触发后缀。所以我们想去:
ffold f1 . fbuild g1 ...
通过内联,然后转到:
g1 f1
通过我们的RULE。
嗯,这不能保证。本质上,在简化器的一个阶段,GHC 可能不会仅内联lengthL 和mapL 的定义,但它也可能在使用时内联ffold 和fbuild 的定义网站。这意味着 RULE 永远不会有机会触发,因为阶段“吞噬”了所有相关标识符,并将它们内联成空。
观察结果是我们希望尽可能晚地内联ffold 和fbuild。因此,我们将尝试尽可能多地暴露我们的 RULE 触发的机会。如果不这样做,那么身体就会内联,而 GHC 仍然会尽力而为。但最终,我们希望它延迟内联; RULE 将比任何聪明的编译器优化节省我们更多的效率。
所以这里的解决方法是注释 ffold 和 fbuild 并指定它们应该只在阶段 1 触发:
ffold g = ...
{-# INLINE[1] ffold #-}
fbuild g = ...
{-# INLINE[1] fbuild #-}
现在,mapL 和朋友们会很早就内联,但这些会很晚。 GHC 从某个阶段数 N 开始,阶段数减少到零。阶段 1 是最后一个阶段。也可以在第 1 阶段之前内联fbuild/ffold,但这基本上意味着您需要开始增加阶段的数量来弥补它,或者开始确保 RULE 总是在一些早期阶段触发。
结论
您可以在此处找到all of this and more in a gist of mine**,其中包含所有提到的定义和示例。它还附带了我们示例的标准基准:当RULE 触发时,GHC 能够将lengthL . mapL2 的运行时间比lengthL . mapL1 减少一半。
如果您想亲自查看,可以使用-ddump-simpl-stats 编译代码,并查看在编译管道期间触发了ffold/fbuild 规则。
最后,大多数相同的原则适用于向量或字节串等库。诀窍是您可能在这里有多个级别的内联,以及 很多 更多规则。这是因为流/数组融合之类的技术倾向于有效地融合循环和重用数组——与这里相反,我们只是通过删除中间数据结构来进行经典的森林砍伐。根据生成的代码的传统“模式”(例如,由于矢量化的并行列表理解),以一种较早消除明显缺陷的方式进行交错或专门的相位优化可能非常值得。或者,针对 RULE 与 INLINE 组合会产生更多 RULEs 的情况进行优化(因此有时您会看到交错的阶段 - 这基本上会交织一个内联阶段。)出于这些原因,您可以还可以控制RULE 触发的阶段。
因此,虽然 RULEs 带有阶段可以为我们节省大量运行时间,但它们也可能需要大量时间才能正确处理。这就是为什么您经常只在最“高性能”、高度优化的库中看到它们的原因。
注意事项
* 您最初的问题是“哪些类型的函数受益于相位控制”,在我看来,这听起来像是在问“哪些函数受益于恒定的子表达式消除”。如果可能的话,我不确定如何准确回答这个问题!这更像是编译器领域的事情,而不是任何关于函数或程序行为的理论结果——即使 数学定律,并非所有“优化”都有你期望的结果。因此,答案实际上是“您可能会在编写和基准测试时知道。”
** 您可以放心地忽略文件中的许多其他内容;它主要是一个游乐场,但你也可能很有趣。那里还有其他示例,例如自然树和二叉树 - 您可能会发现尝试利用各种其他融合机会是值得的。