Haskell 缓存函数的结果

Posted

技术标签:

【中文标题】Haskell 缓存函数的结果【英文标题】:Haskell caching results of a function 【发布时间】:2011-01-14 02:36:22 【问题描述】:

我有一个接受参数并产生结果的函数。不幸的是,该函数需要很长时间才能产生结果。该函数经常使用相同的输入调用,这就是为什么如果我可以缓存结果会很方便。类似的东西

let cachedFunction = createCache slowFunction
in (cachedFunction 3.1) + (cachedFunction 4.2) + (cachedFunction 3.1)

我正在研究 Data.Array,虽然该数组是惰性的,但我需要使用对列表(使用 listArray)对其进行初始化 - 这是不切实际的。如果“关键”是例如'Double' 类型,我根本无法初始化它,即使理论上我可以为每个可能的输入分配一个 Integer,我也有数万个可能的输入,而我实际上只使用了少数几个。我需要使用函数而不是列表来初始化数组(或者,最好是哈希表,因为只会使用少数结果)。

更新:我正在阅读 memoization 文章,据我了解,MemoTrie 可以按照我想要的方式工作。可能是。有人可以尝试生成“cachedFunction”吗?最好是一个需要 2 个 Double 参数的慢速函数?或者,或者,这需要一个 Int 参数在一个大约 [0.1 亿] 的域中不会吃掉所有的内存?

【问题讨论】:

【参考方案1】:

GHC 的运行时系统中有许多工具明确支持记忆。

不幸的是,记忆并不是万能的,因此我们需要支持几种不同的方法来应对不同的用户需求。

您可能会发现 1999 年的原始文章很有用,因为它包含几个实现作为示例:

Stretching the Storage Manager: Weak Pointers and Stable Names in Haskell Simon Peyton Jones、Simon Marlow 和 Conal Elliott

【讨论】:

【参考方案2】:

我将添加我自己的解决方案,这似乎也很慢。第一个参数是一个返回 Int32 的函数 - 这是参数的唯一标识符。如果您想通过不同的方式(例如通过“id”)唯一地标识它,则必须将 H.new 中的第二个参数更改为不同的哈希函数。我将尝试找出如何使用 Data.Map 并测试是否能获得更快的结果。

import qualified Data.HashTable as H
import Data.Int
import System.IO.Unsafe

cache :: (a -> Int32) -> (a -> b) -> (a -> b)
cache ident f = unsafePerformIO $ createfunc
    where 
        createfunc = do
            storage <- H.new (==) id
            return (doit storage)

        doit storage = unsafePerformIO . comp
            where 
                comp x = do
                    look <- H.lookup storage (ident x)

                    case look of
                        Just res -> return res
                        Nothing -> do
                            result <- return (f x)
                            H.insert storage (ident x) result
                            return result

【讨论】:

【参考方案3】:

我有数万个可能的输入,而我实际上只使用了少数几个。我需要初始化数组...使用函数而不是列表。

我会选择listArray (start, end) (map func [start..end])

func 并没有真正在上面被调用。 Haskell 是惰性的,它会创建 thunk,当实际需要该值时会对其进行评估。 使用普通数组时,您始终需要初始化其值。因此,无论如何创建这些 thunk 所需的工作都是必要的。 几万远不是很多。如果你有数万亿,那么我建议使用哈希表 yada yada

【讨论】:

所以 - 换一种说法:我有 60.000 个点,我感兴趣的是这些点之间的距离。所以域实际上是 60.000^2,大约是 30 亿......我可以将距离函数附加到每个点 - 这对空间复杂性没有帮助,而且考虑到我主要需要缓存大约 100 个值,这是非常浪费的每点。 @ondra: 好的 - 对于 30 亿我不会使用数组 :)【参考方案4】:

嗯,有Data.HashTable。不过,哈希表往往不能很好地处理不可变数据和引用透明性,所以我认为它的用处不大。

对于少量值,将它们存储在搜索树中(例如Data.Map)可能就足够快了。如果你能忍受对你的Doubles 做一些修改,一个更健壮的解决方案是使用类似树的结构,例如Data.IntMap;它们的查找时间主要与密钥长度成正比,并且在集合大小上大致恒定。如果Int 限制太多,您可以在 Hackage 上四处挖掘,找到在使用的密钥类型方面更灵活的 trie 库。

