Header

原文:Folding in Parallel
作者:Oleg Kiselyov

本文描述了一种有趣的方法,用 map 和幺半群 reduce 来表述顺序执行的 fold。这样一来,某些看似有顺序的算法不仅可以并行运行,甚至还拥有美妙的并行性质:我们可以将输入序列任意分割给工作单元(如有必要,甚至可以递归地分割出子部分),并让工作单元并行运行——没有竞态条件、数据依赖,甚至不会有内存 bank 冲突。这种美妙的并行性质是多核、GPU 或分布式计算的理想选择。

这种方法的内在原理早已人尽皆知,但同样众所周知的是:将原理应用到具体案例需要独创性。

导言:fold vs. 幺半群 reduce

Guy Style 在二〇〇九年的 ICFP 会议上发表了一篇题为 «foldl and foldr considered slightly harmful» 的主题演讲1。在演讲中,他提倡用 (map)reduce 取代 fold。

回想一下,序列上的 fold 本质上是一种带状态的按顺序累加;为具体起见,我们在列表上定义

fold_left  : ('z -> 'a -> 'z) -> 'z -> 'a list -> 'z
fold_right : ('a -> 'z -> 'z) -> 'a list -> 'z -> 'z

有一说一,现在我们有两种 fold:左 fold 与右 fold。下面的例子应该可以清楚地看出它们对应的语义

fold_left (+) 0 [1;2;3;4]  (((0 + 1) + 2) + 3) + 4
fold_right (+) [1;2;3;4] 0  1 + (2 + (3 + (4 + 0)))

这里用于 fold 的函数是加法函数,这种情况下两种 fold 得出的结果完全一致:它们都计算了列表中所有元素的和。而一般来说,左 fold 和右 fold 会产生不同的结果——譬如用减法函数来 fold

列表中元素的类型 'a 与累加值的类型 'z 也不需要一样,我们可以用

fold_left (fun z _ -> z + 1) 0 l

来计算任意列表的长度;也可以用

fold_left (fun z x -> x :: z) [] l

翻转列表;甚至可以用

fold_right (fun x z -> if p x then x::z else z) l []

过滤掉那些对谓词函数 p 返回 false 的元素。许多列表上的操作(事实上,是所有操作)都可以通过 fold 表达。fold 实质上就是「对序列的进行有状态的串行处理」的一般模式。

fold 也有其他定义:有时候人们会交换 fold-right 的后两个参数。交换后,左 fold 和右 fold 具有相同的类型签名,但它们的行为和结合模式仍有不同。Olivier Danvy 回顾了列表 fold 和其参数顺序的历史(见下)。

为了具体起见,我们用列表演示 fold。但是数组、流、文件、树、字典以及其他任意集合都存在类似的操作。

在本文中所谓的 reduce 总是指幺半群[monoid]上的 reduce。幺半群是一个集合,这个集合有一个带有满足结合律的二元运算,以及一个单位元[unit element](也称 zero2)。具体来说,在 OCaml 中有

type 'a monoid = {zero: 'a; op: 'a -> 'a -> 'a }

'a 是幺半群元素的类型,op 必须满足结合律,且对于幺半群上的所有元素 x 都应满足

op zero x = op x zero = x

在 Google 的 MapReduce 中,运算 op 还需满足交换律,本文没有这样的要求。

序列上的 reduce 就是(以列表为例)

reduce : 'a monoid -> 'a list -> 'a

其行为可以描述如下

reduce monoid []   monoid.zero
reduce monoid [x]  x             (* 对于任意 x 和 monoid 都成立 *)

reduce {zero=0;op=(+)} [1;2;3;4]  1 + 2 + 3 + 4

有人可能会把 reduce 的操作形容成「将幺半群的运算『楔入』序列的相邻元素中间」。鉴于 op 满足结合律,括号并不重要,因此只有一个 reduce,没有左 fold 与右 fold 那样的区别。

我们常常看到 reducemap 被预先组合在一起,这两个操作也可以融合成

map_reduce : ('a -> 'z) -> 'z monoid -> 'a list -> 'z

不过,为了展示清楚,本文还是把 mapreduce 分开来写,并假设实际中的执行使用融合过的高效操作。

鉴于 fold 的执行过程依赖于累加值数据,它必须按顺序求值。事实上,左 fold 只是 for 循环的另一种表示(为了使我们的示例不那么单调无味,这里使用数组切片作为序列):

type 'a array_slice = {arr:'a array; from:int; upto:int} 
let fold_left_arr : ('z -> 'a -> 'z) -> 'z -> 'a array_slice -> 'z = 
  fun f z {arr;from;upto} ->
  let acc = ref z in
  for i=from to upto do
    acc := f !acc arr.(i)
  done;
  !acc

而另一方面,reduce 有多种实现方式。可以按顺序执行:

let seqreduce_arr (m: 'a monoid) (arrsl: 'a array_slice) : 'a =
  fold_left_arr m.op m.zero arrsl

也可以并行执行

let rec parreduce_arr (m: 'a monoid) {arr;from;upto} : 'a =
  match upto+1-from with
  | 0 -> m.zero
  | 1 -> arr.(from)
  | 2 -> m.op arr.(from) arr.(from+1)
  | 3 -> m.op (m.op arr.(from) arr.(from+1)) arr.(from+2)
  | n -> let n' = n / 2 in
         (* 这里的两个 parreduce_arr 可以并行执行! *)
         m.op 
           (parreduce_arr m {arr;from;upto=from+n'-1})
           (parreduce_arr m {arr;from=from+n';upto})

代码中的两个对 parreduce_arr 的递归调用可以并行运行——它们甚至具有美妙的并行性质,互相不存在竟态条件,甚至没有读冲突。诚然,「是否应该并行执行」则另当别论,需要实事求是的分析。例如,如果数组切片很短,那么最好让两个 parreduce_arr 调用按顺序执行(因为并行计算确实存在一些开销)。如果被分解的切片本身又被再次分解了并行执行的两半,就得到了一种层次分解:二叉树式的处理。

我们不必递归分解切片,我们可以将输入数组任意切割成一系列不重叠的切片,然后将这些切片分给可用的计算核。这些计算核可以按自己的方式来完成为分配的任务,不需要和其他核进行同步。最后我们利用幺半群运算合并这些结果。

由于(左或右)fold 强制我们进行顺序求值,Guy Steele 称其「slightly harmful」(略微有害)。他呼吁人们尽可能使用 reducereduce 很灵活,它分离了算法本身和执行策略,具体执行策略(顺序、并行、分布式)和如何分割数据可以稍后再根据具体情况和可用资源进行选择。

因此,主要问题是:我们能否将 fold 转换为 reduce?本文的剩余部分将回答这个问题。

引用文献

  • Guy Steele: Organizing Functional Code for Parallel execution or, foldl and foldr considered slightly harmful

ICFP 2009 主题演讲,二〇〇九年八月。

  • Olivier Danvy: ``Folding left and right matters: direct style, accumulators, and continuations''

根据 Danvy 的说法,第一个对 fold_leftfold_right 的研究是 Christopher Strachey 在 1961 年进行的(!)。Reduce 也是 APL 语言(Iversion, 1962)的一部分,它在 APL 中被称为 /:因此对数组 x 求和就是 +/xT 系统[System T]中的哥德尔递归子 R 是 fold 的一个推广版本(现在也称 para-fold)。丘奇数[Church numeral]也是一种 fold

本文使用的完整代码

一个完整的,用 fold 表示列表的库,展示了所有列表处理操作都可以用 fold 表示。

在 XML 解析上的应用。

用 map-reduce 表达 fold:平凡解

主要问题在于如何用 reduce 表达 fold_leftfold_right,本节将展示两个平凡解。事实上,我们可以发现:fold 总是可以用 reduce 表示,但这种表示方式既无用也无趣。

如果 fold 用函数(即 fold 的第一个参数)满足结合律(这也意味着序列元素的类型与累加值/计算结果的类型相同),且该函数具有单位元。那么 fold 自然就是 reduce 的一个实例,我们已经看过这个例子:对于任意列表 l

fold_left (+) 0 l  fold_right (+) l 0  reduce {op=(+);zero=0} l

第二个平凡解是:fold 总可以无条件地表示为幺半群上的 reduce,即对任意具有适当类型的 fzl

let compose_monoid = {op=(fun g h -> fun x -> g (h x)); zero=Fun.id}
fold_right f l z  List.map f l |> reduce compose_monoid |> (|>) z

对于左 fold 也有一个类似的表达式(留作读者练习)。关键思想在于「函数组合」运算满足结合律。

不幸的是,这个平凡解几乎没有实际用途。设有 f:'a -> 'z -> 'zmap f l 会创建一个类型为 'z -> 'z 的闭包构成的列表,然后 reduce 将这些闭包组合成一个(大的)'z -> 'z 闭包,最后再将这个闭包应用于 z 上。若列表 l 的大小为 N,那么 reduce 的结果是 N 个闭包的函数组合,其大小与 N 成正比(这个比例常数难以忽略,因为一个闭包可能会占用数个机器字大小的堆空间)——实际上,我们构建了一个无界限大小的中间数据结构。而尽管组合闭包可以并行执行,但只有程序将组合后的大闭包应用于初始累加值时,程序才会开始执行实际工作——此时程序是按顺序逐步应用函数 f,和原始 fold_right f l z 中的完全一致。这种平凡解只是在浪费时间和空间。

因此,问题不仅在于如何用 reduce 表示 fold,还在于如何高效地,不产生不必要和无界限的开销下做到这一点。只有这样,我们才能有效地并行进行 fold

用 map-reduce 表示 fold:更有趣的解

我们的问题是如何高效地reduce 表示 fold,不能产生无限制大小的中间数据,也不能消耗过多的时间。换言之,如果 fold 能在恒定的时间和(工作)空间下处理单个序列元素,那么 reduce 也应该有这样的良好性质。

有时候这个问题可以简单地解决,之前已经展示过这样的例子:如果用于 fold 的函数满足结合律,且具有单位元,左 fold 和右 fold 就是 reduce 的实例。一个更有趣的情况是:对于任意序列元素 x 和累加值 z,用于 fold 的函数 f 都可以分解为 gop 两个其他函数。如下所示:

f z x = op z (g x)

这里的 op 是一种满足结合律且具有单位元的运算。试举一例,回顾上文中「计算列表长度」的函数,它可以用 fold 表示如下

List.fold_left (fun z _ -> z + 1) 0 l

这个 fold 用函数显然可以分解为

z + 1 = z + (Fun.const 1 x)

因此,计算长度的函数可以重新写成

map (Fun.const 1) l |> reduce {op=(+);zero=0}

或者写成 map_reduce 形式,因此,列表长度可以并行计算。

这种分解不过是一个普遍原则的具体案例,Guy Steele 在他的演讲中将这种原则称之为共轭变换[conjugative transform]——该原则要求我们将 fold 表示为

fold_left (f:'z->'a->'a) (z:'z) l = map g l |> reduce m |> h

fold 也类似。这里的 m:'u monoid 是一个关于类型 'u 的幺半群,g:'a->'uh:'u->'z 是一些函数,这些函数通常会依赖 fz。非形式地说,这一原则建议我们寻找一种更大的,能嵌入 'a'z,以及一个恰当的「满足结合律且有单位元的运算」元素。

正如上一节中所示,这样的方法总是可以用于表示 fold:只需选择 compose_monoidm,并将 'z->'z 作为类型 'u

「共轭变换」是一种原则或者模板。它本身不能告诉我们如何实际找出高效的幺半群,也不能揭示这样的幺半群是否存在。事实上,要找到高效的幺半群并不简单,需要创造力。譬如,考虑之前提到的减法 fold:减法不满足结合律,因此 fold_left (-) 不是 reduce 的实例。然而,稍加思考就能发现

fold_left (-) z l = z - fold_left (+) 0 l = z - reduce {op=(+);zero=0} l

将其表述为共轭变换的话:g 是取负数的函数,h 则是 (+) z 函数。

第二个思考时刻是:fold_right (-) 似乎并不能这么简单地用 reduce 实现(或许根本并不能?)。实际上,减法右 fold 也可以通过幺半群 reduce 高效地并行计算。这里鼓励读者自己寻找这个幺半群——从而欣赏其难度和精妙。

推广 Horner 法则

Horner 法则[Horner rule],或称 Horner 模式3,是一种广泛使用的顺序算法,用于高效对多项式求值。类似的累加模式在解析中也常常出现。尽管该算法本质上是顺序执行的,但是似乎也可以用幺半群 reduce 来表示。我们的幺半群 reduce 方法比其他已知的 Horner 法则并行算法要更灵活,也更具有推广性。

作为示例,我们将一串十进制数字组成的列表(大端在前)转换为对应的整数——这是一个简单的解析操作,它也是一个带有累加值的顺序操作,可以写成 fold

let digits_to_num =
  List.fold_left (fun z x -> 10*z + (Char.code x - Char.code '0')) 0

digits_to_num ['1'; '7'; '5'] 应返回 175

易将该操作分解为 mapList.fold_left (fun z x -> 10*z + x) 0 的组合。这本质上就是 Horner 法则的应用:令 $b=10$,求 $\sum{}d_{i} b^{i}$。然而这里的 fold 用函数 fun z x -> 10*z + x 却不满足结合律。

所幸,仍有一种方法可以构造出幺半群。它一种是带有下述运算的幺半群

let op (x,b1) (y,b2) = (x*b2+y, b1*b2)

显然满足结合律(但是不满足交换律)

((x,b1) `op` (y,b2)) `op` (z,b3)
= (x*b2+y, b1*b2) `op` (z,b3)
= (x*b2*b3+y*b3+z, b1*b2*b3)
= (x,b1) `op` (y*b3+z,b2*b3)
= (x,b1) `op` ((y,b2) `op` (z,b3))

其单位元为 (0,1)

(x,b) `op` (0,1) = (0,1) `op` (x,b) = (x,b)

digits_to_num 现在可以写成

let digits_to_num = 
    List.map (fun x -> (Char.code x - Char.code '0')) >>
    List.map (fun x -> (x,10)) >>
    reduce {op;zero=(0,1)} >> fst

>> 表示从左到右的函数组合,我们的构造也是一种共轭变换:gfun x -> (x,10)hfst。因此可见,Horner 法则可以用 map-reduce 表示,并非常简单地并行求值。

我们的构造可以推广到任意满足下列性质的 fold 用函数 f : 'z -> 'a -> 'z

f z x = m.op (h z) x

m.op 应满足结合律且具有单位元(即它应为某个幺半群 m 上的运算)。这种分解看起来非常类似于上一节的展示,但无论幺半群之间有着多么微小的差异,都会使寻找它变得更困难。最终我们发现这一幺半群确实存在:

let hmonoid (m:'a monoid) : ('a * ('a->'a)) monoid = 
  {op = (fun (x,h1) (y,h2) -> (m.op (h2 x) y, h1 >> h2));
   zero = (m.zero, Fun.id)}

其载体集合为幺半群 m 元素和元素上的函数的对子 (x,h) 构成的集合。暂且假设组合 >> 可以高效表示——比如当 h 是乘某个常数的函数时,我们就可以用那个常数来表示 h,并通过对应常量的积来表示这种组合。

Horner 法则问题可能会令人想起并行前缀问题4和 Guy Blelloch 著名的并行 scan 问题(PPoPP 2009)5。不过,Horner 法则并不要求算法汇报中间计算结果。我们的幺半群 reduce 法不同于 Blelloch 的奇偶交错法,也不同于 Guy Steele ICFP09 主题演讲中提出的幺半群缓存树法——我们不需要任何特殊数据结构,只需使用普通数组,然后将其划分到可用的计算核即可。

由于 Horner 法则在求值多项式中非常普遍,已经有了一些将其并行化的方法。最常见的是按多项式系数的奇偶性进行分割,虽然这种方法非常适合 SIMD 处理,但奇偶分割对多核来说并不好,它会造成读竞争(bank 冲突)并浪费缓存空间。我们的幺半群 reduce 法可以把不同的内存 bank 分配给不同的核,让这些核独占地无冲突访问。

此外,Estrin 方法也可以看作我们构造的幺半群的一个特定用法(二分组)。我们的幺半群 reduce 法支持任意分组,如有必要,还可以进行层次分组,分组大小也不一定要等同。

Boyer-Moore 多数票决算法

Boyer-Moore 多数票决算法是一种寻找序列中多数元素的算法——所谓多数元素,指的是在序列中出现次数超过一半的元素。该算法需要遍历一次序列,消耗恒定的工作空间。必须记住的一点是:只有存在多数元素时,算法的返回值才是多数元素。该算法一般情况下无法判定序列中是否存在多数元素:如果不存在就返回任意元素,不一定返回出现频率最高的元素。我们通常需要对序列进行二次遍历以计算返回元素的出现次数,从而确定返回元素确实是多数元素。在某些特定情况下可以避免这种二次遍历。

普遍认为,Boyer-Moore 算法是一种典型的流式算法,具有顺序执行的本质。但是我们发现该算法竟然可以用幺半群 reduce 法来表示,从而高效地并行执行。这一点可能会令人感到惊讶——事实上,其并行程度可能会超乎想象。幺半群 reduce 法的实现本身很简单,但是要让我们相信「这个算法在任何情况下都正确」就复杂得多。

Boyer-Moore 算法从序列中逐元素地接受输入,并以一种状态机的方式处理之。因此可以用 fold 实现(假设输入为非空列表):

let bm : 'a list -> 'a = function h::t -> 
  let fsm (c,m) x =
      if c = 0 then (1,x)
      else if m = x then (c+1,m)
      else (c-1,m)
  in List.fold_left fsm (1,h) t >> snd

算法的中间状态由计数器 c 和多数票候选 m 组成。当算法扫描过整个序列后,m 就是序列中的多数元素(如果序列中存在多数元素的话)。此时,若计数器 c 超过了序列长度的一半,就说明序列中确实存在多数元素,且这个元素就是 m;否则,必须再遍历一次序列以验证 m 确实为多数元素。而如果我们事先就知道一定存在多数元素,或者我们能接受「若不存在多数元素,返回任意元素」的行为,可以略过验证过程。

虽然这一算法看起来本质上是顺序算法,但实际上可以用幺半群 reduce 法执行:而且这个幺半群既简单又高效:

let bm_monoid (z:'a) : (int * 'a) monoid = 
  {zero = (0,z);
   op = fun (c1,m1) (c2,m2) ->
     if c1 = 0 then (c2,m2) else
     if c2 = 0 then (c1,m1) else
     if m1 = m2 then (c1+c2,m1) else
     if c1 > c2 then (c1-c2, m1) else
     (c2-c1, m2)}

这一幺半群的元素(载体)是由自然数 c 和序列元素 m 组成的对子,且当 c 为零时,m 可以是任意元素。在上述代码中,我们用 z 来表示这个任意元素;我们也可以把幺半群的载体定义成一个更复杂的数据结构,从而避免手动指定 z;或者就用 'a option。但是这样会把下面的推理过程变复杂,为了清楚起见,还是暂且保留上述定义。

不难看出 op 满足交换律,而且 zeroop 的单位元。然而,op 满足结合律吗?bm_monoid 真的是一个幺半群吗?要检查这点很简单

let m1 = (2,10) and m2 = (3,9) and m3 = (2,8)
op (op m1 m2) m3   (1, 8) 
op m1 (op m2 m3)   (1, 10)

不幸的是,bm_monoid 不是幺半群。

不卖关子了,bm_monoid 「理论上」是一种幺半群,用它来进行 reduce 在任何情况下都能得到正确的结果。然而,证明这一点不太简单。

为了证明,我们将 bm_monoid 扩展为:

let bm_monoid_ext (z:'a) : (int * 'a * ('a*'a) list) monoid = 
  {zero = (0,z,[]);
   op = fun (c1,m1,l1) (c2,m2,l2) ->
     if c1 = 0 then (c2,m2,l1@l2) else
     if c2 = 0 then (c1,m1,l1@l2) else
     if m1 = m2 then (c1+c2,m1,l1@l2) else
     if c1 > c2 then (c1-c2, m1, repeat c2 (m1,m2) @ l1 @ l2) else
     (c2-c1, m2, repeat c1 (m1,m2) @ l1 @ l2)}

这里的中缀运算符 @ 是列表连接,repeat (n:int) (x:'a) : 'a list 生成一个长度为 n 且元素都是 x 的列表。

bm_monoid_ext 的载体元素是三元组:由自然数 c、序列元素 m 以及「由两个不相等成分组成的对子」的列表(即对子的 fst 不等同于其 snd)。注意到 op 维持了「l 中所有对子的两个成分都不相等」的不变性质。

让我们在该载体集合上引入关系 ,其定义如下

定义.flatten xflatten y 在不考虑顺序的情况下相等,那么 x ≈ y,其中

let flatten : (int * 'a * ('a*'a) list) -> 'a list = fun (c,m,l) ->
  repeat c m @ List.map fst l @ List.map snd l

也就是说,flatten x 会将 x 中涉及到的所有元素收集到一个多数集[multiset]中。当且仅当 x 涉及的元素构成的多数集等同于 y 涉及的元素构成的多数集时,x ≈ y。不难看出关系 满足自反律、交换律和结合律,它也是一种等价关系,还具有以下对我们有用的性质。

命题. bm_monoid_ext 是一个 等价关系上的幺半群,即有

op (0,z,[]) (c,m,l)  op (c,m,l) (0,z,[])  (c,m,l)
op (c1,m1,l1) (op (c2,m2,l2) (c3,m3,l3))  op (op (c1,m1,l1) (c2,m2,l2)) (c3,m3,l3)

结合律源于「op 运算保留了 (c,m,l) 中出现的元素,包括元素的数量」这一点(并且多重集的相等关系满足结合律)。

此外, 完全能满足寻找多数元素这一工作

命题(充分).(c1,m1,l1) ≈ (c2,m2,l2) 且多重集 flatten (c1,m1,l1) 存在多数元素,那么有 c1>0c2>0 以及 m1=m2

非形式化地说: 保留了已发现的多数元素(如果存在的话)。这一命题由「序列重排序不会影响多数元素」这一不变性质和下面的表示引理共同推导而出。

引理(表示). 若序列(多重集)flatten (c,m,l) 中存在多数元素,那么 c>0,且 m 是多数元素。

证明. 为证明这一点,我们注意到:若互异对子列表 l 中有 n 个对子,那么它总共就有 2*n 个值。虽然并非所有值都互相不同,但是没有一个值的出现次数会超过 n 次——否则按照抽屉原理,l 中的某些对子会出现相同的成分。因此,在 (c,m,l) 中出现的任何不等于 m 的值都不能成为多数元素(如果 c>0)。如果有多数元素,那么必定是 m,且一定有 c>0。 $\blacksquare{}$

我们的证明受到了 Boyer 和 Moore 原始证明的启发,不过他们使用了数学归纳法,我们则相反——使用等式推理和抽屉原理,没有使用归纳。说句题外话:计算机科学中似乎只使用归纳法这一种证明方法——许多广泛使用的教科书都只介绍归纳证明法;在我看来,归纳证明有点被滥用了——数学中还有许多其他证明方法6

最后,我们观察到 bm_monoid_ext 中我们真正关心的部分——元素 m 和计数器 c,可以在不考虑列表 l 的情况下计算。这个列表可以说算是一个「幽灵」列表,只有在证明算法正确性时才需要,计算多数元素时无必要。那么我们就可以省略这一元素,从而恢复到 bm_monoid

综上,多数票决算法可以通过 map-reduce 执行:只需用 (fun x -> (1,x)) 函数来 map,然后在幺半群 bm_monoidreduce 即可。它与最初的 Boyer-Moore 算法一样高效,只消耗较小常数的工作空间,也只需对序列遍历一次。不过,我们可以把输入序列任意分割给可用工作单元,让这些单元并行启动,单元之间不会存在干扰或依赖。多数票决法可以以超乎想象地程度并行执行。

结论

Guy Steele 在 ICFP'09 的主题演讲中告诫人们:尽可能使用 reduce(或 map_reduce)取代 foldreducefold 不同,它不会强制使用某种特定的求值策略。它可以被顺序执行,也可以以超乎想象的程度并行执行,甚至可以以一种树形层次的风格执行——这有点类似于数学中的 $\sum{}$ 记号:它只是指定了应该对什么求和,但是没有指定求和的具体顺序。

Guy Steele 的结论也适用于本文。尤其是他最终的思考:

  • 结合律算子非常重要

  • 发现(并证明)新的结合律算子非常,非常重要。

参考


  1. https://vimeo.com/6624203 ↩︎

  2. 译者注:在中文中,幺半群的单位元常被称为「幺元」,「零元」则另有所指。 ↩︎

  3. 译者注:中文又称「秦九韶算法」。 ↩︎

  4. https://en.wikipedia.org/wiki/Prefix_sum ↩︎

  5. https://people.eecs.berkeley.edu/~culler/cs262b/papers/scan89.pdf ↩︎

  6. https://www.cs.nott.ac.uk/~pszgmh/fold.pdf ↩︎