将函数移至Lib模块后Haskell程序运行时长激增的原因及解决
Haskell跨模块迁移后性能暴跌的原因与解决方法
问题描述
我用stack new创建了一个Haskell模板项目,初始时area与sumOfAreas函数位于Main模块,在我的硬件上平均运行时长为100ms。将这两个函数迁移至Lib模块后,程序运行时长骤增至约1000ms。
项目代码与配置
初始代码(Main模块)
module Main where import Lib import Data.Massiv.Array (Sz (..), Vector) import qualified Data.Massiv.Array as A import Data.Time.Clock.POSIX t mul = round . (mul *) <$> getPOSIXTime timeMillis = t 1000 area :: BBox -> Double area (BBox (x1, y1) (x2, y2)) = (x2 - x1) * (y2 - y1) sumOfAreas :: Vector A.DS BBox -> Double sumOfAreas bboxes = A.sfoldl (\acc box -> acc + area box) 0 bboxes main :: IO () main = do start <- timeMillis putStrLn $ "Start " ++ show start let bboxes = A.makeArray A.Seq (Sz 10000 * 10000) $ (\_ -> BBox (0, 0) (12, 12)) :: Vector A.DS BBox result = sumOfAreas bboxes putStrLn $ show result end <- timeMillis putStrLn $ show (end - start)
初始代码(Lib模块)
module Lib where import Data.Massiv.Array (Vector) import qualified Data.Massiv.Array as A type Point = (Double, Double) data BBox = BBox (Point) (Point) deriving (Eq, Show)
迁移后代码(Main模块)
module Main where import Lib import Data.Massiv.Array (Sz (..), Vector) import qualified Data.Massiv.Array as A import Data.Time.Clock.POSIX t mul = round . (mul *) <$> getPOSIXTime timeMillis = t 1000 main :: IO () main = do start <- timeMillis putStrLn $ "Start " ++ show start let bboxes = A.makeArray A.Seq (Sz 10000 * 10000) $ (\_ -> BBox (0, 0) (12, 12)) :: Vector A.DS BBox result = sumOfAreas bboxes putStrLn $ show result end <- timeMillis putStrLn $ show (end - start)
迁移后代码(Lib模块)
module Lib where import Data.Massiv.Array (Vector) import qualified Data.Massiv.Array as A type Point = (Double, Double) data BBox = BBox (Point) (Point) deriving (Eq, Show) area :: BBox -> Double area (BBox (x1, y1) (x2, y2)) = (x2 - x1) * (y2 - y1) sumOfAreas :: Vector A.DS BBox -> Double sumOfAreas bboxes = A.sfoldl (\acc box -> acc + area box) 0 bboxes
Cabal配置文件
cabal-version: 1.12 -- This file has been generated from package.yaml by hpack version 0.35.0. -- -- see: https://github.com/sol/hpack -- ... info ... source-repository head type: git location: https://github.com/githubuser/profiling library exposed-modules: Lib other-modules: Paths_profiling hs-source-dirs: src ghc-options: -Wall -threaded -O3 build-depends: base >=4.7 && <5 , massiv ==1.0.1.1 , vector ==0.12.3.1 , array ==0.5.4.0 , time ==1.9.3 default-language: Haskell2010 executable profiling-exe main-is: Main.hs other-modules: Paths_profiling hs-source-dirs: app ghc-options: -Wall -threaded -O3 build-depends: base >=4.7 && <5 , massiv ==1.0.1.1 , vector ==0.12.3.1 , array ==0.5.4.0 , time ==1.9.3 , profiling default-language: Haskell2010 test-suite profiling-test type: exitcode-stdio-1.0 main-is: Spec.hs other-modules: Paths_profiling hs-source-dirs: test ghc-options: -Wall -threaded -O3 build-depends: base >=4.7 && <5 , profiling default-language: Haskell2010
环境信息
- Stack版本:
Version 2.9.1, Git revision 409d56031b4240221d656db09b2ba476fe6bb5b1 x86_64 hpack-0.35.0
- GHC版本:9.2.4
原因分析
核心问题是跨模块函数内联被GHC优化器阻止:
- 当
area和sumOfAreas在Main模块时,GHC可以直接将area内联到sumOfAreas的折叠逻辑中,甚至进一步做常量折叠(所有BBox的面积都是固定的144,1亿个的总和是1.44×10¹⁰),几乎不需要实际遍历计算,所以运行极快。 - 迁移到Lib模块后,默认情况下GHC不会跨模块自动内联普通函数,
sumOfAreas里的匿名lambda无法直接嵌入area的逻辑,导致每次循环都要做一次函数调用,同时常量折叠优化也失效,最终性能暴跌10倍。
解决方法
方法1:显式添加内联标记
在Lib模块的area和sumOfAreas函数前加上INLINE编译指示,强制GHC跨模块内联这些函数:
module Lib where import Data.Massiv.Array (Vector) import qualified Data.Massiv.Array as A type Point = (Double, Double) data BBox = BBox (Point) (Point) deriving (Eq, Show) {-# INLINE area #-} area :: BBox -> Double area (BBox (x1, y1) (x2, y2)) = (x2 - x1) * (y2 - y1) {-# INLINE sumOfAreas #-} sumOfAreas :: Vector A.DS BBox -> Double sumOfAreas bboxes = A.sfoldl (\acc box -> acc + area box) 0 bboxes
方法2:调整GHC跨模块优化参数
在Cabal配置的library部分添加更激进的内联参数,提升跨模块内联的阈值:
library # ... 其他配置不变 ... ghc-options: -Wall -threaded -O3 -funfolding-use-threshold=100 -funfolding-keeness-factor=100
不过显式添加INLINE标记的可靠性更高,不会影响其他函数的优化策略。
方法3:针对当前案例的特殊优化
由于所有BBox的面积都是常量,可以直接在数组生成阶段计算面积,完全避免后续的折叠循环:
-- 在Main模块或Lib模块中修改 sumOfAreas :: Int -> Double sumOfAreas count = fromIntegral count * 144.0 -- main函数中直接使用 result = sumOfAreas (10000 * 10000)
这是针对当前场景的最优解,但仅适用于计算逻辑固定的情况。
忽略的关键要点
- 跨模块优化的保守性:GHC默认不会跨模块内联普通函数,必须显式标记或调整编译参数才能开启跨模块激进优化。
- 同模块优化的优势:同一模块内的函数可以被GHC做更多融合、常量折叠、循环展开等优化,跨模块时这些优化会被大幅限制。
- 性能敏感函数的标记:对于高频调用的小函数(如
area这种简单计算函数),一定要显式添加INLINE标记,确保优化器能将逻辑嵌入到调用点,避免函数调用开销。
内容的提问来源于stack exchange,提问作者True Warg
相关产品推荐
相关产品推荐

