在存在一些递归案例时保持内联的可能性

时间:2017-02-14 09:58:55

标签: haskell optimization ghc

考虑以下数据类型,仅用于管理:

data D where
  D1 :: Int -> D
  D2 :: String -> D
  DJ :: D -> D -> D

也许是一个函数,比如说toString

{-# INLINE toString #-}
toString x = case x of
  (D1 x) -> "Int: show x"
  (D2 x) -> "String: show x"
  (DJ x y) -> "(" ++ toString x ++ "," ++ toString y ++ ")"

(值得注意的是我所做的与打印无关,这只是一个说明性的例子)

所以我发现通过这样定义toString会使我的程序快15倍:

{-# INLINE toString #-}
toString x = case x of
  (D1 x) -> "Int: show x"
  (D2 x) -> "String: show x"
  (DJ x y) -> undefined

发生的事情是toString现在能够被GHC内联。这允许在未来的道路上进行大量的优化。 DJ案例是造成问题的原因。所以我试过这个:

{-# INLINE toString #-}
toString x = case x of
  (D1 x) -> intShow x
  (D2 x) -> strShow x
  _ -> go x
  where
    go (D1 x) -> intShow x
    go (D2 x) -> strShow x
    go (DJ x y) -> "(" ++ go x ++ "," ++ go y ++ ")"
    intShow x = "Int: show x"
    strShow x = "String: show x"

这实际上意味着它可以快速编译。原因是(我很确定无论如何)因为toString不再是递归的。 go是,toString不是。因此,编译器将很乐意内联toString,从而允许更多优化。

但在我看来,上述代码很难看。

就像我说的,我所拥有的功能比这更复杂,这种问题在我的代码中都会出现。我有一个包含许多构造函数的数据类型,有些是简单的,有些是递归的。然而,每当我定义递归情况时,即使是简单的情况也会减慢速度。有没有办法保持顶级函数内联而不像我上面那样使用代码?

3 个答案:

答案 0 :(得分:7)

我没有优雅的解决方案,但也许这样的事情可行。未经测试。

{-# INLINE toString #-}
toString x = go (fix go)  -- equivalent to (fix go), but unrolled once
  where
  {-# INLINE go #-}
  go _ (D1 x) -> intShow x
  go _ (D2 x) -> strShow x
  go k (DJ x y) -> "(" ++ k x ++ "," ++ k y ++ ")"
  intShow x = "Int: show x"
  strShow x = "String: show x"

答案 1 :(得分:1)

我认为你已经正确地确定了这个问题以及必须采取哪些措施来解决这个问题,通常情况下(我同意并不满足):

  • 标记INLINE
  • eta fiddling,以便在呼叫站点完全应用左侧
  • 添加一个简单的间接层,以便该函数不是递归的

但是这样的事情应该足够我

{-# INLINE toString #-}
toString x = go x where
 go case x of
  (D1 x) -> "Int: show x"
  (D2 x) -> "String: show x"
  (DJ x y) -> "(" ++ go x ++ "," ++ go y ++ ")"

正如Chi在他们的回答中指出的那样,你在这里做的似乎是手动单级循环展开;很容易理解为什么这会更快,比如说:

(DJ (D1 0) (D2 "zero"))

但是,当你深深嵌套DJ时,这将会更加明显。我很想知道,并了解你是如何进行基准测试的。

大多数情况下,我们关心以这种方式内联,因为我们的x是多态的,我们希望我们在身体x上调用的函数是专用的。或者我们希望结果保持未装箱的类型。

答案 2 :(得分:1)

我已经给了@chi高于勾选标记的answer涉及fix并确实完成了这项工作。但是有点繁琐,因为在我的情况下,我的递归是多态的(fix单态),所以我不得不滚动自己的fix

我还担心通过传递递归参数而不是直接调用它可能会进一步混淆编译器的递归情况。

但是受@ chi的回答启发,并且认为我基本上想要两个函数相同,一个非递归函数和一个递归函数,我已经意识到我可以这样做,就像这样:

import Data.Proxy (Proxy(Proxy))

toString x = go' (Proxy :: Proxy True)
  {-# SPECIALISE INLINE go' :: Proxy True -> String #-}
  go' :: (Proxy a) -> String
  go' _ = case x of
    (D1 x) -> "Int: show x"
    (D2 x) -> "String: show x"
    (DJ x y) -> "(" ++ go x ++ "," ++ go y ++ ")"
  go = go' (Proxy :: Proxy False)

由于go'的特化,编译器将发出两个go'函数,一个用于Proxy参数为True时,另一个用于何时False { {1}}。

第一个,当它是True时,它不是递归的,它从不调用自身(它只调用False版本)。因此,如果我们对此进行专业化,它就是无法实现的。由于go' (True)不是递归的,toString也是如此,因为toString所做的只是调用go' (True),因此toString是可以进行的。

这种方法需要一些样板,但至少样板的长度是恒定的,它不会随着你需要处理的构造函数的数量而增加。