至于如何缓存结果,我想你想要的通常称为"memoization"。如果您想按需计算和记忆结果,该技术的要点是定义一个包含所有可能结果的索引数据结构,这样当您要求特定结果时,它只强制得到你想要的答案所需的计算。常见的例子通常涉及到一个列表的索引,但同样的原则应该适用于任何非严格的数据结构。根据经验,非函数值(包括无限递归数据结构)通常会被运行时缓存,而不是函数结果,所以诀窍是将所有计算包装在一个顶层定义中取决于任何参数。

编辑: MemoTrie 示例啊!

这是一个快速而肮脏的概念证明;可能存在更好的方法。

-# LANGUAGE TypeFamilies #-
-# LANGUAGE TypeOperators #-
import Data.MemoTrie
import Data.Binary
import Data.ByteString.Lazy hiding (map)

mangle :: Double -> [Int]
mangle = map fromIntegral . unpack . encode

unmangle :: [Int] -> Double
unmangle = decode . pack . map fromIntegral

instance HasTrie Double where
    data Double :->: a = DoubleTrie ([Int] :->: a)
    trie f = DoubleTrie $ trie $ f . unmangle
    untrie (DoubleTrie t) = untrie t . mangle

slow x 
    | x < 1 = 1
    | otherwise = slow (x / 2) + slow (x / 3)

memoSlow :: Double -> Integer
memoSlow = memo slow

请注意 MemoTrie 包使用的 GHC 扩展;希望这不是问题。在 GHCi 中加载它并尝试调用 slowmemoSlow 类似 (10^6) 或 (10^7) 来查看它的实际效果。

将其推广到接受多个参数或诸如此类的函数应该相当简单。有关使用 MemoTrie 的更多详细信息,您可能会发现 this blog post by its author 很有帮助。

【讨论】:

关键域约18亿。我无法初始化任何数据结构,因为这会占用我所有的可用内存。 这就是为什么这个想法是 lazy 初始化;从理论上讲,数据结构包含整个密钥空间,但非严格评估只允许您实际使用的部分进行初始化。这与无限列表的想法相同,只是您需要避免线性遍历的东西。 这似乎行得通。我想我能适应我的需要 :) 谢谢。 我做了一些测试,不幸的是它在实践中无法使用。主要是因为 Haskell 的 Double 实现(编码太长,占用太多内存)。我对 Word64 进行了一些测试,希望类似于 8 字节 Double,每 100.000 个结果我得到大约 40MB。每条记录约 400 字节。非常多。无论如何,Double 的 Haskells 实现是可怕的,最终我尝试在 C 中实现它,并通过将函数从 Haskell 移动到 C 得到它的两倍慢 :( 可能有一些方法可以通过战略性地使用拆箱和严格性来提高效率,但就优化 Haskell 代码而言,这已经超出了我的深度。对不起...【参考方案5】:

您可以将慢速函数编写为高阶函数,返回一个函数本身。因此,您可以在慢速函数内进行所有预处理,并在返回的(希望是快速的)函数中进行每次计算中不同的部分。一个示例可能如下所示: (SML代码,不过思路要清楚)

fun computeComplicatedThing (x:float) (y:float) = (* ... some very complicated computation *)
fun computeComplicatedThingFast = computeComplicatedThing 3.14 (* provide x, do computation that needs only x *)
val result1 = computeComplicatedThingFast 2.71 (* provide y, do computation that needs x and y *)
val result2 = computeComplicatedThingFast 2.81
val result3 = computeComplicatedThingFast 2.91

【讨论】:

【参考方案6】:

我不具体了解haskell,但是如何将现有答案保存在一些散列数据结构(可能称为字典或散列图)中?您可以将慢速函数包装在另一个函数中,该函数首先检查地图,只有在没有找到答案时才调用慢速函数。

您可以通过将地图的大小限制为特定大小并在达到该大小时丢弃最近最少使用的条目来使其变得花哨。为此,您还需要保留键到时间戳映射的映射。

【讨论】:

考虑到可变数据结构和不纯函数,这是一种很好的方法,但在 Haskell 中,最好(在可能的情况下)保持引用透明性并避免可变状态。【参考方案7】:

见memoization

【讨论】:

以上是关于Haskell 缓存函数的结果的主要内容,如果未能解决你的问题,请参考以下文章

如何优化这个 Haskell 程序?

来自元组的函数 - Haskell

Haskell中函数调用的优化

Haskell入门篇九:高阶函数(中)

Haskell 示例中的函数组合

Haskell - lambda 表达式