|
| 1 | +--- |
| 2 | +description: 'Mini-adapton: 用 MoonBit 实现增量计算' |
| 3 | +slug: mini-adapton |
| 4 | +image: cover.png |
| 5 | +--- |
| 6 | + |
| 7 | +# Mini-adapton: 用 MoonBit 实现增量计算 |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +## 介绍 |
| 12 | + |
| 13 | +让我们先用一个类似 excel 的例子感受一下增量计算长什么样子. 首先, 定义一个这样的依赖图: |
| 14 | + |
| 15 | + |
| 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 | + |
| 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 | +  |
| 96 | + |
| 97 | +1. 当我们在 `t2.get` 中调用 `t1.get` 时, 将 `t1` 记为 `t2` 的依赖, 并将 `t1` 压栈. |
| 98 | + |
| 99 | +  |
| 100 | + |
| 101 | +1. 当我们在 `t1.get` 中调用 `n1.get` 时, 将 n1 记为 `t1` 的依赖 |
| 102 | + |
| 103 | +  |
| 104 | + |
| 105 | +1. 相同的过程发生在 `n2` 身上. |
| 106 | + |
| 107 | +  |
| 108 | + |
| 109 | +1. 当 `t1.get` 结束时, 将 `t1` 出栈. |
| 110 | + |
| 111 | +  |
| 112 | + |
| 113 | +1. 当我们调用 `n3.get` 时, 将 `n3` 记为 `t2` 的依赖. |
| 114 | + |
| 115 | +  |
| 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 实现 |
0 commit comments