【问题标题】:How to use phase control of inlining in haskell?如何在haskell中使用内联的相位控制?
【发布时间】:2013-01-04 23:08:05
【问题描述】:

文档says

有时您希望准确控制 GHC 管道中何时打开 INLINE pragma。

我为什么要这样? (除了当我也使用 RULES pragma 时,在这种情况下,我可能想推迟函数的内联,以便触发关联的规则。)仅在简化过程的特定阶段内联哪种函数更好?

【问题讨论】:

  • 当你想要它时,你几乎可以描述它
  • repa源码:主要部分函数有不同的相位控制号:0、1、2、4。但是包里没有RULES。
  • @leventov: repa 可能没有定义规则,但它基于vector,这是肯定的。不过乍一看并不完全清楚,因为vector 来源也严重依赖于 CPP。无论如何,repas 相位控制数字已调整为与vector 使用的规则和内联进行交互。
  • +1 @luqui。您已经正确推断出,只有在您希望在内联发生之前有机会触发 RULES 时,它才真正有用。

标签: performance haskell ghc inlining repa


【解决方案1】:

首先,我应该指出,GHC 的默认行为被设计为在大多数情况下都是最佳的。除非你有问题,否则你最好让那些每天都在思考 haskell 的聪明人基本上是正确的(PS 我 不是 那些人之一),但你问...

据我了解,使用它有两个原因。

  1. 使程序更快收敛到最佳形式:

    Haskell 会反复尝试每条规则,只要另一端的结果严格优于它开始时的结果。它总是会收敛,但没有什么说它会在宇宙热寂之前这样做。在常见的情况下,只需要一手传球就可以了,但也有一些极端的情况可能会变得非常糟糕。如果它们发生,这将允许您手动解决这些边缘情况。

  2. 避免收敛到局部最小值

    在某些情况下,应用规则 A 会阻止应用更好的规则 B。那么重要的是B 位于A 之前。默认优化规则精心设计以避免此问题,但正如文档所述,它们也非常保守。随着您添加更多规则,您将不可避免地开始破坏其他可能的优化。然后,您需要在规则链中找到一个不会发生这种情况的地方。据我所知,唯一的判断方法是反复试验。

【讨论】:

    【解决方案2】:

    正如其他人所说,您基本上回答了自己的问题。但我想你可能想要一个更精简和具​​体的例子,说明在哪里使用相位控制和RULES/INLINE 是有益的。* 除了通常很复杂的高度优化的库之外,你看不到它们,所以它是很高兴看到较小的案例。

    这是我最近使用递归方案实现的示例。我们将使用变质的概念来说明这一点。您不需要详细了解这些内容,只需知道它们是“折叠”运算符的特征。 (真的,这里不要过多关注抽象概念。这只是我拥有的最简单的示例,您可以在其中获得很好的加速。)

    变质快速介绍

    我们从定点类型MuAlgebra 的定义开始,这只是一个“解构”f a 值以返回a 的函数的奇特同义词。

    newtype Mu f = Mu { muF :: f (Mu f) }
    
    type Algebra f a = f a -> a
    

    我们现在可以定义两个运算符,ffoldfbuild,它们是用于列表的传统 foldrbuild 运算符的高度通用版本:

    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 定义的结构并产生afbuild 改为 创建 由其 Algebra f a 定义的结构并产生 Mu 值。 Mu 值对应于您正在谈论的任何递归数据类型。就像普通的 foldrbuild 一样:我们使用它的 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
    

    我们希望内联lengthLmapL2 的定义,以便ffold/fbuild 规则可以在正文中触发后缀。所以我们想去:

    ffold f1 . fbuild g1 ...
    

    通过内联,然后转到:

    g1 f1
    

    通过我们的RULE

    嗯,这不能保证。本质上,在简化器的一个阶段,GHC 可能不会内联lengthLmapL 的定义,但它也可能在使用时内联ffoldfbuild 的定义网站。这意味着 RULE 永远不会有机会触发,因为阶段“吞噬”了所有相关标识符,并将它们内联成空。

    观察结果是我们希望尽可能晚地内联ffoldfbuild。因此,我们将尝试尽可能多地暴露我们的 RULE 触发的机会。如果不这样做,那么身体就会内联,而 GHC 仍然会尽力而为。但最终,我们希望它延迟内联; RULE 将比任何聪明的编译器优化节省我们更多的效率。

    所以这里的解决方法是注释 ffoldfbuild 并指定它们应该只在阶段 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 规则。

    最后,大多数相同的原则适用于向量或字节串等库。诀窍是您可能在这里有多个级别的内联,以及 很多 更多规则。这是因为流/数组融合之类的技术倾向于有效地融合循环和重用数组——与这里相反,我们只是通过删除中间数据结构来进行经典的森林砍伐。根据生成的代码的传统“模式”(例如,由于矢量化的并行列表理解),以一种较早消除明显缺陷的方式进行交错或专门的相位优化可能非常值得。或者,针对 RULEINLINE 组合会产生更多 RULEs 的情况进行优化(因此有时您会看到交错的阶段 - 这基本上会交织一个内联阶段。)出于这些原因,您可以还可以控制RULE 触发的阶段。

    因此,虽然 RULEs 带有阶段可以为我们节省大量运行时间,但它们也可能需要大量时间才能正确处理。这就是为什么您经常只在最“高性能”、高度优化的库中看到它们的原因。

    注意事项

    • * 您最初的问题是“哪些类型的函数受益于相位控制”,在我看来,这听起来像是在问“哪些函数受益于恒定的子表达式消除”。如果可能的话,我不确定如何准确回答这个问题!这更像是编译器领域的事情,而不是任何关于函数或程序行为的理论结果——即使 数学定律,并非所有“优化”都有你期望的结果。因此,答案实际上是“您可能会在编写和基准测试时知道。”

    • ** 您可以放心地忽略文件中的许多其他内容;它主要是一个游乐场,但你也可能很有趣。那里还有其他示例,例如自然树和二叉树 - 您可能会发现尝试利用各种其他融合机会是值得的。

    【讨论】:

      猜你喜欢
      • 2011-10-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-12-26
      • 2016-02-19
      • 2015-08-29
      • 2015-12-03
      • 1970-01-01
      相关资源
      最近更新 更多