Jul*_*les 8 performance haskell specialization
我从我的生产项目中提取了以下最小示例。我的机器学习项目由线性代数库、深度学习库和应用程序组成。
线性代数库包含一个基于可存储向量的矩阵模块:
module Matrix where
import Data.Vector.Storable hiding (sum)
data Matrix a = Matrix { rows :: Int, cols :: Int, items :: Vector a } deriving (Eq, Show, Read)
item :: Storable a => Int -> Int -> Matrix a -> a
item i j m = unsafeIndex (items m) $ i * cols m + j
multiply :: Storable a => Num a => Matrix a -> Matrix a -> Matrix a
multiply a b = Matrix (rows a) (cols b) $ generate (rows a * cols b) (f . flip divMod (cols b)) where
f (i, j) = sum $ (\ k -> item i k a * item k j b) <$> [0 .. cols a - 1]
Run Code Online (Sandbox Code Playgroud)
深度学习库使用线性代数库通过深度神经网络实现前向传播:
module Deep where
import Foreign.Storable
import Matrix
transform :: Storable a => Num a => [Matrix a] -> Matrix a -> Matrix a
transform layers batch = foldr multiply batch layers
Run Code Online (Sandbox Code Playgroud)
最后,该应用程序使用深度学习库:
import qualified Data.Vector.Storable as VS
import Test.Tasty.Bench
import Matrix
import Deep
main :: IO ()
main = defaultMain [bmultiply] where
bmultiply = bench "bmultiply" $ nf (items . transform layers) batch where
m k l c = Matrix k l $ VS.replicate (k * l) c :: Matrix Double
layers = m 256 256 <$> [0.1, 0.2, 0.3]
batch = m 256 100 0.4
Run Code Online (Sandbox Code Playgroud)
我喜欢这样一个事实:深度学习库以及通过 FFI 与 BLAS 相关的一些例外,线性代数库也不必担心像Float
或 之类的具体类型Double
。不幸的是,这也意味着除非进行专门化,否则它们使用盒装值,并且性能大约比应有的差 60 倍(959 毫秒而不是 16.7 毫秒)。
我发现获得良好性能的唯一方法是通过编译器编译指示在整个调用层次结构中强制内联或专门化。这是非常烦人的,因为从根本上来说应该是特定于函数的性能问题multiply
现在“感染”了整个代码库。即使是使用 5 级间接和几个中间库的非常高级的函数,multiply
也必须在某种程度上“了解”深层的技术专业化问题。
在我的实际生产代码中,受影响的功能比这个最小示例中的功能要多得多。如果忘记使用正确的编译器编译指示仅注释其中一个函数,就会立即破坏性能。此外,在开发库时,我无法知道它将与哪些类型一起使用,因此专业化编译指示无论如何都不是一个选择。
这是特别不幸的,因为所有性能关键的紧密循环都完全包含在函数内multiply
。该函数本身仅被调用几次,并且如果每次multiply
调用时仅动态拆箱值,则不会损害性能。最后,在高级机器学习函数中确实不需要对值进行专门化和拆箱。我觉得应该有一种方法可以将专业化的请求传递给低级函数,同时保持高级和中级函数的多态性。
Haskell 中通常如何解决这个问题?如果我开发一个使用向量包在紧密循环中生成极快代码的库,我如何将该性能传递给我的库的用户而不丢失所有多态性或强制内联所有内容?
有没有办法只在高级函数内为多态性(以装箱的形式)付出代价,并且仅在需要它的函数的边界进行专门化和拆箱,而不是让专门化“感染”整个调用层次结构?
如果您浏览该vector
包的源代码,您会发现几乎每个函数都有INLINABLE
or INLINE
pragma,无论该函数是低级性能关键核心的一部分还是高级通用接口的一部分。如果您查看lens
或hmatrix
等,您会看到类似的内容。
因此,简短的答案是:不,当前设计获得良好性能的唯一方法是用编译指示感染整个调用层次结构。避免错过编译指示和性能下降的最佳方法是拥有一组详尽的基准来检测性能回归。
有一些编译器标志可能会有所帮助。该标志-fexpose-all-unfoldings
确保所有函数的内联版本都能找到进入接口文件的方式,而该标志-fspecialise-aggressively
则寻找任何专门化这些函数的机会。它们结合在一起就像打开INLINE
每个功能一样。这可能不是一个好的永久解决方案,但它可能在开发过程中很有用,或者作为健全性检查以获得一些基准性能数据。