为什么将数据重构为newtype会加速我的Haskell程序?

7

我有一个程序,它遍历一个执行概率分布代数的表达式树,可以进行采样或计算结果分布。

我有两个实现方法来计算分布:一个(`computeDistribution`)可以很好地重复使用monad transformer,另一个(`simpleDistribution`)需要手动具体化。我不想手动具体化所有内容,因为这会导致采样和计算代码之间存在代码复制。

我还有两个数据表示:

type Measure a = [(a, Rational)]
-- data Distribution a = Distribution (Measure a) deriving Show
newtype Distribution a = Distribution (Measure a) deriving Show

当我在可重用代码中使用 data 版本来计算 20d2 的分布时(ghc -O3 program.hs; time ./program 20 > /dev/null),需要大约1秒钟的时间,这似乎太长了。如果选择更高的 n 值,那就要冒险了。
当我使用手动具体化的代码,或者使用 newtype 表示与任一实现一起,计算 20d2 (time ./program 20 s > /dev/null) 就会很快。
为什么?
如何找出原因?
我对 Haskell 执行的了解几乎为零。我知道基本上有一个懒加载图形,与程序基本相同的形状,但这就是我所知道的全部。
我认为使用 newtypeDistribution 的表示方式与 Measure 相同,即它只是一个列表,而使用 data 版本时,每个 Distribution 有点像一个单字段记录,除了指向包含的列表的指针外,因此 data 版本必须执行更多的分配。这是真的吗?如果是真的,这足以解释性能差异吗?
我刚开始使用单子转换器栈。考虑到 simpleDistribution 中的 LetUniform 的情况,它们是否与基于 walkTree 的实现相同?我怎么判断呢?
这是我的程序。请注意,Uniform n 对应于掷一个 n 面骰子(如果一元性质令人惊讶,则忽略)。 更新: 基于评论,我简化了我的程序,删除了所有不会对性能差距产生贡献的东西。我进行了两个语义上的更改:概率现在被非规范化,并且都是错的,简化步骤消失了。但是我的程序的基本形状仍然存在。(查看问题编辑历史记录以获取非简化程序)。 更新 2: 我进一步简化了,将 Distribution 减少到列表单子并加入一点小调整,移除了所有与概率有关的内容,并缩短了名称。当使用 data 而不是 newtype 时,我仍然观察到很大的性能差异。
import Control.Monad (liftM2)
import Control.Monad.Trans (lift)
import Control.Monad.Reader (ReaderT, runReaderT)
import System.Environment (getArgs)
import Text.Read (readMaybe)

main = do
  args <- getArgs
  let dieCount = case map readMaybe args of Just n : _ -> n; _ -> 10
  let f = if ["s"] == (take 1 $ drop 1 $ args) then fast else slow
  print $ f dieCount

fast, slow :: Int -> P Integer
fast n = walkTree n
slow n = walkTree n `runReaderT` ()

walkTree 0 = uniform
walkTree n = liftM2 (+) (walkTree 0) (walkTree $ n - 1)

data P a = P [a] deriving Show
-- newtype P a = P [a] deriving Show

class Monad m => MonadP m where uniform :: m Integer
instance MonadP P where uniform = P [1, 1]
instance MonadP p => MonadP (ReaderT env p) where uniform = lift uniform

instance Functor P where fmap f (P pxs) = P $ fmap f pxs

instance Applicative P where
  pure x = P [x]
  (P pfs) <*> (P pxs) = P $ pfs <*> pxs

instance Monad P where
  (P pxs) >>= f = P $ do
    x <- pxs
    case f x of P fxs -> fxs


2
这是微妙的,特别是当你周围可能有thunks时。比较test_1 = if expensiveTest then K value1 else K value2test_2 = K (if expensiveTest then value1 else value2)。如果我们执行case test_i of K x -> ...,那么当i=2或者i=1且K是一个newtype构造器时,expensiveTest不会立即计算。当i=1且K是一个数据构造器时,expensiveTest会立即计算。对于newtypes,构造和模式匹配始终是零成本的no-ops,与data不同。 - chi
有趣。如果 i = 1,而 K 不是 newtype,我们为什么要进行“expensiveTest”?在我的程序中有一些昂贵的测试吗?有没有一种跟踪程序执行以验证这是否确实是性能差异原因的方法?您能指引我查看有关Haskell如何执行的详细信息以便更全面地了解您的观点吗?了解一个事实很好,但将其与其他事实联系起来才是您的重点所在,我认为。 - Jonas Kölker
2
在GHCi中尝试运行case K (error "this was evaluated") of K x -> "not using x":由于x未被评估,因此不会打印错误,无论是新类型还是数据类型。相反,尝试case error "this might be evaluated" of K x -> "not using x":这对于数据类型会出错,但对于新类型则不会。新类型始终是无操作的,而匹配数据构造函数会评估表达式。我不知道你的程序,它太复杂了,无法立即发现问题,但这就是通常影响性能的数据类型和新类型之间的区别。 - chi
1个回答

3

我怎样才能找到原因?

总的来说,这很困难。

一种极端的方法是查看核心代码(可以通过使用 -ddump-simpl 运行 GHC 来生成)。这可能会变得非常复杂,并且它基本上是一个全新的语言需要学习。您的程序已经足够大了,以至于我在从核心转储中学到很多东西时遇到了麻烦。

另一种找出原因的方法是继续使用 GHC 并提出问题并学习 GHC 优化,直到您认识到某些模式为止。

为什么?

简而言之,我认为这是由于列表融合。

注意:我不确定这个答案是否正确,而且要验证它需要更多的时间和工作量,而我现在不愿意投入这些。尽管如此,它符合证据。

首先,我们可以通过在没有优化的情况下运行O0来检查您所看到的减速是否是由基本原理引起的,还是触发了GHC优化。在此模式下,两种Distribution表示都会导致大约相同(非常长)的运行时间。这使我相信,问题并不在于数据表示本身,而是使用newtype版本时触发了一种优化,而使用data版本时则没有。

当 GHC 在 -O1 或更高级别下运行时,它会启用某些重写规则来将列表的不同折叠和映射“融合”在一起,以便它不需要分配中间值。(请参见 https://markkarpov.com/tutorial/ghc-optimization-and-fusion.html#fusion 以获取有关此概念的不错教程,以及 https://dev59.com/h1kT5IYBdhLWcg3wN8-W#38910170,其中还有一个链接到 base 中所有重写规则的要点。)由于 computeDistribution 基本上只是一堆列表操作(这些操作本质上都是折叠),因此这些规则有可能触发。
关键在于使用 Distributionnewtype 表示时,在编译期间会擦除 newtype 包装器,并允许列表操作融合。然而,使用 data 表示时,包装器不会被擦除,重写规则也不会触发。
因此,我会提出一个未经证实的说法:如果你希望你的data表示与newtype一样快,那么你需要设置类似于列表折叠的重写规则,但是这些规则适用于Distribution类型。这可能涉及编写自己的特殊fold函数,然后重写你的Functor/Applicative/Monad实例来使用它们。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接