英文:
Sharing data in Haskell
问题
我想了解Haskell中内存共享机制是如何工作的。实际上,编写计算斐波那契数列项的函数的一种方式是:
fibo' n = f n
where
f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
这假定为了提高性能,fibs列表不会被多次计算,而是在调用之间共享,但我对这个假设或它是如何工作的不确定。
英文:
I would like to understand how the memory sharing mechanism works in Haskell. Indeed, a way to program a function calculating the terms of the fibonnaci sequence is:
fibo' n = f n
where
f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
This assumes for there to be an improvement that the fibs list is not evaluated multiple times but is shared between calls but I'm not sure about this assumption or how it works.
答案1
得分: 5
作为第一个近似,您可以简单地使用变量作用域来预测将被共享的内容。
使用 fibs
的所有地方都在同一个作用域内,因此该名称的每次出现都解析为内存中的同一对象。这意味着在一个顶层 fibo'
调用内每次调用 f
时,都有一个单独的 fibs
列表。它在 f
调用之间是共享的。
然而,fibs
定义在一个 where
作用域中,附加到函数方程 fibo' n =
。这意味着作用域是在 fibo'
的每个调用(在某个特定的 n
上)"内部"的;where
子句中定义的所有名称在每次调用 fibo'
时都引用不同的内存对象。 (这就像任何主流编程语言中定义在函数内部的本地变量的工作方式一样)
这里有一个函数,将使用预先计算的斐波那契数列表保存在一个顶层调用内,以避免在下一个顶层调用时重新计算。
这可能正是您想要的,因此从本质上来说并没有错。如果 fibs
在 顶层应用之外 的作用域中定义,那么它将在所有调用之间共享,但这也意味着它将永久占用内存以便在下一次调用时保持可用。由于 Haskell 中的对象在更深度评估时可以在内存中扩展,这可能被视为内存泄漏。而在每个顶层调用之后丢弃它意味着多个调用会重复一些工作,但您仅在运行时使用为每个调用加速所需的内存(而不是为您曾经做过的最大调用所需的内存),而且只在运行时使用。因此,“更好”的方式取决于程序的其余部分如何使用此函数,而不是取决于此函数本身。
请注意,您的 where
子句中的任何定义实际上都没有使用 fibo'
的参数,而 fibo'
本身并没有真正“做任何事情”;它只是立即将调用转发到 f
。因此,将代码放在 where
中唯一“有趣”的效果是在顶层调用内部但在内部 f
调用之外定义 fibs
的作用域。(我认为访问控制效果是非有趣的)。如果您不希望这样,并且希望 fibs
在调用之间共享,那么定义代码会更简单:
fibo'' 0 = 1
fibo'' 1 = 1
fibo'' n = fibs !! (n-1) + fibs !! (n-2)
fibs = [fibo'' x | x <- [0..]]
现在,fibs
只是全局作用域中的单个对象,并将在所有调用之间共享(但也将在所有调用中占用内存)。
如果您关心访问控制(如果它在本地作用域中定义,则除非定义在全局作用域中,否则没有其他内容可以访问 fibs
),您可以改用以下方式:
fibo''' = f
where
f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
在这里,fibs
(和 f
)在本地作用域中定义。但附加到 where
的方程式只是 fibo''' = f
。这个本地作用域仍然是在顶层调用之外的,“进入”本地作用域是在 fibo'''
接收到其参数之前。而原始的 fibo'
在已经将参数 n
带入作用域之后,附加到已经进行了顶层调用的方程式的 where
作用域是在每个顶层调用之后才“进入”的。
现在,我开始这个帖子是以“作为第一个近似”为开头的。编译器允许重新组织代码以尝试优化它。例如,理论上它可以注意到 fibs
不依赖于参数 n
,因此将其移出顶层调用之外,使您的 fibo'
的行为类似于 fibo'''
。或者(同样在理论上)它可以决定内联 fibs
,这将消除所有共享并使您的 fibo'
行为类似于天真的递归斐波那契计算器。
然而,在实践中,它极不可能执行这两种操作。它“允许”执行不改变代码最终结果的任何转换,但 GHC 开发人员实际上放入编译器中的转换旨在使代码更好。因此,他们非常努力地避免以导致大型内存泄漏或减速的方式增加或减少共享。
因此,尽管编译和优化后的代码通常与您实际编写的代码有很大不同,但在“如果您给某物一个名称,同一作用域内该名称的所有使用将被共享”的假设下推理您
英文:
As a first approximation, you can just use variable scopes to predict what will be shared.
All the places that use fibs
are within the same scope, so each occurrence of that name resolves to the same object in memory. That means each time f
is invoked within one one top-level fibo'
call, there is one single fibs
list. It's shared across f
calls.
However, fibs
is defined in a where
scope, attached to a function equation fibo' n =
. That means the scope is "inside" each call of the fibo'
(on some particular n
); all the names defined in the where
clause refer to different objects in memory each time fibo'
is called. (This is just like how local variables - defined inside a function - work in any mainstream programming language)
What you have here is a function that will work with a list of pre-computed Fibonacci numbers to save recomputation within one top-level call, but then will throw that away and start from scratch on the next top-level call.
That may be exactly what you want, so this isn't wrong per se. If fibs
was defined in a scope outside the top-level application, then it would be shared across all calls, but that also means it would permanently occupy memory to remain available for the next call. As objects in Haskell can expand in memory as they are evaluated more-deeply, this could be considered a memory leak. Throwing it away after each top-level call instead means multiple calls repeat some work, but you're only using the memory needed to speed up each call (rather than the memory needed for the largest call you have ever made), and only while it is running. So which way is "better" depends on what the rest of your program is doing with this function, not on this function itself.
Note that none of the definitions in your where
clause are actually using the arguments of fibo'
, and fibo'
itself isn't really "doing anything"; it just forwards the call immediately to f
. So the only "interesting" effect of putting your code in a where
like this is creating a scope where you can define fibs
inside the top-level call but outside the inner f
calls. (I'm considering the access control effects to be non-interesting). If you didn't want that, and did want fibs
to be shared across calls, it would be simpler to just define your code like this:
fibo'' 0 = 1
fibo'' 1 = 1
fibo'' n = fibs !! (n-1) + fibs !! (n-2)
fibs = [fibo'' x | x <- [0..]]
Now fibs
is just a single object in the global scope, and will be shared across all calls (but will occupy memory across all calls too).
If you do care about the access-control (nothing else can access fibs
if it's defined in a local scope, but can if it's defined in a global scope), you can instead do this:
fibo''' = f
where
f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
Here fibs
(and f
) are defined in a local scope. But the equation which the where
is attached to is just fibo''' = f
. This local scope is still "outside" the top-level call, because the scope is "entered" before fibo'''
receives its argument. Whereas the original fibo'
has the where
attached to an equation that has already brought the argument n
into scope, so the where
scope is "entered" after each top-level call is made.
Now, I did start this post with "as a first approximation". The compiler is allowed to reorganise your code to try and optimise it. For example it could theoretically notice that fibs
doesn't depend on the argument n
, and so move it outside the top-level call, making your fibo'
behave like fibo'''
. Or (again theoretically) it could decide to inline fibs
which would remove all sharing and make your fibo'
behave like a naiive recursive Fibonacci calculator.
In practice, however, it is extremely unlikely to do either of these things. It's "allowed" to do any transformation that doesn't change the end result of your code, but the transformations the GHC developers actually put into the compiler are designed to make your code better. So they try very hard not to increase or reduce sharing in a way that causes large memory leaks or slowdowns.
So while the compiled and optimised code frequently is quite different from what you actually wrote, it's pretty safe to reason about your code under the assumption that "if you give something a name, all uses of that name within the same scope will be shared". You just have to be careful about when exactly local scopes are "entered" so that you know what counts as "within the same scope".
答案2
得分: 3
以下是您要翻译的内容:
在 GHC 中,就近似而言,只有当它是同一个变量时,它在内存中的值才相同。给一个事物命名会创建共享的可能性。
这个规则有一些例外:
- 如果 GHC 决定内联一个定义,它会在每个内联的地方复制它。通常情况下,这不是问题,因为只有 "简单 "的东西会被内联。只有当您的值使用
unsafePerformIO
与不纯的东西一起使用,并且您依赖于共享以使值在整个程序执行过程中保持一致时,这才会成为您需要担心的问题。在这种情况下,通常的解决方法是使用NOINLINE
pragma 标记那些不安全定义的东西。 - 如果 GHC 进行常见子表达式消除,这可能会在不明确命名的情况下引入一个表达式的共享。只有当何时应用该转换的启发式算法失误时,这才会有关,而且现在它们已经非常好了很长时间。我猜这对于像
criterion
这样的库来说可能是个问题,它希望重复评估相同的表达式的时间。我查看了它的源代码以了解它是如何处理这个问题的,似乎它通过将评估移到 GHC 完全不知道具体类型的上下文中来解决了这个问题,这足以防止 CSE 触发。 - 完全惰性转换有时会将堆分配移到 lambda 之外,如果堆分配不依赖于 lambda 的参数。这可能会非常令人头疼。实际上,它会应用于您的代码。既不是
f
也不是fibs
依赖于fibo'
的参数。如果 GHC 决定应用完全惰性转换,您的代码可能会被转换为以下形式:
它会将当前形式转换为等效形式:
fibo' = \n' -> let f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
in f n'
而后将 let
浮动到 lambda 之外:
fibo' = let f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
in \n' -> f n'
这里的作用域很重要。在将 let
浮动到顶部之后,f
和 fibs
的定义将在多次调用 fibo'
中共享,而不是为每次调用重新分配。在这种情况下,这可能会引入 太多 的共享,因为使用大参数一次调用 fibs
将导致保留整个计算列表的那部分,而不管程序的其余部分需要多少。总的来说,我认为完全惰性转换是一个不好的功能,并通过编译器选项禁用它。如果我需要共享,我会手动浮动一个分配。
英文:
To a first approximation in GHC, it's the same value in memory if and only if it's the same variable. Naming a thing creates the potential for sharing it.
Exceptions to this rule:
- If GHC decides to inline a definition, it's duplicated each place it's inlined. In general this isn't a problem, as only "simple" things are inlined. This only really becomes something you need to worry about if your value is using
unsafePerformIO
with something that isn't actually pure and you were counting on sharing so that the value remains consistent across an entire program execution. The typical solution here is to mark things defined that unsafely with aNOINLINE
pragma. - If GHC does common subexpression elimination, that can introduce sharing of an expression without explicitly naming it. This should only matter if the heuristics for when to apply that transformation misfire, and they've been very good for a long time now. I guess this is a problem for libraries like
criterion
which want to time evaluating the same expression repeatedly. I dug through its source to figure out how it deals with it, and it seems to solve it by moving the evaluation into a context where GHC has no idea what the concrete types are, which is enough to prevent CSE from firing. - The full laziness transform will sometimes float a heap allocation outside of a lambda, if the heap allocation doesn't depend on an argument to the lambda. This one can be a giant pain. It would in fact apply to your code. Neither
f
norfibs
depend on the argument tofibo'
. If GHC decides to apply the full laziness transform, your code might end up transformed as such:
It would transform the current form, which is equivalent to:
fibo' = \n' -> let f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
in f n'
To the following, with the let
floated outside of the lambda:
fibo' = let f 0 = 1
f 1 = 1
f n = fibs !! (n-1) + fibs !! (n-2)
fibs = [f x | x <- [0..]]
in \n' -> f n'
The scoping matters there. After floating the let
to the top, the definitions of f
and fibs
are shared across multiple invocations of fibo'
, instead of being reallocated for each call. In this case, that might introduce too much sharing, as calling fibs
once with a large argument would result in holding on to that that much of the calculated list regardless of how much is needed for the rest of the program. In general I consider the full laziness transformation to be a misfeature and disable it via compiler options. I will float an allocation by hand if I want sharing.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论