Sum 类型函数参数的 GHC 调用约定

GHC Calling Convention for Sum Type Function Arguments

GHC 在将求和类型传递给函数时会解压它们吗?例如,假设我们有以下类型:

data Foo
  = Foo1 {-# UNPACK #-} !Int {-# UNPACK #-} !Word
  | Foo2 {-# UNPACK #-} !Int
  | Foo3 {-# UNPACK #-} !Word

然后我定义了一个在其 Foo 参数中严格的函数:

consumeFoo :: Foo -> Int
consumeFoo x = case x of ...

在运行时,当我调用 consumeFoo 时,预计会发生什么? GHC calling convention 是在寄存器中传递参数(或者一旦有太多就在堆栈中传递)。我可以看到参数传递的两种方式:

  1. 指向堆上 Foo 的指针作为一个参数传入。
  2. 使用了 Foo 的三参数表示,一个参数表示所使用的数据构造函数,另外两个表示数据中可能的 IntWord 值构造函数。

我更喜欢第二种表示,但我不知道它是否真的发生了。我知道 UnpackedSumTypes 登陆 GHC 8.2,但不清楚它是否符合我的要求。如果我改为将函数编写为:

consumeFooAlt :: (# (# Int#, Word# #) | Int# | Word# #) -> Int

那么我希望评估 (2) 会发生什么。并且解包总和页面的 Unpacking section 表明我也可以这样做:

data Wrap = Wrap {-# UNPACK #-} !Foo
consumeFooAlt2 :: Wrap -> Int

而且我认为这也应该具有我想要的表示形式。

所以我的问题是,在不使用包装器类型或原始解压总和的情况下,当我将总和作为参数传递给函数时,如何保证总和解压到寄存器(或堆栈)中?如果可能的话,这是 GHC 8.0 已经可以做到的事情,还是只有 GHC 8.2 才能做到的事情?

首先:保证优化和 GHC 不能很好地结合。由于级别很高,很难预测 GHC 在每种情况下都会生成的代码。唯一可以确定的方法是查看核心。如果您正在使用 GHC 开发对性能极为依赖的应用程序,那么您需要熟悉 Core I。

我不知道 GHC 中有任何优化完全符合您的描述。这是一个示例程序:

module Test where

data Sum = A {-# UNPACK #-} !Int | B {-# UNPACK #-} !Int

consumeSum :: Sum -> Int
consumeSum x = case x of
  A y -> y + 1
  B y -> y + 2

{-# NOINLINE consumeSumNoinline #-}
consumeSumNoinline = consumeSum

{-# INLINE produceSumInline #-}
produceSumInline :: Int -> Sum
produceSumInline x = if x == 0 then A x else B x

{-# NOINLINE produceSumNoinline #-}
produceSumNoinline :: Int -> Sum
produceSumNoinline x = if x == 0 then A x else B x

test :: Int -> Int
--test x = consumeSum (produceSumInline x)
test x = consumeSumNoinline (produceSumNoinline x)

让我们先看看如果我们不内联 consumeSumproduceSum 会发生什么。这是核心:

test :: Int -> Int
test = \ (x :: Int) -> consumeSumNoinline (produceSumNoinline x)

(用ghc-core test.hs -- -dsuppress-unfoldings -dsuppress-idinfo -dsuppress-module-prefixes -dsuppress-uniques制作)

在这里,我们可以看到 GHC(在本例中为 8.0)没有拆箱作为函数参数传递的总和类型。如果我们内联 consumeSumproduceSum.

则没有任何变化

但是,如果我们内联两者,则会生成以下代码:

test :: Int -> Int
test =
  \ (x :: Int) ->
    case x of _ { I# x1 ->
    case x1 of wild1 {
      __DEFAULT -> I# (+# wild1 2#);
      0# -> lvl1
    }
    }

这里发生的事情是,通过内联,GHC 最终得到:

\x -> case (if x == 0 then A x else B x) of
   A y -> y + 1
   B y -> y + 2

通过case-of-case(if只是一个特殊的case)变成:

\x -> if x == 0 then case (A x) of ...  else case (B x) of ...

现在这是一个已知构造函数的情况,因此 GHC 可以在编译时减少这种情况,最终以:

\x -> if x == 0 then x + 1 else x + 2

所以它完全消除了构造函数。


综上所述,我认为 GHC 在 8.2 版本之前没有任何 "unboxed sum" 类型的概念,这也适用于函数参数。获得"unboxed"和的唯一方法是通过内联完全消除构造函数。

如果您需要这样的优化,最简单的解决方案就是自己动手。 我认为实际上有很多方法可以实现这一点,但一个是:

data Which = Left | Right | Both
data Foo = Foo Which Int Word

这种类型的任何字段的解包与 'shape of the representation' 的问题完全无关,这才是您真正要问的问题。枚举已经高度优化——每个构造函数只创建一个值——所以添加这个字段不会影响性能。这种类型的解压缩表示正是您想要的 - Which 构造函数一个词,每个字段一个词。

如果您以正确的方式编写函数,您将获得正确的代码:

data Which = Lft | Rgt | Both
data Foo = Foo Which {-# UNPACK #-} !Int {-# UNPACK #-} !Word 

consumeFoo :: Foo -> Int 
consumeFoo (Foo w l r) = 
  case w of 
    Lft  -> l 
    Rgt  -> fromIntegral r 
    Both -> l + fromIntegral r 

生成的内核比较明显:

consumeFoo :: Foo -> Int
consumeFoo =
  \ (ds :: Foo) ->
    case ds of _ { Foo w dt dt1 ->
    case w of _ {
      Lft -> I# dt;
      Rgt -> I# (word2Int# dt1);
      Both -> I# (+# dt (word2Int# dt1))
    }
    }

但是,对于简单的程序,例如:

consumeFoos = foldl' (+) 0 . map consumeFoo 

此优化没有任何区别。如另一个答案所示,内部函数 consumeFoo 只是内联的:

Rec {
$wgo :: [Foo] -> Int# -> Int#
$wgo =
  \ (w :: [Foo]) (ww :: Int#) ->
    case w of _ {
      [] -> ww;
      : y ys ->
        case y of _ {
          Lft dt -> $wgo ys (+# ww dt);
          Rgt dt -> $wgo ys (+# ww (word2Int# dt));
          Both dt dt1 -> $wgo ys (+# ww (+# dt (word2Int# dt1)))
        }
    }
end Rec }

对比

Rec {
$wgo :: [Foo] -> Int# -> Int#
$wgo =
  \ (w :: [Foo]) (ww :: Int#) ->
    case w of _ {
      [] -> ww;
      : y ys ->
        case y of _ { Foo w1 dt dt1 ->
        case w1 of _ {
          Lft -> $wgo ys (+# ww dt);
          Rgt -> $wgo ys (+# ww (word2Int# dt1));
          Both -> $wgo ys (+# ww (+# dt (word2Int# dt1)))
        }
        }
    }
end Rec }

在几乎所有情况下,在处理低级、未打包的数据时,无论如何,这都是目标,因为您的大多数函数都很小,内联成本很小。