1. 概要
reduceと
scanはGPGPUなどの並列環境下において重要な役割を果たす関数だけど、あまりこれらの関数、特にscanについて言及している記事はあまり見かけない。なので今回は
reduceと
scanについて調べた結果をまとめていきたいと思う。
2. reduce, scan関数の概要
2.1. reduce
scanについては
reduceが分かればより理解しやすくなるので、ひとまずは
reduceから始めることにする。
reduceとは、端的に言うと、対象のリストを与えられた二項演算子で纏め上げる関数だ。とは言っても、いきなりそんなことを言われてもよく分からない。まずは例を見ていこう:
>>> import operator
>>> reduce(operator.add, [1, 2, 3, 4, 5])
15
>>> reduce(operator.sub, [1, 2, 3, 4, 5])
-13
>>> reduce(operator.mul, [1, 2, 3, 4, 5])
120
reduceにおいて重要なのは二項演算子であり、上記の場合、
reduceは以下のような演算を行う。
(((1 + 2) + 3) + 4) + 5 = 15
(((1 - 2) - 3) - 4) - 5 = -13
(((1 * 2) * 3) * 4) * 5 = 120
これにより、最初の演算は
sum関数、3番目の関数は
prod関数と等価であることが分かる。
リストの型は何も数値に限られている訳ではない:
>>> reduce(operator.and_, [True, False, True])
False
>>> reduce(operator.or_, [True, False, True])
True
これより、上記2つの演算は
all,
any関数と機能的に等価であることが分かるのだけれど、
all,
any関数のほうが効率的なので、実際はちゃんと
all,
any関数を使うべきだ。
他にも
reduceを用いて様々な関数、アルゴリズムを記述できるが、あまりにも多いので今回は後回しにして、
scanを重点的に解説していく。
2.2. scan
scan関数は
reduceと比べて文献が少ない。言及している文章もあまり見かけないが、並列処理において
scanは重要な関数だ。残念ながらpythonで実装されていないけれど、仮に実装されていたとしたら以下のような働きをする:
>>> scan(operator.add, [1, 2, 3, 4, 5])
[1, 3, 6, 10, 15]
>>> scan(operator.sub, [1, 2, 3, 4, 5])
[1, -1, -4, -8, -13]
>>> scan(operator.mul, [1, 2, 3, 4, 5])
[1, 2, 6, 24, 120]
きちんと書き下せばどのような働きをしているのか分かりやすい:
[1, 1+2, (1+2)+3, ((1+2)+3)+4, (((1+2)+3)+4)+5]
結局のところ、
scanは先頭から
reduceしていった結果をリストにまとめ、それを返す関数となる。
scanは別名
prefix sumとも呼ばれている。
ちなみに、haskellでは
scanl1(scanl)という名前で実装されている。
Prelude> :type scanl1
scanl1 :: (a -> a -> a) -> [a] -> [a]
Prelude> scanl1 (+) [1,2,3,4,5]
[1,3,6,10,15]
また、
CUDAライブラリである
thrustには
inclusive_scan(exclusive_scan)という名前で実装されている。
#include <thrust/scan.h>
int data[6] = {1, 0, 2, 2, 1, 3};
thrust::inclusive_scan(data, data + 6, data); // in-place scan
// data is now {1, 1, 3, 5, 6, 9}
3. 並列による制限
何故並列下において
reduce,
scanが重要になってくるのか、それは、両方ともメニーコアな環境において効率的に動作するアルゴリズムが複数提唱されているからだ。
reduce,
scanは並列と相性がいい。つまり、既存のアルゴリズムをどうにかして
reduce,
scanに落とし込むことができれば、並列化の恩恵を受けれられると。
ただし、並列環境においては、演算にいくつかの制限が生じてくる。具体的には、集合Gと演算子"・"の組において、
- 演算子"・"は交換則"a・b = b・a"を満たしていなければならない
- 演算子"・"は結合則"(a・b)・c = a・(b・c)"を満たしていなければならない
後者に関してはあたり前の話で、これが成り立っていなければ並列に計算を行うことができない。前者については多少曖昧で、実際には満たさなくてもよいアルゴリズムも作ることはできるけど、コアレッシングなどの問題を考えると満たしていることが強く求められる。実際、thrustの
reduceは交換則を満たしていることを前提としている。
reduceの場合はこの2つの制限で十分だけれど、
scanの場合は上記2つの制限に加えてさらに2つの制限を満たしておくと都合がいい。具体的には、集合Gと演算子"・"の組において、
- ただ一つの単位元"e"が存在する
- 演算子"・"は任意の元"a"に対して"a・a-1 = a-1・a = e"を満たす、ただ一つの逆元"a-1"が存在する
これら4つの制限を満たしている集合Gと演算子"・"の組を『
アーベル群』という。要するに、scan関数は、対象の型から成る集合と二項演算子の組がアーベル群を成していれば都合がいいということになる。
具体例を出そう。整数と和の組
(Integer, (+))は明らかに上記4つの制限を満たす。よって、この組は並列に
reduce,
scanオペレーションを適用でき、かつ都合が良い。
1 + 2 == 2 + 1
(1 + 2) + 3 == 1 + (2 + 3)
1 + (-1) == (-1) + 1 == 0
真偽値と論理積の組
(Bool, (&&))は上記2つの制限を満たすため並列に
reduce,
scanオペレーションを実行可能だが、後半2つの制限は満たしていないため都合は良くない。
3次元の回転行列と積の組
(Matrix, (*))は交換則を満たしていないため―少なくともthrustでは―並列に
reduce, scanオペレーションを実行することはできない。
3.1. 都合が良いとは?
とは言っても、一体アーベル群であればどこがどう都合がいいのか?それは、リスト中において局所的に
reduceを行いたい場合に役に立つ。
リスト
[a0, ... , an]にscanオペレーションを実行して
を手に入れたとする。この場合、
i < jにおいて
bj+bi-1を計算すると、
%20%5Coplus%20(a_0%20%5Coplus%20%5Ccdots%20%5Coplus%20a_i)%5E%7B-1%7D%20%5C%5C%0A%26%26%20%3D%20(a_0%20%5Coplus%20%5Ccdots%20%5Coplus%20a_j)%20%5Coplus%20(a_i%5E%7B-1%7D%20%5Coplus%20%5Ccdots%20%5Coplus%20a_0%5E%7B-1%7D)%20%5C%5C%0A%26%26%20%3D%20(a_0%20%5Coplus%20a_0%5E%7B-1%7D)%20%5Coplus%20%5Ccdots%20%5Coplus%20(a_i%20%5Coplus%20a_i%5E%7B-1%7D)%20%5Coplus%20(a_%7Bi+1%7D%20%5Coplus%20%5Ccdots%20%5Coplus%20a_j)%20%5C%5C%0A%26%26%20%3D%20a_%7Bi+1%7D%20%5Coplus%20%5Ccdots%20%5Coplus%20a_j.png)
となる。つまり、
i+1から
jまでの要素についての
reduceを僅か
O(1)で求めることができる。
これには大きな利点がある。例えば、大規模な時系列データを、その周りのデータから平均化(平滑化)したいとする。各要素ごとに計算を行うのは明らかに非効率であるので、まず
scanオペレーションを
(+)に対して適用し、その後で差分を計算し、正規化を行ってやればよい。結局、
scan (+)のやっていることはf(x)の不定積分F(x)を求めることと本質的に等価となる。この考え方はCVでは、
積分画像として様々な箇所で用いられる。
そしてさらに、この操作はアーベル群であれば何でも良い……とは言っても、アーベル群であることは結構厳しい制限となる。
例を出すと、リスト中の任意の範囲ーiからjの成分がすべてある条件式に適合しているかどうか確かめたいとする。
関数型の考え方だと、まずは
map関数を用いて(C++でなら
transform_iteratorを用いて)リストの成分を
Boolに変え、その後で
scanを利用することを思いつくかもしれない。しかし、残念ながら
(Bool, (&&))はアーベル群でないので、そのまま愚直に適用することができない。
Prelude> scanl1 (&&) $ map (<3) [1,2,3,2,1]
[True,True,False,False,False]
-- 最後の2要素については条件を満たしているのに、Falseで埋もれてしまって分からない!
この場合は、単純にアーベル群である
(Integer, (+))を
scanに適用してやればよい。
Prelude> scanl1 (+) $ map (\x->if x<3 then 1 else 0) [1,2,3,2,1]
[1,2,2,3,4]
-- きちんと第3要素のみ条件から外れていることが分かる
Pythonっぽく書くとたぶんこんな感じ:
>>> import operator
>>> f = lambda x: 1 if x < 3 else 0
>>> scan(operator.add, [f(x) for x in [1,2,3,2,1]])
4. より詳しく知りたい方は
scanアルゴリズムのGPGPU実装に関する詳細については、現在のところ『
Parallel Prefix Sum (Scan) with CUDA(Mark Harris, NVIDIA)』が一番分かりやすい。より詳しく知りたい方はそちらをどうぞ。
というより、基本NVIDIAの出しているテキストは分かりやすい。すごいことです。