Skip to content

Commit 7f0f9f9

Browse files
author
Bao Zhiyuan
committed
add new pearl
1 parent 14f77ef commit 7f0f9f9

File tree

20 files changed

+769
-0
lines changed

20 files changed

+769
-0
lines changed
974 KB
Loading
77.4 KB
Loading
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
---
2+
description: 'Mini-adapton: 用 MoonBit 实现增量计算'
3+
slug: mini-adapton
4+
image: cover.png
5+
---
6+
7+
# Mini-adapton: 用 MoonBit 实现增量计算
8+
9+
![](./cover.png)
10+
11+
## 介绍
12+
13+
让我们先用一个类似 excel 的例子感受一下增量计算长什么样子. 首先, 定义一个这样的依赖图:
14+
15+
![](./example.png)
16+
17+
在这个图中, `t1` 的值通过 `n1 + n2` 计算得到, `t2` 的值通过 `t1 + n3` 计算得到.
18+
19+
当我们想得到 `t2` 的值时, 该图定义的计算将被执行: 首先通过 `n1 + n2` 算出 `t1`, 再通过 `t1 + n3` 算出 `t2`. 这个过程和非增量计算是相同的.
20+
21+
但当我们开始改变`n1`, `n2``n3` 的值时, 事情就不一样了. 比如说我们想将 `n1``n2` 的值互换, 再得到 `t2` 的值. 在非增量计算中, `t1``t2` 都将被重新计算一遍, 但实际上 `t2` 是不需要被重新计算的, 因为它依赖的两个值 `t1``n3` 都没有改变 (将 `n1``n2` 的值互换不会改变 `t1` 的值).
22+
23+
下面的代码实现了我们刚刚举的例子. 我们使用 `Cell::new` 来定义 `n1`, `n2``n3` 这些不需要计算的东西, 使用 `Thunk::new` 来定义 `t1``t2` 这样需要计算的东西.
24+
25+
```mbt
26+
test {
27+
// a counter to record the times of t2's computation
28+
let mut cnt = 0
29+
// start define the graph
30+
let n1 = Cell::new(1)
31+
let n2 = Cell::new(2)
32+
let n3 = Cell::new(3)
33+
let t1 = Thunk::new(fn() {
34+
n1.get() + n2.get()
35+
})
36+
let t2 = Thunk::new(fn() {
37+
cnt += 1
38+
t1.get() + n3.get()
39+
})
40+
// get the value of t2
41+
inspect(t2.get(), content="6")
42+
inspect(cnt, content="1")
43+
// swap value of n1 and n2
44+
n1.set(2)
45+
n2.set(1)
46+
inspect(t2.get(), content="6")
47+
// t2 does not recompute
48+
inspect(cnt, content="1")
49+
}
50+
```
51+
52+
在这篇文章中, 我们将介绍如何在 MoonBit 中实现一个增量计算库. 这个库的 API 就是我们上面例子中出现的那些:
53+
54+
```
55+
Cell::new
56+
Cell::get
57+
Cell::set
58+
Thunk::new
59+
Thunk::get
60+
```
61+
62+
## 问题分析和解法
63+
64+
要实现这个库, 我们主要有三个问题需要解决:
65+
66+
### 如何在运行时构建依赖图
67+
68+
作为一个使用 MoonBit 实现的库, 没有简单方法让我们可以静态地构建依赖图, 因为 MoonBit 目前还不支持任何元编程的机制. 因此我们需要动态地把依赖图构建出来. 事实上, 我们关心的只是哪些 thunk 或 cell 被另一个 thunk 依赖了, 所以一个不错的构建依赖图的时机就是在用户调用 `Thunk::get` 的时候. 比如在上面的例子中:
69+
70+
```mbt skip
71+
let n1 = Cell::new(1)
72+
let n2 = Cell::new(2)
73+
let n3 = Cell::new(3)
74+
let t1 = Thunk::new(fn() { n1.get() + n2.get() })
75+
let t2 = Thunk::new(fn() { t1.get() + n3.get() })
76+
t2.get()
77+
```
78+
79+
当用户调用 `t2.get()` 时, 我们在运行时会知道 `t1.get()``n3.get()` 在其中也被调用了. 因此 `t1``n3``t2` 的依赖, 并且我们可以构建一个这样的图:
80+
81+
![](./subgraph.png)
82+
83+
同样的过程也会在 `t1.get()` 被调用时发生.
84+
85+
所以计划是这样的:
86+
87+
1. 我们定义一个栈来记录我们当前在获得哪个 thunk 的值. 在这里使用栈的原因是, 我们事实上是在尝试记录每个 `get` 的调用栈.
88+
1. 当我们调用 `get` 时, 将其标记为栈顶 thunk 的依赖, 如果它是一个 thunk, 再把它压栈.
89+
1. 当一个 thunk 的 `get` 结束时, 将它出栈.
90+
91+
让我们看看上面那个例子在这个算法下的过程是什么样子的:
92+
93+
1. 当我们调用 `t2.get` 时, 将 `t2` 压栈.
94+
95+
![](./step1.png)
96+
97+
1. 当我们在 `t2.get` 中调用 `t1.get` 时, 将 `t1` 记为 `t2` 的依赖, 并将 `t1` 压栈.
98+
99+
![](./step2.png)
100+
101+
1. 当我们在 `t1.get` 中调用 `n1.get` 时, 将 n1 记为 `t1` 的依赖
102+
103+
![](./step3.png)
104+
105+
1. 相同的过程发生在 `n2` 身上.
106+
107+
![](./step4.png)
108+
109+
1.`t1.get` 结束时, 将 `t1` 出栈.
110+
111+
![](./step5.png)
112+
113+
1. 当我们调用 `n3.get` 时, 将 `n3` 记为 `t2` 的依赖.
114+
115+
![](./step6.png)
116+
117+
除了这些从父依赖到子依赖的边之外, 我们最好也记录一个从子依赖到父依赖的边, 方便后面我们在这个图上反向便利.
118+
119+
在接下来的代码中, 我们将使用 `outgoing_edges` 指代从父依赖到子依赖的边, 使用 `incoming_edges` 指代中子依赖到父依赖的边.
120+
121+
### 如何标记过时的节点
122+
123+
当我们调用 `Cell::set` 时, 该节点本身和所有依赖它的节点都应该被标记为过时的. 这将在后面作为判断一个 thunk 是否需要重新计算的标准之一. 这基本上是一个从图的叶子节点向后遍历的过程. 我们可以用这样的伪 MoonBit 代码表示这个算法:
124+
125+
```moonbit skip
126+
fn dirty(node: Node) -> Unit {
127+
for n in node.incoming_edges {
128+
n.set_dirty(true)
129+
dirty(node)
130+
}
131+
}
132+
```
133+
134+
### 如何决定一个 thunk 需要被重新计算
135+
136+
当我们调用 `Thunk::get` 时, 我们需要决定是否它需要被重新计算. 但只用我们在上一节描述的方法是不够的. 如果我们只使用是否过时这一个标准进行判断, 势必会有不需要的计算发生. 比如我们在一开始给出的例子:
137+
138+
```mbt skip
139+
n1.set(2)
140+
n2.set(1)
141+
inspect(t2.get(), content="6")
142+
```
143+
144+
当我们调换 `n1``n2` 的值时, `n1`, `n2`, `t1``t2` 都应该被标记为过时, 但当我们调用 `t2.get` 时, 其实没有必要重新计算 `t2`, 因为 `t1` 的值并没有改变.
145+
146+
这提醒我们除了过时之外, 我们还要考虑依赖的值是否和它上一次的值一样. 如果一个节点既是过时的, 并且它的依赖中存在一个值和上一次不同, 那么它应该被重新计算.
147+
148+
我们可以用下面的伪 MoonBit 代码描述这个算法:
149+
150+
```mbt skip
151+
fn propagate(self: Node) -> Unit {
152+
// 当一个节点过时了, 它可能需要被重新计算
153+
if self.is_dirty() {
154+
// 重新计算之后, 它将不在是过时的
155+
self.set_dirty(false)
156+
for dependency in self.outgoing_edges() {
157+
// 递归地重新计算每个依赖
158+
dependency.propagate()
159+
// 如果一个依赖的值改变了, 这个节点需要被重新计算
160+
if dependency.is_changed() {
161+
// 移除所有的 outgoing_edges, 它们将在被计算时重新构建
162+
self.outgoing_edges().clear()
163+
self.evaluate()
164+
return
165+
}
166+
}
167+
}
168+
}
169+
```
170+
171+
## 实现
172+
173+
基于上面描述的代码, 实现是比较直观的.
174+
175+
首先, 我们先定义 `Cell`:
176+
177+
```mbt
178+
struct Cell[A] {
179+
mut is_dirty : Bool
180+
mut value : A
181+
mut is_changed : Bool
182+
incoming_edges : Array[&Node]
183+
}
184+
```
185+
186+
由于 `Cell` 只会是依赖图中的叶子节点, 所以它没有 `outgoing_edges`. 这里出现的特征 `Node` 是用来抽象依赖图中的节点的.
187+
188+
接着, 我们定义 `Thunk`:
189+
190+
```mbt
191+
struct Thunk[A] {
192+
mut is_dirty : Bool
193+
mut value : A?
194+
mut is_changed : Bool
195+
thunk : () -> A
196+
incoming_edges : Array[&Node]
197+
outgoing_edges : Array[&Node]
198+
}
199+
```
200+
201+
`Thunk` 的值是可选的, 因为它只有在我们第一次调用 `Thunk::get` 之后才会存在.
202+
203+
我们可以很简单地给这两个类型实现 `new`:
204+
205+
```mbt
206+
fn[A : Eq] Cell::new(value : A) -> Cell[A] {
207+
Cell::{
208+
is_changed: false,
209+
value,
210+
incoming_edges: [],
211+
is_dirty: false,
212+
}
213+
}
214+
```
215+
216+
```mbt
217+
fn[A : Eq] Thunk::new(thunk : () -> A) -> Thunk[A] {
218+
Thunk::{
219+
value: None,
220+
is_changed: false,
221+
thunk,
222+
incoming_edges: [],
223+
outgoing_edges: [],
224+
is_dirty: false,
225+
}
226+
}
227+
```
228+
229+
`Thunk``Cell` 是依赖图的两种节点, 我们可以使用一个特征 `Node` 来抽象它们:
230+
231+
```mbt
232+
trait Node {
233+
is_dirty(Self) -> Bool
234+
set_dirty(Self, Bool) -> Unit
235+
incoming_edges(Self) -> Array[&Node]
236+
outgoing_edges(Self) -> Array[&Node]
237+
is_changed(Self) -> Bool
238+
evaluate(Self) -> Unit
239+
}
240+
```
241+
242+
为两个类型实现这个特征:
243+
244+
```mbt
245+
impl[A] Node for Cell[A] with incoming_edges(self) {
246+
self.incoming_edges
247+
}
248+
249+
impl[A] Node for Cell[A] with outgoing_edges(_self) {
250+
[]
251+
}
252+
253+
impl[A] Node for Cell[A] with is_dirty(self) {
254+
self.is_dirty
255+
}
256+
257+
impl[A] Node for Cell[A] with set_dirty(self, new_dirty) {
258+
self.is_dirty = new_dirty
259+
}
260+
261+
impl[A] Node for Cell[A] with is_changed(self) {
262+
self.is_changed
263+
}
264+
265+
impl[A] Node for Cell[A] with evaluate(_self) {
266+
()
267+
}
268+
269+
impl[A : Eq] Node for Thunk[A] with is_changed(self) {
270+
self.is_changed
271+
}
272+
273+
impl[A : Eq] Node for Thunk[A] with outgoing_edges(self) {
274+
self.outgoing_edges
275+
}
276+
277+
impl[A : Eq] Node for Thunk[A] with incoming_edges(self) {
278+
self.incoming_edges
279+
}
280+
281+
impl[A : Eq] Node for Thunk[A] with is_dirty(self) {
282+
self.is_dirty
283+
}
284+
285+
impl[A : Eq] Node for Thunk[A] with set_dirty(self, new_dirty) {
286+
self.is_dirty = new_dirty
287+
}
288+
289+
impl[A : Eq] Node for Thunk[A] with evaluate(self) {
290+
node_stack.push(self)
291+
let value = (self.thunk)()
292+
self.is_changed = match self.value {
293+
None => true
294+
Some(v) => v != value
295+
}
296+
self.value = Some(value)
297+
node_stack.unsafe_pop() |> ignore
298+
}
299+
```
300+
301+
这里唯一复杂的实现是 `Thunk``evaluate`. 这里我们需要先把这个 thunk 推到栈顶用于后面的依赖记录. `node_stack` 的定义如下:
302+
303+
```mbt
304+
let node_stack : Array[&Node] = []
305+
```
306+
307+
然后做真正的计算, 并且把计算得到的值和上一个值做比较以更新 `self.is_changed`. `is_changed` 会在后面帮助我们判断是否需要重新计算一个 thunk.
308+
309+
`dirty``propagate` 的实现几乎和上面的伪代码相同:
310+
311+
```mbt
312+
fn &Node::dirty(self : &Node) -> Unit {
313+
for dependent in self.incoming_edges() {
314+
if not(dependent.is_dirty()) {
315+
dependent.set_dirty(true)
316+
dependent.dirty()
317+
}
318+
}
319+
}
320+
```
321+
322+
```mbt
323+
fn &Node::propagate(self : &Node) -> Unit {
324+
if self.is_dirty() {
325+
self.set_dirty(false)
326+
for dependency in self.outgoing_edges() {
327+
dependency.propagate()
328+
if dependency.is_changed() {
329+
self.outgoing_edges().clear()
330+
self.evaluate()
331+
return
332+
}
333+
}
334+
}
335+
}
336+
```
337+
338+
有了这些函数的帮助, 最主要的三个 API: `Cell::get`, `Cell::set``Thunk::get` 实现起来就比较简单了.
339+
340+
为了得到一个 cell 的值, 我们直接返回结构体的 `value` 字段即可. 但在此之前, 如果它是在一个 `Thunk::get` 中被调用的, 我们要先把他记录为依赖.
341+
342+
```mbt
343+
fn[A] Cell::get(self : Cell[A]) -> A {
344+
if node_stack.last() is Some(target) {
345+
target.outgoing_edges().push(self)
346+
self.incoming_edges.push(target)
347+
}
348+
self.value
349+
}
350+
```
351+
352+
当我们更改一个 cell 的值时, 我们需要先确保 `is_changed``dirty` 这两个状态被正确地更新了, 再将它的每一个父依赖标记为过时.
353+
354+
```mbt
355+
fn[A : Eq] Cell::set(self : Cell[A], new_value : A) -> Unit {
356+
if self.value != new_value {
357+
self.is_changed = true
358+
self.value = new_value
359+
self.set_dirty(true)
360+
&Node::dirty(self)
361+
}
362+
}
363+
```
364+
365+
`Cell::get` 类似, 在实现 `Thunk::get` 时我们需要先将 `self` 记录为依赖. 之后我们模式匹配 `self.value`, 如果它是 `None`, 这意味着这是第一次用户尝试计算这个 thunk 地值, 我们可以简单地直接计算它; 如果它是 `Some`, 我们需要使用 `propagate` 来确保我们只重新计算那些需要的 thunk.
366+
367+
```mbt
368+
fn[A : Eq] Thunk::get(self : Thunk[A]) -> A {
369+
if node_stack.last() is Some(target) {
370+
target.outgoing_edges().push(self)
371+
self.incoming_edges.push(target)
372+
}
373+
match self.value {
374+
None => self.evaluate()
375+
Some(_) => &Node::propagate(self)
376+
}
377+
self.value.unwrap()
378+
}
379+
```
380+
381+
## 参考
382+
383+
- [Adapton: Composable, demand-driven incremental computation, PLDI 2014](http://matthewhammer.org/adapton/) adapton 的原论文
384+
- [illusory0x0/adapton.mbt](https://github.com/illusory0x0/adapton.mbt) adapton 库的 MoonBit 实现
14 KB
Loading
38.2 KB
Loading
46.4 KB
Loading
52.4 KB
Loading
58.1 KB
Loading
58.9 KB
Loading
46.1 KB
Loading

0 commit comments

Comments
 (0)