[英]Why does refactoring data to newtype speed up my haskell program?
我有一個程序,它遍歷一個表達式樹,它對概率分布進行代數,采樣或計算結果分布。
我有兩種計算分布的實現:一種( computeDistribution
)可以很好地與 monad 轉換器重用,另一種( 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
) 的分布大約需要一秒鍾,這似乎太長了。 選擇更高的n
值,后果自負。
當我使用手動具體化的代碼,或者我在任一實現中使用newtype
表示時,計算 20d2 ( time ./program 20 s > /dev/null
) 眨眼之間。
為什么?
我怎樣才能找出原因?
我對 Haskell 如何執行的知識幾乎為零。 我收集了一個與程序基本相同形狀的 thunk 圖,但這就是我所知道的。
我認為使用newtype
的Distribution
的表示與Measure
的表示相同,即它只是一個列表,而對於data
版本,每個Distribution
有點像一個單字段記錄,除了指向包含列表的指針,所以data
版本必須執行更多分配。 這是真的? 如果屬實,這足以解釋性能差異嗎?
我是使用 monad 變壓器堆棧的新手。 考慮simpleDistribution
中的Let
和Uniform
案例——它們是否與基於walkTree
的實現相同? 我該怎么說?
這是我的程序。 請注意, Uniform n
對應於滾動 n 面骰子(以防一元性令人驚訝)。
更新:根據評論,我通過刪除不影響性能差距的所有內容來簡化我的程序。 我做了兩個語義上的改變:概率現在是非規范化的,而且都是不穩定的和錯誤的,簡化步驟已經消失了。 但是我的程序的基本形狀仍然存在。 (請參閱非簡化程序的問題編輯歷史。)
更新 2 :我做了進一步的簡化,將Distribution
減少到 list monad 並稍作改動,刪除了與概率有關的所有內容,並縮短了名稱。 使用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
我怎樣才能找出原因?
一般來說,這很難。
做到這一點的極端方法是查看核心代碼(您可以通過使用-ddump-simpl
運行 GHC 來生成這些代碼)。 這很快就會變得復雜,它基本上是一門全新的語言來學習。 您的程序已經足夠大,以至於我無法從核心轉儲中學到很多東西。
找出原因的另一種方法是繼續使用 GHC 並提出問題並學習 GHC 優化,直到您認識到某些模式。
為什么?
簡而言之,我相信這是由於列表融合。
注意:我不確定這個答案是否正確,並且需要比我現在願意投入更多的時間/工作來驗證。 也就是說,它符合證據。
首先,我們可以通過在O0
中運行(即沒有優化)來檢查您看到的這種減速是否是真正基本的與 GHC 優化觸發的結果。 在這種模式下,兩種Distribution
表示都會產生大致相同(非常長)的運行時間。 這讓我相信,問題本質上不是數據表示,而是由新類型版本觸發的newtype
,而不是data
版本。
當 GHC 在-O1
或更高版本中運行時,它會使用某些重寫規則來將不同的折疊和列表映射融合在一起,這樣它就不需要分配中間值。 (請參閱https://markkarpov.com/tutorial/ghc-optimization-and-fusion.html#fusion以獲取有關此概念的體面教程以及https://stackoverflow.com/a/38910170/14802384 ,其中還有一個鏈接到包含所有重寫規則的要點base
。)由於computeDistribution
基本上只是一堆列表操作(本質上都是折疊),因此這些操作有可能被觸發。
關鍵是使用Distribution
的newtype
表示,newtype 包裝器在編譯期間被擦除,並且允許列表操作融合。 但是,使用data
表示,不會刪除包裝器,並且不會觸發重寫規則。
因此,我將提出一個未經證實的主張:如果您希望您的data
表示與新類型一樣newtype
,您將需要設置類似於列表折疊但適用於Distribution
類型的重寫規則。 這可能涉及編寫您自己的特殊折疊函數,然后重寫您的 Functor/Applicative/Monad 實例以使用它們。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.